Jetpack Compose

一、Compose 简介与基础使用

1.1 compose 是什么

Jetpack Compose 是用于构建原生 Android 界面的新工具包。它使用更少的代码、强大的工具和直观的 Kotlin API,可以帮助简化并加快 Android 界面开发。使用 Compose,我们无需修改任何 XML 布局,也不需要使用布局编辑器。只需调用可组合函数来定义所需的元素,Compose 编译器即会完成后面的所有工作。

// ...
import androidx.compose.runtime.Composable

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Greeting("world")
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

@Preview
@Composable
fun PreviewGreetingd() {
    Greeting("world")
}

关于可组合函数:

  • 所有可组合函数都必须带有 @Composable 注解;此注解可告知 Compose 编译器:此函数旨在将数据转换为界面。

  • 可以接受数据,用于描述界面,在上述例子中,接受了一个 String,调用 Text() 可组合函数来创建文本界面。

  • 没有返回值,仅仅用来描述状态,而不是构造一个 widget 。

  • 快速幂等,没有任何附带效应。

1.2 compose 解决的问题

结论先行,一句话总结 Compose 的优势:使用 Composable API 我们可以通过声明式的方式更方便快捷的构建 UI ,同时更自由的对UI层进行封装以及重构,最终降低 UI 开发与维护的复杂度与难度。

1.2.1 关注点分离

关注点分离 (Separation of concerns, SOC) 是一个很常见的软件设计原则,一个好的架构设计必须把变化点错落有致地封装到软件系统的不同部分,具体可以解释为:

系统中的一个部分发生了变化,不会影响其他部分。即使需要改变,也能够清晰地识别出那些部分需要改变。如果需要扩展架构,影响将会最小化,已经可以工作的每个部分都将继续工作。

实现关注点分离,要做好高内聚低耦合。

编写代码时,我们会创建包含多个单元的模块。不同模块中单元之间的依赖关系我们称之为 “耦合”,它反映了一个模块中的内容是如何影响另一个模块内容的。“内聚”则表示的是一个模块中各个单元之间的关系,它指示了模块中各个单元相互组合的合理程度。

以传统的 XML 布局为例,我们使用视图模型(view model)向布局提供数据,这中间有很多的依赖关系,比如 findViewByID ,使用这些 api 需要对 XML布局的形式和内容有一定了解。由于两者语言不同,视图模型我们使用 Java 或者 Kotlin,而布局则使用 XML,会使一些很明显的依赖变得比较隐蔽,这样我们在处理起这些依赖关系的时候就比较繁琐,而且容易出错

同时,每个 widget 都维护自己的内部状态,并且提供 gettersetter 方法,这样我们就需要频繁的通过命令的方式与 widget 进行交互,在一些较为复杂的业务逻辑场景下代码会变得非常臃肿

那如果我们开始就使用同一种语言来定义视图模型与布局呢,这样一些隐式的依赖就会变得清晰且明显,并且我们可以根据项目的需要直接调整调用关系来降低耦合。Composable 函数便是这样的一种工具,来帮助我们更好的实现关注点分离。

🌰:一个简单的 Composable 函数示例。

@Composable
fun App(appData: AppData) {
  val derivedData = compute(appData)
  Header()
  if (appData.isOwner) {
      EditButton()
  }
  Body {
    for (item in derivedData.items) {
      Item(item)
    }
  }
}

如上述代码,理想情况下,传递给 App 函数的数据是不可变数据,我们也不需要关心后续可能会发生的变化,只需要关注当前的状态,这样一来,我们便可以使用任何 Kotlin 代码来获取这一数据,并利用它来描述的我们的层级结构,例如 Header()Body()调用。

1.2.2 声明式UI

声明式 vs 命令式:

🌰:在一个天气 App 中,如果要展示的是当天的天气,需要展示当天的天气数据,当前温度以及天气预测列表;如果是一段时间的天气,需要展示这段时间的天气数据,这段时间的温度区间,以及天气预测列表,如果是未来某一天的时间,只需要展示天气数据跟温度区间。

如果使用传统命令式来实现这部分 UI 逻辑:

public class WeatherView extends RelativeLayout {
   // ...
   // 天气信息自定义 view
   WeatherInfoView weatherInfoView;
   // 天气温度自定义 view
   WeatherTemperatureView weatherTemperatureView;
   // 天气预测区间自定义 view
   MultidaysWeatherView multidaysWeatherView;
   public void refreshView(MiWeather miWeather) {
      ......
      weatherInfoView.refreshView(miWeather);
      weatherTemperatureView.refreshView(miWeather);
      if (miWeather.isTodayWeather() || miWeather.isDurationWeather()) {
          // 今天天气 or 一段时间天气
          multidaysWeatherView.refreshView(miWeather);
      }
      if (multidaysWeatherView.getVisibility() == VISIBLE) {
          // 未来某天天气
          multidaysWeatherView.setVisibility(INVISIBLE);
      }
   }
   // ...
}

使用声明式 :

@Composable
fun WeatherInfo(
    modifier: Modifier = Modifier,
    weatherInfo: WeatherInfo
) {
    // ...
    Column (
        modifier = Modifier.wrapContentSize()
    ) {
        WeatherData(
            weatherData = weatherData,
            modifier = Modifier.fillMaxWidth()
        )
        if (!weatherInfo.isSomeday()) {
            WeatherForecast(
                weatherData = weatherData,
                modifier = Modifier.fillMaxWidth()
            )
        }
    }
}

