Jetpack Compose中那些离奇的重组情况,你遇到了吗?

缘起

真正使用Compose做线上项目还是在两年前了,详见这篇文章《直播、聊天交友APP的开发及上架GooglePlay总结【Compose版】》,文章地址在:https://juejin.cn/post/7042181171706331150。去年由于职位的变动移交给了面向海外的团队人员开发,后来虽然没有专门做Compose的项目了,但是自己写Android端示例项目或者桌面端项目的时候都会第一选择Compose来进行开发。

最近海外组的小伙伴做复盘的时候发现一件离奇的事情,Compose的“重组”在有些情况下没有按照预想的来,是我们预想不对呢?还是出现了其它隐形的影响重组的因素呢?官方说的所有函数类型(lambda)是稳定的类型到底靠不靠谱呢?

注: 该文章基于Compose 1.3.0版本编写,其它版本暂未进行实验。

场景复现

首先我们要把遇到的问题重新复现出来,这种情况也是费了我九牛二虎之力,由于思维的惯性以及多年没有继续深耕Compose,说多了都是泪。

主要的UI效果很简单,第一层是一个Text和一个Box组件,Text组件中的文本数量跟随下层Button组件的点击次数不断增加,Box组件也添加了点击事件,点击也可使得数字增加。

Snipaste_2023-06-28_16-32-27.png

场景类Activity如下所示,已将部分代码精简处理,注意其中的mTemp变量,虽然全局都没有使用它。我们主要需要关注的是 WrapperBox() 函数,它包含了一个Modifier参数和函数类型的参数,按官方的说法来说应该是不会重组的:

class SceneActivity : ComponentActivity() {

    private val mCurrentNum = mutableStateOf(0)

    // 这个注释打开、关闭会影响WrapperBox进行重组
    // private var mTemp = "Hello"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Column {
                Row {
                    Text(
                        text = "当前数量:${mCurrentNum.value}",
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(26.dp)
                            .weight(1f)
                            .colorBg()
                    )

                    WrapperBox(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(26.dp)
                            .weight(1f),
                        onClick = {
                            mCurrentNum.value++
                        })
                }

                Button(onClick = { mCurrentNum.value++ }) {
                    Text(text = "点击增加数量")
                }
            }
        }
    }

    @Composable
    private fun WrapperBox(
        modifier: Modifier,
        onClick: () -> Unit
    ) {
        Box(
            modifier = modifier
                .clickable {
                    onClick.invoke()
                }
                .colorBg()
        )
    }
}

// 扩展的随机背景色修饰符,每次重组都会显示不同颜色
fun Modifier.colorBg() = this
    .background(
        color = randomComposeColor(),
        shape = RoundedCornerShape(4.dp)
    )
    .padding(4.dp)

直接给大家看下不同场景下的效果:

  • 没有mTemp变量的时候

scrcpy1.gif
可以看到,点击按钮的时候,只有左侧的文本组件在重组,文本在跟随点击的数量不断更新,这个情况跟我们所认为的情况是一样的

  • 有mTemp变量的时候

scrcpy2.gif
这个时候除了左侧的文本组件在不断重组,右侧的Box组件居然也在不断重组(变换颜色)。

为什么多了一个变量就会导致原本不会重组的组件发生重组呢?我们分别看下反编译后的源码,已做部分删减处理:

  • 没有mTemp变量的时候
public final class SceneActivity extends ComponentActivity {
    public static final int $stable = 0;

    public void onCreate(Bundle bundle) {
        super.onCreate(bundle);
    
        // ...省略代码
        Modifier weight$default = RowScope.weight$default(rowScopeInstance, SizeKt.m473height3ABfNKs(SizeKt.fillMaxWidth$default(Modifier.Companion, 0.0f, 1, null), Dp.m4662constructorimpl(f2)), 1.0f, false, 2, null);
        composer.startReplaceableGroup(1157296644);
        ComposerKt.sourceInformation(composer, "C(remember)P(1):Composables.kt#9igjgp");
        boolean changed = composer.changed(sceneActivity);
        Object rememberedValue = composer.rememberedValue();
        if (changed || rememberedValue == Composer.Companion.getEmpty()) {
            rememberedValue = (Function0) new Function0<Unit>() { // from class: com.example.recomposationsample.SceneActivity$onCreate$1$1$1$1$1
                
                // ...省略代码
                public final void invoke2() {
                    MutableState mutableState2;
                    mutableState2 = SceneActivity.this.mCurrentNum;
                    mutableState2.setValue(Integer.valueOf(((Number) mutableState2.getValue()).intValue() + 1));
                }
            };
            composer.updateRememberedValue(rememberedValue);
        }
        composer.endReplaceableGroup();

        // 需要注意两个参数:rememberedValue 和最后一个参数0
        sceneActivity.WrapperBox(
            weight$default, 
            (Function0) rememberedValue, 
            composer, 
            0);

        // ...省略代码
    }

