由 View.getParent() 的设计想到的一种列表实现
View.getParent() 的设计思想
不知道有没有人注意到,当我们使用 View.getParent()
时,其返回的 ParentView
并不是一个真正的 View
,而是一个 ViewParent
接口。
这样做的设计思想是:一个 View
有非常多的 public
方法,ParentView
只希望暴露方法的有限集合给 ChildView
,这样能够避免 ChildView
对 ParentView
的修改影响到其他的兄弟 BrotherView
。
项目背景
现在我们项目中有这样的场景:有一个展示列表,列表中的每一项 ItemView
都是一种固定样式的 xml 布局,只是每个 ItemView
的内容和行为不一样。
比如下图所示,第一行是一种标题 TitleItemView
,它只显示标题文案。第二、三行是一种内容 ContentItemView
,左边是描述文案,右边是操作按钮。
项目实践中,这个列表并不是一个动态无限长的列表(通过网络不断加载更多列表项)。它是一个静态有限长的的列表,但是需要满足需求的不断扩展(不断添加新的静态列表项)。这些不同样式的 ItemView
相互交错,很自然地我们想到使用 RecyclerView
。
传统的写法
我们传统的 RecyclerView
写法是在 Adapter.onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
中,根据 position
找到 Adapter
的数据集中对应的数据,然后将数据设置到 holder#itemView
中进行展示。展示数据的变化则是通过外部的业务类进行更新,然后将变化通知给 Adapter
,有它通过 onBindViewHolder()
来更新 itemView
中的展示数据。
缺点
1. 约束问题
官方的实现中 ViewHolder#itemView
是 public
的。因此可能存在这样的情况:列表中的一个 itemA 改变了 TitleItemView
的一个属性,比如 alpha
属性,当另一个 itemB 复用这个 TitleItemView
时,无法知道它的 alpha
属性已经被改过了。因为这两个 item 虽然有一样的布局样式,但是由于业务的不同,导致业务的数据会进行不一样的展现。要约束对标题类型 TitleItemView
的操作就要么使用文档,要么口头相传的形式来告诉开发者:这种类型的 itemView
哪些属性可能会被改变,使用前需要将它们全部重置一遍。
2. View ViewModel 的绑定问题
列表中数据的改变需要来自 Adapter
外部,而 ItemView
的实例化却在 Adapter.onCreateViewHolder
中。这样就导致每一个列表项的业务 ViewModel
与它对应的 ItemView
并不是强相关,它们两个之间通过数据位置 position
来联系起来。即 ViewModel
改变数据集中 position
位置的数据,Adapter
收到数据集更新的通知,将 position
位置的数据通过 onBindViewHolder()
方法更新到 UI 上面。
解决方案
问题 1 可以通过上面类似 ViewParent
的设计来解决:一种类型的 ItemView
通过实现特定类型的接口,来暴露特定类型的修改 View
的方法。
- 设计一个基类接口,它包含了所有
ItemView
的通用操作
public interface SettingItemViewInterface {
/**
* RecyclerView viewHolder 解绑的时候会调用这个方法, 子类需要在这个方法内恢复ui到xml设置的状态
*/
void unBind();
/**
* 整个View是不是enable态
* @param enabled
*/
void setEnabled(boolean enabled);
/**
* 整个View是不是enable态
*/
boolean isEnabled();
}
enabled
容易理解,这里不说了。暴露一个 unBind()
接口,每个类型的 ItemView
实现该方法,根据自己设定的初始状态将 ItemView
的状态重置掉(因为只暴露了特定修改 ItemView
的方法,因此一定能在 unBind()
方法中将 ItemView
的状态重置)
下面我们看一个具体的类型接口。
定义标题类型的接口,可以注意到 它只暴露了有限几个方法给开发者进行使用。比如,通过该接口就限制了开发者改变 SettingItemTitleView
的 alpha
值:
@Implement(view = SettingItemTitleView.class)
public interface SettingItemTitleViewInterface extends SettingItemViewInterface {
/**
* 左侧TextView 文字
* @param charSequence
*/
void setLeftText(CharSequence charSequence);
/**
* 左侧TextView 文字
* @param stringId
*/
void setLeftText(@StringRes int stringId);
/**
* 左侧文字颜色
* @param color
*/
void setLeftTextColor(@ColorInt int color);
...
}
代码中的 @Implement
注解可以先不管,它并不会生成中间胶水代码,只是标注一下该接口与其对应的实现类,从而能够通过接口快速找到对应实现类。
这样设计的原因是:我们的初衷是将 ItemView
的所有操作都变为面向接口的操作,因此 Adapter
中能够看到的应该也只有接口。这个注解就是为了方便 Adapter
通过接口找到对应的 ItemView
类,然后通过反射将 ItemView
实例化。关于 Adapter
中具体如何使用,会在下面的小节中讲到。
下面我们再看一看 SettingItemTitleViewInterface
所对应的 ItemView
:
public class SettingItemTitleView extends RelativeLayout implements SettingItemTitleViewInterface {
private final static float ENABLED_ALPHA = 1.0f;
private final static float DISABLED_ALPHA = 0.5f;
private TextView mLeftText;
public SettingItemTitleView(Context context, AttributeSet attrs) {
super(context, attrs);
setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
inflate(context, R.layout.setting_ui_2_item_title_layout, this);
mLeftText = findViewById(R.id.setting_ui_2_item_title_left_tv);
}
@Override
public void unBind() {
mLeftText.setText(R.string.setting_ui_na);
mLeftText.setTextColor(ContextCompat.getColor(getContext(), R.color.setting_ui_2_item_main_text_color));
}
@Override
public void setLeftText(CharSequence charSequence) {
mLeftText.setText(charSequence);
}
@Override
public void setLeftText(int stringId) {
mLeftText.setText(stringId);
}
@Override
public void setLeftTextColor(int color) {
mLeftText.setTextColor(color);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
mRightText.setEnabled(enabled);
mRightText.setAlpha(enabled? ENABLED_ALPHA : DISABLED_ALPHA);
}
...
}
SettingItemTitleView
对外只能通过接口 SettingItemTitleViewInterface
进行操作。Adapter
会在复用这个 SettingItemTitleView
之前调用 unBind()
方法将接口所操作的 View
属性全部重置回了默认值
问题 2 我们就需要设计一种数据结构 SettingItemStandardViewController
接口,列表中的每一项 Item 都是 SettingItemStandardViewController
的实例。
从我们的设计思路上,我们要使 SettingItemViewModel
与具体的 ItemView
绑定,但是又只能通过 SettingItemViewInterface
来操作对应的 ItemView
,那么就需要有一个中间类通过持有 SettingItemViewModel
和 SettingItemViewInterface
两个引用,使它们两个之间建立绑定关系。SettingItemStandardViewController
就是这样的中间类,它本质上是 SettingItemViewInterface
的代理。
SettingItemStandardViewController
通过方法 onBindViewHolder()
将 viewInterface
传入进来,这样 SettingItemStandardViewController
就成为了 viewInterface
的代理。然后 SettingItemStandardViewController
生成对应 ItemView
的 ViewModel
。
public abstract class SettingItemStandardViewController<VM extends SettingItemViewModel, V extends SettingItemViewInterface>
extends SettingItemViewController<VM> {
private Context mContext;
@CallSuper
public void onBindViewHolder(Context context, V viewInterface, CompositeDisposable compositeDisposable, int position) {
mContext = context;
if (mViewModel == null) {
mViewModel = generateViewModel();
} else {
mViewModel.onUnbindView();
}
if (mViewModel != null) {
mViewModel.onBindView();
}
}
protected abstract VM generateViewModel();
...
}
上面的 onBindViewHolder
方法会在 Adapter.onBindViewHolder
中被调用。在使用 mViewModel
之前先进行 onUnbindView()
清理原来的订阅关系,然后调用 onBindView
来初始化新的数据。
我们来看一个具体的设备名称条目的实现:
public class SettingDeviceNameViewController extends SettingItemStandardViewController<SettingDeviceNameViewModel, SettingItemTitleViewInterface> {
private SettingDeviceNameViewModel mItemViewModel;
// 因为每一个 Controller 都是一个具体业务的 Controller,因此它能够生成改业务对应的 ViewModel
@NonNull
@Override
protected SettingDeviceNameViewModel generateViewModel() {
mItemViewModel = new SettingDeviceNameViewModel();
return mItemViewModel;
}
@Override
public void onBindViewHolder(Context context, SettingItemTitleViewInterface viewInterface, CompositeDisposable compositeDisposable, int position) {
super.onBindViewHolder(context, viewInterface, compositeDisposable, position);
viewInterface.setLeftText(R.string.setting_equipment_name_list);
compositeDisposable.add(mItemViewModel.getNameObservable()
.subscribe(viewInterface::setLeftText));
}
}
使用
最后我们来看看上面这些类是如何联系起来的。
class PageAdapter extends RecyclerView.Adapter<SettingItemViewHolder> {
private Context context;
private List<SettingItemViewController<?>> controllerList;
private SparseArrayCompat<Class> customViewTypeMap = new SparseArrayCompat<>();
public PageAdapter(Context context, List<SettingItemViewController<?>> controllerList) {
this.context = context;
this.controllerList = new ArrayList<>(controllerList);
create();
}
protected void create() {
for (SettingItemViewController controller : controllerList) {
controller.onCreate();
}
}
protected void destroy() {
for (SettingItemViewController controller: controllerList) {
controller.onDestroy();
}
controllerList.clear();
}
@Override
public void onViewRecycled(SettingItemViewHolder holder) {
super.onViewRecycled(holder);
holder.getCompositeDisposable().clear();
}
@Override
public int getItemViewType(int position) {
SettingItemViewController controller = controllerList.get(position);
ParameterizedType parameterizedType = (ParameterizedType) controller.getClass().getGenericSuperclass();
if (parameterizedType != null) {
Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
for (Type actualTypeArgument : actualTypeArguments) {
Class genericClass = (Class) actualTypeArgument;
if (SettingItemViewInterface.class.isAssignableFrom(genericClass)) {
Implement annotation = (Implement) genericClass.getAnnotation(Implement.class);
if (annotation != null) {
int viewType = annotation.view().hashCode();
customViewTypeMap.put(viewType, annotation.view());
return viewType;
}
}
}
}
throw new RuntimeException("Undefined ViewType");
}
@Override
public SettingItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
Class clazz = customViewTypeMap.get(viewType);
if (clazz == null) {
throw new RuntimeException("onCreateViewHolder: undefined viewType");
}
try {
Constructor constructor = clazz.getConstructor(Context.class, AttributeSet.class);
constructor.setAccessible(true);
View v = (View) constructor.newInstance(context, null);
if (v.getLayoutParams() == null) {
v.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
return new SettingItemViewHolder(context, v);
} catch (IllegalAccessException | java.lang.InstantiationException |
NoSuchMethodException | InvocationTargetException e) {
e.printStackTrace();
throw new RuntimeException("onCreateViewHolder: view cannot be inflated = "
+ clazz.getSimpleName() + "\n" + DJILogUtils.exceptionToString(e));
}
}
@Override
public void onBindViewHolder(SettingItemViewHolder holder, int position) {
// 把重用的viewHolder中的原有订阅关系取消
holder.getCompositeDisposable().clear();
SettingItemViewController<?> controller = controllerList.get(position);
if (controller instanceof SettingItemStandardViewController) {
((SettingItemViewInterface) holder.itemView).unBind();
((SettingItemStandardViewController) controller).onBindViewHolder(
holder.itemView.getContext(),
(SettingItemViewInterface) holder.itemView,
holder.getCompositeDisposable(),
position);
}
}
@Override
public int getItemCount() {
return controllerList.size();
}
}
-
controllerList
:这个就是需要展示的列表 Item List。每一个具体项都是一个具体的SettingItemViewController
,例如上面的SettingDeviceNameViewController
。 -
getItemViewType
方法:遍历Adapter
所持有的controllerList
中的每一个controller
,通过反射方法找到泛型中所写对应的viewInterface
。然后查找这个viewInterface
的@Implement
注解,找到对应的ItemView.class
。将ItemView.class
的hashCode
作为一种ViewTypeId
返回,并且将[ViewTypeId, ItemView.class]
的映射缓存下来,用于之后在onCreateViewHolder()
中根据ViewTypeId
方便地找到ItemView.class
。这个方法收集了所有的ItemViewType
。 -
onCreateViewHolder
方法:通过方法参数viewType
从[ViewTypeId, ItemView.class]
中找到需要创建的ItemView
,然后反射调用它的构造方法创建ItemView
的实例。最终返回统一的SettingItemViewHolder
。 -
onBindViewHolder
方法:这个是RecyclerView
通过ViewHolder
复用ItemView
的关键方法。将holder.itemView
强制转换为SettingItemViewInterface
,然后调用unBind()
方法将holder.itemView
的状态重置为初始状态。接着根据方法中传入的position
获取到需要被重新绑定的SettingItemViewController
,强转为SettingItemStandardViewController
后,调用onBindViewHolder()
将holder.itemView
以接口的形式注入。之后这条设置项的逻辑就全部都在,比如SettingDeviceNameViewController
这样的具体ViewController
中运行了。 -
onViewRecycled
方法:我们在这里将ViewController
中的所有被holder.getCompositeDisposable()
管理的订阅关系解除。这样不可见的ItemView
就不会在后台运行业务逻辑。