Introducing Trio | Half I. A 3 half sequence on how we constructed a… | by Eli Hart | The Airbnb Tech Weblog | Mar, 2024

Introducing Trio | Half I. A 3 half sequence on how we constructed a… | by Eli Hart | The Airbnb Tech Weblog | Mar, 2024
Introducing Trio | Half I. A 3 half sequence on how we constructed a… | by Eli Hart | The Airbnb Tech Weblog | Mar, 2024

By: Eli Hart, Ben Schwab, Yvonne Wong

At Airbnb, we’ve developed an Android framework for Jetpack Compose display structure, which we name Trio. Trio is constructed on our open-source library Mavericks, which it leverages to keep up each navigation and utility state inside the ViewModel.

Airbnb started growth of Trio greater than two years in the past, and has been utilizing it in manufacturing for over a yr and a half. It’s powering a good portion of our manufacturing screens in Airbnb’s Android app, and has enabled our engineers to create options in 100% Compose UI.

On this weblog put up sequence, we’ll take a look at how Mavericks can be utilized in fashionable, Compose primarily based purposes. We’ll focus on the challenges of Compose-based structure and the way Trio has tried to resolve them. This may embody an exploration of ideas corresponding to:

  • Sort-safe navigation between function modules
  • Storing navigation state in a ViewModel
  • Communication between Compose-based screens, together with opening screens for outcomes and two-way communication between screens
  • Compile-time validation of navigation and communication interfaces
  • Developer instruments created to assist Trio workflows

This sequence is cut up into three elements. Half 1 (this weblog put up) covers Trio’s high-level structure. Keep tuned for Half 2, which can element Trio’s navigation system, and Half 3, which can study how Trio makes use of Props for communication between screens.

Background on Mavericks

To know Trio’s structure, it’s vital to know the fundamentals of Mavericks, which Trio is constructed on prime of. Airbnb initially open sourced Mavericks in 2018 to simplify and standardize how state is managed in a Jetpack ViewModel. Try this put up from the preliminary Mavericks (“MvRx”) launch for a deeper dive.

Utilized in nearly all of the a whole lot of screens in Airbnb’s Android app (and by many different corporations too!), Mavericks is a state administration library that’s decoupled from the UI, and can be utilized with any UI system. The core idea is that display UI is modeled as a perform of state. This ensures that even essentially the most advanced display might be rendered in a means that’s thread protected, unbiased of the order of occasions main as much as it, and straightforward to purpose about and check.

To realize this, Mavericks enforces the sample that every one information uncovered by the ViewModel have to be contained inside a single MavericksState information class. In a easy Counter instance, the state would include the present rely.

information class CounterState(
val rely: Int = 0
) : MavericksState

State properties can solely be up to date within the ViewModel by way of calls to setState. The setState perform takes a “reducer” lambda, which, given a earlier state, outputs a brand new state. We will use a reducer to increment the rely by merely including 1 to the earlier worth.

class CounterViewModel : MavericksViewModel<CounterState>(...) {
enjoyable incrementCount() {
setState {
// this = earlier state
this.copy(rely = rely + 1)
}
}
}

The bottom MavericksViewModel enqueues all calls to setState and runs them serially in a background thread. This ensures thread security when adjustments are made in a number of locations directly, and ensures that adjustments to a number of properties within the state are atomic, so the UI by no means sees a state that’s solely partially up to date.

MavericksViewModel exposes state adjustments by way of a coroutine Circulation property. When paired with reactive UI, like Compose, we will acquire the most recent state worth and assure that the UI is up to date with each state change.

counterViewModel.stateFlow.collectAsState().rely

This unidirectional cycle might be visualized with the next diagram:

Challenges with Fragment-based structure

Whereas Mavericks works properly for state administration, we have been nonetheless experiencing some challenges with Android UI growth, stemming from the truth that we have been utilizing a Fragment-based structure built-in with Mavericks. With this strategy, ViewModels are primarily scoped to the Exercise and shared between Fragments by way of injection. Fragment views are up to date by state adjustments from the ViewModel, and name again to the ViewModel to make state adjustments. The Fragment Supervisor manages navigation independently when Fragments have to be pushed or popped.

As a consequence of this structure, we have been working up in opposition to some ongoing difficulties, which turned the motivation for constructing Trio.

  1. Scoping — Sharing ViewModels between a number of Fragments depends on the implicit injection of the ViewModel. Thus, it isn’t clear which Fragment is liable for creating the Exercise ViewModel initially, or for offering the preliminary arguments to it.
  2. Communication — It’s troublesome to share information between Fragments straight and with sort security. Once more, as a result of ViewModels are injected, it’s onerous to have them talk straight, and we don’t have good management over the ordering of their creation.
  3. Navigation — Navigation is completed by way of the Fragment Supervisor and should occur within the Fragment. Nevertheless, state adjustments are carried out within the ViewModel. This results in synchronization issues between ViewModel and navigation states. It’s onerous to coordinate if-then situations like making a navigation name solely after updating a state worth within the ViewModel.
  4. Testability — It’s troublesome to isolate the UI for testing as a result of it’s wrapped within the Fragment. Screenshot checks are liable to flakiness and a number of indirection is required for mocking the ViewModel state, as a result of ViewModels are injected into the Fragment with property delegates.
  5. Reactivity — Mavericks supplies a unidirectional state stream to the View, which is useful for consistency and testing, however the View system doesn’t lend itself properly to reactive updates to state adjustments, and it may be troublesome or inefficient to replace the view incrementally on every state change.

Whereas a few of these issues may have been mitigated through the use of a greater Fragment primarily based structure, we discovered that Fragments have been total too limiting with Compose and determined to maneuver away from them fully.

Why we constructed Trio

In 2021, our workforce started to discover adopting Jetpack Compose and fully transitioning away from Fragments. By absolutely embracing Compose, we may higher put together ourselves for future Android developments and get rid of years of gathered tech debt.

Persevering with to make use of Mavericks was vital to us as a result of we’ve a considerable amount of inner expertise with it, and we didn’t wish to additional complicate an architectural migration by additionally altering our state administration strategy. We noticed a possibility to rethink how Mavericks may assist a contemporary Android utility, and tackle issues we encountered with our earlier structure

With Fragments, we struggled to ensure sort protected communication between screens at runtime. We wished to have the ability to codify the expectations about how ViewModels are used and shared, and what interfaces appear to be between screens.

We additionally didn’t really feel our wants have been absolutely met by the Jetpack Navigation part, particularly given our closely modularized code base and enormous app. The Navigation part is not type safe, requires defining the navigation graph in a single place, and doesn’t enable us to co-locate state in our ViewModel. We seemed for a brand new structure that might present higher sort security and modularization assist.

Lastly, we wished an structure that might enhance testability, corresponding to extra steady screenshot and UI checks, and easier navigation testing.

We thought-about the open supply libraries Workflow and RIBs, however opted to not use them as a result of they weren’t Compose-first and weren’t suitable with Mavericks and our different pre-existing inner frameworks.

Given these necessities, our choice was to develop our personal resolution, which we named Trio.

Trio Structure

Trio is an opinionated framework for constructing options. It helps us to outline and handle boundaries and state in Compose UI. Trio additionally standardizes how state is hoisted from Compose UI and the way occasions are dealt with, imposing unidirectional information stream with Mavericks. The design was impressed by Sq.’s Workflow library; Trio differs in that it was designed particularly for Compose and makes use of Mavericks ViewModels for managing state and occasions.

Self-contained blocks are referred to as “Trios”, named for the three foremost lessons they include. Every Trio has its personal ViewModel, State, and UI, and might talk with and be nested in different Trios. The next diagram represents how these elements work collectively. The ViewModel makes adjustments to state by way of Mavericks reducers, the UI receives the most recent state worth to render, and occasions are routed again to the ViewModel for additional state updates.

In case you’re already accustomed to Mavericks this sample ought to look very related! The ViewModel and State utilization is similar to what we did with Fragments. What’s new is how we embed the ViewModels in Compose UI and add Routing and Props primarily based communication by way of Trio.

Trios are nested to kind customized, versatile navigation hierarchies. “Father or mother” Trios create baby Trios with preliminary arguments by a Router, and retailer these youngsters of their State. The mother or father can then talk dynamically with its youngsters by a stream of Props, which give information, dependencies, and purposeful callbacks.

The framework helps us to ensure sort security when navigating and speaking between Trios, particularly throughout module boundaries.

Every Trio might be examined individually by instantiating it with mocked arguments, State, and Props. Coupled with Compose’s state-based rendering and Maverick’s immutable state patterns, this supplies managed and deterministic testing environments.

The Trio Class

Creating a brand new Trio implementation requires subclassing the Trio base class. The Trio class is typed to outline Args, Props, State, ViewModel, and UI; this enables us to ensure type-safe navigation and inter-screen communication.

class CounterScreen : Trio<
CounterArgs,
CounterProps,
CounterState,
CounterViewModel,
CounterUI
>

A Trio is created with both an preliminary set of arguments or an preliminary state, that are wrapped in a sealed class referred to as the Initializer. In manufacturing, the Initializer will solely include Args handed from one other display, however in growth we will seed the Initializer with mock state in order that the display might be loaded standalone, unbiased of the conventional navigation hierarchy.

class CounterScreen(
initializer: Initializer<CounterArgs, CounterState>
)

Then, in our subclass physique, we outline how we wish to create our State, ViewModel, and UI, given the beginning values of Args and Props.

