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

简介: 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)识别为稳定类型,例如 ImmutableSet,ImmutableList 等,即使他们只是 interface。因此我们可以通过修改 tags 的声明类型来提升其稳定性:

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 引用数据类型时,需要通过辅助手段提升其稳定性


目录
相关文章
|
弹性计算
阿里云游戏服务器价格表
阿里云游戏服务器价格表,可用于搭建幻兽帕鲁服务器,阿里云游戏服务器租用价格表:4核16G服务器26元1个月、146元半年,游戏专业服务器8核32G配置90元一个月、271元3个月,阿里云百科分享阿里云游戏专用服务器详细配置和精准报价
3376 1
|
XML 前端开发 IDE
在 Compose 中使用 Jetpack 组件库
在 Compose 中使用 Jetpack 组件库
989 0
|
移动开发 JavaScript 前端开发
白话 uni-app,细说 uni-app 和传统 H5 的区别
白话uni-app 本文适合对象: 已经通过uni-app官网对产品概念有了了解,看过uni-app的官方视频介绍 熟悉h5,但对小程序、vue不了解 传统的h5只有1端,即浏览器。而uni-app可跨7端,虽仍属前端,与传统h5有不同。
11962 0
|
7月前
|
机器学习/深度学习 JSON 监控
国内最大的MCP中文社区来了,4000多个服务等你体验
国内最大的MCP中文社区MCPServers来了!平台汇聚4000多个服务资源,涵盖娱乐、监控、云平台等多个领域,为开发者提供一站式技术支持。不仅有丰富的中文学习资料,还有详细的实战教程,如一键接入MCP天气服务等。MCPServers专注模块稳定性和实用性,经过99.99% SLA认证,是高效开发的理想选择。立即访问mcpservers.cn,开启你的开发之旅!
9780 16
|
Android开发
AS错误:Duplicate class kotlin.xxx.jdk8.DurationConversionsJDK8Kt found in modules kotlin-stdlib-1.8.22
本文描述了Android Studio中遇到的"Duplicate class found in modules"错误的解决方法,通过在`app/build.gradle`文件中使用`constraints`来排除过时的kotlin-stdlib-jdk7和kotlin-stdlib-jdk8依赖,解决了依赖冲突问题。
1132 1
|
编译器 API Android开发
Android经典实战之Kotlin Multiplatform 中,如何处理不同平台的 API 调用
本文介绍Kotlin Multiplatform (KMP) 中使用 `expect` 和 `actual` 关键字处理多平台API调用的方法。通过共通代码集定义预期API,各平台提供具体实现,编译器确保正确匹配,支持依赖注入、枚举类处理等,实现跨平台代码重用与原生性能。附带示例展示如何定义跨平台函数与类。
424 0
|
存储 人工智能 文字识别
AI开发初体验:昇腾加持,OrangePi AIpro 开发板
本文分享了作者使用OrangePi AIpro开发板的初体验,详细介绍了开箱、硬件连接、AI程序开发环境搭建、以及通过Jupyter Lab运行AI程序的过程,并展示了文字识别、图像分类和卡通化等AI应用实例,表达了AI时代已经到来的观点。
1951 1
|
监控 数据可视化 BI
ERP系统中的财务报告与财务分析解析
【7月更文挑战第25天】 ERP系统中的财务报告与财务分析解析
685 4
|
Java 数据库 Android开发
使用Hilt完成依赖注入,让你的安卓代码层次有几层楼那么高(三)
使用Hilt完成依赖注入,让你的安卓代码层次有几层楼那么高(三)
346 0
|
机器学习/深度学习 监控 算法
Keras进阶:模型调优与部署
该文介绍了Keras模型调优与部署的策略。调优包括调整网络结构(增减层数、改变层类型、使用正则化)、优化算法与参数(选择优化器、学习率衰减)、数据增强(图像变换、噪声添加)、模型集成(Bagging、Boosting)和超参数搜索(网格搜索、随机搜索、贝叶斯优化)。部署涉及模型保存加载、压缩(剪枝、量化、蒸馏)、转换(TensorFlow Lite、ONNX)和服务化(TensorFlow Serving、Docker)。文章强调了持续监控与更新的重要性,以适应不断变化的数据和需求。【6月更文挑战第7天】
447 8