Kotlin & Compose Multiplatform 跨平台(Android端、桌面端)开发实践之使用 SQLDelight 将数据储存至数据库

前言

关于标题和文章主题

取标题的时候我还在想,我应该写 Compose 跨平台呢还是写 Kotlin 跨平台。

毕竟对于我的整体项目而言,确实是 Compose 跨平台开发,但是对于我这篇文章要说的东西,那其实也涉及不到多少 Compose 相关的内容,更多的应该是 Kotlin Multiplatform 相关的内容。

二者取舍不下,干脆都写上得了,于是就有了这个读起来怪怪的标题。

前情回顾

很久很久以前,我使用 Compose 写了一个安卓端的计算器 APP:使用 Jetpack Compose 实现一个计算器APP

其中有一个模式叫做程序员模式,可以很方便的做不同进制之间的计算,所以实际上我自己也经常使用这个 APP 来算一些东西。

特别是上次在写有关串口校验的内容时,经常需要计算二进制和十六进制的数据,还会涉及到位运算。

而众所周知,macOS 自带的计算器非常的 “简洁”,不如 Windows 上的计算器强大。所以我只能使用手机来计算。

显然,这很不方便啊,于是萌发出了将我写的这个计算器移植到桌面端的想法。

非常幸运的,我的计算器使用的是 Compose 来编写 UI 布局,所以几乎可以直接无缝迁移到桌面端上来。

具体迁移过程可以参考我之前写的迁移另外一个 APP 的文章: 跟我一起使用 compose 做一个跨平台的黑白棋游戏(1)整体实现思路跟我一起使用 compose 做一个跨平台的黑白棋游戏(4)移植到compose-jb实现跨平台

对于移植这个计算器 APP ,需要重点解决的有两个问题:

  1. 原安卓端程序中我使用了 Jetpack ViewModel 来进行状态管理,但是截止目前,Viewmodel 都还没有移植到 Kotlin Multiplatform 中,并且我没记错的话,官方并没有移植的打算。所以我们需要将原本的 ViewModel 改为使用支持跨平台的状态管理方式。
  2. 原安卓端程序使用了 Room 和 Sqlite 来储存计算历史记录,而 Room 并不支持跨平台,另外,我没记错的话,官方也是没有移植的计划。

对于问题 1 ,在我上面提到的文章中已经给出过解决方案了,所以这里就不再赘述了,我们这篇文章的主题是关于如何使用支持跨平台的数据库 ORM 框架 SQLDelight。

SQLDelight

简介

首先,SQLDelight 是什么东西呢?先来看官方的介绍:

SQLDelight generates typesafe kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time and provides IDE features like autocomplete and refactoring which make writing and maintaining SQL simple.

简单说就是一个能让我们在 Kotlin 中使用 SQL 更方便的一个库。如果还是不太理解的话,你可以把它当成支持跨平台的 Room。

虽然它俩好像就没有多少相似之处,哈哈哈。

并且,不像 Room,提供了几个常用的查询语句(Insert、Update、Delete 等),可以直接使用,SQLDelight 的所有查询语句都需要自己手写。

值得一提的是,这个框架是 Square 旗下公司的产品,没错,就是那个开发了 OKHttp、Glide 的公司。

第一步,添加依赖

在开始之前先提一嘴,因为我使用的 Gradle 版本和官网文档中的版本不一样,所以我这里写的添加依赖和官网的不太一样,各位需要根据自己的实际情况来写。

什么?我为什么不用和文档一样的 Gradle 版本?因为官网文档的代码使用的还是老版本的 Gradle 语法……而我为了使用 Compose 只能用新版本的 Gradle……

首先,在项目根目录下的 build.gradle.kts 文件的 plugins 中添加插件依赖:

plugins {
    // ……
    id("app.cash.sqldelight") version "2.0.0-alpha05" apply false
}

这里在末尾添加了 apply false 是因为这个项目是 跨平台 项目,所以插件并不一定所有模块都会用到,所以加上这个表示只是定义需要这个插件以及定义需要的版本,但是并不会实际加载并应用。

