Compose 类型稳定性注解:@Stable & @Immutable

·  阅读 2679
Compose 类型稳定性注解:@Stable & @Immutable

前言

@Stable@Immuable 是 Compose 特有的类型稳定性注解,可以帮助 Compose 提升重组性能。本文将针对 Compose 类型的稳定性以及相关注解的使用做一个介绍。

1. 重组与稳定类型

我们知道 Compose 的重组非常“智能”,一个 Composable 函数在重组中被调用时,如果参数与上次调用时相比没有发生变化,则函数的执行会跳过重组,提升重组性能。但其实有时候即使参数没有发生变化重组也会进行,看下面的例子:

class MutableString(var data: String)

@Composable
fun StableTest() {
    val str = remember { MutableString("Hello") }
    var state by remember { mutableStateOf(false) }
    if (state) {
        str.data = "World"
    }
    // WrapperText 会随 state 的变化而重组
    Button(onClick = { state = true }) {
        WrapperText(str)
    }
}

@Composable
fun WrapperText(data: MutableString) {
    Text("${data.data}")
}
复制代码

我们点击 Button 后 state 改变造成 StableTest 重组,MutableString 类型的 str 在重组前后指向同一实例,只是 data 值发生 "Hello" > "World" 的变化,如果在调用 WrapperText 时,对重组前后的参数进行比较将无法发现变化,但是实际执行会发现,重组并没有被跳过,此时 WrapperText 依然参与重组,正确地更新了文本。

重组中 Composable 参数进行比较的前提是参数类型必须是“稳定”类型,如果 Composable 参数中有不稳定类型,则 Composable 无法跳过重组。所以看来 MutableString 并非稳定类型,那什么样的类型算是“稳定”的呢?Compose 中稳定类型需符合以下特征:

  • 对于类型 T 的两个实例 ab,如果 a.equals.(b) 的结果是长期不变的,那么 T 是一个稳定类型。所以一个 Immutable 类型自然也是稳定类型
  • 如果类型 T 存在可变的 public 属性,且所有 public 属性的变化都能被感知并正确反映到 Compositioin,即属性的类型是 MutableState 的,那么 T 也是一个稳定类型。
  • 稳定类型的所有 public 的属性也必须是稳定类型。因为有可能你对 equals 进行了重写造成某个 public 属性不参与比较,但属性却有可能在 Composition 中被引用,为了保证引用的正确性,则要求它也必须是稳定的。

一言以蔽之,稳定类型要么不可变,要么其变化可被追踪。回看前面例子中的 MutableString,它的成员 data 不是 final 的且其变化无法被追踪,所以它并不是一个稳定类型。

2. @Stable 与 @Immutable

Compose 编译器在编译期会识别 Composable 函数的参数是否是稳定类型,当识别为稳定类型时,意味着参数比较的结果是可信的,此时会插入相关 equals 代码,以便于跳过不必要的重组。 编译器会将以下类型自动识别为稳定类型:

  • Kotlin 中的基本类型,Boolean, Int, Long, Float, Char 等等
  • String 类型
  • 各种函数类型、Lambda
  • 所有 public 属性都是 final (val 声明)的对象类型,且属性类型是不可变类型或可观察类型

不符合上述规范的类型是不稳定类型,但是我们可以通过手动添加 @Stable 或者 @Immutable 注解让编译器将其看待为稳定类型,@Immutable 代表类型完全不可变,@Stable 代表类型虽然可变但是变化可追踪。

文章开头的例子中,如果为 MutableString 添加 @Stable 或者 @Immutable 后,再次执行会发现结果中 "Hello" 不会变为 "World"。

//data 虽然是 var 但是由于添加 @Stable,被认为是稳定类型
@Stable class MutableString(var data: String) 
复制代码

MutableString 作为稳定类型被插入了 equals 逻辑,由于比较结果恒为 true 所以跳过重组。这造成了 str 的更新无法整成显示,不符合预期,因此我们添加 @Stable 注解时一定要慎之又慎,避免出现不符合预期的错误。

