在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;
}
}
根据areItemsTheSame
或areContentsTheSame
返回值,自动判断需要刷新的Item。(其他用法自行查阅资料。)
如果你的列表是多种类型的Model,我想实现这些方法会比较麻烦吧。并且有个大问题,如果oldItem和newItem是同一个对象,那么怎么比较都是一样的,可是按业务逻辑上讲oldItem数据展示到UI上后,自己改变了数据,按理需要刷新UI的。找出变化的“东西”并能拿出来使用也不方便。
正如前文提到的,将采用APT去解决它。
APT(Annotation Processing Tool)即注解处理器,是一种处理注解的工具,确切的说它是javac的一个工具,它用来在编译时扫描和处理注解。注解处理器以Java代码(或者编译过的字节码)作为输入,生成.java文件作为输出。
可以简单理解为根据注解,在编译期生成Java代码。
解决思路
基本思路:给Model做为比较依据的属性简单的加个注解,比较的地方地方自动处理。使用APT根据注解在编译器生成辅助的副本类(并包含比较的方法),oldItem将会有一份副本保存旧的数据,用来跟newItem比较。
方案设计
几个问题
Q1:比较依据种类有几种?
A1:两种。比较Item和比较Content。定义两个注解:@SameItem
,@SameContent
。Q2:如果Model有继承关系,父类的依据在子类是否可用?(比如聊天列表各种类型继承基类)
A2:可用。(难点1,需要查找父类)-
Q3:如果Model的属性也是个Model,并且里面有判断依据,是否穿透到内部去判断?(比如消息里的User也是个Model)
A3:支持。新定义注解@SameType
表示这个属性需要穿透。(难点2,不能产生穿透回路,不然会形成无限递归,编译期检查出来。不能产生无效的@SameType
注解的属性。)
Q3.1: 什么是无效的@SameType
注解的属性?
A3.1:该属性的类型或其父类内部必须至少含有@SameItem
,@SameContent
,@SameType
其中一个,@SameType
引用链最后那个类型必须至少含@SameItem
,@SameContent
其中一个。(处理起来最复杂的问题) Q4:生成的副本是什么样子的?
A4:副本类只会有注解标记的属性。具体后面讲。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
)。后面把sameItemCount
和sameContentCount
也优化掉了,只返回具体数据,因为在编译期间就可以计算出具体的数值了。
如何创建这些副本类?再自动生成一些工厂类和获取工厂的类即可。(注意,根据类型判断时,子类的放父类的前面)。
APT实现
这部分代码比较长。。。
主要针对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添加到列表上喽。框架会帮你自动计算变化了那些。
项目地址
欢迎Star或Issues。
其他
搭配这个更香!DataBinding下的RecyclerView万能适配器:
wzmyyj/FeAdapter