Общие шаблоны модульности

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

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

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

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

Типы модулей

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

Модули данных

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

  1. Инкапсуляция всех данных и бизнес-логики определённой предметной области : каждый модуль данных должен отвечать за обработку данных, представляющих определённую предметную область. Он может обрабатывать множество типов данных, если они связаны между собой.
  2. Предоставьте доступ к репозиторию как к внешнему API : публичный API модуля данных должен быть репозиторием, поскольку он отвечает за предоставление данных остальной части приложения.
  3. Скройте все детали реализации и источники данных от внешнего мира : источники данных должны быть доступны только репозиториям из того же модуля. Они остаются скрытыми от внешнего мира. Вы можете обеспечить это с помощью ключевых слов Kotlin private или internal visible.
Рисунок 1. Примеры модулей данных и их содержимое.

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

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

Рисунок 2. Каждую вкладку этого приложения можно определить как функцию.

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

Рисунок 3. Примеры функциональных модулей и их содержимое.

Модули приложений

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

Рисунок 4. График зависимости модулей вкусов продукта *Demo* и *Full*.

Если ваше приложение предназначено для нескольких типов устройств, таких как Android Auto, Wear или телевизор, определите модуль приложения для каждого из них. Это поможет разделить зависимости, специфичные для каждой платформы.

Рисунок 5. График зависимости приложений Android Auto.

Общие модули

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

  • Модуль пользовательского интерфейса : Если вы используете в своём приложении нестандартные элементы пользовательского интерфейса или сложный брендинг, вам следует рассмотреть возможность инкапсуляции коллекции виджетов в модуль для повторного использования всех функций. Это поможет обеспечить единообразие пользовательского интерфейса для различных функций. Например, если у вас централизованная тематика, вы сможете избежать болезненного рефакторинга при ребрендинге.
  • Модуль аналитики : Отслеживание часто диктуется бизнес-требованиями, не принимая во внимание архитектуру программного обеспечения. Аналитические трекеры часто используются во множестве не связанных между собой компонентов. В этом случае вам может быть полезен отдельный модуль аналитики.
  • Сетевой модуль : если сетевое подключение требуется многим модулям, можно рассмотреть возможность использования отдельного модуля для предоставления HTTP-клиента. Это особенно полезно, если клиент требует индивидуальной настройки.
  • Модуль утилит : утилиты, также известные как вспомогательные функции, обычно представляют собой небольшие фрагменты кода, которые повторно используются в приложении. Примерами утилит могут служить вспомогательные функции тестирования, функция форматирования валюты, валидатор адресов электронной почты или пользовательский оператор.

Тестовые модули

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

Примеры использования тестовых модулей

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

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

  • Более чистые конфигурации сборки : Тестовые модули позволяют создавать более чистые конфигурации сборки, поскольку у них может быть собственный файл build.gradle . Вам не нужно загромождать файл build.gradle модуля приложения конфигурациями, которые нужны только для тестов.

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

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

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

Связь между модулями

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

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

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

navController.navigate("checkout/$bookId")

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

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

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

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

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

Рисунок 8. Два функциональных модуля, использующих общий модуль данных.

Инверсия зависимости

Инверсия зависимостей — это когда вы организуете свой код таким образом, что абстракция отделена от конкретной реализации.

  • Абстракция : контракт, определяющий взаимодействие компонентов или модулей вашего приложения. Модули абстракции определяют API вашей системы и содержат интерфейсы и модели.
  • Конкретная реализация : модули, которые зависят от модуля абстракции и реализуют поведение абстракции.

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

Рисунок 9. Вместо того, чтобы высокоуровневые модули напрямую зависели от низкоуровневых модулей, высокоуровневые и реализационные модули зависят от модуля абстракции.

Пример

Представьте себе функциональный модуль, которому для работы нужна база данных. Функциональному модулю не важно, как реализована база данных, будь то локальная база данных Room или удалённый экземпляр Firestore. Ему нужно только хранить и читать данные приложения.

Для этого функциональный модуль зависит от модуля абстракции, а не от конкретной реализации базы данных. Эта абстракция определяет API базы данных приложения. Другими словами, она устанавливает правила взаимодействия с базой данных. Это позволяет функциональному модулю использовать любую базу данных, не зная деталей её реализации.

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

Внедрение зависимости

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

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

Преимущества

