company_banner

Доступный MVVM на хакнутых экстеншенах


    Много лет подряд я, помимо всего прочего, занимался настройкой MVVM в своих рабочих и не очень рабочих проектах. Я увлеченно делал это в Windows-проектах, где паттерн является родным. С энтузиазмом, достойным лучшего применения, я делал это в iOS-проектах, где MVVM просто так не приживается.


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


    Советую запастись попкорном и кока-колой — это вечернее шоу о том, как я ни в чем себе не отказывал, в очередной раз реализуя MVVM в одном из своих домашних проектов. Сегодня вторая серия: про то, как из MVC сделать MVVM и не наступить в реактивщину.


    Введение


    Как я уже говорил в прошлой статье, MVVM успешного программиста должен включать в себя, помимо реализации самого паттерна, решение для управления зависимостями и реализацию роутинга. Про управление зависимостями мы уже поговорили и сейчас перейдем непосредственно к реализации MVVM. В яблочном мире паттерн, придуманный в Microsoft, чувствует себя немного иностранцем, и его реализация потребует от нас дополнительных усилий. Эти усилия мы будем прикладывать к конкретному приложению, на примере которого рассмотрим все тонкости и подводные камни. Так как с идеями у меня традиционно не очень, это приложение будет состоять из одного-единственного экрана, отображающего список заказов.


    Напомню также, что в коде я стараюсь с переменным успехом придерживаться нескольких простых правил, о которых уже рассказывал:


    Нескольких простых правил, о которых уже рассказывал
    1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
    2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
    3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
    4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

    Для тех, кто не приемлет неожиданностей, вот полное содержание статьи.


    Полное содержание



    Действующие лица


    Я не планирую расшифровывать каждую букву аббревиатуры MVVM и объяснять, как работает паттерн и зачем он вообще нужен. Уверен, все это вы и без меня знаете. Если по какой-то причине MVVM для вас в новинку, советую перестать читать эту статью и поскорее заполнить пробел в знаниях. Невероятно скучная, но познавательная статья из «Википедии» может послужить неплохим стартом.


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


    OrdersVC Вью-контроллер экрана заказов. Без него никак, потому что iOS — это вью-контроллеры. Является источником событий жизненного цикла экрана и занимается отображением данных, которые приходят из вью-модели. В нашем случае он будет содержать таблицу для отображения списка заказов
    OrdersView Вьюха для контроллера OrdersVC. Хорошая практика — для каждого VC заводить свою собственную View отдельным классом, но в этой статье для упрощения мы так делать не будем. Поэтому OrdersView — это такая вьюха, которой нет, но нужно помнить, что она очень даже может быть
    OrdersVM Модель представления для OrdersVC, а также для его вьюхи, если бы она у него была. С помощью OrdersProvider вью-модель получает заказы и преобразует их в пригодный для отображения вид
    Order Ничего особенного, типичная модель, каких много. Представляет собой заказ
    OrderCell Ячейка UITableView, отображающая заказ
    OrderVM Модель представления для ячейки OrderCell. Это тот же Order, но пригодный для отображения
    OrdersProvider Сервис, который будет загружать заказы — из базы данных, из файла, с бэкэнда — неважно откуда. Для нашего обучающего примера мы будем грузить заказы из бездонной пустоты небытия

    Вот так все эти ребята уживаются вместе на диаграмме классов.






    Стоит отметить, что в мире MVVM нет такого понятия, как контроллер, в то время как в iOS, где безраздельно властвует MVC, без вью-контроллеров никуда. Чтобы разрешить это противоречие, здесь и далее мы будем считать, что контроллер — это просто View, тем более что в iOS эти две сущности традиционно очень тесно связаны.


    Запомните: все, что я говорю в этой статье о View, можно в равной степени отнести к контроллеру, и наоборот.


    Начиная отсюда и до конца статьи мы будем заниматься реализацией этой картинки в коде.


    Знакомим представление с его моделью


    Сплошная стрелочка, направленная от View к ViewModel, символизирует их абьюзивные отношения: вьюха владеет вью-моделью, держит ее сильной ссылкой и напрямую вызывает ее методы. Узаконим эти отношения с помощью протокола. Может существовать сколько угодно реализаций MVVM, но одна штука в них будет неизменной: у View должно появиться свойство viewModel:


    protocol IHaveViewModel: AnyObject {
        associatedtype ViewModel
    
        var viewModel: ViewModel? { get set }
        func viewModelChanged(_ viewModel: ViewModel)
    }

    Буква I в начале имени протокола означает interface. В предсказуемом мире статической типизации у разных вьюх экземпляры вью-моделей, скорее всего, будут принадлежать разным классам. Чтобы выразить это средствами языка, нам пригодился протокол с дженериком ассоциированным типом.


    Заметим, что свойство viewModel доступно для записи извне. В какой-то момент оно обязательно изменится, что неизбежно приведет к вызову метода viewModelChanged(_:), в котором вьюха обязуется проделать работу по синхронизации своего состояния в соответствии со своей моделью представления. Нехитрая реализация протокола IHaveViewModel на примере связки OrderCell — OrderVM могла бы выглядеть вот так:


    final class OrderCell: UITableViewCell, IHaveViewModel {
        var viewModel: OrderVM? {
            didSet {
                guard let viewModel = viewModel else { return }
                viewModelChanged(viewModel)
            }
        }
    
        func viewModelChanged(_ viewModel: OrderVM) {
            textLabel?.text = viewModel.name
        }
    }

    Замечу, что для простоты мы взяли ячейку стандартного лайаута, поэтому пользуемся свойством textLabel для отображения текста. Модель представления заказа нам не очень интересна, поэтому сделаем ее максимально неумной:


    final class OrderVM {
        let order: Order
        var name: String {
            return "\(order.name) #\(order.id)"
        }
        init(order: Order) {
            self.order = order
        }
    }

    Нетерпеливый читатель может задаться вопросом, кто же проставляет свойство viewModel у ячейки и, что более важно, как именно это происходит. На самом деле типичная OrderCell не очень отличается от своих собратьев, и ее жизненный цикл протекает довольно обычным образом:


    1. В методе делегата таблицы tableView(_:cellForRowAt:) извлекаем ячейку при помощи вызова dequeueReusableCell(withIdentifier:for:) и получаем экземпляр класса UITableViewCell.
    2. Осуществляем приведение типа к протоколу IHaveViewModel, чтобы получить доступ к свойству viewModel и записать туда вью-модель.
    3. Грустим оттого, что код, который мы написали на шаге 2, не компилируется.
    4. Гуглим ошибку Protocol 'IHaveViewModel' can only be used as a generic constraint because it has Self or associated type requirements.

    Чтобы справиться с такой ошибкой, нам придется применить специальную технику с загадочным названием стирание типов (type erasure). Некоторые авторы выделяют несколько разновидностей стирания типов. Для нашего случая подходит вариант, похожий на секретную технику ниндзя — теневое стирание типов (shadow type erasure). Кто придумывает эти названия? На практике весь пафос сводится к тому, что надо просто завести еще один протокол:


    protocol IHaveAnyViewModel: AnyObject {
        var anyViewModel: Any? { get set }
    }

    Этот протокол не обременен ассоциированным типом, поэтому к нему можно будет кастить любые объекты. Протокол IHaveViewModel почти не изменился, найдите одно отличие:


    protocol IHaveViewModel: IHaveAnyViewModel {
        associatedtype ViewModel
    
        var viewModel: ViewModel? { get set }
        func viewModelChanged(_ viewModel: ViewModel)
    }

    Реализация OrderCell теперь будет выглядеть так:


    final class OrderCell: UITableViewCell, IHaveViewModel {
        typealias ViewModel = OrderVM
    
        var anyViewModel: Any? {
            didSet {
                guard let viewModel = anyViewModel as? ViewModel else { return }
                viewModelChanged(viewModel)
            }
        }
    
        var viewModel: ViewModel? {
            get {
                return anyViewModel as? ViewModel
            }
            set {
                anyViewModel = newValue
            }
        }
    
        func viewModelChanged(_ viewModel: ViewModel) {
            textLabel?.text = viewModel.name
        }
    }

    Свойство anyViewModel, лишенное информации о типе, удобно использовать снаружи класса. Оно позволяет любую вьюху привести к типу IHaveAnyViewModel и задать ей вью-модель. Свойство viewModel, которое содержит типизированную вью-модель, удобно использовать внутри класса, например для того, чтобы в методе viewModelChanged(_:) обновлять состояние вьюхи.


    Удивительно, насколько отвратительную реализацию MVVM мы с вами только что написали: пользоваться ею решительно невозможно. Все потому, что в каждой вьюхе и вью-контроллере мы вынуждены будем писать очень много повторяющегося кода для реализации IHaveViewModel, что, вообще говоря, довольно утомительно и ни капельки не весело. Хорошая новость в том, что этот недостаток можно преодолеть с помощью расширения, обеспечивающего реализацию по умолчанию для протокола IHaveViewModel.


    Реализация по умолчанию через расширение протокола


    В основе концепции расширений (extensions) лежит очень простая идея: расширения не могут добавлять никаких новых данных к типу. Все свойства и методы, предоставляемые расширением, являются не чем иным, как комбинацией данных, которые уже объявлены в типе.


    Таким образом, если мы попробуем написать реализацию по умолчанию для IHaveViewModel, то ожидаемо столкнемся с неизбежными сложностями в виде ошибки extensions must not contain stored properties:


    extension IHaveViewModel {
        var anyViewModel: Any? // Не компилируется :(
    }

    Вполне вероятно, что это ограничение связано с невозможностью нормально аллоцировать память под структуру, обмазанную экстеншенами с хранимыми свойствами. Но это даже к лучшему.


    Представьте, в какую анархию погрузилось бы программирование, если бы все могли вот так запросто добавлять новые данные к любым типам. Возможно, с помощью ошибки extensions must not contain stored properties создатели языка нежно заботятся о нас, не позволяя пойти по скользкой дорожке, покатиться под откос, ринуться в бурлящую бездну хаоса. Вопреки их стараниям, именно этим мы сейчас и займемся, предварительно хакнув свифтовые расширения с помощью старого доброго Objective-C-рантайма. Читай дальше, если не боишься, что полиция экстеншенов придет за тобой:


    private var viewModelKey: UInt8 = 0
    
    extension IHaveViewModel {
    
        var anyViewModel: Any? {
            get {
                return objc_getAssociatedObject(self, &viewModelKey)
            }
            set {
                let viewModel = newValue as? ViewModel
    
                objc_setAssociatedObject(self, 
                    &viewModelKey, 
                    viewModel, 
                    .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    
                if let viewModel = viewModel {
                    viewModelChanged(viewModel)
                }
        }
    
        var viewModel: ViewModel? {
            get {
                return anyViewModel as? ViewModel
            }
            set {
                anyViewModel = newValue
            }
        }
    
        func viewModelChanged(_ viewModel: ViewModel) {
    
        }
    }

    Это код компилируется, потому что в расширении мы не объявили ни одного нового хранимого свойства. Необходимого поведения удалось достичь с помощью двух функций языка Си: objc_getAssociatedObject и objc_setAssociatedObject, которые мы используем в гетере и сетере соответственно.


    Эти функции с успехом заменяют хранимые свойства, так как позволяют сопоставить объекту некоторое значение по некоторому ключу. Обычно в качестве такого ключа используют адрес глобальной переменной, такой как viewModelKey. Благодаря расширению реализация OrderCell избавилась от лишнего кода и выглядит теперь гораздо привлекательнее:


    final class OrderCell: UITableViewCell, IHaveViewModel {
        typealias ViewModel = OrderVM
    
        func viewModelChanged(_ viewModel: OrderVM) {
            textLabel?.text = viewModel.name
        }
    }

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


    Отображение списка заказов (на самом деле нет)


    Вооружившись дефолтной реализацией IHaveViewModel можно быстро накидать код связки OrdersVC — OrdersVM. Вью-модель выглядит так:


    final class OrdersVM {
        var orders: [OrderVM] = []
    
        private var ordersProvider: OrdersProvider
    
        init(ordersProvider: OrdersProvider) {
            self.ordersProvider = ordersProvider
        }
    
        func loadOrders() {
            ordersProvider.loadOrders() { [weak self] model in
                self?.orders = model.map { OrderVM(order: $0) }
            }
        }
    }

    OrdersVM использует OrdersProvider для загрузки отзывов. OrdersProvider с умным видом имитирует асинхронный запрос и отвечает списком отзывов через секунду после вызова loadOrders(completion:):


    struct Order {
        let name: String
        let id: Int
    }
    
    final class OrdersProvider {
        func loadOrders(completion: @escaping ([Order]) -> Void) {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                completion((0...99).map { Order(name: "Order", id: $0) })
            }
        }
    }

    И, наконец, вью-контроллер:


    final class OrdersVC: UIViewController, IHaveViewModel {
        typealias ViewModel = OrdersVM
    
        private lazy var tableView = UITableView()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            tableView.dataSource = self
            tableView.register(OrderCell.self, forCellReuseIdentifier: "order")
            view.addSubview(tableView)
    
            viewModel?.loadOrders()
        }
    
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
    
            tableView.frame = view.bounds
        }
    
        func viewModelChanged(_ viewModel: OrdersVM) {
            tableView.reloadData()
        }
    }

    В методе viewDidLoad() посредством вызова loadOrders() мы сообщаем вью-модели, что нам хотелось бы начать загрузку заказов. На изменение вью-модели мы реагируем в методе viewModelChanged(_:), перезагружая таблицу. Работу с источником данных для таблицы мы вынесли в отдельный экстеншен:


    extension OrdersVC: UITableViewDataSource {
        func tableView(_ tableView: UITableView, 
            numberOfRowsInSection section: Int) -> Int {
    
            return viewModel?.orders.count ?? 0
        }
    
        func tableView(_ tableView: UITableView, 
            cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
            let cell = tableView.dequeueReusableCell(withIdentifier: "order", 
                for: indexPath)
    
            if let cell = cell as? IHaveAnyViewModel {
                cell.anyViewModel = viewModel?.orders[indexPath.row]
            }
            return cell
        }
    }

    Здесь все совершенно стандартно, за исключением приведения к протоколу IHaveAnyViewModel, которое жизненно необходимо для того, чтобы сконфигурировать вью-модель ячейки. Вот и все. Теперь мы можем собрать всех ребят вместе, примерно так:


    let viewModel = OrdersVM(ordersProvider: OrdersProvider())
    let viewController = OrdersVC()
    viewController.viewModel = viewModel

    После того как мы отобразим OrdersVC на экране, мы должны увидеть таблицу со списком заказов. Однако внимательный читатель, возможно, уже догадался, что ничего у нас не выйдет и список заказов мы так и не увидим.


    Дело в том, что метод loadOrders(completion:) работает асинхронно, список заказов формируется только через секунду после вызова viewDidLoad(), а это значит, что на момент вызова reloadData() массив orders пуст. Для того чтобы все заработало, нам не хватает одной важной детали — уведомления об изменениях вью-модели.


    Уведомление об изменении модели представления


    Одна из ключевых концепций MVVM состоит в том, что ViewModel ничего не желает знать о View. Она не держит ссылку на View и не вызывает ее методы — ни напрямую, ни через протокол. Вью-модель ведет себя так, словно View просто-напросто не существует. Компенсируя свое нежелание общаться с View, вью-модель поддерживает механизм уведомления о важных событиях, происходящих в ее жизни. Этим механизмом пользуется View, чтобы поддерживать себя в актуальном виде, и на диаграмме классов это выражается пунктирной стрелкой, направленной от ViewModel к View.


    В самобытном мире iOS-разработки сложилась невеселая ситуация: уведомления об изменении свойств модели представления чаще всего реализуют через реактивные сигналы. Этот подход настолько распространен, что некоторые авторы едва ли не ставят знак равенства между MVVM и Rx. Между тем MVVM вовсе не подразумевает использование стороннего реактивного фрэймворка. В том же .NET — исторической родине паттерна — уведомления работают через интерфейс INotifyPropertyChanged, реализуемый на стороне ViewModel, в связке с декларативными биндингами — на стороне View.


    Автор этой статьи, мягко говоря, не фанат реактивного подхода. Очень уж непросто бывает разобраться в хитросплетении сигналов, которые стреляют другими сигналами, которые трансформируются в третьи сигналы. Написать запутанный реактивный код слишком просто. Сегодня вы добавляете в проект один маленький сигнальчик, а завтра ваше простое на первый взгляд приложение превращается в неуправляемый реактивный истребитель, несущийся на сверхзвуковой скорости в бездну отчаяния. Да и не хочется в мелкий домашний проект целый RxSwift тащить, а Combine — так вообще только с iOS 13.


    В общем, мы с вами, как обычно, пойдем скользкой, но интересной дорожкой и напишем под iOS нечто похожее на события из .NET. Наше творение обеспечит нам вечную славу и поддержку уведомлений об изменениях ViewModel.


    Заново изобретаем события


    События в .NET — это реализация известного паттерна «Наблюдатель», такой сталкинг от программирования: вьюха очень пристально следит за тем, что происходит c вью-моделью. Для нас критически важно, чтобы событие поддерживало несколько подписчиков, потому что, например, на одно и то же событие вью-модели может подписаться как ViewController, так и его View.


    Реализовать такое на Swift можно несколькими способами: через массив делегатов, через массив замыканий, через NotificationCenter. Тот способ, который поджидает читателя ниже по тексту, вынуждает нас для начала написать небольшой вспомогательный класс. Вот такой:


    final class Weak<T: AnyObject> {
    
        private let id: ObjectIdentifier?
        private(set) weak var value: T?
    
        var isAlive: Bool {
            return value != nil
        }
    
        init(_ value: T?) {
            self.value = value
            if let value = value {
                id = ObjectIdentifier(value)
            } else {
                id = nil
            }
        }
    }

    Это нехитрая обертка, которая держит слабую ссылку на экземпляр ссылочного типа, передаваемый в инициализатор. Если в инициализатор пришло что-то отличное от nil, обертка запоминает ObjectIdentifier этого объекта, который впоследствии используется для реализации Hashable:


    extension Weak: Hashable {
        static func == (lhs: Weak<T>, rhs: Weak<T>) -> Bool {
            return lhs.id == rhs.id
        }
    
        func hash(into hasher: inout Hasher) {
            if let id = id {
                hasher.combine(id)
            }
        }
    }

    Вооружившись Weak<T>, можно приступить к реализации событий:


    final class Event<Args> {
        // Тут живут подписчики на событие и обработчики этого события
        private var handlers: [Weak<AnyObject>: (Args) -> Void] = [:]
    
        func subscribe<Subscriber: AnyObject>(
            _ subscriber: Subscriber,
            handler: @escaping (Subscriber, Args) -> Void) {
    
            // Формируем ключ
            let key = Weak<AnyObject>(subscriber)
            // Почистим массив обработчиков от мертвых объектов, чтобы не засорять память
            handlers = handlers.filter { $0.key.isAlive }
            // Создаем обработчик события
            handlers[key] = {
                [weak subscriber] args in
                // Захватываем подписчика слабой ссылкой и вызываем обработчик,
                // только если подписчик жив
                guard let subscriber = subscriber else { return }
                handler(subscriber, args)
            }
        }
    
        func unsubscribe(_ subscriber: AnyObject) {
            // Отписываемся от события, удаляя соответствующий обработчик из словаря
            let key = Weak<AnyObject>(subscriber)
            handlers[key] = nil
        }
    
        func raise(_ args: Args) {
            // Получаем список обработчиков с живыми подписчиками
            let aliveHandlers = handlers.filter { $0.key.isAlive }
            // Для всех живых подписчиков выполняем код их обработчиков событий
            aliveHandlers.forEach { $0.value(args) }
        }
    }

    Этот код не очень сложный, но, чтобы он не казался вам совсем уж примитивным, я написал побольше комментариев. Класс Weak<T>, как оказалось, нужен был для того, чтобы хранить всех подписчиков в словаре и дать им спокойно умереть, если они того желают — не держать их сильной ссылкой.


    Обработчик события представляет собой замыкание, в аргументы которого попадает живой подписчик и некоторые данные, если таковые актуальны для данного события. Получившийся класс Event<Args> позволяет подписываться на событие с помощью метода subscribe(_:handler:) и отписываться от него с помощью метода unsubscribe(_:). Когда источник события (в нашем случае это вью-модель) захочет уведомить о чем-то свою армию подписчиков, ему следует воспользоваться методом raise(_:).


    Для удобства можно обмазаться экстеншеном, который позволяет опустить аргументы типа Void при подписке на событие и при его вызове:


    extension Event where Args == Void {
        func subscribe<Subscriber: AnyObject>(
            _ subscriber: Subscriber,
            handler: @escaping (Subscriber) -> Void) {
    
            subscribe(subscriber) { this, _ in
                handler(this)
            }
        }
    
        func raise() {
            raise(())
        }
    }

    Пользоваться событиями можно примерно так. На стороне источника событий создаем объект события, после чего в какой-то момент стреляем нотификацией:


    let event = Event<Void>()
    event.raise() // Какой-то момент наступил, стреляем

    На стороне подписчика подписываемся и выполняем полезную работу в замыкании. Обратите внимание, что в аргументы обработчика события поступает живой экземпляр подписчика, что позволяет не писать weak self, если нам нужен доступ к подписчику внутри замыкания:


    event.subscribe(self) { this in
        this.foo() // Тут полезная работа
    }

    Если подписчик более не заинтересован в получении событий, он делает вот так:


    event.unsubscribe(self) // Нам лучше расстаться

    Ура! Вы прочитали почти всю статью, осталось совсем немного. Чтобы передохнуть, отвлекитесь на минутку и подумайте о том, насколько MVVM прекрасен. Следующий раздел почти последний.


    Отображение списка заказов


    Чтобы научить OrdersVM уведомлять OrdersVC об изменении списка заказов, необходимо во вью-модель добавить соответствующее событие. Однако, согласитесь, не хочется в каждой вью-модели, которая должна уведомлять о своих изменениях, снова и снова писать код по созданию события. Поэтому мы пойдем уже знакомым путем и обратимся за помощью к запретным техникам Objective-C-рантайма, клятвенно пообещав себе больше никогда так не делать:


    private var changedEventKey: UInt8 = 0
    
    protocol INotifyOnChanged {
        var changed: Event<Void> { get }
    }
    
    extension INotifyOnChanged {
        var changed: Event<Void> {
            get {
                if let event = objc_getAssociatedObject(self, 
                    &changedEventKey) as? Event<Void> {
                    return event
                } else {
                    let event = Event<Void>()
                    objc_setAssociatedObject(self, 
                        &changedEventKey, 
                        event, 
                        .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
                    return event
                }
            }
        }
    }

    С помощью протокола INotifyOnChanged и его дефолтной реализации любая вью-модель сможет бесплатно получить событие changed. С появлением INotifyOnChanged дефолтная реализация протокола IHaveViewModel вынуждена будет немного эволюционировать: в ней мы захотим подписаться на изменение вью-модели и вызвать viewModelChanged(_:) в обработчике события:


    extension IHaveViewModel {
        var anyViewModel: Any? {
            get {
                return objc_getAssociatedObject(self, &viewModelKey)
            }
            set {
                (anyViewModel as? INotifyOnChanged)?.changed.unsubscribe(self)
                let viewModel = newValue as? ViewModel
    
                objc_setAssociatedObject(self, 
                    &viewModelKey, 
                    viewModel, 
                    .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    
                if let viewModel = viewModel {
                    viewModelChanged(viewModel)
                }
    
                (viewModel as? INotifyOnChanged)?.changed.subscribe(self) { this in
                    if let viewModel = viewModel {
                        this.viewModelChanged(viewModel)
                    }
                }
            }
        }
    }

    И, наконец, финальный штрих:


    final class OrdersVM: INotifyOnChanged {
        var orders: [OrderVM] = []
    
        private var ordersProvider: OrdersProvider
    
        init(ordersProvider: OrdersProvider) {
            self.ordersProvider = ordersProvider
        }
    
        func loadOrders() {
            ordersProvider.loadOrders() { [weak self] model in
                self?.orders = model.map { OrderVM(name: $0.name) }
                self?.changed.raise() // Пыщ!
            }
        }
    }

    Все, что мы делали выше — класс Weak<T>, класс Event<Args>, протокол INotifyOnChanged и его дефолтная реализация, — было нужно ради того, чтобы мы смогли написать одну единственную строчку кода во вью-модели: changed.raise().


    Вызов raise(), произведенный в подходящий момент, после получения всех данных, приводит к тому, что в контроллере вызывается метод viewModelChanged(_:), который перезагружает таблицу, и она успешно отображает список заказов.


    One More Thing: подписка на изменение отдельных свойств модели представления через обертки свойств


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


    Но зачем самим писать код, который за нас могут генерировать инженеры Apple?


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


    Чтобы написать свой property wrapper, нужно создать класс или структуру, предоставить свойство wrappedValue и украсить все это дело, как вишенкой на торте, атрибутом @propertyWrapper. Однако обертки свойств не так просты и позволяют манипулировать не только самим свойством, которое они оборачивают, но и его «проекцией» через специальное свойство projectedValue. Согласитесь, звучит очень непонятно, поэтому, чтобы еще больше вас запутать, рассмотрим такой код:


    @propertyWrapper
    struct Observable<T> {
        let projectedValue = Event<T>()
    
        init(wrappedValue: T) {
            self.wrappedValue = wrappedValue
        }
    
        var wrappedValue: T {
            didSet {
                projectedValue.raise(wrappedValue)
            }
        }
    }

    Мы только что создали обертку свойства и назвали ее Observable. Она умеет работать со свойствами любых типов и может похвастаться наличием projectedValue. Проекция является событием, которое обучено сообщать своим подписчикам о любых изменениях wrappedValue. Это событие, как видно из кода, мы используем по своему прямому назначению в didSet.


    Имея в своем арсенале обертку Observable<T>, мы можем применить ее к списку заказов:


    @Observable
    var orders: [OrderVM] = []

    Это приведет к тому, что компилятор сгенерирует за нас примерно такой код:


    private var _orders = Observable<[OrderVM]>(wrappedValue: [])
    
    var orders: [OrderVM] {
      get { _orders.wrappedValue }
      set { _orders.wrappedValue = newValue }
    }
    
    var $orders: Event<[OrderVM]> {
      get { _orders.projectedValue }
    }

    Видно, что, помимо свойства orders, которое через обертку просто возвращает wrappedValue, компилятор сгенерировал дополнительное свойство $orders, которое возвращает нам projectedValue. В нашем случае projectedValue — это экземпляр события, что позволяет на стороне вьюхи подписаться на изменение свойства orders вот таким нехитрым образом:


    viewModel.$orders.subscribe(self) { this, orders in
        this.update(with: orders)
    }

    Поздравляю! Вы только что в 15 строчках кода написали свой собственный аналог атрибута Published из фрэймворка Combine от Apple, а я только что дописал очередную статью.


    Заключение


    Сегодня мы вероломно поступились основным принципом работы расширений, хакнув их с помощью Objective-C-рантайма. Это позволило нам, используя протоколы и экстеншены, реализовать паттерн MVVM в одном маленьком приложении под iOS. В процессе у нас возникло непреодолимое желание применить реактивный фреймворк, и мы едва удержались, написав вместо этого свою реализацию событий, вдохновившись дружественной технологией .NET. Попутно познакомились с парой полезных техник iOS-разработки, таких как shadow type erasure и property wrappers с применением projected value.




    Весь код из этой статьи можно скачать в виде Swift Playground.

    Tinkoff
    it’s Tinkoff — просто о сложном

    Похожие публикации

    Комментарии 4

      0

      Не стал читать после obj-c экстеншенов

        0
        Так и знал, что нужно было их в конец статьи добавить, после заключения :)
        0
        Вы интересно заметили что родина паттерна это NET. Между тем C# и Swift по своей сути довольно разные языки. Я не могу понять всеобщую любовь к MVVM в кровавом интерпрайзе.
          0
          Если у вас Windows + .NET + XAML, то MS заботливо избавила вас от невыносимых мук выбора — все технологии заточены под MVVM, который работает из коробки. Если у вас iOS + Swift, то придется, конечно, выбирать из нескольких вариантов. В моем случае лёгкая ностальгия по .NET — решающий фактор <3

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое