4.让Compose再次智能
上述问题我们已经定位了,那么如何解决呢?这里提出两种解决方案:
4.1.让LaunchEffect重启
LaunchedEffect
的本质是remember
,因此在key发生变化的时候,LaunchedEffect
会重启,我们把出问题的代码改成以下即可:
@Composable fun DelayOutputText( text: String, ) { var delayOutputText by remember { mutableStateOf("") } // 👇🏻这里使用text作为key,发生变化的时候重启 LaunchedEffect(text) { delay(3000L) delayOutputText = text } Text("延迟输出的值:$delayOutputText") }
重新执行代码,发现没问题了,但是产生了另外一个问题:delay也重启了。这显然和我们的初衷是不一样的,因为我们希望的是3秒后显示最新的值,而不是值变化后又重启倒计时。
除非你的业务上就是要重启倒计时,否则通过修改key来获取最新值的方案是不符合需求的。
我知道你很急,你先别急,下面还有一种方案:
4.2.使用rememberUpdateState
先看看这个Api的源码:
@Composable fun <T> rememberUpdatedState(newValue: T): State<T> = remember { mutableStateOf(newValue) }.apply { value = newValue }
非常的简单,就是一个remember+mutableStateOf的常见组合再加上一个apply来完成赋新值。
既然如此简单,为什么官方还专门封装了一个这样的Api呢,因为上述提到的问题实在太普遍了,普遍到官方需要专门为这种场景封装一个语法糖。
看看如何使用这个Api来解决问题吧,把有问题的代码改造成如下:
@Composable fun DelayOutputText( text: String, ) { // 👇🏻包裹text val rememberText by rememberUpdatedState(newValue = text) var delayOutputText by remember { mutableStateOf("") } LaunchedEffect(Unit) { delay(3000L) // 👇🏻取值的时候使用包裹后的变量 delayOutputText = rememberText } Text("延迟输出的值:$delayOutputText") }
我们使用rememberUpdatedState
来包裹住text
,由于返回的是一个State
,我们使用by委托来取值,重新运行后查看结果:
结果正确了,这是为什么呢,简单的Api居然解决了大问题,让我们简单分析下做了什么:
- 声明一个mutableState,使用text初始化它的值,text变化后,修改它的值
- 延时3秒后,从mutableState中取值
实际上我们就是用一个容器,即mutableState存住了text的值,延时结束后通过容器取值。remember没有重启,取的容器依然是最初那个,但是这并不影响,因为我们取的不是容器本身,而是容器内部的变量。
去掉by委托会让答案更加清楚:
@Composable fun DelayOutputText( text: String, ) { val rememberText: State<String> = rememberUpdatedState(newValue = text) var delayOutputText by remember { mutableStateOf("") } LaunchedEffect(Unit) { delay(3000L) // 👇🏻容器还是旧的,但是容器的value变了,取的是最新值 delayOutputText = rememberText.value } Text("延迟输出的值:$delayOutputText") }
所以我们并没有去除remember没有重启的影响,而是通过一个容器来规避掉没有重启导致的取旧值的问题,我们不在乎取的是容器的旧值,因为这个容器内部的value是最新的即可。
这就是rememberUpdateState
出现的原因,kotlin的lambda虽然方便阅读,但是太容易在Compose的重组场景下出现旧值问题,合理使用rememberUpdateState
可以解决掉这个问题。
5.项目中还是踩了坑
笔者的项目代码大致如下:
@Composable fun BoxContent( text: String, ) { TextContentWithLambda( onClick = { Log.d("临时测试", "当前的值:$text") } ) } @Composable private fun TextContentWithLambda( onClick: () -> Unit, ) { Row( Modifier, verticalAlignment = Alignment.CenterVertically ) { Box( Modifier .heightIn(30.dp) .background(Color.Black) .pointerInput(Unit) { detectTapGestures( onTap = { onClick() } ) }, contentAlignment = Alignment.Center ) { Text( text = "点击", color = Color.White ) } } }
在TextContentWithLambda
做了一个类似手势监听的逻辑,然后点击后执行onClick()
,但是BoxContent
组件那个onClick取到的text依然是旧值。
思考了一大段时间后,笔者突然意识到,手势监听也有一个key作为重启标识,难道手势监听内部也是remember?打开源码一看:
fun Modifier.pointerInput( key1: Any?, block: suspend PointerInputScope.() -> Unit ): Modifier = composed( //省略 ) { //省略 remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter -> LaunchedEffect(filter, key1) { filter.coroutineScope = this filter.block() } } }
家人们谁懂啊,被remember坑到怀疑人生,问题找到了,还是同样的问题,由于remember
导致了新的onClick并没有传递到内部,那么监听手势后执行的onClick自然也是旧的。
怎么解决这个问题呐,在kotlin中万物皆对象,高阶函数也是一个对象,那么我们可以使用rememberUpdateState把高阶函数包裹起来即可:
@Composable private fun TextContentWithLambda( onClick: () -> Unit, ) { val rememberOnClick by rememberUpdatedState(newValue = onClick) //忽略 }
最后把手势监听的onClick
改成rememberOnClick
即可。
总结
一切问题的根源就是remember机制导致新值被丢失,使用State作为容器让新值可以正常被访问,理解了这个原理就可以理解何时使用rememberUpdateState
以及解决那些莫名其妙的bug了,希望这篇文章能帮到你,如果你喜欢这篇文章可以点个赞支持一下。