Привет, на связи Федотов Михаил и Абдульманов Эдуард, мы технические лидеры Android-разработки в Альфа-банке и занимаемся приложением для физических лиц.
В этой статье вспомним наш опыт разбиения большого монолитного Android-приложения на мини-приложения. Занимались мы этим уже довольно давно, но тема всё равно актуальная.
Переход к таким мини-приложениям позволяет ускорить конфигурацию Gradle и уменьшить время компиляции проекта как локально, так и на CI. Думаю, это будет полезно тем, кто интересуется архитектурой android-приложений, KSP, Dagger, а также тем, у кого крупное многомодульное приложение и есть проблемы с производительностью работы Gradle в проекте.
Cтек
Опишем используемый нами технический стек, который имеет значение для текущей темы, чтобы разговор был предметным, хотя общая концепция конечно не зависит от применяемых нами фреймворков.
№1. Имеем многомодульное Android-приложение, около 800 модулей. Мы используем подход с base, feature и api-модулями. Когда выше я говорил про монолит, имел ввиду не то, что все многочисленные фичи живут в 5-10 общих модулях, а то, что оно собирается в единый-монолитный APK, который содержит весь наш скомпилированный код всех Gradle-модулей.
Если вы не сталкивались с таким разбиением, то вот краткое его описание:
feature — модуль с какой-то функциональностью приложения, обычно это экран или целый пользовательский путь (несколько связанных экранов). Feature модули не могут напрямую обращаться к другим feature модулям. Для этого есть api модули.
api — для каждого feature модуля существует свой api модуль. По сути это интерфейс для взамодействия с feature. Почему это именно модуль, а не просто класс? Чтобы создать фасад компиляции. Подразумевается, что api меняется редко, а feature часто. Таким образом при изменениях в одном feature-модуле, Gradle не потребуется рекурсивно пересобирать кучу зависящих от него feature модулей.
base — модуль с переиспользуемым кодом (например, общие классы от которых нужно наследоваться).
№2. Нативная навигация. Приложение работает по схеме multiple activity. то-есть каждый экран это самостоятельная activity. Чтобы пользовательская фича 1 могла вызвать экран или флоу другой фичи 2, мы используем классы-фасады (назовем их Navigator). Типичный Navigator имеет одну функцию, чтобы открыть экран фичи. Каждая фича имеет свой Navigator, интерфейс которого лежит в соответствующем ей api Gradle-модуле и feature-модуль его реализует. Все Navigator регистрируются в глобальном Dagger компоненте.
№3. Deeplink. Чтобы иметь возможность динамически открыть тот или иной экран, нативная навигация не подходит — требуется навигация по deeplink. Это похоже на url web-страницы. Для определения поведения каждой deeplink ссылки объявляем специальный класс-наследник от DeeplinkCreator. Все наследники DeeplinkCreator регистрируются в глобальном Dagger-компоненте.
№4. Для DI используем Dagger 2 c KSP-процессором для кодогенерации. Благодаря DI feature-модуль 1 может легко получить доступ к api-модулю feature 2 и вызвать её . А также открыть любой экран динамически через deeplink.
Почему я упомянул нативную навигацию и deeplink? Дело в том что эти сущности должны быть зарегистрированы в некотором общем месте доступном глобально. У нас это глобальный Dagger component, у которого статическое время жизни и инициализируется он в Application::onCreate. Уверен у Вас тоже есть аналог. Чтобы иметь к ним доступ из любой точки приложения или перебирать как коллекцию. У нас это происходит через Dagger механизмы @IntoMap и @IntoSet.
Проблемы
Итак теперь у нас есть общее представление о структуре проекта. Пора обсудить проблемы, которые мы пытались решить.
№1. Скорость сборки.
Обычно разработчик, разрабатывая функциональность, не меняет всё приложение, а больше сосредотачивается на одном или нескольких связанных по смыслу модулях. Но монолитная APK вынуждает его систему сборки загружать все модули проекта и компилировать весь проект целиком (хотя бы один раз, чтобы прогреть build-cache). На объёме 800 модулей конфигурация Gradle и компиляция всего проекта занимают много времени и ресурсов ПК.
№2. Merge conflicts.
Как уже я упоминал для работы нативной навигации и deeplink эти сущности должны быть доступны глобально и собраны с общие колле��ции. В Dagger для этого требуется объявить аггрегирующий Dagger-компонент где перечисляются все эти сущности. В результате мы получаем один длинный файл, в который вынуждены встраиваться все разработчики проекта, что приводило к частым мердж конфликтам в таких файлах-портянках.
№3. Точки входа.
Когда вы разрабатываете какой-то экран или fragment, удобно, чтобы он и открывался при нажатии play в Android Studio. Но если приложение монолитное, часто разработчики вместо этого открывают главный и потом проделывают долгий путь через несколько экранов и запросов до попадания в свою фичу. На это тоже уходит время.
Сompile time плагины
Если вы пользовались всевозможными плагинами и расширениями функциональности приложений, то знаете, что обычно они работают в runtime. Мы берём и подкладываем в нужное место библиотеку с правильным интерфейсом и приложение при загрузке его подхватывает и начинает использовать.
Но что если рассмотреть Android-приложение как такую же расширяемую структуру, которая способна присоединять к себе feature-модули с пользовательскими экранами?
В данном случае нас будет интересовать загрузка таких feature-плагинов, не в runtime, а в compile time.
Представим, что мы объявляем application-модуль и учим его искать в определённом месте feature-плагины модули. А не ждём, что разработчик самостоятельно зарегистрирует свой новый feature модуль в каком-то глобальном месте в application-модуле (Dagger-компоненте).
Это похоже на принцип инверсии зависимостей на уровне Gradle-модулей. Мы как бы разворачиваем зависимость application от feature-модулей в обратном направлении. Теперь application зависит от абстрактного интерфейса feature-плагина, а не от конкретных реализаций. И самостоятельно ищет и подключает доступные feature-плагины.
Весь код, который пишется вручную, не выходит за пределы feature.
При разработке новой feature это полностью избавит нас от необходимости как-либо затрагивать общий код application для подключения новой функциональности. Главное, позволит легко менять набор feature-плагинов. Так как теперь application явно не зависит от всех feature, а просто ищет все доступные, мы можем управлять конечным результатом.
С таким подходом становится возможно быстро и просто создавать мини-приложения срезки, которые содержат только интересующий нас набор feature модулей, а не все.
Как же добиться такого поведения compile-time плагинов? Мы используем для этого Focus Gradle-плагин и KSP кодогенерацию.
Focus Gradle-плагин
Это open-source плагин который позволяет на лету создавать settings.gradle.kts файл. Напомню — это файл, который содержит в себе список всех Gradle-модулей. Его содержимое выглядит примерно так:
include(":core") include(":base-ui") include(":feature1") include(":feature2") include(":api-feature1") include(":api-feature2") // .. more features
Когда этих модулей много, система сборки тратит много времени на то, чтобы проанализировать все эти модули. Поэтому, если выкинуть из него все лишнее и оставить только те модули, с которыми вы реально сейчас работаете, система сборки будет работать на порядок быстрее: как ваш любимый pet-проект, а не как огромный неповоротливый enterprise.
Это работает так:
мы указываем важные для нас модули (те, что должны быть в сборке),
запускем focus Gradle-таску, в которой анализируются зависимости, и подключаем всё, что требуется для их работоспособности, выкидывая все лишнее
в результате создается целевой
settings.gradle.kts.
Примечание. Github Focus plugin
KSP
Кодогенерация в Kotlin приходит на помощь в достижении желаемого результата.
Как я говорил, чтобы работать в стиле compile-time плагина, каждый feature-модуль должен реализовать абстрактный интерфейс.
Этим интерфейсом является Kotlin-аннотация. В ней в декларативном стиле описаны возможности, которые feature-плагин поставляет в application. У нас она выглядит примерно так:
@file:ModulePluginConfig( navigators = [ SpidermanNavigatorImpl::class, ], deeplinkCreators = [ BatmanDeeplinkCreator::class, SupermanDeeplinkCreator::class, ] ) package ru.alfabank.android.comicsfeature // import ...
На этапе компиляции приложения, KSP-процессор подхватывает ModulePluginConfig аннотацию каждого модуля и генерирует для application-модуля все необходимые классы: в нашем случае это Dagger-модули, необходимые для подключения наших базовых сущностей к глобальному Dagger-component.
package ru.alfabank.phase1.generated // этот package ищем в фазе 2 // import ... @Module interface SpidermanNavigatorImplModule { @Binds fun bind(navigator: SpidermanNavigatorImpl): SpidermanNavigator @Binds @IntoMap . @MediatorsQualifier @ClassKey(SpidermanNavigator::class) fun bindNavigator(navigator: SpidermanNavigatorImpl): Any }
Так как application-модуль и каждый feature-модуль это отдельные единицы компиляции (библиотеки), KSP-процессоры для каждого из них запускаются независимо, отдельно для каждого Gradle-модуля. Поэтому нам приходится разбивать процесс на 2 фазы, за которые отвечают самостоятельные KSP-процессоры.
В первой фазе KSP на уровне feature-модуля генерирует Dagger-модули на основе
ModulePluginConfigи кладёт их в определенный package.Во второй фазе KSP работает на уровне application и подключает результат работы первой фазы в глобальный dagger component. Он ищет выход первой фазы по package, через
resolver.getDeclarationsFromPackage(), так как иначе в KSP нет возможности получить данные о классах из библиотек-зависимостей.
Dagger тоже использует свой KSP-процессор, для кодогенерации и с этим есть сложность. Даггеровский KSP-процессор валидирует граф зависимостей уже на первом раунде кодогенерации. Так как мы генерируем Dagger-модули, это ограничение становится критичным.
К сожалению, он не умеет дожидаться окончания всех раундов, чтобы ��алидировать окончательный граф. А повторный раунд может требоваться, например:
если в процессе кодогенерации были сгенерированы новые классы аннотации, которые тоже требуют обработки KSP-процессором;
или какие-то классы не могут быть обработаны, так как их зависимости ещё не сгенерированы.
Так как при компиляции application-модуля у нас работает и первая и вторая фаза в одном модуле, из-за содержащихся там старых DeelplinkCreator и Navigator (им следовало бы быть отдельными feature-модулями) и желания Dagger-процессора сразу валидировать граф, пришлось вставить костыль, который откладывает работу второй фазы до момента, пока первая фаза не была окончена (проверяя наличие файлов в папке). Обычно KSP-процессоры не упорядочены, так как предполагается, что они корректно поддерживают исполнение в несколько раундов.
Примечание. Про кодогенерацию по раундам можно почитать в официальной документации.
FocusApp
Объединяя возможность быстро настраивать набор подключенных Gradle-модулей с помощью focus Gradle-плагина и автоматическое подключение feature-модулей в application как compile-time плагинов с помощью KSP, мы получаем возможность собирать Android-приложение с произвольным набором feature-модулей. Мы назвали такое приложение FocusApp.
Оно позволяет легко получить произвольную срезку приложения из несколький указанных модулей.
Такое приложение можно запустить, оно работает и корректно отображает экраны, которые содержит. При попытке навигации в экран, который отсутствует в данной сборке, выбрасывается исключение и отображается ошибка. Из некоторых методов для которых срезалась реализация, мы можем возвращать объекты-заглушки, созданные через dynamic proxy механизм JVM.
Также у focusapp есть свой главный экран, который позволяет выполнять авторизацию пользователей и содержит полезные функции для отладки.
Изначально мы рассчитывали, что этим будут активно пользоваться разработчики, чтобы работать с легковесным проектом локально, но по факту этим пользуются не так часто, как могло бы быть. Хотя, несомненно, это полезно, особенно для тех, у кого слабые рабочие ноутбуки.
Мы активно используем BDUI-технологии в разработке, так что для работы с ними в focusapp есть пресеты (наборы необходимых модулей), чтобы можно было ещё проще работать с BDUI-экранами в легковесном focusapp.
Но где срезка действительно используется постоянно, так это в CI.
CI
На всех промежуточных пулл-реквестах (которые идут не в основную ветку), где (возможно) мы запускаем сборки на focus-приложении, это позволяет в разы сократить время компиляции и конфигурации проекта, даже с учётом всех build и configuration Gradle-кешей.
Для сравнения время полных сборок проекта у нас на CI находится в диапазоне 12-25 минут, тогда как focus сборка занимает примерно 2-5 минут.
На CI-сборка focusapp (срезки) работает следующим образом:
имеем PR с какими-то правками в коде, которые внес программист;
в CI пайплайне простым скриптом находим папки модулей, которые были задеты правками;
в зависимости от характера правок, на этом этапе можем понять что focus сборки не достаточно и тогда запустить полную, иначе идем дальше;
список папок-модулей записывается в config focusapp;
этот config читается в build.gradle файле focusapp и подключает все модули из него как зависимости;
далее выполняется фокусировка (работа focus-плагина) на focusapp модуле (это application), при этом focus-плагин включает в сборку все небходимые транзитивные зависимости;
запускется компиляция focusapp приложения.
GlobalDaggerComponent
Следующим этапом мы поняли что для полной автоматизации процесса подключения и отключения feature-плагинов нам не хватает в них работы с глобальными Dagger-компонентами.
Некоторые feature-модули требуют объявления глобальных сущностей в Dagger. Для этого мы добавили новое поле в интерфейс feature-плагина:
@file:ModulePluginConfig( globalDaggerComponentFactories = [MarvelGlobalComponent.Companion::class] )
Это позволяет feature-модулям декларировать наличие в них глобальных Dagger-компонентов, которые будут подхвачены и инициализированы application модулем при старте приложения. Также для них доступна возможность ленивой инициализации по первому обращению, чтобы снизить время старта процесса приложения.
Для этого пришлось потратить время, чтобы распутать клубок циклических зависимостей в нашем старом глобальном Dagger-компоненте, который был монолитным, чтобы разбить его на отделяемые составные части.
В результате переход к составной структуре глобального Dagger-компонента позволяет конфигурировать его динамически, также как и набор Navigator и DeeplinkCreator на этапе компиляции приложения.
Feature toggles
Позднее мы использовали аналогичное решение с KSP годогенерацией для feature toggles. Фича тоглы у нас — это большой enum с флагами, которые в runtime говорят, что какая-то функциональность должна быть доступна пользователю или отключена.
С ним были аналогичные проблемы:
Много разработчиков правит один длинный файл — возможны конфликты.
Изменение enum FT триггерило пересборку почти всех Gradle-модулей.
Мы перешли на KSP-процессор и локальные файлы LocalFeatureToggle в каждом Gradle-модуле.
Теперь модули не зависят от общего enum с FT. На этапе компиляции приложения локальные фича-тоглы собираются KSP-процессором и валидируются на консистентность с основным enum. Это решает проблему 2 (пересборка проекта при добавлении FT).
Заключение
Мы уже довольно продолжительное время живем с такими compile-time плагинами и такое решение довольно хорошо себя показало. Проект собирается действительно быстрее и с меньшими вычислительными ресурсами. На CI это особенно важно. В поддержке этого решения у нас не возникает систематических проблем, сделали один раз и забыли.
Спасибо за внимание. Также мы недавно писали о том, как ускорили прохождение unit-тестов. Заходите.
