Cross-platform development significantly reduces overhead for both developers and organizations. While React Native leveraged web expertise and Flutter introduced a unified UI for multi-platform delivery, Kotlin Multiplatform (KMP) represents the next evolution. KMP offers the flexibility of Compose for shared UI, while uniquely allowing developers to implement native interfaces where maximum performance and stability are required—all while maintaining a single shared logic base in Kotlin.
Finding comprehensive resources for Kotlin Multiplatform (KMP) using Native UI can be challenging, so this series aims to bridge that gap.
We will build a production-ready foundation using a modular Clean Architecture—the industry standard for scalable apps. Our roadmap covers the essential pillars of modern development:
- Dependency Injection,
- Networking,
- and Architecture.
In this article, we will test the dependency injection setup by injecting a view model for iOS and Android. Be sure to follow the previous article for setting up Koin di.
View model library
- let's start by updating the shared module
shared/build.gradle.kts
...
kotlin {
...
sourceSets {
commonMain.dependencies {
...
implementation(libs.androidx.lifecycle.viewmodelCompose)
}
...
}
}
It should already be in the toml file, if not, you can add it
[versions]
androidx-lifecycle = "2.9.6"
[libraries]
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
Let's create a simple view model to make sure DI works as expected
A simple shared ViewModel
In the shared module, we create a view model.
// shared/kotlin/home/HomeViewModel.kt
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class HomeViewModel : ViewModel() {
private val _state = MutableStateFlow("initial state")
val state: StateFlow<String> = _state.asStateFlow()
fun updateString(newString: String) {
_state.value = newString
}
}
View models usage in Android
We will update the App.kt to test our new view model.
// composeApp/kotlin/App.kt
...
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.irsc.kotlinnativedemo.home.presentation.HomeViewModel
kotlinnativedemo.composeapp.generated.resources.compose_multiplatform
import org.koin.java.KoinJavaComponent.inject
@Composable
@Preview
fun App() {
val viewModel: HomeViewModel by inject(HomeViewModel::class.java)
val state by viewModel.state.collectAsStateWithLifecycle()
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = {
showContent = !showContent
viewModel.updateString("new string")
}) {
Text("Click me!")
}
Text(state)
...
}
}
}
Clicking the button should update the state, and the state flow should update the text under the button. Now we are sure the DI works in Android, the easy one, next is the iOS...
View models usage in iOS
Using view models in iOS is more challenging; the Observable class is actually the iOS ViewModel, but it is kinda tricky mapping them, according to this we have two options
- KMP-NativeCoroutines: the recommended one for now
- SKIE: I think it is better, but it does not support Kotlin 2.3.20; this may have changed by the time you read this article. I should create another article for it by the time.
KMP-NativeCoroutines libraries setup
- We start by adding the plugins to the toml file
[versions]
ksp = "2.3.6"
nativecoroutines = "1.0.2"
[plugins]
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kmp-nativecoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "nativecoroutines" }
make sure to sync after every toml file update to help the IDE
- Next, we update the root project
build.gradle.kts
// build.gradle.kts
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
...
alias(libs.plugins.devtools.ksp) apply false
alias(libs.plugins.kmp.nativecoroutines) apply false
}
- Then, we update the shared module
build.gradle.kts
// shared/build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.koin.compiler)
alias(libs.plugins.devtools.ksp) // new
alias(libs.plugins.kmp.nativecoroutines) // new
}
...
- and finally, the shared module sourceSets
// shared/build.gradle.kts
kotlin {
// ...
sourceSets{
all {
languageSettings {
optIn("kotlin.experimental.ExperimentalObjCName")
optIn("kotlin.time.ExperimentalTime")
}
}
// ...
}
}
Nice, this should work, let's build the project ...
> Task :shared:compileKotlinIosSimulatorArm64 FAILED
error: org.jetbrains.kotlin.cli.jvm.plugins.PluginCliParser$PluginProcessingError: Plugin com.rickclephas.kmp.nativecoroutines.compiler.KmpNativeCoroutinesCompilerPluginRegistrar is incompatible with the current version of the compiler.
error: org.jetbrains.kotlin.cli.jvm.plugins.PluginCliParser$PluginProcessingError: Plugin com.rickclephas.kmp.nativecoroutines.compiler.KmpNativeCoroutinesCompilerPluginRegistrar is incompatible with the current version of the compiler.
org.jetbrains.kotlin.cli.jvm.plugins.PluginCliParser$PluginProcessingError: Plugin com.rickclephas.kmp.nativecoroutines.compiler.KmpNativeCoroutinesCompilerPluginRegistrar is incompatible with the current version of the compiler.
Caused by: java.lang.AbstractMethodError: Missing implementation of resolved method 'abstract java.lang.String getPluginId()' of abstract class org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar.
Caused by: java.lang.AbstractMethodError: Missing implementation of resolved method 'abstract java.lang.String getPluginId()' of abstract class org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar.
Those errors confused me a lot. Finding error causes in 3 platforms is kinda ugly. In short, the problem is in our start, the Kotlin version. let's update it.
[versions]
...
kotlin = "2.3.20"
...
Syncing and building again should resolve the error.
KMP-NativeCoroutines shared code update
We should inform the NativeCoroutines of our public state flow, there are multiple options like @NativeCoroutines and @NativeCoroutinesState. Personally, I suggest to always use NativeCoroutinesState. Let's update our view model class
// shared/kotlin/home/HomeViewController.kt
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
class HomeViewModel : ViewModel() {
private val _state = MutableStateFlow("initial state")
@NativeCoroutinesState // new
val state: StateFlow<String> = _state.asStateFlow()
fun updateString(newString: String) {
_state.value = newString
}
}
Great, we need to update the iOS native project next ...
KMP-NativeCoroutines iOS project update
We need to open the iOS project in Xcode, choose to add a package dependency
look for
https://github.com/rickclephas/KMP-NativeCoroutines.git
and install KMPNativeCoroutinesAsync, KMPNativeCoroutinesCombine, and KMPNativeCoroutinesCore and finally click add packages
Nice, now we can use KMP-NativeCoroutines in iOS, let's go back to Android Studio.
PS. I know you tried to build it in Xcode and faced No such module 'Shared', we will solve this in another article
The ViewModel in Swift UI
I wanted to map ViewModel directly to @Observable Swift class, but it is still work in progress, for now we will wrap it with ObservableObject. Before that, we need to create a DI helper class
// shared/src/iosMain/kotlin/di/KoinHelper.kt
import com.irsc.kotlinnativedemo.home.presentation.HomeViewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class KoinHelper : KoinComponent {
fun homeViewModel(): HomeViewModel = get()
}
Time to create our own ObservableObject
import Foundation
import Shared
import KMPNativeCoroutinesCombine
import Combine
class HomeViewModelWrapper : ObservableObject {
@Published var lastUpdated = Date()
let viewModel: HomeViewModel = KoinHelper().homeViewModel()
private var cancellables = Set<AnyCancellable>()
init() {
// Observe the flow, but we don't need to manually copy every field to Swift anymore
createPublisher(for: viewModel.stateFlow)
.receive(on: DispatchQueue.main)
.sink { _ in } receiveValue: { [weak self] _ in
// Just trigger a refresh; the View will read from viewModel.state directly
self?.lastUpdated = Date()
}
.store(in: &cancellables)
}
}
Let's point out a few points here
- The
lastUpdatedfield is used to trigger UI reload only. - We read data and called methods directly from the
viewModel. - The
statefield in the view-model is reflected asstateFlowin Swift, because we are using@NativeCoroutinesState; it would be different if we used@NativeCoroutines. - This
ObservableObjectclass blueprint should work for any type of view model - I still think an @observable class would be better, but I couldn't find the best way to deploy it yet.
Finally, let's test it in the ContentView
import SwiftUI
import Shared
struct ContentView: View {
@State private var showContent = false
@StateObject private var wrapper = HomeViewModelWrapper()
var body: some View {
VStack {
Button("Click me!") {
withAnimation {
showContent = !showContent
wrapper.viewModel.updateString(newString:"new string")
}
}
Text(wrapper.viewModel.state)
if showContent {
VStack(spacing: 16) {
Image(systemName: "swift")
.font(.system(size: 200))
.foregroundColor(.accentColor)
Text("SwiftUI: \(Greeting().greet())")
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding()
}
}
That is it, it wasn't too simple for iOS, I know, yet fun and reusable, I hope
We are fixing No such module 'Shared' for Xcode project next time, so
Stay tuned


Top comments (0)