0%

Android架构学习之路三-MVX

前言

架构学习之路系列

这是架构学习系列的第三篇,主要介绍一下 MVC, MVP 以及 MVVM 架构,至于 MVI 后面会单独介绍。这些 MVX 的目的都是为了将业务和视图分离,松耦合,作为 Android 程序猿,大多不陌生了。

一个 App 离不开 Model 和 View 这两个角色, Model 决定了 App 的数据,而 View 决定怎么向用户展示这些数据,大多框架或组件基本上都是用来处理这两者之间的交互关系的。

因此一个 App 的架构需要处理两个任务:

  1. 更新 Model —— 如何处理 View action?
  2. 更新 View —— 如何将 Model 的数据表现到 View 上?

基于此,在 Android 上一般有如下三种常用架构(本期不讲 MVI):

  • MVC —— Model-View-Controller: 作为 Controller 层的 Actvity/Fragment 等充当了 View 的角色,代码过于臃肿;同时在 View 层又容易直接操作 Model,导致 View 和 Model 层耦合,无法独立复用。有时候看到一个 Activity 能有几千甚至上万行的代码,简直噩梦。
  • MVP —— Model-View-Presenter: Presenter 和 View 层之间通过定义接口实现通信,解耦了 View 和 Model 层。然而当业务场景比较复杂时,接口定义会越来越多,且可能定义模糊,接口一旦变化,对应实现也需要发生变化。
  • MVVM —— Model-View-ViewModel: MVVM 解决了 MVP 的问题,使得 ViewModel 和 View 之间不再依赖接口通信,而是通过 LiveData, RxJava, Flow 等响应式开发的方式来通信。

我们在这里可以看下 Model 和 View 的理解:

  • View: 视图,向用户呈现的界面,与用户直接交互的一层。
  • Model: Model 通常应包括数据和一些业务逻辑,即数据的结构定义,以及存储和获取等。而针对外部组件而言, Model 往往表示向其提供的数据,毕竟它们不关心数据是咋来的,咋走的,它们只关心它们自己。

MVC

该架构涉及三个角色: Model-View-Controller。其中 Controller 是 Model 与 View 之间的桥梁,用来控制程序的流程。

我记得曾经在网上看过不少 MVC 的文章,但是貌似有些文章里面给的模型图不太一样,一度有些费解,其实这些不一样的地方在于 MVC 模型经过发展存在着变体而已。一个版本的 MVC 是这样子的:

该版本一般的交互流程是:

  1. 用户操作 View, 比如说产生了一个点击事件。
  2. Controller 接收事件,对其作出反应。比如说是点击登录事件,它会校验用户输入是否为空,若为空则直接返回 View 让其提示用户;若不为空则请求 Model 层。
  3. Model 作出处理后,需要把登录用户的数据通知到相关成员,上图中即是 View 层。View 收到后作出相关展示。

在上图中 View 层依赖了 Model 层,降低了 View 的可复用性,为了解耦,出现了下图的版本:

这个版本的主要改动就是 View 和 Model 不直接通信了,View 通过 Controller 去更新 Model 层的数据,Model 层完成逻辑后通知 Controller 层,Controller 再去更新 View。

MVC 架构小结

  • MVC 为视图和业务的分离提供了开创性的思路,解耦了 View 和 Model 层,提高了复用性。
  • 然而在 Android 的实际应用中, 容易出现一个新的角色 —— ViewController, 比如说 Activity 又当 View 又当 Controller 的,十分臃肿,耦合也随之变得严重了起来,还不方便单元测试。

MVP

该架构涉及三个角色: Model-View-Presenter。关系图如下:

这张图跟上面第二个版本的 MVC 结构很像,不一样的地方在于 Controller 换成了 Presenter 层,其职责是类似的,但是实现方式不一样。MVP 之间是通过接口来通信的,三个层都有各自的接口来定义其行为与能力,这样可以降低耦合,提高复用性,也方便了单元测试。

其交互流程依旧是:用户操作 View 层,产生了一个事件; Presenter 接收事件,并对其作出反应,请求 Model 层; Model 层作出处理后通知给 Presenter, Presenter 进而再通知到 View 层。

通过登录场景举个栗子🌰

