Epoxy——RecyclerView的絕佳助手

前言

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

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

什么是Epoxy?Epoxy是Airbnb開源的一個庫,主要幫助我們構(gòu)建復(fù)雜的RecyclerView,使用Epoxy可以讓我們在毫無感知的情況下構(gòu)建出復(fù)雜的多ViewType的RecyclerView。這么形容這個庫有點太平淡了,實際上Epoxy是響應(yīng)式框架MvRx的重要組成部分,是實現(xiàn)響應(yīng)式界面的關(guān)鍵(強行安利,MvRx是Airbnb開源的一個另一個庫,簡單來說它是一套Android響應(yīng)式MVVM開發(fā)框架,具體可以查看Android真響應(yīng)式架構(gòu)——MvRx)。
我因為使用MvRx而接觸到這個庫,由衷地覺得其異常強大,大大簡化了界面的開發(fā)。但是這么個強大的庫,在國內(nèi)幾乎沒啥人知道,也沒幾篇與之相關(guān)的文章,因此我決定寫一篇文章來介紹它的使用方式,做個布道者。

Epoxy這個名字起得很有意思,Epoxy的原意是環(huán)氧樹脂,一聽就不是一個常用的單詞,環(huán)氧樹脂的一個重要用途是黏合,我猜起這個名字也是這個意思,黏合數(shù)據(jù)與item,item與RecyclerView。

1. Epoxy的作用

試問使用RecyclerView的關(guān)鍵是什么,無外乎兩點:

  1. 數(shù)據(jù)是什么
  2. 數(shù)據(jù)如何顯示

數(shù)據(jù)是千變?nèi)f化的,顯示更是形形色色的。眾所周知,要讓RecyclerView顯示多種ViewType,還是一件比較繁瑣的事。Epoxy的第一個重要作用就是簡化了復(fù)雜多ViewType的開發(fā)流程,使我們專注在數(shù)據(jù)與顯示的綁定上就可以了,剩下的Epoxy會幫我們處理。實際上,無論RecyclerView是單ViewType還是多ViewType,在Epoxy的幫助下,兩者對于開發(fā)者而言是一模一樣的,就是這么的無感。
你以為這就完了,too young。以上只描述了一半,也就是數(shù)據(jù)不變動的情形,還有另一半內(nèi)容,即在數(shù)據(jù)發(fā)生變化的情況下,RecyclerView如何更新。又眾所周知了,手動跟蹤數(shù)據(jù)的變動然后通知RecyclerView更新是很費勁的,要不然就得無腦地notifyDataSetChanged,這都不是我們想要的,理想情況下應(yīng)該是這樣的:

這不是巧了這不是,Epoxy剛好可以幫我們完成上述任務(wù)的后兩步。我們要做的就是聲明數(shù)據(jù)的變化。

總結(jié):Epoxy的主要作用有兩個,第一,簡化RecyclerView多ViewType的開發(fā)(簡化到毫無感知的地步);第二,如果數(shù)據(jù)變化,幫我們找出差別,然后做出對應(yīng)的更新。因此,在Epoxy的幫助下,我們使用RecyclerView的主要任務(wù)就變成了:

  1. 綁定數(shù)據(jù)與View(定義數(shù)據(jù)如何顯示)
  2. 按照順序聲明數(shù)據(jù)
  3. 數(shù)據(jù)變化后重新聲明數(shù)據(jù)

2. 基本概念

Epoxy有兩個重要組件:

  1. EpoxyModel:描述了某個view如何在RecyclerView中顯示
  2. EpoxyController:確定哪些item顯示在RecyclerView中

使用RecyclerView繞不開就是數(shù)據(jù)如何在View中顯示的問題,這一步任何庫都幫不了我們,必須自己定義。原來我們使用RecyclerView是在onBindViewHolder中完成的這一步,在Epoxy中,使用EpoxyModel來完成這一步。當(dāng)然EpoxyModel的功能還遠不止于此。上面提到,Epoxy會幫我們找出“數(shù)據(jù)”的差別,然后做出對應(yīng)更新,這里說的“數(shù)據(jù)”指的就是EpoxyModel。找出“數(shù)據(jù)”差別的前提就是知曉“數(shù)據(jù)”的同與不同,也就是說,該“數(shù)據(jù)”需要有hashCodeequals方法,剛好,EpoxyModel中有這樣的方法。以上是EpoxyModel的作用,關(guān)于其定義,下面會有介紹。
EpoxyModel定義了item顯示的基礎(chǔ),EpoxyController則決定了item的顯示順序。通過EpoxyController的buildModel方法提交“當(dāng)前狀態(tài)”下的EpoxyModels,Epoxy會幫我們和上一次EpoxyModels(如果有的話)進行比較,然后更新RecyclerView。如果數(shù)據(jù)發(fā)生變化,調(diào)用EpoxyController的requestModelBuild方法,EpoxyController的buildModel方法會再次被調(diào)用,創(chuàng)建“當(dāng)前狀態(tài)”下的EpoxyModels,如此反復(fù)(注意,不能直接調(diào)用buildModel)。
可以看出EpoxyController本身就是為了適應(yīng)/鼓勵MVVM的模式。數(shù)據(jù)只能單向流動:

