В предыдущей статье этой серии мы познакомили вас с Trio, фреймворком Airbnb для экранной архитектуры Jetpack Compose в Android. Некоторые из преимуществ Trio включают:
- Гарантированная типо-безопасность при взаимодействии через границы модулей в сложных приложениях
- Кодификация ожиданий относительно использования и шаринга ViewModel, а также интерфейсов между экранами
- Обеспечение стабильного скриншот- и UI-тестирования, а также простое тестирование навигации
- Совместимость с Mavericks, библиотекой управления состояниями Airbnb с открытым исходным кодом для Jetpack (Trio построен поверх Mavericks)
Если вам нужно освежить в памяти информацию о Trio или вы впервые знакомитесь с этим фреймворком, начните с первой части. В ней представлен обзор того, почему мы создали Trio при переходе к Compose от архитектуры, основанной на Фрагментах. В первой части также объясняются основные концепции фреймворка, такие как класс Trio и класс UI.
В этом посте мы продолжим рассказывать о том, как мы разрабатываем на этом фреймворке и погрузимся в то, как работает навигация в Trio. Как вы увидите, мы разработали Trio, чтобы сделать навигацию проще и легче для тестирования, особенно для больших модульных приложений.
Навигация в Trio
Уникальность нашего подхода заключается в том, что Trio хранятся в State в ViewModel, наряду с другими данными, которые экран отображает в пользовательском интерфейсе. Например, часто используется список Trio для представления стека экранов.
data class ParentState( @PersistState val trioStack: List<Trio> ) : MavericksState
Аннотация PersistState
— это механизм Mavericks, который автоматически сохраняет и восстанавливает parcelable значения State при смерти процесса, так что состояние навигации сохраняется. Проверка во время компиляции гарантирует, что значения Trio в классах State аннотированы подобным образом, так что их состояние всегда сохраняется правильно.
ViewModel контролирует это состояние и может предоставлять функции для перехода на новый экран или выхода из экрана. Поскольку ViewModel имеет прямой контроль над списком Trio, она также может легко выполнять более сложные навигационные изменения, такие как изменение порядка экранов, сброс нескольких экранов или очистка всех экранов. Это делает навигацию чрезвычайно гибкой.
class ParentViewModel : TrioViewModel { fun pushScreen(trio: Trio) = setState { copy(trioStack = trioStack + trio) } fun pop() = setState { copy(trioStack = trioStack.dropLast(1)) } }
Пользовательский интерфейс родительского Trio получает доступ к списку всех Трио из State и выбирает, как и где разместить Трио. Мы можем реализовать поток экрана, показывая последнее Trio в стеке.
@Composable override fun TrioRenderScope.Content(state: ParentState) { ShowTrio(state.trioStack.last()) }
Координирование навигации
Зачем хранить Трио в State? Альтернативные подходы могут использовать объект навигатора в Compose UI. Однако представление навигационного графа приложения в State позволяет ViewModel обновлять свои данные и навигацию в одном месте. Это может быть очень полезно, когда нам нужно отложить изменение навигации до завершения асинхронного действия, например сетевого запроса. Мы не могли сделать это с помощью Фрагментов и обнаружили, что с подходом Trio наша навигация становится более простой, явной и легко тестируемой.
В этом примере показано, как ViewModel может обрабатывать вызов «сохранить и выйти» из пользовательского интерфейса, запуская приостановленный сетевой запрос в корутине. Как только запрос завершается, мы можем открыть экран, обновив стек Трио в State. В то же время мы также можем атомарно изменять другие значения в State, возможно, основываясь на результате сетевого запроса. Это легко гарантирует, что навигация и состояние ViewModel остаются синхронизированными.
class CounterViewModel : TrioViewModel { fun saveAndExit() = viewModelScope.launch { val success = performSaveRequest() setState { copy( trioStack = trioStack.dropLast(1), success = success ) } } }
По мере усложнения навигационного стека иерархия пользовательского интерфейса приложения моделируется цепочкой ViewModel и их Состояниями. При визуализации State создается соответствующая иерархия пользовательского интерфейса Compose.
Trio может представлять произвольный элемент пользовательского интерфейса любого размера, включая вложенные экраны и секции, обеспечивая при этом резервное состояние и механизм взаимодействия с другими Trio в иерархии.
Есть два дополнительных преимущества моделирования иерархии в Состоянии ViewModel. Первое — это простота задания пользовательских сценариев навигации при настройке тестирования — мы можем легко создавать любые состояния навигации, которые нам нужны для наших тестов.
Другое преимущество заключается в том, что, поскольку иерархия навигации отделена от пользовательского интерфейса Compose, мы можем предварительно загрузить все Trio, которые нам понадобятся, просто инициализировав их ViewModel заранее. Это значительно упростило оптимизацию производительности за счет предварительной загрузки экранов.
В Mavericks в State обычно хранятся простые классы данных, а не сложные объекты, такие как Trio, которые имеют жизненный цикл. Однако мы считаем, что преимущества такого подхода вполне оправдывают дополнительные сложности.
Управление Активити
В идеале приложение с Trio должно использовать только одну Активити, следуя стандартным рекомендациям Google по архитектуре приложений. Однако, особенно в целях взаимодействия, Trio иногда необходимо запускать новые Активити. Традиционно это не делается из ViewModel, поскольку ViewModel не должны содержать ссылки на Activity, так как они переживают жизненный цикл Activity. Однако, чтобы сохранить нашу парадигму, согласно которой вся навигация осуществляется во ViewModel, Trio делает исключение.
Во время инициализации Trio ViewModel получает Поток Activity через свой инициализатор. Этот Flow предоставляет текущую Активити, к которой прикреплена ViewModel, и null, когда она отсоединена, например, во время восстановления Активити. Внутренние механизмы Trio управляют Flow, чтобы гарантировать его актуальность и отсутствие утечки Активити.
При необходимости ViewModel может получить доступ к следующему ненулевому значению Активити с помощью функции awaitActivity suspend. Например, мы можем использовать ее для запуска новой Активити после завершения сетевого запроса.
class ViewModelInitializer<S : MavericksState>( val initialState: S, internal val activityFlow: Flow<Activity?>, ... ) class CounterViewModel( initializer: ViewModelInitializer ) : TrioViewModel { fun saveAndOpenNextPage() = viewModelScope.launch { performSaveRequest() awaitActivity().startActivity() } }
Функция awaitActivity
предоставляется TrioViewModel как удобный способ получения следующего значения в потоке Активити.
suspend fun awaitActivity(): ComponentActivity { return initializer.activityFlow.filterNotNull().first() }
Несмотря на некоторую нестандартность, этот паттерн позволяет использовать activity-based навигацию, размещенную вместе с другой бизнес-логикой во ViewModel.
Структура модуляризации
Правильная модуляризация большой кодовой базы — это проблема, с которой сталкиваются многие приложения. В Airbnb мы разделили нашу кодовую базу на более чем 2000 модулей, чтобы увеличить скорость сборки и четко определить границы владения. Чтобы поддержать это, мы создали собственную систему навигации, которая отделяет функциональные модули. Изначально она была создана для поддержки Fragments и Activities, а затем была расширена для интеграции с Trio, что помогло нам решить общую проблему навигации в масштабе большого приложения.
В структуре нашего проекта каждый модуль имеет определенный тип, обозначаемый префиксом и суффиксом, которые определяют его назначение и вводят набор правил о том, от каких других модулей он может зависеть.
Фиче-модули с префиксом «feat» содержат наши экраны Trio; каждый экран в приложении может жить в отдельном модуле. Чтобы предотвратить появление круговых зависимостей и повысить скорость сборки, мы не разрешаем фиче-модулям зависеть друг от друга.
Это означает, что одна функция не может напрямую инстанцировать другую. Вместо этого у каждого функционального модуля есть соответствующий навигационный модуль с суффиксом «nav», который определяет маршрутизатор к его функции. Чтобы избежать круговой зависимости, маршрутизатор и его целевое трио связаны мультибиндингом Dagger.
В этом простом примере у нас есть функция счетчика и функция десятичной дроби. Функция счетчика может открыть функцию десятичной дроби, чтобы изменить десятичный счет, поэтому модуль счетчика должен зависеть от навигационного модуля десятичной дроби.
Роутинг (маршрутизация)
Модуль навигации невелик. Он содержит только класс Routers с вложенными объектами Router, соответствующими каждому Trio в фиче-модуле.
// In feat.decimal.nav @Plugin(pluginPoint = RoutersPluginPoint::class) class DecimalRouters : RouterDeclarations() { @Parcelize data class DecimalArgs(val count: Double) : Parcelable object DecimalScreen : TrioRouter<DecimalArgs, NavigationProps, NoResult> }
Объект Router параметризуется типами, определяющими публичный интерфейс Trio: Arguments для его инстанцирования, Props, которые он использует для активного взаимодействия, и, при желании, Result, который Trio возвращает.
Arguments — это класс данных, часто включающий примитивные данные, указывающие начальные значения для экрана.
Важно отметить, что класс Routers аннотирован @Plugin
, чтобы объявить, что он должен быть добавлен в Routers PluginPoint. Эта аннотация является частью внутреннего KSP-процессора, который мы используем для инъекции зависимостей, но по сути он просто генерирует код для установки набора мультипривязок Dagger. В результате каждый класс Routers добавляется в набор, к которому мы можем получить доступ из графа Dagger во время выполнения.
Для соответствующего класса Trio в модуле функций мы используем аннотацию @TrioRouter
, чтобы указать, к какому Router относится Trio. Наш KSP-процессор сопоставляет их во время компиляции и генерирует код, который мы можем использовать в рантайме, чтобы найти место назначения Trio для каждого маршрутизатора.
// In feat.decimal @TrioRouter(DecimalRouters.DecimalScreen::class) class DecimalScreen( initializer: Initializer<DecimalArgs, ...> ) : Trio<DecimalArgs, NavigationProps, ...>
Во время компиляции процессор проверяет, что аргументы и реквизиты маршрутизатора соответствуют типам Trio, и что каждый маршрутизатор имеет единственное соответствующее место назначения. Это гарантирует безопасность типов в рантайме для нашей навигационной системы.
Использование маршрутизаторов
Вместо того чтобы вручную создавать Trio, мы позволяем Роутеру делать это за нас. Он убеждается, что указан правильный тип Arguments, ищет соответствующий класс Trio в графе Dagger, создает класс инициализатора для обертывания аргументов, и, наконец, использует отражение для вызова конструктора Trio.
Эта функциональность доступна через функцию createTrio
на маршрутизаторе, которую мы можем вызвать из ViewModel. Это позволяет нам легко создать новый экземпляр Trio и поместить его в наш стек Trio. В следующем примере экземпляр Props позволяет Trio обращаться к своему родителю, чтобы выполнить это перемещение; мы подробно рассмотрим Props в третьей части этой серии.
class CounterViewModel : TrioViewModel { fun showDecimal(count: Double) { val trio = DecimalRouters.DecimalScreen.createTrio(DecimalArgs(count)) props.pushScreen(trio) } }
Если мы хотим запустить Trio в новой Активити, Router также предоставляет функцию для создания интента для новой Активити, которая обертывает экземпляр Trio; затем мы можем запустить его из ViewModel, используя механизм Активити Trio, как обсуждалось ранее.
class CounterViewModel : TrioViewModel { fun showDecimal(count: Double) = viewModelScope.launch { val activity = awaitActivity() val intent = DecimalRouters.DecimalScreen .newIntent(activity, DecimalArgs(count)) activity.startActivity(intent) } }
Когда Trio запускается в новой Активити, нам нужно просто извлечь экземпляр Parcelable Trio из интента и показать его в корне содержимого Activity.
class TrioActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val trio = intent.parseTrio() setContent { ShowTrio(trio) } } }
Мы также можем начать Активити для получения результата, определив тип Result в роутере.
class DecimalRouters : RouterDeclarations() { data class DecimalResult(val count: Double) object DecimalScreen : TrioRouter<DecimalArgs, …, DecimalResult> }
В этом случае ViewModel содержит свойство launcher, которое используется для запуска новой Активити.
class CounterViewModel : TrioViewModel { val decimalLauncher = DecimalScreen.createResultLauncher { result -> setState { copy(count = result.count) } } fun showDecimal(count: Double) { decimalLauncher.startActivityForResult(DecimalArgs(count)) } }
Например, если пользователь корректирует десятичные числа на экране, мы можем вернуть новый счетчик, чтобы обновить состояние счетчика. Лямбда-аргумент к лаунчеру позволяет нам обрабатывать результат, когда десятичный экран возвращает значение, который мы можем использовать для обновления состояния. Это способствует достижению нашей цели — централизации всей навигации в ViewModel, гарантируя при этом безопасность типов.
Наша система Router предлагает и другие приятные возможности в дополнение к модульности, такие как цепочки перехватчиков в разрешении Router, предоставляющие промежуточные экраны перед показом конечного Trio. Мы используем это для перенаправления пользователей на страницу входа в систему, когда это необходимо, а также для показа страницы загрузки, если динамическая функция должна быть загружена первой.
Взаимодействие с Фрегментами
Для нас было очень важно сделать экраны Trio совместимыми с существующими экранами Фрагментов. Наш переход на Trio — это многолетняя работа, и Trio и Fragment должны легко сосуществовать.
Наш подход к совместимости состоит из двух частей. Во-первых, если Фрагменту и Trio не нужно динамически обмениваться информацией при создании (то есть они принимают только начальные аргументы и возвращают результат), то при переходе от Фрагмента к Trio проще всего начать новую Активити. Оба типа архитектуры могут быть легко запущены в новой Активити с Arguments и могут по желанию возвращать результат по завершении, поэтому между ними очень легко перемещаться таким образом.
В качестве альтернативы, если экраны Trio и Фрагменты должны обмениваться данными между собой, пока оба экрана активны (т. е. эквивалент Props в Trio), или им нужно обмениваться сложными данными, которые слишком велики для передачи с помощью Arguments, тогда Trio может быть вложен в «Interop Fragment», и оба фрагмента могут быть показаны в одной Активити. Фрагменты могут взаимодействовать через общую ViewModel, подобно тому, как Фрагменты обычно шарят ViewModel в Mavericks.
Наш объект Router позволяет легко создать и показать Trio из другого Фрагмента с помощью одного вызова функции:
class LegacyFragment : MavericksFragment { fun showTrioScreen() { showFragment( CounterRouters .CounterScreen .newInteropFragment(SharedCounterViewModelPropsAdapter::class) ) } }
Роутер создает оболочку Fragment и рендерит Trio внутри нее. Фрагменту может быть передан дополнительный класс адаптера, SharedCounterViewModelPropsAdapter в приведенном выше примере, чтобы указать, как Trio будет взаимодействовать с ViewModel Mavericks, используемыми другими Фрагментами в Активити. Этот адаптер позволяет Trio указать, к каким ViewModel он хочет получить доступ, и создает StateFlow, который преобразует состояния ViewModel в класс Props, потребляемый Trio.
class SharedCounterViewModelPropsAdapter : LegacyViewModelPropsAdapter<SharedCounterScreenProps> { override suspend fun createPropsStateFlow( legacyViewModelProvider: LegacyViewModelProvider, navController: NavController<SharedCounterScreenProps>, scope: CoroutineScope ): StateFlow<SharedCounterScreenProps> { // Look up an activity view model val sharedCounterViewModel: SharedCounterViewModel = legacyViewModelProvider.getActivityViewModel() // You can look up multiple view models if necessary val fragmentClickViewModel: SharedCounterViewModel = legacyViewModelProvider.requireExistingViewModel(viewModelKey = { SharedCounterViewModelKeys.fragmentOnlyCounterKey }) // Combine state updates into Props for the Trio, // and return as a StateFlow. This will be invoked anytime // any state flow has a new state object. return combine(sharedCounterViewModel.stateFlow, fragmentClickViewModel.stateFlow) { sharedState, fragmentState -> SharedCounterScreenProps( navController = navController, sharedClickCount = sharedState.count, fragmentClickCount = fragmentState.count, increaseSharedCount = { sharedCounterViewModel.increaseCounter() } ) }.stateIn(scope) } }
Заключение
В этой статье мы обсудили, как работает навигация в Trio. Мы используем некоторые уникальные подходы, такие как наша собственная система маршрутизации, предоставление доступа к Активити во ViewModel и хранение Trio в ViewModel State, чтобы достичь наших целей модульности, совместимости и упрощения логики навигации.
Оставайтесь с нами до третьей части, где мы расскажем, как Props в Trio обеспечивают динамическое взаимодействие между экранами.