Jetpack Compose - 彻底弄懂状态之remember和derivedStateOf

4,175 阅读25分钟

本篇文章将由浅入深,一环扣一环,把Composable函数中的状态相关问题搞懂。

说明 本篇文章侧重的是对remember、derivedStateOf的概念以及Compose框架的一小部分设计理念的理解,以及一些使用场景的具体介绍和分析。

对于其他的remember

  • 例如rememberSaveable、rememberUpdatedState、rememberCoroutineScope等

或者其他的一些状态

  • 例如mutableStateListOf、CompositionLocal等

或者更大层面的状态管理

  • 例如ViewModel等架构层面

它们并不是本文的重点,不会过多介绍。

1 从Composable的状态聊起

我们先来非常通俗直观地理解一下remember到底是个什么玩意。

(这部分非常基础)

一个简单示例

我们来写一个点击计数器,功能是点击按钮,则计数+1,代码如下。

@Composable
fun RememberExample() {
    var myText = 0
    
    Column {
        Text(text = myText.toString())
        Button(onClick = { myText++ }) {}
    }
}

运行后发现,并不能如我们所愿,Text的显示根本没有变化。

其实非常好理解,点击Button,触发onClick,myText++,然后...然后呢?myText确实增加了,但是Compose框架怎么知道myText增加了?这样的变量改变,Compose框架根本无法感知,那当然UI自然没有变化。

那么接下来我们的目标就变成了让Compose框架能够感知变量的变化,从而刷新UI


让Compose框架感知变量变化

修改我们的代码,用State包裹我们的变量。

@Composable
fun RememberExample() {
    val myText = mutableStateOf(0)

    Column {
        Text(text = myText.value.toString())
        Button(onClick = { myText.value++ }) {}
    }

    Log.d(TAG, "RememberExample: ${myText.value}")
}

可以看到,对比之前,我们使用mutableStateOf()构造了一个State,这个State可以是任意类型的,State#value就是实际的值,这里我们传入初值0。

这时候myText就是一个State了,也就是所谓的“状态”,我们可以通过.value访问它的值。因为State的变化是可以被Compose框架感知的,所以这下可以正常的触发UI刷新了——也就是我们通常所说的“重组”。

打个log看看,确实,每当我们点击按钮,log都触发了,说明Compose框架能够感知了,但是!观察屏幕我们发现,尽管触发了重组,我们的UI却没有变化,Text显示的数字还是0!

其实也非常好理解,你看,我们的RememberExample是一个函数,既然是一个函数,每次重组执行的时候,myText都会按照同一个代码逻辑,也就是 val myText = mutableStateOf(0) 被赋值。

再说清楚一些,就是:

  • 首先,点击Button
    • 触发onClick
    • myText++
  • 然后,触发重组
    • Compose框架重新执行RememberExample函数
    • 执行第一行 val myText = mutableStateOf(0)
    • Text读取myText的值进行显示,读取到了0

那么接下来的目标就是,如何让状态能够跨越重组而持久存在,也就是重新执行这个Composable函数之后,能够访问到之前已经改变了的值,而不是每次都重新赋一遍值。


让状态能够跨越重组而持久存在

其实我们能够自然而然地想到一个思路:我们只需要把这个State储存在一个更持久的地方,Composable函数第一次调用时把这个State扔进去,然后在点击Button时访问并更新它的值,当触发重组后再次需要访问时,我们也去访问这个持久储存的State,那么自然而然地,State就会不受这个Composable函数域的影响了,因为即使函数执行完了,State变量仍然储存在某个更持久的地方。

所幸,Compose框架的大致思路也是这样,因此Compose框架已经为我们提供了一个函数——remember,来完成上面所说的功能,到这里,我们这篇文章的主角之一就正式登场了。

顾名思义,remember就是记住,用来记住Composable内部的状态,让状态能够跨越重组而存在,这样,Composable函数每次重新执行时,能以正确的状态来显示UI,代码如下。

@Composable
fun RememberExample() {
    var myText by remember { mutableStateOf(0) }

    Column {
        Text(text = myText.toString())
        Button(onClick = { myText++ }) {}
    }

    Log.d(TAG, "RememberExample: $myText")
}

这回,UI终于能够正确刷新了。

那么接下来,我们乘胜追击,扒一扒这个remember本身。

2 解读remember函数

首先看到remember的定义。

@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

源码的Javadoc注释如下。

Remember the value produced by calculation. calculation will only be evaluated during the composition. Recomposition will always return the value produced by composition.