显然,我们需要在公共代码模块使用到这个插件,所以转到通用模块(即 common 模块)下的 build.gradle.kts 文件,并在 plugins 中应用插件:

plugins {
    // ……
    id("app.cash.sqldelight")
}

然后,依旧是在通用模块的 build.gradle.kts 文件中添加 SQLDelight 的核心运行库到通用模块源集(sourceSets -> commonMain)中:

    sourceSets {
        val commonMain by getting {
            dependencies {
                // ……
                implementation("app.cash.sqldelight:runtime:2.0.0-alpha05")
            }
        }
      }

最后,需要为不同的平台添加对应的驱动依赖,依旧是在通用模块的 build.gradle.kts 文件中的平台对应(androidMain、desktopMain)源集添加:

    sourceSets {
        val androidMain by getting {
            dependencies {
                // ……
                implementation("app.cash.sqldelight:android-driver:2.0.0-alpha05")
            }
        }
        
        val desktopMain by getting {
            dependencies {
                // ……
                implementation("app.cash.sqldelight:sqlite-driver:2.0.0-alpha05")
            }
        }
      }

需要注意的是,这里的桌面端驱动需要选择 sqlite-driver 这个驱动。

自此,依赖就全部添加完毕。

第二步,编写需要的 SQL 语句

在开始编写 SQL 语句前,我们需要先在 build.gradle.kts 中为 SQLDelight 插件添加一个配置,用于指定从 SQL 语句中生成的 Kotlin 接口代码的名称以及包名之类的信息。

在通用模块下的 build.gradle.kts 文件中添加以下内容:

sqldelight {
    databases {
        create("HistoryDatabase") {
            packageName.set("com.equationl.common.database")
        }
    }
}

其中的 HistoryDatabase 为 SQLdelight 生成的 Kotlin 接口名,com.equationl.common.database 为它的包路径。

比如我这个配置,编译后自动生成的代码路径和名称为:

1

完成配置后接下来就是编写 SQL 文件,这个文件将使用 .sq 作为文件后缀。

为了让 Android Studio 或者 IDEA 支持 .sq 的代码高亮和代码提示等,我们可以安装一下 SQLDelight 插件:

2

注意这里说的插件是 IDE 的插件,不是 Gradle 插件,不要搞混了。

.sq 文件默认放在和源代码根目录同级目录的 sqldelight 目录下,并且包路径和文件名与上面刚配置的保持一致:

3

HistoryDatabase.sq 文件中写入以下内容:

import com.equationl.common.dataModel.Operator;
import kotlin.Int;

CREATE TABLE History (
   id INTEGER AS Int PRIMARY KEY AUTOINCREMENT,
   show_text TEXT,
   left_number TEXT,
   right_number TEXT,
   operator TEXT AS Operator,
   result TEXT,
   create_time INTEGER
);

getAllHistory:
SELECT * FROM History;

insertHistory:
INSERT INTO History(show_text, left_number, right_number, operator, result, create_time)
VALUES ?;

deleteHistory:
DELETE FROM History WHERE History.id == ?;

deleteAllHistory:
DELETE FROM History;

代码不长,我们拆开来一段一段看。

第一部分,如果略懂 SQL 一眼就能看出来,就算不懂 SQL 的也很好理解,就是创建一个名为 History 的表,并且定义了表的字段:

CREATE TABLE History (
   id INTEGER AS Int PRIMARY KEY AUTOINCREMENT,
   show_text TEXT,
   left_number TEXT,
   right_number TEXT,
   operator TEXT AS Operator,
   result TEXT,
   create_time INTEGER
);

其实它就是 SQL 语句,只是增加了一些 SQLDelight 特有的语法,所有 SQL 中有的语法它也能用,例如我们这里没有使用到的 NOT NULL 用于定义字段不能为空之类的。

这里需要注意一下 .sq 中支持的数据类型和 Kotlin 中数据类型的对应关系:

类型 在数据库中的类型 Kotlin中的类型
INTEGER INTEGER Long
REAL REAL Double
TEXT TEXT String
BLOB BLOB ByteArray

