← 返回学习路线
🗄️

第 5 章:本地数据库(SQLDelight)

用 SQL 文件生成类型安全的 Kotlin API,在 Android 和 iOS 上使用同一套数据库逻辑

🗄️ SQLDelight 简介

SQLDelight 是 Square 开发的 KMP 数据库库,其核心思想是以 SQL 为第一公民:你先写 SQL 语句,SQLDelight 自动生成对应的类型安全 Kotlin API。

  • Schema 优先 在 .sq 文件中用标准 SQL 编写建表语句和查询语句,SQLDelight 在编译时解析这些 SQL 并生成对应的 Kotlin 接口代码,包含编译期类型检查。
  • 类型安全 生成的 Kotlin API 完全类型安全。例如 INSERT 语句会生成一个接受正确类型参数的函数,避免运行时 SQL 错误。
  • Flow 集成 通过 coroutines-extensions 扩展包,SELECT 查询可以返回 Flow,数据库内容变化时 Flow 自动发射新值,天然支持响应式 UI。
  • 多平台驱动 同一套 API,Android 使用 AndroidSqliteDriver,iOS 使用 NativeSqliteDriver(基于 SQLite3 C 库),Desktop 使用 JdbcSqliteDriver。
SQLDelight vs Room: Room 是 Android 专属(JVM only),SQLDelight 支持 KMP 所有目标平台。如果你的项目只面向 Android,Room 也是很好的选择;如果需要跨平台,选 SQLDelight。

⚙️ 依赖与插件配置

