Jetpack Compose 架構比較:MVP & MVVM & MVI

2021-06-08 08:00:01

本次 I/O 大會上曝出了 Compose 1.0 即將釋出的訊息,雖然 API 層面已趨於穩定,但真正要在專案中落地還少不了一套合理的應用架構。傳統 Android 開發中的 MVP、MVVM 等架構在宣告式UI這一新物種中是否還依舊可用呢?

本文以一個簡單的業務場景為例,試圖找出一種與 Compose 最契合的架構模式

Sample : Wanandroid Search

App基本功能:使用者輸入關鍵字,在 wanandroid 網站中搜尋出相關內容並展示

Wanandroid Search

功能雖然簡單,但是集合了資料請求、UI展示等常見業務場景,可用來做UI層與邏輯層的解耦實驗。

前期準備:Model層

其實無論 MVX 中 X 如何變化, Model 都可以用同一套實現。我們先定義一個 DataRepository ,用於從 wanandroid 獲取搜尋結果。 後文Sample中的 Model 層都基於此 Repo 實現

@ViewModelScoped
class DataRepository @Inject constructor(){

    private val okhttpClient by lazy {
        OkHttpClient.Builder().build()
    }

    private val apiService by lazy {
        Retrofit.Builder()
            .baseUrl("https://www.wanandroid.com/")
            .client(okhttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build().create(ApiService::class.java)
    }


    suspend fun getArticlesList(key: String) =
        apiService.getArticlesList(key)
}

Compose為什麼需要架構?


首先,先看看不借助任何架構的 Compose 程式碼是怎樣的?

不使用架構的情況下,邏輯程式碼將與UI程式碼偶合在一起,在Compose中這種弊端顯得尤為明顯。常規 Android 開發預設引入了 MVC 思想,XML的佈局方式使得UI層與邏輯層有了初步的解耦。但是 Compose 中,佈局和邏輯同樣都使用Kotlin實現,當佈局中夾了雜邏輯,界限變得更加模糊。

此外,Compose UI中混入邏輯程式碼會帶來更多的潛在隱患。由於 Composable 會頻繁重組,邏輯程式碼中如果涉及I/O 就必須當做 SideEffect{} 處理、一些不能隨重組頻繁建立的物件也必須使用 remember{} 儲存,當這些邏輯散落在UI中時,無形中增加了開發者的心智負擔,很容易發生遺漏。

Sample 的業務場景特別簡單,UI中出現少許 remember{}LaunchedEffect{} 似乎也沒什麼不妥,對於一些相對簡單的業務場景出現下面這樣的程式碼沒有問題:

@Composable
fun NoArchitectureResultScreen(
    answer: String
) {

    val isLoading = remember { mutableStateOf(false) }

    val dataRepository = remember { DataRepository() }

    var result: List<ArticleBean> by remember { mutableStateOf(emptyList()) }
    
    LaunchedEffect(Unit) {
        isLoading.value = true
        result = withContext(Dispatchers.IO) { dataRepository.getArticlesList(answer).data.datas }
        isLoading.value = false
    }

    SearchResultScreen(result, isLoading.value , answer)

}

但是,當業務足夠複雜時,你會發現這樣的程式碼是難以忍受的。這正如在 React 前端開發中,雖然 Hooks 提供了處理邏輯的能力,但卻依然無法取代 Redux。


Android中的常見架構模式


MVPMVVMMVI 是 Android中的而一些常見架構模式,它們的目的都是服務於UI層與邏輯層的解耦,只是在解耦方式上有所不同,如何選擇取決於使用者的喜好以及專案的特點

「沒有最好的架構,只有最合適的架構。」

那麼在 Compose 專案中何種架構最合適呢?


MVP


MVP 主要特點是 PresenterView 之間通過介面通訊, Presenter 通過呼叫 View 的方法實現UI的更新。

mvp

這要求 Presenter 需要持有一個 View 層物件的參照,但是 Compose 顯然無法獲得這種參照,因為用來建立 UI 的 Composable 必須要求返回 Unit,如下:

@Composable
fun HomeScreen() {
    Column {
        Text("Hello World!")
    }
}

官方檔案中對無返回值的要求也進行了明確約束:

The function doesn’t return anything. Compose functions that emit UI do not need to return anything, because they describe the desired screen state instead of constructing UI widgets.
https://developer.android.com/jetpack/compose/mental-model

Compose UI 既然存在於 Android 體系中,必定需要有一個與 Android 世界連線的起點,起點處可能是一個 Activity 或者 Fragment,用他們做UI層的參照控制程式碼不可以嗎?

理論上可以,但是當 Activity 接收 Presenter 通知後,仍然無法在內部獲取區域性參照,只能設法觸發整體Recomposition,這完全喪失了 MVP 的優勢,即通過獲取區域性參照進行精準重新整理。

通過分析可以得到結論: 「MVP 這種依賴介面通訊的解耦方式無法在 Compose 專案中使用」


MVVM(Without Jetpack)


相對於 MVP 的介面通訊 ,MVVM 基於觀察者模式進行通訊,當 UI 觀察到來自 ViewModle 的資料變化時自我更新。 UI層是否能返回參照控制程式碼已不再重要,這與 Compose 的工作方式非常契合。

mvvm

自從 Android 用 ViewModel 命名了某 Jetpack 元件後,在很多人心裡,Jetpack 似乎就與 MVVM 畫上了等號。這確實客觀推動了 MVVM 的普及,但是 Jetpack 的 ViewModel 並非只能用在 MVVM 中(比如如後文介紹的 MVI 也可以使用 ); 反之,沒有 Jetpack ,照樣可以實現 MVVM。

先來看看不借助 Jetpack 的情況下,MVVM 如何實現?

Activity 中建立 ViewModel

首先 View 層建立 ViewModel 用於訂閱

class MvvmActivity : AppCompatActivity() {

