Introducing Trio | Part II
By: Eli Hart, Ben Schwab, and Yvonne Wong
In the previous post in this series, we introduced you to Trio, Airbnb’s framework for Jetpack Compose screen architecture in Android. Some of the advantages of Trio include:
- Guarantees type safety when communicating across module boundaries in complex apps
- Codifies expectations about how ViewModels are used and shared, and what interfaces look like between screens
- Allows for stable screenshot and UI tests and simple navigation testing
- Compatible with Mavericks, Airbnb’s open source state management library for Jetpack (Trio is built on top of Mavericks)
If you need a refresher on Trio or are learning about this framework for the first time, start with Part 1. It provides an overview of why we built Trio when transitioning to Compose from a Fragments-based architecture. Part 1 also explains the core framework concepts like the Trio class and UI class.
In this post, we’ll build upon what we’ve shared so far and dive into how navigation works in Trio. As you’ll see, we designed Trio to make navigation simpler and easier to test, especially for large, modularized applications.
Navigating with Trio
A unique approach in our design is that Trios are stored in the ViewModel’s State, right alongside all other data that a Screen exposes to the UI. For example, a common use case is to store a list of Trios to represent a stack of screens.
data class ParentState(
val trioStack: List<Trio>
) : MavericksState
The PersistState
annotation is a mechanism of Mavericks that automatically saves and restores parcelable State values across process death, so the navigation state is preserved. A compile time validation ensures that Trio values in State classes are annotated like this so that their state is always saved correctly.
The ViewModel controls this state, and can expose functions to push a new screen or pop off a screen. Since the ViewModel has direct control over the list of Trios, it can also easily perform more complex navigation changes such as reordering screens, dropping multiple screens, or clearing all screens. This makes navigation extremely flexible.
class ParentViewModel : TrioViewModel {
fun pushScreen(trio: Trio) = setState { copy(trioStack = trioStack + trio) }
fun pop() = setState {
copy(trioStack = trioStack.dropLast(1)) }
}
The Parent Trio’s UI accesses the Trio list from State and chooses how and where to place the Trios. We can implement a screen flow by showing the latest Trio in the stack.
override fun TrioRenderScope.Content(state: ParentState) { ShowTrio(state.trioStack.last())
}
Coordinating Navigation
Why store Trios in State? Alternative approaches might use a navigator object in the Compose UI. However, representing the application’s navigation graph in State allows the ViewModel to update its data and navigation in a single place. This can be extremely helpful when we need to delay making a navigation change until after an asynchronous action, like a network request, completes. We could not do this easily with Fragments and found that with Trio’s approach, our navigation becomes simpler, more explicit, and more easily testable.
This example shows how the ViewModel can handle a “save and exit” call from the UI by launching a suspending network request in a coroutine. Once the request completes, we can pop the screen by updating the Trio stack in State. We can also atomically modify other values in the state at the same time, perhaps based on the result of the network request. This easily guarantees that navigation and ViewModel state stay in sync.
class CounterViewModel : TrioViewModel {
fun saveAndExit() = viewModelScope.launch {
val success = performSaveRequest() setState { copy(
trioStack = trioStack.dropLast(1),
success = success ) } }
}
As the navigation stack becomes more complex, application UI hierarchy gets modeled by a chain of ViewModels and their States. As the state is rendered, it creates a corresponding Compose UI hierarchy.
A Trio can represent an arbitrary UI element of any size, including nested screens and sections, while providing a backing state and a mechanism to communicate with other Trios in the hierarchy.
There are two additional nice benefits of modeling the hierarchy in ViewModel state like this. One is that it becomes simple to specify custom navigation scenarios when setting up testing — we can easily create whatever navigation states we want for our tests.
Another benefit is that since the navigation hierarchy is decoupled from the Compose UI, we can pre-load Trios that we anticipate needing, just by initializing their ViewModels ahead of time. This has made it significantly simpler for us to optimize performance through preloading screens.
Mavericks State typically holds simple data classes, and not complex objects like a Trio, which have a lifecycle. However, we find that the benefits this approach brings are well worth the extra complexity.
Managing Activities
Ideally, an application with Trio would use just a single activity, following the standard application architecture recommendation from Google. However, especially for interop purposes, Trios will sometimes need to start new activity intents. Traditionally, this isn’t done from a ViewModel because ViewModels should not contain Activity references, since they outlive the Activity lifecycle; however, in order to maintain our paradigm of doing all navigation in the ViewModel, Trio makes an exception.
During initialization, the Trio ViewModel is given a Flow of Activity via its initializer. This Flow provides the current activity that the ViewModel is attached to, and null when it is detached, such as during activity recreation. Trio internals manage the Flow to guarantee that it is up to date and the activity is not leaked.
When needed, a ViewModel can access the next non-null activity value via the awaitActivity suspend function. For example, we can use it to start a new activity after a network request completes.
class ViewModelInitializer<S : MavericksState>(
val initialState: S,
internal val activityFlow: Flow<Activity?>, ...)
class CounterViewModel(
initializer: ViewModelInitializer) : TrioViewModel {
fun saveAndOpenNextPage() = viewModelScope.launch {
performSaveRequest() awaitActivity().startActivity() }
}
The awaitActivity
function is provided by the TrioViewModel as a convenient way to get the next value in the activity flow.
suspend fun awaitActivity(): ComponentActivity {
return initializer.activityFlow.filterNotNull().first()
}
While a bit unorthodox, this pattern allows activity-based navigation to also be collocated with other business logic in the ViewModel.
Modularization Structure
Properly modularizing a large code base is a problem that many applications face. At Airbnb, we’ve split our codebase into over 2000 modules to allow faster build speeds and explicit ownership boundaries. To support this, we’ve built an in house navigation system that decouples feature modules. It was originally created to support Fragments and Activities, and was later expanded to integrate with Trio, helping us to solve the general problem of navigation at scale in a large application.
In our project structure, each module has a specific type, indicated by its prefix and suffix, which defines its purpose and enforces a set of rules about which other modules it can depend on.
Feature modules, prefixed with “feat”, contain our Trio screens; each screen in the app might live in its own separate module. To prevent circular dependencies and improve build speeds, we do not allow feature modules to depend on each other.
This means that one feature cannot directly instantiate another. Instead, each feature module has a corresponding navigation module, suffixed with “nav”, which defines a router to its feature. To avoid a circular dependency, the router and its destination Trio are associated with Dagger multibinding.
In this simple example, we have a counter feature and a decimal feature. The counter feature can open the decimal feature to modify the decimal count, so the counter module needs to depend on the decimal navigation module.
Routing
The navigation module is small. It contains only a Routers class with nested Router objects corresponding to each Trio in the feature module.
class DecimalRouters : RouterDeclarations() {
data class DecimalArgs(val count: Double) : Parcelable
object DecimalScreen
: TrioRouter<DecimalArgs, NavigationProps, NoResult>
}
A Router object is parameterized with the types that define the Trio’s public interface: the Arguments to instantiate it, the Props that it uses for active communication, and if desired, the Result that the Trio returns.
Arguments is a data class, often including primitive data indicating starting values for a screen.
Importantly, the Routers class is annotated with @Plugin
to declare that it should be added to the Routers PluginPoint. This annotation is part of an internal KSP processor that we use for dependency injection, but it essentially just generates the boilerplate code to set up a Dagger multibinding set. The result is that each Routers class is added to a set, which we can access from the Dagger graph at runtime.
On the corresponding Trio class in the feature module, we use the @TrioRouter
annotation to specify which Router the Trio maps to. Our KSP processor matches these at compile time, and generates code that we can use at runtime to find the Trio destination for each Router.
class DecimalScreen(
initializer: Initializer<DecimalArgs, ...>
) : Trio<DecimalArgs, NavigationProps, ...>
The processor validates at compile time that the Arguments and Props on the Router match the types on the Trio, and that each Router has a single corresponding destination. This guarantees runtime type safety in our navigation system.
Router Usage
Instead of manually instantiating Trios, we let the Router do it for us. The Router ensures that the proper type of Arguments is provided, looks up the matching Trio class in the Dagger graph, creates the initializer class to wrap the arguments, and finally, uses reflection to invoke the Trio’s constructor.
This functionality is accessible through a createTrio
function on the router, which we can invoke from the ViewModel. This allows us to easily create a new instance of a Trio, and push it onto our Trio stack. In the following example, the Props instance allows the Trio to call back to its parent to perform this push; we’ll explore Props in detail in Part 3 of this series.
class CounterViewModel : TrioViewModel {
fun showDecimal(count: Double) {
val trio = DecimalRouters.DecimalScreen.createTrio(DecimalArgs(count)) props.pushScreen(trio) }
}
If we want to instead start a Trio in a new activity, the Router also provides a function to create an intent for a new activity that wraps the Trio instance; we can then start it from the ViewModel using Trio’s activity mechanism, as discussed earlier.
class CounterViewModel : TrioViewModel {
fun showDecimal(count: Double) = viewModelScope.launch {
val activity = awaitActivity()
val intent = DecimalRouters.DecimalScreen .newIntent(activity, DecimalArgs(count)) activity.startActivity(intent) }
}
When a Trio is started in a new activity, we simply need to extract the Parcelable Trio instance from the intent, and show it at the root of the Activity’s content.
class TrioActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val trio = intent.parseTrio()
setContent { ShowTrio(trio) } }
}
We can also start activities for a result by defining a Result type on the router.
class DecimalRouters : RouterDeclarations() {
data class DecimalResult(val count: Double)
object DecimalScreen : TrioRouter<DecimalArgs, …, DecimalResult>
}
In this case, the ViewModel contains a “launcher” property, which is used to start the new activity.
class CounterViewModel : TrioViewModel {
val decimalLauncher = DecimalScreen.createResultLauncher { result ->
setState { copy(count = result.count) } }
fun showDecimal(count: Double) {
decimalLauncher.startActivityForResult(DecimalArgs(count)) }
}
For example, if the user adjusts the decimals on the decimal screen, we could return the new count to update our state in the counter. The lambda argument to the launcher allows us to handle the result when the decimal screen returns, which we can then use to update the state. This furthers our goal of centralizing all navigation in the ViewModel, while guaranteeing type safety.
Our Router system offers other nice features in addition to modularization, like interceptor chains in the Router resolution providing intermediary screens before showing the final Trio destination. We use this to redirect users to the login page when required, and also to show a loading page if a dynamic feature needs to be downloaded first.
Fragment Interop
Making Trio screens interoperable with our existing Fragment screens was very important to us. Our migration to Trio is a years-long effort, and Trios and Fragments need to easily coexist.
Our approach to interoperability is twofold. First, if a Fragment and Trio don’t need to dynamically share information while created (i.e., they only take initial arguments and return a result), then it is easiest to start a new activity when transitioning between a Fragment and a Trio. Both architecture types can be easily started in a new activity with Arguments, and can optionally return a result when finished, so it is very easy to navigate between them this way.
Alternatively, if a Trio and Fragment screen need to share data between themselves while the screens are both active (i.e., the equivalent of Props with Trio), or they need to share complex data that is too large to pass with Arguments, then the Trio can be nested within an “Interop Fragment”, and the two Fragments can be shown in the same activity. The Fragments can communicate via a shared ViewModel, similar to how Fragments normally share ViewModels with Mavericks.
Our Router object makes it easy to create and show a Trio from another Fragment, with a single function call:
class LegacyFragment : MavericksFragment {
fun showTrioScreen() {
showFragment( CounterRouters .CounterScreen
.newInteropFragment(SharedCounterViewModelPropsAdapter::class)
) }
}
The Router creates a shell Fragment and renders the Trio inside of it. An optional adapter class, the SharedCounterViewModelPropsAdapter in the above example, can be passed to the Fragment to specify how the Trio will communicate with Mavericks ViewModels used by other Fragments in the activity. This adapter allows the Trio to specify which ViewModels it wants to access, and creates a StateFlow that converts those ViewModel states into the Props class that the Trio consumes.
class SharedCounterViewModelPropsAdapter : LegacyViewModelPropsAdapter<SharedCounterScreenProps> {
override suspend fun createPropsStateFlow(
legacyViewModelProvider: LegacyViewModelProvider,
navController: NavController<SharedCounterScreenProps>,
scope: CoroutineScope
): StateFlow<SharedCounterScreenProps> {
val sharedCounterViewModel: SharedCounterViewModel = legacyViewModelProvider.getActivityViewModel()
val fragmentClickViewModel: SharedCounterViewModel = legacyViewModelProvider.requireExistingViewModel(viewModelKey = {
SharedCounterViewModelKeys.fragmentOnlyCounterKey })
return combine(sharedCounterViewModel.stateFlow, fragmentClickViewModel.stateFlow) { sharedState, fragmentState ->
SharedCounterScreenProps( navController = navController, sharedClickCount = sharedState.count, fragmentClickCount = fragmentState.count, increaseSharedCount = { sharedCounterViewModel.increaseCounter() } ) }.stateIn(scope) }
}
Conclusion
In this article, we discussed how navigation works in Trio. We use some unique approaches, such as our custom routing system, providing access to activities in a ViewModel, and storing Trios in the ViewModel State to achieve our goals of modularization, interoperability, and making it simpler to reason about navigation logic.
Stay tuned for Part 3, where we will explain how Trio’s Props enable dynamic communication between screens.
And if this sounds like the kind of challenge you love working on, check out open roles — we’re hiring!