Эта статья - перевод недавно вышедшего гайда о модуляризации Android-приложений от Google.

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

Принципы высокой сцепленности и слабой связанности

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

Слабая связанность означает, что модули должны быть максимально независимы друг от друга, таким образом изменения в одном модуле не влияют на другие модули. Модули не должны знать о внутренней реализации других модулей.

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

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

Типы модулей

То, как вы организуете модули, в основном зависит от архитектуры вашего приложения. Ниже приведены некоторые распространенные примеры модулей, которые вы можете добавить в свое приложение, следуя рекомендуемой архитектуре Android-приложений.

Data-модули (модули данных).

Data-модуль обычно содержит репозиторий, источники данных и классы моделей. Три основные обязанности data-модуля:

  1. Инкапсуляция, сокрытие данных и бизнес-логики для конкретной предметной области, домена. Каждый data-модуль отвечает за обработку и работу с данными представляющими конкретный домен, предметную область. Он может обрабатывать многие типы данных, если они связаны.

  2. Предоставить репозиторий в качестве внешнего API. Общедоступный API data-модуля должен быть репозиторием, поскольку он отвечает за предоставление данных остальной части приложения.

  3. Скрыть доступ к деталям реализации и Data Source: Источники данных (Data Source) должны быть доступны только репозиториям из того же модуля. Они остаются скрытыми от доступа снаружи. Вы можете обеспечить это, используя ключевое слово private или internal visibility.

Рисунок 1. Пример Data-модулей и их структура. Оранжевым цветом выделены feature-модули, о которых будет ниже.
Рисунок 1. Пример Data-модулей и их структура. Оранжевым цветом выделены feature-модули, о которых будет ниже.

Feature-модули. Функциональные модули.

Под фичей (feature) подразумевается изолированная часть функциональности приложения, которая обычно соответствует экрану или набору тесно связанных экранов, таких как процесс регистрации или оформления заказа. Если в вашем приложении есть Bottom Bar для навигации, вполне вероятно, что каждый пункт назначения является отдельной фичей.

Каждая вкладка может быть отдельной фичей.
Каждая вкладка может быть отдельной фичей.

Фичи (feature) связаны с экранами или местами назначения в вашем приложении. Они, скорее всего, будут иметь связанный пользовательский интерфейс и ViewModel для обработки своей логики и состояния. Примером может быть функционал оплаты, авторизация пользователя. Функциональность не обязательно должна быть ограничена одним экраном или местом навигации. Feature-модули зависят от data-модулей.

Пример feature-модулей и их структура. Такие модули зависят от data-модулей.
Пример feature-модулей и их структура. Такие модули зависят от data-модулей.

App-модули. Модули приложения.

App-модули являются точкой входа в приложение. Они зависят от feature-модулей и обычно обеспечивают корневую навигацию. Один модуль приложения может быть скомпилирован в несколько разных двоичных файлов благодаря разным вариантам сборки (build variant).

Граф зависимостей для демо и полной версии приложения, в зависимости от выбранного варианта сборки
Граф зависимостей для демо и полной версии приложения, в зависимости от выбранного варианта сборки

Если ваше приложение предназначено для нескольких типов устройств, таких как android auto, android wear или android TV, вы можете разделить модули приложения для каждого из устройств. Это помогает отделить зависимости от конкретной платформы.

Граф зависимостей для приложения Android Auto
Граф зависимостей для приложения Android Auto

Common-модули. Общие модули.

Общие модули, также известные как базовые (core) модули, содержат код, который часто используется другими модулями. Они уменьшают избыточность и не представляют какой-либо конкретный уровень в архитектуре приложения. Ниже приведены примеры общих модулей:

  • UI-модуль. Если у вас есть настраиваемые UI-компоненты или сложные брендинг, вы можете выделить UI-виджеты в отдельный модуль для дальнейшего повторного использования. Это поможет сделать UI вашего приложения консистентным. Кроме того, если ваш UI выделен в отдельный модуль, то вы сможете избежать болезненного рефакторинга при ребрендинге.

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

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

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

Взаимодействие между модулями.

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

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

Чтобы решить эту проблему, можно иметь третий модуль, который является посредником между двумя другими модулями. Модуль-посредник может прослушивать сообщения от обоих модулей и пересылать их по мере необходимости. В нашем примере приложения экран проверки должен знать, какую книгу купить, даже если событие возникло на отдельном экране, который является частью другой функции. В этом случае посредником является модуль, которому принадлежит навигационный граф (обычно это модуль приложения). В этом примере мы используем навигацию для передачи данных из feature-модуля Home в feature-модуль Checkout с помощью компонента навигации.

navController.navigate("checkout/$bookId")

Используя в качестве аргумента id книги можно будет получить полную информацию о книге.

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

Вы не должны передавать объекты в качестве аргументов для навигации. Вместо этого используйте простые идентификаторы, через которые можно загрузить данные используя data-слой. Таким образом, вы поддерживаете низкую связанность и не нарушаете принцип единственного источника истины (single source of truth principle).

В приведенном ниже примере оба feature-модуля зависят от одного и того же модуля данных (data:books). Это позволяет свести к минимуму объем данных, которые модуль-посредник должен пересылать, и поддерживает низкую связь между модулями. Вместо передачи объектов модули должны обмениваться идентификаторами примитивов и загружать ресурсы из общего data-модуля.

Два feature-модуля которые используют один общий data-модуль для получения данных.
Два feature-модуля которые используют один общий data-модуль для получения данных.

Общие рекомендации

Как упоминалось в начале, не существует единственно правильного способа разработки многомодульного приложения. Точно так же, как существует множество программных архитектур, существует множество способов разбиения приложения на модули. Тем не менее, следующие общие рекомендации помогут вам сделать код более читабельным, удобным для сопровождения и тестирования.

Поддерживайте консистентную конфигурацию

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

Свести к минимуму публичный API

Публичный интерфейс модуля должен быть минимальным и отображать только самое необходимое. Детали реализации не должны быть доступны наружу. Используйте модификаторы private или internal  для уменьшения области видимости, чтобы сделать методы или свойства приватными для модуля. При объявлении зависимостей в вашем модуле отдавайте предпочтение подключению через implementation а не api. Последний предоставляет транзитивные зависимости потребителям вашего модуля. Использование implementation может сократить время сборки, поскольку уменьшает количество модулей, которые необходимо пересобрать после изменений.

Предпочитайте Kotlin и Java-модули вместо Android-модулей.

Android Studio поддерживает три основных типа модулей:

  • App-модули, модули приложения — это точка входа в ваше приложение. Они могут содержать исходный код, ресурсы, Activity и файл AndroidManifest.xml. Результатом модуля приложения является артефакт приложения Android (AAB или APK).

  • Модули библиотеки имеют то же содержимое, что и App-модули. Они используются другими модулями Android в качестве зависимости. Выходные данные библиотечного модуля — это Android-архив (AAR), структурно идентичные модулям приложения, но они скомпилированы в AAR, который впоследствии может использоваться другими модулями в качестве зависимости. Модуль библиотеки позволяет инкапсулировать и повторно использовать одну и ту же логику и ресурсы во многих App-модулях.

  • Kotlin и Java-модули не содержат никаких ресурсов Android, Activity или файлов Android-манифеста. Если в вашем модуле нет зависимости от Android SDK, желательно, чтобы вы использовали Kotlin или Java - модуль.

Не забудьте присоединиться к нам в Telegram, а на платформе  AndroidSchool.ru[ссылка удалена модератором] публикуются полезные материалы для Android-разработчика и современные туториалы.

Комментарии (11)


  1. jershell
    12.09.2022 23:44

    Жаль перевод. Есть несколько вопросов и дополнений. Часто вижу как делить, но ни где не видел зачем делить. Хоть бы один какделитель написал бы, что это нужно, когда у вас instant или вы шарите между несколькими разными приложениями какой-либо функционал, в остальных случаях деление почти всегда избыточно, а пакетов хватает с лихвой. Так же что есть и цена этого деления, поддержка клея между модулями тоже требует и время и увеличивает сложность проекта. Чем больше модулей, тем больше клея, тем дороже эта модульность обходится.Без явной практической необходимости не стоит этого делать.


    1. android_school_ru Автор
      13.09.2022 00:25
      +2

      Современные android-приложения уже давно переваливают за несколько сотен экранов. Таким образом деление на модули - способ организации кода для дальнейшего переиспользования и уменьшения связанности даже в пределах одного приложения. В статье есть примеры data-модулей, утилитных модулей - они могут пригодиться даже в небольшом проекте. А если у вас команда в проекте больше 20 android-разработчиков, то выделение в отдельные фича -модули позволяют ускорить разработку, уменьшить кол-во merge-конфликтов, снизить кол-во ошибок во всем приложении. Не зря большие команды типа Uber имеют несколько десятков, а то и сотен модулей. Если у вас небольшое приложение с небольшой командой - то скорее всего разбиение на модули и не нужно.


      1. ChPr
        13.09.2022 01:34
        +1

        модули позволяют ускорить разработку, уменьшить кол-во merge-конфликтов, снизить кол-во ошибок во всем приложении.

        В последнее время многомодульность преподносится в виде решения вообще всех насущных проблем.

        Мердж конфликты появляются когда несколько человек трогают одно и то же место, нет никакой разницы, будет это место в одном из сотен модулей или в одном единственном. Если экраны раскидать по пакетам как по модулям, а не городить package by type, то и конфликтов будет ровно столько же.

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

        И все это идет с огромной пачкой проблем по поддержанию корректности структуры модулей и поддержки Gradle конфигураций.


    1. WraithOW
      13.09.2022 12:26
      +2

      в остальных случаях деление почти всегда избыточно, а пакетов хватает с лихвой.

      Пока ваше приложение умещается в десяток-другой экранов - да. А потом наступает момент, когда вы начинаете тратить по 5-10-15 минут на сборку, потому что у вас мономодуль и фишки типа параллельной сборки и кеширования вам недоступны.


      1. jershell
        13.09.2022 12:37

        А есть цифры во сколько раз ускоряется сборка? После какого числа пакетов модульная сборка начинает обгонять обычную сборку? Почему параллельная сборка не работает в проекте "на пакетах"?


        1. WraithOW
          13.09.2022 13:04
          +1

          А есть цифры во сколько раз ускоряется сборка?

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

          После какого числа пакетов модульная сборка начинает обгонять обычную сборку?

          Та же история. В теории - с любого, хотя бы за счёт более агрессивного кеширования.

          Почему параллельная сборка не работает в проекте "на пакетах"?

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

          Если же у вас есть модули, то у системы сборки есть простые и четкие критерии того, что можно гарантированно параллелить и кешировать, а что нужно пересобрать. Если два модуля не зависят друг от друга - их можно параллелить. Если в модуле нет изменений и ни одна из его зависимостей не поменялась - модуль не нужно пересобирать, даже смотреть на него не нужно.

          Можете сами прикинуть, что проще обработать: граф модулей из 30 нод или граф зависимостей классов из нескольких тысяч нод.


    1. Paul85
      13.09.2022 21:49

      вот точно так же думаю. Я пришел к тому, что многомодульный проект будет оправдан если на проекте несколько команд или если какие-то фичи динамические и не во все билды нужны.


  1. snuk182
    13.09.2022 00:06
    +1

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


    1. android_school_ru Автор
      13.09.2022 00:29

      В основном, когда говорим о модулях в контексте android-разработки, то имеются ввиду Android-модули. Но иногда, если не нужны классы из Android SDK, то достаточно java или kotlin - библиотеки, но в оригинальной статье их тоже называют модулями.


    1. WraithOW
      13.09.2022 12:41

      Потому что в терминах Gradle и Maven, которые де-факто стандарты для Java/Kotlin разработки, подпроекты называются модулями. Библиотека - внешняя зависимость, модуль - обособленная часть проекта.

      Во всем мире это обычные библиотеки

      https://en.cppreference.com/w/cpp/language/modules


      1. snuk182
        13.09.2022 14:09

        По ссылке — реф на модули в плюсах, принятые только в стандарте С++20.
        В сборочных тулзах Java подмодулями называются именно подпроекты этих тулзов, но каждый из них собирается в библиотеку, в случае ванильной Java — в shared, в случае ведра — в AAR. В ведре действительно есть «модули», отвечающие просто за кусок расшаренного кода — это как раз то, что я и назвал странным. Впрочем, это терминология самого гугла.