相信各位也看了 N 個介紹 MVVM 的文章了吧,不知道你們有沒有覺得大家所描述的 MVVM 是不是有點不太一樣呢?或是套用在你的專案後,實作中所遇到的問題跟網路教學差了十萬八千里呢?或是在專案的初期,你看著你完美、設計好的 MVVM 架構,期待它永遠都可以這麼完美下去,結果半年後被 PM 的需求搞的亂七八糟,自己都不認識它了,不再是你熟悉的那個 MVVM?沒關係,遇到這些事情都很正常,請不要慌張,今天我介紹的 MVVM 也可能是一個新的 MVVM,所以,萬一你正在思考什麼才是最“正確”的 MVVM 的話,我會建議你放下這個想法,因為本來就沒有一個最“正確”的架構存在的必要,有解決到所遇到的問題才是重點,今天的 MVVM 將會針對這專案目前的範圍去做設計,而在更之後的篇幅中,你也會看到因為需求而做的架構調整。
現在 Android 最主流的架構模式,就屬於 MVVM 了,今天會從多層式架構為起點,來說明多層式架構的核心訴求,再帶到 MVVM 這樣的架構模式,以及為什麼這個架構模式非常適合本專案,最後,示範如何套用 MVVM 到便利貼這個專案上。
多層式架構的英文為 Multilayer architecture,又稱 Multitier architecture。最主要的用途是將程式模組化,不同層級有著不同的責任,也有著不同的存取權限。在多層式架構中,最常被來使用的就屬於三層式架構了,當然多層式架構可以不只三層,只是以一般的應用程式來說,這樣的分層方式能完成大部分的功能,而且好管理。
如上圖所示,三層式架構從上到下分別為:顯示層、商業邏輯層、資料層
一般來說,顯示層能直接對商業邏輯層進行操作,但是不能對資料層進行操作,也完全不知道資料層的存在。商業邏輯層不知道顯示層的存在,但是能直接操作資料層,而資料層呢?就只能等待被別人使用了。所以三層式架構有一個從上而下的相依關係,上層使用下層,下層無法知道上層。
在某些使用案例中,可能不只有三層,也有可能因爲需求而需要跨層存取,第一層可以使用第三層或是第四層的物件,所以每一層到下面幾層之間的存取並不是絕對的,如果因採用嚴格的限制而導致開發速度緩慢,或是沒有一個合理的原因去做這樣的限制的話,就不會是一個好的架構,就像前面所說的,好的架構應該要最大化工程師的生產力。
MVVM 總共是由三個部分組成 View, ViewModel, Model ,那這三個部分是不是就剛好可以跟三層式架構一一對應呢?很可惜的是這部分我覺得是有爭議的。
上圖擷取自 Wiki(https://en.wikipedia.org/wiki/Model–view–viewmodel) ,請注意右半邊的 Model 部分,下面的說明是 BusinessLogic and Data,也就是說三層式架構的底下兩層 - 商業邏輯層、資料層都是屬於 MVVM 的 Model,左半邊 View 跟 ViewModel 的部分則是強調資料綁定,藉由資料綁定,ViewModel 的任何資料更新都會自動的在 View 上面產生相對應的變化,因此任何對 ViewModel 的邏輯操作,也就是等於在對 View 進行操作,進而使得在這樣的架構模式下寫單元測試變得非常容易,因為 ViewModel 就是純粹的資料類別,沒有平台的相依性,同時 View 跟 ViewModel 之間也有一些顯示的邏輯在這邊處理。
以上這是最原始的定義,那我們在 Android 上採用時需要把這些定義原封不動的照搬過來嗎?這邊有幾個問題我想跟大家討論一下:
其實以上的問題都沒有標準的答案,最後還是那一句“It depends”。但對我來說,我希望整個 App 的架構是越簡單越好,在沒有複雜的問題需要被解決的情況下,採用“過於嚴格”的定義往往沒什麼好處,最後反而會花費太多時間在討論一些對使用者或是對公司產品沒有價值的事情。以現在我要做的 App 來說好了,便利貼應用程式就是一個充滿 UI 互動的軟體,對於這個 App 來說,什麼是商業邏輯,什麼是顯示邏輯,似乎沒有這麼容易的可以分清楚,那沒有分清楚是一個大問題嗎?目前也看不太出來,那就也不用花太多時間糾結在這上面。相對的,如果最後我要開發一個可以用來跑 Sprint 流程的看板應用程式, “Sprint 流程”本身就已經隱含了非常多規則在裡面,所以在這情況下,分出商業邏輯層跟顯示邏輯層是一件再正常也不過的事情。
以目前這個 App 的規模來說,分成三大組件就非常夠用了:
那這三個組件組在一起就是完整的 MVVM 嗎?ViewModel 跟 Model 中間的那條分層的線在哪裡?我想,這個問題會依據不同的定義會有不同的答案,但是現在有沒有回答這個問題也不是那麼的重要了,更好的問題會是:我們已經知道要在哪個地方寫怎樣的程式碼了嗎?這幾個元件的職責已經夠清楚了嗎?
不知道各位心中是不是已經有答案了呢?沒有也沒關係,下面直接用程式來做示範吧!由於相依的順序是從上到下,最下層是被別人使用的,所以就從最下層來介紹起吧!
今天需要的 gradle dependency:
implementation 'io.reactivex.rxjava3:rxjava:3.0.12'
implementation "androidx.compose.runtime:runtime-rxjava3:$compose_version"
目前只需要拿既有資料就好,所以該層的實作將會非常的簡單。
interface NoteRepository {
fun getAll(): Observable<List<Note>>
}
class InMemoryNoteRepository(): NoteRepository {
private val allNotes = listOf(
Note.createRandomNote()
)
override fun getAll(): Observable<List<Note>> {
return Observable.just(allNotes)
}
}
在還沒有資料的情況底下,我們暫時用隨機的假資料來做替代,請注意這邊回傳的型別是 Observable
,所以我們已經預期這些資料將會隨著時間而有所變動,一但資料產生改變,這裡的 Observable
就會送出最新的資料給下游訂閱的人。
由於目前沒有任何的邏輯,也不需要轉換資料格式,所以 ViewModel 這邊也非常的簡單,下一篇整合手勢操作後才會有比較大的用途,不過我們已經很明確的知道這一個元件是需要的,就算看起來很笨也無妨。
class BoardViewModel(
private val noteRepository: NoteRepository
): ViewModel() {
val allNotes = noteRepository.getAll()
}
很單純的呼叫 Repository 的 API 給外面的人使用,對 Android 開發已經很熟悉的你可能會有疑問,怎麼不用 LiveData 呢?答案將會在下面揭曉:
View 的部分其實在之前就已經完成的差不多了,讓我們再複習一下:
@Composable
fun StickyNote(note: Note) {
Surface(
Modifier
.offset(x = note.position.x.dp, y = note.position.y.dp)
.size(108.dp, 108.dp),
color = Color(note.color.color),
elevation = 8.dp
) {
Column(modifier = Modifier
.padding(16.dp)
) {
Text(text = note.text, style = MaterialTheme.typography.h5)
}
}
}
我們已經有辦法顯示單張便利貼,但是從資料層傳來的資料是一個 List ,所以我們需要做另外一個 View ,來顯示多張便利貼。由於便利貼通常是貼在白板上,這邊就將他命名為 BoardView
吧!
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rxjava3.subscribeAsState
@Composable
fun BoardView(boardViewModel: BoardViewModel) {
val notes by boardViewModel.allNotes.subscribeAsState(initial = emptyList())
Box(Modifier.fillMaxSize()) {
notes.forEach { note ->
StickyNote(note = note)
}
}
}
很自然而然的,把剛剛的 ViewModel 當作這個函式的參數傳進來,還有為了要顯示所有的便利貼,在下面,對 notes
這個 list 使用了一個 forEach
迴圈來將他們全部畫出來,這也正是 Jetpack Compose 令人著迷的地方之一,使用起來非常直覺友善。
剩下來的問題就是,notes
是怎麼來的了,這邊有用到一個委派的語法: by 以及 subscribeAsState
這兩個不認識的新東西,在 View 層的資料綁定就是由這兩個元素組合而成的。首先,subscribeAsState
會將 Observable 轉成另一種物件: State
。我們可以把他想像成這是一種 Observer 的實作方式,而且這邊State
中的狀態永遠都會是最新的,最基本的使用方式是可以透過 State.value
來拿到現在狀態中的值:
但是要是每次取值時都要使用State.value
也是挺麻煩的,這時候 by 就派出用場了,使用這個委派的語法,其實就是等於我在使用當下這個變數的時候 - 以目前的例子來說就是 notes
,也是等於對這個 State
取值,所以也可以暫時忽略這個型別的存在,在程式碼的使用上比較簡潔。
那為什麼不用 LiveData 轉成 State 而是 Observable 呢?讓我們再看一下 LiveData 要解決的問題是什麼:
依據官方文件的定義:LiveData 是一种可观察的数据存储器类。与常规的可观察类不同,LiveData 具有生命周期感知能力,意指它遵循其他应用组件(如 Activity、Fragment 或 Service)的生命周期。这种感知能力可确保 LiveData 仅更新处于活跃生命周期状态的应用组件观察者。
我們現在有看到任何 Activity、 Fragment 還是 Service 嗎?沒有,對吧?現在使用的全部都是 Composable function,那是不是代表我們沒有生命週期的問題了?這也不對,因為 Composable function 跟 Android framework 之間還是有一些生命週期的事件要處理。但是這些我們已經不需要去煩惱了(至少已現在的需求來說),Jetpack Compose 自動幫我們解決這些問題,不用煩惱 Memory leak,不用自己控制資源回收的時機,一切交給 Jetpack Compose 的 API 吧!subscribeAsState
已經處理好生命週期的問題了,所以這時候如果在 ViewModel 硬要多一個轉換到 LiveDate 的動作的話,就只是在浪費時間。除非...這個 ViewModel 除了 Composable function之外,還要跟其他的 Fragment 一起共用,那這時候使用 LiveData 就比較說得過去。
除了這三個組件之外,還有什麼其他的物件嗎?當然有!像是 Activity ,以及建立這些組件的物件。一般來說都會認為 Activty 是屬於 View 的一部分,但是有了 Jetpack Compose,Activity 就可以完全的拋棄這個職責,全部交給 Composable function 來處理就好,在這裡,Activity 就只要做一件事就好,就是把所有必要相依的物件準備好,然後把這些相依的物件丟給 View ,剩下的一率不處理,在 Clean architecture 的書中的第 26 章: Main component
,就是我現在對 Activity 的定位。在 Main component
中,通常我還會使用 Dependency Injection 函式庫幫忙做相依性管理,這邊就隨大家的喜好,用 Dagger2 或是 Koin 都可以,由於我對 Koin 比較熟悉,在之後的章節我會預設使用 Koin 來當作Dependency Injection 函式庫。
什麼是 MVVM? -> 真的是大哉問
十個開發者大概會給出十一個不同答案的那種
搞不好我就那種會給兩個答案的XD