Привет, я Антон, iOS-разработчик в inDriver. К компании я присоединился год назад, став одним из первых разработчиков в новой платформенной команде. Перед платформенными командами, в отличие от продуктовых, стоят задачи по разработке, а не по продукту как таковому. Мы выделили основные направления: создание общих компонент и стандартов разработки, а также развитие и поддержка архитектуры проекта. В этой статье остановимся на архитектуре. Разберем, с какими проблемами я столкнулся в процессе ее масштабирования, какие ошибки допустил и как исправил. Обо всем по порядку.
UPD: технические детали подробнее рассказаны во второй части.
Для начала расскажу об iOS-проекте inDriver на момент создания платформенной команды. inDriver — ride-hailing стартап, созданный в 2013 году в Якутии. За 8 лет существования компания быстро росла: запускалась в новых странах, а в приложении открывались новые фичи и модули — мы называем их вертикали. Со временем приложение inDriver превратилось в суперапп, в котором просто вызвать грузовую машину, такси по городу или за город, заказать курьера или найти специалиста для решения бытовых задач.
Разнообразие сервисов повлияло на код проекта. Изначально написанный на Objective-C, он лавинообразно расширялся, а потом стал обрастать новым кодом на Swift. Времени на тесты и детальное продумывание архитектуры у разработчиков не было — чем больше становился проект, тем сложнее его было поддерживать.
Продуктовые команды работали над разными вертикалями и не имели никаких ограничений в выборе архитектуры. Поэтому, если какая-то команда создавала переиспользуемую логику или UI, использовать ее можно было только для своей вертикали. В результате вертикалям приходилось дублировать код, даже если его уже написали в другой вертикали. Код имел классические проблемы быстрорастущего стартапа и работать с ним становилось сложнее.
Мы решили, что хватит это терпеть, и пришли к выводу, что нужна общая архитектура для приложения. Это позволило бы ускорить разработку с помощью написания общих компонент, которые можно использовать во всех вертикалях. Кроме того, это упростило бы ротацию разработчиков между вертикалями и понимание кода на код-ревью. Мы сформулировали требования для общей архитектуры:
Поддержка модуляризации. Мы делили приложение на небольшие части с точки зрения бизнес-логики, UI и фич. Модуляризация позволяет отвязать код вертикалей друг от друга. Чем меньше код одной вертикали влияет на другую, тем меньше возможность получить неожиданный баг. С другой стороны, модуляризация позволяет создать переиспользумые компоненты и подключать их в нуждающихся вертикалях. Благодаря этому пишется меньше кода и ускоряется процесс разработки.
Быстрое и эффективное тестирование кода. Любой код без должного внимания со временем становится легаси-кодом. Код в inDriver не стал исключением. Согласен с Майклом Физерсом, что легаси-код — это код, не покрытый тестами. Не знаю другого способа предотвратить превращение кода в легаси, кроме покрытия тестами (если знаете, можем обсудить их в комментариях). Но с тестами есть одна большая проблема — их бывает сложно и долго писать. По этой причине многие разработчики часто отказываются от тестов, оправдывая это тем, что бизнес не дает на них время. В результате код неминуемо превращается в легаси. Наша архитектура должна легко и быстро тестироваться.
Возможность быстро перейти на SwiftUI. На примере Objective-C мы убедились, как болезненно, когда технологии меняются, а код устаревает. Хороший код на этом языке программирования сейчас является обузой. Проблемы, уже решенные в Swift, остаются без поддержки для Objective-C. Да и найти разработчиков на Objective-C становится сложнее. Поэтому приходится тратить усилия по переписыванию проектов на Swift.
Подозреваю, что со временем такая же судьба ждет и UIKit. Apple все активнее развивает SwiftUI. Не хочется попасть в ситуацию как с Objective-C и переписывать весь код под SwiftUI. Мы пока не используем SwiftUI в продакшене, но решили подстраховаться и учесть это, чтобы наша архитектура поддерживала как UIKit, так и SwiftUI. При необходимости перехода на SwiftUI, мы бы с легкостью смогли это сделать, переписав UI-слой, но не трогая бизнес-логику.
Прежде чем вводить новую архитектуру и переписывать старый код мы посмотрели, какие подходы уже реализованы в проекте. Помимо MVC (тот, что Massive) в проекте был Clean Swift и реализация Redux в виде фреймворка Unicore. На нем была написана одна фича и самая свежая вертикаль. До этого с Redux мы не работали. Был опыт работы с RxSwift и RxFeedback, поэтому некоторые вещи из Redux оказались знакомы.
Мы решили детальнее посмотреть на Redux, так как он уже был в проекте и многие разработчики успели с ним поработать. Redux — изначально JS-библиотека, которая создана для веба и работы в связке с React. Помимо Redux, в вебе множество схожих библиотек и даже целые языки, например, Elm. Да и на Swift уже хватает похожих решений: ReSwift, TCA, RxFeedback. Их объединяет использование шаблона Unidirectional Data Flow (UDF). Чтобы понять, какой из фреймворков больше подойдет команде, разберу, что собой представляет Unidirectional Data Flow.
Основная идея Unidirectional Data Flow заключается в том, чтобы данные в приложении двигались только в одном направлении: от модели приложения к UI, но не обратно. Если в UI что-то произошло, он никак не пытается интерпретировать эти события. Все, что делает UDF — отправляет события в модель, которая решает, как обновить состояние системы.
В такой схеме мы легко добиваемся того, чтобы данные, передаваемые в UI, были иммутабельными. UI получает на вход данные и отображает их, а если надо что-то изменить, UI отправляет событие (Action) в модель и ждет, когда к нему придут уже обновленные данные.
Разные фреймворки по-разному реализуют модель приложения. Попробуем найти в них общие части. Привожу названия из Redux, в скобках — альтернативные именования:
State (Model) — состояние системы. Это неизменяемые value-типы, которые описывают текущее состояние приложения.
Action (Event/Message) — события в системе. Помогают из UI сообщить о произошедших изменений и уведомить об этом модель.
Reducer (Update) — чистая функция с сигнатурой (State, Action) -> State. Единственное место, где разрешено изменение стейта. На вход получает старый State и произошедший Action, и формирует новый State. В некоторых фреймворках имеет дополнительные параметры или возвращаемые значения.
Store (Core) — агрегирующая сущность. Хранит в себе State и запускает Reducer. В качестве интерфейса предоставляет возможность отправить Action и подписаться на обновление State. Чаще всего один на приложение.
Вместе это работает так:
В UI произошло событие, и он отправляет в Store Action.
Store вызывает Reducer и передает в качестве параметров текущий State и пришедший Action. На выходе — новый State, который сохраняется в Store вместо старого.
Store оповещает UI и передает ему обновленный State.
Может показаться, что такой подход далек от мобильной разработки и не подходит ни для iOS, ни для Android. На самом деле и Apple, и Google используют Unidirectional Data Flow в своих фреймворках. Если внимательно присмотреться к схеме работы SwiftUI, мы обнаружим много сходств с нашей схемой. Google же прямым текстом упоминает Unidirectional Data Flow в документации по Jetpack Compose.
Рассмотрим плюсы Unidirectional Data Flow:
Четкое разделение доменной логики и сайд-эффектов. Принцип не новый и давно используется в функциональном (чистые функции, монады) и объектно-ориентированном программировании (CQRS). Однако большинство мобильных архитектур не акцентируют внимание на том, как реализовывать модель приложения, и бизнес-логика часто просачивается в Controller / Presenter / Interactor или View. UDF дает четкие инструкции, как организовать доменный слой приложения и получить хорошую переиспользуемую модель.
Легкое написание тестов. Так как бизнес-логика реализована в чистых функциях, протестировать ее просто. UI зависит только от полученных данных и занимается исключительно их рендерингом. Так удобно тестировать UI через snapshot-тесты. Достаточно сконфигурировать нужный State и проверить, что UI корректно рендерит его.
Но есть и ряд минусов:
1. Сложности с модуляризацией. В нашем приложении уже были модули. Вся бизнес-логика была собрана в модуле Core и каждой фиче нужно импортировать этот модуль себе:
С одной стороны, такое разделение позволяло отделить модель приложения от UI. C другой, модель получилась монолитной и сложной. Не было возможности отделить часть логики и использовать отдельно. Каждая фича знала о модели всего приложения, а, значит, и о других фичах. С таким подходом дальнейшее масштабирование проекта лишь усугубило бы текущие проблемы.
2. Проблемы с производительностью. Большинство UDF-фреймворков предполагают наличие одного Store. Это позволяет гарантировать единый источник правды и обновлять State в одном месте. Но такой подход ведет к проблемам с производительностью. Из-за того, что в Store приходят Action со всего приложения, обновления AppState могут происходить очень часто. Это создает большую нагрузку как на Reducer, так и на UI.
Существующий в проекте Redux соответствовал 2 из 3 наших требований к общей архитектуре. Во-первых, он легко покрывается тестами, как со стороны модели, так и UI. Во-вторых, State, Action и Reducer не зависят от UIKit, и вся модель приложения легко подключается к SwiftUI. Самой большой проблемой оказалась модуляризация проекта. В следующей статье расскажу, как мы справились с модуляризацией Unidirectional Data Flow и что из этого вышло.
MFilonen2
А можете пояснить, бывает ли соответствие Action и Reducer не 1 к 1? Если нет, то какой смысл разделения этих слоёв?
MasterWatcher Автор
Соответствие Action, State и Reducer 1 к 1 или 1 к многим - это тема для большой отдельной статьи. Как-нибудь доберемся до нее и расскажем какие соглашения действуют у нас. Но потенциально это, конечно, возможно, чтобы один Action обрабатывался в двух разных Reducer. Как пример приведу example-проект для The Composable Architecture. Вот тут на 50-ой строчке appReducer ловит Action успешного логина и стартует игру. Этот же Action ловится и в loginReducer из модуля LogicCore.
А что вы подразумеваете под разделением слоев в случае Action и Reducer? Мы воспринимаем и Action и Reducer как часть доменной логики приложения, поэтому всегда кладем их в доменный слой.
MFilonen2
Типичное приложение на редуксе выглядит как простыня передачи вызовов без их обработки, что, как по мне, довольно бессмысленно.
ArturRuZ
Спорить об архитектурах можно бесконечно, как по мне - топик зашел. У ребят были определенные боли - они их решили. Да, идеальной архитектуры нет, но ее задача в первую очередь решать проблемы. Если взять тот же MVVM - там можно еще большую простыню сотворить :) В концепте единого стейта - есть своя прелесть как не крути.