Всем привет! Меня зовут Георгий Рябых, и я — android-разработчик в hh.ru. Сегодня поговорим об иерархии модулей и разберемся, как правильно их укрощать. Если у вас многомодульное приложение, то вы скорее всего уже сталкивались с проблемами в  зависимостях между модулями и сложностями в навигации по проекту. Но если вы только планируете разделение на модули, то вам еще предстоит познакомиться с этими сложностями. 

А чтобы избежать проблем у вас должны быть четкие правила по работе с многомодульностью. Мы в hh.ru много думали над подходом работы с модулями и считаем, что у нас получилось хорошее решение.

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

Статья также доступна в видео-формате.

Какие проблемы решаем

Первая проблема — если у нас большой проект, становится сложно ориентироваться в коде. Наше приложение — многомодульный проект, в котором несколько сотен модулей. Если сложить весь код в одну директорию, потеряться будет проще простого. 

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

Вторая проблема — если не накладывать ограничения на зависимости между модулями, можно потерять одно из преимуществ многомодульного проекта — относительно небольшое время сборки проекта. Давайте здесь остановимся и чуть подробней и изучим проблему. 

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

Итак в нашем мини-приложении есть app-модуль “applicant” и всего одна-единственная фича — “vacancy” (экран вакансии), и в этом модуле содержится ее код. Также есть несколько базовый модулей: “remote-config”, “model”, “utils”. 

Приложение развивается, и у нас появляется еще одна фича — “resume” (экран с резюме). 

Процесс сборки в нашем случае будет выглядеть так: сборка начинается с самого низу, с наших двух модулей “model” и “utils” — у них никаких связей и дополнительных зависимостей, поэтому они собираются первыми и параллельно. А вот модули “remote-config” и “user” зависят от  модулей “model” и “utils”, поэтому они дожидаются своей очереди. Как только первые два модуля закончат сборку, начнут собираться “remote-config” и “user”, а за ними “vacancy” и “resume”. При этом получается, что фича “vacancy” и фича “resume” могут собираться параллельно — на разных потоках. 

В итоге сборка проекта занимает примерно столько же времени, сколько было до добавления новой фичи “resume”. 

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

Допустим, проект продолжает развиваться. Теперь мы хотим доработать фичу “resume”. У нас уже есть некоторый код из модуль “vacancy”, который отлично подходит для нашей новой функциональности в фиче “resume”. Поэтому мы, недолго думая, просто берем и подключаем один модуль к другому. И в итоге получается, что нам даже не пришлось ничего рефакторить: мы просто взяли и переиспользовали уже существующий код.

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

Теперь фича “resume” уже зависит не только от модуля “user”, но и от модуля “vacancy”. И теперь, перед тем как собрать модуль “resume”, приходится ждать пока синий блок полностью соберется. По итогу, мы теряем параллельную сборку модулей — фичи больше не могут собираться одновременно, это грустно. 

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

Изначально у нас был монолит, который мы решили поделить на модули. Не было четких правил, как мы будем подключать их друг к другу: просто брали кусочек приложения и выделяли его в отдельный модуль. А только потом стали разбираться, как можно уменьшить время сборки приложения. Посмотрели наш критический путь — он оказался равен 23. Представьте, 23 модуля, которые связаны друг с другом. Жуть!

Типы модулей в приложении

Чтобы решить эти проблемы, для начала мы выделили несколько основных типов модулей в нашем приложении:

  • app-модули

  • feature-модули

  • core-модули

Начнем с app-модулей. Они предназначены для связи частей приложения воедино. Это те самые модули приложений, которые находятся на самом верху иерархии модулей. Сколько app-модули, столько и приложений в проекте. 

Далее по списку — feature-модули. Feature-модули предназначены для кода конкретной фичи. Это может быть какой-нибудь экран приложения или базовая фича для работы с геолокацией. Также мы выделяем нескольких различных типов feature-модули. Есть feature-модуль, который шарится для всех приложений внутри проекта — так называемые shared:feature-модули. Еще каждое из приложений может иметь специфичные модули для себя app:feature-модули. Например, определенный экран, который предназначен только для этого приложения.

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

По итогу мы имеем три app-модуля для соискательского (applicant), работодательского (hr-mobile) и технического (design-gallery) приложений. У нас есть две директории для каждого из основных приложений и общая shared-директория. В каждой из этих директорий есть core-директория и feature-директория для модулей.

