Android DiffUtil 封装|深拷贝

5,510 阅读9分钟

痛点以及问题

RecyclerView已经逐渐成为一个安卓开发写一个滑动布局必备的控件了,但是项目中用的大部分还是notifyDataSetChanged ,而在方法注释上其实更推荐我们直接使用增删改换这四个方法。

       /**
         * Notify any registered observers that the data set has changed.
         *
         * <p>There are two different classes of data change events, item changes and structural
         * changes. Item changes are when a single item has its data updated but no positional
         * changes have occurred. Structural changes are when items are inserted, removed or moved
         * within the data set.</p>
         *
         * <p>This event does not specify what about the data set has changed, forcing
         * any observers to assume that all existing items and structure may no longer be valid.
         * LayoutManagers will be forced to fully rebind and relayout all visible views.</p>
         *
         * <p><code>RecyclerView</code> will attempt to synthesize visible structural change events
         * for adapters that report that they have {@link #hasStableIds() stable IDs} when
         * this method is used. This can help for the purposes of animation and visual
         * object persistence but individual item views will still need to be rebound
         * and relaid out.</p>
         *
         * <p>If you are writing an adapter it will always be more efficient to use the more
         * specific change events if you can. Rely on <code>notifyDataSetChanged()</code>
         * as a last resort.</p>
         *
         * @see #notifyItemChanged(int)
         * @see #notifyItemInserted(int)
         * @see #notifyItemRemoved(int)
         * @see #notifyItemRangeChanged(int, int)
         * @see #notifyItemRangeInserted(int, int)
         * @see #notifyItemRangeRemoved(int, int)
         */
        public final void notifyDataSetChanged() {
            mObservable.notifyChanged();
        }

但是真实开发中,如果只是分页增加可能还简单点,我们可以用notifyItemRangeInserted去做插入操作。但是数据结构一旦发生增删替换等等,情况就会变得很复杂。谷歌也考虑到这个问题,直接让开发去做数据内容变更判断是不友善的,所以在support包中提供了DiffUtil工具给我们去做数据变更的后序开发。

Android AAC中的Paging底层也是基于DiffUtil计算的Item差异,但是我们不展开讲Paging,原因的话后面会逐步分析这个问题。

基于DiffUtil封装一下

我在文章开动之前也特地去查了一些相关文章内容,我个人看法写的都还是有点微妙的。先介绍下原理,之后我们在说一些痛点。

 	public static DiffResult calculateDiff(@NonNull Callback cb, boolean detectMoves) {
 	 ....
 	}
     /**
     * A Callback class used by DiffUtil while calculating the diff between two lists.
     */
    public abstract static class Callback {
 /**
         * Returns the size of the old list.
         *
         * @return The size of the old list.
         */
        public abstract int getOldListSize();

        /**
         * Returns the size of the new list.
         *
         * @return The size of the new list.
         */
        public abstract int getNewListSize();

        /**
         * Called by the DiffUtil to decide whether two object represent the same Item.
         * <p>
         * For example, if your items have unique ids, this method should check their id equality.
         *
         * @param oldItemPosition The position of the item in the old list
         * @param newItemPosition The position of the item in the new list
         * @return True if the two items represent the same object or false if they are different.
         */
        public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition);

        /**
         * Called by the DiffUtil when it wants to check whether two items have the same data.
         * DiffUtil uses this information to detect if the contents of an item has changed.
         * <p>
         * DiffUtil uses this method to check equality instead of {@link Object#equals(Object)}
         * so that you can change its behavior depending on your UI.
         * For example, if you are using DiffUtil with a
         * {@link RecyclerView.Adapter RecyclerView.Adapter}, you should
         * return whether the items' visual representations are the same.
         * <p>
         * This method is called only if {@link #areItemsTheSame(int, int)} returns
         * {@code true} for these items.
         *
         * @param oldItemPosition The position of the item in the old list
         * @param newItemPosition The position of the item in the new list which replaces the
         *                        oldItem
         * @return True if the contents of the items are the same or false if they are different.
         */
        public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition);
    }

从源码分析,Callback的注释中有明确的说明,DiffUtil比较的是两个List结构。DiffUtil通过CallBack的接口(只是简单的介绍,其过程会更复杂),首先比较两个List中的size,然后逐个比较元素是不是同一个条目,也就是同一个Item,如果是同一个Item之后则比较同一个Item内的元素是不是也相同,之后生成一份DiffResult结果,开发根据这个结果进行后序的增删改等操作。

其中比较元素相同的方法是areContentsTheSame,正常情况下我们会通过模型内部定义的Id来作为模型的唯一标识符,通过这个标识符去判断这个元素是不是相同。

比较同一个元素的内容是不是相同的方法是areItemsTheSame,我直接使用的Object内的equals方法进行内容相同的判断。

痛点以及问题

首先我们需要的是两个List,其中一个代表旧数据(OldList),一个代表变更的数据(NewList)。然后两个List比较去做差异性。之后根据差异性结果来刷新adapter的内容。