还有一点需要特别注意,对于 interface 添加 @Stable 注解后,其派生类默认都会被当做稳定类型处理。比如下面的 UiState 接口的子类都是稳定类型

@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}
复制代码

@Stable 与 @Immutable 在编译器的处理上并没什么不同,都是在适当的代码位置插入参数比较代码,而且 @Stable 相对于 @Immutable 的使用场景更广泛,除了修饰 Class,还可以修饰函数、属性等等,因此大家可以优先使用 @Stable,@Immutable 或许会在未来被逐渐废弃。

下面通过几个例子,再体会一下编译器对稳定类型的处理:

//1. 不可变类型:String
@Composable fun showString(string: String) { 
    Text(text = "Hello ${string}")
}

//2. 可变类型:有可变的属性
class MutableString(var data: String)
@Composable fun showMutableString(string: MutableString) {
    Text(text = "Hello ${string.data}")
}

//3. 不可变类型:成员属性全是 final 
class ImmutableString(val data: String)
@Composable fun showImmutableString(string: ImmutableString) {
    Text(text = "Hello ${string.data}")
}

//4. 可变类型加 @Stable 注解
@Stable class StableMutableString(var data: String)
@Composable fun showStableMutableString(string: StableMutableString) {
    Text(text = "Hello ${string.data}")
}

//5. 变化可被追踪
class MutableString2(
    val data: MutableState<String> = mutableStateOf(""),
)
@Composable fun showMutableString2(string: MutableString2) {
    Text(text = "Hello ${string.data}")
}
复制代码

以上除了 2 以外,其他 1,3,4,5 都都会被编译器作为稳定类型对待,字节码如下:

 // 1,3,4,5
 public static final void showString(String string, Composer $composer, int $changed) {
        //...
        Composer $composer = $composer.startRestartGroup(601350781);
        int $dirty = $changed;
        if ((i & 14) == 0) {
            // Composer#changed 对参数进行比较
            $dirty |= $composer.changed((Object) string) ? 4 : 2;
        }
        if ((($dirty & 11) ^ 2) != 0 || !$composer.getSkipping()) {
            // 参数输入有变化则调用 Text
            Text($composer, string.getData(), /*...*/);
        } else {
            // 没有变化则不调用 Text
            $composer.skipToGroupEnd();
        }
        ScopeUpdateScope endRestartGroup = $composer.endRestartGroup();
        //...
    }

// 2
public static final void showMutableString(MutableString string, Composer $composer, int $changed) {
        //...
        Composer $composer = $composer.startRestartGroup(1498293802);
        Text($composer, string.getData(), /*...*/);
        ScopeUpdateScope endRestartGroup = $composer2.endRestartGroup();
        //...
    }
复制代码

可以看到,当编译器将参数类型识别为稳定类型时,会插入 $composer.changed((Object) string) 对参数与上次重组中的输入进行比较,看一下 changed 的实现非常简单:

override fun changed(value: Any?): Boolean {
    return if (nextSlot() != value) {
        updateValue(value)
        true
    } else {
        false
    }
}
复制代码

如上,nextSlot() 从 Composition 中读取存储的上一次的参数与 value 进行比较,对 Slot 的概念不清楚的,可以看我的这篇文章:

需要注意,稳定类型的所有 public 子属性必须全部为稳定类型,对于上面例子中的 3 和 5,一旦有成员是非 fianl 或者非 MutableState 的,那么就不会被视为稳定类型了。

3. 提升类型的稳定性

通过前面介绍,我们知道了 Compose 编译器针对稳定类型的特殊处理,在日常开发中,我们可以留意那些可以被提升稳定性的类型,以提高重组性能。我们以官方 Sample 中的 JetSnack 源码为例,源码中有一个 · 数据类,定义如下:

/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */

data class Snack(
  val id: Long,
  val name: String,
  val imageUrl: String,
  val price: Long,
  val tagline: String = "",
  val tags: Set<String> = emptySet()
)
复制代码

Snack 所有成员均为 final,看起来是一个稳定类型,但是很遗憾它会被视为一个非稳定类型,因为 · 作为一个 interface 将被视为一个非稳定类型。因为编译器不知道其实现类是否是 Mutable 的,比如下面这样:

val set: Set<String> = mutableSetOf(“foo”)
复制代码

实际上在 JetSnack 中,· 并不存在动态修改的场景,如果能让其被识别为稳定类型则可以提升重组性能。一个好消息是 Compose 编译器 1.2 之后,可以将 Kotlin 的 Immutable 集合(org.jetbrains.kotlin.kotlinx.collections.immutable)识别为稳定类型,例如 ·,· 等,即使他们是 interface。因此我们可以通过修改 · 的声明类型来提升其稳定性:

val tags: ImmutableSet<String> = persistentSetOf()
复制代码

另一个提升稳定性的方法,就是通过添加本文介绍的 @Stable 注解,如下:

/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */

@Stable
data class Snack(
  val id: Long,
  val name: String,
  val imageUrl: String,
  val price: Long,
  val tagline: String = "",
  val tags: Set<String> = emptySet()
)
复制代码

看一下 JetSnack 中引用了 Snack 的类型的稳定性的变化

data class OrderLine(
    val snack: Snack,
    val count: Int
)


data class SnackCollection(
    val id: Long,
    val name: String,
    val snacks: List<Snack>,
    val type: CollectionType = CollectionType.Normal
)


@Composable
private fun HighlightedSnacks(
    index: Int,
    snacks: List<Snack>,
    onSnackClick: (Long) -> Unit,
    modifier: Modifier = Modifier
) { ... }
复制代码
  • OrderLine 由于 snack 属性变为稳定类型,其自身会被自动推断为稳定类型。
  • SnackCollection 的 snacks 的 List 中的泛型类型是 Snack ,但是 List 本身是不稳定的,所以想要将 SnackCollection 改为稳定类型,可以添加 @Stable 或者 @Immutable,亦或者将 List 改为 ImmutableList
  • HighlightedSnacks 也是同样,可以通过添加 @Stable 注解,或者将 List 改为 ImmutableList 提升稳定性。注意 @Immutable 不能修饰函数。

前面讲过,我们对于 @Stable 和 @Immutable 注解的使用要慎之又慎,所以优先推荐使用不依靠注解提升稳定性的方法。

4. 跨 Modules 的类型引用

通常我们的项目中可能不止一个 Gradle Module 。很多项目会按照官方推荐的架构规范,将 UI 层、Data 层等分 Module 管理,Composable 定义在 UI 层,而数据类可能定义在 Data 层,被 UI 层引用。此时需要特别注意的是,Data 层的 Module 由于没有启动 Compose 编译器插件,对于非基本型的稳定性无法自动推断。比如前面 Snack 如果定义在单独的 Module 且没有启动编译期插件,那么即使将 List 改为 ImmutableList,对于使用到他的 UI 层 Composable 来说仍然无法识别为稳定类型。此时有以下几种方式解决:

  1. 添加 @Stable 或者 @Immutable 注解,强制设为稳定类型,这会导致增加对 compose-runtime 的依赖,注意没必要依赖 compose-ui 的任何库
  2. 为 Data 层的 Module 开启 Compose 插件
  3. 在 UI 层对 Data 层的类型进行封装,并添加稳定性注解。

当然,同样的问题也发生在对三方库的依赖上,而且三方库没法修改源码,只能用上面第三种方式予以解决。

5. 总结

  1. Compose 会针对稳定类型进行编译期优化,通过对输入参数的比较跳过不必要的重组
  2. 稳定类型包括所有的基本型、String类型、函数类型,以及符合以下条件的非基本类型:
  • 非 interface
  • 所有 public 属性均为 final
  • 所有 public 属性均为稳定类型或者 MutableState
  1. 通过添加 @Stable 或者 @Immutable 注解可以提升重组性能,注解的使用要慎重
  2. 跨 Module 引用数据类型时,需要通过辅助手段提升其稳定性

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

收藏成功!
已添加到「」, 点击更改