Android 三类「看着无关」的故障:大事务、UI 阻塞与 ViewModel 作用域

三条资源边界上的"图省事",最后都会在别的地方爆开

前言

线上总有一类问题特别难查:不崩溃、不报错、日志干干净净,用户只说"选的点没了""点了没反应""数据改了界面不动"。你拿着手机在工位上复现,一切正常;去了现场,问题又出现了。

这类问题有个共同点------它们都不发生在业务代码里,而发生在某条资源边界上:数据从一个进程搬到另一个进程、代码从后台线程回到主线程、对象从一个页面活到页面之外。边界之内是你写的东西,边界之外是框架的地盘;而框架在越界时,往往不会抛异常,而是选一个"能继续跑"的降级方案,把错误留给你猜。

我在一套测绘与工业采集的 Android 工程里,先后踩过三次这样的边界:把几 MB 的点位列表塞进 Intent 返回、在点击回调里写了 runBlocking、同一个 ViewModel 在五个地方被五种方式取出来。三次都不是"某个 API 拼错了",而是三条边界被越过了。这篇把它们放在一起讲,是因为修法背后的判断是同一套:先找到边界在哪,再决定数据、时间、生命周期分别归谁所有。

一、第一条边界:Binder 事务的 1 MB

1.1 现象:一个 3 MB 的 parcel

有个页面要打开点库让用户多选点位,可能几百上千个,选完返回给调用方。

最初的实现很自然:把选中的列表塞进 Intent / Bundle 返回。

kotlin 复制代码
// 选择页(示意)
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(...)

3 MB 的数据,超过了 Binder 事务能承载的上限。

1.2 为什么会炸

startActivityForResult / setResult 的数据走 Binder 事务。关键约束有三条:

  • Binder 事务缓冲区的大小是固定的 1 MB 量级 ,而且是按进程 分配的:每个进程在打开 Binder 驱动时 mmap 出一块内存,收发共用;
  • 这块缓冲区被该进程所有进行中的事务共享,不是"每次事务都有 1 MB";
  • 超限不是"慢慢传",而是 transact 直接失败并抛异常。

第二条最容易被忽略。它意味着:你只传了 800 KB,也可能炸 ------因为同一时刻这个进程还有别的事务在占用缓冲区(onSaveInstanceState、其他 Service 调用、系统的 Activity 生命周期回调,都算)。官方文档对这个异常的描述也是这个意思:即使每个事务都不大,只要同时进行的事务够多,照样会抛。

还有两个细节值得记住。

第一,异常抛出的位置经常不是你写代码的地方 。setResult 本身只是把结果挂到 ActivityRecord 上,真正的大数据传输发生在 finish() 触发的 finishActivity 调用里。所以栈顶常常是 finishActivity,而不是你的 putParcelableArrayListExtra。这会让第一眼排查的方向完全跑偏。

第二,onSaveInstanceState 的 Bundle 走的是同一条路 。页面被系统回收前保存状态,数据量大了同样失败。而在不少版本上,这条路径失败只打一行 !!! FAILED BINDER TRANSACTION !!! 的 warning,不抛异常------页面回来时状态没了,但没有任何崩溃记录。这是比 TransactionTooLargeException 更隐蔽的一种越界。

1.3 "快速修复":静态变量

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

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。改完当天就发版,一切正常。问题在两周后开始出现。

1.4 然后,新坑开始了

坑 1:进程被杀后数据消失

用户在选择页选了两百个点,这时:

  • 切到后台回个微信,系统内存紧张,进程被回收;
  • 或者开发者选项里开了"不保留活动";
  • 或者低端机上横竖屏切换时进程被重建。

回来之后 Activity 重建了,但静态变量归零 。用户看到的是"刚选的两百个点没了",而且没有任何提示 ------代码读到 null,判空、返回、结束,一路安静。

这里要区分两种"重建",它们的表现完全不同:

重建类型 进程 静态变量 表现
配置变更(横竖屏、字体大小) 存活 还在 数据还在,但持有它的是旧 Activity 时代的对象,可能带着已失效的引用
进程被回收后恢复 已死 归零 数据彻底消失,静默返回 null

第二种是致命的。第一种也不干净:静态变量里那些 PointItem 如果持有 Canvas、Bitmap、数据库游标之类的资源,进程活着但它们已经不能用了,下游拿到的是一个"半死"的列表。

补一条工程上更隐蔽的:静态变量是进程内共享的,不是应用内共享的 。如果点库跑在独立进程(:picker),主进程读到的永远是 null。多进程工程里这类 Bug 会被误判成"偶发",因为单进程调试永远不复现。

坑 2:内存泄漏与流程互相覆盖
kotlin 复制代码
companion object {
    var tempList: List<PointItem>? = null    // 强引用,直到被覆盖才会释放
}

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

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

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

这类 Bug 的表现是"偶发数据错乱",且基本无法复现。它比崩溃难查一个数量级,因为没有任何一个时刻是"错的"------每一步都是合法写入。

坑 3:时序耦合

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

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

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

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

1.5 四种正确的解法与取舍

解法 1:共享 Repository + token(推荐)

把数据放在一个明确的、有生命周期归属的地方,用 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:不同流程互不干扰,token 是随机的,A 流程不可能读到 B 的数据;
  • 取完即清 (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)。

注意这里用的是 repeatOnLifecycle(STARTED) 而不是裸 collect。后者在 Fragment 进后台时仍然持有订阅,虽然 lifecycleScope 最终会取消,但中间这段时间的收集是浪费的,而且容易在 onStop 之后触发一次 UI 更新。

解法 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,否则接收方拿到 URI 也读不了;
  • 临时文件要有清理策略(启动时清理过期缓存),否则缓存目录会慢慢涨到几百 MB。
解法 4:分页 / 只传 ID

最彻底的方案是重新设计交互:选择页不返回数据本身,只返回 ID 集合,使用方自己按需加载。

kotlin 复制代码
// 只回传 ID:1000 个 long 也就 8KB
setResult(Activity.RESULT_OK, Intent().apply {
    putLongArrayExtra("ids", selectedIds.toLongArray())
})

如果业务允许,这是最优解------传引用而不是传数据。它同时解决了大小、进程重建、内存占用三个问题:ID 是幂等的,重新查一次就有;数据在数据库里,天然不怕进程死。

四种解法对比
方案 数据量 进程重建 内存 改造成本
共享 Repository 中(< 几十 MB) 可检测并提示 取完即清 低
共享 ViewModel 中 自动恢复(配 SavedStateHandle) 随 Activity 低(需同 Activity)
URI / 文件 大(无上限) 文件还在 磁盘 中
只传 ID 小 天然安全 最小 高(要改交互)
静态变量 不限 丢失 常驻泄漏 极低(但代价最高)

1.6 为什么"静态变量传数据"这么诱人

因为它立刻有效、改动最小、不需要理解生命周期。在 deadline 压力下,这是最容易做的选择。

但它的本质是:

用"进程全局可变状态"替代"显式的、有生命周期的数据传递"。

一旦开了这个口子,就会出现第二个、第三个静态变量,最后成为一个谁都不敢删、删了就崩的"隐式数据库"。

一条团队规则值得写进规范:

静态变量只能存"不可变配置"和"无状态工具",禁止用于页面间数据传递 。Code Review 时见到 companion object { var xxx: List<...> } 一律打回。

二、第二条边界:主线程的 5 秒

2.1 现象:点一下"炸开",卡死几秒

功能背景:地图上有复杂图形,选中一个实体点击"炸开"(分解成基本图元)。数据量大的时候,点击后界面完全卡死几秒,然后要么恢复,要么弹出 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
    }
}

调用链:

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

写这段代码的同学是这么想的:Dispatchers.IO 不就是"切到 IO 线程"吗?为什么还会卡 UI?

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

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

kotlin 复制代码
public fun <T> runBlocking(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope.() -> T
): T

它做两件事:

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

所以:

kotlin 复制代码
runBlocking(Dispatchers.IO) { ... }
//         ↑ 协程体在 IO 线程跑
// 但:调用 runBlocking 的线程(主线程)被阻塞,一直等到协程结束

runBlocking 的全部意义就是"阻塞当前线程" ,指定 Dispatchers.IO 只是让协程体换个线程执行------它不能让调用者不阻塞,反而多了一次线程切换的开销。

再往里看一层:runBlocking 内部会创建一个 BlockingCoroutine,用一个锁 + LockSupport.park() 把当前线程挂起在"等待完成"这个状态上;同时它会在当前线程上跑一个事件循环,用来处理协程体里"切出去又切回来"的续体。也就是说,被阻塞的线程并不是完全闲着,它在替协程跑事件循环------但它不能回到 Looper 去处理输入事件。用户的点击、触摸、绘制,全都排在它后面。

对照表:

写法 协程体线程 调用者线程
runBlocking { } 当前线程(主线程) 主线程(阻塞)
runBlocking(Dispatchers.IO) { } IO 线程池 主线程(仍然阻塞)
lifecycleScope.launch(Dispatchers.IO) { } IO 线程池 主线程(不阻塞)✅
withContext(Dispatchers.IO) { } IO 线程池 挂起,不阻塞线程 ✅

结论:在 Android 的 UI 调用链上出现 runBlocking,几乎一定是 Bug。

还有一条更隐蔽的:runBlocking 启动的协程不属于任何 scope 。它不是 lifecycleScope 的子协程,页面销毁时没有任何人取消它。所以它不仅在阻塞,还在"无法被取消地阻塞"。

2.3 ANR 是怎么触发的

Android 的 ANR 阈值:

场景 阈值
输入事件(点击、触摸)未及时处理 5 秒
BroadcastReceiver.onReceive 10 秒(前台)/ 60 秒(后台)
Service 生命周期 20 秒(前台)/ 200 秒(后台)

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

注意这里的因果链:ANR 的直接原因不是"你的函数跑了 6 秒",而是"主线程 5 秒内没有把输入事件分发出去"。这两件事在结果上等价,但在排查时不一样------ANR 的 traces 里,主线程栈会停在 runBlocking 的 park 上,而不是停在你的几何计算里。看到 LockSupport.park 不要以为是锁竞争,往上看一帧就知道是谁在等。

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

ANR 类问题的共性:在本地"看起来还好",在真实数据 + 低端机上才炸。 所以判断"这段代码会不会 ANR",不能靠"我点了一下不卡",只能靠"这段代码在主线程上做了什么"。

2.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、能响应取消。

这里有个容易忽略的细节:上面 catch (e: Exception) 会把 CancellationException 一起吞掉。如果这个协程被取消了(页面销毁、用户返回),异常会被记一条 "explode failed",然后函数返回 false,取消信号就此中断。正确写法是先判断:

kotlin 复制代码
} catch (e: CancellationException) {
    throw e                       // 取消必须继续向上传递
} catch (e: Exception) {
    Log.e(TAG, "explode failed", e)
    false
}

这不是吹毛求疵。结构化并发的取消依赖异常一路向上抛,中途被吞一次,父协程就永远等不到子协程结束。

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

注意 scope 的来源------它必须是调用方传进来的、有明确生命周期的 scope,不能在这个函数里 GlobalScope.launch。否则你只是把 ANR 换成了泄漏。

改法 3:如果确实需要"同步等待"(极少见)

只有两种场景可以保留 runBlocking:

  1. 测试代码(JUnit 里等协程跑完);
  2. 非主线程的后台线程(且已确认没有性能要求)。
kotlin 复制代码
// 可以接受:在测试里
@Test
fun testLoad() = runBlocking {
    val data = repository.load()
    assertEquals(3, data.size)
}

即便如此,也建议用 runTest(kotlinx-coroutines-test)替代------它能跳过 delay、能在测试结束时检测"还有协程没跑完"。

2.5 怎么在工程里杜绝它

1. 全局搜索,逐个确认
复制代码
runBlocking

在 Android 工程里,这个搜索的结果应该只有测试代码 。任何出现在 src/main 下的 runBlocking 都要能给出理由,而且理由要能写成注释放在函数头上------写不出来的,就是没有理由。

2. 上 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
3. 主线程耗时监控

光靠规范不够,要有运行时监控。最简单的方案是给主线程 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")
}

上面这段是示意:setMessageLogging 接的是 Printer,实际落地时把 start、startMsg 补成字段,Looper.getMainLooper().logging 那行可以删掉。

它的价值在于能在开发/测试阶段就把"哪个操作把主线程堵了"定位到具体堆栈,比等线上 ANR 报告高效得多。阈值取 500 ms 是个经验值:低于这个数的卡顿用户基本感知不到,抓了只会淹没真正的问题。

4. 接入 ANR 上报

线上用 ANR 监控(大部分崩溃平台都支持,原理是监听 /data/anr/traces.txt 或用 FileObserver)。关键是要保留主线程堆栈,否则只知道"卡了",不知道"卡在哪"。

2.6 同一类问题的其他形态

runBlocking 只是"主线程被同步阻塞"的一种。同类写法还有:

写法 问题
Thread.sleep() 在主线程 直接阻塞
CountDownLatch.await() 在主线程 等另一个线程,主线程白等
Future.get() / Deferred.await() 在主线程 同上(协程里用 await 是挂起,但 runBlocking 包住就是阻塞)
数据库/文件 IO 直接在主线程 慢操作时阻塞(这也是 StrictMode.detectDiskReads() 存在的意义)
synchronized 持锁跨线程等待 锁竞争导致主线程等待

统一原则:

主线程上不允许出现任何"等待"语义,只允许"挂起"或"回调"。

"等待"和"挂起"的区别不是措辞:等待是线程停在原地,什么都干不了;挂起是线程被释放,回去处理别的事,条件满足后再回来。主线程只有一个,它必须一直在分发消息。

三、第三条边界:ViewModel 的生命周期归属

3.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",但作用域完全不同,拿到的实例也不同。

3.2 作用域对照表

写法 ViewModelStoreOwner 作用域 谁销毁它
by viewModels()(Fragment) Fragment Fragment(随 Fragment 销毁清理,配置变更保留) 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,作用域不同 ❌

这里的关键机制是:ViewModelProvider 并不"创建"一个全局单例,它每次都是去 owner.viewModelStore 这个 Map 里按 key 取。key 默认是 "androidx.lifecycle.ViewModelProvider.DefaultKey:" + 类名。所以同一个 store、同一个类名 → 同一个实例;store 不同,哪怕类名一样,也是两个对象。

3.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 才发现不是同一个。

这起事故的根因不是"谁写错了",而是写法 4 和写法 1 在字面上太像了。ViewModelProvider(context.getActivity()) 和 by viewModels() 都只是"拿一个 VM",但一个 owner 是 Activity,一个是 Fragment。代码能编译、能跑、不报错,只是永远不同步。

事故 2:Fragment 重建后数据丢了(或者说,没丢但没人认)

另一个方向的问题:Fragment 用 by viewModels(),但 ViewModel 里存的是"整个页面流程的状态"。配置变更(横竖屏)时 Fragment 重建,viewModels() 拿到的是新实例,状态丢失。

这里要澄清一个很常见的误解:by viewModels() 在配置变更时是保留的 。ViewModelStore 挂在 Fragment 实例上,而 Fragment 在配置变更时是被保留的(onDestroy 不调用,只走 onDestroyView)。真正会丢的情况是 Fragment 被 remove / replace 掉,或者宿主 Activity 被销毁。

而如果改成 activityViewModels(),状态跟着 Activity 走,重建后恢复------但如果同一个 Activity 里有两个该 Fragment 的实例(比如 ViewPager 的两页),它们会互相污染 。两个页面共享同一个 VM,A 页的筛选条件会出现在 B 页。这时候正确的选择不是换 owner,而是用 navBackStackEntry 或者干脆让每个页面持有自己的状态。

事故 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 串了起来。 表现为"切到别的模式,上次的残留数据还在"。

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

  1. 用 ViewModelProvider(workModeViewModelStoreOwner)------需要给 WorkMode 实现 ViewModelStoreOwner;
  2. 或者干脆不用 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()          // 关键:主动清理
    }
}

两个细节必须写对:

  • viewModelStore.clear() 会调用每个 ViewModel 的 onCleared(),顺序是先派发 ON_DESTROY 再 clear,否则 ViewModel 里注册的生命周期观察者会来不及收尾;
  • defaultViewModelProviderFactory 来自 HasDefaultViewModelProviderFactory。如果不想实现这个接口,用 ViewModelProvider(this)[Xxx::class.java] 也行,前提是 ViewModel 有无参构造函数。

3.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。

判断标准可以简化成一句话:这个数据是"页面的"还是"流程的"? 页面的用 viewModels(),流程的用 activityViewModels() 或者下沉到 Repository。用错方向,不是泄漏就是脏状态。

3.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)。

命名是成本最低的防御。它不改变任何运行时行为,但能让"作用域"这件事在 review 时无法被忽略------而大多数作用域事故,起因就是没人意识到这里有个选择。

3.6 配套的排查技巧

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

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

不一致就说明作用域不同。用 identityHashCode 而不是 hashCode,是因为后者可能被 ViewModel 重写,而我们要看的是对象身份。

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

kotlin 复制代码
abstract class BaseViewModel : ViewModel() {
    override fun onCleared() {
        Log.d("VM", "${javaClass.simpleName}@${System.identityHashCode(this)} cleared")
        super.onCleared()
    }
}

