Mavericks Style Architecture on Kotlin Compose Multiplatform: A Tutorial
Mavericks is an MVI (Model-View-Intent) framework for Android, developed by Airbnb, known for its advantages in being thread-safe, user-friendly and featuring an independent and powerful rendering system. Mavericks employs simple and easily comprehensible approaches such as using data classes for state, a ViewModel that automatically handles state changes and state that can be observed independently by the view. With these Mavericks approaches in mind, We will attempt to create an architecture pattern that is at least similar in usage in Kotlin Multiplatform.
Core Concept
Before we learn into creating an architecture pattern, we need to understand the principles of unidirectional data flow. The principle of unidirectional flow is one of the design approaches used in Android application development architecture, particularly in the context of “Model-View-Intent” (MVI) or “Unidirectional Data Flow.” This principle aims to maintain a structured data flow and minimize complexity in Android application development. There are three main components in this pattern:
- Model: This is the component of the application responsible for storing and managing data. It can represent data from network APIs, local databases, or other application states. The Model is immutable, meaning that whenever there is a change in data, a new model is created and the old one is discarded. This principle helps ensure that data doesn’t change unexpectedly and can be accessed safely. In practice, this model is known as the state.
- View: The View is responsible for displaying data to the user. The View should be passive and only display data provided by the Model. This means the View doesn’t have its own state and doesn’t directly alter data. The View should also not have business logic. Instead, it observes data provided by the Model.
- Intent: Intent refers to actions or events triggered by the user or the system that result in changes to the Model. It can be user actions like clicking buttons, sending data, or even system events like broadcasts. Intents are used to update the Model, which then triggers changes in the View.
The data flow process in the unidirectional flow principle is as follows:
- The user interacts with the View (e.g., by clicking a button).
- The View generates the corresponding Intent and sends it to the Model.
- The Model processes the Intent, generates a new Model and sends it to the View.
- The View observes changes in the Model and updates the display accordingly based on the new Model.
The unidirectional flow principle helps separate business logic, presentation and user interaction in Android applications. This makes applications easier to understand, test and manage. Additionally, this principle supports event-driven programming and avoids complex issues.
Returning to Mavericks, by adopting the main components mentioned above, Mavericks focuses on just three classes:
MavericksState
: This class functions as the Model and is also responsible for each View. This means there is only oneMavericksState
for each rendered View.MavericksViewModel
: This class doesn’t represent one of the three main components, but it constructs the Model so that it can be observed by the View. In this class, Intent functions as intended.MavericksView
: This class serves as the View, where every data in the Model is rendered. This class can observeMavericksViewModel
so that any changes in the Model can be known by the View.
State and Intent
The first step in emulating the Mavericks concept is to create an interface for State to provide a place for the ViewModel.
interface State
Every Model data will implement this interface. Is it sufficient to have just that, without any functions? Yes, for now, it’s sufficient. So, the Model data will look like this:
data class CounterState(
val counter: Int = 0
) : State
In this data class, the CounterState
class will serve as the Model that will be observed by the View. For now, that’s all we need to do because we will discuss State, its usage and modifications in more detail later.
The second step is to create Intent. Here, Intent is a special class that has the same parent class so that it can be identified simultaneously. In other words, the implementation of Intent uses a sealed class.
interface Intent
An example of implementing intent simply:
sealed class CounterIntent : Intent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
}
This Intent will be used by the ViewModel as centralized commands.
Abstraction of ViewModel
Step three involves creating the ViewModel. In Kotlin Multiplatform, ViewModel is not provided specifically by Google or JetBrains. We will create our own ViewModel with a relatively simple concept. In this discussion, we will create two core models for the ViewModel. The first one is the fundamental ViewModel class, related to the primary functions of ViewModel. The second one is ViewModelState
, which will be used in our MVI framework. Why do we need two base ViewModels? It’s because of the following reasons:
- ViewModel works separately from
ViewModelState
. ViewModelState
is a derivative of ViewModel.ViewModelState
is responsible for handling State.- ViewModel only has the basic functions of ViewModel that we are familiar with.
So, how do we create a ViewModel class in Kotlin Multiplatform in a simple manner? There are many libraries that provide ViewModel for Kotlin Multiplatform, but we won’t create one ourselves to avoid overkill.
The initial step is to create an abstraction and implement it on the Android and iOS platforms. Here’s the code:
Common code:
expect abstract class ViewModel() {
val viewModelScope: CoroutineScope
protected open fun onCleared()
fun clear()
}
Platform code:
// android
import androidx.lifecycle.ViewModel as LifecycleViewModel
import androidx.lifecycle.viewModelScope as androidViewModelScope
actual abstract class ViewModel : LifecycleViewModel() {
actual val viewModelScope = androidViewModelScope
actual override fun onCleared() {
super.onCleared()
}
actual fun clear() {
onCleared()
}
}
// ios
actual abstract class ViewModel {
actual val viewModelScope: CoroutineScope = object : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = SupervisorJob() + Dispatchers.IO
}
protected actual open fun onCleared() {
viewModelScope.cancel()
}
actual fun clear() {
onCleared()
}
}
In this simple function, ViewModel provides a CoroutineScope
on each platform and can cancel jobs when the ViewModel is destroyed. So, how do we automatically destroy ViewModel depending on the Compose lifecycle? The answer is to use remember
and DisposableEffect
.
We need a specific function that can detect when a Compose node has ended. This function will wrap the ViewModel class so that ViewModel can independently destroy itself based on the lifecycle.
Common code:
@Composable
expect fun <T: ViewModel> rememberViewModel(viewModel: () -> T): T
Platform code:
// android
@Composable
actual fun <T: ViewModel> rememberViewModel(viewModel: () -> T): T {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val vm = remember { viewModel.invoke() }
DisposableEffect(lifecycle) {
onDispose {
vm.clear()
}
}
return vm
}
//ios
@Composable
actual fun <T: ViewModel> rememberViewModel(viewModel: () -> T): T {
val vm = remember { viewModel.invoke() }
DisposableEffect(vm) {
onDispose {
vm.clear()
}
}
return vm
}
Now we can use ViewModel in a Compose node with the following simple code:
@Composable
fun Counter() {
val counterViewModel = rememberViewModel { CounterViewModel() }
}
The basic ViewModel abstraction has been created and the next step is to create the ViewModelState
abstraction, which serves to:
- Store State as the Model.
- Update the State.
- Have an exposed observation function for consumption by the View or Compose node.
- Have functions that can handle Intent.
To store State in ViewModel, we use StateFlow
. This is used to fulfill the main concept of MVI in the model, which is immutable and creates a new State data whenever there is a change. The advantage of StateFlow
is its direct observability by Compose nodes.
In ViewModelState
, we can also define the intent contract, making the code in each ViewModelState
more consistent.
abstract class ViewModelState<STATE : State, INTENT : Intent>(
initialState: STATE
) : ViewModel() {
private val _state: MutableStateFlow<STATE> = MutableStateFlow(initialState)
val state: StateFlow<STATE> get() = _state
abstract fun intent(intent: INTENT)
}
Flow of State
After creating a state flow, the next step is to create a function to update the state flow. The process is simple; we need to obtain the current state first. Since State is a Kotlin data class, there is a copy
function that can be used to create a new State. This new State must be emitted in the StateFlow to initiate the flow of State data with the updated data.
Here’s the code for the updated ViewModelState
:
abstract class ViewModelState<STATE : State, INTENT : Intent>(
initialState: STATE
) : ViewModel() {
private val _state: MutableStateFlow<STATE> = MutableStateFlow(initialState)
val state: StateFlow<STATE> get() = _state
abstract fun intent(intent: INTENT)
protected fun setState(block: STATE.() -> STATE) = viewModelScope.launch {
val currentState = _state.value
val newState = block.invoke(currentState)
_state.tryEmit(newState)
}
}
Conceptually, this simple creation of ViewModelState
can be implemented in the Compose node we create.
Here’s an example of how to use it:
// ViewModel
class CounterViewModel : ViewModelState<CounterState, CounterIntent>(CounterState()) {
override fun intent(intent: CounterIntent) {
when (intent) {
is CounterIntent.Increment -> increment()
is CounterIntent.Decrement -> decrement()
}
}
private fun increment() = setState {
copy(counter = this.counter + 1)
}
private fun decrement() = setState {
copy(counter = this.counter - 1)
}
}
// Compose node
@Composable
fun Counter() {
val counterViewModel = rememberViewModel { CounterViewModel() }
val counterState by counterViewModel.state.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = counterState.counter.toString())
Button(
onClick = {
counterViewModel.intent(CounterIntent.Decrement)
}
) {
Text("Decrement")
}
Button(
onClick = {
counterViewModel.intent(CounterIntent.Increment)
}
) {
Text("Increment")
}
}
}
The result obtained is as follows:
Asynchronous Handling
After successfully creating a framework like that, a problem shown: how to handle asynchronous states, such as data coming from a network API?
In this practice, we will create our own Async
class by adopting these states.
sealed class Async<out T> {
object Default : Async<Nothing>()
object Loading : Async<Nothing>()
data class Success<T>(val data: T) : Async<T>()
data class Failure(val throwable: Throwable) : Async<Nothing>()
}
We will incorporate this Async class into the State data class we’ve used before.
To implement this practice, we need to use a specific network API. We all agree that in the future, we will use the Football Store API to display the number of products. The API page can be found here: https://utsmannn.github.io/football-store-api/.
# Product list URL
https://footballstore.fly.dev/api/product
Don’t forget to add Ktor and Kotlin Serialization dependencies because we will use them for networking. Create a network repository responsible for handling data calls from that URL.
class ProductRepository {
@OptIn(ExperimentalSerializationApi::class)
private val client: HttpClient by lazy {
HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
explicitNulls = false
})
}
install(Logging) {
logger = Logger.SIMPLE
level = LogLevel.INFO
}
}
}
suspend fun getProductResponse(): ProductResponse {
return client.get(URL) {
contentType(ContentType.Application.Json)
}.body()
}
companion object {
private const val URL = "https://footballstore.fly.dev/api/product"
}
}
The next step is to create an executor that can convert asynchronous data into Async
, which can be referred to as a reducer in this case. This executor is in the form of an interface, allowing us to create various executors based on our needs.
interface AsyncExecutor<T> {
suspend fun reduce(execute: suspend () -> T): Flow<Async<T>>
}
It’s necessary to create a default executor as the simplest and basic executor. This default will also be used as the default value in the ViewModel.
interface AsyncExecutor<T> {
suspend fun reduce(execute: suspend () -> T): Flow<Async<T>>
companion object {
@Suppress("FunctionName")
fun <T>Default(): AsyncExecutor<T> {
return object : AsyncExecutor<T> {
override suspend fun reduce(execute: suspend () -> T): Flow<Async<T>> {
return flow<Async<T>> {
val dataSuccess = execute.invoke()
emit(Async.Success(dataSuccess))
}.catch {
emit(Async.Failure(it))
}.onStart {
emit(Async.Loading)
}
}
}
}
}
}
Don’t forget to modify the State class we created earlier to include the Async
type with the default value of Async.Default
.
data class CounterState(
val counter: Int = 0,
val asyncProductSize: Async<Int> = Async.Default
) : State
Then, we need to add an execute()
function to the ViewModel as a wrapper for the executor we created and as a place to observe the State values generated by the executor. These State values will be stored by the ViewModel.
// ViewModelState.kt
suspend fun <T: Any?> (suspend () -> T).execute(
asyncExecutor: AsyncExecutor<T> = AsyncExecutor.Default(),
reducer: suspend STATE.(Async<T>) -> STATE
): Job {
return viewModelScope.launch {
val currentState = _state.value
asyncExecutor.reduce { invoke() }
.collectLatest {
_state.tryEmit(reducer.invoke(currentState, it))
}
}
}
The next requirement is a mapper function. This function is optional and can be used when you need to transform the Async
data into another data type. Here’s an example of its usage:
class CounterViewModel(
private val productRepository: ProductRepository
) : ViewModelState<CounterState, CounterIntent>(CounterState()) {
override fun intent(intent: CounterIntent) {
when (intent) {
is CounterIntent.Increment -> increment()
is CounterIntent.Decrement -> decrement()
is CounterIntent.GetProductSize -> getProductSize()
}
}
private fun increment() = setState {
copy(counter = this.counter + 1)
}
private fun decrement() = setState {
copy(counter = this.counter - 1)
}
private fun getProductSize() = viewModelScope.launch {
suspend {
productRepository.getProductResponse()
}.execute {
// result is Async<ProductResponse>
}
}
}
Because the data type generated inside the getProductSize()
function is Async<ProductResponse>
, while what we need in our State is Async<Int>
, a mapper is needed in the ViewModelState
. You can create it as follows:
// ViewModelState.kt
protected suspend fun <T, U>Async<T>.map(block: suspend (T) -> U): Async<U> {
return if (this is Async.Success) {
Async.Success(block.invoke(data))
} else {
when (this) {
is Async.Default -> Async.Default
is Async.Loading -> Async.Loading
is Async.Failure -> Async.Failure(throwable)
else -> throw IllegalArgumentException("Mapper failed")
}
}
}
Simply put this mapper is placed within the ViewModelState
with the assumption that this function will only be used within the ViewModel. The implementation will look like this:
// CounterViewModel.kt
private fun getProductSize() = viewModelScope.launch {
suspend {
productRepository.getProductResponse()
}.execute { asyncProductResponse ->
val asyncProductSize = asyncProductResponse.map {
it.data?.data?.size ?: 0
}
copy(asyncProductSize = asyncProductSize)
}
}
Quite easy, right? Now, how do we implement the ViewModel in a Compose node? Let’s take a look at the following code:
@Composable
fun Counter() {
val productRepository = remember { ProductRepository() }
val counterViewModel = rememberViewModel { CounterViewModel(productRepository) }
val counterState by counterViewModel.state.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// other nodes
with(counterState.asyncProductSize) {
when (this) {
is Async.Loading -> {
CircularProgressIndicator()
}
is Async.Failure -> {
Text("Product count failure: ${throwable.message}")
}
is Async.Success -> {
Text("Product count: $data")
}
is Async.Default -> {}
}
}
Button(
onClick = {
counterViewModel.intent(CounterIntent.GetProductSize)
}
) {
Text("Get product size")
}
}
}
In the above implementation, we observe counterState.asyncProductSize
and render it with the respective nodes. If data retrieval is in progress, a loading indicator (CircularProgressIndicator
) will be displayed. If there is an error, an error message will be shown and if the data is successfully retrieved, the product size will be displayed. All changes in this view are controlled through the CounterViewModel
, which manages the state and logic related to the counter and product data retrieval.
The expected result is like this:
Error handling
Error handling is an important aspect of a framework pattern architecture. Instead of manually handling errors, we need to handle errors globally and expose them to the View so that error conditions can be displayed in a placeholder.
Handling Internal Exception
With the architecture we’ve created, we need to understand the current error handling. In our first experiment, we will try to see what happens if the counter is divided by zero. The expectation is that an error log will appear and the app won’t force close. To do this, we need to add the “divide” Intent.
class CounterViewModel(
private val productRepository: ProductRepository
) : ViewModelState<CounterState, CounterIntent>(CounterState()) {
override fun intent(intent: CounterIntent) {
when (intent) {
is CounterIntent.Increment -> increment()
is CounterIntent.Decrement -> decrement()
is CounterIntent.Divide -> divide(intent.value)
is CounterIntent.GetProductSize -> getProductSize()
}
}
private fun divide(value: Int) = setState {
if (value == 0) throw ArithmeticException("Divide zero is invalid!")
copy(counter = this.counter / value)
}
// some code
}
So, what happens after the app is relaunched?
The application experiences a force close. There is an arithmetic error, which is division by zero, but it is not handled properly. To handle this, you can use the CoroutineExceptionHandler
interface. This interface allows a Throwable to be handled by a Coroutine as long as it occurs during the execution of a Job.
In this case, the CoroutineExceptionHandler
should be placed within the ViewModelState
. Additionally, it should be combined with a StateFlow
for the Throwable, which becomes the State of the Throwable generated by the CoroutineExceptionHandler
.
// ViewModelState.kt
abstract class ViewModelState<STATE : State, INTENT : Intent>(
initialState: STATE
) : ViewModel() {
private val _throwableState: MutableStateFlow<Throwable?> = MutableStateFlow(null)
private val errorHandling = CoroutineExceptionHandler { coroutineContext, throwable ->
_throwableState.tryEmit(throwable)
}
private val safeScope = viewModelScope + errorHandling
// some code
}
The variable safeScope
acts as a scope generated from ViewModelScope
and CoroutineExceptionHandler
. Its function is to make all executions using safeScope
capture errors to be handled by CoroutineExceptionHandler
. Therefore, all lines defining viewModelScope
within ViewModelState
can be replaced with safeScope
.
Don’t forget, we can also expose Throwable State to a higher function that is useful for displaying an error in the view without causing a force close.
// ViewModelState.kt
abstract class ViewModelState<STATE : State, INTENT : Intent>(
initialState: STATE
) : ViewModel() {
private val _throwableState: MutableStateFlow<Throwable?> = MutableStateFlow(null)
private val errorHandling = CoroutineExceptionHandler { coroutineContext, throwable ->
_throwableState.tryEmit(throwable)
}
private val safeScope = viewModelScope + errorHandling
// expose the throwable state
fun catch(block: (Throwable) -> Unit) = safeScope.launch {
_throwableState
.filterNotNull()
.collectLatest { block.invoke(it) }
}
// some code
}
With this code, we can display errors using something like a snackbar.
// Counter.kt
fun Counter() {
val productRepository = remember { ProductRepository() }
val counterViewModel = rememberViewModel { CounterViewModel(productRepository) }
val counterState by counterViewModel.state.collectAsState()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
counterViewModel.catch {
scope.launch {
snackbarHostState.showSnackbar(it.message.orEmpty())
}
}
}
}
The result obtained is like this:
Handling Network Exception
The next question, how do we handle errors from the generated response? In a scenario where we want to transform a URL into:
https://footballstore.fly.dev/api/productrrt
With this URL, it results in a status code 404 with an error body like this:
{
"status" : false,
"message" : "HTTP 404 Not Found",
"data" : null
}
In this architecture, the displayed outcome is as follows:
From the logs and results displayed, we can conclude the following:
- The state returns the default value, not a failure value.
- The response error is not readable in the console.
These two points lead to the conclusion that the current architecture cannot handle error responses. While our expectations are:
- Error responses should appear in the console.
- We should be able to display the error message or response in the View.
The steps we can take involve at least two stages:
- The
ViewModelState
needs to be aware of the original class of the response generated by the Ktor Client (since we are using Ktor). - We need to create a dedicated
AsyncExecutor
for networking because the default reducer cannot handle errors in the network API. - We also need to create a specific exception for the network that includes status codes and error responses.
To ensure that the ViewModelState
has complete information about the response generated by Ktor, we can create a function that returns an HttpResponse
class instead of a response data class. Initially, our repository code looks like this:
suspend fun getProductResponse(): ProductResponse {
return client.get(URL) {
contentType(ContentType.Application.Json)
}.body()
}
It should be changed to something like this:
suspend fun getProductResponse(): HttpResponse {
return client.get(URL) {
contentType(ContentType.Application.Json)
}
}
This way, the getProductResponse()
function does not have direct knowledge of the response data but has knowledge of HttpResponse
, where the response data is still anonymous. To access the response data from this class, you need to use the body()
function with the appropriate data type.
Next, for error handling, we need to create an exception that can represent the status code and error response:
class ApiException(private val code: Int, private val errorResponse: Any) : Throwable() {
override val message: String
get() {
return "{\"code\": $code, \"error_response\": $errorResponse, \"message\": ${cause?.message}}"
}
}
In the above class, the message
function contains information about the code and error response, so even if the Throwable generated is not cast to ApiException
, all the information can still be read clearly.
The next step is to create an AsyncExecutor
for Ktor as a reducer. The AsyncExecutor
will determine if the response is a success, in which case it will send Async.Success<T>
and if not, it will send other Async
states. The KtorAsyncExecutor
class for Ktor looks like this:
class KtorAsyncExecutor<T: HttpResponse> : AsyncExecutor<T> {
override suspend fun reduce(execute: suspend () -> T): Flow<Async<T>> {
return flow {
val httpResponse = execute.invoke()
if (httpResponse.status == HttpStatusCode.OK) {
emit(Async.Success(httpResponse))
} else {
emit(Async.Failure(
ApiException(httpResponse.status.value, httpResponse.bodyAsText())
))
}
}.catch {
when (it) {
is ClientRequestException -> {
val apiException = ApiException(it.response.status.value, it.response.bodyAsText())
emit(Async.Failure(apiException))
}
is RedirectResponseException -> {
val apiException = ApiException(it.response.status.value, it.response.bodyAsText())
emit(Async.Failure(apiException))
}
is ServerResponseException -> {
val apiException = ApiException(it.response.status.value, it.response.bodyAsText())
emit(Async.Failure(apiException))
}
else -> {
emit(Async.Failure(it))
}
}
}.onStart {
emit(Async.Loading)
}
}
}
The explanation is that first, we need to determine whether the generated response is successful or HttpStatusCode.OK
. If it’s true, it emits Async.Success
and if it’s false, it emits Async.Failure
. In the catching flow, various classes need to be validated for their data types, which will affect the error types in Ktor:
- RedirectResponseException
for 3xx responses.
- ClientRequestException
for 4xx responses.
- ServerResponseException
for 5xx responses.
In the ViewModel implementation, the code also changes. We now need to cast the response body directly when the map
function is executed because the executor produces a block that contains an HttpResponse
, not a ProductResponse
as before.
private fun getProductSize() = viewModelScope.launch {
suspend {
productRepository.getProductResponse()
}.execute(
asyncExecutor = KtorAsyncExecutor() // <-- add KtorAsyncExecutor
) { response ->
val asyncProductSize = response.map {
it.body<ProductResponse>().data?.data?.size ?: 0
}
copy(asyncProductSize = asyncProductSize)
}
}
Now, you can run the project again and see if the error handling meets the expectations. To display the response in the console, simply change the logging level from LogLevel.INFO
to LogLevel.BODY
. The result looks like this:
Alright, that’s it for this short tutorial on how to imitate styles of Mavericks architecture for multiplatform Compose. You can further develop the patterns that have been explained on your own.
Closing
This is not the multiplatform version of Mavericks, but this pattern only adopts a small portion of the simple yet powerful Mavericks style. Mavericks itself does support multiplatform, but only in the mvrx-common
module and it's still experimental. Source: https://github.com/airbnb/mavericks/issues/558
The purpose of this writing is not to compare which pattern is better, but to offer an alternative to clean architecture in Kotlin Compose Multiplatform.
For the final project, you can check the repository:
Thank you for reading. This article original writing with Bahasa and translated to English by ChatGPT.