KMM(Kotlin Multiplatform Mobile)入门(六)使用 SQLDelight 操作数据库

数据库在 App 中的作用

移动 App 的数据库与 Server 数据库不同,其主要目的是为了缓存一些数据,如:历史消息、数据打点、列表数据缓存等,宗旨都是为优化用户体验建立一套简单的数据基础

由于 SQLite 完全开源,且比较轻量(不需要像 MySQL 这样建立一个单独的进程,直接操作 DB 文件),目前,在各类移动端操作系统(包括不限于 Android、iOS、Windows)当中,都会内置 SQLite,以便开发者存取结构化数据

于是,围绕 SQLite 展开的开发框架也越来越多,比如:iOS 上的 FMDB、以及 Apple 官方的 CoreData,Android 上的 SQLiteOpenHelper,以及基于它构建的 GreenDAO、Android 官方的 Room 等等,这些框架使得开发者不需要关注 SQLite 中 C/C++ 一层的 API,大幅降低了移动端数据库的开发成本,使得数据存取变得容易

虽然 SQLite 用途广泛,但 SQLite 也存在着一些性能问题,这些性能问题在数据量比较庞大时,体现地更为明显,近几年也出现了一些面向移动端,基于 NoSQL 或对 SQLite 进行改进数据库框架,如:Realm、WCDB……

那么在 KMM 中,如果需要操作数据库,使用 SQLDelight 框架,无疑是目前比较好的选择

SQLDeilight 简介及特点

SQLDelight 由 Square (开发过 OkHttp、Retrofit、LeakCanary 等一些著名的框架)发起,起初是应用在 Cash App 上,完全使用 Kotlin 进行开发,利用 Kotlin Native 的特性,可以实现 Android、iOS、macOS、Windows 等平台,或 JVM、JavaScript 运行时上的 SQLite 读写操作,也可以借助 JVM,实现对 MySQL、PostgreSQL、HSQL/H2 数据库的支持

利用 SQLDelight 开发的大体思路是先使用 SQL 语句构建表结构和基本的 CURD 操作,根据开发人员编写的 SQL 语句,通过 IDEA 上的 Plugin 进行扫描和解析,从而生成用于建表、迁移及读写数据的 Kotlin 代码,于是在 KMM 模块的 Common 目录中便可调用相关的 CURD 方法,实现对数据库的增删改查

另外,由于双端底层实现的差异(Android 基于 JVM,iOS 基于 Kotlin Native),需要借助 expect/actual 来注入实际的 SQLiteDriver 并进行初始化,才可以正常使用 SQLite 数据库

SQLDelight 框架实现对 SQLite 的基本操作流程,如下图所示

SQLDelight

由于其只需要编写符合 SQLite 规范的 SQL 脚本文件(.sq)即可自动生成对应的 Entity、DAO 等,在一定程度上比 Android 上已有的一些框架使用起来还更为简单、方便

使用 SQLDelight 开发的流程

注意:本文使用的是 SQLDelight 1.5.0 版本,需要搭配 Gradle 6.8 及以上的版本使用,使用较低的版本会导致报错!

插件安装

想要使用 SQLDelight 进行开发,首先需要安装官方推出的插件,同 KMM 插件一样,首先需要进入到 Android Studio 设置的『Plugins』页面,只需要在『Marketplace』Tab 中搜索 SQLDelight,根据提示安装,然后重启 Android Studio 即可

plugin

它的源码目录链接为:https://github.com/cashapp/sqldelight/tree/master/sqldelight-idea-plugin,如果有兴趣还可以自己手动编译插件

添加 SQLDelight 依赖

由于需要引入 SQLDelight 的 Gradle 插件,所以首先需要在工程根目录的 build.gradle(或 kts)文件中,加入 SQLDelight 插件依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
// build.gradle(根目录)

buildscript {
// ...
repositories {
google()
mavenCentral()
}
dependencies {
// 需要在 dependencies 闭包中加入如下依赖
classpath 'com.squareup.sqldelight:gradle-plugin:1.5.0'
}
}

