崩溃会留下堆栈,静默失败只留下一个"点了没反应"------后者才是最贵的。
线上报来一个问题:"点某个功能没反应,每天都有一两次。"三条常规排查路同时走空------崩溃上报平台一条记录都没有,logcat 抓复现只有一堆正常日志、没有 FATAL EXCEPTION,本地加打印也只看到一句 D/Test: uncaughtException: 之后进程就没了,冒号后面是空的。异常对象根本没进日志。
这还不是孤例。在同一个工程里,同类型的故障陆陆续续出现过五次:崩溃日志凭空消失、registerForActivityResult 第二次永远不回调、Bundle 超限后改用静态变量、主线程被 runBlocking 堵死、ViewModel 拿到了却不是你以的那个。它们有一个共同点------程序没崩,行为不对,而且什么都没留下。
这篇要解决的麻烦就是它:当"日志一片空白"时,怎么从框架的兜底机制和生命周期边界里,把被吞掉的信号重新捞出来。五个事故都来自真实线上,每个都给出根因链、可复现的最小结构,以及工程上怎么防止再犯。开头先给一张速览表,末尾把它们放进同一个结构里对照------它们看起来是五个 bug,其实是同一种失败机制的五个切片。
一、五个事故,一个共同点
五种故障指向同一条判断:最贵的 bug 不是崩得响的那个,是静悄悄不执行的那个。 崩溃至少留下堆栈,静默失败只留下一个"点了没反应"。下面是五张速览卡,细节在后续各节展开。
| 事故 | 现象 | 根因 | 解法 |
|---|---|---|---|
| 崩溃日志消失 | 闪退回桌面,无 FATAL EXCEPTION、无上报 | 后设置的 UncaughtExceptionHandler 覆盖了原 handler,且未委托 |
保存并委托 origin、落盘、兜底自杀 |
| 回调第二次不来了 | 第一次有效,之后永远无反应 | replace() 销毁 Fragment,registry 在 ON_DESTROY 注销回调 |
tab 用 add/hide/show;用 replace 就必须每次新建实例 |
| 传参超限后改静态变量 | 选了两百个点,回来后全没了且无提示 | 静态变量在进程重建后归零,且常驻泄漏 | 传 ID / 带 token 的 Repository / 共享 ViewModel |
| 主线程被堵死 | 点击后卡死数秒或弹 ANR,日志指向别处 | runBlocking(Dispatchers.IO) 仍然阻塞调用线程 |
suspend + withContext(Dispatchers.IO) + lifecycleScope.launch |
| 作用域失控 | 数据改了界面不更新,切模式残留上次状态 | 五种获取写法拿到了不同作用域的实例 | 统一 viewModels() / activityViewModels();普通类自实现 owner |
五者之所以"没有声音",是因为它们都不是在业务代码里失败的,而是在框架的兜底机制 或生命周期边界上失败的。下面逐个拆开。
二、崩溃日志凭空消失:处理器被后来的那一层覆盖
2.1 现象:闪退回桌面,日志里一片空白
用户反馈:"点某个功能就闪退回桌面,每天都有一两次。"
排查三件套全军覆没:
| 手段 | 结果 |
|---|---|
| 崩溃上报平台 | 一条记录都没有 |
| logcat 抓复现 | 只有一堆正常日志,没有 FATAL EXCEPTION |
| 本地复现 | 日志里出现一句 D/Test: uncaughtException: 然后进程就没了 |
注意最后一条------uncaughtException: 后面是空的。这句日志来自下面这段代码:
kotlin
// 宿主模块 Application(示意,已脱敏)
Thread.setDefaultUncaughtExceptionHandler { t, e ->
Log.d("Test", "uncaughtException: ") // ① 只打了个标记,没打 e
ActivityUtils.finishAllActivities() // ② 关掉所有页面
} // ③ 没有委托原 handler,没有 killProcess
三个致命点,一次全占:
e从来没被打印------异常对象根本没进日志;- 没有保存原 handler 并委托------系统默认的崩溃处理(打 FATAL EXCEPTION + 弹"已停止运行")被完全顶掉;
- 没有
killProcess------只是finishAllActivities(),进程进入"没有 Activity 但还活着"的状态,最后被系统回收。
结果就是:崩溃发生了,但全世界都不知道它长什么样。
2.2 原理:它是一条链,不是一个槽
Thread.setDefaultUncaughtExceptionHandler() 的语义经常被误解:
它是覆盖 ,不是追加 。后设置的 handler 会完全替换掉之前的,而之前的那个连引用都拿不回来(没有任何 API 能读取"上一个" handler)。
正因为如此,三方 SDK(统计、推送、IM、地图)都会遵守一个不成文的规矩:先取出当前 handler,做自己的事,再把异常交回给"下家"。
kotlin
// 三方 SDK 的标准写法
val origin = Thread.getDefaultUncaughtExceptionHandler() // 先取
Thread.setDefaultUncaughtExceptionHandler { t, e ->
try {
reportToServer(e) // 做自己的事
} catch (ignored: Throwable) {
} finally {
origin?.uncaughtException(t, e) // 再交给下家
}
}
谁先初始化谁就在链尾。 如果多个 SDK 都遵守规矩,崩溃会沿着链一路传下去,最终到系统默认的 handler(打印 FATAL EXCEPTION 并杀进程)。
这里有个容易忽略的细节:链的顺序由初始化顺序决定,而不是由"谁更重要"决定。 启动流程里 SDK 的初始化次序一旦被调整(比如把某个 SDK 的 init 从 Application.onCreate 挪到了懒加载),整条链的拓扑就变了。更常见的是------业务自己的 handler 在 Application.onCreate 里较早安装,把链的入口占了,之后 SDK 再装,链就变成"业务 → SDK → 系统";可如果业务代码在初始化末尾又装了一次,它会跑到最前面,把前面所有 handler 都变成"拿不回来的过去"。
而我们的代码做了什么?在链上贴了一个终点站。
erlang
崩溃发生
↓
我们的 handler(链上最后一环,我们自己是"第一个"被系统回调的)
↓
Log.d("Test", "uncaughtException: ") ← 信息到此为止
↓
finishAllActivities()
↓
(结束,没有下家)
2.3 更糟的是:这个终点站还不止一个
工程里有两个 Application 类(TinkerApplication 代理类和一个已标记 @Deprecated 的旧 Application,注释写着"不再使用,仅作代码对比"),两个类里都写了这段一模一样的占位处理器。
更讽刺的是:崩溃上报 SDK 的初始化代码在别处(某个 Activity 生命周期回调里,且只在"云服务掉线超过 60 秒"的分支中才执行)。也就是说------
崩溃上报组件在绝大多数情况下根本没被初始化,而那个"捕获一切"的 handler 又把所有异常都吞了。
这是一个教科书级的组合:最激进的捕获 + 最敷衍的处理 + 从未生效的上报。
把几种处理器写法摆在一起对比,问题会非常直观:
| 处理器写法 | 是否委托 origin | 是否落盘 | 进程最终状态 | 结果 |
|---|---|---|---|---|
| 系统默认 handler | --- | 否 | 被杀 | FATAL EXCEPTION 进 logcat |
| SDK 标准链式写法 | 是 | 由 SDK 决定 | 交给下家 | 崩溃沿链传递,最终到系统 |
| 只打标记不回传 | 否 | 否 | 存活但无界面 | 无日志、无上报、无反馈 |
| 覆盖后不回传且无自杀 | 否 | 否 | 长期存活、持脏状态 | 僵尸进程,比崩溃更难查 |
| 正确 CrashHandler | 是 | 是 | killProcess + exitProcess |
落盘 + 上报 + FATAL EXCEPTION |
2.4 正确的崩溃处理器长什么样
kotlin
object CrashHandler : Thread.UncaughtExceptionHandler {
private var origin: Thread.UncaughtExceptionHandler? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun install(context: Context) {
origin = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(t: Thread, e: Throwable) {
// 1) 落盘:这是唯一可信的记录(进程马上要死)
runCatching { writeCrashFile(t, e) }
// 2) 上报:给一个有限的等待时间,别无限阻塞
runCatching { uploadBlocking(timeoutMs = 2000) }
// 3) 交给下家(系统默认 handler 会打 FATAL EXCEPTION 并 killProcess)
runCatching { origin?.uncaughtException(t, e) }
// 4) 兜底自杀:防止下家没杀进程,导致"白屏卡死"
Process.killProcess(Process.myPid())
exitProcess(10)
}
private fun writeCrashFile(t: Thread, e: Throwable) {
val file = File(crashDir, "crash_${System.currentTimeMillis()}.log")
file.printWriter().use { out ->
out.println("Thread: ${t.name} (${t.id})")
out.println("Time: ${Date()}")
out.println("App: ${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})")
out.println("Flavor: ${BuildConfig.FLAVOR}")
out.println("Device: ${Build.MANUFACTURER} ${Build.MODEL} API${Build.VERSION.SDK_INT}")
e.printStackTrace(out)
}
}
}
四个要点:
- 必须保存并委托
origin------你不打日志没关系,但别拦着系统打; - 必须落盘------进程随时会死,网络上报大概率来不及,下次启动时补传;
- 必须有兜底自杀 ------
finishAllActivities()之后进程不会退出,会留下一个"没有界面的空进程",用户以为卡死了; - handler 内部所有操作都要
runCatching------崩溃处理里再抛异常,就真的什么都没了。
这里第 1 步和第 2 步有个隐藏顺序:落盘一定在委托之前。 一旦调了 origin?.uncaughtException(),系统那一环随时可能 killProcess,后面的代码就不再保证执行。而"给上报一个有限等待时间"也很关键------uploadBlocking(timeoutMs = 2000) 里的超时不是可选项,没有它,网络抖动时会把这个已经要死的进程再挂十几秒,触发"应用无响应",用户看到的是崩溃之后又卡一下。
2.5 为什么 finishAllActivities() 不是收尾
这是很多人(包括当时的我)的误解:以为关掉所有 Activity 就等于"优雅退出"。实际上:
| 动作 | 进程状态 | 用户感知 |
|---|---|---|
finishAllActivities() |
进程仍在,Service/协程/线程都活着 | 回到桌面,但状态栏可能还有通知,再次点击图标可能恢复出诡异状态 |
Process.killProcess() |
进程死亡 | 回到桌面,下次冷启动 |
| 什么都不做(系统默认) | 系统弹出"应用已停止运行",然后杀进程 | 明确的崩溃反馈 |
更麻烦的是:finishAllActivities() 之后如果还有前台服务在跑,进程会长期存活 ,持有崩溃时的脏状态。用户再次点图标,进入的是一个半死的进程------这类"僵尸状态"引发的问题,比崩溃本身更难查。
2.6 延展:协程和 RxJava 的异常是另一条链路
顺便说一个容易混淆的点:UncaughtExceptionHandler 管不到所有异常。
| 异常来源 | 由谁兜底 |
|---|---|
| 主线程抛出的未捕获异常 | Thread.setDefaultUncaughtExceptionHandler |
| 子线程抛出的未捕获异常 | 同上 |
协程 launch 内未捕获异常 |
CoroutineExceptionHandler(无则走全局 handler) |
协程 async 内异常 |
被封装进 Deferred,await 时才抛,不 await 就静默丢失 |
RxJava onError 未实现 |
RxJavaPlugins.setErrorHandler(无则崩溃) |
| native 崩溃 | 完全不管,需要 Breakpad/信号捕获 |
所以"崩溃没日志"的排查清单应该包括:
-
UncaughtExceptionHandler是否被覆盖且未委托? - 协程是否用了
GlobalScope且没有CoroutineExceptionHandler? - 是否有
async的Deferred从未被await? - RxJava 的
onError是否为空实现? - native 层是否有独立的信号捕获上报?
2.7 怎么保证不再犯
这类问题靠 code review 很难发现(代码看起来"处理"了异常)。建议三条硬约束。
第一,启动时自检:确认崩溃处理器链完整。
kotlin
// Debug 包启动时断言
fun assertCrashHandlerInstalled() {
val handler = Thread.getDefaultUncaughtExceptionHandler()
check(handler is CrashHandler) {
"崩溃处理器被覆盖,当前是 ${handler?.javaClass?.name}"
}
}
放在 Application 初始化的最后一步。如果三方 SDK 之后又覆盖了,这个断言能在开发期就发现。
第二,用"崩溃自检"验证链路。 Debug 包加一个隐藏入口,抛一个受控异常:
kotlin
fun testCrash() {
throw RuntimeException("CrashHandler self-test")
}
手动触发一次,检查三件事:logcat 有没有 FATAL EXCEPTION、本地文件有没有落盘、上报平台有没有收到。这是唯一能证明链路真的通的方法。
第三,CI 静态检查:禁止出现"吞异常"的处理器。 用自定义 Lint 规则或简单的源码扫描,禁止匹配这种模式:
markdown
setDefaultUncaughtExceptionHandler 的实现中:
- 未调用 getDefaultUncaughtExceptionHandler() 保存原 handler
- 或 uncaughtException 方法体内未出现 origin/prev/defaultHandler 的调用
下面这张图把上面这条"覆盖链"画成流程。注意终点站的位置:信息在业务 handler 里就断了,永远到不了系统那一环。
三、回调第二次不来了:Fragment 替换与注册时机
3.1 现象:切走再切回来,选择器就哑了
业务场景:一个主页 Activity,底部 5 个 tab,每个 tab 是一个 Fragment。第二个 tab 里有一个"从点库选择点位"的入口,用 registerForActivityResult 启动选择页并接收结果。
Bug 表现:
markdown
第一次进入 tab2 → 点击选择 → 选择页返回 → 回调触发 ✅
切到 tab1,再切回 tab2 → 点击选择 → 选择页正常返回 → 回调不触发 ❌
(无任何日志、无崩溃)
选择页都正常关闭了,数据也确实选了,但回调就是不来。而且之后无论切几次,永远不回调。
3.2 第一层原因:replace() 会销毁 Fragment
tab 切换的实现是这样的:
kotlin
private fun updateFragment(index: Int) {
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.container, fragmentList[index]) // ← 问题在这
transaction.commit()
}
replace() 的语义是:移除容器内所有已存在的 Fragment,然后添加新的那个。
被移除的 Fragment 会走完整销毁流程:
markdown
onPause → onStop → onDestroyView → onDestroy → onDetach
↑
lifecycle 到达 DESTROYED
而我们复用了 fragmentList 里的同一个实例------切回来时又把那个已经 DESTROYED 的实例 add 回去。
这一步本身就会引发一堆问题(View 状态、ViewModel 作用域、子 Fragment),但让回调永久失效的是下一层。
3.3 第二层原因:ActivityResultRegistry 在 DESTROYED 时注销回调
registerForActivityResult 不是简单的 startActivityForResult 包装。它的核心是 ActivityResultRegistry,我们看它被"注册"时发生了什么(源码简化):
kotlin
// androidx.activity.result.ActivityResultRegistry(源码示意)
fun <I, O> register(
key: String,
lifecycleContainer: LifecycleOwner,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>
): ActivityResultLauncher<I> {
val lifecycle = lifecycleContainer.lifecycle
// 关键:如果生命周期已经到达 DESTROYED,直接抛异常
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
throw IllegalStateException("...")
}
val requestCode = registerKey(key)
// 关键:注册一个生命周期观察者
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
mKeyToCallback[key] = CallbackAndContract(callback, contract)
}
Lifecycle.Event.ON_STOP -> {
mKeyToCallback.remove(key) // STOP 时移除
}
Lifecycle.Event.ON_DESTROY -> {
unregister(key) // DESTROY 时彻底注销
}
else -> Unit
}
}
lifecycleContainer.lifecycle.addObserver(observer)
// ...
}
三个关键点:
- 回调存在
mKeyToCallback这个 Map 里,只有 Map 里有值,结果才能派发; ON_DESTROY时调用unregister(key):从 Map 里移除,并且连 requestCode 都回收了;- 派发结果时:
kotlin
private fun <O> doDispatch(key: String, resultCode: Int, data: Intent, ...) {
val callbackAndContract = mKeyToCallback[key]
if (callbackAndContract != null) {
callbackAndContract.mCallback.onActivityResult(...)
} else {
// 没有回调 → 结果被丢弃,静默
mPendingResults.remove(key)
}
}
没有回调对象时,结果是静默丢弃的。 不报错、不警告、什么都不打。
这里值得停下来想一想:为什么框架不在这里抛一个异常,或者至少打一条 Log.w?因为 doDispatch 的调用时机是"选择页返回、结果回传"的那一刻,链路已经跨进程、跨生命周期,框架无法判断"没有回调"是正常(比如宿主已经退出)还是异常。于是它选择了最保守的策略------静默丢弃。这就是静默失败的温床:框架为了避免误报,把"可能正常"和"一定异常"一起吞了。
3.4 完整链条
arduino
1. tab 切换用 replace() → 旧 Fragment 被 remove
2. Fragment 走到 onDestroy → lifecycle = DESTROYED
3. LifecycleEventObserver 收到 ON_DESTROY → registry.unregister(key)
4. mKeyToCallback 里的回调被删除,requestCode 被回收
5. 切回 tab → 把同一个(已 DESTROYED 的)实例 add 回去
6. 该实例的 launcher 持有的 requestCode 已失效,registry 里没有它的回调
7. 选择页返回结果 → doDispatch 找不到 key → 静默丢弃
8. 永久失效:因为 requestCode 再也不会重新注册(register 只在初次 register 时分配)
为什么"第一次能用"? 因为第一次是 Fragment 全新创建 + 注册,一切正常。
3.5 为什么"复用已销毁实例"还能 add 回去
这看起来应该崩溃才对。实际上 Fragment 被 remove 后实例对象还在(我们在 fragmentList 里持有着引用),add 回去时 Framework 会重新走一遍 onCreateView,看起来"复活"了------但它的 LifecycleRegistry 已经是 DESTROYED 状态,且 Jetpack 组件注册过的回调(ActivityResult、观察者等)全部不会自动恢复。
这就是"静默失败"的典型形态:表面上一切正常,实际上内建状态已经丢了。
下面这张时序图把"第一次成功、第二次丢弃"的差异画在同一条时间线上。
3.6 三种修复方案与取舍
方案 A:改用 add/hide/show(推荐,适合 tab 场景)
kotlin
private var currentIndex = -1
private fun updateFragment(index: Int) {
if (index == currentIndex) return
val transaction = supportFragmentManager.beginTransaction()
// 隐藏当前
if (currentIndex >= 0) {
transaction.hide(fragmentList[currentIndex])
}
// 添加或显示目标
val target = fragmentList[index]
if (!target.isAdded) {
transaction.add(R.id.container, target, "tab_$index")
} else {
transaction.show(target)
}
transaction.commit()
currentIndex = index
}
| 优点 | 缺点 |
|---|---|
| Fragment 实例与生命周期完整保留,View 状态、子 Fragment、注册的回调全都在 | 所有 tab 的 Fragment 常驻内存(5 个 tab 通常可接受) |
| 切换最快(不用重建 View) | 需要处理"首次加载懒加载"(onHiddenChanged + setMaxLifecycle) |
配合懒加载:
kotlin
transaction.add(R.id.container, target, "tab_$index")
.setMaxLifecycle(target, Lifecycle.State.STARTED) // 非当前页限制生命周期
方案 B:每次切换都新建 Fragment 实例
kotlin
private fun updateFragment(index: Int) {
supportFragmentManager.beginTransaction()
.replace(R.id.container, createFragment(index)) // 每次 new
.commit()
}
| 优点 | 缺点 |
|---|---|
| 语义清晰,没有"僵尸实例" | 每次切换重建 View,状态需自己保存恢复 |
| 内存占用低 | 如果 Activity 里持有旧引用做数据保存(比如调 fragmentList[0].onSave()),会失效或 NPE |
这个方案的关键约束 :一旦用 replace + newInstance,就不能 在任何地方缓存旧 Fragment 引用。这个工程里恰恰有 Activity 强转 fragmentList[i] 调用业务方法的地方,所以改这个方案要连带改掉那些调用。
方案 C:用 ViewPager2 / FragmentStateAdapter
kotlin
viewPager.adapter = object : FragmentStateAdapter(this) {
override fun getItemCount() = 5
override fun createFragment(position: Int) = createFragmentByPosition(position)
}
| 优点 | 缺点 |
|---|---|
| 官方方案,生命周期与懒加载都处理好了 | 改造量大,tab 与页面联动逻辑要重写 |
3.7 这类 Bug 怎么快速定位
遇到"回调不触发"时,按顺序做三件事。
第一,打日志确认生命周期状态。
kotlin
// 在 Fragment 里
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy: lifecycle=${lifecycle.currentState}")
}
// 在调用 launcher 之前
Log.d(TAG, "launch: isAdded=$isAdded, state=${lifecycle.currentState}")
如果 state = DESTROYED 却还在调用 launch(),就是这个问题。
第二,检查宿主是怎么切 Fragment 的。 全局搜索 beginTransaction(),看有没有 replace(R.id.xxx, cachedInstance) 这种"replace + 复用实例"的组合。这是最危险的组合。
第三,用 ActivityResultRegistry 的调试能力。 Debug 包里可以反射打印 mKeyToCallback 的大小,确认回调是否还在:
kotlin
fun dumpRegistry(activity: ComponentActivity) {
val registry = activity.activityResultRegistry
val field = ActivityResultRegistry::class.java.getDeclaredField("mKeyToCallback")
field.isAccessible = true
val map = field.get(registry) as Map<*, *>
Log.d("Registry", "registered callbacks: ${map.keys}")
}
3.8 更一般化的教训
这个 Bug 的通用形态是:
"复用一个已经被销毁的组件实例" ------ 它看起来活着,但所有依赖生命周期注册的回调都已经注销。
同类问题还有:
| 场景 | 表现 |
|---|---|
| Fragment 被 remove 后复用 | registerForActivityResult、viewLifecycleOwner 的观察者全部失效 |
LifecycleRegistry 到达 DESTROYED 后复用 |
观察者不再收到任何事件 |
DialogFragment dismiss 后复用 |
show() 抛异常或静默不显示 |
已 cancel() 的 CoroutineScope |
新 launch 的协程立即被取消,代码不执行 |
一条铁律:DESTROYED 是终态。销毁过的实例,必须新建,不能复活。
以及配套的编码规范:
replace()只用于"不需要保留状态"的页面跳转;- tab / 常驻页面一律
add/hide/show; - 如果用了
replace(),绝不能缓存 Fragment 实例。
四、传参超限之后:静态变量只是把坑挖深
4.1 旧问题:Bundle 的 1 MB 限制
有个页面要打开点库让用户多选点位(可能几百上千个),选完返回。
最初的实现很自然:把选中的列表塞进 Intent / Bundle 返回。
kotlin
// 选择页(示意)
setResult(Activity.RESULT_OK, Intent().apply {
putParcelableArrayListExtra("selected", ArrayList(selectedPoints))
})
量一大就炸:
css
android.os.TransactionTooLargeException: data parcel size 3145720 bytes
at android.os.BinderProxy.transactNative(Native Method)
at android.app.IActivityTaskManager$Stub$Proxy.finishActivity(...)
为什么会炸。 startActivityForResult / setResult 的数据走 Binder 事务。关键约束:
- 单次 Binder 事务上限 1 MB (
Binder.SUPPORTED_TRANSACTION_SIZE的一半左右,实际约 512KB~1MB,不同版本有差异); - 这个配额是进程级共享的,同一时刻所有正在进行的 Binder 事务加起来不能超;
- 超限不是"慢慢传",是直接抛异常。
而且------这个异常经常不崩在你期望的地方 。它可能在 finish() 时抛,也可能在目标 Activity 启动时抛,取决于数据量与时机。
4.2 "快速修复":静态变量
线上急着止血,于是有了这个改动:
kotlin
// 结果契约对象(示意,已脱敏)
class PointPickResult : ActivityResultContract<PickParam, List<PointItem>?>() {
companion object {
// 用来绕过 Binder 1MB 限制
var tempList: List<PointItem>? = null
}
override fun createIntent(context: Context, input: PickParam): Intent =
Intent(context, PointPickerActivity::class.java)
override fun parseResult(resultCode: Int, intent: Intent?): List<PointItem>? {
// 不从 Intent 取数据,直接读静态变量
val data = tempList
tempList = null
return data
}
}
选择页里:
kotlin
PointPickResult.tempList = selectedList // 写入静态变量
setResult(Activity.RESULT_OK) // Intent 不带数据
finish()
旧问题没了------无论选多少个点都不会 TransactionTooLarge。
4.3 然后,新坑开始了
坑 1:进程被杀后数据消失(最常见)。 用户在选择页选了两百个点,这时:
- 切到后台回个微信 → 系统内存紧张 → 进程被杀;
- 或者开了"不保留活动"(开发者选项);
- 或者横竖屏 + 低端机触发了进程重建。
回来之后 Activity 重建了,但静态变量归零 。用户看到的是"刚选的两百个点没了"------而且没有任何提示。
更隐蔽的是:静态变量还在,但列表里的对象被反序列化成了不同的实例(tempList 指向的是旧进程的对象),下游代码拿到的可能是个"半死"的列表。
坑 2:内存泄漏。
kotlin
companion object {
var tempList: List<PointItem>? = null // 强引用,直到被覆盖才会释放
}
如果 parseResult 因为某种原因没被调用(用户取消了、流程被打断、回调没触发------就是上一节讲的那种情况),这个列表就一直挂在静态区。点位对象通常不小(坐标、属性、图形引用),几百个点几 MB,就这么常驻了。
而且它是 companion object 上的 var,任何地方都能写。多人协作时,两个流程同时用这个变量会互相覆盖:
css
流程 A 写入 → 流程 B 写入(覆盖 A)→ 流程 A 读取(拿到 B 的数据)
这类 Bug 的表现是"偶发数据错乱",且基本无法复现。
坑 3:时序耦合。 静态变量方案隐含一个假设:写入与读取之间,进程没被杀、没有其他流程插入、读取只发生一次。
这个假设在 Android 上全都不成立:
| 假设 | 现实 |
|---|---|
| 进程不会被杀 | 后台进程随时可能被回收 |
| 只有一个流程在用 | 多选入口有五六个,共用一个静态槽 |
| 只读一次 | 配置变更后 parseResult 可能被调用多次,第二次读到 null |
坑 4:没法做单元测试。 静态可变状态是单元测试的头号敌人:测试之间有隐式耦合,执行顺序会影响结果,而且"清理现场"全靠自觉。
下面这张图把"传参链路的容量边界"画出来------注意分叉点:静态变量绕过了 Binder 的容量限制,却把风险搬到了"进程存活"这个更不可控的维度上。
4.4 四种正确的解法与取舍
解法 1:共享 Repository / 数据仓库单例(推荐)。 把数据放在一个明确的、有生命周期归属的地方,用 ID 或 token 关联:
kotlin
// 数据仓库(作用域是整个进程,但语义明确、可清理)
object PickRepository {
private val cache = ConcurrentHashMap<String, List<PointItem>>()
fun save(data: List<PointItem>): String {
val token = UUID.randomUUID().toString()
cache[token] = data
return token
}
fun take(token: String): List<PointItem>? = cache.remove(token) // 取完即清
fun clear() = cache.clear()
}
kotlin
// 选择页:保存后只回传 token(几十字节)
val token = PickRepository.save(selectedList)
setResult(Activity.RESULT_OK, Intent().putExtra("token", token))
// 接收方:用 token 取回
val token = intent.getStringExtra("token") ?: return
val list = PickRepository.take(token)
相比裸静态变量,改进点:
- 有 key:不同流程互不干扰;
- 取完即清 (
remove):不会常驻内存; - 进程重建后可检测:token 取不到数据时能明确提示用户"数据已失效,请重新选择",而不是静默给个空列表。
解法 2:共享 ViewModel(同 Activity 内多 Fragment)。 如果两个页面在同一个 Activity(Fragment 之间传数据),这是最合适的:
kotlin
// Activity 作用域的共享 ViewModel
class SharedPickViewModel : ViewModel() {
private val _picked = MutableStateFlow<List<PointItem>>(emptyList())
val picked: StateFlow<List<PointItem>> = _picked
fun setPicked(list: List<PointItem>) { _picked.value = list }
}
// Fragment A(选择页)
private val shared: SharedPickViewModel by activityViewModels()
fun confirm(list: List<PointItem>) {
shared.setPicked(list)
findNavController().popBackStack()
}
// Fragment B(使用方)
private val shared: SharedPickViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
shared.picked.collect { list -> render(list) }
}
}
}
优点:生命周期由框架管理,Activity 销毁自动清理,配置变更自动恢复(配合 SavedStateHandle)。
解法 3:改用 URI / 文件 / 数据库中转(大数据首选)。 数据量真的很大(几 MB 以上)时,根本不该在内存里传:
kotlin
// 选择页:写临时文件,只回传 URI
val uri = PickCache.writeToCache(context, selectedList)
setResult(Activity.RESULT_OK, Intent().apply {
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // 别忘了授权
})
// 接收方
val uri = intent.data ?: return
val list = PickCache.readFromCache(context, uri)
要点:
- 用
FileProvider或content://URI,不要传file://(Android 7.0+ 会抛FileUriExposedException); - 必须加
FLAG_GRANT_READ_URI_PERMISSION; - 临时文件要有清理策略(启动时清理过期缓存)。
解法 4:分页 / 只传 ID。 最彻底的方案是重新设计交互:选择页不返回数据本身,只返回 ID 集合,使用方自己按需加载。
kotlin
// 只回传 ID:1000 个 long 也就 8KB
setResult(Activity.RESULT_OK, Intent().apply {
putLongArrayExtra("ids", selectedIds.toLongArray())
})
如果业务允许,这是最优解------传引用而不是传数据。
四种解法对比:
| 方案 | 数据量 | 进程重建 | 内存 | 改造成本 |
|---|---|---|---|---|
| 共享 Repository | 中(< 几十 MB) | 可检测并提示 | 取完即清 | 低 |
| 共享 ViewModel | 中 | 自动恢复(配 SavedStateHandle) | 随 Activity | 低(需同 Activity) |
| URI / 文件 | 大(无上限) | 文件还在 | 磁盘 | 中 |
| 只传 ID | 小 | 天然安全 | 最小 | 高(要改交互) |
| 不限 | 丢失 | 常驻泄漏 | 极低(但代价最高) |
4.5 为什么"静态变量传数据"这么诱人
因为它立刻有效、改动最小、不需要理解生命周期。在 deadline 压力下,这是最容易做的选择。
但它的本质是:
用"进程全局可变状态"替代"显式的、有生命周期的数据传递"。
一旦开了这个口子,就会出现第二个、第三个静态变量,最后成为一个谁都不敢删、删了就崩的"隐式数据库"。
一条团队规则值得写进规范:
静态变量只能存"不可变配置"和"无状态工具",禁止用于页面间数据传递 。Code Review 时见到
companion object { var xxx: List<...> }一律打回。
五、ANR 日志指向别处:runBlocking 写在 UI 调用链上
5.1 现象:点一下"炸开",卡死 5 秒
功能背景:地图上有复杂图形,选中一个实体点击"炸开"(分解成基本图元)。数据量大的时候,点击后界面完全卡死几秒,然后要么恢复,要么弹出 ANR 对话框。
代码是这样的:
kotlin
/**
* 新的炸开逻辑
*/
private fun executeBatchExplode(objectId: DbObject): Boolean {
return runBlocking(Dispatchers.IO) { // ← 看着很像"切到 IO 线程"
var result = false
try {
cadManager.deleteEntity(objectId.id)
val entity = DbEntity.cast(objectId)
if (!entity.isNull && entity.isWriteEnabled) {
// ... 一系列同步的几何计算与数据库操作
result = true
}
} catch (e: Exception) {
Log.e(TAG, "explode failed", e)
}
result
}
}
调用链:
scss
用户点击菜单
→ onMenuItemClick(...)
→ executeBatchExplode(...) ← 主线程
→ runBlocking(Dispatchers.IO) ← 阻塞主线程等待
5.2 根因:Dispatchers.IO 改变的是"协程在哪跑",不是"调用者是否被阻塞"
这是 runBlocking 最大的误解。拆开看它的语义:
kotlin
public fun <T> runBlocking(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> T
): T
它做两件事:
- 启动一个协程 (在
context指定的线程/调度器上执行block); - 阻塞当前线程,直到这个协程执行完,然后返回结果。
所以:
kotlin
runBlocking(Dispatchers.IO) { ... }
// ↑ 协程体在 IO 线程跑
// 但:调用 runBlocking 的线程(主线程)被阻塞,一直等到协程结束
runBlocking 的全部意义就是"阻塞当前线程" ,指定 Dispatchers.IO 只是让协程体换个线程执行------它不能让调用者不阻塞,反而多了一次线程切换的开销。
对照表:
| 写法 | 协程体线程 | 调用者线程 |
|---|---|---|
runBlocking { } |
当前线程(主线程) | 主线程(阻塞) |
runBlocking(Dispatchers.IO) { } |
IO 线程池 | 主线程(仍然阻塞) |
lifecycleScope.launch(Dispatchers.IO) { } |
IO 线程池 | 主线程(不阻塞)✅ |
withContext(Dispatchers.IO) { } |
IO 线程池 | 挂起,不阻塞线程 ✅ |
结论:在 Android 的 UI 调用链上出现 runBlocking,几乎一定是 Bug。
误解的来源其实很具体:协程的入门材料里,runBlocking 常和 launch、async 并排列出,读者容易形成"它们都是启动协程的方式"的印象,于是把"启动"当成了它唯一的作用。但 launch / async 是非阻塞的,runBlocking 是阻塞的------它名字里的 blocking 说的不是"协程体阻塞在 IO 上",而是"它把调用线程阻塞住 "。判断一个协程 API 会不会卡住主线程,只看一件事:它返回的是一个 Job(之后异步拿到结果),还是必须先把结果算出来再同步返回。withContext 是前者语义,所以不阻塞线程;runBlocking 是后者,所以一定要阻塞。
5.3 ANR 是怎么触发的
Android 的 ANR 阈值:
| 场景 | 阈值 |
|---|---|
| 输入事件(点击、触摸)未及时处理 | 5 秒 |
BroadcastReceiver.onReceive |
10 秒(前台)/ 60 秒(后台) |
Service 生命周期 |
20 秒(前台) |
executeBatchExplode 在主线程上被阻塞了几秒,直接超过 5 秒阈值 → Input dispatching timed out。
为什么测试环境不容易复现:
- 测试用的图形数据量小(几十个实体),耗时几百毫秒;
- 测试机性能好(IO、CPU 都快);
- 线上用户的数据是真实工程文件(几千上万个实体),慢十倍;
- 设备是工业手持终端,性能普遍低于旗舰机。
ANR 类问题的共性:在本地"看起来还好",在真实数据 + 低端机上才炸。
这张时序图把"主线程被 IO 协程反向拖住"的关系画清楚------两条时间线是并行的,但主线程那条必须在协程结束前一直 WAITING。
5.4 正确的改法
改法 1:改成挂起函数(最推荐)。
kotlin
// 声明为 suspend,内部用 withContext 切线程
private suspend fun executeBatchExplode(objectId: DbObject): Boolean =
withContext(Dispatchers.IO) {
try {
cadManager.deleteEntity(objectId.id)
val entity = DbEntity.cast(objectId)
if (!entity.isNull && entity.isWriteEnabled) {
// ... 耗时操作
true
} else {
false
}
} catch (e: Exception) {
Log.e(TAG, "explode failed", e)
false
}
}
调用处:
kotlin
// 在 lifecycleScope 里调用,UI 不阻塞
private fun onExplodeClick(objectId: DbObject) {
lifecycleScope.launch {
showLoading() // 主线程:显示进度
val ok = executeBatchExplode(objectId) // 挂起,不阻塞主线程
hideLoading() // 主线程:恢复
if (ok) {
refreshMap()
} else {
showError()
}
}
}
关键点:耗时操作期间 UI 是活的,所以能显示 loading、能响应取消。
改法 2:如果调用方是 Java / 不能改成 suspend。 用回调或 CompletableDeferred:
kotlin
fun executeExplodeAsync(objectId: DbObject, callback: (Boolean) -> Unit) {
scope.launch {
val result = withContext(Dispatchers.IO) {
doExplode(objectId)
}
withContext(Dispatchers.Main) {
callback(result)
}
}
}
改法 3:如果确实需要"同步等待"(极少见)。 只有两种场景可以保留 runBlocking:
- 测试代码(JUnit 里等协程跑完);
- 非主线程的后台线程(且已确认没有性能要求)。
kotlin
// 可以接受:在测试里
@Test
fun testLoad() = runBlocking {
val data = repository.load()
assertEquals(3, data.size)
}
即便如此,也建议用 runTest(kotlinx-coroutines-test)替代。
5.5 怎么在工程里杜绝它
第一,全局搜索,逐个确认。
runBlocking
在 Android 工程里,这个搜索的结果应该只有测试代码 。任何出现在 src/main 下的 runBlocking 都要能给出理由。
第二,上 Lint / Detekt 规则。 Detekt 有现成规则(ForbiddenMethodCall):
yaml
# detekt.yml
ForbiddenMethodCall:
active: true
methods:
- reason: "禁止在主线程使用 runBlocking"
value: "kotlinx.coroutines.runBlocking"
或者在 CI 里做简单的源码扫描:
bash
# 检查 src/main 下是否出现 runBlocking
if grep -rn "runBlocking" --include="*.kt" */src/main/ ; then
echo "ERROR: 主源码中禁止使用 runBlocking"
exit 1
fi
第三,主线程耗时监控。 光靠规范不够,要有运行时监控。最简单的方案是给主线程 Looper 装一个"卡顿探针":
kotlin
object BlockDetector {
fun install() {
val printer = Looper.getMainLooper().logging
Looper.getMainLooper().setMessageLogging { msg ->
if (msg.startsWith(">>>>>")) {
start = System.currentTimeMillis()
startMsg = msg
} else if (msg.startsWith("<<<<<")) {
val cost = System.currentTimeMillis() - start
if (cost > 500) {
// 记录卡顿,并 dump 主线程堆栈
Log.w("Block", "${cost}ms\n${stackTraceOfMainLooper()}")
}
}
}
}
private fun stackTraceOfMainLooper(): String =
Looper.getMainLooper().thread.stackTrace.joinToString("\n")
}
这段代码能在开发/测试阶段就把"哪个操作把主线程堵了"定位到具体堆栈,比等线上 ANR 报告高效得多。
第四,接入 ANR 上报。 线上用 ANR 监控(大部分崩溃平台都支持,原理是监听 /data/anr/traces.txt 或用 FileObserver)。关键是要保留主线程堆栈,否则只知道"卡了",不知道"卡在哪"。
5.6 同一类问题的其他形态
runBlocking 只是"主线程被同步阻塞"的一种。同类写法还有:
| 写法 | 问题 |
|---|---|
Thread.sleep() 在主线程 |
直接阻塞 |
CountDownLatch.await() 在主线程 |
等另一个线程,主线程白等 |
Future.get() / Deferred.await() 在主线程 |
同上(协程里用 await 是挂起,但 runBlocking 包住就是阻塞) |
| 数据库/文件 IO 直接在主线程 | 慢操作时阻塞(这正是 StrictMode.detectDiskReads() 的价值) |
synchronized 持锁跨线程等待 |
锁竞争导致主线程等待 |
统一原则:
主线程上不允许出现任何"等待"语义,只允许"挂起"或"回调"。
六、作用域失控:ViewModel 的五种写法
6.1 先看这五种写法
在一个 40 模块的工程里,由于历史演进 + 不同同学的书写习惯,ViewModel 的获取方式出现了五种:
kotlin
// 写法 1:KTX 委托,Fragment 作用域
private val vm: PointViewModel by viewModels()
// 写法 2:KTX 委托,Activity 作用域
private val vm: PointViewModel by activityViewModels()
// 写法 3:手动 ViewModelProvider,用 Activity 作为 storeOwner
private val vm = ViewModelProvider(activity)[PointViewModel::class.java]
// 写法 4:普通类里,从 Context 拿 Activity 再创建(本工程特有:业务模式不是 Fragment)
protected val vm = ViewModelProvider(context.getActivity())[CommonViewModel::class.java]
// 写法 5:Java 代码里的传统写法
viewModel = new ViewModelProvider(this).get(PointViewModel.class);
它们看起来都是"拿一个 ViewModel",但作用域完全不同,拿到的实例也不同。
6.2 作用域对照表
| 写法 | ViewModelStoreOwner | 作用域 | 谁销毁它 |
|---|---|---|---|
by viewModels()(Fragment) |
Fragment | Fragment(View 销毁即清) | Fragment 销毁 |
by viewModels()(Activity) |
Activity | Activity | Activity 销毁 |
by activityViewModels() |
Activity | Activity(跨 Fragment 共享) | Activity 销毁 |
ViewModelProvider(activity) |
Activity | Activity | Activity 销毁 |
ViewModelProvider(requireActivity()) |
Activity | Activity | Activity 销毁 |
ViewModelProvider(this)(Java,Activity 内) |
Activity | Activity | Activity 销毁 |
ViewModelProvider(this)(Java,Fragment 内) |
Fragment | Fragment | Fragment 销毁 |
ViewModelProvider(navBackStackEntry) |
导航回退栈条目 | 单个目的地 | 出栈时 |
判断方法只有一个:看传给 ViewModelProvider / 委托的那个 ViewModelStoreOwner 是谁。
kotlin
// 这三个是同一个对象吗?
ViewModelProvider(activity)[Vm::class.java] // Activity 作用域
ViewModelProvider(requireActivity())[Vm::class.java] // 同上,同一对象 ✅
by activityViewModels() // 同上,同一对象 ✅
ViewModelProvider(this)[Vm::class.java] // 如果 this 是 Fragment,作用域不同 ❌
6.3 真实事故:以为是共享,其实是两个实例
事故 1:数据改了,界面没更新。 业务模式类(不是 Fragment,是工程里的 BaseWorkMode)里这样拿 ViewModel:
kotlin
// BaseWorkMode(普通类,持有 Context)
protected val commonViewModel =
ViewModelProvider(context.getActivity())[CommonViewModel::class.java]
而它承载的 Fragment 里是这样拿的:
kotlin
// 顶部 Fragment
private val vm: CommonViewModel by viewModels() // Fragment 作用域!
结果:两个不同的实例。
- WorkMode 改了
commonViewModel.xxx,Fragment 观察的是另一个实例,永远收不到通知; - 表现:点了按钮,数据变了,界面纹丝不动;
- 日志:什么都没有;
- 排查:断点看两个对象的
hashCode才发现不是同一个。
事故 2:Fragment 重建后数据丢了(或者说,没丢但没人认)。 另一个方向的问题:Fragment 用 by viewModels(),但 ViewModel 里存的是"整个页面流程的状态"。配置变更(横竖屏)时 Fragment 重建,viewModels() 拿到的是新实例,状态丢失。
而如果改成 activityViewModels(),状态跟着 Activity 走,重建后恢复------但如果同一个 Activity 里有两个该 Fragment 的实例(比如 ViewPager),它们会互相污染。
事故 3:普通类持有 ViewModel 导致的生命周期错位。 最隐蔽的一种:普通类(WorkMode)用 ViewModelProvider(context.getActivity()) 拿 ViewModel,而 Activity 的生命周期比 WorkMode 长得多。
css
Activity 创建
├─ WorkMode A 创建 → 拿到 VM(Activity 作用域)
├─ WorkMode A 销毁 → VM 还在(作用域是 Activity)
├─ WorkMode B 创建 → 拿到同一个 VM,里面还是 A 留下的状态
└─ ...
WorkMode 之间本该隔离的状态,被 Activity 作用域的 ViewModel 串了起来。 表现为"切到别的模式,上次的残留数据还在"。
正确做法(配合手写 LifecycleOwner):ViewModel 的作用域必须与其使用者的生命周期匹配。如果 WorkMode 需要自己的 ViewModel,要么:
- 用
ViewModelProvider(workModeViewModelStoreOwner)------需要给 WorkMode 实现ViewModelStoreOwner; - 或者干脆不用 ViewModel,用普通状态容器 +
lifecycleScope清理。
给普通类实现 ViewModelStoreOwner 其实很简单:
kotlin
class BaseWorkMode(...) : ViewModelStoreOwner, LifecycleOwner {
override val viewModelStore: ViewModelStore = ViewModelStore()
private val registry = LifecycleRegistry(this)
override fun getLifecycle(): Lifecycle = registry
// 让 ViewModel 能感知生命周期(实现 HasDefaultViewModelProviderFactory 更佳)
val viewModel: CommonViewModel by lazy {
ViewModelProvider(this, defaultViewModelProviderFactory)[CommonViewModel::class.java]
}
fun destroy() {
registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
viewModelStore.clear() // 关键:主动清理
}
}
6.4 Fragment 里该用哪个:viewModels() 还是 activityViewModels()
决策树:
scss
这个 ViewModel 的数据需要给"同一 Activity 下的其他 Fragment"用吗?
├─ 是 → activityViewModels()
└─ 否 ↓
这个 ViewModel 的数据需要在 Fragment 重建(配置变更/返回栈)后保留吗?
├─ 是 → viewModels() 其实也能保留(ViewModel 在 Fragment 非销毁性重建时保留)
│ ⚠️ 但要注意:Fragment 被 remove(非配置变更)时 ViewModel 会清掉
└─ 否 → viewModels()
一个反直觉但重要的细节 :Fragment 的 by viewModels() 在配置变更时是保留的(ViewModelStore 跟随 Fragment 实例,而非 View)。所以"防配置变更丢数据"不是选择 activityViewModels() 的理由。
真正需要 activityViewModels() 的理由只有两个:
- 同一 Activity 下的多个 Fragment 需要共享状态;
- 数据要活过 Fragment 的替换 (比如
replace()之后还要用)------但这种情况更应该考虑把数据下沉到 Repository。
下面这张图把"同一个 ViewModelStoreOwner 决定同一个实例"的关系画出来,右边两条虚线标出最常见的两种失效路径。
6.5 五种写法怎么收敛
一个工程里并存五种写法,本身就是技术债。收敛建议分三步。
第一步:先统一"能统一的"。
kotlin
// Activity 内
private val vm: XxxViewModel by viewModels()
// Fragment 内,非共享
private val vm: XxxViewModel by viewModels()
// Fragment 内,需要共享
private val vm: XxxViewModel by activityViewModels()
// Java 代码(无法用 KTX 委托时)
private final XxxViewModel vm = new ViewModelProvider(this).get(XxxViewModel.class);
ViewModelProvider(activity) 这种写法全部替换成 by activityViewModels()------语义等价但可读性更好,且不容易传错 owner。
第二步:给"普通类"定一条明确规则。 普通类(非 Activity/Fragment)获取 ViewModel,只有两种合规写法:
kotlin
// 合规 1:明确知道要共享 Activity 级数据,且注释说明
// 注意:这个 VM 的生命周期是 Activity,WorkMode 销毁不会清理它
protected val sharedVm by lazy(LazyThreadSafetyMode.NONE) {
ViewModelProvider(context.getActivity())[SharedViewModel::class.java]
}
// 合规 2:类自己实现 ViewModelStoreOwner,拥有独立作用域(推荐)
protected val ownVm by lazy(LazyThreadSafetyMode.NONE) {
ViewModelProvider(this)[OwnViewModel::class.java]
}
不合规:在普通类里隐式持有 Activity 作用域的 ViewModel 却不写注释------半年后没人知道这个 VM 活得比使用者还久。
第三步:命名暴露作用域。 用命名把作用域写进名字里,让 code review 时一眼看出来:
kotlin
private val sharedStakeVm by activityViewModels<StakeViewModel>() // 共享
private val pageVm by viewModels<PageViewModel>() // 页面内
或者更直接:给跨 Fragment 共享的 ViewModel 加 Shared 前缀(SharedStakeViewModel)。
6.6 配套的排查技巧
怀疑"ViewModel 不是同一个实例"时,最快的验证:
kotlin
// 在两个地方各打一次
Log.d("VM", "location=A, hash=${System.identityHashCode(vm)}")
Log.d("VM", "location=B, hash=${System.identityHashCode(vm)}")
不一致就说明作用域不同。
更系统的做法是给 ViewModel 加 onCleared 日志:
kotlin
abstract class BaseViewModel : ViewModel() {
override fun onCleared() {
Log.d("VM", "${javaClass.simpleName}@${System.identityHashCode(this)} cleared")
super.onCleared()
}
}
这样能看到"谁在什么时候被清理了",比猜作用域快得多。
七、横向对比:五种静默失败的共同结构
把五个事故摆到一张表上,共同点会立刻浮出来:
| 案例 | 失败点 | 谁吞掉了信号 | 为什么没声音 |
|---|---|---|---|
| 崩溃处理器吞异常 | 覆盖 handler 未委托 | 业务 handler 自己 | 异常对象从未被打印 |
| 回调第二次不来了 | registry 回调被注销 | 生命周期边界 ON_DESTROY |
派发时找不到 key,结果静默丢弃 |
| 静态变量传数据 | 进程重建后归零 | 进程边界 | 读到 null,代码继续跑 |
runBlocking 阻塞 |
主线程被堵 | 同步等待语义 | 只是慢,不崩 |
| ViewModel 作用域错 | 拿到不同实例 | ViewModelStoreOwner 边界 | 数据改了没人观察,不报错 |
共同结构可以概括成一句话:五个事故都被"兜底机制"或"生命周期边界"吞掉了信号。 前者是框架主动选择静默降级(doDispatch 找不到 key 就丢弃、Deferred 不 await 就不抛),后者是组件越过边界后内建状态全部失效(DESTROYED 是终态、进程重建后静态区归零)。业务代码的 try/catch 和日志碰不到这两处,因为失败根本没发生在业务代码里。
更深一层的共同点:
- 错误发生在"框架内部",不在业务代码里------所以业务层的 try/catch 和日志都碰不到它;
- 框架选择了"静默降级"而不是"快速失败"------这是 Android Framework 的一贯风格(为了兼容性);
- 没有显式契约------静态变量、隐式作用域、隐式覆盖,都是"约定"而非"契约";
- 都能被一条工程规范防住。
顺带把"看起来对、实际会失效"的写法单独列一张对照表------这类写法最危险,因为它们在 code review 里几乎看不出问题:
| 看起来对 | 实际行为 | 失效时机 |
|---|---|---|
用 try/catch 包住业务逻辑 |
框架内部抛出的异常不经过业务层 | 崩溃处理器被覆盖时 |
replace() + 复用已缓存的 Fragment 实例 |
实例复活但内建回调已注销 | 第二次进入该 tab |
runBlocking(Dispatchers.IO) "切到 IO 线程" |
调用线程仍被阻塞 | 主线程调用且耗时超 5 秒 |
companion object 存 var 列表传数据 |
进程重建后归零,无任何提示 | 后台被杀后返回 |
Fragment 里 by viewModels() 想共享 |
拿到 Fragment 级实例而非 Activity 级 | 与 Activity 级实例混用时 |
对应的四条防御:
| 防御 | 落地方式 |
|---|---|
| 让错误尽早暴露 | 不用 NoOp 兜底;check() 断言;启动自检 |
| 让行为可观测 | 崩溃处理器落盘;打点日志;卡顿探针 |
| 让作用域显式 | ViewModel 命名带 Shared;Fragment 切换不用 replace+复用 |
| 让规则进 CI | 禁止 runBlocking;禁止静态传值;禁止吞异常的处理器 |
值得注意的是,这四条防御都不需要重构业务逻辑------它们全是"边界上的约定":断言加在初始化末尾,落盘加在崩溃处理器里,CI 规则扫一遍源码。门槛足够低,正是它们值得被推广的原因。
八、小结
- 最贵的 bug 不是崩得响的那个,是静悄悄不执行的那个------崩溃留下堆栈,静默失败只留下一个"点了没反应"。
setDefaultUncaughtExceptionHandler是覆盖 不是追加,必须保存并委托origin;崩溃处理器三件事缺一不可:落盘、委托、兜底自杀。replace()+ 复用已销毁的 Fragment 实例,会让ActivityResultRegistry的回调永久失效;DESTROYED 是终态,销毁过的实例必须新建。- 静态变量传数据能绕过 Binder 的 1 MB 限制,却换来进程重建丢数据、内存泄漏、流程互相覆盖、无法测试;正解是传 ID 或带 token 的 Repository。
runBlocking(Dispatchers.IO)仍然阻塞调用线程;主线程上只允许"挂起"或"回调",不允许"等待"。- ViewModel 的作用域只由
ViewModelStoreOwner决定;判断实例是否相同,看 owner,不看写法。 - 这五种失败都被"兜底机制"或"生命周期边界"吞掉了信号,因此一条工程规范就能防住:让错误尽早暴露、让行为可观测、让作用域显式、让规则进 CI。
系列导航:Android 技术总结系列第 4 篇(共 9 篇)。上一篇《Android AOP 的多品牌定制与代价》,下一篇《Android 一个工程里的四代异步方案》。
这五个事故里,你有没有遇到过"代码看起来处理了、线上却完全没声音"的那种------当时是靠什么线索把被吞掉的信号找回来的?