Compose:长期副作用 + 智能重组 = 若智?

笔者曾经写过一篇关于新手入坑Jetpack Compose的文章,其中谈到了rememberUpdateState的使用场景,但是最近的一次项目中还是踩坑了,而且收到了很多人反馈表示依然不理解如何正常使用这个Api,于是单独写一篇文章展开说说。

关于提到的文章传送门:妈!Jetpack Compose太难学了,别怕,这里帮你理清几个概念 – 掘金 (juejin.cn)

如果你完全不明白什么是智能重组副作用,可以先看看笔者写的这篇文章。

1.长期副作用

在Jetpack Compose的世界中,所谓的长期副作用基本就是等价于协程中的挂起函数,这个定义不一定对,但是足够覆盖绝大多数场景。

让我们看看一个长期副作用的样子:

@Composable
fun LongRunningSideEffectExample(){
LaunchedEffect(Unit){
delay(1000)
// TODO: 我是长期副作用
}
}
@Composable









fun LongRunningSideEffectExample(){
    LaunchedEffect(Unit){
        delay(1000)
        // TODO: 我是长期副作用 
    }

}
@Composable fun LongRunningSideEffectExample(){    LaunchedEffect(Unit){        delay(1000)        // TODO: 我是长期副作用   } }

可见,一个简单的长期副作用其实就是一个一段时间后才执行的逻辑,在大多数场景下,在delay结束后执行的逻辑都没有什么问题。

2.智能重组

众所周知,Jetpack Compose的编译器存在魔法,会在重组的时候,根据参数的是否发生了变化来决定是否充足当前的组件,这就是所谓的智能重组。

让我们看看一个智能重组的案例:

@Composable
fun RecompositionExample(
text:String
){
SideEffect {
Log.d("重组记录","当前的值:$text")
}
Text(
text=text
)
}
@Composable









fun RecompositionExample(
    text:String
){
    SideEffect {
        Log.d("重组记录","当前的值:$text")
    }
    Text(
        text=text
    )

}
@Composable fun RecompositionExample(    text:String ){    SideEffect {        Log.d("重组记录","当前的值:$text")   }    Text(        text=text   ) }

SideEffectApi会在重组成功后调用lambda,因此我们可以通过观察日志来查看当前组件的重组时刻,通过实验得知,只有text参数发生变化的时候,SideEffect的lambda才会被执行,这就是所谓的智能重组,Compose会尽可能跳过没意义的重组。

3.长期副作用+智能重组=?

两者都是Jetpack Compose非常优秀的机制,但是两者在一起很容易出问题,例如下面这个组件:

@Composable
@Preview
fun LongRunningSideEffectWrongExample() {
var count by remember {
mutableStateOf(0)
}
Column {
Button(onClick = { count++ }) {
Text("当前的值:$count")
}
DelayOutputText(text = "$count")
}
}
@Composable
fun DelayOutputText(
text: String,
) {
var delayOutputText by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
delay(3000L)
delayOutputText = text
}
Text("延迟输出的值:$delayOutputText")
}
@Composable









@Preview
fun LongRunningSideEffectWrongExample() {
    var count by remember {
        mutableStateOf(0)
    }

    Column {
        Button(onClick = { count++ }) {
            Text("当前的值:$count")
        }
        DelayOutputText(text = "$count")
    }

}

@Composable
fun DelayOutputText(
    text: String,
) {
    var delayOutputText by remember { mutableStateOf("") }
    LaunchedEffect(Unit) {
        delay(3000L)
        delayOutputText = text
    }
    Text("延迟输出的值:$delayOutputText")
}
@Composable @Preview fun LongRunningSideEffectWrongExample() {    var count by remember {        mutableStateOf(0)   }    Column {        Button(onClick = { count++ }) {            Text("当前的值:$count")       }        DelayOutputText(text = "$count")   } } @Composable fun DelayOutputText(    text: String, ) {    var delayOutputText by remember { mutableStateOf("") }    LaunchedEffect(Unit) {        delay(3000L)        delayOutputText = text   }    Text("延迟输出的值:$delayOutputText") }

组件非常简单,在出现DelayOutputText3秒后,尝试显示最新的text值,但是实际运行结果如下:

image-20230802111727968.png

可见,3秒后并没有显示最新的值,而是显示初始化的值,不是说智能重组吗,怎么没重组,问题出在哪里了?

让我们回到LaunchedEffect本身的源码:

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
@Composable









@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
@Composable @NonRestartableComposable @OptIn(InternalComposeApi::class) fun LaunchedEffect(    key1: Any?,    block: suspend CoroutineScope.() -> Unit ) {    val applyContext = currentComposer.applyCoroutineContext    remember(key1) { LaunchedEffectImpl(applyContext, block) } }

LaunchEffect内部使用了一个remember来包裹LaunchedEffectImpl,总所周知,如果key没有发生变化,remember的lambda是不会重新被执行的,而我们通过LaunchedEffect传入的block参数,就在remember的lambda中,这导致了一个问题:

如果LaunchedEffect的key没有发生变化,LaunchedEffect内部的lambda拿到的block参数是旧的

回到上文提到的出问题的代码,

image-20230802112455457.png

笔者框住的这个代码块,看似是3秒后用最新的text值赋值给delayOutputText,实际上这是一种思维误区,真实的情况则是:如果key没有发生变化的情况,即没有重启LaunchedEffect的情况下,lambda一直都是最初的那个实例,那个lambda实例取的text则是最初启动的时刻的值,因此3秒后,delayOutputText = text这段代码,实际上是将text第一次的值传给了delayoOutputText,后续的text值都被忽略了。

一切问题的根源是remember

remember忽视掉了新的lambda,最终执行的lambda都是最初那个,那么lambda内部的变量自然也是旧的了。

问题找到了,笔者想用一句经典的话来概括上述这段问题:

这不是一个bug,而是一个feature

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")
}
@Composable









fun DelayOutputText(


    text: String,



) {




    var delayOutputText by remember { mutableStateOf("") }
    //              ??这里使用text作为key,发生变化的时候重启
    LaunchedEffect(text) {
        delay(3000L)
        delayOutputText = text
    }
    Text("延迟输出的值:$delayOutputText")
}
@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 }
@Composable









fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }
@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")
}
@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")
}
@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委托来取值,重新运行后查看结果:

image-20230802114547989.png

结果正确了,这是为什么呢,简单的Api居然解决了大问题,让我们简单分析下做了什么:

  1. 声明一个mutableState,使用text初始化它的值,text变化后,修改它的值
  2. 延时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")
}
@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")
}
@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
)
}
}
}
@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
            )
        }
    }
}
@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()
}
}
}
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()
        }
    }

}
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)
//忽略
}
@Composable









private fun TextContentWithLambda(
    onClick: () -> Unit,
) {




    


    val rememberOnClick by rememberUpdatedState(newValue = onClick)
    //忽略
  }
@Composable private fun TextContentWithLambda(    onClick: () -> Unit, ) {        val rememberOnClick by rememberUpdatedState(newValue = onClick) //忽略 }

最后把手势监听的onClick改成rememberOnClick即可。

总结

一切问题的根源就是remember机制导致新值被丢失,使用State作为容器让新值可以正常被访问,理解了这个原理就可以理解何时使用rememberUpdateState以及解决那些莫名其妙的bug了,希望这篇文章能帮到你,如果你喜欢这篇文章可以点个赞支持一下。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYYgSxyu' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片