Android—RecyclerView进阶(4)—复用机制及性能优化

我的CSDN: ListerCi
我的简书: 东方未曦

一、RecyclerView基本结构

RecyclerView的运行主要依赖于Adapter、LayoutManager和Recycler这三个类,其中Adapter负责与数据集交互,LayoutManager负责ItemView的布局,Recycler负责管理ViewHolder,其结构如下图。

RecyclerView结构.png

得益于RecyclerView设计时的解耦,ItemView的创建、绑定和复用对LayoutManager来说都是不可见的,LayoutManager只需要关心如何布局ItemView即可。当LayoutManager要布局数据集中的第i个Item时,它通过recycler.getViewForPosition(i)获取该ItemView,此时Recycler会先查找缓存中是否存在该ItemView,如果不存在就调用Adapter的onCreateViewHolder(...)新建一个。

而Recycler中缓存的ViewHolder也是LayoutManager放进去的,那LayoutManager什么时候将ItemView放入缓存中呢?主要分为两种情况。
① 数据集发生变化。此时LayoutManager调用detachAndScrapAttachedViews()回收当前屏幕上的所有ItemView,再根据新的数据进行布局。
② ItemView滑出可视区域。此时LayoutManager会将该ItemView放入Recycler的缓存中。缓存为FIFO结构,当有新的ItemView被放入缓存时,旧的ItemView会被移出。

二、回收复用机制原理

Recycler中有多个缓存池,其定义如下。

// mAttachedScrap在重新layout时使用
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
// mChangedScrap用于动画
ArrayList<ViewHolder> mChangedScrap = null;
// mCachedViews和RecycledViewPool用于滑动时的缓存
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
RecycledViewPool mRecyclerPool;
// 用户自定义缓存,一般不用
private ViewCacheExtension mViewCacheExtension;

当ItemView因为不同的原因被回收时,它们也会进入不同的缓存池,最常见的场景就是数据集发生变化或Item滑出可视区域,下面根据Item回收的场景来看各个缓存池的使用。

场景1—数据集发生变化

mAttachedScrap被称为一级缓存,在重新layout时使用,主要是数据集发生变化的场景。被mAttachedScrap缓存的ItemView大部分会马上得到复用。当LayoutManager通过recycler.getViewForPosition(i)寻找ItemView时会优先去mAttachedScrap中查找。

当数据集发生变化时,LayoutManager的onLayoutChildren(...)方法会被调用,该方法先通过detachAndScrapAttachedViews(Recycler recycler)将当前屏幕上的所有ItemView缓存至mAttachedScrap,之后再重新布局。

举个栗子,假设初始有5个Item,remove掉Data1,此时数据集发生了变化,需要重新布局。LayoutManager的onLayoutChildren(...)方法被调用,初始的5个ItemView都被添加到了mAttachedScrap中,随后重新布局时,有4个ItemView得到了复用。

数据集变化示例.png

之前提到,Recycler以ItemView在数据集中的position作为唯一定位,当需要展示数据集中第i项时,LayoutManager通过recycler.getViewForPosition(i)获取对应的ItemView。来看一下Recycler在mAttachedScrap中查找时,是怎么判断缓存中的ItemView是否就是当前所需要的。

    final int scrapCount = mAttachedScrap.size();
    for (int i = 0; i < scrapCount; i++) {
        final ViewHolder holder = mAttachedScrap.get(i);
        if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
                && !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
            holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
            return holder;
        }
    }

主要的判断条件就是holder.getLayoutPosition() == position,用于判断ViewHolder Layout时的位置是否与数据集中的position相等。ViewHolder的getLayoutPosition()方法如下。

    public final int getLayoutPosition() {
        return mPreLayoutPosition == NO_POSITION ? mPosition : mPreLayoutPosition;
    }

这个有个要注意的地方:数据集发生变化后,ViewHolder原本的LayoutPosition很有可能过期,就像上面例子中,Remove掉Data1后,Data2的LayoutPosition应该从2变为1,所以我们断定,RecyclerView一定在某个地方对ViewHolder的位置信息进行了更新,我们来看下RecyclerView是怎么做的。

