三、没有正确理解重组和处理附带效应
很多刚上手的Compose新手可能会写出这种代码,然后发现Compose没有按照自己预期的方式显示结果,这是没有理解Compose的重组机制导致的,每次重组就是重新执行一遍可组合函数,这会导致函数中的变量被重新声明和创建。
@Composable fun WrongScreen(){ var num=0 Button(onClick = { num++ }) { Text("加一") } }
笔者写过的一篇文章大致阐述了Compose的重组概念以及如何使用几种官方的附带效应Api解决附带效应的问题,读者可以自行阅读。
妈!Jetpack Compose太难学了,别怕,这里帮你理清几个概念 - 掘金 (juejin.cn)
四、预览时不遵循Compose的规范
很多Compose新手在写预览代码时,简单的认为预览系统不是正式运行的代码,只是提供界面预览而已,因此不注重附带效应的处理,会写出下面这种代码:
@Composable @Preview fun PreviewTest(){ var a=1 Text("$a") }
这样的代码表面上是不会影响预览的,但是是一种很错误的行为。
首先,在预览中不注重Compose的规范(如果你看不懂上述代码有什么问题可以去看笔者第三节提到的另外一篇文章),只会让你写实际的Compose代码时养成不好的编码习惯,写出错误的代码。
其次,当可组合项很复杂的时候,特别是涉及较多重组的场景下,不正确处理好附带效应的问题,只会得到错误的预览。
因此笔者特别建议不要把预览当成是一种简单的UI预览,而是把预览的代码当成是实际的运行的项目代码来编写,这样项目运行时才可以得到正确的UI。
五、提前读取导致性能下降
很多新手会尝试在较高层的可组合项直接读取一些该组合项用不到的状态,这样的问题是:可被观察的状态变化时,会导致它所在的重组作用域发生重组,而它所在的重组作用域并不直接使用这个状态。我们看一个案例:
@Composable fun SnackDetail() { Box(Modifier.fillMaxSize()) { // 重组作用域开始 val scroll = rememberScrollState(0) // ... Title(snack, scroll.value) // ... } // 重组作用域结束 } @Composable private fun Title(snack: Snack, scroll: Int) { val offset = with(LocalDensity.current) { scroll.toDp() } Column( modifier = Modifier .offset(y = offset) ) { // ... } }
我们逐步分析上面这段代码:
1.scroll.value
所在的重组作用域是SnackDetail
,因为Box是内联函数,编译后实际不是函数。
2.实际使用scroll.value
的是Title
。
3.scroll.value
变化时,发生重组的不仅仅是Title
,还有它的父可组合项SnackDetail
,因为scroll在SnackDetail
中。
因此,scroll
导致了不必要的重组,因为scroll
理应只影响Title
,现在还导致了父可组合项的重组。
解决方法有两种:
1.将scroll
作为参数传入到Title
中,在Title
中调用scroll.value
,使scroll.value的重组作用域变成Title
2.将scroll.value的读取转化为lambda,仅在使用时调用lambda函数,如下所示:
@Composable fun SnackDetail() { // ... Box(Modifier.fillMaxSize()) { // 重组作用域开始 val scroll = rememberScrollState(0) // ... Title(snack) { scroll.value } // ... } // 重组作用域结束 } @Composable private fun Title(snack: Snack, scrollProvider: () -> Int) { // ... val offset = with(LocalDensity.current) { scrollProvider().toDp() } Column( modifier = Modifier .offset(y = offset) ) { // ... } }
此外,还有一个巨大的优化点就是,Modifier.offset使用lambda版本
对Title的代码改造成如下:
@Composable private fun Title(snack: Snack, scrollProvider: () -> Int) { // ... Column( modifier = Modifier .offset { IntOffset(x = 0, y = scrollProvider()) } ) { // ... } }
这样做有什么意义呢,offset
的非lambda版本会在scroll发生变化的时候导致整个重组作用域发生重组,这就有点不必要了,因为scroll值的变化仅会导致可组合项发生位移,我们并不需要重组,只需要重新绘制或者重新布局就行了。
使用offset的lambda版本就可以实现这种方式,我们看看该方法的部分注释:
This modifier is designed to be used for offsets that change, possibly due to user interactions. It avoids recomposition when the offset is changing, and also adds a graphics layer that prevents unnecessary redrawing of the context when the offset is changing.
翻译:
此Modifier设计用于可能由于用户交互而发生变化的偏移量。它避免了偏移量变化时的重新组合,并且还添加了图形层,以防止偏移量变化时不必要的上下文重绘。
可以看出,lambda版本的offset避免了重组,只会在测量的时候重新修改可组合项的位置关系,这样性能进一步提高了。
总而言之就是,尽可能将读取状态的行为延后。
六、LazyColumn、LazyRow等没有使用key
实际上在绝大部分的声明式UI框架中,懒加载的列表与安卓的传统列表开发不同,在RecyclerView
中,在修改了数据源后,我们需要手动通过Adapter
告知列表,刚才修改了数据源的哪项数据,例如删除了某项,修改了某项,移动了某项,这样RecyclerView
才能正确处理UI和数据源的关系。
但是声明式UI框架中,例如Compose,我们是没有“通知”这个行为的,只需要传递整个列表,LazyColumn等可组合项就自动完成列表构建了,这到底发生了什么?
@Composable fun MessageList(messages: List<Message>) { LazyColumn { items( items = messages, ) { message -> MessageRow(message) } } }
遗憾的是,什么都没特别的,LazyColumn只是100%重新构建了整个列表,类似RecyclerView
的notifyDataSetChanged()
。
what?哪怕你只是添加了一条数据,或者修改了某一条数据的某一个小参数,都会导致整个列表重新构建。这是无法接受的,特别是列表项特别多元素时。
因此,要完成高效的重组,列表必须定位出当前列表和旧列表的变化,鉴定出这种变化必须了解每一个项的以下两点内容:
- 我是谁
- 我有什么内容
第一点用于让列表了解,每一个项的独一无二的标志是什么,这让列表可以知道项的位置关系是否发生了变化,项是否是新增的或者已经被移除了。
第二点用于让列表了解,每一个项自身的元素是否发生了变化。
第二点,Compose的延迟列表中是使用对象自身的equals方法来完成的,而对于第一点,则是使用key。
将代码改造成如下:
@Composable fun MessageList(messages: List<Message>) { LazyColumn { items( items = messages, key = { message -> message.id } ) { message -> MessageRow(message) } } }
我们多传入一个参数key,即使用message中的id,必须要清楚的是,这个key必须是独一无二的,因为当存在两个相同的key时,列表将无法确定item的唯一性。
这样的好处就是,列表可以清楚感知每一个item的唯一性,当数据源只发生了项的位置的变化,或者部分项被新增或者移除了,列表只需要处理那些发生过变化的项对应的可组合项即可,不需要重组整个列表。这样列表的性能提高了一个数量级。
额外内容:
一个很多人不知道的点是,哪怕不是Lazy系列的可组合项,也可以使用key来提高性能,例如普通的Column可以通过key来提高重组效率!
@Composable fun NiceColumn(list:List<String>){ Column{ list.forEach { key(it){ Text(text=it) } } } }
如果你有一个不断变化的列表,也可以使用key这个可组合函数来完成对项的唯一性声明,当列表变化时,避免其他项被重组。
七、业务对象入侵可组合函数
许多可组合函数的业务就是显示一些后台返回的数据,假设你有一个这样的后台对象:
data class Message( val content:String, val id:Int )
业务需要在一个列表中展示所有的这些对象,因此很多人会尝试写一个这样的可组合项:
@Composable fun MessageContent( message:Message ){ Text(message.content) } @Composable fun MessageList(list:List<Message>){ LazyColumn{ items(list){ MessageContent(it) } } }
这样是不存在任何代码上的问题的,但是千万别忘记,业务是会发生变化和重合的。当另外一个业务,或者另外一个接口也使用到这个可组合项的时候呢,就会非常难受,因为该可组合项已经和某个后台对应的实体类发生耦合了(特别是一些使用了Retrofit网络框架的项目,每一个接口都有一个对应的实体类)。
因此,我们应该避免把可组合项和某个业务绑定起来,在设计可组合项的状态对象时,不应该考虑只和某个业务的对象绑定(除非你非常明确该可组合项只用于某个特定的业务),脱离业务去设计状态对象即可。当某个业务想使用该可组合项时,例如可组合项要显示接口返回的列表,我们应该将该接口的实体类映射成可组合项的状态类,再传入可组合项,避免业务和某个可组合项发生耦合。