Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

   2016-10-17 0
核心提示:ListView是Android中最常用的视图之一,使用的频率仅仅次于三大基础布局,虽然由于使用性和扩展性等原因备受争议,且尽管后来出现了RecyclerView的替代方案,但是ListView仍然广泛地使用在我们的项目中。自从ListView出道至今,已经不知道衍生出了多少问题,

ListView是Android中最常用的视图之一,使用的频率仅仅次于三大基础布局,虽然由于使用性和扩展性等原因备受争议,且尽管后来出现了RecyclerView的替代方案,但是ListView仍然广泛地使用在我们的项目中。

自从ListView出道至今,已经不知道衍生出了多少问题,然而很多人只关心功能功能的实现,却极少关注ListView过度调用导致的性能问题。在实际项目中,即使你正确使用了ViewHolder机制来优化ListView性能,但是在某些场景下依然会感觉卡顿严重,到底是什么原因导致的呢,我们来分析下。

1、问题演示

很多时候,我们在使用ListView的时候,都是随手写上一个layout_height=”wrap_content”或者layout_height=”match_parent”,非常常规的写法,乍一看,并没有什么问题,尤其是功能实现上也是无可挑剔。

然而,就是layout_height=”wrap_content”这个属性是导致严重的性能问题的根源,下面以一个简单的例子说明一下:

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

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

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

下面,我们来看看log打印的情况:

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

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

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

同样的,我们测试一下当layout_height=”match_parent”的情况:

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

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

在实际项目中,Adapter的getView方法承载着大量的业务逻辑,在性能方面,除去创建视图的损耗,不正确的ListView使用方式导致的性能损耗大约是正常的 3倍 左右!那么到底是什么原因导致的呢?我们下面来简单分析下ListView源码。

2、原因分析

在演示了layout_height=”wrap_content”导致性能问题的现象之后,我们来从源码的角度分析下,出现这种过度调用问题的根本原因。(源码以API 23为例)

首先,layout_height=”wrap_content”属性意味着ListView的高度需要由子View决定,即在onMeasure的时候,需要一一测量子View的高度,所以我们先从其onMeasure方法入手。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    ...
    final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    ...
    if (heightMode == MeasureSpec.AT_MOST) {
        // TODO: after first layout we should maybe start at the first visible position, not 0
        heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1);
    }
    ...
}

了解View绘制原理的都知道wrap_content对应的mode为MeasureSpec.AT_MOST,所以很容易就能找打测量子视图高度的代码measureHeightOfChildren,当然方法名也体现出来了,所以具体来看这个方法。

final int measureHeightOfChildren(int widthMeasureSpec, int startPosition, 
    int endPosition, int maxHeight, int disallowPartialChildPosition) {

    ...

    endPosition = (endPosition == NO_POSITION) ? adapter.getCount() - 1 : endPosition;
    int i;
    View child;
    final boolean[] isScrap = new boolean[1];

    ...

    for (i = startPosition; i <= endPosition; ++i) {
        child = obtainView(i, isScrap);
        measureScrapChild(child, i, widthMeasureSpec, maxHeight);

        ...

        recycleBin.addScrapView(child, -1);

        ...

        returnedHeight += child.getMeasuredHeight();
        if (returnedHeight >= maxHeight) {
            return ...;
        }
    }

    ...

}

核心代码如上,很明显,所有的子View实例都是由obtainView方法返回的,然后再调用具体measureScrapChild来具体测量子View的高度,正常情况下这里for循环的次数就等于所有子项的个数,不过特殊的是已测量的子View高度之和大于maxHeight就直接return出循环了。这种做法其实很好理解,ListView能显示的最大高度就是屏幕的高度,如果有1000个子项,前面10项已经占满了一屏幕了,那后面的990项就没必要继续测量高度了,这样可以大大提高性能。

另外,当一个子View测量完了之后,会通过recycleBin加到复用缓存之中,毕竟这个View只是测量了,还没有加到视图树之中,完全是可以继续复用的。

继续来看obtainView方法的实现,源码在AbsListView中。

View obtainView(int position, boolean[] isScrap) {

    ...

    final View scrapView = mRecycler.getScrapView(position);
    final View child = mAdapter.getView(position, scrapView, this);

    ...

}

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。

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

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

当Measure过程结束了,下面就要开始Layout过程了,由于onLayout方法代码较多,我们直接pass,来看makeAndAddView方法,也就是真真创建View的代码。

