Android真响应式架构——Epoxy的使用

前言

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注解设置了NullOnRecycleDoNotHash两个选项。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的两个扩展特性:

  1. Grouping Models
  2. Carousels

Grouping Models是指将多个Models结合成一组,再以组的形式交由Epoxy管理。Grouping Models的内容较多,这里就不展开讲了,具体内容可以查看Epoxy的文档

Carousels本意是旋转木马或者跑马灯。Epoxy帮我们大大简化了RecyclerView嵌套RecyclerView的使用,因为常用于“旋转木马”的效果,所以就将这种特性称为Carousels。Carousels的内容也很多,具体内容可以查看Epoxy的文档

旋转木马效果,纵向RecyclerView嵌套横向RecyclerView

虽然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不可滑动。经过一番尝试后,发现可以将外层RecyclerView的LayoutManager设置为不可滑动的,这样外层RecyclerView就不会拦截滑动事件了。

class NoScrollLayoutManager(context: Context) : LinearLayoutManager(context) {
    override fun canScrollHorizontally() = false
    override fun canScrollVertically() = false
}

以上以一个例子说明了如果通过嵌套RecyclerView的方式扩展Epoxy的使用场景,其实,这不仅适用于底部有固定按钮的情况,界面顶部有什么固定元素,或者顶部底部都有固定元素,甚至中间有固定元素的都可以使用。

顶部固定
顶部底部均固定

总结

本文介绍了我关于Epoxy的一些实践经验。第一点是关于Epoxy点击事件容易犯的错误及解决方案,这是很容易犯错的一点,当你的点击事件跟你想要的效果不一样的时候,可以查看一下是不是这个地方错了;第二点是如何扩展Epoxy使用场景的问题,也是我在实践中摸索出来的方式,希望对你有用。Epoxy的内容很多,其源码也比较复杂,我知之有限,如果你有什么问题,欢迎留言交流。

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

推荐阅读更多精彩内容