Всем привет! Меня зовут Георгий Рябых, и я — 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-файлы выглядели так:  Из-за того, что имя модуля не совпадало с директорией, в котором он лежит, нам приходилось писать две дополнительных строчки — include и project. А поскольку у нас таких модулей сотни, эти две строчки превращались в огромную простыню.  Еще были ситуации, когда модуль shared-core-utils мог лежать вообще не в директории shared/core, что тоже вносило свою путаницу. И непонятно, где этот модуль искать.  Чтобы это исправить мы четко связали нейминг модулей с его положением в проекте.  После того, как мы стали использовать этот подход, те же самые gradle-файлы стал выглядеть следующим образом:  У нас сохранился наш понятный нейминг, но появилась новая возможность быстро снавигироваться на модуль, поскольку мы четко понимаем, где он лежит. А в settings.gradle-файле наша простыня сократилась в два раза.  В итоге наш settings.gradle стал выглядеть вот так:  Чтобы легко работать со сложной иерархией модулей, у нас в проекте есть строгое деление на типы модулей, а имя модуля полностью отражает его суть. А для легкой навигации по проекту, имя модуля полностью совпадает с его директорией в проекте. Для контроля зависимостей между модулями у нас существуют строгие правила подключения модулей между собой. Корректность подключения модулей проверяется статическим анализом кода на каждом PR.  Если вы планируете разбить ваш проект на модули или в начале этого процесса, то лучше сразу задуматься о том, какие правила вам нужны как для именования модулей, так и для связей между ними.  Потому что искать и решать проблемы в зависимостях между модулями из-за непродуманной иерархии долго и сложно.  Android developer