Crash & ANR 排查根治
× AI 提效实战
从读懂第一行堆栈,到根治线上疑难杂症,再到让 AI 代理接管崩溃工单——一条完整的进阶路径。覆盖 Crash 图鉴、ANR 解剖、Native 崩溃、全套工具链、Play Console / Firebase 后台用法、测试机取数,以及本库原创的 crash-triage AI 技能。
全景与方法论
稳定性问题分类地图
先建立地图,再谈排查。Android 的"闪退/卡死"只有四大类,每类的证据、工具、章节都不同:
| 类型 | 表现 | 本质 | 关键证据 | 详见 |
|---|---|---|---|---|
| Java/Kotlin Crash | 应用闪退 | 未捕获异常沿调用栈上抛到主线程顶层 | logcat 里的 FATAL EXCEPTION 堆栈 | 02 |
| Native Crash | 应用闪退 | C/C++ 层信号(SIGSEGV/SIGABRT…) | tombstone 文件、*** *** 段日志 | 04 |
| ANR | 卡死→系统弹窗"无响应" | 主线程被占用,超时未处理关键事件 | /data/anr/ trace、Vitals ANR 报告 | 03 |
| 系统杀进程 | "用着用着回到桌面"、后台丢进程 | OOM/LMK 低内存杀、后台限制、用户强停 | ApplicationExitInfo 退出原因 | 08 |
为什么必须治:平台的硬红线
Google Play 的 Android Vitals 定义了"坏行为阈值":用户感知崩溃率 ≥1.09% 或 用户感知 ANR 率 ≥0.47%(全设备口径),应用在整个商店的曝光都会被降权;任一单机型上 ≥8%,该机型降权且商店页可能挂警告。稳定性不是技术洁癖,是流量问题。
贯穿全文的五步方法论
- 现象固定 —— 拿到最原始的证据:完整堆栈/trace/日志,设备、系统版本、App 版本、复现路径。禁止在"二手描述"上开工。
- 定位 —— 从堆栈找到"第一现场":你的代码在哪一帧?混淆了先还原(retrace)。
- 归因 —— 回答"为什么会走到这一步":空从哪来?锁被谁持有?内存被谁吃掉?——用工具取证,不靠猜。
- 根治 —— 修根因而不是包一层 try-catch;修完必须有一条能证明修复的验证路径(复现步骤/回归测试/灰度指标)。
- 防复发 —— 同类问题的防线:lint 规则、StrictMode、CI 检查、监控告警。修一个 bug,堵一类 bug。
第一次值班:前 30 分钟照表执行
| 时间 | 必须完成 | 产物 / 决策 |
|---|---|---|
| 0–5 分钟 | 确认版本、开始时间、受影响用户、是否启动即崩/核心链路不可用;暂停相关灰度 | 严重级别、事故群/负责人;达到止血线立即回滚或关 feature flag |
| 5–10 分钟 | 按四类地图分流:Java/Kotlin、Native、ANR、系统杀进程 | 选择唯一主证据:stack / tombstone / trace / ExitInfo |
| 10–20 分钟 | 取 3 个以上事件样本,对齐 mapping/Build ID,按版本、OS、机型、国家、key 看共性 | 「确定事实」与「未知项」列表,禁止把单样本当普遍根因 |
| 20–30 分钟 | 列最多 3 个可证伪假设,每个写下一条验证命令、负责人和截止时间 | 继续灰度 / 暂停 / 回滚决策;向团队发布首报而非未经验证的修复承诺 |
第一课:读懂一次崩溃
堆栈解剖:四个信息一眼定位
FATAL EXCEPTION: main ← ① 崩在哪个线程(main=主线程)
Process: com.example.app, PID: 12345
java.lang.NullPointerException: ← ② 异常类型:决定去哪一章查图鉴
Attempt to invoke virtual method 'int java.lang.String.length()'
on a null object reference ← ③ 异常消息:哪个对象、什么操作
at com.example.app.OrderDetail.bind(OrderDetail.kt:87) ← ④ 第一现场:自己包名的最上一帧
at com.example.app.OrderAdapter.onBindViewHolder(OrderAdapter.kt:42)
at androidx.recyclerview.widget.RecyclerView... ← 框架帧:通常只是"路过"
Caused by: ... ← 有 Caused by 时,根因在最底下那个!
- 读栈顺序:先看异常类型和消息 → 再从上往下找第一帧属于自己包名的代码 → 有
Caused by链时,一路追到最底层的那个才是根因; - 混淆包还原:线上堆栈是
a.b.c(Unknown Source)时,用 R8 的 mapping.txt 还原:retrace mapping.txt stacktrace.txt(每个发版的 mapping 必须归档!); - 抓现场三板斧:
adb logcat -b crash只看崩溃缓冲区;adb logcat --pid $(adb shell pidof -s 包名)只看自己进程;崩溃瞬间没抓到就adb bugreport事后取(见 07 章)。
复现:排查价值的一半
拿到堆栈后第一件事不是改代码,是建立稳定复现——能复现的 bug 已经修好一半。三个层级依次尝试:精确步骤复现(用户路径回放)→ 条件复现(特定数据/网络/权限状态)→ 压力复现(monkey / 反复旋转屏幕 / 开发者选项"不保留活动"模拟进程重建)。
# 三个经典压力复现开关
adb shell settings put global always_finish_activities 1 # 不保留活动:暴露进程重建类崩溃
adb shell monkey -p com.example.app --throttle 300 -v 5000 # monkey 随机事件流
# 开发者选项 → 模拟辅助显示/切换语言/切换深色模式:暴露配置变更类崩溃
证据交接单:任何人接手都能继续
问题 ID / 首次时间 / 当前负责人:
影响:版本、轨道、用户数、Crash/ANR 率、核心链路
分类:Java/Kotlin | Native | ANR | 系统退出
已对版:mapping / native Build ID / 源码 commit
分布:App 版本、OS、机型、国家、feature flags
主证据:完整 stack/trace/tombstone 链接(不只贴截图)
确定事实:只写证据直接支持的结论
候选假设:按概率×影响排序;每条附证伪方法
止血状态:回滚/开关/降级/未处理,含时间
下一步:命令或测试、负责人、截止时间
这份交接单也是第 09 章的「崩溃上下文包」雏形。先让人类能无损接手,再把同一份结构化证据交给 AI;否则 AI 只会更快地产生未经验证的猜测。
把上面的 NPE 真正走完
假设第 87 行是 order.title.length,继续向上追到 Java/JSON 边界,发现服务端旧数据缺少 title,Kotlin 把 platform type 当成非空。完整闭环应是:
- 验证根因:用缺 title 的固定响应复现;确认同样数据进入 mapper 后产生 null,而正常响应不触发;
- 根治:在 DTO → Domain 边界把字段声明为
String?,按业务规则返回可展示的占位值或明确的MalformedOrder,UI 只接收非空 Domain; - 回归:覆盖缺字段、空字符串、正常值和旧缓存反序列化;运行 release + R8 构建,复跑原路径;
- 预防:开启严格反序列化/schema 监控,为 Java platform type 边界加 lint/review 规则,并给 Crashlytics key 记录响应 schema 版本。
order.title.length 改成 order.title?.length ?: 0,却没有定义「缺 title 的订单是否有效」,它只消除了闪退,没有修复数据契约。常见 Crash 图鉴与根治
图鉴不是让你背异常名,而是帮你从「异常消息」跳到下一条取证动作。表中的根治方案都遵循同一原则:修复状态和边界,不吞异常。
| 异常 / 典型信号 | 产生原因与排查 | 根治与预防 |
|---|---|---|
NullPointerException... on a null object reference | Java 可空值未判空;Kotlin 最危险的是 Java platform type(显示为 String!)、反序列化缺字段和 lateinit 未初始化。先看第一条业务帧,再反查空值的生产路径。 | 在 Java/网络/数据库边界立刻转成明确的可空类型或领域对象;用 requireNotNull 只守真正不变量。不要用连续 ?. 把必填数据静默吞掉。 |
IndexOutOfBoundsExceptionIndex: 5, Size: 5 | 索引与集合不是同一快照;分页回调、DiffUtil 或并发更新后仍用旧 position。记录 index、size、adapterPosition 和数据版本。 | 用 getOrNull 只处理允许缺失的读取;RecyclerView 点击时重新取 bindingAdapterPosition 并检查 NO_POSITION;提交不可变列表。 |
ClassCastExceptionX cannot be cast to Y | Bundle/Intent、JSON、多类型列表或错误 ClassLoader 产生了与假设不一致的实际类型。先打印实际类名与入口数据。 | 使用类型安全导航参数、sealed class 与显式序列化 schema;跨版本数据必须迁移,不能靠强转。 |
IllegalStateExceptionFragment not attachedafter onSaveInstanceState | Fragment 已 detach 仍访问 Context;状态已保存后提交事务;RecyclerView 布局中途修改数据会报 inconsistency。对照生命周期时间线与所有数据写入口。 | 只在适当生命周期访问 UI;导航事件去重并在可恢复状态执行;列表只通过单一数据源提交新快照。commitAllowingStateLoss 仅适合确实允许丢 UI 状态的场景。 |
WindowManager.BadTokenExceptionUnable to add window | 用非 Activity Context 弹窗,或 Activity 已 finishing/destroyed。结合页面生命周期和回调时间判断。 | 让 UI 事件由当前可见页面消费;展示前校验状态只是最后护栏,根治是取消过期异步结果。 |
WindowLeakedActivity has leaked window | Activity 销毁时 Dialog/PopupWindow 仍显示。它常由框架构造后写入日志,用来指出窗口泄漏,不一定等同一次未捕获 Crash;与「无效 token 导致加窗失败」的 BadTokenException 是两类问题。 | 由生命周期所有者持有并在 onDestroy 关闭,优先使用 DialogFragment;同时检查日志后的真正 FATAL EXCEPTION,避免把诊断日志当最终崩溃类型。 |
IllegalArgumentExceptionReceiver not registered | 注册与反注册路径不对称、重复反注册,或注册失败后仍执行清理。 | 集中管理注册状态;与 onStart/onStop 或 onResume/onPause 成对;新 API 按可见性指定 receiver flags。 |
| ConcurrentModificationException | 遍历集合时由当前线程或其他线程修改;日志同时记录线程名与修改入口。 | 单线程归属、不可变快照或并发集合;不要用 try-catch 包住迭代继续跑。 |
| ClassNotFoundException / NoSuchMethodError | R8 误删反射目标、动态特性未安装、MultiDex 初始化时序、依赖二进制版本冲突。核对 APK 内容、dependencyInsight、mapping 与受影响 ABI/版本。 | 给反射/JNI/序列化入口加精确 keep 规则;锁定依赖版本并做 release 构建冒烟;动态模块调用前检查安装状态。 |
| ActivityNotFoundException | 隐式 Intent 无处理者、组件被裁剪或厂商应用缺失。 | 调用前用 PackageManager resolve;提供站内降级路径;显式组件随 manifest 测试。 |
| SecurityException | 运行时权限缺失、Android 12+ exported 配置、后台启动/前台服务限制、访问未授权 URI。 | 按目标 SDK 建权限状态机;使用 Activity Result API;组件最小暴露、URI 用临时授权;覆盖拒绝与「不再询问」测试。 |
| TransactionTooLargeException | Binder 事务缓冲区由进程共享;大 Bundle、Intent、Fragment arguments 或 saved state 常在生命周期切换时爆。用 dumpsys activity 和序列化后大小定位。 | 只传 ID,把大对象存数据库/文件/Repository;SavedState 只留恢复 UI 所需的小状态。 |
OutOfMemoryErrorJava heap space | Bitmap、缓存、集合或泄漏导致 retained heap 增长。看 heap dump 的 dominator tree 与 GC 后存活对象。 | 修引用链、缩放图片、设置有上限的 LRU;不要把 largeHeap 当根治。 |
OutOfMemoryErrorpthread_create failed | 线程数或每线程栈耗尽,而非 Java 对象堆满。抓线程列表并按名称/创建栈聚类。 | 共享有界线程池,限制并发,取消泄漏任务;禁用每请求新建线程。 |
| EMFILE / too many open files | fd 耗尽常被包装成 OOM、SQLite 或网络异常。比较 /proc/PID/fd 数量和软上限。 | 所有 Cursor、流、ParcelFileDescriptor、Socket 用 use 关闭;压测监控 fd 曲线。 |
| CursorWindowAllocationException | 查询列/行过大、多个 Cursor 未关、一次加载 BLOB。先查 SQL、窗口大小和存活 Cursor。 | 只查所需列,分页;BLOB 改文件或流;Cursor 必须结构化关闭。 |
| SQLiteException / SQLiteDatabaseLockedException | 迁移不完整、schema 与代码不匹配、并发长事务、磁盘满或损坏。保留原始 SQLite 错误码并核对升级路径。 | 为每条版本迁移写真实数据库测试;事务短小;Room 单例;磁盘满要可恢复,禁止无条件删库。 |
| Resources.NotFoundException | 把颜色值当资源 ID、资源缩水误删反射引用、换肤包版本不匹配。 | 用类型安全资源 API;精确 keep resources;主题包携带并校验 schema 版本。 |
| RxJava UndeliverableException | 流已取消后异步错误无消费者,进入全局 error handler。先看 cause,区分取消后的网络错误与真实编程错误。 | 正确处理 dispose 和业务错误;全局 handler 只忽略明确无害的取消/中断类 cause,其余上报或交给线程异常处理器。 |
| CancellationException / 协程异常 | CancellationException 通常是协作式取消信号;误吞它会让任务继续。兄弟任务互相取消则检查 Job 层级。 | 捕获宽泛 Exception 后必须重新抛取消;独立子任务用 SupervisorJob/supervisorScope;CoroutineExceptionHandler 只处理根协程未捕获异常,不代替 try-catch。 |
ForegroundServiceStartNotAllowedExceptionandroid.app,API 31+ | App 在后台状态尝试启动前台服务(推送到达、定时任务回调里直接 startForeground 是重灾区)。少数场景(如用户刚点了通知)有豁免,其余一律拒绝启动。 | 后台触发的工作改用 WorkManager/JobScheduler 出队;确需前台服务时,把启动动作挪到应用仍在前台的窗口内触发,或改用 startForegroundService 前先确认满足豁免条件。 |
MissingForegroundServiceTypeExceptionandroid.app,API 31+ | 调用 startForeground() 但 AndroidManifest.xml 里对应 <service> 没声明 android:foregroundServiceType(location/camera/microphone 等)。 | 每个前台服务在 manifest 里显式声明类型;Android 14+ 部分类型还要求运行时权限,发版前用 targetSdk 34+ 的机型跑一遍冒烟。 |
InvalidForegroundServiceTypeException / StartForegroundCalledOnStoppedServiceExceptionandroid.app | 前者是声明的 type 与实际调用不匹配;后者是服务已经 stopSelf()/被系统回收后仍调用 startForeground()——常见于异步回调在 onDestroy 之后才回来。 | startForeground 调用前检查服务自身生命周期状态;异步结果回调统一经过"服务是否还活着"的判断层,和 Activity 侧"页面是否还在"是同一类模式。 |
生命周期类崩溃:真正的问题是「状态过期」
- 进程重建:系统杀掉后台进程后会用
savedInstanceState重建 Activity/Fragment。禁止依赖静态单例仍有数据;以持久 ID 恢复,ViewModel 的 SavedStateHandle 保存最小状态。 - Fragment View 生命周期:Fragment 还活着不代表 View 还活着。观察 LiveData/Flow 时绑定
viewLifecycleOwner,在onDestroyView清 binding。 - 异步结果过期:网络/定位/支付回调回来时页面可能已销毁。让任务随 lifecycleScope/viewModelScope 取消,或把结果写入状态容器,由当前界面渲染。
ANR 深入:从主线程快照追到阻塞源
先认清超时类型
| 类型 | AOSP / Pixel 默认超时 | 第一检查点 |
|---|---|---|
| Input dispatch | 5 秒 | main 是否运行、可运行却没被调度、或在等锁/Binder |
| Broadcast | Android 13 及以下:前台优先级 10 秒、后台 60 秒;Android 14+ 在 CPU 饥饿时可延长到 20 / 120 秒 | ANR subject 的 intent flags、冷启动耗时、onReceive/goAsync 是否及时 finish |
| Execute service | 前台服务进程 20 秒;后台 200 秒 | onCreate/onStartCommand/onBind 与冷启动是否阻塞主线程 |
| ContentProvider | 由调用方 ContentProviderClient.setDetectNotResponding 指定 | 远程查询、冷启动与 Binder 线程池是否阻塞 |
startForeground()、JobScheduler 回调超时等独立 ANR 类型;不要只凭「5 秒」判断所有 ANR。八类高频根因
- 主线程 I/O:文件、数据库、DNS、网络、Binder 或大 JSON/Proto 序列化;
- SharedPreferences:
commit()同步落盘;apply()虽异步写盘,但待完成写入会在 Activity/Service 生命周期切换时由框架等待,仍可能通过 QueuedWork 造成 ANR。新项目优先 DataStore; - 锁竞争/死锁:main 等后台线程持有的锁,而后台又等 main 或同步 Binder;
- 同步 Binder:你的栈停在
BinderProxy.transactNative,真正慢点可能在 system_server 或另一个进程; - 广播和服务:
onReceive、冷启动初始化、onStartCommand做重活;goAsync()忘记finish(); - 布局风暴:深层嵌套、反复 requestLayout、Compose 高频无效重组导致 measure/layout 超时;
- GC 抖动:短时间大量分配使 main 频繁停顿;单看一次 GC 不足以定罪,要结合 Perfetto 的分配与调度时间线;
- 系统负载:main 处于 Runnable 却长时间没 CPU、system_server 锁住或 GPU hang,可能不是 App 自身长任务。
读 trace:沿锁链找真正持有者
"main" prio=5 tid=1 Blocked
| group="main" sCount=1 dsCount=0 flags=1 obj=0x... self=0x...
| sysTid=18420 nice=-10 cgrp=default sched=0/0 handle=0x...
| state=S schedstat=(...) utm=82 stm=21 core=4 HZ=100
| held mutexes=
at com.example.Cache.read(Cache.kt:48)
- waiting to lock <0x07c1> (a java.lang.Object)
at com.example.HomeActivity.onResume(HomeActivity.kt:91)
"sync-worker" prio=5 tid=27 TimedWaiting
- locked <0x07c1> (a java.lang.Object)
at android.os.BinderProxy.transactNative(Native method)
at com.example.remote.ISyncService$Stub$Proxy.refresh(...)
- 看 main 状态:Runnable 表示可能在算或饿死;Blocked 表示等 monitor;Waiting/TimedWaiting 要看在等谁;Native 需结合顶部 native 方法和 Perfetto。
- 找等待对象:复制
<0x07c1>,全文搜索locked或「held by thread N」,定位持锁线程。 - 继续追链:持锁线程若又等 Binder/另一把锁,继续追到最终运行者;同时看 system_server 与 Binder 线程栈。
- 校准快照:trace 可能在超时后才抓到;顶部是
nativePollOnce且 main 已空闲时,优先看同一 cluster 的其他事件和 Perfetto,而非硬猜。
根治与提前暴露
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.penaltyDeath()
.build()
)
}
- I/O 与序列化下移到有界 dispatcher,并设计取消、超时和结果回主线程的边界;
- 不要持锁执行 I/O/Binder/回调;缩小临界区,以不可变快照或单线程状态机替代共享可变状态;
- 广播只调度工作;需要保证执行时用合适的 WorkManager/JobScheduler,不在
onReceive里等结果; - ANR-Watchdog 能在线程长时间不响应时提前抓自有线程栈,但会误报 debugger、系统饿死和长帧,也看不到系统完整 ANR 上下文;它是补充观测,不替代 Vitals、ExitInfo 和 bugreport。
来源:Android:Diagnose and fix ANRs · SharedPreferences API · Android Vitals:ANR
Native Crash:从信号到源码行
| 信号 | 常见含义 | 优先证据 |
|---|---|---|
SIGSEGV | 空/野指针、越界、use-after-free、访问权限错误 | signal code、fault addr、首个业务 so 帧、memory map |
SIGABRT | abort/assert、FORTIFY 或 CHECK 主动终止 | Abort message 与崩溃前 logcat;不要把所有 Java 异常都泛称为 SIGABRT |
SIGILL | 非法指令、损坏函数指针、CPU/ABI 不匹配 | pc 所在映射、ABI、反汇编 |
SIGBUS | 未对齐访问、mmap 文件截断、总线错误 | fault addr、映射文件大小与访问偏移 |
SIGFPE | 整数除零、溢出陷阱等算术错误 | 寄存器、指令与输入值 |
先拿到 tombstone:四条路
下面五个区块怎么读之前,读者手上通常还没有 tombstone 文件——这一步经常被跳过。四条获取路径:
| 场景 | 怎么拿 |
|---|---|
| 有 root 的测试机 | 直接读 /data/tombstones/tombstone_NN(数字滚动覆盖,别等太久去抓) |
| 无 root 设备 | adb bugreport 生成的压缩包里,路径形如 FS/data/tombstones/ |
| 命令行直接查(无 root) | adb shell dumpsys dropbox --print SYSTEM_TOMBSTONE |
| 已接 Crashlytics NDK | Firebase 控制台对应 native crash 事件页可直接下载符号化前的 tombstone |
tombstone 五个区块怎么读
下面是 AOSP 官方文档给出的 tombstone 格式示例(历史机型,字段结构与当前一致,只是十六进制地址位宽因 ABI 而异):
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'Android/aosp_flounder/flounder:5.1.51/AOSP/enh08201009:eng/test-keys'
Revision: '0'
ABI: 'arm'
pid: 1656, tid: 1656, name: crasher >>> crasher <<<
signal 6 (SIGABRT), code -6 (SI_TKILL), fault addr --------
Abort message: 'some_file.c:123: some_function: assertion "false" failed'
r0 00000000 r1 00000678 r2 00000006 r3 f70b6dc8
r4 f70b6dd0 r5 f70b6d80 r6 00000002 r7 0000010c
r8 ffffffed r9 00000000 sl 00000000 fp ff96ae1c
ip 00000006 sp ff96ad18 lr f700ced5 pc f700dc98 cpsr 400b0010
backtrace:
#00 pc 00042c98 /system/lib/libc.so (tgkill+12)
#01 pc 00041ed1 /system/lib/libc.so (pthread_kill+32)
#02 pc 0001bb87 /system/lib/libc.so (raise+10)
#03 pc 00018cad /system/lib/libc.so (__libc_android_abort+34)
#04 pc 000168e8 /system/lib/libc.so (abort+4)
#05 pc 0001a78f /system/lib/libc.so (__libc_fatal+16)
#06 pc 00018d35 /system/lib/libc.so (__assert2+20)
#07 pc 00000f21 /system/xbin/crasher
#08 pc 00016795 /system/lib/libc.so (__libc_init+44)
#09 pc 00000abc /system/xbin/crasher
Tombstone written to: /data/tombstones/tombstone_06
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
- Abort message:主动失败通常已把条件写出来,先处理它——上面这条已经明写了断言文件、行号与条件;
- signal / code / fault addr:这条示例是
SIGABRT(主动终止,fault addr为空);换成SIGSEGV时这一行会是signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0这种形式——0x0或很低地址常是空指针及字段偏移;高位随机地址常见野指针/越界,但必须结合 map 和 sanitizer 证据;靠近 stack guard 要考虑栈溢出; - backtrace:每行格式是
#帧号 pc 相对地址 库路径 (符号名+偏移)(如上面的#00 pc 00042c98 /system/lib/libc.so (tgkill+12));找到首个属于自己 so 的 pc offset,不要停在 libc 的 memcpy/free; - registers:结合崩溃指令判断哪个寄存器是坏指针;
- memory map:确认 fault addr 落在已映射区、权限是否匹配,并核对库的 Build ID。符号文件必须与线上 APK 的 ABI 和 Build ID 精确对应。
符号化三件套
# 先列出候选目录;用 readelf -n 比对 tombstone 中 Build ID,禁止静默选旧 hash
find app/build/intermediates/cxx -type d \
-path '*/RelWithDebInfo/*/obj/arm64-v8a' -print
$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf \
-n 候选目录/libfoo.so | grep 'Build ID'
# 确认 Build ID 后,把 ACTUAL_HASH 换成匹配目录
SYMBOL_DIR=app/build/intermediates/cxx/RelWithDebInfo/ACTUAL_HASH/obj/arm64-v8a
$ANDROID_NDK_HOME/ndk-stack \
-sym "$SYMBOL_DIR" \
-dump tombstone.txt
# 单个 so + 相对 PC;-C 反混淆 C++ 名,-f 显示函数,-e 指定 ELF
$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-addr2line \
-C -f -e libfoo.so 0x1234
# Crashlytics NDK 符号上传;路径按构建产物调整
firebase crashlytics:symbols:upload \
--app=1:1234567890:android:abcdef app/build/native-debug-symbols
内存错误工具怎么选
| 工具 | 适用阶段 | 代价 / 特点 |
|---|---|---|
| ASan | 本地/测试发现堆越界、UAF | 兼容面广但开销高;按当前 NDK 指南配置测试构建 |
| HWASan | 64 位 Arm 测试环境深挖内存错误 | 比传统 ASan 更适合 Android,仍有显著内存开销;设备/系统版本有要求 |
| GWP-ASan | 线上低开销抽样 | 只采样部分分配,量大才更容易命中;Android 14+ 有 Recoverable GWP-ASan 平台能力 |
| MTE | 支持硬件上的持续检测/防护 | 利用内存标签;同步模式定位更准、异步模式开销更低,取决于设备与部署策略 |
从命中点回到源码根治
- UAF/双重释放:明确对象所有权,优先 RAII 与
std::unique_ptr;跨线程传递共享生命周期时才使用shared_ptr,并设计停止/回收顺序; - 越界:所有长度与偏移都在同一处校验,使用带长度的容器/视图;对解析器加入截断、超长、畸形输入的单测与 fuzz;
- JNI 契约:检查 Java 异常、局部/全局引用和线程附着;禁止跨线程保存局部引用,缓存 method/class ID 时处理 ClassLoader 与卸载边界;开 CheckJNI 让这类问题在开发期就报出来,而不是等到线上 native crash(见下方 CheckJNI 小节);
- 栈溢出:消除无界递归和巨型栈数组,改为迭代/堆分配并对深度设上限;
- 防复发:修复后用同一 sanitizer 重跑原始输入,再加入 release 符号上传校验、ABI 冒烟、native 单测/fuzz 与线上 GWP-ASan 采样。
CheckJNI:把 JNI 契约违反提前到开发期报错
模拟器默认开启(logcat 能看到 CheckJNI is ON);真机开启方式:
# 无 root,仅对之后启动的进程生效
adb shell setprop debug.checkjni 1
# 有 root,从系统层面常驻开启
adb shell stop && adb shell setprop dalvik.vm.checkjni true && adb shell start
android:debuggable="true" 的 App 同样会自动启用。CheckJNI 会在下面这些场景直接报错阻断,而不是让它悄悄跑出一个 native crash:
| 检查类别 | 典型触发场景 |
|---|---|
| 坏引用/坏指针 | 把已释放的 jobject/jarray/jstring 传回 JNI;该传对象却传了 NULL |
| 线程归属 | 从错误的线程使用不属于它的 JNIEnv*(每个线程的 JNIEnv* 不能跨线程用) |
| 异常待处理时仍调用 JNI | 有未清理的 Java 异常时又发起了新的 JNI 调用(DeleteGlobalRef/DeleteLocalRef/异常相关函数例外,这几个允许在异常待处理时调) |
| jfieldID / jmethodID 误用 | NULL 字段 ID、类型不匹配、静态/非静态搞混、返回类型与声明不符 |
| 引用类型误用 | 对局部引用调 DeleteGlobalRef,反之亦然 |
| UTF-8 校验 | 传入非法的 Modified UTF-8 字节序列 |
局部引用最常见的两个坑:循环里批量创建局部引用不释放(该用 DeleteLocalRef 或 PushLocalFrame/PopLocalFrame 框起来);用 AttachCurrentThread 附加的原生线程里创建的局部引用不会自动释放,直到线程 detach,必须手动删除。全局引用最常见的坑是用 == 比较两个引用是否指向同一对象——必须用 IsSameObject,官方原文强调"不能假设对象引用是常量或唯一的"。
来源:Android NDK:Debug your project · AOSP:Diagnose native crashes · Memory error debugging and mitigation · JNI Tips(CheckJNI 与引用管理)
工具箱与用法:先问证据在哪
| 现象 | 第一工具 | 升级工具 |
|---|---|---|
| 刚刚 Java/Kotlin 闪退 | adb logcat -b crash -v threadtime | Crashlytics / Play Vitals + retrace |
| 卡住、ANR、偶发慢 | ANR trace / ApplicationExitInfo | bugreport + Perfetto 看调度、锁、Binder |
| Native 闪退 | tombstone + 对版符号 | ASan/HWASan/GWP-ASan |
| 内存上涨 | Memory Profiler / heap dump | LeakCanary、MAT dominator tree、fd/线程计数 |
| 掉帧/重组异常 | CPU Profiler、Layout Inspector | Perfetto Frame Timeline / Compose 重组计数 |
工具给证据,证据决定根治
| 证据确认 | 根治动作 | 回归证明 |
|---|---|---|
| 业务第一帧 + 输入状态证明空值/越界 | 修数据边界、状态机或快照一致性 | 最小复现测试 + release/R8 冒烟 |
| Perfetto 显示 main 长任务、锁等待或 CPU 饥饿 | 下移工作、缩短锁、追到 Binder 对端;系统负载问题按机型/系统聚类 | 同场景 trace 对比 + ANR 灰度指标 |
| heap dominator 指向泄漏链/无界缓存 | 断开生命周期引用、给缓存设预算 | 重复场景后 retained heap 回落 |
| sanitizer 报告分配/释放栈 | 修所有权、边界或 JNI 契约 | 原始输入在 sanitizer 下不再命中 + fuzz 回归 |
| ExitInfo 显示 LMK/用户请求/权限变化 | 按退出类型处理恢复与内存预算,而非伪造 Crash 修复 | 进程重建测试 + 线上退出原因分布 |
命令行取证
# 崩溃缓冲区;保存原始时间、PID、TID
adb logcat -b crash -v threadtime -d > crash.log
# 只看当前进程;App 重启后 PID 会变化
adb logcat --pid=$(adb shell pidof -s com.example.app) -v threadtime
# 一次性系统现场包
adb bugreport bugreport.zip
# 20 秒 Perfetto:调度、频率、窗口、绘制、Binder、运行时
adb shell perfetto -o /data/misc/perfetto-traces/anr.perfetto-trace \
-t 20s sched freq idle am wm gfx view binder_driver hal dalvik input res memory
adb pull /data/misc/perfetto-traces/anr.perfetto-trace .
把 trace 拖进 ui.perfetto.dev,先看 main 的 Thread State:Running 是真在执行,Runnable 长段可能是 CPU 饥饿,Sleeping/Blocked 要沿唤醒者或锁链追。Android 旧版本若直接 pull 无权限,可用 adb shell cat 路径 > 本地文件。
开发期护栏与内存工具
- StrictMode:在 debug 尽早暴露主线程磁盘/网络、泄漏的 Closeable 与注册对象;
penaltyDeath只在开发构建使用; - LeakCanary:对已销毁对象保留弱引用,触发 GC 后仍不可达预期却未回收时 dump heap,自动找引用链;它定位泄漏,不解释所有 OOM;
- Memory Profiler / MAT:先比较两个时点的存活对象,再在 dominator tree 按 retained heap 找「删掉它能释放最多内存」的支配者;
- CPU / Energy Profiler:复现路径前后录制,关注主线程热点、系统调用和唤醒;采样适合低干扰,方法插桩更细但会改变时序;
- Compose Layout Inspector:看重组次数/跳过次数,确认状态读取范围;计数高只是线索,结合 Perfetto 判断是否真的造成帧预算超支;
- Native sanitizer:测试环境用 ASan/HWASan 提高命中率,线上用 GWP-ASan 抽样,详见 04。
启动时回收上次退出证据
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val am = getSystemService(ActivityManager::class.java)
val exits = am.getHistoricalProcessExitReasons(packageName, 0, 8)
exits.filter { it.timestamp > lastUploadedAt }.forEach { info ->
val kind = when (info.reason) {
ApplicationExitInfo.REASON_ANR -> "ANR"
ApplicationExitInfo.REASON_CRASH -> "JAVA_CRASH"
ApplicationExitInfo.REASON_CRASH_NATIVE -> "NATIVE_CRASH"
ApplicationExitInfo.REASON_LOW_MEMORY -> "LOW_MEMORY"
else -> "OTHER_${info.reason}"
}
uploadExit(kind, info.timestamp, info.importance, info.description)
}
}
来源:Logcat 命令行 · Perfetto:Recording system traces · ActivityManager 退出历史 API
Play Console 与 Firebase:线上问题的两副镜头
Android Vitals:先看影响,再切维度
「用户感知」不是所有后台异常的总和。指标按每日活跃用户中至少经历 1 次事件的用户占比计算:用户感知 Crash 指 App 正在显示 Activity 或运行前台服务期间的未处理崩溃;当前用户感知 ANR 只计入 Input dispatching timed out。Play 当前核心坏行为阈值为:崩溃率 1.09%、ANR 率 0.47%;单手机型号均为 8%,Wear OS 单手表型号则分别为 Crash 4%、ANR 5%。Play 每日用最近 28 天数据评估质量;超线可能影响可见度并展示商店警告。
- 先看新增 cluster:同一版本突然出现的新问题,通常比多年存量更有可操作性;
- 按 App 版本切:确认是新版本引入,还是旧版本用户仍未升级;
- 按 OS / 机型切:单厂商、单 GPU、单 Android 版本尖峰,优先查兼容性与系统栈;
- 按国家/轨道切:网络、区域配置、灰度人群可能改变触发条件;
- 比较 affected users 而非只看次数:一个用户循环崩 100 次与 100 个用户各崩 1 次,治理优先级不同。
| 为什么 Vitals 与 Crashlytics 数字不同 | 解释 |
|---|---|
| 采集层不同 | Vitals 来自 Android 系统,可看到 SDK 初始化前的崩溃等事件;Crashlytics 依赖 SDK、初始化和上传成功。 |
| 样本不同 | Vitals 只覆盖 Google Play 安装、认证设备和同意共享诊断数据的用户,并受隐私门槛影响。 |
| 分母不同 | Vitals 的示例口径按每日活跃用户;Crashlytics 同时提供 crash-free users 与 crash-free sessions。若一个用户当天确有 3 个独立 session 且其中 1 个崩溃,换算成崩溃率后按用户与按 session 可分别为 100% 与约 33%(对应 crash-free 0% 与约 67%);「打开 3 次」不必然是 3 个 session。 |
| 聚类不同 | 堆栈归一化、变体拆分、去重窗口不同;先对齐版本、时间区间、设备与 issue 样本,再比较趋势。 |
Crashlytics:把复现上下文塞进每条事件
Firebase.crashlytics.apply {
setUserId(hashUserId(accountId)) // 不上传邮箱/手机号等直接标识
setCustomKey("app_track", buildTrack)
setCustomKey("feature_flags", flagDigest)
setCustomKey("screen", currentRoute)
setCustomKey("order_state", orderState.name)
log("checkout: confirm tapped")
}
try {
repository.refresh()
} catch (e: RecoverableDomainException) {
Firebase.crashlytics.recordException(e) {
key("endpoint", "orders")
key("retry_count", retryCount)
}
showRecoverableError(e)
}
- Keys:使用稳定、低基数、可聚类的维度。当前 Android SDK 最多保留 64 个 key/value;不要把 timestamp 或随机 ID 塞进异常 message 破坏聚类;
- Logs:记录关键状态转移,不要复制整份网络响应。单 session 日志有大小上限,旧日志会被滚动删除;
- User ID:使用内部匿名/哈希标识,并遵守隐私与删除流程;
- Non-fatal:只记录「已处理但需要统计」的异常。把每次 404/取消都
recordException会挤掉真正问题、增加成本并污染告警; - Velocity alert:关注短时间激增,用来触发止血,不替代按受影响用户和版本分布排优先级;
- BigQuery:需要自定义趋势、feature flag 交叉分析或长期留存时导出;用版本采用率与 crash-free/事件率一起判断灰度是否继续。
符号发布链:没上传 mapping,前面几章全部作废
02-04 章的崩溃图鉴、tombstone 解剖、ANR trace 分析,全部建立在"线上堆栈能还原成源码"这个前提上。mapping/符号没上传,Vitals 和 Crashlytics 看到的就是一堆 a.b.c 和裸地址——这一步经常被跳过,直到线上出问题才发现堆栈看不懂。
// build.gradle.kts,release 变体开启 mapping 与 native symbol 自动上传
android {
buildTypes {
getByName("release") {
isMinifyEnabled = true
configure<com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension> {
mappingFileUploadEnabled = true // 默认就是 true,混淆产物才需要它
nativeSymbolUploadEnabled = true // 默认 false(避免拖慢构建),有 native 代码务必手动开
}
}
}
}
| 产物 | AAB | APK |
|---|---|---|
| Java/Kotlin 混淆 mapping(Play Console 侧) | 上传 AAB 时自动带上(前提:uploadCrashlyticsMappingFileRelease 之类的 Gradle task 在构建时跑过,或走 Play App Signing 关联) | 需要在 Play Console「App bundle 浏览器」对应版本手动上传 mapping.txt |
| Native symbol(Crashlytics 侧) | 随 uploadCrashlyticsSymbolFile<Variant> task 自动生成上传,也可手动 firebase crashlytics:symbols:upload --app=<FIREBASE_APP_ID> <符号目录> | 同左,命令行方式与 AAB 一致 |
readelf -n libfoo.so 看不到 build ID 的话,Crashlytics 没法把上传的符号和线上二进制对上,即使符号确实传了也匹配不到。缺 build ID 通常是链接参数漏了 -Wl,--build-id。CI 里把这条 mapping/符号上传的 task 和 assembleRelease/bundleRelease 绑在同一次构建、和 commit 一起归档,比事后补传更可靠。来源:Firebase:Get deobfuscated crash reports · Firebase:Get and customize NDK reports
Gemini AI insights 与 MCP
Firebase 控制台的 AI insights 可对 issue 生成摘要、潜在根因和调试建议,但它不能读取或修改你的本地代码。官方 Firebase MCP server 可让编码代理读取 issue、事件样本、堆栈、用户/事件数、版本维度与 notes,也能新增/删除 note、更新或关闭 issue;它使用运行环境中 Firebase CLI 的当前凭据。数据交给外部 AI 工具后如何处理取决于该工具条款,因此应使用最小权限账号、限制可用项目,所有写操作要求人工确认并保留审计。截至 2026-07-15,该能力仍为 Experimental,可能发生不兼容变更,接入时固定版本并准备人工回退。
| 确认的线上证据 | 根治 | 预防 / 收案 |
|---|---|---|
| 仅新版本、新 feature flag 组激增 | 回滚/关开关止血,定位对应 diff 并修复 | 灰度门禁按新增 cluster、用户率和版本采用率自动暂停 |
| 单机型/OS 集中且含系统栈 | 能力探测与降级;必要时调整覆盖并向厂商提交最小复现 | 建立代表机型实验室与兼容性回归 |
| 自定义 key 指向同一业务状态 | 修状态机/输入数据,而不是只改异常分组 | 把该状态变成低基数监控维度和回归用例 |
来源:Android Vitals · 自定义 Crashlytics 报告 · Crashlytics MCP · BigQuery 导出
测试机数据导出:把事故现场完整带走
bugreport 是无 root 设备的系统快照
adb bugreport bugreport.zip
unzip -l bugreport.zip | less
# 常用服务快照
adb shell dumpsys meminfo com.example.app
adb shell dumpsys gfxinfo com.example.app framestats
adb shell dumpsys activity activities
adb shell dumpsys dbinfo com.example.app
adb shell dumpsys batterystats --charged com.example.app
# 系统 DropBox 事件索引;具体 tag 因系统版本而异
adb shell dumpsys dropbox --print SYSTEM_TOMBSTONE
adb shell dumpsys dropbox --print data_app_anr
| bugreport 区域 | 能回答什么 |
|---|---|
FS/data/anr/ 或 ANR dump 段 | ANR 时各线程、锁链;未 root 时通常通过 bugreport 获取,而不是直接 pull /data/anr |
| tombstones / SYSTEM_TOMBSTONE | Native signal、fault addr、Build ID、backtrace;内容可能因版本、权限和隐私策略裁剪 |
| logcat buffers | 崩溃前后系统与应用日志;先按时间、PID、process name 关联 |
| dumpsys | 进程、Activity、内存、数据库、窗口、电量等系统服务状态 |
| dumpstate_board / 厂商段 | 热、GPU、内核和厂商服务线索;字段不稳定,需按机型解释 |
ApplicationExitInfo:启动后回收上一轮 ANR trace
Android 11(API 30)起,可在 App 下次启动时查询历史退出原因;ANR 记录常带系统抓取的 trace。它非常适合补齐「用户设备无法连 adb」的线上证据,但不保证每条记录都有 trace,也不是实时 ANR 回调。
class ExitInfoReporter(
private val context: Context,
private val store: ExitCursorStore,
private val upload: suspend (ExitEnvelope) -> Unit
) {
private val collectMutex = Mutex()
suspend fun collect() = collectMutex.withLock {
withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return@withContext
val am = context.getSystemService(ActivityManager::class.java)
val records = am.getHistoricalProcessExitReasons(
context.packageName, 0, 32
).sortedWith(compareBy({ it.timestamp }, { it.processName }, { it.reason }))
for (info in records) {
val eventKey = listOf(
info.timestamp, info.processName, info.reason, info.status
).joinToString(":")
if (!store.shouldAttempt(eventKey, System.currentTimeMillis())) continue
try {
val trace = info.traceInputStream?.use { input ->
input.readAtMost(512 * 1024) // API 30 兼容且限制体积
}
upload(
ExitEnvelope(
eventKey = eventKey,
timestamp = info.timestamp,
processName = info.processName,
reason = info.reason,
status = info.status,
importance = info.importance,
description = info.description,
pssKb = info.pss,
rssKb = info.rss,
traceGzip = trace?.let(::gzip)
)
)
store.markUploaded(eventKey)
} catch (retryable: RetryableUploadException) {
store.scheduleRetry(eventKey, retryable) // 有上限的指数退避,不因离线次数直接死信
continue
} catch (io: IOException) {
store.scheduleRetry(eventKey, io)
continue
} catch (permanent: PermanentUploadException) {
store.markDiscarded(eventKey, permanent) // 仅 schema/权限等确定永久错误
continue // Error 与未知异常不吞,继续上抛
}
}
}
}
private fun InputStream.readAtMost(limit: Int): ByteArray {
val output = ByteArrayOutputStream(minOf(limit, 64 * 1024))
val buffer = ByteArray(16 * 1024)
var remaining = limit
while (remaining > 0) {
val count = read(buffer, 0, minOf(buffer.size, remaining))
if (count == -1) break
output.write(buffer, 0, count)
remaining -= count
}
return output.toByteArray()
}
}
- 在冷启动关键路径之外调用,放到受约束的后台任务;
shouldAttempt/scheduleRetry持久化下次时间并做有上限的指数退避,离线不能只按次数死信;只有明确的 schema/权限等永久错误才丢弃。不要捕获全部 Throwable; - 本地与服务端都按 eventKey(timestamp + process + reason + status)幂等去重,并保存 App 版本、设备/OS、feature flags;
- 对
REASON_ANR、REASON_CRASH、REASON_CRASH_NATIVE、REASON_LOW_MEMORY分开治理;REASON_USER_REQUESTED不是应用崩溃; - trace 可能在进程后来以其他原因退出时仍随记录保留,必须结合 reason、timestamp 和内容判断。
无法连接 adb 的设备
指导测试/用户在开发者选项选择「提交错误报告/获取错误报告」,说明问题发生时间、App 版本和操作路径;部分厂商另有日志助手或售后拨号码,必须走公司隐私审批与厂商文档,不能让用户盲目上传整机日志。
| 取到的证据 | 根治动作 | 防复发 |
|---|---|---|
| ANR trace 的 main/锁链 | 下移阻塞操作或修锁顺序 | 同场景 Perfetto 对比 + StrictMode/并发回归 |
| ExitInfo 为 LOW_MEMORY | 按 PSS/RSS、进程重要性和整机压力收缩内存预算 | 低内存/后台重建测试与 ExitInfo 趋势告警 |
| tombstone + 匹配 Build ID | 符号化后修 native 所有权/边界 | sanitizer/fuzz + 符号上传门禁 |
| 日志不足或已被覆盖 | 补低成本 keys、退出回收和滚动诊断,再等待新样本 | 把「证据完整率」纳入稳定性基础设施指标 |
来源:Capture and read bug reports · dumpsys · ApplicationExitInfo
疑难杂症侦查术:把不可观测变成可验证
取证:按版本、机型、OS、国家、网络、账号状态和 feature flag 聚类;每个维度先看 affected users 与基数,避免把小样本 100% 当主因。
推进:列出可证伪假设 → 为最高优先假设增加低基数 key/状态转移 log → 定向灰度 → 比较命中与对照组。一次只改变一个观测变量。
取证:确认崩溃帧属于 App、公开 Android API 还是厂商 framework;同 Android 版本找 AOSP/其他厂商对照,收集 Build fingerprint 与系统组件版本。
根治:优先改用公开 API;反射兼容要探测能力而非猜品牌,并有 no-op/降级路径。最小复现、bugreport、时间与机型提交厂商渠道,不在业务层吞所有 Throwable。
# fd 与线程是两条独立预算;/proc 可见性受版本与权限限制
PID=$(adb shell pidof -s com.example.app)
adb shell ls -l /proc/$PID/fd | wc -l
adb shell 'cat /proc/'$PID'/status | grep -E "Threads|VmRSS"'
adb shell cat /proc/$PID/limits分流:Java heap space 看 hprof dominator;pthread_create failed 看线程创建栈/数量;EMFILE 看 fd 类型。若 ExitInfo 是 LOW_MEMORY/LMK,结合 RSS/PSS 和整机压力,不要只调 Java heap。
PID=$(adb shell pidof -s com.example.app)
adb shell kill -3 $PID # 向 ART 进程请求 Java 线程 dump,输出位置依系统而异
adb logcat -d -v threadtime > manual-thread-dump.log连续抓 2–3 份:相同锁链不动更像死锁,栈持续前进可能只是慢。main 等锁时找持有者,持有者若在 Binder/IO 再追下去;debugger attach 会改变时序,只作为补充。
保险丝:在尽可能早且稳定的进程入口记录启动尝试;若同一版本短时间连续失败 N 次,进入最小恢复模式:关闭远程开关、跳过可选初始化、清理可安全重建的缓存,并展示恢复/反馈入口。
边界:计数器必须跨进程持久化且有成功启动复位、时间窗和版本隔离;禁止自动删除用户数据库或登录态。最终仍要修根因,保险丝只负责止血。
git bisect start
git bisect bad BAD_COMMIT
git bisect good LAST_KNOWN_GOOD
# 每次用同一自动化复现脚本判定,再执行 git bisect good/bad
git bisect reset没有可稳定复现时,可二分 feature flags、初始化模块或请求路径;CI 保存每个候选产物与 mapping。判定脚本不稳定会污染整个二分,先量化误报率。
侦查结束必须落成防线
| 剧本 | 根治 | 防复发验证 |
|---|---|---|
| OOM / fd / 线程 | 修泄漏/无界缓存、收口线程池、结构化关闭 fd | 压力回放后 heap、Threads、fd 回到稳态并设置预算告警 |
| 卡死 / 死锁 | 统一锁顺序,禁止持锁 I/O/Binder,必要时改消息传递 | 并发测试 + 多次线程 dump/Perfetto 不再出现锁环 |
| 二分回归 | 对首个坏提交做最小修复或回滚 | 把判定脚本加入 CI,并为 mapping/产物建立可追溯归档 |
来源:Android Vitals · Android 内存管理概览 · AOSP 调试入口 · Git bisect 文档。启动保险丝、feature flag 二分与多次 kill -3 对比属于工程实践;信号输出、/proc 可见性和厂商日志入口随 Android/ROM/权限变化,必须在目标设备验证。
AI 结合:证据质量决定排查上限
AI 排查质量 ≈ 证据完整度 × 问题约束 × 验证反馈。模型擅长跨堆栈、源码、diff 和分布找关联,但看不到你没提供的 mapping、运行时状态和失败样本。先把输入标准化,再谈提示词技巧。
崩溃上下文包:给人和 AI 的统一输入契约
| 物料 | 最低要求 | 缺失时的风险 |
|---|---|---|
| 完整主证据 | 已 retrace 的 Java stack、对版 tombstone、完整 ANR main + 锁链;保留 cause 和线程名 | 只见症状帧,把框架调用者误判为根因 |
| 源码与版本 | 第一业务帧相关文件、调用方、构建 commit、mapping/Build ID | 分析错误版本或编造不存在的 API |
| 分布与影响 | affected users、版本/OS/机型/国家、首次时间、灰度轨道 | 把小样本巧合当普遍根因 |
| 运行上下文 | 稳定低基数 keys、关键状态转移、复现步骤、网络/权限/账号状态 | 只能解释「哪里崩」,无法解释「为何走到这里」 |
| 变更证据 | 相关文件近期 git log -p、依赖/target SDK/开关变化 | 遗漏最可能的新回归 |
| 负样本 | 同版本正常设备/路径、未受影响分组 | 假设无法证伪,相关性被当因果 |
五段式提示词:可直接复制
你是 Android 稳定性负责人。请把证据与假设严格分开;禁止虚构源码、
API 行为或缺失日志。证据不足时先指出缺口,不要直接给补丁。
[主证据]
粘贴完整 retrace stack / ANR trace / tombstone,保留线程、Caused by、Build ID。
[环境与分布]
App 版本与 commit:___;Android/机型/ABI:___;
受影响用户与时间趋势:___;feature flags/业务状态:___;正常对照组:___。
[相关源码与变更]
粘贴第一业务帧、调用方、生命周期/并发相关代码,以及近期 diff。
[任务与输出]
1. 先列「确定事实」,每条引用具体证据行;
2. 给最多 3 个根因假设,按概率×影响排序;
3. 每个假设给证伪条件、最小验证命令/测试和预期结果;
4. 只有验证通过后才给最小修复,并说明生命周期、并发、兼容性边界;
5. 给回归测试、灰度指标、防复发项;
6. 输出:事实表 → 假设表 → 下一步 → 修复候选 → 风险/未知项。
三种高价值任务型提示词
「从最底层 cause 开始,找第一业务帧;沿参数/状态生产链向上追。输出空值/非法状态从何处进入、为何未在边界失败、需要读哪些调用方。不要把 null check 当默认答案。」
「先判断 main 是 Running/Runnable/Blocked/Waiting;若等锁,按对象地址追持有者直到运行线程;若 Binder,指出需补哪段对端/system_server trace;区分 App 阻塞、CPU 饥饿与晚到快照。」
「不要按异常类机械分组。按共同根因/修复杠杆聚类:生命周期、状态边界、版本兼容、内存预算、线程/锁、符号/发布链。输出 affected users、可合并修复项、优先级与负责模块。」
AI 防幻觉验证协议
- 引用证据:所有事实必须指向堆栈帧、源码行、分布或官方文档;无法引用则标为假设。
- 反例检查:主动问「什么证据会推翻这个结论?正常对照为何没触发?」。
- 工具验证:让 AI 给命令,但由受控环境执行;命令输出回填后再更新概率。
- 最小补丁:只改根因路径,附失败测试;不接受大范围“顺手重构”。
- 独立复核:第二代理只找错误归因、未覆盖边界和回归风险,不看第一代理的“信心”。
- 线上闭环:测试通过仍不是结案;灰度观察新增 cluster、用户率和副作用,达到窗口后再关闭 issue。
来源:Firebase:Crashlytics AI assistance · Claude Code Best Practices · Codex Best Practices
AI 工作流与原创 crash-triage Skill
工作流一:官方 Crashlytics MCP × 编码代理
# 方案 A(交互式官方推荐):安装 Firebase 插件;与方案 B 二选一
claude plugin marketplace add firebase/firebase-tools
claude plugin install firebase@firebase
claude plugin marketplace list
# 方案 B(生产固定版):不安装上述插件,手动只接 Crashlytics
# 首次解析当前版本后,把具体版本号写入配置与变更记录
FIREBASE_TOOLS_VERSION=$(npm view firebase-tools version)
claude mcp add firebase-crashlytics npx -- \
-y firebase-tools@$FIREBASE_TOOLS_VERSION mcp \
--dir "$(pwd)" --only crashlytics
claude mcp list
# 查看并审查该固定版本实际暴露的工具;后续不要自动跟随 latest
npx -y firebase-tools@$FIREBASE_TOOLS_VERSION mcp \
--only crashlytics --generate-tool-list
- 先让代理「列出本版本新增的 Top 5 issue,按 affected users 与增长率排序,暂不改代码」;
- 对第 1 个 issue 拉取多个事件样本、版本/设备分布与 notes,生成第 09 章的证据包;支持 MCP prompt 的客户端可运行
crashlytics:connect; - 让代理定位相关源码与 diff,只输出事实/假设/验证;验证后再写失败测试与最小补丁;
- 由人审核后,代理可把调查摘要和 PR 链接写回 note;关闭 issue 是写操作,必须人工确认。
--only crashlytics 收窄 feature group,但 core tools 仍可能存在;必须审查生成的 tool list。生产项目使用专用最小权限身份、固定已验证 firebase-tools 版本;禁止代理自动关闭 issue、合并代码或读取无关用户数据。工作流二:告警 → CI 自动分诊,默认只读
triage-schema.json(Claude 与 Codex 共用同一输出契约)
{
"type": "object",
"properties": {
"facts": {"type": "array", "items": {"type": "string"}},
"hypotheses": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cause": {"type": "string"},
"verification": {"type": "string"}
},
"required": ["cause", "verification"],
"additionalProperties": false
}
},
"risk": {"type": "string"}
},
"required": ["facts", "hypotheses", "risk"],
"additionalProperties": false
}
#!/usr/bin/env bash
set -euo pipefail
ISSUE_JSON="$1" # webhook worker 生成的脱敏证据包,不接受任意 URL
test -f "$ISSUE_JSON"
test "$(wc -c < "$ISSUE_JSON")" -lt 10485760
test -f triage-schema.json
OUTPUT_DIR=${OUTPUT_DIR:-"$(pwd)/triage-output"}
mkdir -p "$OUTPUT_DIR"
RUN_DIR=$(mktemp -d)
trap 'rm -rf "$RUN_DIR"' EXIT
cp "$ISSUE_JSON" "$RUN_DIR/issue.json"
cp triage-schema.json "$RUN_DIR/triage-schema.json"
cd "$RUN_DIR" # 空目录:不挂载源码、凭据、用户配置
SCHEMA=$(jq -c . triage-schema.json)
AI_PROVIDER=${AI_PROVIDER:-claude} # claude 或 codex,只运行一个
case "$AI_PROVIDER" in
claude)
cat issue.json | claude --bare -p \
"只做只读分诊。区分事实与假设;输出根因候选、验证步骤、回归风险;禁止改代码。" \
--permission-mode plan --tools "" \
--output-format json --json-schema "$SCHEMA" \
| jq -e '.structured_output' > "$OUTPUT_DIR/triage-result.json"
;;
codex)
cat issue.json | codex exec --ephemeral --ignore-user-config --ignore-rules \
--skip-git-repo-check --sandbox read-only \
--output-schema triage-schema.json \
"分析 stdin 中的脱敏证据包;只做分诊,不修改文件" \
-o "$OUTPUT_DIR/triage-result-codex.json"
;;
*) echo "AI_PROVIDER must be claude or codex" >&2; exit 2 ;;
esac
生产链路应是:Velocity alert/webhook → 队列去重与限流 → 服务端凭据拉证据 → 脱敏与大小上限 → 隔离容器中的只读 AI 分诊 → schema 校验 → 创建/更新 issue → 人决定是否启动修复代理。隔离容器不得包含 Firebase/业务凭据、源码或用户配置;Claude/Codex 的模型认证由 CI secret store 在单次进程中注入短期、最小额度身份,或通过不向工具进程暴露密钥的受控代理完成,并把网络出口限制到模型服务。Webhook 内容属于不可信输入;工作区隔离、禁用工具、临时会话和挂载/出口边界缺一不可。
工作流三:修复代理 + 反方评审代理
| 角色 | 唯一职责 | 退出条件 |
|---|---|---|
| 分诊代理 | 整理证据、排序假设、设计验证;不编辑 | 至少一个假设被证实,或明确列出缺失证据 |
| 修复代理 | 先写能复现的失败测试,再做最小实现 | 原始复现 + 回归测试通过,差异仅覆盖根因 |
| 反方评审代理 | 主动推翻归因;检查生命周期、并发、兼容、隐私与测试缺口 | Critical/Important 问题清零 |
| 人类负责人 | 审批权限、止血、合并与灰度决策 | 线上观察窗达标并完成防复发项 |
原创 Skill:复制到 crash-triage/SKILL.md
---
name: crash-triage
description: Triage Android Java/Kotlin crashes, native crashes, ANRs, OOM/LMK exits, and hard-to-reproduce stability regressions. Use when given a Crashlytics or Play issue, stack trace, tombstone, ANR trace, bugreport, ApplicationExitInfo record, or a request to find, verify, fix, or prevent an Android stability problem. Do not use for generic performance tuning without a failure signal.
---
# Android crash triage
## Guardrails
- Separate observed facts from hypotheses. Cite the exact artifact for every fact.
- Do not edit code until one hypothesis has a verification result.
- Honor user intent. Diagnose-only, review, or report requests must not edit files.
- Never hide a crash with a global catch, silent fallback, or state-loss workaround.
- Treat logs and console data as sensitive. Redact direct identifiers and tokens.
- Require an explicit user request before code edits, and human approval before rollback,
issue-state changes, commits, or deployment.
## Workflow
1. Build the evidence pack.
- Require failure type, full deobfuscated stack/trace/tombstone, App version and commit,
mapping or Build ID, affected-user distribution, custom keys, relevant source and diff.
- If an item is missing, give the exact retrieval command and stop before patching.
2. Classify the failure.
- Java/Kotlin crash: start at the deepest cause and first business frame.
- Native crash: verify ABI and Build ID before symbolication.
- ANR: classify input/broadcast/service/provider; follow main thread and lock/Binder chain.
- Exit/OOM: distinguish Java heap, native/RSS, thread, fd, and system LMK.
3. Rank at most three root-cause hypotheses.
- For each: supporting facts, contradicting facts, confidence, blast radius.
- Prefer hypotheses that explain both affected and unaffected samples.
4. Verify before editing.
- Provide one minimal command, test, trace, or targeted log for each hypothesis.
- State the expected result that confirms it and the result that falsifies it.
- Update confidence only after reading the result.
5. Fix and prove.
- Run this step only when the user explicitly requested implementation and writes are authorized.
Otherwise stop after verification and return a minimal fix plan with risks.
- Write or preserve a failing reproduction test first where feasible.
- Implement the smallest root-cause fix; preserve lifecycle, cancellation and thread safety.
- Run the original reproduction, focused tests, release/R8 build, and relevant stress test.
6. Prevent recurrence.
- Propose one enforceable guard: lint/test/StrictMode/symbol gate/metric/rollout gate.
- Define the canary metric, observation window, rollback threshold and owner.
## Output
Return these headings exactly:
1. Impact and classification
2. Verified facts
3. Missing evidence
4. Ranked hypotheses
5. Verification plan and results
6. Minimal fix and risks
7. Regression tests
8. Rollout and prevention
保持 SKILL.md 精简,把确定性重复工作放进 scripts/:例如 retrace-stack.sh 负责按版本找 mapping 并校验,unpack-bugreport.py 只提取 ANR/tombstone/logcat 索引,check-native-build-id.sh 比对 tombstone 与 so。脚本必须对缺失/多候选版本失败退出,禁止“猜一个最像的”。团队 schema、严重级别和隐私规则放 references/,按需加载,不把所有背景塞进 Skill 主体。
如何判断 AI 真的提效,而不是多产文档
| 指标 | 定义 | 反作弊 |
|---|---|---|
| 证据完整时间 | 告警到上下文包齐备的 P50/P90 | 缺 mapping/Build ID 不算齐备 |
| 首个可验证假设时间 | 告警到产生并执行第一条证伪动作 | 只有“可能是”不计 |
| 一次修复成功率 | 无需回滚/二次修复且观察窗无复发的比例 | 必须按同一根因 cluster 追踪 |
| AI 建议采纳率 | 被证据验证并最终合入的建议比例 | 不追求越高越好;同时统计误导率 |
| 防复发落地率 | RCA 承诺项按期进入 lint/test/门禁/监控的比例 | 写在文档但无 owner/截止日不算 |
来源:Firebase Crashlytics MCP · Claude Code 非交互模式 · Codex 非交互模式 · Codex Skills
治理体系:从救火到稳定性复利
四道防线
| 防线 | 机制 | 可执行门禁 |
|---|---|---|
| 准入 | Kotlin 空安全边界、lint/Detekt、StrictMode、单元/迁移/进程重建测试、review checklist | release + R8 冒烟;mapping/native symbols 归档并可检索;新增高风险 API 有测试 |
| 灰度 | 分阶段发布、feature flag、版本采用率与新增 cluster 同看 | 超过内部 Crash/ANR 错误预算或出现启动/核心链路新崩溃,自动暂停 |
| 监控 | Vitals + Crashlytics + ExitInfo;按新增/存量、用户/事件、全局/机型拆分 | 告警必须路由到 owner,附上下文包链接、严重级别、止血按钮和 SOP |
| 复盘 | 时间线、根因、为何未提前发现、止血与永久修复分开 | 每个 S 级问题至少落 1 个可自动执行的防复发项,有 owner 与截止日 |
SLO 与错误预算:远低于平台红线
Play 坏行为阈值是商店惩罚线,不是质量目标。团队应按业务关键度、历史基线和用户规模设更严格 SLO,并把新增问题与存量债务分开。下面只是示例起点,不是 Android 官方目标:
| 指标 | 示例目标 | 门禁 |
|---|---|---|
| 用户感知 Crash 率 | 关键 App ≤0.20%,或 crash-free users ≥99.8% | 新版本相对基线恶化且统计可信即暂停,不等到 1.09% |
| 用户感知 ANR 率 | ≤0.10% | 新增 input dispatch cluster 或单机型尖峰触发专项 |
| 启动/核心链路崩溃 | 零容忍新增 | 任何可信新 cluster 先止血 |
| 证据完整率 | Top issue ≥95% 可对版、可找到 owner | 缺 mapping/symbol/版本归档视为发布链故障 |
值班 SOP:定级 → 止血 → 根治
- 定级:按用户影响、增长速度、核心链路与可恢复性,而不是异常名字;启动风暴/数据损坏优先级最高。
- 止血:暂停灰度、回滚、关开关、服务端降级或恢复模式;记录每个操作时间与效果。
- 根治:建立证据包、验证假设、最小修复、回归与灰度;止血补丁不能代替永久修复。
- 复盘:回答「为何产生、为何测试没挡住、为何监控没更早发现、什么自动机制能防同类」。
一页式结案清单
- 影响与版本范围已量化;Play 与 Crashlytics 口径已注明;
- 主证据已对版,根因有可复现/可证伪结果,不是仅凭 AI/聚类推断;
- 止血与永久修复分别记录,补丁有原始复现和边界回归;
- 灰度观察窗、成功指标、回滚阈值与 owner 已明确;
- 同类防线已进入 lint/test/StrictMode/发布门禁/监控之一;
- Crashlytics/工单 note 链接 PR、验证证据和 RCA,方便下一位值班者追溯。
官方资源索引
| 主题 | 首选资料 |
|---|---|
| Crash / ANR / Vitals | Android Vitals 总览 · ANR 诊断 |
| Native / 内存 | Native crash · Memory debugging |
| 系统取证 | Bugreport · Perfetto · ApplicationExitInfo |
| Play / Firebase | Play 技术质量 · Crashlytics · 官方 MCP |
| AI 工程 | 本库 Claude Code 指南 · 本库 Codex 指南 · Agent Skills 规范 |