这周我决定做一个关于彻底解耦合的应用架构的实验。我想探究的主题是:
“如果所有的应用内通讯都通过一个事件流来完成会怎么样?”
我构造了一个待办事项应用,因为这是我一时激动下所能想到的最原始微型的项目。我会大概地说一下应用结构背后的想法,展示具体实现中的一些代码片段,然后给出几个有关利弊的结论。
整个项目在Github上。作为参考,这篇文章基于0.1标签下的代码。
应用演示
架构总述
为了有一个名字来关联,我把这个架构叫做EventMVVM。它使用少量的MVVM(Model-View-ViewModel)架构。虽然它使用ReactiveCocoa作为事件流的管道,但是我在后面会说到许多工具也可以代替。它是用Swift写的,这有点重要,由于Swift的枚举关联值的特性以及容易定义和使用值类型。
我能够解释架构的最好方法是命名和列举参与者,定义它们,再列出规则。
Event
EventsSignal & EventsObserver
Server
Model
ViewModel
View
Event
一个Event是一个消息的构建代码块。定义为枚举,每种情况下都有多达一个相关联的值(注意:这与ReactiveCocoa的Event不同)。你可以把它看作一个强类型NSNotification。每一种情况约定以Request或Response开始。下面是几个例子。
/// Event.swift enum Event { // Model case RequestReadTodos case ResponseTodos(Result) case RequestWriteTodo(Todo) // ... // ViewModel case RequestTodoViewModels case ResponseTodoViewModels(Result) case RequestDeleteTodoViewModel(TodoViewModel) // ... }
Model和ViewModel"类型"的事件都包括在Event枚举中(注解:1)。
RequestReadTodos没有参数,因为这个应用不需要预先筛选或排序(注解:2)。
所有枚举项的关联值都是值类型,这对于确保系统的健全是很重要的。同一个Event可能被任何一个的线程上的许多对象接收到。
EventsSignal & EventsObserver
eventsSignal和eventsObserver将是我们共享的事件流。我们将把它们注入进类里,这些类将能够附加观察者块到eventsSignal,并发送新的Event到eventsObserver。
/// AppContext.swift class AppContext { let (eventsSignal, eventsObserver) = Signal.pipe() // ... }
我们把这个元组放在一个叫做AppContext的类里。它们使用一个ReactiveCocoa的Signal和一对通过.pipe()创建的观察者来实现。这里有一些实现细节,稍后我们将讨论。
简而言之语法如下:
// 在流中创建新的观察者 eventsSignal.observeNext { event in print(event) } // 在流中发送一个新的事件 eventsObserver.sendNext(Event.RequestTodoViewModels)
Server
Server是一个长久存活的类,它包含观察者并能发送消息。在我们的示例应用中,有两个Server--ViewModelServerhe和ModelServer。这些都是由AppDelegate创建并持有的。从名字你可能会认为ViewModelServer设置了我们应用的ViewModel相关的职责的观察者。例如,它负责为ViewModels接收请求并满足它们,不是改变事件里的ViewModel,就是发送一个新的事件请求它需要的数据(注解:4,注解:5)。
Server代表我们应用里的"智能"对象。它们是协调器。它们创建和操纵我们的ViewModel、Model值类型,并与其他server通过创建Event和附加在它们之上的值进行交流。
Model
一个Model是一个包含基本数据的值类型。在标准MVVM里,它不应该包含任何一个针对底层数据库的东西。
在示例应用中,我用扩展来把Todo model对象序列化成TodoObject用于我们的Realm数据库。
模型层只知道自己。它不知道ViewModel和View。
ViewModel
一个ViewModel是一个值类型,它包含在View层里并且是一个可以直接使用的属性。例如,UILabel显示的文本就该是一个String。ViewModel在init函数里接收和存储一个Model对象,并将之转变为View层可使用的。一个ViewModel可使其他ViewModels能够被子视图等使用。
按这种解释(注解:6),ViewModels是完全惰性的,并且不能异步操作和向事件流发送消息。这确保它们可以安全地在线程间传递。
ViewModel不知道View层。它们可以操作其他ViewModel和Model。
View
我们的View层是UIKit,包括UIViewControllers和UIViews及其子类。虽然我的初衷是探索让View层也通过事件流发送自己的事件,但是在这个简单的实现里却是不必要的,并且可能是最使人分心的(注解:7)。
View层只允许与View和ViewModel层进行交互。这意味着它对Model一无所知。
实现
现在我们对所有的组件系统已经有了一个基本的了解,让我们深入进代码,看看它是如何工作的。
The Spec(软件规格说明书)
我们的待办列表的特点是什么?这类似于我们的Event。(对我来说,这是最激动人心的部分。)Event.swift:
RequestTodoViewModels:我们希望能够看到所有待办事项按预设顺序排序,并过滤掉已删除的条目。
RequestToggleCompleteTodoViewModel:我们需要能够在列表视图把待办事项标记为完成。
RequestDeleteTodoViewModel:我们也需要能够将在列表视图删除它们。
RequestNewTodoDetailViewModel:我们需要能够创建新的待办事项。
RequestTodoDetailViewModel:我们需要能够漂亮地查看/编辑一个待办事项。
RequestUpdateDetailViewModel:我们需要能够提交我们的更改。
这些都是我们的请求。它们将所有来自View层。因为这些只是我们广播的事件/消息,不一定有直接的一对一的响应。这对我们同时有积极和消极的后果。
影响之一是我们需要更少类的响应事件。ResponseTodoViewModels和RequestTodoViewModels会有一对一的响应,但RequestToggleCompleteTodoViewModel、RequestDeleteTodoViewModel和RequestUpdateDetailViewModel都会由ResponseTodoViewModel响应。这简化了我们的view的代码,也保证了一个view可以获得更新并传给被一个不同的view改变的ViewModel,我们也不需要额外做什么。
RequestNewTodoDetailViewModel和RequestTodoDetailViewModel(又名新建和编辑)将由ResponseTodoDetailViewModel响应。
有趣的是,RequestUpdateDetailViewModel必须由ResponseUpdateDetailViewModel和ResponseTodoViewModel响应,因为它们的底层待办Model改变了。稍后我们将详细探讨这个场景。
为了满足这些来自View层的请求,ViewModelServer需要有自己的对Model数据的请求。这些都是一对一的请求-响应。
RequestReadTodos -> ResponseTodos
RequestWriteTodo -> ResponseTodo
我们在待办Model里通过设置一个flag来实现删除。这种技术明显使它能更容易地协调我们的应用层之间变化。
以下是一个很长的图,有关这四个主要对象如何发送和观察事件。
系统设置
/// AppDelegate.swift class AppDelegate: UIResponder, UIApplicationDelegate { var appContext: AppContext! var modelServer: ModelServer! var viewModelServer: ViewModelServer! // ... func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool { self.appContext = AppContext() self.modelServer = ModelServer(configuration: Realm.Configuration.defaultConfiguration, appContext: appContext) self.viewModelServer = ViewModelServer(appContext: appContext) let todoListViewModel = TodoListViewModel() let todoListViewController = TodoListViewController(viewModel: todoListViewModel, appContext: appContext) let navigationController = UINavigationController(rootViewController: todoListViewController) // ... } }
正如之前所说,AppContext包含元组eventSignal和eventObserver。我们会将它注入到我们所有的其他高层组件,并允许它们进行交流。
我们必须保留ModelServer和ViewModelServer,因为他们没有view层和互相的直接引用(注解:8)。
记得TodoListViewModel只是一个惰性结构。虽然对于这个简单的应用,我们可以让TodoListViewController创建自己的ViewModel,但是注入是更好的实践途径。你可以很容易地想象把"列表的列表"功能添加到应用。在这种情况下我们(可能?)不需要改变我们的任何接口。
View层:列表
实际上我们的系统边界很清楚。View层将处理所有ViewModel的请求并观察所有ViewModel的响应。
我们这个部分的主题是TodoListViewController。作为参考:
// TodoListViewController.swift final class TodoListViewController: UITableViewController { let appContext: AppContext var viewModel: TodoListViewModel // ... }
我们会发送我们的第一个事件去请求TodoViewModels来填视图出现时的列表。
// TodoListViewController.swift override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) appContext.eventsObserver.sendNext(Event.RequestTodoViewModels) }
接着我们需要设置一个观察者来响应事件。View层的观察者们总是会放置在viewDidLoad里,同时它的生命周期和UIViewController本身的一样。
override func viewDidLoad() { // ... appContext.eventsSignal // ... .observeNext { _ in // ... } }
剖析一个观察者
现在我们需要深入了解语法。我们所有观察者的结构非常相似:
生命周期
过滤
拆箱
映射
错误处理
输出
对于View层,输出的形式通常是副作用(如更新ViewModel和刷新列表)。对于其他Server,输出通常是发送另一个Event。
让我们看看Event.ResponseTodoViewModels。
appContext.eventsSignal .takeUntilNil { [weak self] in self } // #1 .map { event -> Result? in // #2 if case let .ResponseTodoViewModels(result) = event { return result } return nil } .ignoreNil() // #2 .promoteErrors(NSError) // #3 .attemptMap { $0 } // #3 .observeOn(UIScheduler()) // #4 .flatMapError { [unowned self] error -> SignalProducer in // #3 self.presentError(error) return .empty } .observeNext { [unowned self] todoViewModels in // #5 let change = self.viewModel.incorporateTodoViewModels(todoViewModels) switch change { case .Reload: self.tableView.reloadData() case .NoOp: break } }
#1:这是一个ReactiveCocoa实现细节,它(相当于注解9)把观察者生命周期限制在self的生命周期里。换句话说,当TodoListViewController消失时,停止处理这个观察者。
#2:这里是我们在必要时从事件中过滤和拆包的地方。记住,我们在观察整个应用发送的Event的消防带。我们只想要Event.ResponseTodoViewModels,并且如果得到,我们希望它的值被传递。对于其他所有到达的事件,它们会被映射到nil然后被ignoreNil()运算符丢弃。
#3:这是我们的错误处理。promoteErrors是一个ReactiveCocoa的实现细节,它将一个无法报错的信号转化成一个能发送错误到指定类型的信号。然后attemptMap从Result对象中拆包,并允许我们使用ReactiveCocoa内建的错误处理。flatMapError就是我们错误的副作用,在这种情况下,错误以警报形式呈现。相反,如果我们用observeError,我们的观察者将在第一个错误事件后被处理掉,这不是我们想要的(注解10)。
#4:Event可以被eventsSignal交付到任何线程。因此,对于任何线程的关键工作我们需要指定目标调度器。在这种情况下,我们的关键工作是UI相关,因此我们使用UIScheduler。注意,只有在observeOn之后的操作能够在UIScheduler上执行(注解11)。
#5:最后,我们有一个来自正确的事件的非错值。我们将使用这个完全取代TodoListViewModel并且有条件地刷新列表,如果列表有任何真正的改变。
记住,这个例子实际上是复杂应用的一种,因为有错误处理和多个未展开的阶段。
更多操作
我们将使用UITableViewRowActionde API来发送事件为待办事项标志完成或删除它们。
// TodoListViewController.swift override func tableView(tableView: UITableView, editActionsForRowAtIndexPath indexPath: NSIndexPath) -> [UITableViewRowAction]? { let todoViewModel = viewModel.viewModelAtIndexPath(indexPath) let toggleCompleteAction = UITableViewRowAction(style: UITableViewRowActionStyle.Normal, title: todoViewModel.completeActionTitle) { [unowned self] (action, path) -> Void in self.appContext.eventsObserver.sendNext(Event.RequestToggleCompleteTodoViewModel(todoViewModel)) } // ... return [deleteAction, toggleCompleteAction] }
这些Event只是修改ViewModel。View层只关心TodoViewModel粒度级别的变化。
我们想要观察ResponseTodoViewModel,这使我们的视图总是显示最准确的待办事项。我们也想有动画效果,因为那样好看。
// TodoListViewController.swift - viewDidLoad() appContext.eventsSignal // Event.ResponseTodoViewModel // ... .observeNext { [unowned self] todoViewModel in let change = self.viewModel.incorporateTodoViewModel(todoViewModel) switch change { case let .Insert(indexPath): self.tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Top) case let .Delete(indexPath): self.tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Left) case let .Reload(indexPath): self.tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .Fade) case .NoOp: break } }
这些都是基本的View层。让我们再看看ViewModelServer,看看我们如何响应这些请求Event和发出新的Event。
ViewModel:列表
ViewModelServer是一个大的配置观察者的init函数。
// ViewModelServer.swift final class ViewModelServer { init(appContext: AppContext) { // ... all observers go here } }
Event.RequestTodoViewModels
ViewModelServer监听ViewModel的请求并发送ViewModel响应Event。
.RequestTodoViewModels相当简单。它只是从model层创建一个相对应的请求(注解12)。
appContext.eventsSignal // ... Event.RequestTodoViewModels .map { _ in Event.RequestReadTodos } .observeOn(appContext.scheduler) .observe(appContext.eventsObserver)
我们把这个事件发回eventsObserver来派遣我们的新Event。注意我们必须派遣这个事件在一个特定的调度器里,否侧会死锁。有关ReactiveCocoa的实现细节超出了本文的范围,所以暂时我们只要注意必须添加任何观察者到新事件的映射。
Event.ResponseTodos
现在我们可以得到一个我们刚刚发出的Model事件的响应。
appContext.eventsSignal // ... Event.ResponseTodos .map { result -> Result in return result .map { todos in todos.map { (todo: Todo) -> TodoViewModel in TodoViewModel(todo: todo) } } .mapError { $0 } // placeholder for error mapping } .map { Event.ResponseTodoViewModels($0) } .observeOn(appContext.scheduler) .observe(appContext.eventsObserver)
我们把Result<[todo], nserror="">映射到Result<[todoviewmodel], nserror="">,并返回result作为一个新的Event。有一个占位符,在我们可以将Model层的错误映射到一个更适合展示给用户的地方(注解13)。
其他ViewModel事件
在view层,我们看到两个事件,RequestToggleCompleteTodoViewModel和RequestDeleteTodoViewModel,可能被发送来动态地改变个别ViewModels。
用于删除的map块:
.map { todoViewModel -> Event in var todo = todoViewModel.todo todo.deleted = true return Event.RequestWriteTodo(todo) }
用于标记已完成的map块:
.map { todoViewModel -> Event in var todo = todoViewModel.todo todo.completedAt = todo.complete ? nil : NSDate() return Event.RequestWriteTodo(todo) }
简单的转换,然后我们发出一个消息。
这两个事件将在Event.ResponseTodo接收响应。
.map { result -> Result in return result.map { todo in TodoViewModel(todo: todo) } } .map { Event.ResponseTodoViewModel($0) }
其他要点
我不会深究其他事件。我只会提一些其他有趣的要点。
TodoDetailViewModel
TodoDetailViewController接受一个TodoDetailViewModel来允许用户去改变其属性。当完成按钮被点击,TodoDetailViewController将用它自己的TodoDetailViewModel发送一个请求到ViewModelServer。ViewModelServer会验证所有的新参数然后回复一个响应。响应事件Event.ResponseUpdateDetailViewModel很有趣,因为它将由三个不同对象的观察。
TodoDetailViewController将观察它的错误。如果有错误的验证,它将在当前上下文前展现错误。
TodoListViewController将观察非错值,作为一个用户结束编辑ViewModel的信号去解释它,然后它应该弹回TodoDetailViewController。
ViewModelServer将观察其本身将发送的消息,因为现在它必须立即创建一个更新待办事项Model并发送一个写待办事项的Event。它的响应会通过正常的Event流传回并由TodoListViewController透明地更新。
ResponseUpdateDetailViewModel
我有点想把一般化的CRUD如何进行新建和编辑操作集于一个接口。以前保存过的和未保存的待办事项都可以同样处理。验证被看作是异步的,因此这可以很容易地被当作一个在服务器端的操作。
加载
我没有实现任何加载指示器,只因这是小事。ViewController会观察它自己的Request事件并打开加载指示器作为一个副作用。然后它将关闭加载指示器当作Response事件的副作用。
唯一标识符
有一件事你可能会注意到,在代码库中每一个值类型必须equatable。由于请求和响应不直接配对,有一个惟一标识符是能够过滤和操作响应的关键。实际上在起作用的有两个相等的概念。首先是一般的相等,比如"这两个model有所有参数的值都相同吗?"。第二个是身份的相等,比如"这两个model表示的是相同的底层资源吗?"(即lhs.id == rhs.id)。身份的相等在操作一个已经被更新并且你想替换它的model时,是有用的。
测试
我认为测试明显是在ViewModelServer和ModelServer层。这些Servers注册的观察者在本质上是纯函数,它们收到一个单独的事件并派遣一个单独的事件。一个单元测试示例:
// todostreamTests.swift // ... func testRequestToggleCompleteTodoViewModel() { viewModelServer = ViewModelServer(appContext: appContext) let todo = Todo() XCTAssert(todo.complete == false) let todoViewModel = TodoViewModel(todo: todo) let event = Event.RequestToggleCompleteTodoViewModel(todoViewModel) let expectation = expectationWithDescription("") appContext.eventsSignal.observeNext { (e: todostream.Event) in if case let todostream.Event.RequestWriteTodo(model) = e { XCTAssert(model.complete == true) expectation.fulfill() } } appContext.eventsObserver.sendNext(event) waitForExpectationsWithTimeout(1.0, handler: nil) }
上面的部分测试了一个ViewModelServer里的观察者,并在ViewModelServer和ModelServer之间的边界等待获得结果Event。
集成测试也不是不可能。以下是一个相同事件的集成测试版本,它不再等待在View和ViewModelServer层之间的边界:
// todostreamTests.swift // ... func testIntegrationRequestToggleCompleteTodoViewModel() { viewModelServer = ViewModelServer(appContext: appContext) modelServer = ModelServer(configuration: Realm.Configuration.defaultConfiguration, appContext: appContext) let todo = Todo() XCTAssert(todo.complete == false) let todoViewModel = TodoViewModel(todo: todo) let event = Event.RequestToggleCompleteTodoViewModel(todoViewModel) let expectation = expectationWithDescription("") appContext.eventsSignal.observeNext { (e: todostream.Event) in if case let todostream.Event.ResponseTodoViewModel(result) = e { let model = result.value!.todo XCTAssert(model.id == todo.id) XCTAssert(model.complete == true) expectation.fulfill() } } appContext.eventsObserver.sendNext(event) waitForExpectationsWithTimeout(1.0, handler: nil) }
在这种情况下,后台有两个其他事件同时发送,但是我们只等待最后一个。
这两个server都处在表层,只对EventSignal有依赖性。
回顾
我们已经看了一个非常基本的应用的一些实现,现在让我们退一步,看看我们一路上发现的利弊。
利 一些在其他架构很难的事情变容易了!:D
弊 一些在其他架构很简单的事情变难了!:(
利 实际上这种代码风格很有趣。
弊 可能有目前未知的性能影响,考虑到存在很多观察者,每个又接收大量的必须过滤的事件。
利 线程似乎很安全。
弊 仍然有很多没有解决的问题。如何处理图像加载?身份验证?特定顺序的多步操作?列表重排序?更复杂的视图改变类型?其他异步API封装?问题是无止境的。一个半生不熟的待办事项应用几乎没有扩大系统复杂性的范围。
利 所有代码(除了UIKit)风格都很相似且非常实用。
弊 所有的事件是全局的(对于系统来说),因此在系统规模和复杂性上增长后,更多的意想不到的后果可能发生。
弊 在观察者声明里有相当数量的同样格式的陈词滥调。
利 更容易理清对象的所有者和生命周期。
弊 使用Result来异常处理并不适合。我直觉有另一个能做得更好的办法,我需要研究(注解14)。
利 测试可以说是一个相当无痛的过程。
利 使得"重放"用户的整个会议过程成为可能,通过管道传输,从eventsSignal的序列化保存的输出到eventsObserver的一个新的会话。
利 分析会变得很容易,设置作为一个单独的Server-type对象,当它们被放到流中可以监听Event并转换然后在必要时POST到服务器。
库
我完成了构建这个待办事项应用后,我意识到ReactiveCocoa不一定是最好的实现EventMVVM的工具。它的很多特性我并没用到,我有一些怪癖,它旨在被用而我却不使用它(注解15)。
我决定去试试我可不可以写我自己的简单的为EventMVVM量身定做的库来实现EventMVVM。我花了一天的事件来与这个类型系统搏斗,只因为我有一个最先的念头--我要继续试着测试。它只有大约100行代码。不幸的是,它不能自动化所有我想要的东西,观察的过程仍有缺点。我会找时间写一些关于这个库的事。
你可以在Github看到我的进展。
总结
探索EventMVVM架构很有趣。我可能会继续探索它,作为兼职。我绝对不会建议用它来实现任何重要的东西。
如果你有任何关于EventMVVM的想法,请通过Twitter让我知道。我确定这种风格已经有个名称(也许是观察者模式?)。
只要添加这个观察者到AppDelegate,就能获取到系统中传递的每个Event的日志,该有多酷?
appContext.eventsSignal.observeNext { print($0) }
1.未来EventMVVM的扩展可以是ModelEvent或ViewModel事件,并且每一个有输入流。这样,一个View对象只会看到ViewModel流,而ViewModelServers(稍后我会介绍它)会看到ViewModel和Model流。
2.一个更复杂的应用,将需要一个ReadTodosRequest结构来封装一个分类描述符和谓词。或者更好的是,一个更彻底的TodoListViewModel包含所有这些信息。
3.事实证明,在响应本身嵌入一个可选的错误参数会更好。否则,就无法知道这个错误与哪个请求相关。我们暂时不考虑这个问题。
4.你当然可以把ViewModelServer和ModelServer合并到一个Server(或把一切都放在AppDelegate),但MVVM是帮助我们分离我们关心的事。
5.我最大的一个未解决的问题是如果Server对象相互大量创建该怎么办。任何像样的应用,一个ViewModelServer的一个流里有成百上千的观察者是很笨拙的。它们也可能使用了太多的资源。如果我们把每个ViewModel类型分离出ViewModelServers,那么主ViewModelServer怎么知道如何管理它们的生命周期?
6.我大多数其他使用MVVM的项目,有些ViewModel是类并且挑起大部分关于异步工作和应用内数据流组织的重担,有些则是惰性的值类型。这背后的原因是通过分离逻辑来让ViewControllers有点"迟钝"。
7.这些类型的事件的例子有ViewControllerDidBecomeActive(UIViewController)和ButtonWasTapped(UIButton)。正如你所看到的,这将打破我们只有通过流发送值类型的假设,并且更需要深思熟虑。当我在工作中把它与其他框架一起使用时发现,你可以跳过很多的障碍来避免UIKit期望你去做的方式,虽然通常你想出来另一种方式会更糟。
8.在"经典"MVVM里,View将拥有ViewModel的,ViewModel将拥有Model/Controller。
9.准确来说,观察者将被触发转换到完成状态,当任何事件被发送并且自身不再是活动的。就我们的目的而言,这不该是一件大事。虽然还有其他的办法来解决这个问题,但是它们需要更多语法上的要求。
10.回想起来,让Result一路穿过observeNext并在同一代码块内处理成功和错误的情况,可能更清晰。
11.Scheduler是ReactiveCocoa原生的。它很巧妙。
12.如果你不熟悉MVVM,您可能想知道为什么View层不直接发出Event.RequestReadTodos而是通过ViewModelServer传送Event.RequestTodoViewModels。一个受欢迎的间接层是让我们的View层不知道所有与Model层相关的事务。它引入了对自己和项目中的其它人的可预测性,所有类型的对象和值遵守同一套规则--哪些它们可以做,哪些对象它们可以交互。这显然是一般化的,而且感觉它存在项目的早期,但在大型项目我很少发现它会被毫无根据的优化。
13.不包括Model层的枚举类型错误是因为懒。我们设立的转换管道已经能够很容易让我们对于正确的上下文正确地表示。
14.提示:这是添加一个error参数到所有model和ViewModel。
15.它可以用NSNotificationCenter实现(并非我有试过)。也可以用其他的Reactive Swift库。
本文仅用于学习和交流目的,转载请注明文章译者、作者、出处以及本文链接。
感谢博文视点对本期翻译活动的支持