以上面的示例作为说明,将Data1从数据集移除后,RecyclerView开始重新布局,在dispatchLayoutStep1()中的processAdapterUpdatesAndSetAnimationFlags()方法中更新ViewHolder的position信息。假设没有设置动画,则执行mAdapterHelper.consumeUpdatesInOnePass()

    private void processAdapterUpdatesAndSetAnimationFlags() {
        // ......
        // simple animations are a subset of advanced animations (which will cause a
        // pre-layout step)
        // If layout supports predictive animations, pre-process to decide if we want to run them
        if (predictiveItemAnimationsEnabled()) {
            mAdapterHelper.preProcess();
        } else {
            mAdapterHelper.consumeUpdatesInOnePass();
        }
        // ......
    }

mAdapterHelper.consumeUpdatesInOnePass()中判断数据集发生的变化,通过AdapterHelper.Callback回调通知RecyclerView遍历ViewHolder更新position

    void consumeUpdatesInOnePass() {
        // ......
        for (int i = 0; i < count; i++) {
            UpdateOp op = mPendingUpdates.get(i);
            switch (op.cmd) {
                case UpdateOp.ADD:
                    mCallback.onDispatchSecondPass(op);
                    mCallback.offsetPositionsForAdd(op.positionStart, op.itemCount);
                    break;
                case UpdateOp.REMOVE:
                    mCallback.onDispatchSecondPass(op);
                    mCallback.offsetPositionsForRemovingInvisible(op.positionStart, op.itemCount);
                    break;
                case UpdateOp.UPDATE:
                    mCallback.onDispatchSecondPass(op);
                    mCallback.markViewHoldersUpdated(op.positionStart, op.itemCount, op.payload);
                    break;
                case UpdateOp.MOVE:
                    mCallback.onDispatchSecondPass(op);
                    mCallback.offsetPositionsForMove(op.positionStart, op.itemCount);
                    break;
            }
            if (mOnItemProcessedCallback != null) {
                mOnItemProcessedCallback.run();
            }
        }
        recycleUpdateOpsAndClearList(mPendingUpdates);
        mExistingUpdateTypes = 0;
    }

经过一连串的方法调用,最终走到了ViewHolder的offsetPosition(...)方法,如下所示。在我们的示例中,offset参数就是-1。

    void offsetPosition(int offset, boolean applyToPreLayout) {
        if (mOldPosition == NO_POSITION) {
            mOldPosition = mPosition;
        }
        if (mPreLayoutPosition == NO_POSITION) {
            mPreLayoutPosition = mPosition;
        }
        if (applyToPreLayout) {
            mPreLayoutPosition += offset;
        }
        mPosition += offset;
        if (itemView.getLayoutParams() != null) {
            ((LayoutParams) itemView.getLayoutParams()).mInsetsDirty = true;
        }
    }

dispatchLayoutStep1()最后还会调用clearOldPositions()将ViewHolder中的mOldPosition和mPreLayoutPosition都重置为-1,则getLayoutPosition()方法最终返回的就是ViewHolder中的mPosition变量。

    void clearOldPosition() {
        mOldPosition = NO_POSITION;
        mPreLayoutPosition = NO_POSITION;
    }

一般情况下,重新onLayout()之后,mAttachedScrap中大部分的ItemView都会得到重用;而得不到复用的ItemView会被放入RecyclerPool中,用于滑动时复用。

场景2—Item滑出可视区域

RecyclerView滑动时使用到的缓存池为mCachedViews和RecycledViewPool。当一个Item滑出可视区域时会被放入缓存;而当某个ItemView滑进可视区域时,会去缓存中查找是否有可用的ViewHolder,没有则新建一个。

mCachedViews的结构比较简单,是一个ArrayList,默认大小为2,其特性与mAttachedScrap类似,缓存到mCachedViews中的Item可以直接复用,效率较高。