Здесь главное не путаться app-модуль приложения headhunter-applicant и директорию applicant. Они специально имеют похожие названия, чтобы по смыслу связать модуль приложения с директориями для feature- и core-модулей.

В дополнение хотелось бы рассказать про деление модулей внутри фичи. Могут быть core-модули предназначенные только для определенной группы фич. Как пример, в приложении hr-mobile есть ряд feature-модулей, связанных с функциональностью аутентификации. При этом они имеют общую логику. Мы не хотим вытаскивать эту логику на уровень hr-mobile:core, т.к. она связана только с группой фич. Поэтому мы выделяем core-модуль внутри общей директории для этих feature-модулей.

Правила подключения модулей

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

Shared:core подключаем куда угодно: модули могут подключаться к любому из других модулей. А всё потому что shared:core-модули — это фундамент нашего приложения, и обычно собираются они в первую очередь. После первой сборки приложения мы можем подключать их куда угодно и быть спокойными, что это никак не замедлит наш последующий ребилд приложения. 

Важно понимать, что core-модули — это не помойка, куда вы просто складываете всё, что хотя бы отдаленно напоминает общий код для нескольких модулей. Если у вас будет слишком много всего в базовом core-модуле, то рано или поздно нарветесь на то, что при изменениях, касающихся определенной группы фич, будет пересобираться весь проект. Просто по потому, что вы поменяли что-то в shared:core модуле, а он подключен везде. 

Поэтому следует четко понимать, что именно вы хотите туда вносить. И важно следить за тем, чтобы эти модули сильно не разрастались. Если у вас действительно есть код, который вы используете чуть ли ни в каждом втором модуле, то его можно и нужно положить в shared:core-модуль. Но если у вас всего лишь пару feture-модулей и у них есть общий код, то лучше вынести их в core-модули на уровне приложения — в app:core-модуль. 

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

{app}:core — только к core- и feature-модулям определенного app-модуля.

{app}:feature — только к определенному app-модулю. Это те самые фичи, предназначенные только для определенного application. А поскольку они предназначены только для конкретного app-модуля, то и подключаться могут лишь к нему.

 

И последнее — это {app/core}:feature:{sub-feature}:core. Их подключаем только к определенному sub-feature-модулю.

Где растет сложность зависимостей и критический путь

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

Первый блок — это app-модули. И здесь понятно, что он никак не может расти по вертикали, app-модули не зависят друг от друга, а значит и критический путь никак не увеличивается. 

Второй блок — feature-модули. Можно условно принять, что здесь также не может появляться модулей ниже уровня. Но поскольку у feature-модулей все-таки могут возникать core-модули, критический путь может увеличиваться на 1-2 уровня. Однако это происходит далеко не всегда, и такой рост весьма ограничен. По итогу, здесь критический путь и сложность зависимостей тоже особенно не растут.

Третий блок — core-модули. Здесь сложность зависимостей может расти непредсказуемым образом, поскольку эти модули легко становятся зависимыми друг от друга. Но этот участок обычно меняется реже всего — риск минимальный. Поэтому за ними достаточно следить время от времени и проверять, чтобы не было каких-то адских зависимостей и больших цепочек связей.

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

Зачем делить большие фичи

Очевидно, что чем больше кода, тем сложнее становится с ней работать. Допустим, у нас есть фича “резюме”. В нашем случае она разделена на несколько модулей, но представим, что это один большой модуль. Во время разработки можно делать всё в одном модуле: разложить по директориям и запомнить, как что работает. Разработал — отложил на год, а когда вернулся, то обнаружил, что уже не все так очевидно. Все забылось.

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

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

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

Оздоровление gradle-файлов 

Раньше на gradle-файлы выглядели так: 

// settings.gradle
include ':shared-core-utils'
project(':shared-core-utils').projectDir =
    new File(settingsDir, ‘./shared/core/shared-core-utils')

// build.gradle
implementation project(’:shared-core-utils’)

Из-за того, что имя модуля не совпадало с директорией, в котором он лежит, нам приходилось писать две дополнительных строчки — include и project. А поскольку у нас таких модулей сотни, эти две строчки превращались в огромную простыню. 

Еще были ситуации, когда модуль shared-core-utils мог лежать вообще не в директории shared/core, что тоже вносило свою путаницу. И непонятно, где этот модуль искать. 

Чтобы это исправить мы четко связали нейминг модулей с его положением в проекте. 

После того, как мы стали использовать этот подход, те же самые gradle-файлы стал выглядеть следующим образом: 

// settings.gradle
include(’:shared:core:utils’)

// build.gradle
implementation project(‘:shared:core:utils’)

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

В итоге наш settings.gradle стал выглядеть вот так: 

// Feature Help
include(":shared:feature:help:help-screen")
include(":shared:feature:help:core:faq-domain")
include(":shared:feature:help:core:faq-data-webim")

// Feature vacancy
include(":applicant:feature:search-vacancy:core:logic")
include(":applicant:feature:search-vacancy:search-vacancy-full")
include(":applicant:feature:search-vacancy:search-vacancy-shorten")
include(":applicant:feature:search-vacancy:search-clusters")
include(":applicant:feature:search-vacancy:search-advanced")

// Feature negotiation
include(":applicant:feature:negotiation:core:logic")
include(":applicant:feature:negotiation:core:network")
include(":applicant:feature:negotiation:negotiation-with-similar-result")
include(":applicant:feature:negotiation:negotiation-screen")
include(":applicant:feature:negotiation:negotiation-list")

// Feature search
include(":applicant:feature:search-history:core:logic")
include(":applicant:feature:search-history:search-history-list")
include(":applicant:feature:search:core:logic")
include(“:applicant:feature:search:search-quick-query")
include(":applicant:feature:search:search-query")
include(":applicant:feature:search:search-main")

Итоги

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

Для контроля зависимостей между модулями у нас существуют строгие правила подключения модулей между собой. Корректность подключения модулей проверяется статическим анализом кода на каждом PR. 

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

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

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


  1. jumperk
    19.08.2022 12:04
    +2

    Хотелось бы еще раскрыть тему деления на модули + ui тесты для них. Например, есть у вас три аппликейшн модуля - applicant, hr-mobile, design-gallery. Есть ui тесты в каждом из этих модулей в папке androidTest, которые относятся к конкретному апликейшену, есть ui фича от которой зависят все три апп модуля - надо написать ui тест для это фичи, вопрос куда поместить этот тест и можно ли общие тесты вынести в отдельный модуль? Интересует как вы пишете ui тесты для каждого апп модуля и что делаете, если нужно протестировать общую ui фичу от которой зависят несколько апп модулей?) Спасибо


    1. georgyR Автор
      19.08.2022 18:06
      +1

      Мы ui-тесты не выносим в отдельные модули. Допустим, тесты для applicant лежат в app-модуле этого приложения. Фичи достаточно сложно тестить в отрыве от контекста их использования. Фича может иметь внешние зависимости, несколько точек входа, и это все может сильно менять поведение фичи. Поэтому если фича покрывается ui-тестами, то в контексте конкретного приложения.

      На самом деле у нас не так много общих ui-фичей между приложениями. В основном это какие-то общие core-модули для архитектурных компонентов, работа с data-слоем и пр. А этот код хорошо покрывается unit-тестами. А такие тесты уже складываем в конкретные модули этих компонентов.


  1. Savitskiy_Sergey
    19.08.2022 13:31
    +1

    как вы посчитали зависимости (критический путь) между модулями у себя в проекте? Руками или есть какой то инструмент?


    1. horseunnamed
      19.08.2022 18:36
      +1

      Когда-то считали самописным Gradle-плагином в связке с https://github.com/cdsap/Talaiot, чтобы учитывать фактическое время сборки тасок, но сейчас эта фича есть в Gradle Build Scan (https://scans.gradle.com/). В build scan есть Timeline сборки и в поиске по нему можно поставить галочку "On critical path", чтобы подсветить участвующие в критическом пути таски. При этом для оценки критического пути важно запускать сборку с флагом --no-cache, чтобы исключить влияние кэша.


  1. Steeshock
    19.08.2022 18:15
    +3

    Полезная статья. Главное сильно не увлекаться вынесением общего кода между фичами, бывает сложно на начальном этапе выделить корректную абстракцию. И то, что кажется похожим и общим сейчас, может иметь тенденцию к расхождению по разным сторонам в будущем. Общий код != правильная абстракция, и иногда бывает даже лучше продублировать некоторые вещи, чем закинуть всё в Core модуль.


    1. Xanderblinov
      19.08.2022 19:41

      Очень частый вопрос: как вообще выделять правильно модули. Так и не смог для этого вынести какие-то формальные критерии. Получается что-то типа: отдельная фича — это кусок кода с изолированной логикой, не очень большой и не очень маленький >_<