recycleview高级UI性能优化

RecycleView优化

2019-07-03  本文已影响20人  酸菜多余丶

我们可以做的

降低item的布局层次

其实这个优化不光适用于rv,activity的布局优化也同样适用,降低页面层次可以一定程度降低cpu渲染数据的时间成本,反应到rv中就是降低mCreateRunningAverageNs的时间,不光目前显示的页面能加快速度,预取的成功率也能提高,关于如何降低布局层次还是要推荐下google的强大控件ConstraintLayout,具体使用就自行百度吧。

比较容易上手,这里吐槽下另一个控件CoordinatorLayout的上手难度确实是有点大啊,不了解CoordinatorLayout源码可能会遇到一些奇葩问题。降低item的布局层次可以说是rv优化中一个对于rv源码不需要了解也能完全掌握的有效方式。

去除冗余的setitemclick事件

rv和listview一个比较大的不同之处在于rv居然没有提供setitemclicklistener方法,这是当初自己在使用rv时一个非常不理解的地方,其实现在也不是太理解,但是好在我们可以很方便的实现该功能。

一种最简单的方式就是直接在onbindview方法中设置,这其实是一种不太可取的方式,onbindview在item进入屏幕的时候都会被调用到(cached缓存着的除外),而一般情况下都会创建一个匿名内部类来实现setitemclick,这就会导致在rv快速滑动时创建很多对象,从这点考虑的话setitemclick应该放置到其他地方更为合适。

自己的做法就是将setitemclick事件的绑定和viewholder对应的rootview进行绑定,viewholer由于缓存机制的存在它创建的个数是一定的,所以和它绑定的setitemclick对象也是一定的。

还有另一种做法可以通过rv自带的addOnItemTouchListener来实现点击事件,原理就是rv在触摸事件中会使用到addOnItemTouchListener中设置的对象,然后配合GestureDetectorCompat实现点击item,示例代码如下:


recyclerView.addOnItemTouchListener(this);
gestureDetectorCompat = new GestureDetectorCompat(recyclerView.getContext(), new SingleClick());

@Override
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
    if (gestureDetectorCompat != null) {
        gestureDetectorCompat.onTouchEvent(e);
    }
    return false;
}

private class SingleClick extends GestureDetector.SimpleOnGestureListener {

    @Override
    public boolean onSingleTapConfirmed(MotionEvent e) {
        View view = recyclerView.findChildViewUnder(e.getX(), e.getY());
        if (view == null) {
            return false;
        }
        final RecyclerView.ViewHolder viewHolder = recyclerView.getChildViewHolder(view);
        if (!(viewHolder instanceof ViewHolderForRecyclerView)) {
            return false;
        }
        final int position = getAdjustPosition(viewHolder);
        if (position == invalidPosition()) {
            return false;
        }
        /****************/
        点击事件设置可以考虑放在这里
        /****************/
        return true;
    }
}

相对来说这是一个比较优雅点的实现,但是有一点局限在于这种点击只能设置整个item的点击,如果item内部有两个textview都需要实现点击的话就可能不太适用了,所以具体使用哪种看大家的实际应用场景,可以考虑将这两种方式都封装到adapter库中,目前项目中使用的adapter库就是采用两种结合的形式。

复用pool缓存

四级缓存中我已经介绍过了,复用本身并不难,调用rv的setRecycledViewPool方法设置一个pool进去就可以,但是并不是说每次使用rv场景的情况下都需要设置一个pool,这个复用pool是针对item中包含rv的情况才适用,如果rv中的item都是普通的布局就不需要复用pool

image

如上图所示红框就是一个item中嵌套rv的例子,这种场景还是比较常见,如果有多个item都是这种类型那么复用pool就非常有必要了,在封装adapter库时需要考虑的一个点就是如何找到item中包含rv,可以考虑的做法就是遍历item的根布局如果找到包含rv的,那么将对该rv设置pool,所有item中的嵌套rv都使用同一个pool即可,查找item中rv代码可以如下.


private List<RecyclerView> findNestedRecyclerView(View rootView) {
    List<RecyclerView> list = new ArrayList<>();
    if (rootView instanceof RecyclerView) {
        list.add((RecyclerView) rootView);
        return list;
    }
    if (!(rootView instanceof ViewGroup)) {
        return list;
    }
    final ViewGroup parent = (ViewGroup) rootView;
    final int count = parent.getChildCount();
    for (int i = 0; i < count; i++) {
        View child = parent.getChildAt(i);
        list.addAll(findNestedRecyclerView(child));
    }
    return list;
}

得到该list之后接下来要做的就是给里面的rv绑定pool了,可以将该pool设置为adapter库中的成员变量,每次找到嵌套rv的item时直接将该pool设置给对应的rv即可。

关于使用pool源码上有一点需要在意的是,当最外层的rv滑动导致item被移除屏幕时,rv其实最终是通过调用.

removeview(view)完成的,里面的参数view就是和holder绑定的rootview,如果rootview中包含了rv,也就是上图所示的情况,会最终调用到嵌套rv的onDetachedFromWindow方法:


@Override
public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) {
    super.onDetachedFromWindow(view, recycler);
    if (mRecycleChildrenOnDetach) {
        removeAndRecycleAllViews(recycler);
        recycler.clear();
    }
}

注意里面的if分支,如果进入该分支里面的主要逻辑就是会清除掉scrap和cached缓存上的holder并将它们放置到pool中,但是默认情况下mRecycleChildrenOnDetach是为false的,这么设计的目的就在于放置到pool中的holder要想被拿来使用还必须调用onbindview来进行重新绑定数据,所以google默认将该参数设置为了false,这样即使rv会移除屏幕也不会使里面的holder失效,下次再次进入屏幕的时候就可以直接使用避免了onbindview的操作。

但是google还是提供了setRecycleChildrenOnDetach方法允许我们改变它的值,如果要想充分使用pool的功能,最好将其置为true,因为按照一般的用户习惯滑出屏幕的item一般不会回滚查看,这样接下来要被滑入的item如果存在rv的情况下就可以快速复用pool中的holder,这是使用pool复用的时候一个需要注意点的地方。

保存嵌套rv的滑动状态

原来开发的时候产品就提出过这种需求,需要将滑动位置进行保存,否则每次位置被重置开起来非常奇怪,具体是个什么问题呢,还是以上图嵌套rv为例,红框中的rv可以看出来是滑动到中间位置的,如果这时将该rv移出屏幕,然后再移动回屏幕会发生什么事情。

这里要分两种情况,一种是移出屏幕一点后就直接重新移回屏幕,另一种是移出屏幕一段距离再移回来。

你会发现一个比较神奇的事就是移出一点回来的rv会保留原先的滑动状态,而移出一大段距离后回来的rv会丢失掉原先的滑动状态,造成这个原因的本质是在于rv的缓存机制,简单来说就是刚滑动屏幕的会被放到cache中而滑出一段距离的会被放到pool中,而从pool中取出的holder会重新进行数据绑定,没有保存滑动状态的话rv就会被重置掉,那么如何才能做到即使放在pool中的holder也能保存滑动状态。

其实这个问题google也替我们考虑到了,linearlayoutmanager中有对应的onSaveInstanceState和onRestoreInstanceState方法来分别处理保存状态和恢复状态,它的机制其实和activity的状态恢复非常类似,我们需要做的就是当rv被移除屏幕调用onSaveInstanceState,移回来时调用onRestoreInstanceState即可。

需要注意点的是onRestoreInstanceState需要传入一个参数parcelable,这个是onSaveInstanceState提供给我们的,parcelable里面就保存了当前的滑动位置信息,如果自己在封装adapter库的时候就需要将这个parcelable保存起来:


private Map<Integer, SparseArrayCompat<Parcelable>> states;

map中的key为item对应的position,考虑到一个item中可能嵌套多个rv所以value为SparseArrayCompat,最终的效果

image

可以看到几个rv在被移出屏幕后再移回来能够正确保存滑动的位置信息,并且在删除其中一个item后states中的信息也能得到同步的更新,更新的实现就是利用rv的registerAdapterDataObserver方法,在adapter调用完notify系列方法后会在对应的回调中响应,对于map的更新操作可以放置到这些回调中进行处理。

视情况设置itemanimator动画

使用过listview的都知道listview是没有item改变动画效果的,而rv默认就是支持动画效果的,之前说过rv内部源码有1万多行,其实除了rv内部做了大量优化之外,为了支持item的动画效果google也没少下苦功夫,也正是因为这样才使得rv源码看起来非常复杂。

默认在开启item动画的情况下会使rv额外处理很多的逻辑判断,notify的增删改操作都会对应相应的item动画效果,所以如果你的应用不需要这些动画效果的话可以直接关闭掉,这样可以在处理增删改操作时大大简化rv的内部逻辑处理,关闭的方法直接调用setItemAnimator(null)即可。

setHasFixedSize

