Part three on how we built a Compose based architecture with Mavericks in the Airbnb Android app
By: Eli Hart, Ben Schwab, and Yvonne Wong
Trio is Airbnb’s framework for Jetpack Compose screen architecture in Android. It’s built on top of Mavericks, Airbnb’s open source state management library for Jetpack. In this blog post series, we’ve been breaking down how Trio works to help explain our design decisions, in the hopes that other teams might benefit from aspects of our approach.
We recommend starting with Part 1, about Trio’s architecture, and then reading Part 2, about how navigation works in Trio, before you dive into this article. In this third and final part of our series, we’ll discuss how Props in Trio allow for simplified, type-safe communication between ViewModels. We’ll also share an update on the current adoption of Trio at Airbnb and what’s next.
Trio Props
To better understand Props, let’s look at an example of a simple Message Inbox screen, composed of two Trios side by side. There is a List Trio on the left, showing inbox messages, and a Details Trio on the right, showing the full text of a selected message.
The two Trios are wrapped by a parent screen, which is responsible for instantiating the two children, passing along data to them, and positioning them in the UI. As you might recall from Part 2, Trios can be stored in State; the parent’s State includes both the message data as well as the child Trios.
data class ParentState(
val inboxMessages: List<Message>,
val selectedMessage: Message?,
val messageListScreen: Trio<ListProps>,
val messageDetailScreen: Trio<DetailsProps>,
} : MavericksState
The parent’s UI decides how to display the children, which it accesses from the State. With Compose UI, it’s easy to apply custom layout logic: we show the screens side by side when the device is in landscape mode, and in portrait we show only a single screen, depending on whether a message has been selected.
@Composable
override fun TrioRenderScope.Content(state: ParentState) {
if (LocalConfiguration.current.orientation == ORIENTATION_LANDSCAPE) {
Row(Modifier.fillMaxSize()) {
ShowTrio(state.listScreen, modifier = Modifier.weight(1f))
ShowTrio(state.detailScreen)
}
} else {
if (state.selectedMessage == null) {
ShowTrio(state.listScreen)
} else {
BackHandler { viewModel.clearMessageSelection() }
ShowTrio(state.detailScreen)
}
}
}
Both child screens need access to the latest message state so they know which content to show. We can provide this with Props!
Props are a collection of Kotlin properties, held in a data class and passed to a Trio by its parent.
Unlike Arguments, Props can change over time, allowing a parent to provide updated data as needed throughout the lifetime of the Trio. Props can include Lambda expressions, allowing a screen to communicate back to its parent.
A child Trio can only be shown in a parent that supports its Props type. This ensures compile-time correctness for navigation and communication between Trios.
Defining Props
Let’s see how Props are used to pass message data from the parent Trio to the List and Details Trios. When a parent defines child Trios in its State, it must include the type of Props that those children require. For our example, the List and Details screen each have their own unique Props.
The List screen needs to know the list of all Messages and whether one is selected. It also needs to be able to call back to the parent to tell it when a new message has been selected.
data class ListProps(
val selectedMessage: Message?,
val inboxMessages: List<Message>,
val onMessageSelected: (Message) -> Unit,
)
The Details screen just needs to know which message to display.
data class DetailProps(
val selectedMessage: Message?
)
The parent ViewModel holds the child instances in its State, and is responsible for passing the latest Props value to the children.
Passing Props
So, how does a parent Trio pass Props to its child? In its init block it must use the launchChildInitializer
function — this function uses a lambda to select a Trio instance from the State, specifying which Trio is being targeted.
class ParentViewModel: TrioViewModel {init {
launchChildInitializer({ messageListScreen }) { state ->
ListProps(
state.selectedMessage,
state.inboxMessages,
::showMessageDetails
)
}
launchChildInitializer({ detailScreen }) { state ->
DetailProps(state.selectedMessage)
}
}
fun showMessageDetails(message: Message?) ...
}
The second lambda argument receives a State value and returns a new Props instance to pass to the child. This function manages the lifecycle of the child, initializing it with a flow of Props when it is first created, and destroying it if it is ever removed from the parent’s state.
The lambda to rebuild Props is re-invoked every time the Parent’s state changes, and any new value of Props is passed along to the child through its flow.
A common pattern we use is to include function references in the Props, which point to functions on the parent ViewModel. This allows the child to call back to the parent for event handling. In the example above we do this with the showMessageDetails
function. Props can also be used to pass along complex dependencies, which forms a dependency graph scoped to the parent.
Note that we cannot pass Props to a Trio when it is created, like we do with Args. This is because Trios must be able to be restored after process death, and so the Trio class, as well as the Args used to create it, are Parcelable. Since Props can contain lambdas and other arbitrary objects that cannot be safely serialized, we must use the above pattern to establish a flow of Props from parent to child that can be reestablished even after process recreation. Navigation and inter-screen communication would be a lot simpler if we didn’t have to handle process recreation!
Using Props
In order for a child Trio to use Props data in its UI, it first needs to be copied to State.
Child ViewModels override the function updateStateFromPropsChange
to specify how to incorporate Prop values into State. The function is invoked every time the value of Props changes, and the new State value is updated on the ViewModel. This is how children stay up-to-date with the latest data from their parent.
class ListViewModel : TrioViewModel<ListProps, ListState> {override fun updateStateFromPropsChange(
newProps: ListProps,
thisState: ListState
): ListState {
return thisState.copy(
inboxMessages = newProps.inboxMessages,
selectedMessage = newProps.selectedMessage
)
}
fun onMessageSelected(message: Message) {
props.onMessageSelected(message)
}
}
For non-state values in Props, such as dependencies or callbacks, the ViewModel can access the latest Props value at any time via the props
property. For example, we do this in the onMessageSelected
function in the sample code above. The List UI will invoke this function when a message is selected, and the event will be propagated to the parent through Props.
There were a lot of complexities when implementing Props — for example, when handling edge cases around the Trio lifecycle and restoring state after process death. However, the internals of Trio hide most of the complexity from the end user. Overall, having an opinionated, codified system with type safety for how Compose screens communicate has helped improve standardization and productivity across our Android engineering team.
One of the most common UI patterns at Airbnb is to coordinate a stack of screens. These screens may share some common data, and follow similar navigation patterns such as pushing, popping, and removing all the screens of the backstack in tandem.
Earlier, we showed how a Trio can manage a list of children in its State to accomplish this, but it’s tedious to do that manually. To help, Trio provides a standard “screen flow” implementation, which consists of a parent ScreenFlow
Trio and related child Trio screens. The parent ScreenFlow
automatically manages child transactions, and renders the top child in its UI. It also broadcasts a custom Props class to its children, giving access to shared state and navigation functions.
Consider building a Todo app that has a TodoList screen, a TaskScreen, and an EditTaskScreen. These screens can all share a single network request that returns a TodoList model. In Trio terms, the TodoList data model could be the Props for these three screens.
To manage these screens we use ScreenFlow infrastructure to create a TodoScreenFlow Trio. Its state extends ScreenFlowState
and overrides a childScreenTransaction
property to hold the transactions. In this example, the flow’s State was initialized to start with the TodoListScreen, so it will be rendered first. The flow’s State object also acts as the source of truth for other shared state, such as the TodoList data model.
data class TodoFlowState(
@PersistState
override val childScreenTransactions: List<ScreenTransaction<TodoFlowProps>> = listOf(
ScreenTransaction(Router.TodoListScreen.createFullPaneTrio(NoArgs))
),
// shared state
val todoListQuery: TodoList?,
) : ScreenFlowState<TodoFlowState, TodoFlowProps>
This state is private to the TodoScreenFlow. However, the flow defines Props to share the TodoList data model, callbacks like a reloadList lambda, and a NavController
with its children.
data class TodoFlowProps(
val navController: NavController<TodoFlowProps>,
val todoListQuery: TodoList?,
val reloadList: () -> Unit,
)
The NavController
prop can be used by the children screens to push another sibling screen in the flow. The ScreenFlowViewModel
base class implements this NavController
interface, managing the complexity of integrating the navigation actions into the screen flow’s state.
interface NavController<PropsT>(
fun push(router: TrioRouter<*, in PropsT>)
fun pop()
)
Lastly, the navigation and shared state is wired into a flow of Props when the TodoScreenFlowViewModel
overrides createFlowProps
. This function will be invoked anytime the internal state of TodoScreenFlowViewModel
changes, meaning any update to TodoList model will be propagated to the children screens.
class TodoScreenFlowViewModel(
initializer: Initializer<NavPopProps, TodoFlowState>
) : ScreenFlowViewModel<NavPopProps, TodoFlowProps, TodoFlowState>(initializer) {override fun createFlowProps(
state: TodoFlowState,
props: NavPopProps
): TodoFlowProps {
return TodoFlowProps(
navController = this,
state.todoListQuery,
::reloadList,
)
}
}
Inside one of the children screen’s ViewModels, we can see that it will receive the shared Props:
class TodoListViewModel(
initializer: Initializer<TodoFlowProps, TodoListState>
) : TrioViewModel<TodoFlowProps, TodoListState>(initializer) {override fun updateStateFromPropsChange(
newProps: TodoFlowProps,
thisState: TodoTaskState
): TodoTaskState {
// Incorporate the shared data model into this Trio’s private state passed to its UI:
return thisState.copy(todoListQuery = newProps.todoListQuery)
}
fun navigateToTodoTask(task: TodoTask) {
this.props.navController.push(Router.TodoTaskScreen, TodoTaskArgs(task.id))
}
}
In navigateToTodoTask
, the NavController
prepared by the flow parent is used to safely navigate to the next screen in the flow (guaranteeing it will receive the shared TodoFlowProps). Internally, the NavController
updates the ScreenFlow’s childScreenTransactions
triggering the ScreenFlow infra to provide the shared TodoFlowProps to the new screen, and render the new screen.
Development history and launch
We started designing Trio in late 2021, with the first Trio screens seeing production traffic in mid 2022.
As of March 2024, we now have over 230 Trio screens with significant production traffic at Airbnb.
From surveying our developers, we’ve heard that many of them enjoy the overall Trio experience; they like having clear and opinionated patterns and are happy to be in a pure Compose environment. As one developer put it, “Props was a huge plus by allowing multiple screens to share callbacks, which simplified some of my code logic a lot.” Another said, “Trio makes you unlearn bad habits and adopt best practices that work for Airbnb based on our past learnings.” Overall, our team reports faster development cycles and cleaner code. “It makes Android development faster and more enjoyable,” is how one engineer summed it up.
Dev Tooling
To support our engineers, we have invested in IDE tooling with an in-house Android Studio Plugin. It includes a Trio Generation tool that creates all of the files and boilerplate for a new Trio, including routing, mocks, and tests.
The tool helps the user choose which Arguments and Props to use, and helps with other customization such as setting up custom Flows. It also allows us to embed educational experiences to help newcomers ramp up with Trio.
One piece of feedback we heard from engineers was that it was tedious to change a Trio’s Args or Props types, since they are used across many different files.
We leveraged our IDE plugin to provide a tool to automatically change these values, making this workflow much faster.
Our team leans heavily on tooling like this, and we’ve found it to be very effective in improving the experience of engineers at Airbnb. We’ve adopted Compose Multiplatform for our Plugin UI development which we believe made building powerful developer tooling more feasible and enjoyable.
Overall, with more than 230 of our production screens implemented as Trios, Trio’s organic adoption at Airbnb has proven that many of our bets and design choices were worth the tradeoffs.
One change we are anticipating, though, is to incorporate shared element transitions between screens once the Compose framework provides APIs to support that functionality. When Compose APIs for this are available, we’ll likely have to redesign our navigation APIs accordingly.
Thanks for following along with the work we’ve been doing at Airbnb. Our Android Platform team works on a variety of complex and interesting projects like Trio, and we’re excited to share more in the future.
If this kind of work sounds appealing to you, check out our open roles — we’re hiring!
إرسال تعليق