主线程操作 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 日志才能定案。

  1. 抓取 ANR trace:在 /data/anr/traces.txt 中查找 "main" 线程堆栈,顶部方法若包含 RoomDatabase.query()、SQLiteSession.execute() 或 CursorWindow.native_init() 则说明主线程正阻塞在数据库操作上。
  2. 开启 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 不再发生。建议执行以下三条验证测试:

  1. 复现原场景 3 次:连续进入列表页、反复冷启动、反复点击存库按钮,每次等待 10 秒以上,确保未弹出 ANR。
  2. 使用 StrictMode 检测磁盘读写:在 Debug 构建中开启 StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().penaltyDeath(),如果修复后任何主线程磁盘操作导致应用崩溃,说明仍有遗漏。
  3. 检查 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。
主线程 Room ANR 故障树 - 三个典型场景 主线程操作 Room → ANR 故障树 主线程 ANR 场景 A 列表页加载数据 主线程同步查询 大结果集/关联查询 场景 B 启动时初始化 创建/迁移数据库 DDL 阻塞主线程 场景 C 点击事件同步写库 主线程 insert/update 事务锁竞争 修复方案 切换至 IO 线程 (协程/Rx) 预防:StrictMode + Lint 检查 + 数据库预热