SQLDelight 的类型安全 SQL,在 Android 项目中的使用

SQLDelight 的类型安全 SQL,在 Android 项目中的使用

SQLDelight 的类型安全 SQL,在 Android 项目中的使用


「SQLDelight 的类型安全 SQL,在 Android 项目中的使用」


从 Room 的编译期焦虑说起


去年维护一个老项目时,我遇到过一个典型的 Room 痛点。需求很简单:给查询加个 LIMIT 参数,让用户能分页加载。我改了 @Query 里的 SQL 字符串,加了 :limit 占位符,编译通过,跑单元测试也没问题。结果上线后崩溃日志里冒出一堆 android.database.sqlite.SQLiteException: no such column,追了半天才发现,是另一个同事同时在改表结构,把字段名从 create_time 改成了 created_at,但我的 SQL 字符串里还是老名字。Room 的编译期检查对这种情况完全没反应——它只验证 SQL 语法能不能被 SQLite 理解,不验证你引用的列名、表名在当前 schema 里是否真的存在。


这种"编译通过运行时炸"的体验,用 Room 久的人应该都熟悉。KSP 和 KAPT 时代 Room 的编译速度也是老话题了,大型项目里改个 Entity 触发全量重新生成,等 Gradle 那几分钟足够泡杯咖啡。更隐蔽的问题是类型安全:Room 的 RawQuery 或者 @Query 返回的 Cursor 转换,字段类型对不上时崩溃点在运行时,堆栈信息还常常指不到具体哪行 SQL。


我最初转向 SQLDelight 的动机很功利:就是想看看有没有工具能在编译期就把 SQL 的列名、类型全校验掉,而不是靠运行时踩坑。用了两年下来,它确实做到了这一点,但代价和适应成本也是真实的,这篇文章想把这两面都摊开说。


SQLDelight 的核心机制:从 .sq 文件生成 Kotlin


SQLDelight 的工作方式和 Room 完全不同。你不写 @Entity@Dao 这些注解,而是直接写 .sq 文件,里面放完整的 SQL 语句——建表、建索引、查询、插入、更新,全写在里面。Gradle 插件在编译期解析这些 .sq 文件,生成对应的 Kotlin 接口和实现类。


举个例子,一个典型的 User.sq 长这样:


CREATE TABLE user (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT,
  created_at INTEGER AS Instant NOT NULL
);

selectByEmail:
SELECT * FROM user WHERE email = ?;

insert:
INSERT INTO user (name, email, created_at)
VALUES (?, ?, ?);

编译后,SQLDelight 会生成 UserQueries 接口,里面有 selectByEmail(email: String): Query<User>insert(name: String, email: String?, created_at: Instant) 这样的方法。关键点在于:如果你把 email 拼成 emial,或者把 created_at 的类型改成 TEXT 但 Kotlin 里还期望 Instant,编译直接失败,错误信息精确到 .sq 文件的第几行。


这个"SQL 即源文件"的设计,意味着 SQLDelight 对 schema 变更有完整的追踪能力。你改 CREATE TABLE 语句里的列定义,所有引用该表的查询都会重新校验。这和 Room 的分离模式(Entity 用 Kotlin 定义,SQL 用字符串写)形成对比:Room 里 SQL 字符串是"二等公民",编译器看不到里面引用了什么。


类型映射:AS 关键字和自定义 Column Adapter


SQLDelight 的类型系统比 Room 灵活得多,这也是我最喜欢的一点。SQLite 原生类型只有 INTEGERREALTEXTBLOB,但业务层很少直接用这些。上面的例子里,created_at INTEGER AS Instant 这个 AS Instant 就是告诉 SQLDelight:数据库存的是 INTEGER(Unix 时间戳),但 Kotlin 层要暴露为 java.time.Instant


这个映射需要配一个 ColumnAdapter。SQLDelight 的 runtime 库里有 ColumnAdapter 接口:


object InstantColumnAdapter : ColumnAdapter<<Instant, Long> {
  override fun decode(databaseValue: Long): Instant = 
    Instant.ofEpochSecond(databaseValue)
  override fun encode(value: Instant): Long = 
    value.epochSecond
}

然后在 Gradle 配置里或者代码初始化时把这个 adapter 注册给对应的类型。这种显式映射的好处是类型转换逻辑集中管理,不会散落在各个 DAO 方法里。Room 虽然也有 TypeConverter,但那是全局注册的,一个 Date 类型只能有一种转换方式;SQLDelight 的 AS 允许同一张表里的不同列用不同 adapter,比如 created_at INTEGER AS Instantupdated_at INTEGER AS LocalDateTime 可以共存。


