Compose重组及重组范围优化

239 阅读4分钟

Compose重组及重组范围优化

更新UI流程对比

View架构

在原生的View,命令式架构中,如果要使用新的数据,来刷新更改某个控件的显示状态,可以调用这个控件类的状态set方法,例如将某个TextView的文本内容进行修改:

binding.tvTest.text = "test a very very very very very very long text"

TextView的setText方法会触发重新绘制,但是如果这个TextView的父控件的宽高没有发生变化,那么就不会触发重新绘制。如果这个父控件的宽高发生了变化,那么就会触发重新绘制。并且所有受影响的View和ViewGroup控件均会更新。

Compose架构

在 Compose 架构中,您只需要更新这个新的可观察状态的数据,然后就可以自动地重新调用一次可组合函数。这样做会导致函数进行重组。Compose 框架可以智能地仅重组已更改的组件。大致的更新流程上我认为是相同的,尽量只更新受影响的Composeable函数。

这里感受感受写法的差异。

例如,假设有以下可组合函数,用于显示一个按钮,并记录点击的次数:

@Composable
fun TestDemo(){
    var clickTimes by remember { mutableStateOf(0) }

    ClickCounter(clickTimes){
        clickTimes++
    }
}

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

每次点击该按钮时,调用方都会在lambda里更新 clicks 的值。Compose 会再次调用 lambda 与 Text 函数以显示新值;此过程称为“重组”。不依赖于该值的其他函数不会进行重组。

Compose 编译器在背后做了大量工作来保证 recomposition 范围尽可能小,从而避免了无效开销。

重组作用域

还是以这个流程举例

val TAG = "TestDemoPage"
@Composable
fun TestDemo() {

    var clickTimes by remember { mutableStateOf(0) }
    Log.d (TAG, "TestDemo recomposition")
    Button(onClick = { clickTimes += 1 }.also {
        Log.d (TAG, "lambda recomposition")
    }) {
        Log.d (TAG, "Button content recomposition")
        Text("I've been clicked $clickTimes times").also {
            Log.d (TAG, "Text recomposition")
        }
    }.also {
        Log.d (TAG, "Button recomposition")
    }
}

当按钮点击之后,只有Text和Button内容这个lambda会进行重组。外部的TestDemo和这个Button组件不会进行重组。

为什么不是只有Text进行重组呢?

因为Android系统基于C++编译的虚拟机,在调用到clickTimes变化之后,实际会走两个步骤,将这个新的clickTimes拼接成一个新字符串:

I've been clicked $clickTimes times

然后将这个新的字符串赋值给Text的text属性,这个过程是在Button的lambda中完成的,所以需要将Button Content这个lambda也进行重组。

将委托改为等于

val clickTimes = remember { mutableStateOf(0) }

这种写法会导致Button和外部的TestDemo重组吗?

依然不会。

  • 第一,Compose 关心的是代码块中是否有对 state 的 read,而不是 write。

  • 第二,这里的 = 并不意味着 text 会被赋值新的对象,因为 text 指向的 MutableState 实例是永远不会变的,变的只是内部的 value

将字符串提取到外面

val TAG = "TestDemoPage"
@Composable
fun TestDemo() {

    var clickTimes by remember { mutableStateOf(0) }
    Log.d (TAG, "TestDemo recomposition")

    val stringTest = "I've been clicked $clickTimes times"

    Button(onClick = { clickTimes += 1 }.also {
        Log.d (TAG, "lambda recomposition")
    }) {
        Log.d (TAG, "Button content recomposition")
        Text(stringTest).also {
            Log.d (TAG, "Text recomposition")
        }
    }.also {
        Log.d (TAG, "Button recomposition")
    }
}

这种写法会导致Button和外部的TestDemo重组吗?

答案是上面所有的打印log的地方都会参与重组,因为stringTest是一个变量,外部的TestDemo和Button对它都有read的可能,这个变量的变化会影响到所有的读取方。

