前言
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)鍵是什么,無外乎兩點:
- 數(shù)據(jù)是什么
- 數(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ù)就變成了:
- 綁定數(shù)據(jù)與View(定義數(shù)據(jù)如何顯示)
- 按照順序聲明數(shù)據(jù)
- 數(shù)據(jù)變化后重新聲明數(shù)據(jù)
2. 基本概念
Epoxy有兩個重要組件:
- EpoxyModel:描述了某個view如何在RecyclerView中顯示
- 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ù)”需要有hashCode
和equals
方法,剛好,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:
- 通過自定義View(推薦的方式)
- 通過DataBinding
- 通過ViewHolder
- Kotlin可以通過data class直接繼承EpoxyModel(非官方方式)
這里只介紹第一種推薦的方式。例如,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的要點:
- 用
@ModelView
注解該class,其中的autoLayout = Size.MATCH_WIDTH_WRAP_HEIGHT
屬性決定了該item加入到RecyclerView時的寬高。 - 用
@ModelProp
注解一個方法(方法只能有一個參數(shù))可以為該EpoxyModel增加一個屬性,該屬性決定了View的某種顯示,這就是Epoxy綁定數(shù)據(jù)與顯示的方式。 - 一個比較特殊的屬性是字符串,因為我們經(jīng)常需要Android String resources的支持。這時可以用
@TextProp
注解一個方法(該方法參數(shù)類型必須為CharSequence
),生成的EpoxyModel會包含若干該屬性的重載方法,方便我們直接使用Android String resources。 - 除了屬性以外還需要有回調(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_
中還包含了equals
和hashCode
方法(比較我們定義的屬性),這兩個方法是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,但是該屬性必須提供equals
和hashCode
方法,否則會產(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的正確運行,必須保證:
- 所有的EpoxyModels必須有唯一的ID
- EpoxyModels一旦被添加到EpoxyController之后就不能更改
以上兩條是為了保證diff的正常工作,Epoxy也會幫我們驗證這兩條規(guī)則(可以在線上版本時關(guān)閉)。
5. EpoxyRecyclerView
Epoxy提供了一個RecyclerView的子類EpoxyRecyclerView,它使得Epoxy和RecyclerView的結(jié)合變得更加簡單,推薦使用。
EpoxyRecyclerView做了如下改進:
- 所有EpoxyRecyclerView共享的view回收池
- 默認(rèn)設(shè)定了LinearLayoutManager
- 提供了輔助方法可以不用自己創(chuàng)建EpoxyController
- 更加簡單的設(shè)定EpoxyController的方式
- 支持添加item之間的間隔距離
- 等等
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ā)流程。