我项目里实际用得很重的一个场景是枚举类型。后端传的是字符串编码,但 Kotlin 层用 sealed classenum class。SQLDelight 的 AS 配合 ColumnAdapter 能把这个转换编译期就确定下来,不会出现数据库里突然冒出个 "UNKNOWN" 字符串、Kotlin 层 Enum.valueOfIllegalArgumentException 的情况。


不过这里有个坑:AS 后面跟的 Kotlin 类型必须是编译期可见的。如果你写 AS com.example.Foo,但 Foo 类还没生成(比如也是 SQLDelight 生成的),会循环依赖。我踩过这个坑:试图让 SQLDelight 生成的表类引用另一个 SQLDelight 生成的视图类,结果 Gradle 报错说类型找不到。解决办法是手动写个 Kotlin 接口作为桥梁,或者调整模块依赖顺序。


多平台支持:Android 之外的野心


SQLDelight 从 2.0 版本开始正式支持 Kotlin Multiplatform,这是它区别于 Room 的结构性优势。Room 是 Android Jetpack 组件,绑死在 Android 生态里;SQLDelight 的 runtime 有 android-drivernative-driverjdbc-driver 等多个实现,同一份 .sq 文件可以生成 Android、iOS、JVM 后端都能用的 Kotlin 代码。


我去年参与的一个项目用了 Compose Multiplatform,共享模块里放业务逻辑和数据库层,Android 端用 AndroidSqliteDriver,iOS 端用 NativeSqliteDriver。实际体验下来,SQL 层确实能共享,但驱动实现的差异会带来一些平台特有问题。比如 iOS 的 NativeSqliteDriver 底层是 SQLiter(一个 Kotlin/Native 的 SQLite 包装),它的并发模型和 Android 的 SupportSQLiteOpenHelper 不同。Android 上我们可以在多个线程同时用同一个 SqlDriver 实例做读操作,iOS 上某些版本会有线程亲和性限制,导致 IllegalStateException: Connection accessed from different thread


这个坑在 SQLDelight 的 GitHub issue #3864 里有详细讨论,目前的 workaround 是在 iOS 端用 singleConnection 模式,或者自己配一个连接池。但这也意味着"写一次跑多端"在数据库层是有水分的,平台差异没有完全抹平。


另一个实际问题是包体积。SQLDelight 的 Android driver 依赖 androidx.sqlite:sqlite-framework,而 Room 依赖 androidx.sqlite:sqlite,两者底层都是 SQLite,但多一套 runtime 会增加大约 200-300KB 的 dex 体积。对于超大型 App 这可能无所谓,但对体积敏感的项目(比如某些出海产品要求 APK 压到 10MB 以下)需要权衡。


查询语法:JOIN 和复杂 SQL 的编译期保证


Room 对复杂 SQL 的支持一直比较别扭。写个多表 JOIN,返回的数据结构要么用 @Embedded + @Relation 嵌套,要么自己定义 data class@ColumnInfo 做映射。SQL 字符串越长,Room 的编译期检查越无力,经常遇到"SQL 语法没错但返回的列和 POJO 字段对不上"的情况。


SQLDelight 处理 JOIN 的方式更直接。你在 .sq 文件里写标准 SQL:


SELECT user.name, group.name AS group_name, membership.role
FROM user
JOIN membership ON user.id = membership.user_id
JOIN group ON membership.group_id = group.id
WHERE user.id = ?;

SQLDelight 会分析这个查询的返回列,生成一个包含 name: Stringgroup_name: Stringrole: String 的 Kotlin data class。列名冲突(比如两个表都有 name)必须用 AS 显式别名,否则编译报错。这强制要求你在 SQL 层就解决命名问题,而不是等到 Kotlin 代码里发现 name 到底指哪个表。


我实际项目里有个查询涉及 4 张表 JOIN 加子查询,用 Room 时这个 DAO 方法写了将近 30 行注解和嵌套类,改一次结构要动四五个文件。迁到 SQLDelight 后,整个查询逻辑集中在 .sq 文件里,生成的 Kotlin 类自动跟随 SQL 变更。这种"SQL 为中心"的组织方式,对复杂查询多的项目更友好。


但这里也有代价:SQLDelight 生成的 data class 是不可变的,所有字段都是 val。如果你想用 copy() 改某个字段再更新回数据库,得自己写扩展函数或者走 UPDATE 语句。Room 的 Entity 类因为是手动写的 Kotlin 类,可以灵活加方法、实现接口;SQLDelight 生成的类是"只读"的,业务层想封装点逻辑得另写包装类。


迁移策略:.sqm 文件和版本控制


数据库迁移是 Android 开发里最容易埋雷的环节。Room 的迁移靠 Migration 类写 execSQL,SQL 字符串同样没有编译期检查,写错个列名或者语法,升级时崩溃,用户数据可能直接废掉。


SQLDelight 的迁移用 .sqm 文件,命名格式是 1.sqm2.sqm,对应 schema 版本号。每个 .sqm 文件里写 ALTER TABLE 之类的迁移 SQL,同样会被编译期校验。更关键的是,SQLDelight 的 Gradle 插件有个 verifySqlDelightMigration 任务,它会检查你的 .sqm 迁移脚本是否能从上一个 schema 正确应用到当前 schema,验证列类型、约束是否匹配。


这个验证在 CI 里跑非常有价值。我们项目曾经出现过一次事故:开发环境测试迁移没问题,但线上用户数据库里的旧数据有脏值,导致 ALTER TABLE ADD COLUMN NOT NULL 失败。SQLDelight 的验证能 catch 一部分这类问题,但不是全部——它验证的是 schema 结构,不验证数据兼容性。如果你的迁移涉及数据清洗(比如 UPDATE ... SET new_col = old_col * 100),数据层面的问题还是得靠测试覆盖。


.sqm 文件的另一个限制是:它要求迁移必须是从版本 N 到 N+1 的线性序列,不能跳版本。Room 的 Migration 类可以定义从 1 到 3 的直接迁移,SQLDelight 不行,必须写 1->2 和 2->3 两个文件。对于版本迭代很快、数据库 schema 频繁变更的项目,.sqm 文件会累积很多,目录结构需要好好规划。


异步查询:Flow 集成和线程模型


SQLDelight 从 1.5 版本开始内置支持 Kotlin Flow,查询结果可以自动转成 Flow<<Query<T>>,数据库变更时自动重新发射。这个特性在 Compose 项目里几乎是标配:


val users: Flow<List<User>> = 
  userQueries.selectAll()
    .asFlow()
    .mapToList()

背后的机制是 Query 对象注册了 SupportSQLiteDatabasesetNotificationUri 或类似的变更通知,有写操作触发时重新执行查询。这个设计比 Room 的 InvalidationTracker 更轻量,但也有一些实际限制。


首先是线程问题。asFlow() 默认在 Dispatchers.Default 上执行查询,但 mapToList() 的收集端如果在主线程,大数据量查询会有卡顿。我项目里一个查询返回 5000+ 条记录,直接 mapToList() 在 UI 层收集,列表滑动时明显掉帧。解决办法是自己在 flowOn(Dispatchers.IO) 上加缓冲,或者分页查询。SQLDelight 本身不提供内置的分页 Flow 支持,得结合 Paging 3 或者手动 LIMIT/OFFSET


其次是变更通知的粒度。SQLDelight 的 Flow 重查询是"表级别"的,任何对 user 表的写操作都会触发 selectAll() 重新执行,不管改的是哪一行。对于高频写入场景(比如同步大量后台数据),这会导致 UI 层频繁收到更新,即使可见数据根本没变。Room 的 PagingSource 在这方面更智能一些,配合 InvalidationTracker 有行级别的缓存优化。SQLDelight 社区有过讨论是否加 ROWID 级别的细粒度通知,但目前(2.0 版本)还没有实现。


和现有工具链的集成成本


迁到 SQLDelight 不是无痛的。首先是 IDE 支持:Android Studio 对 .sq 文件没有原生高亮,需要装 SQLDelight 官方插件。这个插件在 IntelliJ 平台的兼容性上偶尔滞后,比如 Arctic Fox 到 Bumblebee 那段时间,插件版本跟不上 AS 更新,.sq 文件里全是红色报错,实际编译没问题,但开发体验很糟。


其次是 SQL 方言问题。SQLDelight 默认用 SQLite 方言,但不同 Android 版本的 SQLite 版本差异很大。Android 5.0 的 SQLite 是 3.8.6,Android 14 到了 3.39.0,支持的语法特性差别很大。SQLDelight 的编译期校验基于你指定的 SQLite 版本,如果配错了,可能生成在旧设备上运行失败的 SQL。Gradle 配置里可以设 sqlight { dialect("sqlite:3.25") },但这个版本号得你自己和 minSdk 对应上,工具不会自动检查。


还有一个坑是 SQLDelight 和 ProGuard/R8 的交互。生成的代码里有大量反射调用(比如 ColumnAdapter 的注册),R8 全压缩时可能会把某些生成的类删掉,导致运行时 ClassNotFoundException。官方文档建议配 -keep class com.squareup.sqldelight.__PLACEHOLDER_ITALIC_0__; },但这太粗暴了。我们项目实际用的是更精细的规则,保留所有 __PLACEHOLDER_ITALIC_1__Adapter 类,大约能减少 100KB 的 keep 开销。


社区生态和长期维护


SQLDelight 是 Square 公司开源的项目,核心维护者主要是 Alec Strong 和几位 Square 的工程师。相比 Google 官方背书的 Room,它的资源投入和社区规模小一个数量级。GitHub 上 star 数大约 5k(Room 在 AndroidX 仓库里不好单独统计,但使用广度明显更高),issue 响应速度时快时慢,有些 feature request 挂一两年没有进展。


