Advertisement

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

阅读量:

缘起

Compose版

真正使用Compose进行线上项目的经历一直持续到两年前为止。如需了解更多信息,请参考文章《直播、聊天交友APP的开发及上架GooglePlay总结【Compose版

最近海外团队在复盘过程中发现了令人困惑的现象,在Compose中‘重组’功能有时未能按照预期执行。究竟是由于我们预期有误还是存在其他潜在因素影响重组的过程呢?官方声称所有函数类型(如lambda)均为稳定类型的这一说法是否值得信赖呢?

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

场景复现

首要任务是准确地还原出现的问题;这一情况确实耗费了不少精力和时间;因为长期未深入研究Compose而产生思维定式;实在令人感到沮丧与遗憾。

界面的主要功能相对简单。第一层包含一个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) {
    }        
    }

这些读者可能会觉得有点吃力不讨好。强烈建议大家先把这几篇文章认真读一遍,并在看完后再回头仔细分析情况:

很深入的内容笔者也没有探查到其背后的机制,因此无需过度解读。总体而言,由于类体中新增了一个易受干扰的因素,从而使得后续处理无法自主判断变更状态。值得注意的是,最后那个参数传递的值发生了变化,从0一路跃升至512数值,并由此引发了整个组件的重建过程

重组中的注意点

在上文中所描述的情境中, 我们注意到一个WrapperBox本应不应被重新组合的现象; 然而, 在类内部随意引入的一个mTemp变量却最终导致了这种重组的发生. 结果显然不符合我们的预期. 针对官方声明的所有函数类型(包括lambda) Composable编译器将被视为稳定类型的说法, 我们对这一观点表示怀疑. 也可能是我的理解有误? 如果有错误, 还请各位大佬直接指出, 谢谢.

那如何防止出现这种情况?
为了确保传递的数据类型稳定,请采取哪些措施?
为了优化性能,请降低 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进行重构包装,并构建为非内联函数如下所示: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
    )
    }

当我们重新审视重组情况时

inline2.gif

另外一种方法在这里也被专门作为一个小节进行了介绍(Compose始终认为这不失为一个值得推崇的做法)。

多封装(包装)

我们将三个 Text 组件依次排列放置在工作区中。第一个 Text 组件必须接收名为 changeText 的参数输入;中间那个 Text 组件则不接收任何输入参数;最后一个 Text 组件则完全模仿了中间那个 Text 组件,并额外封装了一层结构。那么这三个 Text 组件的整体组合情况你是否已经明白了呢?

复制代码
    @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()
    )
    }

可以看出,在重组过程中

text2.gif

List陷阱

在Kotlin语言中,List无法更改;然而,在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认为您的parameter列表不稳, 它总是会进行重新组织. 那么, 如何解决这一问题呢? 下面介绍两种方法供您参考.

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

该列表参数被remember{}赋值以保持其状态,在此时刻我们再审视重组的情况:

list3.gif

无论怎样调整changeText参数值时, LazyRow中的子项都不会受到影响; 然而其背景将呈现某种颜色变化, 并且这与其与Text共享重组作用域有关

List类型的参数(使用SnapshotStateList)

另一种情况是将 List 类型更改为 SnapshotStateList 类型,并且 SnapshotStateList 类具有 @Stable 标记注释;因此,在这种情况下,默认情况下不会触发数据重新组合;此外,在需要的情况下可以通过添加适当的 @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看看是否能解决这个问题。封装后的效果如下:

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

状态提升

State elevation in the Compose module is achieved by moving the state to an upstream component, thereby transforming that component into a stateless pattern. Typically, the standard state elevation pattern in Jetpack Compose involves replacing a state variable with two parameters.

  • value: T :将显示当前数值为 T
  • onValueChange: (T) → Unit:当发生更改当前数值的事件时(此处指参数为 (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必然会导致随之发生重组。

event1.gif

封装为事件类

在大多数情况下我们并不希望出现上面所述的情况因此我们需要确保WrapperText不会发生重组为此我们可以创建一个用于管理事件的小型组件并定义名为MyEventIntent的意图该意图既可以作为普通类型使用也可以作为数据类型设计它将包含所有相关的事件处理功能需要注意的是其中参数必须使用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

总结

川峰

川峰

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

参考文章

全部评论 (0)

还没有任何评论哟~