Thumbtack’s New Android UI Framework: Cork

Dominic Zirbel
Thumbtack Engineering
10 min readMay 12, 2023
Image by Google Developers

At Thumbtack, we’ve recently started adopting a new set of core architecture components on Android under the codename “Cork”. This post describes the design principles, process, and some early results.

Cork

Cork is Thumbtack’s lightweight and opinionated Android MVVM UI framework built with modern Android architecture components.

To break this down:

  • Lightweight: Thumbtack’s own code in Cork is minimal, with the bulk of its functionality provided by third-party libraries like ViewModel and Compose.
  • Opinionated: While Cork does not add much functionality, it does intentionally restrict the set of options available to ensure best practices are followed and code is standardized.
  • MVVM: Cork is built around the Model-View-ViewModel pattern.
  • Android architecture components: Cork uses patterns which have become standard across the industry.

Cork is designed to emphasize declarative and functional programming. In particular, we have found that minimizing side effects, decoupling components, and avoiding unnecessary abstractions have been key to developer productivity and product quality. Most importantly, as many architecture components as possible should be stateless — their current state should be captured externally and explicitly, rather than as mutable internal properties. This makes code significantly easier to reason about, even if it comes at the cost of extra class definitions or overhead.

As a UI framework, Cork’s purview includes most areas of day-to-day feature development:

  • screen-level business logic based on Jetpack Lifecycle/ViewModel
  • entry points to build the UI layer in Jetpack Compose
  • navigation and deeplinking infrastructure based on Jetpack Navigation
  • app-wide performance and user behavior instrumentation
  • unit and connected testing infrastructure and utilities

Some related areas of app architecture are not considered part of Cork proper, and we will only touch on them here:

  • the domain and data layers, including infrastructure to make network calls and cache data
  • dependency injection
  • our design system, Thumbprint

How did we get here?

Before we go into details, we’ll start with some historical context on Thumbtack’s Android codebase.

At Thumbtack, we are developing two Android apps: one for home owners to hire local service professionals and one for professionals to list their services. Across these two apps, we have around 300 screens — some simple and some very complicated. We have almost entirely completed our migration to Kotlin, with 100k+ lines of Kotlin across the codebase (and 8k lines of Java).

The last time we made major updates to our app architecture was around 2018 when we developed a Model-View-Presenter (MVP) architecture based heavily on RxJava, creatively named “RxArch”. It built on top of existing custom navigation infrastructure which used a stack of Views in each Activity rather than Fragments. Notably, this architecture was developed before many core architecture components like ViewModel had become available and paradigms like MVVM were still being worked out across the industry.

As RxArch evolved over the years it left behind some of the pitfalls of MVP (such as tight coupling between the View and Presenter) and developed into a functional and — in some ways — declarative pattern of its own. The core structure looks like this:

abstract class RxPresenter<UIModel : Any> {
protected abstract val computationScheduler: Scheduler
protected abstract val mainScheduler: Scheduler

private val disposable = CompositeDisposable()

fun open(control: RxControl<UIModel>) {
control.uiEvents()
.observeOn(computationScheduler)

// convert events to a stream of "results"
// side effects like network calls and tracking happen here
.publish { events -> reactToEvents(events) }

// apply each result to the UI model; this should be pure and idempotent
.scan(control.initialUIModel) { uiModel, result ->
applyResultToUIModel(uiModel, result)
}

// bind each distinct new UI model to the control (on the UI thread)
.distinctUntilChanged()
.observeOn(mainScheduler)
.subscribe(
{ uiModel -> control.bind(uiModel) }, // onNext
{ exception -> Timber.e(exception) }, // onError
)

.also { disposable.add(it) }
}

fun close() {
disposable.dispose()
}

abstract fun reactToEvents(events: Observable<UIEvent>): Observable<Any>

abstract fun applyResultToUIModel(uiModel: UIModel, result: Any): UIModel
}

interface RxControl<UIModel : Any> {
val initialUIModel: UIModel

fun bind(uiModel: UIModel)

fun uiEvents(): Observable<UIEvent>
}

Despite being nominally MVP, this already mirrors modern Android MVVM architecture in many ways! The Presenter and View (an implementation of RxControl) are decoupled, communicating only by the event stream the View provides to the Presenter and the models the Presenter then binds to the View. The events stream — translated into a result and then UIModel stream — fit well with single source of truth and unidirectional data flow principles.

