2022年的ListView分析

209 阅读7分钟

一,前言

多年以后,某Android程序员坐在电脑前回顾自己的开发生涯,准会想起入门时编写ListView的那个遥远的下午。

ListView已经多年不用了,作为曾经的明星控件,研究一下它是如何工作的还是很有必要的

本文主要分为三个部分:绘制流程,滑动处理,缓存分析。不会贴很多源码细节,配合源码使用效果更佳

二,ListView绘制流程分析

(1)setAdapter()

使用细节就不唠了,直接从setAdapter() 开始,主要做了两件事

(a)重置ListView状态,移除HeaderView,FooterView,清除缓存,移除数据监听等待

(b)调用requestLayout(); 触发view重绘,依次执行,测量,布局,绘制流程

(2)onMeasure

(a)先调用 super.onMeasure() ,使ListView先测量一遍

(b)之后的代码是处理 三种特殊情况:

ListView 宽度的测量模式为:MeasureSpec.*UNSPECIFIED* 的情况, 使用子view的宽度当作ListView宽度

ListView 高度的测量模式为:MeasureSpec.*UNSPECIFIED* 的情况, 使用子view的高度当作ListView高度

ListView 高度的测量模式为:MeasureSpec.AT_MOST 的情况, 遍历子view累加高度,最大高度不能超过ListView的父view给予的最大高度: int heightSize = MeasureSpec.*getSize*(heightMeasureSpec);

(c)总结:比较常规的测量手段,没什么特殊性,把各种情况都考虑了

(d)测量的目的是为了确定view的展示大小,绘制具体内容使用canvas,canvas是无限大的我将它称为画布大小。 展示大小受父容器的影响,也受自身内容的影响。

比如:父view的宽高是100100,如果按照规范来说,子view的宽高最大也只能是100100,但子view的内容实际有200200。 100100是展示大小,200*200是画布大小

让内容全部展示的解决办法:

按比例缩小内容 或 添加滑动。

滑动可以添加到父view上,作为内容的子view在测量时完全按照自身内容大小展示,内容200,宽高也是200。

滑动也可以添加到子view上,子view测量时受父view宽高限制,内容不按比例缩放,正常显示,通过内容滑动查看全部。

(e)测量分两种:viewGroup测量,view测量。

(i)普通view测量,

只需要测量自身,onMeasure() 方法参数的MeasureSpec 可以从中解析出size 和 mode。 根据mode(测量模式不同) size的意义也不同。mode共三种:

mode == MeasureSpec.*AT_MOST* 时,表示view的宽度不能超过 size,size是父view对子view的限制,对应wrap_content 。比如:size是100,子view计算自身内容高度可能是60,80,即便内容计算后超过100,也要赋值为100

mode == MeasureSpec.*EXACTLY*时,表示view的宽度必须是size,此时view不用计算自身内容宽高,直接使用size作为宽高即可。对应match_parent 和 具体数值 如60dp。

mode == MeasureSpec.*UNSPECIFIED* 时,表示父view不限制子view的宽高,子view使用自身测量出的高度即可

view提供了一个静态方法,实现好了上述逻辑 参数 size是自己计算的view宽高,measureSpeconMeasure() 的方法参数。

public static int resolveSize(int size, int measureSpec)

(ii)viewGroup测量

在执行view本身测量逻辑外,还要负责子view的测量。

子view测量实际上是构建子view的MeasureSpec 过程。

子view的MeasureSpec 由当前ViewGroup的MeasureSpec 和子view的LayoutParams构成。

放一个代码片段一看就懂,先判断ViewGroup的测量模式,再判断子view的LayoutParams取值

ViewGroup同样也预先处理好了这段逻辑,提供静态方法 getChildMeasureSpec

public static int getChildMeasureSpec(int spec, int padding, int childDimension){
	//省略代码
	// Parent has imposed an exact size on us
	case MeasureSpec.EXACTLY:
	    if (childDimension >= 0) {
	        resultSize = childDimension;
	        resultMode = MeasureSpec.EXACTLY;
	    } else if (childDimension == LayoutParams.MATCH_PARENT) {
	        // Child wants to be our size. So be it.
	        resultSize = size;
	        resultMode = MeasureSpec.EXACTLY;
	    } else if (childDimension == LayoutParams.WRAP_CONTENT) {
	        // Child wants to determine its own size. It can't be
	        // bigger than us.
	        resultSize = size;
	        resultMode = MeasureSpec.AT_MOST;
	    }
    break;
}

(2)onLayout

对于一般的自定义View,onLayout() 布局方法要相对简单一点,父View对子View进行位置摆放,遍历子view调用layout() 比如这样,之后你就不用管了,系统自行工作。