当mCachedViews达到上限后,之后的ViewHolder会被缓存至mRecyclerPool,它根据ViewHolder的viewType缓存不同类型的ViewHolder,每个viewType的默认缓存上限为5,RecycledViewPool定义如下。

    public static class RecycledViewPool {
        private static final int DEFAULT_MAX_SCRAP = 5;

        static class ScrapData {
            final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
            int mMaxScrap = DEFAULT_MAX_SCRAP;
            long mCreateRunningAverageNs = 0;
            long mBindRunningAverageNs = 0;
        }
        SparseArray<ScrapData> mScrap = new SparseArray<>();
        ......
    }

从mRecyclerPool取出的ViewHolder会被重置,因此从mRecyclerPool查找缓存时只需要viewType相等即可,最终会调用Adapter的onBindViewHolder()重新绑定数据。

举个栗子,如图所示。RecyclerView滑动时,刚开始的时候回收了Position0和Position1,它们被添加到了mCachedViews中。随后回收Position2时,达到数量上限,最先进入mCachedViews的Position0被放进了mRecyclerPool中。
再看下方进入可视区域的3个Item,最初的Position6和Position7找不到对应的缓存,只能新建ViewHolder并绑定。当Position8滑入可视区域时,发现mRecyclerPool中有一个ViewType相等的缓存,则将其取出并绑定数据进行复用。

复用示例.png

之前提到,LayoutManager通过recycler.getViewForPosition(i)寻找对应位置的ItemView,而Recycler有多个缓存池(mAttachedScrap, mCachedViews…),那么遍历缓存池的顺序又是怎样的呢?我们从代码的角度来分析下。

        public View getViewForPosition(int position) {
            return getViewForPosition(position, false);
        }

        View getViewForPosition(int position, boolean dryRun) {
            return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
        }

关注一下dryRun参数,一般来说,我们从缓存中取出某个ItemView时,会将它从缓存中移除,但是参数dryRun为true时,取出的ViewHolder不会从缓存中移除。
getViewForPosition(int position)直接传了false。而且整个RecyclerView中我没找到dryRun传true的地方,我猜测这个参数应该是给开发人员用于特殊场景的。

言归正传,getViewForPosition(...)最终调用了tryGetViewHolderForPositionByDeadline(...),参数中的FOREVER_NS表示对这个方法的运行时间没有限制,不管花多长时间,这个方法都要返回一个绑定好数据的ItemView,来看精简后的代码。

        ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) {
            // ......
            boolean fromScrapOrHiddenOrCache = false;
            ViewHolder holder = null;
            // 0) 从mChangedScrap中查找
            if (mState.isPreLayout()) {
                holder = getChangedScrapViewForPosition(position);
                fromScrapOrHiddenOrCache = holder != null;
            }
            // 1) 如果还没找到,依次去mAttachedScrap和mCachedViews中查找
            if (holder == null) {
                holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
                if (holder != null) {
                    // 判断该ViewHolder是否有效,如果无效则置空
                }
            }
            if (holder == null) {
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);
                // ......
                final int type = mAdapter.getItemViewType(offsetPosition);
                // 2) 如果存在stableId,依次去mAttachedScrap和mCachedViews中查找
                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) {
                    // 3) 如果自定义缓存mViewCacheExtension不为空,就去其中查找
                }
                // 4) 之前的缓存中都查找不到,去RecycledViewPool中查找
                if (holder == null) {
                    holder = getRecycledViewPool().getRecycledView(type);
                    if (holder != null) {
                        // 将ViewHolder重置
                        holder.resetInternal();
                        if (FORCE_INVALIDATE_DISPLAY_LIST) {
                            invalidateDisplayListInt(holder);
                        }
                    }
                }
                if (holder == null) {
                    // 5) 所有的缓存中都查找不到,调用createViewHolder新建一个
                    holder = mAdapter.createViewHolder(RecyclerView.this, type);
            }

            // ......

            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()) {
                // 如果ViewHolder需要重新绑定,则调用tryBindViewHolderByDeadline()绑定数据
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);
                bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
            }

            // ......
            return holder;
        }

