作者简介
禹昂,携程机票移动端资深工程师,专注于 Kotlin 移动端跨平台领域,Kotlin 中文社区核心成员,图书《Kotlin 编程实践》译者。
一、背景
2022年9月 Kotlin 1.7.20 发布之后,Kotlin Multiplatform Mobile(简称KMM)进入 Beta 阶段,Kotlin/Native new memory management 也变更为默认启用状态。无论从多端统一性还是性能上来看,Kotlin Multiplatform 都进入了下一个里程碑阶段。
携程机票移动端团队在2021年介绍过 KMM 技术在机票产线的落地情况(参考链接 1),2022 年年中开源了团队首个 KMM 项目—— MMKV-Kotlin(参考链接 2),并撰文(参考链接 3)详述 MMKV-Kotlin 的研发过程和一些常见问题。目前继续在 Kotlin Multiplatform 开源领域发力,打造出了基于 DSL 及 Kotlin Symbol Processor(KSP)开发的 SQLite 框架—— SQLlin。
二、需求调研
2.1 为什么要使用 SQLite 框架?
在移动端开发领域,在对 CRUD 操作有着复杂需求的数据存取场景上,SQLite 一直是首选方案。它同时内置于 Android 与 iOS 系统框架中,开发者无需增加额外的包大小。在数据的增删查改上它支持绝大部分 SQL 语法,功能足够强大。SQLite 本身是 C 语言库,虽然官方为它打造了多种语言及开发环境的 wrapper,但目前还不直接支持 Kotlin Multiplatform。因此,寻找或开发一款支持 Kotlin Multiplatform 的 SQLite 框架是我们的必选项。
但同时我们也注意到,SQLite 框架本身的意义并不仅仅在于扩展其支持的技术栈。例如,在 Android 开发中,我们有 Android Framework SQLite Java API,但是开发者们通常会在项目中使用 Jetpack Room 来操作数据库。在 iOS 开发中,开发者可以直接调用 SQLite C API,但是大家也仍然倾向于选择类似 FMDB 这样的框架。原因主要在于以下三点:
(1)SQLite 的原始 API 颗粒度较细,直接在业务代码中使用较为繁琐且容易出错。
(2)SQL 语句以字符串的形式存在于代码中,不受编译器检查。
(3)SQLite 不支持直接存取对象,将基本数据类型与对象进行转换需要编写大量样板代码。
我们期待我们未来使用的 SQLite 框架在支持 Kotlin Multiplatform 的同时可以解决掉以上三个痛点问题。
2.2 开源方案调研
在开发一个项目之前,我们通常会在开源社区寻找成熟的解决方案,如果可以完全契合我们的需求则没有必要重复造轮子。但如果我们调研的项目不完全符合我们的预期,则仍然可以学习其设计思想,为我们自己的设计与研发提供思路与参考。
2.2.1 Jetpack Room
Jetpack Room(参考链接 4)是 Google 官方提供的 SQLite 框架,最初用 Java 打造,并非专为 Kotlin 而生。它仅能用于 Android 开发,暂不支持 Kotlin Multiplatform,因此不符合我们的期望,但我们可以参考它的 API 设计:
@Entity
data class User(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "first_name") val firstName: String?,
@ColumnInfo(name = "last_name") val lastName: String?
)
@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAll(): List<User>
@Query("SELECT * FROM user WHERE uid IN (:userIds)")
fun loadAllByIds(userIds: IntArray): List<User>
@Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
"last_name LIKE :last LIMIT 1")
fun findByName(first: String, last: String): User
@Insert
fun insertAll(vararg users: User)
@Delete
fun delete(user: User)
}
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
它的 API 采用 DAO(Data Access objects)思想,它可以自动完成对象到 SQL 语句的序列化与查询结果 Cursor 到对象的反序列化。开发者只需要定义 DAO 的 interface,并用它提供的注解描述需要操作的对象即可。Room 采用 APT/KAPT(目前正在向 KSP 迁移)对注解进行处理并生成代码,可以避免用户手动编写大量样板代码。用户在使用 Room 时仅需要通过 DAO set/get 对象即可。
不过它也有一些问题。例如:查询操作与按条件的更新和删除操作,用户仍然需要编写 SQL 语句,这些 SQL 语句虽然 Android Studio 提供了高亮,但是仍然是以字符串的形式存在,不受编译器静态类型检查。
2.2.2 Exposed
Kotlin在正式发布时有一个主力卖点就是可以用来构建开发者自己的DSL。Exposed(参考链接 5)是当时官方宣传DSL的范例项目之一。Exposed主要场景是 JVM 后端,它使用 JDBC 可以连接多种数据库,包括:MySQL、Oracle、MariaDB、SQLite 等等。从场景上看 Exposed 也不符合我们的预期,但是我们仍然可以看一下它的 API 设计:
object Users : Table() {
val id = varchar("id", 10) // Column<String>
val name = varchar("name", length = 50) // Column<String>
val cityId = (integer("city_id") references Cities.id).nullable() // Column<Int?>
override val primaryKey = PrimaryKey(id, name = "PK_User_ID") // name is optional here
}
fun main() {
Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver", user = "root", password = "")
transaction {
addLogger(StdOutSqlLogger)
Users.insert {
it[id] = "andrey"
it[name] = "Andrey"
it[Users.cityId] = saintPetersburgId
}
Users.update({ Users.id eq "alex"}) {
it[name] = "Alexey"
}
Users.deleteWhere{ Users.name like "%thing"}
for (cit