В поисках идеальной архитектуры
За 9 лет работы фич в проектах роботов становилось все больше, запутаться в коде становилось все проще.
Когда разработчиков стало больше десятка, появилась еще одна проблема — болезненная ротация людей между проектами. Аутсорс-разработка славится жесткими дедлайнами, и у разработчиков нет месяцев или недель на погружение в особенности нового проекта, в то же время работа над разными проектами нужна для развития специалистов.
Главная проблема, которая возникает при долгосрочном развитии приложения — масштабируемость. Решить ее может переход на новую архитектуру или рефакторинг кодовой базы и добавление новых сущностей, которые разгружают объекты с большим количеством обязанностей.
Disclaimer
В нашем понимании не существует такого понятия как «универсальная архитектура». Каждый делает выбор в пользу той, с которой эффективнее и удобнее всего работать на протяжении всего проекта. То, что эффективно используется у нас, может стать бесполезным overhead«ом для других команд.
Начало
Началось все с популярного MVC и его приятеля network manager.
Основные проблемы MVC: вызов сетевых запросов и запросы к базе данных, реализация бизнес-логики и навигация расположены в контроллере. Из-за этого объекты сильно взаимосвязаны, а уровень переиспользования и тестирования низок.
Network manager в серьезных проектах превращается в «божественный объект», который становится невозможно поддерживать из-за его размера.
Представим, что у нас приложение для сети магазинов, в котором есть экраны акций, профиля и настроек. На первом экране отображается список действующих акций, в профиле — текущий бонусный баланс, имя и номер телефона пользователя, в настройках, например, возможность включить push-уведомления о новых акциях. Для входа в приложение необходимо авторизоваться с помощью логина и пароля.
Получается, что в таком случае все запросы к серверу — авторизация, получения списка акций, получение информации по профилю, изменение настроек — находятся в network manager, в том числе логика по созданию модельных объектов из JSON.
Прощание с NetworkManager
На слое взаимодействия с сетью было принято решение придерживаться подхода SOA — разделения сервисного слоя на множество сервисов в зависимости от типа сущности.
В качестве ConcreteService
в нашем примере будут выступать — AuthService, UserService, DealService, SettingsService
. Каждый сервис занимается своим делом — сервис авторизации работает с авторизацией, сервис пользователя — с данными пользователя и так далее. Хорошим правилом является разделение со стороны сервера на разные path: /auth, /user, /deals, /settings
, но необязательно.
Более подробное описание сервисного слоя есть в нашей прошлой статье.
Сериализация/десериализация JSON/XML и др.
Сериализацию/десериализацию объектов выделяем в отдельные сущности: parser и serializer. Операции взаимно обратные: parser преобразует объект типа данных, который принимает от сервера, в модельный объект, serializer — из модельного объекта в объект данных для передачи по сети. Внутри этих классов реализована проверка обязательности полей и логирование ошибок.
class UserParser: JSONParser {
func parseObject(_ data: JSON) -> User?
}
class UserSerializer: JSONSerializer {
func serializeObject(_ object: User) -> Data?
}
Для каждой сущности у нас отдельные парсеры AuthParser, UserParser, DealParser
и SettingsParser
. С сериализаторами — ровно так же.
Разделение на слои
В своей архитектуре мы придерживаемся разделения на слои, верхний слой знает только о существование нижнего.
Выше по порядку: слой пользовательского интерфейса, слой бизнес-логики, слой сервисов и слой данных.
Слой данных
Этот слой мы чаще всего реализуем через паттерн DAO, абстрагируясь от реализации и особенностей базы данных на всех остальных слоях. У нас есть готовые решения для Realm и CoreData, чаще всего используем Realm. Пример реализации тут.
Представим, что в приложении мы хотим кэшировать скидки. Поэтому при использовании DAO у нас появятся следующие классы:
DBDeal
— сущность скидки в БД Realm.DealTranslator
— транслятор сущностей.DealDAO
— DAO для скидок.
В разделе «Кодогенерация» я приведу примеры реализации данных классов.
Как быть с UI-слоем?
Здесь у нас было несколько итераций, в ходе которых мы анализировали существующие решения: MVVM и VIPER, но без практического применения было сложно оценить их объективно. VIPER для наших проектов показался избыточным: большое количество сущностей для одного модуля (во многих случаях являются только посредниками в цепочке вызовов), сложная реализация роутинга с использованием storyboard«ов, отдаление от UIKit. Конечно, тестирование модулей можно отнести к плюсам.
Использование MVVM, по нашему мнению, было проще для понимания со знанием MVC, биндинги решали проблему явных вызовов для обновления данных, стало возможно писать тестируемый код. Проблем с использованием реактивного программирования не было — мы использовали его в связке с MVC.
Переходим на MVVM
Эта архитектура разобрана в деталях, и вряд ли стоит пытаться делать это n+1 раз. Какое преимущество перед MVC мы здесь увидели? В большинстве случаев информация, отображаемая пользователю, является преобразованием моделей от сервера. Поэтому логика по преобразованию этой информации инкапсулируется внутрь view model, или, если есть зависимость между объектами, частично в фабрике view model«ей. Пример того, как номер телефона пользователя преобразуется для дальнейшего отображения на экране:
Переходим к presentation model и router
Через какое-то время мы поняли, что одним MVVM не обойдемся. Класс view controller постепенно «распухал», особенно это было заметно, если на экране вызывается несколько запросов. Следующим шагом выделили обращение к сервисам в отдельную сущность — presentation model и view controller перестал знать об их существовании.
Использование навигации (с segue или без них) на множество экранов так же приводило к разрастанию view controller. Замечу, что сам по себе вызов для показа экрана займет у вас 2–3 строчки кода, тогда как конфигурация и передача нужных данных на другой экран может занять, скажем, 10 строк. Поэтому router был выделен в отдельную сущность (да-да, еще чуть-чуть и VIPER). Использование роутера оказалось удобным в том числе, когда мы отказались от stroyboard’ов в пользу xib. Читать класс router’а, безусловно, тяжелее, чем визуально воспринимать карту экранов с переходами. Но еще менее удобно, если ваш код навигации разбросан повсюду.
Router в этой схеме — отдельное свойство на view controller’е, который создается в методе viewDidLoad
. На каких-то проектах мы создавали его непосредственно в момент совершения навигации.
Здесь важно понимать, что мы не требуем соблюдение разделения на presentation model и router для относительно простых экранов, скажем, где все помещается в 200 строк.
К примеру, у роутера в нашем приложении будут методы
showProfile()
— показать профиль пользователя.showDeal(_ deal: Deal)
— показать подробное описание скидки.showSettings()
— показать настройки.
Так как настройки и пользователь в приложении существует в единственном экземпляре, нет необходимости передавать его в роутер для конфигурации нового экрана. Напротив, имея множество скидок, presentation model экрана подробной информации о скидке должен быть создан с параметром сущности скидки (или view model«и, если ее достаточно).
А как быть с таблицей и коллекцией?
Изначально мы создавали реализацию data source и delegate отдельным классом, который хранили на view controller’е. Этот класс в свою очередь забирал данные (view model’и) из presentation model.
Cell mapper в данной схеме — замыкание, которое приводит в соответствие класс ячейки классу view model. Это сделано для того, чтобы не регистрировать вручную классы ячеек на каждом экране.
Таким образом, мы выделили большую часть кода data source и delegate в отдельную сущность.
Попробовали, оказалось, что делегирование в отдельном классе неудобно, а при выделении одного data source выигрыш не столь существенен.
Поэтому следующей итерацией перешли к использованию table presentation model в качестве data source, view controller стал delegate’ом.
Схема упростилась, ушли ненужные сущности data source и cell mapper. Проще — лучше.
Схема упростилась, ушли ненужные сущности data source и cell mapper. Проще — лучше.
Пересмотрели роутинг
Реализация роутинга, которая была описана выше, плоха тем, что все переходы жестко прописаны во view controller’е. Для реализации слабой связанности между навигацией и внутренним устройством отдельного view controller’а мы делаем следующее:
- На конкретной реализации presentation model заводим в виде optional переменных нужное замыкание — handler (или несколько, если навигация ведет в несколько мест)
- При создании presentation model в router’е устанавливаем этот handler. Например, при вызове которого должен произойти переход на другой экран.
- Из view controller’а в нужный момент вызываем handler у presentation model.
Итого, view controller перестал обладать знанием о router’е.
Кодогенерация
Отдельно стоит упомянуть еще одну особенность разработки в Redmadrobot — это использование кодогенерации. На основе модельных сущностей с помощью консольной утилиты генерируются parser’ы, translator’ы для DAO.
/*
Скидка
@model
*/
class Deal: Entity {
/*
Заголовок
@json
*/
let title: String
/*
Подзаголовок
@json
*/
let subtitle: String?
/*
Дата окончания
@json end_date
*/
let endDateString: String
init(title: String, subtitle: String?, endDateString: String) {
self.title = title
self.subtitle = subtitle
self.endDateString = endDateString
super.init()
}
}
Имеем написанный собственноручно класс скидки Deal
. На основе его и вспомогательных аннотаций (@model, @json
) утилита кодогенерации создает класс парсера DealParser
, класс сущности БД DBDeal
и класс транслятора DealTranslator
.
class DealParser: JSONParser {
override func parseObject(_ data: JSON) -> Deal? {
guard
let title: String = data["title"]?.string,
let endDateString: String = data["end_date"]?.string
else { return nil }
let subtitle: String? = data["subtitle"]?.string
let object = Deal(
title: title,
subtitle: subtitle,
endDateString: endDateString
)
return object
}
}
class DBDeal: RLMEntry {
@objc dynamic var title = ""
@objc dynamic var subtitle: String? = nil
@objc dynamic var endDateString = ""
}
class DealTranslator: RealmTranslator {
override func fill(_ entity: Deal, fromEntry: DBDeal) {
entity.entityId = fromEntry.entryId
entity.title = fromEntry.title
entity.subtitle = fromEntry.subtitle
entity.endDateString = fromEntry.endDateString
}
override func fill(_ entry: DBDeal, fromEntity: Deal) {
if entry.entryId != fromEntity.entityId {
entry.entryId = fromEntity.entityId
}
entry.title = fromEntity.title
entry.subtitle = fromEntity.subtitle
entry.endDateString = fromEntity.endDateString
}
}
С недавних пор мы научились генерировать еще и service на основе документированного protocol (про это стоит написать отдельную статью). До момента, когда начали использовать zeplin, генерировали стили цветов и шрифтов на основе текстового файла с их описанием.
Для написания утилит для генерации мы используем свою библиотеку Model Compiler, но для этой задачи вполне может подойти и Sourcery.
Заключение
Развивая архитектуру мы, прежде всего, задумывались над возможностью расширяемости наших проектов, явным разделением обязанностей и низким порогом входа для новых разработчиков. Безусловно, мы так же сталкиваемся со сложными сценариями, где какие-то из элементов нашей архитектуры «проседают», и мы придумываем, как выйти из этой ситуации, как разнести ответственности на вспомогательные сущности и сделать код более понятным. Очевидно, что ни одна архитектура не решает абсолютно все проблемы. На нескольких проектах, которые мы разрабатываем уже не первый год, наши подходы оказались удобными, и редко с этим возникают какие-то проблемы.
Мы не проповедуем MVC, MVVM, VIPER, Riblets и другие архитектуры. Мы постоянно пробуем что-то новое не в ущерб эффективности. При этом стараемся не изобретать велосипедов. Потом проверяем, насколько удобно работать с тем или иным подходом, насколько новые разработчики быстро могут схватить эти изменения.