公众号「稀有猿诉」 原文链接 降Compose十八掌之『损则有孚』| Lifecycle
这篇文章大部分是官方文档的翻译,但并不是严格的翻译,同时也加入了笔者自己的理解。
通过前面的一系列文章我们已经基本掌握了使用Jetpack Compose来构建UI的方法,在接下来的几篇文章中将重点转移到Compose本身,理解一下Compose是如何把一坨坨的函数(Composables)转化成为目标平台UI的。先从composable的生命周期开始。
注意: 这里的生命周期是指Compose中的基本单元composable函数的生命周期,与目标平台(如Android)的生命周期不是一个概念,没有关系。
概述
在前面讲解状态(State)的文章中提到过,composable函数是Jetpack Compose的基本单元,运行composables就是组合(Composition),组合将会变成应用的UI。
当Jetpack Compose首次运行composables时,也即首次组合(Initial composition),它会追踪在组合中用来描述UI的composables。之后,当有状态变化时,Jetpack Compose会安排重组。重组就是重新执行状态发生变化的composables以作为对状态变化的响应,然后再更新组合体现变更。
组合仅能在首次组合过程中生成然后在重组中更新。修改组合的唯一方式就是通过重组。
生命周期定义
一个composable的生命周期可以用三个事件来定义:进入组合,重组,离开组合。
图1. 组合中的一个composable的生命周期:进入组合,没有重组或者重组多次,最后离开组合。
重组通常都是由状态对象发生变化触发的。Compose会追踪这些状态然后执行在组合中读取这些状态的所有composables,以及被这些composables调用的且无法被跳过的composables。
注意: Composable的生命周期较View系统和Android平台的Activity以及Fragment要相对简单一些。如果一个composable需要处理外部的资源或者管理更为复杂的生命周期,可以使用副作用(Side Effects)。
如果一个composable被调用了多次,就会有多个实例被放入到组合之中。每一次调用都有独立的属于它自己的生命周期。来看一个例子:
@Composable
fun MyComposable() {
Column {
Text("Hello")
Text("World")
}
}
图2. 在组合中MyComposable的可视化表示。如果一个composable被调用了多次,会在组合中生成多个实例。图中不同颜色的元素代表不同的实例。
剖析组合中的composables
组合中一个composable实例是用其调用点来标识的。Compose编译器认为每个调用点都是不一样的。从多个调用点调用composables会在组合中创建多个实例。
关键术语: 调用点指的是composable被调用的代码位置。调用点会影响组合,进而影响最终UI。
在重组过程中,如果一个composable调用了与其上一次重组中调用的不同的composables,Compose会标识出哪些composables已调用过,哪些还未被调用过,对于两次组合中都调用了的composables,如果它们的输入没有变化则Compose不会予以执行。
因此,给关联到composable的副作用(各种Side Effects)指定标识就显得龙为重要,这样它们能成功的执行完成,而不是每次重组时都重新启动。
对于下面这个例子:
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
LoginInput() // This call site affects where LoginInput is placed in Composition
}
@Composable
fun LoginInput() { /* ... */ }
@Composable
fun LoginError() { /* ... */ }
上面的代码中,函数LoginScreen会在一定条件下调用函数LogginError,并且总是会调用函数LoginInput。每个调用都有一个独一无二的调用点和代码位置,编译器正是用这些信息来独一无二的标识每个composable。
图3. 在组合中,当有状态变化和重组发生时,LoginScreen的可视化展示。相同的颜色元素代表没有被重组。
尽管LoginInput从第一个被调用的函数变成了第二个被调用的函数,它的实例在重组中得以留存。并且,因为LoginInput并没有在重组之间发生变化的参数,Compose会跳过对LoginInput的再次调用。
提供额外的信息以优化重组
多次调用一个composable会在组合中添加多个实例。当在同一个调用点多次调用同一个composable时,因为Compose没有可用的信息来独一无二的标识每个调用,所以composable的执行顺序被用以区别这些composable实例。有些时候这也够用了,但有些时候这会导致一些非预期的行为。
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
// MovieOverview composables are placed in Composition given its
// index position in the for loop
MovieOverview(movie)
}
}
}
在上面的代码中,Compose会用执行顺序来区别调用的composable实例。如果一个新的数据元素movie被添加到了列表的底部(最后面),Compose可以复用已经在组合中的实例,因为它们的位次没有变化,故而这些composable的输入数据元素movie并不会变化,也就是说因为只在最后添加,先前存在的实例与其数据还是能够对应得上的。
图4. 当一个新数据元素moviei添加到列表底部后时,组合中MovieScreen的可视化表示。组合中函数MovieOverview的实例会被复用。相同颜色的元素表示未被重组。
然而,如果输入列表的变化是在其顶部添加新元素,或者在中间添加新元素,或者有移除,或者变化元素顺序时,就会对列表中位置发生变化的所有MovieOverview进行重组。如果有储如在MovieOverview中获取电影图片的副作用函数的话,这些仅因位置改变而发生的重组就特别重要了。因为重组会影响副作用函数,如果副作用正在进行中,会被取消然后重新启动。
@Composable
fun MovieOverview(movie: Movie) {
Column {
// Side effect explained later in the docs. If MovieOverview
// recomposes, while fetching the image is in progress,
// it is cancelled and restarted.
val image = loadNetworkImage(movie.url)
MovieHeader(image)
/* ... */
}
}
图5. 新元素添加到列表中时组合中MovieScreen的可视化表示。MovieScreen实例无法复用,所有的副作用会重启。不同的颜色代表发生了重组。
理想情况下,应该让函数MovieScreen的实例标识与其数据项的标识联系起来。如果列表数据项顺序有变化,最为想理的办法是也把组合树中的对应的函数实例进行次序调整,而不是进行重组(前面说了次序作为函数实例的标识,次序变了,就要使用新位置的数据项调用composable进行重组)。Compose给我们提供了一个方法用以标识组会树中的函数实例:即函数key。
把代码块放入函数key里面,再传给函数key一些数据,这些数据会被组合起来以标识组合中的函数实例。传给函数key的数据不必是全局唯一的,它只需要在key所在的调用点是唯一的就行。比如在前面例子中,每个数据项movie需要有一个唯一的标识,它能在这个列表中唯一标识一部电影就可以了:
@Composable
fun MoviesScreenWithKey(movies: List<Movie>) {
Column {
for (movie in movies) {
key(movie.id) { // Unique ID for this movie
MovieOverview(movie)
}
}
}
}
像上面用了key以后,无论列表怎么变化,Compose都能辩识出具体composable实例,然后加以复用:
图6. 当新数据元素添加到列表时组合中MovieScreen的可视化展示。因为有了唯一标识,Compose能识别出哪些实例未发生变化,加以复用,它们附带的副作用会继续执行。
关键点: 适度的使用函数key来帮助Compose唯一标识函数实例。特别是针对在同一个调用点大量调用同一个composable时,比如在各种集合性布局中。
有些composable有更为友好的key支持方法。比如像LazyColumn它可以直接在其items DSL中传入一个lambda作为key:
@Composable
fun MoviesScreenLazy(movies: List<Movie>) {
LazyColumn {
items(movies, key = { movie -> movie.id }) { movie ->
MovieOverview(movie)
}
}
}
重组时跳过composable的策略
在重组过程中,一些具备条件的composable函数可以让Compose跳过他们的执行,如果它们的输入参数较前一次组合时没有任何变化。
除了以下情况外,就可以说一个composable函数具备跳过条件:
- 函数有返回值(non-Unit return type)
- 函数使用了注解@NonRestartableComposable或者@NonSkippableComposable修饰
- 必需的参数是一个非稳定类型(non-stable type)
前两个都好理解,接下来重点看第三个情况。一个类型要想成为稳定的(stable),必须符合以下约定:
- 对于两个相内实例来说,对其们使用equals方法的返回值必须永远相同
- 如果一个类型的公开属性发生变化,组合会得到通知
- 所有公开属性类型也必须是稳定的
有一些重要的常见类型符合这个约定,Compose编译器会把它们当成稳定的类型,尽管他们并没有使用注解@Stable显式地标注为稳定的:
- 所有的基础数据类型:布尔(Boolean),整数(Int),长整数(Long),浮点(Float),字符(Char)等
- 字符串(String)
- 所有的函数类型(lambdas)
所有这些类型都能符合稳定约定,因为他们都是不可变类型。因为不可变类型实例不会改变,它们不会通知组合说值有所改变,因此就能符合上述约定。
注意: 所有的整体不可变类型都可以安全地当成稳定的类型。
一个值得注意的类型是可变状态类型(MutableState),虽然是稳定的但却可变可修改。如果MutableState中持有一个值,这个状态对象被认为是稳定的,因为State属性.value发生的任何变化都会通知给Compose。
当作为传递给一个composable函数参数的所有类型都是稳定的(stable)时,这些参数的值会基于它们在UI树中的函数位置进行等值比较(equality)。从前一次组合起如果值未变化就会跳过其重组。换句话说输入参数的类型是稳定的(stable)是一个大前提,只有稳定的类型比较等值才有意义。
关键点: 如果一个composable的输入是稳定的且未有变化,Compose就会跳过它的重组。等值比较使用的是方法equals。
仅当Compose能够证明一个类型是稳定的时,才会把一个类型当作稳定的。例如,接口(interface)通常认为是不稳定的,拥有可变公开属性的类型,虽然这些属性的实现可以是不可变的,但这种类型也认为是不稳定的。
如果Compose无法推断出一个类型是不是稳定的,但是想强制它被当作稳定的类型,可以使用注解@Stable来标注。
// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
val value: T?
val exception: Throwable?
val hasError: Boolean
get() = exception != null
}
上面的代码片段中,因为UiState是一个接口,会被当成不稳定的类型。通过添加注解@Stable,告诉Compose它是稳定的,让Compose进行智能重组。这也意味着,当接口类型用于参数类型时,Compose会把接口的所有具体实现当成稳定的类型。
关键点: 如果Compose无法推断出类型的稳定性,使用注解@Stable来标注以让Compose进行智能重组。
总结
Composable函数是Compose的基本单元,通过此文我们理解了一个composable的生命周期,并对Compose的重组机制做了介绍,以及如何更好的让Compose做智能重组。
References
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!