Android 静默失败实录:五个没有日志的线上事故

0 阅读13分钟

崩溃会留下堆栈,静默失败只留下一个"点了没反应"——后者才是最贵的。

线上报来一个问题:"点某个功能没反应,每天都有一两次。"三条常规排查路同时走空——崩溃上报平台一条记录都没有,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: 后面是空的。这句日志来自下面这段代码:

// 宿主模块 Application(示意,已脱敏)
Thread.setDefaultUncaughtExceptionHandler { t, e ->
    Log.d("Test", "uncaughtException: ")      // ① 只打了个标记,没打 e
    ActivityUtils.finishAllActivities()       // ② 关掉所有页面
}                                             // ③ 没有委托原 handler,没有 killProcess

三个致命点,一次全占:

  1. e 从来没被打印——异常对象根本没进日志;
  2. 没有保存原 handler 并委托——系统默认的崩溃处理(打 FATAL EXCEPTION + 弹"已停止运行")被完全顶掉;
  3. 没有 killProcess——只是 finishAllActivities(),进程进入"没有 Activity 但还活着"的状态,最后被系统回收。

结果就是:崩溃发生了,但全世界都不知道它长什么样。

2.2 原理:它是一条链,不是一个槽

Thread.setDefaultUncaughtExceptionHandler() 的语义经常被误解:

它是覆盖,不是追加。后设置的 handler 会完全替换掉之前的,而之前的那个连引用都拿不回来(没有任何 API 能读取"上一个" handler)。

正因为如此,三方 SDK(统计、推送、IM、地图)都会遵守一个不成文的规矩:先取出当前 handler,做自己的事,再把异常交回给"下家"。

// 三方 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 都变成"拿不回来的过去"。

而我们的代码做了什么?在链上贴了一个终点站。

崩溃发生
   ↓
我们的 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 正确的崩溃处理器长什么样

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)
        }
    }
}

四个要点:

  1. 必须保存并委托 origin——你不打日志没关系,但别拦着系统打;
  2. 必须落盘——进程随时会死,网络上报大概率来不及,下次启动时补传;
  3. 必须有兜底自杀——finishAllActivities() 之后进程不会退出,会留下一个"没有界面的空进程",用户以为卡死了;
  4. 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 很难发现(代码看起来"处理"了异常)。建议三条硬约束。

第一,启动时自检:确认崩溃处理器链完整。

// Debug 包启动时断言
fun assertCrashHandlerInstalled() {
    val handler = Thread.getDefaultUncaughtExceptionHandler()
    check(handler is CrashHandler) {
        "崩溃处理器被覆盖,当前是 ${handler?.javaClass?.name}"
    }
}

放在 Application 初始化的最后一步。如果三方 SDK 之后又覆盖了,这个断言能在开发期就发现。

第二,用"崩溃自检"验证链路。 Debug 包加一个隐藏入口,抛一个受控异常:

fun testCrash() {
    throw RuntimeException("CrashHandler self-test")
}

手动触发一次,检查三件事:logcat 有没有 FATAL EXCEPTION、本地文件有没有落盘、上报平台有没有收到。这是唯一能证明链路真的通的方法。

第三,CI 静态检查:禁止出现"吞异常"的处理器。 用自定义 Lint 规则或简单的源码扫描,禁止匹配这种模式:

setDefaultUncaughtExceptionHandler 的实现中:
  - 未调用 getDefaultUncaughtExceptionHandler() 保存原 handler
  - 或 uncaughtException 方法体内未出现 origin/prev/defaultHandler 的调用

下面这张图把上面这条"覆盖链"画成流程。注意终点站的位置:信息在业务 handler 里就断了,永远到不了系统那一环。

flowchart TD
    A["未捕获异常抛出"] --> B["业务 handler 被系统回调"]
    B --> C["只打印一句日志 对象从未打印"]
    C --> D["finishAllActivities 关掉所有页面"]
    D --> E["链到此终止 没有下家"]
    E --> F["进程半死 无界面却仍在运行"]
    F --> G["结果 无日志 无上报 无反馈"]
    B -.->|"正确做法"| H["保存 origin 并委托"]
    H --> I["落盘 上报 交给系统 handler"]
    I --> J["FATAL EXCEPTION 进 logcat 并杀进程"]

三、回调第二次不来了:Fragment 替换与注册时机

3.1 现象:切走再切回来,选择器就哑了

业务场景:一个主页 Activity,底部 5 个 tab,每个 tab 是一个 Fragment。第二个 tab 里有一个"从点库选择点位"的入口,用 registerForActivityResult 启动选择页并接收结果。

Bug 表现:

第一次进入 tab2 → 点击选择 → 选择页返回 → 回调触发 ✅
切到 tab1,再切回 tab2 → 点击选择 → 选择页正常返回 → 回调不触发 ❌
                                                     (无任何日志、无崩溃)

选择页都正常关闭了,数据也确实选了,但回调就是不来。而且之后无论切几次,永远不回调。

3.2 第一层原因:replace() 会销毁 Fragment

tab 切换的实现是这样的:

private fun updateFragment(index: Int) {
    val transaction = supportFragmentManager.beginTransaction()
    transaction.replace(R.id.container, fragmentList[index])   // ← 问题在这
    transaction.commit()
}

replace() 的语义是:移除容器内所有已存在的 Fragment,然后添加新的那个。

被移除的 Fragment 会走完整销毁流程:

onPause → onStop → onDestroyView → onDestroy → onDetach
                                      ↑
                              lifecycle 到达 DESTROYED

而我们复用了 fragmentList 里的同一个实例——切回来时又把那个已经 DESTROYED 的实例 add 回去。

这一步本身就会引发一堆问题(View 状态、ViewModel 作用域、子 Fragment),但让回调永久失效的是下一层。

3.3 第二层原因:ActivityResultRegistry 在 DESTROYED 时注销回调

registerForActivityResult 不是简单的 startActivityForResult 包装。它的核心是 ActivityResultRegistry,我们看它被"注册"时发生了什么(源码简化):

// 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)
    // ...
}

三个关键点:

  1. 回调存在 mKeyToCallback 这个 Map 里,只有 Map 里有值,结果才能派发;
  2. ON_DESTROY 时调用 unregister(key):从 Map 里移除,并且连 requestCode 都回收了;
  3. 派发结果时:
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 完整链条

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、观察者等)全部不会自动恢复。

这就是"静默失败"的典型形态:表面上一切正常,实际上内建状态已经丢了。

下面这张时序图把"第一次成功、第二次丢弃"的差异画在同一条时间线上。

sequenceDiagram
    participant Frag as Fragment
    participant Reg as ActivityResultRegistry
    participant Pick as 选择页
    Frag->>Reg: register 注册回调并保存到 Map
    Reg-->>Frag: 返回 launcher
    Frag->>Pick: launch 启动选择页
    Pick-->>Frag: setResult 返回结果
    Note over Frag,Reg: 第一次回调正常触发
    Note over Frag,Reg: 切 tab 时 replace 销毁 Fragment
    Reg->>Reg: ON_DESTROY 触发 unregister 回收 requestCode
    Frag->>Frag: 切回来时复用同一个已销毁实例
    Pick->>Reg: doDispatch 派发结果
    Reg->>Reg: Map 中找不到 key 结果静默丢弃

3.6 三种修复方案与取舍

方案 A:改用 add/hide/show(推荐,适合 tab 场景)

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)

配合懒加载:

transaction.add(R.id.container, target, "tab_$index")
          .setMaxLifecycle(target, Lifecycle.State.STARTED)  // 非当前页限制生命周期

方案 B:每次切换都新建 Fragment 实例

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

viewPager.adapter = object : FragmentStateAdapter(this) {
    override fun getItemCount() = 5
    override fun createFragment(position: Int) = createFragmentByPosition(position)
}
优点缺点
官方方案,生命周期与懒加载都处理好了改造量大,tab 与页面联动逻辑要重写

3.7 这类 Bug 怎么快速定位

遇到"回调不触发"时,按顺序做三件事。

第一,打日志确认生命周期状态。

// 在 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 的大小,确认回调是否还在:

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 返回。

// 选择页(示意)
setResult(Activity.RESULT_OK, Intent().apply {
    putParcelableArrayListExtra("selected", ArrayList(selectedPoints))
})

量一大就炸:

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 "快速修复":静态变量

线上急着止血,于是有了这个改动:

// 结果契约对象(示意,已脱敏)
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
    }
}

选择页里:

PointPickResult.tempList = selectedList        // 写入静态变量
setResult(Activity.RESULT_OK)                  // Intent 不带数据
finish()

旧问题没了——无论选多少个点都不会 TransactionTooLarge。

4.3 然后,新坑开始了

坑 1:进程被杀后数据消失(最常见)。 用户在选择页选了两百个点,这时:

  • 切到后台回个微信 → 系统内存紧张 → 进程被杀;
  • 或者开了"不保留活动"(开发者选项);
  • 或者横竖屏 + 低端机触发了进程重建。

回来之后 Activity 重建了,但静态变量归零。用户看到的是"刚选的两百个点没了"——而且没有任何提示。

更隐蔽的是:静态变量还在,但列表里的对象被反序列化成了不同的实例(tempList 指向的是旧进程的对象),下游代码拿到的可能是个"半死"的列表。

坑 2:内存泄漏。

companion object {
    var tempList: List<PointItem>? = null    // 强引用,直到被覆盖才会释放
}

如果 parseResult 因为某种原因没被调用(用户取消了、流程被打断、回调没触发——就是上一节讲的那种情况),这个列表就一直挂在静态区。点位对象通常不小(坐标、属性、图形引用),几百个点几 MB,就这么常驻了。

而且它是 companion object 上的 var,任何地方都能写。多人协作时,两个流程同时用这个变量会互相覆盖:

流程 A 写入 → 流程 B 写入(覆盖 A)→ 流程 A 读取(拿到 B 的数据)

这类 Bug 的表现是"偶发数据错乱",且基本无法复现。

坑 3:时序耦合。 静态变量方案隐含一个假设:写入与读取之间,进程没被杀、没有其他流程插入、读取只发生一次。

这个假设在 Android 上全都不成立:

假设现实
进程不会被杀后台进程随时可能被回收
只有一个流程在用多选入口有五六个,共用一个静态槽
只读一次配置变更后 parseResult 可能被调用多次,第二次读到 null

坑 4:没法做单元测试。 静态可变状态是单元测试的头号敌人:测试之间有隐式耦合,执行顺序会影响结果,而且"清理现场"全靠自觉。

下面这张图把"传参链路的容量边界"画出来——注意分叉点:静态变量绕过了 Binder 的容量限制,却把风险搬到了"进程存活"这个更不可控的维度上。

flowchart LR
    A[&#34;选择页&#34;] -->|&#34;putParcelableArrayListExtra&#34;| B{&#34;数据量&#34;}
    B -->|&#34;小于约 1MB&#34;| C[&#34;Binder 事务成功回传&#34;]
    B -->|&#34;超过阈值&#34;| D[&#34;TransactionTooLargeException&#34;]
    D -->|&#34;应急处理&#34;| E[&#34;改用静态变量传数据&#34;]
    E --> F[&#34;绕过 Binder 限制&#34;]
    F --> G[&#34;但进程重建后归零 且常驻泄漏&#34;]
    C --> H[&#34;结果正常返回&#34;]
    G -.->|&#34;正解&#34;| I[&#34;传 ID 或带 token 的 Repository&#34;]
    C -.->|&#34;数据很大时&#34;| I

4.4 四种正确的解法与取舍

解法 1:共享 Repository / 数据仓库单例(推荐)。 把数据放在一个明确的、有生命周期归属的地方,用 ID 或 token 关联:

// 数据仓库(作用域是整个进程,但语义明确、可清理)
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()
}
// 选择页:保存后只回传 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 之间传数据),这是最合适的:

// 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 以上)时,根本不该在内存里传:

// 选择页:写临时文件,只回传 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 集合,使用方自己按需加载。

// 只回传 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 对话框。

代码是这样的:

/**
 * 新的炸开逻辑
 */
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
    }
}

调用链:

用户点击菜单
   → onMenuItemClick(...)
       → executeBatchExplode(...)          ← 主线程
           → runBlocking(Dispatchers.IO)    ← 阻塞主线程等待

5.2 根因:Dispatchers.IO 改变的是"协程在哪跑",不是"调用者是否被阻塞"

这是 runBlocking 最大的误解。拆开看它的语义:

public fun <T> runBlocking(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope.() -> T
): T

它做两件事:

  1. 启动一个协程(在 context 指定的线程/调度器上执行 block);
  2. 阻塞当前线程,直到这个协程执行完,然后返回结果。

所以:

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.onReceive10 秒(前台)/ 60 秒(后台)
Service 生命周期20 秒(前台)

executeBatchExplode 在主线程上被阻塞了几秒,直接超过 5 秒阈值 → Input dispatching timed out。

为什么测试环境不容易复现:

  • 测试用的图形数据量小(几十个实体),耗时几百毫秒;
  • 测试机性能好(IO、CPU 都快);
  • 线上用户的数据是真实工程文件(几千上万个实体),慢十倍;
  • 设备是工业手持终端,性能普遍低于旗舰机。

ANR 类问题的共性:在本地"看起来还好",在真实数据 + 低端机上才炸。

这张时序图把"主线程被 IO 协程反向拖住"的关系画清楚——两条时间线是并行的,但主线程那条必须在协程结束前一直 WAITING。

sequenceDiagram
    participant UI as 主线程
    participant IO as IO 线程池
    UI->>UI: onMenuItemClick 收到点击事件
    UI->>IO: runBlocking 启动协程体
    Note over UI: 主线程进入阻塞 等待协程结束
    IO->>IO: 几何计算 与 数据库操作 持续数秒
    Note over UI: 阻塞时间超过 5 秒阈值
    UI->>UI: Input dispatching timed out 触发 ANR
    IO-->>UI: 协程返回 主线程才恢复

5.4 正确的改法

改法 1:改成挂起函数(最推荐)。

// 声明为 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
        }
    }

调用处:

// 在 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:

fun executeExplodeAsync(objectId: DbObject, callback: (Boolean) -> Unit) {
    scope.launch {
        val result = withContext(Dispatchers.IO) {
            doExplode(objectId)
        }
        withContext(Dispatchers.Main) {
            callback(result)
        }
    }
}

改法 3:如果确实需要"同步等待"(极少见)。 只有两种场景可以保留 runBlocking:

  1. 测试代码(JUnit 里等协程跑完);
  2. 非主线程的后台线程(且已确认没有性能要求)。
// 可以接受:在测试里
@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):

# detekt.yml
ForbiddenMethodCall:
  active: true
  methods:
    - reason: "禁止在主线程使用 runBlocking"
      value: "kotlinx.coroutines.runBlocking"

或者在 CI 里做简单的源码扫描:

# 检查 src/main 下是否出现 runBlocking
if grep -rn "runBlocking" --include="*.kt" */src/main/ ; then
  echo "ERROR: 主源码中禁止使用 runBlocking"
  exit 1
fi

第三,主线程耗时监控。 光靠规范不够,要有运行时监控。最简单的方案是给主线程 Looper 装一个"卡顿探针":

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 的获取方式出现了五种:

// 写法 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)FragmentFragment(View 销毁即清)Fragment 销毁
by viewModels()(Activity)ActivityActivityActivity 销毁
by activityViewModels()ActivityActivity(跨 Fragment 共享)Activity 销毁
ViewModelProvider(activity)ActivityActivityActivity 销毁
ViewModelProvider(requireActivity())ActivityActivityActivity 销毁
ViewModelProvider(this)(Java,Activity 内)ActivityActivityActivity 销毁
ViewModelProvider(this)(Java,Fragment 内)FragmentFragmentFragment 销毁
ViewModelProvider(navBackStackEntry)导航回退栈条目单个目的地出栈时

判断方法只有一个:看传给 ViewModelProvider / 委托的那个 ViewModelStoreOwner 是谁。

// 这三个是同一个对象吗?
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:

// BaseWorkMode(普通类,持有 Context)
protected val commonViewModel =
    ViewModelProvider(context.getActivity())[CommonViewModel::class.java]

而它承载的 Fragment 里是这样拿的:

// 顶部 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 长得多。

Activity 创建
  ├─ WorkMode A 创建 → 拿到 VM(Activity 作用域)
  ├─ WorkMode A 销毁 → VM 还在(作用域是 Activity)
  ├─ WorkMode B 创建 → 拿到同一个 VM,里面还是 A 留下的状态
  └─ ...

WorkMode 之间本该隔离的状态,被 Activity 作用域的 ViewModel 串了起来。 表现为"切到别的模式,上次的残留数据还在"。

正确做法(配合手写 LifecycleOwner):ViewModel 的作用域必须与其使用者的生命周期匹配。如果 WorkMode 需要自己的 ViewModel,要么:

  1. 用 ViewModelProvider(workModeViewModelStoreOwner)——需要给 WorkMode 实现 ViewModelStoreOwner;
  2. 或者干脆不用 ViewModel,用普通状态容器 + lifecycleScope 清理。

给普通类实现 ViewModelStoreOwner 其实很简单:

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()

决策树:

这个 ViewModel 的数据需要给"同一 Activity 下的其他 Fragment"用吗?
    ├─ 是 → activityViewModels()
    └─ 否 ↓
