【探秘 Jetpack Compose 框架深水区】 第8课:rememberSaveable 的边界在哪里?——Bundle、Saver 与什么值得跨进程存活

摘要 :本文围绕 rememberSaveable 的四层边界展开:一是生命周期边界,remember、retain、rememberSaveable、rememberSerializable 覆盖四段不同的存活窗口,只有 retain 保证返回同一实例;二是 Bundle 的类型白名单与 50KB 容量上限,autoSaver 只接受原始类型与 Parcelable / Serializable,超限会触发 TransactionTooLargeException;三是 Saver 的职责是"简化与重建"而非"序列化一切",save / restore 返回 null 都是合法语义,且 restore 同步执行、不发起数据加载;四是恢复只承诺"等价"而非"同一",锚点是组合身份而非自定义 key,inputs 只负责"输入变化时重新初始化"。文章进一步区分状态恢复与状态持久化,指出 rememberSaveable 属于 UI 层、SavedStateHandle 属于 ViewModel 层,并说明非 Android 平台上"保存"语义的差异------不做序列化、不保证跨重启存活,失败模式从崩溃转为内存压力。最后给出七问决策框架与 12 道边界检验习题,核心结论是:rememberSaveable 不是"更强的 remember",而是一个有严格约束的临时恢复通道,知道什么不该放进去,比知道怎么放进去更重要。

