Compose for app architecture
Android development has changed a lot since 2013, from MVC pattern to MVI in the last few years. With the introduction of Compose, we have the possibility to use newer and powerful tools to improve our architectures. This article will explore how we can elevate our code and create even more maintainable projects.
The theory
From everything inside an activity to modern design patterns, architectures in the android world have renewed themselves several times.
MVC introduced the separation of concern between the UI, the business logic, and the data. MVP added abstractions between the layers. With the gain in popularity of reactive-programming and Rx-Java came MVVM, which changed quite a lot the way programmers conceptualize the data flow across an app (UDF). When Coroutines and Flows joined the party, MVI took off by leveraging several of their advantages.
Today, Compose gives us a significant paradigm change and several developers imagine different new ways to further improve our code bases. Even though Compose is often assimilated to UI, some of its component like the runtime library focuses purely on data structures and logic. As stated by the Circuit documentation :
Compose itself is essentially two libraries — Compose Compiler and Compose UI. Most folks usually think of Compose UI, but the compiler (and associated runtime) are actually not specific to UI at all and offer powerful state management APIs.
Jake Wharton has an excellent post about this: A Jetpack Compose by any other name — Jake Wharton
Two projects have recently taken the spotlight in the area of app architecture with Compose :
- Molecule from Cashapp, builds Flow/Stateflow with Compose but requires a
CoroutineScope
from the outside. One needs to either create it or get the scope from a component with a lifecycle (activity, fragment and view model) or a side effect like rememberCoroutineScope from a composable function. - Circuit from Slack is a framework built 100% on top of Compose, it deals with logic and UI. From the documentation :
Circuit is a simple, lightweight, and extensible framework for building Kotlin applications that’s Compose from the ground up. It builds upon core principles we already know like Presenters and UDF, and adds native support in its framework for all the other requirements we set out for above.
Circuit resembles a lot to another project called “The Composable Architecture” (TCA) from Point Free, a framework for Swift development. While Circuit documentation mentions UDF and Presenter patterns, they do not use a specific name for the general architecture of the framework. One can say it is an adapted implementation of TCA leveraging Kotlin and Compose.
The projects are designed for different OS and programming languages but implement the same concepts. Three main components are necessary to build a feature :
- State: A structure that describes which data is needed to execute the logic and render the UI.
- Action: A Structure representing all the events that can happen on a screen, due to user inputs, like a click on a button for example.
- Presenter (Reducer in TCA): A function describing how the current state evolves based on a specific action.
One downside of Circuit is that it comes with a complete solution for both logic and UI. One cannot integrate it in existing code, it needs to be refactored completely. But with a minimum of boilerplate, we can implement our own TCA pattern in any existing app.
Show me the code
Let’s create a minimal feature showing departure times at a given train station. We’ll focus on the essentials and focus on some parts for the sake of this demonstration. We can start by creating the state structure :
data class DepartureState(
val isLoading: Boolean,
val stationId: Int,
val departures: List<String>,
)
isLoading
indicates when the UI should show a spinner or something similar to represent a loading/waiting time.stationId
is the ID of the chosen train station.departures
represents all the departure times for a station.
Then we can define the actions available in our screen :
sealed interface DeparturesDemoScreenAction {
data class OnLoadMoreClicked(val stationId: Int): DeparturesDemoScreenAction
}
We use a sealed interface to limit possibilities and reinforce our logic. We define an OnLoadMoreClicked
action that will be used when the user clicks on a “Load more” button. Now we need a presenter to transform OnLoadMoreClicked
into a new (or updated) DepartureState.
fun interface GetDeparturesService {
suspend operator fun invoke(station: Int, offset: Int, limit: Int): List<String>
}
fun interface DeparturesPresenter {
@Composable
operator fun invoke(actions: Flow<DeparturesDemoScreenAction>): State<DepartureState>
}
@Composable fun departuresPresenter(getDeparturesService: GetDeparturesService) = DeparturesPresenter { actions ->
val initialValue = DepartureState(isLoading = false, stationId = 0, departures = emptyList())
produceState(initialValue) {
launch {
actions.collect {
when(it) {
is DeparturesDemoScreenAction.OnLoadMoreClicked -> {
value = value.copy(isLoading = true)
val offset = if (it.stationId == value.stationId) value.departures.size else 0
val departures = getDeparturesService(it.stationId, offset, 10)
value = value.copy(isLoading = false, departures = value.departures + departures)
}
}
}
}
}
}
First, we give an initial value to our state. We then use a produceState
from the Compose framework to create a block of code capable of emitting a Compose State
object. If the IDs match, we append content to the list otherwise we replace it.
Now we only need a @Composable
function to show the UI and use what we have created so far.
@Composable fun DeparturesScreen(presenter: DeparturesPresenter) {
val eventBus = EventBus<DeparturesDemoScreenAction>()
val state by presenter(eventBus.events)
if (state.isLoading) {
CircularProgressIndicator()
}
var selectedStation by remember { mutableIntStateOf(1) }
Column {
Row {
(1..3).forEach {
Button(
onClick = {
selectedStation = it
eventBus.produceEvent(DeparturesDemoScreenAction.OnLoadMoreClicked(selectedStation))
}
) { Text("Station $it") }
}
state.departures.forEach {
Text(it)
}
if (state.departures.isNotEmpty()) {
Button(
onClick = { eventBus.produceEvent(DeparturesDemoScreenAction.OnLoadMoreClicked(selectedStation)) }
) { Text("Load more") }
}
}
}
Finally, we show three buttons that can be used to choose a train station, a list containing the departures, and a button to load more data if the list is not empty.
Great… but why?
ViewModel was originally designed for activities and fragments. The documentation on the class itself states :
ViewModel is a class that is responsible for preparing and managing the data for an Activity or a Fragment.
To get an instance of a view model, a whole bunch of androidx.lifecycle
classes are used under the hood, like ViewModelProvider
, ViewModelStoreOwner
, HolderFragment
etc… For composable functions, there is a viewModel
function inside android.lifecycle.viewmodel.compose
that can be used to get the instance of a view model. But the view model will be scoped to the activity or fragment “owning” the composable function, not the function where it is actually used itself.
Returns an existing ViewModel or creates a new one in the given owner (usually, a fragment or an activity), defaulting to the owner provided by LocalViewModelStoreOwner.
The created ViewModel is associated with the given viewModelStoreOwner and will be retained as long as the owner is alive (e. g. if it is an activity, until it is finished or process is killed).
It’s possible to avoid the issue by using additional libraries like Dagger/Hilt or Koin but it means additional “backstage” work. A presenter emitting states with produceState
is automatically scoped to the composable function calling it.
The producer is launched when
produceState
enters the Composition, and will be cancelled when it leaves the Composition. The returnedState
conflates; setting the same value won't trigger a recomposition.
For those interested in how fragments internally work, here’s some detailed reading: Dive deep into Android’s ViewModel — Android Architecture Components.
We can also mention that the pattern used with view model consisting of a combination of a private MutableStateFlow and a public StateFlow isn’t necessary anymore since the produceState
function takes care of it for us.
If we widen our scope from android only to kotlin multiplatform, we can find a strong advantage in the TCA pattern. It provides friendly APIs to the swift developer most likely already familiar with it’s concepts while view models are not a thing in the swift/ios world. (A library like KMP-NativeCoroutines will help simplify interfacing shared logic).
By leveraging the functional programming approach provided by TCA, we strengthen our codebase and future proof it against all sort of potential creeping issues that come up on long living projects.