37

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

 3 years ago
source link: https://enginebai.com/2020/11/09/moviehunt-part-4/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

2677Jrv.png!mobile

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

view raw MovieListViewModel.kt hosted with ❤ by GitHub

我們從 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 raw MovieListViewModel.kt hosted with ❤ by GitHub

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 的狀態。

6juErm.png!mobile

這樣做可以讓 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 正在使用它。

VZZfe2B.jpg!mobile 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。

Nnq2QjA.png!mobile

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

N7raauQ.png!mobile

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

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

eEFn6bZ.png!mobile
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()
   }
}

view raw MovieDetailViewModel.kt hosted with ❤ by GitHub

結語

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

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK