以下是到目前為止的架構圖,已經成功的將 ViewModel 層的全部商業邏輯移到了 Domain 層:
接下來,將在右邊的 ContextMenu 也開一條從 View 層到 Domain 層的依賴,讓 CoEditorViewModel
不需要認識 ContextMenu
,也就是說拿掉刪除、改顏色、改文字的這幾個功能,如此一來,也不需要將 CoEditorViewModel
的函式經過這麼多層傳到 ContextMenuView
才能做事,ContextMenuView
會有獨立的 ViewModel ,所有的事件交給這個 ViewModel 即可,所以,我們就先從建立 ViewModel 開始吧!
class ContextMenuViewModel(
private val contextMenu: ContextMenu
): ViewModel() {
val selectedColor = contextMenu.selectedColor
val colorOptions = contextMenu.colorOptions
fun onDeleteClicked() {
contextMenu.onDeleteClicked()
}
fun onColorSelected(color: YBColor) {
contextMenu.onColorSelected(color)
}
fun onEditTextClicked() {
contextMenu.onEditTextClicked()
}
}
這個 ViewModel 真是意料之外的簡單,幾乎就是個空殼,因為大部分的實作都已經在 Domain 層完成了。有了這個類別有助於我們可以更加的將職責分離,讓 CoEditorViewModel
的職責更加單一,那我們該如何把它串接到 View 呢?讓我們來看看原本的 MenuView
是長怎樣的吧!
@Composable
fun MenuView(
modifier: Modifier = Modifier,
selectedColor: YBColor,
onDeleteClicked: () -> Unit,
onColorSelected: (YBColor) -> Unit,
onTextClicked: () -> Unit
) {
// in EditorScreen...
@Composable
fun EditorScreen(viewModel: EditorViewModel) {
// ..others
MenuView(
selectedColor = selectingColor,
onDeleteClicked = viewModel::onDeleteClicked,
onColorSelected = viewModel::onColorSelected,
onTextClicked = viewModel::onEditTextClicked
)
}
原本是由 EditorScreen
這個 Composable function 擁有 MenuView
,同時也會使用 EditorViewModel
當中的函式來與 MenuView
做互動,現在我們要脫離這樣的相依關係,可以怎麼做呢?
還記得 Stateful 跟 Stateless 的觀念嗎?一個有 ViewModel 的 Composable function 就是一個 Stateful 的元件,換句話說,如果我們要讓 ContextMenuView
有 ViewModel 的相依的話,ContextMenuView
****本身就要是一個 Stateful 元件,但同時我又不想破壞現在既有的函式,畢竟現在MenuView
是可以支援預覽的,要快速修改很方便,一但這個函式跟 ViewModel 有連結的話將會無法預覽,所以,我們為何不乾脆做出不同類型的 ContextMenuView 呢?
@Composable
fun StatefulContextMenuView(
modifier: Modifier = Modifier
) {
val contextMenuViewModel by LocalViewModelStoreOwner.current!!.viewModel<ContextMenuViewModel>() // [1]
val selectedColor by contextMenuViewModel.selectedColor.subscribeAsState(initial = YBColor.Aquamarine)
ContextMenuView(
modifier = modifier,
selectedColor = selectedColor,
allColors = contextMenuViewModel.colorOptions, // [2]
onDeleteClicked = contextMenuViewModel::onDeleteClicked,
onColorSelected = contextMenuViewModel::onColorSelected,
onTextClicked = contextMenuViewModel::onEditTextClicked
)
}
這裡新增了一個新類別:StatefulContextMenuView
,可以用來跟 ContextMenuView
作區別,其最主要的職責是獲取 ViewModel 以及掌控 ContextMenu 的狀態。另外,在 [1] 這邊出現了一個新的類別:LocalViewModelStoreOwner
,使用這個類別就可以獲取當下的 ViewModelStoreOwner,還記得嗎?在之前介紹 Navigation 的時候有出現過這個類別,以一般的情況下, ViewModelStoreOwner 會是 Activity 或是 Fragment ,但是有時候我們不希望拿到“全域”的 ViewModelStoreOwner ,而是能夠隨著當下的 Scope 一起生、一起死,這個 LocalViewModelStoreOwner
所做的正是這件事,會隨著當下的情況去拿到當下所提供的 ViewModelStoreOwner ,而不是“全域”的那一個。
至於依我們的使用案例來說,這個 ViewModelStoreOwner 會是誰呢?答案就是 EditorScreen
這邊的 backStackEntry :
ReactiveStickyNoteTheme {
NavHost(navController, startDestination = Screen.Board.route) {
composable(Screen.Board.route) { backStackEntry -> // 就是它
EditorScreen(
// ...
)
}
想了解更多關於 LocalViewModelStoreOwner
的資訊,可以去看看 Jetpack Compose 的 CompositionLocal 相關文章。像是這個官方介紹:https://developer.android.com/jetpack/compose/compositionlocal
另外在上方程式碼 StatefulContextMenuView
中的 [2] 將 allColor 傳進了 ContextMenuView
,這樣做的目的是讓更多的控制權放在 ViewModel ,不要讓 View 層知道需要顯示哪些顏色的選項,以下的程式碼是 Stateless 的 ContextMenuView
:
@Composable
fun ContextMenuView(
modifier: Modifier = Modifier,
selectedColor: YBColor,
allColors: List<YBColor>,
onDeleteClicked: () -> Unit,
onColorSelected: (YBColor) -> Unit,
onTextClicked: () -> Unit
) {
var expended by remember {
mutableStateOf(false)
}
Surface(
modifier = modifier.fillMaxWidth(),
elevation = 4.dp,
color = MaterialTheme.colors.surface
) {
Row {
IconButton(onClick = onDeleteClicked ) {
val painter = painterResource(id = R.drawable.ic_delete)
Icon(painter = painter, contentDescription = "Delete")
}
IconButton(onClick = onTextClicked ) {
val painter = painterResource(id = R.drawable.ic_text)
Icon(painter = painter, contentDescription = "Edit text")
}
IconButton(onClick = { expended = true }) {
Box(modifier = Modifier
.size(24.dp)
.background(Color(selectedColor.color), shape = CircleShape))
DropdownMenu(expanded = expended, onDismissRequest = { expended = false }) {
for (color in allColors) {
DropdownMenuItem(onClick = {
onColorSelected(color)
expended = false
}) {
Box(modifier = Modifier
.size(24.dp)
.background(Color(color.color), shape = CircleShape))
}
}
}
}
}
}
}
運用 CompositionLocal 與 Stateful 概念的結合還有另一個好處,就是這樣的做法可以任意的控制 Recomposition 所影響的範圍。一般來說,如果使用參數傳遞的方式,任何有經過參數的 Composable function,只要參數的數值有修改過,不管改的範圍有多少,所有的 Composable function 都會再重新 Recompose 一次,這樣對於效能來說會是個負擔:
以上圖為例,A、B、C 都是 Immutable 的 class ,他們彼此之間是組合關係。現在可能在 ViewModel 中因為某個功能的關係,修改了 c 的值,照理來說,我只想要重新執行 ComposableC
就好了,但是由於他們都是 Immutable ,所以 ComposableA
跟 ComposableB
也會跟著受影響而一起被重新執行了。所以如果需要的話,使用 CompositionLocal 與 Stateful 會是一個不錯的組合技。
在原本的 EditorScreen
中,控制 ContextMenu 還有 Adder Button 顯示狀態的方式是觀察 selectingNote
這個變數,如果該值為空的,就顯示 Adder Button,反之則顯示 ContextMenu,但是這其實算是領域知識的一部分,交給 View 來判斷其實不是很好,而且如果要寫測試的話,測試很難描述出這邊的行為變化,於是比較好的做法還是將這段邏輯放到 Domain 層:
class CoEditor(
private val noteRepository: NoteRepository
) {
private val _showContextMenu = BehaviorSubject.createDefault(false)
private val _showAddButton = BehaviorSubject.createDefault(true)
val showAdderButton: Observable<Boolean> = _showAddButton.hide()
val openEditTextScreen: Observable<String> = _openEditTextScreen.hide()
// 變更選擇狀態時也一起更新選單的顯示狀態
fun selectNote(noteId: String) {
if (selectedNoteId.value.isPresent && selectedNoteId.value.get() == noteId) {
clearSelection()
} else {
selectedNoteId.onNext(Optional.of(noteId))
_showAddButton.onNext(false)
_showContextMenu.onNext(true)
}
}
fun clearSelection() {
selectedNoteId.onNext(Optional.empty())
_showAddButton.onNext(true)
_showContextMenu.onNext(false)
}
// ..others
}
以下是更改過後的 View :
fun CoEditorScreen(
viewModel: EditorViewModel,
openEditTextScreen: (String) -> Unit
) {
...
// 預設值為 true,什麼都沒做的情況下應該是未選擇狀態
val showAddButton by viewModel.showAddButton.subscribeAsState(initial = true)
val showContextMenu by viewModel.showContextMenu.subscribeAsState(initial = false)
// Adder button
AnimatedVisibility(
visible = showAddButton,
modifier = Modifier.align(Alignment.BottomEnd)
) { ... }
// ContextMenu
AnimatedVisibility(
visible = showContextMenu,
modifier = Modifier.align(Alignment.BottomCenter)
) { ... }
}
今天又踏出了改變結構的一小步,讀者試著想像一下,如果沒有經過之前的“小步快跑”,這樣的結構改變會是多麽危險,憑空建立一個 ContextMenuViewModel
,同時又要將其商業邏輯做搬移,我們將很難確保這樣巨大的改變不會改壞任何功能。
接著,好好運用 Jetpack Compose 的 Stateful 組件,可以讓我們更方便的達到職責分離的這件事,從現在開始可以轉換思維:不是只有像是 Activity 或是 Fragment 這種以“頁面”為單位的元件才可以建立 ViewModel 的實例,只要是一個夠獨立的 UI 元件,都可以擁有屬於自己的 ViewModel ,讓 ViewModel 不再因為要認識所有 UI 細節而臃腫肥大。
最後,表達清晰的意圖可以大大的增加程式碼的可讀性,有時為了貪圖方便,讓商業邏輯下放到了 View 層,這樣做的結果會使得下一個看程式碼的工程師需要再做一次的“腦內翻譯”,讓每個行為有適合的名稱看似麻煩但是卻是一個划算的交易。