1、首先定义各层的接口,一个场景的接口写在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface ILogin {
interface ILoginView {
fun loginLoading() // 登陆中
fun loginResult(result: Boolean) // 登陆结果
fun isAvailable(): Boolean // IView 是否可用
}

interface ILoginPresenter {
fun attachView(view: ILoginView) // attach View
fun detachView() // detach View, 防止内存泄漏
fun isViewAvailable(): Boolean
fun login()
}

interface ILoginModel {
fun login(listener: OnLoginListener)
}

interface OnLoginListener {
fun result(result: Boolean)
}
}

2、View 层实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MVPLoginActivity : AppCompatActivity(), ILogin.ILoginView {
private val loginPresenter = LoginPresenter()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(Button(this).apply {
text = "登录"
setOnClickListener {
loginPresenter.login()
}
})
loginPresenter.attachView(this)
}

override fun loginLoading() {
Toast.makeText(this, "Login...", Toast.LENGTH_SHORT).show()
}

override fun loginResult(result: Boolean) {
Toast.makeText(this, "Login result: $result", Toast.LENGTH_SHORT).show()
}

override fun isAvailable() = !isDestroyed && !isFinishing

override fun onDestroy() {
super.onDestroy()
loginPresenter.detachView()
}
}

3、Presenter 层实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class LoginPresenter : ILogin.ILoginPresenter, ILogin.OnLoginListener {
private val loginModel: ILogin.ILoginModel = LoginModel()
private var loginView: ILogin.ILoginView? = null

override fun attachView(view: ILogin.ILoginView) {
loginView = view
}

override fun detachView() {
loginView = null
}

override fun isViewAvailable(): Boolean = loginView?.isAvailable() ?: false

override fun login() {
loginView?.loginLoading()
loginModel.login(this)
}

override fun result(result: Boolean) {
if (isViewAvailable()) {
loginView?.loginResult(result)
}
}
}

4、Model 层实现

1
2
3
4
5
6
7
8
9
10
11
class LoginModel : ILogin.ILoginModel {
override fun login(listener: ILogin.OnLoginListener) {
thread {
Thread.sleep(1000)
runOnUIThread {
// 返回登录结果
listener.result(Random.nextBoolean())
}
}
}
}

以上只是一个示例,实际开发中当然会把一些基础的重复的逻辑抽成 Base 类

MVP 架构小结

  • MVP 模式清晰划分了各个层的职责,避免了 ViewController 的问题,降低了代码的臃肿程度。
  • 解除 View 与 Model 的耦合,通过接口来交互,提高了可复用性和扩展性,利于单元测试。
  • 但随着业务的复杂化,接口的定义越来越多,提高了项目的复杂度,对开发的设计能力要求也更高了。
  • Presenter 如果持有 Activity 等的引用,容易出现内存泄漏,生命周期不同步等问题。

MVVM

MVVM模式

该架构涉及三个角色: Model-View-ViewModel。关系图如下:

它跟 MVP 看起来也是比较类似的,不同之处在于 Presenter 换成了 ViewModel, ViewModel 负责与 Model 层交互,并且将数据以可观察对象的形式提供给 View, ViewModel 与 View 层分离,即 ViewModel 不应该知道与之交互的 View 是什么。

上面说过 Model 层里包括了一些业务逻辑和业务数据模型,而 ViewModel 层即是视图模型(Model of View),其内是视图的表示数据和逻辑。比如说 Model 层的业务数据是 1, 2, 3, 4, 而翻译到 View 层,则可能是表示 A, B, C, D 了。ViewModel 除了做这个事情外,还会封装视图的行为动作,如点击某个控件后的行为等。另外注意这里的 ViewModel 和 Jetpack 包里提供的 ViewModel 组件不是一个东西,这里的 ViewModel 是一个概念,而 Jetpack 包则提供了一个比较方便的实现方式。

很多讲 MVVM 的文章示例都会用 DataBinding, 然而没有 DataBinding 照样可以使用 MVVM 架构,比如说借用 LiveData, RxJava, Flow 等,这些工具都是基于响应式开发的原理,来替代基于接口的通信方式。实际开发中基本没看到过使用 DataBinding 的,另外如果真要使用 DataBinding 的话,尽量避免在 xml 里写代码逻辑,而应替换成变量来表示某个属性,在 Kotlin 代码里赋值。

这里的响应式开发强调一种基于观察者模式的开发方式: View 订阅 ViewModel 暴露的响应式接口,接收到通知后进行相应逻辑,而 ViewModel 不再持有任何形式的 View 的引用,减少耦合,提高了可复用性。

另外如果使用 LiveData 的话, ViewModel 对 View 层仅暴露 LiveData 接口,在 View 层不允许直接更新 LiveData, 因为一旦 View 层拥有直接更新 LiveData 的能力,就无法约束 View 层进行业务处理的行为:

1
2
3
4
class LoginViewModel : ViewModel() {
private val _loginResult: MutableLiveData<Boolean> = MutableLiveData()
val loginResult: LiveData<Boolean> = _loginResult
}

这样会导致我们需要写很多模版化的代码,并且导致 ViewModel 层更为臃肿,因此可以考虑定义成这样:

1
val loginResult: LiveData<Boolean> = MutableLiveData()

然后定义一个针对 LiveData setValue 的扩展方法,方法内部判断其类型是否为 MutableLiveData 类型,如是则可以更新值。

关于 flow 之前有在掘金上写过一篇学习笔记,感兴趣可以看看: Kotlin协程之flow工作原理

以登录结果为例, MVVM 基于 LiveData 的交互流程: 首先 ViewModel 中有一个 LiveData 属性表示登录结果,对外暴露出 LiveData 而不是 MutableLiveData, View 层会订阅这个数据; View 层点击登录后,调用 VM 的登录接口, VM 然后请求 Model 层的登录能力; Model 完事后通知到 VM, VM 更新 MutableLiveData 登录状态, 而 View 则收到了 LiveData 的变化通知,进而更新 UI。

实例

1、Model 层模拟登录,返回登录结果

1
2
3
4
5
6
7
class LoginModel {
// 模拟登录
suspend fun login(): Boolean = withContext(Dispatchers.IO) {
delay(1000)
Random.nextBoolean()
}
}

2、ViewModel 层暴露 login 方法,并提供 LiveData 数据表示登录状态,让 View 层订阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class LoginViewModel : ViewModel(), CoroutineScope by MainScope() {
private val loginModel = LoginModel()

private val _loginResult: MutableLiveData<Int> = MutableLiveData()
val loginResult: LiveData<Int> = _loginResult

fun login() {
launch {
_loginResult.value = 0
val result = loginModel.login()
_loginResult.value = if (result) 1 else -1
}
}

// 模拟状态
fun loginProgressText(result: Int): String = when (result) {
0 -> "登录中"
1 -> "登录成功"
else -> "登录失败"
}
}

3、View 层处理点击事件,并订阅登录状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MVVMLoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by lazy {
ViewModelProvider(this).get(LoginViewModel::class.java)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(Button(this).apply {
text = "登录"
setOnClickListener {
viewModel.login()
}
})
// 监听登录状态
viewModel.loginResult.observe(this, {
Toast.makeText(
this,
"Login result: ${viewModel.loginProgressText(it)}",
Toast.LENGTH_SHORT
).show()
})
}
}

Repository

Repository 模式的概念来自于领域驱动开发(Domain Driven Design)。主要思想是通过抽象一个 Repository 层,对业务(领域)层屏蔽不同数据源的访问细节,业务层(可能是 ViewModel)无需关注具体的数据访问细节。

Repository 内部实现了对不同数据源(DataSource)的访问,典型的 DataSource 包括远程数据, Cache 缓存, Database 数据库等,可以用不同的 Fetcher 来实现, Repository 持有多个 Fetcher 引用。

因此上面实例中的 LoginModel 可以换成 LoginRepository 类, LoginRepository 不暴露具体的数据访问方式,只暴露出这一能力的接口。

写在最后

写代码的时候,记得三思而后行,想一想你写的代码是不是在它该在的位置,是不是以该有的形式存在的。

架构不是一蹴而就的,希望我们有一天的时候,能够从自己写的代码中找到架构的成就感,而不是干几票就跑路。这个系列应该会一直更新,记录我在架构之路上学习的脚印儿,一件一件扒开架构神秘的面纱。