本文为现代化 Android 开发系列文章第六篇。

完整目录为:

  • 现代化 Android 开发:根底架构
  • 现代化 Android 开发:数据类
  • 现代化 Android 开发:逻辑层
  • 现代化 Android 开发:组件化与模块化的抉择
  • 现代化 Android 开发:多 Activity 多 Page 的 UI 架构
  • 现代化 Android 开发:Jetpack Compose 最佳实践(本文)
  • 现代化 Android 开发:性能监控

假如一向重视 Compose 的发展的话,能够显着感受到 2022 年和 2023 年的 Compose 运用讨论的声音现已完全不相同了, 2022 年还多是观望,2023 年就有许多团队开端采纳 Compose 来进行开发了。不过也有许多同学接触了下 Compose,然后就放弃了。要么运用起来贼特么不随手,要么便是感觉性能不行,卡。其实,问题只是咱们的思想没有转换过来,还不会写 Compose

为何要挑选 Compose

许多 Android 开发都会问:View 现已这么成熟了,为何我要引进 Compose

前史也总是惊人的相似,React 横空出世时,许多前端同学也会问:jQuery 现已如此强壮了,为何要引进 JSXVirtual DOM

争论总是无效的,时刻会慢慢证明谁才会成为实在的操纵。

现在的前端同学,或许连 jQuery 是什么都不知道了。其作为曾经前端的操纵,何其强壮,却也经受不住来自 React 的降维打击。回看这端前史,那咱们挑选 Compose 就显得很自然了。

另一个大趋势是 Kotlin 跨渠道的逐渐兴起与成熟,也会推进 Compose 成为 Fultter 之外的挑选,并且能够不必学习那除了写 Flutter 就完全没用的 Dart 语言。

可是,我也不推荐咱们随随便便就把 Compose 接入的项目中。由于,国内的开发现状便是那样,迭代速度要求快,可是也要寻求稳定。而接入 Compose 到运用 Compose 快速迭代,也是有一个苦楚的进程的,搞欠好就要背锅,现在这环境,背锅或许就代表被裁了。

所以目前 Compose 依旧只能作为简历亮点而非必备点。可是假如你不学,万一被要求是必备点,那该怎样办?

所以即使你不喜欢 Compose 这一套,那为了饭碗,该掌握的仍是得掌握,毕竟商场饱满,咱们是被挑选的哪一方。

Compose 的思想

声明式 UI

Compose 的思想与 ReactViewFultterSwiftUI 都是一脉相传,那便是数据驱动 UI 与 声明式 UI。曾经的 View 系统,咱们称它为指令 UI

指令式 UI 是咱们拿到 View 的句柄,然后经过履行指令,自动更新它的的颜色、文字等等

声明式 UI 则是咱们构建一个状况机,描述各个状况下 UI 是个什么姿态的。

那些写 Compose 怎样都不随手的童鞋,便是总想拿 View 的句柄,但又拿不到,所以就很苦楚,但假如转换到状况机的思想上,去界说各种情形的状况,那写起来就十分舒服了。

ComposeView 系统进化的点便是它贴近于实在的 UI 国际。由于每个界面便是一个杂乱的状况机,以往咱们指令式的操作,咱们依旧要界说一套状况系统,某种状况更新为某种 UI,有时候处理得欠好,还会出现状况错乱的问题。 Compose 则强制咱们要考虑 UI 的状况机该是怎姿态的。

Virtual DOM

Compose 的国际中,是没有介绍 Virtual DOM 这一概念的,但我觉得了解 Virtual DOM 能够协助咱们更好的了解 ComposeVirtual DOM 的诞生,一个原因是由于 DOM/View 节点实在是太重了,所以咱们不能在数据改变时删去这个节点再从头创立,咱们也不没有办法经过 diff 的方法去追寻究竟产生了哪些改变。但大佬们的思想就比较活泼,由于开发进程中重视的一个 DOM/ View 的属性是很少的,所以就创造了一个轻量级的数据结构来表明一个 DOM/View 节点,由于数据结构比较轻量,那么销毁创立就能够随意点。每次更新状况,我能够用新状况去创造一个新的 Virtual DOM Tree, 然后与旧的 Virtual DOM Tree 进行 diff,然后将 diff 的结果更新到 DOM / View 上去, React Native 便是把前端的 DOM 变成移动端的 View,因而敞开了 UI 跨渠道动态化的大门。

