Compose 怎样精细化控制重组,实现局部刷新

2,906 阅读9分钟

在将项目中的页面转成 Compose 实现的过程中,发现控制重组是一件很值得细细说道的事情。

比如下面是一个列表页用来选择 APP 语言,将每个元素添加上随机颜色背景,在点击Item时触发页面变更,可以看到所有的元素背景色都发生了改变,所有组件都发生了重组。但这并不是我们想要的,接下来就要探究如何实现只刷新需要变更的部分,也就是局部重组。

20240529-174728.gif

预期效果应该是:

  1. 被点击的 item 重组为选中状态

  2. 上一个选中的 Item 重组为非选中状态

  3. 标题栏 Save 按钮重组为对应的可点击态\不可点击态

  4. 其它所有不相关的组件都无需触发重组

明确预期后,我们一步步分析探究如何实现

重组优化探究

在 Compose 中,一个很重要的概念是重组(Recompose),可以简单理解为:

  1. 当首次执行@Composable函数时,即将其中的组件(Components)进行组合,最终渲染展示上屏

  2. 当组件需要更新 UI 时,重新组合,更新页面

组合 这个操作本身是会消耗性能的,所以我们要尽可能减少重新 组合 的次数,那么就有以下途径:

  1. 减少重组的次数,不要进行无意义的重组,比如重组前后毫无变化
  2. 缩小重组的范围,只在需要变化的局部进行重组,而不是重组整个界面树

问题一、重组是怎样被触发的

Compose 编译器内部实现了对重组的优化,可以智能重组【需要被重组的部分】,其它【不需要重组的部分】则会被略过

可以简单理解为,当传参发生变化时,只有使用到这些参数的组件会重组,而没用到的就不会

简单的示例,点击增加数字后更新数字显示:

@Composable
private fun Demo() {
    var count by remember { mutableIntStateOf(0) }
    Column(modifier = Modifier.randomColorBg()) {
        Text(
            modifier = Modifier.randomColorBg(),
            text = "count = $count"
        )
        Button(
            modifier = Modifier.randomColorBg(),
            onClick = { count++ }
        ) {
            Text(
                modifier = Modifier.randomColorBg(),
                text = "Increase"
            )
        }
    }
}

点击自增后的效果:

  1. Demo函数中的Column()Text()Button()都被重组了
  2. Button() 当中的 Text() 没有重组

在上面的示例中,发生变化的参数是count,使用到它的组件只有展示"count = $count"Text(),那为什么其它组件也重组了呢?

问题二、哪些内容会被重组

我们回看智能重组的原则:

当传参发生变化时,只有使用到这些参数的组件会重组

可以简单理解为:

使用了这个参数的 @Composable 方法(父组件),会重新调用(重组),从而让方法内的所有子组件也都被重组

这也是重组作用域的概念

回看代码,将所有的组件按照 UI 层级抽象可以得到:

Demo

  • Column

    • Text1

    • Button

      • Text2

Text1进行了传参count,那么实际获取count内容的地方就是Column

  • 所以Column对应的重组作用域(Column 内部的Text1Button)都会重组
  • Button 的内部是自己独立的重组作用域了,因为Button的内部(Text2)没有引用的参数变化,所以Button的重组作用域不会重组(Text2没有重组)
@Composable
private fun Demo() {
    var count by remember { mutableIntStateOf(0) }
    Column(modifier = Modifier.randomColorBg()) {
        //Text1
        Text(
            modifier = Modifier.randomColorBg(),
            text = "count = $count" //此处使用了 count 参数
        )
        Button(
            modifier = Modifier.randomColorBg(),
            onClick = { count++ }
        ) {
            //Text2
            Text(
                modifier = Modifier.randomColorBg(),
                text = "Increase"
            )
        }
    }
}

总结来说就是:

Text1的调用处Column 方法使用了参数countcount变化导致了Column重组,其内部的所有组件即重组作用域,都重组了

但其实上面的理论并不是完全正确的

