导读:本期聚焦于美谷创作的《如何在Kotlin Multiplatform项目中共享同一个SQLite数据库?》,敬请观看详情。跨平台项目的数据库层经常成为重复工作量最大的部分。SQLite在Android和iOS上都有成熟实现,但Kotlin Multiplatform共享代码里无法直接调用平台API,必须通过expect/actual机制为不同平台提供统一入口,或者借助SQLDelight这类库在编译期生成类型安全的查询代码。本文从共享模块的工程结构讲起,说明如何声明公共数据库操作接口,如何在Android端使用android.database.sqlite与iOS端使用SQLite3 C API分别实现actual,再对比SQLDelight的配置与使用体验。同时会覆盖连接管理、事务控制、数据库版本迁移以及多平台测试的做法。看完之后你可以决定哪种方案更适合自己的项目规模,并避免常见的线程与生命周期问题。

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

如何在Kotlin Multiplatform项目中共享同一个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

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。