Разбиваем монолит по шагам

This post in english

Поговорим о модуляризации. Уже лет пять, наверное, как хайповая тема в контексте Android-разработки. За это успел поработать на нескольких больших проектах, где эта самая модуляризация была в процессе. Где-то шло хорошо, где-то плохо, но насмотрелся я на всякое. Хотелось как-то зафиксировать этот опыт в статье со своими размышлениями о том как надо.

Зачем нам разбивать проект на модули?

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

Модуляризация вам нужна:

  • Если вы хотите более явно разделить ответственности
    • По разработчикам или командам
    • По коду
  • Если вы ощущаете проблемы со скоростью сборки

Но делать это нужно продуманно и очень хорошо подготовившись.

Монолит

Что есть монолит? Обычно определить достаточно просто даже на глаз. Если у вас один app модуль, то это он и есть. Если модулей уже много и сразу неочевидно какой из них самый большой и неповоротливый, то на вопрос может ответить Gradle Scan. Если вы видите, что десяток модулей ждёт сборки одного, то это скорее всего он. Это наша основная цель из которой мы хотим выносить куски.

Gradle Scan

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

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

Gradle Scan прекрасно показывает то, как сборка вообще работает. Всё, что можно выполнять параллельно должно выполняется параллельно. То есть, идеальный пример проекта в вакууме с точки зрения сборки - это app модуль, который зависит от множества независящих друг от друга модулей.

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

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

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

Серебряная пуля

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

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

Всё что могу я - это подсказать какие-то шаги с чего начать.

Шаги

Почистите Gradle

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

Если вы не привели в порядок свой Gradle, то модуляризацию можете даже не начинать. С каждым модулем будет всё сложнее.

Для начала переключитесь с вкладки Android на вкладку Project в Project Navigator. Если вы не видите какие-то файлы, то это не значит, что их нет. Научитесь следить за тем, что у вас лежит в каждом модуле.

И настраивайте Gradle также как пишете код. DRY, KISS и вот это всё.

На секунду представьте, что у нас когда-то станет много модулей, где в конфигах зависимости будут захардкожены, а версии и другие базовые настройки плагинов указаны в каждом модуле. Сложно поддерживать, правда?

В этом вопросе нам могут помочь собственные Gradle плагины и вынесение зависимостей.

Я не буду вдаваться в глубокие подробности, т.к. на эту тему достаточно материалов. Скажу только ключевые слова: composite builds, convention plugins и version catalog. Это ваши лучшие друзья, если вы хотите разгрузить Gradle конфиги и описать всю логику где-то в одном месте.

Определите типы модулей

Об этом тоже в интернете можно найти с десяток отличных докладов. Например:

  • :app - тонкие модули, из которых готовится apk/aab, собирают граф зависимостей и по сути больше ничего не делают
  • :feature:api - тонкие модули с публичным интерфейсом фичи для внешнего использования. Здесь же описывается интерфейс со всеми зависимостями фичи.
  • :feature:impl - толстые модули, реализующие интерфейсы из :feature:api, могут зависеть только от других :feature:api модулей, но не от :feature:impl реализаций других фич
  • :core - чаще всего небольшие утилитные модули разбитые достаточно атомарно по смыслу

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

Благодаря таким типам проект (граф зависимостей модулей) у нас будет неограниченно расти в ширину, но очень сдержанно в глубину. То есть идеально, чтобы даже при большом количества кода время сборки проекта оставалось нормальным.

Генерируйте модули

Будем честны, из коробки у нас три варианта как создавать модули:

  • Воспользоваться New - Module в Android Studio
  • Создать папку самому, прописать модуль в settings.gradle
  • Скопировать уже существующий модуль, прописать его в settings.gradle

Все эти способы отстой, вы или генерируете много всего ненужного, или что-то нужное надо всё равно дописывать вручную. А почему бы нам не генерировать модули автоматически так как это нужно именно нам, со всеми нашими правилами и соглашениями.

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

Вам нужны шаблоны. Когда-то давно Android Studio умела из коробки подтягивать FreeMarker шаблоны, но разучилась. Поэтому приходится искать решения на стороне.

Например Android Studio плагин Geminio от HH позволяет взять эти шаблоны и создавать модули из них через привычный интерфейс в Android Studio.

Мы пошли по иному пути и написали свою Gradle таску, которая создаёт модуль :core:network (например), задавая все плагины автоматически.

gradle createModule --Pmodule=network