布局摆放并不麻烦,麻烦的是如何确定坐标,自定义Layout的情况确实比较少,我了解确定坐标的方案有两种。

(a)view布局排布规则不复杂,通过累加计算即可得出正确结果,比如:LinearLayout 竖直排列效果。测量结束后,每个子view获得了宽高,在onLayout()方法中遍历获取子view高度,累加高度即可。

(b)在onMeasure()测量完成后,根据规则计算好每个子view的位置,保存到变量中,在onLayout()中遍历子view,把位置传给他们。

emm 其实区别不大的样子,核心在于测量阶段计算出的数值

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    super.onLayout(changed, l, t, r, b)
    for (child inchildren){
        //传入坐标
        child.layout(l, t, r, b)
    }
}

对于ListView 肯定要更复杂一点,相比于普通的ViewGroup,ListView的子view并不是在inflater 阶段添加到viewGroup中,在布局流程中,结合Adapter动态添加子view。经过measure过程的代码分析,可知没有addView的操作。

所以需要关注两点:子view的添加 和 布局。

listView与其他ViewGroup有点不同,ListView内部可能存在N个子View,其他ViewGroup的子View是有数。

ListView肯定不会一次性把添加所有的view,也不会一次性布局所有的view。如何根据可用空间展示足够数量的子view,处理边界问题也是需要我们关注的。

ListView的布局代码写在layoutChildren() 方法中,代码相比上述例子要复杂的多,原因在于mLayoutMode 字段,布局模式,共六种。通过switch语句执行不同的条件,默认int mLayoutMode = *LAYOUT_NORMAL ,在*default 中编写逻辑

这块逻辑实在有点乱糟糟,参考郭神的博客找到了核心代码 。

(a) fillDown()

主要逻辑,执行while循环,处理边界问题

空间边界int end = (mBottom - mTop); 判断当前ListView的展示高度。在循环获取当前view的Bottom+分割线,作为下一个子view的top。 nextTop = child.getBottom() + mDividerHeight ,只有nextTop < end 循环才会继续执行。

数据边界:这个好理解,判断数组下标是否越剧即可 pos < mItemCount

添加,布局view的逻辑在 makeAndAddView() 执行

private View fillDown(int pos, int nextTop) {
    View selectedView = null;

    int end = (mBottom - mTop);
    if ((mGroupFlags &CLIP_TO_PADDING_MASK) ==CLIP_TO_PADDING_MASK) {
        end -= mListPadding.bottom;
    }

    while (nextTop < end && pos < mItemCount) {
        // is this the selected item?
        boolean selected = pos == mSelectedPosition;
        View child = makeAndAddView(pos, nextTop, true, mListPadding.left, selected);

        nextTop = child.getBottom() + mDividerHeight;
        if (selected) {
            selectedView = child;
        }
        pos++;
    }

    setVisibleRangeHint(mFirstPosition, mFirstPosition + getChildCount() - 1);
    return selectedView;
}

(b)makeAndAddView()

逻辑分为两段,mDataChanged == true 表示数据发生变化,false表示没有发生变化,体现到代码上就是调用 notifyDataSetChanged()mDataChanged == true

在数据没有发生变化的时候,从缓存中获取view。

如果数据发生变化,调用过notifyDataSetChanged()

调用obtainView() 方法,在其内部调用熟悉的 adapter.getView(int position, View convertView, ViewGroup parent) 创建view 。convertView 通过 mRecycler.getScrapView(position) 获取。

setupChild() 执行view的添加,布局,根据条件判断可能还会二次测量。

添加view时有个小知识点需要注意,ListView并没有调用 addView 而是调用 addViewInLayout

两者的差别在于addView内部调用了 requestLayout()invalidate() ,每添加一个view都会执行一次view的绘制流程,对性能影响较大。

大家在自己自定义Layout的时候 ,也可以addViewInLayout 来做性能优化

注意 :mRecycler.getActiveView 于列表滑动时移除的view缓存不同,它是在Layout阶段添加的缓存,取一次就没了,emm 没太搞懂它的作用。

private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
        boolean selected) {
    if (!mDataChanged) {
        // Try to use an existing view for this position.
        final View activeView = mRecycler.getActiveView(position);
        if (activeView != null) {
            // Found it. We're reusing an existing child, so it just needs
            // to be positioned like a scrap view.
            setupChild(activeView, position, y, flow, childrenLeft, selected, true);
            return activeView;
        }
    }

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

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

    return child;
}

(3)onDraw

onDraw() 方法没有可说的,ListView并不负责绘制,分发给子view执行

(4)小结

ListView的绘制流程,赶紧没有什么特殊的操作,实现第一屏数据的展示。大家可以借由分析过程,回顾一下子view的知识,了解ListView对于数据边界问题是如何处理的。

三,滑动分析

内容滑动实现思路,一般都是通过重写onTouchEvent() 在*ACTION_MOVE* 事件调用scrollTo() scrollBy() 实现,通过VelocityTracker 计算速度 实现惯性滑动。

另外,ListView只会展示一屏数据,就算数据有100,1000条 也只会展示一屏数据,不会一次性把所有view全部加载,这点是需要格外关注的。

ListView的onTouchEvent() 在父类 AbsListView 中实现,

(a)startNestedScroll(*SCROLL_AXIS_VERTICAL*); 嵌套滑动的api,看来不知道某个版本ListView也支持的嵌套滑动

(b)initVelocityTrackerIfNotExists(); 初始化VelocityTracker 用于计算速度

(c)final int actionMasked = ev.getActionMasked(); 支持多点触控

(d)通过switch语句,判断MotionEvent 事件的各种情况, ACTION_DOWN ACTION_MOVE 等都有对应的方法实现 onTouchDown(ev); onTouchMove(ev, vtev); 直接查看移动是如何处理的。

(e)onTouchMove() 通过switch语句处理 mTouchMode 又分了好几种情况,根据注释 TOUCH_MODE_SCROLL 表示滑动, 进入scrollIfNeeded() 方法

(f)scrollIfNeeded()

判断两种TouchMode,TOUCH_MODE_SCROLLTOUCH_MODE_OVERSCROLL 有点整不明白它俩的区别了。主要做滑动前的准备工作:

Y坐标判断 (y != mLastY) ; 最小滑动距离判断 Math.*abs*(rawDeltaY) > mTouchSlop ;滑动冲突处理 parent.requestDisallowInterceptTouchEvent(true); 计算滑动距离等;

自己写过滑动的同学看到上述代码应该会非常熟悉,常规套路。

实现滑动逻辑在trackMotionScroll() 方法中。

(g)trackMotionScroll()

主要逻辑:

(i)根据滑动方向 和 ListView的上下边界,判断子view是否被滑出屏幕,如果滑出屏幕则回收到缓存中等待备用。同时移除(detach)滑出屏幕的子view 。 通过 mRecycler.addScrapView(child, position); 添加缓存

(ii)调用 offsetChildrenTopAndBottom(incrementalDeltaY) 进行滑动,内部原理是利用view.layout() 对子view进行重新布局。

(iii)有移除就会有添加,fillGap(down); 参数是 滑动方向,fillGap 是抽象方法,子类实现。在ListView的fillGap() 的方法中调用fillDown()fillUp() 在布局过程已经分析过了,调用 makeAndAddView() ,因为ListView中已经有子view了,缓存中也有数据,这次不会调用obtainView() 创建view, 会从缓存中取view

偷一张图

Untitled.png

四,RecycleBin

经过滑动分析后可知,ListView在子View滑出屏幕时会添加到RecycleBin中缓存起来,即将进入屏幕时又会从RecycleBin中把缓存的子view取出,周而复始,ListView的魔力也在于此,虽然数据可能有成百上千条,但实际创建的view,只有一屏多点。

RecycleBin内部通过集合保存子View,核心属性 和 方法如下:

(a)ListView也是支持多类型的,mViewTypeCount 记录当前listView有几种布局,默认为1。

(b)当只有一种类型时,mCurrentScrap 缓存view,当有多种类型时 mScrapViews 集合数组缓存view,每一种view单独一个缓存List。

(c)getScrapView 取缓存,addScrapView 添加缓存

class RecycleBin {
		private ArrayList<View>[] mScrapViews;

		private int mViewTypeCount;

		private ArrayList<View> mCurrentScrap;

		public void setViewTypeCount(int viewTypeCount) {
            if (viewTypeCount < 1) {
                throw new IllegalArgumentException("Can't have a viewTypeCount < 1");
            }
            //noinspection unchecked
            ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount];
            for (int i = 0; i < viewTypeCount; i++) {
                scrapViews[i] = new ArrayList<View>();
            }
            mViewTypeCount = viewTypeCount;
            mCurrentScrap = scrapViews[0];
            mScrapViews = scrapViews;
     }

		View getScrapView(int position) {

		}

		void addScrapView(View scrap, int position) {

		}
}

五,参考

Android ListView工作原理完全解析,带你从源码的角度彻底理解_guolin的博客-CSDN博客

2.5.1 ListView Item多布局的实现 | 菜鸟教程 (runoob.com)