流程圖

Epoxy幫我們實現(xiàn)了從數(shù)據(jù)State到EpoxyModels到RecyclerView顯示的黏合,至于如何操縱數(shù)據(jù)State,以及何時調(diào)用requestModelBuild方法則取決于我們(推薦MvRx,它完美地解決了這個問題)。

Epoxy比較前后兩次EpoxyModels找出差別的過程,以下簡稱為diff

3. 創(chuàng)建EpoxyModel

有四種方式可以創(chuàng)建EpoxModel:

  1. 通過自定義View(推薦的方式)
  2. 通過DataBinding
  3. 通過ViewHolder
  4. Kotlin可以通過data class直接繼承EpoxyModel(非官方方式)

這里只介紹第一種推薦的方式。例如,item如下所示:

item

假如我們已經(jīng)定義好layout叫item_lottery.xml,那么EpoxyModel可以這么定義:

@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class LotteryView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {

    private val ivLottery by lazy {
        findViewById<ImageView>(R.id.ivLottery)
    }
    private val tvName by lazy {
        findViewById<TextView>(R.id.tvName)
    }
    private val tvTag by lazy {
        findViewById<TextView>(R.id.tvTag)
    }
    private val tvPoints by lazy {
        findViewById<TextView>(R.id.tvPoints)
    }

    init {
        //setPadding(...)
        inflate(context, R.layout.item_lottery, this)
    }

    @ModelProp
    fun setImgUrl(imgUrl: String) {
        //show image with you own way
    }

    @TextProp
    fun setName(name: CharSequence?) {
        tvName.text = name
    }

    @TextProp
    fun setTag(tag: CharSequence?) {
        tvTag.text = tag
    }

    @ModelProp
    fun setPoints(points: Int) {
        tvPoints.text = resources.getString(R.string.points, points)
    }

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

以上就定義了一個基本的EpoxyModel。雖說叫自定義view的方式,但是我們往往都是直接extends某個布局,因此為了避免不必要的View嵌套,item_lottery.xml會這么定義:

<?xml version="1.0" encoding="utf-8"?>
<merge
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:parentTag="android.support.constraint.ConstraintLayout">
    
</merge>

使用merge標(biāo)簽作為根。

下面解釋一下以自定義View的方式創(chuàng)建EpoxyModel的要點:

  1. @ModelView注解該class,其中的autoLayout = Size.MATCH_WIDTH_WRAP_HEIGHT屬性決定了該item加入到RecyclerView時的寬高。
  2. @ModelProp注解一個方法(方法只能有一個參數(shù))可以為該EpoxyModel增加一個屬性,該屬性決定了View的某種顯示,這就是Epoxy綁定數(shù)據(jù)與顯示的方式。
  3. 一個比較特殊的屬性是字符串,因為我們經(jīng)常需要Android String resources的支持。這時可以用@TextProp注解一個方法(該方法參數(shù)類型必須為CharSequence),生成的EpoxyModel會包含若干該屬性的重載方法,方便我們直接使用Android String resources。
  4. 除了屬性以外還需要有回調(diào)接口,例如OnClickListener?;卣{(diào)接口之所以不同于普通屬性,是因為它們并不會影響View的顯示,并且需要在item滾動出屏幕之外時解綁(設(shè)為null防止內(nèi)存泄漏),@CallbackProp注解的方法剛好幫我們完成這一切。

完成LotteryView這一“自定義View”之后,(構(gòu)建工程后)Epoxy會幫我們生成一個名為LotteryViewModel_的類,原類名加上Model_后綴,然后LotteryViewModel_就可以在EpoxyController中使用了。

LotteryViewModel_是擴展自EpoxyModel<LotteryView>的子類,包含了我們注解的所有屬性,它代表了數(shù)據(jù)與View的綁定關(guān)系。此外,LotteryViewModel_中還包含了equalshashCode方法(比較我們定義的屬性),這兩個方法是diff的基礎(chǔ)。

3.1 EpoxyModel的ID

EpoxyModel另一個重要概念是ID。每個EpoxyModel都應(yīng)該有唯一確定的ID來標(biāo)識它,這個ID被用來diff和保存state。作為所有Model的基類EpoxyModel,它包含了以下方法:

public EpoxyModel<T> id(long id)

public EpoxyModel<T> id(@Nullable Number... ids)
  
public EpoxyModel<T> id(long id1, long id2)

public EpoxyModel<T> id(@Nullable CharSequence key)
  
public EpoxyModel<T> id(@Nullable CharSequence key, @Nullable CharSequence... otherKeys)

public EpoxyModel<T> id(@Nullable CharSequence key, long id)

可見生成ID的方法非常多

//例如來自數(shù)據(jù)庫主鍵
model.id(id)
//通過字符串
model.id("header")
//組合的方式,類似名稱空間的概念
model.id("photo", photoId)
model.id("video", videoId)

ID是以64位的long值表示的,除了model.id(id)這種最直接的方式,其它方式會以hash的方式計算ID,存在沖突的可能性,假如有幾百個Model,都使用hash的方式生成ID,那么沖突的可能性是100萬億分之一(可比你寫bug的概率小太多了)??梢赃x擇過濾掉ID相同的EpoxyModel,具體內(nèi)容請查看文檔。

3.2 更多EpoxyModel的使用

EpoxyModel中的屬性可以是任意的,不一定非得是primitive type,但是該屬性必須提供equalshashCode方法,否則會產(chǎn)生編譯錯誤。

//MyObject類必須實現(xiàn) equals和 hashCode方法
@ModelProp
fun setImg(param: MyObject) {
    //...
}

屬性可以分組:

/**
* 重載的方式
*/
@ModelProp
fun setImage(url: String)

@ModelProp
fun setImage(@DrawableRes drawableRes: Int)

/**
* 顯式的指定
*/
@ModelProp(group = "image")
fun setImageUrl(url: String)

@ModelProp(group = "image")
fun setImageDrawable(@DrawableRes drawableRes: Int)

對于以上的Image屬性,我們提供url或者drawableRes就可以了。

上面提到@ModelProp注解的方法只能有一個參數(shù),有些情況下需要有多個屬性共同決定View的顯示,這時可以用@AfterPropsSet注解:

var point = 0
    @ModelProp set

var originPoint: Int = 0
    @ModelProp set

//在所有屬性都設(shè)置后調(diào)用
@AfterPropsSet
fun setupPoints() {
    val ss = SpannableString("$point $originPoint")
    //...
    tvPoints.text = ss
}

還可以為屬性提供默認(rèn)值,或者設(shè)定屬性為可選的(即可以不設(shè)置該屬性)。具體可以查看文檔。

4. EpoxyController的使用

以你想要的順序向EpoxyController中添加EpoxyModel,就可以確定RecyclerView中應(yīng)該顯示哪些items。使用EpoxyController的關(guān)鍵在于重寫其buildModel方法,buildModel方法只需要根據(jù)其調(diào)用時刻的數(shù)據(jù)狀態(tài)構(gòu)建EpoxyModels即可。例如:

public class PhotoController extends EpoxyController {

   private List<Photo> photos = Collections.emptyList();
   private boolean loadingMore = true;

   public void setLoadingMore(boolean loadingMore) {
      this.loadingMore = loadingMore;
      requestModelBuild();
   }

   public void setPhotos(List<Photo> photos) {
      this.photos = photos;
      requestModelBuild();
   }

    @Override
    protected void buildModels() {
      new HeaderModel_()
          .id("header model")
          .title("My Photos")
          .addTo(this); //通過addTo添加到EpoxyController中

      for (Photo photo : photos) {
        new PhotoModel_()
              .id(photo.getId())
              .url(photo.getUrl())
              .comment(photo.getComment())
              .addTo(this);
      }

      new LoadingModel_()
          .id("loading model")
          .addIf(loadingMore, this); //選擇性添加到EpoxyController
    }
}

controller = new PhotoController();
recyclerView.setAdapter(controller.getAdapter());
controller.requestModelBuild();

如上所示,每當(dāng)數(shù)據(jù)發(fā)生變化時,調(diào)用EpoxyController的requestModelBuild方法就會通知EpoxyController重新構(gòu)建EpoxyModels(即buildModel方法被調(diào)用),完成diff之后,RecyclerView被更新。
requestModelBuild的名稱一樣,只是請求Model的構(gòu)建,并不保證構(gòu)建會立即完成(除了第一次請求),新的請求會取消掉舊的請求(debounced),所以我們不用擔(dān)心多余的requestModelBuild,任意數(shù)據(jù)的變化都應(yīng)該發(fā)起requestModelBuild,不用考慮優(yōu)化的問題。
每次的buildModels都是完全獨立的(之前一次的EpoxyModels不會保存),每次都是從空的models列表開始的,所以每次buildModels都要構(gòu)建完整的EpoxyModels列表。
默認(rèn)情況下構(gòu)建Models和diff都發(fā)生在主線程,顯然這可能會引起界面的卡頓(尤其是diff操作),所以應(yīng)該把他們都放在子線程,有個開箱即用的方式:

public class PhotoController extends AsyncEpoxyController {
    //...
}

如果采用這種方式就要保證在buildModels中訪問數(shù)據(jù)是線程安全的,并且不能在該方法中更新View(例如根據(jù)數(shù)據(jù)狀態(tài)改變title什么的)。也可以選擇構(gòu)建Models仍在主線程,diff放在子線程,可以去查看文檔。

為了保證EpoxyController的正確運行,必須保證:

  1. 所有的EpoxyModels必須有唯一的ID
  2. EpoxyModels一旦被添加到EpoxyController之后就不能更改
    以上兩條是為了保證diff的正常工作,Epoxy也會幫我們驗證這兩條規(guī)則(可以在線上版本時關(guān)閉)。

5. EpoxyRecyclerView

Epoxy提供了一個RecyclerView的子類EpoxyRecyclerView,它使得Epoxy和RecyclerView的結(jié)合變得更加簡單,推薦使用。
EpoxyRecyclerView做了如下改進:

  1. 所有EpoxyRecyclerView共享的view回收池
  2. 默認(rèn)設(shè)定了LinearLayoutManager
  3. 提供了輔助方法可以不用自己創(chuàng)建EpoxyController
  4. 更加簡單的設(shè)定EpoxyController的方式
  5. 支持添加item之間的間隔距離
  6. 等等

6. 有用的局部更新

如果item還在RecyclerView中顯示,此時代表該item的EpoxyModel如果更新了,那么只會進行局部的更新,不會更新整個item。以如下界面為例:

假設(shè)生成的EpoxyModel叫PayWayModel_

流程圖

整體流程如上圖所示,其中diff操作會把ID相同的PayWayModel_進行比較,例如從微信支付切換到支付寶支付,前者的check屬性從true變?yōu)閒alse,前者的check屬性從false變?yōu)閠rue,其它屬性無變化,那么就只有這兩個item的CheckBox會刷新,其它的都不會刷新。如果按照我們傳統(tǒng)的方式,一般會調(diào)用notifyItemChanged方法,這會使這兩個item重新綁定,item中所有View均會刷新,導(dǎo)致item輕微閃爍,相反,Epoxy的局部更新就不會有這樣的問題。

以上以一個實際事例描述了Epoxy的更新流程,在這個例子中,由于PayWayModel_始終只有三個,只是屬性會更新,并且一般情況下這三個item會始終顯示在RecyclerView中,因此Epoxy會僅僅進行局部更新。不過,diff的功能絕不僅僅是比較相同ID的Model屬性的變化這么簡單,EpoxyModels的增加、刪除甚至是順序調(diào)整都會被diff比較出來,然后做出對應(yīng)界面更新。

總結(jié)

Epoxy是RecylerView的絕佳助手,大大簡化了RecyclerView的使用。有了Epoxy的幫助,你會發(fā)現(xiàn)RecyclerView的使用范圍也被大大擴展了,幾乎一切界面的主體部分都可以使用RecyclerView來承載。界面開發(fā)就變成了定義一個又一個的EpoxyModel,界面被拆分成一個又一個的EpoxyModel后,界面元素的復(fù)用也變得異常簡單,整個界面的開發(fā)就跟搭積木一樣。
如前所述,Epoxy完成了從數(shù)據(jù)State到EpoxyModels到RecyclerView顯示的黏合,至于如何操縱數(shù)據(jù)State以及通知更新則取決于我們,但是,這并不是一項簡單的任務(wù),這也可能是為啥Epoxy并不太流行的原因。還好MvRx幫我們完美地解決了這個問題,所以還是強烈推薦結(jié)合MvRx一起使用的,你會體驗到不同以往的Android開發(fā)流程。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容