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

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

关于说到的文章传送门:妈!Jetpack Compose太难学了,别怕,这儿帮你理清几个概念 – ()

假如你彻底不明白什么是智能重组副作用,能够先看看笔者写的这篇文章。

1.长时刻副作用

在Jetpack Compose的国际中,所谓的长时刻副作用根本便是等价于协程中的挂起函数,这个界说不一定对,可是满足掩盖绝大多数场景。

让咱们看看一个长时刻副作用的姿态:

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

可见,一个简略的长时刻副作用其实便是一个一段时刻后才履行的逻辑,在大多数场景下,在delay完毕后履行的逻辑都没有什么问题。

2.智能重组

众所周知,Jetpack Compose的编译器存在魔法,会在重组的时分,依据参数的是否发生了改变来决议是否充足当时的组件,这便是所谓的智能重组。

让咱们看看一个智能重组的事例:

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

组件十分简略,在呈现DelayOutputText3秒后,尝试显现最新的text值,可是实际运转成果如下:

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

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

让咱们回到LaunchedEffect自身的源码:

@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参数是旧的

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

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

笔者框住的这个代码块,看似是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")
}

从头履行代码,发现没问题了,可是产生了别的一个问题: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托付来取值,从头运转后检查成果:

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

成果正确了,这是为什么呢,简略的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")
}

所以咱们并没有去除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了,期望这篇文章能帮到你,假如你喜爱这篇文章能够点个赞支撑一下。