这便是声明式 API 的含义。我们按照当前数据的状态与内容生成 UI,而不是如何转换到对应的状态。这里的关键是,编写像这样的声明式代码时,不需要关注 UI 在先前是什么状态,而只需要指定当前应当处于的状态。框架通过重组来控制如何从一个状态转到其他状态,所以我们不再需要考虑它。

1.2.3 重组

在命令式界面模型中,如需更改某个 widget,需要在该 widget 上调用 setter 以更改其内部状态。在 Compose 中,可以使用新数据再次调用可组合函数。这样做会导致函数进行重组 -- 系统会根据需要使用新数据重新绘制函数发出的 widget。Compose 框架可以智能地仅重组已更改的组件。

需要注意的是,只有当 Compose 跟踪某一值的更改,该值发生改变的时候才会重组数据发生了变化的组件,需要使用 State 与 MutableState

State & MutableState 是两个接口,它们具有特定的值,每当该值发生变化时,它们就会触发界面更新(重组)。

@Composable
fun Greeting(name: String) {
    val expanded = remember { mutableStateOf(false) }

    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

需要注意的是,不能只是将 mutableStateOf 分配给可组合项中的某个变量。如前所述,重组可能会随时发生,这会再次调用可组合项,从而将状态重置为值为 false 的新可变状态。如需在重组后保留状态,需要使用 remember 记住可变状态。

实际使用当中,UI 层的状态一般会存放在 ViewModel 中,例如我们想更新最新的天气数据,按照 XML View 的方式我们的实现为:

private fun initObserve() {
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.uiState.collect {
                when (it) {
                    is Result.success -> {
                        println("获取天气成功,weather=${it.value}")
                        updateWeather(it)
                    }
                    is Result.failure -> {
                        println("获取天气失败,error=${it.exception}")
                    }
                }
            }
        }
    }
}

我们通过StateFlow的collect()传入 lambda,lambda 会在每次 StateFlow 信息流更新时被调用,因此我们在 lambda 中更新视图。

使用 Compose,我们可以反转这种关系:

@Composable
fun WeatherScreen() {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    WeatherData(uiState)
}

collectAsStateWithLifecycle()方法会将 StateFlow 映射为 State ,State 实例 collect 了 StateFlow 实例,这意味着 State 会在 StateFlow 发生改变的任何地方更新,也意味着,无论在何处读取 State 实例,包裹它的、已被读取的 Composable 函数将会自动订阅这些改变。

结果就是,这里不再需要指定 LifecycleOwner 或者更新回调,Composable 可以隐式地实现这两者的功能,在 StateFlow 信息流更新时自动进行重组。

1.2.4 封装

Compose 做的很好的另一个方面是 "封装"。我们可以将一些 UI 组件封装成一些公共的Composable API,我们在需要使用的时候直接调用该 API,传递我们想要的参数即可。

另一方面,Composable 函数可以管理和创建状态,我们在创建公共 API 时最好提升状态,然后在调用处传递该状态,保证共用的 Composable API 是无状态的,无需跟业务逻辑耦合。

二、Compose 原理简单探索

使用 Compose 并不一定需要理解它是如何实现的,但通过了解其背后的原理,能帮助我们合理的使用框架,同时更清晰的理解重组触发的时机与方式,从而实现更好的UI架构。

2.1 Compose 项目结构

Compose 的通用结构如下图所示:

由代码、编译器插件、runtime库、以及各平台对应的UI库组成。首先需要明确的一点是,Compose 的前几层结构,即不包括UI的其余部分,是控制树状节点的一套项目,其实是可以完全脱离 UI 层独立运行的。对于Android UI而言,这个节点就是 LayoutNode 类。

对于 Android 平台的 compose,结构是这样的:

除了 Animation 层之外,其他三层每一层都是对上层的封装,一般来说我们使用的都是 Material 层的,当然 Foundation 层和 UI 层也是直接使用的。

2.2 @Composable 注解

@Composable 并不是一个注解处理器。Compose 在 Kotlin 编译器的类型检测与代码生成阶段依赖 Kotlin 编译器插件工作,所以无需注解处理器即可使用 Compose。

其类似于一个语言关键字,可以参考 Kotlin 的 suspend 关键字:

// 函数声明 
suspend fun MyFun() { … }
// lambda 声明
val myLambda = suspend { … }
// 函数类型
fun MyFun(myParam: suspend () -> Unit) { … }
// 函数声明
@Composable fun MyFun() { … }
// lambda 声明
val myLambda = @Composable { … }
// 函数类型
fun MyFun(myParam: @Composable () -> Unit) { … }

由此可知,当使用 @Composable 注解一个函数类型时,会导致它类型的改变:未被注解的相同函数类型与注解后的类型互不兼容。就像挂起 (suspend) 函数需要调用上下文作为参数,只能在其他挂起函数中调用挂起函数。

在 Compose 中,传递的上下文称为“ Composer ”,Composer 的实现包含了一个与 Gap Buffer (间隙缓冲区) 密切相关的数据结构,这一数据结构通常应用于文本编辑器。

间隙缓冲区是一个含有当前索引或游标的集合,它在内存中使用扁平数组 (flat array) 实现。这一扁平数组比它代表的数据集合要大,而那些没有使用的空间就被称为间隙。

一个正在执行的 Composable 的层级结构可以使用这个数据结构,而且我们可以在其中插入一些东西:

如果我们已经完成了层级结构的执行。在某个时刻,我们会重新组合一些东西。所以我们将游标重置回数组的顶部并再次遍历执行。在我们执行时,可以选择仅仅查看数据并且什么都不做,或是更新数据的值。

当我们打算更新数据的值时,也就是决定要改变 UI 的结构,并希望进行一次插入操作,在这个时候,我们会把间隙移动至当前位置。

现在,可以进行插入操作了。

除了移动间隙,所有其他操作包括获取 (get)、移动 (move) 、插入 (insert) 、删除 (delete) 都是常数时间操作。移动间隙的时间复杂度为 O(n)。之所以选择这一数据结构是因为 UI 的结构通常不会频繁地改变。当我们处理动态 UI 时,它们的值虽然发生了改变,却通常不会频繁地改变结构。

当它们确实需要改变结构时,则很可能需要做出大块的改动,此时进行 O(n) 的间隙移动操作便是一个很合理的权衡。

🌰:一个计数器。

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(
        text = "Count: $count",
        onPress = { count += 1 }
    )
}

当 Kotlin 编译器看到 Composable 注解时,它会在函数体中插入额外的参数和调用。首先,编译器会添加一个 composer.start()方法的调用,并向其传递一个编译时生成的整数 key。编译器也会将 composer 对象传递到函数体里的所有 composable 调用中。

fun Counter($composer: Composer) {
    $composer.start(123)
    var count by remember($composer) { mutableStateOf(0) }
    Button(
        $composer,
        text = "Count: $count",
        onPress = { count += 1 },
    )
    $composer.end()
}

当此 composer 执行时,它会进行以下操作:

  1. composer.start() 被调用并存储了一个组对象 (group object)

  2. remember 插入了一个组对象 mutableStateOf 的值被返回,而 state 实例会被存储起来

  3. Button 基于它的每个参数存储了一个分组

当 composer.end 执行结束后,我们得到了下面这样的数据结构:

数据结构现在已经持有了来自组合的所有对象,整个树的节点也已经按照深度优先遍历的执行顺序排列。现在,所有这些组对象已经占据了很多的空间,它们为什么要占据这些空间呢?原因是这些组对象是用来管理动态 UI 可能发生的移动和插入的。

编译器知道哪些代码会改变 UI 的结构,所以它可以有条件地插入这些分组。大部分情况下,编译器不需要它们,所以它不会向插槽表 (slot table) 中插入过多的分组:

@Composable 
fun App() {
    val result = getData()
    if (result == null) {
        Loading()
    } else {
        Header(result)
        Body(result)
    }
}

在上述函数中,getData()返回了一些结果并在某个情况下绘制了一个 Loading composable 函数;而在另一个情况下,它绘制了 Header 和 Body 函数。编译器会在 if 语句的每个分支间插入分隔关键字。

fun App($composer: Composer) {
    val result = getData()
    if (result == null) {
        $composer.start(123)
        Loading($composer)
        $composer.end()
    } else {
        $composer.start(456)
        Header($composer, result)
        Body($composer, result)
        $composer.end()
    }
}

首次加载时,此时 App 数据为空,这会使一个分组插入空隙并运行 Loading 界面。

当 App 数据异步加载返回结果时,会执行上述代码的第二个分支,也就是加载页头与内容的部分,对 composer.start 的调用有一个 key 为 456 的分组。

编译器会看到插槽表中 key 为 123 分组与之并不匹配,所以此时它知道 UI 的结构发生了改变。于是编译器将缝隙移动至当前游标位置并使其在以前 UI 的位置进行扩展,从而有效地消除了旧的 UI。

此时,代码会像一般的情况一样执行,而且新的 UI —— header 和 body —— 也已被插入其中。

在这种情况下,if 语句的开销为插槽表中的单个条目。通过插入单个组,我们可以在 UI 中任意实现控制流,同时启用编译器对 UI 的管理,使其可以在处理 UI 时利用这种类缓存的数据结构。这是一种称之为 Positional Memoization 的概念,同时也是贯穿整个 Compose 的重要概念。

2.3 Positional Memoization (位置记忆化)

通常,所说的全局记忆化,指的是编译器基于函数的输入缓存了其结果。下面是一个正在执行计算的函数,我们用它作为位置记忆化的示例:

@Composable
fun App(items: List<String>, query: String) {
    val results = items.filter { it.matches(query) }
    // ...
}

该函数接收一个字符串列表与一个要查找的字符串,并在接下来对列表进行了过滤计算。我们可以将该计算包装至对 remember 函数的调用中 —— remember 函数知道如何利用插槽列表。

remember 函数会查看列表中的字符串,同时也会存储列表并在插槽表中对其进行查询。过滤计算会在之后运行,并且 remember 函数会在结果传回之前对其进行存储。