Args and Props each present enter information, with the distinction being that Args are static whereas Props are dynamic. Args assure the soundness of static info, corresponding to IDs used to start out a display, whereas Props enable us to subscribe to information that will change over time.

override enjoyable createInitialState(args: CounterArgs, props:  CounterProps) {
return CounterState(args.rely)
}

Trio supplies an initializer to create a brand new ViewModel occasion, passing crucial info just like the Trio’s distinctive ID, a Circulation of Props, and a reference to the mother or father Exercise. Dependencies from the applying’s dependency graph will also be additionally handed to the ViewModel by its constructor.

override enjoyable createViewModel(
initializer: Initializer<CounterProps, CounterState>
) {
return CounterViewModel(initializer)
}

Lastly, the UI class wraps the composable code used to render the Trio. The UI class receives a stream of the most recent State from the ViewModel, and likewise makes use of the ViewModel reference to name again to it when dealing with UI occasions.

override enjoyable createUI(viewModel: CounterViewModel ): CounterUI {
return CounterUI(viewModel)
}

We like that grouping all of those manufacturing unit capabilities within the Trio class makes it specific how every class is created, and standardizes the place to look to know dependencies. Nevertheless, it may possibly additionally really feel like boilerplate. As an enchancment, we frequently use reflection to create the UI class, and we use assisted inject to automate creation of the ViewModel with Dagger dependencies.

The ensuing Trio declaration as an entire seems like this:

class CounterScreen(
initializer: Initializer<CounterArgs, CounterState>
) : Trio<
CounterArgs,
CounterProps,
CounterState,
CounterViewModel,
CounterUI
>(initializer) {

override enjoyable createInitialState(CounterArgs, CounterProps) {
return CounterState(args.rely)
}
}

The UI Class

The Trio’s UI class implements a single Composable perform named “Content material”, which determines the UI that the Trio exhibits. Moreover, the Content material perform has a “TrioRenderScope” receiver sort. It is a Compose animation scope that enables us to customise the Trio’s animations when it’s displayed.

class CounterUI(
override val viewModel: CounterViewModel
) : UI<CounterState, CounterViewModel> {

@Composable
override enjoyable TrioRenderScope.Content material(state: CounterState) {
Column {
TopAppBar()
Button(
textual content = state.rely,
modifier = Modifier.clickable {
viewModel.incrementCount()
}
)
...
}
}
}

The Content material perform is recomposed each time the State from the ViewModel adjustments. The UI directs all UI occasions, corresponding to clicks, again to the ViewModel for dealing with.

This design enforces unidirectional information stream, and testing the UI is simple as a result of it’s decoupled from the logic of state adjustments and occasion dealing with. It additionally standardizes how Compose state is hoisted for consistency throughout screens, whereas eradicating the boilerplate of establishing entry to the ViewModel’s state stream.

Rendering a Trio

Given a Trio occasion, we will render it by invoking its Content material perform, which makes use of the beforehand talked about manufacturing unit capabilities to create preliminary values of the ViewModel, State, and UI. The state stream is collected from the ViewModel and handed to the UI’s Content material perform. The UI is wrapped in a Field to respect the constraints and modifier of the caller.

@Composable
inner enjoyable TrioRenderScope.Content material(modifier: Modifier = Modifier) {
key(trioId) {
val exercise = LocalContext.present as ComponentActivity

val viewModel = keep in mind {
getOrCreateViewModel(exercise)
}

val ui = keep in mind { createUI(viewModel) }

val state = viewModel.stateFlow
.collectAsState(viewModel.currentState).worth

Field(propagateMinConstraints = true, modifier = modifier) {
ui.Content material(state = state)
}
}
}

To allow customizing entry and exit animations, the Content material perform additionally makes use of a TrioRenderScope receiver; this wraps an implementation of Compose’s AnimatedVisibilityScope which shows the Content material. A helper perform is used to coordinate this.

@Composable
enjoyable ShowTrio(trio: Trio, modifier: Modifier) {
AnimatedVisibility(
seen = true,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
val animationScope = TrioRenderScopeImpl(this)
trio.Content material(modifier, animationScope)
}
}

In observe, the precise implementation of Trio.Content material is sort of a bit extra advanced due to extra tooling and edge circumstances we wish to assist — corresponding to monitoring the Trio’s lifecycle, managing saved state, and mocking the ViewModel when proven inside a screenshot check or IDE preview.

Conclusion

On this introduction to Trio we mentioned Airbnb’s background with Mavericks and Fragments, and why we constructed Trio to transition to a Jetpack Compose-based structure. We offered an summary of Trio’s structure, and checked out core elements such because the Trio class and UI class.

In upcoming articles, we’ll proceed this three-part sequence by detailing how navigation works with Trio, and the way Trio’s Props enable dynamic communication between screens. And if this work sounds attention-grabbing to you, try open roles at Airbnb!