前言
Android真响应式架构系列文章:
Android真响应式架构——MvRx
Epoxy——RecyclerView的绝佳助手
Android真响应式架构——Model层设计
Android真响应式架构——数据流动性
Android真响应式架构——Epoxy的使用
Android真响应式架构——MvRx和Epoxy的结合
在第一篇文章中,我就说过,MvRx界面响应式的关键在于Epoxy,并且在第二篇文章中对Epoxy的使用方式做了简单介绍。我个人认为,MvRx真正难以掌握的是Epoxy,而不是MvRx本身。你可以去查看一下MvRx的代码,真的没有几个类,也没有多少代码,还是很容易理解的。但是Epoxy就显得复杂多了,代码量及复杂程度都大大增加。虽说,MvRx将Epoxy视为可选项,但是,我觉得没有Epoxy的话,MvRx的作用将大打折扣。如果没有Epoxy,MvRx也就失去了界面响应式的能力,那么MvRx也不能称之为“真响应式架构”,虽说真不真的也没有什么意义(这个名字也是我瞎起的),至少MvRx相较于Android Architecture Component的优势就小了很多。所以,我还是推荐MvRx结合Epoxy一起使用的。
Epoxy之于MvRx的作用是毋庸置疑的,但是,Epoxy本身的复杂性也是无法回避的。关于Epoxy,我个人的理解也有限,这篇文章谈谈,我在使用Epoxy的过程中遇到的容易出错的地方,以及一种不太容易想到的使用方式。
这篇文章主要讲两点:1. Epoxy是如果设置item的点击事件的;2. 使用Epoxy对RecyclerView进行嵌套使用,以拓展Epoxy的使用范围。以上内容都是基于Epoxy的具体实践,会涉及到Epoxy的很多内容,这些内容我不可能面面俱到,希望你已经熟悉Epoxy的基本使用方式,然后再来看这篇文章。
1. 点击事件
在Epoxy——RecyclerView的绝佳助手文中,提到了如何设置点击事件,讲得很简单,实际上这是个很tricky的点。
在Epoxy中我们经常这么设置点击事件:
@CallbackProp
fun onClickListener(listener: OnClickListener?) {
setOnClickListener(listener)
}
这实际上等价于
@ModelProp(options = {Option.NullOnRecycle, Option.DoNotHash})
fun onClickListener(listener: OnClickListener?) {
setOnClickListener(listener)
}
CallbackProp
注解相当于ModelProp
注解设置了NullOnRecycle
和DoNotHash
两个选项。NullOnRecycle
的含义是:当View滑出屏幕,从RecyclerView解绑时,将对应的属性设为null(对于上例而言,即调用onClickListener(null)
);DoNotHash
的含义是:该属性的hashcode发生变化时,不进行重新的绑定。这两点都非常符合类似于点击事件这样的回调。通常情况下,我们都会使用匿名内部类的方式去设置点击事件的回调,如果没有设置DoNotHash
,那么每次EpoxyModels重建时(调用requestModelBuild
方法),那么所有包含点击事件回调的EpoxyModel都会被认为是发生了改变的,因为点击事件的回调是以匿名内部类的方式实现的,这次匿名内部类的hashcode自然和上次的不同,这会导致几乎所有EpoxyModel都需要重新绑定到RecyclerView上。然而,一般而言,这是不必要的,因为虽然匿名内部类不相等了,但是匿名内部类表达的含义并没有改变,因此没必要重新绑定,而DoNotHash
正是起到这样的作用。所以说,实际上DoNotHash
或者说CallbackProp
起到了一定优化的作用。
但是,这里面有个大问题,如果我们表示回调的匿名内部类捕获了外部的变量,当这个回调被调用时,这个变量可能已经过时了。来看个例子:
@ModelView
class OptionItem @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : TextView(context, attrs, defStyleAttr) {
@ModelProp
fun setName(name: CharSequence?) {
text = name
}
@ModelProp
fun setChecked(checked: Boolean) {
if (checked)
setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.checked, 0)
else
setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
@CallbackProp
fun onClickListener(listener: OnClickListener?) {
setOnClickListener(listener)
}
}
//使用 OptionItem 的代码片段
subjects.forEach { subject ->
optionItem {
id(subject.id)
name(subject.name)
//已经选择的科目ID为checkedSubjectID
checked(checkedSubjectID == subject.id)
onClickListener { view ->
//这里捕获了外层变量checkedSubjectID
if (checkedSubjectID != subject.id)
//...
}
}
}
在Kotlin中我们一般使用lambda表达式来实现点击事件的回调,本质上跟匿名内部类是一样的。在该lambda表达式内部,我们捕获了外部变量checkedSubjectID
,但是该变量会随着我们切换学科而改变,当点击事件发生,lambda表达式被调用时,被捕获的checkedSubjectID
的值可能已经过时了。这是因为,我们使用了DoNotHash
,第一次lambda表达式捕获的变量checkedSubjectID
是多少,之后就总是那个值,不会改变。这显然是不行的,Epoxy的做法是使用OnModelClickListener
来替代OnClickListener
接口。以下是OnModelClickListener
接口的定义:
/** Used to register a click listener on a generated model. */
public interface OnModelClickListener<T extends EpoxyModel<?>, V> {
/**
* Called when the view bound to the model is clicked.
*
* @param model The model that the view is bound to.
* @param parentView The view bound to the model which received the click.
* @param clickedView The view that received the click. This is either a child of the parentView
* or the parentView itself
* @param position The position of the model in the adapter.
*/
void onClick(T model, V parentView, View clickedView, int position);
}
以上面提到的OptionItem
为例,Epoxy会生成如下的OptionItemModel_
:
public class OptionItemModel_ extends EpoxyModel<OptionItem> {
//OnModelClickListener接口
public OptionItemModel_ onClickListener(
@Nullable final OnModelClickListener<OptionItemModel_, OptionItem> onClickListener) {
//...
}
//OnClickListener接口
public OptionItemModel_ onClickListener(@Nullable OnClickListener onClickListener) {
//...
}
}
虽然我们在OptionItem
中定义的是OnClickListener
接口,但是Epoxy会帮我们生成另外一个接口OnModelClickListener
。通过这个接口提供的第一个参数model
,我们可以获取当前这个EpoxyModel的最新的属性:
//使用 OptionItem 的代码片段
subjects.forEach { subject ->
optionItem {
id(subject.id)
name(subject.name)
//已经选择的科目ID为checkedSubjectID
checked(checkedSubjectID == subject.id)
onClickListener { model, _, _, _ ->
//model指的就是当前这个OptionItemModel_,通过其checked()方法可以获取当前model最新的属性
if (!model.checked())
//...
}
}
}
通过OnModelClickListener
接口获取的model的最新的属性,这种方式不会出现数据过时的问题。
不过,这种方式只适用于点击事件回调,对于别的回调(例如长按事件回调等等),Epoxy并不会帮我们生成类似的接口。关于这个问题更多的解决方案,可以查看Epoxy的文档。
2. 扩展Epoxy的使用
在Epoxy的帮助下,大部分界面的主体部分都可以使用RecyclerView来实现,有的界面可能看上去并不像是需要RecyclerView来实现的,此时,你可以把界面作为唯一的元素放进RecyclerView中,这样便于Epoxy的统一管理。但是,界面是千变万化的,有些情况下,Epoxy也显得力不从心。
如上图所示,界面主体部分仍然可以使用RecyclerView,但是,在界面的底部却锚定着一个按钮,通常情况下,我们会使用LinearLayout
装载RecyclerView和底部按钮,让按钮固定在底部就可以了。这没有太大的问题,只是在网络请求过程中,界面显示Loading的状态下,底部的按钮会显示出来。假设我们需要在网络出错时,整个界面显示“网络出错,点击重试”之类的提示,那么我们还需要控制底部按钮是否可见等等。这样的界面显得就不那么响应式,如果能做到把底部按钮也放进RecyclerView进行统一管理就更完美了,这样无论网络请求成功与否,都可以通过Epoxy管理要显示的元素(网络成功时显示列表+按钮,失败时显示网络错误提示),这样显然更加符合界面响应式的思想。Epoxy其实提供了这样的能力。
如果要把底部按钮也放进RecyclerView中,并且保持上部的仍然是个列表,需要使用Epoxy的两个扩展特性:
- Grouping Models
- Carousels
Grouping Models是指将多个Models结合成一组,再以组的形式交由Epoxy管理。Grouping Models的内容较多,这里就不展开讲了,具体内容可以查看Epoxy的文档。
Carousels本意是旋转木马或者跑马灯。Epoxy帮我们大大简化了RecyclerView嵌套RecyclerView的使用,因为常用于“旋转木马”的效果,所以就将这种特性称为Carousels。Carousels的内容也很多,具体内容可以查看Epoxy的文档。
虽然Carousels常用于“旋转木马”的效果,但是其本质还是RecyclerView的嵌套,我们可以扩展Carousels,把它用于两个纵向的RecyclerView嵌套,并且结合Grouping Models,就可以把底部按钮也放进RecyclerView中,并且保持上部的仍然是个RecyclerView:
/**
* RecyclerView内部嵌套的RecyclerView(纵向)
*/
@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_MATCH_HEIGHT)
class InnerRv @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : Carousel(context, attrs, defStyleAttr) {
override fun createLayoutManager(): LayoutManager {
return LinearLayoutManager(context)
}
override fun getSnapHelperFactory(): SnapHelperFactory? {
return null
}
override fun getDefaultSpacingBetweenItemsDp(): Int {
return 0
}
}
R.layout.bottom_btn_recycler_view
如下
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ViewStub
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:inflatedId="@+id/recyclerView">
</ViewStub>
<ViewStub
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_button_height"/>
</LinearLayout>
把底部按钮也放入RecyclerView中:
fun bottomModelGroup(bottomModel: EpoxyModel<*>, models: List<EpoxyModel<*>>): EpoxyModelGroup {
return EpoxyModelGroup(
R.layout.bottom_btn_recycler_view,
InnerRvModel_().id(1).models(models),
bottomModel.id(2)
)
}
//真正使用
bottomModelGroup(
BottomButtonModel_(), //底部按钮的Model
pointModels() //上部考点的Models
).addTo(epoxyController)
以上代码省略了非常多的内容,仅仅是个示例。大致含义是,先通过扩展Carousel
定义我们自己的,用于纵向嵌套的RecyclerView;然后通过EpoxyModelGroup
把嵌套的RecyclerView和底部的按钮都放入外层的主RecyclerView中。这只是网络请求成功的情况,失败的情况下,我们可以把网络错误提示的Model放入主RecyclerView中,无缝切换,做到真正的界面响应式。
最后一个问题,一个纵向滑动的RecyclerView内部又嵌套了一个纵向滑动的RecyclerView,如果外层的RecyclerView拦截了滑动事件,那么滑动事件将传递不到内部的RecyclerView,这将导致内部的RecyclerView不可滑动。经过一番尝试后,发现可以将外层RecyclerView的LayoutManager设置为不可滑动的,这样外层RecyclerView就不会拦截滑动事件了。
class NoScrollLayoutManager(context: Context) : LinearLayoutManager(context) {
override fun canScrollHorizontally() = false
override fun canScrollVertically() = false
}
以上以一个例子说明了如果通过嵌套RecyclerView的方式扩展Epoxy的使用场景,其实,这不仅适用于底部有固定按钮的情况,界面顶部有什么固定元素,或者顶部底部都有固定元素,甚至中间有固定元素的都可以使用。
总结
本文介绍了我关于Epoxy的一些实践经验。第一点是关于Epoxy点击事件容易犯的错误及解决方案,这是很容易犯错的一点,当你的点击事件跟你想要的效果不一样的时候,可以查看一下是不是这个地方错了;第二点是如何扩展Epoxy使用场景的问题,也是我在实践中摸索出来的方式,希望对你有用。Epoxy的内容很多,其源码也比较复杂,我知之有限,如果你有什么问题,欢迎留言交流。