Привет, Хабр! Меня зовут Арсений Шпилевой, и я Android-разработчик в core-команде WB Partners, Wildberries & Russ. Сегодня поделюсь нашим опытом развития архитектуры приложения на Jetpack Compose: с какими ограничениями мы столкнулись, как укротили навигацию от Google и какими молитвами всё это подружили с Koin. В начале мы затронем структуру модулей, затем обсудим скоупы в Koin для многомодульных проектов, и завершим это все библиотекой Jetpack Navigation.
Старый подход: один экран — один модуль
В ранней версии нашей архитектуры под каждый экран приложения создавался отдельный API/IMPL-модуль. Для открытия экранов использовались навигационные Destination-классы, которые хранились все вместе в общем модуле навигации.

Поначалу это казалось удобным, но по мере роста проекта стали возникать проблемы.
Во-первых, такая структура усложняла соединение нескольких экранов в один продуктовый флоу. Приходилось добавлять обвязочные модули с common-сущностями.
Во-вторых, общий навигационный модуль, подключённый к каждому экрану, содержал реализации переходов для всех экранов (более 100 классов Destination). Все фича-модули знали друг о друге, создавая неявные зависимости между экранами и усложняя рефакторинг.
В-третьих, на каждый экран генерировался шаблонный API-модуль, и он зачастую оставался пустым, потому что контракт модуля фактически определялся Destination-классом в общем модуле навигации.
Новый подход: один модуль на продуктовый флоу
Мы умеем учиться на своих ошибках, поэтому решили менять архитектуру. И в первую очередь отказались от концепции «один экран — один модуль». Теперь, если продакт-менеджеры приносят фичу из нескольких экранов, мы понимаем, что это единый продуктовый флоу, и все экраны этой фичи делаем в одном модуле.
Мы обновили Jetpack Navigation до версии, поддерживающей сериализуемые data-классы (мы называем их Route-классы), и сразу кладём эти классы в API-модуль фичи. Теперь, если какому-то экрану нужно навигироваться на фичу, он подключает у себя API этой фичи через Gradle. Так видно, от каких модулей зависит тот или иной экран.
Кроме того, теперь модуль выделяется под весь продуктовый флоу. Работать с common-сущностями и внутренней навигацией стало проще — мы размещаем их в этом же модуле.
Вот так выглядит схема нового модуля:

Обратите внимание: объединение экранов в один модуль не означает простой перенос пакетов — в каждом слое пакеты теперь не привязываются к конкретному экрану. Например, на уровне Data у нас три источника данных, в Domain — две группы UseCase, а в Presentation — четыре экрана.
Мы специально отвязали слои друг от друга: в продуктовом флоу появляется общая логика, и, например, один репозиторий может использоваться двумя экранами. Или два экрана могут пользоваться одной группой UseCase. Архитектура получилась более гибкой и масштабируемой.
Почему мы решил поступить именно так? Представим, что у нас есть экран создания товара. С появлением новой функциональности форма создания становится слишком большой и дизайнеры решают разбить экран на несколько небольших экранов со степпером. С новой структурой пакетов разработчик затронет только код в пакетах presentation и navigation, что соответствует реальности — при изменении дизайна логика создания не поменялась. Если на МР по редизайну экрана были затронуты другие слои, хотя продуктово изменений быть не должно, то это повод забеспокоиться и провести дополнительный аудит модуля фичи.
Подведем небольшой итог по блоку структуры модуля:
Объединили экраны одного продуктового флоу в одном модуле.
Начали чистить навигационный модуль от Destination-классов, перенося контракт фичи в API-модуль.
Избавились от костылей с обвязочными модулями — теперь всё общее спрятано внутри модуля продуктового флоу.
А главное — мы подготовили почву для дальнейших переделок, в частности для работы с навигацией и Koin.
Koin и Scoped-зависимости
Для тех, кто не работал с Koin — это простейший сервис-локатор, который можно использовать как DI-фреймворк. Написан полностью на Kotlin, работает в рантайме.
Слово "Рантайм" часто пугает в контексте DI, и вы, вероятно, можете подумать, что мы, часто сталкиваемся с ошибками в продакшне, раз используем такой фреймворк на большом проекте. Это не так. И мы даже не пишем какие-либо тесты для DI: все потенциальные ошибки с инициализацией отлавливаются разработчиком при первых запусках своей фичи в рамках задачи.
Для этого у нас есть небольшой лайфхак: минимизируйте прямое обращение к сервис-локатору (by inject, Koin.get() и т.д.). Такое получение зависимости срабатывает только при непосредственном выполнении кода фичи и может быть скрыто под тогглами или сложным условием в if. В результате класс требует дополнительного покрытия тест-кейсами при ручном и Unit-тестировании ради одной лишь проверки инициализации объектов, и все равно нет гарантий, что были покрыты все множества сценариев.

Если зависимости внедряются через конструктор, то все они собираются в момент создания ViewModel через koinViewModel. Чтобы протестировать DI такого экрана, достаточно просто его открыть.
Вопрос необходимости Koin, как видите, не стоял. Но нам не было понятно, как работать со скоупами. Официальная документация слабо освещает эту тему, особенно для Compose.
Рассмотрим пример файла с описанием зависимостей для одного из модулей старой архитектуры:
fun goodsModule() = module {
singleOf(::GoodsRepository)
factoryOf(::GoodsInteractor)
viewModelOf(::GoodsViewModel)
/*другие зависимости*/
}
В примере можно заметить, что scoped-зависимостей нет вообще. Если репозиторий требовалось заинжектить более чем в один класс, он автоматически становился синглтоном. После первого инжекта инстанс продолжал существовать в памяти до закрытия приложения, что избыточно, особенно для короткоживущих фичей. К тому же такие репозитории требовали дополнительной обработки текущего состояния, при перезаходе в фичу (например, вычистить данные после прошлого применения).
Для решения этих проблем и нужны скоупы. Они позволяют иметь зависимости с жизненным циклом, соответствующим времени жизни конкретных сценариев, а не всего приложения. Это экономит ресурсы и упрощает код (не надо проверять лишний раз валидность данных внутри флоу).
Переходим к практике
Представим небольшую схему приложения с разными жизненными циклами:

Теперь напишем для неё псевдокод:
Scope<App> {
Scope<Auth> {
val deps = createDeps()
Scope<Flow1>(deps) {...}
Scope<Flow2>(deps) {...}
}
}
Получилось довольно декларативно, напоминает Compose. Поэтому мы начали думать в этом направлении.
Для работы скоупов мы решили вводить сущность, похожую на Component в Dagger, но не хотели изобретать велосипед, так что для начала заглянули в возможности библиотеки Koin.
Первое, на что мы обратили внимание, — это ScopeViewModel.
abstract class ScopeViewModel : ViewModel(), KoinScopeComponent {
override val scope: Scope = createScope(this)
open fun onCloseScope(){}
override fun onCleared() {
super.onCleared()
onCloseScope()
scope.close()
}
}
В теории она покрывала бы наши потребности, но мы решили её не использовать по двум причинам:
Не хотелось связывать ViewModel с DI-фреймворком — иначе её сложнее тестировать. Пришлось бы поднимать Koin-контекст в тестах.
Если вы создаёте какие-то зависимости в скоупе этой ViewModel, то не можете инжектировать их через конструктор в эту же ViewModel — все зависимости придётся получать через by inject.
(Справедливо будет сказать, что недавно Koin выпустил обновление, где появилась возможность внедрять зависимости через конструктор, но на момент, когда мы писали код, этого не было).
Далее мы обратили внимание на KoinScopeComponent, от которого наследуется ScopeViewModel. Это очень простой интерфейс, что-либо сломать с ним тяжело, поэтому мы решили использовать его в качестве держателя зависимостей, а ViewModel уже создавать под этим скоупом. Мы даже успели написать одну фичу на этих компонентах. Выглядели компоненты так:
internal class BuyerPortraitRootComponent : KoinScopeComponent {
override val scope: Scope by getOrCreateScope()
}
internal class BuyerPortraitChartComponent : KoinScopeComponent {
override val scope: Scope by getOrCreateScope()
}
internal class BuyerPortraitTableComponent : KoinScopeComponent {
override val scope: Scope by getOrCreateScope()
}
Всё работало отлично до тех пор, пока в этой фиче не потребовалось поворачивать экран. Тут начались проблемы: поворот экрана — это пересоздание Activity. Мы создавали компонент в Compose через remember, поэтому при повороте у него был уж новый инстанс, а ViewModel продолжала работать со старым. Разрыв жизненных циклов — приложение начинает работать некорректно.
Расстроиться и бросить всё мы не могли — фича должна была быть готова ещё вчера. Так что мы решили… совместить оба подхода!
Мы написали собственный абстрактный класс — Component, который на самом деле является ScopeViewModel. Чтобы не смущать разработчиков, сделали для него обёртку – extension-функцию Module.component(), которая под капотом вызывает viewModel():
abstract class Component : ScopeViewModel() {
override val scope: Scope by getOrCreateScope()
}
inline fun <reified C : Component> Module.component(
crossinline constructor: Scope.() -> C,
) = viewModel { this.constructor() }
Фактически у нас создавалась скрытая ViewModel, которая определяла скоуп зависимостей, а внутри нее уже появлялась настоящая ВМ для экрана. Возможно, не очень изящно, но это работало, поэтому пока остановились на этом.
Компонент есть, теперь нужно как-то объяснять Compose, что определённые зависимости должны предоставляться с соответствующим скоупом
Продолжив исследовать библиотеку, мы нашли функцию KoinContext. Внутри через CompositionLocalProvider она объявляет LocalKoinScope, который передаёт некоторый скоуп. Казалось бы, можно сделать так: создаём компонент, берём из него Koin через getKoin(), помещаем в KoinContext — и всё должно работать.
@Composable
fun KoinContext(
context: Koin = KoinPlatform.getKoin(),
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalKoinApplication provides context,
LocalKoinScope provides context.scopeRegistry.rootScope,
content = content
)
}
Но рано радоваться: KoinContext всегда передаёт Root-скоуп. Какой бы компонент вы ни создали, getKoin вернёт глобальный Koin, а тот — всегда рутовый скоуп. Скоуп вашего компонента игнорируется.
Тогда мы просто написали свой аналог и назвали его ScopedContext. Работает он практически так же, но берёт скоуп не глобальный, а именно из нашего компонента. Мы также спрятали создание компонента, использовав наш KoinViewModel (чтобы разработчики вообще не знали про ViewModel).
@Composable
inline fun <reified C : Component> ScopedContext(
crossinline content: @Composable Scope.() -> Unit
) {
val component: C = koinViewModel()
CompositionLocalProvider(
LocalKoinApplication provides component.scope.getKoin(),
LocalKoinScope provides component.scope,
) {
content(component.scope)
}
}
Лямбда в нашем решении — это extension для скоупа, чтобы внутри Compose-функции (контента) мы могли создавать LinkedContext. Эта функция-близнец работает так же, как и ScopedContext, но дополнительно линкует дочерний скоуп к родительскому.
@Composable
inline fun <reified C : Component> Scope.LinkedContext(
crossinline content: @Composable Scope.() -> Unit
) {
val component: C = koinViewModel()
component.scope.linkTo(this)
CompositionLocalProvider(
LocalKoinApplication provides component.scope.getKoin(),
LocalKoinScope provides component.scope,
) {
content(component.scope)
}
}
В Dagger, как вы знаете, можно передавать рантайм-зависимости при создании компонента через билдеры или фабрику. Мы не стали пока так усложнять: просто передаём зависимости через конструктор компонента и тут же объявляем их в Koin при инициализации.
class StoreComponent(storeId: Long?) : Component {
init {
scope.declare(StoreId(storeId))
}
@JvmInline
value class StoreId(val id: Long? = null)
}
Обратите внимание, что у нас используется класс-обёртка StoreId. Это лазейка для передачи nullable-зависимости. В Koin нельзя просто объявить nullable-объект: его обязательно нужно оборачивать.
Теперь фича-модуль может выглядеть так: создаём компонент через функцию component, затем описываем для него скоуп-зависимости.
fun storesModule() {
component { (storeId: Long?) -> StoreComponent(storeId) }
scope<StoreComponent> {
scopedOf(::StoreRepository)
viewModelOf(::StoreViewModel)
/*другие зависимости*/
}
}
Репозиторий, который создаётся в скоупе, может сразу заинжектить StoreId и достать из базы нужный Store.
class StoreRepository(private val storeId: StoreId, ...) {
private val selectedStoreId = storeId.id
val storeDataFlow: Flow<StoreData> = api
.getStoreFlow(selectedStoreId)
.map(mapper::map)
}
ViewModel больше не оперирует конкретными ID — ей не нужно брать на себя эту ответственность. Она сразу вызывает UseCase, который возвращает конкретные данные, потому что ViewModel уже существует в контексте деталей определённого объекта (например, склада). Если эта ViewModel создана, значит, нужный склад уже выбран.
Подытожим эту часть. Главное, что мы сделали, — собственные обёртки (ScopedContext, LinkedContext) и возможность создавать компоненты с рантайм-зависимостями. Это позволило проще поддерживать жизненный цикл различных компонентов в продуктовых флоу.
Страх и ненависть в Jetpack Navigation
Теперь настала очередь Jetpack Navigation.
Плюсы этой библиотеки весьма существенны:
очень удобный API для Compose;
поддержка диплинков из коробки;
продукт разрабатывает крупная компания — Google.
Но также есть и минусы:
NavGraphBuilder (неотъемлемая часть библиотеки) не умеет работать с Compose внутри себя;
Google своими обновлениями заставляет переписывать часть навигации;
неочевидно, как прокидывать результаты от дочерних экранов в родительские;
плохое масштабирование для больших приложений.
Про проброс результатов между экранами мы писали в этой статье.
Основная боль для нас — масштабирование. Об этом сейчас и поговорим.
Как Google презентует свою навигацию? У нас есть приложение, мы создаём в корне NavHost, затем определяем экраны и прикрепляем их к этому NavHost. Глубина дерева навигации невелика — всего один уровень.

Если есть группы экранов, которые можно выделить в отдельный узел, Jetpack Navigation предлагает использовать функцию navigation — она объединяет несколько экранов в отдельный флоу.

Мы думали, что этой функциональности нам хватит на любые хотелки, поэтому базовую архитектуру строили вокруг этой парадигмы. Мы даже сразу написали свой класс WbNavigator. По сути, это шина, которая кидает события из ViewModel в корневой NavController, чтобы избавить разработчиков от необходимости напрямую вызывать методы навигации. В корневом модуле App мы создаём этот WbNavigator и прикрепляем его к NavController через нашу функцию collectEvents.
val wbNavigator = getKoin().get<WbNavigator>()
val navController = rememberNavController()
navController.CollectEvents(wbNavigator, onCloseApp)
Наша BaseViewModel наследовалась от KoinComponent и внутри получала WbNavigator, через by inject(), так что у всех ViewModel был доступ к этому навигатору.
open class BaseViewModel : ViewModel(), KoinComponent {
private val wbNavigator: WbNavigator by inject()
protected fun navigateToScreen(navigationEvents: NavigationEvents) {
wbNavigator.navigate(navigationEvents)
}
}
Проблемы стали появляться достаточно быстро.
Рассмотрим сложные экраны. К примеру, экран с bottom-навигацией и общей шапкой с фильтрами, а в центральном контенте — отдельные экранчики со своими ViewModel. Или экран без bottom-навигации, но с общей шапкой и табами, которые переключают контент. Или похожий экран, где вместо табов — степпер. Все эти варианты оказалось невозможно реализовать, имея только один NavHost и цепляя внутренние экраны к нему.