函数第二次执行时,remember 函数会查看新传入的值并将其与旧值进行对比,如果所有的值都没有发生改变,过滤操作就会在跳过的同时将之前的结果返回。这便是位置记忆化。

这一操作的开销十分低廉:编译器必须存储一个先前的调用。这一计算可以发生在 UI 的各个地方,由于是基于位置对其进行存储,因此只会为该位置进行存储。下面是 remember 的函数签名,它可以接收任意多的输入与一个 calculation 函数。

@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T

这里有一个比较有趣的一点,我们可以故意误用这一 API,比如记忆一个像 Math.random 这样不输出稳定结果的计算:

@Composable 
fun App() {
    val x by remember { Math.random() }
    // ...
}

使用全局记忆化来进行这一操作将不会有任何意义,但如果换做使用位置记忆化,此操作将最终呈现出一种新的语义。每当我们在 Composable 层级中使用 App 函数时,都将会返回一个新的 Math.random 值。不过,每次 Composable 被重新组合时,由于此时 composer 中的 key 值并未改变,因此它将会返回相同的 Math.random 值。这一特性使得持久化成为可能,而持久化又使得状态成为可能。

2.4 存储参数

下面,让我们用 Google Composable 函数来说明 Composable 是如何存储函数的参数的。这个函数接收一个数字作为参数,并且通过调用 Address Composable 函数来绘制地址。

@Composable 
fun Google(number: Int) {
    Address(
        number = number,
        street = "Amphitheatre Pkwy",
        city = "Mountain View",
        state = "CA",
        zip = "94043"
    )
}

@Composable 
fun Address(
    number: Int,
    street: String,
    city: String,
    state: String,
    zip: String
) {
    Text("$number $street")
    Text(city)
    Text(", ")
    Text(state)
    Text(" ")
    Text(zip)
}

Compose 将 Composable 函数的参数存储在插槽表中。在本例中,我们可以看到一些冗余:Address 调用中添加的 “Mountain View” 与 “CA” 会在下面的 Text() 调用时被再次存储,所以这些字符串会被存储两次。

为此,编译器为 Composable 函数添加 static 参数来消除这种冗余。

fun Google(
    $composer: Composer,
    $static: Int,
    number: Int
) {
    Address(
        $composer,
        0b11110 or ($static and 0b1),
        number = number,
        street = "Amphitheatre Pkwy",
        city = "Mountain View",
        state = "CA",
        zip = "94043"
    )
}

static 参数是一个用于指示运行时是否知道参数不会改变的位字段。如果已知一个参数不会改变,则无需存储该参数。所以这一 Google 函数示例中,编译器传递了一个位字段来表示所有参数都不会发生改变。接下来,在 Address 函数中,编译器可以执行相同的操作并将参数传递给 text。

fun Address(
    $composer: Composer,
    $static: Int,
    number: Int, street: String,
    city: String, state: String, zip: String
) {
    Text($composer, ($static and 0b11) and (($static and 0b10) shr 1), "$number $street")
    Text($composer, ($static and 0b100) shr 2, city)
    Text($composer, 0b1, ", ")
    Text($composer, ($static and 0b1000) shr 3, state)
    Text($composer, 0b1, " ")
    Text($composer, ($static and 0b10000) shr 4, zip)
}

这些位操作逻辑难以阅读且令人困惑,但我们也没有必要理解它们:编译器擅长于此,而我们则不然。在 Google 函数的实例中,这里不仅有冗余,而且有一些常量其实是不需要存储的。这样一来,number 参数便可以决定整个层级,它也是唯一一个需要编译器进行存储的值。

有赖于此,编译器最终可以理解 number 是唯一一个会发生改变的值的代码。因此编译器最终生成的代码便可以在 number 没有发生改变时直接跳过整个函数体,也可以指导 Composer 将当前索引移动至函数已经执行到的位置。

fun Google(
    $composer: Composer,
    number: Int
) {
    if (number != $composer.next()) {
        Address(
            $composer,
            number=number,
            street="Amphitheatre Pkwy",
            city="Mountain View",
            state="CA",
            zip="94043"
        )
    } else {
        $composer.skip()
    }
}

2.5 重组

为了解释重组是如何工作的,我们需要回到计数器的例子:

fun Counter($composer: Composer) {
    $composer.start(123)
    var count by remember($composer) { mutableStateOf(0) }
    Button(
        $composer,
        text = "Count: $count",
        onPress = { count += 1 },
    )
    $composer.end()
}

编译器为 Counter 函数生成的代码含有一个 composer.start 和一个 compose.end。每当 Counter 执行时,运行时就会理解:当它调用 count.value 时,它会读取一个新的模型实例属性。在运行时,每当我们调用 compose.end,我们都可以选择返回一个值。

$composer.end()?.updateScope { nextComposer ->
    Counter(nextComposer)
}

接下来,该返回值上使用 lambda 来调用 updateScope 方法,从而告诉运行时在有需要时如何重启当前的 Composable。这一方法等同于 LiveData or StateFlow 接收的 lambda 参数。

在这里使用问号的原因——可空的原因——是因为如果在执行 Counter 的过程中不读取任何模型对象,则没有理由告诉运行时如何更新它,因为我们知道它永远不会更新。

2.6 小结

