本文由Colin Eberhardt
发表于raywenderlich
,原文可查看MVVM Tutorial with ReactiveCocoa: Part ½
你可能已经在Twitter
上听过这个这个笑话了:
“iOS Architecture, where MVC stands for Massive View Controller”
当然这在iOS
开发圈内,这是个轻松的笑话,但我敢确定你大实践中遇到过这个问题:即视图控制器太大且难以管理。
这篇文章将介绍另一种构建应用程序的模式—MVVM(Model-View-ViewModel)
。通过结合ReactiveCocoa
便利性,这个模式提供了一个很好的代替MVC
的方案,它保证了让视图控制器的轻量性。
在本文我,我们将通过构建一个简单的Flickr
查询程序来一步步了解MVVM
,这个程序的效果图如下所示:
在开始写代码之前,我们先来了解一些基本的原理。
原文简要介绍了一下ReactiveCocoa
,在此不再翻译,可以查看以下两篇译文:
ReactiveCocoa Tutorial – The Definitive Introduction: Part ½
ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2
MVVM模式介绍
正如其名称一下,MVVM
是一个UI
设计模式。它是MV*
模式集合中的一员。MV*
模式还包含MVC(Model View Controller)
、MVP(Model View Presenter)
等。这些模式的目的在于将UI逻辑与业务逻辑分离,以让程序更容易开发和测试。为了更好的理解MVVM
模式,我们可以看看其来源。
MVC
是最初的UI
设计模式,最早出现在Smalltalk
语言中。下图展示了MVC
模式的主要组成:
这个模式将UI
分成Model
(表示程序状态)、View
(由UI控件组成)、Controller
(处理用户交互与更新model
)。MVC
模式的最大问题是其令人相当困惑。它的概念看起来很好,但当我们实现MVC
时,就会产生上图这种Model-View-Controller
之间的环状关系。这种相互关系将会导致可怕的混乱。
最近Martin Fowler
介绍了MVC
模式的一个变种,这种模式命名为MVVM
,并被微软广泛采用并推广。
这个模式的核心是ViewModel
,它是一种特殊的model
类型,用于表示程序的UI
状态。它包含描述每个UI
控件的状态的属性。例如,文本输入域的当前文本,或者一个特定按钮是否可用。它同样暴露了视图可以执行哪些行为,如按钮点击或手势。
我们可以将ViewModel
看作是视图的模型(model-of-the-view
)。MVVM
模式中的三部分比MVC
更加简洁,下面是一些严格的限制
View
引用了ViewModel
,但反过来不行。ViewModel
引用了Model
,但反过来不行。
如果我们破坏了这些规则,便无法正确地使用MVVM
。
这个模式有以下一些立竿见影的优势:
- 轻量的视图:所有的UI逻辑都在
ViewModel
中。 - 便于测试:我们可以在没有视图的情况下运行整个程序,这样大大地增加了它的可测试性。
现在你可能注意到一个问题。如果View
引用了ViewModel
,但ViewModel
没有引用View
,那ViewModel
如何更新视图呢?哈哈,这就得靠MVVM
模式的私密武器了。
MVVM和数据绑定
MVVM
模式依赖于数据绑定,它是一个框架级别的特性,用于自动连接对象属性和UI控件。例如,在微软的WPF
框架中,下面的标签将一个TextField
的Text
属性绑定到ViewModel
的Username
属性中。
1
|
|
WPF框架将这两个属性绑定到一起。
不过可惜的是,iOS
没有数据绑定框架,幸运的是我们可以通过ReactiveCocoa
来实现这一功能。我们从iOS
开发的角度来看看MVVM
模式,ViewController
及其相关的UI
(nib
, stroyboard
或纯代码的View
)组成了View:
……而ReactiveCocoa
绑定了View
和ViewModel
。
理论讲得差不多了,我们可以开始新的历程了。
启动项目结构
可以从FlickrSearchStarterProject.zip中下载启动项目。我们使用Cocoapods
来管理第三方库,在对应目录下执行pod install
命令生成依赖库后,我们就可以打开生成的RWTFlickrSearch.xcworkspace
来运行我们的项目了,初始运行效果如下图:
我们行熟悉下工程的结构:
Model
和ViewModel
分组目前是空的,我们会慢慢往里面添加东西。View
分组包含以下几个类
RWTFlickSearchViewController
:程序的主屏幕,包含一个搜索输入域和一个GO
按钮。RWTRecentSearchItemTableViewCell
:用于在主页中显示搜索结果的table cell
RWTSearchResultsViewController
:搜索结果页,显示来自Flickr
的tableview
RWTSearchResultsTableViewCell
:渲染来自Flickr
的单个图片的table cell
。
现在来写我们的第一个ViewModel
吧。
第一个ViewModel
在ViewModel
分组中添加一个继承自NSObject
的新类RWTFlickrSearchViewModel
。然后在该类的头文件中,添加以下两行代码:
1
2
|
|
searchText
属性表示文本域中显示文本,title
属性表示导航条上的标题。
打开RWTFlickrSearchViewModel.m
文件添加以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
|
这段代码简单地设置了ViewModel
的初始状态。
接下来我们将连接ViewModel
到View
。记住View
保存了一个ViewModel
的引用。在这种情况下,添加一个给定ViewModel
的初始化方法来构造View
是很有必要的。打开RWTFlickrSearchViewController.h
,并导入ViewModel
头文件:
1
|
|
并添加以下初始化方法:
1
2
3
4
5
|
|
在RWTFlickrSearchViewController.m
中,在类的扩展中添加以下私有属性:
1
|
|
然后添加以下方法:
1
2
3
4
5
6
7
8
9
10
11
|
|
这就在view
中存储了一个到ViewModel
的引用。注意这是一个弱引用,这样View
引用了ViewModel
,但没有拥有它。
接下来在viewDidLoad
里面添加下面代码:
1
|
|
该方法的实现如下:
1
2
3
4
5
|
|
最后我们需要创建ViewModel
,并将其提供给View
。在RWTAppDelegate.m
中,添加以下头文件:
1
|
|
同时添加一个私有属性:
1
|
|
我们会发现这个类中已以有一个createInitialViewController
方法了,我们用以下代码来更新它:
1
2
3
4
|
|
这个方法创建了一个ViewModel
实例,然后构造并返回了View
。这个视图作程序导航控制器的初始视图。
运行后的状态如下:
这样我们就得到了第一个ViewModel
。不过仍然有许多东西要学的。你可能已经发现了我们还没有使用ReactiveCocoa
。到目前为止,用户在输入框上的输入操作不会影响到ViewModel
。
检测可用的搜索状态
现在,我们来看看如何用ReactiveCocoa
来绑定ViewModel
和View
,以将搜索输入框和按钮连接到ViewModel
。
在RWTFlickrSearchViewController.m
中,我们使用如下代码更新bindViewModel
方法。
1
2
3
4
5
|
|
在ReactiveCocoa
中,使用了分类将rac_textSignal
属性添加到UITextField
类中。它是一个信号,在文本域每次更新时会发送一个包含当前文本的next
事件。
RAC
是一个用于做绑定操作的宏,上面的代码会使用rac_textSignal
发出的next
信号来更新viewModel
的searchText
属性。
搜索按钮应该只有在用户输入有效时才可点击。为了方便起见,我们以输入字符大于3
时输入有效为准。在RWTFlickrSearchViewModel.m
中导入以下头文件。
1
|
|
然后更新初始化方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
|
运行程序并在输入框中输入一些字符,在控制台中我们可以看到以下输出:
1
2
3
|
|
上面的代码使用RACObserve
宏来从ViewModel
的searchText
属性创建一个信号。map
操作将文本转化为一个true
或false
值的流。
最后,distinctUntilChanges
确保信号只有在状态改变时才发出值。
到目前为止,我们可以看到ReactiveCocoa
被用于将绑定View
绑定到ViewModel
,确保了这两者是同步的。另进一步地,ViewModel
内部的ReactiveCocoa
代码用于观察自己的状态及执行其它操作。
这就是MVVM
模式的基本处理过程。ReactiveCocoa
通常用于绑定View
和ViewModel
,但在程序的其它层也非常有用。
添加搜索命令
本节将上面创建的validSearchSignal
来创建绑定到View
的操作。打开RWTFlickrSearchViewModel.h
并添加以下头文件
1
|
|
同时添加以下属性
1
|
|
RACCommand
是ReactiveCocoa
中用于表示UI
操作的一个类。它包含一个代表了UI
操作的结果的信号以及标识操作当前是否被执行的一个状态。
在RWTFlickrSearchViewModel.m
的initialize
方法的最后添加以下代码:
1
2
3
4
|
|
这创建了一个在validSearchSignal
发送true
时可用的命令。另外,需要在下面实现executeSearchSignal
方法,它提供了命令所执行的操作。
1
2
3
4
|
|
在这个方法中,我们执行一些业务逻辑操作,以作为命令执行的结果,并通过信号异步返回结果。
到目前为止,上述代码只提供了一个简单的实现:空信号会立即完成。delay
操作会将其所接收到的next
或complete
事件延迟两秒执行。
最后一步是将这个命令连接到View
中。打开RWTFlickrSearchViewController.m
并在bindViewModel
方法的结尾中添加以下代码:
1
|
|
rac_command
属性是UIButton
的ReactiveCocoa
分类中添加的属性。上面的代码确保点击按钮执行给定的命令,且按钮的可点击状态反应了命令的可用状态。
运行代码,输入一些字符并点击GO
,得到如下结果:
可以看到,当输入有效点击按钮时,按钮会置灰2
秒钟,当执行的信号完成时又可点击。我们可以看下控制台的输出,可以发现空信号会立即完成,而延迟操作会在2
秒后发出事件:
1
2
|
|
是不是很酷?
绑定、绑定还是绑定
RACCommand
监听了搜索按钮状态的更新,但处理activity indicator
的可见性则由我们负责。RACCommand
暴露了一个executing
属性,它是一个信号,发送true
或false
来标明命令开始和结束执行的时间。我们可以用这个来影响当前命令的状态。
在RWTFlickrSearchViewController.m
中的bindViewModel
方法结尾处添加以下代码:
1
|
|
这将UIApplication
的networkActivityIndicatorVisible
属性绑定到命令的executing
信号中。这确保了不管命令什么时候执行,状态栏中的网络activity indicator
都会显示。
接下来添加以下代码:
1
|
|
当命令执行时,应该隐藏加载indicator
。这可以通过not
操作来反转信号。
最后,添加以下代码:
1
2
3
|
|
这段代码确保命令执行时隐藏键盘。executionSignals
属性发送由命令每次执行时生成的信号。这个属性是信号的信号(见ReactiveCocoa Tutorial – The Definitive Introduction: Part ½)。当创建和发出一个新的命令执行信号时,隐藏键盘。
运行程序看看效果如何吧。
Model在哪?
到目前为止,我们已经有了一个清晰的View(RWTFlickrSearchViewController)
和ViewModel(RWTFlickrSearchViewModel
),但是Model
在哪呢?
答案很简单:没有!
当前的程序执行一个命令来响应用户点击搜索按钮的操作,但是实现不做任何值的处理。ViewModel
真正需要做的是使用当前的searchText
来搜索Flickr
,并返回一个匹配的列表。
我们应该可以直接在ViewModel
添加业务逻辑,但相信我,你不希望这么做。如果这是一个viewcontroller
,我打赌你一定会直接这么做。
ViewModel
暴露属性来表示UI
状态,它同样暴露命令来表示UI
操作(通常是方法)。ViewModel
负责管理基于用户交互的UI
状态的改变。然而它不负责实际执行这些交互产生的的业务逻辑,那是Model
的工作。
接下来,我们将在程序中添加Model
层。
在Model
分组中,添加RWTFlickrSearch
协议并提供以下实现
1
2
3
4
5
6
7
|
|
这个协议定义了Model
层的初始接口,并将搜索Flickr
的责任移出ViewModel
。
接下来在Model
分组中添加RWTFlickrSearchImpl
类,其继承自NSObject
,并实现了RWTFlickrSearch
协议,如下代码所示:
1
2
3
4
5
|
|
打开RWTFlickrSearchImpl.m
文件,提供以下实现:
1
2
3
4
5
6
7
8
|
|
看着是不是有点眼熟?没错,我们在上面的ViewModel
中有相同的实现。
接下来我们需要在ViewModel
层中使用Model
层。在ViewModel
分组中添加RWTViewModelServices
协议并如下实现:
1
2
3
4
5
|
|
这个协议定义了唯一的一个方法,以允许ViewModel
获取一个引用,以指向RWTFlickrSearch
协议的实现对象。
打开RWTFlickrSearchViewModel.h
并导入头文件
1
|
|
更新初始化方法并将RWTViewModelServices
作为一个参数:
1
|
|
在RWTFlickrSearchViewModel.m
中,添加类的分类并提供一个私有属性来维护一个到RWTViewModelServices
的引用:
1
2
3
|
|
在该文件下面,添加初始化方法的实现:
1
2
3
4
5
6
7
8
9
10
11
12
|
|
这只是简单的存储了services
的引用。
最后,更新executeSearchSignal
方法:
1
2
3
4
|
|
最后是连接Model
和ViewModel
。
在工程的根分组中,添加一个NSObject
的子类RWTViewModelServicesImpl
。打开RWTViewModelServicesImpl.h
并实现RWTViewModelServices
协议:
1
2
3
4
|
|
打开RWTViewModelServicesImpl.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
|
|
这个类简单创建了一个RWTFlickrSearchImpl
实例,用于Model
层搜索Flickr
服务,并将其提供给ViewModel
的请求。
最后,在RWTAppDelegate.m
中添加以下头文件
1
|
|
并添加一个新的私有属性
1
|
|
再更新createInitialViewController
方法:
1
2
3
4
5
|
|
运行程序,验证程序有没有按之前的方式来工作。当然,这不是最有趣的变化,不过,可以看看新代码的形状了。
Model
层暴露了一个ViewModel
层使用的’服务’。一个协议定义了这个服务的接口,提供了松散的组合。
我们可以使用这种方式来为单元测试提供一个类似的服务实现。程序现在有了正确的MVVM
结构,让我们小结一下:
- Model层暴露服务并负责提供程序的业务逻辑实现。
ViewModel
层表示程序的视图状态(view-state
)。同时响应用户交互及来自Model
层的事件,两者都受view-state
变化的影响。View
层很薄,只提供ViewModel
状态的显示及输出用户交互事件。
搜索Flickr
我们继续来完成Flickr的搜索实现,事情变得越来越有趣了。
首先我们创建表示搜索结果的模型对象。在Model
分组中,添加RWTFlickrPhoto
类,并为其添加三个属性。
1
2
3
4
5
6
7
|
|
这个模型对象表示由Flickr
搜索API
返回一个图片。
打开RWTFlickrPhoto.m
,并添加以下描述方法的实现:
1
2
3
4
|
|
接下来,新建一个新的模型对象类RWTFlickrSearchResults
,并添加以下属性:
1
2
3
4
5
6
7
|
|
这个类表示由Flickr
搜索返回的照片集合。
是时候实现搜索Flickr
了。打开RWTFlickrSearchImpl.m
并导入以下头文件:
1
2
3
4
|
|
然后添加以下类扩展:
1
2
3
4
5
6
|
|
这个类实现了OFFlickrAPIRequestDelegate
协议,并添加了两个私有属性。我们会很快看到如何使用这些值。
继续添加代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
|
这段代码创建了一个Flickr
的上下文,用于存储ObjectiveFlickr
请求的数据。
当前Model
层服务类提供的API
有一个单独的方法,用于查找基于文本搜索字符的图片。不过我们一会会添加更多的方法。
在RWTFlickrSearchImpl.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
|
|
这个方法需要传入请求方法及请求参数,然后使用block
参数来转换响应对象。我们重点看一下第4
步:
1
2
3
4
5
6
7
8
9
10
11
12
|
|
rac_signalForSelector:fromProtocol:
方法创建了successSignal
,同样也在代理方法的调用中创建了信号。
代理方法每次调用时,发出的next
事件会附带包含方法参数的RACTuple
。
实现Flickr
搜索的最后一步如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
|
上面的方法使用signalFromAPIMethod:arguments:transform:
方法。flickr.photos.search
方法提供的字典来搜索照片。
传递给transform
参数的block
简单地将NSDictionary
响应转化为一个等价的模型对象,让它在ViewModel
中更容易使用。
最后一步是打开RWTFlickrSearchViewModel.m
方法,然后更新搜索信号来记录日志:
1
2
3
4
5
|
|
编译,运行并输入一些字符后可在控制台看到以下日志:
1
2
3
4
5
6
7
8
|
|
这样我们MVVM
指南的第一部分就差不多结束了,但在结束之前,让我们先看看内存问题吧。
内存管理
正如在ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2中所讲的一样,我们在block
中使用了self
,这可能会导致循环引用的问题。而为了避免此问题,我们需要使用@weakify
和@strongify
宏来打破这种循环引用。
不过看看signalFromAPIMethod:arguments:transform:
方法,你可能会迷惑为什么没有使用这两个宏来引用self
?这是因为block
是作为createSignal:
方法的一个参数,它不会在self
和block
之间建立一个强引用关系。迷茫了吧?不相信的话只需要测试一样这段代码有没有内存泄露就行。当然这时候就得用Instruments
了,自己去看吧。哈哈。
何去何从?
例子工程的完整代码可以在这里下载。在下一部分中,我们将看看如何从ViewModel
中初始化一个视图控制器并实现更多的Flickr
请求操作。