在我们上面的代码中,有两个字段的定义是这样的:

id INTEGER AS Int PRIMARY KEY AUTOINCREMENT,
operator TEXT AS Operator,

我们把 id 定义为了 INTEGER 类型,它会被转成 kotlin 中的 Long 类型,但是实际上,在我们这个 APP 中, id 应该是 Int 类型的,所以我们使用 AS 关键字将其转为了在 kotlin 中的 Int。

同理,operator 原本是 String 类型,这里我们转成了一个我们自定义的枚举类 Operator

对了,别忘了导入这两个类型,不然会编译失败:

import com.equationl.common.dataModel.Operator;
import kotlin.Int;

可能你会疑问,它怎么知道应该怎么转呢?

答案就是它不知道,所以需要我们自己编写转换函数,这有点类似于 Room 中的 @TypeConverter ,但是这里我们暂时先不说怎么写这个转换函数,不过后面我们会说。

第二部分

getAllHistory:
SELECT * FROM History;

先看第二句,这也是个很简单的 SQL 查询语句,用于查询 History 表中的所有数据。

但是我们在它前面额外的多加了一个不属于 SQL 的语法,这个是 SQLDelight 特有的语法,表示这段 SQL 语句将被编译成名为 getAllHistory 的函数:

7

根据这条 SQL 语句内容,这个函数不需要任何参数,且返回值为 Query<History> (可以通过调用 .executeAsList() 转为 List<History>),也就是第一段中定义的表结构,它也会被自动编译成一个 kotlin 中的数据类 data class History:

data class History(
  public val id: Int,
  public val show_text: String?,
  public val left_number: String?,
  public val right_number: String?,
  public val operator_: Operator?,
  public val result: String?,
  public val create_time: Long?,
)

注意:其实这里编译生成的 getAllHistory 还会生成一个带有 mapper 参数的同名函数 ,但是这里为了方便理解,就先不做讲解

假如我们需要按条件查询,例如按指定 id 查询,那也可以这样写:

getHistoryById:
SELECT * FROM History WHERE id == ?;

此时编译生成的 getHistoryById 将会需要传递一个名为 id 的参数:

4

这里的代码中的 ? 可以简单理解为需要传递的参数,SQLDelight 在编译生成 kotlin 代码时,会按照语句自动判断它的参数名称。

再来看第三部分

insertHistory:
INSERT INTO History(show_text, left_number, right_number, operator, result, create_time)
VALUES (?, ?, ?, ?, ?, ?);

这个 insertHistory 作用是将数据插入到数据库中,编译生成的函数将需要六个参数:

5

这么多参数,你可能会说,怎么这么麻烦,就不能像 Room 那样直接插入数据模型吗?上面不是都说了创建的表会自动生成一个数据类吗?

诶嘿,还真可以,只要这样写:

insertHistory:
INSERT INTO History(show_text, left_number, right_number, operator, result, create_time)
VALUES ?;

生成的函数就是这样的了:

6

所以我们就可以直接传入 History 了。

其实讲到这里,后面的函数就不需要我再一一讲解了吧,哈哈。

对了,上面说到的 .sq 自动编译生成的 kotlin 文件在 模块目录/build/generated/sqldelight/code/ 目录下,感兴趣的话可以自己打开看看生成的 Kotlin 文件是什么样子的。

第三步,实现不同平台的驱动

为了使用 SQLDelight ,我们需要适配不同平台的驱动程序。

为此,我们需要用到 kotlin 中的 expectactual 两个关键字。

首先,我们声明一个用于初始化驱动的 except 函数(或类):

// 该段内容位于 common 模块下 commonMain 包中
expect fun createDriver(): SqlDriver

然后在不同的平台代码模块中写上对应的 actual 实现:

// 这段代码位于 common 模块 androdMain 包中
actual fun createDriver(): SqlDriver {
    return AndroidSqliteDriver(HistoryDatabase.Schema, ActivityUtils.getTopActivity(), "history.db")
}

