Android真響應(yīng)式架構(gòu)——Epoxy的使用

前言

Android真響應(yīng)式架構(gòu)系列文章:

Android真響應(yīng)式架構(gòu)——MvRx
Epoxy——RecyclerView的絕佳助手
Android真響應(yīng)式架構(gòu)——Model層設(shè)計(jì)
Android真響應(yīng)式架構(gòu)——數(shù)據(jù)流動性
Android真響應(yīng)式架構(gòu)——Epoxy的使用
Android真響應(yīng)式架構(gòu)——MvRx和Epoxy的結(jié)合

在第一篇文章中,我就說過,MvRx界面響應(yīng)式的關(guān)鍵在于Epoxy,并且在第二篇文章中對Epoxy的使用方式做了簡單介紹。我個人認(rèn)為,MvRx真正難以掌握的是Epoxy,而不是MvRx本身。你可以去查看一下MvRx的代碼,真的沒有幾個類,也沒有多少代碼,還是很容易理解的。但是Epoxy就顯得復(fù)雜多了,代碼量及復(fù)雜程度都大大增加。雖說,MvRx將Epoxy視為可選項(xiàng),但是,我覺得沒有Epoxy的話,MvRx的作用將大打折扣。如果沒有Epoxy,MvRx也就失去了界面響應(yīng)式的能力,那么MvRx也不能稱之為“真響應(yīng)式架構(gòu)”,雖說真不真的也沒有什么意義(這個名字也是我瞎起的),至少M(fèi)vRx相較于Android Architecture Component的優(yōu)勢就小了很多。所以,我還是推薦MvRx結(jié)合Epoxy一起使用的。
Epoxy之于MvRx的作用是毋庸置疑的,但是,Epoxy本身的復(fù)雜性也是無法回避的。關(guān)于Epoxy,我個人的理解也有限,這篇文章談?wù)?,我在使用Epoxy的過程中遇到的容易出錯的地方,以及一種不太容易想到的使用方式。

這篇文章主要講兩點(diǎn):1. Epoxy是如果設(shè)置item的點(diǎn)擊事件的;2. 使用Epoxy對RecyclerView進(jìn)行嵌套使用,以拓展Epoxy的使用范圍。以上內(nèi)容都是基于Epoxy的具體實(shí)踐,會涉及到Epoxy的很多內(nèi)容,這些內(nèi)容我不可能面面俱到,希望你已經(jīng)熟悉Epoxy的基本使用方式,然后再來看這篇文章。

1. 點(diǎn)擊事件

Epoxy——RecyclerView的絕佳助手文中,提到了如何設(shè)置點(diǎn)擊事件,講得很簡單,實(shí)際上這是個很tricky的點(diǎn)。
在Epoxy中我們經(jīng)常這么設(shè)置點(diǎn)擊事件:

@CallbackProp
fun onClickListener(listener: OnClickListener?) {
    setOnClickListener(listener)
}

這實(shí)際上等價于

@ModelProp(options = {Option.NullOnRecycle, Option.DoNotHash})
fun onClickListener(listener: OnClickListener?) {
    setOnClickListener(listener)
}

CallbackProp注解相當(dāng)于ModelProp注解設(shè)置了NullOnRecycleDoNotHash兩個選項(xiàng)。NullOnRecycle的含義是:當(dāng)View滑出屏幕,從RecyclerView解綁時,將對應(yīng)的屬性設(shè)為null(對于上例而言,即調(diào)用onClickListener(null));DoNotHash的含義是:該屬性的hashcode發(fā)生變化時,不進(jìn)行重新的綁定。這兩點(diǎn)都非常符合類似于點(diǎn)擊事件這樣的回調(diào)。通常情況下,我們都會使用匿名內(nèi)部類的方式去設(shè)置點(diǎn)擊事件的回調(diào),如果沒有設(shè)置DoNotHash,那么每次EpoxyModels重建時(調(diào)用requestModelBuild方法),那么所有包含點(diǎn)擊事件回調(diào)的EpoxyModel都會被認(rèn)為是發(fā)生了改變的,因?yàn)辄c(diǎn)擊事件的回調(diào)是以匿名內(nèi)部類的方式實(shí)現(xiàn)的,這次匿名內(nèi)部類的hashcode自然和上次的不同,這會導(dǎo)致幾乎所有EpoxyModel都需要重新綁定到RecyclerView上。然而,一般而言,這是不必要的,因?yàn)殡m然匿名內(nèi)部類不相等了,但是匿名內(nèi)部類表達(dá)的含義并沒有改變,因此沒必要重新綁定,而DoNotHash正是起到這樣的作用。所以說,實(shí)際上DoNotHash或者說CallbackProp起到了一定優(yōu)化的作用。
但是,這里面有個大問題,如果我們表示回調(diào)的匿名內(nèi)部類捕獲了外部的變量,當(dāng)這個回調(diào)被調(diào)用時,這個變量可能已經(jīng)過時了。來看個例子:

