只有读取可变状态的作用域才会被重组
这句话的意思是只有读取 mutableStateOf()
函数生成的状态值的那些 Composable
函数才会被重新执行。注意,这与 mutableStateOf()
函数在什么位置被定义没有关系。读取操作指的是对状态值的 get
操作。也就是取值的操作。
从一个最简单的例子开始:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
Text("Text1", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { counter++ },
shape = RoundedCornerShape(5.dp)
) {
Text("Text2: counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
}
fun getRandomColor() = Color(
red = Random.nextInt(256),
green = Random.nextInt(256),
blue = Random.nextInt(256),
alpha = 255
)
在上面的代码中,我们为每个 Composable
组件都设置了一个随机的背景颜色,这样,一旦它们发生了重组,我们就可以观察到。
这里点击 Button
修改 counter
的值之后,只有读取 counter
的 Text
组件背景色发生变化,这充分的说明了只有这个 Text
组件才会重组。位于 Button
之上的 Text
组件,虽然它与 counter
定义在同一作用域范围内,但是它不会被触发重组,因为它没有读取该 counter
的值。
假如我们把 Button
内的组件换成一个自定义的 Composable
组件,只要它读取 counter
的值,那么该自定义组件的整个作用域范围都会执行重组:
@Composable
private fun Sample1() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
Text("Text1", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { counter++ },
shape = RoundedCornerShape(5.dp)
) {
MyText(counter)
}
}
}
@Composable
fun MyText(counter: Int) {
Column {
Text("MyText: counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Text("Another Text", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
可以看到,点击修改counter
值的时候,不仅 MyText
组件中的第一个读取 counter
值的 Text
组件会发生重组,而且 MyText
组件中的另一个未读取 counter
值的 Text
组件也发生了重组。也就是说整个 MyText
组件都发生了重组。
内联组件的重组作用域与其调用者相同
在一般情况下,读取某个state值的组件和未读取某个state值的组件,它们的重组作用域是隔离的,互不影响。但是内联组件除外。可以通过下面的例子来说明这个问题:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp)
) {
var update1 by remember { mutableStateOf(0) }
println("ROOT")
Text("Text in outer Column", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { update1++ },
shape = RoundedCornerShape(5.dp)
) {
println("🔥 Button 1")
Text(
text = "Text in Button1 read update1: $update1",
textAlign = TextAlign.Center,
color = Color.White,
modifier = Modifier.background(getRandomColor())
)
}
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp)
) {
println("🚀 Inner Column")
var update2 by remember { mutableStateOf(0) }
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { update2++ },
shape = RoundedCornerShape(5.dp)
) {
println("✅ Button 2")
Text(
text = "Text in Button2 read update2: $update2",
textAlign = TextAlign.Center,
color = Color.White,
modifier = Modifier.background(getRandomColor())
)
}
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(6.dp)
) {
println("☕ Bottom Column")
/**
* 🔥🔥 Observing update(mutableState) causes entire composable to recompose
*/
Text(
text = "🔥 Text in Inner Column read update1: $update1",
textAlign = TextAlign.Center,
color = Color.White,
modifier = Modifier.background(getRandomColor())
)
}
}
}
}
当我们点击 Button 2
时,只会影响读取 update2
值的范围,这没有问题。但是我们点击 Button 1
时,整个组件都在为我们闪烁!这并没有像我们预想的那样:只影响 Button 1
中的读取 update1
的 Text
以及内部嵌套 Column
中读取 update1
的 Text
组件。而是影响了整个外部的 Column
组件的作用域。
这是因为 Column
组件是被定义为 inline
内联的。我们可以通过它的源码定义中发现:
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
) {
val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
Layout(
content = { ColumnScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
所以在上面的例子中,最外层的 Column
组件嵌套了第二个 Column
组件,而第二个 Column
组件嵌套了第三个 Column
组件,由于inline
函数的特性,最终所有 Column
组件内部的组件都会被直接在编译期插入到最外层中。所以第三个 Column
组件中读取 update1
的 Text
实际上相当于是处在最外层中。因此当 update1
发生变化时,整个最外层都会重组,因为它们属于同一个重组作用域。
同样的,Row
组件也是内联的。因为这两个组件是在开发当中是会高频使用的组件,所以我们要尤其注意这一点。如果我们不想某个状态值导致整个组件都重组,换句话说,如果我们想最大程度的做到状态隔离,缩小重组作用域,那么最好使用非 inline
的组件,例如 Box
组件、Surface
组件等等。
隔离重组作用域
上面提到, inline
组件会将重组作用域暴露给调用者,进而导致调用者的重组作用域被放大,子组件发生重组时父组件也受到了牵连。但是,假如我们的业务代码中已经大量的应用了 Column
或 Row
这样的内联组件,或者我们此时想提升一下页面渲染的性能,想要追求极致的用户体验,我们该怎么办呢?换句话说,就是 如何隔离重组作用域? 其实很简单,说出来你可能不信:既然 inline
的不行,那么改成 非 inline
的不就可以了嘛。
比如上面的例子,我们可以将 Column
组件换成一个自定义的 Column
,我们只需要简单地在外层包装一个 Composable
函数透传 content
即可。例如像下面这样:
@Composable
fun RandomColorColumn(content: @Composable () -> Unit) {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp)
) {
content()
}
}
然后我们只需将上面例子中的 Column
全部换成这个 RandomColorColumn
,而其他部分的代码基本不动:
@Composable
fun Sample() {
RandomColorColumn {
var update1 by remember { mutableStateOf(0) }
Text("Text in outer Column", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { update1++ },
shape = RoundedCornerShape(5.dp)
) {
Text(
text = "Text in Button1 read update1: $update1",
textAlign = TextAlign.Center,
color = Color.White,
modifier = Modifier.background(getRandomColor())
)
}
RandomColorColumn {
var update2 by remember { mutableStateOf(0) }
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { update2++ },
shape = RoundedCornerShape(5.dp)
) {
Text(
text = "Text in Button2 read update2: $update2",
textAlign = TextAlign.Center,
color = Color.White,
modifier = Modifier.background(getRandomColor())
)
}
RandomColorColumn {
Text(
text = "🔥 Text in Inner Column read update1: $update1",
textAlign = TextAlign.Center,
color = Color.White,
modifier = Modifier.background(getRandomColor())
)
}
}
}
}
效果:
现在,事情就会变得跟我们预期的那样:当疯狂点击 Button 1
时,只有 Button 1
中的读取 update1
的 Text
以及内部嵌套 RandomColorColumn
中读取 update1
的 Text
组件会发生重组,而不是影响所有整个组件。
也许有人会问:这相比原来直接使用 Column,你又多了嵌套一层,不会影响性能吗?答案是:并不会。原因主要有两点:
- 之前在 Jetpack Compose中的绘制流程和自定义布局 中提到过,Compose 中不允许被多次测量,每个子元素只允许被测量一次,因此并不会因为嵌套层级的增加而导致测量次数的指数爆炸问题,正所谓 “一时嵌套一时爽,一直嵌套一直爽”。
- 实际上 Compose 编译器会对 Composable 函数施加一些 “魔法”,而 Compose runtime 会持有对这些 Composable 函数的引用,它们可能在运行时以任意顺序被重新执行、并行执行(可能多线程)、甚至被跳过执行,所以它们并不像我们传统意义上的标准函数调用堆栈那样,调用顺序也不会跟我们代码书写的那样按照先后顺序一层一层的往下调用再返回。这一点在 Jetpack Compose 深入探索系列一:Composable 函数 中有介绍过,如果你感兴趣的话可以自行了解。
当然嵌套多了的话,也不能说完全没有影响,至少会增加 Compose 编译器的编译时间成本,还有就是最终生成的DEX包可能会大一些。
另外,Jetpack Compose 这个框架本身已经极为优秀了,正常情况下也不会出现太大的性能问题,一般也不需要这么做。只有在你想要鸡蛋里挑骨头、追求极致性能体验的情况下,才需要十分小心的留意你所使用的 Composable 组件是否是 inline
的。
重组作用域内不读取任何参数的组件不会被重组
这里表达的意思是某个组件不从外部接受参数,当我们定义一个组件时,可以在组件内部维护一些状态值,也有可能通过状态提升,将一些状态作为参数暴露出来,交给其公共父组件来管理。但是在父组件中,一旦发生重组,它只会影响那些会为其传递参数的子组件,如果某个子组件不从父组件接受任何参数,那么它在重组中保持不变(从父组件的视角)。这貌似跟本文列出的第一点有点重复,但是我还是想列出来单独说一下。
我们再利用一下本文最开头的例子进行一下修改:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { counter++ },
shape = RoundedCornerShape(5.dp)
) {
Text("Text2 Don't Read counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
}
我们发现 Button
中的 Text
组件不会发生变化,这是因为它没有读取 counter
的状态值。但是我们发现 Button
本身的颜色却在发生变化,说明它正在发生重组,这是因为 Column
组件是内联的,这一点我们上面已经介绍过,因此 Button
和 Button
之上的那个 Text
组件实际上在调用 Sample()
的组件中,是处于相同的重组作用域。所以这里我们就会看到有趣的事: Button
会重组,但它内部的组件却不一定,这取决于其内部组件是否会从外部接受参数,在这个例子中并没有。
现在,我们来修改一下代码,让 Button
中的 Text
组件接受一些外部的参数:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
val myData = remember { MyData() }
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
myData.value = myData.value + 1
},
shape = RoundedCornerShape(5.dp)
) {
Text("Text2 Read myData.value: ${myData.value}", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
}
class MyData(var value: Int = 0)
再次运行查看效果:
这里让 Button
中的 Text 2
读取的是一个普通的类 MyData
中的字段值,并且特意没有设置成 mutableStateOf()
,只使用了 remember
函数, remember
充其量只是一个缓存函数(只有第一次执行初始化操作),也就是说 myData
并不是一个能被 Composable 观察的 state
值。
但是我们发现了比较神奇的事情: Button
中没有读取任何可变状态的 Text 2
竟然也发生了重组!因为MyData
这个类会被编译器推断为具有不稳定性,而不确定性的东西是会破坏Compose runtime的智能重组的,所以Compose就干脆放弃这种情况,每次都会调用它重新执行组合。严格来讲, Text 2
组件发生重组是由于 Text 1
组件读取 counter组件导致的,如果你把 Text 1
组件注释掉,那么Text 2
组件就不会发生重组(如果还会发生重组,那就是见鬼了)。
或者,我们可以更简单一点,不要用什么 MyData
类了,就直接放一个普通的变量让 Text 2
去读取:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
var myVal = 0
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
myVal++
},
shape = RoundedCornerShape(5.dp)
) {
Text("Text2 Read myVal: $myVal", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
}
你会发现虽然 Text 2
组件读取的 myVal
的值没有变化,但是 Text 2
组件的背景色却在闪,说明它仍然在发生重组。
既然如此,那我们可再简化一点,直接让 Text 2
组件什么也不读取了,但是我们在它上面一行加一个 println
函数,打印一下myVal
的值:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
var myVal = 0
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
myVal++
},
shape = RoundedCornerShape(5.dp)
) {
println(myVal)
Text("Text2 Read Nothing", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
}
这时发现 Text 2
组件的背景色仍然在闪,说明它仍然在发生重组。奇怪了,不是说 Compose 智能重组只会重组那些读取状态值发生改变的组件吗???
是不是很纳闷,那我们再改一下,直接让 println
函数打印一个固定的字符串:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
var myVal = 0
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
myVal++
},
shape = RoundedCornerShape(5.dp)
) {
println("Hello World")
Text("Text2 Read Nothing", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
}
这下 Text 2
是真的不会变了。
这说明了什么?Button
组件受到重组时,其内部只要读取了外部参数就会发生重组,这里的内部是指 Button { }
大括号中传入的名为 content
的 Composable lambda 参数:
前面的例子中在Text
组件的前后去打印外部的参数,Text
组件也会重组是因为 打印外部参数 这件事实际是直接发生在 content
这个 Composable
组件的内部,也就是说 content
这个 Composable
组件读取了外部的参数。
也就是说 Button
的 content
lambda中只要读取了外部参数就会触发 content
的重组,我们修改成下面这样, Text 2
组件也是会发生重组,因为虽然 Text 2
组件没有读取,但是相邻的组件读取了,而它们都最终都会导致 content
lambda 从外部读取参数。(注意下面的代码中,即便你将 Button
内部的 Column
套上Box
等非inline
的组件也不能隔离重组)
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
var myVal = 0
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
myVal++
},
shape = RoundedCornerShape(5.dp)
) {
Column {
Text("Text2 Read Nothing", color = Color.White, modifier = Modifier.background(getRandomColor()))
Text("Text3 Read myVal: $myVal", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
}
}
因为 Button
的 content
内容我们是可以自定义的,也就是说我们可以像下面这样定义:
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { counter++ },
) {
SomeComposable()
}
这样可能更能表达上面的意思,因为在实际中,向外暴露 content
Composable lambda 的自定义组件是很常见的。这种情况下,在 SomeComposable()
中的任何子组件都不会因为 Button
的重组而发生重组,因为它没有从外部接收任何参数。假如我们希望上面的例子中, Text 3
组件读取外部参数的情况下,Text 2
组件不要受到影响,那么解决方法就是将其包装进一个 不接受任何参数的 SomeComposable()
组件中:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
var myVal = 0
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
myVal++
},
shape = RoundedCornerShape(5.dp)
) {
Column {
SomeComposable()
Text("Text3 Read myVal: $myVal", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
}
}
@Composable
private fun SomeComposable() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 6.dp))
.background(getRandomColor())
.padding(4.dp)
) {
Text("Text2 Read Nothing", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
优化 Modifier 的状态读取
一般来说,应该尽量避免在 Modifier 修饰符上读取状态,因为 Modifier 并不是用来显示数据元素的地方,并且从修饰符上频繁读取状态值会导致频繁的重复进而导致过渡重绘。但有时我们可能避免不了,比如想要位置偏移、大小、背景或边距等根据某个状态来不断变化,从而达到某种效果。甚至有可能会经常这么干,一旦你有这样的需求,官方为我们提供了一些更好的原则值得去遵守。
总的来说就是一条原则:尽量使用带lambda
的修饰符方法来读取Modifier
需要的状态值。
这是因为使用 lambda
意味着延迟读取(Deffered Reading),而不是在组合期间读取(这样会反复触发重组,而重组意味着触发一些列的测量流程,从布局到绘制)。标准的Modifier
修饰符方法是一定会在组合期间被执行的,而lambda
形式的Modifier
修饰符基本上可以确定是不会在在组合期间被执行的,Compose的官方团队就这一点也已经给出了明确的说明。
下面是使用 Modifier.offset(offset: Dp)
和 Modifier.offset(offset: Density.() -> IntOffset)
的一个例子,它很好的表达了这两者的不同效果:
@Composable
fun PhasesSample() {
var offsetX by remember { mutableStateOf(0f) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "OffsetX")
Spacer(modifier = Modifier.width(5.dp))
Slider(value = offsetX,
valueRange = 0f..50f,
onValueChange = {
offsetX = it
}
)
}
val modifier1 = Modifier
.offset(x = offsetX.dp) // 直接读
.layout { measurable, constraints ->
val placeable: Placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
println("😃️ modifier1 LAYOUT")
placeable.placeRelative(0, 0)
}
}
.background(Blue400)
.drawWithContent {
println("😜 modifier1 DRAW")
drawContent()
}
val modifier2 = Modifier
.offset { // 放在 lambda 中返回
val newX = offsetX.dp.roundToPx()
IntOffset(newX, 0)
}
.layout { measurable, constraints ->
val placeable: Placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
println("🍏 modifier2 LAYOUT")
placeable.placeRelative(0, 0)
}
}
.background(Blue400)
.drawWithContent {
println("🍎 modifier2 DRAW")
drawContent()
}
MyBox(modifier = modifier1, "modifier1")
Spacer(modifier = Modifier.height(8.dp))
MyBox(modifier = modifier2, "modifier2")
}
@Composable
private fun MyBox(modifier: Modifier, title: String) {
LogCompositions(msg = "🔥 MyBox() COMPOSITION $title")
Column(modifier) {
// This Text changes color in every recomposition
Text(
text = title,
color = Color.White,
modifier = Modifier
.background(getRandomColor())
.fillMaxWidth()
.padding(2.dp)
)
Text(
text = "modifier hash: ${modifier.hashCode()}\nModifier: $modifier",
color = Color.White,
modifier = Modifier.heightIn(max = 200.dp),
fontSize = 12.sp
)
}
}
运行效果:
上面代码中,modifier1
使用标准版本的 offset()
来设置 x
轴的偏移量,而 modifier2
使用 lambda
版本的 offset{}
来设置 x
轴的偏移量。为了观察这两个修饰符在各个布局绘制阶段的执行情况,还为它们设置了layout
和drawWithContent
,它们只用来打印 Log
,并没有做额外的事情,代码也只是执行原本的逻辑。同时为了观察Modifier
的状态,还在 MyBox
组件中将Modifier
的hashCode
和其属性值显示到Text
组件上。
最终我们看到的结果就是,使用标准版本 offset()
的modifier1
背景一直在闪烁,也就是一直在发生重组,并且其hashCode
在不停的变化,而使用 lambda
版本 offset{}
的modifier2
背景不变即没有在发生重组,且其hashCode
也保持不变。
同时,通过观察 Logcat 控制台的输出,可以发现:
使用标准版本 offset()
的modifier1
在不断的执行:COMPOSITION -> LAYOUT -> DRAW
三个阶段
使用 lambda
版本 offset{}
的modifier2
只会输出:LAYOUT
这一个阶段。
结合这些结果,下面再从组合树的角度看一下,为啥标准版不好,而 lambda 大法好:
标准版本 offset()
在每次偏移量发生改变时,会重新创建 Modifier
实例(hashCode
不停变化证实了这一点),当 Modifier
实例发生变化时,组合树会先删除旧的,然后再添加一个新的 Modifier
实例。组合树的变化会导致重组的发生,因此如果偏移量频繁变化,重组就会频繁的发生,而每次重组都有可能触发组合->布局->绘制这三个阶段,这非常要命。
而 lambda
版本 offset{}
的优势是, Modifier
实例不会改变(hashCode
保持不变证实了这一点),Compose 只会在需要的时候 调用 lambda
函数,因此组合树不会发生变化,也就是说相比之下可以跳过不必要的重组。(前面的Log输出证实了其只会执行Layout阶段)
除了跳过重组的好处, lambda
版本还有一点好处就是它可能会跳过布局的一半(在大小没有变化的情况下),因为我们知道Compose的布局阶段包含测量和摆放两个阶段,如果只修改了位置,尺寸大小没有变化,那么Compose可以完全可以只执行摆放阶段。因此在最好的情况下,它能为我们节省整个流程的一半的时间。😃️
如果我们有机会使用 Modifier.layout()
(一般是在自定义组件时), 也可以直接在其layout(){}
方法中摆放组件的时候进行设置,这会直接发生在布局阶段的摆放阶段。例如将上面例子中的modifier1
修改代码如下:
val modifier1 = Modifier
.layout { measurable, constraints ->
val placeable: Placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
println("😃️ modifier1 LAYOUT")
// placeable.placeRelative(0, 0)
placeable.placeRelative(offsetX.dp.roundToPx(), 0) // 在这里设置
}
}
.background(Blue400)
.drawWithContent {
println("😜 modifier1 DRAW")
drawContent()
}
这时再运行发现二者都安静了,两个修饰符都不会随offsetX
变化而发生重组:
对于背景色,也是一样的道理,可以使用 Modifier.drawWithContent{}
这个修饰符中绘制背景框,请参考下面的例子:
@Composable
fun PhasesSample() {
var index by remember { mutableStateOf(0f) }
Text(text = "bgColor")
Slider(
value = index,
valueRange = 0f..100f,
onValueChange = { index = it }
)
val modifier1 = Modifier
.layout { measurable, constraints ->
val placeable: Placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
println("😃️ modifier1 LAYOUT")
placeable.placeRelative(0, 0)
}
}
.drawWithContent {
println("😜 modifier1 DRAW")
drawContent()
}
.background(randomColors[index.roundToInt()%randomColors.size]) // 直接设置
val modifier2 = Modifier
.layout { measurable, constraints ->
val placeable: Placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
println("🍏 modifier2 LAYOUT")
placeable.placeRelative(0, 0)
}
}
.drawWithContent { // 在 lambda 中设置
val color = randomColors[index.roundToInt()%randomColors.size]
println("🍎 modifier2 DRAW color: $color")
drawRect(color)
drawContent()
}
MyBox(modifier = modifier1, "modifier1")
Spacer(modifier = Modifier.height(20.dp))
MyBox(modifier = modifier2, "modifier2")
}
@Composable
private fun MyBox(modifier: Modifier, title: String) {
LogCompositions(msg = "🔥 MyBox() COMPOSITION $title")
Column(Modifier.background(purple500)) {
Text(
text = title,
color = Color.White,
modifier = modifier
.fillMaxWidth()
.padding(4.dp)
)
Text(
text = "modifier hash: ${modifier.hashCode()}\nModifier: $modifier",
color = Color.White,
fontSize = 12.sp
)
}
}
运行效果:
同样的,这里使用标准版background()
直接设置背景色,会导致Modifier
实例不断变化,而使用 Modifier.drawWithContent{}
方法绘制背景色,Modifier
实例就会保持不变。如果观察log输出,会发现前者不断输出 COMPOSITION
和 DRAW
(因为位置大小没有变,所以跳过了布局 LAYOUT
阶段),而后者只会输出 DRAW
,也就是说在 lambda
中进行绘制只会影响绘制阶段,它可以为我们跳过组合和布局两个阶段。不得不说,这样大大滴好呀。😃️
看完背景色的设置,再来看一下 padding
,奇怪的是在 Compose 的API中我并没有找到关于 padding
修饰符的 lambda
版本,这让我很是不解,我就搞不懂了,既然要搞,官方为啥不搞一套呢?难道又需要在Modifier.layout(){}
中设置吗?那岂不是很麻烦?其实我们可以自己搞一个 lambda
, 因为 lambda
就是一个函数类型的函数参数而已,所以我们可以这样做:
@Composable
fun DeferredPaddingComposablesSample() {
var padding by remember { mutableStateOf(0f) }
//...
PaddingDeferred {
padding.dp
}
}
@Composable
private fun PaddingDeferred(padding: () -> Dp) {
Text(
text = "PaddingDeferred",
modifier = Modifier
.padding(start = padding())
.fillMaxWidth()
.background(getRandomColor())
)
}
这样虽然不能避免读取 padding
值本身的组件的重组,但是却能够尽量的避免父组件的重组,将重组范围缩小到最小。请看下面的示例:
@Composable
fun DeferredPaddingComposablesSample() {
var padding by remember { mutableStateOf(0f) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "Padding")
Spacer(modifier = Modifier.width(5.dp))
Slider(value = padding,
valueRange = 0f..50f,
onValueChange = {
padding = it
}
)
}
PaddingOuterDeferred { padding.dp } // 使用 lambda 传
PaddingOuterDirectly(padding.dp) // 直接传
}
@Composable
private fun PaddingOuterDeferred(padding: () -> Dp) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(getRandomColor())
.padding(10.dp)
) {
LogCompositions(msg = "😍 PaddingOuterDeferred")
Text("PaddingOuterDeferred")
PaddingMiddleDeferred(padding)
}
}
@Composable
private fun PaddingMiddleDeferred(padding: () -> Dp) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(getRandomColor())
.padding(10.dp)
) {
LogCompositions(msg = "😃 PaddingMiddleDeferred")
Text("PaddingMiddleDeferred")
PaddingInnerDeferred(padding)
}
}
@Composable
private fun PaddingInnerDeferred(padding: () -> Dp) {
LogCompositions(msg = "😜 PaddingInnerDeferred")
Text(
text = "PaddingInnerDeferred",
modifier = Modifier
.padding(start = padding())
.fillMaxWidth()
.background(getRandomColor())
)
}
@Composable
private fun PaddingOuterDirectly(padding: Dp) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(getRandomColor())
.padding(10.dp)
) {
LogCompositions(msg = "PaddingOuterDirectly")
Text("PaddingOuterDirectly")
PaddingMiddleDirectly(padding)
}
}
@Composable
private fun PaddingMiddleDirectly(padding: Dp) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(getRandomColor())
.padding(10.dp)
) {
LogCompositions(msg = "PaddingMiddleDirectly")
Text("PaddingMiddleDirectly")
PaddingInnerDirectly(padding)
}
}
@Composable
private fun PaddingInnerDirectly(padding: Dp) {
LogCompositions(msg = "PaddingInnerDirectly")
Text(
text = "PaddingInnerDirectly",
modifier = Modifier
.padding(start = padding)
.fillMaxWidth()
.background(getRandomColor())
)
}
同样的,为了对比效果,这里我们搞了两套代码,一套直接传参数dp
值不使用函数类型,另一套传递函数类型。
运行效果:
对比效果很明显:不使用 lambda
直接传 dp
值的,所有父组件都在发生重组,而使用 lambda
传 dp
值的只有最里面的子组件才会重组。
但是感觉还不是很完美,假如我们想做到像 Modifier.offset{ }
lambda 版本那样的效果,也就是读取 padding
状态值的时候完全不发生重组,难道就没有办法了吗?当然不是,首先 我们可以使用 Modifier.layout(){}
来自己处理,这样比较麻烦,但是可以实现。还有更简单一点的办法:自定义 Modifier
修饰符。我们可以仿照 Modifier.offset{ }
的方式来自定义,但我们不需要自己写padding
的逻辑,因为我们可以直接查看系统内置padding
源码的实现,找到下面的代码:
// Padding.kt
@Stable
fun Modifier.padding(paddingValues: PaddingValues) =
this.then(
PaddingValuesModifier(
paddingValues = paddingValues,
inspectorInfo = debugInspectorInfo {
name = "padding"
properties["paddingValues"] = paddingValues
}
)
)
private class PaddingValuesModifier(
val paddingValues: PaddingValues,
inspectorInfo: InspectorInfo.() -> Unit
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
require(
paddingValues.calculateLeftPadding(layoutDirection) >= 0.dp &&
paddingValues.calculateTopPadding() >= 0.dp &&
paddingValues.calculateRightPadding(layoutDirection) >= 0.dp &&
paddingValues.calculateBottomPadding() >= 0.dp
) {
"Padding must be non-negative"
}
val horizontal = paddingValues.calculateLeftPadding(layoutDirection).roundToPx() +
paddingValues.calculateRightPadding(layoutDirection).roundToPx()
val vertical = paddingValues.calculateTopPadding().roundToPx() +
paddingValues.calculateBottomPadding().roundToPx()
val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
val width = constraints.constrainWidth(placeable.width + horizontal)
val height = constraints.constrainHeight(placeable.height + vertical)
return layout(width, height) {
placeable.place(
paddingValues.calculateLeftPadding(layoutDirection).roundToPx(),
paddingValues.calculateTopPadding().roundToPx()
)
}
}
override fun hashCode() = paddingValues.hashCode()
override fun equals(other: Any?): Boolean {
val otherModifier = other as? PaddingValuesModifier ?: return false
return paddingValues == otherModifier.paddingValues
}
}
我们直接把上面的源码复制一份,然后把不必要的调试信息去掉,稍微改一下,我们自己的 lambda
版本的padding{ }
修饰符就诞生了:
@Stable
private fun Modifier.padding(paddings: Density.() -> PaddingValues) = then(
PaddingValuesModifier(paddings, rtlAware = true)
)
private class PaddingValuesModifier(
val paddings: Density.() -> PaddingValues,
val rtlAware: Boolean,
) : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val paddingValues = paddings()
require(
paddingValues.calculateLeftPadding(layoutDirection) >= 0.dp &&
paddingValues.calculateTopPadding() >= 0.dp &&
paddingValues.calculateRightPadding(layoutDirection) >= 0.dp &&
paddingValues.calculateBottomPadding() >= 0.dp
) {
"Padding must be non-negative"
}
val horizontal = paddingValues.calculateLeftPadding(layoutDirection).roundToPx() +
paddingValues.calculateRightPadding(layoutDirection).roundToPx()
val vertical = paddingValues.calculateTopPadding().roundToPx() +
paddingValues.calculateBottomPadding().roundToPx()
val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
val width = constraints.constrainWidth(placeable.width + horizontal)
val height = constraints.constrainHeight(placeable.height + vertical)
return layout(width, height) {
if (rtlAware) {
placeable.placeRelative(
paddingValues.calculateLeftPadding(layoutDirection).roundToPx(),
paddingValues.calculateTopPadding().roundToPx()
)
} else {
placeable.place(
paddingValues.calculateLeftPadding(layoutDirection).roundToPx(),
paddingValues.calculateTopPadding().roundToPx()
)
}
}
}
override fun hashCode() : Int {
var result = paddings.hashCode()
result = 31 * result + rtlAware.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
val otherModifier = other as? PaddingValuesModifier ?: return false
return paddings == otherModifier.paddings &&
rtlAware == otherModifier.rtlAware
}
}
现在,我们把前面例子中的使用函数类型传递dp值的最内层的padding应用改一下,改成使用我们自己的 lambda
版本:
// ...省略其余代码
@Composable
private fun PaddingInnerDeferred(padding: () -> Dp) {
Text(
text = "PaddingInnerDeferred",
modifier = Modifier
//.padding(start = padding())
.padding {
PaddingValues(start = padding())
}
.fillMaxWidth()
.background(getRandomColor())
)
}
再次运行代码,查看效果:
可以看到,上面的 Inner Text 上读取变化的padding
值时,也不会闪了,腰也不疼了,腿也不酸了,再也不会发生重组了。真香😃
避免重组循环(循环阶段依赖项)
系统始终按照相同的顺序来调用 Compose 的各个阶段,并且无法在同一帧中后退。不过,这并未禁止应用跨不同的帧进入组合循环。请思考以下示例:
Box {
var imageHeightPx by remember { mutableStateOf(0) }
Image(
painter = painterResource(R.drawable.rectangle),
contentDescription = "I'm above the text",
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { size ->
// Don't do this
imageHeightPx = size.height
}
)
Text(
text = "I'm below the image",
modifier = Modifier.padding(
top = with(LocalDensity.current) { imageHeightPx.toDp() }
)
)
}
在本例中,我们实现了一个垂直列(并不理想),其顶部是图片,而图片下方则是文本。我们要使用 Modifier.onSizeChanged()
来获取图片的解析大小,然后对文本使用 Modifier.padding()
以将其下移。从 Px
转换回 Dp
的过程很不自然,这就说明代码存在一些问题。
该示例的问题在于,我们没有在单个帧中达到“最终”布局。该代码依赖发生多个帧,它会执行不必要的工作,并导致界面在用户屏幕上跳动。
接下来,我们要逐一检查每个帧,看看发生了什么:
在第一帧的组合阶段,imageHeightPx
的值为 0
,并且文本是按照 Modifier.padding(top = 0)
提供的。接着,布局阶段紧随其后,并且系统调用 onSizeChanged
修饰符的回调。此时,imageHeightPx
更新为了图片的实际高度。Compose 为下一帧安排重组。在绘制阶段,由于值发生的更改尚未得到反映,因此,系统渲染文本时将内边距设为了 0
。
然后,Compose 启动根据 imageHeightPx
的值发生的更改安排的第二帧。系统在 Box
内容块中读取状态,并在组合阶段调用该状态。此时,系统提供文本时会采用与图片高度相匹配的内边距。在布局阶段,代码确实会再次设置 imageHeightPx
的值,但不会安排重组,因为该值会保持不变。
最终,我们会在文本上实现所需的内边距,但这并不是最理想的情况,因为我们还要使用一个额外的帧将内边距值传递回其他阶段,而这会导致产生一个内容重叠的帧。
该示例可能显得有些刻意,但请注意以下通用模式:
Modifier.onSizeChanged()、onGloballyPositioned()
或一些其他布局操作- 更新某种状态
- 使用该状态作为对布局修饰符(
padding()、height()
或类似元素)的输入 - 可能会重复
若要修复以上示例,您可以使用适当的布局,以上示例可以使用一个简单的 Column() 来实现,但您可能会遇到需要进行自定义的更复杂的示例,这时需要编写自定义布局。
在列表中使用 key 指定唯一 id
Compose编译器会在每个Composable函数的函数体中插入一个group
,每个 group
都会使用一个 key
作为唯一标识,这个 key
是使用Composable在源码中的位置信息来生成的(由Compose runtime生成)。也就是说这个 key
就是Composable的标识。 一般情况下Composable在源码中的位置是不会变的,但是使用列表的情况除外。当代码中会生成一个Composable列表时,对Compose runtime
来说,分配唯一标识是很困难的。
例如下面的例子:
在这种情况下,每次都从相同的位置调用Talk(talk)
,但是每个Talk
表示列表上的不同项,因此是树上的不同节点。在这种情况下,Compose runtime
依赖于调用的顺序来生成唯一的id
,并且仍然能够区分它们。
当将一个新元素添加到列表的末尾时,这段代码仍然可以正常工作,因为其余的调用保持在与以前相同的位置。但如果我们在顶部或中间的某个位置添加元素呢?Compose runtime
将重新组合该位置以下的所有Talk
,因为它们改变了位置,即使它们的输入没有变化。这是非常低效的(尤其是对于长列表而言),因为这些调用本应该被跳过。
为了解决这个问题,Compose提供了一个用来设置 key
的 Composable,因此我们可以手动为Composable调用分配一个显式的 key
:
在这个例子中,我们使用talk.id
(可能是唯一的)作为每个Talk
的 key
,这将允许runtime保存列表中所有项的标识,而不管它们的位置如何。
记住,位置记忆 是 Composable 函数的属性之一,这一部分在 Jetpack Compose 深入探索系列一:Composable 函数 中有介绍过,感兴趣的话可以去看看。
derivedStateOf
derivedStateOf
主要作用是将其他 state
转换成另一个 state
,其在性能优化方面的应用场景和示例,之前在 Jetpack Compose中的副作用 中的 derivedStateOf { } VS remember(key) { }
部分有提到过,感兴趣的可以点击过去查看。这里不重复列举了。
总而言之:当你需要依赖其他状态生成另一个状态来更新 UI,并且其他状态的更新频率远远高于 UI 的刷新频率时,这时就是可以考虑使用 derivedStateOf
的时候了。
@Stable 和 @Immutable
这两个注解都是关于稳定性的话题,在前面的例子中有提到过,但是没有详细展开。下面再来看一下:
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
val myData = remember { MyData(0) }
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
myData.value = myData.value + 1
},
shape = RoundedCornerShape(5.dp)
) {
MyText(myData.value)
}
}
}
class MyData(var value: Int = 0)
@Composable
fun MyText(counter: Int = 0) {
Column {
Text("MyText: Read myData.value: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Text("Another Text", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
这里的 MyData
类的myData
对象并没有被 mutableStateOf()
包装,按理说它应该是一个不能被Composable观察到的状态,但是这里当Button
组件重组时,读取myData
对象值的MyText
组件也发生重组了。这说明Compose还是为它执行了Composable函数。
这是因为myData
对象虽然是被定义为 val
不可被重新赋值,但是 MyData
类中包含了可变成员var value
,这就意味着myData
对象并不是真正意义上的不可变对象,因为随时都可以通过其可变成员修改它的数据,实际上面代码中也是这么干的,在点击事件中,不断的修改它的value成员值+1。因此它被编译器推断为是一个不稳定的类,而不稳定的类会破坏智能重组,因此意味着不能跳过重组。
智能重组指的是当Composable函数的输入没有改变且这些输入被认为是稳定时,跳过重新组合。稳定性在这个意义上是一个非常重要的概念,因为它意味着Compose runtime可以安全地读取和比较这些输入,以在需要时跳过重新组合。稳定性的最终目标是帮助runtime。
那么什么样的类会被编译器确认为稳定的类型呢?总的来说,一个稳定的类型需要满足如下三点:
- 对相同的两个实例调用 equals 的结果始终相同。这意味着比较是一致的,因此运行时可以依赖它。
- 当类型的公开属性更改时,总是会通知Composition组合。否则,我们可能会遇到输入与最新状态不同步的情况。为了确保不会发生这种情况,总是针对此类情况触发重新组合。智能重组合无法依赖此输入。
- 所有公开属性也必须是稳定的。
默认情况下,所有基本类型都是稳定的,String类型和所有函数类型也是稳定的。
有些类型不是不可变的,但可以被Compose假设为稳定的,它们可以用@Stable
进行注解。 对于我们在代码中创建的自定义类型,我们可以判断它们是否符合上面列出的属性,并使用@Immutable
或@Stable
注解手动将它们标记为稳定类型。@Immutable
和@Stable
都是通过 @StableMarker
元注解注解的注解,如果要使用这三个注解来标注一个类为稳定的,则该类必须也满足上面三点要求。
因此对于上面的示例代码,即便将 MyData
类上手动添加 @Stable
注解也是不能避免重组的,因为其公开属性就是不稳定的,这违背了上面的要求。另外这三个注解都是开发者对编译器的承诺,但编译器在编译时不会验证它们。因此依靠开发者履行契约是相当危险的,而且很难长期维持,所以编译器会自动推断类的稳定性。
为了推断一个类的稳定性,Compose考虑了不同的东西。当类的所有字段都是只读且稳定时,类型被推断为稳定。像 class Foo
或 class Foo(val value: Int)
这样的类将被推断为稳定的,因为它们没有字段或只有稳定字段。然后像 class Foo(var value: Int)
这种将被推断为不稳定的。对于由其他类组成的类,如 class Foo(val bar: Bar, val bazz: Bazz)
,稳定性被推断为所有参数稳定性的组合。这种情况会通过递归来解决。
像内部可变状态这样的东西也会使类不稳定。其中一个例子如下:
这种状态会随着时间的推移发生变化,即使它是由类本身在内部发生变化。这意味着运行时并不能真正的相信它会总是保持一致不变的。
总的来说,Compose编译器只有在能够证明一个类型是稳定的时候才会考虑它。例如:一个接口被认为是不稳定的,因为Compose不知道它将如何实现。
再来看一个示例代码:
在这个例子中,我们得到一个List
作为参数,它能以可变的方式实现(它可以是List
也可以是MutableList
实现)。对编译器来说,这种情况下推断会变得及其困难,所以它会直接假设它是不稳定的。
对于开头的例子,我们只需将 MyData
类中的公开属性value
改为 val
类型即可使编译器推断它为稳定类型(因为它只有一个公开可变类型而且是基本类型):
@Composable
fun Sample() {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
var counter by remember { mutableStateOf(0) }
var myData = remember { MyData1(0) }
Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
myData = MyData1(myData.value + 1)
},
shape = RoundedCornerShape(5.dp)
) {
MyText(myData.value)
}
}
}
class MyData1(val value: Int = 0)
@Composable
fun MyText(counter: Int = 0) {
Column {
Text("MyText: Read myData.value: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
Text("Another Text", color = Color.White, modifier = Modifier.background(getRandomColor()))
}
}
这样MyText
组件便不会发生重组了,但同时它也不会读取到 myData
中的值。
关于 @Satble
如何使用。官方给出了一个简单的示例:
// 告诉编译器,其类型是稳定的可以跳过不必要的重组
@Stable
interface UiState<T> {
val value T?
val exception: Throwable?
val hasError: Boolean
get() = exception != null
}
在目前来讲,Compose中使用 @Immutable
和 @Satble
的效果是一样的实际效果是一样的。使用它们都会开启和优化智能重组。但实际中应该谨慎使用它们,而是让编译器自动推断类的稳定性。
建议:Composable中使用的所有class
中的var
修饰的公开属性都使用 mutableStateOf()
来修饰。(即满足 “所有公开属性的更改时,总是会通知Composition组合” 的要求)
除了使用注解,保证稳定性的另一种方法是使用不可变的集合类,例如使用 kotlinx 提供的 immutable 集合工具类,不过这需要单独添加依赖:
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:$version")
如果不满足使用前面注解的条件,也不能使用可变的集合类,那么还有一种笨方法,就是将接受不稳定的数据类型手动改为稳定类型。例如下面的代码中,组件接受了一个不稳定的类型:
@Composable
fun WelcomeView(user: UnStableUser) {
Text(text = "Welcome ${user.username}!")
}
data class UnStableUser(
var id: String,
var email: String,
var username: String
)
那么可以自己手工写一个工具方法将此类转换成另一个 Stable 的类:
data class User(
val id: String,
val email: String,
val username: String
)
fun UnStableUser.toUser(): User {
return User(
id = id,
email = email,
username = username
)
}
fun User.toUnStableUser(): ExternalUser {
return UnStableUser(
id = id,
email = email,
username = username
)
}
在转换的时候,可以尽量只选择那些被UI组件使用到的字段,避免引入多余的可变因素。例如上面的使用user的组件其实只使用到了一个 username,在这种情况下其实将输入参数改成String也是可以的。但如果参数较多,还是通过转换类比较方便。
优化 lambda 回调
前面提到 lambda 版本的修饰符可以在读取可变状态时提升性能,但是某些情况下,使用 lambda 也会带来一些性能问题。比如我们通常使用的 lambda 回调:
@Composable
fun SomeLayout(onClick: () -> Unit) {
Button(onClick = onClick) {
Text(text = "text")
}
}
假如我们在另一个 Composable 中调用它
@Composable
fun SomeComposable() {
SomeLayout {
// ...
}
}
在 Composable 中,这种 lambda 回调经常被用于控制反转,即将事件处理的主动权交于外部调用者来实现,但是在 kotlin 中,这种方式的 lambda 调用会生成一个匿名内部类对象(对应JVM平台):
object: Function0<Unit> {
override fun invoke() {
// ...
}
}
由于 Java 中没有所谓的函数类型,当函数类型被翻译到 JVM 平台时,就会以对应的函数接口类型来替代不同参数个数的kotlin函数类型(Function0...Function22
,最多有22个)。而这也是问题所在,调用 lambda 的 Composable 实际上无意间向子组件中传递了一个匿名内部类对象。前面提到过,Compose编译器会对 Composable 中使用的数据类型进行自动类型推断,如果被推断为不稳定的类型,那么就不会执行智能重组(不能跳过重组)。因此,当调用者无意间在子组件暴露的 lambda 中引入了可变因素,那么该匿名内部类对象很可能就会被Compose编译器推断为不稳定,从而导致每次都会触发不必要的重组。
来看下面的例子,
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CoffeeSelector(
type = viewModel.type,
onTypeChange = {
viewModel.changeType(it)
}
)
}
}
@Composable
fun CoffeeSelector(
type: Type,
onTypeChange: (Type) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(100.dp)
.background(getRandomColor()),
contentAlignment = Alignment.Center
) {
val text = when(type) {
Type.BIG -> "大杯"
Type.MIDDLE -> "中杯"
Type.SMALL -> "小杯"
}
Text(text = text, fontSize = 16.sp, color = Color.White)
}
Spacer(modifier = Modifier.height(16.dp))
Column {
MyButton("大杯") {
onTypeChange(Type.BIG)
}
MyButton("中杯") {
onTypeChange(Type.MIDDLE)
}
MyButton("小杯") {
onTypeChange(Type.SMALL)
}
}
}
}
@Composable
fun MyButton(text: String, onClick: () -> Unit) {
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = text,
fontSize = 16.sp,
color = Color.White,
modifier = Modifier.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
}
enum class Type { BIG, MIDDLE, SMALL }
class MainViewModel: ViewModel() {
var type by mutableStateOf(Type.MIDDLE)
private set
fun changeType(type: Type) {
this.type = type
}
}
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainScreen(viewModel)
}
}
}
上面代码中,三个按钮将选择结果通过点击事件回调,回传给调用者,调用者获得选择结果后,更新状态值,这会再次将状态值反馈给组件显示结果。显示选择结果的文本组件会发生重组,这是正常的,但是底部的三个按钮它们没有读取任何状态,每次点击的时候却也发生了重组。这就是问题所在。正常情况下,应该只有展示选择结果的文本组件会发生重组,底部的三个按钮不应该发生任何重组。
这是由于调用 CoffeeSelector
的地方通过 lambda 的方式创建了匿名内部类对象(onTypeChange
的调用),而在这个 lambda 中引入了 viewModel
这个局部变量,因此匿名内部类对象会持有对该局部变量的引用,而我们观察到 MainViewModel
其实是一个不稳定的类型。(因为它具有公开可变类型)
所以CoffeeSelector
实际上是读取了一个包含不稳定的viewModel
对象的匿名内部类对象,该对象被编译器推断为不稳定,CoffeeSelector
中的三个按钮实际上都读取了该匿名内部类对象,因此每次它们都会发生重组。
那么该如何避免这种多余的重组发生呢?首先我们可以直接在调用处避免直接使用 lambda 的形式调用回调,而是改用函数引用进行传递:
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CoffeeSelector(
type = viewModel.type,
onTypeChange = viewModel::changeType
)
}
}
现在运行之后就会发现,点击按钮时,三个按钮不会再发生重组了,而只要上面展示结果的文本组件才会重组:
假如 lambda 回调的参数和要调用的 viewModel
的方法参数不匹配,比如个数不一样,那么就不能直接引用 viewModel
的方法,此时可以采用另一种办法,通过 remember
来创建一个 函数类型的 lambda 变量,同样可以达到目的,虽然它的可读性不是很好:
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
val changeTypeLambda = remember<(Type) -> Unit> {
{
viewModel.changeType(it)
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CoffeeSelector(
type = viewModel.type,
onTypeChange = changeTypeLambda
)
}
}
最后还有一种迂回战术,即不在 lambda 中引入不稳定的类型,或者说引入被Compose认可的类型,例如 mutableStateOf()
:
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
var type by remember { mutableStateOf(Type.MIDDLE) }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CoffeeSelector(
type = type,
onTypeChange = {
type = it
}
)
}
}
这里 type
的类型是枚举类型,它是稳定的类型(因为它不可修改),虽然 type
本身是 var
可变的,但是同时 type
是通过 mutableStateOf()
创建,这意味着,一旦它发生变化,它就会通知组合。因此这种方式是官方的标准用法,也是我们日常开发最常见用法。这种方式也能避免上面例子中的三个按钮的无必要的重组。
测试和开启性能报告
关于 Jetpack Compose 的性能测试,可以参考官方的使用 Macrobenchmark 检查应用性能的Codelab,其中列出了使用 Macrobenchmark 库对应用进行性能测试的详细步骤。这里有一点需要注意的是 benchmark 测试应当是基于真实设备且运行 release 版本的测试(但是你可以使用debug签名),以达到更加接近实际的测试结果。
运行 benchmark 测试后,可以通过 logcat 观察两个重要的指标:TTID (timeToInitialDisplay
) 和 TTFD (timeToFullDisplay
),分别表示第一帧准备好和内容完全绘制完所需的时间。
这里表示启动时间的最小值为 294.8 毫秒,中位数为 301.5 毫秒,最大值为 314.8 毫秒。
TTID 指标跟应用的启动类型密切相关,在Android中,应用启动类型主要分为三种:热启动、温启动和冷启动。它们分别对应的生命周期方法如下图所示:
在冷启动开始时,系统有三个任务,分别是:
- 加载并启动应用。
- 在启动后立即显示应用的空白启动窗口。
- 创建应用进程。
系统一旦创建了应用进程,应用进程就负责后续阶段:
- 创建 Application 对象。
- 启动主线程消息循环。
- 创建主 Activity。
- 填充视图内容。
- 进行View布局。
- 执行初始绘制。
一旦应用进程完成第一次绘制,系统进程就会换掉当前显示的后台窗口,替换为主 activity。此时,用户可以开始使用应用。对于启动性能而言,最容易出现问题的阶段是在创建 Application 和创建主 Activity的过程中。
TTID 发生在 Activity#onStart
阶段,在应用首次冷启动时,它就是从 Application#onCreate
到第一帧开始绘制时的耗时,而在热启动的情况下,它的耗时比冷启动要少很多。注意 TTFD 是内容完全绘制完毕的时间,此时用户真正的可以与应用进行交互操作,注意它跟TTID的区别。
通过 Macrobenchmark 可以自动衡量应用呈现第一帧所需的时间TTID 。不过,直到第一帧呈现之后,应用内容仍未完成加载的情况也很常见,而您可能想要了解用户需要等待多长时间才能使用应用。此等待时间称为完全显示所用时间 TTFD,即应用内容已全部加载,且用户可以与应用互动所需的时间。你可以使用 Activity.reportFullyDrawn()
函数来主动报告应用真正完全显示的时间,以便向 Macrobenchmark 库传达更精准的时间。
如果是在 Composable 中,可以调用 ReportDrawnWhen
和 ReportDrawnAfter
来报告,但这两个 Api 是从 activity-compose 1.7
开始支持的,需要添加对应的依赖。