用APT让DiffUtil自动比较差异

在Android开发中,常常使用含列表的UI,基本选择RecyclerView做为列表控件。针对列表刷新简化,Google提供了DiffUtil工具,根据数据的变化指定性的更新UI。开发者不再需要查找需要更新的是哪个Item。

但是DiffUtil提供了判断的接口,需要开发者自行根据自己的item Model来实现判断依据。用过的小伙伴也许发现了,这个用起来似乎比较麻烦,特别是在列表多类型Item的情况下,问题将变得费劲。本文将采用APT技术让判断变得简单。

如果不想了解原理,可以跳过方案设计,只看解决思路如何使用

下面先简单介绍一下 DiffUtil 相关的:

小葵花课堂

DiffUtil 是 androidx.recyclerview.widget 下的一个工具类,一般我们不需要直接使用它,而是使用封装好的androidx.recyclerview.widget.ListAdapter,内部使用了DiffUtil的功能。开发者需要继承并实现DiffUtil.ItemCallback 抽象类的三个方法:

 public abstract static class ItemCallback<T> {
        // 判断是不是同一个Item,注意不是指同一类型,而是同一行数据。
        public abstract boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem);
        // 当areItemsTheSame返回true时,判断item的内容是否相同。
        public abstract boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem);
        // 当areContentsTheSame返回false时,找出变化的东西并返回它。
        // 返回的“东西”将在RecyclerView的方法:
        // onBindViewHolder(holder: BindingViewHolder, position: Int, payloads: MutableList<Any>)
        // 第三个参数里面。可以做Item里面局部刷新。
        public Object getChangePayload(@NonNull T oldItem, @NonNull T newItem) {
            return null;
        }
    }

根据areItemsTheSameareContentsTheSame返回值,自动判断需要刷新的Item。(其他用法自行查阅资料。)

如果你的列表是多种类型的Model,我想实现这些方法会比较麻烦吧。并且有个大问题,如果oldItem和newItem是同一个对象,那么怎么比较都是一样的,可是按业务逻辑上讲oldItem数据展示到UI上后,自己改变了数据,按理需要刷新UI的。找出变化的“东西”并能拿出来使用也不方便。

aaaazhe..

正如前文提到的,将采用APT去解决它。

APT(Annotation Processing Tool)注解处理器,是一种处理注解的工具,确切的说它是javac的一个工具,它用来在编译时扫描和处理注解。注解处理器以Java代码(或者编译过的字节码)作为输入,生成.java文件作为输出。
可以简单理解为根据注解,在编译期生成Java代码。

解决思路

基本思路:给Model做为比较依据的属性简单的加个注解,比较的地方地方自动处理。使用APT根据注解在编译器生成辅助的副本类(并包含比较的方法),oldItem将会有一份副本保存旧的数据,用来跟newItem比较。

方案设计

几个问题

  1. Q1:比较依据种类有几种?
    A1:两种。比较Item和比较Content。定义两个注解:@SameItem@SameContent

  2. Q2:如果Model有继承关系,父类的依据在子类是否可用?(比如聊天列表各种类型继承基类)
    A2:可用。(难点1,需要查找父类)

  3. Q3:如果Model的属性也是个Model,并且里面有判断依据,是否穿透到内部去判断?(比如消息里的User也是个Model)
    A3:支持。新定义注解@SameType 表示这个属性需要穿透。(难点2,不能产生穿透回路,不然会形成无限递归,编译期检查出来。不能产生无效的@SameType 注解的属性。)

    只能单向的引用链

    Q3.1: 什么是无效的@SameType 注解的属性?
    A3.1:该属性的类型或其父类内部必须至少含有@SameItem@SameContent@SameType 其中一个,@SameType引用链最后那个类型必须至少含@SameItem@SameContent其中一个。(处理起来最复杂的问题)

  4. Q4:生成的副本是什么样子的?
    A4:副本类只会有注解标记的属性。具体后面讲。

  5. Q5:每个Model都有存副本吗?那数据量会产生2倍哎。
    Q5:不是,只有bind到ViewHolder上的Model才有副本,因为只有这些是需要判断来刷新的。也就是说,副本的个数=ViewHolder的个数。

设计分析

根据上面问答,需定义3个注解:@SameItem@SameContent@SameType。根据3个注解的含义,我们得出三种不需要共存(不能同时作用在同一个属性上)否则就重复判断了。

注解 作用对象 含义 value
@SameItem 属性 判断同一个Item的依据
@SameContent 属性 判断内容的依据 payload 的key
@SameType 属性 穿透属性,同时去判断对象内的属性 payload 的key

支持继承的话,如果子类Model没有这些注解,而父类有注解,那么这个类判断时用的是 “最近”的父类的副本类。Model副本类的继承关系和Model的继承关系大致一致(继承链中可以空掉几个没注解的类)。

支持属性穿透,如果XModol里有个属性y(类型为YModel)需要穿透,那么这个XModel的副本类有一个属性y 是YModel的副本类类型。

举个栗子

假如几个Model体型长这样:

public class XxModel {

