Introducing Trio | Half II. Half two on how we constructed a Compose… | by Eli Hart | The Airbnb Tech Weblog | Apr, 2024
Half two on how we constructed a Compose primarily based structure with Mavericks within the Airbnb Android app
By: Eli Hart, Ben Schwab, and Yvonne Wong
Within the earlier submit on this sequence, we launched you to Trio, Airbnb’s framework for Jetpack Compose display structure in Android. A few of the benefits of Trio embrace:
- Ensures kind security when speaking throughout module boundaries in complicated 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)
When you want a refresher on Trio or are studying about this framework for the primary time, begin with Half 1. It supplies an outline 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 submit, 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 giant, modularized functions.
Navigating with Trio
A novel method in our design is that Trios are saved within the ViewModel’s State, proper alongside all different information {that a} Display screen exposes to the UI. For instance, a typical use case is to retailer a listing of Trios to characterize a stack of screens.
information 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 lessons are annotated like this in order that their state is at all times saved appropriately.
The ViewModel controls this state, and may expose features to push a brand new display or pop off a display. Because the ViewModel has direct management over the checklist of Trios, it could possibly additionally simply carry out extra complicated navigation modifications similar 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 Mother or father Trio’s UI accesses the Trio checklist from State and chooses how and the place to put the Trios. We will implement a display movement by displaying the newest 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 may use a navigator object within the Compose UI. Nonetheless, representing the appliance’s navigation graph in State permits the ViewModel to replace its information 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 method, our navigation turns into less complicated, extra express, 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 are able to pop the display by updating the Trio stack in State. We will additionally atomically modify different values within the state on the identical 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 complicated, software UI hierarchy will get modeled by a sequence 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 dimension, 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 are able to 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 are able to 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 by means of preloading screens.
Mavericks State sometimes holds easy information lessons, and never complicated objects like a Trio, which have a lifecycle. Nonetheless, we discover that the advantages this method brings are properly price the additional complexity.
Managing Actions
Ideally, an software with Trio would use only a single exercise, following the usual application architecture recommendation from Google. Nonetheless, particularly for interop functions, Trios will typically want to begin new exercise intents. Historically, this isn’t achieved from a ViewModel as a result of ViewModels should not contain Activity references, since they outlive the Exercise lifecycle; nonetheless, in an effort to preserve our paradigm of doing all navigation within the ViewModel, Trio makes an exception.
Throughout initialization, the Trio ViewModel is given a Circulation of Exercise through its initializer. This Circulation supplies the present exercise that the ViewModel is connected to, and null when it’s indifferent, similar to throughout exercise recreation. Trio internals handle the Circulation to ensure that it’s updated and the exercise just isn’t leaked.
When wanted, a ViewModel can entry the subsequent non-null exercise worth through the awaitActivity droop perform. For instance, we are able to use it to begin a brand new exercise after a community request completes.
class ViewModelInitializer<S : MavericksState>(
val initialState: S,
inner val activityFlow: Circulation<Exercise?>,
...
)class CounterViewModel(
initializer: ViewModelInitializer
) : TrioViewModel
enjoyable saveAndOpenNextPage() = viewModelScope.launch
performSaveRequest()
awaitActivity().startActivity()
The awaitActivity
perform is supplied by the TrioViewModel as a handy option to get the subsequent worth within the exercise movement.
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 functions face. At Airbnb, we’ve cut up our codebase into over 2000 modules to permit sooner construct speeds and express 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 unravel the overall drawback of navigation at scale in a big software.
In our undertaking construction, every module has a particular kind, indicated by its prefix and suffix, which defines its objective and enforces a algorithm about which different modules it could possibly rely on.
Function modules, prefixed with “feat”, comprise our Trio screens; every display within the app may 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 one function can’t straight 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, now we have a counter function and a decimal function. The counter function can open the decimal function to change the decimal rely, 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
information class DecimalArgs(val rely: Double) : Parcelable
object DecimalScreen
: TrioRouter<DecimalArgs, NavigationProps, NoResult>
A Router object is parameterized with the kinds 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 Consequence that the Trio returns.
Arguments is a knowledge class, usually together with primitive information indicating beginning values for a display.
Importantly, the Routers class is annotated with @Plugin
to declare that it needs to be added to the Routers PluginPoint. This annotation is a part of an inner KSP processor that we use for dependency injection, nevertheless it 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 are able to 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 are able to 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 kinds on the Trio, and that every Router has a single corresponding vacation spot. This ensures runtime kind 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 kind of Arguments is supplied, 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 by means of a createTrio
perform on the router, which we are able to invoke from the ViewModel. This permits 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 mother or father to carry out this push; we’ll discover Props intimately in Half 3 of this sequence.
class CounterViewModel : TrioViewModel enjoyable showDecimal(rely: Double)
val trio = DecimalRouters.DecimalScreen.createTrio(DecimalArgs(rely))
props.pushScreen(trio)
If we wish to as an alternative begin a Trio in a brand new exercise, the Router additionally supplies a perform to create an intent for a brand new exercise that wraps the Trio occasion; we are able to then begin it from the ViewModel utilizing Trio’s exercise mechanism, as mentioned earlier.
class CounterViewModel : TrioViewModel enjoyable showDecimal(rely: Double) = viewModelScope.launch
val exercise = awaitActivity()
val intent = DecimalRouters.DecimalScreen
.newIntent(exercise, DecimalArgs(rely))
exercise.startActivity(intent)
When a Trio is began in a brand new exercise, we merely have to 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 will additionally begin actions for a consequence by defining a Consequence kind on the router.
class DecimalRouters : RouterDeclarations() information class DecimalResult(val rely: 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(rely = consequence.rely)
enjoyable showDecimal(rely: Double)
decimalLauncher.startActivityForResult(DecimalArgs(rely))
For instance, if the person adjusts the decimals on the decimal display, we may return the brand new rely to replace our state within the counter. The lambda argument to the launcher permits us to deal with the consequence when the decimal display returns, which we are able to then use to replace the state. This furthers our objective of centralizing all navigation within the ViewModel, whereas guaranteeing kind security.
Our Router system provides different good options along with modularization, like interceptor chains within the Router decision offering middleman screens earlier than displaying the ultimate Trio vacation spot. We use this to redirect customers to the login web page when required, and in addition 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 method to interoperability is twofold. First, if a Fragment and Trio don’t have to 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 could 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 manner.
Alternatively, if a Trio and Fragment display have to share information between themselves whereas the screens are each energetic (i.e., the equal of Props with Trio), or they should share complicated information that’s too massive to go with Arguments, then the Trio could be nested inside an “Interop Fragment”, and the 2 Fragments could be proven in the identical exercise. The Fragments can talk through 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, could 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 desires 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>
// Lookup an exercise view mannequin
val sharedCounterViewModel: SharedCounterViewModel = legacyViewModelProvider.getActivityViewModel()
// You may lookup 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 shall be invoked anytime
// any state movement has a brand new state object.
return mix(sharedCounterViewModel.stateFlow, fragmentClickViewModel.stateFlow) sharedState, fragmentState ->
SharedCounterScreenProps(
navController = navController,
sharedClickCount = sharedState.rely,
fragmentClickCount = fragmentState.rely,
increaseSharedCount =
sharedCounterViewModel.increaseCounter()
)
.stateIn(scope)
Conclusion
On this article, we mentioned how navigation works in Trio. We use some distinctive approaches, similar to our customized routing system, offering entry to actions in a ViewModel, and storing Trios within the ViewModel State to attain our objectives of modularization, interoperability, and making it less complicated to cause about navigation logic.
Proceed studying in Half 3, the place we clarify how Trio’s Props allow dynamic communication between screens.
And if this sounds just like the type of problem you’re keen on engaged on, try open roles — we’re hiring!