摘要:本文围绕
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 覆盖四段不同的存活窗口
- 二、边界一:
Bundle的类型白名单与容量上限 - 三、边界二:
Saver的职责是“简化与重建”,不是“序列化一切” - 四、边界三:恢复是“等价”,不是“同一”;恢复锚点是组合身份
- 五、边界四:状态恢复不是状态持久化
- 六、跨平台边界:非 Android 平台上,“保存”不是同一件事
- 七、决策框架:什么状态应该进入
rememberSaveable - 八、习题:12 道保存与恢复边界检验
- 九、本节课知识点总结
- 十、下期预告
前言
一个需要先放在桌面上的结论: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”,而是“保存的是重建配方,不是对象快照”。
// 错误:把对象原样当 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["Original 对象"] -->|"save"| B{"能否简化?"}
B -->|"能"| C["Saveable(Bundle 白名单内)"]
B -->|"不能"| D["返回 null:不保存"]
C --> E["写入 Bundle"]
E --> F["restore(同步,只还原结构)"]
F --> G{"能否重建?"}
G -->|"能"| H["等价对象"]
G -->|"不能"| I["返回 null:重新初始化"]
H --> J["数据补全交给 effect / ViewModel"]
四、边界三:恢复是“等价”,不是“同一”;恢复锚点是组合身份
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["旧组合:rememberSaveable 注册 provider"] --> B["onSaveInstanceState 触发(onPause 之后、onStop 之前)"]
B --> C["performSave:Saver.save 把值转成 Saveable"]
C --> D["写入 Bundle(类型白名单 + 50KB 建议线)"]
D --> E["进程可能被杀死 / Activity 重建"]
E --> F["新组合:SaveableStateRegistry 反序列化 Bundle"]
F --> G["consumeRestored:按组合位置匹配"]
G --> H{"Saver.restore 返回 null?"}
H -->|"否"| I["重建等价对象(==)"]
H -->|"是"| J["放弃恢复,重新执行 init"]
五、边界四:状态恢复不是状态持久化
这一节是本课最深的一条边界。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["一个状态"] --> B{"丢失后用户可感知?"}
B -->|"否"| Z["不保存,可重建"]
B -->|"是"| C{"能从其他数据重建?"}
C -->|"能"| Z
C -->|"不能"| D{"类型能进 Bundle?"}
D -->|"否"| E["提供 Saver / @Parcelize / rememberSerializable"]
E --> F{"大小在 50KB 内?"}
D -->|"是"| F
F -->|"否"| G["上移 ViewModel / 持久化层"]
F -->|"是"| H{"依赖对象身份?"}
H -->|"是"| I["改用 retain 或移出保存状态"]
H -->|"否"| J{"UI 状态还是业务数据?"}
J -->|"业务数据"| K["SavedStateHandle + 重新加载"]
J -->|"UI 状态"| L{"是否跨平台?"}
L -->|"是"| M["rememberSerializable + @Serializable"]
L -->|"否"| N["rememberSaveable"]
八、习题: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 题(代码分析)
下面代码有什么问题?
@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 的保存改对,应该怎么写?
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 的结果无法被保存。正确方向是“简化”:
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 保存一个动画的当前进度,并依赖恢复后的对象身份做监听器注销。它有什么问题?
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课讲的是当一个值活在列表项的组合位置里,而列表项本身会被回收和复用,这个值的生命周期到底由谁定义。