用APT讓DiffUtil自動(dòng)比較差異

在Android開(kāi)發(fā)中,常常使用含列表的UI,基本選擇RecyclerView做為列表控件。針對(duì)列表刷新簡(jiǎn)化,Google提供了DiffUtil工具,根據(jù)數(shù)據(jù)的變化指定性的更新UI。開(kāi)發(fā)者不再需要查找需要更新的是哪個(gè)Item。

但是DiffUtil提供了判斷的接口,需要開(kāi)發(fā)者自行根據(jù)自己的item Model來(lái)實(shí)現(xiàn)判斷依據(jù)。用過(guò)的小伙伴也許發(fā)現(xiàn)了,這個(gè)用起來(lái)似乎比較麻煩,特別是在列表多類(lèi)型Item的情況下,問(wèn)題將變得費(fèi)勁。本文將采用APT技術(shù)讓判斷變得簡(jiǎn)單。

如果不想了解原理,可以跳過(guò)方案設(shè)計(jì),只看解決思路如何使用。

下面先簡(jiǎn)單介紹一下 DiffUtil 相關(guān)的:

小葵花課堂

DiffUtil 是 androidx.recyclerview.widget 下的一個(gè)工具類(lèi),一般我們不需要直接使用它,而是使用封裝好的androidx.recyclerview.widget.ListAdapter,內(nèi)部使用了DiffUtil的功能。開(kāi)發(fā)者需要繼承并實(shí)現(xiàn)DiffUtil.ItemCallback 抽象類(lèi)的三個(gè)方法:

 public abstract static class ItemCallback<T> {
        // 判斷是不是同一個(gè)Item,注意不是指同一類(lèi)型,而是同一行數(shù)據(jù)。
        public abstract boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem);
        // 當(dāng)areItemsTheSame返回true時(shí),判斷item的內(nèi)容是否相同。
        public abstract boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem);
        // 當(dāng)areContentsTheSame返回false時(shí),找出變化的東西并返回它。
        // 返回的“東西”將在RecyclerView的方法:
        // onBindViewHolder(holder: BindingViewHolder, position: Int, payloads: MutableList<Any>)
        // 第三個(gè)參數(shù)里面??梢宰鯥tem里面局部刷新。
        public Object getChangePayload(@NonNull T oldItem, @NonNull T newItem) {
            return null;
        }
    }

根據(jù)areItemsTheSameareContentsTheSame返回值,自動(dòng)判斷需要刷新的Item。(其他用法自行查閱資料。)

如果你的列表是多種類(lèi)型的Model,我想實(shí)現(xiàn)這些方法會(huì)比較麻煩吧。并且有個(gè)大問(wèn)題,如果oldItem和newItem是同一個(gè)對(duì)象,那么怎么比較都是一樣的,可是按業(yè)務(wù)邏輯上講oldItem數(shù)據(jù)展示到UI上后,自己改變了數(shù)據(jù),按理需要刷新UI的。找出變化的“東西”并能拿出來(lái)使用也不方便。

aaaazhe..

正如前文提到的,將采用APT去解決它。

APT(Annotation Processing Tool)注解處理器,是一種處理注解的工具,確切的說(shuō)它是javac的一個(gè)工具,它用來(lái)在編譯時(shí)掃描和處理注解。注解處理器以Java代碼(或者編譯過(guò)的字節(jié)碼)作為輸入,生成.java文件作為輸出。
可以簡(jiǎn)單理解為根據(jù)注解,在編譯期生成Java代碼。

解決思路

基本思路:給Model做為比較依據(jù)的屬性簡(jiǎn)單的加個(gè)注解,比較的地方地方自動(dòng)處理。使用APT根據(jù)注解在編譯器生成輔助的副本類(lèi)(并包含比較的方法),oldItem將會(huì)有一份副本保存舊的數(shù)據(jù),用來(lái)跟newItem比較。

方案設(shè)計(jì)

幾個(gè)問(wèn)題

  1. Q1:比較依據(jù)種類(lèi)有幾種?
    A1:兩種。比較Item和比較Content。定義兩個(gè)注解:@SameItem@SameContent。

  2. Q2:如果Model有繼承關(guān)系,父類(lèi)的依據(jù)在子類(lèi)是否可用?(比如聊天列表各種類(lèi)型繼承基類(lèi))
    A2:可用。(難點(diǎn)1,需要查找父類(lèi))

  3. Q3:如果Model的屬性也是個(gè)Model,并且里面有判斷依據(jù),是否穿透到內(nèi)部去判斷?(比如消息里的User也是個(gè)Model)
    A3:支持。新定義注解@SameType 表示這個(gè)屬性需要穿透。(難點(diǎn)2,不能產(chǎn)生穿透回路,不然會(huì)形成無(wú)限遞歸,編譯期檢查出來(lái)。不能產(chǎn)生無(wú)效的@SameType 注解的屬性。)

    只能單向的引用鏈

    Q3.1: 什么是無(wú)效的@SameType 注解的屬性?
    A3.1:該屬性的類(lèi)型或其父類(lèi)內(nèi)部必須至少含有@SameItem ,@SameContent,@SameType 其中一個(gè),@SameType引用鏈最后那個(gè)類(lèi)型必須至少含@SameItem ,@SameContent其中一個(gè)。(處理起來(lái)最復(fù)雜的問(wèn)題)

  4. Q4:生成的副本是什么樣子的?
    A4:副本類(lèi)只會(huì)有注解標(biāo)記的屬性。具體后面講。

  5. Q5:每個(gè)Model都有存副本嗎?那數(shù)據(jù)量會(huì)產(chǎn)生2倍哎。
    Q5:不是,只有bind到ViewHolder上的Model才有副本,因?yàn)橹挥羞@些是需要判斷來(lái)刷新的。也就是說(shuō),副本的個(gè)數(shù)=ViewHolder的個(gè)數(shù)。

設(shè)計(jì)分析

根據(jù)上面問(wèn)答,需定義3個(gè)注解:@SameItem ,@SameContent , @SameType。根據(jù)3個(gè)注解的含義,我們得出三種不需要共存(不能同時(shí)作用在同一個(gè)屬性上)否則就重復(fù)判斷了。

注解 作用對(duì)象 含義 value
@SameItem 屬性 判斷同一個(gè)Item的依據(jù) 無(wú)
@SameContent 屬性 判斷內(nèi)容的依據(jù) payload 的key
@SameType 屬性 穿透屬性,同時(shí)去判斷對(duì)象內(nèi)的屬性 payload 的key

支持繼承的話,如果子類(lèi)Model沒(méi)有這些注解,而父類(lèi)有注解,那么這個(gè)類(lèi)判斷時(shí)用的是 “最近”的父類(lèi)的副本類(lèi)。Model副本類(lèi)的繼承關(guān)系和Model的繼承關(guān)系大致一致(繼承鏈中可以空掉幾個(gè)沒(méi)注解的類(lèi))。

支持屬性穿透,如果XModol里有個(gè)屬性y(類(lèi)型為YModel)需要穿透,那么這個(gè)XModel的副本類(lèi)有一個(gè)屬性y 是YModel的副本類(lèi)類(lèi)型。

舉個(gè)栗子

假如幾個(gè)Model體型長(zhǎng)這樣:

public class XxModel {

    @SameItem
    public long id;

    @SameContent
    public String name;
   
    // 這個(gè)屬性沒(méi)注解,副本類(lèi)里就沒(méi)這個(gè)。
    public int count;

    @SameContent
    public boolean valid;

    @SameType
    public YyModel yy;
}
// 穿透的屬性類(lèi)型
public class YyModel {

    @SameItem
    public long id;

    @SameContent
    public String title;
//    //這里不能這樣用哦,否則就跟XxModel產(chǎn)生回環(huán)了。
//    @SameType()
//    public XxModel xx;
}
// 繼承的類(lèi)型
public class ZzModel extends XxModel {

    @SameContent
    public boolean zzz;
}

期望對(duì)應(yīng)生成的副本類(lèi)代碼如下:

// XxModel的副本類(lèi)
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的副本類(lèi)
public class YyModel$$Diff$$Model implements IDiffModelType {
  private long id;

  private String title;

  ....省略輔助方法....
}
// ZzModel的副本類(lèi),繼承XxModel的副本類(lèi)
public class ZzModel$$Diff$$Model extends XxModel$$Diff$$Model {
  private boolean zzz;

  ....省略輔助方法....
}

有了副本類(lèi)的結(jié)構(gòu),需要怎么用呢?定義幾個(gè)輔助方法在他們的共同接口里面。

public interface IDiffModelType {
    // 統(tǒng)計(jì)sameItem判斷依據(jù)的個(gè)數(shù),包括父類(lèi)的和穿透屬性的
    int sameItemCount();
    // 統(tǒng)計(jì)sameContent判斷依據(jù)的個(gè)數(shù),包括父類(lèi)的和穿透屬性的
    int sameContentCount();
    // 當(dāng)前副本與傳入數(shù)據(jù)是否同一個(gè)Item,對(duì)各@SameItem屬性判斷Objects.equals(this.attr,model.attr)
    boolean isSameItem(Object o);
    // 當(dāng)前副本與傳入數(shù)據(jù)是否內(nèi)容相同,對(duì)各@SameIContent屬性判斷Objects.equals(this.attr,model.attr)
    boolean isSameContent(Object o);
    // 當(dāng)前副本是否可以處理傳入的對(duì)象的類(lèi)型
    boolean canHandle(Object o);
   // 從傳入的對(duì)象上獲取屬性值,即記錄副本
    void from(Object o);
   // 找出傳入對(duì)象與副本不一樣的值,Payload 內(nèi)部是一個(gè)map存變化了的數(shù)據(jù)。
    Payload payload(Object o);
}