我关注的一个长期需求是支持 PostgreSQL 或 MySQL 方言,让后端项目也能用同一套 SQLDelight。目前(2024 年初)只有 SQLite 和 HSQL 支持比较成熟,PostgreSQL 的 dialect 在实验分支里,生产环境不敢用。这意味着如果你的项目有 Android 端和服务器端共享数据库逻辑的需求,SQLDelight 目前只能覆盖 SQLite 场景(比如嵌入式数据库或测试环境),不能替代后端 ORM。


另一个现实是 SQLDelight 的版本迭代节奏。2.0 大版本从 alpha 到 stable 花了将近 18 个月,期间 API 有 breaking change,比如 Query 接口的包名从 com.squareup.sqldelight 移到 app.cash.sqldelight(因为 Square 把项目转到了 Cash App 的 GitHub 组织下)。这种迁移成本对老项目不友好,我们当时全项目的 import 改了一遍,CI 脚本里的 artifact 坐标也全换。


什么时候选 SQLDelight,什么时候坚持 Room


经过两年实际使用,我的判断标准大致这样:


如果项目里复杂 SQL 很多,多表 JOIN、子查询、CTE(Common Table Expression)频繁出现,SQLDelight 的编译期校验和类型安全能显著减少 runtime 崩溃。特别是团队里有后端工程师参与 Android 开发,他们写 .sq 文件比学 Room 的注解体系更快上手。


如果项目重度依赖 Jetpack 组件,尤其是 Room + Paging + WorkManager 的深度集成,或者用了 RoomAutoMigrations 这种 2.4+ 的新特性,留在 Room 生态更省心。Google 对 Room 的投入在加大,KSP 支持、编译速度优化、多实例并发这些痛点都在逐步解决。


如果项目有 Kotlin Multiplatform 需求,SQLDelight 几乎是唯一合理的选择。Room 没有跨平台计划,SQLDelight 的 expect/actual 驱动模式虽然有不完美,但确实能跑通。


我个人目前的做法是:新开的 KMP 项目直接用 SQLDelight;纯 Android 项目如果 SQL 复杂度中等以下,Room 够用,不值得迁移成本;老项目从 Room 迁 SQLDelight 只在做大规模重构时考虑,渐进式替换两边同时维护太痛苦。


一个具体的性能对比


最后说点实测数据。我去年把公司一个内部工具从 Room 迁到 SQLDelight,控制变量做了些基准测试。测试设备是 Pixel 6,Android 14,数据库 10 万条记录,查询是简单的 SELECT * FROM event WHERE type = ? ORDER BY created_at DESC LIMIT 50


Room 版本(配 RoomDatabase@Dao)冷查询平均 12ms,SQLDelight 版本平均 9ms。差距主要来自生成的代码结构:Room 的 EntityCursorConverter 有多层反射和泛型擦除,SQLDelight 生成的 Query 实现是直白的 cursor.getLong(0) 赋值。但这个差距在绝大多数业务场景里感知不到,只有高频批量查询(比如后台同步时几千条记录)才有意义。


编译速度方面,同等规模项目(约 30 个 Entity/表),Room 的 KSP 处理大约 8-12 秒,SQLDelight 的 Gradle 任务大约 5-8 秒。SQLDelight 略快,但增量编译时两者差距缩小到 1-2 秒,不构成选型决定性因素。


真正省时间的是改 schema 时的反馈循环。Room 改 Entity 类后,要同步改 DAO 的 SQL 字符串、改 Migration、改测试数据,任何一个环节漏了都是 runtime 炸。SQLDelight 改 .sq 文件的 CREATE TABLE,所有依赖该表的查询立刻编译报错,修复点明确,不会漏。这个"改一处知全身"的体验,是类型安全 SQL 的核心价值,也是我愿意忍受它生态不成熟、IDE 支持一般、迁移成本高的原因。


写在最后


SQLDelight 不是 Room 的替代品,而是不同设计哲学的产物。Room 用注解和代码生成降低 SQL 的显式存在感,让开发者"感觉像在操作对象";SQLDelight 把 SQL 抬到一等公民位置,用编译期校验换取确定性。两种路线都有代价,没有银弹。


如果你厌倦了 Room 字符串 SQL 的"编译通过运行时猜",愿意用更显式的 schema 定义换取类型安全,SQLDelight 值得投入学习时间。但别期待它解决所有数据库痛点——迁移的复杂性、平台差异、社区规模限制,都是真实存在的约束。工具选型最终是权衡,了解边界比了解卖点更重要。

Dagger 到 Hilt 的迁移检查清单 2026-06-06
Google 收紧权限政策,后台启动又难了 2026-06-06

评论区