[持续更新]细数那些Compose新手容易犯的错误(二)

简介: [持续更新]细数那些Compose新手容易犯的错误

三、没有正确理解重组和处理附带效应


很多刚上手的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%重新构建了整个列表,类似RecyclerViewnotifyDataSetChanged()

what?哪怕你只是添加了一条数据,或者修改了某一条数据的某一个小参数,都会导致整个列表重新构建。这是无法接受的,特别是列表项特别多元素时。

因此,要完成高效的重组,列表必须定位出当前列表和旧列表的变化,鉴定出这种变化必须了解每一个项的以下两点内容:

  1. 我是谁
  2. 我有什么内容

第一点用于让列表了解,每一个项的独一无二的标志是什么,这让列表可以知道项的位置关系是否发生了变化,项是否是新增的或者已经被移除了。

第二点用于让列表了解,每一个项自身的元素是否发生了变化。

第二点,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网络框架的项目,每一个接口都有一个对应的实体类)。

因此,我们应该避免把可组合项和某个业务绑定起来,在设计可组合项的状态对象时,不应该考虑只和某个业务的对象绑定(除非你非常明确该可组合项只用于某个特定的业务),脱离业务去设计状态对象即可。当某个业务想使用该可组合项时,例如可组合项要显示接口返回的列表,我们应该将该接口的实体类映射成可组合项的状态类,再传入可组合项,避免业务和某个可组合项发生耦合


相关文章
|
编译器 API 容器
Compose:从重组谈谈页面性能优化思路,狠狠优化一笔
Compose:从重组谈谈页面性能优化思路,狠狠优化一笔
1114 0
|
传感器 Android开发 iOS开发
Flutter插件开发指南02: 事件订阅 EventChannel
上一节我们讲了 Channel 通道,但是如果你是卫星定位业务,原生端主动推消息给 Flutter 这时候就要用到 EventChannel 通道了。 本节会写一个 1~50 的计数器,到 50 后自动关闭原生的消息订阅。
492 1
Flutter插件开发指南02:  事件订阅 EventChannel
|
XML Java Android开发
Android Studio App开发之翻页视图ViewPager的讲解及实战(附源码 包括翻页视图和翻页标签栏)
Android Studio App开发之翻页视图ViewPager的讲解及实战(附源码 包括翻页视图和翻页标签栏)
1794 0
|
安全 关系型数据库 MySQL
|
存储 关系型数据库 API
深入理解后端技术:构建高效、可扩展的服务器端应用
本文将探讨后端开发的核心概念和技术,包括服务器端编程、数据库管理、API设计和安全性等方面。通过深入浅出的方式,让读者了解如何构建高效、可扩展的后端系统。我们将从基本的后端框架开始,逐步深入到高级主题,如微服务架构和容器化部署。无论您是初学者还是有经验的开发人员,都能在本文中找到有价值的信息和实用的建议。
|
监控 小程序 数据处理
揭秘支付宝小程序性能优化秘籍:从加载到运行,每一步都快人一步!
【8月更文挑战第27天】本文深入探讨了支付宝小程序性能优化的关键技术和策略,包括减少网络请求、利用CDN加速、代码按需加载、图片压缩、懒加载以及性能监控等多方面内容,并提供了实用的示例代码,帮助开发者显著提升小程序的加载速度与运行效率,创造更佳用户体验。
763 1
|
存储 数据处理 Kotlin
Kotlin Flow背后的神秘力量:背压、缓冲与合并策略的终极揭秘!
【9月更文挑战第13天】Kotlin Flow 是 Kotlin 协程库中处理异步数据流的强大工具,本文通过对比传统方法,深入探讨 Flow 的背压、缓冲及合并策略。背压通过 `buffer` 函数控制生产者和消费者的速率,避免过载;缓冲则允许数据暂存,使消费者按需消费;合并策略如 `merge`、`combine` 和 `zip` 则帮助处理多数据源的整合。通过这些功能,Flow 能更高效地应对复杂数据处理场景。
712 2
|
JSON 前端开发 JavaScript
浅谈JavaScript中的Promise、Async和Await
【10月更文挑战第30天】Promise、Async和Await是JavaScript中强大的异步编程工具,它们各自具有独特的优势和适用场景,开发者可以根据具体的项目需求和代码风格选择合适的方式来处理异步操作,从而编写出更加高效、可读和易于维护的JavaScript代码。
452 1
|
Java API Android开发
19. 【Android教程】进度条 ProgressBar
19. 【Android教程】进度条 ProgressBar
1200 4