我们在Demo里额外增加一个和 Column 同级的 Text3,按照上面的理论,count变化会导致Column重组,如果新增的 Text3并不在 Column 里面(即不属于它的重组作用域),也没有引用参数的变化,那它应该不会被重组,对吗?

@Composable
private fun Demo() {
    var count by remember { mutableIntStateOf(0) }
    Column(modifier = Modifier.randomColorBg()) {
        ···
    }
    //Text3
    Text(
        modifier = Modifier.randomColorBg(),
        text = "Demo"
    )
}

来看实际结果:

Column 同级的 Text3,还是重组了。

原因是 Column组件(也包括RowBox等),其内部是用 inline 修饰的内联方法,这就会导致所有 Column内部的组件,实质上都同属于【调用 Column 的组件】内部,也就是Demo

按照实际的重组作用域来进行抽象会得到:

Demo

  • Column

  • Text1

  • Button

    • Text2
  • Text3

所以本质上是 Demo方法内使用的参数变更了,内部的ColumnText1ButtonText3都属于它重组作用域,所以也都被重组了

如何影响重组

到这里我们终于理清了为什么被重组、哪些会被重组的问题,接下来就要思考如何改动可以影响重组

再次总结回顾重组的概念:

当传参变化,使用了这个参数的 @Composable 方法(父组件),会重新调用(重组),从而让方法内的所有子组件(重组作用域)也都被重组

继续思考:当父组件重组时,所有子组件重组,那子组件中的孙组件会重组吗?

答案前面其实讲过:将子组件套用上面的概念,如果子组件内使用到的参数没有变化,那么不会触发其内部的重组,孙组件也就不会重组

一、嵌套

依据这个理念,可以想到影响重组的方式之一就是——嵌套,将不需要变化的组件放到新的重组作用域中,也即用一个新的方法包裹(非内联方法)

上面的示例可以改写成:

@Composable
private fun Demo() {
    var count by remember { mutableIntStateOf(0) }
    Column(modifier = Modifier.randomColorBg()) {
        CountText(count)
        IncreaseButton(onClick = { count++ })
    }
    DemoText()
}

@Composable
private fun CountText(count:Int) {
    Text(
        modifier = Modifier.randomColorBg(),
        text = "count = $count"
    )
}

@Composable
private fun IncreaseButton(onClick: () -> Unit) {
    Button(
        modifier = Modifier.randomColorBg(),
        onClick = onClick
    ) {
        Text(
            modifier = Modifier.randomColorBg(),
            text = "Increase"
        )
    }
}

@Composable
private fun DemoText() {
    Text(
        modifier = Modifier.randomColorBg(),
        text = "Demo"
    )
}

新的重组作用域:

Demo

  • Column

  • CountText

    • Text1
  • IncreaseButton

    • Button

      • Text2
  • DemoText

    • Text3

四个都重组,但其中IncreaseButtonDemoText内部没有引用参数变更,所以其内部的ButtonText3都没有重组

这里可以继续优化,将 Column 内部也用一个方法包裹(实质上是将其从内联函数变成了非内联函数),让其自己内部实现单独的重组作用域,并将参数转移到内部:

@Composable
private fun Demo() {
    Column(modifier = Modifier.randomColorBg()) {
        Content()
    }
    DemoText()
}

@Composable
private fun Content() {
    var count by remember { mutableIntStateOf(0) }
    CountText(count)
    IncreaseButton(onClick = { count++ })
}

这样修改下来,Demo方法内不再有任何参数变更,ColumnDemoText完全不会触发重组, 重组发生在了Content内部,而实际上也只有CountText内真正发生了重组,一切终于达到了预期的效果

嵌套实现了我们想要的效果,但这是最终的解决方案吗?

首先要说,对 Compose 方法的嵌套并不像传统 View 体系中的布局嵌套那样影响性能,它确实是一个可行的方式

原因主要有两点:

  • Compose 中不允许被多次测量,每个子元素只允许被测量一次,因此并不会因为嵌套层级的增加而导致测量次数的指数爆炸问题,正所谓 “一时嵌套一时爽,一直嵌套一直爽”。
  • 实际上 Compose 编译器会对 Composable 函数施加一些 “魔法”,而 Compose runtime 会持有对这些 Composable 函数的引用,它们可能在运行时以任意顺序被重新执行、并行执行(可能多线程)、甚至被跳过执行,所以它们并不像我们传统意义上的标准函数调用堆栈那样,调用顺序也不会跟我们代码书写的那样按照先后顺序一层一层的往下调用再返回。

当然嵌套多了的话,也不能说完全没有影响,至少会增加 Compose 编译器的编译时间成本,还有就是最终生成的DEX包可能会大一些。

二、状态下降

再次回顾重组的概念:

当传参变化,使用了这个参数的@Composable方法(父组件),会重新调用(重组),从而让方法内的所有子组件(重组作用域)也都被重组

为了影响重组范围,前面我们针对后半句,将不需要重组的部分放到了新的重组作用域中,规避了重组

现在我们看回第一个条件:当传参变化——如果传参没有变,那不就不会触发重组了?

可传参不变,内容怎么更新呢?先来看看 Compose 是如何判断参数变化的

每一次可组合函数被调用的时候,会检查所有传入的参数,如果本次传入的参数和上一次传入的参数都是相同的话(这里指的相同是指结构性相等,在kotlin中指的是==,在java中指的是调用对象的equals()方法) ,那么Compose就会略过调用这个可组合函数,以达到最快的重组效率。

既然调用的是对象的equals()方法,那把实际用到的参数再包裹一层,对象没变值变了不就行了

这里提供两种思路:

  1. 传参从传具体的值改为传返回值的方法,也就是通过lambda将使用值的场景由父组件转移到子

传递的参数() -> Int不会发生变化,所以尽管父组件Democount发生了变化,但它并没有直接作为参数被使用,所以也没有让父组件产生重组

@Composable
private fun Demo() {
    var count by remember { mutableIntStateOf(0) }
    Column(modifier = Modifier.randomColorBg()) {
        CountText(getCount = { count } )
        Button(
            modifier = Modifier.randomColorBg(),
            onClick = { count++ }
        ) {
            Text(
                modifier = Modifier.randomColorBg(),
                text = "Increase"
            )
        }
    }
}

@Composable
private fun CountText(getCount: () -> Int) {
    Text(
        modifier = Modifier.randomColorBg(),
        text = "count = ${getCount()}"
    )
}
  1. 传参从传具体的值改为传包裹值的state

上一个方法中,我们直接通过by代理得到了remember { mutableIntStateOf(0) }的结果,而如果直接使用=,得到的则是包裹着countState,将State作为传参传递,自然也没有发生对象变化

@Composable
private fun Demo() {
    // 注意这里使用了 = 而不是 by
    val countState = remember { mutableIntStateOf( 0 ) }
    Column(modifier = Modifier.randomColorBg()) {
        CountText(countState)
        Button(
            modifier = Modifier.randomColorBg(),
            onClick = { countState.value++ }
        ) {
            Text(
                modifier = Modifier.randomColorBg(),
                text = "Increase"
            )
        }
    }
}

@Composable
private fun CountText(countState: State<Int>) {
    Text(
        modifier = Modifier.randomColorBg(),
        text = "count = ${countState.value}"
    )
}

总结来看,想要只让部分组件重组,按照上面所述,至少有两种方案:

  1. 父组件重组,所有不该重组的子组件嵌套一层来规避重组

  2. 将对参数的实际使用转移到需要接收变化的子组件中,完全避免父组件和其它子组件的重组

明显可以看出,方式2更加符合预期。实际上这也正是 Compose 中的基本开发思想:状态向下,事件向上

重组优化实践

  1. 状态向下,事件向上

通过示例搞明白如何优化重组范围后,回到开头部分,按照上面的思路,将状态往下分发、将事件往上返回,对语言选择页进行重组优化:

@Composable
private fun LanguageSettingPage(
    viewModel: LanguageSettingViewModel = viewModel()
) {
    val languageList = viewModel.languages.toList()
    // 当前选中的语言Flow转换为Compose中的 State,这样在发生变化的时候,就可以让所有用到的地方刷新 UI
    val selectedCodeState = viewModel.selectedLanguageCodeFlow.collectAsState() 
    Column(
        modifier = Modifier
            .fillMaxSize()
            .statusBarsPadding()
            .randomColorBg()
    ) {
        // 标题栏
        TitleBar(
            getIsSaveEnabled = { selectedCodeState.value != viewModel.localeLanguageCode },  //传递 lambda
            onSaveClick = { viewModel.saveLanguageSetting() }
        )
        // 语言列表
        Content(
            languageList = languageList,
            selectedCodeState = selectedCodeState,  //传递 state
            onLanguageSelected = { viewModel.onLanguageSelected(it) }
        )
    }
}

@Composable
private fun TitleBar(getIsSaveEnabled: () -> Boolean, onSaveClick: () -> Unit) {
    CommonTitleBar(
        modifier = Modifier.randomColorBg(),
        title = stringResource(id = R.string.language),
        onBackClick = { finish() }
    ) {
        val isEnable = getIsSaveEnabled()

        Text(
            modifier = Modifier
                .fillMaxHeight()
                .align(Alignment.CenterEnd)
                .debouncedClickable(
                    enabled = isEnable,
                    onClick = onSaveClick
                )
                .padding(horizontal = 20.dp)
                .wrapContentSize(align = Alignment.Center)
                .randomColorBg(),
            text = stringResource(id = R.string.save),
            style = TextStyles.mainBody,
            color = if (isEnable) Colors.BasicPrimary else Colors.SecondaryPrimaryDisable
        )
    }
}

@Composable
private fun Content(
    languageList: List<LanguageBean>,
    selectedCodeState: State<String>,
    onLanguageSelected: (LanguageBean) -> Unit
) {
    SideEffect {
        logMine("Content")
    }
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .recomposeHighlighter()
    ) {
        items(
            items = languageList,
            // 优化 使用key避免重复渲染
            key = { it.code }
        ) { language ->
            LanguageItem(
                language = language,
                selectedCodeState = selectedCodeState,
                onLanguageSelected = onLanguageSelected
            )
        }
    }
}

@Composable
private fun LanguageItem(
    language: LanguageBean,
    selectedCodeState: State<String>,
    onLanguageSelected: (LanguageBean) -> Unit
) {
    val isSelected by remember {
        derivedStateOf {
            language.code == selectedCodeState.value
        }
    }
    // 单个语言 Item,横向排列,内部元素竖向居中
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .height(63.dp)
            .debouncedClickable(
                enabled = isSelected.not(),
                onClick = { onLanguageSelected(language) }
            )
            .padding(horizontal = 20.dp)
            .randomColorBg(),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            modifier = Modifier.weight(1f),
            text = language.language,
            style = TextStyles.body,
            // 选中状态下,显示高亮颜色
            color = if (isSelected) Colors.BasicPrimary else Colors.TextWhiteImportant
        )
        // 选中状态下,显示勾选图标
        if (isSelected) {
            IconFontText(
                iconRes = R.string.ic_check,
                iconColor = Colors.BasicPrimary
            )
        }
    }
}

