三条资源边界上的"图省事",最后都会在别的地方爆开
前言
线上总有一类问题特别难查:不崩溃、不报错、日志干干净净,用户只说"选的点没了""点了没反应""数据改了界面不动"。你拿着手机在工位上复现,一切正常;去了现场,问题又出现了。
这类问题有个共同点------它们都不发生在业务代码里,而发生在某条资源边界上:数据从一个进程搬到另一个进程、代码从后台线程回到主线程、对象从一个页面活到页面之外。边界之内是你写的东西,边界之外是框架的地盘;而框架在越界时,往往不会抛异常,而是选一个"能继续跑"的降级方案,把错误留给你猜。
我在一套测绘与工业采集的 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
它做两件事:
- 启动一个协程 (在
context指定的线程/调度器上执行block); - 阻塞当前线程,直到这个协程执行完,然后返回结果。
所以:
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:
- 测试代码(JUnit 里等协程跑完);
- 非主线程的后台线程(且已确认没有性能要求)。
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,要么:
- 用
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() // 关键:主动清理
}
}
两个细节必须写对:
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() 的理由只有两个:
- 同一 Activity 下的多个 Fragment 需要共享状态;
- 数据要活过 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 作用域错 | 生命周期归属 | 拿到不同实例 | 数据改了没人观察,不报错 |
共同点有四条:
- 错误发生在"框架内部",不在业务代码里------所以业务层的 try/catch 和日志都碰不到它;
- 框架选择了"静默降级"而不是"快速失败"------这是 Android Framework 的一贯风格(为了兼容性);
- 没有显式契约------静态变量、隐式作用域、隐式阻塞,都是"约定"而非"契约";
- 都能被一条工程规范防住。
对应的四条防御:
| 防御 | 落地方式 |
|---|---|
| 让错误尽早暴露 | 不用 NoOp 兜底;check() 断言;启动自检 |
| 让行为可观测 | 崩溃处理器落盘;切面打日志;卡顿探针 |
| 让作用域显式 | ViewModel 命名带 Shared;Fragment 切换不用 replace+复用 |
| 让规则进 CI | 禁止 runBlocking;禁止静态传值;依赖方向检查 |
如果只记一句:每次把数据、时间、对象"临时放在某处"时,先问一句"谁负责清它"。 答不上来的那个地方,就是下一次线上问题的位置。
结论
- Binder 事务上限约 1 MB,且是进程级共享的 ------大列表不要走 Intent,
TransactionTooLargeException只是它最响的一种表现。 - 静态变量不是"绕过限制",是"把限制换成静默失败"------进程重建丢数据、内存泄漏、流程互相覆盖、无法测试,四个问题一个都不少。
- 传数据的正解是传引用:只传 ID > 共享 Repository + token > 共享 ViewModel > URI / 文件中转,按数据量和改造成本选。
- 数据失效必须可检测:token 取不到就明确提示"请重新选择",而不是静默返回空列表。
runBlocking的语义是"阻塞当前线程" ,Dispatchers.IO只改变协程体在哪跑,不会让调用者不阻塞。- 主线程只允许"挂起"或"回调",不允许"等待"------输入事件的 ANR 阈值是 5 秒。
- ViewModel 的唯一判据是
ViewModelStoreOwner:同一个 store、同一个 key 才是同一个实例,by viewModels()和activityViewModels()拿到的可能完全是两个对象。 - 普通类拿 ViewModel 必须显式声明作用域 :要么自己实现
ViewModelStoreOwner,要么在注释里写清"它活得比使用者久"。 - 三条边界的通用问法:这块数据、这段时间、这个对象,归谁所有,谁来释放,失效时谁知道。答不上来就别写。
这三个问题我在工程里都遇到过,修完之后回头看,真正省事的做法反而是最开始就把边界想清楚。如果你也踩过"数据没传过来""点了没反应""改了不生效"这三类现象,欢迎在评论区说说当时的排查路径------尤其是最后是怎么定位到边界的,这部分经验比结论更有用。
关键词标签 :#Android #Binder #ANR #ViewModel #协程 #性能优化 #架构设计