所以,这种变量里直接使用了remember变量的写法是不推荐的。

日志:

TestDemo recomposition
lambda recomposition
Button content recomposition
Text recomposition
Button recomposition
插入 重组顺序

还可以看出重组的顺序是从clickTimes这个变量的最紧密的读取方开始,发散进行的。

  • 首先stringTest是直接使用方,所以拥有这个变量的TestDemo会进行重组。
  • lambda代码块在编译后会编译成静态方法,在TestDemo重组调用后,会立即调用lambda代码块的初始化方法,即对其进行重组。
  • Button内容的lambda重组原理同上
  • Text和Button的重组就是Compose的正常流程,编译之后的调用顺序为从内部到外部调用。

将Text用Box包一层

如果我们再使用Box这个组件包裹一下Text,会有什么效果呢?

val TAG = "TestDemoPage"
@Composable
fun TestDemo() {

    var times by remember { mutableStateOf(0) }
    Log.d (TAG, "TestDemo recomposition")

    val stringTest = "I've been clicked $times times"

    Button(onClick = { times += 1 }.also {
        Log.d (TAG, "lambda recomposition")
    }) {
        Log.d (TAG, "Button content recomposition")
        Box {
            Log.d (TAG, "Box recomposition")
            Text(stringTest).also {
                Log.d (TAG, "Text recomposition")
            }
        }
    }.also {
        Log.d (TAG, "Button recomposition")
    }
}

日志打印:

TestDemo recomposition
lambda recomposition
Button content recomposition
Box recomposition
Text recomposition
Button recomposition

可以看到Box的重组是在Button内容的lambda重组之后进行的。而不是在Text的重组最后面。

这是为什么呢?

因为Box的实现是一个inline方法,编译之后会被铺平到调用的地方,而不是按照像Button和Text的层级结构从内到外。

所以Box这种inlne方法,是不会算作为最小重组范围内的。而是和其调用的组件共享重组优先级。

同样的,Column、Row、Box 乃至 Layout 这种容器类 Composable 都是 inline 函数。

优化重组范围最小化

在上面的例子中,我们可以看到,一些看起来类似的写法,所产生的最小重组范围是不一样的。

那么如何优化代码,使重组范围最小化呢。

我们可以使用一个非inline的Composable函数包裹起来,这样就可以避免Box的这种情况。

val TAG = "TestDemoPage"
@Composable
fun TestDemo() {

    var times by remember { mutableStateOf(0) }
    Log.d(TAG, "TestDemo recomposition")
    
    Button(onClick = { times += 1 }.also {
        Log.d(TAG, "lambda recomposition")
    }) {
        Log.d(TAG, "Button content recomposition")
        Wrraper {
            Text("I've been clicked $times times").also {
                Log.d(TAG, "Text recomposition")
            }
        }
    }.also {
        Log.d(TAG, "Button recomposition")
    }
}

@Composable
fun Wrraper(content: @Composable () -> Unit) {
    Log.d(TAG, "Wrraper recomposition")
    Box {
        Log.d(TAG, "Box recomposition")
        content()
    }
}

这样在点击之后,所需要重组范围的就只有Text一个组件了。

结论

Compose 在编译期分析出会受到某 state 变化影响的代码块,并记录其引用,当此 state 变化时,会根据引用找到这些代码块并标记为 Invalid 。在下一渲染帧到来之前 Compose 会触发 recomposition ,并在重组过程中执行 invalid 代码块。Invalid 代码块即编译器找出的下次重组范围。能够被标记为 Invalid 的代码必须是非 inline 且无返回值的 @Composalbe function/lambda,必须遵循 重组范围最小化 原则。

  • 对于 inline 函数,由于在编译期会在调用处中展开,因此无法在下次重组时找到合适的调用入口,只能共享调用方的重组范围。

  • 而对于有返回值的函数,由于返回值的变化会影响调用方,因此无法单独重组,而必须连同调用方一同参与重组,因此它不能作为入口被标记为 invalid