選擇學(xué)科
@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)
        //已經(jīng)選擇的科目ID為checkedSubjectID
        checked(checkedSubjectID == subject.id)
        onClickListener { view ->
            //這里捕獲了外層變量checkedSubjectID
            if (checkedSubjectID != subject.id)
            //...
        }
    }
}

在Kotlin中我們一般使用lambda表達(dá)式來實(shí)現(xiàn)點(diǎn)擊事件的回調(diào),本質(zhì)上跟匿名內(nèi)部類是一樣的。在該lambda表達(dá)式內(nèi)部,我們捕獲了外部變量checkedSubjectID,但是該變量會隨著我們切換學(xué)科而改變,當(dāng)點(diǎn)擊事件發(fā)生,lambda表達(dá)式被調(diào)用時,被捕獲的checkedSubjectID的值可能已經(jīng)過時了。這是因?yàn)?,我們使用?code>DoNotHash,第一次lambda表達(dá)式捕獲的變量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。通過這個接口提供的第一個參數(shù)model,我們可以獲取當(dāng)前這個EpoxyModel的最新的屬性:

//使用 OptionItem 的代碼片段
subjects.forEach { subject ->
    optionItem {
        id(subject.id)
        name(subject.name)
        //已經(jīng)選擇的科目ID為checkedSubjectID
        checked(checkedSubjectID == subject.id)
        onClickListener { model, _, _, _ ->
            //model指的就是當(dāng)前這個OptionItemModel_,通過其checked()方法可以獲取當(dāng)前model最新的屬性
            if (!model.checked())
            //...
        }
    }
}

通過OnModelClickListener接口獲取的model的最新的屬性,這種方式不會出現(xiàn)數(shù)據(jù)過時的問題。
不過,這種方式只適用于點(diǎn)擊事件回調(diào),對于別的回調(diào)(例如長按事件回調(diào)等等),Epoxy并不會幫我們生成類似的接口。關(guān)于這個問題更多的解決方案,可以查看Epoxy的文檔。

2. 擴(kuò)展Epoxy的使用

在Epoxy的幫助下,大部分界面的主體部分都可以使用RecyclerView來實(shí)現(xiàn),有的界面可能看上去并不像是需要RecyclerView來實(shí)現(xiàn)的,此時,你可以把界面作為唯一的元素放進(jìn)RecyclerView中,這樣便于Epoxy的統(tǒng)一管理。但是,界面是千變?nèi)f化的,有些情況下,Epoxy也顯得力不從心。

底部有按鈕

如上圖所示,界面主體部分仍然可以使用RecyclerView,但是,在界面的底部卻錨定著一個按鈕,通常情況下,我們會使用LinearLayout裝載RecyclerView和底部按鈕,讓按鈕固定在底部就可以了。這沒有太大的問題,只是在網(wǎng)絡(luò)請求過程中,界面顯示Loading的狀態(tài)下,底部的按鈕會顯示出來。假設(shè)我們需要在網(wǎng)絡(luò)出錯時,整個界面顯示“網(wǎng)絡(luò)出錯,點(diǎn)擊重試”之類的提示,那么我們還需要控制底部按鈕是否可見等等。這樣的界面顯得就不那么響應(yīng)式,如果能做到把底部按鈕也放進(jìn)RecyclerView進(jìn)行統(tǒng)一管理就更完美了,這樣無論網(wǎng)絡(luò)請求成功與否,都可以通過Epoxy管理要顯示的元素(網(wǎng)絡(luò)成功時顯示列表+按鈕,失敗時顯示網(wǎng)絡(luò)錯誤提示),這樣顯然更加符合界面響應(yīng)式的思想。Epoxy其實(shí)提供了這樣的能力。
如果要把底部按鈕也放進(jìn)RecyclerView中,并且保持上部的仍然是個列表,需要使用Epoxy的兩個擴(kuò)展特性:

  1. Grouping Models
  2. Carousels

