文章

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)

表面上看,TextColumn 包裹,似乎可以组成一个 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 不再判断,直接重组。

「不再判断」有两层含义:

  1. 结构性相等也会重新执行。
  2. 哪怕是同一个对象(引用相等),也会重新执行。

什么叫「不可靠的」?简单说就是可变的。我们把上面的代码改一下,非常简单,仅仅是把 Message 中的字段改成可变:

data class Message(var v: String)

小知识
data class 的字段一般来说定义为不可变,但允许写成可变的。

就那么一个看起来没有实质性修改的修改,依然保持内容是 "hello" 不变,点击按钮后竟然打印了 "Recompose heavy" 😮!

到这有同学也许和我一样奇怪,明明没有变,凭什么仅仅因为是可变的,就要重新执行一遍?那我们来考虑这样一个情况:msg1msg2 是两个不同的对象,它们的值都是 "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 的类还应该满足其他的要求,即一共有三个要求:

  1. 现在相等的两个类,需永远相等。(相等性恒定不变)
  2. public 属性改变时需要触发刷新。 如果满足这一条编译器自动视为可靠,无需 @Stable 注解。
  3. 它的 public 属性也得是 Stable 的。

尽管要成为可靠类需要满足三个条件,但编译器只有能力检测第二个。 日常开发中我们还是需要留意一下,以免不小心被作为可靠对象埋下祸根。

第二个要求看起来复杂,实现起来却简单:把属性包一层 mutableStateOf() 就行了。比如这样:

//@Stable
class Message(v: String) { 
    var v by mutableStateOf(v)
}

解释一下:

  1. 这不是 data class 也没有重写 equals(),它的相等性恒定不变(同一个对象永远相等,不同的对象永远不等)。
  2. public 属性 v 能够触发刷新。
  3. 由于构造方法中写字段的语法不支持 by,因此把参数与字段分离了。这是 Compose 中常见的写法。
  4. 现在已经满足了条件 2,编译器会自动判定可靠,所以不需要 @Stable 注解了。

💡 Tips: 基本类型与 String 都是可靠的。因为它们根本就没有内部属性。

@Immutable

@Immutable 是比 stable 更 stable 的注解,它表示内部属性根本就不会变。如今 @Immutable@Stable 的效果是一致的,它俩的区别更多是开发者自己备注用。

小结

为了提高重组时的性能,应该尽量使用可靠的对象。目前能想到的包括下面这些情况:

  • 使用 data class 时属性全部声明为不可变类型。
  • 使用普通类时,若包含可变属性则不要重写 equals(),可变的 public 属性用 MutableState 包裹一下。
  • 若系统无法自动判定出可靠,则添加注解。通常是在接口或抽象类处,因为其他地方编译器一般都能自动判断为可靠。

参考