Как стать автором
Обновить
0
QIWI
Ведущий платёжный сервис нового поколения в России

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

Время на прочтение6 мин
Количество просмотров5.4K

Изначально весь проект был написан на 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, вход бесплатный, приходите.

Теги:
Хабы:
Всего голосов 19: ↑18 и ↓1+17
Комментарии10

Публикации

Информация

Сайт
qiwi.com
Дата регистрации
Численность
1 001–5 000 человек
Местоположение
Россия

Истории