Мы в iOS отделе решили создать единую архитектуру для приложений. За 16 лет работы мы разработали больше 60 приложений. Регулярно происходят ситуации, когда, приходя на другой проект, приходилось неделями вникать в код, чтобы понять его структуру и работу. Какие-то проекты мы писали на MVP, какие-то на MVVM, где-то придумывали своё. Переключение между проектами и ревью чужого кода — всё это увеличивало время разработки приложений еще на несколько часов. Решившись на создание архитектуры, мы в начале определили главные цели, которых хотим добиться:
Простота и скорость. Одна из главных задач — упростить жизнь разработчикам. Для этого код должен быть читаемым, а приложение должно иметь простую и понятную структуру.
Быстрое погружение в проект. Аутсорс-разработка не предоставляет большого количества времени на погружение в проект. Важно, чтобы при переходе на другой проект разработчику не потребовалось много времени на изучение кода приложения.
Масштабируемость и расширяемость. Разрабатываемое приложение должно быть готово к большим нагрузкам и иметь возможность простого добавления новой функциональности. Для этого важно, чтоб архитектура соответствовала современным принципам разработки, таким как SOLID, и последним версиям SDK
Постоянное развитие. За один раз не создашь идеальную архитектуру, это приходит со временем. Каждый разработчик вносит свой вклад в неё — еженедельно мы проводим митинги, на которых обсуждаем преимущества и недостатки существующей архитектуры и моменты, которые хочется усовершенствовать.
Основа нашей архитектуры — паттерн MVVM с координаторами
Сравнивая популярные MV(X) паттерны, мы остановились на MVVM. Он показался самым оптимальным из-за хорошей скорости разработки и гибкости.
MVVM означает Model, View, ViewModel:
- Model — предоставляет данные и методы работы с ними. Запрос на получение, проверка на корректность и т.д.
- View — слой, ответственный за уровень графического представления.
- ViewModel — посредник между Model и View. Отвечает за изменения Model, реагируя на действия пользователя, выполненные на View, и обновляет View, используя изменения из Model. Главная отличительной особенность от других посредников в MV(X) паттернах — реактивные биндинги View и ViewModel, что существенно упрощает и сокращает код работы с данными между этими сущностями.
Вкупе к MVVM мы добавили координаторы. Это объекты, которые контролируют навигационный поток нашего приложения. Они помогают:
- изолировать и повторно использовать ViewControllers
- передавать зависимости вниз по иерархии навигации
- определять варианты использования приложения
- внедрить Deep Links
Также в архитектуре мы использовали паттерн DI (внедрения зависимостей). Это настройки над объектами, при которых зависимости объекта задаются извне, а не создаются самим объектом. Используем DITranquillity — легкий, но мощный фреймворк, с которым можно конфигурировать зависимости в декларативном стиле.
Давайте разберем нашу архитектуру на примере приложения для заметок.
Создадим основу для будущего приложения. Реализуем необходимые протоколы для роутинга.
Настройка AppDelegate и AppCoordintator
Схема взаимодействия делегата и координаторов представлена ниже.
В App Delegate создадим для DI контейнер. В методе registerParts() добавляем все наши зависимости в приложении. Далее инициализируем AppCoordinator, передавая window и контейнер и вызывая метод start(), тем самым передавая ему управление.
App Coordinator определяет, по какому сценарию должно запуститься приложение. Например, если пользователь не авторизован, то для него показывается авторизация, иначе запускается основной сценарий приложения. В случае с приложением заметок, у нас 1 сценарий — показ списка заметок.
Делаем по аналогии с App Coordinator, только вместо window, уже передаем router.
В NoteListCoordinator берем зависимость экрана списка заметок, через метод container.resolve(). Обязательно надо указать тип нашей зависимости чтоб библиотека знала какую именно зависимость надо извлечь. Также настраиваем обработчики перехода на следующие экраны. Настройка зависимостей представим потом.
Создание модуля
Каждый модуль в приложении можно представить в следующем виде
Слой Модели у нас представляет сущность Provider. Схема у него такая
Провайдер — это сущность, ответственная за общение с сервисами и менеджерами, для получения, отправки и обработки данных, нужных для экрана, например обратиться к сервисам для получения данных из сети, или из БД.
Создадим протокол, по которому будет происходить общение с нашим провайдером, объявляя нужные поля и методы, а также создадим структуру ProviderState, там объявляем данные, от которых будет зависеть наш экран. В протоколе объявляем такие поля, как Current State с типом ProviderState и его наблюдателя State с типом Observable<ProviderState> и методы для изменения нашего Current State.
Потом создадим реализацию нашего протокола, именуя как название протокола+”Impl”. CurrentState помечаем, как @Published, этот property wrapper, позволяет создавать наблюдаемый объект, который автоматически сообщает о внесении изменений. Аналогично с этим мог справиться BehaviorRelay, имеющий свойства как наблюдаемого так и наблюдателя, но у него был достаточно сложный флоу обновления данных, который занимал 3 строки, а с использованием @Published всего 1. Также установим уровень доступа private(set), так как стейт провайдера не должен изменятся вне провайдера. State будет выступать наблюдателем CurrentState и транслировать изменения своим подписчикам, а именно а нашу будущую View Model. Не забываем реализовать методы, которые понадобятся нам при работе на данном экране.
Также как и для провайдера создаем протокол. Объявляем такие поля как ViewInputData, и Events. ViewInputData это данные, которые непосредственно будут передаваться в наш viewController. Создадим реализацию нашей ViewModel, у viewInputData подпишемся на state провайдера и изменим в нужный формат для view c помощью функции mapToViewInputData. Создаем enum Events, в котором определяем все события, которые должны обрабатывать на экране, например загрузка вью, нажатие на кнопку, выбор ячейки и т.д. Делаем Events типом PublishSubject, для возможности подписки и добавления новых элементов, подписываемся и делаем обработку для каждого события.
В этом слое настраиваем UI экрана и биндинги с вью-моделью. Слой View у нас представляет UIViewController. Во viewWillAppear() подписываемся на ViewInputData и отдаем данные в render, который распределяет их в нужные UI элементы
Теперь, когда все компоненты модуля готовы, приступим к связи объектов между ними. Модуль представляет собой класс, подписанный к протоколу DIPart, который в первую очередь служит для поддержания иерархии кода, объединяя некоторые части системы в один общий класс и в будущем включает часть, а не некоторые компоненты списка. Реализуем обязательный метод load(container:), где и будем регистрировать наши компоненты.
Регистрировать компоненты будем с помощью метода container.register(), передавая туда наш объект, и указывая протокол, по которому он будет общаться, а также время жизни объекта. По такому же принципу работаем со всеми остальными компонентами
Наш модуль готов, не забудем добавить модуль в контейнер в AppDelegate. Перейдем в NoteListCoordinator в функцию открытия списка. Возьмем нужную зависимость через функцию container.resolve, обязательно явно объявляя тип переменной. Затем создаем обработчики событий onNoteSelected и onCreateNote, а viewController передаем роутеру.
По этим шагам создаются и другие модули и навигация. В заключение можно сказать, что архитектура не лишена недостатков. Из проблем можно выделить - изменение одного поля в viewInputData заставляет обновлять весь UI, а не определенные его элементы, не разработан общий флоу работы с UITabBarController и UIPageViewController.
[UPD]: Ноябрь 2022
С публикации статьи прошло полгода. За это время мы проработали слабые места, которые описали ранее, и вот, что мы сделали:
- Теперь не нужно обновлять весь стейт провайдера при изменении одного поля.
- В нашу архитектуру теперь можно реализовать UIPageViewController и UITabBarController.
Стейт
Мы писали, что сделали State провайдера через кастомный propertyWrapper — RxPublished, аналог Published в Combine, только через RxSwift. В него завёрнут BehaviorRelay. Поэтому, когда мы изменяли State, мы автоматически присылали новый экземпляр сабджекту, И только потом он транслировал его своим подписчикам. Но возникал случай, когда надо было обновить несколько полей в стейте, а изменённый стейт транслировать только при завершении операции.
Мы нашли ёмкое решение с inout параметром и замыканием. Функция с параметром через inout после завершения возвращает измененный параметр в переменную, которая была указана в функции. Решение получилось на 3 строчки (и экономит ОЧЕНЬ много времени):
- Копируем текущий стейт;
- Выполняем замыкание;
- Присваиваем полученный изменённый стейт в сабджект.
UIPageViewController
Реализация UIPageViewController в архитектуре упростила процесс разработки. Вот, как сделать это по шагам:
- Создаём модуль для PageViewController
- В провайдере подготавливаем данные, которые нужны будут для конфигурации моделей внутри UIPageViewController.
- Делаем ViewModel как обычно: преобразуем стейт провайдера в стейт представления, обрабатываем события и изменения состояния.
- Добавляем DI-модкли экранов в viewController через инициализатор.
Если нужно переиспользовать модули, убедитесь, чтобы при последующем обращении к этому модулю, возвращался новый экземпляр. Это можно сделать через свойство Provider (не путать с провайдером модуля). Он как раз отвечает за то, чтобы при обращении к переменной возвращался новый экземпляр. Рекомендуем библиотеку SwiftLazy, от автора DITranquility. Она заменяет нативный lazy более разнообразным и крутым функционалом. Там как раз есть нужный Provider. - Уже при рендере конфигурируем каждый экран с необходимыми данными. Например:
UITabBarController
У TabBarController теперь есть собственный координатор, чтобы для каждой вкладки можно было конфигурировать отдельный флоу — пару координатора и роутера. Тут важно не забыть добавить его в хранилище дочерних координаторов методом addDependency() и вызвать у них метод start():
Как видите, все апдейты достаточно легко применить к архитектуре мобильного приложения. Мы также планируем добавить кастомные поп-апы и ещё кучу крутых штук.
Заключение
C созданием архитектуры, нам стало намного легче работать. Не так страшно стало заменять коллегу в отпуске и принимать новый проект. А решения той или иной реализации можно посмотреть у коллег, не ломая голову, как это прикрутить, чтобы правильно работало с нашей архитектурой.
Комментарии