QIWI的我们如何看待MVVM中View和ViewModel之间的通用交互方式

最初,整个项目是用Objective-C编写的,并使用了ReactiveCocoa 2.0版


View和ViewModel之间的交互是通过绑定视图模型的属性来进行的,除非调试此类代码非常困难,否则一切都会很好。 都是由于堆栈跟踪中缺少键入和稀饭:(


现在是时候使用Swift了。 首先,我们决定尝试完全不进行任何反应。 View在ViewModel上显式调用的方法,ViewModel使用委托报告其更改:


protocol ViewModelDelegate { func didUpdateTitle(newTitle: String) } class View: UIView, ViewModelDelegate { var viewModel: ViewModel func didUpdateTitle(newTitle: String) { //handle viewModel updates } } class ViewModel { weak var delegate: ViewModelDelegate? func handleTouch() { //respond to some user action } } 

看起来不错 但是随着ViewModel的发展,我们开始在委托中获得一堆方法来处理ViewModel产生的每一次喷嚏:


 protocol ViewModelDelegate { func didUpdate(title: String) func didUpdate(subtitle: String) func didReceive(items: [SomeItem]) func didReceive(error: Error) func didChangeLoading(isLoafing: Bool) //...  } 

每种方法都需要实现,因此,我们从视图中的方法中获得了巨大的足迹。 看起来不是很酷。 一点也不酷。 如果考虑一下,如果使用RxSwift,将会遇到类似的情况,但是除了实现委托方法之外,还有一堆用于不同ViewModel属性的活页夹。


输出表明:您需要将所有方法合并为一个,并且枚举属性如下所示:


 enum ViewModelEvent { case updateTitle(String) case updateSubtitle(String) case items([SomeItem]) case error(Error) case loading(Bool) //...  } 

乍一看,本质没有改变。 但是,除了六种方法之外,我们还可以选择一种方法:


 func handle(event: ViewModelEvent) { switch event { case .updateTitle(let newTitle): //... case .updateSubtitle(let newSubtitle): //... case .items(let newItems): //... case .error(let error): //... case .loading(let isLoading): //... } } 

为了对称,可以在ViewModel中创建另一个枚举及其处理程序:


 enum ViewEvent { case touchButton case swipeLeft } class ViewModel { func handle(event: ViewEvent) { switch event { case .touchButton: //... case .swipeLeft: //... } } } 

一切看起来都更加简洁,而且它提供了View和ViewModel之间的单点交互,这非常影响代码的可读性。 事实证明,这是双赢的,并且提速了对拉取请求的审查,新来者迅速加入了该项目。


但不是万能药。 当一个视图模型想要向多个视图报告其事件时,就会出现问题,例如ContainerView和ContentView(一个嵌入在另一个视图中)。 同样,解决方案本身就产生了,我们编写一个新类而不是委托:


 class Output<Event> { var handlers = [(Event) -> Void]() func send(_ event: Event) { for handler in handlers { handler(event) } } } 

handlers属性中, handlers存储带有对handle(event:)方法的调用的书签,并且当我们调用send(_ event:)方法时,我们将为此事件调用所有处理程序。 再说一次,问题似乎已经解决了,但是每次绑定View-ViewModel时,都必须编写以下代码:


 vm.output.handlers.append({ [weak view] event in DispatchQueue.main.async { view?.handle(event: event) } }) view.output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) 

不是很酷。
我们使用以下协议关闭View和ViewModel:


 protocol ViewModel { associatedtype ViewEvent associatedtype ViewModelEvent var output: Output<ViewModelEvent> { get } func handle(event: ViewEvent) func start() } protocol View: ViewModelContainer { associatedtype ViewModelEvent associatedtype ViewEvent var output: Output<ViewEvent> { get } func setupBindings() func handle(event: ViewModelEvent) } 

为什么需要start()setupBindings()方法-我们将在后面描述。 我们正在为该协议编写扩展:


 extension View where Self: NSObject { func bind<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = vm else { return } vm.output.handlers.append({ [weak self] event in DispatchQueue.main.async { self?.handle(event: event) } }) output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) setupBindings() vm.start() } } 

而且,我们获得了一种现成的方法来链接任何事件匹配的View-ViewModel。 start()方法可确保执行该视图时,该视图将已经接收到所有将从ViewModel发送的事件,如果需要将ViewModel放入自己的子视图中,则需要setupBindings()方法,因此该方法可以默认在扩展名'中实现e。


事实证明,对于View和ViewModel之间的关系,它们的特定实现绝对不重要,主要是View可以处理ViewModel事件,反之亦然。 为了在视图中存储到ViewModel的特定链接而不是ViewModel的通用版本,可以编写一个附加的TypeErasure包装器(因为不可能将协议类型的属性与associatedtype ):


 class AnyViewModel<ViewModelEvent, ViewEvent>: ViewModel { var output: Output<ViewModelEvent> let startClosure: EmptyClosure let handleClosure: (ViewEvent) -> Void let vm: Any? private var isStarted = false init?<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = vm else { return nil } self.output = vm.output self.vm = vm self.startClosure = { [weak vm] in vm?.start() } self.handleClosure = { [weak vm] in vm?.handle(event: $0) }//vm.handle } func start() { if !isStarted { isStarted = true startClosure() } } func handle(event: ViewEvent) { handleClosure(event) } } 

更进一步


我们决定走的更远,显然不将属性存储在视图中,而是通过运行时进行设置,总的来说, View协议的扩展结果如下:


 extension View where Self: NSObject { func bind<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = AnyViewModel(with: vm) else { return } vm.output.handlers.append({ [weak self] event in if #available(iOS 10.0, *) { RunLoop.main.perform(inModes: [.default], block: { self?.handle(event: event) }) } else { DispatchQueue.main.async { self?.handle(event: event) } } }) output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) p_viewModelSaving = vm setupBindings() vm.start() } private var p_viewModelSaving: Any? { get { return objc_getAssociatedObject(self, &ViewModelSavingHandle) } set { objc_setAssociatedObject(self, &ViewModelSavingHandle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } var viewModel: AnyViewModel<ViewModelEvent, ViewEvent>? { return p_viewModelSaving as? AnyViewModel<ViewModelEvent, ViewEvent> } } 

这是一个有争议的时刻,但是我们决定不每次都声明此属性会更方便。


模式


这种方法非常适合Xcode模板,并允许您单击几次即可快速生成模块。 View的示例模板:


 final class ___VARIABLE_moduleName___ViewController: UIView, View { var output = Output<___VARIABLE_moduleName___ViewModel.ViewEvent>() override func viewDidLoad() { super.viewDidLoad() setupViews() } private func setupViews() { //Do layout and more } func handle(event: ___VARIABLE_moduleName___ViewModel.ViewModelEvent) { } } 

对于ViewModel:


 final class ___VARIABLE_moduleName___ViewModel: ViewModel { var output = Output<ViewModelEvent>() func start() { } func handle(event: ViewEvent) { } } extension ___VARIABLE_moduleName___ViewModel { enum ViewEvent { } enum ViewModelEvent { } } 

在代码中创建模块初始化只需三行:


 let viewModel = SomeViewModel() let view = SomeView() view.bind(with: viewModel) 

结论


结果,我们获得了在View和ViewModel之间交换消息的灵活方法,该方法具有单个入口点,并且很好地基于Xcode代码生成。 这种方法除了可以提高代码的可读性和简单性并简化测试的编写之外,还可以加快功能的开发和请求请求的审查(由于事实,即从视图模型中知道接收事件的所需顺序,因此很容易编写此顺序的单元测试可以保证)。 尽管这种方法是最近才开始与我们一起使用的,但我们希望它能充分证明其合理性并大大简化开发过程。


聚苯乙烯


还有一个针对iOS开发爱好者的小公告-在7月25日星期四,我们将在ART-SPACE中举行iOS mitap大会,免费入场。

Source: https://habr.com/ru/post/zh-CN460875/


All Articles