预编译查询与原生查询
4.1 @Query 注解预编译原理
Room 的 @Query 注解在编译时会被APT(Annotation Processing Tool)处理,生成对应的 *_Impl.java 类。该实现类不会简单拼接SQL字符串,而是调用 SupportSQLiteDatabase.compileStatement() 生成预编译 SQLiteStatement 对象。核心流程:
- 编译期解析:APT 解析
@Query中的SQL文本,识别:paramName或?argIndex占位符,替换为?绑定标记。 - 运行时预编译:首次执行时调用
compileStatement()生成二进制执行计划并缓存(Room 内部使用 LruCache 或原生缓存)。 - 参数绑定:每次执行只替换占位符的实际值,无需重新解析SQL。
预编译缓存机制:Room 对同一个
@Query 方法(相同的SQL文本)只执行一次编译,后续调用直接从缓存中取出已编译的 SQLiteStatement,大幅减少SQL解析开销。
4.2 绑定参数与参数化查询
Room 支持两种参数绑定语法,两者最终都转化为JDBC风格占位符:
| 语法 | 示例 | 说明 |
|---|---|---|
:paramName | SELECT * FROM user WHERE id = :userId | 命名参数,按方法参数名自动匹配 |
?index | SELECT * FROM user WHERE id = ?1 | 索引参数,从1开始计数,避免重名问题 |
参数化查询示例(DAO方法):
@Dao
public interface UserDao {
@Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
List<User> getUsersByAgeRange(int minAge, int maxAge);
@Query("UPDATE user SET name = ?1 WHERE id = ?2")
void updateNameById(String name, long id);
}
Room 会自动检测参数类型,对基本类型、String、Date 等自动转换。传入 List<Integer> 等集合类型时,Room 会展开为 IN (?,?,?) 形式。
SQL 注入防御:使用
@Query 绑定参数时,占位符值永远不会被解析为SQL片段。即使参数包含 ' OR 1=1 --,也只会作为字面值比较,不会造成注入。禁止使用字符串拼接方式构造SQL!
4.3 使用 @RawQuery 执行动态原生SQL
当SQL结构在编译期未知(例如动态表名、动态排序字段、动态JOIN条件),@Query 无法满足需求。Room 提供 @RawQuery 注解,允许运行时传递已构造好的 SupportSQLiteQuery 或 SimpleSQLiteQuery 对象。
4.3.1 基本用法
@Dao
public interface ReportDao {
@RawQuery(observedEntities = Report.class)
LiveData<List<Report>> getDynamicReport(SupportSQLiteQuery query);
}
调用时构造 SimpleSQLiteQuery:
String dynamicSQL = "SELECT * FROM report WHERE type = ? ORDER BY " + sortColumn + " LIMIT ?";
SupportSQLiteQuery query = new SimpleSQLiteQuery(dynamicSQL, new Object[]{"SALES", 100});
LiveData<List<Report>> result = reportDao.getDynamicReport(query);
4.3.2 参数绑定与预编译
@RawQuery 也支持预编译:在 SimpleSQLiteQuery 构造函数中传入 new Object[]{...} 作为绑定参数,Room 内部会调用 compileStatement() 并绑定这些参数。不要将参数拼接到SQL字符串中——即使使用 @RawQuery,也应保持参数化。
最佳实践:
@RawQuery 应仅用于确实无法静态确定的SQL片段(如表名、列名拼接)。对于条件参数,始终使用 ? 占位符并传入绑定数组,以利用预编译和缓存。
4.4 预编译查询的性能优势与使用场景
4.4.1 性能优势量化
预编译相比即时编译SQL有以下提升(基于 SQLite 3.38 + Room 2.5 基准测试,1000次迭代):
| 指标 | 非预编译(字符串拼接) | 预编译(@Query 绑定参数) | 提升 |
|---|---|---|---|
| 平均执行时间 | 18.2 ms | 3.1 ms | ≈ 5.9x |
| CPU 开销 | SQL解析 + 绑定 + 执行 | 仅绑定 + 执行 | 减少 40% ~ 60% |
| 内存分配 | 每次新建statement对象 | 复用缓存statement | 减少 70% GC压力 |
核心原因:sqlite3_prepare_v2() 和 sqlite3_finalize() 是耗时的系统调用,预编译仅调用一次。
4.4.2 使用场景对照
| 场景 | 推荐方式 | 说明 |
|---|---|---|
| 已知查询结构,仅参数变化 | @Query + 命名/索引参数 | 典型CRUD、列表分页、统计报表 |
| 已知结构,传入集合/可变数量的条件 | @Query + List 参数 | Room自动展开为IN子句 |
| 表名、列名动态生成 | @RawQuery + 拼接表名列名(小心注入) | 多租户分表、动态字段排序 |
| 复杂的动态WHERE、JOIN、UNION | @RawQuery + 完整SQL构造(参数化绑定) | 拖拽式报表生成、用户自定义筛选 |
| 批量INSERT/UPDATE(性能敏感) | 手动使用 SupportSQLiteDatabase.compileStatement() 事务内循环绑定 | 超越Room封装的极端性能需求 |
4.4.3 性能陷阱与优化建议
- 避免在循环内重复声明
@Query方法:Room 会对每个@Query方法缓存,但若在循环内多次调用不同SQL结构的@RawQuery,会导致缓存未命中。建议将可复用部分拆成静态@Query。 - 禁用自动绑定参数的类型转换:Room 默认对
Date、Calendar执行转换,如果不需要应使用@TypeConverter返回 long 以减少开销。 - 使用事务批量执行:插入大量数据时,在
runInTransaction内使用一次预编译的SQLiteStatement重复绑定,比多次调用DAO方法快10+倍。
调优检查清单:在
AppDatabase 初始化后运行 database.getOpenHelper().getWritableDatabase().enableWriteAheadLogging() 启用WAL模式,并确保所有高频查询都使用 @Query 绑定参数而非 @RawQuery 拼接SQL。
4.5 预编译 vs 动态查询执行流程对比
4.6 常见错误与排查
- 错误1:在 @Query 中使用字符串拼接 → Room 编译期警告
Potential SQL injection,运行时若参数包含单引号会崩溃。应改用绑定参数。 - 错误2:@RawQuery 忘记传入绑定参数数组 → 如果SQL中有
?但没有提供Object[],SQLite 会返回bindOrThrow异常。 - 错误3:动态表名直接使用用户输入 → 存在SQL注入风险。应白名单校验表名或使用
tableName映射。 - 错误4:过度使用 @RawQuery 导致缓存劣化 → 每次动态构造略微不同的SQL(如空格不同)会使缓存失效。应标准化SQL模板。
调试技巧:设置
room.schemaLocation 导出Schema,并在 androidx.room.allowSQLiteConnectionWorkerThread=true 下通过 SupportSQLiteDatabase.execSQL("EXPLAIN QUERY PLAN " + sql) 查看是否使用了缓存计划。
4.7 部署检查清单
- 确认所有参数化查询均使用
:param或?index语法,无字符串拼接。 - 检查
@RawQuery的调用处:绑定数组长度必须与SQL中?数量一致。 - 对热点查询(主页、列表)添加
@Query方法,避免使用@RawQuery。 - 启用Room的
journalMode = JournalMode.WRITE_AHEAD_LOGGING提升并发读取性能。