用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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353

推荐阅读更多精彩内容