[Из песочницы] Масштабируемая архитектура для больших мобильных приложений

В этой статье мы не будем разбирать MVP, MVVM, MVI или что-то подобное. Сегодня мы поговорим о более глобальной вещи, чем просто архитектура уровня представления. Как спроектировать действительно большое приложение, в котором смогут комфортно работать десятки или сотни разработчиков? То приложение, которое легко расширять независимо от того, как много кода мы уже написали.


Требования к большим проектам:


  1. Слабая связность кода. Любые изменения должны затрагивать как можно меньше кода.
  2. Переиспользование кода. Одинаковые вещи должно быть легко переиспользовать без copy-past’a.
  3. Легкость расширения. Разработчику должно быть легко добавлять новый функционал в существующий код.
  4. Стабильность. Любой новый код можно легко отключить с помощью feature toggles, особенно если вы используете trunk-based development.
  5. Владение кодом. Проект должен быть разделен на модули, что бы легко было назначить владельца для каждого модуля. Это поможет нам на этапе code review. И тут не только про крупные вещи, как Gradle/Pods модули, но и обычные фичи, у которых то же могут быть разные владельцы.


Компонент

image

Вот типичный экран приложения. Обычно мы берем какую-то архитектуру слоя представления (MV*) и делаем Presenter/ViewModel/Interactor/что-то ещё для этого экрана. Но когда команда растет, все хотят что-то менять на этом экране и использовать какие-то части этого экрана на других. Чем больше экран, тем больше всяких событий, порождаемых пользователем и системой, и зачастую наступает момент когда уже тяжело понять что в данный момент времени происходит на экране и что нужно показать/скрыть/поменять на экране что бы отобразить нужное состояние. Так же появляются проблеммы при мердже кода, изменения накладываются и перекрывают друг-друга, правки для одной части экрана косвенно затрагивают другие части.

Отсюда следует правило, что экран мы должны разбить на маленькие и независимые компоненты. Каждый компонент будет содержать минимум кода и будет максимально изолирован.

image


Требования к компоненту


  1. Единая ответственность. Один компонент определяет какую-то минимальную бизнес сущность.
  2. Простота имплементации. Компонент должен содержать минимальное количество кода.
  3. Независимость. Компонент не должен знать ничего о прочих компонентах на странице.
  4. Анонимность коммуникаций. Общение между компонентами на одном экране должно осуществляться через отдельную сущность, которая в свою очередь, должна лишь получать входные данные и не знать какой именно компонент передал эти данные.
  5. Единое состояние UI. Это поможет нам легко восстанавливать состояние экрана и понимать что именно показано на экране в данный момент.
  6. Unidirectional data flow . Состояние компонента должно быть однозначно определено и должна быть только одна сущность способная изменить это состояние.
  7. Отключаемость. Каждый компонент должен быть легко отключаемый через механизм feature toggles.
    К примеру, в одном из компонентов есть критический баг, и нужно выключить целый компонент, чтобы избежать ошибки. В другом случае, мы включаем какую-то фичу только для определенных пользователей.


Как работает компонент

image


  1. На вход компонент получает данные из внешнего источника (DomainObject).
  2. На основе этих данных формируется состояние экрана (UI State).
  3. Состояние экрана отображается для пользователя.
  4. Если пользователь что-то сделал (нажал на кнопку, к примеру), то формируется действие (Action) которое передается в сущность, отвечающую за бизнес логику компонента. Бизнес логика решает нужно ли сразу сформировать новый UI State, или же отправить действие (Action) дальше в сущность за пределами компонента, назовем ее Service. Другой компонент также может быть подписан на Service и обновлять свое состояние (см. пункт 1).


Архитектура страницы

Как мы уже говорили, компоненты на экране ни чего не знаю друг о друге, но могут отравлять свои события (Actions) в общие сущности (Service). Компоненты также подписаны на Service и получают обновленные данные (DomainObjects) обратно. В качестве Service могут выступать какие-то глобальные сущности как: UserService, PaymentsService, CartService, или же локальный сервис страницы: ProductDetailsService, OrderService.

image

Другими словами, мы можем сказать, что каждый отдельный компонент это маленький MVP/MVC/MVVM/MVI или то к чему вы привыкли на своем проекте, но каждый из этих компонентов удовлетворяет условиям работы компонента (выше).


Стейт машина

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

Введем новые сущности:


  • Middleware — обрабатывает входящие события (Actions). Также может создать собственное событие, для взаимодействия с другими Middleware или внешними объектами. По сути вся бизнес логика для работы с событиями здесь.
  • Reducer — берет текущий стейт и объединяет его с новым стейтом из Middleware. На выходе он рассылает новый стейт для подписанных компонентов.

image

В зависимости от подхода, Middleware и Reducer может быть один на весь экран или даже приложение, или же каждая бизнес сущность будет иметь свой Middleware и Reducer.
Про разные идеи имплементаций можете почитать тут: Flux, Redux, MVI


Server Drive UI

По скольку мы уже имеем полность автономные модули, которые получают на вход данные (DomainObject), мы можем получать список этих модулей с сервера и динамически конфигурировать структуру экрана. Это позволяет нам динамически менять контент на экране без необходимости публикации новой версии приложения в Play Store/App Store. Да, маркетинговая команда будет рада такой возможности!

image

От сервера мы можем получать список компонентов на экране с указанием их типа, положения на экране, версии, и данных необходимых для работы и отображения.

{
  "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 имплементации.

© Habrahabr.ru