在不考虑动画和stableId的情况下,查找缓存的顺序为:mAttachedScrap->mCachedViews->RecycledViewPool,从getScrapOrHiddenOrCachedHolderForPosition(...)方法中我们可以发现,当从mAttachedScrap和mCachedViews中查找ViewHolder时,主要的判断条件都是holder.getLayoutPosition() == position,得到的ViewHolder不用重新绑定。

        ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
            final int scrapCount = mAttachedScrap.size();
            // Try first for an exact, non-invalid match from scrap.
            for (int i = 0; i < scrapCount; i++) {
                final ViewHolder holder = mAttachedScrap.get(i);
                if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
                        && !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
                    holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
                    return holder;
                }
            }
            // ......

            // 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);
                if (!holder.isInvalid() && holder.getLayoutPosition() == position
                        && !holder.isAttachedToTransitionOverlay()) {
                    return holder;
                }
            }
            return null;
        }

当从RecyclerPool中查找缓存时调用了RecyclerPool的getRecycledView(int viewType)方法,只要ViewType相等即可。

        public ViewHolder getRecycledView(int viewType) {
            final ScrapData scrapData = mScrap.get(viewType);
            if (scrapData != null && !scrapData.mScrapHeap.isEmpty()) {
                final ArrayList<ViewHolder> scrapHeap = scrapData.mScrapHeap;
                for (int i = scrapHeap.size() - 1; i >= 0; i--) {
                    if (!scrapHeap.get(i).isAttachedToTransitionOverlay()) {
                        return scrapHeap.remove(i);
                    }
                }
            }
            return null;
        }

从RecyclerPool中得到ViewHolder后再调用holder.resetInternal()重置,tryGetViewHolderForPositionByDeadline(...)方法最后会对未绑定的ViewHolder重新绑定数据。

三、掉帧优化

近几年来,屏幕高刷新率成为了手机厂商追逐的热点,那么高刷新率是怎么一回事呢?掉帧现象又是如何产生的?
对于传统的60刷新率手机来说,每16ms会发出一个VSync信号,复制CPU/GPU放在缓存中的图像,再通知CPU/GPU计算下一帧要显示的内容,再把刚复制的图像显示在屏幕上,这就是一个屏幕刷新周期。而如果在16ms内没有计算完毕的话,该帧就无法展示,屏幕进入下一个刷新周期,就产生了所谓的掉帧现象。

3.1 掉帧监控

监控掉帧现象时,我们可以使用下方的adb命令,具体可见参考3。

adb shell dumpsys gfxinfo <packageName>

该命令展示的信息比较完整,如下所示。

Applications Graphics Acceleration Info:
Uptime: 275941522 Realtime: 391854346

** Graphics info for pid 6887 [packageName] **

Stats since: 275926453465347ns
Total frames rendered: 523   // 本次共收集了523帧的信息
Janky frames: 26 (4.97%)   // 有26帧的耗时超过16ms,掉帧率为4.97%
50th percentile: 5ms   // 50%的帧耗时在5ms以内
90th percentile: 8ms
95th percentile: 16ms
99th percentile: 20ms
Number Missed Vsync: 0   // 垂直同步失败的帧
Number High input latency: 259   // 处理input时间超时的帧数
Number Slow UI thread: 1   // 因UI线程上的工作导致超时的帧数
Number Slow bitmap uploads: 0   // 因bitmap的加载耗时的帧数
Number Slow issue draw commands: 0   // 因绘制导致耗时的帧数
Number Frame deadline missed: 1
HISTOGRAM: 5ms=346 6ms=72 7ms=31 .........   // 耗时0-5ms的帧有346......

各种缓存......
Total GPU memory usage:
  40704248 bytes, 38.82 MB (36.77 MB is purgeable)
......

如果只看掉帧率,可以用adb shell dumpsys gfxinfo <packageName> | grep "Janky frames"命令。
如果想重新开始计算帧率信息,可以通过adb shell dumpsys gfxinfo <packageName> reset重置。

当然我们也可以通过可视化界面查看UI性能,打开"开发者选项"中的"GPU渲染模式分析",即可在屏幕上看到每一帧绘制时间的直方图,某个值越大,代表该帧绘制的时间越长。如下图所示,冷启动APP时有不少帧的绘制时间已经远远超过了16ms。

GPU渲染模式分析.png

除了"GPU渲染模式分析",还有Android Studio中的CPU Profile用于查看APP运行时的方法调用栈,辅助开发人员定位热点方法并优化。我们来做个实验,在Demo中的onBindViewHolder()中添加Thread.sleep(5),使每次绑定ItemView都会多消耗5ms。

运行程序后打开Profile,可以看到CPU、MEMORY、NETWORK和ENERGY四个动态图表,点击CPU后,下方出现CPU Profile界面,如下所示,点击"record"即可开始记录,点击"stop"后得到这一段时间内的方法调用栈。

CPU-Profile示例.png

得到方法调用栈信息后,先从"Flame Chart"模式来看热点方法,很明显sleep函数耗时较多。

FlameChart示例.png

如果想要数字化的信息,可以通过"Top Down"模式查看每个方法及其子方法的耗时和百分比,分析时一般点击耗时占比高的方法查看它的子方法哪个耗时较多,再一步步追踪下去。
在我们的例子中,sleep()函数占总耗时的49.58%,是耗时最多的方法。

TopDown示例.png

总结一下,CPU Profile为开发者提供了强大的分析工具,我们很容易定位APP运行时耗时多的方法,然后具体问题具体分析。当然CPU Profile不仅仅用于掉帧优化,有优化的地方就有它的身影,例如启动优化等。

3.2 掉帧优化措施
① 正确使用缓存

关于mCachedViews:
mCachedViews针对ItemView的position进行缓存。当一个Item滑出可视区域时,它会先被放入mCachedViews中;而当一个Item滑入可视区域时,Recycler也会优先去mCachedViews中查找。
根据这个特性,当用户频繁地上下滑动时,mCachedViews的利用率会较高。那么针对频繁上下滑动的场景,我们可以通过RecyclerView.setItemViewCacheSize(...)来增大mCachedViews的容量,这样Recycler更容易在mCachedViews中找到缓存,减少之后的onBindViewHolder()onCreateViewHolder()调用。

关于RecyclerPool:
RecyclerPool针对某个ViewType进行缓存,默认大小为5,但是对于某些场景这是远远不够的。试想一个能在可视区域展示n(n>>5)条数据的RecyclerView(如历史记录),当滑动的时候RecyclerPool的缓存明显不够,会不断地创建ViewHolder,很消耗性能。针对这种情况,可以通过RecyclerView.getRecycledViewPool().setMaxRecycledViews(int viewType, int max)增大特定ViewType的缓存容量。

如果多个RecyclerView的内容性质相同,例如在信息流中,多个Fragment中的Item类型相同。那么可以为它们设置同一个RecyclerPool(默认是1个RecyclerView创建一个RecyclerPool),通过RecyclerView.setRecycledViewPool(pool)设置即可。

② 优化onBindViewHolder()耗时

从RecyclerPool中取出的ViewHolder都会调用onBindViewHolder()加载数据,该方法是在主线程运行的,处理不当时很容易造成滑动卡顿。
当为ItemView设置点击监听时,不要在onBindViewHolder()中新建OnClickListener,这不仅会新建多余的对象消耗内存,也会增加onBindViewHolder()的耗时。可以让所有的Item共用一个监听器,然后根据具体的Item来处理事件。

平时重写的onBindViewHolder(ViewHolder holder, int pos)会更新ItemView的所有内容,如果想要局部更新,可以重写onBindViewHolder(ViewHolder holder, int pos, List<Object> payloads)。当ItemView更新时,调用Adapter.notifyItemChanged(position, payLoad)即可。具体可见参考5,通过这个方法解决了ItemView更新时图片闪烁的问题。

③ 布局优化

布局优化一个比较典型的优化项就是优化过度绘制,打开"开发者选项"中的"调试GPU过度绘制",就能看到屏幕上每个像素点在屏幕上绘制了多少次。

对过度绘制进行优化时,首先要考虑合适的控件容器,也就是Layout。虽然Google推出了约束布局ConstraintLayout,但是它性能上并不优秀,不建议使用。
其次要善用merge和ViewStub。merge用于减少布局层级,例如自定义ViewGroup时,可以用作为根布局。ViewStub是布局文件中的占位符,对于某些在特殊场景下才需要显示的控件,可以先用ViewStub代替,等到需要显示时再加载。

GPU过度绘制.png

还有一个常见的优化项就是layout_weight,该属性可以很轻松地实现空间分配,但是也很容易成为性能瓶颈,能不用就不用。

④ measure()优化和减少requestLayout()调用

当RecyclerView宽高的测量模式都是EXACTLY时,onMeasure()方法不需要执行dispatchLayoutStep1()等方法来进行测量。而当RecyclerView的宽高不确定并且至少一个child的宽高不确定时,要measure两遍。
因此将RecyclerView的宽高模式都设置为EXACTLY有助于优化性能。

    protected void onMeasure(int widthSpec, int heightSpec) {
        // ......
        if (mLayout.isAutoMeasureEnabled()) {
            final int widthMode = MeasureSpec.getMode(widthSpec);
            final int heightMode = MeasureSpec.getMode(heightSpec);

            mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);

            final boolean measureSpecModeIsExactly =
                    widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY;
            if (measureSpecModeIsExactly || mAdapter == null) {
                return;
            }
            // ......
    }

还有一个方法RecyclerView.setHasFixedSize(true)可以避免数据改变时重新计算RecyclerView的大小,来看一下方法注释。
注释上说,如果Adapter的变化不会影响RecyclerView的size,那么可以设置mHasFixedSize为true来避免Adapter改变时RecyclerView刷新整个Layout。也就是说,不管数据变成什么样,如果RecyclerView的宽高都不会变,那么设置这个属性为true。

    /**
     * RecyclerView can perform several optimizations if it can know in advance that RecyclerView's
     * size is not affected by the adapter contents. RecyclerView can still change its size based
     * on other factors (e.g. its parent's size) but this size calculation cannot depend on the
     * size of its children or contents of its adapter (except the number of items in the adapter).
     * <p>
     * If your use of RecyclerView falls into this category, set this to {@code true}. It will allow
     * RecyclerView to avoid invalidating the whole layout when its adapter contents change.
     *
     * @param hasFixedSize true if adapter changes cannot affect the size of the RecyclerView.
     */
    public void setHasFixedSize(boolean hasFixedSize) {
        mHasFixedSize = hasFixedSize;
    }

当Adapter调用onItemRangeChanged(), onItemRangeInserted(), onItemRangeRemoved(), onItemRangeMoved()这4个方法时会调用triggerUpdateProcessor(),当mHasFixedSize为true时,不会调用requestLayout()重新计算宽高。
注意: 如果调用notifyDataSetChanged()还是会调用requestLayout()去计算宽高。

    void triggerUpdateProcessor() {
        if (POST_UPDATES_ON_ANIMATION && mHasFixedSize && mIsAttached) {
            ViewCompat.postOnAnimation(RecyclerView.this, mUpdateChildViewsRunnable);
        } else {
            mAdapterUpdateDuringMeasure = true;
            requestLayout();
        }
    }

参考

  1. RecyclerView缓存原理,有图有真相
  2. 图解 RecyclerView 的缓存机制
  3. 使用dumpsys gfxinfo 测UI性能(适用于Android6.0以后)
  4. 理解 VSync
  5. RecyclerView局部刷新的坑
  6. RecyclerView notifyDataSetChanged 导致图片闪烁的真凶
  7. Android布局优化(二),减少过度绘制
  8. 公司大佬分享
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值