Мы начали добавлять под такие экраны свои хосты — они становились небольшими корневыми узлами. А так как для работы с NavController мы использовали шину, соответственно, для каждого нового хоста мы заводили свой экземпляр WbNavigator.
Управлять такой системой становилось всё сложнее, особенно потому, что WbNavigator инжектился в базовую ViewModel. Пришлось изобретать ухищрения, чтобы определить, какой именно навигатор инжектировать. Особенно тяжело, когда какая-то ViewModel должна уметь работать и с внешней, и с внутренней навигацией.
Нам это надоело, и мы сформулировали новые принципы навигации:
Полностью отказаться от WbNavigator.
NavHost — не один, и это норма.
Каждый модуль — отдельный узел в дереве навигации.
Экраны открываются по сериализуемым Route-классам.
Навигация отдельного продуктового флоу изолирована.
VM ничего не знает о сущностях навигации.
Как может выглядеть навигационный граф новой фичи?
Сценарий 1: фича содержит всего один экран. Здесь всё просто: делаем так, как рекомендует Google: создаём composable и описываем в нём этот экран.
fun NavGraphBuilder.someGraph(navController: NavController) {
composable<SomeRoute> {
SomeRoute(onBack = { navController.navigateUp() })
}
}
Сценарий 2: фича содержит несколько экранов, но они не имеют общего UI. Они просто заменяют друг друга, и это можно оформить через вложенный граф navigation.
fun NavGraphBuilder.someGraph(navController: NavController) {
navigation<SomeRoute>(startDestination = InnerRoute) {
composable<InnerRoute> {
InnerRoute(
onBack = { navController.navigateUp() },
openSecondScreen = { controller.navigate(InnerRoute2) }
)
}
composable<InnerRoute2> {
InnerRoute2(onBack = { controller.navigateUp() })
}
}
}
Сценарий 3: фича содержит несколько экранов с общим UI. Мы комбинируем оба подхода: создаём отдельный узел Root и передаём внутрь него (через параметр) NavGraphBuilder для внутренних экранов. Под капотом Root имеет свой NavController и NavHost: он принимает NavGraphBuilder и вставляет его. Внутри Root могут быть свои ViewModel, тулбар, общая bottom-навигация и т. д. Так как навигация изолирована в одном файле, мы можем напрямую управлять тем, с какой навигацией работает фича. Снаружи в Root передаётся родительский NavController, а внутри (в лямбде NavGraphBuilder) используется внутренний NavController. Соответственно, экраны внутри могут решать, к какому NavController обращаться.
fun NavGraphBuilder.someGraph(navController: NavController) {
composable<SomeRoute> {
SomeRootRoute(startDestination = InnerRoute) { controller ->
composable<InnerRoute> {
InnerRoute(
onBack = { navController.navigateUp() },
openSecondScreen = { controller.navigate(InnerRoute2) }
)
}
composable<InnerRoute2> {
InnerRoute2(onBack = { controller.navigateUp() })
}
}
}
}
@Composable
fun SomeRootRoute(startDestination: Any, navGraphBuilder: NavGraphBuilder.(NavController) -> Unit) {
//val vm: RootViewModel = ...
//common toolbar
val navController = rememberNavController()
NavHost(navController = navController, startDestination = startDestination) {
navGraphBuilder(navController)
}
}
Пара слов про ViewModel
Старая BaseViewModel содержала много легаси-кода и, как я упоминал выше, наследовалась от KoinComponent. Мы не стали усложнять и написали новую WbViewModel, выкинув из неё почти всё лишнее и добавив только пару методов жизненного цикла.
Главное отличие новой ViewModel — у неё есть дженерик-Router. Это контракт между ней и внешним миром. ViewModel вообще не знает, как этот контракт реализован, она лишь говорит: «В таких-то ситуациях я вызову такие-то методы». При этом внутрь ViewModel больше не проникают абстракции навигации.
fun interface Router {
fun goBack()
}
abstract class WbViewModel<T : Router> : ViewModel() {
companion object {
const val DEBOUNCE_DELAY = 300L
const val REFRESH_DELAY = 600L
}
var router: T? = null
open suspend fun onLaunch() {}
open fun onDispose() {}
}
Например, у нас есть экран «Подписка Jam». Мы создаём JamViewModel, наследуем её от WbViewModel. В этом же файле создаём интерфейс JamRouter, который наследуется от нашего Router.
internal interface JamRouter : Router {
fun openManagementScreen()
fun goBack(update: Boolean)
}
internal class JamViewModel(...) : WbViewModel<JamRouter>() {...}
В чём плюс такого подхода? Открывая код JamViewModel, вы сразу видите её контракт — какие навигационные действия она может совершать. Видно, что эта ViewModel умеет открывать, скажем, экран управления Jam и закрываться. При этом сама JamViewModel никак не реализует эти переходы (не содержит их логики).
Как же создать такую ViewModel? Мы сделали свою обёртку над koinViewModel (назвали её wbViewModel). По сути, она ничем не отличается, разве что требует указать Router. В самой ViewModel мы задаём нужные дженерики. Наша JamViewModel требует передать ей реализацию Router, и мы это делаем прямо в навигационном графе — с помощью лямбд. В графе навигации видно JamRoute с тремя лямбдами — эти лямбды реализуются через вызовы NavController.
@Composable
internal fun JamRoute(
goBack: () -> Unit,
openManagementScreen: () -> Unit,
) {
val vm: JamViewModel = wbViewModel<JamViewModel, JamRouter>(
router = object : JamRouter {
override fun goBack() = goBack()
override fun openManagementScreen() = openManagementScreen()
},
)
val state by vm.screenStateFlow.collectAsStateWithLifecycle()
JamScreen(
state = state,
onUIEvent = vm::onUIEvent,
)
}
Теперь у нас единообразный подход к навигации: флоу может состоять из одного экрана, нескольких экранов или нескольких экранов с общим UI — и для каждого случая у нас есть готовое решение. Раньше не всем командам было понятно, как поступать в том или ином случае; теперь у нас есть внутренняя документация на этот счёт.
Мы обновили шаблон-генератор: это наш внутренний плагин для Android Studio, который позволяет создать фичу сразу с несколькими экранами и при необходимости добавляет «общий Root». Мы изолировали навигацию в одном файле, а ViewModel сделали более тестируемой — убрав KoinComponent и избавившись от любой зависимости ViewModel от навигаторов.
Для чего мы всё это городили?
Попробуем объединить Koin и навигацию.
Рассмотрим пример — флоу создания поставки. В этом флоу есть общая шапка со степпером; переключая шаги, мы меняем контент. На первом экране заполняем одни данные, на втором — другие, и т. д. На последнем экране нажимаем «Сохранить» — и всё отправляется на бэкенд. В итоге получается пять экранов, у каждого свой жизненный цикл. Но всем им нужен общий репозиторий, который живёт дольше, чем каждый из экранов, но только пока живёт весь флоу. Он не должен быть синглтоном.