上述只是对Compose底层原理的简单探索,通过简化的场景来大概展示出 Compose 的运行时模型,Compose 实际上通过“快照(Snapshot)”系统来支撑状态管理与重组机制的运行,要真正吃透需要阅读大量源码与深入分析整体架构,在这里就不多赘述了,如果对这部分有兴趣话可以参考:

三、Compose 进阶使用

3.1 Compose + Mavericks

Mavericks 在 Compose 中仍然有用。我们使用Compose 构建所有 UI,但将 Mavericks 用于业务逻辑、数据获取、依赖注入等方面。

要将 Mavericks 与Compose 结合使用,需要添加com.airbnb.android:mavericks-compose

🌰:依然计数器

data class CounterState(val count: Int = 0) : MavericksState

class CounterViewModel(initialState: CounterState) : MavericksViewModel<CounterState>(initialState) {
    fun incrementCount() {
        setState { copy(count = count + 1) }
    }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Counter()
        }
    }
}

@Composable
fun Counter(
    viewModel: CounterViewModel = mavericksViewModel()
) {
    val count by viewModel.collectAsState(CounterState::count)
    Button(onClick = { viewModel.incrementCount() }) {
        Text(text = "Count: $count")
    }
}

3.2 去 XML 化

使用 Compose 之后,便没有必要再使用 XML 来进行资源管理了,这大大简化了我们在编写 UI 部分代码时的工作,避免了一边写着代码逻辑,一边还要去各种资源文件中增添 XML 标签,由于相互之间不能直接调用,这部分工作既繁琐又容易出错。

我们可以定义一个 Layout.kt 文件,将资源值以变量的方式添加进去,在 Composable 函数中直接调用即可:

@Composable
fun <T> fold(
    mediumScreen: T,
    largeScreen: T,
): T {
    return when (LocalConfiguration.current.screenWidthDp) {
        in 0..MEDIUM_WIDTH -> mediumScreen
        in MEDIUM_WIDTH + 1..LARGE_WIDTH -> largeScreen
        else -> largeScreen
    }
}

object Layout {
    internal val loadingIconSize: Dp
        @Composable get() = fold(
            mediumScreen = 40.dp,
            largeScreen = 52.dp
        )
}

@Composable
fun LoadingDisplay() {
    CircularProgressIndicator(
        modifier = Modifier.size(Layout.loadingIconSize),
        color = Color.White
    }
}

同时,像深浅模式, UI 一致性,可以通过设置主题来做到统一,目前 Launcher 的 Theme 部分我添加了一些初始的代码。个人感觉要完善好主题设置,需要跟设计同学进行共创,提供一些例如一二三级标题字体大小,自重,卡片的通用样式,色彩,圆角等。一个完善主题能很大程度简化我们在编写 UI 控件,适配不同机型等方面的工作。

四、Compose 性能优化

4.1 Configuration

R8 enabled:

release {
    isMinifyEnabled = true
    proguardFiles(
        getDefaultProguardFile("proguard-android.txt"),
        "proguard-rules.pro"
    )
}

4.2 Use Remember

🌰:我们需要一个排序状态的联系人列表,每当该列表的数据源有改动时,需要对列表重新排序。

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // Don't do this
        items(contacts.sortWith(comparator)) { contact ->
            // ...
        }
    }
}

上述代码中,因为排序操作在整个作用域中进行,因此在每次重组时都会从新调用排序代码。可重组项可能会非常频繁的运行,我们需要使用 remember{} 来缓存开销大的操作,并确保这些操作仅在需要时进行。

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortWith(comparator)
    }
    LazyColumn(modifier) {
        items(sortedContacts) { contact ->
            // ...
        }
    }
}

我们将 contacts 与 comparator 作为 remember{} 的键,这样做会使当两者其中任意一项发生变化时,列表都会重新排序,而不会在每次重组时都会排序。

在实际项目当中,更好的做法是将列表的状态前置,在 ViewModel 甚至 DataSource 中就提前排序好,Composable 只需要接受无状态的列表然后展示即可。仅在需要时再更改 Composable 的状态,将开销降到最低。

4.3 Use key

继续联系人列表的例子,我们还可以向 LazyColumn() 传递 LazyList Key ,来帮助 API 了解哪些项发生了变化。

默认情况下, Compose 会使用每一项的位置作为键,当项需要在列表中移动时,会给性能带来不利的影响,因为该项之后的每一项也会重组,这显然不是我们希望看到的。那我们该如何传递 Key 呢?

LazyColumn(modifier) {
    items(contacts, key = { it.id }) { contact ->
        // ...
    }
}

我们将键的 lambda 添加到 items 函数中即可,需要注意每一项的 Key 需要是唯一的,此时,当项在移动时,Compose 便会知道是哪一项在移动了,只重组移动项即可。

4.4 Use derivedStateOf

继续联系人列表的例子,如果产品希望增加一个 floating button ,当我们下划列表时才会展示,点击时返回列表顶部:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisiblity(visible = showButton) {
    ScrollToTopButton()
}

上述代码通过监听列表状态来控制 AnimatedVisiblity 进行淡入淡出,轻松实现了该功能。但是有一个陷阱,由于 LazyList 会在滚动时的每一帧都去更新 listState ,而我们又去读取了该变量,因此会导致大量无意义的重组。