那这和 Compose 有什么关系呢?咱们能够以为,Compose 的函数让咱们来生成 Virtual DOM 树,Compose 内部叫 SlotTable,结构用了全新的内部结构来代表 DOM 节点。每次咱们状况的改变,就会触发 Composable 函数从头履行以生成新的 Virtual DOM,这个进程叫做 Recomposition

所以重点来了,产生状况更新后,结构会首先去从头生成 Virtual DOM 树,交给底层去比对改变,最终渲染输出。假如咱们频频的改变状况,那就会频频的触发 Recomposition,假如每次仍是从头生成一个巨大的 Virtual DOM 树,那结构内部的 diff 就会十分耗时,那么性能问题随之就来了,这是许多同学用 Compose 写出的代码卡顿的原因。

Compose 性能最佳实践

假如咱们有了 Virtual DOM 这一层认识,那么就能够想到该怎样去坚持 Compose 的高性能了,那便是

  1. 削减 Composable 函数本身的核算
  2. 减小状况改变的频次
  3. 减小状况改变的形成 Recomposition 的规模以减小 diff 更新量
  4. 减小 Recomposition 时的改变量以减小 diff 更新量

削减 Composable 函数本身的核算

这个很好了解,假如 Recomposition 产生了,那么整个函数就会从头履行,假如有杂乱的核算逻辑,那就会形成函数本身的消耗很大,而解决办法也简单,便是经过 remember 缓存核算结果

@Composable
func Test(){
    val ret = remember(arg1, arg2) { // 经过参数判别是否要从头核算
        // 杂乱的核算逻辑
    }
}

削减状况改变的频次

这个主要是削减无效的状况改变,假如有多个状况,其每个状况下的履行结果是相同的,那这些状况间的改变就没有意义了,应该一致成唯一的状况。

其实官方在 mutableStateOf 的入参 policy 上现已定制了几种判别状况值是否改变的策略:

  • StructuralEqualityPolicy: 经过值判等(==)的来看其是否产生改变
  • ReferentialEqualityPolicy: 有必要是同一个目标(===)才算未产生改变
  • NeverEqualPolicy : 总是触发状况改变

默以为 StructuralEqualityPolicy,也契合一般状况的要求。

除此之外,咱们减小状况改变频率的手段便是 derivedStateOf。 它的用处主要是咱们便是将多个状况值收归为一致的状况值, 例如:

  1. 列表是否翻滚到了顶部,咱们拿到的 scorllY 是很频频改变的值,但咱们重视的只是 scorllY == 0
  2. 根据内容为空断定发送按钮是否可点击,咱们重视的是 input.isNotBlank()
  3. 多个输入的联合校验

咱们以发送按钮为例:

@Composable
func Test(){
    val input = remember {
        mutabtleStateOf('')
    }
    val canSend = remember {
        derivedStateOf { input.value.isNotBlank() }
    }
    // 运用 canSend
    SendButton(canSend)
    // 其它许多代码
}

这姿态,咱们能够屡次更新 input 的值,可是只有当 canSend 产生改变时才会触发 TestRecomposition

减小状况改变的形成 Recomposition 的规模

Recomposition 是以函数为效果规模的,所以某个状况触发了 Recomposition,那么这个函数就会从头履行一次。但需求留意的是,不是状况界说的函数履行Recomposition,而是状况读取的函数会触发 Recomposition

仍是以上面的输入的例子为例。 假如我在 Test 函数履行期内读取了 input.value, 那么 input 改变时就会触发 Test 函数的重组。留意的是函数履行期内读取,而不是函数代码里写了 input.value。上面 canSendderivedStateOf 尽管也有调用 input.value,但由于它是以 lambda 的方法存在,不是会在履行 Test 函数时就履行,所以不会由于 input.value 改变就形成 TestRecomposition

但假如我在函数体内运用 input.value,例如:

@Composable
func Test(){
    val input = remember {
        mutabtleStateOf('')
    }
    val canSend = remember {
        derivedStateOf { input.value.isNotBlank() }
    }
    Text(input.value)
    SendButton(canSend)
    OtherCode(arg1, arg2)
    OtherCode1(arg1, arg2)
}

那就会由于 input 的改变而形成 Test 的重组, canSend 运用 derivedStateOf 也便是做无用功了。更严重的是或许有许多其它与 input 无关的代码也会再次履行。

所以咱们需求把状况改变触发 Recomposition 的代码用一个子组件来承载:

