虽然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
}