待 Gradle Sync 完成以后,再到 KMM 模块的 build.gradle.kts 文件中,依赖 SQLDelight 插件、主库、Driver 等,同时进行基本配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// build.gradle.kts(KMM 模块目录中)

plugins {
kotlin("multiplatform")
id("com.android.library")
// 1. 在 plugins 闭包中加入以下依赖
id("com.squareup.sqldelight")
}

// ...

kotlin {
// ...
sourceSets {
// ...
val androidMain by getting {
dependencies {
// 2. 在 androidMain 后面的闭包中,加入 Android 平台的数据库驱动依赖(Android)
implementation("com.squareup.sqldelight:android-driver:1.5.0")
}
}
val iosMain by getting {
dependencies {
// 3. 在 iosMain 后面的闭包中,加入 iOS 平台的数据库驱动依赖(Native)
implementation("com.squareup.sqldelight:native-driver:1.5.0")
}
}
}
}

// ...

// 4. 与 kotlin 闭包同级,加入 sqldelight 闭包,配置数据库基本信息
sqldelight {
// database 方法的首个参数为数据库名称,数据库文件、入口类的命名都以此为准
database("MyKmmAppDB") {
// packageName 为生成 SQLite 操作类的包名,根据情况合理指定即可
packageName = "com.coderyuan.kmm"
// 除包名外,还可以配置生成的类文件目录、sq 文件路径、迁移文件等等,这里先不做过多介绍
}
}

创建 sq 文件目录

这个目录的作用是用来存放数据库表结构及 CRUD 操作的 SQL 语句文件,以便 SQLDelight 插件能够扫描到并自动生成对应的 Kotlin 类文件

其默认的创建规则如下(如在 Gradle 中进行了特殊配置,需要根据配置修改路径):

  • 在 commonMain 目录中,与 kotlin 目录平级,取名为:sqldelight
  • 需要创建类似 Java/Kotlin 的包结构目录,如:com/coderyuan/kmm,包名要与 Gradle 中配置的包名相符,否则会在编译时报错

建议 sq 文件中不要声明过多的表结构,sq 文件名不强制要求与表名一致,但应该按照业务命名,提升可读性,减少维护成本

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE User (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, -- 用户 ID
name TEXT, -- 用户名
age INTEGER, -- 年龄
gender INTEGER, -- 性别
phoneNumber TEXT -- 电话
);

insertUser:
INSERT INTO User(name, age, gender, phoneNumber) VALUES(?,?,?,?);

queryById:
SELECT * FROM User WHERE id = ?;

比如创建一个以上的 User 表(具体语法可以参考 SQLite 的相关资料)附带插入和查询两个方法,如果 Android Studio 上已经安装了 SQLDelight 插件,此时按下保存(Command + S)即可触发类文件的生成

生成数据库操作类文件

确保 sq 文件中没有语法错误后,可以试着构建一下工程,在使用默认配置的情况下,如果没有错误,会在 KMM 模块的 build/generated/sqldelight 目录中生成类似下图中的几个和数据库操作相关的文件

gen

如果执行 ./gradlew assembleDebug 进行构建时,提示有如下的错误,大概率是需要升级 Gradle 的版本

1
Caused by: java.lang.NoSuchMethodError: kotlin.jvm.internal.FunctionReferenceImpl.<init>(ILjava/lang/Class;Ljava/lang/String;Ljava/lang/String;I)V

以我现在使用的 Android Studio 4.2.1 版本为例,用它创建的 Android 工程,默认会使用 Gradle 6.7.1,而 SQLDelight 1.5.0 需要使用 Gradle 6.8,不过部分工程可能因为历史原因,不能直接将 Gradle 版本升级,此时或许需要考虑适当对 SQLDelight 降级

配置数据库 Driver 并进行初始化

由于 SQLDelight 对于双端的底层能力实现不同,在完成上面的操作以后,也只是生成了基本的 CRUD 方法,想让数据库真正 Run 起来,还得为其配置 SQLite Driver,并进行初始化操作