现在点击 Item,可以实现预期的效果,只重组三块地方了

  1. 衍生状态(derivedStateOf

但在此基础上,仍有可优化的空间:Save 按钮虽然同样依赖于selectedLanguageCode的变化,但不是每次变化都需要重组,它的变化条件是当前选中语言是否不同于默认语言

针对此种场景,可以使用 Compose 中的derivedStateOf衍生状态来处理,即从selectedCodeState中衍生出一个新的State,用于判断是否需要显示保存按钮。这也有点类似于Flow中的distinctUntilChanged操作

@Composable
private fun LanguageSettingPage(
    viewModel: LanguageSettingViewModel = viewModel()
) {
    val languageList = viewModel.languages.toList()
    // 当前选中的语言Flow转换为Compose中的 State,这样在发生变化的时候,就可以让所有用到的地方刷新 UI
    val selectedCodeState = viewModel.selectedLanguageCodeFlow.collectAsState()
    // 从selectedCodeState中衍生出一个新的State,用于判断是否需要显示保存按钮
    val isSaveEnabledState = remember { 
 derivedStateOf { selectedCodeState.value != viewModel.localeLanguageCode } 
 } 
    Column(
        modifier = Modifier
            .fillMaxSize()
            .statusBarsPadding()
            .randomColorBg()
    ) {
        // 标题栏
        TitleBar(
            isSaveEnabledState = isSaveEnabledState,
            onSaveClick = { viewModel.saveLanguageSetting() }
        )
        // 语言列表
        Content(
            languageList = languageList,
            selectedCodeState = selectedCodeState,
            onLanguageSelected = { viewModel.onLanguageSelected(it) }
        )
    }
}

@Composable
private fun TitleBar(isSaveEnabledState: State < Boolean > , onSaveClick: () -> Unit) {
    CommonTitleBar(
        modifier = Modifier.randomColorBg(),
        title = stringResource(id = R.string.language),
        onBackClick = { finish() }
    ) {
        Text(
            modifier = Modifier
                .fillMaxHeight()
                .align(Alignment.CenterEnd)
                .debouncedClickable(
                    enabled = isSaveEnabledState.value,
                    onClick = onSaveClick
                )
                .padding(horizontal = 20.dp)
                .wrapContentSize(align = Alignment.Center)
                .randomColorBg(),
            text = stringResource(id = R.string.save),
            style = TextStyles.mainBody,
            color = if (isSaveEnabledState.value) Colors.BasicPrimary else Colors.SecondaryPrimaryDisable
        )
    }
}

做完以上内容,对于这个页面的重组相关优化基本就达到极致了。还有一些使用 drawBind 等方式的优化在此页面用不上,就不过多赘述

最后给页面加上动画

@Composable
private fun TitleBar(isSaveEnabledState: State<Boolean>, onSaveClick: () -> Unit) {
    CommonTitleBar(
        title = stringResource(id = R.string.language),
        onBackClick = { finish() }
    ) {
        val saveButtonColor by animateColorAsState(
            targetValue = if (isSaveEnabledState.value) {
                Colors.BasicPrimary
            } else {
                Colors.SecondaryPrimaryDisable
            }
        )
        Text(
            modifier = Modifier
                .fillMaxHeight()
                .align(Alignment.CenterEnd)
                .debouncedClickable(
                    enabled = isSaveEnabledState.value,
                    onClick = onSaveClick
                )
                .padding(horizontal = 20.dp)
                .wrapContentSize(align = Alignment.Center),
            text = stringResource(id = R.string.save),
            style = TextStyles.mainBody,
            color = saveButtonColor
        )
    }
}

@Composable
private fun LanguageItem(
    language: LanguageBean,
    selectedCodeState: State<String>,
    onLanguageSelected: (LanguageBean) -> Unit
) {
    val isSelected by remember {
        derivedStateOf {
            language.code == selectedCodeState.value
        }
    }
    // 单个语言 Item,横向排列,内部元素竖向居中
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .height(63.dp)
            .debouncedClickable(
                enabled = isSelected.not(),
                onClick = { onLanguageSelected(language) }
            )
            .padding(horizontal = 20.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        val textColor by animateColorAsState(
            targetValue = if (isSelected) Colors.BasicPrimary else Colors.TextWhiteImportant
        )
        Text(
            modifier = Modifier.weight(1f),
            text = language.language,
            style = TextStyles.body,
            // 选中状态下,显示高亮颜色
            color = textColor
        )
        // 选中状态下,显示勾选图标
        AnimatedContent(targetState = isSelected) { isSelected ->
            if (isSelected) {
                IconFontText(
                    iconRes = R.string.ic_check,
                    iconColor = Colors.BasicPrimary
                )
            }
        }
    }
}

最终实机效果