private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
            boolean selected) {
    ...

    // Make a new view for this position, or convert an unused view if possible
    child = obtainView(position, mIsScrap);

    // This needs to be positioned and measured
    setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);

    ...

}

同样的,子View实例都是由obtainView方法返回的。这时候就有个小细节了,由于前面Measure的时候,第0项的View已经创建了并且加入到了复用缓存当中,这一次就可以直接拿出来继续用了。接着创建第1,2 … 后面项的时候就没复用缓存了,只能一次次地Inflate。

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

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

按理说,Layout之后,应该就不会在调用getView方法了,但是我们明显能看到log仍然多了5次调用,那么这又是怎么回事呢?

前面说到onMeasure方法会导致getView调用,而一个View的onMeasure方法调用时机并不是由自身决定,而是由其父视图来决定。ListView放在FrameLayout和RelativeLayout中其onMeasure方法的调用次数是完全不同的。具体可以参考我之前的一篇博客: http://blog.csdn.net/megatronkings/article/details/52270461

由于onMeasure方法会多次被调用,例子中是两次,其实完整的调用顺序是onMeasure - onLayout - onMeasure - onLayout - onDraw。所以我们又会看到5次调用,和最前面5次是一模一样的。

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

那么,肯定有童鞋又要问,既然onLayout也被执行两次,那为何不是调用5x2+5x2=20次呢?

这就涉及到makeAndAddView方法中一段关键的代码:

private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
            boolean selected) {
    ...

    if (!mDataChanged) {
         // Try to use an existing view for this position
         child = mRecycler.getActiveView(position);
         if (child != null) {
             // Found it -- we're using an existing child
             // This just needs to be positioned
             setupChild(child, position, y, flow, childrenLeft, selected, true);

             return child;
         }
    }

    // Make a new view for this position, or convert an unused view if possible
    child = obtainView(position, mIsScrap);

    // This needs to be positioned and measured
    setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);

    ...

}

在第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成正比关系。如果从关系式看不出来这个值有多恐怖,那么我下面来举个项目里的大坑你就明白了。

坑一:ListView高度为wrap_content放在RelativeLayout中

前面博客 http://blog.csdn.net/megatronkings/article/details/52270461 中分析到,RelativeLayout布局会使得子View的onMeasure周期翻倍调用。

比如4层嵌套的RelativeLayout会使得子View的onMeasure次数达到32,其中heightMeasureSpec为AT_MOST的次数为16,所以如果ListView同时显示的项数为10,那么getView的次数达到(16+1) * 10=170次,虽然只有10项,但是却相当于一次性加载了170项,性能损耗之大可想而知。

下面,我们用实际的测试来验证这个现象。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <RelativeLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content">

                <ListView
                    android:id="@+id/listview_test"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content" />
            </RelativeLayout>
        </RelativeLayout>
    </RelativeLayout>
</RelativeLayout>

上面是4层RelativeLayout嵌套一个ListView,不要说实际项目中不可能出现这种情况,要知道布局嵌套有时其实非常隐蔽,稍不注意就这样了。

为了方便查看日志,我们将ListView的Item数量设置成1项,log如下:

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

虽然onMeasure次数一共是32次,但实际上heightMeasureSpec是一次EXACTLY一次AT_MOST对半,所以导致getView调用的onMeasure次数为16次,加上onLayout的1次getView,总共恰好是17次,如日志所示。

可以总结出一个公式:如果RelativeLayout嵌套层数为n,ListView显示项数为m,getView调用次数为(2^n+1)* m

坑二:ListView放在ScrollView等垂直滚动视图中

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

NoScrollListView是什么呢?来看一段代码你就明白了,估计绝大数多项目中都有。

public class NoScrollListview extends ListView {  

    ...

    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
         int expandSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);  
         super.onMeasure(widthMeasureSpec, expandSpec);  

    }    

    ...

}

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次!

Android应用性能优化系列视图篇——ListView自适应导致的严重性能问题

相信看到这里,终于知道为什么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两者的死循环。

3、几点建议

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

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 https://github.com/MegatronKing/SimulateListView

6、由于GridView的measure机制和ListView有些差别,虽然同样会有性能损耗但不大,不过还是建议开发者遵循以上几点!

本博客不定期持续更新,欢迎关注和交流:

http://blog.csdn.net/megatronkings

 
反对 0举报 0 评论 0
 

