Android App 開發實戰系列 Part 5. Epoxy on RecyclerView

Part 5. 要來介紹呈現電影列表的套件 Epoxy,主要是用這個套件來呈現比較複雜的列表,像是我們的首頁,穿插夾雜了橫滑和直滑的列表,用嵌套的 RecyclerView 來實作技術上來說一定做得到,只是你要多花時間和心力,而 Epoxy 套件提供一個更容易的實作方式,讓我們來看 Epoxy 如何簡化我們的列表實作。

完整程式碼 https://github.com/enginebai/MovieHunt 已經釋出,可以下載程式碼邊看程式碼邊學習,歡迎給星 支持。這一系列文章是有連貫性的,如果還沒看過前面文章,建議先去看過前面的章節。傳送門:(Part 1.) (Part 2.) (Part 3.) (Part 4.)

還沒使用 Epoxy 之前

假設我們現在列表第一筆資料希望比較突顯,要放大圖片和文字,和其他地方用不同方式呈現,以我們傳統的 RecyclerView 作法就會是宣告兩個不同的介面檔 item_movie_large.xml / item_movie_normal.xml,然後在 Adapter 宣告不同的 View Type 分開使用:

data class MovieModel(
...
val largeSize: Boolean
)
class MovieHomeAdapter(private val movieList: List<MovieModel>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun getItemViewType(position: Int): Int {
return if (movieList.get(position).largeSize) {
R.layout.item_movie_large
} else {
R.layout.item_movie_normal
}
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerViewHolder {
val inflater = LayoutInflater.from(viewGroup.context)
return ViewHolder(inflater.inflate(viewType, viewGroup, false))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
// bind the data to the view holder
}
class ViewHolder(view: View): RecyclerView.ViewHolder(view) {
// find the view by id, display the data
}
}
view raw MovieHomeAdapter.kt hosted with ❤ by GitHub

如果今天再複雜一點,像是下列需求:

  1. 列表有分頁載入,當滑到列表最底下需要呈現載入中的 ProgressBar,像是 MovieHunt 的垂直列表。
  2. 列表要呈現不同分類的橫滑列表,且橫滑列表要支援分頁載入,載入中要呈現 ProgressBar,像是 MovieHunt 的首頁。
  3. 列表一開始要呈現另一個橫滑的列表、或是不同的橫滑項目列表,像是 FunNow。
  4. 列表要呈現不同分類的橫滑列表,像是 Google Play 呈現方式。

這樣的介面在傳統的 RecyclerView 寫起來肯定是複雜非常多,垂直 RecyclerView 裡面要嵌套橫向 RecyclerView,要定義不同的 Adapter,裡面要支援不同的 ViewType … 等,需要很多的元件模板和配置才能達到,以幾個知名的預定 App 來說需求肯定比上述幾個來要來的複雜,Airbnb 的工程團隊推出了 Epoxy 來解決這樣的問題。

Epoxy 是?

Epoxy 簡單來說就是讓你可以用輕鬆的方式來建立複雜的列表,你只需要定義

  • Step 1. 各別每一種 item view 長的樣子。
  • Step 2. 怎麼用 Step 1. 定義的 item view 在列表中排列出來。

對應到 Epoxy 的話,就是定義下面兩種不同元件:

  1. EpoxyModel:以傳統 RecyclerView 的作法來說,這個 EpoxyModel 就是 RecyclerView.ViewHolder 的角色,在 Model 裡面給定資料、定義資料如何在介面上呈現、要怎麼互動 … 等等。
  2. EpoxyController:把定義好不同的 EpoxyModel 在這邊組合起來成為列表的樣子。

以圖來說明這兩個元件:

以我們文章一開始提到「列表第一筆資料希望圖片和文字放大」需求來說,我們會宣告 LargeEpoxyModel & NormalEpoxyModel,然後實作 EpoxyController,程式碼大致是這樣(這邊先著重了解概念就好,等等會講細節怎麼宣告和使用):

class LargeEpoxyModel : EpoxyModel {
override fun bind() {
// binding implementation
}
}
class NormalEpoxyModel : EpoxyModel {
override fun bind() {
// binding implementation
}
}
class ModelController(private val movieList: List<MovieModel>): EpoxyController() {
override fun buildModels() {
movieList.orEachIndexed { index, movie ->
if (index == 0) {
LargeEpoxyModel_()
.addTo(this)
} else {
NormalEpoxyModel_()
.addTo(this)
}
}
}
}
view raw MovieController.kt hosted with ❤ by GitHub

對於 Epoxy 有一個大致的了解後,我們就來針對 MovieHunt 專案的使用來細說 EpoxyModelEpoxyController 用法。

Epoxy Model

這就代表了列表中每一個 item 的實作,EpoxyModel 支援三種不同的實作方式:

  1. Custom View
  2. Data Binding
  3. View Holder

用法是你依照這三種不同的方式來實作 Model 類別,然後建置專案 Make Project 後,EpoxyModel 會幫你自動產生以底線 _ 結尾的類別或者 Kotlin DSL Builder,你就可以在 EpoxyController 裡面使用這些自動產生的類別。

注意:你只要新增一個 EpoxyModel 或者對 EpoxyModel 類別裡面做什麼變動,都要建置專案,Epoxy 才會自動重新幫你建立新的類別,你才可以在 EpoxyController 使用。

我們依序來看怎麼在專案中使用不同的實作:

Custom Views

我們專案比較沒用到這種模式,這種比較多用在客製化的介面上,我提供一個簡單的範例,假設我們要顯示一個客製化下拉選單,選單是使用 RecyclerView 來實作,裡面的 item view 需要客製化,選單項目要可以選取或取消選取,選取起來要顯示特別的圖示,那麼我們會這樣宣告那個 custom view:

@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class DropdownItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
@TextProp
var itemText: CharSequence? = null
@ModelProp
fun selected(selected: Boolean = false) {
textMenuItem.isSelected = selected
}
@CallbackProp
var clickListener: OnClickListener? = null
private val textMenuItem: TextView
init {
View.inflate(context, R.layout.item_dropdown_menu, this)
orientation = VERTICAL
textMenuItem = findViewById(R.id.textMenu)
}
@AfterPropsSet
fun userProps() {
textMenuItem.text = itemText
textMenuItem.setOnClickListener(clickListener)
}
}
view raw DropdownItemView.kt hosted with ❤ by GitHub

