走进RecyclerView未解之迷 ------ 原理和优化

Posted 鸽一门

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了走进RecyclerView未解之迷 ------ 原理和优化相关的知识,希望对你有一定的参考价值。

(前言暂位符号)



View holder究竟是什么?

Problem

  • View holder 和 item view 是什么关系? 一对一?一对多?多对一?
  • View holder 解决的是什么问题?
  • View holder 和 ListView 的 item view 的复用有什么关系?

没有实现 View holder 的 getView() 的例子如下,大量的 findViewById() 方法被调用,看似不是很大的开销,但其方法的底层实现是深度优先搜索,时间复杂度是O(n)。

完全可以避免以上不必要开支,见下例子,通过setTag()getTag()复用view,只有在第一次convertView为空的时候去创建ViewHolder,并调用findViewById将convertView中的view赋值到holder中,并且将holder存起来留给下次复用,避免重复找view操作。这也是 ViewHolder名字的来历:用来保存View的容器。

setTag() 为什么可以绑定convertView和holder?其实这里的内部实现很简单,把任意一个对象Object作为参数(成员变量)存到View当中,另一种方法则是用Key、Value的方式进行绑定。


ANSWER

  • Item view 和 view holder 是一一对应的关系;

  • View holder解决的是防止重复进行findViewById,提升效率;

  • 没什么关系,对比以上2个例子,第一个效率不高的getView()方法中可见即使没有使用View holder的存在,但是通过convertView的空判断选择创建或直接findViewById,这就是在复用,只是比较耗性能!


ViewHolder的最佳实践

ViewType种类过多,尽量减少在onBindViewHolder(VireHolder holder, int position)里if else的冗杂判断,可将具体的数据绑定逻辑放到 ViewHolder内部。



缓存机制

1. ListView

如下图,RecyclerBin是ListView的回收站,专门负责管理ListView的缓存。RecyclerBin中有两层缓存:

  • Active View:活跃View,正在屏幕中展示的View;
  • Scrap View:废弃View,已经从屏幕移出去的item view,已经被回收掉了;

结合下图理解,当ListView需要创建一个view,也就是Adapter.getView时,首先从两层缓存中找,如果没有就创建一个view返回。

结合下图再来加深一下ListView的二层缓存理解:屏幕内外的View,和复用、缓存有什么关系?

android系统的屏幕刷新机制16.6ms/1帧,每次刷新的时候会把最新的View渲染到屏幕上,也就是说在下一帧画面时,ListView会将**屏幕上所有的item**的数据清空,再根据最新的业务逻辑状态结合ListView内部机制(及layout、draw等等),重新绘制到屏幕上。

那么,被清空的这些item呢?例如滑动列表,数据没变,只是item的位置改变!因此这些item view是可以拿回来继续复用的,并不需要重新bind数据,即ListView直接跳过adapter.getView步骤的。正因为ListView内部已经处理过了,也无需开发者在getView中处理这些逻辑。

诶嘿,上述讲解还有一个重点:凡是跳过getView 方法的item不需要重新bind数据,那么执行过getView 方法的item view是肯定要重新bind数据的!

再来看Srap View,上滑动列表后,右再回到原来位置,这时ListView会在Scrap View,即被废弃的View中找到item view,既然是脏数据,找到后就需要重新bind数据。(注:如果脏数据中没有找到,就重新创建)

留一个问题:Srap View内部使用的什么数据结构?


2. RecyclerView

如下图,这对比起来相较于ListView似乎复杂了些,但其实二者还是有些许相同之处,只是RV的缓存机制更加完善高效。

RecyclerView内部是由Recycler来管理缓存机制,注意:RecyclerView和ListView缓存本质上很重要的一点差异,前者缓存的是ViewHolder、而后者缓存的是View对象!但是但是,在文章一开始我们就分析过ViewHolder和item view的关系,一一对应,相互绑定的,其实差别也不大。

RV接收到创建一个item view的需求时,首先从四层缓存中找,没找到才会去创建。来看这四层缓存:

  1. Scrap: 虽然名字叫“废弃”,但实则对标ListView中的Active View,即屏幕内的item view;
  2. Cache: 同上,对标于ListView中的Scrap View,即屏幕外的item view;
  3. ViewCacheExtension:(用户自定义过,才会走这层,否则直接跳过)
  4. RecycledViewPool: Pool池子,看到这个定义不由得想起线程池,其实也是一个类似概念;

从下图屏幕的角度,来理解这四层缓存:

首先来看第一层 Scrap,即屏幕内的item view,回顾ListView讲解的缓存内容,这部分是可以直接被复用的。注意:这类型“可以直接被复用的view” 是通过数据集的position来查找对应的viewholder。例如在滑动的过程中需要position为5的view(注意:position为5的item view一直在屏幕内的!只是每次刷新数据重新渲染),这时可以直接去Scrap干净数据中找,直接拿来复用,跳过bind数据步骤。

再来看第二层 Cache,即移出屏幕的item view,直接被回收到Cache,再拿来复用时就需要重新bind数据。例如往上滑动列表,position为2、3的item view被移出屏幕(被回收到Cache),再往下滑,position为2、3的item view在Cache中找到拿回来复用。同Scrap一样,也是通过position找到viewholder,直接拿回来复用,无需重新bind数据,即跳过onBindViewHolder方法

第三层 ViewCacheExtension,说实话使用场景很少,估计很多人闻所未闻,其API设计更是有些奇怪,要知道RecyclerView的缓存机制目标是ViewHolder,读取ViewHolder中有效信息(可从它获取item view)。但是ViewCacheExtension源码自行实现返回的对象却是item view,那ViewHolder?ViewCacheExtension内部还会去检查item view 是否绑定ViewHolder,没有绑定则直接crash。

第四层 RecycledViewPool ,内部保存的都是被废弃的脏(dirty)数据,通过viewType 找到对应数据后需要重新绑定数据(注意这一层缓存是通过viewType查找),即虽然缓存读取跳过了onCreateViewHolder步骤,但是需要重新绑定数据onBindViewHolder

底层的数据结构?

拓展:ViewCacheExtension使用?
  • 广告卡片
    • 每一页一共有4哥广告
    • 这些广告短期内不会发生变化
  • 每次滑入一个广告卡片,一般情况下都需要重新绑定
  • Cache只关心position,不关心view type
  • RecycledViewPool 只关心view type,都需要重新绑定
  • 在ViewCacheExtension 里保持4个广告Card缓存

注意:列表中item/广告 的impression统计:

  • ListView 通过getView() 统计,该方法被调用就相当于item view曝光;
  • RecyclerView 通过 onBindViewHolder() 方法统计?数据有误!经过上述缓存讲解,在第二层Cache时,item重新曝光,在Cache中找到并复用item,此方法并不会被调用,因此数据会有些许不精准。
    • 可通过onViewAttachedToWindow() 统计;


RecyclerView 性能优化策略

1. 在onBindViewHolder里设置点击监听?

OnClickListener对象多次被创建,观察者模式

onCreateViewHolder里设置点击监听,View、ViewHolder、View.OnClickListener三者一一对应!


2. LinerLayoutManager.setInitialPrefetchItemCount()

  • 用户滑动到横向滑动的item RecyclerView的时候,由于需要创建更复杂的RV以及多个子View,可能会导致页面卡顿;
  • 由于RenderThread的存在,RV会进行prefetch
  • LinerLayoutManager.setInitialPrefetchItemCount()横向列表初次显示时可见的item个数
    • 只有LinerLayoutManager才有这个API
    • 只有嵌套在内部的RV才会生效,在外部的RV调用此方法是无效的

3. RecyclerView.setHasFixedSize()

//伪代码解释

void onContentsChanged() 
  if(mHasFiedSize) 
    	layoutChildren();
   else 
    	requestLayout();
  

如果Adapter的数据变化(例如item增加、删除)不会导致RV的大小变化,那么可以为RV设置此属性;这样当RV内部的item变化时,可简化重走整个绘制过程。


4. 多个RecyclerView公用RecycledViewPool

重合的viewtype,即可走第四层缓存复用item

使用方法

RecyclerView.RecycledViewPool recycledViewPool = 
  	new RecyclerView.RecycledViewPool();
rv1.setRecycledViewPool(recycledViewPool);
rv2.setRecycledViewPool(recycledViewPool);
rv3.setRecycledViewPool(recycledViewPool);


DiffUtil

1. 介绍

  • DiffUtil is a utility class that can calculate the difference between two lists and output a list of update operations that converts the first list into the second one.
    • 计算两个不同列表的差异;
    • 输出一系列更新操作:第一个列表转换成第二个列表;
  • 局部更新方法 notifyItemXXX() 不适用于所有情况。
  • notifyDataSetChange() 会导致整个布局重新绘制,所有ViewHolder被重新绑定,而且会失去可能的动画效果。
  • DiffUtil 适用于整个页面需要刷新,但是有部分数据可能相同的情况。

内部算法 Myers Diff Algorith 是动态规划,不要求掌握其具体实现,给出的链接是对该算法的可视化讲解,感兴趣的朋友可以深入研究下:Myers Diff Algorithm - Code & Interactive Visualization


2.原理

那具体怎么运用DiffUtil到实际操作中,首先来介绍一个 重点抽象类DiffUtil.Callback,实现Callback里的方法告诉系统如何计算:

//DiffUtil.class
public abstract static class Callback 
  	public abstract int getOldListSize();
  
  	public abstract int getNewListSize();
  
  	public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition);

  	public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition);

  	@Nullable
    public Object getChangePayload(int oldItemPosition, int newItemPosition) 
            return null;
    

  • getOldListSize() :返回旧数据集的 size。
  • getNewListSize() :返回新数据集的 size。
  • areItemsTheSame(int oldItemPosition, int newItemPosition) :比较两个位置的对象是否是同一个item。注意这里指的是逻辑(业务)上判断,而并非内存地址。
    • 举个例子,比较两个人是否相同,可以通过身份证ID作为唯一标识。
  • areContentsTheSame(int oldItemPosition, int newItemPosition) :比较两个 item 的数据内容是相同。注意该方法被调用的前提:只有当 areItemsTheSame() 返回true时会调用。
    • 举个例子,还是两个人之间的比较,只有当这2个人的身份证ID都相同,也就是 areItemsTheSame() 返回true时此方法才会被调用,这个时候再来做这2个人具体信息比较,例如姓名、性别、出身地等等。
  • getChangePayload(int oldItemPosition, int newItemPosition) : 【不是抽象方法】返回这个 item 更新相关的信息。注意该方法被调用的前提:当 areItemsTheSame() 返回 true ,并且 areContentsTheSame() 返回 false 时被调用。
    • 在上个例子的基础上,这两个人身份证ID相同,比较信息内容时有不同点,则此方法会被调用,并且返回需要更新的信息,例如名字的纠正。

以上几个方法的调用前提、顺序通过下图总结归纳:


3.实践

上代码,实现一个简单的UserDiffCallback例子,来比较两个用户列表:

public class UserDiffCallback extends DiffUtil.Callback 
    private List<User> oldList;
    private List<User> newList;

    public UserDiffCallback(List<User> oldList, List<User> newList) 
        this.oldList = oldList;
        this.newList = newList;
    

    @Override
    public int getOldListSize() return oldList.size();

    @Override
    public int getNewListSize() return newList.size();

    @Override
    public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) 
        //唯一标识符比较
        return oldList.get(oldItemPosition).id == newList.get(newItemPosition).id;
    

    @Override
    public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) 
        final User oldUser = oldList.get(oldItemPosition);
        final User newUser = newList.get(oldItemPosition);
        //内容比较
      	//优化:可以在User类中实现equals方法,根据Model自身特性实现比较“相等”逻辑判断
        return oldUser.id == newUser.id &&
                oldUser.name.equals(newUser.name) &&
                oldUser.sex.equals(newUser.sex) &&
                oldUser.address.equals(newUser.address);
    

上述UserDiffCallback类继承于DiffUtil.Callback抽象类,并实现了其中4个抽象方法,其中areItemsTheSame()areContentsTheSame() 实现不同层度的item比较逻辑。此例子简单易懂,无需多余赘述,值得注意的是上述例子并未实现getChangePayload()方法。

如果没有实现此方法,那么就享受不到“增量更新”的便利,例如User类中可能只有名字name字段修改了,其余的数据都未变化,由于此方法没有被实现,因此整个item还是会被重新刷新。也就是“增量更新”和“全量更新”的区别。

如下代码,我们来实现getChangePayload()方法,其内部主要逻辑看似与内容比较方法areContentsTheSame()相似,但还有一点不同是:将两个对象的差异部分存储到Bundle中返回。

public class UserDiffCallback extends DiffUtil.Callback 
  	......
      
		@Nullable
    @Override
    public Object getChangePayload(int oldItemPosition, int newItemPosition) 
        final User oldUser = oldList.get(oldItemPosition);
        final User newUser = newList.get(oldItemPosition);

        final Bundle payload = new Bundle();
        if(oldUser.id != newUser.id)
            payload.putInt(User.KEY_ID, newUser.id);
        
        if(oldUser.sex != newUser.sex)
            payload.putString(User.KEY_SEX, newUser.sex);
        
        if(oldUser.address != newUser.address)
            payload.putString(User.KEY_ADDRESS, newUser.address);
        

        if(payload.size() == 0)
            return null;
        
        return payload;
    
  
  	......
  

外界调用

实现了 DiffUtil.Callback 后,我们就可以在自定义Adapter中根据自身逻辑选择使用增量更新来update列表,封装方法如下:

public class ShowcaseAdapter extends RecyclerView.Adapter<ShowcaseAdapter.ViewHolder> 
  	private List<User> userList;
    ShowcaseAdapter(List<User> userList)
        this.userList = userList;
    
  	
  	......
      
    public void swapData(List<User> newList, boolean diff)
        if(diff)
            DiffUtil.Callback diffUtilCallback;
            DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(
                    new UserDiffCallback(userList, newList), false);
            userList = newList;
            //增量更新
            diffResult.dispatchUpdatesTo(this);
         else 
            userList = newList;
            //全量更新
            notifyDataSetChanged();
        
    
  	

看以上swapData(List<User> newList, boolean diff) 方法封装设计:

  • 第一个参数newList,故名思义则是新列表;
  • 第二个参数diff 指定是否使用**“增量更新”的方式更新数据,若不需要则直接将新数据替代旧数据列表,进行“全量更新”**;

那么,来看**“增量更新”**的具体操作,此时就使用到了之前封装的 “计算两个列表差异”的工具类UserDiffCallback

  1. 调用官方APIDiffUtil.calculateDiff() 方法,即计算需要进行更新操作的列表,而此方法返回的结果则是:旧列表转换成新列表需要更新的部分。
    • 第一个参数传入自定义工具类UserDiffCallback(待计算的2个列表);
    • 第二个参数比较特殊,涉及到列表特征和算法内部计算:如果此RV的 新旧列表的约束条件、位置相同,则传入false,这样内部计算时会取消掉item移动的检测,这种检测会花费O(n^2)时间,n代表item移动、添加、删除的数量。
  2. 新列表还是赋值到旧列表;
  3. 获取到 DiffUtil 计算出的列表需要更新结果diffResult后,调用diffResultdispatchUpdatesTo()方法,即所谓的“增量更新”,将列表更换的差异分发给Adapter,使其根据接收到的差异数据做更新。(而并非“全量更新”中的所有item刷新)

总结

getChangePayload() 返回的差异数据 DiffResultDiffResult 再分发给 notifyItemRangeChanged(position, count, payload) 方法,最终交给 Adapter 的 onBindViewHolder(… List< Object > payloads) 处理。


4. 异步计算Diff

DiffUtil 一般通过这四个方法通知 Adapter 来更新数据。

  • notifyItemChanged()

  • notifyItemMoved()

  • notifyItemRangeChanged()

  • notifyItemRangeInserted()

  • notifyItemRangeRemoved()

DiffUtil 的效率?

上述例子调用calculate 计算方法是在主线程

Android系统每次刷新频率是 1帧/16.6ms,若计算时间过长反而会导致掉帧现象,本想是RV的优化?却…

因此在列表数据比较大的时候,异步计算Diff。 说到异步,这事儿就好办了,以下三个方法推荐:

  • 使用 Thread/Handler 将 DiffResult 发送到主线程;
  • 使用 RxJava/courtinous 将 calculateDiff 操作放到后台线程;
  • 使用Google 提供的 AsyncListDiffer(Executor) / ListAdapter

前两种方式不必多少,来看下第三种官方推荐,可查看:

  • https://developer.android.com/reference/android/support/v7/recyclerview/extensions/AsyncListDiffer
  • https://developer.android.com/reference/android/support/v7/recyclerview/extensions/ListAdapter

以上是关于走进RecyclerView未解之迷 ------ 原理和优化的主要内容,如果未能解决你的问题,请参考以下文章

走进RecyclerView未解之迷 ------ 原理和优化

谁有奇闻怪事未解之迷的资料

南阳理工 55 未解之谜

未解之谜

观《西游记未解之谜 》后记

Vue 世界未解之谜合集(不定时更新)