在一次项目开发中接触到了jetpack Compose,并且还项目中在逻辑简单的页面,使用了compose去实现。当时觉得很新颖,实践中也感觉到,这种响应式的,与当时的Vue/微信小程序/Flutter中思想大同小异,可能是未来的一种原生写UI的趋势。在现在的每记和脚印项目中,新实现的页面,都会优先考虑用Compose去实现。然而,Compose的一些性能优化点及注意点,也是做为开发人员需要熟悉的,今天将做一个小的总结。
一、声明式 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,状态读取+使用
) {
// ...
}
}
理解: 由于我们将scroll.value变成了Lambda,所以,它并不会在composition期间产生状态读取行为,这样,当scroll.value发生变化的时候,就不会触发「重组」,这就是 延迟 的意义。
五、小结
其实以上案例优化的点在本质上,都是在践行:状态读取与使用位置一致的原则。但是需要我们对Compose的底层原理,快照系统,还有ScopeUpdateScope有一定的了解。这样才会让我们有着深刻的理解,代码为什么要这么写。