    @SameItem
    public long id;

    @SameContent
    public String name;
   
    // 这个属性没注解,副本类里就没这个。
    public int count;

    @SameContent
    public boolean valid;

    @SameType
    public YyModel yy;
}
// 穿透的属性类型
public class YyModel {

    @SameItem
    public long id;

    @SameContent
    public String title;
//    //这里不能这样用哦,否则就跟XxModel产生回环了。
//    @SameType()
//    public XxModel xx;
}
// 继承的类型
public class ZzModel extends XxModel {

    @SameContent
    public boolean zzz;
}

期望对应生成的副本类代码如下:

// XxModel的副本类
public class XxModel$$Diff$$Model implements IDiffModelType {
  private long id;

  private int count;

  private boolean valid;

  private String name;

  private YyModel$$Diff$$Model yy = new YyModel$$Diff$$Model();

  ....省略辅助方法....
}
// YyModel的副本类
public class YyModel$$Diff$$Model implements IDiffModelType {
  private long id;

  private String title;

  ....省略辅助方法....
}
// ZzModel的副本类,继承XxModel的副本类
public class ZzModel$$Diff$$Model extends XxModel$$Diff$$Model {
  private boolean zzz;

  ....省略辅助方法....
}

有了副本类的结构,需要怎么用呢?定义几个辅助方法在他们的共同接口里面。

public interface IDiffModelType {
    // 统计sameItem判断依据的个数,包括父类的和穿透属性的
    int sameItemCount();
    // 统计sameContent判断依据的个数,包括父类的和穿透属性的
    int sameContentCount();
    // 当前副本与传入数据是否同一个Item,对各@SameItem属性判断Objects.equals(this.attr,model.attr)
    boolean isSameItem(Object o);
    // 当前副本与传入数据是否内容相同,对各@SameIContent属性判断Objects.equals(this.attr,model.attr)
    boolean isSameContent(Object o);
    // 当前副本是否可以处理传入的对象的类型
    boolean canHandle(Object o);
   // 从传入的对象上获取属性值,即记录副本
    void from(Object o);
   // 找出传入对象与副本不一样的值,Payload 内部是一个map存变化了的数据。
    Payload payload(Object o);
}

这些方法计算的时候,都会调用其父类的和穿透属性的同名方法结合计算结果(除了canHandle)。后面把sameItemCountsameContentCount也优化掉了,只返回具体数据,因为在编译期间就可以计算出具体的数值了。

如何创建这些副本类?再自动生成一些工厂类和获取工厂的类即可。(注意,根据类型判断时,子类的放父类的前面)。

APT实现

这部分代码比较长。。。

源码:DiffProcessor.java

主要针对QA中的几个难点需要做一些数据结构的复杂逻辑。(看源码吧,有注释)

需要注意一点,通过Model给副本赋值时,java的话属性是public的,直接赋值即可。但是如果Model是kotlin代码,看上去public的属性,实际上会变成private属性,然后生成GET方法。

    private String spellGetFunction(VariableElement element) {
        if (element.getModifiers().contains(Modifier.PUBLIC)) {
            return element.getSimpleName().toString();
        } else {
            String name = element.getSimpleName().toString();
            // boolean或者Boolean类型的话,如果is开头,第3个字母不是小写的话,特殊处理。
            if (element.asType().getKind() == TypeKind.BOOLEAN
                    || element.asType().toString().equalsIgnoreCase(BOOLEAN_TYPE)) {
                byte[] items = name.getBytes();
                if (items.length >= 3) {
                    char c0 = (char) items[0];
                    char c1 = (char) items[1];
                    char c2 = (char) items[2];
                    if (c0 == 'i' && c1 == 's' && (c2 < 'a' || c2 > 'z')) {
                        return name + "()";
                    }
                }
            }
            // get+属性名首字母变大写+()
            return "get" + toUpper(name) + "()";
        }
    }

写框架,虽然很麻烦,如果用起来就值得了。

对外封装

APT生成了副本类,工厂类,获取工厂的类。剩下的还需要封装使用这些类。对外提供一个Helper类。

public final class DiffModelHelper {

    private static class Data {
        Object model;
        IDiffModelType diff;
    }
    // 保存 数据与绑定对象(一般时ViewHolder 或者它的itemView)
    private final Map<Object, Data> bindMap = new WeakHashMap<>();
    private boolean byObjectsEquals = true;

    /**
     * 没有依据时是否用 {@link Objects#equals(Object, Object)} 来判断是否同一行。
     */
    public synchronized void isSameItemByObjectsEquals(boolean use) {
        this.byObjectsEquals = use;
    }

    /**
     * 新旧数据内容是否同一行。
     */
    public synchronized boolean isSameItem(@NonNull Object oldModel, @NonNull Object newModel) {
        IDiffModelType diff = findDiff(oldModel);
        if (diff == null) return false;
        if (diff.canHandle(newModel) && diff.sameItemCount() > 0) {
            return diff.isSameItem(newModel);
        }
        return byObjectsEquals && Objects.equals(oldModel, newModel);
    }

