乐趣区

关于android:Android性能优化ListView自适应性能问题

作者:京东物流 张振勇

ListView 是 Android 中最罕用的视图之一,应用的频率仅仅次于几大根底布局,尽管因为应用性和扩展性等起因备受争议,且只管起初呈现了 RecyclerView 的代替计划,然而 ListView 依然宽泛地应用在咱们的我的项目中。

自从 ListView 出道至今,曾经不晓得衍生出了多少问题,然而很多人只关怀性能性能的实现,却极少关注 ListView 适度调用导致的性能问题。在理论我的项目中,即便你正确应用了 ViewHolder 机制来优化 ListView 性能,然而在某些场景下仍然会感觉卡顿重大,到底是什么起因导致的呢,咱们来剖析下

1 问题演示

很多时候,咱们在应用 ListView 的时候,都是顺手写上一个 layout\_height=”wrap\_content”或者 layout\_height=”match\_parent”,十分惯例的写法,乍一看,并没有什么问题,尤其是性能实现上也是无可挑剔。
然而,就是 layout\_height=”wrap\_content”这个属性是导致重大的性能问题的本源,上面以一个简略的例子阐明一下:

布局如上,接下来,假如 ListView 一共有 5 项,那么显示逻辑代码如下:

上面,咱们来看看 log 打印的状况:

数一数,一个是 15 次 getView 调用,其中 6 次 convertView 为 null,残余 9 次 convertView 为复用,而 ListView 的数据源真正只有 5 项!

当然,为了场景的简单化,咱们先不思考 ListView 内容超过一屏幕的状况(也就是不思考其复用机制),所以咱们期待的状况应该是 getView 调用 5 次且 convertView 全副为 null,而事实上 getView 多调用了 10 次且有一次 convertView 为 null。

同样的,咱们测试一下当 layout\_height=”match\_parent”的状况:

另外,ListView 内容超过一屏幕的状况下(思考复用机制),测试后果一样,这里就不再演示了。

在理论我的项目中,Adapter 的 getView 办法承载着大量的业务逻辑,在性能方面,除去创立视图的损耗,不正确的 ListView 应用形式导致的性能损耗大概是失常的 3 倍左右!那么到底是什么起因导致的呢?咱们上面来简略剖析下 ListView 源码。

2 ListView 代码剖析

在演示了 layout\_height=”wrap\_content”导致性能问题的景象之后,咱们来从源码的角度剖析下,呈现这种适度调用问题的根本原因。(源码以 API 29 为例)

2.1 onMeasure

首先,layout\_height=”wrap\_content”属性意味着 ListView 的高度须要由子 View 决定,即在 onMeasure 的时候,须要一一测量子 View 的高度,所以咱们先从其 onMeasure 办法动手。

wrap\_content 对应的 mode 为 MeasureSpec.AT\_MOST,所以很容易就能找测量子视图高度的代码 measureHeightOfChildren,当然办法名也体现进去了,所以具体来看这个办法

外围代码如上,很显著,所有的子 View 实例都是由 obtainView 办法返回的,而后再调用具体 measureScrapChild 来具体测量子 View 的高度,失常状况下这里 for 循环的次数就等于所有子项的个数,不过非凡的是已测量的子 View 高度之和大于 maxHeight 就间接 return 出循环了。这种做法其实很好了解,ListView 能显示的最大高度就是屏幕的高度,如果有 1000 个子项,后面 10 项曾经占满了一屏幕了,那前面的 990 项就没必要持续测量高度了,这样能够大大提高性能。

另外,当一个子 View 测量完了之后,会通过 recycleBin 加到复用缓存之中,毕竟这个 View 只是测量了,还没有加到视图树之中,齐全是能够持续复用的。
持续来看 obtainView 办法的实现,源码在 AbsListView 中。

obtainView 办法外面外围的代码其实就两行,首先从复用缓存中取出一个能够复用的 View,而后作为参传入 getView 中,也就是 convertView。

这时咱们梳理一下 measure 过程中调用 getView 的全过程:
A、测量第 0 项的时候,convertView 必定是 null 的,通常须要咱们 Inflate 一个 View 返回;
B、第 0 项测量完结,这个第 0 项的 View 就被退出到复用缓存当中了;
C、开始测量第 1 项,这时因为是有第 0 项的 View 缓存的,所以 getView 的参数 convertView 就是这个第 0 项的 View 缓存,而后反复 B 步骤增加到缓存,只不过这个 View 缓存还是第 0 项的 View;
D、持续测量 3、4、5…项,反复 C。

