(本篇涉及内容较多,篇幅较长,建议先点赞收藏,再静心阅读。)

前言

Q1的时候公司列了个培训计划,部分人作为讲师要上报培训课题。那时候刚从好几个Android项目里抽离出来,正好看到Jetpack发布了新玩意儿——Compose,我被它的快速实时打包给吸引住了,就准备调研一下,于是上报了此次课题。

可是计划总赶不上变化,刚把课题报上去,我就扎入了前端的水深火热之中。从0到1地学习前端,一边学一边做项目,一边做项目一边分享,思考怎么让别人也学会做前端项目,这段时间,真的酸爽。

随着时间推移,之前上报的课题分享也快临近了,这才想起来我还欠一个交代。没办法,自己报的课题,熬夜也要赶出来。

下面,我会从这几个方面来阐述这次课题:

  • Compose是什么
  • 如何优雅地使用Compose
  • 最后,Compose是否值得一试

名词解析:

以下用到的专业术语可能会有出入,为了避免混淆,下面做一个名词解析表:

名词解析备注
组件可以控制页面展示的部分UI的逻辑单元
View可以展示的UI,并具备自己维护状态的能力
微件组件,可以控制页面展示的部分UI的逻辑单元

Compose官方文档中,新发明了一个名词——“微件”
微件 可以理解为Android目前用到的各种 View,也可以理解为H5前端里常说的 组件

1 Compose是什么

Jetpack Compose 是用于构建原生界面的新款 Android 工具包。它可简化并加快 Android 上的界面开发。使用更少的代码、强大的工具和直观的 Kotlin API,快速让应用生动而精彩。

这么一听感觉有点抽象,不知道再讲什么。

我来翻译一下:

Jetpack Compose 是一款基于Kotlin API,重新定义Android布局的一套框架,它可以更快速地实现Android原生应用。节省开发时长,减少包体积,提高应用性能。

节省开发时长,减少包体积,提高应用性能。 这个听起来很诱人,我们来看看它的效果如何。

1.1 Android Studio 对Compose 的支持

(本节要感谢 依然范特稀西 提供的实践数据)

这一功能基于新版Android Studio 对Compose 的支持。

新版的Android Studio Arctic Fox(现在还是Canary版本) 中添加了许多新工具来支持Jetpack Compose新特性,比如:实时文字、动画预览,布局检查等等。

1.1.1 强大的预览

新的Android Studio 增加了对文字更改实时预览的效果,可以在Preview、模拟器、或者真机上实时预览。

1.1.2 动画预览

可以在AndroidStudio内查看、检查或播放动画,还可以逐针播放。

1.1.3 布局检查器

Android Studio Arctic Fox 增加了布局监测器对Compose的支持,可以分析Compose组件的层级。如下所示:

1.1.4 交互式预览

在此模式下,你可以与界面组件互动、点击组件,以及查看状态如何变化。通过这种方式,你可以快速获得有关界面如何反应的反馈,并可快速预览动画。如要启用此模式,只需点击“互动”图标 ,系统即会切换预览模式。

如需停止此模式,请点击顶部工具栏中的 Stop Interactive Preview。

以上是AndroidStudio对Compose的支持,可以说是大手笔了。

1.2 Jetpack Compose 使用前后对比

你以为Compose只是添加了预览功能?那可不是。

从普通应用切换到Compose应用,你的应用速度和性能可以得到大幅提升。

我们来看一个Google官方改造的应用示例。

1.2.1 APK 尺寸缩减

用户最为关心的指标,莫过于 APK 大小。

下面是开启了 资源缩减 的最小化发布版 APK (使用了 R8) 通过 APK Analyzer 所测量的结果:

关于上述数字的说明:

1、使用了 APK Analyzer 报告的 "APK file size" (而不是下载时的大小)。
APK 大小分析

2、在使用了 Compose 后,我们发现 APK 大小缩减了 41%,方法数减少了 17%

1.2.2 代码行数

源代码行数虽然不能作为衡量软件好坏的标准,但是可以对比出一个实验在“瘦身”上面做了多大的努力,为观察实验变化提供了一个统计视角。

从图中可以看到,XML 行数大幅减少了 76%再见了,布局文件,以及 styles、theme 等其他的 XML 文件 。

同时,Kotlin 代码的总行数也下降了。

这就是 APK 能够瘦身的很大一部分原因。

1.2.3 构建速度

构建速度是开发者们十分关心的一项指标。

这里需要做一些说明:

"完全接入 Compose" 使用的是最新版本的 Dagger/Hilt,该版本使用了 Android Gradle Plugin 7.0 中的新 ASM API。而其他版本使用了较旧的 Hilt 版本,其使用了不同的机制,会严重拖慢生成 dex 文件的时间。

除此之外,Kotlin 编译器与 Compose 编译器插件为我们所做的事情,如 位置记忆化、细粒度重组 等工作,构建时间能够 减少 29%, 可以说十分惊人。

2 如何优雅地使用Compose

上面讲了很多Compose的优点,那么,接下来我们如何使用它呢。

2.1 准备

在开始使用Compose之前,你需要具备一下基础。

  • 下载 Android Studio Arctic Fox 或更高版本
  • Kotlin 1.4.32 或更高版本
  • Kotlin 语言使用无障碍

2.2 快速搭建Compose

如何快速搭建Compose新项目,以及如何将旧有项目迁移成Compose项目。

具体实践步骤,我在《用Android 最新UI 框架 「 Compose 」快速构建应用》 里已经讲得非常详细,这里不再赘述。

2.3 如何快速学习Compose

首先,恭喜你看到这里,这篇文章看完,你就可以上手开发了。

你也可以到官网提供的 【快速上手】 示例教程 学习如何快速使用Compose基础。

你还可以在YouTube上观看【教学视频】

或者到gitHub下载【demo集合】,官方提供了很多demo示例,见下方示例图:

2.4 Compose编程思想

了解了如何搭建,以及如何编写demo,最终应用到项目之前,你还需要了解一些必备且重要的Compose基础。

首当其冲的是 编程思想

2.4.1 声明性编程范式

在Compose之前,我们最常见的界面更新方式是使用 findViewById() 方法找到UI控件,并通过调用 button.setText(String)container.addChild(View)img.setImageBitmap(Bitmap) 等方法更新控件。

这种手动操做布局的方式,可以提高代码可读性,但也容易出错。比如:A控件被移除后紧接着在另一段代码中又给A布局赋值。这可能会导致代码异常。

在过去的几年中,整个行业已开始转向声明性界面模型,该模型大大简化了与构建和更新界面关联的工程设计。该技术的工作原理是在概念上从头开始重新生成整个屏幕,然后仅执行必要的更改。此方法可避免手动更新有状态视图层次结构的复杂性。

简单点说,就是声明性布局可以做到只更新需要更新的布局,而不会因为一个小改动刷新整个屏幕,这是性能提升的一大进步。

Compose 是一个声明性界面框架。

重新生成整个屏幕所面临的一个难题是,在时间、计算能力和电池用量方面可能成本高昂。为了减轻这一成本,Compose 会智能地选择在任何给定时间需要重新绘制界面的哪些部分。这会对你设计界面组件的方式有一定影响,下面会说到。

2.4.2 简单的可组合函数

使用 Compose,你可以通过定义一组接受数据而发出界面元素的可组合函数来构建界面。

下面一个简单的示例是 Greeting 组件,它接受 String 类型文案,而发出一个显示问候消息的 Text 组件。

Greeting组件解析:

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

2.微件接受一个 String,因此它可以按名称问候用户。

3.此函数可以在界面中显示文本。为此,它会调用 Text() 可组合函数,该函数实际上会创建文本界面元素。可组合函数通过调用其他可组合函数来发出界面层次结构。

4.此函数不会返回任何内容。发出界面的 Compose 函数不需要返回任何内容,因为它们描述所需的屏幕状态,而不是构造界面微件。

5.此函数快速、幂等且没有副作用。

  • 5.1 使用同一参数多次调用此函数时,它的行为方式相同,并且它不使用其他值,如全局变量或对 random() 的调用。
  • 5.2 此函数描述界面而没有任何副作用,如修改属性或全局变量。