记住calculation这个lambda产生的值。calculation这个lambda将仅在组合过程中被调用。重组将始终返回组合过程生成的值。

我们解读如下。

  • remember也是一个Composable函数,这意味着它仅能出现在Composable函数内,这正好契合了我们之前说的,它是用来记住Composable的内部状态的。
  • remember传入了一个lambda,这个lambda用于Composable在需要的时候去计算状态的值。
    • 说通俗一些,remember的lambda只是定义了一个如何去计算state值的算式,并没有执行,当这个函数组合且Compose框架判断需要依据lambda去获取这个state的值时,这时,这个lambda就会被执行,lambda的返回值就是计算结果,那么这个Composable函数后面访问到这个状态,访问的都是lambda的计算结果。
  • remember传入的这个lambda有一个@DisallowComposableCalls注解,意思很明确,就是不允许这个lambda中出现类似Text(text = "aaa")这种Composable函数的调用,其实也很好理解,因为如果被remember的状态值需要依赖可组合函数进行计算,就会导致混乱。remember函数本身就是被设计用来跨越组合的。

关于remember的写法

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

这三种写法是等效的,只是语法上的区别,我个人更偏向属性委托by的方式,感觉最为简洁方便。

那么,remember的lambda执行的时机是什么呢?其实只会在这个Composable第一次组合时被调用,也就是State的值只会通过lambda计算一次。

那如果我们想要在重组中触发lambda的重新执行,或者说当我们面临一个需要进行监听并触发重新计算状态值的场景的时候,又该如何应对呢?可以发现,remember有若干重载方法来完成这个需求。

/**
 * Remember the value returned by [calculation] if [key1] is equal to the previous composition,
 * otherwise produce and remember a new value by calling [calculation].
 */
@Composable
inline fun <T> remember(key1: Any?, calculation: @DisallowComposableCalls () -> T): T

@Composable
inline fun <T> remember(key1: Any?, key2: Any?, calculation: @DisallowComposableCalls () -> T): T

@Composable
inline fun <T> remember(
    key1: Any?,
    key2: Any?,
    key3: Any?,
    calculation: @DisallowComposableCalls () -> T
): T

@Composable
inline fun <T> remember(
    vararg keys: Any?,
    calculation: @DisallowComposableCalls () -> T
): T

其实都是一回事,就是对传入的keys作监听。只要任何一个传入的key有变化,则会调用calculation重新计算state值,反之,如果没有任何key有变化(包括不传入key的情况),那么重组时不会调用calculation去重新计算state值。

这里的“key是否有变化”比较的是对象的equals方法。

所以,如果有以下两段代码,点击按钮之后,显示的Text不会发生任何变化。

@Composable
fun RememberExample() {
    var number by remember { mutableStateOf(0) }

    RememberExampleInner(onClick = { number++ }, number = number)
}

@Composable
fun RememberExampleInner(onClick: () -> Unit, number: Int) {
    val myText by remember { mutableStateOf(number) } 
    //就算依赖了number,也只会初次组合时执行,所以尽管传入的number变化了,lambda不执行,myText还是不变

    Column {
        Text(text = myText.toString())
        Button(onClick = onClick) { }
    }
}
@Composable
fun RememberExample() {
    var number by remember { mutableStateOf(0) }
    val myText by remember { mutableStateOf(number) } 
    //就算依赖了另一个State,也不会重新执行

    Column {
        Text(text = myText.toString())
        Button(onClick = {number++}) { }
    }
}

2.1 remember的原理

简单看一下remember的原理。

@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

@Composable
inline fun <T> remember(
    key1: Any?,
    calculation: @DisallowComposableCalls () -> T
): T {
    return currentComposer.cache(currentComposer.changed(key1), calculation)
}

remember函数调用了Composer#cache方法,cache就是Compose框架对状态进行缓存,即所谓的“记住”。

inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

cache的第一个参数invalid代表目前记住(remember)的state是否无效。

  • 如果是无key的remember,则不会无效,invalid直接传入false。
  • 如果是有key的remember,则会先调用Composer#changed去比较key是否变化,若变化了,则无效。
override fun changed(value: Any?): Boolean {
    return if (nextSlot() != value) { //kotlin中==就是equals
        updateValue(value)
        true
    } else {
        false
    }
}

这里changed方法内的nextSlot()就是从SlotTable去取缓存的key(SlotTable就是Compose框架缓存各种“状态”和“数据”的地方)。取到缓存key后进行比对,如果值没发生变化,返回false,否则调用updateValue,记录一条change,在组合后的applyChanges阶段将新值更新到SlotTable。具体的内容我们不再深入,感兴趣可以看看fundroid大佬的文章,有详尽的分析。

回到cache函数,它首先调用rememberedValue取出之前“记住”的值,如果无效或者为空,则调用lambda重新计算state值并更新。

  • 无效:即key变了。
  • 为空:意味着当前正在创建组合的新部分,即合成将在生成的树中插入新节点,说白了就是第一次组合。

看到这里,其实也就解释了为什么无key参数的remember函数只会在第一次组合时执行一次lambda。

3 实战中的remember

接下来的部分,会对实战写代码时关于remember的一些常见问题和疑惑进行探讨。

3.1 什么场景使用remember?

其实从前文我们已经能够总结出remember的一般使用场景,就是Composable需要“记住”某些内部状态的时候,同时,我们能够设置key,以判断是否需要对状态值进行重新计算

除了缓存状态以外,还可以用remember去初始化计算成本高昂的对象,保证只在需要更新的时候更新

官方文档的一个示例:

val brush = remember {
  ShaderBrush(
    BitmapShader(
      ImageBitmap.imageResource(res, R.drawable.myDrawable).asAndroidBitmap(),
      Shader.TileMode.REPEAT,
      Shader.TileMode.REPEAT
    )
  )
}

以上代码用一张背景图创建并缓存了一个Brush,因为它只需要被创建一次,所以使用remember创建就可以避免重组时重复创建。那么,如果想在背景图发生变化时去创建新的Brush,则可以加一个key参数,代码如下。

//使用key的remember
@Composable
fun BackgroundBanner(
   @DrawableRes avatarRes: Int,
   modifier: Modifier = Modifier,
   res: Resources = LocalContext.current.resources
) {
   val brush = remember(key1 = avatarRes) {
       ShaderBrush(
           BitmapShader(
               ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
               Shader.TileMode.REPEAT,
               Shader.TileMode.REPEAT
           )
       )
   }

   Box(
       modifier = modifier.background(brush)
   ) {
       // ...
   }
}

这样,当传入的avatarRes变化时,ShaderBrush也会更新,而avatarRes不变时,则不会,避免了昂贵对象的重复创建。

3.2 可以在Activity重建后继续记住状态吗?

可以使用rememberSaveable方法,它可以在重新创建 activity 或进程后保持状态,本篇文章不过多介绍,感兴趣可以看看官网的这个部分

3.3 该把状态放在内部remember,还是以参数传入?

也许会有一个疑问,我们的State既可以作为一个内部状态remember起来,也可以作为一个外部状态以函数参数的形式传入,那么什么时候该remember,什么时候该以参数传入呢?

这其实涉及到有状态组件/无状态组件以及状态提升的概念,基本思想是“状态向下,事件向上”,例如下面3.4节的“使用组件MyButton”的例子。

但这方面如果展开就太多了,并非三言两语能说清楚,更详细的内容可以看一下官网提升状态的场景的这篇,或者这一篇中有关滥用remember的内容,也许会有所帮助。

3.4 状态类的专用rememberXXState

在自定义组件时,可以对外提供封装好的rememberXXState方法。

假设现在我们正在自定义一个组件,如果说这个组件需要使用者传入的状态比较多,我们就可以把这些状态封装成一个状态类,这个类可以是@Stable的以提升性能。然后提供rememberXXState以便把组件状态交给更上层,然后组件本身内部并不持有这些状态,这样有助于降低复杂性,遵循关注点分离原则。此外,这些状态类还可以包含一定的逻辑。

接下来看一个示例。我们现在自定义一个MyButton组件,可以修改按钮的颜色和圆角大小,代码如下。

//自定义组件MyButton
@Composable
fun MyButton(state: MyButtonState, onClick: () -> Unit = {}) {
    Button(
        onClick = onClick,
        colors = ButtonDefaults.buttonColors(backgroundColor = state.color),
        shape = RoundedCornerShape(corner = CornerSize(state.radius))
    ) {

    }
}

data class MyButtonState(val color: Color, val radius: Float) {
    ...//逻辑
}

@Composable
fun rememberMyButtonState(myButtonState: MyButtonState) = remember {
    mutableStateOf(myButtonState)
}

使用者使用这个组件时,可以方便地定制组件的state,例如,点击按钮时,修改它的颜色,代码如下。

//使用组件MyButton
val colors = listOf(Color.Cyan, Color.Gray, Color.Red, Color.Blue, Color.Yellow, Color.Green)

@Composable
fun RememberExample() {
    var state by rememberMyButtonState(
        myButtonState = MyButtonState(color = Color.Green, radius = 5f)
    )

    MyButton(state = state, onClick = {
        state = state.copy(color = colors.random())
    }) 
}

在官方的LazyColumn等一些组件里,我们也可以看到类似的写法,以LazyColumn为例,它提供了一个状态类 LazyListState来给我们处理滚动相关的需求,同时给我们提供了rememberLazyListState方法来生成这个State。

我们可以用下面的代码实现一个能点击按钮后滚动到顶部的LazyColumn。

//LazyColumn的rememberLazyListState示例
val colors = listOf(Color.Cyan, Color.Gray, Color.Red, Color.Blue, Color.Yellow, Color.Green)

@Composable
fun RememberExample() {
    Box(modifier = Modifier.fillMaxSize()) {
        val lazyColumnState = rememberLazyListState()
        val coroutineScope = rememberCoroutineScope()

        LazyColumn(
            state = lazyColumnState,
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier.fillMaxSize()
        ) {
            items(50) {
                Card(
                    modifier = Modifier.padding(vertical = 10.dp),
                    backgroundColor = colors[it % colors.size]
                ) {
                    Text("PTQ is Power", Modifier.size(20.dp))
                }
            }
        }

        Button(modifier = Modifier
            .align(Alignment.BottomCenter)
            .padding(bottom = 20.dp),
            onClick = {
                coroutineScope.launch {
                    lazyColumnState.animateScrollToItem(0)
                }
            }) {
            Text(text = "滚动到顶部")
        }
    }
}

到这里,remember相关的内容已经分析得差不多了你可能注意到了,而你可能已经注意到了,这篇文章的标题除了remember以外还出现了一位,就是derivedStateOf,那么它又是干什么的呢?

接下来就该是derivedStateOf相关的内容了。

4 derivedStateOf出场

刚看到这个“derivedStateOf”,如果不知道derive这个单词是什么意思,那不妨先来翻译一下。

翻译告诉我们,derive是“派生、衍生、推导出”的意思,那么,derivedStateOf就好理解了,就是“衍生出的状态”。

从上面的翻译,我们能知道三件事:

  • derivedStateOf用于产生一个状态,它的返回值肯定是一个State类型的。
  • derivedStateOf既然产生了一个状态,那么它的使用也应该包裹在remember里。
  • derivedStateOf产生的状态是衍生而来的,那么从什么衍生而来的呢?当然是从其他状态衍生计算而来的

看一下它的函数签名。

fun <T> derivedStateOf(calculation: () -> T): State<T>

和remember同样有一个叫calculation的lambda块,我们可以类比着remember理解。在remember中,state的值依赖于key和calculation的计算结果,而derivedStateOf同样,它产生的state值也依赖于calculation这个lambda的计算结果,并且,既然是“衍生”,calculation的计算就应该要包含其它的state,也就是“用其它state推导、衍生出一个新state”,这就是derivedStateOf的作用。

看下面这个例子。

//derivedStateOf示例
@Composable
fun RememberExample() {
    var state0 by remember { mutableStateOf(0) }
    var state1 by remember { mutableStateOf(0) }

    val showText by remember {
        derivedStateOf {
            state0 + state1 > 10
        }
    }

    Button(onClick = {
        state0 = (0..10).random()
        state1 = (0..10).random()
    }) {
        if (showText) {
            val myText = state0 + state1
            Text(text = myText.toString())
        }
    }
}

我们定义了两个变量state0和state1分别记录两个数,然后又使用了derivedStateOf定义了一个变量showText,并且calculation是state0和state1之和是否大于10,如果大于10,则showText为true,显示文本。点击按钮时,对state0和state1进行随机重新赋值。

在这个例子里,showText就是一个衍生状态,它依赖于state0和state1这两个状态计算得出。

现在,我们对derivedStateOf有了一个初步的认识。那么,如果只是这样的话,为什么我们要单独把它拎出来特别说明呢?又为什么要把它和remember放在一起比较呢?是不是还有什么我们忽略了的地方?

请看以下几个问题:

  • 如果我们使用remember(keys)来监听,好像似乎也能完成和derivedStateOf一样的功能,无非就是监听然后重新计算嘛,那么remember(keys)和derivedStateOf有没有什么区别呢?derivedStateOf的使用场景究竟是什么?
  • 必须至少监听两个state才能使用derivedStateOf吗?如果只想监听一个state我可以用吗?
  • derivedStateOf被包裹在remember里并没有什么问题,因为derivedStateOf产生的是一种state,那就应该被remember去“记住”。但是,是否有一种场景,derivedStateOf也有可能需要被包裹在同样象征着监听的remember(keys)里呢?

这三个问题如果你都能回答出来,那么derivedStateOf和remember的意义就已经完全被你弄明白了,如果回答不上来,那么我们接着往下分析。

4.1 细说“监听”

为了解答上面的问题,我们需要仔细思考一下“监听”的场景。

我们大致可以将“监听,并触发calculation重新执行”这个场景细分为三种具体情况:

监听变量,一旦变量变化便触发监听、重新计算:

  • 一次变化导致一个新的计算结果:
    • [情形一] 变量不频繁变化,即不频繁触发监听。
    • [情形二] 变量频繁变化,即基本上次次触发监听。
  • [情形三] 一次变化不一定导致一个新的计算结果:
    • 有可能监听的变量变化了但是计算结果仍不改变。

我们来分别举具体的场景例子。

情形一

这种场景非常常见,例如3.1中的例子。我们需要监听avatarRes这个变量,当不同的avatarRes传入时,我们需要更新Brush。并且这种监听是非频繁的,avatarRes不一定每一次传入的时候都会变化。

情形二

这类场景其实就是正常的根据变量去计算了,严格要说的话,它也算一种监听,所以把它放到一起讨论。

其实就是:

fun test(a: Int) {
    val b = a * 2
}

我们用监听的视角来看这段代码,就是:对变量a进行监听,一旦a变化,就执行变量b的值的计算,且a很可能经常变化,且对于不同的a的输入,b基本上都会产生不同的结果。

所以这类场景其实就是很普通的根据变量计算新值。

情形三

这种场景其实在上面的derivedStateOf使用示例里就出现了。我们要根据state0和state1来判断文字是否展示,自然需要监听state0和state1,但是,我们的是否展示是有条件的,必须要state0和state1之和大于10才行。换句话说,假设state0/1一开始分别为0和0,点击按钮让它们分别变成了4和5,但是,由于其和还是小于10,文字最终还是不展示,showText仍然保持之前的计算结果。

这就是情形三所说的,一次变化不一定导致一个新的计算结果。

还有类似的例子,例如,假设我们需要监听LazyColumn是否发生了滑动,如果发生了滑动(即已经不处于顶部),我们就显示一个按钮,否则按钮不显示。这时候,我们会对LazyListState的firstVisibleItemIndex进行监听,如果firstVisibleItemIndex>0,则是满足条件的。但是我们会发现,firstVisibleItemIndex=1是大于0,firstVisibleItemIndex=2也是大于0,firstVisibleItemIndex=100也是大于0,如果说“按钮是否显示”是从firstVisibleItemIndex衍生出来的一个状态,那么这个状态虽然需要依赖于对firstVisibleItemIndex进行监听,但却不是每次监听触发了都能导致它变化。

再举一例,对于一个常见的登录页面,有用户名和密码两个输入框,我们需要监听用户的输入,当两个框都输入满足条件(例如,长度>8,只含英文和标点),这时候,登录按钮就会亮起,可以点击,否则是灰色不允许点击的。这也是符合情形三的场景。

看完这三种不同的场景之后,我们再回到derivedStateOf上来,接下来我们一一回答之前提出的问题。

4.2 derivedStateOf的使用场景到底是什么?

现在就很好理解了,其实就是情形三,当一次变化不一定导致一个新的计算结果时,我们使用derivedStateOf函数。如果用remember(key)来实现的话,每次key变化都会去计算产生一个新的state,如果使用derivedStateOf,就可以避免这样不必要的重组开销。

上面粗体字描述的使用场景还不是最准确的,请继续往后看。如果觉得分析过程太长了,可以直接看4.5节的总结。

我们以监听登录页面的输入的验证码框为例,使用derivedStateOf实现输入监听,代码如下(代码4.2.1)。

//代码4.2.1
@Composable
fun DerivedStateExample() {
    var input by remember {
        mutableStateOf("")
    }

    val enabled by remember {
        derivedStateOf {
            input.length >= 6
        }
    }

    Log.d(TAG, "DerivedStateExample: 1")

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        TextField(
            value = input,
            onValueChange = {
                input = it
            })

        Spacer(modifier = Modifier.height(height = 10.dp))

        Log.d(TAG, "DerivedStateExample: 2")
        
        Button(
            onClick = { /*TODO*/ },
            enabled = enabled
        ) {
            Text("登录")
        }
    }
}

当用户输入时,触发TextField的onValueChange回调,更新input值,同时enabled重新计算,如果enabled的值不变,Button就没必要发生重组。