对于 showButton,我们其实只需要关心列表的第一个可见索引什么时间 change from or to 0 ,此时我们可以使用 derivedStateOf{}

val listState = rememberLazyListState()

LazyColumn(state = listState) { 
    // ... 
}

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisiblity(visible = showButton) {
    ScrollToTopButton()
}

derivedStateOf{} 将接受频繁变化的 listState ,并挑选出我们所需要的状态变化,本例中,便是第一个可变索引大于0的情况,我们把该条件封装在 remember{} 和 derivedStateOf{}中,这样,我们便可以仅在该条件变化(列表下滑 & 列表回到顶部)的时候才进行重组。

derivedStateOf{} 会将繁忙的信息流转化为布尔条件,当我们遇到需要频繁使用布尔条件来确定状态时,都可以考虑该函数。但需要注意的是,如果是类似于项的数量,项的值等产生了变化,也就是说每次状态的变化都必须要进行重组的情况,此时再使用衍生状态就毫无意义,反而还会增加开销。

4.5 Procrastination

对于一个 Compose 页面来说,它会经历以下 4 个步骤:

  1. Composition,这其实就代表了我们的 Composable 函数执行的过程。Layout,这跟 View 体系的 Layout 类似,系统会测量由组合定义的内容并确定放在屏幕的哪些位置上。

  2. Draw,绘制,Compose 的 UI 元素最终会绘制在 Android 的 Canvas 上。

  3. Recomposition,重组,并且重复。

  4. Composition -> Layout -> Draw 。

Android 官方推荐我们尽可能推迟状态读取的原因,其实还是希望我们可以在某些场景下直接跳过 Recomposition 的阶段、甚至 Layout 的阶段,只影响到 Draw。

🌰:设计希望我们以一个动态的颜色变换来展示背景,背景的颜色需要在青色跟品红色之间以动画的效果不断地来回呈现:

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(Modifier.fillMaxSize().background(color))

在 Compose 中创建这些动画非常容易,我们轻松实现了需求,并且动画效果也很出色,但我们还可以进行一项潜在的优化。上述代码我们要求 Compose 在做一些不必要的工作,该动画会导致上述代码在每一帧都进行重组。

由上面的 Compose 页面的4个步骤,当数据没有变化时,我们便可以跳过前三个步骤中的一个或者多个。

我们其实只需要在每一帧绘制不同的颜色,因此只需要用新颜色重新填充方框,从而跳过 Composition 与 Layout 阶段,也就是说,我们要尽可能的延迟读取数据的时机,从而减少需要重新执行的函数:

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize()
        .drawBehind {
            drawRect(color)
        }
)

我们采用 drawBehind{} 来替代 background()drawBehind{} 接收在 Compose 绘制阶段调用的函数实例,这样在颜色改变的时候只有绘制阶段的结果需要更改,这样绘制阶段变成了唯一需要重新执行的阶段。

同时,我们发现上述代码在函数实例中读取状态,并将其作为参数传递,这是一种很实用的做法,可以减少在重组过程中需要执行的代码数量。我们可以使用函数嵌套,因为嵌套可以隐式创建函数实例。

🌰:我们需要动态显示联系人卡片

@Composable
fun ContactList(contact: Contact) {
    MyCard {
        Text("Name: ${contact.name}")
    }
}

因为值的读取发生在 Text() 中,所以在重组时 ContactList() 与 MyCard() 的调用将会被跳过。这是因为重组可以从任意的函数实例的开头重新开始,因此函数实例可以用于减少数据发生变化时需要重新执行的代码量。

4.6 Avoid backwards writes

🌰:我们需要展示银行交易列表和相应余额,为此,需要实时计算余额与展示所有交易信息

var balance by remember { mutableStateOf(0) }

balance = 0

for (transaction in transactions) {
    Row {
        // Don't do this
        balance += transaction
        Text("Transaction: $transaction Balance: $balance")
    }
}

当我们执行上述代码时,便会发现重组一直在进行,似乎永远也不会停止,这是因为在更新余额时,我们违反了 Compose 的核心假设:一旦值被读取,在组合完成之前会保持不变。我们绝对不能对已在组合中读取的值进行写入,这就是所说的向后写入,可能会导致在每一帧都进行重组。

上述代码中,我们在循环中不断更新 balance 的值,导致 Compose 始终认为该值已过期,需要重新执行。

var balances by remember(transactions) { 
    transactions.runningReduce { a, b -> a + b }
}
for ((transaction, balance) in transactions.zip(balances)) {
    Text("Transaction: $transaction Balance: $balance")
}

现在,仅在可组合函数首次出现时执行一次,并在交易发生变化时才被视为过期。当然,更为推荐的做法还是将状态前置到 ViewModel 当中,提前计算好余额。我们总是要避免 Composable 函数接收一些不是最终状态的数据。

4.7 Baseline Profiles

在使用 Compose 项目时,即便我们启用的发布模式与 R8 优化,还是会出现前几秒可能会出现卡顿,但过一段时间便正常了,这是因为即时编译的影响,我们通过 Android Studio 运行应用时,经常在启动时会出现性能下降的问题,因为代码需要解译。而基准配置文件就是解决该问题的。