2.4.3 声明性范式转变

在以往的 XML 布局编程时,通常会通过增加 XML 布局文件来实现布局扩张,每个 View 内部会维护自己状态,并且提供 gettersetter 方法,允许逻辑与 View 进行交互。

在 Compose 的声明性方法中, View 相对无状态,并且不提供 setter 或 getter 函数。

实际上, View 不会以对象形式提供。

你可以通过调用带有不同参数的同一可组合函数来更新界面。这使得向架构模式(如 ViewModel)提供状态变得很容易,如应用架构指南中所述。

然后,可组合项函数 负责在每次可观察数据更新时将当前应用状态转换为界面。

(下图示例:一个数据源像下传递,应用到每个布局,需要刷新界面时,只需要刷新数据源)

(下图示例:一个字布局出发点击事件时,事件向上传递,最后更改数据源,界面得以刷新)

2.4.4 动态内容

由于可组合函数是用 Kotlin 而不是 XML 编写的,因此它们可以像其他任何 Kotlin 代码一样动态。例如,假设你想要构建一个界面,用来问候一些用户

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

此函数接受名称的列表,并为每个用户生成一句问候语。

可组合函数可能非常复杂。你可以根据功能,使用kotlin进行任意逻辑改造,所有这些动态切定制的内容,是 Compose对比传统xml的优势。

2.4.5 重组

在命令式界面模型中(XML界面模型),如需更改某个View,你可以在该View上调用 setter 以更改内部状态。

Compose 中,你可以使用新数据再次调用可组合函数。

这样做会导致函数进行重组 -- 系统会根据需要使用新数据重新绘制函数发出的View。

Compose 框架可以智能地仅重组已更改的组件。

例如,假设有以下可组合函数,它用于显示一个按钮:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

以上代码,每次点击该按钮时,调用方都会更新 clicks 的值。Compose 会再次调用 lambdaText 函数以显示新值;此过程称为“重组”。不依赖于该值的其他函数不会进行重组。

重组整个界面树在计算上成本高昂,因为会消耗计算能力并缩短电池续航时间。

Compose 根据新输入重组时,它仅调用可能已更改的函数或 lambda,而跳过其余函数或 lambda。通过跳过所有未更改参数的函数或 lambdaCompose 可以高效地重组

切勿依赖于执行可组合函数所产生的附带效应,因为可能会跳过函数的重组。

附带效应:是指对应用的其余部分可见的任何更改。

例如,以下操作全部都是危险的附带效应:

  • 写入共享对象的属性
  • 更新 ViewModel 中的可观察项
  • 更新共享偏好设置

举个例子:

以下代码会创建一个可组合项以更新 SharedPreferences 中的值。

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

该可组合项不应从共享偏好设置本身读取或写入,于是此代码将读取和写入操作移至后台协程中的 ViewModel。应用逻辑会使用回调传递当前值以触发更新。

2.4.6 使用Compose的注意事项

以下是在 Compose 中编程时需要注意的事项:

  • 可组合函数可以按任何顺序执行。
  • 可组合函数可以并行执行。
  • 重组会跳过尽可能多的可组合函数和 lambda。
  • 重组是乐观的操作,可能会被取消。
  • 可组合函数可能会像动画的每一帧一样非常频繁地运行。

在每种情况下,最佳做法都是使可组合函数保持快速、幂等且没有附带效应。

可组合函数可以按任何顺序执行。

例如,假设有如下代码,用于在标签页布局中绘制三个屏幕:

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

StartScreenMiddleScreenEndScreen 的调用可以按任何顺序进行。这意味着,举例来说,你不能让 StartScreen() 设置某个全局变量(附带效应)并让 MiddleScreen() 利用这项更改。相反,其中每个函数都需要保持独立。

可组合函数可以并行执行。

Compose 可以通过并行运行可组合函数来优化重组。这样一来,Compose 就可以利用多个核心,并以较低的优先级运行可组合函数(不在屏幕上)。

这种优化意味着,可组合函数可能会在后台线程池中执行。

假设多个可组合函数调用了 ViewModel 里的方法A,那么方法A会被多个线程调用,需要做好线程同步工作。

也因为可并行执行的特点,调用可能发生在与调用方不同的线程上。因此,所有可组合函数都不应有附带效应(比如修改一个全局变量),而应通过始终在界面线程上执行的 onClick 等回调触发附带效应。

以下示例展示了一个可组合项,它显示一个列表及其项数:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

此代码没有附带效应,它会将输入列表转换为界面。此代码非常适合显示小列表。不过,如果函数写入局部变量,则这并非线程安全或正确的代码:

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

在本例中,每次重组时,都会修改 items。这可以是动画的每一帧,或是在列表更新时。但不管怎样,界面都会显示错误的项数。因此,Compose 不支持这样的写入操作;通过禁止此类写入操作,我们允许框架更改线程以执行可组合 lambda。

重组会跳过尽可能多的可组合函数和 lambda。

如果界面的某些部分无效,Compose 会尽力只重组需要更新的部分。这意味着,它可以跳过某些内容以重新运行单个按钮的可组合项,而不执行界面树中在其上面或下面的任何可组合项。

每个可组合函数和 lambda 都可以自行重组。以下示例演示了在呈现列表时重组如何跳过某些元素:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.h5)
        Divider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

这些作用域中的每一个都可能是在重组期间执行的唯一一个作用域。当 header 发生更改时,Compose 可能会跳至 Column lambda,而不执行它的任何父项。此外,执行 Column 时,如果 names 未更改,Compose 可能会选择跳过 LazyColumnItems

执行所有可组合函数或 lambda 都应该没有附带效应。当你需要执行附带效应时,应通过回调触发。

重组是乐观的操作,可能会被取消。

重组是乐观的操作,也就是说,Compose 预计会在参数再次更改之前完成重组。如果某个参数在重组完成之前发生更改,Compose 可能会取消重组,并使用新参数重新开始。

取消重组后,Compose 会从重组中舍弃界面树。

如有任何附带效应依赖于显示的界面,则即使取消了组成操作,也会应用该附带效应。这可能会导致应用状态不一致(导致状态错乱,或重复赋值)。

可组合函数可能会像动画的每一帧一样非常频繁地运行。

在某些情况下,可能会针对界面动画的每一帧运行一个可组合函数。如果该函数执行成本高昂的操作(例如从设备存储空间读取数据),可能会导致界面卡顿。

如果可组合函数需要数据,它应为相应的数据定义参数,从参数中获取。

你可以将成本高昂的工作移至组成操作线程之外的其他线程,并使用 mutableStateOfLiveData 将相应的数据传递给 Compose

总结:可组合函数应尽量写成纯函数,数据仅从参数中获取,更改数据仅从用户操作事件中进行。所有异步数据需要先准备好,再传入函数的参数中。

3 Compose是否值得一试

前面讲到Compose的特性,优缺点,以及如何快速入门、如何正确使用。

那么Compose是否值得应用到项目中来呢?

这些还需要具体情况具体分析。

如果你是新项目

我建议你大胆尝鲜,毕竟聪明的“部分刷新”机制,是提高页面性能的重要手段。而且声明式布局在未来应该会取代传统的xml布局形式,这是大势所趋。

如果你是现有项目改造。

首先,你可以评估一下是否已经具备开始Compose的基础能力——kotlin语言的灵活运用

Compose可以说是为Kotlin量身定制的、与View model紧密结合的一种衍生物,有了KotlinView modelCompose的作用可以发挥到极致,也就能实现前面的目标:

  • 构建时间能够 减少 29%
  • XML 行数大幅减少了 76%
  • APK 大小缩减了 41%
  • 方法数减少了 17%

如果你已经具备了上述能力,那么可以在小范围进行试点,或者从性能要求比较高的页面入手。

建议先单个页面引入,最后再做全量替换。Google官方的改造案例也是这么做的。

最后,放开手,撸起来吧!

社区需要你我共建,更需要走在前沿的实践者,期待看到更多、更好的文章出现,这就是我写作的动力。