主线程操作 Room 的典型场景:列表页加载数据、启动时初始化、点击事件中同步写库
在 Android 应用中,绝大多数 SQLite / Room ANR 都集中在三个高频业务场景上。它们分别出现在页面启动、列表渲染和用户交互环节,对应的是开发者在时间压力下最容易“偷懒”在主线程直接调用的位置。本章按“现象识别 → 数据采集 → 原因定位 → 修复动作 → 复测 → 预防”的完整排查链路,逐一拆解这三个场景。
场景总览
| 场景编号 | 典型代码位置 | 触发条件 | ANR 主因 |
|---|---|---|---|
| 场景 A · 列表页加载数据 | Activity.onCreate() 或 Fragment.onCreateView() 中调用 roomDao.getAll() 并直接赋值给 RecyclerView |
数据量大 > 500 行,或包含复杂 @Relation / 嵌套查询 |
主线程等待数据库 I/O 完成,超过 5 秒 |
| 场景 B · 启动时初始化 | Application.onCreate() 或 ContentProvider.onCreate() 中同步 Room.databaseBuilder()…build() 并执行首次 DDL |
数据库文件首次创建,或版本迁移包含耗时 SQL | createFromAsset()/fallbackToDestructiveMigration() 在主线程阻塞数十秒 |
| 场景 C · 点击事件中同步写库 | Button/View 的 onClick() 中调用 roomDao.insert() / update() 且不切换线程 |
写入事务未完成,或与其他事务冲突(如 SyncAdapter 同时写入) | 主线程等待锁释放或 WAL checkpoint 同步 |
2.1 现象识别
场景 A:进入列表页时出现“应用无响应”对话框,点击“等待”后页面恢复正常,但再次进入仍会 ANR。
场景 B:冷启动过程中直接 ANR,甚至应用图标还未出现对话框就已弹出。常见于第一次安装或版本升级。
场景 C:用户点击按钮后界面卡住,1-2 秒后弹 ANR,取消后按钮可能被多次点击导致重复写入。
判定规则:如果 ANR 发生时机与上述三个动作之一高度吻合(进入页面、冷启动、点击),且“等待”后能恢复,优先怀疑主线程操作 Room。
2.2 数据采集
必须获取 ANR trace 文件和 Room 执行的 SQL 日志才能定案。
- 抓取 ANR trace:在
/data/anr/traces.txt中查找"main"线程堆栈,顶部方法若包含RoomDatabase.query()、SQLiteSession.execute()或CursorWindow.native_init()则说明主线程正阻塞在数据库操作上。 - 开启 Room 日志:通过以下代码在 Debug 构建中输出每条 SQL 的执行线程和执行时长:
// 在 Application.onCreate() 或 Database 构建时注入
Room.databaseBuilder(context, AppDatabase.class, "app.db")
.openHelperFactory(new SupportFactory(
SupportFactory.createSupportOpenHelper(context, new SupportFactory.Options())))
.setQueryCallback((query, bindArgs) -> {
long start = System.currentTimeMillis();
// 实际框架中 callback 在查询结束后调用,此处仅为示例
android.util.Log.i("RoomTrace", "Thread: " + Thread.currentThread().getName()
+ " SQL: " + query + " took " + (end - start) + "ms");
}, Executors.newSingleThreadExecutor()) // 注意:回调本身不应在主线程执行
.build();
排查表:从 trace 里提取主线程堆栈后,对照下表快速定位场景:
· 看到
· 看到
· 看到
· 看到
RecyclerView.onLayout + RoomDatabase.query → 场景 A· 看到
Application.onCreate + SQLiteSession.beginTransaction → 场景 B· 看到
View$PerformClick + RoomDatabase.insert → 场景 C
2.3 原因定位
根据采集到的 traces.txt 和 RoomTrace 日志,定位到具体代码位置。下面展示一段真实的 ANR trace 摘录,主线程调用栈显示“等待数据库连接池获取锁”:
"main" prio=5 tid=1 Blocked
| group="main" sCount=1 dsCount=0 flags=1 obj=0x754e38d0 self=0x7a9d89f800
| sysTid=12345 nice=-10 cgrp=default sched=0/0 handle=0x7a9ea6d000
| state=S schedstat=( 123456789 098765432 321 ) utm=45 stm=34 core=7 HZ=100
| stack=0x7fa76000-0x7fa78000 stackSize=8MB
| held mutexes=
at java.lang.Object.wait(Native method)
- waiting on <0x0c5f8a78> (a java.lang.Object)
at java.lang.Thread.parkFor$(Thread.java:2137)
...
at androidx.room.RoomDatabase.internalQuery(RoomDatabase.java:475)
at com.example.app.data.dao.ArticleDao_Impl.getAll(ArticleDao_Impl.java:98)
at com.example.app.ui.list.ArticleListViewModel.loadData(ArticleListViewModel.java:45)
at com.example.app.ui.list.ArticleListFragment.onCreateView(ArticleListFragment.java:30)
...
如上所示,主线程从 Fragment.onCreateView → ViewModel → DAO → RoomDatabase.internalQuery → 最终阻塞在 Object.wait。结合日志中 SQL 执行时间 > 5s,即可确定是主线程同步查询导致 ANR。
2.4 修复动作
修复的核心原则:所有 Room 操作(读/写/迁移)必须在非主线程执行。针对三个场景给出标准化方案。
| 场景 | 错误写法 | 修复方式(协程) | 修复方式(RxJava) |
|---|---|---|---|
| A | recyclerView.adapter = MyAdapter(dao.getAll()) |
lifecycleScope.launch { val list = withContext(Dispatchers.IO) { dao.getAll() }; adapter.submitList(list) } |
dao.getAllFlowable().subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(list -> adapter.submitList(list)) |
| B | Room.databaseBuilder(this, DB::class.java, "db").build().useDatabase { /* 空操作 */ } 在主线程触发 DDL |
将 build() 和首次访问封装在 asyncInit() 中,并在 ContentProvider 或 Application 的 worker 线程提早初始化:thread { db = Room.databaseBuilder(app, DB::class.java, "db").build(); db.openHelper.writableDatabase } |
使用 Completable.fromAction { db = Room.databaseBuilder(ctx, DB::class.java, "db").build() }.subscribeOn(Schedulers.io()).subscribe() |
| C | 直接在 onClick 中写库:binding.btnSave.setOnClickListener { dao.insert(entity) } |
viewModelScope.launch(Dispatchers.IO) { dao.insert(entity) }(注意同步 UI 反馈需切回主线程) |
Completable.fromAction(() -> dao.insert(entity)).subscribeOn(Schedulers.io()).subscribe() |
避坑提示:即使使用
LiveData + Room 自动异步查询,也要确保 DAO 方法返回值类型为 LiveData/Flow/Flowable,而不是直接返回 List<T>。后者仍是同步阻塞调用。
2.5 复测
修复完成后,必须在真机或模拟器上复现原场景并确认 ANR 不再发生。建议执行以下三条验证测试:
- 复现原场景 3 次:连续进入列表页、反复冷启动、反复点击存库按钮,每次等待 10 秒以上,确保未弹出 ANR。
- 使用
StrictMode检测磁盘读写:在 Debug 构建中开启StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().penaltyDeath(),如果修复后任何主线程磁盘操作导致应用崩溃,说明仍有遗漏。 - 检查 Room 线程调度日志:通过上面设置的
setQueryCallback输出日志,确认所有 SQL 都在pool-*-thread-*(非主线程)上执行。
2.6 预防
将以下规则纳入项目代码规范与 CI 检查:
- 禁止在主线程调用同步 DAO 方法:团队约定所有 DAO 方法必须返回
LiveData/Flow或在调用处显式切换线程(使用@WorkerThread注解标识)。 - 使用 Lint 自定义检查:编写 Gradle 插件或 Android Lint 规则,检测主线程中调用
RoomDatabase.*.apply()、@Insert、@Update、@Delete且未在Dispatchers.IO或@WorkerThread方法中的代码,并给出WARNING或ERROR。 - 启动时数据库预热(Warm-up):在
Application.onCreate()中后台线程调用database.openHelper.writableDatabase以提前创建表/迁移,避免首次用户交互时触发阻塞。 - UI 组件绑定安全:在 ViewModel 中始终使用
viewModelScope.launch或liveData(Dispatchers.IO){ ... },Activity/Fragment 中不直接操作 DAO。