@ModelView, @TextProp, @ModelProp, @CallbackProp, @AfterPropsSet 都是 Epoxy 針對 Custom View 所提供的標注,分別是用在 1. 定義 Custom View 類別 2. 定義文字屬性 3. 定義一般屬性 3. 定義 Callback 屬性 4. 在屬性設定後呼叫的方法,更多更詳細的用法可以參考官方 Wiki

Data Binding

我們對於資料綁定本身在 Android 的設定不多做說明,我們假設你已經設定好且可以正常使用 Android data binding,這邊我們只講解如何在 Epoxy 使用,以常見一般情況來說,我們可以做一個設定,然後就可以靠 Epoxy 來幫我們自動產生 EpoxyModel

以 MovieHunt 專案來解說怎麼設定,我們會到 package 根目錄也就是 com.enginebai.moviehunt 新增一個檔案名稱是 package-info.java

這個檔案裡面要把 R.class 和你想要讓 Epoxy 幫你自動產生 EpoxyModel 的 layout 檔名前綴加進去:

@EpoxyDataBindingPattern(rClass = R.class, layoutPrefix = "item")
package com.enginebai.moviehunt;
import com.airbnb.epoxy.EpoxyDataBindingPattern;
view raw package-info.java hosted with ❤ by GitHub

注意:這邊 import 是放在 package 宣告之後。

檔案 package-info.java 加入後,之後建置專案時,Epoxy 就會自動幫你抓檔名以 item 為開頭的 layout 檔來產生 EpoxyModel。首頁列表的 Item View 我們會使用資料綁定的方式來實作 item_movie_home_normal.xml

<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable
name="movieId"
type="String" />
<variable
name="posterImage"
type="String" />
<variable
name="title"
type="String" />
<variable
name="rating"
type="String" />
<variable
name="clickListener"
type="com.enginebai.moviehunt.ui.MovieClickListener" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="@{() -> clickListener.onMovieClicked(movieId)}"
>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="0dp"
android:id="@+id/cardPoster"
app:cardBackgroundColor="@color/darkBlue"
app:cardCornerRadius="@dimen/corner"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintDimensionRatio="1:1.5"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/imagePoster"
android:scaleType="centerCrop"
android:src="@color/darkBlue"
app:imageUrl="@{posterImage}"
app:error="@{@color/darkBlue}"
app:placeholder="@{@color/darkBlue}"
/>
</androidx.cardview.widget.CardView>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/textTitle"
style="@style/TitleText.Normal"
android:layout_marginTop="@dimen/size_8"
android:text="@{title}"
app:layout_constraintTop_toBottomOf="@+id/cardPoster"
tools:text="Avengers: III"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/textRating"
android:layout_marginTop="@dimen/size_4"
android:drawableStart="@drawable/thumbs_up"
android:text="@{rating}"
style="@style/ContextText"
tools:text="89.6%"
app:layout_constraintTop_toBottomOf="@+id/textTitle"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

完成後建置專案,你就可以在 EpoxyController 使用 :

MovieHomeLargeBindingModel_()
.id("${movieCategory}${this.id}")
.movieId(this.id)
.posterImage(this.getPosterUrl())
.title(this.displayTitle())
.rating(this.voteAverage)
.voteCount(this.displayVoteCount())
.duration(this.displayDuration())
.clickListener(clickListener)

View Holder

這個實作方式和原本的 RecyclerView.ViewHolder 非常的接近,你就像平常一樣定義介面檔,然後實作一個繼承 EpoxyModelWithHolder<T> 的抽象類別:

@EpoxyModelClass(layout = R.layout.holder_movie_landscape)
abstract class MovieListEpoxyModel : EpoxyModelWithHolder<MovieListEpoxyModel.Holder>() {
@EpoxyAttribute
var movieId = ""
@EpoxyAttribute
var imagePoster = ""
...
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var itemClickListener: (String) -> Unit = {}
override fun bind(holder: Holder) {
Glide.with(holder.imagePoster)
.load(imagePoster)
.error(R.color.darkBlue)
.placeholder(R.color.darkBlue)
.into(holder.imagePoster)
...
holder.itemView.setOnClickListener { itemClickListener(movieId) }
}
class Holder : EpoxyHolder() {
lateinit var itemView: View
lateinit var imagePoster: ImageView
...
override fun bindView(itemView: View) {
this.itemView = itemView
imagePoster = itemView.findViewById(R.id.imagePoster)
...
}
}
}
  1. 這邊 @EpoxyModelClass(layout = R.layout.holder_movie_landscape) 指定你的介面 xml 檔
  2. Model 裡面會用 @EpoxyAttribute 來定義不同的屬性,好讓你在 bind() 方法可以用來顯示資料
  3. class Holder : EpoxyHolder() 則是定義 Holder 以及 View 元件。

Model 宣告的部份就到這邊,更詳盡的用法可以參考官方文件,文件上介紹更多用法和注意事項(像是宣告 ID,ID 是用來做 Model 的 Diff 和狀態儲存 … 等),接下來我們要來講解如何在 EpoxyController 使用這些 EpoxyModel 來建立你的列表。

Epoxy Controller

這類別控制列表要如何呈現,我們會建立一個類別來繼承 EpoxyController,然後實作唯一的方法 buildModels()

class MovieListController(
private val context: Context,
private val clickListener: MovieClickListener
) : PagedListEpoxyController<MovieModel>() {
private val loadMoreView = LoadMoreView_().apply { id(LoadMoreView::class.java.simpleName) }
var loadingMore = false
set(value) {
field = value
requestModelBuild()
}
override fun buildItemModel(currentPosition: Int, item: MovieModel?): EpoxyModel<*> {
return item?.run {
MovieListEpoxyModel_()
.id(this.id)
.movieId(this.id)
.imagePoster(this.getPosterUrl())
.textTitle(this.displayTitle())
.rating(this.display5StarsRating())
.voteCount(context.getString(R.string.vote_count, this.displayVoteCount()))
.duration(this.displayDuration())
.releaseDate(this.displayReleaseDate())
.itemClickListener { clickListener.onMovieClicked(this.id) }
} ?: run {
MovieListEpoxyModel_()
.id(-currentPosition)
}
}
override fun addModels(models: List<EpoxyModel<*>>) {
super.addModels(models)
loadMoreView.addIf(loadingMore, this)
}
}

我們列表是使用 PagedList,所以是繼承 PagedListEpoxyController,概念上是一樣的,最後一個 addModels() 方法則是當我們滑到底部要載入下一頁資料時,可以在底部呈現載入中的介面,這邊順序是非常重要的,在 Controller 裡面如何擺放 Model 就直接決定了最後介面呈現的樣子,如果在 Controller.buildModel() 這樣設定:

class MyController : EpoxyController() {
override fun buildModels() {
HeaderImageModel_()
LoadingModel_()
MovieModel_()
MovieModel_()
FooterModel_()
}
}
view raw MyController.kt hosted with ❤ by GitHub

那麼最後畫面就會呈現這樣:

結語

Epoxy 可以幫助開發者建構出複雜的頁面,降低 RecyclerView 顯示不同介面的實作複雜度,同時可以提高介面元件的重複利用性,大致上的用法就這樣,更多注意事項和選項非常建議到 官方 Wiki 好好看過。

你看完這篇可能會非常好奇首頁是如何實作的?要如何在垂直列表裡面新增不同的水平列表、直的列表裡面還有很多橫的列表該怎麼實作?這部份屬於進階用法超過這章節的深度,礙於篇幅我會在後續章節提及怎麼實作,敬請鎖定這一系列文章。

如果你有任何和此專案相關的疑問,歡迎留言給我交流或討論。完整程式碼:https://github.com/enginebai/MovieHunt 歡迎 Fork + Star ⭐ 支持。

2 thoughts on “Android App 開發實戰系列 Part 5. Epoxy on RecyclerView

Add yours

  1. 期待你講解首頁如何實作?之後我們app改版也是要改成有垂直水平的列表。

  2. 請問我列表當中有兩種 items,他們有一個共同的數值(總數),而且可以兩邊都可以修改這個數值,可以怎麼定義我的列表會比較好呢?謝謝

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