Grouping Models是指將多個Models結(jié)合成一組,再以組的形式交由Epoxy管理。Grouping Models的內(nèi)容較多,這里就不展開講了,具體內(nèi)容可以查看Epoxy的文檔

Carousels本意是旋轉(zhuǎn)木馬或者跑馬燈。Epoxy幫我們大大簡化了RecyclerView嵌套RecyclerView的使用,因?yàn)槌S糜凇靶D(zhuǎn)木馬”的效果,所以就將這種特性稱為Carousels。Carousels的內(nèi)容也很多,具體內(nèi)容可以查看Epoxy的文檔。

旋轉(zhuǎn)木馬效果,縱向RecyclerView嵌套橫向RecyclerView

雖然Carousels常用于“旋轉(zhuǎn)木馬”的效果,但是其本質(zhì)還是RecyclerView的嵌套,我們可以擴(kuò)展Carousels,把它用于兩個縱向的RecyclerView嵌套,并且結(jié)合Grouping Models,就可以把底部按鈕也放進(jìn)RecyclerView中,并且保持上部的仍然是個RecyclerView:

/**
 * RecyclerView內(nèi)部嵌套的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() //上部考點(diǎn)的Models
).addTo(epoxyController)

以上代碼省略了非常多的內(nèi)容,僅僅是個示例。大致含義是,先通過擴(kuò)展Carousel定義我們自己的,用于縱向嵌套的RecyclerView;然后通過EpoxyModelGroup把嵌套的RecyclerView和底部的按鈕都放入外層的主RecyclerView中。這只是網(wǎng)絡(luò)請求成功的情況,失敗的情況下,我們可以把網(wǎng)絡(luò)錯誤提示的Model放入主RecyclerView中,無縫切換,做到真正的界面響應(yīng)式。

嵌套的RecyclerView

最后一個問題,一個縱向滑動的RecyclerView內(nèi)部又嵌套了一個縱向滑動的RecyclerView,如果外層的RecyclerView攔截了滑動事件,那么滑動事件將傳遞不到內(nèi)部的RecyclerView,這將導(dǎo)致內(nèi)部的RecyclerView不可滑動。經(jīng)過一番嘗試后,發(fā)現(xiàn)可以將外層RecyclerView的LayoutManager設(shè)置為不可滑動的,這樣外層RecyclerView就不會攔截滑動事件了。

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

以上以一個例子說明了如果通過嵌套RecyclerView的方式擴(kuò)展Epoxy的使用場景,其實(shí),這不僅適用于底部有固定按鈕的情況,界面頂部有什么固定元素,或者頂部底部都有固定元素,甚至中間有固定元素的都可以使用。

頂部固定
頂部底部均固定

總結(jié)

本文介紹了我關(guān)于Epoxy的一些實(shí)踐經(jīng)驗(yàn)。第一點(diǎn)是關(guān)于Epoxy點(diǎn)擊事件容易犯的錯誤及解決方案,這是很容易犯錯的一點(diǎn),當(dāng)你的點(diǎn)擊事件跟你想要的效果不一樣的時候,可以查看一下是不是這個地方錯了;第二點(diǎn)是如何擴(kuò)展Epoxy使用場景的問題,也是我在實(shí)踐中摸索出來的方式,希望對你有用。Epoxy的內(nèi)容很多,其源碼也比較復(fù)雜,我知之有限,如果你有什么問題,歡迎留言交流。

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

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

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