Jetpack Compose 笔记(3) - 重组的性能风险
重组的性能问题
[第一节]({{< ref "JetpackCompose笔记(1).md" >}})就讲到,Compose 过程第一步是「组合」,当变量发生改变时,可能触发「重组」。所谓重组,就是要重新执行我们写的 Compose 代码,为此 Compose 对代码进行了包装。同时为了优化性能,包装一般是比较细粒度的。这是否意味着可以高枕无忧了呢?看下面的代码:
var msg = Message("hello")
setContent {
println("Recompose 1")
Column {
var num by remember { mutableStateOf(1) }
println("Recompose 2")
Heavy(msg)
Text(text = "数字为 $num")
Button(onClick = { num++ }) {
Text(text = "Change Name")
msg = Message("hello")
}
}
}
@Composable
fun Heavy(msg: Message) {
println("Recompose heavy")
Text(text = "很重的操作:${msg.v}")
}
data class Message(val v: String)
表面上看,Text
被 Column
包裹,似乎可以组成一个 Recompose Scope,所以 num
发生改变重组的时候,"Recompose 1" 应该不会打印。非也!Column
是内联函数,编译展开后根本不存在,自然也就没有包裹效果。所以整个 setContent
块是一个重组作用域。假设我们执行了一些无关 num
的操作,或者创建了其他 Compose 组件,它们就会一并重新执行,带来额外开销。
智能优化
可靠类
根据上面的分析,Heavy()
毫无疑问会在重组时执行,但事实再一次反直觉:"Recompose heavy" 没有打印 😮!这就是 Compose 的智能优化了。
我们所写的代码,不是直接被编译为字节码执行。而是 由 Compose 先行加工,这个加工包含很多处理,其中之一是添加对参数的判断,若参数没有改变,那就不再执行内部代码,以此增强性能。
注意:智能优化不是 Compose 相对于传统 View 的优势。 为了解决自动更新时范围过大的问题,智能优化应运而生。而传统 View 系统根本就没有自动更新功能,UI 的刷新是我们手动控制的,连问题本身都不存在,何谈解决方案的优势?
此处的「相等判断」,采用的是 Structural Equality,即 Kotlin 中的 ==
,也就是 java 中的 equals()
。因此虽然我们在点击事件中给 msg
分配了新的对象,也不会重新执行。
⚠️ 以上规则只对「可靠的」类生效!
不可靠类
对于不可靠类 Compose 不再判断,直接重组。
「不再判断」有两层含义:
- 结构性相等也会重新执行。
- 哪怕是同一个对象(引用相等),也会重新执行。
什么叫「不可靠的」?简单说就是可变的。我们把上面的代码改一下,非常简单,仅仅是把 Message
中的字段改成可变:
data class Message(var v: String)
小知识
data class 的字段一般来说定义为不可变,但允许写成可变的。
就那么一个看起来没有实质性修改的修改,依然保持内容是 "hello" 不变,点击按钮后竟然打印了 "Recompose heavy" 😮!
到这有同学也许和我一样奇怪,明明没有变,凭什么仅仅因为是可变的,就要重新执行一遍?那我们来考虑这样一个情况:msg1
和 msg2
是两个不同的对象,它们的值都是 "hello"。一开始令 msg = msg1
,点击按钮后令 msg = msg2
。假设因为结构性相等此时不重新执行,目前来说,还没什么问题。但如果未来某时某地,执行了 msg2.v = "world"
,UI 刷新完毕后,显示的内容会更新吗?答:不会。此时 Heavy()
内部参考的对象是 msg1
,而我们修改的是 msg2
,所以这个改变不会反应到 UI 上,喜提 🐛 Bug 一枚。总结,如此设计的原因是:两个可变对象(不可靠对象)现在的值相等,不代表未来的值也相等。
@Stable
现在,我们知道了 Compose 自动优化的能力,也了解了它的局限。自然而然地,我们想,能不能打破这个局限?当然可以,而且很简单。只要给类加一个 @Stable
注解,Compose 就将其视为可靠的,无论真实情况如何。
需要强调的是,我们要解决的是问题,而不是提出问题的人 [doge],掩耳盗铃最终受伤的还是自己。从某种程度上说,@Stable
就是一种掩耳盗铃,除非,我们保证了它真的不会改变,或者说,即使改变了也会和曾经的对象同步。@Stable
是一种契约,就像 Kotlin 的 lateinit
一样。我们对编译器许下承诺换取了更为宽松的限制,也就意味着需要独自面对潜在的风险。 —— 不要滥用这个注解,除非你知道究竟是在干什么。
「With great power , there must also comes great resposibility」 —— Voltaire
除此之外,标记 @Stable
的类还应该满足其他的要求,即一共有三个要求:
- 现在相等的两个类,需永远相等。(相等性恒定不变)
- public 属性改变时需要触发刷新。 如果满足这一条编译器自动视为可靠,无需
@Stable
注解。 - 它的 public 属性也得是 Stable 的。
尽管要成为可靠类需要满足三个条件,但编译器只有能力检测第二个。 日常开发中我们还是需要留意一下,以免不小心被作为可靠对象埋下祸根。
第二个要求看起来复杂,实现起来却简单:把属性包一层 mutableStateOf()
就行了。比如这样:
//@Stable
class Message(v: String) {
var v by mutableStateOf(v)
}
解释一下:
- 这不是
data class
也没有重写equals()
,它的相等性恒定不变(同一个对象永远相等,不同的对象永远不等)。 - public 属性
v
能够触发刷新。 - 由于构造方法中写字段的语法不支持
by
,因此把参数与字段分离了。这是 Compose 中常见的写法。 - 现在已经满足了条件 2,编译器会自动判定可靠,所以不需要
@Stable
注解了。
💡 Tips: 基本类型与 String
都是可靠的。因为它们根本就没有内部属性。
@Immutable
@Immutable
是比 stable 更 stable 的注解,它表示内部属性根本就不会变。如今 @Immutable
与 @Stable
的效果是一致的,它俩的区别更多是开发者自己备注用。
小结
为了提高重组时的性能,应该尽量使用可靠的对象。目前能想到的包括下面这些情况:
- 使用
data class
时属性全部声明为不可变类型。 - 使用普通类时,若包含可变属性则不要重写
equals()
,可变的 public 属性用 MutableState 包裹一下。 - 若系统无法自动判定出可靠,则添加注解。通常是在接口或抽象类处,因为其他地方编译器一般都能自动判断为可靠。