Хабрахабр

Как мы в QIWI пришли к единому стилю взаимодействия между View и ViewModel в рамках MVVM

Изначально весь проект был написан на Objective-C и использовал ReactiveCocoa версии 2.

Все из-за отсутствия типизации и каши в стек-трейсе 🙁 Взаимодействие между View и ViewModel осуществлялось посредствам биндингов свойств вью модели, и все бы ничего, за исключением того, что отладкой такого кода заниматься было очень сложно.

Поначалу мы решили попробовать вообще без реактивщины. И вот настало время использовать Swift. View явно вызывало методы у ViewModel, а ViewModel сообщала о своих изменениях с помощью делегата:

protocol ViewModelDelegate { func didUpdateTitle(newTitle: String)
} class View: UIView, ViewModelDelegate } 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) //... итд
}

Но вместо шести методов получаем один со switch'ом: На первый взгляд, сути не меняет.

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: //... } }
}

Получается win-win — и ревью пулл-реквестов ускоряется, и новички быстрее в проект вкатываются. Выглядит все это намного более лаконично, плюс дает единую точку взаимодействия между View и ViewModel, что очень хорошо сказывается на читабельности кода.

Проблемы начинают возникать тогда, когда одна вью модель хочет сообщать о своих событиях нескольким вьюхам, например, ContainerView и ContentView (одно вложено в другое). Но не панацея. Решение, опять же, возникает само собой, пишем вместо делегата новый класс:

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

И опять, проблема вроде решена, но приходится каждый раз при связывании View — ViewModel писать такое: В свойстве handlers храним кложуры с вызовами методов handle(event:) и при вызове метода send(_ event:) вызываем все хэндлеры с данным ивентом.

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() }
}

Метод start() гарантирует, что при своем выполнении вью уже получит все ивенты, которые будут посылаться из ViewModel, а метод setupBindings() нужен будет в случае, если понадобится прокинуть ViewModel в свои же сабвьюхи, поэтому данный метод можно реализовать дефолтом в extension'e. И получаем готовый метод для связывания любых View — ViewModel, ивенты которых совпадают.

А чтобы хранить во вьюхе не конкретную ссылку на ViewModel, а ее обобщенный вариант, можно написать дополнительную обертку TypeErasure (так как невозможно использовать свойства типа протокола с associatedtype): Получается, что для связи View и ViewModel совершенно не важны конкретные их реализации, главное — чтобы View умела обрабатывать события ViewModel, и наоборот.

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> }
}

Спорный момент, но мы решили, что так будет просто удобнее, чтобы каждый раз не объявлять это свойство.

Шаблоны

Пример шаблона для View: Данный подход отлично ложится на шаблоны XCode и позволяет очень быстро генерировать модули в пару кликов.

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)

Заключение

Данный подход позволил ускорить разработку фич и ревью пулл-реквестов, к тому же повысил читаемость и простоту кода и упростил написание тестов (благодаря тому, что, зная желаемую последовательность получения событий от вью модели, легко написать Unit-тесты, с помощью которых эту последовательность можно будет гарантировать). В результате мы получили гибкий способ обмена сообщения между View и ViewModel, имеющий единую точку входа и хорошо ложащийся на кодогенерацию XCode. Хоть этот подход у нас начал использоваться довольно недавно, мы надеемся, что он полностью себя оправдает и существенно упростит разработку.

PS

И небольшой анонс для любителей разработки под iOS — уже в этот четверг, 25 июля, мы проведем iOS-митап в ART-SPACE, вход бесплатный, приходите.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть