Многомодульный BDSM: стоит ли внедрять Gradle модули и какие типы модулей бывают?
С каждым годом многомодульность в Android становится всё популярнее и популярнее. Выходит всё больше и больше статей, рассказывающих о ней. Но есть ощущение, что везде описывается просто подход, применяемый в рамках конкретного проекта. При этом можно заметить, что каждая компания применяет многомодульность по-своему.
Многомодульность — это лишь подход. Кому-то он может помочь, а кому-то и навредить. Во многих статьях лишь кратко касаются типов и структуры модулей. В этой статье я бы хотел это исправить, расписав, какие типы модулей вижу лично я. Потому что читая другие статьи мне постоянно не хватало каких-то типов модулей под конкретные ситуации.
Надеюсь, к концу статьи вы станете на чуточку ближе к ответам на вопросы: «Каким образом вообще можно внедрить многомодульность в свой проект?», «Какие типы модулей есть?» и «Нужна ли многомодульность в моём проекте?»
Многомодульность и кучки
Чтобы ответить на вопрос о том, нужна ли вам многомодульность, придётся сначала ответить на вопрос: «Что она нам даёт?». Грубо говоря, это разделение одной большой кучи гавнокода на кучки поменьше. И-и-и что? Звучит как что-то не слишком полезное. Но всё дело тут в связях между этими кучками.
Маленькие кучки теперь не могут просто так брать и использовать содержимое другой кучки. Это можно провернуть только с той кучкой, с которой у нас есть связь. Более того, нам доступно не всё содержимое этой кучки, а только лишь та часть, которую другая кучка нам предоставит. Если кучка что-то не должна предоставлять, то это можно пометить ключевым словом internal в Kotlin.
«связь» означает отношения между Gradle модулями, а «использует»/«не использует» между кодовой базой этих модулей
За счёт этого у нас естественным образом улучшается уровень важных для хорошего ООП приложения параметров. Ведь внутри кучки можно что-то скрыть (сокрытие), собрать в одной кучке связанные между собой классы (инкапсуляция). Звучит уже неплохо.
А что это за связи такие? Это возможность сборщика, в нашем случае Gradle (в статье будет он, так как он самый популярный в Android, да и знаю я только его), подключить один кусок кода к другому. Для этого в build.gradle модуля мы просто указываем, что хотим подключить и как. Все хоть раз писали что-то вроде:
implementation 'androidx.appcompat:appcompat:1.4.1'
Есть три важных для нас способа подключения: implementation, api, runtimeOnly.
Implementation
Основной и самый понятный способ. Если мы подключим таким образом модуль или библиотеку, то нам станет доступным для использования его/её код, а также при сборке эта библиотека попадет в наш .apk (при условии, что и наш модуль попадет в .apk).
Api
Если сделать такое подключение, то для нашего модуля вроде ничего и не изменится. Но-о-о. Тот, кто подключит к себе наш модуль, автоматически получит к себе все api зависимости нашего модуля. Это называется транзитивная зависимость.
Если вы понимаете, что для использования вашего модуля нужен доступ к коду используемой в нём сторонней зависимости, то можно подключить эту зависимость как api, и тому, кто подключит ваш модуль, уже не придётся подключать её.
Вроде бы всё хорошо, но с такими способом подключения надо быть аккуратным, если начать злоупотреблять api, то код опять начнет быть доступным всем и какой тогда вообще смысл от этой вашей модульности. К тому же, изменение в транзитивной зависимости приведет к полной пересборке всей цепочки, и если у модуля есть транзитивные зависимости, которые ему на самом деле не нужны, то модуль не начнёт собираться, пока все зависимости, в том числе транзитивные, не соберутся.
RuntimeOnly
Подключив таким образом другой модуль, вы НЕ сможете из своего модуля получить доступ к коду этого модуля до окончания сборки. Но его содержимое попадёт в .apk (при условии, что и наш модуль попадет в .apk) и уже в runtime вы сможете получить к нему доступ через рефлексию. Либо, если в таком модуле, что-то было объявлено в AndroidManifest (например ContentProvider), то оно будет работать также, как и в случае с другими модулями. Вам ничто не помешает открыть Activity по имени класса.
Иерархия
У всех троих способов есть одна общая и очень важная черта — они односторонние. «Нельзя просто так взять и» подключить модуль 1 к модулю 2, а модуль 2 к модулю 1. Либо одно, либо другое. Иначе получается циклическая зависимость — когда маленький гномик внутри компьютера будет бесконечно по кругу подключать один модуль к другому, пока не устанет, и сборщик не выдаст ошибку.
Такое ограничение заставляет нас выстроить иерархию кода. Чётко понимать, кто, кого и зачем использует, что косвенно приводит к ещё большему уровню инкапсуляции.
Иерархия кода приводит нас к иерархии модулей. Нужно чётко понимать, какие типы модулей может подключать данный тип модулей. Всё это нужно, чтобы избегать циклических зависимостей.
Все эти бонусы многомодульности приводят к самому, на мой взгляд, важному — уменьшению количества связей между компонентами приложения. Это приводит к меньшему количеству явных и неявных конфликтов в коде. В особенности в общих файлах типа strings, общих enum и т. п.
Есть два таких термина:
Зацепление — степень взаимодействия и взаимосвязанности между разными компонентами приложения;
Связность — степень взаимодействия и взаимосвязанности элементов внутри одного компонента.
Так вот, хорошим показателем является высокий уровень связности и низкий уровень зацепления. В одномодульном проекте делать код менее «зацепленным» и более «связным» — просто правило. В многомодульном же проекте — обязанность. Вам придётся делать код менее «зацепленным», иначе проект не соберётся из-за циклических зависимостей. Это словно статический анализатор, который бьёт вас по рукам за слишком большую длину строки, только здесь вас бьют за слишком сложные связи между модулями. Но как и в случае со статическим анализатором, платой станет время. Придётся тратить время на выстраивание иерархии и связей. Что в свою очередь, вообще может привести к проблеме конвергенции в многомодульном приложении.
А стоит ли?
На мой взгляд, многомодульность хорошо себя покажет в проекте с множеством разработчиков. Допустим, от пяти. Многомодульность поможет разделить их труд. Количество конфликтов уменьшится, а фичи не будут мешать друг другу попадать в релиз (и вылетать из него), так как их кодовая база не пересекается, если этого не требуется.
Если же у вас нет проблем с разделением и конфликтами, ваш проект небольшой и не собирается слишком разрастаться, то, как мне кажется, многомодульность не для вас. Уровень сокрытия, инкапсуляции, зацепления и связности кода можно улучшить другими способами, не прибегая к разделению на модули, хоть и следить за этим будет сложнее. В общем, как и с любым другим инструментом, не стоит интегрировать лишнего, если оно не решает вашу конкретную проблему.
Но есть ещё один аспект многомодульности, которым часто апеллируют, а именно — ускорение сборки. Но он начнёт работать в полную силу тогда, когда у вас на модули будет разделена большая часть проекта. Разделение приложения на модули, которое под них не проектировалось изначально, — довольно трудозатратный процесс. Если постараться перевести эти трудозатраты в деньги, то многомодульность может стать довольно дорогой. Если ваша цель состоит именно в ускорении сборки, то куда проще просто потратить эти деньги на мощный сервер и настроить на нём mainframer. Это даст куда больший прирост скорости сборки. Если же у вас настолько огромный проект, что приложение даже на очень мощном сервере собирается по несколько минут, то да, многомодульность вам поможет, но мне кажется, такое может случиться только в очень крупных компаниях, а им мои советы уже не очень нужны.
Допустим — стоит
Если же вы поняли, что многомодульность вам нужна. Решили, что пора всё разносить на модули. Но встаёт вопрос: «Как именно?». Как мне кажется, проще всего это получится понять, если разобраться: «Какие типы модулей бывают?». Поэтому я постарался систематизировать типы модулей в своей голове и получилось следующее:
главный модуль;
библиотека;
прослойка;
базовый модуль;
обёртка;
api модуль;
модули слои;
фичёвый модуль.
Остальное, как мне кажется, является частными случаями этих типов. Также многие (и я в их числе) разделение по слоям называют вертикальным, а по фичам — горизонтальным. В целом, по этой табличке вам станет понятно почему.
Остальные названия, скорее всего, вам пока ничего особо не говорят, ибо эти термины я придумал (ну или где-то слышал, но уже не помню где), так что цепляйтесь за идею, а не за названия. Давайте начнём от простого к сложному изучать то, что я там «насистематизировал».
Главный модуль
Он есть у всех, это наш app-модуль.
Какой красавец, не правда ли? Именно он является корневым, а в случае одномодульного приложения, ещё и единственным. С помощью него формируется .apk. В .apk попадут все модули, подключённые к нему прямо или транзитивно, а также все, подключённые уже к ним и т. д. По сути, при сборке Gradle начинает строить граф модулей, которые необходимо собрать.Так вот, корнем этого графа и является наш app. Если какой-либо из модулей не попадёт в этот граф, то он и не попадёт в .apk. Как Gradle определяет, какой из модулей главный? Всё довольно тривиально — у главного модуля в зависимостях указан плагин:
'com.android.application'
Тогда как у рядовых модулей один из двух плагинов:
'com.android.dynamic-feature'
'com.android.library'
Важный момент: главных модулей может быть несколько и использовать они могут совершенно разный набор модулей. Что позволяет делать разные приложения с общей кодовой базой.
Например, в нашем приложении два главных модуля. Один из них — наше основное приложение, второй — пример виджетов из ui-kit. Второй главный модуль нужен, чтобы собирать дизайнерам сборку со всеми нашими UI-компонентами, при этом кодовая база этих компонентов одна и та же для обоих главных модулей. Таким образом, дизайнеры могут видеть компоненты именно такими, какими они будут в основном приложении.
Ну, а выбирать между ними можно через окно выбора конфигурации:
Библиотека
Для начала, давайте попробуем ответить на такие вопросы: «Что вообще такое библиотека?» и «Какой код нужно в неё выносить?». Как по мне, библиотека — это какой-то код, который не привязан к архитектуре вашего приложения, он является обособленным и выполняет конечную атомарную задачу. Ключевым тут является именно фактор оторванности от архитектуры приложения. Такой код можно использовать где угодно, при этом архитектура конечного проекта совершенно не важна.
Например, как вы могли заметить, в нашем приложении библиотекой является UI Kit. Он никак не привязан к нашей архитектуре. В нём содержится код всех компонентов UI Kit.
Почему же мы решили вынести его в отдельную библиотеку? В теории у нашей компании может быть несколько приложений (одно время прям плотно рассматривали этот вопрос), но нам бы хотелось, чтобы они выглядели достаточно похоже. При этом оба наших приложения могут использовать совершенно разные архитектуры. Ведь не хочется завязывать все проекты на одну архитектуру, которая, в первую очередь, разрабатывалась для основного приложения.
Как подключать? К модулю, в котором библиотека необходима как implementation, или же если библиотека нужна везде, то как api в одном из базовых модулей.
Кого может подключать? Другие библиотеки.
Когда и кому может понадобиться? Такой подход стоит применять, в первую очередь, для кода, которым вы хотите поделиться между несколькими вашими приложениями. Либо же поделиться с другими людьми, не относящимися к вашей компании. Думаю, подобного рода модули и так есть во многих компаниях и вы хотели услышать вовсе не об этом типе, поэтому давайте двигаться дальше.
Прослойка
Прослойка нужна в качестве «нейтральной зоны» между вашим приложением и библиотекой. Допустим, у вас есть библиотека для оплаты. Вы поговорили с заказчиком и понимаете, что спустя некоторое время систему оплаты могут и заменить, на другую — более выгодную. Поэтому вашей целью становится недопуск классов этой библиотеки внутрь вашего основного приложения. Иначе, когда вы решите избавиться от этой библиотеки, то вам придётся переписывать приличную часть проекта. Вот здесь и пригодится модуль-прослойка.
Он полностью скрывает, что есть какая-то там библиотека для оплаты. Есть только он — модуль-обёртка, который и отвечает за оплату. Везде подключается именно он и только он знает о библиотеке. С помощью модулей можно гарантировать, что её классы не выйдут за пределы модуля прослойки. По сути, весь его код будет состоять из проксирующих классов и интерфейсов.
У нас в приложении такой подход используется, например, для библиотек показа рекламы.
Как подключать? К модулю, в котором прослойка необходима как implementation, или же если прослойка нужна везде, то как api в одном из базовых модулей.
Кого может подключать? Другие прослойки или библиотеки.
Когда и кому может понадобиться? Когда вы хотите гарантировать, что код библиотеки не попадёт в ваше приложение.
Базовые модули
Раз библиотека — это модуль, который не зависит от архитектуры приложения, то кажется логичным иметь модуль, в котором эта самая архитектура и содержится. Именно здесь лежат всякие BaseViewModel, BaseRepository, BaseValidator и прочие базовые классы архитектуры, к которым должны иметь доступ остальные модули. Ещё их называют core-модули, но я их называю базовыми, так как для меня ядро (core) в программировании — это совершенно другое.
Иметь всего один базовый модуль, честно говоря, идея не из лучших. Какому-то модулю, например, нужна только работа с сетью, а другому и работа с сетью, и работа с базами данных. Лучше сделать несколько модулей для разных ситуаций и подключать их по мере необходимости.
Главное не расплодить их слишком много, иначе это только навредит проекту. Нужно смотреть именно на то, как и где используются базовые компоненты. К примеру, если у вас к каждому запросу на сервер обязательно что-то кладётся в базу данных, то и разделять их нет смысла.
При этом, ничто не мешает подключать один базовый модуль к другому. Это даст возможность делать композицию. Например, вы хотите сделать базовый Repository который делает запрос на сервер, а результат кладёт в базу данных. Для этого просто создадим отдельный базовый модуль, например — base-data. Он как api подключит к себе base-network и base-database. Таким образом, когда кто-то захочет подключить его к себе — ему сразу будут доступны и работа с сетью и работа с базой данных и базовый Repository.
Сюда же, например, можно отнести ситуацию, когда у вас в двух модулях используется похожий UI и часть компонентов пересекаются. При этом они не относятся к UI Kit. Как по мне, вполне хорошим решением будет вынести их в отдельный базовый модуль — base-
Как подключать? Подключается как implementation, по сути, ко всем модулям вашего приложения, в которых нужен доступ к общим компонентам. Это все типы модулей кроме библиотек и прослоек. Либо как api к другому базовому модулю.
Когда и кому может понадобиться? Всем. У всех есть какая-то своя базовая архитектура приложения.
Кого может подключать? Библиотеки, прослойки и другие базовые модули. Чисто теоретически мне, не очень нравится подключение одного базового модуля к другому. Но на практике лучше так, чем постоянно объединять-разъединять базовые модули в зависимости от изменившихся условий. Ведь это трудозатратно и может привести к конфликтам. Так что, тут в моей голове появляется конфликт между «так правильно в теории» и «так дешевле и проще на практике».
Обёртки
Теперь давайте попробуем поговорить о типе модулей, который я называю обёрткой. Что это вообще такое? Это модуль, к которому подключается библиотека, а уже сам модуль-обёртка подключается к модулю проекта. Так это же модуль-прослойка?!! Не совсем. Обёртка может подключать к себе базовые модули с архитектурой приложения, позволяя тем самым как бы вписать библиотеку в вашу архитектуру по правилам, которые диктуете (или придумываете) вы. При этом библиотека подключается к модулю-обёртке как api. Таким образом, код библиотеки доступен в приложении, а обёртка лишь расширяет его возможности и подгоняет под архитектуру, являясь неким адаптером.
В нашем приложении такое произошло опять же с UI Kit. Есть отдельный модуль-библиотека ui-kit и есть отдельный модуль-обёртка для него, под названием ui-kit-wrapper. В нём компоненты UI Kit оборачиваются под нашу архитектуру, например, добавляется возможность использования компонента как ViewHolder для RecyclerView.
Как подключать? Подключается к модулю, в котором обёртка необходима как implementation, или же, если обёртка нужна везде, то как api в одном из базовых модулей.
Когда и кому может понадобиться? Если нужно расширить возможности библиотеки или подогнать под архитектуру проекта.
Кого может подключать? Библиотеку которую нужно обернуть как api и базовый модуль как implementation.
API модули
Api-модуль — особый модуль, который содержит в себе только интерфейсы, а также модели, исключения и прочие классы для работы с этим интерфейсом. При этом такой модуль не содержит в себе никакой логики. Реализация интерфейсов и вся логика работы содержатся в другом модуле.
Причин для создания такого модуля я вижу целых три.
Первая, если вам надо подменять реализацию какого-либо интерфейса.
Допустим, у вас есть как использование Google Play Services, так и Huawei Services. При этом хочется, чтобы в итоговый .apk попадал код только одного из них.
Для этого мы заведём api-модуль services, который у нас будет содержать все интерфейсы работы с сервисами. По сути, это будет некий «усреднённый API» этих двух библиотек, при этом вообще ничего не зная о коде этих библиотек. Реализации будут лежать каждая в своём отдельном модуле. При этом в build.gradle модуля app подключаем либо Google, либо Huawei. Допустим, это решается по параметру Store при сборке.
В самом приложении же везде подключаем только api-модуль. Таким образом, у нас могут быть разные реализации в зависимости от каких-либо условий сборки.
Вторая причина — развязывание зависимостей.
Допустим, у нас есть 2 модуля. Модулю 1 нужна информация из модуля 2, а модулю 2 нужна информация из модуля 1. Мы не можем подключить их друг другу, потому что получится циклическая зависимость.
Саму логику, которую хочет использовать другой модуль, вынести в отдельный модуль 3 не получится, так как общая логика потянет за собой всё остальное. Но мы можем выделить у каждого модуля отдельный api-модуль. В котором будут лежать все необходимые интерфейсы. Их реализации останутся в модулях.
Теперь они будут обращаться к api-модулям друг друга и циклической зависимости не возникнет. По сути, это реализация одного из принципов SOLID, а именно «Принцип инверсии зависимостей», только не на уровне классов, а на уровне модулей.
Третья причина — сложность пересборки. Допустим, у вас есть модуль, содержимое которого нужно многим модулям. При этом логика этого модуля постоянно изменяется. Из-за этого зависимые модули вынуждены пересобираться, что может бить по скорости сборки. Можно просто разнести этот модуль на api и impl. Везде теперь будет подключаться api-модуль. Вследствие этого при изменении логики в impl модуле можно будет избежать лишних пересборок других модулей.
В итоге у нас получается, что модули зависят от интерфейсов. Соответственно, получать реализацию по интерфейсу придётся из какого-то хранилища. Тут вариантов масса. Та же есть свои нюансы с тем, как «заставить» это хранилище по интерфейсу отдавать именно нужную реализацию. Я это называю связыванием, видел что некоторые называют это склейкой, но моё эго подсказывает мне, что мой вариант мне нравится больше. Надеюсь, в скором будущем расскажу и об этом.
Как подключать? Подключается к любому модулю с логикой как implementation или же как api к другому api-модулю.
Когда и кому может понадобиться? Если нужно подменить реализацию, развязать зависимости или решить проблемы с частыми пересборками.
Кого может подключать? Базовые модули (точнее даже один единственный базовый модуль — base-api, в котором будут лежать классы, необходимые для связывания) и другие api-модули
Обычно говорят, что api-модули не могут никого подключать. Мне кажется, что было бы неплохо, если бы всё-таки могли подключать другие api-модули.
Тут ход мыслей такой же, как и с подключением одних базовых модулей к другим. В теории если одному api-модулю понадобилось подключить другой, то значит, он не может без него.
Например, есть api-модуль для фичи с экраном объявления. Появляется новая фича с экраном адреса этого объявления, api-модулю которой нужен api-модуль экрана объявления. Это означает, что фича адреса не имеет смысла без фичи экрана объявления и не может без неё работать, а значит, должна быть добавлена в модуль с экраном объявления. Следовательно, их бы объединить.
Но вот на практике это опять же может привести к тому, что придётся часто объединять-разъединять модули, что, на мой взгляд, ещё хуже. Ведь фичу с экраном адреса можно сделать и независимой. Для этого достаточно обеспечить ей независимость, используя модели, отличные от экрана объявления.
Мы поговорили про всякую обвязку, но где же, собственно, модули с логикой, в которых мы пишем основной код. Настало их время.
Модули-слои
Если попросить человека, который до этого не сталкивался с многомодульностью, например, свою бабушку, разделить приложение на «эти ваши модули», то первое, что ей придёт в голову — разделить по слоям. Clean Architecture сильно распространён, а существуют ведь и другие слоистые архитектуры. При этом слои довольно независимы, что, на первый взгляд, делает их хорошими и главное простыми кандидатами на вынесение в отдельный модуль.
В целом, деление на модули действительно хорошо ложится на слоистые архитектуры, ведь за счёт иерархии можно гарантировать, что один слой не полезет в другой слой, в который ему нельзя. А бьёт по рукам разработчика не другой разработчик на Code Review, а сама система сборки.
Но есть один нюанс… Просто разделить всё приложение на три модуля кажется не слишком полезным. Так как, мы не получим достаточного разделения кода на маленькие кучки. Это будут всё ещё три массивных «кучищи».
Если же попытаться делить каждую фичу на слои, то получается аж три модуля на фичу, а скорее всего, даже четыре. Ведь по-хорошему надо обеспечить domain-модуль отдельным api-модулем, чтобы избежать циклических зависимостей и лишних пересборок.
Из-за целых четырёх модулей на фичу общее количество модулей быстро пойдёт вверх. В чём тут проблема? Во-первых, если у вас становится очень большое количество модулей, то вам становится очень тяжело ориентироваться в проекте. Во-вторых, Gradle не то чтобы хорошо работает с большим количеством модулей. И если скорость сборки у вас действительно уменьшится, то вот скорость конфигурации сборки, наоборот, сильно вырастет.
В итоге общее время от нажатия на заветную зелёную кнопочку до полной сборки .apk может остаться таким же, каким оно и было до внедрения многомодульности, а то и хуже. Поэтому, в целом, надо стараться не плодить лишние модули, если в этом нет нужды. Ну или переходить на Bazel/Buck, но если у вас нет отдельного человека, отвечающего за сборку, то я бы не рекомендовал вам этого делать, так как на поддержку сборки придётся тратить сильно больше времени.
Так по какой же причине, вообще может понадобиться подобное разделение на модули? Я вижу две. Первая — если у вас многократно переиспользуется бизнес-логика отдельно от UI, ну или бизнес-логика имеет несколько разных реализаций UI. В таком случае имеет смысл разделить фичу на модули по слоям, чтобы переиспользуемый модуль содержал только переиспользуемый код. Единственное, что зачастую лучше объединить модуль data- и domain-слоя. В реалиях Android-приложений, где приложение зачастую выступает в роли тонкого клиента, то подмена data-слоя для бизнес-логики маловероятна, так как они жёстко связаны. Так мы сэкономим целый один модуль.
Вторая причина — кроссплатформенная бизнес-логика, например, KMM. В таком случае всё равно придётся держать UI в отдельном модуле.
Но и тут, скорее всего, data- и domain-слои у вас объединятся в один модуль. Ведь KMM уже умеет ходить в сеть и работать с базами данных.
Как подключать? UI- и data-модули подключаются как implementation к главному модулю. domain модуль через api-модуль подключается как implementation к UI и/или data модулям.
Когда и кому может понадобиться? Если между фичами многократно переиспользуется data- или domain-слой, а также если бизнес-логика — кроссплатформенная.
Кого может подключать? Базовые модули, обёртки, прослойки, api-модули. Подключать один UI-модуль к другому строго не рекомендуется, то же самое и про data- и domain-модули. Если хочется, лучше использовать api-модули, иначе можно будет быстро нарваться на циклические зависимости.
Фичёвый модуль
Остался последний и самый важный тип модулей — фичёвый. По названию можно догадаться, что такой модуль содержит функционал фичи приложения. Соответственно, каждая отдельная фича имеет свой модуль.
Встает вопрос — «А что такое фича?». Ответ, на деле, не такой простой как может показаться. На мой взгляд, это совокупность связанных между собой экранов, их бизнес-логики и данных. Это именно совокупность, а не отдельный экран, так как часто бывает, что один экран не имеет смысла без другого. В качестве примера, в нашем приложении — экран отзывов и экран ответов на отзывы. Ответы на отзывы, как следует из названия, вообще не имеют смысла без самих отзывов, а значит, и выносить их в отдельный модуль смысла нет. Ну или в качестве альтернативного примера — сценарий оплаты, состоящий из нескольких экранов. Каждый следующий экран не имеет смысла без предыдущего, а значит, и разделять их смысла нет.
Значит, просто делим приложения на фичи и выносим в отдельные модули? Звучит просто. Но на практике всё чуточку сложнее. У нас может сложиться ставшая классикой ситуация, когда, например, экран из фичи 1 умеет открывать экран из фичи 2, а экран из фичи 2 умеет открывать экран из фичи 1. Если мы просто подключим их друг к другу, то получим старую добрую циклическую зависимость.
А что мы делаем с циклической зависимостью? Правильно — создаём api-модуль. Поэтому у каждого фичёвого модуля, по сути, есть компаньон в виде api-модуля. Это поможет избежать циклических зависимостей.
Именно подход с фичёвыми модулями помогает разбить наше приложение на так желаемые нами маленькие «кучки».
Как подключать? Фичёвый модуль подключается к главному модулю.
Когда и кому может понадобится? Всем.
Кого может подключать? Базовые модули, обёртки, прослойки, api-модули, модули-слои.
Солянка
В реальном приложении используются все (ну или почти) типы модулей. У нас в приложении 400+ модулей, используются все, описанные выше, типы модулей и вроде даже всё работает хорошо. Конечно, со временем мы доработаем текущий подход и местами улучшим, так всегда.
Если сильно упростить нашу картину модулей, то получиться монструозная, на первый взгляд, картина:
Так ещё и все модули у нас подключаются к app через runtimeOnly из-за особенностей связывания (склейки) модулей. Но не стоит это пугаться, на деле всё это будет работать, пока у вас соблюдается иерархия. Неважно сколько модулей вы добавите на схему.
Главное, что нужно понимать, что то, какие конкретно типы модулей вам понадобятся, в каком количестве, какая структура и какая у них будет иерархия — зависит от вашего конкретного проекта. Хотя, в целом, всё должно быть достаточно похоже. Многомодульность — это лишь подход, в рамках которого можно делать что угодно. Комбинируйте разные типы и у вас получится правильная, именно для вашего приложения, структура модулей. Использовать и надеяться на чужую структуру можно лишь в общих чертах.
Надеюсь, что эта статья помогла вам разобраться, какие существуют типы модулей (в моей интерпретации), для чего они нужны и как выбрать правильную их структуру. Если я вдруг что-то не учёл, ошибся или упустил (статья то получилась немаленькая), то добро пожаловать в комментарии. Ну или если у вас нестандартный тип приложения, например, без UI или кроссплатформенное, то было бы интересно узнать, как делите на модули вы.