在Kotlin Multiplatform(KMP)项目中,把网络层、业务逻辑甚至界面状态都放到共享模块里已经不算新鲜,但数据库层却常常被留在各平台原生代码中。SQLite作为移动端使用最广泛的关系型数据库,在Android和iOS上都有稳定实现,问题在于共享代码无法直接导入平台专属的SQLite API。解决办法主要有两条:一是使用SQLDelight这类跨平台数据库框架,通过SQL语句生成Kotlin查询接口;二是手动声明expect/actual,为Android和iOS分别桥接原生SQLite能力。两种方案都能实现同一套数据库结构、同一份查询逻辑在多端复用,但在工程复杂度、类型安全和维护成本上有明显差异。

一、共享数据库的两种实现路径:SQLDelight与手动expect/actual
SQLDelight由Square维护,它并不重新实现SQLite引擎,而是把.sql文件中的建表语句和查询语句解析后生成强类型Kotlin代码。公共模块只需要依赖SQLDelight运行时,平台模块分别提供对应的数据库驱动即可。Android端使用基于android.database.sqlite的AndroidSqliteDriver,iOS端使用基于SQLite3 C API的NativeSqliteDriver。这种方式最大的好处是查询参数和结果列都有编译期检查,SQL语句写错会在生成代码阶段就暴露出来。
手动桥接则不需要引入代码生成工具。你可以在commonMain中声明一个expect class,例如NoteRepository,里面定义插入、查询、删除等方法。然后在androidMain中实现actual类,内部使用SQLiteOpenHelper管理数据库文件;在iosMain中实现另一个actual类,通过Kotlin/Native的cinterop调用sqlite3_open、sqlite3_exec等函数。这种方案更灵活,依赖更少,但需要自己处理游标读取、参数绑定、异常转换以及类型映射,工作量大且容易在不同平台之间产生行为不一致。
无论选择哪种路径,都需要先解决一个基础问题:数据库文件存放在哪里。Android通常把数据库放在Context的私有目录中,iOS则放到NSFileManager的文档目录或应用沙箱的Library目录。共享代码中可以用一个expect fun databasePath(): String来屏蔽平台差异,Android端调用context.getDatabasePath(name).absolutePath,iOS端通过NSSearchPathForDirectoriesInDomains获取路径后拼接文件名。
二、基于SQLDelight搭建共享数据库层
引入SQLDelight后,数据库结构不再写在Kotlin代码里,而是放在.sq文件中。一个典型的工程配置需要在Gradle中声明SQLDelight插件以及各平台依赖。共享模块的build.gradle.kts大致如下:
plugins {
kotlin("multiplatform")
id("app.cash.sqldelight")
}
kotlin {
androidTarget()
iosArm64()
sourceSets {
commonMain.dependencies {
implementation("app.cash.sqldelight:runtime:2.0.2")
}
androidMain.dependencies {
implementation("app.cash.sqldelight:android-driver:2.0.2")
}
iosMain.dependencies {
implementation("app.cash.sqldelight:native-driver:2.0.2")
}
}
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.example.shared.db")
}
}
}接下来在src/commonMain/sqldelight目录下创建.sq文件,内容包含建表语句和以分号隔开的SQL查询。SQLDelight会为每个查询生成对应的函数,函数名可以由注释或语句名指定。例如一个简单的备忘录数据库可以这样定义:
CREATE TABLE note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
selectAll:
SELECT * FROM note ORDER BY created_at DESC;
insertNote:
INSERT INTO note(title, content, created_at) VALUES (?, ?, ?);
deleteById:
DELETE FROM note WHERE id = ?;生成完成后,公共代码中可以通过AppDatabase类拿到一个类型安全的查询对象。为了在共享模块中创建数据库实例而不直接依赖平台API,通常会再声明一个expect工厂类。androidMain里接收Context,iosMain里直接创建NativeSqliteDriver。工厂类的作用是把平台差异隔离到最小范围,让commonMain的其他业务代码只面对AppDatabase。
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// androidMain
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver =
AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
// iosMain
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver =
NativeSqliteDriver(AppDatabase.Schema, "app.db")
}事务处理在SQLDelight中也非常直接。获取到AppDatabase实例后,可以调用其transaction方法,传入一个Lambda表达式,内部执行多条查询。如果Lambda抛异常,事务会自动回滚;正常结束则提交。这个行为在Android和iOS上保持一致,底层分别调用SQLiteDatabase.beginTransaction和sqlite3_exec的BEGIN/COMMIT命令。对于涉及多表写入或需要保证原子性的操作,应当统一放在事务块中。
三、手动expect/actual桥接SQLite:更轻量但更繁琐
如果项目规模较小,或者团队不希望引入额外的代码生成步骤,手动桥接是一种可控的选择。首先在commonMain中定义数据模型和expect类。注意数据模型应当只依赖Kotlin标准库,不要引入任何平台类型。示例:
data class Note(
val id: Long,
val title: String,
val content: String,
val createdAt: Long
)
expect class NoteRepository {
fun insertNote(title: String, content: String, createdAt: Long)
fun getAllNotes(): List<Note>
fun deleteNote(id: Long)
}Android端的actual实现可以借助SQLiteOpenHelper。构造函数需要Context,因此actual类的构造参数会暴露给调用方,通常在上层注入。写入操作使用ContentValues组装数据,查询操作通过Cursor遍历结果集。以下是一个简化实现:
actual class NoteRepository(private val context: Context) {
private val dbHelper = NoteDbHelper(context)
actual fun insertNote(title: String, content: String, createdAt: Long) {
dbHelper.writableDatabase.insert("note", null, ContentValues().apply {
put("title", title)
put("content", content)
put("created_at", createdAt)
})
}
actual fun getAllNotes(): List<Note> {
val notes = mutableListOf<Note>()
dbHelper.readableDatabase.query(
"note", null, null, null, null, null, "created_at DESC"
).use { cursor ->
while (cursor.moveToNext()) {
notes.add(Note(
id = cursor.getLong(0),
title = cursor.getString(1),
content = cursor.getString(2),
createdAt = cursor.getLong(3)
))
}
}
return notes
}
actual fun deleteNote(id: Long) {
dbHelper.writableDatabase.delete("note", "id = ?", arrayOf(id.toString()))
}
}iOS端的实现要复杂不少。Kotlin/Native通过cinterop调用SQLite3 C库,需要处理指针、内存分配和错误码。大致的流程是调用sqlite3_open打开数据库文件,然后使用sqlite3_prepare_v2准备SQL语句,绑定参数后调用sqlite3_step执行,读取列数据时根据类型调用sqlite3_column_text或sqlite3_column_int64。整个过程没有Android那种友好的Cursor和ContentValues封装,开发者必须手动释放语句对象并检查返回码。因此除非团队对C API非常熟悉,否则不建议在iOS端手动实现复杂查询。
手动桥接方案的另一大难点是保持行为一致。例如Android的SQLiteOpenHelper会在数据库中自动创建android_metadata表,而iOS原生SQLite没有这个表;Android的insert方法在冲突失败时返回-1,而iOS的sqlite3_step会返回SQLITE_DONE或SQLITE_CONSTRAINT。这些差异都需要在actual实现中逐一抹平,否则业务代码在两端可能出现细微但不一致的表现。
四、事务、并发与版本迁移实践
无论选择哪种实现方式,事务和并发都是移动端数据库无法绕开的话题。SQLite在同一时刻只允许一个写事务,多个写操作同时发起会触发SQLITE_BUSY错误。在Kotlin Multiplatform中,数据库操作通常放在挂起函数里,并切换到Dispatchers.IO执行,避免阻塞主线程。以SQLDelight为例:
suspend fun saveNoteWithTransaction(db: AppDatabase, note: Note) {
withContext(Dispatchers.IO) {
db.transaction {
db.appDatabaseQueries.insertNote(
note.title, note.content, note.createdAt
)
db.appDatabaseQueries.deleteOldNotes(System.currentTimeMillis())
}
}
}对于SQLDelight项目,版本迁移可以在.sq文件中定义迁移脚本,并在Gradle配置里通过deriveSchemaFromMigrations指向存放迁移文件的目录。SQLDelight会在编译时校验迁移链的完整性,生成新的Schema对象。Android端由AndroidSqliteDriver在打开数据库时执行onUpgrade逻辑,iOS端则读取PRAGMA user_version来决定执行哪些迁移。如果项目使用手动桥接,Android端需要重写SQLiteOpenHelper的onUpgrade方法,iOS端需要在每次打开数据库后检查并执行对应版本的SQL语句。
改进并发性能的常用做法是启用SQLite的WAL模式。WAL允许读操作和写操作并行,减少读写冲突。可以通过执行以下SQL开启:
PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
在SQLDelight中可以在创建数据库后调用execute语句;手动桥接时在onCreate或打开数据库后执行。需要注意的是,WAL模式会额外生成-wal和-shm文件,在iOS上如果涉及文件备份或清理,要把这些文件一并处理,否则可能导致数据库损坏。
五、多平台测试与调试建议
共享数据库层最大的好处是查询逻辑可以在普通JVM环境下测试,不需要启动模拟器。SQLDelight官方提供了JdbcSqliteDriver,可以在单元测试中使用内存数据库。先加载建表语句,再插入测试数据,然后验证查询结果。这样可以把大部分数据层逻辑测试放在本地JVM上快速运行,只有少数涉及平台行为的场景才需要跑仪器测试或模拟器测试。
@Test
fun testInsertAndSelect() {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
AppDatabase.Schema.create(driver)
val db = AppDatabase(driver)
db.appDatabaseQueries.insertNote("标题", "内容", 1000L)
val notes = db.appDatabaseQueries.selectAll().executeAsList()
assertEquals(1, notes.size)
assertEquals("标题", notes[0].title)
}手动桥接的共享逻辑同样可以通过抽象接口进行测试。在commonTest中定义一个FakeNoteRepository,使用内存中的MutableList模拟数据存储,验证业务层对数据库接口的调用是否正确。这种方式虽然无法测试真实SQL行为,但足以覆盖大多数排序、过滤和状态更新逻辑。平台相关的actual实现则需要分别编写Android仪器测试和iOS单元测试来验证文件读写和C API调用。
调试时Android Studio的Database Inspector可以直接查看模拟器或连接设备上的SQLite文件,包括表结构和实时查询结果。iOS端则可以通过打印数据库文件路径,用模拟器的Finder定位后使用DB Browser for SQLite打开查看。SQLDelight默认不输出执行日志,可以在创建Driver时打开日志参数,或者在开发阶段记录each query的参数值。无论哪种方案,尽早把数据库文件路径暴露到日志中,能显著减少联调时定位数据问题的时间。
综合来看,如果你的团队希望快速获得类型安全和跨平台一致性,SQLDelight是更省心的选择;如果依赖管理严格、需要深度控制SQLite行为,或者只是共享少量查询,手动expect/actual也完全可行。真正的关键在于把平台差异收敛在驱动和文件路径层,让共享模块中的业务代码只依赖抽象的数据库接口。
Kotlin MultiplatformSQLite共享数据库修改时间:2026-08-26 12:27:56