Реализация Unidirectional Data Flow в супераппе. Часть I

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Привет, я Антон, iOS-разработчик в inDriver. К компании я присоединился год назад, став одним из первых разработчиков в новой платформенной команде. Перед платформенными командами, в отличие от продуктовых, стоят задачи по разработке, а не по продукту как таковому. Мы выделили основные направления: создание общих компонент и стандартов разработки, а также развитие и поддержка архитектуры проекта. В этой статье остановимся на архитектуре. Разберем, с какими проблемами я столкнулся в процессе ее масштабирования, какие ошибки допустил и как исправил. Обо всем по порядку.

Для начала расскажу об iOS-проекте inDriver на момент создания платформенной команды. inDriver — ride-hailing стартап, созданный в 2013 году в Якутии. За 8 лет существования компания быстро росла: запускалась в новых странах, а в приложении открывались новые фичи и модули — мы называем их вертикали. Со временем приложение inDriver превратилось в суперапп, в котором просто вызвать грузовую машину, такси по городу или за город, заказать курьера или найти специалиста для решения бытовых задач.

Разнообразие сервисов повлияло на код проекта. Изначально написанный на Objective-C, он лавинообразно расширялся, а потом стал обрастать новым кодом на Swift. Времени на тесты и детальное продумывание архитектуры у разработчиков не было — чем больше становился проект, тем сложнее его было поддерживать.

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

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

  1. Поддержка модуляризации. Мы делили приложение на небольшие части с точки зрения бизнес-логики, UI и фич. Модуляризация позволяет отвязать код вертикалей друг от друга. Чем меньше код одной вертикали влияет на другую, тем меньше возможность получить неожиданный баг. С другой стороны, модуляризация позволяет создать переиспользумые компоненты и подключать их в нуждающихся вертикалях. Благодаря этому пишется меньше кода и ускоряется процесс разработки.

  2. Быстрое и эффективное тестирование кода. Любой код без должного внимания со временем становится легаси-кодом. Код в inDriver не стал исключением. Согласен с Майклом Физерсом, что легаси-код — это код, не покрытый тестами. Не знаю другого способа предотвратить превращение кода в легаси, кроме покрытия тестами (если знаете, можем обсудить их в комментариях). Но с тестами есть одна большая проблема — их бывает сложно и долго писать. По этой причине многие разработчики часто отказываются от тестов, оправдывая это тем, что бизнес не дает на них время. В результате код неминуемо превращается в легаси. Наша архитектура должна легко и быстро тестироваться.

  3. Возможность быстро перейти на 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 и Angular. Помимо 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. Чаще всего один на приложение.

Вместе это работает так:

  1. В UI произошло событие, и он отправляет в Store Action.

  2. Store вызывает Reducer и передает в качестве параметров текущий State и пришедший Action. На выходе — новый State, который сохраняется в Store вместо старого.

  3. Store оповещает UI и передает ему обновленный State.

Может показаться, что такой подход далек от мобильной разработки и не подходит ни для iOS, ни для Android. На самом деле и Apple, и Google используют Unidirectional Data Flow в своих фреймворках. Если внимательно присмотреться к схеме работы SwiftUI, мы обнаружим много сходств с нашей схемой. Google же прямым текстом упоминает Unidirectional Data Flow в документации по Jetpack Compose.

Рассмотрим плюсы Unidirectional Data Flow:

  1. Четкое разделение доменной логики и сайд-эффектов. Принцип не новый и давно используется в функциональном (чистые функции, монады) и объектно-ориентированном программировании (CQRS). Однако большинство мобильных архитектур не акцентируют внимание на том, как реализовывать модель приложения, и бизнес-логика часто просачивается в Controller / Presenter / Interactor или View. UDF дает четкие инструкции, как организовать доменный слой приложения и получить хорошую переиспользуемую модель.

  2. Легкое написание тестов. Так как бизнес-логика реализована в чистых функциях, протестировать ее просто. 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 и что из этого вышло.

Источник: https://habr.com/ru/company/indriver/blog/571394/


Интересные статьи

Интересные статьи

Дамы, господа, сегодня отличный день! Скорее всего вы помните, что существует такая форма компьютерного искусства как «демосцена», но если слышите это слово впервые — просто прочитайте тематич...
Вторая часть перевода лонгрида посвященного визуализации концепций из теории информации. Во второй части рассматриваются энтропия, перекрестная энтропия, дивергенция Кульбака-Лейблера, взаимн...
Если в вашей компании хотя бы два сотрудника, отвечающих за работу со сделками в Битрикс24, рано или поздно возникает вопрос распределения лидов между ними.
В первой части статьи про язык Arend мы рассматривали простейшие индуктивные типы, рекурсивные функции, классы и множества. 2. Сортировка списков в Arend 2.1 Упорядоченные списки в Arend Опр...
Сегодня мы публикуем вторую часть перевода материала о новшествах JavaScript. Здесь мы поговорим о разделителях разрядов чисел, о BigInt-числах, о работе с массивами и объектами, о globalThis, о ...