    private val mvvmViewModel = MvvmViewModel(DataRepository())

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposePlaygroundTheme {
                MvvmApp(mvvmViewModel) //將vm傳給Composable
            }
        }
    }
}

Compose 專案一般使用單 Activity 結構, Activity 作為全域性入口非常適合建立全域性 ViewModel。 子 Compoable 之間需要基於 ViewModel 通訊,所以構建 Composable 時將 ViewModel 作為引數傳入。

Sample 中我們在 Activity 中建立的 ViewModel 僅僅是為了傳遞給 MvvmApp 使用,這種情況下也可以通過傳遞 Lazy<MvvmViewModel>,將建立延遲到真正需要使用的時候以提高效能。

定義 NavGraph

當涉及到 Compose 頁面切換時,navigation-compose 是一個不錯選擇,Sample中也特意設計了SearchBarScreenSearchResultScreen 的切換場景

// build.gradle
implementation "androidx.navigation:navigation-compose:$latest_version"

@Composable
fun MvvmApp(
    mvvmViewModel: MvvmViewModel
) {
    val navController = rememberNavController()

    LaunchedEffect(Unit) {
        mvvmViewModel.navigateToResults
            .collect { 
                navController.navigate("result") //訂閱VM路由事件通知,處理路由跳轉
            } 
    }

    NavHost(navController, startDestination = "searchBar") {
        composable("searchBar") {
            MvvmSearchBarScreen(
                mvvmViewModel,
            )
        }
        composable("result") {
            MvvmSearchResultScreen(
                mvvmViewModel,
            )
        }
    }
}

  • 在 root-level 的 MvvmApp 中定義 NavGraphcomposable("$dest_id"){} 中構造路由節點的各個子 Screen,構造時傳入 ViewModel 用於 Screen 之間的通訊

  • 每個 Composable 都有一個 CoroutineScope 與其 Lifecycle 繫結,LaunchedEffect{} 可以在這個 Scope 中啟動協程處理副作用。 程式碼中使用了一個只執行一次的 Effect 訂閱 ViewModel 的路由事件通知

  • 當然我們可以將 navConroller 也傳給 MvvmSearchBarScreen ,在其內部直接發起路由跳轉。但在較複雜的專案中,跳轉邏輯與頁面定義應該儘量保持解耦,這更利於頁面的複用和測試。

  • 我們也可以在 Composeable 中直接 mutableStateOf() 建立 state 來處理路由跳轉,但是既然選擇使用 ViewModel 了,那就應該儘可能將所有 state 集中到 ViewModle 管理。