    // WrapperBox函数的反编译代码完全相同
    public final void WrapperBox(
        final Modifier modifier, 
        final Function0<Unit> function0, 
        Composer composer, 
        final int i) {
    }        
}
  • 有mTemp变量的时候
public final class SceneActivity extends ComponentActivity {
    public static final int $stable = 8;

    public void onCreate(Bundle bundle) {
        super.onCreate(bundle);
    
        //...省略代码

        // 需注意第二个参数和最后一个参数512
        sceneActivity.WrapperBox(
            RowScope.weight$default(rowScopeInstance, SizeKt.m473height3ABfNKs(SizeKt.fillMaxWidth$default(Modifier.Companion, 0.0f, 1, null), Dp.m4662constructorimpl(f2)), 1.0f, false, 2, null),
            new Function0<Unit>() { // from class: com.example.recomposationsample.SceneActivity$onCreate$1$1$1$1
                //...省略代码

                public final void invoke2() {
                    MutableState mutableState2;
                    mutableState2 = SceneActivity.this.mCurrentNum;
                    mutableState2.setValue(Integer.valueOf(((Number) mutableState2.getValue()).intValue() + 1));
                }
            }, 
            composer, 
            512);

        // ...省略代码
    }

    // WrapperBox函数的反编译代码完全相同
    public final void WrapperBox(
        final Modifier modifier, 
        final Function0<Unit> function0, 
        Composer composer, 
        final int i) {
    }        
}

这里有些小伙伴可能就看不大懂了,强烈先建议仔细阅读下这几篇文章,再回头来看这种情况:

很深入的东西笔者也并未探究出个所以然来,所以我也不误导大家了。总之因为类中多了一个不稳定的变量,导致Compose后续不再有判断是否change的逻辑了,最后一个参数传值也从0变成了512,导致直接重组。怎么解决?我们继续往下看吧。

重组中的注意点

从上文的场景中我们可以看到我们认为的不应该重组的WrapperBox却因为类中一个随意的mTemp变量就导致了重组,这肯定不是我们想要的结果。对于官方所说的所有函数类型 (lambda) Compose编译器会将其视为稳定类型,这一点上我有了怀疑,也可能是我理解的不到位,如有错误还请大佬直接指出,谢谢。

那如何避免这样的情况呢,如何保证传递的参数确实是稳定类型呢?如何减少Compose的重组情况保证性能呢?接下来我们从简单点的示例一点点进行说明。

inline函数

这个是老生常谈的问题了,Column、Row、Box等都是inline函数,它们共享重组作用域,常见示例如下所示:

@Composable
private fun InlineSample1(changeText: String) {
    Column(modifier = Modifier
        .fillMaxWidth()
        .colorBg(),
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        // Text1
        Text(text = "${currentTime()} changeText=$changeText", modifier = Modifier.colorBg())

        Column(modifier = Modifier.colorBg()) {
            // Text2
            Text(text = "${currentTime()} 无参数的文本", modifier = Modifier.colorBg())
        }
    }
}

这个时候虽然Text2跟外界的参数无关,但其仍然因为Column的关系,导致会不断跟随changeText的改变而重组,如下所示:
inline1.gif

如果我们不想让Text2组件重组,那么很简单,第一种方式就是将Column重新包装下,做成非inline函数,如下WrapperColumn:

@Composable
private fun InlineSample2(changeText: String) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .colorBg(),
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        // Text1
        Text(text = "${currentTime()} changeText=$changeText", modifier = Modifier.colorBg())

        // WrapperColumn
        WrapperColumn(modifier = Modifier.colorBg()) {
            // Text2
            Text(text = "${currentTime()} 无参数的文本", modifier = Modifier.colorBg())
        }
    }
}