免责声明:本文仅代表作者个人观点,与乐学笔记(本网)无关。其原创性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容、文字的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。
    本网站有部分内容均转载自其它媒体,转载目的在于传递更多信息,并不代表本网赞同其观点和对其真实性负责,若因作品内容、知识产权、版权和其他问题,请及时提供相关证明等材料并与我们留言联系,本网站将在规定时间内给予删除等相关处理.

  • Android 自定义ListView adapter(zt)
    Android 自定义ListView adapter(zt)
    本文讲实现一个自定义列表的Android程序,程序将实现一个使用自定义的适配器(Adapter)绑定数据,通过contextView.setTag绑定数据有按钮的ListView。系统显示列表(ListView)时,首先会实例化一个适配器,本文将实例化一个自定义的适配器。实现自定义适配器
    12-01 ListView
  • Android ListView 优化之 getView 与 ViewHolder 是如何工作的?
    Android ListView 优化之 getView 与 ViewHolde
    Android中我们经常会用到ListView,然后ListView到底是如何通过ViewHolder去优化的?1.常见的适配器中利用ViewHolder去优化ListView的代码@Overridepublic View getView(int position, View convertView, ViewGroup parent) {ViewHolder viewHolder;if (conve
    11-07 ListView
  • listviewd优化---viewHolder的封装
    listviewd优化---viewHolder的封装
    android项目中如果使用listview控件,则在优化上我们一般使用viewHolder保证列表项的布局convertView可以被重用避免多次重新绘制。 一般方法中getView的写法@Overridepublic View getView(int position, View convertView, ViewGroup parent) {ViewHolder vie
    11-07 安卓开发
  • Android ListView优化之局部刷新(更新)(非notifyDataSetChanged)
    Android ListView优化之局部刷新(更新)(非no
    在Android开发中我们经常会用到listview的数据和界面刷新动作,我们每次可能会用到的都是Adapter.notifyDataSetChanged()方法。这个方法的原理是利用观察者模式对我们的数据源进行监听,当我们的数据源发生变化的时候,会调用Adapter的getView()方法进行整个
    11-04 ListView
  • 基础篇章:关于 React Native 之 ListView 组件的讲解
    基础篇章:关于 React Native 之 ListView 组件
    我们讲完ScrollView组件,其实顺其自然的就应该讲解ListView,对于前段和移动端的开发人员应该非常熟悉这样的控件吧,具体是做什么的,我感觉不用我讲了吧。我们来看看它怎么使用吧。大家好,我是ListView,我是React Native大家族中基础组件中,一个核心组件
  • Android ListView 使用不同对象加载不同布局
    Android ListView 使用不同对象加载不同布局
    因为未知原因,突然想到了关于一个 List 集合里面能否添加不同对象的问题,因为我们平时开发过程中,关于List 的比较常规的写法就是:ListXXX list = new ArrayListXXX();这让我形成了一种 List 里面就只能添加一种类型的对象的潜在想法(或许是 Java 基础不
    10-31 ListView
  • android中listview的一些样式设置
    在 Android中,ListView是最常用的一个控件,在做UI设计的时候,很多人希望能够改变一下它的背景,使他能够符合整体的UI设计,改变背景背很简单只需要准备一张图片然后指定属性 android:background=”@drawable/bg”,不过不要高兴地太早,当你这么做以后,发
    10-13 ListView
  • React Native填坑之旅--ListView篇
    列表显示数据,基本什么应用都是必须。笔者写作的时候RN版本是0.34。今天就来从浅到深的看看React Native的ListView怎么使用。首先是使用写死的数据,之后会使用网络请求的数据在界面中显示。最后加上一个ActivityIndicator,网络请求的过程中显示Loading图标
  • RecyclerView、ListView 实现单选列表的优雅之路.
    RecyclerView、ListView 实现单选列表的优雅之
    一 概述: 这篇文章需求来源还是比较简单的,但做的 优雅 仍有值得挖掘的地方。 需求来源:一个类似饿了么这种 电商优惠券的选择界面 : 其实就是 一个普通的列表,实现了 单选 功能,效果如图:(不要怪图渣了,我撸了四五遍,公司录出来的GIF就这么渣。。。
  • [原]判断Listview滑动到了最底部(且最后一个ite
    记录下代码:listView.setOnScrollListener(new AbsListView.OnScrollListener() {@Overridepublic void onScrollStateChanged(AbsListView view, int scrollState) {}@Overridepublic void onScroll(AbsListView view, int firstVisibleItem, int visibleIte
    09-14 ListView
点击排行