这样能看到"谁在什么时候被清理了",比猜作用域快得多。如果某个 VM 在页面退出后迟迟没有 cleared 日志,基本可以确定它挂在了比页面更长的 owner 上。

四、三条边界的共同结构

三个案例放在一起看,会发现它们的形状是一样的:

案例 被越过的边界 失败点 为什么没声音
静态变量传数据 IPC 的 1 MB 缓冲区 进程重建后读到 null 判空后直接返回,代码继续跑
runBlocking 阻塞 主线程的 5 秒预算 输入事件分发不出去 只是慢,不崩,没有异常
ViewModel 作用域错 生命周期归属 拿到不同实例 数据改了没人观察,不报错

共同点有四条:

  1. 错误发生在"框架内部",不在业务代码里------所以业务层的 try/catch 和日志都碰不到它;
  2. 框架选择了"静默降级"而不是"快速失败"------这是 Android Framework 的一贯风格(为了兼容性);
  3. 没有显式契约------静态变量、隐式作用域、隐式阻塞,都是"约定"而非"契约";
  4. 都能被一条工程规范防住。

对应的四条防御:

防御 落地方式
让错误尽早暴露 不用 NoOp 兜底;check() 断言;启动自检
让行为可观测 崩溃处理器落盘;切面打日志;卡顿探针
让作用域显式 ViewModel 命名带 Shared;Fragment 切换不用 replace+复用
让规则进 CI 禁止 runBlocking;禁止静态传值;依赖方向检查

如果只记一句:每次把数据、时间、对象"临时放在某处"时,先问一句"谁负责清它"。 答不上来的那个地方,就是下一次线上问题的位置。

结论

  1. Binder 事务上限约 1 MB,且是进程级共享的 ------大列表不要走 Intent,TransactionTooLargeException 只是它最响的一种表现。
  2. 静态变量不是"绕过限制",是"把限制换成静默失败"------进程重建丢数据、内存泄漏、流程互相覆盖、无法测试,四个问题一个都不少。
  3. 传数据的正解是传引用:只传 ID > 共享 Repository + token > 共享 ViewModel > URI / 文件中转,按数据量和改造成本选。
  4. 数据失效必须可检测:token 取不到就明确提示"请重新选择",而不是静默返回空列表。
  5. runBlocking 的语义是"阻塞当前线程" ,Dispatchers.IO 只改变协程体在哪跑,不会让调用者不阻塞。
  6. 主线程只允许"挂起"或"回调",不允许"等待"------输入事件的 ANR 阈值是 5 秒。
  7. ViewModel 的唯一判据是 ViewModelStoreOwner :同一个 store、同一个 key 才是同一个实例,by viewModels() 和 activityViewModels() 拿到的可能完全是两个对象。
  8. 普通类拿 ViewModel 必须显式声明作用域 :要么自己实现 ViewModelStoreOwner,要么在注释里写清"它活得比使用者久"。
  9. 三条边界的通用问法:这块数据、这段时间、这个对象,归谁所有,谁来释放,失效时谁知道。答不上来就别写。

这三个问题我在工程里都遇到过,修完之后回头看,真正省事的做法反而是最开始就把边界想清楚。如果你也踩过"数据没传过来""点了没反应""改了不生效"这三类现象,欢迎在评论区说说当时的排查路径------尤其是最后是怎么定位到边界的,这部分经验比结论更有用。

关键词标签 :#Android #Binder #ANR #ViewModel #协程 #性能优化 #架构设计

相关推荐
换元不配限1 小时前
Android 休眠与唤醒完全指南:从手机到 IoT 设备的实战解析
android·物联网·智能手机·alarmmanager·wakelock·workmanager·休眠和唤醒
通信侠2 小时前
android 16触摸提示音Back虚拟按键无声
android
GY-933 小时前
【Android 】App工程结构和配置
android
360智汇云3 小时前
隔离VPC标准路径:出公网链路的设计与实现
android·服务器·数据库
民乐团扒谱机4 小时前
【超详细】从“硬编码”到“主题化宏”:大型桌面端UI重构与模块解耦实战避坑指南
ui·重构
nece0014 小时前
win10家庭版安装Android模拟器(AVD 安卓虚拟设备)
android·模拟器
GY-934 小时前
【Android之Compose开发基础】创建首个Android项目
android
00后程序员张5 小时前
无需 Mac 的 iOS App 上架工具,开心上架Appuploader 负责哪些环节
android·ios·小程序·https·uni-app·iphone·webview
吹什么轩16 小时前
MySQL:数据类型
android·adb