@Composable
private fun WrapperColumn(modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(4.dp),
        content = content
    )
}

这个时候我们再看重组的情况,Text2中的时间和背景颜色都不会再改变,说明我们做的这一层非inline函数的改造有用。而WrapperColumn的背景还是在改变,这个是因为它和Text1同在一个作用域内,是符合常理的。

inline2.gif
还有一种方式呢,这里也单独作为一小节来说明了,如下所示(可能这也是Compose打心底里推荐我们这么做的)。

多封装(包装)

我们将三个Text组件顺序摆放,第一个Text组件需要读取changeText参数,第二个组件不读取任何参数,第三个组件是根据第二个组件完全一致的封装了一层,那么它们的重组情况你能猜到了吗?

@Composable
private fun RecompositionSample1(changeText: String) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .colorBg(),
        verticalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Text(
            text = "${currentTime()} Change Text $changeText",
            modifier = Modifier.colorBg()
        )

        Text(
            text = "${currentTime()} Final Text1",
            modifier = Modifier.colorBg()
        )

        FinalText2()
    }
}

@Composable
private fun FinalText2() {
    Text(
        text = "${currentTime()} Final Text2",
        modifier = Modifier.colorBg()
    )
}

可以看到重组情况如下所示:
第一个Text会变,因为参数changeText改变了;
第二个Text会变,因为和Text1共享重组作用域,currentTime()和colorBg()方法也会重新执行,所以时间和背景颜色都会改变;
第三个Text不变,因为做了一层包装、隔离,它的改变现在和任何参数无关;
text2.gif

List陷阱

List在Kotlin中是不可修改的,但是Compose却认为它是不稳定的,这也是官方着重强调的一点,千万不要弄混了。

List类型的参数

先看第一个示例,我们直接是用了List类型的参数:


@Composable
fun ListSample1(
    changeText: Long,
    list: List<Int>,
) {

    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {

        Text(
            text = "当前时间:${currentTime(changeText)}",
            modifier = Modifier.colorBg()
        )

        LazyRow(
            horizontalArrangement = Arrangement.spacedBy(4.dp),
            modifier = Modifier.colorBg()
        ) {
            items(
                items = list,
            ) {
                Text(
                    text = it.toString(),
                    modifier = Modifier
                        .colorBg()
                        .padding(horizontal = 8.dp)
                )
            }
        }
    }
}

运行效果如下所示:
list1.gif
有两点需要注意:

  • 只更新list参数的时候,Text组件的时间及背景不会变化,LazyRow的背景不会变化
  • 只更新changeText参数的时候,Text组件的时间及背景变化,LazyRow的背景和子项的背景居然也都会变化

按道理来说我们只更新changeText参数,是不想影响到LazyRow中的组件重组的,但是由于Compose认为你的参数List是不稳定的,所以它就每次都会重组,那么如何解决这个问题呢,下面有两种方式都可以帮到我们。

List类型的参数(使用remember)

@Composable
fun ListSample3(changeText: Long, list: List<Int>) {

    // 加上这一句就可以保证list不变则不重组
    val realList = remember {
        mutableStateOf(list)
    }

    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {

        Text(
            text = "当前时间:${currentTime(changeText)}",
            modifier = Modifier.colorBg()
        )

        LazyRow(
            horizontalArrangement = Arrangement.spacedBy(4.dp),
            modifier = Modifier.colorBg()
        ) {
            items(
                items = realList.value,
            ) {
                Text(
                    text = it.toString(),
                    modifier = Modifier
                        .colorBg()
                        .padding(horizontal = 8.dp)
                )
            }
        }
    }
}

我们给list参数通过remember{}来保存其状态,这个时候我们再看重组的情况:
list3.gif
无论再怎么更新changeText参数,LazyRow中的子项都不会收到影响,而LazyRow的背景会变色,是因为LazyRow和上面Text共享了重组的作用域,这个符合常理。

List类型的参数(使用SnapshotStateList)

还有一种情况就是我们直接把List类型更改为SnapshotStateList类型,SnapshotStateList类是有 @Stable 注解标记的,这样Compose编译器就会认为它是稳定的类型,就不会每次进行重组了(我们也可以使用@Stable来注解我们自己所需的类):

