在 CrossPaste 项目的早期阶段,我们曾选择使用 Realm Kotlin 作为持久化方案,借助其对象映射直观、响应式查询等特性,快速实现了本地数据存储功能。然而,随着 Kotlin 编译器的持续演进以及 Compose Multiplatform 的逐步成熟,我们逐渐意识到 Realm 在当前阶段已难以满足项目的长期发展需求。
2024 年 11 月,Realm 官方维护者在 issue #1852 中明确表示:
"Unfortunately, Realm is under minimum maintenance. It would be better for you to have your own fork."
这代表 Realm Kotlin 已进入最低维护状态,且至今尚未支持 Kotlin 2.1.0。继续使用 Realm 将严重阻碍项目对 Kotlin 新版本的跟进,影响整体依赖升级、跨平台兼容与未来维护。
因此,我们决定将数据持久化方案从 Realm 迁移至 SQLDelight,原因包括:
- ✅ 开源透明,维护活跃:SQLDelight 完全开源,具备稳定的社区支持。
- ✅ 多平台项目验证成熟:多个大型 Kotlin Multiplatform 项目已采用 SQLDelight 作为核心数据库方案。
- ✅ 回归 SQL 思维,更可控的结构设计:相比对象存储模式,SQLDelight 以明确的 SQL 文件建表与查询定义,提供更强的可读性、可维护性与调试能力。
本系列文章将结合 CrossPaste 的迁移实践,深入讲解如何基于 SQLDelight 重构原有的 Realm 数据模型、实现响应式查询、构建全文搜索能力,逐步完成一次稳健而现代的数据库迁移过程。
SQLDelight 插件配置与代码生成机制
SQLDelight 作为跨平台的数据库解决方案,它通过声明式 SQL 文件生成类型安全的 Kotlin API,并支持 Android、JVM 和 Native 等多平台。
🔧 配置依赖与插件
我们使用 libs.versions.toml 管理 SQLDelight 的版本与依赖:
[versions]
sqldelight = "2.0.2"
[libraries]
sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
[plugins]
sqlDelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
在模块的 build.gradle.kts
中添加插件:
plugins {
alias(libs.plugins.sqlDelight)
}
配置 SQLDelight 数据库生成器:
sqldelight {
databases {
create("Database") {
packageName = "com.crosspaste"
dialect("app.cash.sqldelight:sqlite-3-25-dialect:2.0.2")
}
}
}
📁 创建 .sq 文件并自动生成数据库代码
我们在 commonMain/sqldelight
中添加 .sq
文件,并直接书写标准的 SQL 语句。
例如,创建表、索引、触发器等语法,与标准 SQLite 无差异:
CREATE TABLE IF NOT EXISTS PasteDataEntity (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
appInstanceId TEXT NOT NULL,
favorite INTEGER AS Boolean NOT NULL,
pasteAppearItem TEXT,
pasteCollection TEXT NOT NULL,
pasteType INTEGER NOT NULL DEFAULT -1,
source TEXT,
size INTEGER NOT NULL,
hash TEXT NOT NULL,
createTime INTEGER NOT NULL,
pasteSearchContent TEXT,
pasteState INTEGER NOT NULL DEFAULT 0,
remote INTEGER AS Boolean NOT NULL
);
而对于查询、插入、更新等语句,只需添加一个方法名,SQLDelight 插件会自动生成对应的 Kotlin 调用函数:
createPasteDataEntity:
INSERT INTO PasteDataEntity(appInstanceId, favorite, pasteAppearItem, pasteCollection, pasteType, source, size, hash, createTime, pasteSearchContent, pasteState, remote)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
getLastId:
SELECT last_insert_rowid();
getPasteDataListLimit:
SELECT * FROM PasteDataEntity WHERE pasteState != -1 ORDER BY createTime DESC, id DESC LIMIT ?;
通过这种方式,SQLDelight 实现了“声明式 SQL + 自动代码生成 + 类型安全”的整合,既回归了 SQL 的可控性,也保留了 ORM 部分的便利性。
表结构如何设计来替代 Realm 的对象组合
Realm 的数据建模方式以“对象组合”为核心,支持嵌套对象、List<T>
、可选字段等灵活结构。这种模型对于面向对象的开发者来说非常直观,但在关系型数据库中,我们需要显式地进行表设计与关联建模。
在将 Realm 数据迁移到 SQLDelight 时,我们遵循以下两种策略来设计表结构:
🧩 需要查询/过滤的对象:拆分为独立表并关联
如果某个嵌套对象或列表中的元素需要在查询中单独进行筛选(如通过关键词搜索、过滤标签、排序等),我们推荐将其建为一个独立的表,并通过主键与父表进行关联。
根据具体的数据关系,可以选择:
- 一对一:在主表或子表中添加 FOREIGN KEY
- 一对多:在子表中存储父表的主键
- 多对多:使用中间表维护关联关系
📦 无需单独查询的嵌套对象:可作为 JSON 存储
如果某些嵌套对象仅作为主表的一部分存在,不会被独立查询或过滤,可以简化处理,直接以 JSON 的形式序列化后存储在 TEXT
字段中。这种方式更轻量,不需要建立额外的表,也降低了迁移的复杂度。
Kotlin 中使用 kotlinx.serialization 或 Moshi 等库即可完成 JSON 编解码。通过这种方式,我们只需在业务逻辑中反序列化并使用这些数据,无需数据库层面的直接访问。
SQLDelight 如何实现响应式查询
相比 Realm 的 LiveObject / Flow 机制,SQLDelight 提供了一种更轻量、透明、可组合的响应式查询方式。借助 coroutines 扩展,它可以让数据库查询以 Flow
形式暴露给 UI 层,实现数据驱动的界面更新。
✅ 添加响应式支持依赖
首先,确保在版本管理中引入 coroutines-extensions
:
[libraries]
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
然后在 build.gradle.kts
中添加依赖:
implementation(libs.sqldelight.coroutines.extensions)
🔄 声明响应式查询函数
SQLDelight 会根据 .sq
文件中的命名语句生成类型安全的查询接口,我们只需使用 .asFlow()
配合 .map
即可实现响应式查询:
fun getPasteDataFlow(limit: Long): Flow<List<PasteData>> {
return pasteDatabaseQueries
.getPasteDataListLimit(limit, PasteData::mapper) // Mapper 将数据库行映射为 PasteData 实体
.asFlow()
.map { it.executeAsList() }
.catch { e ->
logger.error(e) { "Error executing getPasteDataFlow query: ${e.message}" }
emit(emptyList())
}
}
getPasteDataListLimit
是我们在.sq
文件中定义的命名查询方法;PasteData::mapper
用于将原始字段映射成业务层数据模型;- 返回的
Flow<List<PasteData>
可直接用于 Compose UI 的 collectAsState() 等场景,实现自动刷新。
🧠 它是如何实现响应式的?
你可能会疑惑:SQLDelight 并没有数据库观察者,它是如何实现 Flow 自动刷新的?
只需查看生成代码,就能一探究竟。在 build/generated/sqldelight/code/Database/
中,我们可以看到类似如下的插入和更新函数:
public fun createPasteDataEntity(...) {
driver.execute(..., """INSERT INTO PasteDataEntity(...) VALUES (...)""", ...) {
bindString(...) // 绑定参数
}
notifyQueries(...) { emit ->
emit("PasteDataEntity") // 通知受影响的查询表
emit("PasteDataEntityFts") // 包含全文索引表
}
}
public fun updateFavorite(favorite: Boolean, id: Long) {
driver.execute(..., """UPDATE PasteDataEntity SET favorite = ? WHERE id = ?""", ...) {
bindBoolean(0, favorite)
bindLong(1, id)
}
notifyQueries(...) { emit ->
emit("PasteDataEntity")
emit("PasteDataEntityFts")
}
}
核心在于每次写操作后会调用 notifyQueries(...)
,告知哪些表或虚拟表被修改,从而触发对应的查询重新执行。
只要你的查询函数是基于 .asFlow()
创建的,它就会被正确地通知并重新发射新数据,实现响应式更新。
实现全文搜索:SQLDelight 中的 FTS 支持
在跨平台应用 CrossPaste 中,支持高效搜索历史粘贴数据是非常重要的一环。为此,我们采用 SQLite 的 FTS5
扩展,并通过 SQLDelight 的 .sq
文件定义实现全文索引与查询。
🔍 使用 FTS5 建立虚拟索引表
CREATE VIRTUAL TABLE IF NOT EXISTS PasteDataEntityFts USING fts5(
pasteSearchContent,
content=PasteDataEntity,
content_rowid=id
);
然后建立了三个触发器(Trigger),确保主表数据的增删改能同步更新索引表:
-- 插入时写入索引
CREATE TRIGGER IF NOT EXISTS PasteDataEntityAI AFTER INSERT ON PasteDataEntity BEGIN
INSERT INTO PasteDataEntityFts(rowid, pasteSearchContent)
VALUES (new.id, new.pasteSearchContent);
END;
-- 删除时通知删除索引
CREATE TRIGGER IF NOT EXISTS PasteData_AD AFTER DELETE ON PasteDataEntity BEGIN
INSERT INTO PasteDataEntityFts(PasteDataEntityFts, rowid, pasteSearchContent)
VALUES('delete', old.id, old.pasteSearchContent);
END;
-- 更新时先删后插更新索引
CREATE TRIGGER IF NOT EXISTS PasteData_AU AFTER UPDATE ON PasteDataEntity BEGIN
INSERT INTO PasteDataEntityFts(PasteDataEntityFts, rowid, pasteSearchContent)
VALUES('delete', old.id, old.pasteSearchContent);
INSERT INTO PasteDataEntityFts(rowid, pasteSearchContent)
VALUES (new.id, new.pasteSearchContent);
END;
这样,每当粘贴内容更新,FTS 表就能自动反映出最新的搜索索引,无需额外维护逻辑。
🧠 查询语句设计
CrossPaste 中的搜索语句通过 FTS 实现关键字匹配,并支持多个维度的过滤条件:
SELECT PasteDataEntity.*
FROM PasteDataEntity
JOIN PasteDataEntityFts ON PasteDataEntity.id = PasteDataEntityFts.rowid
WHERE
pasteState != -1
AND PasteDataEntityFts MATCH :searchQuery
AND CASE WHEN :appInstanceId IS NOT NULL
THEN CASE WHEN :local
THEN appInstanceId = :appInstanceId
ELSE appInstanceId != :appInstanceId
END
ELSE TRUE
END
AND CASE WHEN :favorite IS NOT NULL
THEN favorite = :favorite
ELSE TRUE
END
AND CASE WHEN :pasteType IS NOT NULL
THEN pasteType = :pasteType
ELSE TRUE
END
ORDER BY CASE WHEN :sort THEN createTime ELSE -createTime END DESC
LIMIT :number;
核心条件是:PasteDataEntityFts MATCH :searchQuery
这行语句会基于 FTS5 的前缀索引机制执行高性能匹配。
🧩 Kotlin 端构建搜索语句
在 Kotlin 中我们这样构造 searchQuery
:
val searchQuery = "pasteSearchContent:(${searchTerms.joinToString(" AND ") { "$it*" }})"
pasteDatabaseQueries.complexSearch(
local = local == true,
appInstanceId = appInstanceId,
favorite = favorite,
pasteType = pasteType?.toLong(),
searchQuery = searchQuery,
sort = sort,
number = limit.toLong(),
mapper = PasteData::mapper,
)
你会注意到,我们为每个关键词添加了 *,这是因为 FTS5 仅支持前缀搜索,不支持中缀或后缀。因此,我们通过 AND 拼接所有关键词的前缀表达式。
❗ 为什么要做分词?
FTS5 默认使用的是 unicode61 分词器,它对英文和使用空格分隔的语言效果良好,但对于中文、日文、韩文等无空格语言支持较差。以中文为例,假设我们要搜索“微信截图”这段文字,如果原始文本是:
“这是我刚刚在微信中收到的一张截图。”
默认的分词器可能会将整句视为一个长字符串,无法正确识别“微信”或“截图”作为独立的搜索单元。这会导致使用:MATCH '微信*'
这样的查询无法命中,因为词表中没有建立以“微信”开头的索引项。
✅ 解决方案:预处理文本进行分词
为了解决这个问题,我们需要在写入数据库之前,对原始文本进行分词处理,将其转换为由空格分隔的“词语片段”,供 FTS 建立索引。例如:
原始文本:
这是我刚刚在微信中收到的一张截图。
分词结果:
这是 我 刚刚 在 微信 中 收到 的 一张 截图
然后拼接成一个带空格的字符串,存入 pasteSearchContent
字段中。FTS 会基于这些分词创建前缀索引,从而使搜索 "微信*"
或 "截图*"
可以正确命中。
🌐 每个平台的分词方案
由于 SQLDelight 是跨平台方案,因此我们在 CrossPaste 项目中根据平台选用了不同的本地分词实现:
平台 | 分词方式(推荐) |
---|---|
Android | ICU4J(内置于 Android 系统)或 jieba-android |
JVM/Desktop | 使用 com.ibm.icu.text.BreakIterator 实现通用多语言分词 |
iOS | 使用系统自带的 CFStringTokenizer 或 LinguisticTagger |
只要确保最终写入 FTS 索引字段的是空格分隔的高质量关键词列表,就可以让全文搜索在所有语言环境下都表现良好。
✨ 搜索效果显著提升
得益于这一策略,CrossPaste 的搜索功能在中英文混合、Emoji、链接、代码片段等多样内容中依然表现优秀。即使 FTS 本身只支持前缀搜索,通过分词 + 通用拼接:
val searchQuery = "pasteSearchContent:(${terms.joinToString(" AND ") { "$it*" }})"
依然可以实现 “全文搜索” 的体验。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。