// 这段代码位于 common 模块 desktopMain 包中
actual fun createDriver(): SqlDriver {
    val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
    HistoryDatabase.Schema.create(driver)
    return driver
}

注意这里官网和其他教程给出的初始化驱动的代码都是通过一个类来初始化的,因为不同平台可能需要为其提供不同的参数:

// in src/commonMain/kotlin
expect class DriverFactory {
  expect fun createDriver(): SqlDriver
}

// in src/androidMain/kotlin
actual class DriverFactory(private val context: Context) {
  actual fun createDriver(): SqlDriver {
    return AndroidSqliteDriver(Database.Schema, context, "test.db") 
  }
}

// in src/nativeMain/kotlin
actual class DriverFactory {
  actual fun createDriver(): SqlDriver {
    return NativeSqliteDriver(Database.Schema, "test.db")
  }
}

例如在安卓平台中需要提供安卓的上下文 Context ,所以在初始化时需要提供 context 参数,换句话说,涉及到数据库交互的地方可能无法很好的完全实现多个平台通用代码,因为在通用模块中无法拿到安卓上下文,也就无法初始化 SQLDelight 驱动。

但是这里我们这个项目中的业务逻辑几乎全部都写在了通用模块中,对于数据库相关的逻辑我当然也希望能够继续写在通用模块中,好在我的项目可以通过第三方框架拿到安卓的 Context

AndroidSqliteDriver(
   HistoryDatabase.Schema, 
   ActivityUtils.getTopActivity(),  // Context
   "history.db"
)

所以就不存在上面说的这个问题,因此我也就没有以类的形式在获取驱动,而是直接写了一个函数。

最后,注意一下初始化驱动的平台代码,在安卓中返回的是:AndroidSqliteDriver(HistoryDatabase.Schema, ActivityUtils.getTopActivity(), "history.db") 其中第二个参数是 Context,第三个参数是要使用的数据库文件名.

在桌面端则略有不同:

val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
HistoryDatabase.Schema.create(driver)
return driver

从代码也能看出,在桌面端我们并没有将数据库写入到文件中,而是将其放到内存中了。这就意味着,当程序退出时数据库中的数据将丢失。

不过由于我们的计算器的逻辑是仿照的微软计算器,而微软计算器的处理逻辑也是历史记录仅在打开程序时有效,关闭程序后记录会被自动清除。

这个逻辑恰好和这里相同了,所以我就直接使用了官网文档中的这种将数据库写入内存的做法。

如果你们想要将数据库写入指定文件的话,可以参考这里:Desktop Compose File Saving using SqlDelight

最后,在桌面端使用数据库还有一点需要注意,如果我们直接这样运行的话,对于 debug 包确实没有问题,但是如果运行或发布 distributions 包的话会闪退,这是因为在 distributions 包中没有包含所需的数据库支持。

所以我们需要在 desktop 模块下的 build.gradle.kts 文件中的 application -> nativeDistributions 块添加以下代码:

compose.desktop {
    application {
        nativeDistributions {
              // ……
            modules("java.sql")
              // ……
        }
    }
}

第四步,开始使用

在完成了上面的前置准备工作之后,我们就可以开始使用了。

但是为了使用起来更加方便,我们可以自己在 common 模块的 commonMain 包中封装一个帮助类 DataBase。

首先,在这个类中初始化需要使用到的实例:

private val database = HistoryDatabase(
    createDriver(),
    HistoryAdapter = History.Adapter(
        idAdapter = longOfIntAdapter,
        operator_Adapter = stringOfOperatorAdapter
    )
)
private val dbQuery = database.historyDatabaseQueries

在我们后续使用时直接调用 dbQuery.xxx 即可,其中的 xxx 就是我们自己在 .sq 文件中写的那些函数名。

