Android App 開發實戰系列 Part 4. ViewModel + View

Part 4. 我們要來講解 ViewModel + View,同時會講解 MVVM 的大原則和核心概念。

上一個部份 Part 3. 我們完成了 Model 層 – MovieRepo,這 Part 4. 將要來介紹 ViewModel 來為 UI 提供所需要的資料,針對使用者操作做出對應的動作,以及介紹 View 如何使用 ViewModel 所提供的資料流來呈現 UI。

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

ViewModel 介紹

什麼是 ViewModel?? 在 MVVM 架構裡面,View 是不包含資料、也不直接操作資料的邏輯,這樣做是要讓職責更加明確,View 就是只有負責 UI 介面上的呈現或者純 UI 的邏輯,不負責資料處理或呈現的邏輯,我告訴你要呈現什麼資料,而資料怎麼來、怎麼處理不是 View 需要知道和負責的,在測試上可以有很明確測試的目的,我們對於 View 只需要提供正確的資料,就能預期呈現正確的畫面和操作。

那麼介面上的資料處理和呈現邏輯該由誰負責呢?這就是 ViewModel 的職責,它擔任 View 和 Model 的橋樑,負責為 View 提供所需要呈現的資料,也負責為 View 的操作互動提供對應的方法,譬如點擊儲存按鈕後要將資料寫到資料庫去或打 API。

ViewModel 實作

了解 ViewModel 職責之後,我們開始為電影列表頁面實作 ViewModel,這邊有幾個需求我們要滿足:

  • 進入頁面後會開始載入列表。
  • 列表提供分頁載入的機制。
  • 資料載入的時候需要呈現 ProgressBar。
  • 列表可以下拉更新。
class MovieListViewModel : BaseViewModel() {
private val movieRepo: MovieRepo by inject()
private val movieCategoryEvent = BehaviorSubject.create<MovieCategory>()
private val fetchDataSource: Observable<Listing<MovieModel>> = movieCategoryEvent
.map { movieRepo.fetchMovieList(it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.cache()
val movieList: Observable<PagedList<MovieModel>>
get() = fetchDataSource.flatMap { it.pagedList }
val refreshState: Observable<NetworkState>
get() = fetchDataSource.flatMap{ it.refreshState }
val networkState: Observable<NetworkState>
get() = fetchDataSource.flatMap { it.loadMoreState }
fun fetchMovieList(category: MovieCategory) {
movieCategoryEvent.onNext(category)
}
fun refresh() {
fetchDataSource
.map { it.refresh }
.doOnNext { it.invoke() }
.subscribe()
.disposeOnCleared()
}
}

我們從 fetchMovieList(category: MovieCategory) 來讓 View 呼叫可以開始載入列表,ViewModel 裡面宣告了一個 BehaviorSubject 來做資料載入的事件來源,當有載入事件觸發時,會帶動觸發 movieRepo.fetchMovieList(category),最後把載入的 Listing 資料流暫存起來,供後續使用。

我們在 Part 3. 有講解到 Listing 的實作,在 ViewModel 會將 Listing 的每個資料流 PagedList / refreshState / loadMoreState 轉成 UI 所要的欄位屬性,或者可以更簡單的方式直接提供 Listing 供 View 使用。

class MovieListViewModel : BaseViewModel() {
private val movieRepo: MovieRepo by inject()
fun fetchList(category: MovieCategory): Listing<MovieModel> = movieRepo.fetchMovieList(category)
}

View 實作

View 這一層顧名思義就是實作 UI / Layout / Custom View … 等以及單純 UI 相關的邏輯,和 ViewModel 做互動,透過「觀察者模式」作為觀察者來觀察 ViewModel 的變化來更新 View 的狀態。

class MovieListFragment : BaseFragment(), MovieClickListener {
private val viewModel by sharedViewModel<MovieListViewModelV1>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
...
viewModel.fetchMovieList(movieCategory)
viewModel.movieList
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext {
// Display paged list
...
}
.subscribe()
.disposeOnDestroy()
viewModel.refreshState
.observeOn(AndroidSchedulers.mainThread())
.doOnNext {
// Show/hide refresh progress
swipeRefresh.isRefreshing = (NetworkState.LOADING == it)
}
.subscribe()
.disposeOnDestroy()
viewModel.networkState
.observeOn(AndroidSchedulers.mainThread())
.doOnNext {
// Show/hide loading more progress
list.loadingMore = (NetworkState.LOADING == it)
}
.subscribe()
.disposeOnDestroy()
}
}
view raw MovieListFragment.kt hosted with ❤ by GitHub

到目前為止,MVVM View / ViewModel / Model 分層都已經出現介紹到了,我們可以來介紹 MVVM 的核心概念,同時講解上述的程式邏輯。

MVVM 核心概念

關注點分離 Separation of Concerns

MVVM 的三個分層主要職責劃分是:

  1. View 負責畫面的包含各種 Android 相關的元件和實作。
  2. ViewModel 為 UI 提供相對應的資料流讓 View 觀察,主要從 Model 取資料、做相對應的轉換和處理邏輯後給發送出去。在 ViewModel 裡面不會有任何 Android 相關的元件,這個跟 MVP 的 Presenter 是一樣的概念,這樣做的原因也是讓 ViewModel 可以做單純 JVM 的單元測試,而不需要依賴任何 Android 的套件。
  3. Model 負責資料層,資料可能從 API 來、也可能從本機端的資料庫、檔案、RemoteConfig / SharedPreference / Socket / Push Notification… 等等來,可以將資料封裝成觀察者模式,對外提供資料流,只要資料有改變, 它就會送出新資料,通知所有觀察者。

資料變更統一來自於 Model

畫面的更新來自於資料源頭的改變:View 只觀察 ViewModel 的資料變化而更新畫面,而 ViewModel 的變化來自於 Model。ViewModel 不會叫 View 來做任何事情,這是和 MVP 最大的不同,View 和 ViewModel 也不是一對一的關係,這樣的設計讓 ViewModel 相同的邏輯和資料流,可以讓不同的 View 共同使用

如果使用者操作會改變資料(例如:對一部電影按了收藏,收藏按鈕狀態要選取起來),則是去改變 Model 層的資料(MovieModel.isFavorite = true),Model 資料有變更會通知 ViewModel → ViewModel 有變更會通知 View,View 觀察 ViewModel 的變更而做畫面的更新(收藏按鈕選取起來),而我們不會直接去改 UI 的狀態,不會直接去把收藏按鈕選取起來。

這樣做可以讓 UI 上的顯示是和資料是完全同步,資料只有一份,就是從 Model 層來的,這樣做有什麼好處?

  1. 我們在其他地方不做額外的資料複製動作(例如在 ViewModel / View),這樣做會讓開發者付出更多額外的成本來維護資料的一致性和同步,除錯上更是增加困難度,因為你很難知道資料在哪裡被改掉了, UI 上也不知道現在是用哪一份資料。
  2. 資料如果在其他地方複製了,表示我們可以為了做一個功能而隨便改複製的資料來達成效果,但是埋下資料不一致的隱形炸彈。

想想剛剛提的收藏電影例子,假設今天我們在 View 那一層也複製了一個 isFavorite 來讓收藏按鈕可以變更選取的狀態,在 Model 那邊也有原本的 MovieModel.isFavorite 資料,過了一陣子換人接手這功能,他卻沒有注意到按鈕狀態是用 View.isFavorite,而其實真正改的狀態是 MovieModel.isFavorite,今天他按下了收藏按鈕,改了 View.isFavorite 讓按鈕選取起來,但是忘了改 MovieModel.isFavorite 造成資料的不同步,如果其他地方要用到這狀態也可能因此壞掉,產生更多 Bug,這讓開發者更難去使用這樣的程式碼(到底要用哪一份資料?)以及追這樣的問題(WTF 到底是哪邊改了資料?這 UI 是用哪一份資料?)。

回到我們的程式碼來說,MovieListFragment 是 View,觀察 ViewModel 的 PagedList / refreshState / loadMoreState 的狀態變更。

  • 當使用者開啟時,會觸發 MoviewListViewModel 去和 MovieRepo 拉電影列表 API 的資料,拉資料的時候會變更 refreshState 的狀態。
  • 因為 refreshState 狀態變更了,將更新的狀態從 MovieRepo 一路傳遞到 MovieListFragment 的觀察者身上,可以顯示 / 隱藏 ProgressBar。
  • 當列表 API 資料載入完成後,會將資料更新到 PagedList,一樣變更狀態一路從 MovieRepo 傳遞到 MovieListFragment 觀察者身上,可以顯示載入後的電影列表資料。

Dependency Rules

從上面的程式架構來看,可以看到 View 使用 ViewModel、(ViewModel 使用 Model),也就是依賴方向是 View → ViewModel (→ Model),View 知道 ViewModel 的存在,但 ViewModel 不知道 View 的存在,不曉得是哪一個 View 正在使用它。

Source: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

這樣的概念曾在 Clean Architecture 提到,A → B 代表 A 依賴(使用) B,A 知道 B、B 不知道 A 的存在、B 不知道是誰在使用它。 這樣的設計是我們要確保元件之間有適當的隔離, B 受到保護不受到「A 的改變」而影響到或需要更動,也可以讓 A 可以延遲被決定(UI 擺放位置可以延遲到最後再決定)而不影響到 B。

以舉例來說來說(上圖),Business Logic 會用到資料庫 Database Access,但是我們希望不會因為我們選用什麼資料庫而應該影響到 Business Logic,今天不會因為說我們從 MySQL 換到 PostgreSQL 而需要改 Business Logic,所以兩者之間需要適當的隔離。

中間我們劃了一條線,Business Logic 和 Database Access 中間我們墊一層 Database Interface,Business Logic 只透過 Database Interface 存取資料,而 Database Access 底層怎麼實作,Buiness Logic 不在乎也不受影響,這個跟設計模式裡面的 Strategy Pattern 十分類似。

因為我們的 View 可能會時常的變動,這樣簡單的 UI 變化不應該影響到 ViewModel 的邏輯,以我們呈現電影詳細頁面來說,我們 UI 從 v1 → v2 → v3 變化,ViewModel 都可以不用改動、可以一路使用下去。

class MovieDetailViewModel : BaseViewModel() {
private val movieRepo: MovieRepo by inject()
private val _movieDetail = MutableLiveData<MovieModel>()
val posterUrl: LiveData<String> = Transformations.map(_movieDetail) { it.getPosterUrl() }
val title: LiveData<String> = Transformations.map(_movieDetail) { it.displayTitle() }
val rating: LiveData<Float> = Transformations.map(_movieDetail) { it.display5StarsRating() }
val voteCount: LiveData<String> = Transformations.map(_movieDetail) { it.displayVoteCount() }
val duration: LiveData<String> = Transformations.map(_movieDetail) { it.displayDuration() }
val releaseDate: LiveData<String> = Transformations.map(_movieDetail) { it.displayReleaseDate() }
fun fetchMovieDetail(id: String) {
movieRepo.fetchMovieDetail(id)
.subscribeOn(Schedulers.io())
.doOnSuccess { _movieDetail.postValue(it) }
.subscribe()
.disposeOnCleared()
}
}

結語

這篇我們點出了重頭戲 MVVM 的核心概念,我們這邊來做一個小總結:

  1. View 是單純 UI 的實作、ViewModel 則是為 View 提供所需資料、Model 是我們的底層資料或 Business Logic。
  2. Model → ViewModel → View 都是透過觀察者模式來做資料的綁定。
  3. 我們不會直接去改 UI 的狀態,所有 UI 上的變更都來自於 Model 的變更,要改 UI 狀態直接去改 Model。同時 Model 會提供資料的單一出口,不會在其他地方複製資料造成資料狀態不一致。

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

5 thoughts on “Android App 開發實戰系列 Part 4. ViewModel + View

Add yours

  1. 曾經維護過一個專案,和文章中說的狀況類似,同樣的資料複製了好幾種不同形式的方式儲存和顯示,如果資料改了,所有變數都要一起改,沒改到就出現bug,實在非常花時間找問題。

    Like

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 ↑