一,前言
多年以后,某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宽高,measureSpec 是onMeasure() 的方法参数。
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_SCROLL 和 TOUCH_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
偷一张图
四,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) {
}
}