@Composable
func InputText(input: () -> String){
    Text(input())
}
@Composable
func Test(){
    val input = remember {
        mutabtleStateOf('')
    }
    val canSend = remember {
        derivedStateOf { input.value.isNotBlank() }
    }
    InputText {
        input.value
    }
    SendButton(canSend)
    OtherCode(arg1, arg2)
    OtherCode1(arg1, arg2)
}

咱们从头创立了一个 InputText 函数,然后经过 lambda 的方法传递 input,因而现在 input 改变形成的 Recomposition 就局限于 InputText 了,而其它的无关代码就不会被履行,这样规模就大大缩减了。

减小 Recomposition 时的改变量

参加咱们的函数 Recomposition 的规模现已没办法缩减了,例如上面 canSend 改变触发 TestRecomposition,这形成 OtherCode 组件的从头履行好像无法避免了。其实官方也想到了这种状况,所以它结构还会判别 OtherCode 的参数是否产生了改变,依此来判别 OtherCode 函数是否需求从头履行。假如参数没有改变,那么就能够高兴的跳过它,那么 Recomposition 的改变量就大幅减小了。

那么怎样判别参数没有产生改变呢?假如是根底类型和data class 等的数据结果还好,能够经过值判等的方法看其是否改变。但假如是列表或者自界说的数据结构就麻烦了。 由于结构无法知道其内部是否产生了改变。

a: List<T> 为例,尽管重组时我拿到的是同一个目标 a, 但其实现类或许是 ArraryList<T>, 并且或许调用 add/remove 等方法改变了数据结构。所以在确保正确性优先的状况下,结构只得从头调用整个函数。

@Composable
fun SubTest(a: List<String>){
    //...
}
@Composable
fun Test(){
    val input = remember {
        mutabtleStateOf('')
    }
    val a = remember {
        mutableStateOf(ArrayList<String>())
    }
    // 由于读取了 input.value, 所以每次 input 改变,都会早成 Test 的 Recomposition
    Test(input.value)
    // 而由于 a 是个 List,所以每次 SubTest 也会履行 Recomposition
    SubTest(a)
}

那要怎样规避这个问题呢? 那便是运用 kotlinx-collections-immutable 供给的 ImmutableList 等数据结构,如此就能够协助结构正确的判别数据是否产生了改变。

@Composable
fun SubTest(a: PersistentList<String>){
    //...
}
@Composable
fun Test(){
    val input = remember {
        mutabtleStateOf('')
    }
    val a = remember {
        mutableStateOf(persistentListOf<String>())
    }
    // 由于读取了 input.value, 所以每次 input 改变,都会早成 Test 的 Recomposition
    Test(input.value)
    // 而由于 a 是个 List,所以每次 SubTest 也会履行 Recomposition
    SubTest(a)
}

而假如是咱们自己界说的数据结构,假如是非 data class,那就要咱们自动加上 @Stable 注解,告诉结构这个数据结构是不会产生改变,或者其改变咱们都会用状况机去处理的。特别需求留意的是运用 java 作为实体类而给 compose 运用的状况,那便是十分不友好了。

关于列表而言,咱们往往需求用 for 循环或者 LazyColumn 之类的方法运用:

@Composable
fun SubTest(list: PersistentList<ItemData>){
    for(item in list){
        Item(item)
    }
}

这个写法,假如 list 不会改变,那也没什么问题,可是假如列表产生了改变,例如原本是 12345, 我删了一项变成 1345

那么在 Recomposition 的时候,结构在比对改变时,发现从第二项开端就全不同了,那么剩下的 Item 就得悉数从头重组一次了,这也是十分耗费性能的,所以结构供给了 key 的功用,经过它,结构能够检测列表的 Item 移动的状况。

@Composable
fun SubTest(list: PersistentList<ItemData>){
    for(item in list){
        key(item.id){
            Item(item)
        } 
    }
}

不过需求留意的是 key 需求具有唯一性。 LazyColumnitem 也有 key 的功用,其效果相似,其还有 contentType 的传参,其效果和 RecyclerView 的多 itemType 相似,也是一个能够运用的优化办法。

最后

Compose 业务上能做的优化大体上便是这些了。总之咱们便是咱们要坚持组件的颗粒度尽或许的小,简单变动的要独立出来,十分稳定的也要独立出来,尽量运用 Immutable 的数据结构。 如此之后, Compose 的流畅度仍是十分不错的。

假如还觉得卡,那多半是由于你运用的是 Debug 包,Compose 会在 Debug 包加许多调试信息,会很影响其流畅度的。切换到 Release 包,或许丝滑感就出来了。