Harnessing the Trifecta of State: Kotlin Multiplatform — Part 4/4

Christian Gaisl
6 min readSep 27, 2023

--

This is the fourth and final article in my series on state management on mobile devices and the Trifecta of State. In my previous articles, we discussed the concept of the Trifecta of State and explored example implementations on Android using Jetpack Compose and on iOS using SwiftUI. In this article, we will explore using a single state modifier across multiple platforms. This can reduce code duplication and cut down on tests and test maintenance.

The Trifecta of State

In my previous article, we introduced the concept of the Trifecta of State. It basically describes breaking down a mobile screen into three things: state, actions, and side effects.

Example of breaking down a screen into the Trifecta of State: state, actions, and side effects.

The Trifecta of State entails breaking a screen’s code into a state consumer (View, Composable, Fragment, etc.) and a state modifier (ViewModel, Controller, Presenter, etc.). The state consumer should focus on how a screen looks and behaves, whereas the state modifier focuses on what to display. This makes code easy to reason about and testable.

The Trifecta of State. The State Consumer consumes state and side effects and produces action. The State Modifier consumes actions and produces state and side effects.

Code Duplication

Those who have read both my articles about Compose and SwiftUI might have noticed that the state-modifying/ViewModel code between the two platforms was functionally identical. If there’s one thing software developers hate, it’s to repeat themselves. Code duplication can lead to longer development times and frustrating maintenance. Our goal for this article is to consolidate the state-modifying code, ViewModels, in our case, into a single codebase and use it both in Android and iOS.

Kotlin Multiplatform

Kotlin Multiplatform is a technology that allows us to use Kotlin code on multiple platforms, Android and iOS in our case. What it boils down to is that we can write code in Kotlin and, with some limitations, call that code from Swift as if it was written natively. This lets us write our ViewModel in Kotlin once and use it on both platforms. Setting up a Kotlin Multiplatform project and including its resulting framework in our iOS app is sadly out of the scope of this article, but you can check out the accompanying code for this article on GitHub for guidance.

Android

Kotlin is already the primary language on Android. This means we can take our existing ViewModel and make it compatible with Kotlin Multiplatform. This entails replacing the only platform-specific dependency, the Android ViewModel dependency, with a Kotlin multiplatform-compatible dependency. We are going to use Rick Clephas’ KMM-ViewModel library as a substitute. On Android, this library is simply a Kotlin Multiplatform abstraction for the Android ViewModel, meaning the rest of our code can stay identical.

In the code example below, we can see that by slightly changing a few lines of code, our ViewModel can now run on multiple platforms with Kotlin Multiplatform. The rest of our code, most importantly our state-consuming code, stays untouched!

Changes to the ViewModel on Android when moving it into a shared Kotlin Multiplatform library.

Our new Kotlin Multiplatform ViewModel:

package at.cgaisl.kmparticle.common

import com.rickclephas.kmm.viewmodel.KMMViewModel
import com.rickclephas.kmm.viewmodel.MutableStateFlow
import com.rickclephas.kmm.viewmodel.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch

data class PhoneDialerScreenState(
val username: String = "",
val phoneNumber: String = "",
)


interface PhoneDialerScreenActions {
fun inputPhoneNumber(number: String)
fun onDialButtonPress()
}

sealed class PhoneDialerScreenSideEffect {
data class Dial(val phoneNumber: String) : PhoneDialerScreenSideEffect()
}

class PhoneDialerScreenViewModel : KMMViewModel(), PhoneDialerScreenActions {
val state = MutableStateFlow(viewModelScope, PhoneDialerScreenState())
val sideEffects = MutableSharedFlow<PhoneDialerScreenSideEffect>()

fun loadUsername() {
// pretend we're loading the username from a database
state.value = state.value.copy(username = "Christian")
}

override fun inputPhoneNumber(number: String) {
state.value = state.value.copy(phoneNumber = number)
}

override fun onDialButtonPress() {
viewModelScope.coroutineScope.launch {
sideEffects.emit(PhoneDialerScreenSideEffect.Dial(state.value.phoneNumber))
}
}
}

iOS

To be able to use our Multiplatform ViewModel in SwiftUI, we need to add the KMM-ViewModel using SwiftPackage Manager or Cocoapods to our iOS project. This gives us access to the @StateViewModel annotation, which we will use instead of the @StateObject annotation in our code. What this does, in conjunction with the library’s wrapper of StateFlow, is update our UI whenever our ViewModel’s state StateFlow gets updated.

Vanilla Kotlin Multiplatform’s translation of Kotlin code to Swift works well for simple types. However, there is a subset of functionality that is not as straightforward to translate, namely, Kotlin sealed classes and Flows. The closest thing Swift has to those constructs are enums with associated values and AsyncSequences.

Luckily for us, Touchlab recently released a new Kotlin compiler plugin, SKIE. The plugin converts Kotlin sealed classes and Flows to their closest equivalent in Swift for us. This plugin is the closest thing to magic I’ve seen in a while. Simply including the compiler plugin in the Gradle file of our shared Kotlin module makes using Kotlin flows and sealed classes in Swift as easy as using native Swift code.

With our two changes (KMM-ViewModel Swift package and the SKIE Kotlin compiler plugin), we are ready to use our common Kotlin ViewModel in our SwiftUI code:

struct PhoneDialerScreen: View {
@StateViewModel var viewModel = PhoneDialerScreenViewModel() // KMM-ViewModel annotation

var body: some View {
PhoneDialerScreenContent(
state: viewModel.state.value, // enabled by SKIE plugin
actions: viewModel
)
.task {
viewModel.loadUsername()

for await sideEffect in viewModel.sideEffects { // enabled by SKIE plugin
switch onEnum(of: sideEffect) { // enabled by SKIE plugin
case let .dial(data):
if let url = URL(string: "tel://\(data.phoneNumber)"), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
}
}
}
}

struct PhoneDialerScreenContent: View {
/* unchanged */
}
Minimal changes to SwiftUI View when switching from native ViewModel to shared Kotlin Multiplatform ViewModel.

Testing

Having half as many ViewModels to write also means having half as many tests to write and maintain. Since we started with an already existing Android ViewModel, we can use our existing example Test with minimal changes. JUnit is only available on JVM platforms, so we need to replace it with Kotlin Test.

import app.cash.turbine.test
import at.cgaisl.kmparticle.common.PhoneDialerScreenSideEffect
import at.cgaisl.kmparticle.common.PhoneDialerScreenViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals

class PhoneDialerViewModelTest {
@OptIn(ExperimentalCoroutinesApi::class)
@BeforeTest
fun setup() {
Dispatchers.setMain(StandardTestDispatcher())
}

@Test
fun phoneDialerScreenViewModelTest() = runTest {
val viewModel = PhoneDialerScreenViewModel()

// Capture side effects
viewModel.sideEffects.test {

// Trigger action
viewModel.loadUsername()

// Assert state
assertEquals("Christian", viewModel.state.value.username)

// Trigger action
viewModel.inputPhoneNumber("1234567890")

// Assert state
assertEquals("1234567890", viewModel.state.value.phoneNumber)

// Trigger action
viewModel.onDialButtonPress()

// Assert side effects
assertEquals(PhoneDialerScreenSideEffect.Dial("1234567890"), awaitItem())
}
}
}
Minimal changes when porting our ViewModel test from Android to Kotlin Multiplatform.

Conclusion

The concepts of the Trifecta of State are platform agnostic. When splitting our UI code into a State Consumer and State Modifier, most of the platform dependencies, mainly the UI framework itself, live in the State Consumer. The State Modifier for a given screen is functionally identical across platforms. Depending on the project, it makes a lot of sense to write the State Modifier in Kotlin Multiplatform and share the code across platforms. In our example, we used an example screen on Android and iOS, but the concept is generalizable to other platforms!

The accompanying code for this article can be found on my GitHub: https://github.com/cgaisl/StateKmpArticle

This article concludes our four-part series on the Trifecta of State. If you haven’t yet, give my article on the general concept and the example implementations on Android and iOS a read.

Enjoyed this article? Feel free to give me a clap 👏, write a comment, and follow me here on Medium to catch all my latest articles. Thanks 👋

--

--