编程归结起来就是让代码以一种聪明的方式与其他代码进行通信。 软件模式是约束程序员使编程更容易的方法。
MVVM , 大致上有以下几种限制:
- Models 不能访问到其它(和 MVC 相同)。
- ViewModels 只能访问 models 。
- View Controllers 不能直接访问 models; 只能与 ViewModel 和 view 交互。
- View 仅与 View Controller,通知它们交互事件。(和 MVC 相同)。
这与MVC没有什么不同 - 主要区别在于。
- 多了一个
ViewModel
类 - view controller 不再有权访问 Model。
另外, MVVM 在 iOS 上确定了 View 和 View Contrller 之间一对一的关系。 我倾向于将它们看作是恰好分裂成一个.swift文件和一个Storyboard的一个实体。
ViewModel 的工作是处理所有表现逻辑。 如果模型包含 NSDate
,则格式化该日期的NSDateFormatter
将存在于 ViewModel 中。
ViewModel 无权访问用户接口,所以你不应该 import UIKit
在一个 ViewModel 中。
通常,ViewController 会以某种方式观察 ViewModel,以了解何时需要显示新数据。这可以通过 KVO 或 FRP 完成。
MVVM和MVC有一个共同的弱点:都没有定义应用程序的网络逻辑模块应该放在哪里。 我现在已经把它放在 ViewModel 中,但我打算很快将它分离出它自己的对象。该对象将由 ViewModel 拥有。
那么让我们来谈谈我们遇到的一些挑战。
用户界面结构
我们靠近屏幕顶部的部分用户界面由 段控制器(segment control)组成。 当前选中的 segment 确定集合视图 cells 的排序顺序以及集合视图的布局。 我们之前定义了一个枚举来存储对应于每个分段控件的标题和排序顺序; 枚举案例的顺序意味着UI中控件的顺序。
enum SwitchValues: Int {
case Grid = 0
case LeastBids
case MostBids
case HighestCurrentBid
case LowestCurrentBid
case Alphabetical
}
那么 枚举应该在 MVVM 中的什么位置呢? 由于对模型排序的逻辑,按钮标题和按钮的顺序都是属于演示逻辑的一部分,因此枚举看起来像属于 ViewModel 。
但是,决定集合视图使用哪种布局是有些细微差别的。 布局不会影响我们向用户展示的数据或与用户交互的数据;它只影响信息呈现的视觉效果。这表明决定布局的逻辑可能属于 view controller。
我的解决方案是将枚举放入 ViewModel 中,并让 ViewModel 暴露一个信号,以定义应使用两种布局中的哪一种。根据选定的 segment 索引,ViewModel 决定应使用哪个布局并将该值发送到信号上。 view controller 负责将该信号映射到配置的布局,然后在集合视图上设置该布局。
// Respond to changes in layout, driven by switch selection.
viewModel.gridSelectedSignal.map { [weak self] (gridSelected) -> AnyObject! in
switch gridSelected as? Bool {
case .Some(true):
return ListingsViewController.masonryLayout()
default:
return ListingsViewController.tableLayout(CGRectGetWidth(self?.switchView.frame ?? CGRectZero))
}
}.subscribeNext { [weak self] layout -> Void in
self?.collectionView.setCollectionViewLayout(layout as! UICollectionViewLayout, animated: false)
}
view controler 也使用这个信号来定义应该重用那个cell。
// Map switch selection to cell reuse identifier.
RAC(self, "cellIdentifier") <~ viewModel.gridSelectedSignal.map { gridSelected -> AnyObject! in
switch gridSelected as? Bool {
case .Some(true):
return MasonryCellIdentifier
default:
return TableCellIdentifier
}
}
构建视图模型
iOS 开发者关于 MVVM 和 FRP 最普遍的问题是探讨 ViewModel 如何将数据暴露给 view controller。 需要向 view controller 通知有关对底层 Model 的更改 ,但是我们使用什么机制来执行这个操作? 这里有两个选择:
- ViewModel 上使用(动态)属性,可以使用KVO观察(或使用FRP封装在信号/序列中)。
- 使用 signals/sequences/futures 作为视图模型的属性,可以使用它们相应的异步框架。
第一个方法很吸引人,因为它使 view controller 可以选择如何观察属性。
不过,我建议不要这样做; Swift没有对KVO进行类型检查(你需要从 AnyObject!
中投入很多)。
第二种方法是我比较喜欢的,它看起来更像是 “Swift”的风格。 当我们离开 RAC的Objective-C 接口时,ViewModel 将用基于 Swift 泛型的序列替换它的RACSignal属性,这将提供编译时类型检查。
在 ViewModel 中定义这些 信号可能比较棘手,Swift 初始化器在分配属性时有严格的规则
strict rules。
信号需要访问 ViewModel 的内部状态,因此它们在调用 super.init()
后创建。
但是,我们不能直到所有的属性都被赋值,包括信号属性,才调用super.init()
。
这是个典型的鸡生蛋,蛋生鸡
的问题。
我采取了简单的方法,并使用隐式解包的可选项,用 var
定义,可以在调用 super.init()
之后分配给它。这不是一个完美的解决方案。我们可以使用 lazy var
属性,或者只使用计算属性。当我们离开RAC 2
的Objective-C API 时,我希望探索其他选择。
处理用户交互
我遇到的下一个问题是根据用户交互来呈现细节。 用户点击一个按钮,该按钮在视图控制器中处理,该视图显示详细信息。但是,view controller 不应该访问 model ,那么如何配置细节来呈现它们呢.
我的解决方案利用了Swift函数和闭包的互换性。首先,我在视图模型中定义了一个闭包类型。
typealias ShowDetailsClosure = (SaleArtwork) -> Void
然后我给 view model 添加一个属性和相应的参数给初始化器。
class ListingsViewModel {
let showDetails: ShowDetailsClosure
init(...
showDetails: ShowDetailsClosure,
...
接下来我需要调用闭包了。我在 ViewModel 上定义了一个 view controller 可以调用的函数,并传入必要的上下文来决定应该呈现哪个模型的细节。这个上下文只是一个索引路径。
func showDetailsForSaleArtworkAtIndexPath(indexPath: NSIndexPath) {
showDetails(sortedSaleArtworks[indexPath.item])
}
Nice!现在当用户选择一个单元格时,我们可以用户选择的索引路径调用 ViewModel 上的这个函数。ViewModel 决定使用哪个 Model ,并调用闭包。
拼图的最后一部分是创建 ViewModel 的聪明之处。
我们需要传递一个闭包给它的初始化方法,一个显示 Model 的细节。
我在视图控制器上定义了一个与 ShowDetailsClosure
签名匹配的函数。
func showDetailsForSaleArtwork(saleArtwork: SaleArtwork) {
performSegueWithIdentifier(SegueIdentifier.ShowSaleArtworkDetails.rawValue, sender: saleArtwork)
}
然后使用延迟加载(下面讨论)调用 ViewModel 的构造器。我将上述函数的引用作为闭包参数传入。
lazy var viewModel: ListingsViewModelType = {
return ListingsViewModel(..., showDetails: self.showDetailsForSaleArtwork, ...)
}()
- 用户点击一个单元格。
- view controller 上使用选定的索引路径作为参数的 callback 被调用。
- view controller 告诉 ViewModel 哪一个 index path 被选择。
- ViewModel 查找相应的 Model 。
- ViewModel 在初始化时调用赋给它的
showDetails
闭包。 showDetails
“closure” 会与该 Model 执行一个循环。
这不是一个理想的解决方案,因为它仍然将模型暴露给 view controller(即使在非常严格的条件下),但这是一个合理的折中方案。随着我们继续使用更多的 ViewModel ,我很想看看这个解决方案如何扩展。
测试
之前我在 view controller 中提到了 lazy closure
属性。
这是一个让 view controller 通过使用自引用来定制视图模型的技巧。
lazy var viewModel: ListingsViewModelType = {
return ListingsViewModel(
selectedIndexSignal: self.switchView.selectedIndexSignal,
showDetails: self.showDetailsForSaleArtwork,
presentModal: self.presentModalForSaleArtwork
)
}()
view controller 首先在 viewDidLoad()
中访问 viewModel
属性,我们可以在此之前的任何时候通过测试双倍来替换属性。
view controller 使用 snapshots 进行测试,以验证用户界面没有被意外更改。测试很简单:
- 创建一个 view controller 去测试。
- 为每一个 test 创建一个
stubbed view model
- 在调用
viewDidLoad()
之前为 view controller 提供stubbed view model
。 - 验证视图控制器正确呈现。
在编写测试时,我发现很难对现有的视图模型进行子类化(用于存根目的)。由于 ViewModel 的初始化器具有副作用(启动重复性网络请求),因此我无法调用 super.init()
。相反,我做了一个ListingsViewModelType
协议。view controller 只通过这个协议与视图模型交互 - 它没有参考类本身。
现在创建 stubbed view model
就像符合协议一样简单。
现在 ViewModel 和 view controller 是独立的对象,我们不再需要在 view controller 中测试表现逻辑了。ViewModel 现在负责处理网络请求,数据处理等 - 现在所有测试均独立于用户界面进行测试。
在我看来,MVVM的主要优点归结为以下几点:
- 从用户界面分离视图模型使测试演示逻辑变得更加容易。
- 将视图控制器与表示逻辑分开可以更容易地测试用户界面。