细数那些Compose新手容易犯的错误

news2024/11/15 23:19:10

作者:晴天小庭

笔者作为一个日常Jetpack Compose开发者,对Compose的理解也在逐渐加深中,最近回顾当初学习和实践的过程中,犯了不少错误和踩了很多坑,本篇文章作为小总结分享给大家,同时文章会持续更新,也欢迎评论区或者私信给笔者投稿,谈谈你使用Compose过程中踩过的那些坑。

一、ViewModel传递到子可组合项

Jetpack Compose的状态管理是极其重要的一环,当一个可组合项的状态较少时,我们需要使用状态对象来封装状态,而屏幕级的状态对象我们最常用的就是ViewModel

使用ViewModel来管理屏幕级状态时的代码大致如下所示:

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

可以看到ViewModel通过参数的方式直接传递到了MyScreen可组合项中,这样做是没问题的而且非常便利,可组合项可以通过ViewModel直接获取到所需的状态,同时也可以通过ViewModel的方法来访问各种逻辑函数。

正因为这样太便利了,很多Compose新手会直接把ViewModel进一步传递到子可组合项,让子可组合项也能“便利”地访问到状态和逻辑函数,写出这样的代码:

@Composable
fun MyScreen(
    modifier: Modifier = Modifier,
    viewModel: MyScreenViewModel = viewModel(),
    state: MyScreenState = rememberMyScreenState(
        someState = viewModel.uiState.map { it.toSomeState() },
        doSomething = viewModel::doSomething
    ),
    // ...
) {
    /* ... */
    SonComposable(viewModel)
}

@Composable
fun SonComposable(
    viewModel: MyScreenViewModel = viewModel(),
){
    /* ... */
}

🤩哇喔,通过参数将ViewModel传入了子组合项,让子组合项也拥有访问ViewModel的状态和方法的能力,看起来非常完美,代码跑起来也没问题。

但是,这样的方式是错误的,同时也会带来内存泄漏的隐患。

基于官方文档,笔者总结出ViewModel在Compose中的正确方式:

1.ViewModel仅用于最顶层的屏幕级可组合项,即离Activity或者Fragment的setContent{}方法最近的那个可组合项。

2.遵循单一数据源规范,ViewModel将状态传递给子可组合项,子可组合项将事件向上传递给顶层的可组合项,不能将ViewModel直接传递给子可组合项。

注:很久以前官方文档还会提到ViewModel可能会导致子可组合项的内存泄漏,因为ViewModel的生命周期会比子可组合项更长,一些lambda或者匿名方法会导致可组合项被ViewModel持有导致内存泄漏。

我们按照原则(状态下传,事件上传)将代码改造成如下即可:

@Composable
fun MyScreen(
    modifier: Modifier = Modifier,
    viewModel: MyScreenViewModel = viewModel(),
    state: MyScreenState = rememberMyScreenState(
        someState = viewModel.uiState.map { it.toSomeState() },
        doSomething = viewModel::doSomething
    ),
    // ...
) {
    /* ... */
    SonComposable(viewModel.content, onContentChange = {
        viewModel.onContentChange(it)
    })
}
​
@Composable
fun SonComposable(
    content:String,
    onContentChange:(String)->Unit={}
){
    /* ... */
}

二、不恰当的参数导致@Preview不能预览

也许你的一些可组合项会出现无法预览的问题,导致这个问题的原因有很多,大多数都是一个原因导致的:即预览系统遇到了异常

  • 一个常见的错误就是对使用ViewModel的屏幕级可组合项使用@Preview,会出现无法预览的问题,如下:

出现这个问题的原因是预览系统无法正确实例化ViewModel,因为ViewModel的实例化依赖于运行中的android系统,而预览系统实际上是一个阉割版的android系统,它只有和UI相关的代码。

解决方案:

对屏幕级的可组合项抽离出一个只依赖于状态类的的子可组合项,将@Preview下沉到该子可组合项,屏幕级子可组合项不预览。

@Composable
fun MvRankScreen(
    viewModel: MvRankViewModel = viewModel(),
){
    MvRankContent(viewModel.rankState)
}

@Composable
private fun MvRankContent(
    rankState:RankState
){
    /* ... */
}

@Composable
@Preview
private fun PreviewMvRankContent(){
    MvRankContent(remember{RankState()})
}

如上所示,将MvRankScreen的内容抽离出一个MvRankContent出来,然后MvRankContent只使用ViewModel传递下来的状态类,这样只预览MvRankContent,就可以解决ViewModel导致无法预览的问题。

  • 另外一个常见的错误就是使用了项目中的其他类,该类只能android运行时才能获取,也会导致预览系统的崩溃,例如下面的一个类:
object MyClass{
    fun getDesc():String{
        return MyApplication.getInstance().getDesc()
    }
}

该类的方法会从自定义的Application的实例获取一个字符串参数,而这个自定义的Application在预览系统中是不存在的,在Compose中直接使用此类也会导致预览系统的错误。

解决方法:

和一些View依赖于运行时才能获取的状态导致无法预览的问题类似,Compose也提供了一些方法来区分项目实际运行中和预览中的状态,如下所示:

@Composable
fun MyTest(){
    Text(
        text=if(LocalInspectionMode.current) "预览中" else MyClass.getDesc()
    )
}

我们可以通过LocalInspectionMode.current来判断当前Compose是否运行于预览系统中,如果处于预览系统,我们使用固定的字符串,防止了直接访问getDesc()导致Compose预览崩溃。

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

很多刚上手的Compose新手可能会写出这种代码,然后发现Compose没有按照自己预期的方式显示结果,这是没有理解Compose的重组机制导致的,每次重组就是重新执行一遍可组合函数,这会导致函数中的变量被重新声明和创建。

@Composable
fun WrongScreen(){
    var num=0
    Button(onClick = { num++ }) {
        Text("加一")
    }
}

四、预览时不遵循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网络框架的项目,每一个接口都有一个对应的实体类)。

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

如果有对Jetpack Compose掌握的不是很好的小伙伴可以去参考《Jetpack Compose 全家桶笔记》:https://qr18.cn/A0gajp

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/731982.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Leetcode-每日一题【1669.合并两个链表】

题目 给你两个链表 list1 和 list2 &#xff0c;它们包含的元素分别为 n 个和 m 个。 请你将 list1 中下标从 a 到 b 的全部节点都删除&#xff0c;并将list2 接在被删除节点的位置。 下图中蓝色边和节点展示了操作后的结果&#xff1a; 请你返回结果链表的头指针。 示例 1…

Unity游戏C# dll注入

案例游戏下载 首先在网上下载个游戏案例&#xff0c;我就以Captain Molly游戏为例。 这个游戏玩家默认生命值有5点&#xff0c;咱们通过dll注入修改为10点。 dnSpy 我使用dnSpy来查看Unity游戏生成的dll代码&#xff0c;当然你们也可以使用其他工具。 Unity游戏脚本代码最终…

Codeforces-Round-883-Div-3

Codeforces Round 883 (Div. 3) 链接&#xff1a;https://codeforces.com/contest/1846 A. Rudolph and Cut the Rope There are n n n nails driven into the wall, the i i i-th nail is driven a i a_i ai​ meters above the ground, one end of the b i b_i bi​ m…

Pinia: vue的最新状态管理库

Pinia: vue的最新状态管理库&#xff0c;vuex状态管理工具的替代品。 pinia官方文档 注意defineStore()的返回值还是一个方法&#xff0c;所以useCounterStore是一个方法&#xff0c;执行该方法得到一个对象。 getters: 异步action: storeToRefs: 补充 vuex&#xff…

20中文字符识别(matlab程序)

1.简述 随着计算机科学的飞速发展&#xff0c;以图像为主的多媒体信息迅速成为重要的信息传递媒介&#xff0c;在图像中&#xff0c;文字信息(如新闻标题等字幕) 包含了丰富的高层语义信息&#xff0c;提取出这些文字&#xff0c;对于图像高层语义的理解、索引和检索非常有帮助…

Zero-Shot Node Classification

零样本节点分类(Zero-shot node classification) 谱图卷积 图卷积网络 GCN的分解

pandas中比较两个对象相等性 .eq()函数

在使用pandas做数据分析时&#xff0c;往往我们会有这样的数据需求&#xff1a;为某有某一属性的客户打标签。此刻&#xff0c;.eq()函数&#xff0c;就可以实现它自身的价值。 Lets go&#xff0c;一起去探索它的神秘力量吧&#xff01; 先讲讲它的用途&#xff1a;可以用于…

银河麒麟高级服务器系统部署-尚文网络xUP楠哥

进Q群11372462领取专属报名福利! # 什么是银河麒麟 银河麒麟高级服务器操作系统是针对企业级关键业务&#xff0c;适应虚拟化、云计算、大数据、工业互联网时代对主机系统可靠性、安全性、性能、扩展性和实时性等需求&#xff0c;依据CMMI5级标准研制的提供内生本质安全、云原…

Pandas理论与实战(一)

目录 一、Series对象 1.1 认识Series对象 1.2 Series对象的索引 1.3 获取Series的索引和值 二、DataFrame对象 2.1 认识DataFrame对象 2.2 DataFrame重要属性和函数 ​三、导入外部数据 3.1 导入.xls或.xlsx文件 3.2 导入csv文件 3.3 导入.txt文本文件 3.4 导入HTML网页…

Sentinel组件限流降级

官网: home | Sentinel 文档不是很全, 关于nacos的配置中心的使用完全没有 常见的限流算法 静态窗口限流: 即规定1秒内只能固定处理多少请求动态窗口限流: 同样是规定1秒内处理多少请求, 但是统计方式与第一个不同, 比如2.5秒则是统计1.5秒到现在的请求数漏桶限流: 进来可以…

【全栈第三课】通过ChatGPT快速入门NodeJS

前言 往期全栈课程&#xff1a; Vue从入门到精通 微信小程序从入门到精通 Node.js基础 简介 Node.js是什么&#xff1f; Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。Node.js 使用了一个事件驱动、非阻塞式 I/O的模型&#xff0c;使其轻量又高效。Node.js …

迎接高考的倒计时网页(❤️好看好用❤️)HTML+CSS+JS

✨博主&#xff1a;命运之光 &#x1f338;专栏&#xff1a;Python星辰秘典 &#x1f433;专栏&#xff1a;web开发&#xff08;简单好用又好看&#xff09; ❤️专栏&#xff1a;Java经典程序设计 ☀️博主的其他文章&#xff1a;点击进入博主的主页 前言&#xff1a;欢迎踏入…

并发编程_jmm部分

1. JMM 理解 前提&#xff1a;并发编程有3大问题&#xff0c;可见性、有序性、原子性。 导致可见性的原因是缓存&#xff0c;有序性的原因是 编译器优化。解决方法就是直接禁用缓存和编译器优化&#xff0c;导致程序性能堪忧。 因此合理的方案就是按需禁用缓存和编译器优化。 …

MySQL数据库——单表查询练习

一、练习素材 创建表 CREATE TABLE emp (empno int(4) NOT NULL,ename varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,job varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,mgr int(4) NULL DEFAULT NULL,hireda…

遗传算法核心理解,python代码

遗传算法的核心&#xff0c;就在于&#xff0c;把待求的变量转化成二进制串&#xff0c;二进制串就像dna&#xff0c;可以对它的其中某几位进行交换&#xff0c;变异等操作&#xff0c;然后再转换回十进制&#xff0c;带入目标函数&#xff0c;计算适应度&#xff0c;保留适应度…

【lambda函数】lambda()函数

lambda&#xff08;&#xff09; lambda&#xff08;&#xff09;语法捕捉列表mutable lambda 底层原理函数对象与lambda表达式 lambda&#xff08;&#xff09;语法 lambda表达式书写格式&#xff1a; [capture-list] (parameters) mutable -> return-type{ statement }咱…

【数据结构】排序:插入排序与希尔排序详解

本章开始就要分享一些常用的排序方法&#xff0c;我们的日常生活中很多地方都要使用排序&#xff0c;比如电商平台可以按照你的需求进行排序&#xff0c;或者是你想了解大学的综合排名时 我们之前也学到过一些简单的排序比如冒泡排序&#xff0c;虽然他在时间复杂度上可以说是依…

归并排序(思路+代码)

变量&#xff1a; left、right、privot、temp[]、leftIndex、k 思路&#xff1a; 代码&#xff1a; import java.util.Arrays;public class Queue8 {public static void main(String[] args) {int[] arr {8, 9, 1, 7, 2, 3, 5, 4, 6, 0};sort(arr,0,arr.length-1);System.ou…

AST-抽象语法树

js加密解混淆首先想到的是AST语法树&#xff0c;那么什么是AST呢&#xff0c;学习AST过程的一些笔记 1.AST是JS执行的第一步是读取 js 文件中的字符流&#xff0c;然后通过词法分析生成令牌流Tokens&#xff0c;之后再通过语法分析生成 AST&#xff08;Abstract Syntax Tree&a…

3D 旋转木马

在工作中我们常用到3D装换和3D位移 主要知识点 3D位移&#xff1a;transale3d(x,y,z)3D旋转&#xff1a;rotate3d(x,y,z)透视&#xff1a;perspective3D呈现 transfrom-style 1、 transale3d translform: translform:translateX(100px):仅仅是在x轴上移动translform:transl…