remember VS rememberSaveable

在Jetpack Compose中,存在两种方式记住某个计算的值,从而避免在重组的时候重新计算实现性能上的提升,这两种方式分别为remember {}rememberSaveable {}

前言

笔者在前段时间遇到这么个情况:
在一个页面存在两部分组件,分别是Dashboard和Asset List,其中List是可以滚动,而Dashboard会根据用户滑动behavior来调整Scale动画(当然,这并不是最终效果,只是一个简单的use case)
当使用remember {}来记录Scale,如果在tab之间切换比较快的话,偶尔能正常的,但是如果在其他tab停留了一段时间再切回这个tab,scale将被重置从而放大了Dashboard UI,情况如下显示:
Recording
如果使用rememberSaveable {}则符合预期,不管如何,回到页面都能展示之前的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)
        }
    }
}

所以在Compose中如何正确使用这两个方法呢?

remember {} 和 rememberSaveable {}的区别

remember {}

在官方文档对可重组项的生命周期描述中Lifecycle of composables
我们知道对于每一个可重组项,其生命周期分为3个阶段, 进入组合 -> 重组 -> 退出组合
其中对于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会被执行计算,而重组则直接返回无需再计算(当然也可以添加key参数增加重新计算条件)

rememberSaveable {}

在官方文档对rememberSaveable {}的说明是这样的,参考文档

Remember the value produced by init.
It behaves similarly to remember, but the stored value will survive the activity or process recreation using the saved instance state mechanism (for example it happens when the screen is rotated in the Android application).

翻译过来大概意思就是:它和remember一样,都是用来保留某个结果的值的,但是其保留的值在Activity重建之后仍然存在

注:If you use it with types which can be stored inside the Bundle then it will be saved and restored automatically using autoSaver, otherwise you will need to provide a custom Saver implementation via the saver param.

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

因为Bundle不支持没有被系列化的对象,所以普通的data class是无法正常使用的,需要自己实现Saver。
笔者已经验证过,存储普通的data class会报如下错误信息:

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.

想要使用rememberSaveable保留特殊的类型,需要自己实现Saver

通过上面的描述,大致了解了remember {}rememberSaveable {}的过程

  • remember {} 会在进入组合时开始计算,重组则直接返回记录下来的值;当可重组项退出组合后再进入组合,这属于开始新的生命周期,自然会重新计算。
  • rememberSaveable {} 默认是将数据缓存到Bundle中,在屏幕旋转或者其他Configuration变化时,触发重建,直接从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一直有些疑惑。
笔者个人认为,这句话在文中的理解应该是,Configuration变化导致重建,但是可组合项目离开屏幕不是Configuration变化,所以不会导致重建,可以理解成字面上的不是重建的意思。
但是对于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会触发,意味退出组合。
在可组合项目的生命周期来看,退出组合则表明这个可重组项生命周期结束,当需要重新回到屏幕,需要重新走一遍,所以remember中会重新进行计算。
但是对于LazyColumn,当前position的记录需要考虑下个页面返回到当前页面而需要记录当前位置的情况,所以rememberLazyListState使用的就是rememberSaveable记录状态的。

总结

  • 如果某个状态只考虑在重组的时候记录的,remember是最好的选择。
  • 对于其他情况,rememberSaveable或许会更符合。
  • 当然ViewModel中存储也是一种方式,不过有些时候组件的封装,状态提升是更好的设计,ViewModel就不符合这个理念了

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