- PVSM.RU - https://www.pvsm.ru -
Граф зависимостей KMP‑приложения можно собрать обычным Kotlin‑кодом — без рефлексии, кодогенерации, аннотаций и DSL. Composition root и конструкторы, никакой магии. Звучит как шаг назад от зрелых DI‑фреймворков — пока не посмотришь, кому такая форма удобна: тебе, новому человеку в команде и твоему ИИ‑агенту.
Сам composition root — не изобретение и не что‑то про KMP. Термин популяризировал Марк Симан (Mark Seemann), автор книги «Dependency Injection», и описал его как единственную точку, где собирается весь граф объектов приложения (ploeh.dk). Паттерн одинаково работает в.NET, на бэкенде, в Python — в любом модульном приложении, которое собирает зависимости в одной точке входа. KMP добавляет к нему одну ось, специфичную для платформы: иерархию сорс‑сетов. О ней дальше — иерархия контейнеров повторяет иерархию сорс‑сетов, и место каждого компонента определяется тем, какой API виден его сорс‑сету.
Зачем вообще ручной DI, если есть зрелые фреймворки? Главная причина — наглядность. Граф зависимостей здесь — обычный код: видно, что и где создаётся, любую связь можно пройти через «go to definition» в IDE. Никакой магии, никаких объектов «из воздуха» — то, что во фреймворке скрыто за аннотациями и кодогенерацией, лежит открытым текстом. Из наглядности вытекает остальное: нулевая стоимость входа (нет плагина, настройки сборки, версионных конфликтов), отладка обычным дебаггером по коду, низкий порог для нового человека в команде — ничего не нужно учить за пределами проекта, читается как любой Kotlin.
Это не отказ от инструмента из принципа. Фреймворк решает реальные проблемы, и на большом проекте он может окупиться. Но он же добавляет компилятор‑плагин или KSP, свой жизненный цикл сборки, свои правила и свою кривую обучения. Пока проблем, которые он решает, ещё нет, он остаётся аппаратом тяжелее задачи. Дальше — как устроен ручной DI в Tether [1], KMP‑приложении для передачи файлов между устройствами (Android, iOS, Desktop), и где проходит граница, за которой фреймворк начинает себя окупать.
Код в примерах синтетический: имена классов обобщены, чтобы показать форму паттерна, а не конкретную реализацию. Сами паттерны взяты из боевого кода.
Composition root — единственное место, где приложение собирает граф зависимостей. Это место принято называть контейнером. Контейнер единственный создаёт компоненты — все сущности графа, которыми владеет приложение: интеракторы, репозитории, источники данных — и раздаёт их всем, кому они нужны. Никто, кроме контейнера, не вызывает конструкторы компонентов.
open class AppContainer {
open val httpClient: HttpClient by lazy { HttpClient() }
open val messageRepository: MessageRepository by lazy { MessageRepository(httpClient) }
open val syncEngine: SyncEngine by lazy { SyncEngine(messageRepository) }
}
Поля здесь ленивые, но это деталь: порядок и момент создания задаёт контейнер, а не случайность обращения. Где ленивость не нужна — компонент собирается сразу; где by lazy мешает синхронизацией — берётся LazyThreadSafetyMode.NONE. Поле не обязано быть синглтоном: вместо готового объекта в контейнере может лежать фабрика, которая собирает новый экземпляр на каждый вызов.
Каждый компонент получает свои зависимости в конструктор. Класс SyncEngine не знает, откуда взялся MessageRepository, — он его принимает. Это и есть внедрение зависимостей: зависимость передаёт вызывающий.
Главное преимущество видно сразу в тестах. Раз SyncEngine принимает MessageRepository в конструктор, тест передаёт туда фейк — без моков, без рефлексии, без подмены глобалов.
Наглядность. Граф зависимостей — обычный Kotlin‑код: видно, что и где создаётся, любую связь можно пройти в IDE. Никакой магии и объектов «из воздуха».
Честные конструкторы. Сигнатура класса — это его полный список зависимостей. Ничего не приходит из невидимого глобала.
Структура подсказывает, где создавать компонент. Дерево контейнеров повторяет дерево сорс‑сетов, и место компонента — там, где впервые виден нужный API (Иерархия контейнеров [2]).
Тонкий интеграционный слой. Разделение на Api и Impl строго обозначает публичную поверхность модуля (Разделение на Api и Impl [3]).
Нет внешних зависимостей. DI не тянет в проект ни одной сторонней библиотеки: ни плагина сборки, ни рантайм‑артефакта, ни версий, которые надо обновлять и согласовывать между собой.
Чужая ответственность не протекает в фичевый код. Фичевый код остаётся обычным Kotlin, без аннотаций и кодогенерации DI. Связывание живёт только в composition root, а не размазано по классам, которые оно обслуживает.
Правильное направление зависимостей. Компонент не знает, где его применят, и не ссылается на контекст использования. За счёт этого он переиспользуем, а всё знание о месте применения остаётся в корне.
Библиотеки и внутренние модули обрабатываются одинаково. Внутренний модуль и опубликованная библиотека собираются одним приёмом: Api/Impl плюс фабрика. Поэтому модуль можно без переделки вынести в отдельный репозиторий, когда его понадобится переиспользовать.
Дешёвый переход. Контейнеры и провайдеры по форме уже близки к графу фреймворка — переезжать можно без структурных изменений (Миграция на фреймворк [4]).
Проще для ИИ‑агентов.
У наглядности есть второй потребитель, про которого пять лет назад не думали, — кодовый ИИ‑агент. Tether развивается в связке с такими агентами, и ручной DI здесь выгоден по той же причине, что и для человека: граф зависимостей лежит в коде явным текстом, без магии фреймворка.
Когда агент правит фичу, ему нужно понять, что куда передаётся. С ручным DI он это просто читает: конструктор — честный список зависимостей, точка сборки — один файл, любую связь видно через переход к определению. Фреймворк прячет половину этого за аннотациями и кодогенерацией — агенту приходится держать в голове правила фреймворка и достраивать невидимый граф по косвенным признакам. Чем меньше магии, тем меньше агент додумывает то, чего в коде не видно.
Отсюда же — проверяемость. Свод принципов ручного DI («зависимости — в конструктор», «не доставай из глобала», «синглтон создаётся один раз в корне») — это короткий чек‑лист, по которому и человек, и агент‑ревьюер сверяют новый код. В Tether этот чек‑лист оформлен как инструкция для агентов, что считать корректным DI. Ручной подход делает её исполнимой: проверять надо обычный код, а не поведение фреймворка.
Ручному DI часто возражают: на практике он вырождается в service locator — глобальный реестр, из которого классы достают зависимости сами. Для конструкторной инъекции это возражение не работает. Разница в направлении управления: при service locator класс знает про реестр и тянет зависимости из него; при инъекции через конструктор класс не знает ни про какой контейнер — зависимости в него передаёт composition root. Контейнер здесь — место сборки, и классы из него не читают. Эту границу подробно разобрал Мартин Фаулер [5].
Одно исключение: Android‑компоненты с пустыми конструкторами вынуждены доставать контейнер сами. Это локальный service locator на границе с чужим фреймворком — подробности в разделе «Паттерн Provider для Android» [6].
Сюда же — частый вопрос «почему не Koin». Koin тоже обходится без кодогенерации и плагина и нативен для KMP, но его связывание по умолчанию резолвится в рантайме: зависимость достаётся из реестра по запросу, граф не виден в коде, а забытая регистрация всплывает не ошибкой компиляции, а падением в рантайме. Это ровно тот service locator с невидимым графом, от которого ручной DI и уходит.
Composition root держится на нескольких правилах.
Только инъекция через конструктор — компоненты сами не создают компоненты. Конструктор класса — это его честный список зависимостей. Всё, что класс достаёт из глобалов или создаёт внутри, в этом списке невидимо.
// плохо: класс сам создает и владеет другим классом, его не подменить в тесте
class SyncEngine {
private val repo = SomeRepository()
}
// хорошо: зависимость явная
class SyncEngine(private val repo: SomeRepository)
Сюда же — nullable‑зависимость со значением = null по умолчанию. Если боевая сборка всегда передаёт компонент, а в сигнатуре он опциональный, то вызов, который его забыл, тихо отключит фичу без ошибки компиляции. Делайте такую зависимость обязательной. Значение по умолчанию null оставляйте только для по‑настоящему опциональной зависимости — и тогда тестируйте ветку с null.
Платформенный контекст — это зависимость, а не глобал. Точка входа уже держит Context приложения. Передайте его в конструктор, а не доставайте из статического поля.
// плохо
actual class SyncEngine {
private val manager = GlobalApp.context.getSystemService(/* … */)
}
// хорошо
actual class SyncEngine(context: PlatformContext) {
private val manager = context.getSystemService(/* … */)
}
Один синглтон — один владелец. Если компонент задуман как синглтон, он создаётся ровно один раз — в composition root, не в отдельном object и не внутри другого компонента. А если, наоборот, нужен каждый раз свежий объект или кэшируемый по своим правилам — в контейнере лежит фабрика или реестр: они владеют правилами создания и дают удобные методы с минимумом аргументов.
Не создавайте зависимости внутри composable. Composable, который сам собирает сервис, — это composition root не на своём месте. Грубый случай очевиден: без remember на каждой рекомпозиции рождается новый экземпляр и тут же выбрасывается — утечка и мёртвый объект. Но даже аккуратный вариант с remember неверен:
@Composable
fun SomeScreen() {
val engine = remember { SyncEngine(SomeRepository()) }
DisposableEffect(Unit) {
engine.start()
onDispose { engine.stop() }
}
}
remember убирает утечку, но не главную ошибку: composable владеет жизненным циклом синглтона, а владеть им должна точка входа. Правильная форма — собрать контейнер в точке входа и передать готовый компонент внутрь.
Контейнер хранит именованные компоненты, а не голые данные. Поле контейнера — это объект, который чем‑то владеет или управляет: хранилище, сервер, клиент, корутинный скоуп, фабрика. Не строка, не число, не порт, не тег.
Любое значение неявно несёт логику — доменную или слоя данных. Положить в контейнер готовую строку deviceId — значит зашить решение «это значение доступно сразу, всегда и всем». Но откуда оно берётся? Возможно, его надо прочитать с диска, сгенерировать при первом запуске, дождаться после сетевого ответа. Эта логика кому‑то принадлежит — компоненту, который за deviceId отвечает. Контейнер логикой не владеет, даже такой простой; он только собирает компоненты, которые ею владеют.
// плохо: контейнер владеет данными и зашивает «доступно сразу»
class AppContainer {
val deviceId: String = readDeviceId()
}
// хорошо: контейнер собирает владельца, тот отдаёт значение, когда оно готово
class AppContainer {
val identity: DeviceIdentity by lazy { DeviceIdentity(storage) }
}
// потребитель спрашивает identity.deviceId() в нужный момент — в том числе после I/O
Голые данные на контейнере заставляют материализовать их в момент сборки, прячут, кто за них отвечает, и превращают граф зависимостей в мешок свойств.
Никаких молча бросающих общих дефолтов. Когда поле контейнера требует платформенной реализации, общий контракт говорит об этом явно: поле abstract на общем контейнере — тогда каждый платформенный лист обязан его доопределить, иначе не скомпилируется. Конкретный общий дефолт, который бросает исключение и работает только потому, что одна платформа его случайно переопределила, — худший вариант: он компилируется, проходит тесты на фейках и падает в рантайме на той платформе, что забыла переопределить.
В KMP‑приложении контейнеров целое дерево. Оно повторяет дерево сорс‑сетов. Каждый слой добавляет только то, что его сорс‑сет способен увидеть.
source set container
────────── ─────────
commonMain ←→ AppContainer
├ jvmMain ←→ ├ JvmAppContainer
│ ├ androidMain ←→ │ ├ AndroidAppContainer
│ └ desktopMain ←→ │ └ DesktopAppContainer
└ appleMain ←→ └ AppleAppContainer
└ iosMain ←→ └ IosAppContainer
AppContainer в commonMain собирает всё, что выражается общим кодом. AndroidAppContainer в androidMain добавляет компоненты, которым нужен Context или другой Android‑API. IosAppContainer — то, что требует Apple‑фреймворков. Платформенный лист (конечный узел дерева, leaf) наследует общий контейнер и доопределяет недостающее.
Граница проходит по видимости API. Если компонент собирается из общих абстракций — он живёт в общем контейнере. Если ему нужен платформенный тип — он спускается в платформенный лист. Компонент создаётся там, где впервые становится виден нужный API.
На схеме промежуточный jvmMain как общий родитель androidMain и desktopMain — это настраиваемая иерархия сорс‑сетов, а не форма KMP по умолчанию: в дефолтной структуре Android и JVM лежат соседями под commonMain без общего jvm‑слоя, и совместный jvmMain включается шаблоном иерархии в Gradle.
Сколько у платформы точек входа — столько и листьев контейнера. Каждая точка входа собирает свой лист и раздаёт компоненты вниз.
На Android контейнер собирается в подклассе Application и живёт столько же, сколько процесс.
На iOS контейнер собирается в точке входа UI, например в MainViewController.
На Desktop точек входа может быть несколько. GUI‑запуск и CLI‑запуск (main) — две разные точки входа, и каждая собирает свой лист; на classpath каждой компиляции попадает только то, что ей нужно.
Точка входа владеет жизненным циклом, и потому скоуп созданного ей графа зависимостей ограничивается именно временем ее жизни.
Контейнеру нужны значения, которые знает только точка входа: порт сервера, каталог загрузок, ссылка на Android Application. Эти значения собираются в отдельный объект конфигурации, и его иерархия повторяет иерархию контейнеров. В конфигурации лежат не только прямые значения из строк, чисел или флагов. Туда же попадают объекты со своим поведением: реализация транспорта или источника данных, набор настроек UI под версию приложения, выбор модели монетизации — B2B или B2C. И в отличие от результирующего контейнера, здесь прямые значения разрешены (подробнее в Принципах ручного DI [7])
// commonMain
interface AppConfig {
val downloadsDir: String
val port: Int
}
// androidMain
interface AndroidAppConfig : AppConfig {
val application: Application
}
Контейнер принимает конфигурацию в конструктор. AppContainer в commonMain — без аргументов; промежуточные контейнеры берут свой подтип AppConfig; листья передают конкретную конфигурацию наверх.
Конфигурация — это настройки, поступающие на вход. Контейнер — это то, что собирается с применением этих настроек. Разделение даёт ровно одну точку, где приложение задаёт параметры, и отдельную точку, где из них строится граф.
С Android есть отдельная сложность. Его системные компоненты — Activity, Service, BroadcastReceiver — создаёт фреймворк, и у них должны быть пустые конструкторы. Передать им контейнер через конструктор, как обычному классу, нельзя.
Решение, которое ложится на эту ситуацию:
Подкласс Application реализует интерфейс‑провайдер и владеет контейнером.
Системные компоненты достают контейнер через приведение: (application as AppContainerProvider).container.
interface AppContainerProvider {
val container: AppContainer
}
class MyApp : Application(), AppContainerProvider {
override val container: AppContainer by lazy { AndroidAppContainer(/* config */) }
}
class MyService : Service() {
private val container by lazy { (application as AppContainerProvider).container }
}
По сути это service‑locator как вынужденная компенсация ограничения платформы. Доступ к нему ограничен только Android‑классами. Обычные Kotlin‑классы получают зависимости в конструктор. Ограничение здесь платформенное, а не специфичное для ручного DI: любой фреймворк изобретает здесь свои обходы.
Сам приём документирован Android как способ ручного DI (Manual dependency injection [8]) — правда, там он подан как ступень перед Hilt. Здесь он остаётся самостоятельным решением до тех пор, пока не сработают триггеры из раздела про фреймворк.
Описанная выше иерархия контейнеров собирает граф наследованием: лист достраивает то, что общий контейнер уже определил. Наследование может быть заменено или дополнено композицией. Использование композиции оправдано, когда нужно вынести контейнер по отдельной оси. Как пример — та же ось платформы. AppContainer может быть не переопределен, а принимать в конструктор платформенный контейнер параметром.
// главный контейнер собирается из фрагмента-оси
class AppContainer(
private val platformContainer: PlatformContainer, // AndroidContainer | IosContainer | DesktopContainer
) {
val userRepository: UserRepository by lazy { UserRepository(platformContainer.userCredentialsStore) }
}
Выделяем любую такую ось, выносим её в отдельный фрагмент контейнера и подставляем нужный фрагмент при сборке. Осей может быть несколько.
Флейвор сборки. Free и paid различаются набором зависимостей. Общая часть графа живёт в главном контейнере, а различающаяся — в FreeContainer и PaidContainer; точка сборки подставляет нужный фрагмент под текущий флейвор.
Вариант UI. Старый и новый интерфейс тянут разные презентационные зависимости. Они выносятся в OldUiContainer и NewUiContainer, и в графе оказывается ровно тот, что включён сборкой.
// главный контейнер собирается из фрагмента-оси
class AppContainer(
private val flavor: FlavorContainer, // FreeContainer | PaidContainer
) {
val featureGate: FeatureGate by lazy { FeatureGate(flavor.entitlements) }
}
Также здесь проявляется главный плюс — наглядность. В случае, если нужно и можно вынести отдельную ось — скорее всего это кандидат для отдельного модуля или самостоятельной библиотеки. Ручной DI только подсвечивает возможность, но позволяет оставить ситуацию как есть.
Контейнер — это ещё и шов, через который тесты подставляют фейки. Чтобы это работало, поля контейнера объявляются как open val или abstract val: переопределение и есть механизм подмены. Никаких сеттеров, никакого lateinit var.
class FakeContainer : AppContainer() {
override val httpClient = FakeHttpClient()
override val messageRepository = FakeMessageRepository()
}
Тест собирает фейковый контейнер наследованием и переопределяет только нужные поля — не плодя класс под каждый сценарий. Компоненты, которые берут зависимости в конструктор, тестируются напрямую: контейнер им не нужен вовсе, он существует ради боевой сборки.
Для тестов Android‑компонентов под Robolectric нужен Application, который реализует интерфейс‑провайдер. Либо берётся боевой Application, либо определяется тестовый с фейковым контейнером — и каждый тест получает свежий экземпляр, ничего не оседает в статике.
Честная оговорка про то, чего ручной DI не даёт.
Ничто в нём не мешает создать зависимость прямо в классе или спрятать состояние в object. Ровно так же ни один фреймворк не запрещает писать плохо. Разница в том, что нарушение здесь видно глазами: лишний вызов конструктора или обращение к глобалу торчат в коде и в диффе. Нужны гарантии — это уровень кастомных линтеров; без них остаётся инженерная культура команды.
Забытую зависимость компилятор и так ловит: конструктор без аргумента не вызвать, поэтому «забыл предоставить X» просто не скомпилируется. Единственная брешь — циклические зависимости через ленивые поля (by lazy): когда два компонента ссылаются друг на друга, цикл всплывает в рантайме при первом обращении, а граф‑валидирующий фреймворк ловит его на сборке. Брешь узкая и одна; к тому же всплывший цикл — это пойманная реальная проблема дизайна, а не спрятанная, так что чистым проигрышем это назвать сложно.
У ручного DI нет встроенного механизма скоупов. Все контейнеры — синглтоны на весь процесс: нет дочернего графа на экран, нет зависимости, привязанной к жизненному циклу, нет выгрузки части графа из памяти, когда она больше не нужна. Именно это дают зрелые фреймворки.
Объектами, живущими ровно пока открыт экран, владеет слой навигации или экрана — обычным Kotlin: они приходят в конструктор сверху или создаются на месте, а отпускаются вместе с владельцем, когда тот закрывается. Этого хватает, пока владельцев жизненного цикла немного и они выражены явными классами. Когда нужны DI‑управляемые скоупы, lifecycle‑bound зависимости или выгружаемые фичи — ответ даёт фреймворк (см. «Миграция на фреймворк» [4]).
Контейнер держит синглтоны на весь процесс, и Closeable‑компоненты вроде HttpClient закрываются вместе с процессом или Application — отдельной выгрузки графа из памяти у ручного DI нет. Где компонент живёт короче процесса, действует тот же принцип владения: close() вызывает тот, кто владеет его жизненным циклом, — это ручная работа, автоматического освобождения здесь не происходит.
Пока приложение — один модуль, composition root остаётся плоским. Но тот же подход растёт до отдельных KMP‑библиотек и до композиции модулей внутри приложения, и там добавляется ещё один разрез: что модуль или библиотека показывают наружу, а что прячут.
Библиотека или модуль делится надвое. Модуль Api содержит только интерфейсы и модели для внешнего использования. Модуль Impl зависит от Api и содержит все реализации.
Это разделение может выглядеть притянутым, но на самом деле оно — продолжение того же принципа composition root, только на уровне модулей. До сих пор мы обсуждали единственную точку создания компонентов. Разделение на Api и Impl поднимает тот же принцип на ступень выше: реализацию видит только одно место — модуль, который собирает DI. Все остальные модули компилируются против Api и про существование Impl не знают.
Ценность здесь — контроль над тем, кто вообще имеет право зависеть от реализации. Раз на Impl смотрит только DI‑модуль (модуль точки сборки), какой объект стоит за интерфейсом Api — решает точка сборки, и потребители этого не касаются.
Показательный случай — опциональная или подгружаемая фича. Решение «доступна ли реализация и какую версию поднять» принимается ровно один раз, в composition root. Недоступна — корень заводит за тем же интерфейсом Api заглушку или обрабатывает случай иначе. Доступна — резолвит и подставляет настоящий Impl. А как она доставлена — Android dynamic‑feature, отдельная загрузка из удалённого хранилища, выбор нужного .so под рантайм‑условия — остальному коду неважно: потребители держат в руках только Api и вызывают функциональность напрямую, без единой проверки «загружено ли это».
У библиотеки контейнер раздваивается по видимости.
LibPublicContainer — публичное API (фасад) библиотеки. Объявлен в Api‑модуле. В нём лежат все зависимости, которые библиотека отдаёт внешним потребителям.
LibInternalContainer — наследник публичного, объявлен в Impl‑модуле. Добавляет зависимости для внутреннего использования. Внешним потребителям его тип не виден, поэтому опереться на него снаружи библиотеки физически нельзя.
// модуль Api
interface LibPublicContainer {
val someRepository: SomeRepository
}
// модуль Impl
interface LibInternalContainer : LibPublicContainer {
val someInternalService: SomeInternalService
}
Контейнер кто‑то должен собрать — выстроить платформенную часть, выдержать порядок сборки. Эта сборка живёт в модуле Impl, за границей Api, и наружу торчит одной фабричной функцией:
// модуль Impl — виден только DI-модулю приложения
fun createLibContainer(config: LibConfigContainer): LibInternalContainer = LibContainerImpl(config)
Функция собирает реализацию внутреннего контейнера, который содержит все внутренние классы библиотеки, и каждый из них уже получил свои зависимости в конструктор при создании. DI‑модуль приложения — единственный, кто видит Impl, поэтому держит контейнер по внутреннему типу, а наружу отдаёт публичный фасад:
class App {
val libContainer: LibPublicContainer by lazy {
createLibContainer(AndroidLibConfig(application = this))
}
}
Честная оговорка: createLibContainer живёт в Impl‑модуле, поэтому DI‑модуль приложения берёт Impl в зависимости. Если в этом модуле нарушить уговор «внутренний контейнер — только для инициализации, а пользоваться библиотекой — по публичному», спроектированные границы потекут. На чистом Kotlin без рефлексии от этого не защититься: остаются кастомные линтеры, обфускация (где применима) или дисциплина команды.
Дальше готовый контейнер достаточно прокинуть в конструкторы внешних потребителей:
val someInteractor: SomeInteractor by lazy {
SomeInteractor(repository = libContainer.someRepository)
}
Иногда внутренним классам библиотеки нужно достать контейнер статически — та же история, что с Android‑компонентами, и та же оговорка: это локальный service locator на границе с фреймворком. Чтобы он не протёк наружу, провайдер типизируется ровно на то, что нужно вызывающему. Снаружи доступен LibProvider с публичным типом — он объявлен в Api. Внутреннему коду нужен внутренний контейнер, поэтому для него — отдельный LibInternalProvider с внутренним типом, объявленный в Impl и наружу не видимый.
// модуль Api: публичный провайдер
interface LibProvider {
val lib: LibPublicContainer
}
// модуль Impl: провайдер для внутреннего контейнера.
// Наружу не виден не из-за модификатора, а потому что Impl нет на classpath потребителей.
interface LibInternalProvider {
val internalLib: LibInternalContainer
}
// в приложении один и тот же App отдаёт оба лица контейнера
class App : Application(), LibProvider, LibInternalProvider {
private val container: LibInternalContainer by lazy {
createLibContainer(AndroidLibConfig(application = this))
}
override val lib: LibPublicContainer get() = container // наружу — публичный фасад
override val internalLib: LibInternalContainer get() = container // внутрь — расширенный
}
// внутри библиотеки — один каст, и при неверной сборке он падает с понятной ошибкой
class LibActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val provider = application as? LibInternalProvider
?: error("Application must implement LibInternalProvider")
provider.internalLib.someInternalService.start()
}
}
Отдельный внутренний провайдер избавляет от двойного каста as? … as?, в котором два разных промаха слились бы в одну молчаливую ветку и внутренний start() просто не случился бы. Здесь каст один и при неверной сборке падает с IllegalStateException, а не тихо отключает фичу.
Порядок взаимодействия с библиотекой:
Приложение инициализирует библиотеку через ее фабрику
Библиотека возвращает приложению свой контейнер на хранение
Внутренний Android компонент библиотеки получает внутренний контейнер у приложения как LibInternalProvider
Android компонент приложения получает контейнер приложения у приложения как AppContainerProvider
Android компонент приложения получает публичный контейнер библиотеки у приложения как LibPublicProvider
Структурный триггер добавления фреймворка по сути один — скоупы и lifecycle‑bound зависимости. Когда графу нужно ограничение по времени жизни и явная выгрузка из памяти.
Не триггер, но дополнительная стоимость: во многих фреймворках реализованы методы для упрощения получения ViewModel. При использовании MVVM и ручного подхода, нужно будет вручную прописать фабрики моделей и использовать стандартный ViewModelStore.
Переход при этом дёшев: контейнеры и провайдеры по форме уже близки к графу фреймворка — контейнер становится графом зависимостей, платформенные фрагменты и конфигурация — вкладами в него, тесты на open val — графами с переопределённым связыванием. Какой именно фреймворк брать — отдельный разговор; для KMP смотрят на поддержку всех таргетов, проверку графа на компиляции и эргономику в многомодульном проекте, можно рассмотреть Metro [9] или kotlin‑inject.
Практический разбор такой миграции на Metro в KMP‑мобильном приложении — Metro DI for KMP Mobile [10] FunkyMuse: стартовая точка там была не composition root, а самописный God Object, но итоговая форма (скоупы, multibindings, contributes‑to) — ровно то, что разблокирует фреймворк после описанных триггеров.
Ручной DI стоит на одном принципе: граф зависимостей собирается в единственной точке — composition root, а все остальные классы получают готовые зависимости в конструктор. Разделение на Api/Impl и композиция контейнера из частей — тот же принцип, поднятый на уровень модулей и разнесённый по осям, по которым приложение варьируется.
Платой остаётся ручная компоновка, выигрышем — наглядность: граф лежит в коде, без магии, и читается одинаково человеком, IDE и ИИ‑агентом. Пока граф помещается в голову, это дёшево; когда перестанет — контейнеры и провайдеры уже близки по форме к графу фреймворка, и переход не потребует структурной перетряски.
Идея не нова: контейнер‑фасад для KMM с делением на публичную и внутреннюю часть ещё в 2023-м описывал Марцин Пекельны [11]. Здесь добавлено то, что обеспечивает реальную применимость: иерархия контейнеров по сорс‑сетам на всех платформах, паттерн провайдера для Android, композиция по осям и масштабирование на модули — Api/Impl, public/internal.
Код, на котором основана статья, открыт: github.com/khmelevartem/tether [1]. Полный разбор паттерна и чек‑лист для нового кода живут в docs/engineering/dependency-injection.md.
Автор: artem_khmelev
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/android/453900
Ссылки в тексте:
[1] Tether: https://github.com/khmelevartem/tether
[2] Иерархия контейнеров: #%D0%B8%D0%B5%D1%80%D0%B0%D1%80%D1%85%D0%B8%D1%8F-%D0%BA%D0%BE%D0%BD%D1%82%D0%B5%D0%B9%D0%BD%D0%B5%D1%80%D0%BE%D0%B2-%D0%BF%D0%BE%D0%B2%D1%82%D0%BE%D1%80%D1%8F%D0%B5%D1%82-%D0%B8%D0%B5%D1%80%D0%B0%D1%80%D1%85%D0%B8%D1%8E-%D1%81%D0%BE%D1%80%D1%81-%D1%81%D0%B5%D1%82%D0%BE%D0%B2
[3] Разделение на Api и Impl: #%D1%80%D0%B0%D0%B7%D0%B4%D0%B5%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5-%D0%BD%D0%B0-Api-%D0%B8-Impl
[4] Миграция на фреймворк: #%D0%BC%D0%B8%D0%B3%D1%80%D0%B0%D1%86%D0%B8%D1%8F-%D0%BD%D0%B0-%D1%84%D1%80%D0%B5%D0%B9%D0%BC%D0%B2%D0%BE%D1%80%D0%BA
[5] Мартин Фаулер: https://martinfowler.com/articles/injection.html
[6] «Паттерн Provider для Android»: #%D0%BF%D0%B0%D1%82%D1%82%D0%B5%D1%80%D0%BD-provider-%D0%B4%D0%BB%D1%8F-android
[7] Принципах ручного DI: #%D0%BF%D1%80%D0%B8%D0%BD%D1%86%D0%B8%D0%BF%D1%8B-%D1%80%D1%83%D1%87%D0%BD%D0%BE%D0%B3%D0%BE-DI
[8] Manual dependency injection: https://developer.android.com/training/dependency-injection/manual
[9] Metro: https://github.com/ZacSweers/metro
[10] Metro DI for KMP Mobile: https://funkymuse.dev/posts/metro-di-kmp-mobile/
[11] Марцин Пекельны: https://medium.com/@maruchin/kmm-architecture-5-dependency-injection-79052c7ea778
[12] Источник: https://habr.com/ru/articles/1051804/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1051804
Нажмите здесь для печати.