However, this architecture poses its own challenges due in large part to being home-grown and thus not as thoroughly developed or integrated with other architectural components. Managing the lifecycle of a Presenter is complicated, and can’t generally live through configuration changes of its Activity, unlike a ViewModel (although we have implemented some in-memory Presenter storage to mitigate this). The UIModel must be serialized in full, which bloats the size of persisted Bundles (again, we have some mitigations). Binding the entire UIModel at once must either do expensive diffing or potentially wasteful re-binding of widget properties, even if only a small element of the model has changed (mitigations here include powerful wrappers around RecyclerView using DiffUtil).

Many elements of modern Android architecture like ViewModel (RxPresenter with better lifecycle management), Coroutines (RxJava with easier management of scheduling and disposing), and Compose (much more powerful UI binding) fit into this picture easily without losing the benefits of RxArch we’ve come to enjoy. After watching their development unfold for some time to ensure these new components are stable and on the path to widespread adoption we came to the decision of revamping our architecture around them.

Cork Implementation

Cork builds UI around screen-level components: those which are designed to use the entire screen and manage their own state and lifecycle (although in some situations they may only be part of the screen, such as for large dialogs or when displaying split content on tablets). Each screen-level component defines four primary classes:

Model: declarative, immutable state of the screen

@Immutable // mark as immutable for the Compose compiler; or use ImmutableList
data class MessengerModel(
val contactName: String,
val messages: List<Message>? = null,
)

data class Message(val text: String, val outgoing: Boolean)
  • The model is a plain-old-Kotlin data class. To make it easy to reason about the state of the screen, it should be immutable, with updates represented as copies of the object. For performance, Compose should either be able to infer the immutability of the model (and its fields) or it should be explicitly declared @Immutable.
  • The model is a single object rather than split across various streams or properties collected by the view. This adds overhead, but keeps its state easy to reason about (for example, avoiding race conditions when updating different aspects of related state like a loading state and the main content) and avoids a long list of state components in the top-level Composable.
  • The state is persisted in-memory as part of the ViewModel’s lifecycle. In particular, the model object will be preserved through configuration changes. On process death the state will be rebuilt from scratch based on the preserved navigation arguments, e.g. by fetching data from a database instead of requesting it from the network, but the model itself is not serialized.

Events: user-initiated events passed from the View to the ViewModel

sealed interface MessengerEvent {
object ClickProfile : MessengerEvent
class SendMessage(val message: String) : MessengerEvent
}
  • Events are defined in an object-oriented way, typically as implementations of a sealed interface. This has two implications:
  1. Events are emitted from the view layer by passing an event object into a callback lambda as opposed to a usual MVVM pattern of making a direct function call on the ViewModel. This avoids the view layer Composable needing to accept a large number of callback lambdas or a direct reference to the ViewModel, which keeps its function definition reasonably scoped.
  2. Events are handled through a single stream which allows powerful functional-style operations such as flat-mapping, debouncing, and interactions between different event types like zipping.

CorkView: view layer, declaratively defines the user interface appearance with Jetpack Compose

object MessengerView : CorkView<MessengerModel, MessengerEvent> {
@Composable
override fun ViewScope<MessengerEvent>.Content(modelState: State<MessengerModel>) {
Column {
Text(
text = modelState.value.contactName,
style = Thumbprint.typography.title1,
modifier = Modifier.clickable { emitEvent(MessengerEvent.ClickProfile) },
)

Column {
// use a derived state to recompose message list only when it specifically changes
val messages = derivedStateOf { modelState.value.messages.orEmpty() }.value
for (message in messages) {
val alignment = if (message.outgoing) Alignment.End else Alignment.Start
Text(text = message.text, modifier = Modifier.align(alignment))
}
}

MessageField(onSendMessage = { emitEvent(MessengerEvent.SendMessage(message = it)) })
}
}

@Composable
private fun MessageField(onSendMessage: (String) -> Unit) {
Row {
// trivial state which the ViewModel does not need may be remembered locally
var enteredText: String by remember { mutableStateOf("") }

TextField(value = enteredText, onValueChange = { enteredText = it })

Button(onClick = { onSendMessage(enteredText) }) {
Text("Send")
}
}
}
}
  • The UI of CorkView is provided by a single @Composable function Content().
  • Views are object-oriented as plain objects implementing CorkView. This differs from the standard practice of top-level @Composable functions and is generally discouraged. Cork makes an exception for this case to (1) provide structure to screen-level components: it is always clear what the top-level Composable is, (2) allow a standardized definition of the top-level content Composable, since top-level functions cannot be made to conform to an interface and references to Composable functions are not yet supported. Object-orientation also enables us to write convenience functions such as for Compose previews and screenshots (often as extension functions, like fun CorkView.Preview(model: Model)).
  • CorkView passes user events up to the ViewModel by means of the receiver parameter ViewScope, which provides the emitEvent function. This avoids an explosion of lambda parameters for the various types of UI events. The ViewScope should not be passed to sub-Composables, which should use the usual pattern of callback parameters.
  • CorkView receives the model wrapped in a Compose State object. This allows it to intelligently query aspects of the model at relevant points in the composition to avoid excessive recomposition. Cork relies on heavy use of derived state to maintain performance.

CorkViewModel: screen-level business logic as a Jetpack Lifecycle ViewModel

class MessengerViewModel @Inject constructor(
// constructor parameters are provided via assisted injection
override val computationDispatcher: CoroutineDispatcher,
contactName: String,
private var contactId: String,
private val messageRepository: MessageRepository,
) : CorkViewModel<MessengerModel, MessengerEvent>(
initialModel = MessengerModel(contactName = contactName),
) {
init {
// asynchronously load initial messages
viewModelScope.launch {
val messages = messageRepository.loadMessages(contactId = contactId)
mutateModel { it.copy(messages = messages) }
}

// react to clicks on the contact name to navigate to their profile page
collect(MessengerEvent.ClickProfile::class) {
navigate(CorkNavigationEvent.GoToRoute("profile/$contactId"))
}

// react to new messages being sent to update the model and send via the Repository
collect(MessengerEvent.SendMessage::class) { event ->
val message = Message(text = event.message, outgoing = true)
mutateModel { it.copy(messages = it.messages?.plus(message)) }

messageRepository.sendMessage(contactId = contactId, message = event.message)
}
}
}
  • The bulk of a ViewModel’s logic is in reacting to UI events, by collecting specific types of them from the event stream, and mutating the model as a result of computation. CorkViewModel provides restricted utilities to do this which enforce common patterns of event processing. collect() accepts additional parameters to control how the events are processed, for example to handle them concurrently (the default) or restart processing with each latest event.
  • Unlike most ViewModels, CorkViewModel does not allow ViewModel nesting, since state is exposed in a single object and sub-Composables should not define their own internal ViewModels. We will continue to explore ways to relax this restriction without sacrificing statelessness in the view layer.
  • In addition to the model which is published to the view layer, CorkViewModel may also maintain its own internal state as regular properties. For example, contactId is a private field provided by the navigation route and which does not need to be exposed to the view.

Another important feature that Cork provides is navigation; in particular, interoperability with our legacy navigation stack. In greenfield development, we would use Jetpack Navigation with its Compose bindings for a pure-Compose stack. This does lose the safety benefits of Navigation Safe Args, but allows for much cleaner handling of composition state and lifecycle than wrapping Compositions in Fragments.

In practice, we have adopted a mixed strategy to allow migrating screen-by-screen before we can replace the entire navigation stack. To integrate with our view stack, we have a custom View which wraps a ComposeView that hosts the navigation graph. The first navigation event that enters the new architecture pushes one of these wrapper Views onto the stack; from there the Compose NavGraph captures new Intents and routes them accordingly. Navigation destinations are injected and wrapped in some convenience Composables provided by Cork which include instrumentation (such as tracking time-to-first-paint) and set up the CorkView and its CorkViewModel.

Adoption and Results

We’ve only just begun the rollout of Cork. We’ve migrated a handful of internal tools screens, one screen has been shipped in production, and a few more are in progress.

The path for adoption takes a middle road between allowing indiscriminate incremental adoption and requiring a rewrite from scratch. At our scale an entire rewrite is not feasible, but neither is allowing ad-hoc use of new technologies without some amount of standardization and consistency. We have also taken care to avoid common performance pitfalls, in particular from overuse of ComposeView/AndroidView interoperability (so migrating only one component on a screen at a time is a risk, especially when they are in a RecyclerView). Put together, we only allow migrations screen-by-screen; from RxArch MVPs to Cork MVVMs. We encourage screens to continue to use RxJava and avoid Compose until they can be fully migrated.

While we don’t yet have conclusive metrics on the performance impact of Cork, we already have some results and sentiment on developer productivity. As a coarse estimate of simplification, over a handful of rewrites we have seen about a 30% reduction in lines of code from RxArch to Cork. Cork has achieved its primary goal of modernizing our architecture and simplifying code and the team has been quite happy with the direction it provides.

In conclusion, while modernizing the architecture of a large codebase like ours can be a daunting task, we’ve found good results using modern Android architecture components and an incremental adoption path. We are only at the beginning of this journey and can’t see yet where it will end, but our team is enthusiastic to carry on and continue to align with the direction of the industry as a whole. We expect many teams are in a similar situation to ours, and as they adopt new libraries and tools the ecosystem will continue to grow and strengthen. We hope that Cork can be a part of that and look forward to sharing more details about its implementation and results.

--

--