remember VS rememberSaveable

引言

在使用 Jetpack Compose 进行开发时,我们通常希望能够记住某个计算的值,以避免在重组时重复计算,进而提升性能。为此,Compose 提供了两种主要的方式:remember {}rememberSaveable {}

遇到的问题

最近,我在开发中遇到了这样一个问题:

在某个页面中包含两个主要组件 —— DashboardAsset List,其中 Asset List 可以滚动,而 Dashboard 会根据用户的滚动行为动态调整缩放(Scale)动画。

最初,我使用 remember {} 来存储 scale 的状态。然而,在快速切换 Tab 时,表现正常;但如果在其他 Tab 停留一段时间后再切回该页面,scale 会被重置,导致 Dashboard UI 发生放大,效果如下所示:
Recording

当改用 rememberSaveable {} 后,无论如何切换 Tab,返回页面时 Dashboard 仍能保持之前的 scale 值,符合预期。

代码示例

// remember/rememberSaveable
var scale by rememberSaveable {
    mutableFloatStateOf(1.0f)
}
val nestedScrollConnection = remember {...}
Box(
    modifier = modifier.nestedScroll(nestedScrollConnection),
) {
    DashboardView(
        modifier = Modifier
            .fillMaxWidth()
            .scale(scale)
            .graphicsLayer { translationY = toolbarHeightRange.last * (scale - 1.0f) },
        onEvent = onEvent
    )
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .graphicsLayer { translationY = toolbarState.height + toolbarState.offset }
            .background(MaterialTheme.colorScheme.background)
            .shadow(
                elevation = 3.dp,
                shape = RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp)
            )
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = { scope.coroutineContext.cancelChildren() }
                )
            },
        verticalArrangement = Arrangement.spacedBy(12.dp),
        state = listState
    ) {
        items(uiState.tokens, key = { it.token.id }) {
            TokenItemView(extraToken = it, onEvent = onEvent)
        }
    }
}

remember {} 和 rememberSaveable {}的区别

首先通过文档了解 remember {}rememberSaveable {}

remember {}

官方文档Lifecycle of composables,可组合项的生命周期可以分为 进入组合(Enter Composition)重组(Recomposition)退出组合(Exit Composition) 三个阶段。

其中对于remember {}方法的注释如下:

Remember the value produced by calculation. calculation will only be evaluated during the composition. Recomposition will always return the value produced by composition.
翻译:计算的值会在进入组合时生成,并在后续重组时直接返回,而不会重新计算。

这意味着:

  • remember {} 仅在 初次进入组合 时执行 block 代码计算;
  • 重组 过程中会返回先前存储的值,避免重复计算
  • 但如果可组合项退出组合并重新进入,则 remember {} 里的 block 会被重新计算。

rememberSaveable {}

官方文档对 rememberSaveable {} 的说明如下:官方文档rememberSaveable

rememberSaveable {} 类似于 remember {},但它存储的值会在 Activity 或进程重建后仍然保留,使用的是 Android 的 SavedInstanceState 机制。

在默认的情况下,会使用autoSaver()保存在Bundle中,当然可以自己实现Saver。

注意:如果保存的对象无法直接存入 Bundle(如自定义 data class),需要提供 Saver 实现,否则会报错。

要点总结:

  • rememberSaveable {}remember {} 类似,都会存储计算结果。
  • rememberSaveable {} 可以在 Activity 重建(如屏幕旋转)后恢复数据。
  • 默认情况下,仅支持可以存储在 Bundle 中的类型,如 Int、String 等。
  • 若要存储自定义数据类型(如 data class),需要提供 Saver 实现,否则会报错:
java.lang.IllegalArgumentException: MutableState containing ScaleStore(scale=0.0) cannot be saved using the current SaveableStateRegistry. The default implementation only supports types which can be stored inside the Bundle.

验证实验

首先验证使用remember {}的用例,使用最简单的日志法。加上如下代码

// 验证是否进行重新计算
var scale by remember {
    println("=== re-calculate")
    mutableFloatStateOf(1.0f)
}
// 是否退出组合
DisposableEffect(key1 = null) {
    onDispose {
        println("=== onDispose")
    }
}

DisposableEffect 和 key1 = null,是为了保证可重组项是因为退出组合时才执行onDispose
打印日志如下:
Logger
除了第一次进入组合打印的re-calculate,之后的都是要经过dispose之后再打印,在dispose之后,重新进入重组而进行再次计算,会将scale重置为1.0,也就是放大了Dashboard组件的大小。符合笔者遇到情况的解释。
(注:如果在tab之间切换较快,两条日志都不会出现。这也对应前面提到过当在tab之间切换较快,结果正常,scale没有变化,而是保留了原来的大小。笔者对这种情况的猜测解释是可组合项还没来得及回收保留的数据,又被重新唤醒了)
同样将remember 换成 rememberSaveable,日志如下:
Logger1
除了第一次进入组合打印的re-calculate,之后再也没有出现re-calculate,证明了一直使用的都是记录下来的数据。

额外探讨

在搜索资料时,笔者找到这么一篇记录 One Off the Slack: When Do We Use rememberSaveable()?
其中提到composable leaving the screen is not recreation,翻译后是:composable 退出屏幕并不等于重建。

而在Compose中:

  • 组件离开屏幕会调用 onDispose,生命周期结束。
  • 组件重新进入屏幕时会重新组合,相当于新的生命周期。

日常开发过程中,除去特殊情况,可组合项离开屏幕主要包含2种:

  1. Navigation跳转
  2. 滑动导致某些可组合项滑出屏幕外

对于Navigation跳转,上文已经解释了,此处就不赘述。接下来通过简单的例子看看滑动的情况:
一个简单的LazyColumn + Item DisposableEffect

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
        .padding(it)
) {
    items(uiState.instruments) {
        SpotItemView(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp), instrument = it
        )
    }
}

@Composable
private fun SpotItemView(
    modifier: Modifier = Modifier,
    instrument: Instrument
) {
    DisposableEffect(key1 = null, effect = {
        onDispose {
            println("=== ${instrument.instId}")
        }
    })
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        ...
    }
}

通过打印日志可以看出,当某个item滑出屏幕外时,onDispose会触发,意味着item退出组合。
对于LazyColumn,当前position的记录需要考虑下个页面返回到当前页面而需要记录当前位置的情况,所以rememberLazyListState使用的就是rememberSaveable记录状态的。

笔者个人对上文中**composable 退出屏幕并不等于重建。** 的理解是:Configuration变化导致重建,但是可组合项目离开屏幕不是Configuration变化,所以不会导致重建

何时使用哪种方式?

  • 如果状态仅需在重组时保持,remember {} 是最佳选择。
  • 如果状态需要在 Activity 重建(如屏幕旋转)时保持,rememberSaveable {} 更合适。
  • 若状态需要跨页面存储,ViewModel 可能是更好的选择,但在组件封装和状态提升的场景下,rememberSaveable {} 会更适用。

不管如何,弄明白其原理和实现之后,再使用的过程中自然会考虑到这方面的差异而选择更好的。