首先需要在 Common 模块中定义一个 DBManager 单例用来 Hold 数据库的连接及一系列数据库 Query 的 Transacter 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// DBManager.kt (Common)

package com.example.mykmmapp

import com.squareup.sqldelight.db.SqlDriver

const val DB_NAME = "MyKMMAppDB.db" // 数据库实际 db 文件名

// 创建、存储 Transacter 的单例
expect object DBManager {
fun getInstance(): MyKmmAppDB?
}

// Schema 是一套数据库操作的 API
object Schema : SqlDriver.Schema by MyKmmAppDB.Schema {
override fun create(driver: SqlDriver) {
MyKmmAppDB.Schema.create(driver)
}
}

完成以上定义后,由于底层 Driver 的差异,需要在 androidMainiosMan 中分别实现 Driver 的初始化代码

首先是 Android 中的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// DBManager.kt (Android)

package com.example.mykmmapp

import android.content.Context
import com.squareup.sqldelight.android.AndroidSqliteDriver
import com.squareup.sqldelight.db.SqlDriver
import java.lang.ref.WeakReference

actual object DBManager {

// 使用弱引用引用 Context,防止内存泄露
// 也可以考虑使用自建工具类中的 Application Context
var contextRef = WeakReference<Context?>(null)

private var driverRef: SqlDriver? = null
private var dbRef: MyKmmAppDB? = null

private val ready: Boolean
get() = driverRef != null

private fun dbSetup(driver: SqlDriver) {
val db = MyKmmAppDB(driver)
driverRef = driver
dbRef = db
}

// clear 可在适当的时间调用,释放内存
fun dbClear() {
driverRef?.close()
dbRef = null
driverRef = null
contextRef = null
}

@JvmStatic
actual fun getInstance(): MyKmmAppDB? {
if (!ready) {
val ctx = contextRef.get() ?: return null
// Android 使用 AndroidSqliteDriver
dbSetup(AndroidSqliteDriver(Schema, ctx, name = DB_NAME))
}
return dbRef
}
}

其次是 iOS 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// DBManager.kt (iOS)

package com.example.mykmmapp

import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
import kotlin.native.concurrent.AtomicReference
import kotlin.native.concurrent.freeze

actual object DBManager {

// 考虑到 iOS 并发的特殊性,为保证多线程共享正确,这里需要使用到 AtomicReference
private val driverRef = AtomicReference<SqlDriver?>(null)
private val dbRef = AtomicReference<MyKmmAppDB?>(null)

private fun dbSetup(driver: SqlDriver) {
val db = MyKmmAppDB(driver)
// 初始化后,即刻 freeze
driverRef.value = driver.freeze()
dbRef.value = db.freeze()
}

fun dbClear() {
driverRef.value?.close()
dbRef.value = null
driverRef.value = null
}

// OC、Swift 调用该方法进行初始化
fun defaultDriver() {
dbSetup(NativeSqliteDriver(Schema, DB_NAME))
}

actual fun getInstance(): MyKmmAppDB? {
return dbRef.value
}
}

在 Android 工程中添加初始化代码

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.mykmmapp.android

import android.app.Application
import com.example.mykmmapp.DBManager
import java.lang.ref.WeakReference

class App : Application() {
override fun onCreate() {
super.onCreate()
DBManager.contextRef = WeakReference(this)
}
}

在 iOS 工程中添加初始化代码

1
2
3
4
5
6
7
8
9
10
11
12
13
import UIKit
import shared

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
DBManager.init().defaultDriver()
return true
}

// ...
}

Objective-C 示例:

1
2
3
4
5
6
7
8
9
10
#import "Test.h"
#import <shared/shared.h>

@implementation Test

- (void)dbSetup {
[[SharedDBManager init] defaultDriver];
}

@end

使用 Query

插入数据

进行完初始化操作以后,我们可以在 KMM 模块中调用 sq 文件中定义好的 Query 方法进行数据库的 CRUD 操作,如下面代码所示:

1
2
3
4
5
6
7
8
fun insertData() {
DBManager.getInstance()?.userQueries?.insertUser(
"张三",
30,
1,
"13800138000"
)
}

建议不要在双端的 App 代码中直接调用数据库的能力,而是应该将一系列操作放在 KMM 模块中执行

此时在 App 中调用 insertData() 方法进行测试

1
2
3
4
5
6
7
8
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// ...
insertData()
}
}
1
2
3
4
5
6
7
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// ...
DBTestKt.insertData()
}
}

运行 App 以后,我们可以在 Database Inspector 中看到数据库中已经有了刚才插入的数据(iOS 数据库可使用 SQLPro for SQLite 等 App 进行查看)

在 App 沙盒目录下,也有了相应的数据库文件

查询数据

首先定义好测试方法,例如按照用户 ID 来查询用户信息,并在控制台打印出来:

1
2
3
4
fun fetchDBData() {
val user = DBManager.getInstance()?.userQueries?.queryById(1)?.executeAsOneOrNull() ?: return
println("User: ${user.name} is ${user.age} years old, gender: ${if (user.gender == 1L) "M" else "F"}, phone: ${user.phoneNumber}")
}

然后同样在 App 中调用该方法,双端运行效果如下:

除此之外,SQLDelight 根据 sq 文件生成的查询方法,还可以设置映射,直接转换为 Model 类,或以 List 的形式接收结果

另外,还可以调用 addListener 来监听结果集的变化

数据库迁移/升级

SQLDelight 支持对数据库表结构进行升级,或进行数据迁移,与 Android、iOS 端处理数据库升级的方法类似,其底层也是依赖数据库 Schema 的 Version 变化,在每次 App 初始化数据库时,运行相应的 SQL 语句对表结构进行修改,或迁移现有数据

如果使用 KMM 的 App 需要进行数据库升级,利用 SQLDelight 可以轻松实现,只需要合理利用 SQL 语句,编写 sqm 文件即可

sqm 文件内容

sqm 文件一般会存放一些对表结构进行修改的 SQL 语句,也可以支持在某个表中进行数据填充

1
2
-- 1.sqm
ALTER TABLE User ADD COLUMN company TEXT;
1
2
3
4
5
6
-- 2.sqm
ALTER TABLE User ADD COLUMN level INTEGER;
CREATE TABLE Content(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, -- 内容 ID
detail TEXT -- 内容详情
);

具体内容,根据业务需要定制即可,但表名要与现有表一致,其他遵循 SQLite 语法即可

另外,由于 sq 文件主要是在 App 新安装时创建数据库表结构,所以 sq 文件中的表结构也要与 sqm 的修改保持一致,sqm 中修改了什么,sq 的声明也应当增加定义,以保证数据库可以正常映射、运行

sqm 文件的命名规则

需要升级的版本号为文件名,如:1.sqm、2.sqm,如果需要从版本 1 升级,则命名为:1.sqm,如果需要从版本 x,则命名为:x.sqm……

sqm 文件也需要和 sq 文件一样,存放在 commonMain 目录中的 sqldelight 子目录中

修改生效

完成 SQL 的编写后,执行一次 Build 或 ./gradlew assembleDebug,SQLDelight 插件会根据 sqm 文件的命名,自动确定 Schema 的版本号,并自动生成 migrate 所需要的 SQL 语句

此时重新编译运行 App(原先手机上的 App 不要卸载),并在 Database Inspector 中观察表结构的变化,即可看到数据库中已经新增了 Content 表,User 表也新加入了 company 和 level 字段

其他使用建议及注意事项

  1. 由于数据库读写是 IO 操作,建议统一放在串行异步队列中执行,减少对 UI 线程的阻塞,并保证逻辑正确
  2. 数据库操作尽量放在 KMM 的 commonMain 目录中,并封装一些工具方法进行调用,保障双端逻辑一致
  3. SQL 语句中控制好字段的可空类型,在 Kotlin 代码中谨慎处理可空判断