注意: 上面例子中的處理路由跳轉的 navigateToResults 是一個「事件」而非「狀態」,關於這部分割區別,在後文在詳細闡述

定義子 Screen

接下來看一下兩個 Screen 的具體實現

@Composable
fun MvvmSearchBarScreen(
    mvvmViewModel: MvvmViewModel,
) {

    SearchBarScreen { 
        mvvmViewModel.searchKeyword(it)
    }

}

@Composable
fun MvvmSearchResultScreen(
    mvvmViewModel: MvvmViewModel
) {

    val result by mvvmViewModel.result.collectAsState()
    val isLoading by mvvmViewModel.isLoading.collectAsState()

    SearchResultScreen(result, isLoading, mvvmViewModel.key.value)

}

大量邏輯都抽象到 ViewModel 中,所以 Screen 非常簡潔

  • SearchBarScreen 接受使用者輸入,將搜尋關鍵詞傳送給 ViewModel

  • MvvmSearchResultScreen 作為結果頁顯示 ViewModel 傳送的資料,包括 Loading 狀態和搜尋結果等。

  • collectAsState 用來將 Flow 轉化為 Compose 的 state,每當 Flow 接收到新資料時會觸發 Composable 重組。 Compose 同時支援 LiveData、RxJava 等其他響應式庫的collectAsState

UI層的更多內容可以查閱 SearchBarScreenSearchResultScreen 的原始碼。經過邏輯抽離後,這兩個 Composable 只剩餘佈局相關的程式碼,可以在任何一種 MVX 中實現複用。

ViewModel 實現

最後看一下 ViewModel 的實現

class MvvmViewModel(
    private val searchService: DataRepository,
) {

    private val coroutineScope = MainScope()
    private val _isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
    val isLoading = _isLoading.asStateFlow()
    private val _result: MutableStateFlow<List<ArticleBean>> = MutableStateFlow(emptyList())
    val result = _result.asStateFlow()
    private val _key = MutableStateFlow("")
    val key = _key.asStateFlow()
    
    //使用Channel定義事件
    private val _navigateToResults = Channel<Boolean>(Channel.BUFFERED)
    val navigateToResults = _navigateToResults.receiveAsFlow()

    fun searchKeyword(input: String) {
        coroutineScope.launch {
            _isLoading.value = true
            _navigateToResults.send(true)
            _key.value = input
            val result = withContext(Dispatchers.IO) { searchService.getArticlesList(input) }
            _result.emit(result.data.datas)
            _isLoading.value = false
        }
    }
}
  • 接收到使用者輸入後,通過 DataRepository 發起搜尋請求

  • 搜尋過程中依次更新 loading(loading顯示狀態)、navigateToResult(頁面跳轉事件)、 key(搜尋關鍵詞)、result(搜尋結果)等內容,不斷驅動UI重新整理

所有狀態集中在 ViewModel 管理,甚至頁面跳轉、Toast彈出等事件也由 ViewModel 負責通知,這對單元測試非常友好,在單測中無需再 mock 各種UI相關的上下文。


Jetpack MVVM


Jeptack 的意義在於降低 MVVM 在 Android平臺的落地成本。

引入 Jetpack 後的程式碼變化不大,主要變動在於 ViewModel 的建立。

Jetpack 提供了多個元件,降低了 ViewModel 的使用成本:

  • 通過 hilt 的 DI 降低 ViewModel 構造成本,無需手動傳入 DataRepository 等依賴
  • 任意 Composable 都可以從最近的 Scope 中獲取 ViewModel,無需層層傳參。
