Re-architect - ContextMenuView

以下是到目前为止的架构图,已经成功的将 ViewModel 层的全部商业逻辑移到了 Domain 层:

1223A20C-B026-40FB-BF47-3B7C938D8E71.jpg

接下来,将在右边的 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 呢?

Stateful & Stateless 同时存在

@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

运用 CompositionLocal 与 Stateful 概念的结合还有另一个好处,就是这样的做法可以任意的控制 Recomposition 所影响的范围。一般来说,如果使用参数传递的方式,任何有经过参数的 Composable function,只要参数的数值有修改过,不管改的范围有多少,所有的 Composable function 都会再重新 Recompose 一次,这样对於效能来说会是个负担:

19F4274B-FD6B-4EDB-ADCC-A28FD8C76E5E.jpg

以上图为例,A、B、C 都是 Immutable 的 class ,他们彼此之间是组合关系。现在可能在 ViewModel 中因为某个功能的关系,修改了 c 的值,照理来说,我只想要重新执行 ComposableC 就好了,但是由於他们都是 Immutable ,所以 ComposableAComposableB 也会跟着受影响而一起被重新执行了。所以如果需要的话,使用 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 层,这样做的结果会使得下一个看程序码的工程师需要再做一次的“脑内翻译”,让每个行为有适合的名称看似麻烦但是却是一个划算的交易。


<<:  Day 19 -HAVING 子句!

>>:  [19] [烧瓶里的部落格] 09. 正式部署

Day04 - 随意玩之 AES-CBC 加/解密

加密前的资料在前几天我们都有拿到了!接着就是实作 AES-CBC 罗~ 流程如下图 关於 AES-C...

成为工具人应有的工具包-02 BrowsingHistoryView

BrowsingHistoryView 好的,就从 NirSoft 官网的 Forensics 分类...

【左京淳的JAVA学习笔记】第二章 阵列与列表

学习重点 一维阵列 多维阵列(阵列内含有阵列,形成多层结构) 列表(java.util.ArrayL...

第 30 型 - 环境配置与建构 (Build)

实务上,因应不同的开发阶段,应用程序会运行在开发环境 (Develop Environment)、预...

微服务-API 闸道器

-API 闸道器和服务网格(来源:Liran Katz) 实施 API 闸道器以促进跨境通信;他们...