乐于分享
好东西不私藏

告别偶现ANR:用ADB脚本给App装上“行车记录仪”

告别偶现ANR:用ADB脚本给App装上“行车记录仪”

你遇到过这样的场景吗?用户反馈App偶尔卡死,但自己拿USB调试时一切流畅;logcat里日志满天飞,等想起来抓线程堆栈时,现场早已被覆盖。这种“幽灵卡顿”让无数开发者在深夜挠头。其实,只要写一个简单的Shell脚本,配合ADB命令,就能像行车记录仪一样24小时不间断录制线程状态——卡顿一出现,关键证据自动存档,再也不用赌概率了。

一、核心思路:轮询采样 + 异常触发

行车记录仪平时循环录制,遇到碰撞才保存碰撞前后片段。我们的脚本同理:

  • 周期性采样:每隔几秒执行一次 dumpsys thread,获取目标进程所有线程的状态、堆栈、CPU占用。

  • 异常检测:如果发现主线程状态变为 BLOCKED 或 WAITING(典型卡顿信号),立即把当前完整线程快照写入文件,并额外触发 kill -3 输出Java堆栈。

  • 自动清理:只保留异常日志,正常采样记录直接丢弃,避免存储爆炸。

这样一来,无论ANR多难复现,脚本都会在故障发生瞬间“按下保存键”,留下铁证。

二、脚本实现(30行搞定)

#!/bin/bash# 配置项PKG="com.example.yourapp"      # 替换成你的包名INTERVAL=2                     # 采样间隔(秒)MAX_KEEP=200                   # 最多保留多少个异常日志OUT_DIR="./anr_snapshots"      # 存储目录mkdir -p $OUT_DIR# 清理超过7天的旧日志find $OUT_DIR -type f -mtime +7 -delete 2>/dev/nullget_pid() {    adb shell "ps | grep $PKG | awk '{print \$2}'" 2>/dev/null}PID=$(get_pid)if [ -z "$PID" ]; then    echo "错误:找不到进程 $PKG ,请确保App正在运行"    exit 1fiecho "开始监控 PID=$PID,按 Ctrl+C 终止"while truedo    # 检查进程是否还活着(可能被系统杀死或用户手动关闭)    if ! adb shell "ps | grep -q $PKG"then        echo "进程已退出,等待5秒后重试..."        sleep 5        PID=$(get_pid)        [ -n "$PID" ] && echo "新进程PID=$PID" || continue    fi    TS=$(date +"%Y%m%d_%H%M%S")    TMP_FILE="$OUT_DIR/temp_$TS.txt"    # 获取线程快照    adb shell dumpsys thread $PID > $TMP_FILE 2>&1    # 检测异常状态(BLOCKED / WAITING)    if grep -qE "State.*(BLOCKED|WAITING)" $TMP_FILEthen        FINAL_FILE="$OUT_DIR/anr_$TS.txt"        mv $TMP_FILE $FINAL_FILE        echo "[!] 捕获到线程阻塞,已保存: $FINAL_FILE"        # 额外抓取Java堆栈(对应kill -3)        adb shell kill -3 $PID        # 注意:kill -3的输出在logcat中,需另外保存,这里只是触发    else        # 无异常,丢弃临时文件        rm -f $TMP_FILE    fi    # 控制异常日志总数,超过则删除最旧的    LOG_COUNT=$(ls -1 $OUT_DIR/anr_*.txt 2>/dev/null | wc -l)    if [ $LOG_COUNT -gt $MAX_KEEP ]; then        ls -1t $OUT_DIR/anr_*.txt | tail -n +$((MAX_KEEP+1)) | xargs rm -f    fi    sleep $INTERVALdone

代码要点解读

  • get_pid 通过 ps | grep 实时获取PID,解决了App因崩溃或用户清理而重启的问题。

  • 临时文件先写 temp_*.txt,只有匹配到 BLOCKED/WAITING 才重命名为 anr_*.txt,否则删除。这相当于“只保存事故录像”。

  • kill -3 会让虚拟机把当前所有线程的Java堆栈输出到 logcat(通常带有 “thread dump” 标签),配合日志一起分析,能直接定位到业务代码行号。

三、避坑指南:常见失败原因及解决

报错/现象
原因
解决办法
dumpsys: thread service not found
Android 8.0以下没有 thread 服务
改用 adb shell dumpsys activity threads 或直接 dumpsys activity | grep -A 100 "PID: $PID"
Permission denial: requires android.permission.DUMP
某些厂商ROM(如MIUI)限制了 dumpsys 权限
先执行 adb shell am force-stop $PKG,再快速启动App并运行脚本,此时权限检查会宽松一些;或者使用 adb shell "run-as $PKG dumpsys thread $PID"(仅限debug包)
adb: more than one device/emulator
多个设备连接
指定设备:adb -s <serial> shell ...,或在脚本开头 export ANDROID_SERIAL=xxx
脚本执行几小时后自动断开
ADB服务不稳定或USB线松动
在循环中加入重连逻辑:adb devices 检查,若掉线则 adb reconnect

四、性能调优:别让监控成为新卡顿源

  • 采样间隔:2秒是黄金平衡点。太短(<1秒)会增加ADB通信开销,导致App多出5%~10%的CPU占用;太长(>5秒)可能漏掉一闪而过的卡顿。实测2秒能捕获绝大多数超过3秒的ANR。

  • 存储优化:只保留异常文件,正常轮空删除。一个异常日志大约100~300KB,即使每天抓100次,7天也不过占用几十MB,完全可以接受。

  • 减少ADB协议开销:将 adb shell dumpsys thread $PID 改为 adb exec-out dumpsys thread $PID,可省去多余的终端转义处理,速度提升约30%。

  • 不落盘方案(高级):直接把线程快照通过 adb logcat 输出到日志缓冲区,然后用另一个脚本实时过滤。这样可以彻底避免写入手机闪存,但实现稍复杂。

五、实战效果:一个真实案例

某社交App用户反馈“发图片时偶尔卡死10秒”。开发组用本脚本在后台跑了三天,终于捕获到一份 anr_20260515_143022.txt。打开看到:

text

"main" prio=5 tid=1 WAITING  at java.lang.Object.wait(Native Method)  at android.media.MediaCodec.dequeueOutputBuffer(...)  ...

结合 kill -3 输出的堆栈,发现是图片压缩时等待硬件编码器超时。问题定位后,改用软编码 + 异步回调,卡顿消失。

总结:再隐蔽的ANR,只要线程状态会变化,就逃不过轮询采样的“天网”。把这30行脚本挂在测试机上一两天,你大概率会收获意想不到的现场证据。与其焦虑复现,不如让脚本替你值班。