以上是我们设想的流程。但是,实际上,打出Log会发现,当输入有变化时,1和2处的Log都会打出来,这就说明,只要input变化,整个Composable仍会全部参与重组,这与我们所想貌似不符。

换句话说,如果我们换一种写法,单纯把变量enabled的声明改成如下的直接赋值的形式。

val enabled = input.length >= 6

这种直接赋值的写法同样也能使Button的禁用状态正常随input进行更新,同样也是打出Log1和2,与derivedStateOf的表现一致。

到这里,似乎事情越发扑朔迷离,我们在这种场景使用derivedStateOf并没有达到减少重组次数的目的,也就是与remember(key)或者直接赋值无异。这究竟是怎么回事?

其实原因与derivedStateOf无关。因为input变了,而TextField用到了input的值,所以不论enabled是否变化,都会使input变量所在的整个Composable域发生重组。

那么如果我们把代码拆开呢?按照前面3.3节提到的思想,即“状态向下,事件向上”,代码如下(代码4.2.2)。

//代码4.2.2
@Composable
fun DerivedStateExample() {
    var input by remember {
        mutableStateOf("")
    }

    val enabled by remember {
        derivedStateOf {
            input.length >= 6
        }
    }

    Log.d(TAG, "DerivedStateExample: 1")

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        DerivedStateExampleInner(input = input, enabled = enabled, onUserInput = {
            input = it
        })
    }
}

@Composable
fun DerivedStateExampleInner(input: String, enabled: Boolean, onUserInput: (String) -> Unit) {
    TextField(
        value = input,
        onValueChange = onUserInput)

    Spacer(modifier = Modifier.height(height = 10.dp))

    Log.d(TAG, "DerivedStateExample: 2")

    Button(
        onClick = { /*TODO*/ },
        enabled = enabled
    ) {
        Text("登录")
    }
}

运行,然后随便输入几个字符,发现仍然还是打出了Log1和2,换句话说,同样没有任何的重组减少了。

这又是怎么回事呢?让我们再仔细想想derivedStateOf的描述,它的监听触发时,如果计算结果与之前无异,则不产生新的状态,那我们不妨从“状态”入手,再修改上述代码如下(代码4.2.3)。

//代码4.2.3
@Composable
fun DerivedStateExample() {
    val input = remember {
        mutableStateOf("")
    } //这里的input变成了State类型,而不再是通过by得到的String类型

    val enabled = remember {
        derivedStateOf {
            input.value.length >= 6
        }
    } //enabled同样也变成了State类型

    Log.d(TAG, "DerivedStateExample: 1")

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        DerivedStateExampleInner(input = input, enabled = enabled, onUserInput = {
            input.value = it
        })
    }
}

@Composable
fun DerivedStateExampleInner(input: State<String>, enabled: State<Boolean>, onUserInput: (String) -> Unit) {
    TextField(
        value = input.value,
        onValueChange = onUserInput)

    Spacer(modifier = Modifier.height(height = 10.dp))

    Log.d(TAG, "DerivedStateExample: 2")

    Button(
        onClick = { /*TODO*/ },
        enabled = enabled.value
    ) {
        Text("登录")
    }
}

现在,代码中我们不使用by委托的方式给变量赋值,input和enabled不再是String和Boolean类型,而是State<String>State<Boolean>,然后函数参数传入的是State类型

这时,如果触发输入回调,尽管input.value改变,但是对于DerivedStateExample这个Composable,enabled触发监听变成新值之前,并没有任何能够触发其重组的条件,因此并不会发生重组,于是我们的目的就这么达到了。

运行试一下,发现在输入6个字符之前,Log1只会在最初始的时候运行一次,而不是像之前一样每次都运行。换句话说,只有derivedStateOf中计算出了不同的结果时,才会触发DerivedStateExample范围的重组,其他时候只触发DerivedStateExampleInner的重组(毕竟input一直在变,这个TextField肯定得重组)。

而如果使用之前提到的,直接赋值,或者remember(key)的场景,则并不能像这样减少重组次数。

事实上,如果更钻牛角尖一些,可以把代码改成下面这样(代码4.2.4),这样在enabled的值变化之前,只会有DerivedStateExampleInner范围的重组,把重组限制在了最小的范围。但是,像代码4.2.4这样的写法其实是没必要的,因为它很奇怪,TextField在结构上理应与Button同一级。不过,如果在很极端的情况下真的遇到了某些性能问题,这种写法为减少重组、提高性能提供了一个思路。

