头图

在 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,原因包括:

  1. ✅ 开源透明,维护活跃:SQLDelight 完全开源,具备稳定的社区支持。
  2. ✅ 多平台项目验证成熟:多个大型 Kotlin Multiplatform 项目已采用 SQLDelight 作为核心数据库方案。
  3. ✅ 回归 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 项目中根据平台选用了不同的本地分词实现:

平台分词方式(推荐)
AndroidICU4J(内置于 Android 系统)或 jieba-android
JVM/Desktop使用 com.ibm.icu.text.BreakIterator 实现通用多语言分词
iOS使用系统自带的 CFStringTokenizer 或 LinguisticTagger

只要确保最终写入 FTS 索引字段的是空格分隔的高质量关键词列表,就可以让全文搜索在所有语言环境下都表现良好。

✨ 搜索效果显著提升
得益于这一策略,CrossPaste 的搜索功能在中英文混合、Emoji、链接、代码片段等多样内容中依然表现优秀。即使 FTS 本身只支持前缀搜索,通过分词 + 通用拼接:

val searchQuery = "pasteSearchContent:(${terms.joinToString(" AND ") { "$it*" }})"

依然可以实现 “全文搜索” 的体验。


GeekCat
819 声望15 粉丝