Android 应用在用户手里崩掉的那一刻,绝大部分开发者能拿到的只有后台里一条冷冰冰的“崩溃率上涨”。真正能救命的现场信息——哪个线程、什么堆栈、崩溃前最后一步操作是什么、内存什么水位——默认全被系统吞掉了。UncaughtExceptionHandler就是补上这一环的入口:它是 Java 层未捕获异常的最后一站,也是 Android 上做全局异常捕获绕不开的 API。这篇内容不讲泛泛的概念,而是把这个接口在真实工程里的注册时机、信息采集、落盘策略、进程收尾、能力边界和多进程坑点拆开讲清楚。适合已经写过几行Thread.setDefaultUncaughtExceptionHandler但线上数据一直不准的中级开发者,也适合刚接手崩溃治理、想知道这条路能走多远的同学。
1. 先搞清楚默认情况下崩溃现场是怎么丢的
1.1 为什么你什么都没收到
很多人第一次做全局异常捕获,是在Application.onCreate里写三行代码把自己设成默认处理器,然后打日志、传服务端,跑通了 demo 就觉得事情结束了。上线之后发现崩溃率数据和第三方统计对不上,或者某些崩溃一条都收不到。这不是代码写错了,是对“默认行为”没有概念——Android 本身就注册了一个默认的未捕获异常处理器,你不去接管它,它就会走完一整套标准流程,而你那三行代码很可能压根没被执行到。
系统那套标准流程做的事其实很固定:把异常标记为致命、打印到 logcat(也就是你平时用adb logcat看到的FATAL EXCEPTION那一段)、通知系统服务记录一次应用崩溃、然后杀掉当前进程。整个过程在几十毫秒内完成,进程一死,你在内存里攒的任何数据都跟着没了。所以如果你的捕获逻辑是“捕获→异步上传”,那么大概率上传线程还没启动,进程已经被回收。
这就引出了第一个关键认知:崩溃处理函数运行在一个即将被终结的进程里,你能做的事情极其有限,必须按“能立刻做完”的标准来设计。
1.2 UncaughtExceptionHandler 到底插在哪一环
Thread.UncaughtExceptionHandler是 JDK 里的接口,只有一个方法uncaughtException(Thread t, Throwable e)。当一个线程因为未捕获的异常而即将终止时,JVM 会回调它。Android 沿用这套机制,并且在RuntimeInit里塞了一个自己的实现类,专门负责收尾工作。
分发顺序是这样的:先看这个线程自己有没有设置setUncaughtExceptionHandler;没有的话交给它所属的ThreadGroup;ThreadGroup会沿着父链往上找,最后如果都没有处理,才落到Thread.getDefaultUncaughtExceptionHandler()返回的那个全局处理器上。注意这个“最后才落到全局”的顺序很重要——它意味着你设置的全局处理器能覆盖绝大多数线程,但覆盖不了那些自己显式设置了处理器、或者所在 ThreadGroup 重写了uncaughtException的线程。实际工程里后者很少见,但前者在第三方库里偶有出现。
另一个容易误解的点:全局处理器是在崩溃发生时才被查询的,不是在线程创建时绑定。所以你在Application里设置它,对进程里已经存在的线程同样生效(只要它们没自己设过)。这一点比很多人以为的“必须在所有线程创建之前设置”要宽松,但也别因此就把注册时机往后拖,后面会讲为什么。
2. 注册时机决定了你能捞到多少崩溃
2.1 Application 的生命周期里,最早的那个点
Application的初始化顺序是:构造 →attachBaseContext(Context)→ContentProvider.onCreate()→Application.onCreate()。如果你把注册逻辑写在onCreate里,那么在这之前发生的崩溃你就完全收不到——最典型的是ContentProvider初始化阶段抛出的异常,以及某些在attachBaseContext里做多进程判断、热修复、加固逻辑时抛出的异常。这类崩溃的特点是量不大但极难排查,因为它们往往发生在最早期,报错信息还经常是Unable to instantiate application或者Unable to get provider,看着像环境问题,实际是你自己的代码炸了。
所以我个人习惯是放在attachBaseContext里,越靠前越好。如果应用集成了某些会在ContentProvider阶段做初始化的组件,还可以考虑用androidx.startup或者自己写一个空的ContentProvider来进一步提前,但这个收益递减,除非你确实有早期崩溃的困扰,否则不必刻意上。
2.2 别把系统默认处理器丢了
写注册代码时有一个必须遵守的规则:保存旧的处理器并在合适的时候委托给它。原因很简单,系统那个处理器承担了通知 ActivityManager、写入系统崩溃记录、触发系统级崩溃弹窗等职责。你把它完全替换掉又不调用,会出现几种后果:系统侧看不到这次崩溃(某些统计口径会失真)、部分 ROM 上会表现出“应用卡住不动然后被杀死”而不是正常的崩溃退出、以及你自己也没法复用系统已经收集好的部分信息。
比较稳妥的写法是链式包装:
class CrashDispatcher( private val context: Context, private val previous: Thread.UncaughtExceptionHandler? ) : Thread.UncaughtExceptionHandler { private val handling = AtomicBoolean(false) override fun uncaughtException(t: Thread, e: Throwable) { // 防止处理逻辑自身抛异常导致无限递归 if (!handling.compareAndSet(false, true)) { previous?.uncaughtException(t, e) return } try { CrashStore.write(context, t, e) } catch (ignored: Throwable) { // 采集失败也绝不能在这里抛 } // 交还给系统,让它完成标准收尾(会杀进程) previous?.uncaughtException(t, e) } }这里的AtomicBoolean不是可有可无的装饰。我实际遇到过采集代码里调了某个工具类,那个工具类在异常状态下又抛了一个NullPointerException,结果uncaughtException被反复重入,日志被刷爆、ANR 检测被触发,最后连原来的崩溃堆栈都找不到了。加一个门闩,成本几乎为零。
2.3 主线程和子线程的差别没你想的那么大
经常有人问“这个处理器能不能捕获子线程的崩溃”。答案是能,只要那个线程没有自己的处理器。真正需要区分的是崩溃之后的进程状态:
| 崩溃线程 | 进程状态 | 能否继续使用 |
|---|---|---|
| 主线程 | Looper 已退出,消息队列不再消费,界面冻结 | 不能,必须重启进程 |
| 子线程(普通) | 进程仍存活,主线程正常运行 | 理论上可以,但状态不可信 |
| 子线程(线程池核心线程) | 该线程终止,池子会补新线程 | 同上,不建议继续 |
主线程崩溃后最典型的症状是“白屏或者黑屏,点什么都没反应,最后弹一个应用无响应”。这是因为主线程的Looper.loop()里抛出了异常,循环终止,之后投递到主线程的任何消息都不会再被处理。这时候唯一正确的做法就是结束进程,让用户重新进入。
3. 一个能上线的崩溃采集器长什么样
3.1 采集什么:从“够用”倒推字段
崩溃日志不是越多越好。字段太多会导致单条日志体积膨胀、序列化耗时变长,在一台低端机上可能直接拖到被系统判定为无响应。我一般按“能不能定位问题”来筛字段,分三档:
- 必须有:异常类型、message、完整堆栈、线程名、崩溃时间戳、进程名、应用版本号与版本码、发生次数序号。
- 强烈建议有:设备型号、系统版本、ROM 标识、屏幕分辨率与密度、可用内存与最大堆、当前前台页面(如果能拿到)、启动至今的存活时长。
- 看情况有:最近若干条自定义埋点(比如“进入详情页”“点击下单”)、网络状态、电量、磁盘剩余空间。
第三档要谨慎。用户行为埋点涉及隐私合规,采集前必须有明确的告知和同意;而且这些数据增长很快,容易把日志文件撑爆。我的做法是只保留最近 20 条轻量事件,每条不超过 60 个字符,滚动的环形缓冲,崩溃时一次性 dump 出来。
3.2 堆栈怎么拿才不丢信息
Throwable.printStackTrace()输出的是带Caused by链的完整文本,这是最省事也最不容易出错的方式,直接字符串化保存就行。如果要做聚合和去重,就得解析成结构化数据:异常类名 + 最顶层的业务栈帧(跳过框架包名)作为指纹。
自己拼堆栈的时候有两个坑。第一,e.stackTrace只包含当前异常的帧,不含cause,必须递归展开。第二,StackTraceElement.toString()的输出格式在不同 JDK 版本上略有差异,如果你要靠它做指纹,最好自己拼className + methodName + fileName + lineNumber,别依赖默认格式。
还有一个细节:Kotlin 协程抛出的异常,堆栈里会出现大量kotlinx.coroutines的帧,看着很乱但别急着过滤,Caused by链里往往藏着真正的业务代码位置。
3.3 落盘:为什么不能在崩溃线程里直接上传
这是踩过最多次的坑。崩溃处理函数运行在即将死亡的进程里,你做网络请求会遇到三个问题:一是时间不够,进程可能在几百毫秒内被杀;二是网络库本身可能依赖主线程的消息队列或者已经停止的调度器,直接卡死;三是就算请求发出去了,响应回来的时候进程已经没了,你也不知道成没成功。
正确做法是同步落盘、下次启动上报。落盘也有讲究:
object CrashStore { private const val DIR = "crash_log" fun write(context: Context, thread: Thread, throwable: Throwable) { val dir = File(context.filesDir, DIR).apply { mkdirs() } val stamp = System.currentTimeMillis() val proc = currentProcessName(context).replace(':', '_') val target = File(dir, "${stamp}_${proc}.log") val tmp = File(dir, "${target.name}.tmp") val text = buildString { appendLine("time=$stamp") appendLine("process=$proc") appendLine("thread=${thread.name}") appendLine("version=${appVersion(context)}") appendLine("---") append(Log.getStackTraceString(throwable)) } tmp.writeText(text) // 先写临时文件 tmp.renameTo(target) // 原子替换,避免半截文件 trim(dir, keep = 30) // 只留最近 30 份 } }先写.tmp再重命名,是为了防止写到一半进程被杀,留下一个不完整的文件。.tmp文件在下次启动时统一清理掉。文件数量必须设上限,否则一个反复崩溃的版本能在几天内把用户的存储写满,这属于严重体验问题。
进程名要拼进文件名,否则多进程应用会互相覆盖——这个坑我在一个带独立推送进程的项目里踩过,两个进程的崩溃日志互相顶掉,排查了一下午才发现。
3.4 进程收尾:killProcess 与 System.exit 的选择
采集完、落盘完,接下来必须让进程干净地死掉。可选的有三种:android.os.Process.killProcess(Process.myPid())、System.exit(0)、以及什么都不做让它自然死。
什么都不做是最糟的,主线程已经停了,用户看到的是一个冻住的界面。System.exit(0)会触发 JVM 的 shutdown hook,理论上更“优雅”,但在 Android 上它有可能被某些运行时状态卡住。主流做法是先把必要的收尾做完,然后killProcess,紧接着再System.exit(0)兜底——因为极少数情况下killProcess因为权限或信号处理原因没有立即生效,System.exit能补一刀。
顺序上有个细节:先落盘、再调用系统默认处理器。因为系统默认处理器会直接杀进程,你如果先调它,后面的代码可能执行不到。但如果你的采集逻辑很重(比如要 dump 内存信息),也不要拖太久,超过一两秒就有被 ANR 机制盯上的风险。
4. 能力边界:有些崩溃它天生捕获不到
4.1 Native 崩溃完全不归它管
这是最容易被误解的一点。UncaughtExceptionHandler捕获的是Java/Kotlin 层的未捕获异常。由 C/C++ 代码触发的SIGSEGV、SIGABRT、SIGBUS等信号,走的是完全不同的路径,Java 层的处理器一点都感知不到。表现就是:崩溃日志里什么都没有,后台只看到一次“闪退”,堆栈里清一色是libxxx.so。
想收这类崩溃,得在 native 层装信号处理器,把崩溃时的寄存器状态、调用栈抓下来自己解析。这块工程量大,一般用现成的方案而不是自研。需要评估的是:你的应用里 native 代码占比高不高?如果只是用了几个带 so 的第三方库,那这类崩溃量通常不大,收益有限;如果核心逻辑本来就在 native 层,那就值得投入。
4.2 ANR、被系统杀死、OOM 是三个不同的东西
ANR 不是异常,它是“主线程在规定时间内没有响应输入事件或广播”的判定,不会触发uncaughtException。这类问题需要另做一套监控:用一个独立的看门狗线程定期向主线程投递任务,如果超过阈值没被消费,就判定为卡顿或疑似 ANR,把当前主线程堆栈 dump 出来。
被系统直接杀死(低内存回收、用户在设置里强制停止、厂商的清理策略)同样捕获不到,因为进程是被SIGKILL干掉的,没有任何回调机会。这类“消失”只能通过下一次启动时的“上次是否正常退出”标记来推断——就是常说的“退出原因归因”,在启动时写一个标记文件,正常退出的路径上清掉它,下次启动发现标记还在,就说明上次是非正常退出。
Native OOM(内存映射失败)也是同理,Java 层的OutOfMemoryError能被捕获,但捕获之后往往也没法做太多事了,因为堆已经满了。这时候能做的就是尽量少分配内存,把已采集好的字符串直接写盘。
4.3 协程和线程池的漏网情况
用 Kotlin 协程的项目要特别注意。launch启动的协程如果抛异常,会先找CoroutineExceptionHandler;没设的话会交给Thread.uncaughtExceptionHandler,也就是你的全局处理器——这条路径没问题。但如果你的项目里给某个CoroutineScope设了自定义的CoroutineExceptionHandler并且在里面把异常吞掉了,那全局处理器就永远看不到。
async更麻烦,异常会被封装进Deferred,只有调用await()时才抛出,如果没人await,这个异常就静静躺在那里。
线程池方面,ThreadPoolExecutor.execute()提交的任务抛异常,会走到线程的未捕获处理器;但submit()提交的任务,异常被FutureTask捕获并存进Future,不调get()就完全静默。这个区别值得在 code review 时专门盯一下。
| 崩溃来源 | 能否被全局处理器捕获 | 补充手段 |
|---|---|---|
| 主线程 Java 异常 | 能 | 无 |
| 子线程 Java 异常 | 能(无自定义处理器时) | 检查是否有库覆盖了默认处理器 |
| 协程(无自定义处理器) | 能 | 统一 scope 的异常处理策略 |
| 协程 async 未 await | 不能 | 业务层保证 await 或加监控 |
| 线程池 submit 未 get | 不能 | 统一封装线程池 |
| Native 信号崩溃 | 不能 | native 层信号捕获 |
| ANR | 不能 | 主线程看门狗 |
| 系统杀死、低内存 | 不能 | 退出原因归因 |
5. 多进程、重启与系统限制的实战记录
5.1 多进程下每个进程都得注册一遍
Application.onCreate()在每个进程里都会执行一次(这是很多人以为只执行一次导致的经典误解),所以注册代码天然是每个进程各注册一份,这一点不用额外处理。真正要处理的是数据隔离:日志文件名必须带进程名,重启逻辑必须先判断当前是哪个进程,否则会出现“推送进程崩溃了把主进程拉起来”这种莫名其妙的行为。
还有一个更隐蔽的坑:多个进程同时往同一个目录写日志,如果文件名只用了时间戳,毫秒级并发下有机会撞名,后写的把先写的覆盖掉。加进程名前缀能解决绝大部分情况,如果还不够,加一个随机后缀。
5.2 崩溃后自动重启,能不能做
能做,但要清楚代价和限制。最朴素的做法是在崩溃处理里直接startActivity拉起自己的启动页,问题是从 Android 10 开始,后台启动 Activity 受到严格限制,应用在即将死亡的瞬间发起的启动请求很可能被系统直接丢弃。较稳的做法是借助AlarmManager加PendingIntent,设定一个 1 到 2 秒后的闹钟来触发拉起:
val intent = Intent(context, LaunchActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } val pending = PendingIntent.getActivity( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager am.setExactAndAllowWhileIdle(AlarmManager.RTC, System.currentTimeMillis() + 1500, pending)FLAG_IMMUTABLE在 Android 12 及以上是强制的,不写会直接抛异常——这个坑我在适配新系统版本时踩过,崩溃处理器里再抛异常,结果就是完全静默。
另外,自动重启这件事要想清楚产品上是否合理。如果崩溃是启动路径上的必然崩溃(比如某个配置解析失败),自动重启会导致无限重启循环,用户体验极差且耗电。建议加一个计数器:记录最近一分钟内的重启次数,超过 3 次就不再重启,直接把用户留在桌面。
5.3 几个具体踩过的坑
在崩溃线程里弹 Toast。早期的方案喜欢在崩溃后弹一个“抱歉,程序出了点问题”的提示。Toast需要Looper,在子线程崩溃的场景下直接抛Can't create handler inside thread that has not called Looper.prepare(),然后你就丢掉了原始崩溃。要弹就用Handler(Looper.getMainLooper()).post,但主线程崩溃时这条消息也不会被处理,所以这个提示本质上只在子线程崩溃时有意义。
用 try-catch 包住Looper.loop()试图让主线程活下去。网上流传过这种写法:在Looper.loop()外面套一层while(true)加try-catch,捕获异常后继续循环。这么做确实能让界面不消失,但此时 Activity 的生命周期状态、View 树的绘制状态、各种单例的内部状态都可能处于不一致,后续出现的诡异 bug 比直接崩溃更难查。除非是极端场景下的保命需求,否则不要这么做。
忘记处理文件权限和存储路径。早期有人在崩溃处理里往外部存储写日志,结果用户没插 SD 卡或者权限被拒,写入抛异常,日志丢失。统一用context.filesDir(内部存储)最稳,不需要任何权限。
加固或热修复框架改写了类加载器。某些方案会在启动早期替换ClassLoader,导致你的Application子类被加载了两次,注册逻辑执行两遍,处理器被层层包装。判断方式是打日志看Thread.getDefaultUncaughtExceptionHandler()的类名,如果看到自己套自己,就是这个问题。解决办法是加一个静态标记或判断当前处理器是不是自己。
6. 上线前的验证方式与数据治理
6.1 怎么可靠地制造一次崩溃
验证链路是否通的唯一方式是真的崩一次,而且要在不同线程上各崩一次。我常用的做法是在调试包的一个隐藏入口里放几个按钮:
- 主线程抛
RuntimeException - 新起一个
Thread抛异常 - 线程池
execute提交一个抛异常的任务 - 线程池
submit提交一个抛异常的任务(预期收不到,用来验证你对边界的理解) - 主动触发一个
OutOfMemoryError(谨慎,可能把模拟器搞挂)
验证时盯三件事:日志文件有没有生成、内容里线程名和堆栈对不对、下次启动有没有成功上报并删除本地文件。第三点最容易被忽略,很多人只验证了生成没验证消费,结果线上日志文件越堆越多。
另外提一个真实感很强的细节:调试时用adb shell am force-stop杀进程,是不会产生崩溃日志的,那是正常终止路径,别拿它当测试手段。要测非正常退出归因,得用系统的“强行停止”或者直接杀进程信号。
6.2 日志体积、上报频率与合规
崩溃日志的体积失控是很常见的问题。一个带完整Caused by链的堆栈,加上设备信息,单条从几 KB 到几十 KB 不等。如果用户连续崩溃,几十条就是几百 KB。控制手段有三层:单条日志超过 64KB 就截断(保留头部和尾部,中间省略);本地最多保留 30 条;上报成功后立刻删除。
上报时机建议放在应用启动后延迟几秒、且在主线程空闲时,不要和启动阶段的其他初始化抢资源。上报失败的日志保留重试次数,超过 3 次就丢弃,避免死循环。
合规方面,设备标识类字段(比如各种硬件 ID)属于敏感信息,采集前必须有用户授权,而且现在的系统版本对这类接口有严格限制,能拿到的东西越来越少。我的建议是尽量只用匿名化的、非唯一标识的信息——机型、系统版本、屏幕参数这些足够定位大部分兼容性问题,不必为了多一个字段去冒合规风险。
6.3 日志解析与聚合的一点经验
原始崩溃日志上报到服务端之后,如果不做聚合,很快就没人看了。聚合的关键是生成稳定的指纹:异常类名 +Caused by链上最内层的业务栈帧 + 崩溃位置的行号。行号这一项要慎重,代码改动会导致行号变化,同一个 bug 被拆成多个指纹,反而不利于观察趋势。折中方案是用“异常类名 + 最内层业务方法名”做主键,行号只做辅助展示。
还有一个实用技巧:给每个崩溃日志打上首次发生时间和累计次数,在客户端本地做一次简单的去重计数。这样即使上报通道不稳定,你也能从本地数据里看出“这个崩溃是不是高频”。实现上就是维护一个小的 map,key 是指纹,value 是次数和首次时间,随日志一起带上去。
这套东西做完之后,我对崩溃数据的信任度明显提高了一个档次——不是因为它变准了,而是因为我知道了哪些崩溃它本来就不该收到,看到数据缺口时能立刻判断是采集问题还是真的没崩。这个判断力比多写几百行采集代码有用得多。