可以看到,在初始化 HistoryDatabase 时,我们提供了两个参数:

  1. 驱动实例,这里的驱动就是上一节中我们所撰写的驱动

  2. 类型转换适配器,文章稍早前有说过,我们把 id 从 Long 转成了 Int 类型,operator 从 String 转成了 Operator 类型,而 SQLDelight 是不知道应该怎么转的,需要我们自己定义转换函数,此时这个参数就是用来定义我们 的转换函数的。

    另外注意,如果我们在 .sq 中没有写需要转换类型的字段的话,这里就没有第二个参数,只需要第一个参数即可。

这两个转换函数是这样定义的,这里以 idAdapter 为例:

private val longOfIntAdapter = object : ColumnAdapter<Int, Long> {
    override fun decode(databaseValue: Long): Int {
        return databaseValue.toInt()
    }

    override fun encode(value: Int): Long {
        return value.toLong()
    }
}

其实也很简单,就是一个匿名函数 ColumnAdapter ,有两个泛型,第一个表示要转换成的类型,第二个表示原本的类型。然后重载 decodeencode 方法,在其中实现类型转换即可。

初始化好数据库实例后,接下里就是写几个方法用于调用数据库查询:

首先是删除行

internal fun delete(historyData: HistoryData?) {
    if (historyData == null) {
        dbQuery.deleteAllHistory()
    }
    else {
        dbQuery.deleteHistory(historyData.id)
    }
}

这里的 dbQuery.deleteAllHistory()dbQuery.deleteHistory(historyData.id) 对应的就是我们前面在 .sq 中写的:

deleteHistory:
DELETE FROM History WHERE History.id == ?;

deleteAllHistory:
DELETE FROM History;

然后是插入数据:

internal fun insert(item: HistoryData) {
    item.run {
        dbQuery.insertHistory(
            History(id, showText, lastInputText, inputText, operator, result, createTime)
        )
    }
}

在这里,其实这个函数的参数可以直接使用 History 类型,这样我们就只需要直接 dbQuery.insertHistory(item) 即可,但是由于我这个项目是迁移自安卓端的,不是新写的,而在安卓端原本的数据模型使用的是自己定义的一个数据类 HistoryData,如果我改用 SQLDelight 生成的 History 的话,就需要改很多地方,所以这里我索性直接在进行数据库查询时转一下得了。

最后,是查询所有记录:

internal fun getAll(): List<HistoryData> {
    return dbQuery.getAllHistory(::mapHistoryList).executeAsList()
}

与上面插入数据函数相同问题,这里 dbQuery.getAllHistory 返回的是 List<History> 数据,而我们需要的是 List<HistoryData> 数据,所以我们需要转一下。

随堂测试,各位还记得上面我们说过的,SQLDelight 生成的 getAllHistory 函数是没有参数的吗?

那么,问题来了,这里传入的参数是什么东西?其实这里的 getAllHistory 不仅有无参的函数,还有一个带有类型为高阶函数的参数的同名函数,且这个高阶函数的参数是所有查询字段的参数。那么这个高阶函数是用来干嘛的呢?当然就是拿来给我们做数据转换或其他处理用的了。

这里的 mapHistoryList 内容如下:

private fun mapHistoryList(
    id: Int,
    show_text: String?,
    left_number: String?,
    right_number: String?,
    operator_: Operator?,
    result: String?,
    create_time: Long?,
): HistoryData {
    return HistoryData(
        id = id,
        showText = show_text ?: "",
        lastInputText = left_number ?: "",
        inputText = right_number ?: "",
        operator = operator_ ?: Operator.NUll,
        result = result ?: "",
        createTime = create_time ?: 0
    )
}

自此,我们的数据库帮助类几乎全部完成了,最后再加一个单例实例方便调用,也避免重复初始化:

companion object {
    val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
        DataBase()
    }
}

完整的 DataBase 类内容如下:

package com.equationl.common.database

import app.cash.sqldelight.ColumnAdapter
import com.equationl.common.dataModel.HistoryData
import com.equationl.common.dataModel.Operator
import com.equationl.common.platform.createDriver

internal class DataBase {

    companion object {
        val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
            DataBase()
        }
    }

    private val longOfIntAdapter = object : ColumnAdapter<Int, Long> {
        override fun decode(databaseValue: Long): Int {
            return databaseValue.toInt()
        }

        override fun encode(value: Int): Long {
            return value.toLong()
        }
    }

    private val stringOfOperatorAdapter = object : ColumnAdapter<Operator, String> {
        override fun decode(databaseValue: String): Operator {
            return try {
                Operator.valueOf(databaseValue)
            } catch (e: IllegalArgumentException) {
                Operator.NUll
            }
        }

        override fun encode(value: Operator): String {
            return value.name
        }

    }

    private val database = HistoryDatabase(
        createDriver(),
        HistoryAdapter = History.Adapter(
            idAdapter = longOfIntAdapter,
            operator_Adapter = stringOfOperatorAdapter
        )
    )
    private val dbQuery = database.historyDatabaseQueries

    internal fun delete(historyData: HistoryData?) {
        if (historyData == null) {
            dbQuery.deleteAllHistory()
        }
        else {
            dbQuery.deleteHistory(historyData.id)
        }
    }

    internal fun getAll(): List<HistoryData> {
        return dbQuery.getAllHistory(::mapHistoryList).executeAsList()
    }

    internal fun insert(item: HistoryData) {
        item.run {
            dbQuery.insertHistory(
                History(id, showText, lastInputText, inputText, operator, result, createTime)
            )
        }
    }

    private fun mapHistoryList(
        id: Int,
        show_text: String?,
        left_number: String?,
        right_number: String?,
        operator_: Operator?,
        result: String?,
        create_time: Long?,
    ): HistoryData {
        return HistoryData(
            id = id,
            showText = show_text ?: "",
            lastInputText = left_number ?: "",
            inputText = right_number ?: "",
            operator = operator_ ?: Operator.NUll,
            result = result ?: "",
            createTime = create_time ?: 0
        )
    }


}

最后,在我们实际需要使用到的地方调用即可。

例如,在我这个项目中,我会在点击历史记录图标后从数据库读取历史记录数据并更新到列表中,所以在 ViewModel (注意,这里的 ViewModel 不是 Jetpack ViewModel 框架,只是一个名字)中的 toggleHistory 函数有这么一段代码:

private val dataBase = DataBase.instance

// ……

private fun toggleHistory(forceClose: Boolean, viewStates: MutableState<StandardState>) {
// ……

        CoroutineScope(Dispatchers.IO).launch {
            var list = dataBase.getAll()
            if (list.isEmpty()) {
                list = listOf(
                    HistoryData(-1, showText = "", "null", "null", Operator.NUll, "没有历史记录")
                )
            }
            viewStates.value = viewStates.value.copy(historyList = list)
        }

// ……

}

对了,在这里的代码中,我自己启动了一个协程用于执行数据库查询操作,这是因为对于我这个项目,可以支持我这么做。

而其实 SQLDelight 官方就有支持使用协程执行查询的扩展库,推荐各位还是使用官方的方式来:

val players: Flow<List<HockeyPlayer>> = 
  playerQueries.selectAll()
    .asFlow()
    .mapToList()

依赖: implementation("app.cash.sqldelight:coroutines-extensions:2.0.0-alpha05")

总结

完整项目源码地址:calculator-Compose-MultiPlatform

好了,现在我们已经完全完成了将原本在安卓端使用 Room 实现的数据库储存迁移到了使用 SQLDelight 实现的跨平台数据库储存。

可以看到,其实使用 SQLDelight 也是十分的方便,相较于 Room ,可能也就是前期配置稍微麻烦了那么一点点(毕竟要手写 SQL)。

最最重要的是,SQLDelight 支持跨平台,无论是移动端的 安卓 和 iOS 还是桌面端的 Windows、macOS、Linux,它都支持。

只是在官网教程和其他大佬写的博客中,大多数都是介绍 SQLdelight 在单一平台使用或者移动端跨平台使用,我没看到有介绍跨安卓和桌面端的文章,所以这里我就写了这篇文章。

参考资料

  1. SQLDelight Multiplatform
  2. 【翻译】使用 Ktor 和 SQLDelight 构建跨平台APP教程