上周又接到一个让人头疼的反馈用户说App在支付成功后突然闪退但开发这边怎么都复现不了。干过Android测试的都知道闪退这东西十个里有八个是薛定谔的bug——用户手机上必现开发手里复现不了等连上adb准备抓日志的时候一切又正常了。这种时候与其纠结复现路径不如换个思路提前在设备上挂一套以adb为基础的监控分析脚本7×24小时盯住崩溃信号一旦闪退发生第一时间把日志、时间戳、进程信息落盘。这篇文章就聊聊我实际写过的adb闪退监控脚本包括命令组合、脚本骨架以及那些文档里不会写的坑。1. 闪退排查的痛点为什么你总在现场迟到一步1.1 复现困境闪退开关在用户手里闪退问题的第一道坎永远是复现。用户报障的描述往往是我正常用着就退出来了或者点了某个按钮就崩。这两个信息对于定位来说远远不够。我们真正需要的是崩溃发生的确切时间点、崩溃进程的pid、崩溃时的堆栈以及当时的系统状态。人工复现的弊端很明显概率性崩溃可能一整天都复现不出来好不容易复现了又常常发现logcat缓冲区里的日志早就被后续操作冲掉了。等你手动连上usb、敲完那行adb logcat看到的往往是风平浪静。这也是为什么我一直觉得闪退问题里最值钱的不是事后分析工具而是事前监控脚本。1.2 脚本监控的核心思路不等日志让日志等我们脚本化监控的思路很朴素不追求复现而是保证崩溃一旦发生日志已经被完整保存下来。传统的排查是人去找日志脚本监控是日志在等我们。整个设计围绕三条原则少打扰脚本只做采集和落盘不做任何影响App运行的操作避免引入额外变量多源头取证不只盯Java层堆栈还要兼顾native崩溃tombstone、ANR、系统内存回收等线索可追溯日志必须带准确的时间戳、设备号、App版本号方便和用户报障信息、埋点数据交叉比对有了这三条原则后面所有脚本设计和踩坑都有了判断依据。接下来从最基础的命令组合讲起。2. 抓取闪退日志的命令组合拳缓冲区选择、格式参数与关键字过滤2.1 logcat的四个核心缓冲区别再只盯main很多新手只知道adb logcat但不知道logcat本身分了好几个缓冲区。Android系统的logcat缓冲区主要有缓冲区存储内容排查闪退的价值mainApp自身打印的日志有埋点日志时非常有用system系统服务输出的日志可辅助判断系统级异常crash崩溃信息专属缓冲区闪退分析的第一数据源events系统关键事件am_anr、am_kill等进程生命周期事件抓闪退日志第一步就是盯死crash缓冲区。Android系统会把未捕获异常和native崩溃的关键信息写到这个缓冲区里相当于系统已经帮你做了一遍初筛。漫无目的地抓main缓冲区效率低且容易在关键信息被冲掉之后才看到。2.2 threadtime格式时间戳和线程信息是分析的地基很多人在终端跑adb logcat直接用默认格式但默认格式不带时间戳只有log等级和tag。对于闪退分析来说时间戳是定位崩溃发生在哪个操作环节的关键锚点。threadtime格式会输出日期、时间、进程号、线程号、级别和标签这是做时序分析的基础。adb logcat -v threadtime # 典型的输出行 08-15 10:23:45.678 1234 5678 E AndroidRuntime: FATAL EXCEPTION: main这一行看起来简单信息量却很大08-15 10:23:45.678是崩溃时间1234是进程pid5678是线程tidAndroidRuntime是日志来源tagFATAL EXCEPTION是崩溃标志。拿到这些才能和用户说的我大概十点半左右点了支付对上号。同样值得关注的还有logcat的-v timestamp参数它输出Unix时间戳适合脚本内部做时间减法计算。如果需要毫秒级精度的时间差对比timestamp格式比threadtime更方便。2.3 关键字过滤FATAL EXCEPTION与完整堆栈抓取Java层闪退的标志性输出是FATAL EXCEPTION由AndroidRuntime进程打出。看到FATAL EXCEPTION这行基本可以断定是未捕获异常导致的崩溃。抓取完整堆栈的做法adb logcat -b crash | grep -A 50 FATAL EXCEPTIONgrep的-A参数会把异常后面的堆栈行一起带出来。大多数情况下真正的堆栈有几十行50行够用但遇到调用链非常深的场景50行可能不够建议直接保存到文件再慢慢看而不是在终端滚动翻页。另一个实用技巧是看到崩溃后马上按pid过滤把该进程的所有日志单独抓出来。先通过logcat的threadtime输出定位到pid然后adb logcat -v threadtime | grep -w 1234这样能看到崩溃进程在崩溃前一段时间里都打了什么日志经常能定位到崩之前最后一次业务日志和异常之间的因果关系。3. 监控脚本的完整落地从单次抓取到持续监听3.1 单次抓取脚本先解决能不能拿到日志的问题最基础的使用方式是先把抓日志封装成一个可复用的小工具一次性把crash和main两个缓冲区都倒出来#!/bin/bash # collect_crash.sh 设备序列号 输出目录 DEVICE_ID$1 OUT_DIR${2:-crash_logs} mkdir -p $OUT_DIR TS$(date %Y%m%d_%H%M%S) adb -s $DEVICE_ID logcat -b crash -v threadtime $OUT_DIR/crash_$TS.log adb -s $DEVICE_ID logcat -b main -v threadtime $OUT_DIR/main_$TS.log这个脚本本身很简单但在实际工作中够用——出事之后手动跑一下至少能保证现场信息不在手忙脚乱找命令的过程中继续丢失。注意-s参数多设备环境下一定要带序列号否则adb默认连第一个设备后面插上的设备根本不会响应。3.2 持续监控脚本清空缓冲区循环守候持续监控的脚本要比手动抓取的复杂一点。我的实际做法是三步走清空旧缓冲区保证监控起点干净开启循环监听crash缓冲区检测到FATAL EXCEPTION后立刻触发快照保存。#!/bin/bash # monitor_crash.sh 设备序列号 DEVICE_ID$1 LOG_DIR./crash_logs mkdir -p $LOG_DIR # 清空旧缓冲区保证监控起点干净 adb -s $DEVICE_ID logcat -c # 持续监听crash缓冲区 adb -s $DEVICE_ID logcat -b crash -v threadtime | while IFS read -r line do echo $line $LOG_DIR/crash_stream_$(date %Y%m%d).log if echo $line | grep -q FATAL EXCEPTION; then TS$(date %Y%m%d_%H%M%S) # 把当前检测到的崩溃单独存一份快照 echo CRASH at $TS on $DEVICE_ID $LOG_DIR/crash_snapshot_$TS.log echo $line $LOG_DIR/crash_snapshot_$TS.log # 追加打印接下来的堆栈上下文直到空行结束大约20行 COUNT0 while IFS read -r stack_line [ $COUNT -lt 20 ]; do echo $stack_line $LOG_DIR/crash_snapshot_$TS.log COUNT$((COUNT1)) done fi done设计上有一个权衡如果循环里每行都向文件追加日志量会非常大。我实测下来crash缓冲区一天可能写入几十万行。所以我的做法是crash缓冲区的完整流落盘一份用于事后深挖同时每次检测到FATAL EXCEPTION时额外生成一份快照文件快照里只保留触发后面的堆栈上下文方便快速查阅。3.3 自动轮转与保留策略日志文件的轮转是持续监控必须解决的问题。我的策略很简单按天分文件定期清理只保留最近7天。脚本尾部加一段清理find $LOG_DIR -name crash_stream_*.log -mtime 7 -delete find $LOG_DIR -name crash_snapshot_*.log -mtime 7 -delete不要小看这一步。我见过同事的设备上挂了一个月监控不清理最后日志文件把磁盘塞满连adb命令都执行不动了。监控脚本本身是来帮忙的别让它变成新的麻烦。3.4 多设备同时监控测试桌面上一拖三四个设备是常有的事。脚本需要支持指定序列号否则设备列表一多就会互相冲突。用adb devices拿到序列号列表再逐个启动监控进程adb devices | grep -w device | awk {print $1}部署的时候我一般每个设备一个独立目录轮流起子进程互不干扰。如果设备掉线脚本要能自动重连——这个在下一节的踩坑部分详细说。4. 日志到手之后堆栈解析与罪魁祸首定位4.1 堆栈的关键四要素一份标准的闪退日志核心部分长这样FATAL EXCEPTION: main Process: com.example.app, PID: 1234 java.lang.NullPointerException: Attempt to invoke interface method void java.util.List.clear() on a null object reference at com.example.app.MainActivity.onClick(MainActivity.java:85) at com.example.app.adapter.ListAdapter.onBindViewHolder(ListAdapter.java:120)拿到堆栈后先看四要素异常类型、异常信息、抛出位置、调用链。NullPointerException和IndexOutOfBoundsException的排查思路完全不同异常信息里的Attempt to invoke... on a null object reference能直接告诉你哪个对象是空的而抛出位置的类名和行号则是定位源码的入口。4.2 版本、代码位置、Bug复现的联动堆栈里的行号最值钱前提是你手上有对应的源码版本。我习惯在保存崩溃快照的同时记录当前安装App的版本号和构建号adb shell dumpsys package com.example.app | grep -E versionName|versionCode adb shell pm path com.example.app把版本信息写进快照头部分析的时候直接对应到git的分支和commit。很多闪退在老版本没有、新版本才有版本信息能帮你快速判断是回归bug还是历史遗留。4.3 没有Java堆栈的闪退event缓冲区和tombstone有些闪退在crash缓冲区里空空如也日志突然中断没有任何异常堆栈。这种情况大概率是进程被系统杀掉了或者native层崩溃。两个线索源特别值得关注一是event缓冲区里的AMS生命周期事件adb logcat -b events -v threadtime | grep -E am_anr|am_kill|am_proc_died这些事件会告诉你系统是不是判定进程无响应或者在内存回收时把它杀了。换言之根本没有异常抛出是被系统判了死刑。二是native崩溃的tombstone文件。Java堆栈只有在Java层异常时才会出现native层崩溃比如JNI代码、第三方so库产生的是tombstone。tombstone文件位于/data/tombstones目录里面包含导致崩溃的so库名称、崩溃地址、寄存器状态、调用栈等信息。虽然在常规配置下需要root权限才能读取但在自带root的开发机上可以直接分析adb shell ls -lt /data/tombstones/ | head adb pull /data/tombstones/tombstone_00 ./tombstone_00.txt对于日志突然中断、无Java异常的闪退tombstone往往才是真正的答案。5. 我在实操中踩过的坑授权失效、日志膨胀、时间错位5.1 adb unauthorized与设备重连设备第一次连上电脑时如果手机上没点允许USB调试的弹窗所有adb命令都会返回error: device unauthorized。脚本挂在前台还好最坑的是挂在后台运维机上设备重启之后授权状态失效脚本就静默罢工了。我的应对方案在脚本启动时先做连通性检查拿不到设备列表就退出并报警同时加上自动重连逻辑每隔几秒检测一次adb devices的输出直到设备重新在线。另外建议在开发者选项里把USB调试授权保持时间调长或者在CI机器上通过adb keygen预置公钥省掉人工点弹窗的环节。这个问题不解决监控脚本就只是纸面上存在。5.2 日志膨胀监控脚本自己成了麻烦持续监控最头疼的就是日志文件疯涨一天下来几十GB都有可能。尤其崩溃频繁的设备上crash缓冲区反复写满监控脚本落盘的日志也成倍增长。处理方案有三层按天分文件超过保留周期就删旧文件我习惯保留最近7天不同时落盘main和crash两个完整流主盯crash缓冲区main缓冲区按需拉取快照文件做二次筛选只保留堆栈和上下文不落完整流5.3 设备时间和主机时间的对齐设备时间和电脑时间经常有偏差偏差大了之后日志上的崩溃时间和埋点时间就对不上。建议在脚本启动时做一次时间校准adb shell date $(date %m%d%H%M%Y.%S)这个命令在部分测试机上需要root权限才能完全生效但实测下来很多设备不用root也能改。时间戳对齐这个细节很多人一开始觉得无所谓真到要把崩溃日志和用户操作路径比对时就知道有多重要了。5.4 关于脚本选型的反思Shell够用但别勉强最后聊一点关于脚本语言选型的思考。纯Shell脚本的好处是依赖最少任何Linux或macOS环境都能跑测试机上开发者基本都有adb环境。但Shell的字符串处理和并发管理都比较笨拙如果监控需求升级比如要做多设备并发监控、日志自动上传、崩溃次数统计分析我更推荐用Python重写。Python版的好处是subprocess管理adb进程更干净轮转逻辑可以用logging.handlers.RotatingFileHandler实现统计崩溃次数直接用正则表达式。我在实测中把Shell版升级成Python版之后维护成本明显下降。但说实话起步阶段用Shell把流程跑通、验证思路是最高效的方案——先用最快的路径拿到第一份崩溃日志再决定要不要上更重的工具。我个人现在的做法是Shell版作为应急工具常驻设备Python版作为长期监控平台跑在专门的工作机上。两套并存互不冲突。如果你也在为闪退复现率低而头疼不妨从今天开始挑一台测试设备把这套脚本部署上去下次崩溃发生时你手上有完整日志排查效率会完全不一样。