Как это реализовать? На самом деле всё необходимое у нас уже есть.
Если несколько экранов имеют общий UI, мы делаем для них общий компонент Root (как говорилось выше). Оборачиваем его в ScopedContext, создаём Root-компонент, в рамках которого живёт, например, временный репозиторий для черновика. Вложенные экраны используют LinkedContext — и каждый из них получает все зависимости родительского компонента.
fun NavGraphBuilder.createSupplyGraph(navController: NavController) {
composable<CreateSupplyRoute> {
ScopedContext<CreateSupplyRootComponent> {
// CreateSupplyRoot(
// startDestination = GoodsStepRoute,
// goBack = { navController.navigateUp() },
// ) { controller ->
composable<GoodsStepRoute> {
LinkedContext<CreateSupplyGoodsComponent> {
// CreateSupplyGoodsRoute(
// goBack = { controller.navigateUp() },
// openNextStep = { controller.navigate(PlanningStepRoute) },
// )
}
}
// composable<PlanningStepRoute> { /*...*/}
// composable<PlanningStepRoute> { /*...*/}
// composable<DateStepRoute> { /*...*/}
// composable<PackageStepRoute> { /*...*/}
// composable<PassesStepRoute> { /*...*/}
}
}
}
}
А как создать общий компонент, если у нас нет отдельного NavHost (например, мы пользуемся функцией navigation)? У navigation нет собственного Compose-скоупа — нам негде объявить родительский компонент.
Честно говоря, мы долго откладывали решение этой задачи. Во всех подобных кейсах использовали Root-экран, который не содержал в себе общего UI. Но у такого подхода есть один недостаток: во вложенные экраны нельзя сделать диплинк без явных костылей. В конце концов любопытство взяло верх, и мы написали своё решение.
Получилась функция, практически неотличимая от гугловской navigation — за тем исключением, что в нашей реализации добавляется дженерик-компонент, определяющий скоуп. Соответственно, такой же дженерик-параметр появляется во внутренней функции composable.
fun NavGraphBuilder.cardsComparisonGraph(controller: NavController) {
navigation<CardsComparisonRootRoute, CardsComparisonRootComponent>(
controller = controller,
startDestination = CardsComparisonRoute,
) { scope ->
with(scope) {
composable<CardsComparisonRoute, CardsComparisonComponent> {...}
composable<CardsComparisonHistoryRoute, CardsComparisonHistoryComponent> {...}
composable<CardsComparisonSearchQueriesRoute, CardsComparisonSearchQueriesComponent> {...}
composable<CardsComparisonReportRoute, CardsComparisonReportComponent> {...}
composable<CardsComparisonCreateReportRoute, CardsComparisonCreateReportComponent> {...}
composable<TableSettingsRoute, CardsComparisonTableSettingsComponent> {...}
}
}
}
Обращу внимание: функция требует передавать NavController в аргументах, потому что для работы функции нам нужен доступ к бекстеку навигации.
Полный код функции можно посмотреть на Github, а здесь я вкратце опишу как она работает.
В Jetpack Navigation когда вы открываете экран, который вложен в navigation-функцию, под капотом в бэкстек добавляется не одна запись, а две: скрытый navigation и сам экран.
Каждая запись NavBackStackEntry содержит свой собственный ViewModelStore, а мы помним, что наши Scope-компоненты на самом деле являются сущностями ViewModel. Поэтому всё, что нам нужно сделать, — найти navigation-запись в бэкстеке и создать компонент в рамках её ViewModelStore. Запись мы можем найти с помощью функции NavController.getBackStackEntry<T>(), а для создания компонента написана extension-функция для NavBackStackEntry:
fun <SC : Component> NavBackStackEntry.getScopeComponent(
scope: Scope,
clazz: KClass<SC>,
parameters: ParametersDefinition? = null,
): SC {
val factory = KoinViewModelFactory(clazz, scope, null, parameters)
val provider = ViewModelProvider.create(viewModelStore, factory, defaultExtras(this))
return provider[clazz]
Таким образом, при открытии экрана, который вложен в navigation, мы достаем обе записи из бекстека, затем создаем для каждой из них свой ScopeComponent, линкуем один к другому, и только потом инициализируем сам экран в рамках конечного скоупа.
При переключении на другой экран в рамках navigation, скрытая запись уже не создается, и используется ранее созданная, в которой уже есть готовый инстанс компонента. Поэтому все дочерние экраны могут делиться общими зависимостями через эту navigation-запись.
Итоги глобальных изменений
Мы объединили несколько экранов в один модуль (теперь экраны группируются по продуктовым флоу).
Изолировали DI и навигацию в отдельных файлах, чтобы продуктовый код о них не знал. При необходимости их можно легко заменить или изменить.
За счёт введения скоупов у нас более гибкое управление жизненным циклом компонентов, экранов и целых флоу.
Мы задокументировали подход к работе с разными видами продуктовых флоу. Теперь любой новый разработчик может зайти во внутреннюю Wiki и понять, что делать для реализации той или иной задачи.
Все эти изменения накладывают более строгий контракт на создание фич и их интеграцию в наш основной проект. И это отлично. Команда растёт, людей становится очень много, и за каждым не уследишь. Чтобы большая система продолжала расти и масштабироваться, определённая «бюрократия» тоже должна развиваться.
Напоследок скажу о паре не до конца решённых моментов:
функция navigation в Jetpack Navigation требует указать startDestination. Если Route-класс фичи содержит обязательные параметры (например, ID), приходится задавать им дефолтные значения (-1). Получается не очень красивое решение: всё равно нужно обрабатывать этот случай (проверять, если ID = -1), что немного ломает концепцию, ведь в рамках скоупа мы хотим быть уверены, что сущность валидна. Пока что у нас мало примеров с таким подходом, и мы ещё думаем, как сделать лучше.
Диплинки. Если в приложении больше одного NavHost, непонятно, как обрабатывать диплинк, который приходит, например, в onNewIntent. Обычно диплинк передают корневому NavController (в MainActivity). Но теперь, когда у нас есть вложенные NavHosts со своими экранами, до внутренних экранов не добраться напрямую. Максимум, что можно сделать, — передать интент локальному NavHost. Пока мы с этим не сталкивались, но у нас есть заготовка решения: добавить в Route-класс строковое поле deepLink, которое будет указывать локальному NavHost, куда навигироваться. Пока обдумываем, как реализовать это изящнее.
Спасибо тем, кто дошёл до конца, вы герои!
Заходите на GitHub за нашими наработками.
Читайте другую нашу статью: «Типобезопасная передача результатов между экранами в Compose с Jetpack Navigation».
Если вам интересны детали или вы хотите обсудить решения — добро пожаловать в комментарии.
Разборы, новости, экспертиза наших разработчиков и вакансии — в телеграм-канале, подписывайтесь!
Paul85
Еще со времен Cicerone начал использовать навигацию с роутами по флоу и с внутренними роутами по экранам. В итоге удобно закрывать-открывать целый флоу сразу и удобно внутри флоу ходить по экранвм. Смотрю потихоньку народ и на компоузе уже приходит к этому. Спасибо за статью, интересно описано. Коин я так и не полюбил, но в остальном ход мыслей понравиося.