又是一个google提供给我们的方法,主要作用就是设置固定高度的rv,避免rv重复measure调用。
这个方法可以配合rv的wrap_content属性来使用,比如一个垂直滚动的rv,它的height属性设置为wrap_content,最初的时候数据集data只有3条数据,全部展示出来也不能使rv撑满整个屏幕,如果这时我们通过调用notifyItemRangeInserted增加一条数据,在设置setHasFixedSize和没有设置setHasFixedSize你会发现rv的高度是不一样的,设置过setHasFixedSize属性的rv高度不会改变,而没有设置过则rv会重新measure它的高度,这是setHasFixedSize表现出来的外在形式,我们可以从代码层来找到其中的原因。
notifiy的一系列方法除了notifyDataSetChanged这种万金油的方式,还有一系列进行局部刷新的方法可供调用,而这些方法最终都会执行到一个方法

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

区别就在于当设置过setHasFixedSize会走if分支,而没有设置则进入到else分支,else分支直接会调用到requestLayout方法.
该方法会导致视图树进行重新绘制,onmeasure,onlayout最终都会被执行到,结合这点再来看为什么rv的高度属性为wrap_content时会受到setHasFixedSize影响就很清楚了,根据上述源码可以得到一个优化的地方在于,当item嵌套了rv并且rv没有设置wrap_content属性时,我们可以对该rv设置setHasFixedSize,这么做的一个最大的好处就是嵌套的rv不会触发requestLayout,从而不会导致外层的rv进行重绘,关于这个优化应该很多人都不知道,网上一些介绍setHasFixedSize的文章也并没有提到这点。
上面介绍的这些方法都是自己在研究rv优化时自己总结的一些心得,文章到这里其实应该可以结束,但在看源码的过程中还发现了几个比较有意思的方法,现在分享出来.

swapadapter

rv的setadapter大家都会使用,没什么好说的,但关于swapadapter可能就有些人不太知道了,这两个方法最大的不同之处就在于setadapter会直接清空rv上的所有缓存,而swapadapter会将rv上的holder保存到pool中,google提供swapadapter方法考虑到的一个应用场景应该是两个数据源有很大的相似部分的情况下,直接使用setadapter重置的话会导致原本可以被复用的holder全部被清空,而使用swapadapter来代替setadapter可以充分利用rv的缓存机制,可以说是一种更为明智的选择。

getAdapterPosition和getLayoutPosition

大部分情况下调用这两个方法得到的结果是一致的,都是为了获得holder对应的position位置,但getAdapterPosition获取位置更为及时,而getLayoutPosition会滞后到下一帧才能得到正确的position,如果你想及时得到holder对应的position信息建议使用前者。
举个最简单的例子就是当调用完notifyItemRangeInserted在rv头部插入一个item后立即调用这两个方法获取下原先处于第一个位置的position就能立即看出区别,其实跟踪下
getAdapterPosition的源码很快能发现原因

public int applyPendingUpdatesToPosition(int position) {
    final int size = mPendingUpdates.size();
    for (int i = 0; i < size; i++) {
        UpdateOp op = mPendingUpdates.get(i);
        switch (op.cmd) {
            case UpdateOp.ADD:
                if (op.positionStart <= position) {
                    position += op.itemCount;
                }
                break;
            case UpdateOp.REMOVE:
                if (op.positionStart <= position) {
                    final int end = op.positionStart + op.itemCount;
                    if (end > position) {
                        return RecyclerView.NO_POSITION;
                    }
                    position -= op.itemCount;
                }
                break;
            case UpdateOp.MOVE:
                if (op.positionStart == position) {
                    position = op.itemCount; //position end
                } else {
                    if (op.positionStart < position) {
                        position -= 1;
                    }
                    if (op.itemCount <= position) {
                        position += 1;
                    }
                }
                break;
        }
    }
    return position;
}

最终getAdapterPosition会进入到上述方法,在这个方法就能很清楚看出为什么getAdapterPosition总是能及时反应出position的正确位置。但是有一点需要注意的就是getAdapterPosition可能会返回-1

if (viewHolder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
        | ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)
        || !viewHolder.isBound()) {
    return RecyclerView.NO_POSITION;
}

这点需要特别留意,做好预防处理。

removeview和detachview

这两个方法在rv进行排布item的时候会遇到,removeview就是大家很常见的操作,但是detachview就不太常见了,其实removeview是一个更为彻底的移除view操作,内部是会调用到detachview的,并且会调用到我们很熟悉的ondetachfromwindow方法,而detachview是一个轻量级的操作,内部操作就是简单的将该view从父view中移除掉,rv内部调用detachview的场景就是对应被移除的view可能在近期还会被使用到所以采用轻量级的移除操作,removeview一般都预示着这个holder已经彻底从屏幕消失不可见了。

上一篇下一篇

猜你喜欢

热点阅读