Introducing Trio | Half II. Half two on how we constructed a Compose… | by Eli Hart | The Airbnb Tech Weblog | Apr, 2024

By: Eli Hart, Ben Schwab, and Yvonne Wong

Within the earlier put up on this collection, we launched you to Trio, Airbnb’s framework for Jetpack Compose display screen structure in Android. Among the benefits of Trio embody:

  • Ensures sort security when speaking throughout module boundaries in advanced apps
  • Codifies expectations about how ViewModels are used and shared, and what interfaces seem like between screens
  • Permits for secure screenshot and UI checks and easy navigation testing
  • Appropriate with Mavericks, Airbnb’s open supply state administration library for Jetpack (Trio is constructed on high of Mavericks)

In the event you want a refresher on Trio or are studying about this framework for the primary time, begin with Half 1. It gives an summary of why we constructed Trio when transitioning to Compose from a Fragments-based structure. Half 1 additionally explains the core framework ideas just like the Trio class and UI class.

On this put up, we’ll construct upon what we’ve shared up to now and dive into how navigation works in Trio. As you’ll see, we designed Trio to make navigation less complicated and simpler to check, particularly for big, modularized purposes.

Navigating with Trio

A singular strategy in our design is that Trios are saved within the ViewModel’s State, proper alongside all different knowledge {that a} Display screen exposes to the UI. For instance, a standard use case is to retailer an inventory of Trios to characterize a stack of screens.

knowledge class ParentState(
@PersistState val trioStack: Checklist<Trio>
) : MavericksState

The PersistState annotation is a mechanism of Mavericks that robotically saves and restores parcelable State values throughout course of dying, so the navigation state is preserved. A compile time validation ensures that Trio values in State courses are annotated like this in order that their state is all the time saved accurately.

The ViewModel controls this state, and may expose capabilities to push a brand new display screen or pop off a display screen. For the reason that ViewModel has direct management over the listing of Trios, it could actually additionally simply carry out extra advanced navigation adjustments akin to reordering screens, dropping a number of screens, or clearing all screens. This makes navigation extraordinarily versatile.

class ParentViewModel : TrioViewModel {
enjoyable pushScreen(trio: Trio) = setState {
copy(trioStack = trioStack + trio)
}

enjoyable pop() = setState {
copy(trioStack = trioStack.dropLast(1))
}
}

The Guardian Trio’s UI accesses the Trio listing from State and chooses how and the place to position the Trios. We are able to implement a display screen stream by exhibiting the most recent Trio within the stack.

@Composable
override enjoyable TrioRenderScope.Content material(state: ParentState) {
ShowTrio(state.trioStack.final())
}

Coordinating Navigation

Why retailer Trios in State? Various approaches would possibly use a navigator object within the Compose UI. Nonetheless, representing the applying’s navigation graph in State permits the ViewModel to replace its knowledge and navigation in a single place. This may be extraordinarily useful when we have to delay making a navigation change till after an asynchronous motion, like a community request, completes. We couldn’t do that simply with Fragments and located that with Trio’s strategy, our navigation turns into less complicated, extra specific, and extra simply testable.

This instance reveals how the ViewModel can deal with a “save and exit” name from the UI by launching a suspending community request in a coroutine. As soon as the request completes, we will pop the display screen by updating the Trio stack in State. We are able to additionally atomically modify different values within the state on the similar time, maybe primarily based on the results of the community request. This simply ensures that navigation and ViewModel state keep in sync.

class CounterViewModel : TrioViewModel {

enjoyable saveAndExit() = viewModelScope.launch {
val success = performSaveRequest()

setState {
copy(
trioStack = trioStack.dropLast(1),
success = success
)
}
}
}

Because the navigation stack turns into extra advanced, utility UI hierarchy will get modeled by a series of ViewModels and their States. Because the state is rendered, it creates a corresponding Compose UI hierarchy.

A Trio can characterize an arbitrary UI component of any measurement, together with nested screens and sections, whereas offering a backing state and a mechanism to speak with different Trios within the hierarchy.

There are two further good advantages of modeling the hierarchy in ViewModel state like this. One is that it turns into easy to specify customized navigation situations when establishing testing — we will simply create no matter navigation states we wish for our checks.

One other profit is that for the reason that navigation hierarchy is decoupled from the Compose UI, we will pre-load Trios that we anticipate needing, simply by initializing their ViewModels forward of time. This has made it considerably less complicated for us to optimize efficiency via preloading screens.

Mavericks State sometimes holds easy knowledge courses, and never advanced objects like a Trio, which have a lifecycle. Nonetheless, we discover that the advantages this strategy brings are effectively value the additional complexity.

Managing Actions

Ideally, an utility with Trio would use only a single exercise, following the usual application architecture recommendation from Google. Nonetheless, particularly for interop functions, Trios will generally want to begin new exercise intents. Historically, this isn’t completed from a ViewModel as a result of ViewModels should not contain Activity references, since they outlive the Exercise lifecycle; nevertheless, so as to preserve our paradigm of doing all navigation within the ViewModel, Trio makes an exception.

Throughout initialization, the Trio ViewModel is given a Circulate of Exercise by way of its initializer. This Circulate gives the present exercise that the ViewModel is hooked up to, and null when it’s indifferent, akin to throughout exercise recreation. Trio internals handle the Circulate to ensure that it’s updated and the exercise is just not leaked.

When wanted, a ViewModel can entry the subsequent non-null exercise worth by way of the awaitActivity droop perform. For instance, we will use it to begin a brand new exercise after a community request completes.

class ViewModelInitializer<S : MavericksState>(
val initialState: S,
inside val activityFlow: Circulate<Exercise?>,
...
)

class CounterViewModel(
initializer: ViewModelInitializer
) : TrioViewModel {

enjoyable saveAndOpenNextPage() = viewModelScope.launch {
performSaveRequest()
awaitActivity().startActivity()
}
}

The awaitActivity perform is offered by the TrioViewModel as a handy strategy to get the subsequent worth within the exercise stream.

droop enjoyable awaitActivity(): ComponentActivity {
return initializer.activityFlow.filterNotNull().first()
}

Whereas a bit unorthodox, this sample permits activity-based navigation to even be collocated with different enterprise logic within the ViewModel.

Modularization Construction

Correctly modularizing a big code base is an issue that many purposes face. At Airbnb, we’ve cut up our codebase into over 2000 modules to permit sooner construct speeds and specific possession boundaries. To assist this, we’ve constructed an in home navigation system that decouples function modules. It was initially created to assist Fragments and Actions, and was later expanded to combine with Trio, serving to us to resolve the overall drawback of navigation at scale in a big utility.

In our mission construction, every module has a selected sort, indicated by its prefix and suffix, which defines its goal and enforces a algorithm about which different modules it could actually rely on.

Function modules, prefixed with “feat”, comprise our Trio screens; every display screen within the app would possibly reside in its personal separate module. To forestall round dependencies and enhance construct speeds, we don’t enable function modules to rely on one another.

Which means that one function can not instantly instantiate one other. As an alternative, every function module has a corresponding navigation module, suffixed with “nav”, which defines a router to its function. To keep away from a round dependency, the router and its vacation spot Trio are related to Dagger multibinding.

On this easy instance, we’ve got a counter function and a decimal function. The counter function can open the decimal function to switch the decimal depend, so the counter module must rely on the decimal navigation module.

Routing

The navigation module is small. It accommodates solely a Routers class with nested Router objects corresponding to every Trio within the function module.

// In feat.decimal.nav
@Plugin(pluginPoint = RoutersPluginPoint::class)
class DecimalRouters : RouterDeclarations() {

@Parcelize
knowledge class DecimalArgs(val depend: Double) : Parcelable

object DecimalScreen
: TrioRouter<DecimalArgs, NavigationProps, NoResult>
}