//代码4.2.4
@Composable
fun DerivedStateExample() {
    val input = remember {
        mutableStateOf("")
    }

    val enabled = remember {
        derivedStateOf {
            input.value.length >= 6
        }
    }

    Log.d(TAG, "DerivedStateExample: 1")

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        DerivedStateExampleInner(input = input, onUserInput = {
            input.value = it
        })

        Spacer(modifier = Modifier.height(height = 10.dp))

        Log.d(TAG, "DerivedStateExample: 2")

        Button(
            onClick = { /*TODO*/ },
            enabled = enabled.value
        ) {
            Text("登录")
        }
    }
}

@Composable
fun DerivedStateExampleInner(input: State<String>, onUserInput: (String) -> Unit) {
    Log.d(TAG, "DerivedStateExample: 3")
    TextField(
        value = input.value,
        onValueChange = onUserInput)
}

看到这里,相信你对derivedStateOf的使用场景和具体的使用注意事项有了更深的了解。但是到这里,我们仍没有结束,我们继续思考一下,在例如输入框和按钮的绑定这种场景下,derivedStateOf真的是必要的吗?前面说了,remember(key)甚至是直接赋值都能完成同样的功能,而在刚刚列举的场景下,其实UI并没有多复杂,即使重组了,也对系统来说完全负担得起,甚至是绰绰有余,所以derivedStateOf就显得很多余了,那么,有没有更加更加需要derivedStateOf出场——或者说它能够发挥更大作用,着实会优化一些性能的应用场景呢?

其实是有的,例如,我们有一个很大的LazyColumn,现在我们希望:

  • 在当前列表的第一个可见项(从上到下)的index大于50时,出现一个“回到顶部”的按钮。

  • 又或者,我们希望有一个“列表分组计数器”,如果当前的第一个可见项(从上到下)的index>50,则显示1,如果>100,则显示2,如果>150,则显示3...以此类推。

我们以第二个例子来分析,代码如下。

//代码4.2.5
@Composable
fun DerivedStateExample() {
    val state = rememberLazyListState()

    val currentGroup by remember {
        derivedStateOf {
            state.firstVisibleItemIndex / 50
        }
    }

    LazyColumn(
        state = state,
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        items(200) {
            Text(
                text = it.toString(),
                Modifier
                    .fillMaxWidth()
                    .padding(10.dp)
                    .background(Color.Yellow),
                textAlign = TextAlign.Center
            )
        }
    }

    ManyContents(current = currentGroup)
}

@Composable
fun ManyContents(current: Int) {
    Log.d(TAG, "DerivedStateExampleInner: aaa")
    Text(text = current.toString())
}

ManyContents依赖于currentGroup这个变量,那么,我们就可以让currentGroup成为derivedStateOf的,这样的话,当我们快速滑动列表时,仅当state.firstVisibleItemIndex / 50的值发生改变,ManyContents才会重组,而一般滑动时,只有LazyColumn内部会重组。假设ManyContents是一个内容很多的Composable,这种写法就减小了重组次数,提高了性能,这是remember(keys)的写法无法做到的。

4.3 remember(keys)和derivedStateOf共同出击

在第4节最后提出的几个问题中,还有最后一个问题,就是“是否有一种场景,derivedStateOf也有可能需要被包裹在象征着监听的remember(keys)里?

其实是有的,让我们看看官方文档的例子

@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("ptq", "power")) {

    val todoTasks = remember { mutableStateListOf<String>() }

    //仅当highPriorityTasks或者todoTasks有变化时才计算highPriorityTasks,而并非每次重组都重新计算
    val highPriorityTasks by remember(highPriorityKeywords) {
        derivedStateOf {
            todoTasks.filter {
                highPriorityKeywords.any { keyword ->
                    it.contains(keyword)
                }
            }
        }
    }

    Box(Modifier.fillMaxSize()) {
        //列表,分别展示highPriorityTasks和todoTasks
        LazyColumn(modifier = Modifier.fillMaxSize()) {
            items(highPriorityTasks) {
                Card(modifier = Modifier.fillMaxWidth(), contentColor = Color.Blue) {
                    Text(text = it)
                }
            }
            item {
                Divider(modifier = Modifier.fillMaxWidth(), color = Color.LightGray)
            }
            items(todoTasks) {
                Card(modifier = Modifier.fillMaxWidth(), contentColor = Color.Yellow) {
                    Text(text = it)
                }
            }
        }

        //用户在此输入task,点击按钮时将其添加至todoTasks
        Row(modifier = Modifier
            .align(Alignment.BottomCenter)
            .padding(vertical = 20.dp)) {
            var input by remember {
                mutableStateOf("")
            }
            TextField(value = input, onValueChange = {
                input = it
            })
            Button(onClick = {
                todoTasks.add(input)
            }) {
                Text("添加")
            }
        }
    }
}

