Изначально весь проект был написан на 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)
//... итд
}
На первый взгляд, сути не меняет. Но вместо шести методов получаем один со 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: //...
}
}
}
Выглядит все это намного более лаконично, плюс дает единую точку взаимодействия между View и ViewModel, что очень хорошо сказывается на читабельности кода. Получается win-win — и ревью пулл-реквестов ускоряется, и новички быстрее в проект вкатываются.
Но не панацея. Проблемы начинают возникать тогда, когда одна вью модель хочет сообщать о своих событиях нескольким вьюхам, например, ContainerView и ContentView (одно вложено в другое). Решение, опять же, возникает само собой, пишем вместо делегата новый класс:
class Output<Event> {
var handlers = [(Event) -> Void]()
func send(_ event: Event) {
for handler in handlers {
handler(event)
}
}
}
В свойстве 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, а метод setupBindings()
нужен будет в случае, если понадобится прокинуть ViewModel в свои же сабвьюхи, поэтому данный метод можно реализовать дефолтом в extension'e.
Получается, что для связи View и ViewModel совершенно не важны конкретные их реализации, главное — чтобы View умела обрабатывать события 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. Данный подход позволил ускорить разработку фич и ревью пулл-реквестов, к тому же повысил читаемость и простоту кода и упростил написание тестов (благодаря тому, что, зная желаемую последовательность получения событий от вью модели, легко написать Unit-тесты, с помощью которых эту последовательность можно будет гарантировать). Хоть этот подход у нас начал использоваться довольно недавно, мы надеемся, что он полностью себя оправдает и существенно упростит разработку.
P.S.
И небольшой анонс для любителей разработки под iOS — уже в этот четверг, 25 июля, мы проведем iOS-митап в ART-SPACE, вход бесплатный, приходите.
Комментарии (7)
zaitsevyan
22.07.2019 18:32Подписка на события заставляет View хранить состояние, а этого хочется избежать, так как за это отвечает ViewModel. Когда мне говорят про MVVM, я представляю что существует ViewModel которая знает все и я могу в ЛЮБОМЙ момент подписаться на нее и получить все нужные мне значения для отображения. В Вашем же варианте, если подписаться позже чем начало отображение — часть событий просто не прийдет, если я правильно понимаю.
Соглашусь с предидущим автором сообщения про Redux. — Если у вас много значений который нужно отправить во View и они происходят одновременно — можно сделать из них простую структура и посылать все сразу. Иногда уместно посылать сразу целый большие обьекты с бд/сети, а View уже разберется как использовать данные.rooooo Автор
23.07.2019 11:48+1Подписка на события заставляет View хранить состояние, а этого хочется избежать, так как за это отвечает ViewModel
В нашей схеме вьюха просто отображает данные, полученные из viewModel.
В Вашем же варианте, если подписаться позже чем начало отображение — часть событий просто не прийдет, если я правильно понимаю.
Как мы описали выше, мы создаем связку до отображения на экране, с ситуацией, когда нам нужно забиндить данные после отображения вьюхи, мы не сталкивались! События присылаются только после метода bind, что исключает ситуацию, когда вью может не получить каких-то событий от вью модели.
Иногда уместно посылать сразу целый большие обьекты с бд/сети, а View уже разберется как использовать данные.
Мы так и делаем, обычно если много значений мы объединяем их в структуру и посылаем в одним событием:case present(let content)
Banannzza
22.07.2019 18:41А какие были причины использования RunLoop.main.perform в методе bind? Интересно узнать аргументы за так как в проектах обычно встречается только DispatchQueue.main.async
rooooo Автор
23.07.2019 11:49По этому поводу есть доклад от Антона Сергеева из VK: vk.com/video-147415323_456239066
Если вкратце: мы не хотим тормозить UI.Banannzza
23.07.2019 12:02Да, я в курсе. Только вот примеры когда можно заметить разницу он не привел
Zoolander
работаю с приложениями, в которых много компонентов, использую элементы из Redux-архитектуры
store по сути можно считать ViewModel, обернутый в любой вариант Observable
компонентов может быть очень много, прямая делегация методов может быть мучительной
поэтому часто для низовых компонентов я использую просто подписку на store
и когда бы store не обновил свое состояние — нужные компоненты сразу отобразят нужное изменение
Мне очень психологически мешает, что моя архитектура не описана у дядюшки Боба, но возможно, я не все читал. И кроме того, я не говорю, что это правильный подход. Он выглядит для меня удобным, но я работаю с небольшими приложениями.
В архитектуре на подписках и эвентах с непривычки довольно тяжело отслеживать, что где происходит — я решаю это тем, что подписка происходит у меня по сути через шину событий, события именованы, прокликивая события, я вижу, кто их выкидывает, а кто подписывается
Попробуйте, возможно, что-то в этом найдете. Но не экспериментируйте на продакшене, мозги должны привыкнуть к смеси MVVM и Redux (то, что описывается под вторым словом, способно только запутать, проще смотреть на картинку)
rooooo Автор
Спасибо, интересная мысль!