Jetpack Compose 的 remember 到底在记什么
Jetpack Compose 的 remember 到底在记什么
Compose 1.0 刚发布那会儿,我把一个项目从 View 系统迁移过去,遇到的第一件怪事就是:为什么我的状态在屏幕旋转后全丢了?当时想当然地用了 remember { mutableStateOf(0) },转一下手机,计数归零。查文档才知道有 rememberSaveable,但这两个 API 的边界到底在哪,文档说得含糊。后来啃源码、跟了几个版本的更新,才发现 "remember" 这个词在 Compose 里是个多层结构,不是简单的"缓存一下"。
最基础的 remember:Slot Table 里的对象引用
Compose 运行时的核心数据结构叫 Slot Table,这是理解 remember 的入口。Compose 编译器会把你的 Composable 函数转换成一种类似"指令序列"的东西,运行时按这个序列填充 Slot Table。每个 Composable 调用对应一个 Group,Group 内部有固定数量的 slot 用来存数据。
remember { ... } 的本质,是在当前 Group 里占一个 slot,把 lambda 的返回值塞进去。下次重组(recomposition)时,如果 key 没变,就直接从 slot 里读,不再执行 lambda。
看源码位置(我跟踪的是 Compose Runtime 1.5.x 的版本):
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
currentComposer.cache(false, calculation)currentComposer.cache(invalid, calculation) 这个方法是关键。它检查当前 slot 的标记位,如果 invalid 为 false 且 slot 已有值,直接返回缓存值。这里的 "invalid" 由 Compose 运行时根据参数变化自动计算。
所以 remember 记住的是同一个 Composable 调用位置上的对象引用。屏幕旋转时 Activity 重建,整个 Composition 被 dispose 掉,Slot Table 没了,remember 的东西自然没了。这不是 bug,是设计如此。
但这里有个容易踩的坑:remember 的"位置敏感"特性。Compose 用源码位置(source location)来标识 Composable,如果你在同一个 Composable 里用条件分支动态决定 remember 的数量,比如:
@Composable
fun Demo(flag: Boolean) {
if (flag) {
val a = remember { mutableStateOf(1) }
} else {
val b = remember { mutableStateOf(2) }
}
}flag 切换时,Compose 的位置匹配算法可能会把 slot 1 的 state 当成 slot 2 的复用,导致状态错位。Compose 1.2 之后引入了 movable content 和更稳定的 group key 机制,但这种"条件 remember"的写法仍然不推荐。我在 1.3.0 版本确实遇到过列表过滤条件切换时状态串位的 bug,后来改成 remember 的 key 参数显式区分才解决。
remember 带 key 的版本:什么时候会"忘记"
remember(key1, key2) { ... } 这个重载很多人用,但 key 变化时的具体行为值得细看。
@Composable
fun UserProfile(userId: String) {
val userData = remember(userId) { fetchUser(userId) }
}当 userId 从 "A" 变成 "B" 时,Compose 会重新执行 lambda,但旧的值怎么处理?看 runtime 里的 CacheUpdater 实现,旧值会被标记为无效,新值写入 slot,但不会主动调用旧值的对象清理。如果你的 remember 返回的是持有资源的对象(比如 MediaPlayer、数据库连接),key 切换后旧对象只是没人引用、等 GC,没有确定性释放。
这是个实际问题。我在一个视频播放页面用 remember(videoId) 持有 ExoPlayer 实例,快速切换视频时内存暴涨,因为旧 ExoPlayer 没被释放。后来改用 DisposableEffect 或者自己管理 onDispose 逻辑。Compose 1.4 之后 remember 配合 androidx.compose.runtime.DisposableEffect 的文档更新过,但很多人(包括我)一开始没意识到 remember 本身不做生命周期管理。
还有个更隐蔽的点:key 的 equals 比较。Compose 用的是结构相等(structural equality),不是引用相等。对于自定义类型,如果你的 equals 实现有问题,remember 可能该刷新时不刷新,或者不该刷新时乱刷新。我踩过一次坑是用了一个 data class 做 key,但那个 class 里有个 List 字段,data class 生成的 equals 会深度比较 List 内容,导致频繁"刷新"。改成只比较 List 的 identity 才解决。
rememberSaveable:跨配置变更的边界
rememberSaveable 是 remember 的"持久化版",但它不是万能的。先看它的实现依赖:
@Composable
fun <T : Any> rememberSaveable(
vararg inputs: Any?,
stateSaver: Saver<T, out Any>,
key: String?,
init: () -> T
): T核心是它需要一个 Saver<T, out Any> 把对象转成 Bundle 能存的类型,以及一个 SaveableStateRegistry 来实际执行保存/恢复。这个 Registry 由 LocalSaveableStateRegistryOwner 提供,通常在 Activity/Fragment 的 ComponentActivity.setContent 里初始化。
关键限制:能存进 Bundle 的东西才能 saveable。Compose 自带的自动支持只有基础类型(Int、String、Boolean 等)、Parcelable、Serializable、以及 List/Map 的特定组合。自定义对象必须自己写 Saver。
我写过一个图表组件,状态是个复杂的数据类,包含 Offset(Compose UI 的几何类型)。Offset 在 Compose 1.0 时没有默认的 Saver,直接 rememberSaveable 会编译报错。后来看了 androidx.compose.ui 的源码,发现 Offset 的 Saver 是内部用的,没公开。自己写了一个:
val OffsetSaver = Saver<<Offset, List<Float>>(
save = { listOf(it.x, it.y) },
restore = { Offset(it[0], it[1]) }
)Compose 1.4.0 之后 Offset 等基础几何类型才陆续有了公开的 Saver。这种"能用但文档没说清楚"的状态在 Compose 早期很常见。
另一个踩坑点:rememberSaveable 的 key 是全局的。同一个 Composable 树里,如果两个 rememberSaveable 用了相同的 key(或者都传 null 导致默认 key 冲突),恢复时会互相覆盖。默认 key 是基于源码位置生成的,但如果在循环里用 rememberSaveable,位置相同,必须显式传 key:
items.forEach { item ->
val state = rememberSaveable(key = "item_${item.id}") { mutableStateOf(...) }
}我漏过一次 key,列表滚动后状态错乱,调试了很久才发现是 saveable key 冲突。
remember 与重组的微妙关系:derivedStateOf 的陷阱
Compose 有个 derivedStateOf,很多人和 remember 混着用。典型场景:
val filteredList = remember(items, query) {
items.filter { it.contains(query) }
}这种写法有问题。items 或 query 任一变化,remember 的 key 就变了,会重新执行 filter。但如果 items 很大、query 变化频繁(比如用户打字),每次重组都全量过滤很浪费。
derivedStateOf 的设计是解决这个问题:
val filteredList by remember {
derivedStateOf {
items.filter { it.contains(query) }
}
}注意 derivedStateOf 本身要包在 remember 里,否则每次重组都创建新的 derivedState 对象。derivedStateOf 内部会跟踪其读取的状态(这里是 items 和 query),但只有这些状态的值变化时才标记自己为脏,触发下游重组。
但这里有个版本差异。Compose 1.2 之前的 derivedStateOf 有个性能 bug:它每次都会重新计算 equality,对于大集合的 filter 结果,equals 比较本身就很重。1.3.0 之后引入了快照系统的优化,derived state 的变更检测更高效。我实际测试过(Compose 1.3.0 vs 1.2.1),一个 5000 条数据的列表过滤场景,快速输入 query 时的帧率从平均 42fps 提升到 58fps,这个提升主要来自 derivedStateOf 内部的 equality 短路优化。
不过 derivedStateOf 还有个容易忽视的约束:它只能在 Composition 里读取,不能拿到外面用。因为它的依赖跟踪依赖 Composer 的上下文。曾经有人想把它当普通 State 传给 ViewModel,结果依赖跟踪失效,状态不更新。
remember 的线程问题:Snapshot 系统的副作用
Compose 的状态系统基于 Kotlin 的 Snapshot 机制,这是多线程并发的基础。mutableStateOf 返回的 MutableState 在 Snapshot 里读写是线程安全的,但 remember 的 lambda 执行时机有讲究。
remember 的 lambda 在 Composable 的"测量/布局"阶段执行,这发生在主线程。但如果你在里面启动协程或者访问后台数据,要注意:
val data = remember {
mutableStateOf<List<Item>>(emptyList())
// 不要在这里 launch 协程!
}remember 的 lambda 应该只执行同步计算。异步初始化要用 LaunchedEffect 或 produceState:
val data by produceState<List<Item>>(initialValue = emptyList(), key1 = userId) {
value = repository.fetch(userId)
}produceState 内部用 remember 持有了 State 对象,但用 LaunchedEffect 管理协程生命周期,这是 Compose 推荐的异步状态模式。我在一个项目里错误地在 remember 里启动 viewModelScope.launch,结果 Composable 被快速移除又重建时,协程泄漏,因为 remember 没有对应的 dispose 钩子。
Compose 1.5 之后 rememberCoroutineScope() 提供了带生命周期管理的 scope,但文档强调这个 scope 的生命周期是 Composition 级别的,不是单个 remember 的。如果你的 remember key 变了但 Composition 还在,scope 不会自动取消旧协程。
自定义 remember:CompositionLocal 的隐性依赖
有时候需要封装自己的 remember 变体,比如带缓存策略的图片加载。Compose 提供了 compositionLocalOf 和 staticCompositionLocalOf 来传递隐式依赖。
val LocalImageLoader = compositionLocalOf<ImageLoader> { error("No ImageLoader provided") }
@Composable
fun rememberAsyncImage(url: String): ImageState {
val loader = LocalImageLoader.current
return remember(url, loader) {
// 用 loader 加载图片
}
}这里 LocalImageLoader.current 的读取会被 Compose 跟踪。如果上层 Provider 的值变了,所有读取过这个 Local 的 Composable 都会重组,即使 remember 的 key(url)没变。这是 CompositionLocal 的设计:它参与状态依赖图。
staticCompositionLocalOf 的区别在于,值变化时不会触发重组,而是要求 Composable 能处理"静态不变"的假设。我早期用 compositionLocalOf 传 Theme 配置,结果主题切换时整个界面重组,性能很差。后来把不常变的配置改成 staticCompositionLocalOf,重组范围缩小到只读动态值的部分。
但 static 的陷阱是:如果你假设它不变,却在运行时偷偷改了,读取的地方不会更新,导致状态不一致。Compose 的 MaterialTheme 内部就是混合使用两种 Local,颜色用 compositionLocalOf(支持动态主题),形状用 staticCompositionLocalOf(通常不变)。
remember 的内存泄漏:一个实际案例
最后说一个我花了一下午才定位的泄漏。场景是一个聊天界面,每条消息用 remember 缓存解析后的富文本:
@Composable
fun MessageItem(message: Message) {
val spanned = remember(message.id) {
parseRichText(message.content)
}
Text(text = spanned)
}看起来没问题,key 是 message.id,每条消息独立。但消息列表是 LazyColumn,item 复用时,旧的 Composable 被"丢弃",新的 message.id 传入。问题在于:remember 的值(SpannableString 对象)在 Slot Table 里被替换后,旧对象只是失去引用,但 SpannableString 里如果包含 ImageSpan 持有 Bitmap,Bitmap 的回收不确定。
更深层的问题是:LazyColumn 的 item 复用不是立刻 dispose 对应的 Composition node,而是有一个缓冲池。Compose 1.4 之前这个缓冲池的清理时机不明确,导致大量 remember 缓存的对象滞留在内存。Google 在 Compose 1.4.3 的 release note 里提到"优化了 LazyList 的 item 回收性能",但具体修复了什么没有细说。我升级到 1.5.0 后同样场景的内存曲线确实平稳了。
这个案例的教训是:remember 缓存的对象如果持有重量级资源,不能依赖 Composition 的自动清理。要么用 DisposableEffect 主动释放,要么把资源生命周期和 Composable 解耦,交给更下层管理。
从 remember 看 Compose 的设计哲学
梳理完这些细节,回头看 remember 的设计,能感受到 Compose 团队的一些取舍。remember 被设计得足够简单——就是一个位置绑定的缓存——但简单意味着边界情况需要其他 API 补充。rememberSaveable 处理配置变更,DisposableEffect 处理资源释放,derivedStateOf 处理派生状态,produceState 处理异步流。
这种"组合优于内置"的思路贯穿 Compose,但也增加了学习曲线。相比 View 系统的 onSaveInstanceState 一个回调管所有,Compose 把状态生命周期拆得更细,要求开发者主动选择正确的原语。
我个人觉得这种拆分是合理的,但文档和工具链的跟进有滞后。比如 rememberSaveable 能存什么类型,编译期没有检查,运行时才抛异常;derivedStateOf 的性能特征在不同版本变化大,官方 benchmark 数据很少。这些只能靠社区踩坑、看源码、读 release note 来补全。
现在 Compose 1.6 已经进入 beta,Snapshot 系统有了进一步优化,remember 的核心实现相对稳定。但新引入的 androidx.compose.runtime:runtime-tracing 模块开始暴露更多内部状态,也许未来调试 remember 的行为会更容易些。至少目前,理解 Slot Table 和 Group 的结构,仍然是排查 remember 相关 bug 的必备知识。