
С того момента, как Олег Шелякин рассказал про запуск демоприложений в проекте мобильного банка, большинство команд успело обзавестись своей собственной уютной демкой, и сейчас их количество приближается к 90.
В предыдущей статье основной акцент был на сокращении времени сборки и синхронизации кода с Android Studio. Но так уж выходит, что, решая одну проблему, мы порождаем другие. Если раньше был один единственный application module, к которому подключались все остальные модули для сборки в конечный артефакт(apk, aab), то сейчас таких модулей стало приблизительно на 90 больше.
Меня зовут Роман Заремба и я расскажу, как мы ушли от кода интеграции в application-модулях, пересмотрели базовые решения, придумали подход collector + initializer, перешли на библиотеку App Startup, избавились от нее и стали использовать стандартный Java ServiceLoader.
Код интеграции в application-модуле
Основная идея демоприложений в том, чтобы подключать в проект минимально необходимое количество модулей, но в большинстве случаев нельзя просто так взять и подключить модуль. Любая функциональность приложения не существует сама по себе, она находится в контексте приложения и взаимодействует с другими функциональностями.
Поэтому почти всегда существует этап интеграции, в рамках которого нужно: предоставить зависимости для корректной работы, построить граф зависимостей, выполнить инициализационный код, зарегистрировать диплинк, зарегистрировать навигацию, зарегистрировать сериализаторы и десериализаторы моделей и так далее.
В случае с одним application-модулем все просто: можно оставить весь интеграционный код либо в самом application модуле, либо вынести в отдельный.
К сожалению, такой подход не применим в работе с демоприложениями, так как одна из главных целей при их использовании — ускорить сборку за счет сокращения количества подключаемых зависимостей. Следовательно, возникают вопросы: «Что делать со всем этим кодом интеграции? Дублировать его при каждом подключении? Как сократить или избежать бойлерплейт-код при интеграции?».
Самый простой и очевидный способ связать части кода разных модулей, избежав при этом прямой связи между ними, — вынести эту связь на более высокий уровень.
Модуль application как раз является корневым модулем, в который подключаются все остальные модули проекта. Зачастую именно в нем и появляется код, с помощью которого связываются воедино функциональности приложения. Это особенно актуально для базовых решений, которые представляют собой common-модули и отвечают за реализацию корневой функциональности приложения.
Слой интеграции в common-модулях спроектирован так, что требует ручной регистрации со стороны клиента в какой-то одной точке приложения. В нашем случае почти все базовые решения в проекте состояли из следующих частей: модуль с абстракциями (api), модуль с имплементацией (impl) и интеграционный код в application.

Любой модуль, который собирается использовать функциональность common-модулей, должен реализовать абстракции, чтобы описать свою логику. После чего подключить модуль с реализацией в application и написать в нем интеграционный код.
Пример интеграционного кода базового решения по диплинкам с использованием Dagger2:
@Module public abstract class DeeplinksModule { @Binds @IntoMap @DeepLinkKey(deepLink = Deeplink.ATMS) abstract BaseDeeplinkStackBuilder bindAtmsBuilder(AtmsDeeplinkStackBuilder impl); @Binds @IntoMap @DeepLinkKey(deepLink = Deeplink.MORE) abstract BaseDeeplinkStackBuilder bindMoreBuilder(MoreDeeplinkStackBuilder impl); ... }
Класс DeeplinksModule содержит несколько сотен строк, в него добавляют свои обработчики все команды. Dagger помогает сформировать мапу Map<Deeplink, BaseDeeplinkStackBuilder>
, которая затем инжектится в менеджер для обработки диплинков.
public class DeepLinkManager { private final Map<Deeplink, BaseDeeplinkStackBuilder> deeplinksToBuilders; @Inject DeepLinkManager(Map<Deeplink, BaseDeeplinkStackBuilder> deeplinksToBuilders) { this.deeplinksToBuilders = deeplinksToBuilders; } ... }
Подход с разбиением модулей на api/impl и выносом кода интеграции на более высокий уровень использовался для всех базовых решений. В лучшем случае интеграционный код конкретного решения был вынесен в отдельный модуль сборщик.
Поэтому, когда начали появляться новые application-модули, мы столкнулись с рядом проблем:
-
Ручное подключение и отключение новой функциональности. Такой дизайн базовых решений не рассчитан на легкое подключение или отключение новой функциональности и требует постоянного контроля и ручной регистрации. При добавлении новой функциональности нужно было не забывать добавлять новые точки интеграции, а при удалении — убирать.
-
Код интеграции нельзя переиспользовать. Весь код интеграции находится в app-модуле, его просто не получится переиспользовать. Даже если выделить весь код интеграции в отдельный модуль app-integration, его подключение в демку будет иметь мало смысла, так как будет подключаться много лишних модулей.

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

Поддержка демок стала довольно затратным занятием, которое легло на плечи команд.
Подход collector + initializer
Чтобы упростить поддержку демоприложений, исправить проблемы с частым возникновением конфликтов и попаданием лишних модулей в application, мы пересмотрели подход интеграции для базовых решений. Основные требования при пересмотре:
-
подключение функциональности добавлением зависимости в build.gradle;
-
добавление и удаление функциональности без изменения исходного кода;
-
без ручного сбора всей функциональности в одном месте (модуле сборщике или application)
Пример хорошей реализации: клиентский модуль использует зависимость с абстракциями, пишет реализацию и со своей стороны регистрирует эту функциональность. Новая функциональность считается подключенной при добавлении клиентского модуля в основное или демоприложение.
Пример хорошей реализации — это плагинная архитектура, которая состоит из двух компонентов: core-часть и plugin-модули. Плагинная архитектура — подход к разработке программного обеспечения, при котором функциональность программы строится из отдельных независимых plugin-модулей.
Plugin-модули могут быть разработаны и интегрированы в приложение независимо друг от друга, что позволяет легко добавлять, удалять или изменять его функциональность без необходимости переписывать весь код. Плагины обычно имеют четко определенные интерфейсы взаимодействия с core-частью, что обеспечивает гибкость и масштабируемость системы.

Следуя установленным требованиям и примеру хорошей реализации, в качестве решения мы сформулировали подход collector + initializer. В рамках подхода базовые решения предоставляют API для интеграции, collector отвечает за сбор и предоставление реализаций из plugin-модулей и initializer как механизм ранней инициализации для plugin-модулей.

Применение подхода collector + initializer помогает решить проблему сборочных точек регистрации в application или модуле сборщике и позволяет достичь интеграции путем простого подключения модуля в build.gradle.
dependencies { implementation(project(":some-feature")) }
Посмотрим детально, что из себя представляют collector и initializer.
Collector — это утилитарный класс, в который можно что-то положить и забрать. В случае с базовыми решениями collector является частью API и предоставляет возможность интеграции функциональности со стороны клиентских модулей.
Пример коллектора для регистрации диплинков:
object DeepLinksCollector { private val deepLinksToRoutes: MutableList<() -> Map<Deeplink, () -> BaseDeepLinkSackBuilder>> = mutableListOf() fun collect(provider: () -> Map<Deeplink, () -> BaseDeepLinkSackBuilder>) { deepLinksToRoutes.add(provider) } fun obtain(): Map<Deeplink, () -> BaseDeepLinkSackBuilder> = deepLinksToRoutes.fold(mutableMapOf()) { result, provider -> result.apply { putAll(provider()) } } }
В модулях-потребителях используют метод collect()
для интеграция функциональности, а менеджер использует метод obtain()
для ее сбора и регистрации.
public class DeepLinkManager { private final Map<Deeplink, BaseDeeplinkStackBuilder> deeplinksToBuilders; DeepLinkManager(Map<Deeplink, BaseDeeplinkStackBuilder> deeplinksToBuilders) { this.deeplinksToBuilders = DeepLinksCollector.obtain(); // для упрощения кода метод obtain() вызывается в конструкторе, вызов коллектора лучше производить в DI модулях } ... }
Другой пример — коллектор для регистрации действий при старте приложения:
object ApplicationOnCreateInitializeActionsCollector { private val actions: MutableList<(Application) -> Unit> = mutableListOf() fun collect(action: (Application) -> Unit) { actions.add(action) } fun obtain(): List<(Application) -> Unit> = actions }
В модулях-потребителях используют метод collect()
для выполнения функциональности при старте приложения, а метод obtain()
вызывается в Application.onCreate()
.
class MyApp : Application() { override fun onCreate() { ApplicationOnCreateInitializeActionsCollector.obtain() .forEach { actionProvider -> actionProvider(this) } ... } }
Возникает вопрос: в каком месте инициализировать весь этот интеграционный код? Сейчас он находится в application-модуле и выполняется при старте приложения. Для решения проблемы инициализации и существует второй компонент — initializer.
Initializer — инструмент, который позволяет зарегистрировать интеграционный код на самом раннем этапе. Не углубляясь в детали реализации, создали абстракцию:
abstract class BaseInitializer { protected abstract fun init() }
Реализуя абстрактный класс, команды могут вынести весь интеграционный или инициализационный код, необходимый для корректной работы модуля, в метод init.
Initializer`ы создаются и выполняют метод init на самом раннем этапе, при старте приложения, поэтому выполнять тяжелые операции запрещено. Также все вызовы кода должны быть ленивыми, чтобы не инициировать преждевременную инициализацию.
Собирая все вместе, связка collector + initializer выглядит так:
internal class SomeFeatureInitializer : BaseInitializer() { override fun init() { DeepLinksCollector.collect { DeepLink.MY_DEEPLINK to BaseDeepLinkSackBuilder { ... } } ApplicationOnCreateInitializeActionsCollector.collect { application -> application.registerActivityLifecycleCallbacks(SomeFeatureLifecycleCallback()) } } }
В итоге постепенно, с применением подхода collector + initializer, пересмотру подверглись почти все базовые решения, и регистрация функциональности перешла из application или сборочных модулей в модули команд. Типичный initializer для модуля стал выглядеть примерно так:
internal class SomeModuleInitializer : BaseInitializer() { override fun init() { ApplicationOnCreateInitializeActionsCollector.collect { } // регистрация кода при старте приложения ScreenProviderCollector.collect { } // регистрация навигации CustomSerializersModuleCollector.collect{ } // регистрация сериализаторов моделей DeepLinksCollector.collect { } // регистрация обработчиков диплинков FeatureTogglesCollector.collect { } // регистрация тоглов WorkerProvidersCollector.collect { } // регистрация WorkerFactory PushHandlersCollector.collect { } // регистрация обработчика уведомлений ... } }
С сущностью collector все более-менее понятно, это простой утилитарный класс для сбора реализаций контрактов базовых решений в модуле. Initializer немного сложнее, ведь для того, чтобы все заработало, их нужно каким-то образом найти и выполнить на раннем этапе.
Библиотека App Startup
Библиотека App Startup обеспечивает простой и производительный способ инициализации компонентов при запуске приложения. Как и большинство других библиотек, app startup использует ContentProvider в качестве механизма ранней инициализации, до вызова Application.onCreate()
. Под капотом она регистрирует один ContentProvider в манифесте приложения, в который можно предоставить компонент для инициализации через конфигурацию meta-data.

Для описания кода инициализации необходимо реализовать простой интерфейс Initializer:
public interface Initializer<T> { // Инициализирует компонент @NonNull T create(@NonNull Context context); // Список зависимостей, которые необходимо инициализировать перед инициализацией текущего компонента @NonNull List<Class<? extends Initializer<?>>> dependencies(); }
Затем зарегистрировать компонент в манифесте модуля
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <application> <provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" android:exported="false" tools:node="merge"> <!-- Automatically run SomeInitializer at app startup --> <meta-data android:name="ru.tbank.SomeInitializer" android:value="androidx.startup" /> </provider> </application> </manifest>
Библиотека поставляется со специальным lint правилом, чтобы разработчики не забывали регистрировать свои initializer’ы в манифесте.
В нашем случае библиотека уже частично применялась в проекте, поэтому было принято решение использовать ее в качестве механизма ранней инициализации.
Следуя подходу collector + initializer, никаких связей между initializer`ами быть не должно, так как инициализация должна быть ленивой и использоваться в связке с коллектором. Поэтому реализация BaseInitializer будет иметь возвращаемый тип компонента Unit и пустой список зависимостей.
abstract class BaseInitializer : Initializer<Unit> { protected abstract fun init(context: Context) final override fun create(context: Context) = init(context) final override fun dependencies(): List<Class<out Initializer<*>>> = emptyList() }
Библиотека App Startup отлично решает задачу ранней инициализации. Разработчики реализуют абстрактный класс BaseInitializer, регистрируют его в манифесте, и библиотека сама мержит метадату в манифесте для единственного ContentProvider`а.
Минусы подхода collector + initializer:
-
Специфика Android. App startup — это android-библиотека, которую можно подключать только в модули, для которых применяется плагин com.android.library или com.android.application.
-
Интеграция во время выполнения и влияние на время старта приложения. Весь интеграционный код собирается во время выполнения, до вызова
Application.onCreate()
, а не во время компиляции. Это влияет на время старта приложения. -
Разработчики могут забыть подключить модуль к application. Не сильно разбираясь, как все устроено под капотом, разработчики могут применять подход, но при этом не подключать свои модули к application.
Долгое время подход с использованием библиотеки App Startup удовлетворял наши потребности. Пока команды, которые ищут пути оптимизации проекта, не пришли к выводу, что модули, в которых применяется плагин com.android.library, генерируют в два раза больше тасок, чем простой kotlin-проект. А еще и миграция модулей с Android на Kotlin в тех случаях, где необходимость в Android отсутствует, приведет к улучшению времени синхронизации.
К тому же мы не использовали все возможности библиотеки: выстраивание зависимостей initializerов через метод dependencies()
и отсутствие необходимости в Context’е для метода init()
. Пришлось рассмотреть альтернативные варианты ранней инициализации.
Механизм ServiceLoader
В какой-то момент мы вспомнили про стандартный механизм из Java-мира — ServiceLoader. Он появился еще в версии 1.6, на его основе работают библиотеки detekt и hyperion. Никакие библиотеки подключать не нужно, все есть в JDK.
ServiceLoader работает со следующими сущностями:
-
Service — интерфейс или абстрактный класс.
-
Service provider — конкретная реализация сервиса.
-
Provider configuration file — файл, который размещается в директории META-INF/services и позволяет идентифицировать service provider. Название файла должно соответствовать полному имени сервиса.
-
Service loader — простой механизм загрузки service provider.
Для расширения функциональности не нужно менять базовый код, достаточно написать реализацию базового интерфейса BaseInitializer. Затем создать конфигурационный файл в директории META-INF/services/ru.tbank.common.BaseInitializer с указанием конкретной реализации.
Чтобы загрузить все зарегистрированные провайдеры, необходимо вызвать ServiceLoader.load(BaseInitializer::class.java)
.
Механизм соответствует всем требованиям, поэтому решили мигрировать. После миграции удалось полностью избавиться от специфики Android и интерфейс BaseInitializer стал выглядеть так:
abstract class BaseInitializer { protected abstract fun init() fun create() = init() }
Если раньше за инициализацию компонентов отвечал ContentProvider, то теперь это наша ответственность. В Андроиде для того, чтобы R8 смог оптимизировать загрузку провайдеров, метод load нужно вызывать с передачей ClassLoaderа и без использования котлиновского экстеншена foreach. Сам вызов можно разместить в Application.onCreate():
class SomeApplication : Application() { override fun onCreate() { super.onCreate() for (s in ServiceLoader.load(BaseInitializer::class.java, BaseInitializer::class.java.classLoader)) { s.create() } } }
Но, как и в случае с App startup, не обошлось без минусов:
-
Регистрация провайдера через конфигурационный файл в META-INF/services. Разработчики просто забывают его создавать и регистрировать в нем провайдеры.
-
Интеграция во время выполнения и влияние на время старта приложения. Весь интеграционный код собирается во время выполнения, до вызова метода
Application.onCreate()
, а не во время компиляции. Это влияет на время старта приложения. -
Разработчики могут забыть подключить модуль к application. Не сильно разбираясь, как все устроено под капотом, они могут применять подход, но при этом не подключать свои модули к application.
Контроль конфигурационного файла и подключения модуля к application
Для решения проблемы контроля конфигурационного файла уже довольно давно существует библиотека на основе кодогенерации — AutoService. При этом есть реализация для KAPT и форк реализации для KSP. Все, что нужно сделать, это добавить аннотацию @AutoService к реализации — библиотека сама сгенерирует конфигурационный файл.
@AutoService(BaseInitializer::class) class SomeInitializer : BaseInitializer() { override fun init() = ... }

Довольно удобно, но на масштабах проекта мобильного банка использование библиотеки вылилось в ~19 минут последовательного выполнения таски KspTaskJvm.

Проект быстро растет, и мы постоянно боремся за каждую минуту сборки — от варианта с кодогенерацией придется отказаться.
Рассматривая варианты контроля, мы пришли к другой идее. С помощью сущности ServiceLoader можно получить список всех классов, для которых существует конфигурационный файл в META-INF/services.
В classpath`е приложения можно найти классы, которые являются наследниками BaseInitializer. Если сравнить эти два списка, то можно найти классы, которые реализуют интерфейс BaseInitializer, но не зарегистрировали конфигурационный файл.
Реализовать это можно с помощью юнит-теста, который находит симметрическую разность двух множеств, где первое множество — это классы, которые являются наследниками BaseInitializer, полученные из classpath, а второе множество — классы, загруженные через сущность ServiceLoader.
class BaseInitializersTest { @Test fun checkInitializers() { val classes = mutableSetOf<String>() getClassesFromClasspath(BaseInitializer::class.java).mapTo(classes) { it.simpleName } val loadedClasses = mutableSetOf<String>() ServiceLoader.load(BaseInitializer::class.java, BaseInitializer::class.java.classLoader) .mapTo(loadedClasses) { it.javaClass.simpleName } val absentResources = ((classes - loadedClasses) union (loadedClasses - classes)) assertTrue( "There is no configuration file in META-INF/services directory for classes: $absentResources", absentResources.isEmpty() ) } }
Используя юнит-тест для проверки наличия конфигурационного файла и дополнив сценарием его создания в IDE-плагине, удалось сократить петлю обратной связи для разработчиков.
Другая проблема в том, что если разработчик не подключит свой модуль в build.gradle, то тест пройдет успешно, проект соберется и о проблеме он узнает довольно поздно. В данном случае тяжело придумать какое-то адекватное решение, мы договорились явно добавлять зависимости с BaseInitializer на уровень application. Для контроля такого поведения написали gradle-плагин c таской:
/** * Task to check that projects with ServiceLoader implementation has been declared in application project. */ internal abstract class ServiceLoaderProjectsDeclaredInApplicationTask : DefaultTask() { @get:Input abstract val projectsLocationsWithModuleNames: MapProperty<String, ConfigurableFileTree> @get:Input abstract val moduleDependenciesNames: SetProperty<String> @TaskAction fun execute() { val baseInitializerProjectsNames = projectsLocationsWithModuleNames.get() .filter { it.value.any { file -> file.name == "ru.tbank.common.BaseInitializer" } } .map { it.key } val notIncludedDependencies = baseInitializerProjectsNames - moduleDependenciesNames.get() if (notIncludedDependencies.isNotEmpty()) { throw GradleException(buildErrorMessage(modulesNeedToBeIncludedMessage(notIncludedDependencies))) } } }
Влияние на время старта приложения
Использование App startup или ServiceLoader подразумевает инициализацию во время выполнения, и это может влиять на время старта приложения. Чтобы оценить степень влияния, провели замеры на проекте со окружением:
-
1000 модулей;
-
к каждому модулю подключена зависимость с BaseInitializer;
-
в каждом модуле есть initializer, в котором выполняется простой код с ленивой инициализацией.
fun interface Router { fun create() companion object { lateinit var Impl: () -> Router } } internal class Initializer : BaseInitializer() { override fun init() { Router.Impl = { Router { } } } }
Замеряем время старта до первого экрана командой:
adb shell am start -R 10 -S -W com.example.lib/com.example.lib.MainActivity
Результаты на Pixel 5, Android 14: медианное время старта приложения без ранней инициализации — 144ms.
|
С ServiceLoader |
С App startup |
Медиана |
172,5 ms |
186,5 ms |
Разница |
+28,5 ms |
+42,5 ms |
В результате влияние на время старта приложения для тестового проекта с 1000 модулей составляет ~28—42 ms. Но в реальности результаты немного другие. В проекте мобильного банка ~893 initializerа и прирост составляет ~100 ms.
Заключение
Почти для всех базовых решений сформированы публичные интерфейсы, реализации которых можно предоставлять через collector+initializer. Используется наиболее эффективный механизм ранней инициализации с минимальным влиянием на время старта приложения.
Инструменты контроля позволяют узнавать об ошибках как можно раньше. Нам удалось решить главную проблему — избавиться от единой точки сбора интеграционного кода в application-модуле.
Теперь для корректной работы функциональности в демоприложении достаточно подключить модуль в build.gradle без дополнительно бойлерплейт-кода. Весь интеграционный и инициализационный код ушел в модули команд. Такой подход позволил в значительной степени упростить поддержку демок и снизить количество подключаемых модулей.
Каждое решение в большом проекте — это трейдоф. В данном случае, строя что-то вроде плагинной архитектуры, мы жертвуем контролем и получаем неявную инициализацию во время выполнения, вдобавок небольшое влияние на время старта приложения. Но это те риски, которые мы решили принять.
За рамками статьи остались темы децентрализованного графа зависимостей и подход api&impl&stub, но о них как-нибудь в другой раз. Надеюсь, эта статья была интересной и полезной. Пишите в комментариях, если есть вопросы или предложения!
ссылка на оригинал статьи https://habr.com/ru/articles/899714/
Добавить комментарий