這些方法計(jì)算的時(shí)候,都會(huì)調(diào)用其父類(lèi)的和穿透屬性的同名方法結(jié)合計(jì)算結(jié)果(除了canHandle)。后面把sameItemCountsameContentCount也優(yōu)化掉了,只返回具體數(shù)據(jù),因?yàn)樵诰幾g期間就可以計(jì)算出具體的數(shù)值了。

如何創(chuàng)建這些副本類(lèi)?再自動(dòng)生成一些工廠類(lèi)和獲取工廠的類(lèi)即可。(注意,根據(jù)類(lèi)型判斷時(shí),子類(lèi)的放父類(lèi)的前面)。

APT實(shí)現(xiàn)

這部分代碼比較長(zhǎng)。。。

源碼:DiffProcessor.java

主要針對(duì)QA中的幾個(gè)難點(diǎn)需要做一些數(shù)據(jù)結(jié)構(gòu)的復(fù)雜邏輯。(看源碼吧,有注釋?zhuān)?/p>

需要注意一點(diǎn),通過(guò)Model給副本賦值時(shí),java的話屬性是public的,直接賦值即可。但是如果Model是kotlin代碼,看上去public的屬性,實(shí)際上會(huì)變成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類(lèi)型的話,如果is開(kāi)頭,第3個(gè)字母不是小寫(xiě)的話,特殊處理。
            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+屬性名首字母變大寫(xiě)+()
            return "get" + toUpper(name) + "()";
        }
    }

寫(xiě)框架,雖然很麻煩,如果用起來(lái)就值得了。

對(duì)外封裝

APT生成了副本類(lèi),工廠類(lèi),獲取工廠的類(lèi)。剩下的還需要封裝使用這些類(lèi)。對(duì)外提供一個(gè)Helper類(lèi)。

public final class DiffModelHelper {

    private static class Data {
        Object model;
        IDiffModelType diff;
    }
    // 保存 數(shù)據(jù)與綁定對(duì)象(一般時(shí)ViewHolder 或者它的itemView)
    private final Map<Object, Data> bindMap = new WeakHashMap<>();
    private boolean byObjectsEquals = true;

    /**
     * 沒(méi)有依據(jù)時(shí)是否用 {@link Objects#equals(Object, Object)} 來(lái)判斷是否同一行。
     */
    public synchronized void isSameItemByObjectsEquals(boolean use) {
        this.byObjectsEquals = use;
    }

    /**
     * 新舊數(shù)據(jù)內(nèi)容是否同一行。
     */
    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);
    }

    /**
     * 新舊數(shù)據(jù)內(nèi)容是否相同。
     */
    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;
    }

    /**
     * 綁定上新的數(shù)據(jù)。
     */
    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是管理工廠的單例(內(nèi)部用來(lái)LruCache對(duì)工廠做了緩存)。

如何使用

目前已經(jīng)發(fā)布到 JitPack。

添加依賴(lài)

我項(xiàng)目里用了.gradle.kts。gradle類(lèi)似,跟常規(guī)依賴(lài)差不多。
Project.build.gradle.kts:

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

app.build.gradle.kts:( lastVersion見(jiàn)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)// 第二個(gè)參數(shù)表示是否debug

可以結(jié)合ListAdapter定義一個(gè)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里局部刷新可以不需要這幾行。
            // 根據(jù).payload做Item里局部刷新。
            val newAttr = payload.getString("key", "xxx")
            holder.itemView.tv.text = newAttr
        } else {
            super.onBindViewHolder(holder, position, payloads)
        }
        // 最后給副本綁定新的數(shù)據(jù)。這行必須加!
        callback.bindNewData(holder, getItem(position))
        // or callback.bindNewData(holder.itemView, getItem(position))
    }

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

class MsgModel {
    // 判斷同一個(gè)Item的依據(jù)
    @SameItem
    var id: Long = 0
    // 判斷內(nèi)容的依據(jù)
    @SameContent
    var content: String? = null
    // 判斷內(nèi)容的依據(jù)
    @SameContent
    var time: Long = 0L
    // 不需要判斷,不加注解
    var valid = false
    // 穿透屬性,會(huì)同時(shí)去判斷UserModel里面的屬性
    @SameType
    var user: UserModel? = null
}
class UserModel {

    @SameContent
    var name: String? = null

    @SameContent
    var avatar: String? = null
}

剩下的就是把model添加到列表上嘍。框架會(huì)幫你自動(dòng)計(jì)算變化了那些。

項(xiàng)目地址

wzmyyj/FeDiff

歡迎Star或Issues。

其他

搭配這個(gè)更香!DataBinding下的RecyclerView萬(wàn)能適配器:
wzmyyj/FeAdapter

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容