一、智能的重组
传统视图中通过修改View
的私有属性来改变UI
, Compose
则通过重组刷新UI
。Compose
的重组非常"智能",当重组发生时,只有状态发生更新的Composable
才会参与重组,没有变化的Composable
会跳过本次重组。
二、避免重组的"陷阱"
由于Composable
在编译期代码会发生变化,代码的实际运行情况可能并不如你预期的那样。所以需要了解Composable
在重组执行时的一些特性,避免落入重组的"陷阱"。
1、Composable会以任意顺序执行
首先需要注意的是当代码中出现多个Composable
时,它们并不一定按照代码中出现的顺序执行。比如,在一个Navigation
中处于Stack
最上方的UI
会优先被绘制,在一个Box
布局中处于前景的UI
具有较高的优先级,因此Composable
会根据优先级来执行,这与代码中出现的位置可能并不一致。
Composable
都应该"自给自足",不要试图通过外部变量与其他Composable
产生关联。在Composable
中改变外部环境变量属于一种"副作用 "行为,Composable
应该尽量避免副作用。
2、Composable会并发执行
重组中的Composable
并不一定执行在UI
线程,它们可能在后台线程池中并行执行,这有利于发挥多核处理器的性能优势。但是由于多个Composable
在同一时间可能执行在不同线程,此时必须考虑线程安全问题。看看下面EventsFeed
的例子:
kotlin
@Composable
fun EventsFeed(localEvents: List<Event>, nationalEvents: List<Event>) {
var totalEvents = 0
Row {
Column { //column-content-1
localEvents.forEach { event ->
Text("Item: $ {event.name}")
totalEvents++
}
}
Spacer(Modifier.height(10.dp))
Column { //column-content-2
nationalEvents.forEach { event ->
Text("Item: $ {event.name}")
totalEvents++
}
}
Text(
if (totalEvents == 0) "No events." else "Total events StotalEvents"
)
}
}
本例想使用totalEvents
记录events
的合计数量并在Text
显示,column-content-1
和column-content-2
有可能在不同线程并行执行,所以totalEvents
的累加是非线程安全的,结果可能不准确。即使totalEvents
的结果准确,由于Text
可能运行在单独线程,所以也不一定能正确显示结果,这同样还是Composable
的副作用带来的问题,大家需要极力避免。
3、Composable会反复执行
除了重组会造成Composable
的再次执行外,在动画等场景中每一帧的变化都可能引起Composable
的执行,因此Composable
有可能会短时间内反复执行,我们无法准确判断它的执行次数。大家在写代码时必须考虑到这一点:即使多次执行也不应该出现性能问题,更不应该对外部产生额外影响。来看下面的例子:
kotlin
@Composable
fun EventsFeed(networkService: EventsNetworkService) {
//异步请求数据
val events = networkService.loadAllEvents()
LazyColumn {
items(events) { event ->
Text(text = event.name)
}
}
}
在EventsFeed
中,loadAllEvents
是一个IO
操作,执行成本高,如果在Composable
中同步调用,会在重组时造成卡顿。也许有人会提出将数据请求逻辑放到异步线程执行,以提高性能。这里尝试将数据请求的逻辑移动到ViewModel
中异步执行,避免阻塞中线程:
kotlin
@Composable
fun EventsFeed(viewModel: EventsViewModel) {
//异步执行结果回调到主线程并将Flow转换为State
val events = viewModel.loadAllEvents().collectAsState(emptyList())
LazyColumn {
items(events) { event ->
Text(text = event.name)
}
}
}
虽然没有了同步IO
的烦恼,但是events
的更新会触发EventsFeed
重组,从而造成loadAllEvents
的再次执行。loadAllEvents
作为一个副作用 不应该跟随重组反复调用,Compose
中提供了专门处理副作用的方法,这个会在后面介绍。
4、Composable的执行是"乐观"的
所谓"乐观"是指Composable
最终总会依据最新的状态正确地完成重组。在某些场景下,状态可能会连续变化,这可能会导致中间态的重组在执行中被打断,新的重组会插入进来。对于被打断的重组,Compose不会将执行一半的重组结果反应到视图树上,因为它知道最后一次状态总归是正确的,因此中间状态会被丢弃。
kotlin
@Composable
fun MyList{
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// 展示列表项 ...
}
//上报列表展现元素用作数据分析
MyAnalyticsService.sendVisibleItem(listState.layoutInfo.visibleItemsInfo)
}
在上面的代码中,MyList
用来显示一个列表数据。这里试图在重组过程中将列表中展示的项目信息上报服务器,用作产品分析,但这样写是很危险的,因为任何时候大家都无法确定重组能够被正常执行而不被打断。如果此次重组被打断了,那么会出现数据上报内容与实际视图不一致的问题,影响产品分析数据。
像数据上报这类会对外界产生影响的逻辑称为副作用。Composable
中不应该直接出现任何对状态有依赖的副作用代码 ,当有类似需求时,应该使用Compose
提供的专门处理副作用的API
进行包裹,例如SideEffect{...}
等,副作用可以在里面安全地执行并获取正确的状态。
针对本小节的介绍得出一个结论:Compose
框架要求Composable
作为一个无副作用的纯函数运行,只要在开发中遵循这一原则,上述这一系列特性就不会成为程序执行的"陷阱",反而有助于提高程序的执行性能。
三、如何确定重组范围
我们知道重组是智能的,会尽可能跳过不必要的重组,仅仅针对需要变化的UI进行重组。那么Compose
如何认定UI需要变化呢?或者说Compose
如何确定重组的最小范围呢?看下面的代码:
kotlin
@Composable
fun Greeting() {
Log.d(TAG, "Scope-1 run") //日志一
var counter by remember { mutableStateOf(0) }
Column { //Scope-2
Log.d(TAG, "Scope-2 run") //日志二
Button(
onClick = run {
Log.d(TAG, "Button-onClick") //日志三
return@run { counter++ }
}
) { //Scope-3
Log.d(TAG, "Scope-3 run") //日志四
Text("+")
}
Text("$counter")
}
}
运行后会得到下面的日志:
kotlin
//运行后,点击按钮前
Scope-1 run
Scope-2 run
Button-onClick
Scope-3 run
//点击按钮后
Scope-1 run
Scope-2 run
Button-onClick
Scope-3 run
第一条日志居然不是Button-onClick
,这是为什么呢?
经过Compose
编译器处理后的Composable代码 在对State进行读取的同时能够自动建立关联,在运行过程中当State
变化时,Compose
会找到关联的代码块标记为Invalid
。在下一渲染帧到来之前,Compose
会触发重组并执行invalid
代码块,Invalid
代码块即下一次重组的范围。能够被标记为Invalid
的代码必须是非inline
且无返回值的Composable
函数或lambda
,如果是inline
的,则在编译时会被展开,无法确定重组范围。
只有受到State变化影响的代码块,才会参与到重组,不依赖State的代码则不参与重组,这就是重组范围的最小化原则。
那么参与重组的代码块为什么必须是inline
的无返回值函数呢?因为inline
函数在编译期会在调用处展开,因此无法在下次重组时找到合适的调用入口,只能共享调用方的重组范围 。而对于有返回值的函数,由于返回值的变化会影响调用方,所以必须连同调用方一同参与重组,因此它不能单独作为Invalid
代码块。
分析一下上面的日志:
按照重组最小化原则,访问counter
的最小范围应该是Scope2
,为什么Scope1-run
也输出了呢?还记得最小化范围的定义必须是非inline
的Composable
函数或lambda
吗?Column
实际上是个inline
声明的高阶函数,内部content
也会被展开在调用处,Scope-2
与Scope-1
共享重组范围,Scope-1 run
日志被输出 。看下Column
源码:
kotlin
@Composable
inline fun Column(...){...} //inline
如果我们将Column
修改为非inline
的Card
结果就会不一样,先看一下Card
的源码:
kotlin
@Composable
@NonRestartableComposable
fun Card(...){...} //非inline
修改为Card
后的代码和日志:
kotlin
@Composable
fun Greeting() {
Log.d(TAG, "Scope-1 run")
var counter by remember { mutableStateOf(0) }
Card { //Scope-2 <---------将Column修改为非inline的Card
Log.d(TAG, "Scope-2 run")
Button(
onClick = run {
Log.d(TAG, "Button-onClick")
return@run { counter++ }
}
) { //Scope-3
Log.d(TAG, "Scope-3 run")
Text("+")
}
Text("$counter")
}
}
kotlin
//运行后,点击按钮前
Scope-1 run
Scope-2 run
Button-onClick
Scope-3 run
//点击按钮后
Scope-2 run
Button-onClick
Scope-3 run
重组范围已经被限定在非inline
且无返回值的函数Card
内部,不再输出Scope-1 run
。
四、优化重组的性能
Composable
经过执行之后会生成一颗视图树,每个Composable
对应了树上的一个节点。因此Composable
智能重组的本质其实是从树上寻找对应位置的节点并与之进行比较,如果节点未发生变化,则不用更新。
补充提示:
视图树构建的实际过程比较复杂,Composable
执行过程中,先将生成的Composition
状态存入SlotTable
,而后框架基于SlotTable
生成LayoutNode
树,并完成最终界面渲染。谨慎来说,Composable
的比较逻辑发生在SlotTable
中,并非是Composable
在执行中直接与视图树节点作比较。
1、Composable的位置索引
在重组过程中,Composition
上的节点可以完成增、删、移动、更新等多种变化。Compose
编译器会根据代码调用位置,为Composable
生成索引key
,并存入Composiitoin
。Compoable
在执行中通过与key
的对比,可以知道当前应该执行何种操作。
(1)如何理解这个key
?
Compose
编译器会根据 Composable
函数在代码中被调用的位置来生成一个唯一的索引 key
。如果同一个函数被调用二次,会生成二个不同的索引key
。
(2)索引key
是如何生成的?
Compose
编译器会使用以下算法来生成索引 key
:
- 首先,
Compose
编译器会为每个Composable
函数生成一个唯一的ID
。这个ID
由Composable
函数的名称和参数列表组成。 - 然后,
Compose
编译器会将这个ID
转换为一个十六进制字符串。 - 最后,
Compose
编译器会将这个十六进制字符串加上一个随机数作为索引key
。
(3)索引 key
有什么作用?
索引 key
用于在 Compose
的布局树中唯一标识一个 Composable
函数。Compose
会使用索引 key
来确定一个 Composable
函数是否已经被渲染过,以及如何更新已经渲染过的 Composable
函数。
(4)哪些情况下会添加索引key
?
在 Jetpack Compose
中,Composable
函数的渲染结果会被添加到一个布局树中。如果 Composable
函数的渲染结果是可变的,Compose
会使用索引 key
来识别该节点是否已经被渲染过,以及如何更新已经渲染过的节点。
例如在 Composable
函数中使用 if/else
等条件语句时,Compose
编译器会在条件语句前插入 startXXXGroup()
代码,并为每个条件分支添加一个唯一的索引 key
。这样,Compose
就可以在布局树中识别条件语句导致的节点增减。示例代码:
kotlin
@Composable
fun MyComposable() {
val isEnabled = true
if (isEnabled) {
// 渲染内容 1
// 添加索引 key 1
startScope {
// ...
}
} else {
// 渲染内容 2
// 添加索引 key 2
startScope {
// ...
}
}
}
除了函数节点,条件语句节点外,Compose
编译器还会在以下情况下添加索引 key
:
- 使用
remember()
函数来缓存数据时。 - 使用
mutableStateOf()
函数来创建可变状态时。 - 使用
MutableList()
或MutableMap()
等可变集合或映射时。
总体来说,Compose
会在任何可能导致布局树中节点增减的地方添加索引 key
。
(5)手动添加索引key
提高性能
先看下面的代码:
kotlin
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
//MovieOverview无法在编译期进行索引
//只能根据运行时的index进行索引
MovieOverview(movie)
}
}
}
在上面的代码中,基于Movie
列表数据展示MovieOverview
。此时无法基于代码中的位置进行索引,只能在运行时基于index进行索引 。这样的索引会根据item
的数量变化而发生变化,无法准确匹配对象进行比较。如下图所示,当前MoviesScreen已经有两条数据,当在头部再插入一条数据时,之前的索引发生错误,无法在比较时起到锚定原对象的作用。
当重组发生时,新插入的数据会与以前的0
号数据比较,以前的0
号数据会与以前的1
号数据比较,以前的1
号数据作为新数据插入,结果所有item
都会发生重组,但我们期望的行为是,仅新插入的数据需要组合,其他数据因为没有变化不应该发生重组。------图中灰色的部分表示参与重组的item
。
此时可以使用key
方法为Composable
在运行时手动建立唯一索引,代码如下:
kotlin
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
key(movie.id){
//使用Movie的唯一id作为Composable的索引
MovieOverview(movie)
}
}
}
}
使用Movie.id
传入Composable
作为唯一索引,当插入新数据后,之前对象的索引没有被打乱,仍然可以发挥比较时的锚定作用,如下图所示,之前的数据没有发生变化,对应的Item无须参与重组。
二、活用@Stable或@Immutable
Composable
基于参数的比较结果来决定是否重组。更准确地说,只有当参与比较的参数对象是稳定的且equals返回true,才认为是相等的。
那么什么样的类型是稳定的呢?比如Kotlin
中常见的基本类型(Boolean
、Int
、Long
、Float
、Char
)、String
类型,以及函数类型(Lambda
)都可以称得上是稳定的,因为它们都是不可变类型,它们参与比较的结果是永远可信的。反之,如果参数是可变类型 ,它们的equlas
结果将不再可信。
下面的例子清晰地展示了这一点:
kotlin
//MutableData类
class MutableData(var data: String)
//MutableDemo函数
@Composable
fun MutableDemo() {
val mutable = remember { MutableData("Hello") }
var state by remember { mutableStateOf(false) }
if (state) {
mutable.data = "World"
}
//WrapperText显示会随着state的变化而变化
Button(onClick = { state = !state }) {
WrapperText(mutable)
}
}
//WrapperText函数
@Composable
fun WrapperText(mutableData: MutableData) {
Text(text = mutableData.data)
}
上述代码中,MutableData
是一个"不稳定"的对象,因为它有一个var
类型的成员data
,当单击Button
改变状态时,mutable
修改了data
。对于WrapperText
来说,参数mutable
在状态改变前后都指向同一个对象,因此仅仅靠equals
判断会认为是参数没有变化。但实际测试后会发现WrapperText
的重组仍然发生了,因为对于Compiler
来说,MutableData
参数类型是不稳定的,equals
结果并不可信。
对于一个非基本类型T
,无论它是数据类还是普通类,若它的所有public
属性都是final
的不可变类型,则T
也会被Compiler
识别为稳定类型。此外,像MutableState
这样的可变类型也被视为稳定类型,因为它的value
的变化可以被追踪并触发重组,相当于在新的重组发生之前保持不变。
对于一些默认不被认为是稳定的类型,比如interface
或者List
等集合类,如果能够确保其在运行时的稳定,可以为其添加@Stable
注解,编译器会将这些类型视为稳定类型,从而发挥智能重组的作用,提升重组性能。需要注意的是,被添加@Stable
的普通父类、密封类、接口等,其派生子类也会被视为是稳定的。
如下面的代码所示,当使用interface
定义UiState
时,可以为其添加@Stable
,当在Composable
中传入UiState
时,Composable
的重组会更加智能。
kotlin
//添加注解,告诉编译器其类型是稳定的,可以跳过不必要的重组
@Stable
interface UiState<T> {
val value: T?
val exception: Throwable?
val hasError: Boolean
get() = exception != null
}
除了@Stable
外,Compose
还提供了另一个类似的注解@Immutable
。两者都继承自@StableMarker
,在功能上类似,都是用来告诉编译器所注解的类型可以跳过不必要的重组。 不同点在于,@Immutable
修饰的类型应该是完全的不可变类型,@Stable
修饰的类型中可以存在可变类型的属性,但只要属性的变化是可以观察的(能够触发重组,例如MutableStable<T>
等),仍然被视作稳定的。另外在使用注解范围上,@Stable
可以用在函数、属性等更多场景,但是总体上@Stable
的能力完全覆盖了@Immutable
。由于功能的重叠,未来@Immutable
有可能会被移除,建议大家优先选择使用@Stable
。
参考了以下内容:
本文大部分内容参考了实体书 Jetpack Compose从入门到实战
其他参考内容:
初学者如有错误欢迎批评指正!