Аналогично с фичами. Эта таска создаст :feature:settings:api и :feature:settings:impl из FreeMarker шаблонов, дописав зависимости на эти модули в settings.gradle.

gradle createModule --Pmodule=settings --feature

Почему своё решение? Потому что не хотели тащить к себе зависимость на сторонний плагин для студии, иначе нужно было бы всем объяснять как его ставить и настраивать. Плюс в Geminio невозможно создать несколько модулей за раз и вообще ощущается недостаточно контроля над тем, что и куда именно генерится.

Зачистите app модуль от лишнего

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

Реализацию класса Application придётся оставить в app, т.к. именно в нём решается как мы будем собирать наш граф DI.

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

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

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

Подготовьте DI

Для начала чуть в сторону - про Dependency Inversion Principle (D из SOLID). Это чуть ли не основной принцип при работе с многомодульностью. Вы невероятно часто будете сталкиваться с тем, что у вас какие-то несколько классов зависит друг от друга и их невозможно вытащить в обособленные модули по отдельности. На самом деле можно, помните что DIP говорит? Зависеть от абстракции, а не от реализации. Делаем интерфейс, зависим везде от интерфейса, а на уровне app (чаще всего) связываем интерфейс с реализацией.

Про фреймворки. Если у вас Dagger и всё на сабкомпонентах, то у меня плохие новости для вас. Если у вас Hilt, то у вас Dagger на сабкомпонентах, только неявно. У Hilt с многомодульностью из-за этого очень туго, а у Dagger хотя бы есть варианты - вы можете через боль и страдания переделать фичи с Subcomponent на Component. Золотая классика на эту тему - доклад Владимира Тагакова.

Сейчас у нас Koin и мы не очень сильно паримся по поводу архитектуры DI модулей. По сути следуем всё тому же правилу - модуль фичи описан в feature модуле, а в app мы его прикрепляем к основному графу.

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

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

Подготовьте навигацию

Вам нужно максимально абстрагироваться от библиотеки навигации. Мы договорились что Api фичи должен быть простой и не зависящий от платформенных зависимостей. Это значит, что никаких Intent, Fragment, Context и так далее. Это значит, что сама реализация фичи (Impl) должна уметь открывать себя с помощью внешних зависимостей. Такой абстракцией может быть например Router из Cicerone.

Мы сейчас на каком-то переходном этапе между Activity/Fragment навигацией с помощью Cicerone на Compose навигацию с помощью Decompose, но принципы не меняются.

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

Держите контакт с разработчиками

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

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

Слово о dynamic фичах

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

Чтобы лишний раз не нагонять на dynamic фичи замечу, что и с ними можно жить.

А как избавиться от dynamic фичей это тема для отдельного доклада. Не так просто перестроиться, когда у себя в голове нужно все зависимости развернуть в обратную сторону.

Выносите core агрессивно

В любой непонятной ситуации выносите переиспользуемый код в core модуль. Помните, что маленькие независимые библиотечки выносить гораздо проще, чем большие фичи с кучей зависимостей. И даже более того, фичи вы не вытащите из монолита не вытащив сначала все core библиотеки, от которых фичи зависят. У вас наверняка таких зависимостей вагон: аналитика, базовые фрагменты/активити, UI компоненты, код для работы с сетью. Чем больше всего будет вынесено в модули ниже по иерархии, тем проще вам будет выносить целые фичи.

Новый core код пишите сразу в новых модулях - это просто.

Фичи выносите осторожно

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

Действуйте итеративно

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

Алгоритм

Как вынести какой-либо код или ресурсы в отдельный модуль:

  1. Проанализируйте какой код зависит от них
  2. Проанализируйте от какого кода и ресурсов зависит ваш код
  3. Создать новый модуль для вашего кода
  4. Добавьте зависимость на новый модуль в модули, которые используют этот код
  5. Перенесите весь код и ресурсы, от которого зависит не только ваш код, в новый модуль ещё ниже уровнем по этому же алгоритму
  6. Перенесите код в созданный модуль

Прогресс

Таким образом ваш прогресс с течнием времени будет представлять такую картину:

Algorithm

  1. Сначала у вас есть один монолит
  2. Потом вы отделяете монолит от тонкого app модуля
  3. Начинаете нащупывать feature и core внутри монолита
  4. Выносите core, от которых зависит feature в отдельные модуль
  5. Выносите feature в отдельные модули
  6. Повторяете с шага 3

Выводы

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

С радостью отвечу на ваши комментарии и замечания. Спасибо за внимание.

Ссылки