@Composable
fun ListSample4(changeText: Long, list: SnapshotStateList<Int>) {

    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {

        Text(
            text = "当前时间:${currentTime(changeText)}",
            modifier = Modifier.colorBg()
        )

        LazyRow(
            horizontalArrangement = Arrangement.spacedBy(4.dp),
            modifier = Modifier.colorBg()
        ) {
            items(
                items = list,
            ) {
                Text(
                    text = it.toString(),
                    modifier = Modifier
                        .colorBg()
                        .padding(horizontal = 8.dp)
                )
            }
        }
    }
}

重组的情况示例如下,跟上面的remember{}效果一致:

list4.gif

到这里可能大家又有疑惑了,为什么添加列表数据的时候,明明之前的列表项中数值是一样的却看着还是重组(背景颜色改变)了呢?这里提示大家可以试试把原来的Text换成WrapperText试试,这样就是不是又回到了3.2小节中的问题了呢。封装后的效果再给大家看下:

list6.gif

注: 在LazyRow,LazyColumn等列表的情况下,我们还可以通过项键key来提升性能,如下官方代码所示,通过为每一项提供一个稳定的键就可以确保Compose来避免不必要的重组,从而提升性能:

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
             key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

状态提升

Compose 中的状态提升,是一种将状态移至可组合项的调用方,使可组合项变成无状态的模式。Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数:

  • value: T:要显示的当前值
  • onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值

状态下降、事件上升的这种模式称为“单向数据流”。

这个东西其实就是跟第二节的场景复现类似了。如果类中不小心写了一个var的变量,那么有函数参数的Composable函数都会重组,这肯定不是我们想要的结果。

普通状态提升

常见情况如下:

private val aChangeText = mutableStateOf(0L)
private var temp: String = "temp"

@Composable
private fun TextEventSample1(changeText: String, onClick: () -> Unit) {
    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text(
            text = "${currentTime()} changeText=$changeText",
            modifier = Modifier
                .colorBg()
        )

        WrapperText {
            onClick()
        }
    }
}

@Composable
fun WrapperText(onClick: () -> Unit) {
    Text(
        text = "${currentTime()} 函数参数文本",
        modifier = Modifier
            .clickable {
                onClick()
            }
            .colorBg()
    )
}

这个时候,假如类中多了一个var类型的变量,那么有函数参数的WrapperText肯定就会跟着Text的重组而重组了,如下所示:
event1.gif

封装为事件类

上面的情况我们绝大多数情况下是不想要的,我们期望WrapperText不重组。所以我们可以构造一个事件类来处理,定义MyEventIntent类,可以是普通类也是可以是data类,它包含了事件处理的功能(需要非常注意的是其中的参数都必须用val修饰,否则还是会重组):

class MyEventIntent(
    val doClick: () -> Unit = {}
)

然后,后续的事件我们就不是往上层提升了,我们将事件类当做参数往下层传递下去:

private val aChangeText = mutableStateOf(0L)
private var temp: String = "temp"

// 这里用val或者var都无所谓了
private var myEventIntent = MyEventIntent(
    doClick = {
        aChangeText.value = aChangeText.value + 1
    }
)


@Composable
private fun TextEventSample2(
    changeText: String,
    event: MyEventIntent,
) {
    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text(
            text = "${currentTime()} changeText=$changeText",
            modifier = Modifier
                .colorBg()
        )

        WrapperTextWithEvent(event = event)
    }
}

@Composable
fun WrapperTextWithEvent(event: MyEventIntent) {
    Text(
        text = "${currentTime()} 事件类文本",
        modifier = Modifier
            .clickable {
                event.doClick()
            }
            .colorBg()
    )
}

之后我们再来看下重组的情况,无论上面的Text怎么重组,它都不会影响到WrapperTextWithEvent组件了,因为MyEventInetnt在WrapperTextWithEvent组件看来是稳定的,只要没发生改变则不重组:
event2.gif

总结

以上就是目前我们在优化Compose性能过程中所做的部分处理了,最有效的方式感觉还是顺从了MVI的单项数据流模式,不得不说,是有点巧的。文章用来显示重组的随机背景的想法完全参考了【川峰】的博客,请见参考文章中的最后一篇,感觉这个方法简单粗暴非常有效。其他也有一些调试Compose重组的技巧这里就不再展示了,请参考官方文章。

文末的参考文章真的需要大家仔细研读,相信我们都能有非常大的收获。

参考文章

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值