美文网首页Android开发经验谈Android开发Android技术知识
优雅地实现一个高效、异步数据实时刷新的列表

优雅地实现一个高效、异步数据实时刷新的列表

作者: Android开发架构 | 来源:发表于2019-02-13 19:55 被阅读18次

    前言

    Android 的业务开发中。列表需求很常见也很重要的部分,列表承载的信息多,涉及的的协议多,布局也多,尤其一些复杂的列表,不管是用 ListView 还是 RecyclerView,使用不当会带来很多的性能问题和后期的维护问题,形成一套规范的,高性能的列表开发模式很有必要。

    案例分析

    用一些案例说明一下吧(只是用一些 App 里的截图来做类比,并不知其协议类型和实现方式)

    类似的列表不容易解决的主要在两个方面:

    1. 先不管列表里每个 Item 的具体 UI,首先列表是可通过下拉刷新和广播通知变化,数据应该也只能全量下发,更新频率可能特别高,列表的长度也可能很长比如几百条(一些聊天列表或者在线用户列表可能存在数据量更大的情况),如果过高频率的刷新很容易造成页面卡顿。

    2. 从 UI 上看有很多特征,昵称、头像、等级、各种特权等等,而且大部分情况一条协议是无法包含所有信息,可能是很多个版本需求的迭代,涉及到多个协议,比如我们项目中这种情况大部分是只返回一些 uid 列表,一般先将基本的数据设置到 adapter 里显示,同时根据 uid 去查询相应的各个对应的协议,异步返回结果,然后更新对应的Item,因为都是异步的数据很难先拼接好数据再更新列表。还有些情况是 Item 中的一些数据可能会根据通知出现变化,如下图常见的聊天列表在线状态,最新的聊天内容,时间,未读消息数等都需要根据通知更新数据,如果 item 中可变的数据太多,更新的代码写起来会很繁琐。

    列表的性能问题

    使用过页面卡顿工具 systrace 分析页面卡顿或者超时的应该有一定的经验就是如果页面存在比较复杂的列表,在一些低端机,有的甚至配置较好的手机上会出现卡顿情况,及时感觉不到卡顿,用 systrace 应该也能看到相对其他 View 比较多的掉帧(也可称为 Jank),谷歌根据比较多的一些 app 的数据也有类似的结论,列表的使用不当是很多卡顿的来源

    systrace 地址:

    https://developer.android.com/studio/command-line/systrace

    列表容易造成卡顿主要原因是相对其他 View,列表承载的内容多,更新又比较频繁,而且还有holder的的重建复用以及无效刷新等带来的很多的子 Item View 的 UI 刷新,列表变化频繁(如删除、移动、新增等),动画会带来很大的UI性能消耗,根据原因主要从下面几个个方面来提高列表的性能:

    • 即使调用再多次 notifyData,列表内容不变化的时候不刷新 UI,内容变化的时候只刷新需要 UI 更新的 Item

    • 列表内容相关的异步数据或者通知需要更新列表时高效更新

    • 根据具体情形,可以禁用列表的动画。

    不易用的DiffUtill

    DiffUtil 是 support-v7:24.2.0 中的新工具类,它用来比较两个数据集,寻找出旧数据集-》新数据集的最小变化量。并不是一个新的工具,这里如果只是介绍如何使用 DiffUtil也没任何意义。DiffUtil 虽然提供很久,能高性能的刷新列表,但是其使用情况上来看,可能并不理想,主要原因是:非常不易使用

    • 写起来麻烦: 使用时需要实现 DiffCallBack 抽象类,需要实现至少四个方法这样即使一个很简单的列表也要写上很多的代码,如果列表里有多种 Type 的 Holder,写起来就更加的臃肿耦合,
    public abstract static class Callback {
        public Callback() {
        }
        public abstract int getOldListSize();
        public abstract int getNewListSize();
        public abstract boolean areItemsTheSame(int var1, int var2);
        public abstract boolean areContentsTheSame(int var1, int var2);
        @Nullable
        public Object getChangePayload(int oldItemPosition, int newItemPosition) {
            return null;
        }
    }
    

    容易崩溃: DiffCallBack计算数据差量时需要放到异步线程,稍有不慎容易崩溃,

    java.lang.IndexOutOfBoundsException,Inconsistency detected. Invalid view holder adapter positionViewHolder{65752ee position=2 id=-1, oldPos=2, pLpos:2 scrap [attachedScrap],
    java.lang.IndexOutOfBoundsException Inconsistency detected. Invalid item position 16(offset:16).state:64, 
    java.lang.IllegalArgumentException: Scrapped or attached views may not be recycled. isScrap:false isAttached:true
    

    类似上面的崩溃相信使用过 DiffUtil 应该都不陌生,根本原因是列表的数据变化的时候没有立刻调用 adapter 刷新列表,而 DiffUtil 的计算需要放在异步线程来处理,需要操作数据和展示数据的在不用的线程,同步比较难控制,尤其是在列表长度变化的时候又更新比较频繁的时候。虽然提供 AsyncListDiffer 的帮助类,但并不能减少这些崩溃发生的概率,而且即使知道大概的原因,这些崩溃还是很难避免。

    不易增、删、更新的列表

    以更新列表为例:

    类似需要异步请求数据的

    类似通知更新数据的:

    实现更新的方式可能有很多种方式,但需要注意:

    不要在 Holder 里监听数据变化,不管是类似 EventBus 的广播还是 LiveData,虽然如果项目里用到LiveData,可能在holder里通过livedate.observer(context,Observer) 很方便监听回调,但是因为 Holder 的没有明显的生命周期,可能会频繁被复用以及 Holder 的回收不可见等状态不可控,如果是使用 LiveData,导致被频繁绑定 observer,或者出现内存泄漏等各种难以定位的问题。下面是 Google 关于列表 View 的使用建议

    <pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; color: rgb(51, 51, 51); font-size: 17px; font-style: normal; font-variant: normal; font-weight: normal; letter-spacing: 0.544000029563904px; line-height: 27.2000007629395px; orphans: auto; text-align: justify; text-indent: 0px; text-transform: none; widows: 1; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255);">

    • When the async code is done, you should update the data, not the views.

    • After updating the data, tell the adapter that the data changed.

    • The RecyclerView gets note of this and re-renders your view.

    • When working with recycling views (ListView or RecyclerView),

    • you cannot know what item a view is representing. In your case,

    • that view gets recycled before the async work is done and

    • is assigned to a different item of your data.

    • So never modify the view. Always modify the data and notify the adapter.

    • bindView should be the place where you treat these cases.

    </pre>

    简单来说就是异步数据结果回来不应该在 Holder 里直接改变 view 的状态,而是应该改变数据,然后通过 adapter 来改变 View。 主要原因还是上面说的 Holder 创建与销毁,可见不可见等状态很难控制。

    不在 Holder 里更新就只能在外部更新,但如果使用了 DiffUtil,外部更新数据不容易实现。首先异步数据获取到后或者广播通知列表中的数据需要变化时,找到需要的变更项更改数据,类型下面的实现

    val allDatas = ...
    
    //广播通知变化,或者异步请求得到新居后更新列表中的数据
    
    fun onDataChanged(data:T) {
    
    val updateItemIdex = findIndex(object.dataFeture)
    
    allDatas.set(updateItemIdex,data)
    
    or //在全部数据中找到需要变更的数据,更改数据中的某些值。这种更常见
    
    val needChangeData = findData(object.dataFeture)
    
    needChangeData.info = data.info
    
    }
    
    fun findData(dataFeture : Long):T {
    
    return allDatas.find...
    
    }
    
    //在全部数据中找到需要变更的数据位置Index,替换数据
    
    fun findIndex(dataFeture : Long):Index {
    
    return allDatas.find...
    
    }
    

    把列表数据更新后,需要让 UI 的 Item 也同步更新

    • 一种是局部刷新:
    adapter.notifyItemChanged(updateItemIdex);
    

    直接刷新单个 Item 很容易出现不同线程同时处理数据带来的崩溃问题等,再具体点这种情况是此时有类似 mAdapter.setDatas(mDatas) 刷新全量列表的行为,而此时的新的列表的长度和原来的不同,就有可能出现上述的崩溃。全量和局部可能都是基于通知或异步数据的结果所以很难控制先后顺序。

    • 还有一种是调用全量更新:
    DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallBack(mDatas, newDatas), true);
    diffResult.dispatchUpdatesTo(mAdapter);
    mDatas = newDatas;
    mAdapter.setDatas(mDatas);
    

    使用了 DiffUtil 的原因,可能会觉得不会刷新所有的 UI,这样性能会提高。但这样使用会出现新的问题,这种方式有个很严重的问题就是每次都要进行 DiffCallBack 的差分运算,虽然可以异步线程里处理,但是数据量较大,异步数据较多,更新频繁的时候会导致cpu被大量占用,从而带来更严重的界面卡顿问题。

    还有很麻烦的地方就是一个异步结果返回更改单个 Item 里的数据时,这时很有可能你看不到列表的更新。StackOverflow 有类似的问题:Update single item in RecyclerView with DiffUtil。因为每次更新的时候你需要 new 一个新的对象,然后将不需要改变的内容复制,需要改变的进行赋值。而不是像上面那样找到原来的数据进行更改局部,因为原数据对象已经在源数据列表里,虽然创建新的列表,但在更新单个对象的时候因为是同一个对象所以旧的数据列表肯定同步更新,导致做差分对比的结果肯定是不需要更新 UI (因为是同一个对象),所以只能创建新的对象,这对更新频繁和每个 Item 有很多异步返回数据的列表来说是个很大的消耗,写起来也会非常非常繁琐。

    同样的,在一些频繁插入、删除、增加数据的列表项使用不当也有容易出现各种各样的问题。

    diffAdapter:一种高效、高性能的方案

    diffadapter就是根据实际项目中各种复杂的列表需求,同时为了解决 DiffUtil 使用不方便,容易出错而实现的一个高效,高性能的列表库,侵入性低,方便接入,致力于将列表需求的开发精力用于具体的 Item Holder 上,而不用花时间在一些能通用的和业务无关的地方。使用 DiffUtil 来做最小更新,屏蔽外部调用 DiffUtil 的接口。只用实现简单的数据接口和展示数据的 Holder,不用自己去实现 Adapter 来管理数据和 Holder 之间的关系,不用考虑 DiffUtil 的实现细节,就能快速的开发出一个高性能的复杂列表需求。

    先看下 demo 的效果,图像 url,名称,价格都是异步或者通知变化的数据。

    进行随机的全量数显,局部刷新,插入,删除等操作。

    diffadapter 地址:

    https://github.com/SilenceDut/diffadapter

    Feature

    • 无需自己实现 Adapter,简单配置就可实现没有各种 if-else 判断类型的多 Type 视图列表

    • 使用 DiffUtil 来找出最小需要更新的 Item 集合,使用者无需做任何 DiffUtil 的配置即可实现高效的列表

    • 提供方便,稳定的更新、删、插入、查询方法,适用于各种非常频繁,复杂的场景(如因为异步或通知的原因同时出现插入,删除,全量设置的情况)

    • 更友好方便的异步数据更新方案

    基本用法

    Step1:继承BaseMutableData,主要实现areUISame(newData: AnyViewData) 和 uniqueItemFeature()

    class AnyViewData(var id : Long ,var any : String) : BaseMutableData<AnyViewData>() {
        companion object {
             //数据展示的layout,也是和Holder一一对应的唯一特征
             const val VIEW_ID = R.layout.holder_skins
        }
        override fun getItemViewId(): Int {
    
            return VIEW_ID
        }
    
        override fun areUISame(newData: AnyViewData): Boolean {
            // 判断新旧数据是否展示相同的UI,如果返回True,则表示UI不需要改变,不会updateItem
    
            return this.any == newData.any
        }
        override fun uniqueItemFeature(): Any {
            // 返回可以标识这个Item的特征,比如uid,id等,用来做UI差分已经可以动态
            return this.id
        }
    
    }
    

    Step 2:继承 BaseDiffViewHolder<T extends BaseMutableData>,泛型类型传入上面定义的 AnyViewData

    class AnyHolder(itemView: View, recyclerAdapter: DiffAdapter): BaseDiffViewHolder<AnyViewData>( itemView,  recyclerAdapter){
    
        override fun getItemViewId(): Int {
            return AnyViewData.VIEW_ID
        }
        override fun updateItem(data: AnyViewData, position: Int) {
            根据AnyViewData.VIEW_ID对应的layout来更新Item
            Log.d(TAG,"updateItem $data")
        }
    }
    

    Step 3:注册,显示到界面

    val diffAdapter = DiffAdapter(this)
    //注册类型,不分先后顺序
    diffAdapter.registerHolder(AnyHolder::class.java, AnyViewData.VIEW_ID)
            diffAdapter.registerHolder(AnyHolder2::class.java, AnyViewData2.VIEW_ID)
    diffAdapter.registerHolder(AnyHolder3::class.java, AnyViewData3.VIEW_ID)
    val linearLayoutManager = LinearLayoutManager(this)
    recyclerView.layoutManager = linearLayoutManager
    recyclerView.adapter = diffAdapter
    //监听数据变化
    fun onDatached(datas : List<BaseMutableData<*>>) {
        diffAdapter.datas = adapterListData
    }
    

    只需要上面几步,就可以完成如类似下图的多 type 列表,其中数据源里的每个BaseMutableData的getItemViewId() 决定着用哪个 Holder 展示 UI。
    (以上均用 kotlin 实现,Java 使用不受任何限制)

    增加、删除、插入、更新

    public <T extends BaseMutableData> void addData(T data) 
    public void deleteData(BaseMutableData data)
    public void deleteData(int startPosition, int size)
    void insertData(int startPosition ,List<? extends BaseMutableData> datas)
    public void updateData(BaseMutableData newData)
    

    上述接口在调用的时机,频率都很复杂的场景下也不会引起崩溃。

    使用 updateData(BaseMutableData newData)时,newData 可以是新 new 的对象,也可以是修改后的原对象,不会出现使用 DiffUtil 更新单个数据无效的问题。

    高阶用法

    基本用法中Data和Holder绑定的模式并没什么特殊之处,早在两年前的项目KnowWeather 就已经用上这种思想,现在只是结合 DiffUtil 以及其他的疑难问题解决方案将其开源,diffadapter 最核心的地方在于高性能和异步获取数据或者通知数据变化时列表的更新上。

    多数据源异步更新

    以一个类似的 Item 为例,这里认为服务器返回的数据列表只包含uid,也就是 List<Long> uids,个人资料,等级,贵族等都属于不同的协议。下面展示的是异步获取个人资料展示的头像和昵称的情况,其他的可以类比。

    Step 1:定义 ViewData

    data class ItemViewData(var uid:Long, var userInfo: UserInfo?, var anyOtherData: Any ...) : BaseMutableData<ItemViewData>() {
        companion object {
            const val VIEW_ID = R.layout....
        }
        override fun getItemViewId(): Int {
            return VIEW_ID
        }
        override fun areUISame(newData: UserInfo): Boolean {
            return this.userInfo?.portrait == newData.userInfo?.portrait && this.userInfo?.nickName == newData.userInfo?.nickName && this.anyOtherData == newData.anyOtherData
        }
        override fun uniqueItemFeature(): Any {
           return this.uid
        }
    }
    

    数据类 ItemViewData 包含所有需要显示到 Item 上的信息,这里只处理和个人资料相关的数据,anyOtherData: Any ...表示 Item 所需的其他数据内容

    BaseMutableData里有个默认的方法allMatchFeatures(@NonNull Set<Object> allMatchFeatures),不需要显示调用,这里当外部有异步数据变化时,提供当前BaseMutableData 用来匹配变化的异步数据的对象

    public void appendMatchFeature(@NonNull Set<Object> allMatchFeatures) {
    
    allMatchFeatures.add(uniqueItemFeature());
    
    }
    

    默认添加了 uniqueItemFeature(),allMatchFeatures 是个 Set,可以重写方法添加多个用来匹配的特征。

    Step 2:定义 View Holder

    同基本用法

    Step 3:监听数据变化,更新列表

    //用于监听请求的异步数据,userInfoData变化时与此相关的数据
    private val userInfoData = MutableLiveData<UserInfo>()
    //在adapter里监听数据变化
    diffAdapter.addUpdateMediator(userInfoData, object : UpdateFunction<UserInfo, ItemViewData> {
        override fun providerMatchFeature(input: UserInfo): Any {
            return input.uid
        }
        override fun applyChange(input: UserInfo, originalData: ItemViewData): ItemViewData {
    
           return originalData.userInfo = input
    
        }
    })
    // 任何通知数据获取到的通知
    fun asyncDataFetch(userInfo : UserInfo) {
        userInfoData.value = userInfo
    }
    

    这样当 asyncDataFetch 接收到数据变化的通知的时候,改变 userInfoData 的值,adapter 里对应的 Item 就会更新。其中找到 adapter 中需要更新的 Item 是关键部分,主要由实现 UpdateFunction 来完成,实现 UpdateFunction 也很简单。

    interface UpdateFunction<I,R extends BaseMutableData> {
        /**
         * 匹配所有数据,及返回类型为R的所有数据
         */
        Object MATCH_ALL = new Object();
        /**
         * 提供一个特征,用来查找列表数据中和此特征相同的数据
         * @param input 用来提供查找数据和最终改变列表的数据
         * @return 用来查找列表中的数据的特征项
         */
        Object providerMatchFeature(@NonNull I input);
        /**
         * 匹配到对应的数据,如果符合条件的数据有很多个,可能会被回调多次
         * @param input 是数据改变的部分数据源
         * @param originalData 需要改变的数据项
         * @return 改变后的数据项
         */
        R applyChange(@NonNull I input,@NonNull R originalData);
    }
    

    UpdateFunction 用来提供异步数据获取到后数据用来和列表中的数据匹配的规则和根据规则找到需要更改的对象后如果改变原对象,剩下的更新都由 diffadapter 来处理。如果符合条件的数据有很多个,applyChange(@NonNull I input,@NonNull R originalData)会被回调多次。如下时:

    Object providerMatchFeature(@NonNull I input) {
        return UpdateFunction.MATCH_ALL
    }
    

    applyChange 回调的次数就和列表中的数据量一样多。

    如果同一种匹配规则providerMatchFeature对应多种Holder类型,UpdateFunction<I,R>的返回数据类型R就可以直接设为基类的 BaseMutableData,然后再 applyChange 里在具体根据类型来处理不同的 UI。

    最高效的Item局部更新方式 —— payload

    DiffUtil 能让一个列表中只更新部分变化的 Item,payload 能让同一个 Item 只更新需要变化的 View,这种方式非常适合同一个 Item 有多个异步数据源的,同时又对性能有更高要求的列表。

    Step 1:重写 BaseMutableData 的 appendDiffPayload

    data class ItemViewData(var uid:Long, var userInfo: UserInfo?, var anyOtherData: Any ...) : BaseMutableData<ItemViewData>() {
        companion object {
            const val KEY_BASE_INFO = "KEY_BASE_INFO"
            const val KEY_ANY = "KEY_ANY"
        }
    
        ...
    
        /**
         * 最高效的更新方式,如果不是频繁更新的可以不实现这个方法
         */
        override fun appendDiffPayload(newData: ItemViewData, diffPayloadBundle: Bundle) {
            super.appendDiffPayload(newData, diffPayloadBundle)
            if(this.userInfo!= newData.userInfo) {
                diffPayloadBundle.putString(KEY_BASE_INFO, KEY_BASE_INFO)
            }
            if(this.anyData != newData.anyData) {
                diffPayloadBundle.putString(KEY_ANY, KEY_ANY)
            }
            ...
        }
    }
    

    默认用 Bundle 存取变化,无需存具体的数据,只需类似设置标志位,表明 Item 的哪部分数据发生了变化。

    Step 2 :需要重写 BaseDiffViewHolder 里的 updatePartWithPayload

    class ItemViewHolder(itemViewRoot: View, recyclerAdapter: DiffAdapter): BaseDiffViewHolder<ItemViewData>( itemViewRoot,  recyclerAdapter){
    
        override fun updatePartWithPayload(data: ItemViewData, payload: Bundle, position: Int) {
        if(payload.getString(ItemViewData.KEY_BASE_INFO)!=null) {
            updateBaseInfo(data)
        }
        if(payload.getString(ItemViewData.KEY_ANY)!=null) {
            updateAnyView(data)
        }
    }
    

    根据变化的标志位,更新 Item 中需要变化部分的 View

    总结

    一些探讨:

    1. 为什么没有提供类似 onItemClickLisener 用来处理点击事件的接口

      不是因为不好实现,其实现实起来非常简单。首先尝试去理解为什么RecyclerView.Adapter 没有提供像 listview 那样的点击事件的 listener,我的理解是大而全的公用点击监听不是一个好的设计方式,尤其对于多类型的view来说,因为点击的是不同的 holder,要在回调里根据类型来处理不同的逻辑,少不了各种 if-else 的代码块,不同 holder 相关的数据,逻辑耦合到一块,试想如果有四五种类型,处理统一点击回调的地方是多大的一块代码,后期的维护又是一个问题。我认为好的方式应该是在各自的holder的构造函数里来各自处理,每个holder 都有自己的数据和类型,很好的隔离开不同类型数据的耦合,每个 holder 各司其职:显示数据,监听点击,维护方便。

    2. 为什么没有下拉刷新、加载更多、动画、分割线等更多的功能

      首先 diffadapter 主要就是为了提供高性能刷新,异步数据更新,高效的配置多类型列表的功能,这也是绝大多数列表最常见的功能,像上面说的那些功能以及onItemClickLisener 都是一些额外的添加项,不想做一个为了看起来更多功能但没有任何难度,堆积代码的开源库,不想为了看起来大而全来吸引别人使用。就是职责很单一,目的很明确,diffadapter 侵入性很低,不影响任何其他功能的引入,包括不限于上面提到的那些。而且上面提到的那些都有很多很好的开源库,你可以根据任何自己的需要来定制。

    更详细,多样的使用方式和细节见 diffadapter demo,有详细的 demo 和使用说明,demo 用 kotlin 实现,使用了 mvvm 和模块化的框架方式。

    这种方式也是目前能想到的比较好的异步数据更新列表的方式,非常欢迎一起探讨更多的实现方式。

    免费获取安卓开发架构的资料(包括Fultter、高级UI、性能优化、架构师课程、 NDK、Kotlin、混合式开发(ReactNative+Weex)和一线互联网公司关于android面试的题目汇总可以加:936332305 / 链接:点击链接加入【安卓开发架构】:https://jq.qq.com/?_wv=1027&k=515xp64

    相关文章

      网友评论

        本文标题:优雅地实现一个高效、异步数据实时刷新的列表

        本文链接:https://www.haomeiwen.com/subject/rafjeqtx.html