    /**
     * 新旧数据内容是否相同。
     */
    public synchronized boolean isSameContent(@NonNull Object oldModel, @NonNull Object newModel) {
        IDiffModelType diff = findDiff(oldModel);
        if (diff == null) return false;
        if (diff.canHandle(newModel) && diff.sameContentCount() > 0) {
            return diff.isSameContent(newModel);
        }
        return false;
    }

    /**
     * 获取改变的差异。
     */
    @Nullable
    public synchronized Payload getPayload(@NonNull Object oldModel, @NonNull Object newModel) {
        IDiffModelType diff = findDiff(oldModel);
        if (diff == null) return null;
        if (diff.canHandle(newModel) && diff.sameContentCount() > 0) {
            return diff.payload(newModel);
        }
        return null;
    }

    /**
     * 绑定上新的数据。
     */
    public synchronized void bindNewData(@NonNull Object bindObj, @NonNull Object newModel) {
        Data data = bindMap.get(bindObj);
        if (data != null && data.diff.canHandle(newModel)) {
            data.diff.from(newModel);
            return;
        }
        IDiffModelType diff = tryCreateDiff(newModel);
        if (diff == null) return;
        diff.from(newModel);
        data = new Data();
        data.model = newModel;
        data.diff = diff;
        bindMap.put(bindObj, data);
    }

    @Nullable
    private IDiffModelType findDiff(@NonNull Object model) {
        for (Data data : bindMap.values()) {
            if (data.model == model) return data.diff;
        }
        return null;
    }

    @Nullable
    private IDiffModelType tryCreateDiff(@NonNull Object model) {
        IDiffModelFactory factory = DiffModelFactoryManager.getInstance().getFactory(model);
        if (factory != null) return factory.create();
        return null;
    }

}

其中DiffModelFactoryManager是管理工厂的单例(内部用来LruCache对工厂做了缓存)。

如何使用

目前已经发布到 JitPack。

添加依赖

我项目里用了.gradle.kts。gradle类似,跟常规依赖差不多。
Project.build.gradle.kts:

allprojects {
    repositories {
        google()
        jcenter()
        maven { url = uri("https://jitpack.io") }// add this line.
    }
}

app.build.gradle.kts:( lastVersion见github)

 implementation("com.github.wzmyyj.FeDiff:lib_diff_api:lastVersion")
 // or kotlin use kapt
 annotationProcessor("com.github.wzmyyj.FeDiff:lib_diff_compiler:lastVersion")

代码

初始化:在Application里。

FeDiff.init(this, true)// 第二个参数表示是否debug

可以结合ListAdapter定义一个DiffUtil.ItemCallback<M>。例如:

class DiffModelCallback<M : IVhModelType> : DiffUtil.ItemCallback<M>() {

    private val helper = DiffModelHelper()

    fun getHelper(): DiffModelHelper = helper

    fun bindNewData(bindObj: Any, newModel: M) {
        helper.bindNewData(bindObj, newModel)
    }

    override fun areItemsTheSame(oldItem: M, newItem: M): Boolean {
        return helper.isSameItem(oldItem, newItem)
    }

    override fun areContentsTheSame(oldItem: M, newItem: M): Boolean {
        return helper.isSameContent(oldItem, newItem)
    }

    override fun getChangePayload(oldItem: M, newItem: M): Any? {
        // return null; //如果不做Item局部刷新就返回 null。
        return helper.getPayload(oldItem, newItem)
    }
}

然后在适配器里:

override fun onBindViewHolder(holder: BindingViewHolder, position: Int, payloads: MutableList<Any>) {
        val payload = payloads.firstOrNull() as? Payload
        if (payload != null && payload.isEmpty.not()) {// 如果不做Item里局部刷新可以不需要这几行。
            // 根据.payload做Item里局部刷新。
            val newAttr = payload.getString("key", "xxx")
            holder.itemView.tv.text = newAttr
        } else {
            super.onBindViewHolder(holder, position, payloads)
        }
        // 最后给副本绑定新的数据。这行必须加!
        callback.bindNewData(holder, getItem(position))
        // or callback.bindNewData(holder.itemView, getItem(position))
    }

callback是上面定义的DiffModelCallback 。然后在你的model里加注解即可,例如:

class MsgModel {
    // 判断同一个Item的依据
    @SameItem
    var id: Long = 0
    // 判断内容的依据
    @SameContent
    var content: String? = null
    // 判断内容的依据
    @SameContent
    var time: Long = 0L
    // 不需要判断,不加注解
    var valid = false
    // 穿透属性,会同时去判断UserModel里面的属性
    @SameType
    var user: UserModel? = null
}
class UserModel {

    @SameContent
    var name: String? = null

    @SameContent
    var avatar: String? = null
}

剩下的就是把model添加到列表上喽。框架会帮你自动计算变化了那些。

项目地址

wzmyyj/FeDiff

欢迎Star或Issues。

其他

搭配这个更香!DataBinding下的RecyclerView万能适配器:
wzmyyj/FeAdapter

推荐阅读更多精彩内容