什么是Jetpack Compose?
Jetpack Compose是一组集成库,用于开发安卓应用程序的用户界面。它是一个强大的工具,提供了许多新的功能,旨在使应用程序更加美观、稳定和易于使用。它可以与现有的代码集成,并提供一个用于创建用户界面的独立框架。
Jetpack Compose的主要目标是改进应用程序的用户界面,并提高应用程序的稳定性和性能。Compose的主要组成部分是UI,它允许您构建您的应用程序的所有界面组件。而且,与其他Android SDK方法不同,Jetpack Compose随着时间的推移可以更容易地进行迭代和维护。这使得它成为学习和使用的可靠工具。
优势和特点
Jetpack Compose具有以下特点和优点:
1. 更快的开发过程:Composed的UI可以以可重复的代码块组成。使您可以 差异化地开发您的UI,并以最快的速度得到反馈。
2. 更直观的开发过程: Compose使得UI设计更为直观。您可以轻松地在代码中添加各种UI元素并将其组合起来,实时预览结果,从而可以更容易地进行开发。
3. 更容易实现一致的用户界面:Compose框架使得开发者能够实现一致的用户界面。开发者可以轻松地定义主题和风格,并与应用程序中的其他组件协调。
4. 较少的代码量:与传统的Android开发方法相比,使用Jetpack Compose可以大大减少代码。这降低了应用程序的维护成本,提高了开发速度。
5. 更好的性能:由于Jetpack Compose可以更好地减少运行时的布局计算,因此可以获得更好的性能。这意味着应用程序可以更快地响应用户操作,提高了用户体验。
据谷歌官方介绍Jetpack Compose 有以下特点
- 更少的代码:使用更少的代码实现更多的功能,并且可以避免各种错误,从而使代码简洁且易于维护。
- 直观的 Kotlin API:只需描述界面,Compose 会负责处理剩余的工作。应用状态变化时,界面会自动更新。
- 加快应用开发:兼容现有的所有代码,方便随时随地采用。借助实时预览和全面的 Android Studio 支持,实现快速迭代。
- 功能强大:凭借对 Android 平台 API 的直接访问和对于 Material Design、深色主题、动画等的内置支持,创建精美的应用。
Compose 出生的目的:就是为了重新定义 Android 上 UI 的编写方式,为了「提高 Android 原生的 UI 开发效率,让 Android 的 UI 开发方式能跟上时代的步伐」。
一、声明式 vs 指令式编程
1、定义
无论是官网文档还是介绍Compose的优点时,都会说到Compose是声明式的。我们来回顾下,在wiki上有着如下定义:
声明式编程(英语:Declarative programming)或译为声明式编程,是对与命令式编程不同的编程范型的一种合称。它们建造计算机程序的结构和元素,表达计算的逻辑而不用描述它的控制流程。
指令式编程(英语:Imperative programming);是一种描述电脑所需作出的行为的编程范型。几乎所有电脑的硬件都是指令式工作;几乎所有电脑的硬件都是能执行机器语言,而机器代码是使用指令式的风格来写的。
通俗的来说就是:声明式编程是一种把程序写成描述结果的形式,而不是如何获得结果的形式。它主要关注结果,而不是实现细节。声明式编程的代码通常更简洁,更容易理解和维护。
命令式编程则是一种把程序写成指令的形式,告诉计算机如何实现结果。它更加关注细节,如何实现任务。命令式编程的代码通常更长,更难理解和维护。
2、个人理解
Compose其实就是UI框架,它最主要的功能就是让开发人员更加快速的实现 页面逻辑&交互效果 这是目的。
对于传统的XML来说,我们通过请求去服务器获取数据,请求成功后,我们需要findViewById
找到页面元素View,再设置View的属性,更新页面展示状态。整个过程是按 http请求 -> 响应 -> 寻找对应View -> 更新对应View
按部就班就地执行,这种思想就是命令式编程。
但是Compose描述为 http请求 -> 响应 -> 更新mutableData -> 引用对应数据的View自动重组
,整个过程不需要我们开发去写更新UI的代码(发出命令),而是数据发生改变,UI界面自动更新,可以理解为声明式。
二、Compose优势
目前对于我的体验感受来说,Compose的优势体现在以下几个点:
- 页面架构清晰。对比以前mvp,mvvm或结合viewbinding,少去了很多接口及编写填充数据相关的代码
- 动画API简单好用。强大的动画支持,使得写动画非常简单。
- 开发效率高,写UI速度快,style、shape等样式使用简单。
- 另外、还有一些官方优势介绍
三、Compose 的重组作用域
虽然Compose 编译器在背后做了大量工作来保证 recomposition 范围尽可能小,我们还是需要对哪些情况发生了重组以及重组的范围有一定的了解 。
假设有如下代码:
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Log.d(TAG, "Foo")
Button(onClick = {
text = "$text $text"
}.also { Log.d(TAG, "Button") }) {
Log.d(TAG, "Button content lambda")
Text(text).also { Log.d(TAG, "Text") }
}
}
其打印结果为:
D/Compose: Button content lambda
D/Compose: Text
按照开发经验,第一感觉会是,text变量只被Text控件用到了。
分析一下,Button控件的定义为:
参数 text
作为表达式执行的调用处是 Button 的尾lambda,而后才作为参数传入 Text()
。 所以此时最小重组范围是 Button 的 尾lambda 而非 Text()
另外还有两点需要关注:
- Compose 关心的是代码块中是否有对 state 的 read,而不是 write。
- text 指向的 MutableState 实例是永远不会变的,变的只是内部的 value
重组中的 Inline 陷阱!
非inline函数 才有资格成为重组的最小范围,理解这点特别重要!
我们将代码稍作改动,为 Text()
包裹一个 Box{...}
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Button(onClick = { text = "$text $text" }) {
Log.d(TAG, "Button content lambda")
Box {
Log.d(TAG, "Box")
Text(text).also { Log.d(TAG, "Text") }
}
}
}
日志如下:
D/Compose: Button content lambda
D/Compose: Box
D/Compose: Text
要点:
Column
、Row
、Box
乃至Layout
这种容器类 Composable 都是inline
函数,因此它们只能共享调用方的重组范围,也就是 Button 的 尾lambda
如果你希望通过缩小重组范围提高性能怎么办?
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Button(onClick = { text = "$text $text" }) {
Log.d(TAG, "Button content lambda")
Wrapper {
Text(text).also { Log.d(TAG, "Text") }
}
}
}
@Composable
fun Wrapper(content: @Composable () -> Unit) {
Log.d(TAG, "Wrapper recomposing")
Box {
Log.d(TAG, "Box")
content()
}
}
- 自定义非 inline 函数,使之满足 Compose 重组范围最小化条件。
四、Compose开发时,提高性能的关注点
当 Compose 更新重组时,它会经历三个阶段(跟传统View比较类似):
- 组合:Compose 确定要显示的内容 - 运行可组合函数并构建界面树。
- 布局:Compose 确定界面树中每个元素的尺寸和位置。
- 绘图:Compose 实际渲染各个界面元素。
基于这3个阶段, 尽可能从可组合函数中移除计算。每当界面发生变化时,都可能需要重新运行可组合函数;可能对于动画的每一帧,都会重新执行您在可组合函数中放置的所有代码。
1、合理使用 remember
它的作用是:
- 保存重组时的状态,并可以有重组后取出之前的状态
引用官方的栗子 :
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
// DON’T DO THIS
items(contacts.sortedWith(comparator)) { contact ->
// ...
}
}
}
LazyColumn
在滑动时,会使自身状态发生改变导致ContactList
重组,从而contacts.sortedWith(comparator)
也会重复执行。而排序是一个占用CPU算力的函数,对性能产生了较大的影响。
正确做法:
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
val sortedContacts = remember(contacts, sortComparator) {
contacts.sortedWith(sortComparator)
}
LazyColumn(modifier) {
items(sortedContacts) {
// ...
}
}
}
- 使用
remember
会对排序的结果进行保存,使得下次重组时,只要contacts
不发生变化 ,其值可以重复使用。 - 也就是说,它只进行了一次排序操作,避免了每次重组时都进行了计算。
提示:
- 更优的做法是将这类计算的操作移出Compose方法,放到ViewModel中,再使用
collectAsState
或LanchEffect
等方式进行观测自动重组。
2、使用LazyColumn、LazyRow
等列表组件时,指定key
如下一段代码,是一个很常见的需求(from官网):
NoteRow记录每项记录的简要信息,当我们进入编辑页进行修改后,需要将最近修改的一条按修改时间放到列表最前面。这时,假若不指定每项Item的Key,其中一项发生了位置变化,都会导致其他的NoteRow
发生重组,然而我们修改的只是其中一项,进行了不必要的渲染。
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes
) { note ->
NoteRow(note)
}
}
}
正确的做法:
- 为每项Item提供 项键,就可避免其他未修改的NoteRow只需挪动位置,避免发生重组
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes,
key = { note ->
// 为每项Item提供稳定的、不会发生改变的唯一值(通常为项ID)
note.id
}
) { note ->
NoteRow(note)
}
}
}
3、使用 derivedStateOf
限制重组
假设我们需要根据列表的第一项是否可见来决定划到顶部的按钮是否可见,代码如下:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
- 由于列表的滑动会使
listState
状态改变,而使用showButton
的AnimatedVisibility
会不断重组,导致性能下降。
解决方案是使用派生状态。如下 :
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
- 派生状态,可以这样理解,只有在
derivedStateOf
里的状态发生改变时,只关注和派发对UI界面产生了影响的状态。这样AnimatedVisibility
只会在改变时发生重组。对应的应用场景是,状态发生了改变,但是我们只关注对界面产生了影响的状态进行分发,这种情况下,就可以考虑使用。
4、尽可能延迟State的读行为
之前我们提到,对于一个Compose页面来说,它会经历以下步骤:
- 第一步,Composition,这其实就代表了我们的Composable函数执行的过程。
- 第二步,Layout,这跟我们View体系的Layout类似,但总体的分发流程是存在一些差异的。
- 第三步,Draw,也就是绘制,Compose的UI元素最终会绘制在Android的Canvas上。由此可见,Jetpack Compose虽然是全新的UI框架,但它的底层并没有脱离Android的范畴。
- 最后,Recomposition,也就是重组,并且重复1、2、3步骤。
尽可能推迟状态读取的原因,其实还是希望我们可以在某些场景下直接跳过Recomposition的阶段、甚至Layout的阶段,只影响到Draw。
分析如下代码:
@Composable
fun SnackDetail() {
// Recomposition Scope
// ...
Box(Modifier.fillMaxSize()) { Start
val scroll = rememberScrollState(0)
// ...
Title(snack, scroll.value) // 1,状态读取
// ...
}
// Recomposition Scope End
}
@Composable
private fun Title(snack: Snack, scroll: Int) {
// ...
val offset = with(LocalDensity.current) { scroll.toDp() }
Column(
modifier = Modifier
.offset(y = offset) // 2,状态使用
) {
// ...
}
}
上面的代码有两个注释,注释1,代表了状态的读取;注释2,代表了状态的使用。这种“状态读取与使用位置不一致”的现象,其实就为Compose提供了性能优化的空间。
那么,具体我们该如何优化呢?简单来说,就是让:“状态读取与使用位置一致”。
改为如下 :
// 代码段12
@Composable
fun SnackDetail() {
// Recomposition Scope
// ...
Box(Modifier.fillMaxSize()) {
val scroll = rememberScrollState(0)
// ...
Title(snack) { scroll.value } // 1,Laziness
// ...
}
// Recomposition Scope End
}
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(
modifier = Modifier
.offset(y = offset) // 2,状态读取+使用
) {
// ...
}
}