MVVM 是一种软件架构模式,它是 Martin Fowler 的 Presentation Model 的一种变体,最先由微软的架构师 John Gossman 在 2005 年提出,并应用在微软的 WPF 和 Silverlight 软件开发中。MVVM
衍生于 MVC ,是对 MVC
的一种演进,它促进了 UI
代码与业务逻辑的分离。
说明:本文将采用理论与实践相结合的方式,重点介绍一个使用 MVVM
和 RAC
开发的 iOS
开源项目 MVVMReactiveCocoa ,目的是希望能为你实践 MVVM
提供帮助。不过,在正式开始介绍正文之前,请你先思考以下三个问题:
MVC
与MVVM
有什么异同点,MVC
到MVVM
是怎样演进的;RAC
在MVVM
中扮演什么样的角色,MVVM
是否一定要结合RAC
使用;- 如何将一个现有的
MVC
应用转变成一个MVVM
应用,有哪些需要注意的地方。
带着以上问题,我们一起进入正文。
名词解释:本文中的 RAC
为 ReactiveCocoa
的缩写。
MVC
MVC
是 iOS
开发中使用最普遍的架构模式,同时也是苹果官方推荐的架构模式。MVC
代表的是 Model–view–controller
,它们之间的关系如下:
是的,MVC
看上去棒极了,model
代表数据,view
代表 UI
,而 controller
则负责协调它们两者之间的关系。然而,尽管从技术上看 view
和 controller
是相互独立的,但事实上它们几乎总是结对出现,一个 view
只能与一个 controller
进行匹配,反之亦然。既然如此,那我们为何不将它们看作一个整体呢:
因此,M-VC
可能是对 iOS
中的 MVC
模式更为准确的解读。在一个典型的 MVC
应用中,controller
由于承载了过多的逻辑,往往会变得臃肿不堪,所以 MVC
也经常被人调侃成 Massive View Controller :
iOS architecture, where MVC stands for Massive View Controller.
坦白说,有一部分逻辑确实是属于 controller
的,但是也有一部分逻辑是不应该被放置在 controller
中的。比如,将 model
中的 NSDate
转换成 view
可以展示的 NSString
等。在 MVVM
中,我们将这些逻辑统称为展示逻辑。
MVVM
因此,一种可以很好地解决 Massive View Controller
问题的办法就是将 controller
中的展示逻辑抽取出来,放置到一个专门的地方,而这个地方就是 viewModel
。其实,我们只要在上图中的 M-VC
之间放入 VM
,就可以得到 MVVM
模式的结构图:
从上图中,我们可以非常清楚地看到 MVVM
中四个组件之间的关系。注:除了 view
、viewModel
和 model
之外,MVVM
中还有一个非常重要的隐含组件 binder
:
view
:由MVC
中的view
和controller
组成,负责UI
的展示,绑定viewModel
中的属性,触发viewModel
中的命令;viewModel
:从MVC
的controller
中抽取出来的展示逻辑,负责从model
中获取view
所需的数据,转换成view
可以展示的数据,并暴露公开的属性和命令供view
进行绑定;model
:与MVC
中的model
一致,包括数据模型、访问数据库的操作和网络请求等;binder
:在MVVM
中,声明式的数据和命令绑定是一个隐含的约定,它可以让开发者非常方便地实现view
和viewModel
的同步,避免编写大量繁杂的样板化代码。在微软的MVVM
实现中,使用的是一种被称为 XAML 的标记语言。
ReactiveCocoa
尽管,在 iOS
开发中,系统并没有提供类似的框架可以让我们方便地实现 binder
功能,不过,值得庆幸的是,GitHub
开源的 RAC
,给了我们一个非常不错的选择。
RAC
是一个 iOS
中的函数式响应式编程框架,它受 Functional Reactive Programming 的启发,是 Justin Spahr-Summers 和 Josh Abernathy 在开发 GitHub for Mac 过程中的一个副产品,它提供了一系列用来组合和转换值流的 API
。如需了解更多关于 RAC
的信息,可以阅读我的上一篇文章《ReactiveCocoa v2.5 源码解析之架构总览》。
在 iOS
的 MVVM
实现中,我们可以使用 RAC
来在 view
和 viewModel
之间充当 binder
的角色,优雅地实现两者之间的同步。此外,我们还可以把 RAC
用在 model
层,使用 Signal
来代表异步的数据获取操作,比如读取文件、访问数据库和网络请求等。说明,RAC
的后一个应用场景是与 MVVM
无关的,也就是说,我们同样可以在 MVC
的 model
层这么用。
小结
综上所述,我们只要将 MVC
中的 controller
中的展示逻辑抽取出来,放置到 viewModel
中,然后通过一定的技术手段,比如 RAC
来同步 view
和 viewModel
,就完成了 MVC
到 MVVM
的转变。
Talk is cheap. Show me the code.
下面,我们直接上代码,一起来看一个 MVC
模式转换成 MVVM
模式的示例。首先是 model
层的代码 Person
:
1 2 3 4 5 6 7 8 9 10 |
|
然后是 view
层的代码 PersonViewController
,在 viewDidLoad
方法中,我们将 Person
中的属性进行一定的转换后,赋值给相应的 view
进行展示:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
接下来,我们引入一个 viewModel
,将 PersonViewController
中的展示逻辑抽取到这个 PersonViewModel
中:
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 30 31 |
|
最终,PersonViewController
将会变得非常轻量级:
1 2 3 4 5 6 |
|
怎么样?其实 MVVM
并没有想像中的那么难吧,而且更重要的是它也没有破坏 MVC
的现有结构,只不过是移动了一些代码,仅此而已。好了,说了这么多,那 MVVM
相比 MVC
到底有哪些好处呢?我想,主要可以归纳为以下三点:
- 由于展示逻辑被抽取到了
viewModel
中,所以view
中的代码将会变得非常轻量级; - 由于
viewModel
中的代码是与UI
无关的,所以它具有良好的可测试性; - 对于一个封装了大量业务逻辑的
model
来说,改变它可能会比较困难,并且存在一定的风险。在这种场景下,viewModel
可以作为model
的适配器使用,从而避免对model
进行较大的改动。
通过前面的示例,我们对第一点已经有了一定的感触;至于第三点,可能对于一个复杂的大型应用来说,才会比较明显;下面,我们还是使用前面的示例,来直观地感受下第二点好处:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
对于 MVVM
来说,我们可以把 view
看作是 viewModel
的可视化形式,viewModel
提供了 view
所需的数据和命令。因此,viewModel
的可测试性可以帮助我们极大地提高应用的质量。
MVVMReactiveCocoa
接下来,我们进入本文的第二部分,重点介绍一个使用 MVVM
和 RAC
开发的开源项目 MVVMReactiveCocoa
。说明,本文将主要介绍这个应用的架构和设计思路,希望可以为你实践 MVVM
提供一个真实的参考案例,有些架构并非是 MVVM
所必须的,而是我们为了更顺畅地使用 MVVM
而引入的,特别是 ViewModel-Based Navigation
。所以,请你在实践的过程中能够结合自身应用的实际情况做出相应的取舍,灵活处理。最后,我们将以登录界面为例,一起探讨下 MVVM
的实践思路。
说明,以下内容均基于 MVVMReactiveCocoa
的 v2.1.1 标签进行展开,并且对部分无关代码做了删减。
类图
为了方便我们从宏观上了解 MVVMReactiveCocoa
的整体结构,我们先来看看它的类图:
从上图中,我们可以看到,在 MVVMReactiveCocoa
中主要有两大继承体系:
- 用蓝色标识出来的
viewModel
的继承体系,基类为MRCViewModel
; - 用红色标识出来的
view
的继承体系,基类为MRCViewController
。
除了提供与系统基类 UIViewController
相对应的基类 MRCViewModel/MRCViewController
外,还提供了与系统基类 UITableViewController
和 UITabBarController
相对应的基类 MRCTableViewModel/MRCTableViewController
和 MRCTabBarViewModel/MRCTabBarController
,其中基类 MRCTableViewModel/MRCTableViewController
的使用最为普遍。
说明,之所以通过基类的方式来组织 MVVMReactiveCocoa
,一方面是因为主要开发者只有我一个人,这个方案非常容易实施;另一方面是因为通过基类的方式可以尽可能简单地实现代码重用,提高开发效率。
服务总线
经过前面的探讨,我们已经知道了 MVVM
中的 viewModel
的主要职责就是从 model
层获取 view
所需的数据,并且将这些数据转换成 view
能够展示的形式。因此,为了方便 viewModel
层调用 model
层中的所有服务,并且统一管理这些服务的创建,我使用抽象工厂模式将 model
层的所有服务集中管理了起来,结构图如下:
从上图中,我们可以看出,在服务总线类 MRCViewModelServices/MRCViewModelServicesImpl
中,主要包括以下三个方面的内容:
- 应用自有的服务类,用柚黄色进行了标识,包括
MRCAppStoreService/MRCAppStoreServiceImpl
和MRCRepositoryService/MRCRepositoryServiceImpl
两个服务类; - 第三方
GitHub
提供的API
框架,用天蓝色进行了标识,主要包括OCTClient
服务类; - 应用的导航服务,用藻绿色进行了标识,包括
MRCNavigationProtocol
协议和实现类MRCViewModelServicesImpl
等。
其中,前两者都是以信号的形式对 viewModel
层提供服务,代表异步的网络请求等数据获取操作,而我们在 viewModel
层则可以通过订阅信号的形式获取到所需的数据。此外,服务总线还实现了 MRCNavigationProtocol
协议,它的内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
看上去是不是有点眼熟?是的,MRCNavigationProtocol
协议其实就是参照系统的导航操作定义出来的,用来实现 ViewModel-Based
的导航服务。注意,服务总线类 MRCViewModelServicesImpl
其实并没有真正实现 MRCNavigationProtocol
协议中声明的操作,只不过是实现了一些空操作而已:
1 2 3 4 5 6 7 8 9 10 11 |
|
那么,我们是怎么实现 ViewModel-Based
的导航操作的呢?用 MRCViewModelServicesImpl
来实现这些空操作到底有什么用意?为什么要这么做,目的是为了什么?兄台,莫急,请接着看下一小节的内容。
ViewModel-Based Navigation
我们先来思考一个问题,就是我们为什么要实现 ViewModel-Based
的导航操作呢?直接在 view
层使用系统的 push/present
等操作来完成导航不就好了么?我总结了一下这么做的理由,主要有以下三点:
- 从理论上来说,
MVVM
模式的应用应该是以viewModel
为驱动来运转的; - 根据我们前面对
MVVM
的探讨,viewModel
提供了view
所需的数据和命令。因此,我们往往可以直接在命令执行成功后使用doNext
顺带就把导航操作给做了,一气呵成; - 这样可以使
view
更加轻量级,只需要绑定viewModel
提供的数据和命令即可。
既然如此,那我们究竟要如何实现 ViewModel-Based
的导航操作呢?我们都知道 iOS
中的导航操作无外乎两种,push/pop
和 present/dismiss
,前者是 UINavigationController
特有的功能,而后者是所有 UIViewController
都具备的功能。注意,UINavigationController
也是 UIViewController
的子类,所以它也同样具备 present/dismiss
的功能。因此,从本质上来说,不管我们要实现什么样的导航操作,最终都是离不开 push/pop
和 present/dismiss
的。
目前,MVVMReactiveCocoa
的做法是在 view
层维护一个 NavigationController
的堆栈 MRCNavigationControllerStack
,不管是 push/pop
还是 present/dismiss
,都使用栈顶的 NavigationController
来执行导航操作,并且保证 present
出来的是一个 NavigationController
。
接下来,我们一起来看看 MVVMReactiveCocoa
在执行了 push/pop
或 present/dismiss
操作后视图层次结构的变化过程。首先,我们来看看用户在登录成功后进入到首页时应用的视图层次结构图:
此时,应用展示的界面是 NewsViewController
。在 MRCNavigationControllerStack
堆栈中只有 NavigationController0
一个元素;而 NavigationController1
并没有在 MRCNavigationControllerStack
堆栈中,这是因为需要支持 TabBarController
的滑动切换而设计的视图层次结构,是首页比较特殊的一个地方。更多信息可以查看 GitHub
开源库 WXTabBarController ,在这里,我们不用太过于关心这个问题,只需要理解原理就好了。
接下来,当用户在 NewsViewController
界面,点击了某一个 cell
,通过 push
的方式,进入到仓库详情界面时,应用的视图层次结构图如下:
应用通过 MRCNavigationControllerStack
栈顶的元素 NavigationController0
,将仓库详情界面 push
到了自身的堆栈中。此时,应用展示的界面是被 push
进来的仓库详情界面 RepoDetailViewController
。最后,当用户在仓库详情界面,点击左下角的切换分支按钮,通过 present
的方式,弹出分支选择界面时,应用的视图层次结构图如下:
应用通过 MRCNavigationControllerStack
栈顶的元素 NavigationController0
,将 NavigationController5
以 present
的方式弹出来。此时,应用展示的是 NavigationController5
的根视图 SelectBranchOrTagViewController
。说明,由于 pop
和 dismiss
与 push
和 present
互为逆操作,所以只要按照从下到上的顺序看上面的视图层次结构图即可,这里不再赘述。
等等,如果我没有记错的话,MRCNavigationControllerStack
堆栈是在 view
层,而服务总线类 MRCViewModelServicesImpl
是在 viewModel
层的。据我所知,viewModel
层是不能引入 view
层的任何东西的,更严格的说,是不能引入任何 UIKit
中的东西的,否则就违背了 MVVM
的基本原则,并且也会散失 viewModel
的可测试性。在这个前提下,你要如何让这两者产生关联呢?
没错,这就是 MRCViewModelServicesImpl
中之所以实现那些空操作的目的所在了。viewModel
通过调用 MRCViewModelServicesImpl
中的空操作来表明需要执行相应的导航操作,而 MRCNavigationControllerStack
则通过 Hook
来捕获这些空操作,然后使用栈顶的 NavigationController
来执行真正的导航操作:
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
|
通过 Hook
的方式,我们最终实现了 ViewModel-Based
的导航操作,并且在 viewModel
层中也没有引入 view
层的任意东西,实现了解耦合。
Router
还有一点值得一提的是,我们在 viewModel
中调用导航操作的时候,只传入了 viewModel
的实例作为参数,那么我们在 MRCNavigationControllerStack
中执行真正的导航操作时,怎么才能知道要跳转到哪个界面呢?为此,我们配置了一个从 viewModel
到 view
的映射,并且约定了一个统一的初始化 view
的方法 initWithViewModel:
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
登录界面
最后,我们一起来看看登录界面中 viewModel
和 view
的部分关键代码,探讨一下 MVVM
的具体实践过程。说明,我们将会尽可能地回避具体的业务逻辑,重点关注 MVVM
的实践思路。下面是登录界面的截图:
其中,主要的界面元素有:
- 一个用于展示用户头像的按钮
avatarButton
; - 用于输入账号和密码的输入框
usernameTextField
和passwordTextField
; - 一个直接登录的按钮
loginButton
和一个跳转到浏览器授权登录的按钮browserLoginButton
。
分析:根据我们前面对 MVVM
的探讨,viewModel
需要提供 view
所需的数据和命令。因此,MRCLoginViewModel.h
头文件的内容大致如下:
1 2 3 4 5 6 7 8 9 10 11 |
|
非常直观,其中需要特别说明的是 validLoginSignal
属性代表的是登录按钮是否可用,它将会与 view
中登录按钮的 enabled
属性进行绑定。接着,我们来看看 MRCLoginViewModel.m
的实现文件中的部分关键代码:
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
|
- 当用户输入的用户名发生变化时,调用
model
层的方法查询本地数据库中缓存的用户数据,并返回avatarURL
属性; - 当用户输入的用户名或密码发生变化时,判断用户名和密码的长度是否均大于
0
,如果是则登录按钮可用,否则不可用; - 当
loginCommand
或browserLoginCommand
命令执行成功时,调用doNext
代码块,使用服务总线中的方法resetRootViewModel:
进入首页。
接下来,我们来看看 MRCLoginViewController
中的部分关键代码:
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
|
- 观察
viewModel
中avatarURL
属性的变化,然后设置avatarButton
中的图片; - 将
viewModel
中的username
和password
属性分别与usernameTextField
和passwordTextField
输入框中的内容进行绑定; - 将
loginButton
的enabled
属性与viewModel
的validLoginSignal
属性进行绑定; - 在
loginButton
和browserLoginButton
按钮被点击时分别执行loginCommand
和browserLoginCommand
命令。
综上所述,我们将 MRCLoginViewController
中的展示逻辑抽取到 MRCLoginViewModel
中后,使得 MRCLoginViewController
中的代码更加简洁和清晰。实践 MVVM
的关键点在于,我们要能够分析清楚 viewModel
需要暴露给 view
的数据和命令,这些数据和命令能够代表 view
当前的状态。
总结
首先,我们从理论出发介绍了 MVC
和 MVVM
各自的概念以及从 MVC
到 MVVM
的演进过程;接着,介绍了 RAC
在 MVVM
中的两个使用场景;最后,我们从实践的角度,重点介绍了一个使用 MVVM
和 RAC
开发的开源项目 MVVMReactiveCocoa
。总的来说,我认为 iOS
中的 MVVM
可以分为以下三种不同的实践程度,它们分别对应不同的适用场景:
MVVM + KVO
,适用于现有的MVC
项目,想转换成MVVM
但是不打算引入RAC
作为binder
的团队;MVVM + RAC
,适用于现有的MVC
项目,想转换成MVVM
并且打算引入RAC
作为binder
的团队;MVVM + RAC + ViewModel-Based Navigation
,适用于全新的项目,想实践MVVM
并且打算引入RAC
作为binder
,然后也想实践ViewModel-Based Navigation
的团队。
写在最后,希望这篇文章能够打消你对 MVVM
模式的顾虑,赶快行动起来吧。
参考链接
https://www.objc.io/issues/13-architecture/mvvm/
https://msdn.microsoft.com/en-us/library/hh848246.aspx
https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel
https://medium.com/ios-os-x-development/ios-architecture-patterns-ecba4c38de52#.p6n56kyc4
http://cocoasamurai.blogspot.ru/2013/03/basic-mvvm-with-reactivecocoa.html
http://www.sprynthesis.com/2014/12/06/reactivecocoa-mvvm-introduction/