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

案例分析

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

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

  1. 先不管列表里每个Item的具体UI,首先列表是可通过下拉刷新和广播通知变化,数据应该也只能全量下发,更新频率可能特别高,列表的长度也可能很长比如几百条(一些聊天列表或者在线用户列表可能存在数据量更大的情况),如果过高频率的刷新很容易造成页面卡顿。
  2. 从UI上看有很多特征,昵称、头像、等级、各种特权等等,而且大部分情况一条协议是无法包含所有信息,可能是很多个版本需求的迭代,涉及到多个协议,比如我们项目中这种情况大部分是只返回一些uid列表,一般先将基本的数据设置到adapter里显示,同时根据uid去查询相应的各个对应的协议,异步返回结果,然后更新对应的Item,因为都是异步的数据很难先拼接好数据再更新列表。还有些情况是Item中的一些数据可能会根据通知出现变化,如下图常见的聊天列表在线状态,最新的聊天内容,时间,未读消息数等都需要根据通知更新数据,如果item中可变的数据太多,更新的代码写起来会很繁琐。

列表的性能问题

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

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

  • 即使调用再多次notifyData,列表内容不变化的时候不刷新UI,内容变化的时候只刷新需要UI更新的Item
  • 列表内容相关的异步数据或者通知需要更新列表时高效更新
  • 根据具体情形,可以禁用列表的动画。

不易用的DiffUtil

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

  • 写起来麻烦: 使用时需要实现DiffCallBack抽象类,需要实现至少四个方法这样即使一个很简单的列表也要写上很多的代码,如果列表里有多种Type的Holder,写起来就更加的臃肿耦合,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    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的帮助类,但并不能减少这些崩溃发生的概率,而且即使知道大概的原因,这些崩溃还是很难避免。

不易增、删、更新的列表

以更新列表为例:

类似需要异步请求数据的

类似通知更新数据的:

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    * 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.

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    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也同步更新

    • 一种是局部刷新:

      1
      adapter.notifyItemChanged(updateItemIdex);

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

    • 还有一种是调用全量更新:

      1
      2
      3
      4
      5
      6
      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,名称,价格都是异步或者通知变化的数据。

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

demo.gif

Feature

  • 无需自己实现Adapter,简单配置就可实现没有各种if-else判断类型的多Type视图列表
  • 使用DiffUtil来找出最小需要更新的Item集合,使用者无需做任何DiffUtil的配置即可实现高效的列表
  • 提供方便,稳定的更新、删、插入、查询方法,适用于各种非常频繁,复杂的场景(如因为异步或通知的原因同时出现插入,删除,全量设置的情况)
  • 更友好方便的异步数据更新方案

Using

基本用法

Step 1:继承BaseMutableData,主要实现areUISame(newData: AnyViewData)uniqueItemFeature()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

1
2
3
4
5
6
7
8
9
10
11
12
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:注册,显示到界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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使用不受任何限制)

增、插入、删除、修改(更新)

1
2
3
4
5
6
7
8
9
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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用来匹配变化的异步数据的对象

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

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

Step 2:定义View Holder,同基本用法

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//用于监听请求的异步数据,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也很简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)会被回调多次。如下时:

1
2
3
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

1
2
3
4
5
6
7
8
9
10
11
12
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

More

一些探讨:

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

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

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

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

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

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