我正在参加「掘金·启航计划」
缘起
真正使用Compose做线上项目还是在两年前了,详见这篇文章《直播、聊天交友APP的开发及上架GooglePlay总结【Compose版】》,文章地址在:juejin.cn/post/704218…。去年由于职位的变动移交给了面向海外的团队人员开发,后来虽然没有专门做Compose的项目了,但是自己写Android端示例项目或者桌面端项目的时候都会第一选择Compose来进行开发。
最近海外组的小伙伴做复盘的时候发现一件离奇的事情,Compose的“重组”在有些情况下没有按照预想的来,是我们预想不对呢?还是出现了其它隐形的影响重组的因素呢?官方说的所有函数类型(lambda)是稳定的类型到底靠不靠谱呢?
注: 该文章基于Compose 1.3.0版本编写,其它版本暂未进行实验。
场景复现
首先我们要把遇到的问题重新复现出来,这种情况也是费了我九牛二虎之力,由于思维的惯性以及多年没有继续深耕Compose,说多了都是泪。
主要的UI效果很简单,第一层是一个Text和一个Box组件,Text组件中的文本数量跟随下层Button组件的点击次数不断增加,Box组件也添加了点击事件,点击也可使得数字增加。
场景类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变量的时候
可以看到,点击按钮的时候,只有左侧的文本组件在重组,文本在跟随点击的数量不断更新,这个情况跟我们所认为的情况是一样的
- 有mTemp变量的时候
这个时候除了左侧的文本组件在不断重组,右侧的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 Compiler(4) 智能重组与 $changed 参数:juejin.cn/post/717125…
- 深入浅出 Compose Compiler(5) 类型稳定性 Stability:juejin.cn/post/717162…
- 沉思录 | 揭秘 Compose 原理:图解 Composable 的本质:juejin.cn/post/710333…
很深入的东西笔者也并未探究出个所以然来,所以我也不误导大家了。总之因为类中多了一个不稳定的变量,导致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的改变而重组,如下所示:
如果我们不想让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同在一个作用域内,是符合常理的。
还有一种方式呢,这里也单独作为一小节来说明了,如下所示(可能这也是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不变,因为做了一层包装、隔离,它的改变现在和任何参数无关;
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)
)
}
}
}
}
运行效果如下所示:
有两点需要注意:
- 只更新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{}来保存其状态,这个时候我们再看重组的情况:
无论再怎么更新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{}效果一致:
到这里可能大家又有疑惑了,为什么添加列表数据的时候,明明之前的列表项中数值是一样的却看着还是重组(背景颜色改变)了呢?这里提示大家可以试试把原来的Text换成WrapperText试试,这样就是不是又回到了3.2小节中的问题了呢。封装后的效果再给大家看下:
注: 在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的重组而重组了,如下所示:
封装为事件类
上面的情况我们绝大多数情况下是不想要的,我们期望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组件看来是稳定的,只要没发生改变则不重组:
总结
以上就是目前我们在优化Compose性能过程中所做的部分处理了,最有效的方式感觉还是顺从了MVI的单项数据流模式,不得不说,是有点巧的。文章用来显示重组的随机背景的想法完全参考了【川峰】的博客,请见参考文章中的最后一篇,感觉这个方法简单粗暴非常有效。其他也有一些调试Compose重组的技巧这里就不再展示了,请参考官方文章。
文末的参考文章真的需要大家仔细研读,相信我们都能有非常大的收获。
参考文章
- 深入浅出 Compose Compiler(4) 智能重组与 $changed 参数:juejin.cn/post/717125…
- 深入浅出 Compose Compiler(5) 类型稳定性 Stability:juejin.cn/post/717162…
- 沉思录 | 揭秘 Compose 原理:图解 Composable 的本质:juejin.cn/post/710333…
- 可组合项的生命周期:developer.android.com/jetpack/com…
- Compose 性能:developer.android.com/jetpack/com…
- Jetpack Compose 中的重组作用域和性能优化:blog.csdn.net/lyabc123456…