A Router object is parameterized with the categories that outline the Trio’s public interface: the Arguments to instantiate it, the Props that it makes use of for energetic communication, and if desired, the End result that the Trio returns.

Arguments is an information class, usually together with primitive knowledge indicating beginning values for a display screen.

Importantly, the Routers class is annotated with @Plugin to declare that it ought to be added to the Routers PluginPoint. This annotation is a part of an inside KSP processor that we use for dependency injection, but it surely basically simply generates the boilerplate code to arrange a Dagger multibinding set. The result’s that every Routers class is added to a set, which we will entry from the Dagger graph at runtime.

On the corresponding Trio class within the function 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 will use at runtime to search out the Trio vacation spot for every Router.

// In feat.decimal
@TrioRouter(DecimalRouters.DecimalScreen::class)
class DecimalScreen(
initializer: Initializer<DecimalArgs, ...>
) : Trio<DecimalArgs, NavigationProps, ...>

The processor validates at compile time that the Arguments and Props on the Router match the categories on the Trio, and that every Router has a single corresponding vacation spot. This ensures runtime sort security in our navigation system.

Router Utilization

As an alternative of manually instantiating Trios, we let the Router do it for us. The Router ensures that the right sort of Arguments is offered, appears to be like up the matching Trio class within the Dagger graph, creates the initializer class to wrap the arguments, and at last, makes use of reflection to invoke the Trio’s constructor.

This performance is accessible via a createTrio perform on the router, which we will invoke from the ViewModel. This enables us to simply create a brand new occasion of a Trio, and push it onto our Trio stack. Within the following instance, the Props occasion permits the Trio to name again to its dad or mum to carry out this push; we’ll discover Props intimately in Half 3 of this collection.

class CounterViewModel : TrioViewModel {

enjoyable showDecimal(depend: Double) {
val trio = DecimalRouters.DecimalScreen.createTrio(DecimalArgs(depend))
props.pushScreen(trio)
}
}

If we need to as a substitute begin a Trio in a brand new exercise, the Router additionally gives a perform to create an intent for a brand new exercise that wraps the Trio occasion; we will then begin it from the ViewModel utilizing Trio’s exercise mechanism, as mentioned earlier.

class CounterViewModel : TrioViewModel {

enjoyable showDecimal(depend: Double) = viewModelScope.launch {
val exercise = awaitActivity()
val intent = DecimalRouters.DecimalScreen
.newIntent(exercise, DecimalArgs(depend))

exercise.startActivity(intent)
}
}

When a Trio is began in a brand new exercise, we merely must extract the Parcelable Trio occasion from the intent, and present it on the root of the Exercise’s content material.

class TrioActivity : ComponentActivity() {
override enjoyable onCreate(savedInstanceState: Bundle?) {
tremendous.onCreate(savedInstanceState)

val trio = intent.parseTrio()
setContent {
ShowTrio(trio)
}
}
}

We are able to additionally begin actions for a consequence by defining a End result sort on the router.

class DecimalRouters : RouterDeclarations() {

knowledge class DecimalResult(val depend: Double)

object DecimalScreen : TrioRouter<DecimalArgs, …, DecimalResult>
}

On this case, the ViewModel accommodates a “launcher” property, which is used to begin the brand new exercise.

class CounterViewModel : TrioViewModel {

val decimalLauncher = DecimalScreen.createResultLauncher { consequence ->
setState {
copy(depend = consequence.depend)
}
}

enjoyable showDecimal(depend: Double) {
decimalLauncher.startActivityForResult(DecimalArgs(depend))
}
}

For instance, if the consumer adjusts the decimals on the decimal display screen, we might return the brand new depend to replace our state within the counter. The lambda argument to the launcher permits us to deal with the consequence when the decimal display screen returns, which we will then use to replace the state. This furthers our aim of centralizing all navigation within the ViewModel, whereas guaranteeing sort security.

Our Router system affords different good options along with modularization, like interceptor chains within the Router decision offering middleman screens earlier than exhibiting the ultimate Trio vacation spot. We use this to redirect customers to the login web page when required, and likewise to indicate a loading web page if a dynamic function must be downloaded first.

Fragment Interop

Making Trio screens interoperable with our present Fragment screens was crucial to us. Our migration to Trio is a years-long effort, and Trios and Fragments want to simply coexist.

Our strategy to interoperability is twofold. First, if a Fragment and Trio don’t must dynamically share info whereas created (i.e., they solely take preliminary arguments and return a consequence), then it’s best to begin a brand new exercise when transitioning between a Fragment and a Trio. Each structure sorts will be simply began in a brand new exercise with Arguments, and may optionally return a consequence when completed, so it is vitally simple to navigate between them this fashion.

Alternatively, if a Trio and Fragment display screen must share knowledge between themselves whereas the screens are each energetic (i.e., the equal of Props with Trio), or they should share advanced knowledge that’s too massive to go with Arguments, then the Trio will be nested inside an “Interop Fragment”, and the 2 Fragments will be proven in the identical exercise. The Fragments can talk by way of a shared ViewModel, just like how Fragments usually share ViewModels with Mavericks.

Our Router object makes it simple to create and present a Trio from one other Fragment, with a single perform name:

class LegacyFragment : MavericksFragment {

enjoyable showTrioScreen() {
showFragment(
CounterRouters
.CounterScreen
.newInteropFragment(SharedCounterViewModelPropsAdapter::class)
)
}
}

The Router creates a shell Fragment and renders the Trio within it. An non-compulsory adapter class, the SharedCounterViewModelPropsAdapter within the above instance, will be handed to the Fragment to specify how the Trio will talk with Mavericks ViewModels utilized by different Fragments within the exercise. This adapter permits the Trio to specify which ViewModels it needs to entry, and creates a StateFlow that converts these ViewModel states into the Props class that the Trio consumes.

class SharedCounterViewModelPropsAdapter : LegacyViewModelPropsAdapter<SharedCounterScreenProps> {

override droop enjoyable createPropsStateFlow(
legacyViewModelProvider: LegacyViewModelProvider,
navController: NavController<SharedCounterScreenProps>,
scope: CoroutineScope
): StateFlow<SharedCounterScreenProps> {

// Search for an exercise view mannequin
val sharedCounterViewModel: SharedCounterViewModel = legacyViewModelProvider.getActivityViewModel()

// You possibly can search for a number of view fashions if obligatory
val fragmentClickViewModel: SharedCounterViewModel = legacyViewModelProvider.requireExistingViewModel(viewModelKey = {
SharedCounterViewModelKeys.fragmentOnlyCounterKey
})

// Mix state updates into Props for the Trio,
// and return as a StateFlow. This will likely be invoked anytime
// any state stream has a brand new state object.
return mix(sharedCounterViewModel.stateFlow, fragmentClickViewModel.stateFlow) { sharedState, fragmentState ->
SharedCounterScreenProps(
navController = navController,
sharedClickCount = sharedState.depend,
fragmentClickCount = fragmentState.depend,
increaseSharedCount = {
sharedCounterViewModel.increaseCounter()
}
)
}.stateIn(scope)
}
}

Conclusion

On this article, we mentioned how navigation works in Trio. We use some distinctive approaches, akin to our customized routing system, offering entry to actions in a ViewModel, and storing Trios within the ViewModel State to attain our targets of modularization, interoperability, and making it less complicated to purpose about navigation logic.

Keep tuned for Half 3, the place we’ll clarify how Trio’s Props allow dynamic communication between screens.

And if this sounds just like the form of problem you like engaged on, try open roles — we’re hiring!