你的 MVVM 不是你的 MVVM

相信各位也看了 N 个介绍 MVVM 的文章了吧,不知道你们有没有觉得大家所描述的 MVVM 是不是有点不太一样呢?或是套用在你的专案後,实作中所遇到的问题跟网路教学差了十万八千里呢?或是在专案的初期,你看着你完美、设计好的 MVVM 架构,期待它永远都可以这麽完美下去,结果半年後被 PM 的需求搞的乱七八糟,自己都不认识它了,不再是你熟悉的那个 MVVM?没关系,遇到这些事情都很正常,请不要慌张,今天我介绍的 MVVM 也可能是一个新的 MVVM,所以,万一你正在思考什麽才是最“正确”的 MVVM 的话,我会建议你放下这个想法,因为本来就没有一个最“正确”的架构存在的必要,有解决到所遇到的问题才是重点,今天的 MVVM 将会针对这专案目前的范围去做设计,而在更之後的篇幅中,你也会看到因为需求而做的架构调整。


现在 Android 最主流的架构模式,就属於 MVVM 了,今天会从多层式架构为起点,来说明多层式架构的核心诉求,再带到 MVVM 这样的架构模式,以及为什麽这个架构模式非常适合本专案,最後,示范如何套用 MVVM 到便利贴这个专案上。

多层式架构

多层式架构的英文为 Multilayer architecture,又称 Multitier architecture。最主要的用途是将程序模组化,不同层级有着不同的责任,也有着不同的存取权限。在多层式架构中,最常被来使用的就属於三层式架构了,当然多层式架构可以不只三层,只是以一般的应用程序来说,这样的分层方式能完成大部分的功能,而且好管理。

Screen Shot 2021-08-26 at 10.11.42 AM.png

如上图所示,三层式架构从上到下分别为:显示层、商业逻辑层、资料层

  • 显示层(Presentation):绘制前端 UI 画面,处理与 UI 元件的互动。
  • 商业逻辑层(Business):应用程序的核心逻辑处理,这边尽量要降低与 platform 或是 SDK 的关系,在进行开发时才不会绑手绑脚。
  • 资料层(Data):资料的提供者,新增、修改、删除、查询资料都是属於这一层的范畴。

一般来说,显示层能直接对商业逻辑层进行操作,但是不能对资料层进行操作,也完全不知道资料层的存在。商业逻辑层不知道显示层的存在,但是能直接操作资料层,而资料层呢?就只能等待被别人使用了。所以三层式架构有一个从上而下的相依关系,上层使用下层,下层无法知道上层。

在某些使用案例中,可能不只有三层,也有可能因爲需求而需要跨层存取,第一层可以使用第三层或是第四层的物件,所以每一层到下面几层之间的存取并不是绝对的,如果因采用严格的限制而导致开发速度缓慢,或是没有一个合理的原因去做这样的限制的话,就不会是一个好的架构,就像前面所说的,好的架构应该要最大化工程师的生产力。

MVVM

MVVM 总共是由三个部分组成 View, ViewModel, Model ,那这三个部分是不是就刚好可以跟三层式架构一一对应呢?很可惜的是这部分我觉得是有争议的。

Screen Shot 2021-08-26 at 10.43.52 AM.png

上图撷取自 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 上采用时需要把这些定义原封不动的照搬过来吗?这边有几个问题我想跟大家讨论一下:

  1. 显示层逻辑跟商业逻辑差在哪?在手机端进行开发时,所谓的“商业逻辑”是什麽?以电商 App 来说,是使用者购买商品的逻辑吗?那如果这部分後端做完了,手机端还需要做什麽呢?还有显示按钮的 enable 状态是属於显示层逻辑还是商业逻辑呢?
  2. Android Architecture Component(AAC) 的 ViewModel 是在做 Android 开发时最常用到的元件之一,如果依照上面 MVVM 的定义,我是不是不能放任何商业逻辑在这个 AAC 的 ViewModel 呢?那商业逻辑应该要放哪?Repository 吗?
  3. 资料绑定非常方便,在 ViewModel 中的 Model 是跟 View 有相互对应关系的,那 ViewModel 的 Model 跟资料库的 Model 还有商业逻辑的 Model 都是同一个吗?同一个比较好还是另外设计比较好呢?

