ANR 日志显示主线程阻塞 5 秒,如何在线下复现并修复

解读

  1. 场景定位:国内主流 Android 机型碎片化严重,厂商 ROM 对主线程调度策略差异大,线下复现必须覆盖高、中、低三档真机与模拟器。
  2. 阻塞成因:5 秒阈值是 Android 对输入事件(Input Dispatching)无响应的硬门槛,阻塞源 80% 以上为磁盘 I/O、锁等待、反射/序列化、Binder 大事务。
  3. 面试考点:能否把“日志→指标→代码→场景→验证”闭环讲清楚,并给出可落地的灰度与回归方案,体现性能测试工程师“量化+推动”价值。

知识点

  1. ANR 触发原理:InputDispatching Timeout 5 s、BroadcastQueue Timeout 10 s、Service Timeout 20 s,日志位于 /data/anr/traces.txt、event log tag am_anr。
  2. 主线程消息调度:Looper#next() 一旦无法在规定时间内返回,即触发 ANR;需掌握 MessageQueue 采样机制(Looper Printer、Systrace、Perfetto)。
  3. 线下复现四件套:
    ① 流量回放:基于线上埋点构造用户路径,使用 Appium+Grafana k6 本地录制脚本,100% 回放点击序列。
    ② 资源劣化:通过 ADB 设置 CPU 核绑定、内存压力(stress-ng --vm 4 --vm-bytes 70%)、I/O 高负载(dd if=/dev/zero of=/sdcard/tmp),模拟低端机。
    ③ 锁注入:在可疑代码段插桩 Thread.sleep(5500)synchronized(lock),配合 Bytedance 的 btrace 动态下发,实现“精准 5 秒阻塞”。
    ④ 采样剖析:使用 Android Studio CPU Profiler + System Trace,关注 android/view/ViewRootImpl#deliverInputEventLooper#next 的 wall clock 耗时。
  4. 修复量化:
    ① 目标 SLA:冷启动首帧 ≤1.5 s,主线程单帧卡顿 ≤16.7 ms,P99 连续卡顿 ≤3 帧。
    ② 策略:I/O 移子线程(RxJava/协程)、共享锁降粒度(读写锁、CAS)、Binder 批量拆包、启用 WebView 独立进程。
    ③ 回归:在 CI 中集成 adb shell am start -W -Sdumpsys gfxinfo <pkg> framestats,若连续 2 版 P99 帧耗时上涨 ≥5%,自动阻塞合并。

答案

步骤一:日志还原

  1. 取回 traces.txt,搜索 “main” prio=5 tid=1,定位栈顶函数,如 SharedPreferencesImpl$EditorImpl.writeToFile()
  2. 结合 event log 时间戳,计算阻塞区间:从 am_anr 回推 5 s,找到对应用户点击事件。

步骤二:线下复现

  1. 真机池选型:华为低端机(4 GB RAM + eMMC)+ 小米中端机(6 GB + UFS)+ 三星高端机(12 GB + UFS 3.1)。
  2. 脚本准备:用公司流量平台导出该用户 30 分钟路径,Appium Python Client 回放,k6 以 1 VU 线性执行,确保点击坐标一致。
  3. 资源加压:
    • CPU:adb shell taskset -p 0x0F <pid> 绑定小核;
    • I/O:循环写 1 GB 垃圾文件,占满 eMMC 带宽;
    • 内存:循环创建 50 MB 数组,触发 lmk 抖动。
  4. 锁注入:在源码 writeToFile() 首行插桩 Thread.sleep(5500),打包 debug 版,通过公司内部 Tinker 热推,秒级生效。

步骤三:指标采集

  1. 打开 adb shell perfetto -c /config/android_trace.cfg -o /data/misc/perfetto/trace,复现前先 30 s 基线采样。
  2. 复现后使用 android/platform-tools/systrace 解析,查看主线程是否出现 5 s 连续 Running 或 Sleep 0 状态。
  3. 同时记录 dumpsys cpuinfomeminfogfxinfo,确认是否伴随 CPU 飙高、内存抖动、掉帧 300+。

步骤四:瓶颈修复

  1. 若 I/O 阻塞:
    • SharedPreferences 替换为 DataStore 协程方案,写入切换 Dispatchers.IO
    • 对高频 key 做内存缓存 + 批量 commit,降低触发次数 70%。
  2. 若锁竞争:
    • 把全局 synchronized(this) 拆分为分段锁,锁粒度从类级别降到账号维度;
    • 引入读写锁,使读并发提升 3 倍。
  3. 若反射/序列化:
    • 使用 kotlinx.serialization 替代 Gson,缓存 TypeToken,耗时从 400 ms 降至 40 ms。

步骤五:量化验证

  1. 修复后重新跑步骤二脚本 10 次,统计主线程最长消息耗时,目标 ≤800 ms。
  2. 灰度 5% 用户,监控后台 ANR 率,若 3 天内 ANR 率从 0.38% 降至 0.05%,即达标。
  3. 合并主干前,在 CI 中加入回归卡口:
    • 冷启动耗时 +5% 触发告警;
    • 连续丢帧 ≥3 帧的用例失败率 >1% 则拒绝 MR。

拓展思考

  1. 线上灰度阶段,如何利用性能监控平台(如阿里 SLS、腾讯 RUM)做“秒级回滚”?
    答:在 ANR 率超过 0.1% 或 P99 帧耗时上涨 8% 时,通过配置中心秒级关闭新特性开关,无需发版。
  2. 如果阻塞发生在系统级 Binder(如 PackageManager)而非应用代码,测试如何推动解决?
    答:先量化:用 atrace binder_driver 确认 Binder 调用耗时;再联合系统组提单,要求厂商 ROM 优化,或应用侧做缓存/异步化兜底。
  3. 针对 Android 14 的“前台服务必须 5 秒内启动完成”新规,性能测试如何提前拦截?
    答:在 CI 中增加 adb shell am startservice --foreground 用例,超时直接失败,并输出启动全链路 Trace,防止上线后因 FGS 启动慢被系统杀进程。