@HiltViewModel
class JetpackMvvmViewModel @Inject constructor(
    private val searchService: DataRepository // DataRepository 依靠DI注入
) : ViewModel() {
    ...
}
@Composable
fun JetpackMvvmApp() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = "searchBar", route = "root") {
        composable("searchBar") {
            JetpackMvvmSearchBarScreen(
                viewModel(navController, "root") //viewModel 可以在需要時再獲取, 無需實現建立好並通過引數傳進來
            )
        }
        composable("result") {

            JetpackMvvmSearchResultScreen(
                viewModel(navController, "root") //可以獲取跟同一個ViewModel範例
            )
        }
    }

}
@Composable
inline fun <reified VM : ViewModel> viewModel(
    navController: NavController,
    graphId: String = ""
): VM =
    //在 NavGraph 全域性範圍使用 Hilt 建立 ViewModel
    hiltNavGraphViewModel( 
        backStackEntry = navController.getBackStackEntry(graphId)
    )

Jetpack 甚至提供了 hilt-navigation-compose 庫,可以在 Composable 中獲取 NavGraph Scope 或 Destination Scope 的 ViewModel,並自動依賴 Hilt 構建。Destination Scope 的 ViewModel 會跟隨 BackStack 的彈出自動 Clear ,避免洩露。

// build.gradle
implementation androidx.hilt:hilt-navigation-compose:$latest_versioin

「未來 Jetpack 各元件之間協同效應會變得越來越強。」
參考 https://developer.android.com/jetpack/compose/libraries#hilt


MVI


MVI 與 MVVM 很相似,其借鑑了前端框架的思想,更加強調資料的單向流動唯一資料來源,可以看做是 MVVM + Redux 的結合。

MVI 的 I 指 Intent,這裡不是啟動 Activity 那個 Intent,而是一種對使用者操作的封裝形式,為避免混淆,也可喚做 Action 等其他稱呼。 使用者操作以 Action 的形式送給 Model層 進行處理。程式碼中,我們可以用 Jetpack 的 ViewModel 負責 Intent 的接受和處理,因為 ViewModel 可以在 Composable 中方便獲取。

mvi

SearchBarScreen 使用者輸入關鍵詞後通過 Action 通知 ViewModel 進行搜尋

@Composable
fun MviSearchBarScreen(
    mviViewModel: MviViewModel,
    onConfirm: () -> Unit
) {
    SearchBarScreen {
        mviViewModel.onAction(MviViewModel.UiAction.SearchInput(it))
    }
}

通過 Action 通訊,有利於 View 與 ViewModel 之間的進一步解耦,同時所有呼叫以 Action 的形式彙總到一處,也有利於對行為的集中分析和監控

@Composable
fun MviSearchResultScreen(
    mviViewModel: MviViewModel
) {
    val viewState by mviViewModel.viewState.collectAsState()

    SearchResultScreen(
        viewState.result, viewState.isLoading, viewState.key
    )

}

MVVM 的 ViewModle 中分散定義了多個 State ,MVI 使用 ViewState 對 State 集中管理,只需要訂閱一個 ViewState 便可獲取頁面的所有狀態,相對 MVVM 減少了不少模板程式碼。

相對於 MVVM,ViewModel 也有一些變化

class MviViewModel(
    private val searchService: DataRepository,
) {

    private val coroutineScope = MainScope()

    private val _viewState: MutableStateFlow<ViewState> = MutableStateFlow(ViewState())
    val viewState = _viewState.asStateFlow()

    private val _navigateToResults = Channel<OneShotEvent>(Channel.BUFFERED)
    val navigateToResults = _navigateToResults.receiveAsFlow()

    fun onAction(uiAction: UiAction) {
        when (uiAction) {
            is UiAction.SearchInput -> {
                coroutineScope.launch {
                    _viewState.value = _viewState.value.copy(isLoading = true)
                    val result =
                        withContext(Dispatchers.IO) { searchService.getArticlesList(uiAction.input) }
                    _viewState.value =
                        _viewState.value.copy(result = result.data.datas, key = uiAction.input)
                    _navigateToResults.send(OneShotEvent.NavigateToResults)
                    _viewState.value = _viewState.value.copy(isLoading = false)
                }
            }
        }
    }

    data class ViewState(
        val isLoading: Boolean = false,
        val result: List<ArticleBean> = emptyList(),
        val key: String = ""
    )

    sealed class OneShotEvent {
        object NavigateToResults : OneShotEvent()
    }

    sealed class UiAction {
        class SearchInput(val input: String) : UiAction()
    }
}
  • 頁面所有的狀態都定義在 ViewState 這個 data class 中,狀態的修改只能在 onAction 中進行, 其餘場所都是 immutable 的, 保證了資料流只能單向修改。 反觀 MVVM ,MutableStateFlow 對外暴露時轉成 immutable 才能保證這種安全性,需要增加不少模板程式碼且仍然容易遺漏。

  • 事件則統一定義在 OneShotEvent中。 Event 不同於 State,同一型別的事件允許響應多次,因此定義事件使用 Channel 而不是 StateFlow

Compose 鼓勵多使用 State 少使用 Event, Event 只適合用在彈 Toast 等少數場景中

通過瀏覽 ViewModel 的 ViewState 和 Aciton 定義就可以理清 ViewModel 的職責,可以直接拿來作為介面檔案使用。


頁面路由


Sample 中之所以使用事件而非狀態來處理路由跳轉,一個主要原因是由於使用了 Navigation。Navigation 有自己的 backstack 管理,當點選 back 鍵時會自動幫助我們返回前一頁面。倘若我們使用狀態來描述當前頁面,當點選 back時,沒有機會更新狀態,這將造成 ViewState 與 UI 的不一致。

關於路由方案的建議:簡單專案使用事件控制頁面跳轉沒有問題,但是對於複雜專案,推薦使用狀態進行頁面管理,有利於邏輯層時刻感知到當前的UI狀態。

我們可以將 NavController 的 backstack 狀態 與 ViewModel 的狀態建立同步:


class MvvmViewModel(
    private val searchService: DataRepository,
) {

    ...
    //使用 StateFlow 描述頁面
    private val _destination = MutableStateFlow(DestSearchBar)
    val destination = _destination.asStateFlow()

    fun searchKeyword(input: String) {
        coroutineScope.launch {
            ...
            _destination.value = DestSearchResult
            ...
        }
    }

    fun bindNavStack(navController: NavController) {
        //navigation 的狀態時刻同步到 viewModel
        navController.addOnDestinationChangedListener { _, _, arguments ->
            run {
                _destination.value = requireNotNull(arguments?.getString(KEY_ROUTE))
            }
        }
    }
}

如上,當 navigation 狀態變化時,會及時同步到 ViewModel ,這樣就可以使用 StateFlow 而非 Channel 來描述頁面狀態了。

@Composable
fun MvvmApp(
    mvvmViewModel: MvvmViewModel
) {
    val navController = rememberNavController()

    LaunchedEffect(Unit) {
        with(mvvmViewModel) {
            bindNavStack(navController) //建立同步
            destination
                .collect {
                    navController.navigate(it)
                }
        }
    }
}

在入口處,為 NavController 和 ViewModel 建立同步繫結即可。


Clean Architecture


更大型的專案中,會引入 Clean Architecture ,通過 Use Case 將 ViewModel 內的邏輯進一步分解。 Compose 只是個 UI 框架,對於 ViewModle 以下的邏輯層的治理方式與傳統的 Andorid 開發沒有區別。所以 Clean Architecture 這樣的複雜架構仍然可以在 Compose 專案中使用


總結


比較了這麼多種架構,那種與 Compose 最契合呢?

Compose 的宣告式UI思想來自 React,所以同樣來自 Redux 思想的 MVI 應該是 Compose 的最佳伴侶。當然 MVI 只是在 MVVM 的基礎上做了一些改良,如果你已經有了一個 MVVM 的專案,只是想將 UI 部分改造成 Compose ,那麼沒必要為了改造成 MVI 而進行重構,MVVM 也可以很好地配合 Compose 使用的。 但是如果你想將一個 MVP 專案改造成 Compose 可能成本就有點大了。

關於 Jetpack,如果你的專案只用於 Android,那麼 Jetpack 無疑是一個好工具。但是 Compose 未來的應用場景將會很廣泛,如果你有預期未來會配合 KMP 開發跨平臺應用,那麼就需要學會不依賴 Jetpack 的開發方式,這也是本文為什麼要介紹非 Jetpack 下的 MVVM 的一個初衷。

Sample程式碼:

https://github.com/vitaviva/JetpackComposePlayground/tree/main/architecture