其实以上的问题都没有标准的答案,最後还是那一句“It depends”。但对我来说,我希望整个 App 的架构是越简单越好,在没有复杂的问题需要被解决的情况下,采用“过於严格”的定义往往没什麽好处,最後反而会花费太多时间在讨论一些对使用者或是对公司产品没有价值的事情。以现在我要做的 App 来说好了,便利贴应用程序就是一个充满 UI 互动的软件,对於这个 App 来说,什麽是商业逻辑,什麽是显示逻辑,似乎没有这麽容易的可以分清楚,那没有分清楚是一个大问题吗?目前也看不太出来,那就也不用花太多时间纠结在这上面。相对的,如果最後我要开发一个可以用来跑 Sprint 流程的看板应用程序, “Sprint 流程”本身就已经隐含了非常多规则在里面,所以在这情况下,分出商业逻辑层跟显示逻辑层是一件再正常也不过的事情。

实作

以目前这个 App 的规模来说,分成三大组件就非常够用了:

  • View:主要是由 Jetpack Compose 来完成,会直接与 ViewModel 进行资料绑定。
  • ViewModel:主体是 Android Architecture Component 的 ViewModel,使用它主要是因为他可以帮我们处理 Android 生命周期事件,在这里会提供公开的栏位给 View 去做资料绑定,另外,这些资料的逻辑运算也是在这边完成,并交给 Repository 来去做资料更新。
  • Repository:代表资料层,这边不写 Model 是为了更清楚的定义以及意图,该元件的职责完全跟资料层一样:新增、修改、删除以及查询资料。

那这三个组件组在一起就是完整的 MVVM 吗?ViewModel 跟 Model 中间的那条分层的线在哪里?我想,这个问题会依据不同的定义会有不同的答案,但是现在有没有回答这个问题也不是那麽的重要了,更好的问题会是:我们已经知道要在哪个地方写怎样的程序码了吗?这几个元件的职责已经够清楚了吗?

不知道各位心中是不是已经有答案了呢?没有也没关系,下面直接用程序来做示范吧!由於相依的顺序是从上到下,最下层是被别人使用的,所以就从最下层来介绍起吧!

今天需要的 gradle dependency:
implementation 'io.reactivex.rxjava3:rxjava:3.0.12'
implementation "androidx.compose.runtime:runtime-rxjava3:$compose_version"

Repository

目前只需要拿既有资料就好,所以该层的实作将会非常的简单。

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

由於目前没有任何的逻辑,也不需要转换资料格式,所以 ViewModel 这边也非常的简单,下一篇整合手势操作後才会有比较大的用途,不过我们已经很明确的知道这一个元件是需要的,就算看起来很笨也无妨。

class BoardViewModel(
    private val noteRepository: NoteRepository
): ViewModel() {
    
    val allNotes = noteRepository.getAll()
}

很单纯的呼叫 Repository 的 API 给外面的人使用,对 Android 开发已经很熟悉的你可能会有疑问,怎麽不用 LiveData 呢?答案将会在下面揭晓:

View

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 来拿到现在状态中的值:

Screen Shot 2021-08-26 at 3.08.39 PM.png

但是要是每次取值时都要使用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 函式库。


<<:  Day7 React-router

>>:  Day03 Filebeat(一) 基本设定

Day21 Open-Match 端点暴露

今天会说明一下,实务上如何将 Open-Match svc endpoints,从 kubernet...

D-12 设定挡 ? configuration ? IOptionsMonitor

设定档 昨日说明关於使用者身分验证以及权限设定的部分加以说明,并且透过第三方插件的方式展现如何在do...

Day 23 : Linux - 如何在Ubuntu的英文介面下使用新酷音中文输入法?

如标题,如果你突然想不开,想将Ubuntu下载成英文版的,那你一定会头疼该如何输入中文 因为英文介面...

[Day12]资料类型转换

SQL的资料类型转换分为隐性和显性转换,隐性转换即不必使用指定的转换函数,语句执行时资料库管理系统会...

[Android Studio 30天自我挑战] Switch 元件介绍

这篇的Switch button与前几篇的ToggleButton很类似, 但ToggleButto...