shared/build.gradle.kts plugins { alias(libs.plugins.sqldelight) } kotlin { sourceSets { commonMain.dependencies { implementation(libs.sqldelight.runtime) implementation(libs.sqldelight.coroutines.extensions) // Flow 支持 } androidMain.dependencies { implementation(libs.sqldelight.android.driver) } iosMain.dependencies { implementation(libs.sqldelight.native.driver) } } } // SQLDelight 插件配置 sqldelight { databases { create("AppDatabase") { // 生成代码的包名 packageName.set("com.example.newsapp.database") // 数据库版本(迁移时需要递增) version = 1 } } }
.sq 文件位置: SQLDelight 会在 shared/src/commonMain/sqldelight/ 目录下查找 .sq 文件,注意路径中要包含包名目录结构,如 sqldelight/com/example/newsapp/database/

📝 Schema 定义(.sq 文件)

5.1 建表语句

commonMain/sqldelight/com/example/newsapp/database/Article.sq CREATE TABLE ArticleEntity ( id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, summary TEXT NOT NULL, cover_url TEXT, author_name TEXT NOT NULL, author_avatar TEXT, published_at INTEGER NOT NULL, -- 存储 Unix 时间戳(毫秒) is_bookmarked INTEGER NOT NULL DEFAULT 0, -- SQLite 中布尔值用 0/1 created_locally INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) ); CREATE INDEX article_published_at ON ArticleEntity(published_at);

5.2 查询语句

Article.sq — 查询语句定义 -- 查询名:getAll(生成 queries.getAll() 函数) getAll: SELECT * FROM ArticleEntity ORDER BY published_at DESC; -- 带分页 getPage: SELECT * FROM ArticleEntity ORDER BY published_at DESC LIMIT :pageSize OFFSET :offset; -- 根据 id 查询 getById: SELECT * FROM ArticleEntity WHERE id = :id; -- 搜索 search: SELECT * FROM ArticleEntity WHERE title LIKE '%' || :query || '%' OR summary LIKE '%' || :query || '%' ORDER BY published_at DESC; -- 仅获取书签 getBookmarked: SELECT * FROM ArticleEntity WHERE is_bookmarked = 1 ORDER BY published_at DESC; -- 插入或替换(UPSERT) upsert: INSERT OR REPLACE INTO ArticleEntity (id, title, summary, cover_url, author_name, author_avatar, published_at) VALUES (:id, :title, :summary, :coverUrl, :authorName, :authorAvatar, :publishedAt); -- 切换书签状态 toggleBookmark: UPDATE ArticleEntity SET is_bookmarked = CASE WHEN is_bookmarked = 0 THEN 1 ELSE 0 END WHERE id = :id; -- 删除旧数据(只保留最近 100 条) deleteOld: DELETE FROM ArticleEntity WHERE id NOT IN ( SELECT id FROM ArticleEntity ORDER BY published_at DESC LIMIT 100 );

⚡ 生成的 API

SQLDelight 在编译时根据 .sq 文件生成如下代码(你不需要手写,只是了解生成结果):

生成代码示意(实际由 SQLDelight 自动生成) // AppDatabase — 数据库入口 interface AppDatabase { val articleQueries: ArticleQueries companion object { operator fun invoke(driver: SqlDriver): AppDatabase val Schema: SqlSchema<QueryResult.AsyncValue<Unit>> } } // ArticleQueries — 所有查询的类型安全接口 interface ArticleQueries : SuspendingTransacter { fun getAll(): Query<ArticleEntity> fun <T> getAll(mapper: (..fields..) -> T): Query<T> fun getById(id: String): Query<ArticleEntity> suspend fun upsert( id: String, title: String, summary: String, coverUrl: String?, authorName: String, authorAvatar: String?, publishedAt: Long ) suspend fun toggleBookmark(id: String) suspend fun deleteOld() }

5.3 使用 Flow 监听变化

commonMain — Flow 查询 import app.cash.sqldelight.coroutines.* import kotlinx.coroutines.* class NewsLocalDataSource(private val db: AppDatabase) { // 返回 Flow,数据库写入时自动通知 fun getAllArticles(): Flow<List<ArticleEntity>> = db.articleQueries.getAll() .asFlow() // 转 Flow .mapToList(Dispatchers.IO) // 在 IO 线程执行查询 fun getBookmarkedArticles(): Flow<List<ArticleEntity>> = db.articleQueries.getBookmarked() .asFlow() .mapToList(Dispatchers.IO) // 批量 UPSERT(在事务中执行,性能更好) suspend fun upsertAll(articles: List<ArticleEntity>) { db.transactionWithContext(Dispatchers.IO) { articles.forEach { article -> db.articleQueries.upsert( id = article.id, title = article.title, summary = article.summary, coverUrl = article.cover_url, authorName = article.author_name, authorAvatar = article.author_avatar, publishedAt = article.published_at ) } } // 清理超出 100 条的旧数据 db.articleQueries.deleteOld() } suspend fun toggleBookmark(id: String) { db.articleQueries.toggleBookmark(id) } }

🔌 多平台驱动

5.4 expect/actual 创建驱动

commonMain expect fun createDatabaseDriver(name: String): SqlDriver
androidMain import app.cash.sqldelight.driver.android.* // Android 需要 Context,通过 Koin 注入 actual fun createDatabaseDriver(name: String): SqlDriver { val context = androidContext() // Koin androidContext() return AndroidSqliteDriver( schema = AppDatabase.Schema, context = context, name = name // 文件名,如 "news.db" ) }
iosMain import app.cash.sqldelight.driver.native.* actual fun createDatabaseDriver(name: String): SqlDriver = NativeSqliteDriver(AppDatabase.Schema, name)

5.5 在 Koin 中注册

commonMain — Koin 模块 val databaseModule = module { single<SqlDriver> { createDatabaseDriver("news.db") } single { AppDatabase(get()) } single { NewsLocalDataSource(get()) } }

📴 离线缓存模式

5.6 Single Source of Truth 模式

推荐的离线缓存设计:数据库是唯一数据来源(SSOT),UI 只订阅数据库的 Flow,网络数据写入数据库后 Flow 自动更新。

commonMain — Repository SSOT 实现 class NewsRepositoryImpl( private val api: NewsApi, private val localDataSource: NewsLocalDataSource ) : NewsRepository { // UI 只从数据库订阅——永远不会无数据显示 override fun getArticles(): Flow<List<Article>> = localDataSource.getAllArticles() .map { entities -> entities.map { it.toDomain() } } // 刷新:拉取网络数据写入数据库,数据库变化自动触发 Flow 更新 override suspend fun refreshArticles() { val remoteDtos = api.getArticles().data localDataSource.upsertAll(remoteDtos.map { it.toEntity() }) } }
ViewModel 中触发刷新 class HomeViewModel(private val repository: NewsRepository) : ViewModel() { private val _isRefreshing = MutableStateFlow(false) val uiState: StateFlow<HomeUiState> = combine( repository.getArticles(), _isRefreshing ) { articles, isRefreshing -> HomeUiState(articles = articles, isRefreshing = isRefreshing) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState()) fun refresh() { viewModelScope.launch { _isRefreshing.value = true try { repository.refreshArticles() } catch (e: ApiException) { /* 处理错误 */ } finally { _isRefreshing.value = false } } } }

🔄 数据库迁移

5.7 创建迁移文件

当需要修改数据库 Schema 时,必须创建迁移文件,避免用户升级 App 时数据丢失。

目录结构 commonMain/sqldelight/ └── com/example/newsapp/database/ ├── Article.sq # 当前 Schema └── migrations/ └── 1.sqm # 版本 1→2 的迁移脚本
migrations/1.sqm — 添加新字段 -- 迁移:版本 1 → 版本 2 -- 为 ArticleEntity 添加 view_count 字段 ALTER TABLE ArticleEntity ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0; -- 添加新表 CREATE TABLE TagEntity ( id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL );
build.gradle.kts — 更新版本号 sqldelight { databases { create("AppDatabase") { packageName.set("com.example.newsapp.database") version = 2 // 从 1 改为 2 } } }
迁移测试: 每次发布数据库迁移,务必测试从老版本升级的场景,不能只测试全新安装。SQLDelight 提供 migrationVerification 工具帮助验证迁移脚本的正确性。

5.8 自定义列类型适配器

SQLite 原生只支持 INTEGER/REAL/TEXT/BLOB,对于 Kotlin 特殊类型需要适配器:

commonMain — 自定义 ColumnAdapter import app.cash.sqldelight.* // Instant 存储为 Long(Unix 毫秒) val instantAdapter = object : ColumnAdapter<Instant, Long> { override fun decode(databaseValue: Long) = Instant.fromEpochMilliseconds(databaseValue) override fun encode(value: Instant) = value.toEpochMilliseconds() } // List<String> 存储为逗号分隔字符串 val stringListAdapter = object : ColumnAdapter<List<String>, String> { override fun decode(databaseValue: String) = databaseValue.split(",").filter { it.isNotBlank() } override fun encode(value: List<String>) = value.joinToString(",") } // 创建数据库时传入适配器 val database = AppDatabase( driver = driver, ArticleEntityAdapter = ArticleEntity.Adapter( published_atAdapter = instantAdapter, tagsAdapter = stringListAdapter ) )

🎯 实践任务

  1. 创建 Article.sq 文件,定义建表语句和 getAll/upsert/toggleBookmark 查询
  2. 用 expect/actual 实现 createDatabaseDriver()
  3. 封装 NewsLocalDataSource,提供 getAllArticles(): Flow 方法
  4. 在 Repository 中实现 Single Source of Truth 模式