[Из песочницы] Масштабируемая архитектура для больших мобильных приложений
В этой статье мы не будем разбирать MVP, MVVM, MVI или что-то подобное. Сегодня мы поговорим о более глобальной вещи, чем просто архитектура уровня представления. Как спроектировать действительно большое приложение, в котором смогут комфортно работать десятки или сотни разработчиков? То приложение, которое легко расширять независимо от того, как много кода мы уже написали.
Требования к большим проектам:
- Слабая связность кода. Любые изменения должны затрагивать как можно меньше кода.
- Переиспользование кода. Одинаковые вещи должно быть легко переиспользовать без copy-past’a.
- Легкость расширения. Разработчику должно быть легко добавлять новый функционал в существующий код.
- Стабильность. Любой новый код можно легко отключить с помощью feature toggles, особенно если вы используете trunk-based development.
- Владение кодом. Проект должен быть разделен на модули, что бы легко было назначить владельца для каждого модуля. Это поможет нам на этапе code review. И тут не только про крупные вещи, как Gradle/Pods модули, но и обычные фичи, у которых то же могут быть разные владельцы.
Компонент
Вот типичный экран приложения. Обычно мы берем какую-то архитектуру слоя представления (MV*) и делаем Presenter/ViewModel/Interactor/что-то ещё для этого экрана. Но когда команда растет, все хотят что-то менять на этом экране и использовать какие-то части этого экрана на других. Чем больше экран, тем больше всяких событий, порождаемых пользователем и системой, и зачастую наступает момент когда уже тяжело понять что в данный момент времени происходит на экране и что нужно показать/скрыть/поменять на экране что бы отобразить нужное состояние. Так же появляются проблеммы при мердже кода, изменения накладываются и перекрывают друг-друга, правки для одной части экрана косвенно затрагивают другие части.
Отсюда следует правило, что экран мы должны разбить на маленькие и независимые компоненты. Каждый компонент будет содержать минимум кода и будет максимально изолирован.
Требования к компоненту
- Единая ответственность. Один компонент определяет какую-то минимальную бизнес сущность.
- Простота имплементации. Компонент должен содержать минимальное количество кода.
- Независимость. Компонент не должен знать ничего о прочих компонентах на странице.
- Анонимность коммуникаций. Общение между компонентами на одном экране должно осуществляться через отдельную сущность, которая в свою очередь, должна лишь получать входные данные и не знать какой именно компонент передал эти данные.
- Единое состояние UI. Это поможет нам легко восстанавливать состояние экрана и понимать что именно показано на экране в данный момент.
- Unidirectional data flow . Состояние компонента должно быть однозначно определено и должна быть только одна сущность способная изменить это состояние.
- Отключаемость. Каждый компонент должен быть легко отключаемый через механизм feature toggles.
К примеру, в одном из компонентов есть критический баг, и нужно выключить целый компонент, чтобы избежать ошибки. В другом случае, мы включаем какую-то фичу только для определенных пользователей.
Как работает компонент
- На вход компонент получает данные из внешнего источника (DomainObject).
- На основе этих данных формируется состояние экрана (UI State).
- Состояние экрана отображается для пользователя.
- Если пользователь что-то сделал (нажал на кнопку, к примеру), то формируется действие (Action) которое передается в сущность, отвечающую за бизнес логику компонента. Бизнес логика решает нужно ли сразу сформировать новый UI State, или же отправить действие (Action) дальше в сущность за пределами компонента, назовем ее Service. Другой компонент также может быть подписан на Service и обновлять свое состояние (см. пункт 1).
Архитектура страницы
Как мы уже говорили, компоненты на экране ни чего не знаю друг о друге, но могут отравлять свои события (Actions) в общие сущности (Service). Компоненты также подписаны на Service и получают обновленные данные (DomainObjects) обратно. В качестве Service могут выступать какие-то глобальные сущности как: UserService, PaymentsService, CartService, или же локальный сервис страницы: ProductDetailsService, OrderService.
Другими словами, мы можем сказать, что каждый отдельный компонент это маленький MVP/MVC/MVVM/MVI или то к чему вы привыкли на своем проекте, но каждый из этих компонентов удовлетворяет условиям работы компонента (выше).
Стейт машина
Архитектура такой страницы — это по своей сути стейт машина которая принимает события от пользовательского интерфейса и от внутренних сервисов приложения, и на основе их формирует стейт данных, который, в свою очередь, и отображают компоненты.
Введем новые сущности:
- Middleware — обрабатывает входящие события (Actions). Также может создать собственное событие, для взаимодействия с другими Middleware или внешними объектами. По сути вся бизнес логика для работы с событиями здесь.
- Reducer — берет текущий стейт и объединяет его с новым стейтом из Middleware. На выходе он рассылает новый стейт для подписанных компонентов.
В зависимости от подхода, Middleware и Reducer может быть один на весь экран или даже приложение, или же каждая бизнес сущность будет иметь свой Middleware и Reducer.
Про разные идеи имплементаций можете почитать тут: Flux, Redux, MVI
Server Drive UI
По скольку мы уже имеем полность автономные модули, которые получают на вход данные (DomainObject), мы можем получать список этих модулей с сервера и динамически конфигурировать структуру экрана. Это позволяет нам динамически менять контент на экране без необходимости публикации новой версии приложения в Play Store/App Store. Да, маркетинговая команда будет рада такой возможности!
От сервера мы можем получать список компонентов на экране с указанием их типа, положения на экране, версии, и данных необходимых для работы и отображения.
{
"components": [
{
"type": "toolbar",
"version": 3,
"position": "header",
"data": {
"title": "Profile",
"showUpArrow": true
}
},
{
"type": "user_info",
"version": 1,
"position": "header",
"data": {
"id": 1234,
"first_name": "Alexey",
"last_name": "Glukharev"
}
},
{
"type": "user_photo",
"position": "header",
"version": 2,
"data": {
"user_photo": "https://image_url.png"
}
},
{
"type": "menu_item",
"version": 1,
"position": "content",
"data": {
"text": "open user details",
"deeplink": "app://user/detail/1234"
}
},
{
"type": "menu_item",
"version": 1,
"position": "content",
"data": {
"text": "contact us",
"deeplink": "app://contact_us"
}
},
{
"type": "button",
"version": 1,
"position": "bottom",
"data": {
"text": "log out",
"action": "log_out"
}
}
]
}
Вопросы?
Я учавствовал в разработке нескольких больших проектов пользуясь этой архитектурой, и я открыт как к вопросам по архитектуре в целом, так и техническим деталям Android имплементации.