一、智能的重组

传统视图中经过修正View的私有特点来改动UI, Compose则经过重组改写UICompose的重组非常“智能”,当重组产生时,只有状况产生更新的Composable才会参加重组,没有改动的Composable会越过本次重组。

二、防止重组的“圈套”

因为Composable在编译期代码会产生改动,代码的实践运转状况或许并不如你预期的那样。所以需求了解Composable在重组履行时的一些特性,防止落入重组的“圈套”。

1、Composable会以恣意次序履行

首要需求留意的是当代码中呈现多个Composable时,它们并纷歧定按照代码中呈现的次序履行。比如,在一个Navigation中处于Stack最上方的UI会优先被绘制,在一个Box布局中处于远景的UI具有较高的优先级,因而Composable会依据优先级来履行,这与代码中呈现的方位或许并不共同。

Composable都应该“自给自足”,不要试图经过外部变量与其他Composable产生相关。在Composable中改动外部环境变量归于一种“副效果”行为,Composable应该尽量防止副效果

2、Composable会并发履行

重组中的Composable并纷歧定履行在UI线程,它们或许在后台线程池中并行履行,这有利于发挥多核处理器的功能优势。可是因为多个Composable在同一时间或许履行在不同线程,此刻有必要考虑线程安全问题。看看下面EventsFeed的比如:

@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-1column-content-2有或许在不同线程并行履行,所以totalEvents的累加对错线程安全的,成果或许不精确。即使totalEvents的成果精确,因为Text或许运转在单独线程,所以也纷歧定能正确显示成果,这相同仍是Composable副效果带来的问题,我们需求极力防止。

3、Composable会重复履行

除了重组会形成Composable的再次履行外,在动画等场景中每一帧的改动都或许引起Composable的履行,因而Composable有或许会短时间内重复履行,咱们无法精确判别它的履行次数。我们在写代码时有必要考虑到这一点:即使多次履行也不应该呈现功能问题,更不应该对外部产生额外影响。来看下面的比如:

@Composable
fun EventsFeed(networkService: EventsNetworkService) {
    //异步恳求数据
    val events = networkService.loadAllEvents()
    LazyColumn {
        items(events) { event ->
            Text(text = event.name)
        }
    }
}

EventsFeed中,loadAllEvents是一个IO操作,履行本钱高,假如在Composable中同步调用,会在重组时形成卡顿。也许有人会提出将数据恳求逻辑放到异步线程履行,以进步功能。这儿测验将数据恳求的逻辑移动到ViewModel中异步履行,防止堵塞中线程:

@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不会将履行一半的重组成果反应到视图树上,因为它知道最终一次状况总之是正确的,因而中间状况会被丢掉。

@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怎么确认重组的最小规模呢?看下面的代码:

@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")
    }
}

运转后会得到下面的日志:

//运转后,点击按钮前
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也输出了呢?还记得最小化规模的界说有必要对错inlineComposable函数或lambda吗?Column实践上是个inline声明的高阶函数,内部content也会被打开在调用处,Scope-2Scope-1同享重组规模,Scope-1 run日志被输出。看下Column源码

@Composable
inline fun Column(...){...}       //inline

假如咱们将Column修正为非inlineCard成果就会纷歧样,先看一下Card的源码:

@Composable
@NonRestartableComposable
fun Card(...){...}              //非inline

修正为Card后的代码和日志:

@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")
    }
}
//运转后,点击按钮前
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,并存入ComposiitoinCompoable在履行中经过与key的对比,能够知道当时应该履行何种操作。

(1)怎么了解这个key

Compose 编译器会依据 Composable 函数在代码中被调用的方位来生成一个仅有的索引 key。假如同一个函数被调用二次,会生成二个不同的索引key

(2)索引key是怎么生成的?

Compose 编译器会运用以下算法来生成索引 key

  1. 首要,Compose 编译器会为每个 Composable 函数生成一个仅有的 ID。这个 IDComposable 函数的名称和参数列表组成。
  2. 然后,Compose 编译器会将这个 ID 转换为一个十六进制字符串。
  3. 最终,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 就能够在布局树中辨认条件句子导致的节点增减。示例代码:

@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进步功能

先看下面的代码:

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            //MovieOverview无法在编译期进行索引
            //只能依据运转时的index进行索引
            MovieOverview(movie)
        }
    }
}

在上面的代码中,依据Movie列表数据展现MovieOverview。此刻无法依据代码中的方位进行索引,只能在运转时依据index进行索引。这样的索引会依据item的数量改动而产生改动,无法精确匹配目标进行比较。如下图所示,当时MoviesScreen已经有两条数据,当在头部再刺进一条数据时,之前的索引产生过错,无法在比较时起到锚定原目标的效果。

Jetpack Compose(十一)-重组与主动改写

当重组产生时,新刺进的数据会与曾经的0号数据比较,曾经的0号数据会与曾经的1号数据比较,曾经的1号数据作为新数据刺进,成果所有item都会产生重组,但咱们期望的行为是,仅新刺进的数据需求组合,其他数据因为没有改动不应该产生重组。——图中灰色的部分表示参加重组的item

此刻能够运用key办法为Composable在运转时手动树立仅有索引,代码如下:

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id){
                //运用Movie的仅有id作为Composable的索引
                MovieOverview(movie)
            }
        }
    }
}

运用Movie.id传入Composable作为仅有索引,当刺进新数据后,之前目标的索引没有被打乱,依然能够发挥比较时的锚定效果,如下图所示,之前的数据没有产生改动,对应的Item无须参加重组。

Jetpack Compose(十一)-重组与主动改写

二、活用@Stable或@Immutable

Composable依据参数的比较成果来决议是否重组。更精确地说,只有当参加比较的参数目标是安稳的且equals回来true,才认为是持平的。

那么什么样的类型是安稳的呢?比如Kotlin中常见的根本类型(BooleanIntLongFloatChar)、String类型,以及函数类型(Lambda)都能够称得上是安稳的,因为它们都是不可变类型,它们参加比较的成果是永久可信的。反之,假如参数是可变类型,它们的equlas成果将不再可信。

下面的比如明晰地展现了这一点:

//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的重组会更加智能。

//增加注解,告知编译器其类型是安稳的,能够越过不必要的重组
@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从入门到实战,我们多多支撑实体书

其他参阅内容:

Jetpack Compose docs

官网Sate

初学者如有过错欢迎批评指正!