这个 ViewModel 的数据需要在 Fragment 重建(配置变更/返回栈)后保留吗?
    ├─ 是 → viewModels() 其实也能保留(ViewModel 在 Fragment 非销毁性重建时保留)
    │        ⚠️ 但要注意:Fragment 被 remove(非配置变更)时 ViewModel 会清掉
    └─ 否 → viewModels()

一个反直觉但重要的细节:Fragment 的 by viewModels() 在配置变更时是保留的(ViewModelStore 跟随 Fragment 实例,而非 View)。所以"防配置变更丢数据"不是选择 activityViewModels() 的理由。

真正需要 activityViewModels() 的理由只有两个:

  1. 同一 Activity 下的多个 Fragment 需要共享状态;
  2. 数据要活过 Fragment 的替换(比如 replace() 之后还要用)——但这种情况更应该考虑把数据下沉到 Repository。

下面这张图把"同一个 ViewModelStoreOwner 决定同一个实例"的关系画出来,右边两条虚线标出最常见的两种失效路径。

flowchart TD
    A[&#34;ViewModelStoreOwner&#34;] --> B[&#34;Activity&#34;]
    A --> C[&#34;Fragment&#34;]
    A --> D[&#34;NavBackStackEntry&#34;]
    A --> E[&#34;自定义普通类&#34;]
    B -->|&#34;activityViewModels&#34;| F[&#34;Activity 级实例 跨 Fragment 共享&#34;]
    C -->|&#34;viewModels&#34;| G[&#34;Fragment 级实例 View 销毁即清&#34;]
    D -->|&#34;viewModel 委托&#34;| H[&#34;单个目的地 出栈即清&#34;]
    E -->|&#34;自实现 owner&#34;| I[&#34;独立作用域 主动 clear&#34;]
    F -.->|&#34;owner 传错&#34;| J[&#34;拿到不同实例 数据改了没人观察&#34;]
    G -.->|&#34;想共享却用了 Fragment 级&#34;| J

6.5 五种写法怎么收敛

一个工程里并存五种写法,本身就是技术债。收敛建议分三步。

第一步:先统一"能统一的"。

// 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,只有两种合规写法:

// 合规 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 时一眼看出来:

private val sharedStakeVm by activityViewModels<StakeViewModel>()   // 共享
private val pageVm by viewModels<PageViewModel>()                    // 页面内

或者更直接:给跨 Fragment 共享的 ViewModel 加 Shared 前缀(SharedStakeViewModel)。

6.6 配套的排查技巧

怀疑"ViewModel 不是同一个实例"时,最快的验证:

// 在两个地方各打一次
Log.d("VM", "location=A, hash=${System.identityHashCode(vm)}")
Log.d("VM", "location=B, hash=${System.identityHashCode(vm)}")

不一致就说明作用域不同。

更系统的做法是给 ViewModel 加 onCleared 日志:

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 和日志碰不到这两处,因为失败根本没发生在业务代码里。

更深一层的共同点:

  1. 错误发生在"框架内部",不在业务代码里——所以业务层的 try/catch 和日志都碰不到它;
  2. 框架选择了"静默降级"而不是"快速失败"——这是 Android Framework 的一贯风格(为了兼容性);
  3. 没有显式契约——静态变量、隐式作用域、隐式覆盖,都是"约定"而非"契约";
  4. 都能被一条工程规范防住。
flowchart TD
    A[&#34;业务出错 或 状态失效&#34;] --> B{&#34;谁吞掉了信号&#34;}
    B -->|&#34;框架兜底机制&#34;| C[&#34;静默降级 不抛异常 不打日志&#34;]
    B -->|&#34;生命周期边界&#34;| D[&#34;越过边界后回调注销 状态归零&#34;]
    C --> E[&#34;业务层 try catch 与日志都碰不到&#34;]
    D --> E
    E --> F[&#34;现象 程序没崩 行为不对&#34;]
    F --> G[&#34;只留下一个 点了没反应&#34;]
    G --> H[&#34;防御 让错误尽早暴露 让行为可观测&#34;]

顺带把"看起来对、实际会失效"的写法单独列一张对照表——这类写法最危险,因为它们在 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 一个工程里的四代异步方案》。

这五个事故里,你有没有遇到过"代码看起来处理了、线上却完全没声音"的那种——当时是靠什么线索把被吞掉的信号找回来的?