Преимущества разделения API и их реализаций заключаются в следующем:

  • Взаимозаменяемость : благодаря чёткому разделению API и модулей реализации вы можете разрабатывать несколько реализаций одного и того же API и переключаться между ними, не изменяя код, использующий API. Это может быть особенно полезно в сценариях, где требуется обеспечить разные возможности или поведение в разных контекстах. Например, имитационная реализация для тестирования и реальная реализация для эксплуатации.
  • Разделение : разделение означает, что модули, использующие абстракции, не зависят от какой-либо конкретной технологии. Если вы решите впоследствии перейти с базы данных Room на Firestore, это будет проще, поскольку изменения будут внесены только в конкретный модуль, выполняющий задачу (модуль реализации), и не повлияют на другие модули, использующие API вашей базы данных.
  • Тестируемость : разделение API и их реализаций может значительно упростить тестирование. Вы можете писать тестовые случаи для контрактов API. Вы также можете использовать разные реализации для тестирования различных сценариев и пограничных случаев, включая имитационные реализации.
  • Повышенная производительность сборки : при разделении API и его реализации на отдельные модули изменения в модуле реализации не заставляют систему сборки перекомпилировать модули в зависимости от модуля API. Это приводит к ускорению сборки и повышению производительности, особенно в крупных проектах, где время сборки может быть значительным.

Когда расставаться

Разделение API и их реализаций целесообразно в следующих случаях:

  • Разнообразные возможности : если вы можете реализовать части своей системы различными способами, понятный API обеспечивает взаимозаменяемость различных реализаций. Например, у вас может быть система рендеринга, использующая OpenGL или Vulkan, или биллинговая система, работающая с Play или вашим внутренним биллинговым API.
  • Несколько приложений : если вы разрабатываете несколько приложений с общими возможностями для разных платформ, вы можете определить общие API и разработать конкретные реализации для каждой платформы.
  • Независимые команды : разделение позволяет разным разработчикам или командам одновременно работать над разными частями кодовой базы. Разработчикам следует сосредоточиться на понимании контрактов API и их правильном использовании. Им не нужно беспокоиться о деталях реализации других модулей.
  • Большая кодовая база : если кодовая база большая или сложная, разделение API и реализации делает код более управляемым. Это позволяет разбить кодовую базу на более мелкие, понятные и удобные для поддержки модули.

Как реализовать?

Чтобы реализовать инверсию зависимостей, выполните следующие действия:

  1. Создайте модуль абстракции : этот модуль должен содержать API (интерфейсы и модели), определяющие поведение вашей функции.
  2. Создание модулей реализации : Модули реализации должны опираться на модуль API и реализовывать поведение абстракции.
    Вместо того чтобы высокоуровневые модули напрямую зависели от низкоуровневых модулей, высокоуровневые и реализационные модули зависят от модуля абстракции.
    Рисунок 10. Модули реализации зависят от модуля абстракции.
  3. Сделайте высокоуровневые модули зависимыми от модулей абстракции : вместо того, чтобы напрямую зависеть от конкретной реализации, сделайте свои модули зависимыми от модулей абстракции. Высокоуровневым модулям не нужны детали реализации, им нужен только контракт (API).
    Модули высокого уровня зависят от абстракций, а не от реализации.
    Рисунок 11. Модули высокого уровня зависят от абстракций, а не от реализации.
  4. Предоставьте модуль реализации : Наконец, вам необходимо предоставить фактическую реализацию для ваших зависимостей. Конкретная реализация зависит от настроек вашего проекта, но обычно модуль приложения — подходящее место для этого. Чтобы предоставить реализацию, укажите её как зависимость для выбранного варианта сборки или исходного набора для тестирования .
    Модуль приложения обеспечивает фактическую реализацию.
    Рисунок 12. Модуль приложения обеспечивает фактическую реализацию.

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

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

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

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

  • Каталоги версий — это типобезопасный список зависимостей, генерируемый Gradle во время синхронизации. Это центральное место для объявления всех зависимостей, доступное всем модулям проекта.
  • Используйте плагины соглашений для совместного использования логики сборки между модулями.

Выставляйте напоказ как можно меньше

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

Предпочитаю модули Kotlin и Java

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

  • Модули приложения — это точка входа в ваше приложение. Они могут содержать исходный код, ресурсы, активы и файл AndroidManifest.xml . Результатом работы модуля приложения является пакет приложений Android App Bundle (AAB) или пакет приложений Android Application Package (APK).
  • Библиотечные модули имеют то же содержимое, что и модули приложения. Они используются другими модулями Android как зависимость. Результатом работы библиотечного модуля является архив Android (AAR), который структурно идентичен модулям приложения, но компилируется в файл архива Android (AAR), который впоследствии может использоваться другими модулями как зависимость . Библиотечный модуль позволяет инкапсулировать и повторно использовать одну и ту же логику и ресурсы во многих модулях приложения.
  • Библиотеки Kotlin и Java не содержат никаких ресурсов Android, активов или файлов манифеста.

Поскольку модули Android имеют накладные расходы, желательно по возможности использовать Kotlin или Java.