Introducing Trio | Part I

Eli Hart on 2024-03-25

Introducing Trio | Part I

A three part series on how we built a Compose based architecture with Mavericks in the Airbnb Android app

By: Eli Hart, Ben Schwab, Yvonne Wong

At Airbnb, we have developed an Android framework for Jetpack Compose screen architecture, which we call Trio. Trio is built on our open-source library Mavericks, which it leverages to maintain both navigation and application state within the ViewModel.

Airbnb began development of Trio more than two years ago, and has been using it in production for over a year and a half. It is powering a significant portion of our production screens in Airbnb’s Android app, and has enabled our engineers to create features in 100% Compose UI.

In this blog post series, we will look at how Mavericks can be used in modern, Compose based applications. We will discuss the challenges of Compose-based architecture and how Trio has attempted to solve them. This will include an exploration of concepts such as:

Background on Mavericks

To understand Trio’s architecture, it’s important to know the basics of Mavericks, which Trio is built on top of. Airbnb originally open sourced Mavericks in 2018 to simplify and standardize how state is managed in a Jetpack ViewModel. Check out this post from the initial Mavericks (“MvRx”) launch for a deeper dive.

Used in virtually all the hundreds of screens in Airbnb’s Android app (and by many other companies too!), Mavericks is a state management library that is decoupled from the UI, and can be used with any UI system. The core concept is that screen UI is modeled as a function of state. This ensures that even the most complex screen can be rendered in a way that’s thread safe, independent of the order of events leading up to it, and easy to reason about and test.

To achieve this, Mavericks enforces the pattern that all data exposed by the ViewModel must be contained within a single MavericksState data class. In a simple Counter example, the state would contain the current count.

data class CounterState(
  val count: Int = 0
) : MavericksState

State properties can only be updated in the ViewModel via calls to setState. The setState function takes a “reducer” lambda, which, given a previous state, outputs a new state. We can use a reducer to increment the count by simply adding 1 to the previous value.

class CounterViewModel : MavericksViewModel<CounterState>(...) {
  fun incrementCount() {
    setState {
      // this = previous state
      this.copy(count = count + 1)
    }
  }
}

The base MavericksViewModel enqueues all calls to setState and runs them serially in a background thread. This guarantees thread safety when changes are made in multiple places at once, and ensures that changes to multiple properties in the state are atomic, so the UI never sees a state that is only partially updated.

MavericksViewModel exposes state changes via a coroutine Flow property. When paired with reactive UI, like Compose, we can collect the latest state value and guarantee that the UI is updated with every state change.

counterViewModel.stateFlow.collectAsState().count

This unidirectional cycle can be visualized with the following diagram:

Challenges with Fragment-based architecture

While Mavericks works well for state management, we were still experiencing some challenges with Android UI development, stemming from the fact that we were using a Fragment-based architecture integrated with Mavericks. With this approach, ViewModels are mainly scoped to the Activity and shared between Fragments via injection. Fragment views are updated by state changes from the ViewModel, and call back to the ViewModel to make state changes. The Fragment Manager manages navigation independently when Fragments need to be pushed or popped.

Due to this architecture, we were running up against some ongoing difficulties, which became the motivation for building Trio.

  1. Scoping — Sharing ViewModels between multiple Fragments relies on the implicit injection of the ViewModel. Thus, it isn’t clear which Fragment is responsible for creating the Activity ViewModel originally, or for providing the initial arguments to it.
  2. Communication — It’s difficult to share data between Fragments directly and with type safety. Again, because ViewModels are injected, it’s hard to have them communicate directly, and we don’t have good control over the ordering of their creation.
  3. Navigation — Navigation is done via the Fragment Manager and must happen in the Fragment. However, state changes are done in the ViewModel. This leads to synchronization problems between ViewModel and navigation states. It’s hard to coordinate if-then scenarios like making a navigation call only after updating a state value in the ViewModel.
  4. Testability — It’s difficult to isolate the UI for testing because it is wrapped in the Fragment. Screenshot tests are prone to flakiness and a lot of indirection is required for mocking the ViewModel state, because ViewModels are injected into the Fragment with property delegates.
  5. Reactivity — Mavericks provides a unidirectional state flow to the View, which is helpful for consistency and testing, but the View system doesn’t lend itself well to reactive updates to state changes, and it can be difficult or inefficient to update the view incrementally on each state change.

Why we built Trio

In 2021, our team began to explore adopting Jetpack Compose and completely transitioning away from Fragments. By fully embracing Compose, we could better prepare ourselves for future Android developments and eliminate years of accumulated tech debt.

Continuing to use Mavericks was important to us because we have a large amount of internal experience with it, and we didn’t want to further complicate an architectural migration by also changing our state management approach. We saw an opportunity to rethink how Mavericks could support a modern Android application, and address problems we encountered with our previous architecture

With Fragments, we struggled to guarantee type safe communication between screens at runtime. We wanted to be able to codify the expectations about how ViewModels are used and shared, and what interfaces look like between screens.

We also didn’t feel our needs were fully met by the Jetpack Navigation component, especially given our heavily modularized code base and large app. The Navigation component is not type safe, requires defining the navigation graph in a single place, and doesn’t allow us to co-locate state in our ViewModel. We looked for a new architecture that could provide better type safety and modularization support.

Finally, we wanted an architecture that would improve testability, such as more stable screenshot and UI tests, and simpler navigation testing.

We considered the open source libraries Workflow and RIBs, but opted not to use them because they were not Compose-first and were not compatible with Mavericks and our other pre-existing internal frameworks.

Given these requirements, our decision was to develop our own solution, which we named Trio.

Trio Architecture

Trio is an opinionated framework for building features. It helps us to define and manage boundaries and state in Compose UI. Trio also standardizes how state is hoisted from Compose UI and how events are handled, enforcing unidirectional data flow with Mavericks. The design was inspired by Square’s Workflow library; Trio differs in that it was designed specifically for Compose and uses Mavericks ViewModels for managing state and events.

Self-contained blocks are called “Trios”, named for the three main classes they contain. Each Trio has its own ViewModel, State, and UI, and can communicate with and be nested in other Trios. The following diagram represents how these components work together. The ViewModel makes changes to state via Mavericks reducers, the UI receives the latest state value to render, and events are routed back to the ViewModel for further state updates.

If you’re already familiar with Mavericks this pattern should look very similar! The ViewModel and State usage is very similar to what we did with Fragments. What’s new is how we embed the ViewModels in Compose UI and add Routing and Props based communication via Trio.

Trios are nested to form custom, flexible navigation hierarchies. “Parent” Trios create child Trios with initial arguments through a Router, and store those children in their State. The parent can then communicate dynamically with its children through a flow of Props, which provide data, dependencies, and functional callbacks.

The framework helps us to guarantee type safety when navigating and communicating between Trios, especially across module boundaries.

Each Trio can be tested individually by instantiating it with mocked arguments, State, and Props. Coupled with Compose’s state-based rendering and Maverick’s immutable state patterns, this provides controlled and deterministic testing environments.

The Trio Class

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

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

A Trio is created with either an initial set of arguments or an initial state, which are wrapped in a sealed class called the Initializer. In production, the Initializer will only contain Args passed from another screen, but in development we can seed the Initializer with mock state so that the screen can be loaded standalone, independent of the normal navigation hierarchy.

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

Then, in our subclass body, we define how we want to create our State, ViewModel, and UI, given the starting values of Args and Props.

Args and Props both provide input data, with the difference being that Args are static while Props are dynamic. Args guarantee the stability of static information, such as IDs used to start a screen, while Props allow us to subscribe to data that may change over time.

override fun createInitialState(args: CounterArgs, props:  CounterProps) {
  return CounterState(args.count)
}

Trio provides an initializer to create a new ViewModel instance, passing necessary information like the Trio’s unique ID, a Flow of Props, and a reference to the parent Activity. Dependencies from the application’s dependency graph can also be also passed to the ViewModel through its constructor.

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

Finally, the UI class wraps the composable code used to render the Trio. The UI class receives a flow of the latest State from the ViewModel, and also uses the ViewModel reference to call back to it when handling UI events.

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

We like that grouping all of these factory functions in the Trio class makes it explicit how each class is created, and standardizes where to look to understand dependencies. However, it can also feel like boilerplate. As an improvement, we often use reflection to create the UI class, and we use assisted inject to automate creation of the ViewModel with Dagger dependencies.

The resulting Trio declaration as a whole looks like this:

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

  override fun createInitialState(CounterArgs, CounterProps) {
    return CounterState(args.count)
  }
}

The UI Class

The Trio’s UI class implements a single Composable function named “Content”, which determines the UI that the Trio shows. Additionally, the Content function has a “TrioRenderScope” receiver type. This is a Compose animation scope that allows us to customize the Trio’s animations when it is displayed.

class CounterUI(
  override val viewModel: CounterViewModel
) : UI<CounterState, CounterViewModel> {
   
  @Composable
  override fun TrioRenderScope.Content(state: CounterState) {
    Column {
      TopAppBar()
      Button(
        text = state.count,
        modifier = Modifier.clickable {
          viewModel.incrementCount()
        }
      )
      ...
    }
  }
}

The Content function is recomposed every time the State from the ViewModel changes. The UI directs all UI events, such as clicks, back to the ViewModel for handling.

This design enforces unidirectional data flow, and testing the UI is easy because it is decoupled from the logic of state changes and event handling. It also standardizes how Compose state is hoisted for consistency across screens, while removing the boilerplate of setting up access to the ViewModel’s state flow.

Rendering a Trio

Given a Trio instance, we can render it by invoking its Content function, which uses the previously mentioned factory functions to create initial values of the ViewModel, State, and UI. The state flow is collected from the ViewModel and passed to the UI’s Content function. The UI is wrapped in a Box to respect the constraints and modifier of the caller.

@Composable
internal fun TrioRenderScope.Content(modifier: Modifier = Modifier) {
  key(trioId) {
    val activity = LocalContext.current as ComponentActivity

    val viewModel = remember {
      getOrCreateViewModel(activity)
    }

    val ui = remember { createUI(viewModel) }

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

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

To enable customizing entry and exit animations, the Content function also uses a TrioRenderScope receiver; this wraps an implementation of Compose’s AnimatedVisibilityScope which displays the Content. A helper function is used to coordinate this.

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

In practice, the actual implementation of Trio.Content is quite a bit more complex because of additional tooling and edge cases we want to support — such as tracking the Trio’s lifecycle, managing saved state, and mocking the ViewModel when shown within a screenshot test or IDE preview.

Conclusion

In this introduction to Trio we discussed Airbnb’s background with Mavericks and Fragments, and why we built Trio to transition to a Jetpack Compose-based architecture. We presented an overview of Trio’s architecture, and looked at core components such as the Trio class and UI class.

In part 2 of this series you will see how navigation works with Trio, and in part 3 we will learn how Trio’s Props allow dynamic communication between screens. And if this work sounds interesting to you, check out open roles at Airbnb!