这个例子中,用户在最底下的输入框输入todoTask,点击按钮时将其添加至todoTasks列表,LazyColumn的todoTasks部分更新,与此同时highPriorityTasks会自动计算,如果刚刚添加的todoTask包含keyword,则highPriorityTasks更新,进而LazyColumn的highPriorityTasks部分也更新。

但是需要注意的是,这个例子与之前4.2中的各个例子不同的地方是它有一个参数highPriorityKeywords是从外部传入的,这导致了什么呢?这导致如果highPriorityKeywords按照以下写法,则其remember的lambda块只会在最开始创建一次,也就是说,即使函数参数中传入的highPriorityKeywords变化了,这个lambda块内部的highPriorityKeywords也不会再变了,这就会导致一旦函数参数有变化,计算结果就会有误,因此我们需要专门再对函数参数作监听,即使用remember(key)去监听highPriorityKeywords。

    val highPriorityTasks by remember {
        derivedStateOf {
            todoTasks.filter {
                highPriorityKeywords.any { keyword ->
                    it.contains(keyword)
                }
            }
        }
    }

以上就是同时需要使用到remember(key)和derivedStateOf的场景。

4.4 derivedStateOf可以用于把状态组合起来吗?

最后一个小问题,例如以下代码。

var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullName = remember { derivedStateOf { "$firstName $lastName" } }

这段代码想使用derivedStateOf把firstName和lastName组合起来,得到新的fullName,这其实是完全没有必要的,这类“监听”其实就是在4.1节中提到的情形二了。

fullName直接用赋值的写法就可以了。

val fullName = "$firstName $lastName"

4.5 总结一下

现在,我们再来总结一下derivedStateOf的有关问题。

1、derivedStateOf一定要被包裹在remember里吗?

是的,derivedStateOf返回了一个状态,那么它的使用也应该包裹在remember里。

2、remember(keys)和derivedStateOf的监听有没有什么区别呢?derivedStateOf的使用场景究竟是什么?

remember(keys)和derivedStateOf的监听是有区别的,区别在输入和输出的数量之差上。

当一次变化不一定导致一个新的计算结果,且这个变化非常快(而我们需要随之衍生推导的计算结果并不需要那么快的变化)时,推荐使用derivedStateOf,这将在一定程度上减少重组次数,提高性能。

具体地,例如,根据快速滑动的LazyColumn进行衍生计算,或者根据快速执行的动画进行衍生计算,且每次快速变化不一定导致一个新的衍生计算结果时。

此外,上面提到的这种使用场景,更多地是“只有使用derivedStateOf才能达到某些目的”的情况,如果实际开发中,就偏要想用derivedStateOf做“一次变化导致一个新的计算结果”的监听,当然也是ok的~

而在实际开发中,其实需要derivedStateOf的场景是非常少的,在绝大多数的场景下,使用remember(keys)就能完成监听需求,这种监听的输入和输出是一对一的。

另外还有一种直接赋值计算的“监听”方式,当“监听”发生得特别频繁(几乎每一次重组都必须要重算一遍)时,就可以使用这种方式,因为即使使用remember(keys),它也是要一遍一遍重新计算的嘛,所以其实区别不大。此外,当“监听”不那么频繁,但是计算较为简单(或者是仅仅进行一些简单的状态组合)时,也可以使用这种方式,反正代价不大嘛~

3、必须监听至少两个state才能使用derivedStateOf吗?如果只想监听一个state我可以用吗?

现在看来,这个问题完全就没有问到点子上,derivedStateOf用于监听变量的变化去进行衍生计算,而这与有几个变量无关。

4、是否有一种场景需要derivedStateOf和remember(keys)同时使用?

有的,当keys是函数参数时,仅用remember是不够的,因为remember的lambda块只会初始化一次,这时候需要remember(keys)来监听这个变化。

5 小结

本篇文章讨论了有关Composable状态的相关内容,包括remember、derivedStateOf以及一些原理和具体的适用场景和案例代码等(文章中出现的大段代码都是可以直接复制进Android Studio运行的)。

当然,实际的开发场景中可能大多数时候并不需要关注文章中提到的一些细枝末节,在大多数场景下,即使不关注这些,Compose的性能其实也是很优越的。

嗯,8400多字了,就写到这里吧。