所以,咱们 log 中的状况是 position=0,convertView=null,而 position 1,2 … convertView 都是同一个对象实例,即被复用第 0 项。

2.2 Layout

当 Measure 过程完结了,上面就要开始 Layout 过程了,因为 onLayout 办法代码较多,咱们间接 pass,来看 makeAndAddView 办法,也就是真真创立 View 的代码。

同样的,子 View 实例都是由 obtainView 办法返回的。这时候就有个小细节了,因为后面 Measure 的时候,第 0 项的 View 曾经创立了并且退出到了复用缓存当中,这一次就能够间接拿进去持续用了。接着创立第 1,2 … 前面项的时候就没复用缓存了,只能一次次地 Inflate。

所以,咱们 log 中的状况是 position=0,convertView 复用第 0 项,而 position 1,2 … convertView=null。

按理说,Layout 之后,应该就不会在调用 getView 办法了,然而咱们显著能看到 log 依然多了 5 次调用,那么这又是怎么回事呢?
后面说到 onMeasure 办法会导致 getView 调用,而一个 View 的 onMeasure 办法调用机会并不是由本身决定,而是由其父视图来决定。
ListView 放在 FrameLayout 和 RelativeLayout 中其 onMeasure 办法的调用次数是齐全不同的。

2.3 小结

因为 onMeasure 办法会屡次被调用,例子中是两次,其实残缺的调用程序是 onMeasure – onLayout – onMeasure – onLayout – onDraw。所以咱们又会看到 5 次调用,和最后面 5 次是截然不同的。
那么,必定有童鞋又要问,既然 onLayout 也被执行两次,那为何不是调用 5 ×2+5×2=20 次呢?
在第 2 次 onLayout 的时候,因为数据并没有变动,即 mDataChanged=false,这时候能够间接用以后项曾经存在的 View 了,不要再通过 getView 办法从新绑定数据,所以 getView 是不须要被调用的。

从下面的剖析中,咱们能够失去 wrap\_content 状况下 getView 被调用的机会和次数,假如 onMeasure(heightMeasureSpec 为 AT\_MOST)次数为 n,onLayout 次数为 m,ListView 控件内同时显示的子项数为 i,那么 getView 次数 =(n + 1)\_i,失常状况 match\_parent 时,getView 次数 = i,多余的 getView 调用次数应该是(n + 1)_i – i = n * i;
由公式能够看出 getView 多余调用次数与 onMeasure 次数 n 以及显示子项数 i 成正比关系。

3 三大根底布局性能比拟

1 层嵌套:
A = FrameLayout
View onMeasure 2 次 onLayout 2 次 onDraw 1 次
A = LinearLayout
View onMeasure 2 次 onLayout 2 次 onDraw 1 次
A = RelativeLayout
View onMeasure 4 次 onLayout 2 次 onDraw 1 次

2 层嵌套:
A = FrameLayout
View onMeasure 2 次 onLayout 2 次 onDraw 1 次
A = LinearLayout
View onMeasure 2 次 onLayout 2 次 onDraw 1 次
A = RelativeLayout
View onMeasure 8 次 onLayout 2 次 onDraw 1 次

3 层嵌套:
A = FrameLayout
View onMeasure 2 次 onLayout 2 次 onDraw 1 次
A = LinearLayout
View onMeasure 2 次 onLayout 2 次 onDraw 1 次
A = RelativeLayout
View onMeasure 16 次 onLayout 2 次 onDraw 1 次

4 层嵌套:
A = FrameLayout
View onMeasure 2 次 onLayout 2 次 onDraw 1 次
A = LinearLayout
View onMeasure 2 次 onLayout 2 次 onDraw 1 次
A = RelativeLayout
View onMeasure 32 次 onLayout 2 次 onDraw 1 次

从下面逻辑能够看出,RelativeLayout 会导致子 View 的 onMeasure 反复调用,假如嵌套层数为 n,子 View 的 onMeasure 次数为 2^(n+1),如果 onMeasure 中做了简单逻辑,将会容易导致卡顿。

另外,如果下面的子 View 是 ListView,且如果高度设置为 wrap_content,恰好一屏幕的 item 个数是 m,那么其 adapter 的 getView 办法调用次数 =(2^n+1)* m。假如 n =4,m=10,getView=170 次!170 次!170 次!(为何会这样,下回合合成,有工夫的能够先去玩下,^-^)

所以,三大布局对子 View 的影响排名应该是:
LinearLayout = FrameLayout >> RelativeLayout

4 常见谬误

4.1 常见谬误 1

比方 4 层嵌套的 RelativeLayout 会使得子 View 的 onMeasure 次数达到 32,其中 heightMeasureSpec 为 AT\_MOST 的次数为 16,所以如果 ListView 同时显示的项数为 10,那么 getView 的次数达到(16+1)\_10=170 次,尽管只有 10 项,然而却相当于一次性加载了 170 项,性能损耗之大可想而知。
能够总结出一个公式:如果 RelativeLayout 嵌套层数为 n,ListView 显示项数为 m,getView 调用次数为(2^n+1)_m

4.2 常见谬误 2

从官网的设计来看,ListView 其实是禁止避免在 ScrollView 等垂直滚动视图中的,但无奈各种各样的业务和设计导致咱们不得不这么做,而后就衍生出了堪称 ListView 历史上最大的坑:NoScrollListView。

NoScrollListview 呈现的次要目标是为了反对 ListView 放在 ScrollView 等垂直滚动视图中,原理很简略,利用后面 ListView 测量原理剖析到的机制,强行设置 AT\_MOST 来测量子 View 高度,也就是强制 ListView 自适应,即便你在 xml 中正确地应用 layout\_height=”match\_parent”,在 Java 代码外面也会强行设置成 wrap\_content,导致的后果就是每一次 onMeasure 都会不停调用 getView。

如果,联合上后面说的 RelativeLayout 嵌套,ListView 的性能损耗还要再翻倍!
假如 ScrollView 中存在 RelativeLayout 外面嵌套 NoScrollListview,RelativeLayout 嵌套层数为 n,那么 onMeasure 的次数为 2^n+2^(n+1) 次,ListView 显示项数为 m,getView 调用次数为(2^n + 2^(n+1) +1)* m 次。如果 n =4,m=10,getView 次数为 490 次

置信看到这里,终于晓得为什么 ScrollView 中嵌有列表的页面会卡出翔了吧!

当然,事件还远远不止这么简略,尤其在某些非凡的场景下,容易导致 onMeasure 频繁调用,以理论我的项目中遇到的问题场景举两个例子。

  1. 有些 ScrollView 具备下拉弹性性能,当手指下拉时会导致子 View 不停 onMeasure,如果子 View 蕴含 NoScrollListview,页面必定一顿一顿的。
  2. 如果你在 getView 中的某些不失当的操作导致 ListView 从新 onMeasure,比方 setVisibility 为 Gone 等,就会造成 onMeasure 和 getView 的互相循环调用,这时候性能耗费十分重大(个别不会 ANR)。
  3. 同样的,某些时候咱们须要监听 ListView 的滚动状态,会应用 setOnScrollListener,因为在 onMeasure 的时候会触发 OnScrollListener 的回调,如果回调外面某些不失当的操作导致 ListView 再次触发 onMeasure 就会导致 OnScrollChangeListener 和 onMeasure 两者的死循环。

5 心得倡议

对于以上几点问题,有如下一些倡议:

  1. 应用 ListView 的时候留神尽量应用 layout\_height=”match\_parent”。
  2. 如果第 1 点无奈防止,须要留神 ListView 的父布局,父布局以上相对不要应用 RelativeLayout,即便应用 FrameLayout 或 LinearLayout 会减少布局层级。
  3. 如果第 1 点无奈防止,须要留神不要在 getView 中应用 setVisibility 这种会触发 ListView 从新 onMeasure 的操作。
  4. 如果 ListView 存在位移,比方下来刷新等,相对要遵循第 1 点来设置 layout\_height=”match\_parent”,不然频繁触发 onMeasure 会导致交互卡顿。
  5. 对于 NoScrollListView,这种布局是严禁应用的,无论是哪种场景,如果 ScrollView 中必须要应用 ListView,能够应用 SimulateListView 控件代替 ListView
退出移动版