目录

  • 前言
  • [一、生命周期地图:四个 API 覆盖四段不同的存活窗口](#一、生命周期地图:四个 API 覆盖四段不同的存活窗口 "#%E4%B8%80%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E5%9C%B0%E5%9B%BE%E5%9B%9B%E4%B8%AA-api-%E8%A6%86%E7%9B%96%E5%9B%9B%E6%AE%B5%E4%B8%8D%E5%90%8C%E7%9A%84%E5%AD%98%E6%B4%BB%E7%AA%97%E5%8F%A3")
  • [二、边界一:Bundle 的类型白名单与容量上限](#二、边界一:Bundle 的类型白名单与容量上限 "#%E4%BA%8C%E8%BE%B9%E7%95%8C%E4%B8%80bundle-%E7%9A%84%E7%B1%BB%E5%9E%8B%E7%99%BD%E5%90%8D%E5%8D%95%E4%B8%8E%E5%AE%B9%E9%87%8F%E4%B8%8A%E9%99%90")
  • [三、边界二:Saver 的职责是"简化与重建",不是"序列化一切"](#三、边界二:Saver 的职责是“简化与重建”,不是“序列化一切” "#%E4%B8%89%E8%BE%B9%E7%95%8C%E4%BA%8Csaver-%E7%9A%84%E8%81%8C%E8%B4%A3%E6%98%AF%E7%AE%80%E5%8C%96%E4%B8%8E%E9%87%8D%E5%BB%BA%E4%B8%8D%E6%98%AF%E5%BA%8F%E5%88%97%E5%8C%96%E4%B8%80%E5%88%87")
  • 四、边界三:恢复是"等价",不是"同一";恢复锚点是组合身份
    • [rememberSaveable 的两个参数:inputs 和已弃用的 key](#rememberSaveable 的两个参数:inputs 和已弃用的 key "#remembersaveable-%E7%9A%84%E4%B8%A4%E4%B8%AA%E5%8F%82%E6%95%B0inputs-%E5%92%8C%E5%B7%B2%E5%BC%83%E7%94%A8%E7%9A%84-key")
    • [SaveableStateRegistry 的注册与恢复周期](#SaveableStateRegistry 的注册与恢复周期 "#savablestateregistry-%E7%9A%84%E6%B3%A8%E5%86%8C%E4%B8%8E%E6%81%A2%E5%A4%8D%E5%91%A8%E6%9C%9F")
  • 五、边界四:状态恢复不是状态持久化
  • [六、跨平台边界:非 Android 平台上,"保存"不是同一件事](#六、跨平台边界:非 Android 平台上,“保存”不是同一件事 "#%E5%85%AD%E8%B7%A8%E5%B9%B3%E5%8F%B0%E8%BE%B9%E7%95%8C%E9%9D%9E-android-%E5%B9%B3%E5%8F%B0%E4%B8%8A%E4%BF%9D%E5%AD%98%E4%B8%8D%E6%98%AF%E5%90%8C%E4%B8%80%E4%BB%B6%E4%BA%8B")
  • [七、决策框架:什么状态应该进入 rememberSaveable](#七、决策框架:什么状态应该进入 rememberSaveable "#%E4%B8%83%E5%86%B3%E7%AD%96%E6%A1%86%E6%9E%B6%E4%BB%80%E4%B9%88%E7%8A%B6%E6%80%81%E5%BA%94%E8%AF%A5%E8%BF%9B%E5%85%A5-remembersaveable")
  • [八、习题:12 道保存与恢复边界检验](#八、习题:12 道保存与恢复边界检验 "#%E5%85%AB%E4%B9%A0%E9%A2%9812-%E9%81%93%E4%BF%9D%E5%AD%98%E4%B8%8E%E6%81%A2%E5%A4%8D%E8%BE%B9%E7%95%8C%E6%A3%80%E9%AA%8C")
  • 九、本节课知识点总结
  • 十、下期预告

前言

一个需要先放在桌面上的结论:rememberSaveable 不是"更持久的 remember"。它是一个有类型白名单、有容量上限、恢复时只承诺"等价"而非"同一"的临时恢复通道。它的能力来自 SavedStateRegistry 与 Bundle,而这两者的边界------类型、容量、恢复语义、持久化能力------反过来决定了什么状态有资格进入恢复流程。

第3课讲 remember,回答"值如何在重组之间存活"。第4课讲 effect,回答"动作拴在哪个生命周期锚点上"。第5课讲组合身份,回答"这个锚点本身由什么定义"。第6、7课分别处理"派生值的过滤"和"读取发生的阶段"。这几课有一个共同前提:状态活在组合的生命周期里,组合还在,状态就在。

但组合会消失。Activity 会因为配置变更被销毁重建,进程会在后台被系统回收。remember 在这两种事件面前无能为力。rememberSaveable 就是 Compose 用来跨过这两种事件的 API。它的能力来自 SavedStateRegistry,而这条机制本身有明确的边界:存什么、存在哪、能存多大、恢复时拿到的是什么。

这一课按这四层边界依次拆开。


一、生命周期地图:四个 API 覆盖四段不同的存活窗口

在讨论 rememberSaveable 之前,需要先知道它在整个状态生命周期谱系里的位置。官方文档明确列出了四个 API,它们的生命周期语义严格区分,不能互换。

API 跨重组 跨配置变更 跨进程死亡 恢复后的对象身份
remember 是 否 否 同一实例
retain 是 是 否 同一实例(===)
rememberSaveable 是 是 是 等价对象(==),可能是副本
rememberSerializable 是 是 是 等价对象(==),可能是副本

四个 API 的存活窗口关系可以用一张图概括:

flowchart LR A["remember"] --> B["跨重组"] C["retain"] --> D["跨重组 + 跨配置变更"] E["rememberSaveable"] --> F["跨重组 + 跨配置变更 + 跨进程死亡"] G["rememberSerializable"] --> F B --> H["同一实例"] D --> H F --> I["等价对象(==),可能是副本"]

remember 绑定组合位置,组合一旦被丢弃,值就没了。retain 跨配置变更存活,进程死亡时丢失,始终返回同一实例(===)。rememberSaveable / rememberSerializable 跨进程死亡存活,但返回等价对象,可能是反序列化副本。

rememberSaveable 和 rememberSerializable 的存活窗口相同,区别在序列化机制:前者依赖 Bundle(原始类型、Parcelable、Serializable),后者依赖 kotlinx.serialization 的 @Serializable,适合跨平台场景下的自定义类型。但它们的恢复语义完全一致------都是"等价而非同一"。

这条身份语义的区分极其重要:rememberSaveable 不承诺对象身份,只承诺值相等。任何依赖 === 的比较、IdentityHashMap 键、或者"注册与注销必须同一个实例"的逻辑,在恢复后都可能失效。retain 是唯一一个能保证"还是那个对象"的选项。rememberSaveable 的存储介质是 Bundle------它不是实现细节,而是边界本身。后面三节的所有限制,都来自这个介质。

一句话记住这一节:remember 管重组,retain 管配置变更,rememberSaveable 管进程死亡;但 rememberSaveable 的"管"是通过 Bundle 实现的,而 Bundle 有自己的容量、类型与身份规则。


二、边界一:Bundle 的类型白名单与容量上限

rememberSaveable 默认使用 autoSaver。官方文档的表述是:如果你使用的类型可以存入 Bundle,它就会通过 autoSaver 自动保存和恢复;否则需要提供自定义 Saver。

类型白名单

autoSaver 不是"万能序列化器"。官方把它的内置能力限定得很清楚:原始数据类型,以及实现了 Parcelable 或 Serializable 的对象 。一个普通的 data class 如果不实现 Parcelable 或 Serializable,autoSaver 不会替你序列化它------它没有"反射推断字段"的能力,也不会悄悄调用 toString() 再解析。你必须显式提供 Saver,或者用 @Parcelize / @Serializable 让它进入白名单。

SaverScope.canBeSaved 是这条白名单的公开检查接口。它允许 Saver 的实现者在 save 之前检查某个值是否能被当前注册表保存;如果返回 false,Saver 可以选择返回 null(不保存),而不是在运行时抛出异常。这个接口把"类型不合适"从运行时崩溃转化为可预期的降级------保存失败时,rememberSaveable 会重新初始化,而不是让应用挂掉。

Parcelable 和 Serializable 的选择本身就是一条边界。Parcelable 走 Android 的 Parcel 序列化,效率高,但只能在 Android 上使用;Serializable 走 Java 序列化,跨平台,但性能差、容易踩坑(序列化 UID、版本不匹配问题)。如果一段代码需要跨平台,rememberSerializable + @Serializable 是更安全的选择;如果只在 Android 上运行,Parcelable 或 @Parcelize 是更好的路径。类型的可移植性和恢复的健壮性,与平台绑定,不能跨平台无脑复用同一套假设。

容量上限

比类型白名单更隐蔽的边界是容量。官方文档在讲 Parcelable 和 Bundle 时明确给出建议:保存的状态应保持在 50KB 以下 ;Bundle 底层走 Binder 事务,传输过多数据会抛出 TransactionTooLargeException。rememberSaveable 的状态和 onSaveInstanceState() 写入的是同一个 Bundle,所以这条限制对它完全适用。

一个具体的越界案例:有开发者用 rememberSaveable 承载 Bitmap,在启动 Activity 时抛出 TransactionTooLargeException;切换到 remember 后问题消失,因为 Bitmap 根本不进入 Bundle。这不是 rememberSaveable 的 bug,而是它的介质边界:Bundle 是为小型、可重建的 UI 状态设计的,不是为大数据或二进制资源设计的。

这条边界可以总结为:rememberSaveable 能保存什么,由 Bundle 的类型系统和大小限制共同决定。类型不在白名单里,需要 Saver;数据超过建议线,就不应该进入这个通道。


三、边界二:Saver 的职责是"简化与重建",不是"序列化一切"

Saver 的接口定义很短,但它的语义比"序列化器"窄得多。官方定义是:Saver 描述 Original 类的对象如何被简化并转换 为可以保存的东西。它有两个方法:save 把原对象转换成 Saveable,restore 把 Saveable 转换回原对象。

Saver 的边界体现在四个地方。

第一,save 返回 null 意味着"这个值不保存"。 官方文档明确:如果 save 返回 null,这个值不会被保存。这不是错误处理机制,而是 Saver 的合法语义:某些对象在某些条件下本来就不应该进入保存通道。

第二,restore 返回 null 意味着"放弃恢复,重新初始化"。 官方 API 文档的原话是:如果 restore 返回 null,值不会被恢复,而是被重新初始化 。这意味着 Saver 的恢复不是"尽力而为",而是"要么完整恢复,要么干净地放弃"------没有中间状态。这个语义之所以重要,是因为它允许 Saver 在保存格式不兼容时选择"重新开始",而不是恢复一个半损坏的对象。这正是 Saver 与通用序列化器的分界线:序列化器通常要求向后兼容,Saver 允许显式地放弃。

第三,Saver 只应该保存"重建所需的最小信息",而不是对象本身。 官方示例 HolderSaver 把 Holder(value: Int) 保存为 Int,恢复时再 Holder(it) 重建。这个模式的关键不是"怎么写 Saver",而是"保存的是重建配方,不是对象快照"。

kotlin 复制代码
// 错误:把对象原样当 Saveable,没有简化,也没有解决类型问题
val BadSaver = Saver<Session, Session>(save = { it }, restore = { it })

// 正确:只保存重建所需的最小标识
val SessionSaver = Saver<Session, String>(
    save = { it.token },
    restore = { token -> Session(token = token) }
)

第四,restore 是同步的,它只还原结构,不发起数据加载。 上面正确示例里的 restore 只构造了一个 Session(token = token),没有去 loadUser() 或 loadPermissions()。这不是偷懒,而是 Saver 的契约:restore 在组合恢复过程中同步执行,它不能挂起,也不应该发起网络请求或磁盘 I/O。真正的数据补全应该发生在恢复之后的 effect 或 ViewModel 初始化中。

这条边界经常被越界:开发者把 Saver 当成"反序列化器",在 restore 里调用 repository.load(),然后发现 restore 不是挂起函数,或者即使强行用 runBlocking,也会阻塞组合恢复。Saver 的职责边界是"结构还原",不是"数据加载"。

listSaver 和 mapSaver 是两种常见的 Saver 构造方式,边界与手写 Saver 相同:列表或映射中的每个值都必须在 Bundle 的白名单内,整个结构的大小仍然受 Bundle 容量约束;mapSaver 的键必须是 String,值必须是可保存类型,它不会帮你做嵌套序列化。

这条边界可以总结为:Saver 的职责是"把对象简化为可保存的重建配方",不是"把对象完整地序列化进去",也不是"在恢复时把数据也加载好"。它的成功路径是 restore 还原出一个结构可用的等价对象;它的失败路径是 null,干净地放弃,让 init 重新开始。

Saver 的职责边界可以用一张图表示:

flowchart LR A[&#34;Original 对象&#34;] -->|&#34;save&#34;| B{&#34;能否简化?&#34;} B -->|&#34;能&#34;| C[&#34;Saveable(Bundle 白名单内)&#34;] B -->|&#34;不能&#34;| D[&#34;返回 null:不保存&#34;] C --> E[&#34;写入 Bundle&#34;] E --> F[&#34;restore(同步,只还原结构)&#34;] F --> G{&#34;能否重建?&#34;} G -->|&#34;能&#34;| H[&#34;等价对象&#34;] G -->|&#34;不能&#34;| I[&#34;返回 null:重新初始化&#34;] H --> J[&#34;数据补全交给 effect / ViewModel&#34;]

四、边界三:恢复是"等价",不是"同一";恢复锚点是组合身份

rememberSaveable 的恢复语义有一个容易被忽视的精确边界:它返回的是等价对象,可能是反序列化副本,而不是原来的实例。官方状态生命周期表把这一点写得很清楚:retain 始终返回相同的 === 实例,而 rememberSaveable / rememberSerializable 返回等效的 == 对象,可能是反序列化的副本。

这条边界在实践中意味着两件事。

第一,不要用引用相等来依赖恢复后的对象。 如果一个 MutableState 里存的是一个自定义对象,恢复后 state.value === original 可能为 false,但 state.value == original 应该为 true(前提是你的 equals 和 Saver.restore 正确)。任何依赖对象身份的缓存、监听器注册、或者 IdentityHashMap 键,都会在恢复后失效。

第二,恢复的"锚点"是组合位置,不是你传入的 key。 rememberSaveable 的保存和恢复由 SaveableStateRegistry 管理,值的键默认来自它在组合树中的位置(currentCompositeKeyHash)。这意味着:如果这个 Composable 在恢复后的组合树中处于不同的位置,或者被包在 key(...) 变化导致重建的子树里,rememberSaveable 找不到对应的保存值,会重新执行 init。恢复不是"按名字查找",而是"按组合身份匹配"------这和第5课的组合身份边界是同一套机制。

rememberSaveable 的两个参数:inputs 和已弃用的 key

rememberSaveable 有两个容易混淆的参数。inputs 的语义是"当这些值变化时,重新初始化",它类似 remember(key) 的 key 参数,但保存/恢复的锚点仍然是组合位置,不是 inputs 的值。key 是显式的字符串保存键,它已被弃用,因为它绕过位置作用域,导致状态 bug 和不一致行为(例如意外的状态共享或丢失,以及嵌套 LazyLayout 中的问题)。

这条区分的实践含义是:如果你想让"输入变化时状态重置",用 inputs;如果你想让"状态在不同的组合位置之间共享",不要用 key------那条路已经被关闭了。

SaveableStateRegistry 的注册与恢复周期

SaveableStateRegistry 的生命周期可以粗略分为四个阶段:注册(registerProvider)、保存(performSave)、恢复(consumeRestored)、清除(unregister)。rememberSaveable 在进入组合时向注册表注册一个 provider,provider 负责把当前值转换成 Saveable;离开组合时注销,但注册表中可能仍保留旧值(直到 performSave 或状态被替换)。恢复时,SaveableStateRegistry 把 Bundle 中的值反序列化,供新组合的 rememberSaveable 按组合位置消费。

这条周期发生在 onSaveInstanceState 触发的时刻------它在 onPause 之后、onStop 之前被调用,是系统在可能杀死进程之前给应用的最后机会。理解这个时机,就能理解为什么 Bundle 是"临时救生包"而不是"持久化仓库":它不是由你调用的,而是由系统在特定的生命周期节点调用的。

这条周期的关键推论是:保存和恢复不是"对象搬家",而是"值通过 Bundle 在两次组合之间做一次序列化旅行"。 旧组合中的对象被序列化成 Bundle 中的原始值/Parcelable/Serializable;新组合中的 rememberSaveable 从 Bundle 中读回这些值,用 Saver.restore 重建对象。两次组合之间没有任何对象引用被保留(同一进程内的情形除外,但语义上仍然以"值"为准)。理解这一点,就能理解为什么"身份"在恢复后不成立。

整个保存与恢复过程可以画成一条时间线:

flowchart TD A[&#34;旧组合:rememberSaveable 注册 provider&#34;] --> B[&#34;onSaveInstanceState 触发(onPause 之后、onStop 之前)&#34;] B --> C[&#34;performSave:Saver.save 把值转成 Saveable&#34;] C --> D[&#34;写入 Bundle(类型白名单 + 50KB 建议线)&#34;] D --> E[&#34;进程可能被杀死 / Activity 重建&#34;] E --> F[&#34;新组合:SaveableStateRegistry 反序列化 Bundle&#34;] F --> G[&#34;consumeRestored:按组合位置匹配&#34;] G --> H{&#34;Saver.restore 返回 null?&#34;} H -->|&#34;否&#34;| I[&#34;重建等价对象(==)&#34;] H -->|&#34;是&#34;| J[&#34;放弃恢复,重新执行 init&#34;]

五、边界四:状态恢复不是状态持久化

这一节是本课最深的一条边界。rememberSaveable 的 Bundle 是临时存储:系统在 Activity 可能返回期间保留它,但它不保证跨应用重启存活,也不适合存储业务数据。而持久化存储(数据库、DataStore、文件)是另一回事。

官方文档在讲状态保存时给出了使用建议:至少应该保留用户输入和导航相关的状态,例如列表滚动位置、用户想查看详情的条目 ID、进行中的偏好选择、文本字段输入。这些状态的共同特征是:小、无法重新创建、丢失会直接损害用户体验 。它们适合 rememberSaveable。

反过来,搜索结果、网络响应、大列表数据这些"可以根据条件重新加载"的内容,不应该进入 Bundle。一个搜索页面的工程实践模式是:让 SavedStateHandle 成为可恢复输入的单一事实来源(查询条件、筛选条件),恢复后根据条件重新加载结果,而不是把结果本身塞进保存状态。这样既避免了 Bundle 膨胀,也避免了数据过期。

SavedStateHandle 和 rememberSaveable 的分工可以这样概括:两者都走 Bundle 机制,但 SavedStateHandle 属于 ViewModel 层,适合"可恢复输入"和"业务状态的种子";rememberSaveable 属于 UI 层,适合"纯 UI 元素状态"(展开/折叠、文本输入、滚动位置)。把业务数据塞进 rememberSaveable,是用 UI 层的临时介质解决数据层的持久化问题,越过了架构边界。

一句话记住这条边界:Bundle 是给"系统可能马上把你的 Activity 叫回来"准备的救生包,不是给你的应用数据准备的仓库。救生包装的是水和信号弹,不是行李箱。


六、跨平台边界:非 Android 平台上,"保存"不是同一件事

Compose Multiplatform 让 rememberSaveable 出现在 common API 中,但它的语义在非 Android 平台上并不同构。

在非 Android 平台(Desktop、iOS、Web),没有系统提供的 saved instance state 机制。rememberSaveable 在那些平台上的行为是:值按原样保存在内存里,不做序列化;任何类型都能存,不需要 Saver;保存的值在被清除前一直被引用;但不提供磁盘序列化,也不保证跨应用重启存活 。SavedStateHandle 在非 Android 平台上同样只是内存中的。

这条差异的实践后果是:Android 上的 Bundle 约束(类型白名单、50KB 建议线)是真实存在的;Desktop/iOS 上这些约束不存在,但这不意味着你可以随意往里放大对象------值会一直驻留在内存中,直到被清除,大对象同样会带来内存压力,而且它不会在应用重启后恢复。

更根本的差异在于失败模式 :Android 上超限会抛 TransactionTooLargeException,是可以在测试中复现的硬边界;非 Android 平台上不会抛这个异常,问题从崩溃转移到了内存压力------代价没有消失,只是变得更隐蔽。跨平台代码不能假设"rememberSaveable 在所有平台上都等价于持久化"。

这也解释了 rememberSerializable 在 KMP 中的价值:它用 kotlinx.serialization 替代 Bundle,让自定义类型在 Android 和非 Android 平台上有一致的序列化路径,但它的恢复语义仍然是"等价而非同一",且非 Android 平台上仍然不提供磁盘持久化。


七、决策框架:什么状态应该进入 rememberSaveable

第一问:这个状态丢失后,用户是否会明显感知? 用户输入、滚动位置、展开/折叠状态、导航 ID------会。网络响应、缓存列表、大对象------不会,它们可以重建或重新加载。

第二问:它能在合理时间内从其他数据重建吗? 能重建的,不进入 Bundle;不能重建的(用户还没提交的输入),才需要保存。

第三问:它的类型能进入 Bundle 吗? 原始类型和 String 直接进;Parcelable / Serializable 可以进;普通 data class 需要 Saver、@Parcelize 或 rememberSerializable + @Serializable;包含大二进制数据或复杂对象图的状态,根本不应该进入这个通道。

第四问:它的大小是否在安全线内? 官方建议保存状态保持在 50KB 以下。超过这条线,考虑把"重建配方"而不是"数据本身"放进 Bundle,或者把它上移到 ViewModel / 持久化层。

第五问:恢复后的对象身份是否被依赖? rememberSaveable 恢复的是等价对象,不是同一实例。如果代码依赖 === 或对象身份做键,要么改用 retain(如果只需要跨配置变更),要么把身份敏感的逻辑移出保存状态。

第六问:这个状态是 UI 元素状态,还是业务数据? UI 元素状态(文本输入、展开状态、滚动位置)适合 rememberSaveable;业务数据(搜索结果、领域对象)应该走 ViewModel + SavedStateHandle 的"可恢复输入 + 重新加载"模式。

第七问:这个状态是否跨平台? 如果代码需要在 Android 和其他平台共享,Saver 的选择不能只考虑 Android 的 Parcelable;rememberSerializable + @Serializable 的可移植性更好,但要接受它更脆弱的恢复语义和额外的序列化插件依赖。跨平台代码要显式处理"这个状态在 Android 上有 Bundle 约束,在其他平台上没有"这一事实。

七问决策框架可以整理成一条判断链:

flowchart TD A[&#34;一个状态&#34;] --> B{&#34;丢失后用户可感知?&#34;} B -->|&#34;否&#34;| Z[&#34;不保存,可重建&#34;] B -->|&#34;是&#34;| C{&#34;能从其他数据重建?&#34;} C -->|&#34;能&#34;| Z C -->|&#34;不能&#34;| D{&#34;类型能进 Bundle?&#34;} D -->|&#34;否&#34;| E[&#34;提供 Saver / @Parcelize / rememberSerializable&#34;] E --> F{&#34;大小在 50KB 内?&#34;} D -->|&#34;是&#34;| F F -->|&#34;否&#34;| G[&#34;上移 ViewModel / 持久化层&#34;] F -->|&#34;是&#34;| H{&#34;依赖对象身份?&#34;} H -->|&#34;是&#34;| I[&#34;改用 retain 或移出保存状态&#34;] H -->|&#34;否&#34;| J{&#34;UI 状态还是业务数据?&#34;} J -->|&#34;业务数据&#34;| K[&#34;SavedStateHandle + 重新加载&#34;] J -->|&#34;UI 状态&#34;| L{&#34;是否跨平台?&#34;} L -->|&#34;是&#34;| M[&#34;rememberSerializable + @Serializable&#34;] L -->|&#34;否&#34;| N[&#34;rememberSaveable&#34;]

八、习题:12 道保存与恢复边界检验

第 1 题(选择)

下列关于 rememberSaveable 与 remember 区别的说法,正确的是: A. rememberSaveable 是 remember 的性能优化版本 B. rememberSaveable 在 Activity 重建和进程死亡后恢复,remember 不能 C. rememberSaveable 返回的始终是同一个对象实例 D. rememberSaveable 可以保存任意类型,不需要 Saver

参考答案:B。

解读: 官方状态生命周期表明确:remember 在 Activity 重建和进程死亡后都不存活,rememberSaveable 在两者之后都能恢复。A 错误:rememberSaveable 有额外的序列化和 Bundle 开销,不是性能优化。C 错误:rememberSaveable 返回的是等价(==)对象,可能是反序列化副本,不保证 ===;保证同一实例的是 retain。D 错误:autoSaver 只接受能存入 Bundle 的类型,其他类型需要显式 Saver。

第 2 题(判断)

rememberSaveable 通过 Bundle 存储状态,因此它可以安全地保存任意大小的对象,只要实现了 Parcelable。

参考答案:错。

解读: Parcelable 只解决了"类型能否进入 Bundle",没有解决"容量是否允许"。Bundle 底层走 Binder 事务,官方明确建议保存状态保持在 50KB 以下,存储大型对象可能导致 TransactionTooLargeException。Bitmap 可以实现 Parcelable,但把它放进 rememberSaveable 仍然是越界。

第 3 题(代码分析)

下面代码有什么问题?

kotlin 复制代码
@Composable
fun UserProfile(user: User) {
    var selectedTab by rememberSaveable { mutableStateOf(0) }
    var cachedPosts by rememberSaveable { mutableStateOf(listOf<Post>()) }
}

参考答案: cachedPosts 越界。Post 如果不是 Parcelable 或 Serializable,autoSaver 无法保存;即使可以保存,一个帖子列表通常是大数据,进入 Bundle 会带来容量风险和 TransactionTooLargeException 隐患。cachedPosts 是"可以重新加载"的数据,应该留在 ViewModel,需要时从 Repository 重新加载。selectedTab 是纯 UI 元素状态,适合 rememberSaveable。

解读: 这道题考的是"UI 元素状态 vs 业务数据"的边界。一个搜索页面的标准模式是:保存查询条件,恢复后重新加载结果,而不是保存结果本身。

第 4 题(简答)

Saver 的 restore 返回 null 时,rememberSaveable 的行为是什么?这个语义与 save 返回 null 有什么不同?

参考答案: restore 返回 null 意味着"放弃恢复",rememberSaveable 不会使用保存的值,而是重新执行 init 来初始化状态。save 返回 null 意味着"这个值不保存",它不会进入 Bundle。两者的区别在于方向:save 的 null 是"不写入",restore 的 null 是"不读取、重新开始"。restore 的 null 不是错误,它是 Saver 的合法语义------当保存的数据无法重建出有效对象时,干净地放弃比恢复一个半损坏的对象更安全。

解读: 很多开发者把 Saver 当成"序列化器",认为 restore 应该总是返回一个对象;但官方的设计允许 restore 返回 null,并且把它定义为"重新初始化"。这意味着 Saver 不需要为所有历史数据保证向后兼容:如果旧格式无法恢复,返回 null 让 init 重新开始,比抛异常或恢复错误数据更符合契约。

第 5 题(判断)

rememberSaveable 恢复后的对象,可以安全地用 === 与保存前的对象比较。

参考答案:错。

解读: 官方状态生命周期表明确:rememberSaveable 返回的是等效(==)对象,可能是反序列化的副本;只有 retain 保证始终返回同一(===)实例。任何依赖对象身份做键的代码在恢复后都可能失效。正确的做法是依赖值相等(equals/hashCode),或者把身份敏感的逻辑移出保存状态。

第 6 题(简答)

为什么带自定义 key 参数的 rememberSaveable 重载被弃用?inputs 参数和已弃用的 key 参数有什么区别?

参考答案: 官方 API 文档的弃用说明是:'rememberSaveable' with a custom 'key' is no longer supported。自定义 key 会绕过位置作用域,导致状态 bug 和不一致行为。inputs 参数的语义是"当这些值变化时,重新初始化",它类似 remember(key) 的 key,但保存/恢复的锚点仍然是组合位置;key 参数是显式的字符串保存键,它直接覆盖了位置锚点,因此被弃用。移除 key 之后,恢复的锚点只能是组合位置(currentCompositeKeyHash)。

解读: 这与第5课的结论完全一致:rememberSaveable 的"位置记忆化"和 remember 共享同一套组合身份逻辑;key(userId) 包裹的子树会在 userId 变化时被重建,保存值也随之重置。inputs 不破坏这个锚点,它只是在锚点内部增加了"输入变化触发重新初始化"的语义。

第 7 题(代码分析)

下面 Saver 有什么问题?如果要把 Session 的保存改对,应该怎么写?

kotlin 复制代码
data class Session(val token: String, val user: User, val permissions: List<String>)

val SessionSaver = Saver<Session, Session>(
    save = { it },
    restore = { it }
)

参考答案: Saver 试图把 Session 原样保存为 Session,但 Session 本身不是 Bundle 支持的类型(包含 User 和 List<String>,除非 User 也实现了 Parcelable / Serializable)。save 返回的对象必须能被 SaveableStateRegistry 接受;如果 Session 不在白名单里,save 的结果无法被保存。正确方向是"简化":

kotlin 复制代码
val SessionSaver = Saver<Session, String>(
    save = { it.token },
    restore = { token -> Session(token = token) }
)

restore 只还原结构(Session(token = token)),不调用 loadUser() 或 loadPermissions()------restore 是同步的,不发起数据加载。真正的数据补全由恢复后的 effect 或 ViewModel 初始化完成。

解读: 这道题考的是 Saver 的核心语义:Saver 描述的是"如何简化并转换"为可保存的东西,不是"如何把对象标记为可保存"。Saver<Original, Saveable> 中的 Saveable 必须落在 Bundle 的白名单内;如果 Saveable 等于 Original 且 Original 不是 Bundle 兼容类型,这个 Saver 没有做任何简化,也没有解决类型边界问题。另外,Saver 只重建结构,不重建业务数据------这是它和"数据加载"之间的边界。

第 8 题(选择)

一个包含搜索输入框和结果列表的页面,在进程死亡后恢复时,下列哪种策略最符合 rememberSaveable 的边界? A. 把输入框文本和整个结果列表都放进 rememberSaveable B. 只把输入框文本放进 rememberSaveable,结果列表根据输入重新加载 C. 把所有状态都放进 ViewModel,完全不用 rememberSaveable D. 用 remember 保存输入框文本,因为 rememberSaveable 太复杂

参考答案:B。

解读: 输入框文本是用户输入,无法重建,体积小,适合 rememberSaveable;结果列表可以根据输入重新加载,不应该进入 Bundle。A 违反容量和"可重建"边界;C 忽略了纯 UI 元素状态在 UI 层保存的合理性,而且 ViewModel 不覆盖进程死亡(除非配合 SavedStateHandle);D 会让输入在配置变更和进程死亡后丢失。B 是官方推荐的分层策略。

第 9 题(判断)

rememberSaveable 在 Desktop 和 iOS 上不做序列化,因此在这些平台上可以安全地保存任意大的对象。

参考答案:错。

解读: 非 Android 平台上,rememberSaveable 的值按原样保存在内存里,不做序列化。但这不意味着可以安全地保存任意大的对象:值会一直驻留在内存中,大对象带来的是内存压力;而且这些值不会在应用重启后存活。更隐蔽的是:Android 上超限会抛 TransactionTooLargeException,是可以在测试中复现的硬边界;非 Android 平台上不会抛这个异常,问题从崩溃转移到了内存压力------代价没有消失,只是变得更隐蔽。

第 10 题(简答)

rememberSaveable 和 SavedStateHandle 都可以保存状态,它们的分工边界在哪里?

参考答案: rememberSaveable 属于 UI 层,适合保存纯 UI 元素状态(文本输入、展开/折叠、滚动位置),它的锚点是组合身份,恢复语义是"等价对象"。SavedStateHandle 属于 ViewModel 层,适合保存"可恢复输入"和业务状态的种子,它的生命周期跟随 ViewModel / 导航条目,而不是组合位置。两者都走 Bundle 机制,但分层不同。边界可以概括为:UI 元素状态归 rememberSaveable,可恢复的业务输入归 SavedStateHandle,大数据和可重建结果归 Repository 重新加载。

第 11 题(代码分析)

下面代码试图用 rememberSaveable 保存一个动画的当前进度,并依赖恢复后的对象身份做监听器注销。它有什么问题?

kotlin 复制代码
val progressState = rememberSaveable { mutableStateOf(0f) }
val listener = remember { ProgressListener { progressState.value = it } }

LaunchedEffect(Unit) { animator.addListener(listener) }
DisposableEffect(Unit) { onDispose { animator.removeListener(listener) } }

参考答案: progressState 是 mutableStateOf(0f),Float 在白名单内,rememberSaveable 可以保存和恢复它;恢复后是一个新的 MutableState 实例,值是等价的 Float,这本身没问题。问题在于:如果代码的某个部分依赖 progressState 或 listener 的对象身份(===)做注销或去重,恢复后身份可能不匹配。listener 是用 remember 创建的,进程死亡后它不会存活,animator 也不会存活,所以恢复后 LaunchedEffect 会重新注册新的 listener。真正需要警惕的是:如果 listener 被错误地用 rememberSaveable 保存,恢复后的 listener 是反序列化副本,animator.removeListener(副本) 可能无法注销原来的监听器。rememberSaveable 只适合保存值 ,不适合保存资源句柄、监听器、Animator 引用这类对象。

解读: rememberSaveable 的恢复语义是"值相等",不是"对象同一";任何依赖对象身份的注册/注销逻辑,都不应该把对象本身放进保存通道。监听器、Animator、CoroutineScope 这些对象也不应该跨进程保存------它们本身就是进程内的资源。

第 12 题(综合简答)

有人说:"rememberSaveable 就是 Compose 版的 onSaveInstanceState,把状态丢进去就行了。"评价这个说法,并说明一个更准确的状态保存策略应该包含哪些层次。

参考答案: 这个说法方向对,但过于简化。rememberSaveable 确实通过 Bundle 和 saved instance state 机制工作,但它的边界比 onSaveInstanceState 更具体:它只接受 Bundle 白名单内的类型(或通过 Saver 简化后的类型),有 50KB 的建议容量线,恢复时返回等价对象而非同一实例,并且在非 Android 平台上不做序列化。一个更准确的状态保存策略至少包含四层:(1)纯 UI 元素状态 (文本输入、展开状态、滚动位置)→ rememberSaveable;(2)可恢复的业务输入 (查询条件、筛选器)→ SavedStateHandle,恢复后重新加载数据;(3)可重建的缓存和结果 → ViewModel / Repository,不进入 Bundle;(4)需要跨应用重启的用户数据 → 持久化存储(DataStore、数据库)。

解读: rememberSaveable 的不可替代职责是:为纯 UI 元素状态提供一个与组合身份对齐的、跨配置变更和进程死亡的恢复通道,而不引入业务层的生命周期依赖。 理解"不该放什么",比知道"可以放什么"更能防止越界。


九、本节课知识点总结

生命周期层级。 remember 管重组;retain 管 Activity 重建,返回同一实例;rememberSaveable / rememberSerializable 管进程死亡,返回等价对象,可能是反序列化副本。rememberSaveable 的存储介质是 Bundle,它的所有约束都来自这个介质;rememberSerializable 用 kotlinx.serialization 替代 Bundle,适合跨平台自定义类型,但恢复语义相同。

Bundle 的类型与容量边界。 默认 autoSaver 只接受能存入 Bundle 的类型(原始类型、Parcelable / Serializable);SaverScope.canBeSaved 提供白名单检查接口;其他类型需要 Parcelable / Serializable,或 listSaver / mapSaver / 自定义 Saver。Bundle 走 Binder 事务,官方建议保存状态保持在 50KB 以下;大型对象可能导致 TransactionTooLargeException。

Saver 的职责边界。 Saver 是"简化与重建",不是"完整序列化",也不是"在恢复时加载数据"。save 返回 null 表示不保存;restore 返回 null 表示放弃恢复、重新初始化。Saver 应保存重建所需的最小信息,而不是对象快照;restore 是同步的,只还原结构,不发起数据加载。autoSaver 对 Parcelable 和 Serializable 的处理不同,跨平台场景下要注意可移植性与恢复健壮性的权衡。

恢复语义边界。 rememberSaveable 恢复的是等价(==)对象,不保证同一(===)实例。恢复的锚点是组合位置(currentCompositeKeyHash),不是自定义 key;带自定义 key 的重载已被弃用,因为它绕过位置作用域。inputs 参数用于"输入变化时重新初始化",它不改变位置锚点。SaveableStateRegistry 的注册、保存、恢复、清除周期发生在 onSaveInstanceState 触发的时刻(onPause 之后、onStop 之前),决定了"保存"本质上是"值通过 Bundle 做一次序列化旅行",两次组合之间没有对象引用被保留。

状态恢复 vs 状态持久化。 Bundle 是临时介质,不是持久化存储。UI 元素状态和可恢复输入适合保存;可重建的结果和大数据应通过 ViewModel / Repository 重新加载。SavedStateHandle 属于 ViewModel 层,rememberSaveable 属于 UI 层,两者的分工不能混用。

跨平台边界。 非 Android 平台上,rememberSaveable 的值按原样保存在内存中,不做序列化,不提供磁盘持久化,也不保证跨应用重启存活。Android 的 Bundle 约束在跨平台代码中不能被假设为"所有平台都同样存在";非 Android 平台上的失败模式不是崩溃,而是内存压力------代价没有消失,只是转移了。

决策框架。 七问:丢失是否可感知、能否重建、类型是否在 Bundle 白名单、大小是否在安全线内、是否依赖对象身份、是 UI 状态还是业务数据、是否跨平台。

一句话收束: 第3课回答"值如何在重组间存活",第4课回答"动作拴在哪儿",第5课回答"哪儿由什么定义",第6课回答"值如何派生、如何过滤",第7课回答"在哪一层读取"。这一课回答的是:一个值能活多久,取决于它被放进了哪个存储介质;而介质的边界------类型、容量、恢复语义、持久化能力------反过来决定了什么状态有资格进入恢复流程。 rememberSaveable 不是"更强的 remember",它是一个有严格约束的临时恢复通道。知道什么不该放进去,比知道怎么放进去更重要。


十、下期预告

第9课:LazyList 的边界在哪里?------组合、复用与"项状态不属于项"

第5课讨论了 key 和组合身份,第7课讨论了阶段和读取,第8课讨论了状态能活多久。LazyColumn / LazyRow 把这三个问题放在了一个动态集合里:列表项可以被回收、复用、重新绑定,一个项的组合身份可能在滚动中消失又出现。remember 在列表项里保存的状态,会在项被回收时丢失;rememberSaveable 在列表项里保存的状态,可能因为项被移出可见范围而被清除。LazyList 的 key 参数决定了项的身份能否在重排中保持,但它不保证项状态永远存活。

下一课拆三件事:Lazy 列表的回收与复用机制如何决定项状态的生命周期;rememberSaveable 在 Lazy 项中的恢复边界------LazySaveableStateHolder 的源码注释明确说,它只保存父注册表触发 performSave 时仍可见的项的状态 ,正是为了节省 Bundle 空间、避免 TransactionTooLargeException;以及"项状态不属于项"这条架构原则------当列表项的数据源变化时,状态应该归数据源所有,还是归组合位置所有。第8课讲的是"值能活多久、活在哪个介质里",第9课讲的是当一个值活在列表项的组合位置里,而列表项本身会被回收和复用,这个值的生命周期到底由谁定义。

相关推荐
Frankie_stand1 小时前
测试自动化实战指南:从脚本化、自动判据到 CI 集成的完整落地路径
android·ci/cd·自动化
恋猫de小郭2 小时前
Flutter 大小核优化又被回滚,然后又开新提案?
android·前端·flutter
Kapaseker2 小时前
领导问我 Kotlin 的 Nothing 是什么?
android·kotlin
韩曙亮3 小时前
【错误记录】安卓 adb 调试 5037 端口占用排查 ( adb 的三层结构 | 找出本机占用 5037 的进程 )
android·数据库·adb·安卓·错误记录
三少爷的鞋4 小时前
不改变异步本质,只改变代码组织:Telegram 项目的 Kotlin 协程实践
android
好的佩奇13 小时前
蓝牙之广播与扫描
android·flutter·蓝牙
传奇开心果编程15 小时前
【Jetpack Compose娓娓道来】第36课:焦点管理与键盘输入——让遥控器、键盘和输入法都“找得到北”
android·kotlin·移动开发·交互·android jetpack·ui框架·声明式ui
hai_android15 小时前
AudioTrack 深度解析:从原理到实战
android·java·kotlin
红尘伴蝶舞16 小时前
从秒开优化到连环崩溃:深入剖析 ViewPager2 在常驻 DialogFragment 中的复用陷阱与自愈方案
android·kotlin