Масштабируемая архитектура для больших мобильных приложений

В этой статье мы не будем разбирать 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


Эту структуру так же можно применить и к компонентам:
image


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

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 10

    0
    Спасибо за статью! А каковы особенности реализации? User Information Component, Menu Component, Action Button Component — это всё отдельные кастомные View или унифицированные элементы в RecyclerView? И второй вопрос: какого размера JSON с описанием всех экранов приложения обычно получается?
      0
      User Information Component, Menu Component, Action Button Component — это не только View, но и классы отвечающие за бизнеслогику, к примеру: UserInformationView + UserInformationPresenter + UserInformationInteractor

      Для RecyclerView это могут быть: UserInformationViewHolder + UserInformationPresenter + UserInformationInteractor

      То же самое если кажый компонент — это MVVM: UserInformationView + UserInformationViewModel + UserInformationInteractor/Repository

      Размер JSON — это не самая критичная вещь, ведь можно использовать gzip для сжатия HTTP тарафика, а если совсем прижмет, то использовать ProtoBuf, но это совсем редкий случай.

      Еще раз на счет компонентов, их стоит рассматривать как View + какие-то сущности отвечающие за логику. Можно использовать любую архитектуру. Но, т.к. тут по большому счету работа со стейтом, я бы рекомендовал MVI
      0
      В зависимости от подхода, Middleware и Reducer может быть один на весь экран или даже приложение, или же каждая бизнес сущность будет иметь свой Middleware и Reducer

      А вот можно поподробнее про "один на всё приложение"? Насколько это подходит для мобильных приложений на ваш взгляд? Есть ли примеры таких приложений?(Только больших, а не с 2-3 экранами).

        0
        Один на все приложение, это скорее специфичный случай, такое не редко в вебе, но для мобильных я вряд ли встречал что-то подобное. Но в мобильных приложения часто встречается один Reducer на экран, и он принимает в себя все входящие состояния из множества Middleware и строит общий стейт всего экрана.

        Так же часто у каждого компонента свой стейт и свой Reducer он его обновляет независимо.

        Для Server Direven UI архитектур — часто один reducer на экран
          0

          А почему в мобилках не используется подход один на всё, а чаше разделяют по экранам?
          Я просто не знаю веб, и интересно почему там это ок, а у нас в мобилках не ок..

        0

        Ещё несколько вопросов:
        1) Что делает блок Business logic внутри компонента? Только преобразуется state в business object? Что если объединить его с ViewModel и сразу делать state->viewobject?
        2) Представим ситуацию. У вас на экране два компонента. В одном чекбокс, а в другом кнопка. По чекбоксу надо делать enable/disable кнопки. Как будет выглядеть путь? Пройдёт ли он через всё, или это сразу будет выполнено во view слое?

          0
          1) Это стандартное разделение бизнес и презентешйн логики. Ну если ни кто из команды не топит за Clean Arcitecture, и вы точно знаете, что логики много не будет, то можно объединить. Но в целом, обычно, вещи которые отвечают за модификацию и работу с UI находятся в ViewModel, а более выскокоуровневые штуки для работы с данными уже уровнем выше Interactor/Repository. Так же если выделить всю бизнеслогику в отдельный класс — это пять увеличит читабельность, будет легче тестировать, изменения в коде будут изолированы

          2) Да, так как у нас Unidirectional data flow, то пройдет весь путь
          — пользователь тапнул на чекбокс
          — View отправит Action
          — Бизнес логика его поймает и сделает две вещи:
          1. скажет своей ViewModel что пора изменить UI стейт. И ViewModel отрисует выбранный чекбокс
          2. отправит Action дальше в некий Service экрана
          — Service экрана примит Action и передаст его компоненту с кнопкой
          — компонент с кнопкой, на основе этого Action обновит состояние кнопки
            0
            ViewModel в общем случае содержит модели данных, которые можно отобразить. Презентацию сделает фрагмент или binding компонент.
            Можно написать ViewModel и без использования UI и платформенных компонент — на пустых экшнах и моделях.
          0

          Любопытная идея с составлением разметки на сервере. Видимо, основная идея была в избежании потребности обновления клиента. Разумеется, интересны моменты "НО":


          1. Часто ли возникают ситуации, когда под новые фичи всё равно приходится обновлять клиент?
          2. Какова политика по поддержке старых версий? Особенно любопытно, как поведёт себя старая версия, которая получит с сервера информацию о новом (неизвестном ей) компоненте.
          3. Насколько это оправданно, если проект по тем или иным причинам предполагается только на Android? То есть, клиент нужно писать только один.
          4. Сравнима ли сложность написания "парсинга" конфигурации с сервера с простым написанием такого же приложения с нуля? Получится ли потом повторно использовать такой же механизм на новом проекте?
            0

            А как решать проблему с анимациями между компонентами? Например, есть экран на котором есть несколько компонент взаимодействия с которым приводят к анимациям с изменением вёрстки(смещения, расскрытия, сдвиги и т.д). Как применять server driven UI в таких случаях? Уже нельзя просто взять и убрать/добавить компонент из ответа сервера. Анимация сломается.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое