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() | |
} | |
} |
到目前為止,MVVM View / ViewModel / Model 分層都已經出現介紹到了,我們可以來介紹 MVVM 的核心概念,同時講解上述的程式邏輯。
MVVM 核心概念
關注點分離 Separation of Concerns
MVVM 的三個分層主要職責劃分是:
- View 負責畫面的包含各種 Android 相關的元件和實作。
- ViewModel 為 UI 提供相對應的資料流讓 View 觀察,主要從 Model 取資料、做相對應的轉換和處理邏輯後給發送出去。在 ViewModel 裡面不會有任何 Android 相關的元件,這個跟 MVP 的 Presenter 是一樣的概念,這樣做的原因也是讓 ViewModel 可以做單純 JVM 的單元測試,而不需要依賴任何 Android 的套件。
- 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 層來的,這樣做有什麼好處?
- 我們在其他地方不做額外的資料複製動作(例如在 ViewModel / View),這樣做會讓開發者付出更多額外的成本來維護資料的一致性和同步,除錯上更是增加困難度,因為你很難知道資料在哪裡被改掉了, UI 上也不知道現在是用哪一份資料。
- 資料如果在其他地方複製了,表示我們可以為了做一個功能而隨便改複製的資料來達成效果,但是埋下資料不一致的隱形炸彈。
想想剛剛提的收藏電影例子,假設今天我們在 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 正在使用它。

這樣的概念曾在 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 的核心概念,我們這邊來做一個小總結:
- View 是單純 UI 的實作、ViewModel 則是為 View 提供所需資料、Model 是我們的底層資料或 Business Logic。
- Model → ViewModel → View 都是透過觀察者模式來做資料的綁定。
- 我們不會直接去改 UI 的狀態,所有 UI 上的變更都來自於 Model 的變更,要改 UI 狀態直接去改 Model。同時 Model 會提供資料的單一出口,不會在其他地方複製資料造成資料狀態不一致。
如果你有任何和此專案相關的疑問,歡迎留言給我交流或討論。完整程式碼:https://github.com/enginebai/MovieHunt 歡迎 Fork + Star ⭐ 支持。
曾經維護過一個專案,和文章中說的狀況類似,同樣的資料複製了好幾種不同形式的方式儲存和顯示,如果資料改了,所有變數都要一起改,沒改到就出現bug,實在非常花時間找問題。
MVVM 和 MVP 的差別在哪邊?所以 Google 捨棄 MVP?
一開始 Google 主推 MVP(更之前我就沒跟到),後來發現到 MVP 存在一些缺點:
1. 最大的問題:VP 存在一對一的「耦合」關係,也就是說 Presenter 如果有相同邏輯,是無法抽換給其他 View 使用的。如果要改其中一邊,相對的兩邊都需要改動。
2. 太多介面,一個 View 對上一個 Presenter 就要三個介面(V / P 各一個,還有一個父介面來包含這兩個介面)
後來 Google 推出 MVVM,調整 VP -> ViewModel + View,由 ViewModel 去提供介面需要的資料流給要呈現的介面去使用,如果一個使用者點數需要在不同地方顯示,ViewModel 只需要實作一次邏輯,不同的 View 就只需要訂閱該資料流就可以顯示。這樣做可以避免耦合和重工。
好奇你首頁是怎麼做到直滑、橫滑的卡片?
我之後會出 Part 9 完整講解首頁如何實作,如果想先知道,可以參考 Epoxy Carousel,Carousel 就是提供水平的列表,讓你可以在垂直列表中放上水平的列表。
哪些資料該存在 ViewModel?哪些應該存在 Model 層?