RecyclerView目前基本上已经替代了ListView,其强大的可定制性和性能上的优化深受开发者的喜爱,这篇文章就不再介绍使用方法,依然是通过带着问题到源码寻找答案,而且更多地通过实践来证实理论。
这次项目做的是游戏列表,每个item都有一个下载进度条,我们知道,recyclerview的viewHolder是复用的,在最开始创建了足够的viewholder,后面在滑动过程中就是复用这些最初create的viewholder。这边就不贴项目效果图了,直接在我练手的项目里再写个demo(下载按钮直通车# DownloadButton),如下
屏幕里有9个item,右侧是继承于progress自定义的下载按钮,假设我们每个item是视频对象,点击下载按钮就是在下载视频,当然这里我就不实际下载视频了,而是通过模拟每秒2%下载,这时候你可能会想到开一个线程,通过sleep来达到这种效果,但是这里有个更强大的Rxjava了解一下,线程灵活切换和强大的操作符完全可以驾驭这等基础操作。写法如下:
Observable.interval(0, 1, TimeUnit.SECONDS) //interval定时器
.subscribeOn(Schedulers.computation())
.filter(new Predicate<Long>() {
@Override
public boolean test(Long aLong) throws Exception {
return aLong < 100; //过滤出100以内的进度
}
})
.map(new Function<Long, Object>() {
@Override
public Object apply(Long aLong) throws Exception {
return aLong.intValue(); //long转int
}
})
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe(new Consumer<Object>() {
@Override
public void accept(Object o) throws Exception {
percent+=2; //每隔1s进度加2%
button.setProgress(percent);
item.setState(Constant.DOWNLOAD_STATE_DOWDLOADING);
item.setProgress(button.getProgress());
}
});
这时候我们随便点一个按钮开启下载,点击第一个下载按钮,进度条走起来,目前一切看起来很尽人意,效果达到预期。这时候往下拉,不愿意看到的发生了,item14也自己开启了下载:
像这种情况如果是你的App用户碰到,他一定觉得你们这是流氓App。下载一个文件,还偷偷送你一个,捆绑销售啊?但是这种情况基本不会流到用户手中,自信何来?因为了解了今天的主题:RecyclerView的复用机制,当你只是用来展示静态数据的时候,RV能够完美地显示成百上千条数据不出差错,但是当你的item出现动态数据的时候,比如进度条,开关等,那么你就必须自己设置好数据,怎么设置好,还是得先了解所谓的复用机制是怎么一回事。
要说复用机制,首先必须知道RecyclerView内部维护的缓存类:Recycler
public final class Recycler {
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<ViewHolder>();
private ArrayList<ViewHolder> mChangedScrap = null;
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
private final List<ViewHolder>
mUnmodifiableAttachedScrap = Collections.unmodifiableList(mAttachedScrap);
private int mViewCacheMax = DEFAULT_CACHE_SIZE;
private RecycledViewPool mRecyclerPool;
private ViewCacheExtension mViewCacheExtension;
private static final int DEFAULT_CACHE_SIZE = 2;
...
}
我们看到了常说的RecyclerView的四级缓存
- 一级缓存:mAttachedScrap
- 二级缓存:mCacheViews
- 三级缓存:mViewCacheExtension
- 四级缓存:mRecyclerPool
简单了解下这四级缓存的概念,mAttachedScrap其实跟item的复用没有关系,它是recyclerview在layout它的child的时候经历了先移除再添加的过程,再添加就是从这个mAttachedScrap去取,白话文就是屏幕内item的复用;mCacheViews只存两个viewHolder;mViewCacheExtension留给开发者扩展的,可以先忽略;mRecyclerPool缓存5个viewholder;那么这池里面缓存的viewholder是怎么被复用到的呢,这当中发生了什么,首先请出第一个当事人---LayoutManager,我们知道RV中itemview的measure、layout就是LayoutManager在负责的,本文暂不研究LayoutManager的工作原理,等后续详细研究了再补充,这边我们只需要知道在LayoutManager里几个重要的方法:
1.fill(recycler, mLayoutState, state, false); //填充itemview
2.layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, LayoutState layoutState, LayoutChunkResult result)
3.View next(RecyclerView.Recycler recycler)
以上三个方法是一级级调用下来的,到next这个方法,我们看到参数RecyclerView.Recycler,非常利索地又回到Recycler这个话题来,这边也就知道了Layout在fill这些itemView是来Recycler来取的,这个后面一一分析,这边先从源头next这个方法入手:
final View view = recycler.getViewForPosition(mCurrentPosition);
精选出以上这行代码,从代码可以初步了解到 通过当前位置获得当前位置的view,先放下,继续跟进getViewForPosition方法,发现进入到了最终的方法,也就是取缓存最最关键的方法块,也就是layoutManager在摆放itemview的时候,就是来这里层层大门拿到需要的viewholder:
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
if (position < 0 || position >= mState.getItemCount()) {
throw new IndexOutOfBoundsException("Invalid item position " + position
+ "(" + position + "). Item count:" + mState.getItemCount()
+ exceptionLabel());
}
boolean fromScrapOrHiddenOrCache = false;
ViewHolder holder = null;
// 0) If there is a changed scrap, try to find from there
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
// 1) Find by position from scrap/hidden list/cache
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
if (holder != null) {
if (!validateViewHolderForOffsetPosition(holder)) {
// recycle holder (and unscrap if relevant) since it can't be used
//当前位置的viewholder是不是当前position的,不是就将viewholder置null
if (!dryRun) {
// we would like to recycle this but need to make sure it is not used by
// animation logic etc.
holder.addFlags(ViewHolder.FLAG_INVALID);
if (holder.isScrap()) {
removeDetachedView(holder.itemView, false);
holder.unScrap();
} else if (holder.wasReturnedFromScrap()) {
holder.clearReturnedFromScrapFlag();
}
//
recycleViewHolderInternal(holder);
}
holder = null;
} else {
fromScrapOrHiddenOrCache = true;
}
}
}
if (holder == null) {
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
if (offsetPosition < 0 || offsetPosition >= mAdapter.getItemCount()) {
throw new IndexOutOfBoundsException("Inconsistency detected. Invalid item "
+ "position " + position + "(offset:" + offsetPosition + ")."
+ "state:" + mState.getItemCount() + exceptionLabel());
}
final int type = mAdapter.getItemViewType(offsetPosition);
// 2) Find from scrap/cache via stable ids, if exists
if (mAdapter.hasStableIds()) {
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
type, dryRun);
if (holder != null) {
// update position
holder.mPosition = offsetPosition;
fromScrapOrHiddenOrCache = true;
}
}
if (holder == null && mViewCacheExtension != null) {
// We are NOT sending the offsetPosition because LayoutManager does not
// know it.
final View view = mViewCacheExtension
.getViewForPositionAndType(this, position, type);
if (view != null) {
holder = getChildViewHolder(view);
if (holder == null) {
throw new IllegalArgumentException("getViewForPositionAndType returned"
+ " a view which does not have a ViewHolder"
+ exceptionLabel());
} else if (holder.shouldIgnore()) {
throw new IllegalArgumentException("getViewForPositionAndType returned"
+ " a view that is ignored. You must call stopIgnoring before"
+ " returning this view." + exceptionLabel());
}
}
}
if (holder == null) { // fallback to pool
if (DEBUG) {
Log.d(TAG, "tryGetViewHolderForPositionByDeadline("
+ position + ") fetching from shared pool");
}
holder = getRecycledViewPool().getRecycledView(type);
if (holder != null) {
holder.resetInternal();
if (FORCE_INVALIDATE_DISPLAY_LIST) {
invalidateDisplayListInt(holder);
}
}
}
if (holder == null) {
long start = getNanoTime();
if (deadlineNs != FOREVER_NS
&& !mRecyclerPool.willCreateInTime(type, start, deadlineNs)) {
// abort - we have a deadline we can't meet
return null;
}
holder = mAdapter.createViewHolder(RecyclerView.this, type);
if (ALLOW_THREAD_GAP_WORK) {
// only bother finding nested RV if prefetching
RecyclerView innerView = findNestedRecyclerView(holder.itemView);
if (innerView != null) {
holder.mNestedRecyclerView = new WeakReference<>(innerView);
}
}
long end = getNanoTime();
mRecyclerPool.factorInCreateTime(type, end - start);
if (DEBUG) {
Log.d(TAG, "tryGetViewHolderForPositionByDeadline created new ViewHolder");
}
}
}
// This is very ugly but the only place we can grab this information
// before the View is rebound and returned to the LayoutManager for post layout ops.
// We don't need this in pre-layout since the VH is not updated by the LM.
if (fromScrapOrHiddenOrCache && !mState.isPreLayout() && holder
.hasAnyOfTheFlags(ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST)) {
holder.setFlags(0, ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST);
if (mState.mRunSimpleAnimations) {
int changeFlags = ItemAnimator
.buildAdapterChangeFlagsForAnimations(holder);
changeFlags |= ItemAnimator.FLAG_APPEARED_IN_PRE_LAYOUT;
final ItemHolderInfo info = mItemAnimator.recordPreLayoutInformation(mState,
holder, changeFlags, holder.getUnmodifiedPayloads());
recordAnimationInfoIfBouncedHiddenView(holder, info);
}
}
boolean bound = false;
if (mState.isPreLayout() && holder.isBound()) {
// do not update unless we absolutely have to.
holder.mPreLayoutPosition = position;
} else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
if (DEBUG && holder.isRemoved()) {
throw new IllegalStateException("Removed holder should be bound and it should"
+ " come here only in pre-layout. Holder: " + holder
+ exceptionLabel());
}
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}
final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
final LayoutParams rvLayoutParams;
if (lp == null) {
rvLayoutParams = (LayoutParams) generateDefaultLayoutParams();
holder.itemView.setLayoutParams(rvLayoutParams);
} else if (!checkLayoutParams(lp)) {
rvLayoutParams = (LayoutParams) generateLayoutParams(lp);
holder.itemView.setLayoutParams(rvLayoutParams);
} else {
rvLayoutParams = (LayoutParams) lp;
}
rvLayoutParams.mViewHolder = holder;
rvLayoutParams.mPendingInvalidate = fromScrapOrHiddenOrCache && bound;
return holder;
}
因为整个方法块都是缓存的精髓所在,没办法剔除无用代码,那就一个个分析下来,首先看到第一个holder判空的地方:
if (holder == null) {
//Returns a view for the position either from attach scrap, hidden children, or cache.
//mAttachedScrap 中寻找 position 一致的 viewHolder
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
}
首先到mAttachedScrap 去寻找holder,mAttachedScrap 什么时候存了holder呢,打听得知是在RecyclerView重新layout的时候,比如Resume了,会将所有chldren的holder移除掉,放到哪里呢,就是放到了这个mAttachedScrap 中;从这个方法中我们也看出,还到hidden和mCached里去找holder了,首先hidden不知道是干啥用的,估计很少场景会使用到这个;然后就是很关键的mCachedView,也就是上文所讲的二级缓存,
// Search in our first-level recycled view cache.
final int cacheSize = mCachedViews.size();
for (int i = 0; i < cacheSize; i++) {
final ViewHolder holder = mCachedViews.get(i);
// invalid view holders may be in cache if adapter has stable ids as they can be
// retrieved via getScrapOrCachedViewForId
if (!holder.isInvalid() && holder.getLayoutPosition() == position) {
if (!dryRun) {
mCachedViews.remove(i);
}
if (DEBUG) {
Log.d(TAG, "getScrapOrHiddenOrCachedHolderForPosition(" + position
+ ") found match in cache: " + holder);
}
return holder;
}
}
遍历mCachedView,找到 position 一致的 ViewHolder;
以上就是第一次寻找holder的过程,主要是通过position来寻找,找不到了就通过id来找,也就是第二次的holder判空方法:
if (holder == null) {
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
final int type = mAdapter.getItemViewType(offsetPosition);
// 2) Find from scrap/cache via stable ids, if exists
if (mAdapter.hasStableIds()) {
//依然是到ScrapView和CachedView中找,通过id
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
type, dryRun);
if (holder != null) {
// update position
holder.mPosition = offsetPosition;
fromScrapOrHiddenOrCache = true;
}
}
这里的id是adapter里的StableId,其实正常也不会走到里面,所以跳过,直接进入下一个主角RecycledViewPool中吧:
if (holder == null) { // fallback to pool
holder = getRecycledViewPool().getRecycledView(type);
if (holder != null) {
holder.resetInternal();
if (FORCE_INVALIDATE_DISPLAY_LIST) {
invalidateDisplayListInt(holder);
}
}
}
这边看到会通过不同的viewType去取holder,如果取到了,就holder.resetInternal()一下,将二手的转成崭新的,也就是adapter里要重新再onBindViewHolder。好了,recyclerview的四级缓存我们已经找过一遍了,这时候如果还是没找到呢,那当然就是创建Holder了,不然还能咋地,所以整个方法的最后就是调用到了adapter的CreateViewHolder:
if (holder == null) {
//创建新的viewholder
holder = mAdapter.createViewHolder(RecyclerView.this, type);
}
以上大篇幅讲的都是holder的取,那recyclerview又是怎么来存这些holder来等待复用的呢?反手又是一波源码亮出来:
void recycleViewHolderInternal(ViewHolder holder) {
...
if (forceRecycle || holder.isRecyclable()) {
if (!holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID | ViewHolder.FLAG_REMOVED
| ViewHolder.FLAG_UPDATE)) {
// Retire oldest cached view
final int cachedViewSize = mCachedViews.size();
if (cachedViewSize == mViewCacheMax && cachedViewSize > 0) {
//mViewCacheMax 默认大小2
recycleCachedViewAt(0);
}
if (cachedViewSize < mViewCacheMax) {
mCachedViews.add(holder);
cached = true;
}
}
if (!cached) {
addViewHolderToRecycledViewPool(holder);
recycled = true;
}
}
...
代码又做了一波精简,映入眼帘的两个熟悉的字眼mCachedViews,RecycledViewPool,没错了这就是我们的缓存器,这边大概的逻辑就是:回收的itemView优先存放到最大容量为2的mCachedViews,如果mCachedViews已经满了,就移除一个出去到RecycledViewPool中去;看到这里,我们也可以猜测,之前将的mAttachedScrap之辈跟复用应该是没半毛钱关系的,整个复用机制的主力军就是这两位mCachedViews和RecycledViewPool;
大篇幅的分析完毕,这时候实战项目的问题还没解决,一顿解说猛如虎,实际操作零杠五,所以根据上面的分析,我们也来理论对号入座;首先一开始,屏幕的9个item经历了一次remove和detach,也就是layoutManager在mAttachedScrap中拿到了view,摆放在了recyclerview可见区域里;这时候点击了下载按钮,这时候缓存池里的兄弟们除了mAttachedScrap都还没操刀干活,只有当屏幕往上滑一小段(还会再create几个viewholder),该复用前面的itemview了,开始找缓存里有没有holder可以复用,所以这边item14很明显是复用到了item 0的holder(可以给itemview打Tag验证),itemview上的text被bind上了新的数字,但是,下载按钮并没有给他新的数据,所以它还是用的item 0的下载状态,这时候,我们就应该主动地去调解复用上的分歧了,直接晒出Adapter里完整的代码(这边使用的时是封装的adapter,convert就是onBindViewHolder):
public class DownloadAdapter extends BaseQuickAdapter<GameBean, DownloadAdapter.SimpleViewHolder> {
private final static String TAG="Adapter";
private CompositeDisposable compositeDisposable;
private Disposable disposable;
private void addDisposable(Disposable disposable){
if (compositeDisposable == null) {
compositeDisposable = new CompositeDisposable();
}
compositeDisposable.add(disposable);
}
private void removeDisposable(Disposable disposable){
if (disposable!=null)
compositeDisposable.remove(disposable);
}
public void removeDisposable(){
if (disposable!=null) {
removeDisposable(disposable);
if (!disposable.isDisposed()) {
disposable.dispose();
}
}
}
public DownloadAdapter(int layoutResId, @Nullable List<GameBean> data) {
super(layoutResId, data);
}
@Override
protected void convert(final SimpleViewHolder helper, final GameBean item) {
helper.setText(R.id.tv,item.getTv());
final DownloadProgressButton button=helper.getView(R.id.btn_download);
//读取下载按钮的状态
if (item.getState()==0){
button.setStartText("下载");
button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_BEGIN);
button.setProgress(0);
} else {
if (item.getState()==Constant.DOWNLOAD_STATE_DEFAULT){
button.setStartText("下载");
button.setProgress(0);
button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_BEGIN);
}else if (item.getState()==Constant.DOWNLOAD_STATE_DOWDLOADING){
button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_DOWNLOADING);
button.setProgress(item.getProgress());
}else if (item.getState()==Constant.DOWNLOAD_STATE_PAUSE){
button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_PAUSE);
button.setProgress(item.getProgress());
}else if (item.getState()==Constant.DOWNLOAD_STATE_FINISH){
button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_FINISH);
button.setProgress(100);
}
}
//每次下载按钮onClick相应时的回调,分别有下载,暂停,完成几种状态
button.setStateChangeListener(new DownloadProgressButton.StateChangeListener() {
@Override
public void onPauseTask() {
button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_PAUSE);
item.setState(Constant.DOWNLOAD_STATE_PAUSE);
item.setProgress(button.getProgress());
if (!disposable.isDisposed()){
disposable.dispose();
}
}
@Override
public void onFinishTask() {
button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_FINISH);
item.setState(Constant.DOWNLOAD_STATE_FINISH);
item.setProgress(100);
}
@Override
public void onLoadingTask() {
disposable= Observable.interval(0, 1, TimeUnit.SECONDS)
.subscribeOn(Schedulers.computation())
.filter(new Predicate<Long>() {
@Override
public boolean test(Long aLong) throws Exception {
return aLong < 100;
}
})
.map(new Function<Long, Object>() {
@Override
public Object apply(Long aLong) throws Exception {
return aLong.intValue();
}
})
.doOnNext(new Consumer<Object>() {
@Override
public void accept(Object o) throws Exception {
}
})
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe(new Consumer<Object>() {
@Override
public void accept(Object o) throws Exception {
percent+=2;
button.setProgress(percent);
item.setState(Constant.DOWNLOAD_STATE_DOWDLOADING);
item.setProgress(button.getProgress());
}
});
addDisposable(disposable);
}
});
}
int percent=0;
public static class SimpleViewHolder extends MyViewHolder{
DownloadProgressButton button;
public SimpleViewHolder(View view) {
super(view);
button=getView(R.id.btn_download);
}
}
}
因为是demo,所以在实际项目开发中这样写法是不科学的,但是原理是一样的,就是每次渲染item的时候,都必须给它设置上数据,这边我模拟给每个itemBean加上两个字段:
public class DownloadBean {
private String tv;
private int state;
private int progress;
public int getProgress() {
return progress;
}
public void setProgress(int progress) {
this.progress = progress;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
public String getTv() {
return tv;
}
public void setTv(String tv) {
this.tv = tv;
}
}
state和progress就是专门给下载按钮加的字段,state是下载的状态:下载中、暂停等等;progress就是下载的进度百分比。
接着就是在你每次点击下载按钮的时候,都需要将状态和进度保存起来
item.setState(Constant.DOWNLOAD_STATE_DOWDLOADING);//不同回调里设置不同状态,详见以上代码
item.setProgress(button.getProgress());
这样,你的onBindViewHolder,在这边是Convert,每次渲染item,就会去从你的bean去读取当前的状态和进度,为每一个下载按钮设置上属于它自己的状态和进度,我们再看下最后的正确效果:
可以看到,不会再出现复用上的差错,每个下载按钮都有自己的状态或下载进度。
-
总结
了解了RecyclerView的复用机制,我们也就很容易找到问题的所在,每个ViewHolder被拿出来复用的时候,除了二级缓存里的mCache的是可以不用重新bind数据,而从RecyclerPool取出来的是光溜溜的,需要重新bind上数据,这时候如果没有给它穿上衣服(bind上数据),它就会穿着之前人的衣服,这就造成了尴尬。
-
最后
这篇断断续续两个星期才写完了,这其中也是边学边总结,还是有理解出错的地方,后续慢慢修正。