我们知道,Compose 是一个未捆绑库,因此支持旧版的 Android 版本和设备,而且能够轻松更新版本,不需要等待 Android 版本更新。但这也带来了一个小问题。

Android 在应用之间共享资源,包括工具包类和可绘制对象,这会加快启动并减少内存耗用,但由于 Compose 是一个未捆绑库,因此它不会参与共享,会被视为应用的另一部分,当我们将 APK 安装到设备上时,包含了项目的所有代码以及所有依赖的库,启动时需要由 Android 运行时解译,并编译成机器代码。这个过程比较耗时,因此会降低性能。

目前我们可以采取云配置文件来解决该问题,其实就是把需要运行时解译的热点代码提前统计好,然后跟着代码一起打到 APK 当中。

Baseline Profile 就是这样的一个文件,它里面会记录我们应用的热点代码,有了它,ART 虚拟机就可以进行相应的 AOT 编译了。Google 推荐我们使用 Jetpack 当中的 Macrobenchmark。它是 Android 里的一个性能优化库,借助这个库,我们可以生成 Baseline Profile 文件。

五、拓展(使用Compose 实现跨平台)

5.1 Compose 跨平台技术简单介绍

5.1.1 什么是 Compose Multiplatform

JetBrains 早在2021年就已经正式发布了 Compose Multiplatform 1.0,标志其在生产环境中使用的时机早已经成熟:compose-jb:即 Compose Multiplatform,包含下面三者

  1. compose-android:即 Jetpack Compose

  2. compose-desktop:即 Compose for Desktop

  3. compose-web:即 Compose for Web

Compose Multiplatform 本质上是将 compose-desktop,compose-web 以及 compose-android 三者进行了整合,开发者可以在单个工程中使用同一套 Artifacts 开发出运行在 Android,Desktop(Windows, macOS, LInux)以及 Web 等多端的应用程序,工程中可以实现大部分代码的共享以此达到跨平台开发的目的。

应用开发无非关注三件事:数据获取,状态管理,界面渲染。JetBrains 推出 Kotlin Multiplatform Mobile (简称 KMM) 实现了数据获取部分的跨平台,而 compose-jb 将跨平台的范围进一步覆盖到状态管理甚至界面渲染(基于 Skia)。

图18.multiplatform ratio of different technologies

5.1.2 compsose-desktop

在一个 compose-jb 工程中,domain 层以及 data 层的代码几乎可以完全共享。在 UI 层,常用的组件和布局例如 TextButtonColumn/Row 等都可以跨越 compose-android 与 compose-desktop 通用,此外 compose-desktop 针对桌面系统的特性还提供了专用能力,比如可以感知鼠标行为和窗口大小、创建 ScrollbarsTooltipsTray 等、

 fun main() {
   Window {
       var windowSize by remember { mutableStateOf(IntSize.Zero) }
       var windowLocation by remember { mutableStateOf(IntOffset.Zero) }
       AppWindowAmbient.current?.apply {
           events.onResize = { windowSize = it }
           events.onRelocate = { windowLocation = it }
       }
       Text(text = "Location: ${windowLocation}\nSize: ${windowSize}")
   }
}

5.1.3 compose-web

compose-web 为 Web 开发者提供了专门的 DOM API,针对常用的 HTML 标签实现了对应的 Composable 组件,例如 DivPA 等等 ,同时提供了 attrs 方法以 key-value 的形式设置标签属性,一些常用属性也有专属方法;另外,基于 CSS-in-JS 技术 compose-web 允许开发者基于 DSL 定义 Style 样式。

compose-web 拥有 HTML 或 JSX 那样的结构化的变现能力,同时有具备了响应式状态管理能力,在 compose-jb 中还可以与 Desktop 和 Android 侧共享逻辑层代码。

fun main() {
    renderComposable("root") {
        var platform by remember { mutableStateOf("a platform") }
        P {
            Text("Welcome to Compose for $platform! ")
            Button(attrs = { onClick { platform = "Web" } }) {
                Text("...for what?")
            }
        }
        A("https://www.jetbrains.com/lp/compose-web") {
            Text("Learn more!")
        }
    }
}

Andorid Studio 作为 IntelliJ 平台下的 IDE ,自然也可以用于 compose-jb 项目的开发( IDEA 2021.1 对应 Android Studio Bumblebee 之后的版本)。AS 自带 Andoid 侧的预览能力,可以实时预览 UI 代码效果。此外 AS 对 Compose 的代码提示也更友好,比如非法调用 @Composable 函数时, IDE 会标红提示错误,而 IDEA 则只能在编译时发现错误。

5.1.4 iOS

compose-jb 目前没有对 iOS 端的支持,这是其成长为主流跨平台框架道路上的一个阻碍。不过compose-jb 工程中已经有针对 iOS 的开发分支,由于 iOS 不使用 Kotlin/JVM ,所以在 Kotlin/Native 编译器以及 iOS 工具链等方面存在大量适配工作,个人感觉compose-jb 在未来一定会增加对 iOS 的支持。

那么在 compose-ios 还未出现的当下,如果我们需要将应用有发布到 iOS 侧,作为一个过渡方案可以先借助 KMM 推荐 D-KMP 架构实现逻辑层和数据层的代码共享,UI 侧现阶段可以使用 Swift-UI 等平台语言进行开发,等待 compose-ios 真正到来时再对 UI 代码进行迁移。具体可见下一小节的 Demo。

5.1.5 Jetpack 是否可以跨平台

很多 Android 开发者习惯于 Compose 搭配 Android 的 Jetpack 系列组件一同使用,所以当一个 compose-android 项目被迁移到 compose-jb 时,不少人希望有对应版本 Jetpack 库可供使用。

目前 Jetpack Multiplatform 仅仅支持了 Collections 和 DataStore 两个组件,而且 Jetpack Multiplatform 还处于早期的预览阶段,不建议在线上版本使用。其实这两个组件并不是什么全新的库,而是基于现在的 Android Jetpack 版本之上进行迭代开发的,源码也在 androidx 仓库中:

  • Collections:集合相关,降低现有和新的小型集合对内存的影响。

  • DataStore:持久化相关,以异步、一致的事务方式存储数据,克服了 SharedPreferences 的一些缺点。

Jetpack 中一些重度依赖 Andorid 平台特性的组件不适合发布 KMP 版本,而一些平台无关的组件例如 Hilt,Room 等,虽然具备 KMP 化的基础但仍然会谨慎启动,因为当前首要任务还是保证其在 Android 端的稳定使用。Jetpack 团队也表示了未来会尝试对其他 Jetpack 库进行 KMP 改造,他们很乐于帮助开发者能更低成本地完成项目向 KMP 的迁移。

对于我们来说,如果对跨平台有兴趣,希望尝试着实现一个跨平台的项目,那么在技术选型上就要避免过度依赖 Jetpack ,而要像 KMP 的 Library 倾向。比如优先使用 Flow 而非 LiveData,使用 SQLDelight 替代 Room 等。

5.2 Demo using KMM

5.2.1 Prepare

要使用 KMM 进行跨平台,我们需要有一台 MAC 电脑。因为本质上,我们最终是创建了两个原生的App,因此需要使用 XCode 来编写 iOS UI 部分的代码。此外,我们还需要进行一些准备工作,来保证我们的环境支持我们进行KMM的搭建,这里推荐一个油管博主的视频:

视频中很详细的介绍了初学者应该怎样从无到有创建一个能够分别运行在 Android 与 iOS 平台上的应用程序,下面是我在搭建环境中参考的一些文档:

5.2.2 Architecture

KMM核心架构如下:

5.2.3 Presentation

该 Demo 是一个跨移动端的本地记事本 App ,支持 CRUD ,跳转二级页查看详情,编辑等功能,具体效果如下:

Android 端:

IOS 端:

该 Demo 已上传 GitHub :https://github.com/wangpin1/Note

六、总结

我在 Compose 的学习过程中,随着了解的越多,越能感受到 Compose 功能的强大,同时也被 Compose 关注点分离的整体项目设计所深深吸引,像 Compose compiler/runtime 完全可以作为一个状态节点的管理工具而完全脱离 UI 层运行,Compose UI 仅仅是对状态节点的一种封装实现,这甚至让我觉得 Compose 只用来作为 UI 工具有点大材小用了。

JAKE WHARTON 在这篇文章(A Jetpack Compose by any other name - Jake Wharton)中提到过:

  • "Each of my three projects built on Compose do not use the new Compose UI toolkit. "

  • "What this means is that Compose is, at its core, a general-purpose tool for managing a tree of nodes of any type. Well a “tree of nodes” describes just about anything, and as a result Compose can target just about anything."

  • "The nodes don’t have to be UI related at all. The tree could exist at the presenter layer as view state objects, at the data layer as model objects, or simply be a value tree of pure data."

因此他才多次呼吁能够将 Compose compiler/runtime 命名为像 “Evergreen” 之类的其他名称,确实,在名称上过度的与 Compose UI 绑定会让很多人忽略它在 compiler/runtim 所能提供的能力。

我在刚开始了解 Compose 的时候也是单纯的把它当成了一个 UI 工具,毕竟,人都是有惰性的,一个好用直观的 UI 工具,有谁会想到这个工具的核心甚至可以单独用来做数据结构管理呢。

当然,在我们的日常工作当中,首先还是需要会用,一个声明式的 UI 框架确实能提高我们的开发效率,写出更干净有效的代码,减少 BUG 的产生。从一些其他平台的技术 (Flutter, Swift UI)不难发现,声明式的 UI 框架已经成为了主流,因此希望这次分享可以帮助到大家对于 Compose 是什么,怎么用,有什么优势,未来的发展方向等能有一个比较明确的认知。也希望我们能够共同努力,慢慢的把 Compose 带入到我们的工作项目中来。

最后,对于该文章如果有疑问,欢迎直接评论,我们一起探讨,如有发现文中不准确或者错误的地方也请指正,谢谢大家!

参考文献:

https://developer.android.google.cn/jetpack/compose

深入详解 Jetpack Compose | 优化 UI 构建

Introduction to the Compose Snapshot system

Understanding Jetpack Compose

https://jakewharton.com/a-jetpack-compose-by-any-other-name/

brew

How to run CocoaPods on Apple Silicon (M1)

  • 46
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值