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

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

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

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

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

Типы модулей

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Общие модули

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

  • Модуль пользовательского интерфейса . Если вы используете пользовательские элементы пользовательского интерфейса или тщательно продумываете фирменный стиль в своем приложении, вам следует рассмотреть возможность инкапсуляции вашей коллекции виджетов в модуль для повторного использования всех функций. Это может помочь сделать ваш пользовательский интерфейс единообразным для различных функций. Например, если ваша тематика централизована, вы можете избежать болезненного рефакторинга при ребрендинге.
  • Модуль аналитики : отслеживание часто диктуется бизнес-требованиями без особого внимания к архитектуре программного обеспечения. Аналитические трекеры часто используются во многих несвязанных компонентах. Если это так, возможно, было бы неплохо иметь специальный аналитический модуль.
  • Сетевой модуль : если многим модулям требуется сетевое подключение, вы можете рассмотреть возможность использования модуля, предназначенного для предоставления 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, чтобы сделать объявления конфиденциальными для модуля. При объявлении зависимостей в вашем модуле отдавайте предпочтение implementation , а не api . Последний предоставляет транзитивные зависимости потребителям вашего модуля. Использование реализации может сократить время сборки, поскольку уменьшает количество модулей, которые необходимо пересобрать.

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

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

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

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