时间过得飞快,距离上次写博客又过了一个半月,想了想不该偷懒下去了,然后就把前段时间写的一个 LayoutManager 拿出来写一下。RecycleView 相信大家一定十分熟悉了,相比 ListView 它具备更多级的缓存以及定向刷新的特性,除此之外也就是本文要说的自定义 LayoutManager 也是十分的靓仔,和传统使用 ViewGroup 布局相比,它增加了回收和复用子 Item 的能力,用起来是真香。
一般在做通讯录的时候,我们会为用户分组,从而绘制的时候也需要绘制各组的头部,如果在使用 RecycleView 来实现这种效果的时候,通常我们会使用 ItemDecoration ,ItemDecoration 在测量时为每个(或指定的)子 View 的 LayoutParams 设置四边的偏移量,然后 LayoutManager 在使用 layoutDecoratedWithMargins() 时会根据相应的偏移量来布局 childView ,我们再根据 childView 之间的留白在 ItemDecoration#onDraw() 时来绘制我们的分组头部,在 ItemDecoration#onDrawOver() 时绘制被移出屏幕的最近一个头部,最终达到我们要的效果。不过,如果我们想要这个头部支持点击怎么办?ItemDecoration 顾名思义就是为 Item 添加装饰物,是不支持点击交互的,那么就需要我们另外想法子了。我的方法是自定义一个 LayoutManager。
先见效果图:
先声明以下 LLM 为 LinearLayoutManager 的简写),FLM 为本文自定义LayoutManger的简称。
自定义 LayoutManager 第一步,返回自定义的 LayoutParams
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
这里我们要做的布局类似于线性布局,所以采用宽高都有view自己决定的测量参数,如果是网格布局或者是交错式网格布局,则会根据方向进行调整。
然后就是第二步,重写 onLayoutChildren 方法,我们先来看一看 LLM 在这里是怎么写的,代码很长,所以简略掉其中各种判断,我们只贴其中的关键行:
/**
* {@inheritDoc}
*/
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
......
// 不需要布局,移除并回收所有的view
if (mPendingSavedState != null || mPendingScrollPosition != RecyclerView.NO_POSITION) {
if (state.getItemCount() == 0) {
removeAndRecycleAllViews(recycler);
return;
}
}
......
// 收集计算锚点信息
......
// 预布局动画相关代码
......
// 遍历所有的 child 根据其 ViewHolder 的不同状态将 ViewVolder 添加到不同的缓存集合
detachAndScrapAttachedViews(recycler);
......
// 根据布局方向填充
if (mAnchorInfo.mLayoutFromEnd) {
// fill towards start
......
fill(recycler, mLayoutState, state, false);
......
} else {
// 另外一个方向
......
}
// 修正偏差,LinearLayoutManager内部的scrap缓存池未移除的view进行布局(应该是与动画相关)
......
}
然后是 fill() 方法里面的关键行:
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable) {
......
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
......
// 这里进行单个view进行布局
layoutChunk(recycler, state, layoutState, layoutChunkResult);
......
}
......
return start - layoutState.mAvailable;
}
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
LayoutState layoutState, LayoutChunkResult result) {
View view = layoutState.next(recycler);
......
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
if (layoutState.mScrapList == null) {
if (mShouldReverseLayout == (layoutState.mLayoutDirection
== LayoutState.LAYOUT_START)) {
addView(view);
} else {
addView(view, 0);
}
} else {
if (mShouldReverseLayout == (layoutState.mLayoutDirection
== LayoutState.LAYOUT_START)) {
addDisappearingView(view);
} else {
addDisappearingView(view, 0);
}
}
measureChildWithMargins(view, 0, 0);
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
int left, top, right, bottom;
if (mOrientation == VERTICAL) {
if (isLayoutRTL()) {
right = getWidth() - getPaddingRight();
left = right - mOrientationHelper.getDecoratedMeasurementInOther(view);
} else {
left = getPaddingLeft();
right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
}
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
bottom = layoutState.mOffset;
top = layoutState.mOffset - result.mConsumed;
} else {
top = layoutState.mOffset;
bottom = layoutState.mOffset + result.mConsumed;
}
} else {
top = getPaddingTop();
bottom = top + mOrientationHelper.getDecoratedMeasurementInOther(view);
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
right = layoutState.mOffset;
left = layoutState.mOffset - result.mConsumed;
} else {
left = layoutState.mOffset;
right = layoutState.mOffset + result.mConsumed;
}
}
// We calculate everything with View's bounding box (which includes decor and margins)
// To calculate correct layout position, we subtract margins.
layoutDecoratedWithMargins(view, left, top, right, bottom);
if (DEBUG) {
Log.d(TAG, "laid out child at position " + getPosition(view) + ", with l:"
+ (left + params.leftMargin) + ", t:" + (top + params.topMargin) + ", r:"
+ (right - params.rightMargin) + ", b:" + (bottom - params.bottomMargin));
}
// Consume the available space if the view is not removed OR changed
if (params.isItemRemoved() || params.isItemChanged()) {
result.mIgnoreConsumed = true;
}
result.mFocusable = view.hasFocusable();
}
当布局存在剩余空间,并且当前 position 小于最大布局个数时,对当前 position 进行布局,然后
View view = layoutState.next(recycler);
这句代码很关键了,这里是 LLM 获取每一行 View 的实现,首先从内部自己的废料池 scrapList 获取缓存的View,如果找不到再从 Recycle 获取相应 position 的 view:
recycler.getViewForPosition(mCurrentPosition);
这是 RecyclerView 内部的缓存复用实现,依照我自己的理解,
1.如果是预布局的话回收器首先去检查 mChangedScrap 缓存池,如果该缓存池存在同一个 position 的 vh 则返回此 vh 若找不到,假使 Adapter 设置 hasStableIds 为 true ,则再判断 vh 的 itemId 是否相等,若相等,则返回该 vh 此等级返回的vh将加上来源标志位;
2.如果第一步返回的 vh 为空,则回收器再从 mAttachedScrap 缓存池查找缓存,若该缓存池有 vh 满足条件,则返回此 vh,若无再从一级缓存 mCacheViews 是否有对应的ViewHolder,如果有并且可用,那么返回该ViewHolder;
3.第2步返回的 vh 仍然是空(或者不可用被及时回收了)的话,如果 RecyclerView 设置了 mViewCacheExtension (扩展缓存池),将从该扩展缓存池获取 vh;
4.如果第3步返回的 vh 是空的,回收器将根据 itemType 从 recyclerPool 回收池中获取 vh,从回收池中获取的 vh 对象将重置数据(需要重新绑定数据);
5.如果以上4步都没有获取到 vh 的话,就轮到我们的 adapter 出场了,耳熟能详的 adapter.onCreateViewHolder 将为 RecyclerView 创建一个全新的 vh.
6.获取到的 vh 在返回前需要检查是否需要重新绑定数据即 adapter.onBindViewHolder(),根据Flag标志位 未绑定 或 需要刷新数据 或 已经被标记无效 来判定。
通常我们说的 RecyclerView 有四级缓存也就是 mChangedScrap,mAttachedScrap,mCacheViews,recyclerPool 这四个对象。
好了,分析完 LLM 的布局,我们也找出了一些关键代码,接下来就是实战了。
1.返回自定义的 LayoutParams ,同 LLM,略过;
2.按照 LLM 的写法:
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (state.getItemCount() == 0) {
// 空布局,回收所有item
removeAndRecycleAllViews(recycler);
return;
}
//暂时移除所有的item
detachAndScrapAttachedViews(recycler);
//真正的布局
relayout(recycler, state);
}
在不考虑回收复用的情况下,以线性布局的布局方式来布局,我们需要先遍历获取每一个 item,然后测量 view 的宽高,最后layout,贴一下代码:
int mTotalHeight;
private void relayout(RecyclerView.Recycler recycler, RecyclerView.State state) {
int itemCount = state.getItemCount();
mTotalHeight = 0;
int paddingLeft = getPaddingLeft();
int paddingTop = getPaddingTop();
for (int i = 0; i < itemCount; i++) {
View view = recycler.getViewForPosition(i);
addView(view);
measureChildWithMargins(view, 0, 0);
int width = getDecoratedMeasuredWidth(view);
int height = getDecoratedMeasuredHeight(view);
layoutDecoratedWithMargins(view, paddingLeft, mTotalHeight + paddingTop, paddingLeft + width, mTotalHeight + paddingTop + height);
mTotalHeight += height;
}
}
这样我们就完成了一个线性布局,但是它还不支持滚动,我们需要重写两个方法:
@Override
public boolean canScrollVertically() {
return true;
}
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//回收所有的vh
detachAndScrapAttachedViews(recycler);
int lastHeight = mTotalHeight - mExtentHeight;
int consume = dy;
//边界判断,使最终的垂直滚动距离不小于0也不大于内容高度减去可见高度
//这里可见内容高度我们暂时定为getHeight() - getPaddingTop() - getPaddingBottom()
if (verticallyScrollOffset + dy < 0) {
//上边界超出
consume = -verticallyScrollOffset;
} else if (verticallyScrollOffset + dy > lastHeight && mTotalHeight >= mExtentHeight) {
//下边界超出
consume = lastHeight - verticallyScrollOffset;
}
//计算垂直滚动距离
verticallyScrollOffset += consume;
//关键-使每个itemView 偏移指定距离
offsetChildrenVertical(-consume);
//重新布局
relayout(recycler, state);
return consume;
}
然后对relayout进行修改:layoutDecoratedWithMargins(view, paddingLeft, mTotalHeight + paddingTop +verticallyScrollOffset , paddingLeft + width, mTotalHeight + paddingTop + height + verticallyScrollOffset );这样才能正确布局到指定位置。
到此我们已经完成了线性布局且支持垂直有边界的滑动,但是每次 relayout 的时候我们都布局了所有的view,那岂不是意味有多少个 itemCount 就有多少个 viewholder ,那 recyclerview 的复用功能岂不是没有用到?咋整?事实上我们每次只需要布局可见部分的那几个itemView就可以了,所以我先创建一个集合维护每个 position 的测量高度,然后根据垂直滚动距离计算第一个可见的 position ,然后从第一个可见 item ,根据可见内容高度填充view,直到最后一个可见的 position,见代码:
private SparseArray viewInfoArray = new SparseArray<>();
private SparseIntArray itemHeightArray = new SparseIntArray();
private int firstVisibleItemPosition = 0;
private int lastVisibleItemPosition = 0;
private void relayout(RecyclerView.Recycler recycler, RecyclerView.State state) {
int itemCount = state.getItemCount();
int top = 0;
int paddingLeft = getPaddingLeft();
int paddingTop = getPaddingTop();
int position = 0;
//循环计算第一个可见的position 以及该 position 的top
while (itemHeightArray.size() != 0 && position < itemCount - 1) {
int height = itemHeightArray.get(position);
top += height;
position++;
if (top - verticallyScrollOffset >= 0) {
top -= height;
position--;
break;
}
}
firstVisibleItemPosition = position;
int visibleHeight = getHeight() - getPaddingTop() - getPaddingBottom();
//循环计算并收集可见 item 的布局信息
while (true) {
View view = recycler.getViewForPosition(position);
measureChildWithMargins(view, 0, 0);
int width = getDecoratedMeasuredWidth(view);
int height = getDecoratedMeasuredHeight(view);
//这里维护每个position 对应的高度
itemHeightArray.put(position, height);
ViewInfo viewInfo = viewInfoArray.get(position);
if (viewInfo == null) {
viewInfo = new ViewInfo();
}
int t = top + paddingTop - verticallyScrollOffset;
viewInfo.rect.set(paddingLeft, t, paddingLeft + width, t + height);
viewInfo.position = position;
viewInfo.view = view;
viewInfo.measureTop = t;
viewInfoArray.put(position, viewInfo);
top += height;
if (top > visibleHeight + verticallyScrollOffset || position == itemCount) {
position--;
break;
}
}
lastVisibleItemPosition = position;
//遍历可见position 根据收集的布局信息进行布局
for (int i = firstVisibleItemPosition; i <= lastVisibleItemPosition; i++) {
ViewInfo viewInfo = viewInfoArray.get(i);
//TODO layout visible item
}
}
到这一步我们就可以发现列表滑动十分丝滑了,BUT:
01/06 补充:
使用场景:adapter 数量都为100个,item 行高一致(50dp),可见数目11~13个,
LLM 静态展示12个 Item,缓慢滚动时新创建ViewHolder 4个,快速滚动创建1个 ViewHolder,最多17个;
FLM 静态展示12个 Item,缓慢滚动时新创建ViewHolder 4个,快速滚动时偶尔创建 n 个 ViewHolder,最多无上限;
问题出现了,讲道理,FLM 单纯拿来使用已经没有什么问题,回收和复用利用起来了,滚动也十分流畅,但是它和 LLM 的差距在哪里,为什么快速滚动的时候 FLM 还会再去创建 ViewHolder 呢?阅读 LLM 的 scrollB() 之后我发现了第一个问题:
1.LLM 这里它并没有调用 detachAndScrapAttachedViews ,而是根据滑动的距离,移除不可见的之后,再填充空白的区域,这样 LLM 每次只布局了 空白处的 item,然后再 offset 其余不需要再次布局的 item,相对于我们的 FLM 来说它减少了布局次数,速度那肯定更快了。
2.根据前面的问题,我改动了代码
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//根据边界计算滑动距离
//......
//移除不可见item
recycleUnVisibleItem(recycler);
//填充空白
fillBlankSpace(recycler,state);
//偏移可见的item
offsetChildren(dy);
return dy;
}
但这并不是 FLM 快速滑动时重新创建ViewHolder的原因,继续阅读源码后我又发现了问题, LLM 是在 layoutChunk 中先回收超出屏幕的无效 Item,然后依次从最后可见的 position 填充 Item,然后再检查该复用的 Item 是否需要回收,Why?为什么layout之后又需要回收呢?假设快速滚动单次距离超过了一屏,这时上一次最后可见的 Item 已经在屏幕外了,依次填充下一个Item时,这个 Item 也有可能也是不可见的,所以 LLM 在对这个 Item 布局之后又检测了是否需要回收。
3.根据以上问题,再次修改代码,这次改动的地方在填充 Item 的地方,每次填充后判断该 Item 是否超出屏幕范围:
private void fillBlankSpace(RecyclerView.Recycler recycler){
//计算空白区域需要布局的Item范围
......
for(...){
layoutItem(recycler,i);
}
}
private void layoutItem(RecyclerView.Recycler recycler,int position){
ViewInfo viewInfo = viewInfoArray.get(position);
//布局
......
if(viewInfo.rect.top >= getHeight() || viewInfo.rect.bottom <= 0){
removeAndRecycleView(viewInfo.view, recycler);
}
}
因为本例子单页可见 Item 数量约12个,按照单次滚动距离超一屏的情况,我们每次填充的个数可能超过12个达到14个或15个,加上以上代码之后我们可以保证单次 layout 的个数不超过可见数量了,但是,它还是出现了,onCreateViewHolder 还是被调用了,于是我在回收不可见 Item 的代码之后加上了日志,检测各级缓存的大小,终于找到了问题根源,RecyclerViewPool 缓存池单个 viewType 最大缓存个数默认值为5,而快速滚动时单次回收 view 的个数可能为全部的 View,导致 RecyclerViewPool 缓存溢出,该次回收的 Viewholder 被丢弃了。要解决这个问题,其实设置 RecyclerViewPool 的最大个数也能解决问题,但是精益求精嘛,我们看看 LLM 是怎么做,再次阅读源码,嚼了嚼,emm,LLM 应该是在每移除1个不可见的Item 时就往空白区域布局一个回收池里的 Item,这样缓存池可以做到一边进一边出的平缓回收复用,而像 FLM 这样每次回收好几个甚至所有的 Item 然后再从缓存池取出大量的 Item,就可能会导致缓存不够用进而再次创建 ViewHolder的情况,显得缓存抖动十分严重。
4.总算发现了问题的根源,所以再次修改代码:
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//根据边界计算滑动距离
//......
while(true){
//移除单个不可见item
boolean remove = recycleUnVisibleItem(recycler);
//填充空白处单个item
boolean fill = fillBlankSpace(recycler,state);
//没有可移除或者没有可添加的Item时跳出循环
if(!remove && !fill){
break;
}
}
//偏移可见的item
offsetChildren(dy);
return dy;
}
现在我们再看一下效果,录屏的手机尺寸比较大,所以可见个数为13个:
可以看到滑动丝滑并且复用到了极致,媲美官方的 LinearLayoutManager,当然在方向以及动画等方面是不如 LLM 了。
好了,回到本主题,打造悬浮吸顶的 LayoutManager 我们继续下一步,悬浮 item,比如我们想要让 position = 1 的 view 作为悬浮头部,那么我们在 layout 时需要做如下判断:
1.如果第一个可见的 position 小于指定悬浮 position 那么不需要做悬浮特殊处理,它还是依然处于它原来的位置;
2.如果第一个可见正好是指定悬浮头部,那么需要对该头部的 top 重新计算使其能贴合头部边缘完整展示,然后因为 view 层级关系,必须在最后 addView(),使其位于其他 view 上层;
3.如果第一个可见 position 大于悬浮头部 position ,那么需要补充测量悬浮头部,因为根据之前的测量逻辑,可见的 position 是不包含悬浮 position 的,测量完毕后,同理,需要在最后 addView();
按照之前的写法,我们在滑动的回调里面执行和 onLayoutChildren 类似的逻辑我们就能达到悬浮吸顶的效果了,但是,我们要有所追求,在达到平缓使用缓存池的前提下,完成悬浮吸顶才是我们的目标~~ GO ON~~
写在最后:
快过年放假了,这几天事情也比较多,所以现在才来把后面的补充上,经过实践已经完成了平缓使用缓存池的代码,不过仍然存在相关问题:不支持定向刷新,移除和新增,只支持 notifyDataSetChanged;由于缓存的Item一直处于可见,本次自定义 LM 对悬浮的 Item 再次进行缓存,所以悬浮的 Item 第一次数据绑定之后不再走onBingViewHolder 除非重新设置数据集合,建议对悬浮 Item 的 itemType 进行区别返回,避免和复用的 itemType 重复。悬浮的 Item 数量多的情况下,若顶部没有复用底部回收的 Item (悬浮Item占用了定不可见区域),也可能导致缓存池溢出。
适用场景:数据集变化少,需要悬停某一个 Item 时。源码见 GitHub。