Работа со статическими шаблонами: учебник Swift MVVM
Опубликовано: 2022-03-11Сегодня мы увидим, как новые технические возможности и ожидания наших пользователей в отношении приложений, управляемых данными в реальном времени, создают новые проблемы в том, как мы структурируем наши программы, особенно наши мобильные приложения. Хотя эта статья посвящена iOS и Swift, многие шаблоны и выводы в равной степени применимы к Android и веб-приложениям.
За последние несколько лет в том, как работают современные мобильные приложения, произошла важная эволюция. Благодаря более распространенному доступу в Интернет и таким технологиям, как push-уведомления и веб-сокеты, пользователь, как правило, больше не является единственным источником событий во время выполнения — и не обязательно самым важным — во многих современных мобильных приложениях.
Давайте подробнее рассмотрим, насколько хорошо каждый из двух шаблонов проектирования Swift работает с современным чат-приложением: классический шаблон модель-представление-контроллер (MVC) и упрощенный неизменяемый шаблон модель-представление-представление (MVVM, иногда стилизованный под шаблон ViewModel). »). Приложения для чата являются хорошим примером, поскольку у них есть много источников данных и им необходимо обновлять свои пользовательские интерфейсы разными способами при каждом получении данных.
Наш чат-приложение
Приложение, которое мы собираемся использовать в качестве руководства в этом руководстве по Swift MVVM, будет иметь большинство основных функций, которые мы знаем из приложений для чата, таких как WhatsApp. Давайте рассмотрим функции, которые мы реализуем, и сравним MVVM и MVC. Приложение:
- Загрузит ранее полученные чаты с диска
- Синхронизирует существующие чаты через
GET-запрос с сервером. - Будет получать push-уведомления, когда пользователю отправляется новое сообщение
- Будет подключен к WebSocket, как только мы окажемся на экране чата
- Может
POSTновое сообщение в чат - Покажет уведомление в приложении, когда будет получено новое сообщение о чате, в котором мы сейчас не находимся.
- Покажет новое сообщение немедленно, когда мы получим новое сообщение для текущего чата
- Будет отправлять прочитанное сообщение, когда мы читаем непрочитанное сообщение
- Будет получено сообщение о прочтении, когда кто-то прочитает наше сообщение
- Обновляет значок счетчика непрочитанных сообщений на значке приложения.
- Синхронизирует все полученные или измененные сообщения обратно в Core Data
В этом демонстрационном приложении не будет реальной реализации API, WebSocket или Core Data, чтобы упростить реализацию модели. Вместо этого я добавил чат-бота, который начнет отвечать вам, как только вы начнете разговор. Однако все остальные маршруты и вызовы реализованы так, как если бы хранилище и соединения были реальными, включая небольшие асинхронные паузы перед возвратом.
Были построены следующие три экрана:
Классический MVC
Во-первых, есть стандартный шаблон MVC для создания iOS-приложения. Именно так Apple структурирует весь свой код документации и как ожидают работать API и элементы пользовательского интерфейса. Это то, чему учат большинство людей, когда они проходят курс iOS.
Часто MVC обвиняют в том, что он приводит к UIViewController из нескольких тысяч строк кода. Но если его правильно применить, с хорошим разделением между каждым уровнем, у нас могут быть довольно тонкие ViewController , которые действуют только как промежуточные менеджеры между View , Model и другими Controller .
Вот блок-схема реализации приложения MVC (без CreateViewController для ясности):
Давайте подробно рассмотрим слои.
Модель
Уровень модели обычно является наименее проблематичным уровнем в MVC. В этом случае я решил использовать ChatWebSocket , ChatModel и PushNotificationController в качестве посредника между объектами Chat и Message , внешними источниками данных и остальной частью приложения. ChatModel является источником правды в приложении и работает только в памяти в этом демонстрационном приложении. В реальном приложении это, вероятно, будет поддерживаться Core Data. Наконец, ChatEndpoint обрабатывает все вызовы HTTP.
Вид
Представления довольно большие, так как они должны выполнять много обязанностей, так как я тщательно отделил весь код представления от UIViewController s. Я сделал следующее:
- Использовал (очень рекомендуемый) шаблон
enumсостояний, чтобы определить, в каком состоянии сейчас находится представление. - Добавлены функции, которые подключаются к кнопкам и другим элементам интерфейса, запускающим действие (например, нажатие «Ввод» при вводе имени контакта).
- Настройте ограничения и каждый раз перезванивайте делегату.
Как только вы UITableView в микс, представления теперь намного больше, чем UIViewController s, что приводит к тревожным 300+ строкам кода и множеству смешанных задач в ChatView .
Контроллер
Поскольку вся логика обработки модели перенесена в ChatModel . Весь код представления, который может скрываться здесь в менее оптимальных, отдельных проектах, теперь живет в представлении, поэтому UIViewController довольно компактны. Контроллер представления совершенно не обращает внимания на то, как выглядят данные модели, как они извлекаются или как должны отображаться — он просто координирует. В примере проекта ни один из UIViewController не превышает 150 строк кода.
Однако ViewController по-прежнему выполняет следующие действия:
- Быть делегатом для представления и других контроллеров представления
- Создание экземпляров и нажатие (или извлечение) контроллеров представления, если это необходимо
- Отправка и получение вызовов в
ChatModelи из него - Запуск и остановка WebSocket в зависимости от стадии цикла контроллера представления
- Принятие логических решений, таких как отказ от отправки сообщения, если оно пустое
- Обновление представления
Это по-прежнему много, но в основном это координация, обработка блоков обратного вызова и переадресация.
Преимущества
- Эта закономерность понятна всем и продвигается Apple.
- Работает со всей документацией
- Никаких дополнительных фреймворков не требуется
Недостатки
- У контроллеров представления много задач; многие из них в основном передают данные туда и обратно между представлением и уровнем модели.
- Не очень подходит для обработки нескольких источников событий
- Классы, как правило, много знают о других классах
Определение проблемы
Это работает очень хорошо до тех пор, пока приложение следует за действиями пользователя и реагирует на них, как вы могли бы представить себе приложение, такое как Adobe Photoshop или Microsoft Word. Пользователь выполняет действие, пользовательский интерфейс обновляется, повторяется.
Но современные приложения связаны, часто более чем одним способом. Например, вы взаимодействуете через REST API, получаете push-уведомления, а в некоторых случаях также подключаетесь к WebSocket.
При этом внезапно контроллеру представления необходимо иметь дело с большим количеством источников информации, и всякий раз, когда внешнее сообщение получено без запуска пользователем — например, получение сообщения через WebSocket — источники информации должны найти свой путь обратно вправо просмотреть контроллеры. Для этого требуется много кода, чтобы склеить каждую часть вместе для выполнения одной и той же задачи.
Внешние источники данных
Давайте посмотрим, что происходит, когда мы получаем push-сообщение:
class PushNotificationController { class func received(notification: UNNotification, whenProcessed result: (_ shouldShow: Bool) -> Void) { let shouldShowNotification: Bool defer { result(shouldShowNotification) } let content = notification.request.content let date = DateParser.date(from: content.subtitle) ?? Date() let sender: Message.Sender = .other(name: content.title) let pushedMessage = Message(with: sender, message: content.body, state: .sent, sendDate: date) ChatModel.received(message: pushedMessage, by: content.title) if let chatViewController = chatViewController(handling: content.title) { chatViewController.received(message: pushedMessage) shouldShowNotification = false } else { shouldShowNotification = true } updateChats(for: content.title) } private static func updateChats(for contact: String) { guard let chat = ChatModel.loadedChats.first(where: { (chat) -> Bool in chat.contact == contact }) else { return assertionFailure("Chat for received message should always exist") } BaseNavigationViewController.navigationController?.viewControllers.forEach({ (viewController) in switch viewController { case let chatsViewController as UpdatedChatDelegate: chatsViewController.updated(chat: chat) default: break } }) } private static func chatViewController(handling contact: String) -> ChatViewController? { guard let lastViewController = BaseNavigationViewController.navigationController?.viewControllers.last as? ChatViewController, lastViewController.chat.contact == contact else { return nil } return lastViewController } } Нам приходится копаться в стеке контроллеров представления вручную, чтобы выяснить, есть ли контроллер представления, который необходимо обновить после того, как мы получим push-уведомление. В этом случае мы также хотим обновить экраны, реализующие UpdatedChatDelegate , которым в данном случае является только ChatsViewController . Мы также делаем это, чтобы знать, должны ли мы скрыть уведомление, потому что мы уже смотрим на Chat , для которого оно предназначено. В этом случае мы, наконец, доставляем сообщение контроллеру представления. Совершенно очевидно, что PushNotificationController должен слишком много знать о приложении, чтобы выполнять свою работу.
Если бы ChatWebSocket доставлял сообщения и в другие части приложения, а не имел однозначное отношение к ChatViewController , мы столкнулись бы там с той же проблемой.
Понятно, что каждый раз, когда мы добавляем еще один внешний источник, нам приходится писать довольно инвазивный код. Этот код также довольно ненадежен, так как он сильно зависит от структуры приложения и делегирует передачу данных обратно в иерархию для работы.
Делегаты
Шаблон MVC также добавляет дополнительную сложность, когда мы добавляем другие контроллеры представления. Это связано с тем, что контроллеры представлений, как правило, узнают друг о друге через делегатов, инициализаторы и — в случае раскадровок prepareForSegue при передаче данных и ссылок. Каждый контроллер представления обрабатывает свои собственные соединения с моделью или промежуточными контроллерами, и они как отправляют, так и получают обновления.
Кроме того, представления обмениваются данными с контроллерами представлений через делегатов. Хотя это действительно работает, это означает, что нам нужно предпринять довольно много шагов для передачи данных, и я всегда ловлю себя на том, что много рефакторю обратных вызовов и проверяю, действительно ли установлены делегаты.
Можно сломать один контроллер представления, изменив код в другом, например устаревшие данные в ChatsListViewController потому что ChatViewController больше не вызывает update updated(chat: Chat) . Особенно в более сложных сценариях сложно синхронизировать все.
Разделение между представлением и моделью
Удалив весь код, связанный с представлением, из контроллера представления в customView и переместив весь код, связанный с моделью, в специализированные контроллеры, контроллер представления стал довольно компактным и разделенным. Однако остается еще одна проблема: существует разрыв между тем, что хочет отобразить представление, и данными, которые находятся в модели. Хорошим примером является ChatListView . Мы хотим отобразить список ячеек, которые сообщают нам, с кем мы разговариваем, каким было последнее сообщение, дату последнего сообщения и сколько непрочитанных сообщений осталось в Chat :
Однако мы передаем модель, которая не знает, что мы хотим видеть. Вместо этого это просто Chat с контактом, содержащий сообщения:
class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }Теперь можно быстро добавить дополнительный код, который даст нам последнее сообщение и количество сообщений, но форматирование дат в строки — это задача, которая прочно принадлежит уровню представления:
var unreadMessages: Int { return messages.filter { switch ($0.sender, $0.state) { case (.user, _), (.other, .read): return false case (.other, .sending), (.other, .sent): return true } }.count } var lastMessage: Date? { return messages.last?.sendDate } Итак, наконец, мы форматируем дату в ChatItemTableViewCell при ее отображении:
func configure(with chat: Chat) { participant.text = chat.contact lastMessage.text = chat.messages.last?.message ?? "" lastMessageDate.text = chat.lastMessage.map { lastMessageDate in DateRenderer.string(from: lastMessageDate) } ?? "" show(unreadMessageCount: chat.unreadMessages) }Даже в довольно простом примере довольно ясно, что существует противоречие между тем, что нужно представлению, и тем, что предоставляет модель.
Статическая управляемая событиями MVVM, также известная как статическая управляемая событиями версия «шаблона ViewModel».
Статическая MVVM работает с моделями представлений, но вместо того, чтобы создавать через них двунаправленный трафик (как это было раньше через наш контроллер представлений с MVC), мы создаем неизменяемые модели представлений, которые обновляют пользовательский интерфейс каждый раз, когда пользовательский интерфейс должен измениться в ответ на событие. .
Событие может быть вызвано практически любой частью кода, если она может предоставить связанные данные, требуемые enum событий. Например, получение события Receive received(new: Message) может быть вызвано push-уведомлением, WebSocket или обычным сетевым вызовом.
Давайте посмотрим на это на схеме:
На первый взгляд, он кажется немного более сложным, чем классический пример MVC, поскольку для выполнения одной и той же задачи задействовано гораздо больше классов. Но при ближайшем рассмотрении ни одно из отношений больше не является двунаправленным.
Еще более важно то, что каждое обновление пользовательского интерфейса запускается событием, поэтому для всего, что происходит, через приложение существует только один маршрут. Сразу понятно, каких событий можно ожидать. Также ясно, где вы должны добавить новый, если это необходимо, или добавить новое поведение при реагировании на существующие события.
После рефакторинга у меня появилось много новых классов, как я показал выше. Вы можете найти мою реализацию статической версии MVVM на GitHub. Однако, когда я сравниваю изменения с помощью инструмента cloc , становится ясно, что на самом деле лишнего кода не так много:
| Шаблон | Файлы | Пустой | Комментарий | Код |
|---|---|---|---|---|
| МВК | 30 | 386 | 217 | 1807 г. |
| МВВМ | 51 | 442 | 359 | 1981 г. |
Количество строк кода увеличилось всего на 9 процентов. Что еще более важно, средний размер этих файлов сократился с 60 строк кода до 39.
Также важно то, что самые большие потери можно найти в файлах, которые обычно являются самыми большими в MVC: представлениях и контроллерах представлений. Представления составляют всего 74 процента от их исходного размера, а контроллеры представлений теперь составляют только 53 процента от их исходного размера.
Следует также отметить, что большая часть дополнительного кода представляет собой библиотечный код, который помогает прикреплять блоки к кнопкам и другим объектам в визуальном дереве, не требуя классических MVC @IBAction или шаблонов делегатов.
Давайте рассмотрим различные слои этого дизайна один за другим.
Мероприятие
Событие всегда является enum , обычно со связанными значениями. Часто они будут пересекаться с одним из объектов в вашей модели, но не обязательно. В этом случае приложение разделено на два основных enum событий: ChatEvent и MessageEvent . ChatEvent предназначен для всех обновлений самих объектов чата:
enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }Другой имеет дело со всеми событиями, связанными с сообщениями:
enum MessageEvent { case sending(message: Message, contact: String, previousMessages: [Message]) case sent(message: Message, contact: String) case failedSending(message: Message, contact: String, reason: String) case received(message: Message, contact: String) case userReads(messagesSentBy: String) case userRead(othersMessages: [Message], sentBy: String) case otherRead(yourMessage: Message, reader: String) } Важно ограничить ваши enum *Event разумным размером. Если вам нужно 10 или более случаев, это обычно признак того, что вы пытаетесь охватить более одной темы.
Примечание. Концепция enum чрезвычайно эффективна в Swift. Я склонен часто использовать enum со связанными значениями, так как они могут устранить большую двусмысленность, которую вы в противном случае имели бы с необязательными значениями.
Учебное пособие по Swift MVVM: Маршрутизатор событий
Маршрутизатор событий — это точка входа для каждого события, происходящего в приложении. Любой класс, который может предоставить связанное значение, может создать событие и отправить его маршрутизатору событий. Таким образом, они могут быть вызваны любым источником, например:
- Пользователь переходит к определенному контроллеру представления
- Пользователь нажимает определенную кнопку
- Запуск приложения
- Внешние события, такие как:
- Сетевой запрос возвращается со сбоем или новыми данными
- Всплывающие уведомления
- Сообщения WebSocket
Маршрутизатор событий должен знать об источнике события как можно меньше, а лучше вообще ничего. Ни одно из событий в этом примере приложения не имеет никакого индикатора, откуда они исходят, поэтому очень легко смешать любой источник сообщения. Например, WebSocket запускает то же самое событие — received(message: Message, contact: String) — как новое push-уведомление.

События (как вы уже догадались) направляются в классы, которые должны в дальнейшем обрабатывать эти события. Обычно вызываются только классы уровня модели (если данные необходимо добавить, изменить или удалить) и обработчик событий. Я рассмотрю и то, и другое чуть позже, но главная особенность маршрутизатора событий — предоставление одной простой точки доступа ко всем событиям и перенаправление работы другим классам. Вот ChatEventRouter :
class ChatEventRouter { static func route(event: ChatEvent) { switch event { case .loaded(let chats): ChatEventHandler.loaded(chats: chats) case .creatingChat(let contact): let chat = ChatModel.create(chatWith: contact) ChatEndpoint.create(chat: chat) ChatEventHandler.creatingChat() case .created(let chat): ChatEventHandler.created(chat: chat) case .createChatFailed(let reason): ChatEventHandler.failedCreatingChat(reason: reason) } } } Здесь происходит довольно мало: единственное, что мы делаем, — это обновляем модель и перенаправляем событие в ChatEventHandler , чтобы обновить пользовательский интерфейс.
Учебное пособие по Swift MVVM: Контроллер модели
Это точно такой же класс, который мы используем в MVC, так как он уже работал достаточно хорошо. Он представляет состояние приложения и обычно поддерживается Core Data или локальной библиотекой хранения.
Слои модели — если они правильно реализованы в MVC — очень редко нуждаются в каком-либо рефакторинге, чтобы соответствовать различным шаблонам. Самое большое изменение заключается в том, что изменение модели происходит из меньшего количества классов, что делает более понятным, где происходят изменения.
В качестве альтернативы этому шаблону вы можете наблюдать за изменениями в модели и следить за тем, чтобы они были обработаны. В этом случае я решил просто разрешить изменять модель только *EventRouter и *Endpoint , поэтому существует четкая ответственность за то, где и когда модель будет обновляться. Напротив, если бы мы наблюдали за изменениями, нам пришлось бы написать дополнительный код для распространения событий, не изменяющих модель, таких как ошибки, через ChatEventHandler , что сделало бы менее очевидным, как события проходят через приложение.
Учебное пособие по Swift MVVM: обработчик событий
Обработчик событий — это место, где представления или контроллеры представлений могут регистрироваться (и отменять регистрацию) в качестве слушателей для получения обновленных моделей представлений, которые создаются всякий раз, когда ChatEventRouter вызывает функцию в ChatEventHandler .
Вы можете видеть, что он примерно отражает все состояния представления, которые мы использовали в MVC ранее. Если вам нужны другие типы обновлений пользовательского интерфейса, такие как звук или запуск движка Taptic, их также можно сделать здесь.
protocol ChatListListening: class { func updated(list: ChatListViewModel) } protocol CreateChatListening: class { func updated(create: CreateChatViewModel) } class ChatEventHandler { private static var chatListListening: [ChatListListening?] = [] private static var createChatListening: [CreateChatListening?] = [] class func add(listener: ChatListListening) { weak var weakListener = listener chatListListening.append(weakListener) } class func remove(listener: ChatListListening) { chatListListening = chatListListening.filter { $0 !== listener } } class func add(listener: CreateChatListening) { weak var weakListener = listener createChatListening.append(weakListener) listener.updated(create: CreateChatViewModelBuilder.build(isSending: false, error: nil)) } class func remove(listener: CreateChatListening) { createChatListening = createChatListening.filter { $0 !== listener } } class func started() { ChatEndpoint.fetchChats() let loadingViewModel = ChatListViewModelBuilder.buildLoading() chatListListening.forEach { $0?.updated(list: loadingViewModel) } } class func loaded(chats: [Chat]) { let chatList = ChatListViewModelBuilder.build(for: chats) chatListListening.forEach { $0?.updated(list: chatList) } } class func creatingChat() { let createChat = CreateChatViewModelBuilder.build(isSending: true, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } } class func failedCreatingChat(reason: String) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: reason) createChatListening.forEach { $0?.updated(create: createChat) } } class func created(chat: Chat) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } updateAllChatLists() let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true, removePreviousFromStack: true) } class func updateAllChatLists() { let chatListViewModel = ChatListViewModelBuilder.build(for: ChatModel.allChats()) chatListListening.forEach { $0?.updated(list: chatListViewModel) } } } Этот класс не делает ничего, кроме того, чтобы убедиться, что правильный слушатель может получить правильную модель представления всякий раз, когда происходит определенное событие. Новые слушатели могут получить модель представления сразу после их добавления, если это необходимо для настройки их начального состояния. Всегда убедитесь, что вы добавили weak ссылку в список, чтобы предотвратить циклы хранения.
Учебное пособие по Swift MVVM: просмотр модели
Вот одно из самых больших различий между тем, что делают многие шаблоны MVVM, и тем, что делает статический вариант. В этом случае модель представления является неизменной, а не настраивается как постоянное промежуточное звено между моделью и представлением. Зачем нам это делать? Давайте сделаем паузу, чтобы объяснить это на мгновение.
Одним из наиболее важных аспектов создания приложения, которое хорошо работает во всех возможных случаях, является проверка правильности состояния приложения. Если пользовательский интерфейс не соответствует модели или имеет устаревшие данные, все, что мы делаем, может привести к сохранению ошибочных данных, сбою приложения или его неожиданному поведению.
Одна из целей применения этого шаблона заключается в том, что у нас нет состояния в приложении, если оно не является абсолютно необходимым. Что такое государство? Состояние — это практически каждое место, где мы храним представление определенного типа данных. Один особый тип состояния — это состояние, в котором в данный момент находится ваш пользовательский интерфейс, и, конечно же, мы не можем предотвратить его с помощью приложения, управляемого пользовательским интерфейсом. Все остальные типы состояний связаны с данными. Если у нас есть копия массива Chat , поддерживающая наш UITableView на экране списка чатов, это пример дублирующего состояния. Традиционная модель представления с двусторонней привязкой может быть еще одним примером дубликата Chat s нашего пользователя.
Передавая неизменяемую модель представления, которая обновляется при каждом изменении модели, мы устраняем этот тип повторяющегося состояния, потому что после того, как оно применяется к пользовательскому интерфейсу, оно больше не используется. Тогда у нас есть только два типа состояния, которых мы не можем избежать — пользовательский интерфейс и модель — и они идеально синхронизированы друг с другом.
Таким образом, модель представления здесь сильно отличается от некоторых приложений MVVM. Он служит только неизменяемым хранилищем данных для всех флагов, значений, блоков и других значений, требуемых представлением для отражения состояния модели, но оно не может быть каким-либо образом обновлено представлением.
Следовательно, это может быть простая неизменяемая struct . Чтобы сделать эту struct как можно более простой, мы создадим ее экземпляр с помощью построителя модели представления. Одна из интересных особенностей модели представления заключается в том, что она получает поведенческие флаги, такие как shouldShowBusy и shouldShowError , которые заменяют механизм enum состояний, который ранее использовался в представлении. Вот данные для ChatItemTableViewCell , которые мы проанализировали ранее:
struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }Поскольку построитель модели представления уже позаботился о точных значениях и действиях, необходимых представлению, все данные предварительно отформатированы. Также новым является блок, который срабатывает при нажатии на элемент. Давайте посмотрим, как это делается конструктором модели представления.
Просмотр построителя моделей
Конструктор модели представления может создавать экземпляры моделей представлений, преобразовывая входные данные, такие как Chat или Message , в модели представлений, которые идеально подходят для определенного представления. Одна из самых важных вещей, которые происходят в построителе модели представления, — это определение того, что на самом деле происходит внутри блоков в модели представления. Блоки, прикрепленные построителем модели представления, должны быть очень короткими, вызывая функции других частей архитектуры как можно быстрее. В таких блоках не должно быть никакой бизнес-логики.
class ChatListItemViewModelBuilder { class func build(for chat: Chat) -> ChatListItemViewModel { let lastMessageText = chat.messages.last?.message ?? "" let lastMessageDate = (chat.messages.last?.sendDate).map { DateRenderer.string(from: $0) } ?? "" let unreadMessageCount = ChatModel.unreadMessages(for: chat.contact).count return ChatListItemViewModel(contact: chat.contact, message: lastMessageText, lastMessageDate: lastMessageDate, unreadMessageCount: unreadMessageCount, itemTapped: { show(chat: chat) }) } private class func show(chat: Chat) { let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true) } }Теперь все предварительное форматирование происходит в одном месте, и здесь также определяется поведение. Это довольно важный класс в этой иерархии, и может быть интересно посмотреть, как различные компоновщики в демонстрационном приложении реализованы и работают с более сложными сценариями.
Учебное пособие по Swift MVVM: Контроллер просмотра
Контроллер представления в этой архитектуре делает очень мало. Он установит и разрушит все, что связано с его представлением. Это лучше всего подходит для этого, потому что он получает все обратные вызовы жизненного цикла, необходимые для добавления и удаления прослушивателей в нужное время.
Иногда требуется обновить элемент пользовательского интерфейса, не покрываемый корневым представлением, например заголовок или кнопку на панели навигации. Вот почему я обычно все еще регистрирую контроллер представления в качестве прослушивателя маршрутизатора событий, если у меня есть модель представления, которая охватывает все представление для данного контроллера представления; После этого я передаю модель представления в представление. Но также нормально зарегистрировать любой UIView в качестве слушателя напрямую, если есть часть экрана, которая имеет другую частоту обновления, например, биржевой тикер в верхней части страницы об определенной компании.
Код для ChatsViewController теперь настолько короток, что занимает меньше страницы. Осталось переопределить базовое представление, добавить и удалить кнопку добавления из панели навигации, установить заголовок, добавить себя в качестве слушателя и реализовать протокол ChatListListening :
class ChatsViewController: UIViewController { private lazy var customView: ChatsView = { let customView = ChatsView() return customView }() private var addButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) override func loadView() { view = customView } override func viewDidLoad() { super.viewDidLoad() ChatEventHandler.add(listener: self) ChatEventRouter.route(event: .started) title = "Chats" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.rightBarButtonItem = addButton } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationItem.rightBarButtonItem = nil } } extension ChatsViewController: ChatListListening { func updated(list: ChatListViewModel) { addButton.action(block: { _ in list.addChat() }) customView.display(viewModel: list) } } Не осталось ничего, что можно было бы сделать в другом месте, так как ChatsViewController до минимума.
Учебное пособие по Swift MVVM: просмотр
Представление в неизменяемой архитектуре MVVM все еще может быть довольно тяжелым, так как у него все еще есть список задач, но мне удалось лишить его следующих обязанностей по сравнению с архитектурой MVC:
- Определение того, что необходимо изменить в ответ на новое состояние
- Реализация делегатов и функций для действий
- Обработка триггеров просмотра, таких как жесты и запускаемая анимация.
- Преобразование данных таким образом, чтобы их можно было показать (например,
DateвStrings)
Особенно последний пункт имеет довольно большое преимущество. В MVC, когда представление или контроллер представления отвечает за преобразование данных для отображения, оно всегда будет делать это в основном потоке, поскольку очень сложно отделить истинные изменения пользовательского интерфейса, которые должны произойти в этом потоке, от вещей, которые не обязательно на нем бегать. А выполнение кода без изменения пользовательского интерфейса в основном потоке может привести к менее быстрому отклику приложения.
Вместо этого с помощью этого паттерна MVVM все, начиная с блока, запускаемого касанием, и заканчивая моментом построения модели представления, которая будет передана слушателю, — мы можем запустить все это в отдельном потоке и погрузиться только в основной поток в конец для выполнения обновлений пользовательского интерфейса. Если наше приложение тратит меньше времени на основной поток, оно будет работать более плавно.
Как только модель представления применяет новое состояние к представлению, оно может испариться, а не оставаться в виде еще одного уровня состояния. Все, что может вызвать событие, прикрепляется к элементу в представлении, и мы не будем связываться с моделью представления.
Важно помнить одну вещь: вы не обязаны сопоставлять модель представления через контроллер представления с представлением. Как упоминалось ранее, части представления могут управляться другими моделями представлений, особенно когда частота обновления различается. Представьте, что Google Sheet редактируется разными людьми, при этом панель чата остается открытой для соавторов — не очень полезно обновлять документ всякий раз, когда приходит сообщение чата.
Известным примером является реализация типа для поиска, когда поле поиска обновляется более точными результатами по мере ввода большего количества текста. Вот как я бы реализовал автозаполнение в классе CreateAutocompleteView : весь экран обслуживается CreateViewModel но вместо этого текстовое поле слушает AutocompleteContactViewModel .
Другим примером является использование валидатора формы, который может быть построен как «локальный цикл» (прикрепление или удаление состояний ошибок к полям и объявление формы допустимой) или через инициирование события.
Статические неизменяемые модели представлений обеспечивают лучшее разделение
Используя статическую реализацию MVVM, нам удалось, наконец, полностью разделить все слои, потому что модель представления теперь является мостом между моделью и представлением. Мы также упростили управление событиями, которые не были вызваны действиями пользователя, и удалили множество зависимостей между различными частями нашего приложения. Единственное, что делает контроллер представления, — это регистрирует (и отменяет регистрацию) себя в обработчиках событий в качестве прослушивателя событий, которые он хочет получать.
Преимущества:
- Реализации представления и контроллера представления, как правило, намного легче.
- Классы более специализированы и разделены
- События можно легко инициировать из любого места
- События следуют по предсказуемому пути через систему
- Состояние обновляется только из одного места
- App can be more performant as it's easier to do work off the main thread
- Views receive tailor-made view models and are perfectly separated from the models
Недостатки:
- A full view model is created and sent every time the UI needs to update, often overwriting the same button text with the same button text, and replacing blocks with blocks that do exactly the same
- Requires some helper extensions to make button taps and other UI events work well with the blocks in the view model
- Event
enums can easily grow pretty large in complex scenarios and might be hard to split up
The great thing is that this is a pure Swift pattern: It does not require a third-party Swift MVVM framework, nor does it exclude the use of classic MVC, so you can easily add new features or refactor problematic parts of your application today without being forced to rewrite your whole application.
There are other approaches to combat large view controllers that provide better separation as well. I couldn't include them all in full detail to compare them, but let's take a brief look at some of the alternatives:
- Some form of the MVVM pattern
- Some form of Reactive (using RxSwift, sometimes combined with MVVM)
- The model-view-presenter pattern (MVP)
- The view-interactor-presenter-entity-router pattern (VIPER)
Traditional MVVM replaces most of the view controller code with a view model that is just a regular class and can be tested more easily in isolation. Since it needs to be a bi-directional bridge between the view and the model it often implements some form of Observables. That's why you often see it used together with a framework like RxSwift.
MVP and VIPER deal with extra abstraction layers between the model and the view in a more traditional way, while Reactive really remodels the way data and events flow through your application.
The Reactive style of programming is gaining a lot of popularity lately and actually is pretty close to the static MVVM approach with events, as explained in this article. The major difference is that it usually requires a framework, and a lot of your code is specifically geared towards that framework.
MVP is a pattern where both the view controller and the view are considered to be the view layer. The presenter transforms the model and passes it to the view layer, while I transform the data into a view model first. Since the view can be abstracted to a protocol, it's much easier to test.
VIPER takes the presenter from MVP, adds a separate “interactor” for business logic, calls the model layer “entity,” and has a router for navigation purposes (and to complete the acronym). It can be considered a more detailed and decoupled form of MVP.
So there you have it: static event-driven MVVM explained. I look forward to hearing from you in the comments below!
