1.什么是Loop陷阱?
在Compose的开发过程中,经常遇到会使用到循环的地方,但是简单的循环背后竟然隐含着巨大的性能隐患甚至是奇怪的UI问题,下面看看这个案例:
UI非常简单,就是一个初始5行的列表,点击按钮后在头部插入一个元素,由于LaunchedEffect
的特性,我们可以监听某个重组作用域是否生成了,如果你不懂LaunchedEffect
,可以看看笔者的这篇文章:
好的回归正题,启动App之后,得到如下日志:
没什么问题,App刚进来的时候,Compose检测出列表有5项元素,于是生成了5个重组作用域,于是`LaunchedEffect`也有5个、日志打印了5次。
点击一下按钮,让列表的头部插入一个元素,那么会打印什么样的日志呢?请读者自己先思考5秒:
5秒...
4秒...
3秒...
2秒...
1秒...
揭露答案:
答案是最后一个元素重新被打印了,哎哟我去,Compose玩花的是吧,插入的是第0个,倒是最后一个给我打印了,发生什么事了
我埋个小伏笔,抛开这个问题不谈,我们使用布局查看器观察一下重组次数:
又发生什么事了,我们观察到,插入新元素之后,新增了一个组件,除去他是新增的没有计算到重组次数以外,其余的Text
都重组了一次,发生什么事了?更让人疑惑的是,不是插入在开头的元素吗,为什么是最后一个是新增的?
先别急,下面正式进入解惑部分。
2.Loop陷阱的本质:萝卜和坑没绑定
程序员在Compose里面调用forEach的时候,实际上是做了两件事:1.生成N个坑位 2.把数据依次插入坑位。为了降低读者的理解难度,下面使用几张图来解释:
2.1.根据List的长度生成N个坑位
什么是坑位?这是笔者为了方便读者理解生造的词,一个萝卜一个坑嘛,其实这里的坑位就是重组作用域,因为列表有5个元素,因此生成了5个重组作用域,这就是LaunchedEffect
被调用了5次的原因
2.2.把数据依次插入坑位
这个没什么好说的,数据一一填入了自己的坑,一个萝卜一个坑。
2.3.插入新数据,坑爹的地方在此
回想一下我们刚才的步骤,Compose挖了N个坑,然后依次埋萝卜,这有什么问题?这会导致坑位和萝卜不是一一对应的,实际如下图:
好家伙,假如你在地里种了5个萝卜,这时候要在田的开头多种一个萝卜,Compose做了以下的事:
把新的萝卜埋在第一个坑,其余的每一个萝卜都挖起来埋到下一个坑去,最后一个萝卜埋新挖的坑
因此,新挖了一个坑(导致LaunchEffect新调用了一次,而且打印的是最后一个数据),然后全部坑都种了新的萝卜(原来5个坑位发生了重组),这就是开头展示的两个怪现象的本质原因。
那么有没有一种办法,让每个萝卜在种下去之后就不要离开他自己的坑位了,新加入的萝卜才需要重新挖一个坑呢?有的,请往下看。
3.一个萝卜一个坑,新来萝卜别换坑
如何让每个坑位都能认出他的萝卜呢,答案是使用key
这个api,简单把代码改造成如下:
这里使用key
包裹住刚才的代码,key里面的参数使用每个Item的主键
,这样每个坑位就和他的萝卜绑定了,不会再做那种把萝卜1搬到坑2的情况,而是坑1和萝卜1对应起来,为了验证正确,我们点一下按钮后,查看一下日志:
同样是输出了一个日志,但是这个日志对应的是新插入的内容,说明新增的坑位和新增的萝卜是一一对应的,这次不是在最后一个位置新增了坑位,而是在开头新增了坑位,让我们再看看重组的情况:
可以看到,原来的5个Text(即后面5个Text)虽然也进入了重组阶段,但是由于每一个坑位的萝卜没有发生变化,他们都因为智能重组的机制跳过了重组阶段,而第一个Text(即新插入的Text)由于是新建的,没有发生重组,一切都符合我们的预期了,如果你还不懂,看看下面这张图:
简单处理后就比较符合我们的自然逻辑了,Compose默认的行为真的有点反直觉,但是想想也有道理,声明式布局都是自动绑定UI和数据的,如果你不主动声明每一个数据的主键是什么,Compose又如何知道变化后的列表中,哪些数据是新增的,哪些数据是发生了变化的,哪些数据是删除掉了呢,他只能当做整个列表都是变化过的,一视同仁给你全部重组了,这有点类似RecyclerView
的notifyDatasetChanged
,我们享受了声明式布局的组件与数据对象自动绑定的便利,同样要付出相应的心智成本。
4.多谈一点,但不多谈
在Comopse的一些懒加载的组件中,例如常见的LazyColumn
,LazyRow
的items
中,同样存在key这个属性,道理都是一样的,就是给告诉Compose如何识别一个数据源,让列表发生后,自动找出变化了的数据,而不是对列表进行整体的重组,提高效率,具体不展开说了,因为官方文档讲的很详细,需要的朋友可以自行阅读官方文档
总结
Compose的踩坑之路任重而道远,许多奇奇怪怪的现象背后都是简单的原理,搞懂了原理一切疑惑都能得到解答,如本文中提到的关于遍历带来的奇怪现象,其实就是声明式布局对于列表处理的通病,常用的解决方式就是通过声明主键来让组件最大程度优化性能。