我们一般在写Adapter的时候其实都是直接把List丢进来,之后就直接调用notifyDataSetChanged,这没有两个List怎么搞?很多博主的方法就是new一个新的数组之后把元数据放到这个新数组,那么我的vm或者presenter只要操作元数据就好了,这样数据变更之后我调用下刷新方法,之后让DiffUtil去做数据差异就好了。

看似我们解决了生成两个List的问题,但是因为这次拷贝只是浅拷贝,所以当元素进行areItemsTheSame不就还是自己和自己比较吗,所以只能说浅拷贝的情况下也不能完美的使用DiffUtil。

数据深拷贝

深度拷贝可以从源对象完美复制出一个相同却与源对象彼此独立的目标对象。

Paging内部的实现DiffUtil的呢,当传入List之后会生成一个快照ListSnapshotPagedList,快照就是OldList,然后对传入的List和这个快照版本进行比较,而快照的OldList就是元数据的深拷贝。其中无论是快照还是源数据,都是从DataSource获取的内容。

Pacel进行数据复制

了解过跨进程通信的老哥应该知道Parcelable的类型内容在传输中是拷贝速度是最快的,那么Parcelable是通过什么做数据拷贝的呢?

Pacel就是负责做Parcelable数据拷贝的。当不涉及到跨进程的情况下,Pacel会在内存中开辟出一个单独的区域存放Parcelable的数据内容,之后我们可以通过Parcel进行数据拷贝的操作。

            val parcelable = itemsCursor?.get(oldPosition) as Parcelable
            val parcel = Parcel.obtain()
            parcelable.writeToParcel(parcel, 0)
            parcel.setDataPosition(0)
            val constructor = parcelable.javaClass.getDeclaredConstructor(Parcel::class.java)
            constructor.isAccessible = true
            val dateEntity = constructor.newInstance(parcel) as T
            mData?.let {
                it.add(oldPosition, dateEntity)
            }
            parcel.recycle()

以上就是我拿来做数据拷贝的操作了。简单的说先构造一个Parcel.obtain()对象,然后调用源数据的writeToParcel方法,将Parcel传入到元数据内进行一次内存的粘贴操作。而每个实现了Parcelable接口的对象都有一个含有Parcel的构造函数,我们通过反射的调用这个构造函数,这样就可以生成一个新的拷贝对象。

项目分析

项目github仓库地址

先讲下为什么不用Paging,因为Paging要更换所有的Adapter,之后再传入一个Diff的Callback,这样才能用Paging的功能。但是谁家项目不封装个BaseAdapter啊,里面说不定还有header和footer之类的操作。如果要更改继承关系再传入一个参数,可能你的同事要跳起来打你膝盖了。

存粹我个人的看法哦,如果DiffUtil可以用组合的方式和当前的Adapter一起使用,这样的话是不是改造成本就是相对来说比较低的了。我们DiffUtil内部只要能完成数据拷贝,之后进行数据比较,之后通知到adapter的变更,这样我就可以根据我的需要决定那些可以先升级到Diff,哪些可以不变更。

我的封装思路是这样的,首先Diff比较的是数据模型,那么我们是不是可以对模型层进行一次增强,将其中的唯一值以及Equals方法进行抽象以及适配。

所以仓库的核心只有两个,第一个是组合,第二个是模型层的增强。

DiffHelper 组合类


class DiffHelper<T> {

    private var itemsCursor: MutableList<T>? = null
    private var mData: CopyOnWriteArrayList<T>? = null
    var diffDetectMoves = true
    var callBack: ListUpdateCallback? = null


    private val mMainThreadExecutor: Executor = MainThreadExecutor()

    private val mBackgroundThreadExecutor: Executor = Executors.newFixedThreadPool(2)

    private class MainThreadExecutor internal constructor() : Executor {
        val mHandler = Handler(Looper.getMainLooper())
        override fun execute(command: Runnable) {
            mHandler.post(command)
        }
    }

    fun setData(itemsCursor: MutableList<T>?, ignore: Boolean = false) {
        this.itemsCursor = itemsCursor
        itemsCursor?.apply {
            mBackgroundThreadExecutor.execute {
                if (mData == null) {
                    copyData()
                }
                if (!ignore) {
                    mMainThreadExecutor.execute {
                        callBack?.onInserted(0, itemsCursor.size)
                    }
                }
            }
        }
    }

    private fun copyData() {
        try {
            itemsCursor?.apply {
                if (isNotEmpty()) {
                    if (this[0] is Parcelable) {
                        mData = CopyOnWriteArrayList()
                    } else {
                        mData = CopyOnWriteArrayList()
                        for (entity in this) {
                            mData?.add(entity)
                        }
                        return
                    }
                } else {
                    mData = CopyOnWriteArrayList()
                    return

                }
                for (entity in this) {
                    val parcel = Parcel.obtain()
                    (entity as Parcelable).writeToParcel(parcel, 0)
                    parcel.setDataPosition(0)
                    val constructor = entity.javaClass.getDeclaredConstructor(Parcel::class.java)
                    constructor.isAccessible = true
                    val dateEntity = constructor.newInstance(parcel) as T
                    mData?.add(dateEntity)
                    parcel.recycle()
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    fun notifyItemChanged() {
        mBackgroundThreadExecutor.execute {
            val diffResult = diffUtils()
            mMainThreadExecutor.execute {
                diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
                    override fun onInserted(position: Int, count: Int) {
                        callBack?.onInserted(position, count)
                    }

                    override fun onRemoved(position: Int, count: Int) {
                        callBack?.onRemoved(position, count)
                    }

                    override fun onMoved(fromPosition: Int, toPosition: Int) {
                        callBack?.onMoved(fromPosition, toPosition)
                    }

                    override fun onChanged(position: Int, count: Int, payload: Any?) {
                        callBack?.onChanged(position, count, payload)
                    }
                })
            }
        }
    }

    @Synchronized
    private fun diffUtils(): DiffUtil.DiffResult {
        val diffResult =
                DiffUtil.calculateDiff(BaseDiffCallBack(mData, itemsCursor), diffDetectMoves)
        copyData()
        return diffResult

    }

    fun getItemSize(): Int {
        return itemsCursor?.size ?: 0
    }

    fun <T> getEntity(pos: Int): T? {
        return if (itemsCursor?.size ?: 0 <= pos || pos < 0) null else itemsCursor?.get(pos) as T
    }

}

我先抽象出一个代理方法,将数据源内容包裹起来,对外提供四个方法,setData, notifyItemChanged,getItemSize,getEntity这四个方法。

  1. setData 设置数据源方法,设置数据源的同时进行数据第一次拷贝操作。
  2. notifyItemChanged 该方法直接调用DiffUtil,当数据源内容发生变更时,调用该方法,会通过接口回掉的方式通知Adapter的变更。
  3. getItemSize adapter获取当前数据源的长度,替换掉adapter内部的size方法。
  4. getEntity 获取数据实体类型。

抽象统一的Model

class BaseDiffCallBack(private val oldData: List<*>?, private val newData: List<*>?) : DiffUtil.Callback() {

    override fun getOldListSize(): Int {
        return oldData?.size ?: 0
    }

    override fun getNewListSize(): Int {
        return newData?.size ?: 0
    }

    override fun areItemsTheSame(p0: Int, p1: Int): Boolean {
        val object1 = oldData?.get(p0)
        val object2 = newData?.get(p1)
        if (object1 == null || object2 == null)
            return false
        return if (object1 is IDifference && object2 is IDifference) {
            TextUtils.equals(object1.uniqueId, object2.uniqueId)
        } else {
            object1 == object2
        }
    }


    override fun areContentsTheSame(p0: Int, p1: Int): Boolean {
        val object1 = oldData?.get(p0)
        val object2 = newData?.get(p1)
        return if (object1 is IEqualsAdapter && object2 is IEqualsAdapter) {
            object1 == object2
        } else {
            true
        }
    }

}

这个类就是抽象出来的模型层比较的。

  1. areItemsTheSame方法比较的是模型层是不是实现了IDifference,通过比较这个接口来进行唯一值比较。
  2. areContentsTheSame 则是根据当前模型层是不是实现了IEqualsAdapter,如果没有实现则标示不需要比较值内容,如果有则直接比较值内容。

TODO

其实一个Diff调用的时间相对来说是比较耗时的,这一块我没咋搞,讲道理是可以用async的方式去实现的。可以去优化一下这方面。

刚刚参考了下AsyncListDiffer写完了,有的抄的情况下,还是很容易的。

如何使用

数据模型的定义,首先必须实现Parcelable(深拷贝的逻辑),然后必须实现IDifference接口,主要来辨别数据主体是否发生变更。

(可选) IEqualsAdapter实现了该接口之后当数据内容发生变更,也会通知Adapter刷新。我们可以通过IDEA插件或者kt的data去实现模型的equals方法,去做元素内容相同的比较,毕竟equals方法写起来还是很恶心的。

data class TestEntity(var id: Int = 0,
                      var displayTime: Long = 0,
                      var text: String? = Random().nextInt(10000).toString()) : Parcelable, IDifference, IEqualsAdapter {

    override val uniqueId: String
        get() = id.toString()

    fun update() {
        displayTime = System.currentTimeMillis()
        text = "更新数据"
    }


    constructor(source: Parcel) : this(
            source.readInt(),
            source.readLong(),
            source.readString()
    )

    override fun describeContents() = 0

    override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) {
        writeInt(id)
        writeLong(displayTime)
        writeString(text)
    }

    companion object {
        @JvmField
        val CREATOR: Parcelable.Creator<TestEntity> = object : Parcelable.Creator<TestEntity> {
            override fun createFromParcel(source: Parcel): TestEntity = TestEntity(source)
            override fun newArray(size: Int): Array<TestEntity?> = arrayOfNulls(size)
        }
    }
}

初始化并传入数据,并设置数据刷新回掉,如果你有header或者别的话自己定义一个。

     val diffHelper: DiffHelper<Any> = DiffHelper()
     diffHelper.callBack = SimpleAdapterCallBack(this)
     diffHelper.setData(items)

当list发生变化(任意变化增删改都行),调用数据刷新。

   diffHelper.notifyItemChanged()

完了

文章的结尾我打算给大家讲个故事,从前有个太监