
If you’ve been writing Android apps for a while, Kotlin Flows probably feel like second nature. You know StateFlow for UI state, SharedFlow for one-off events, and you’ve long said goodbye to LiveData. But the moment you bring KMP into the picture, the bridge between Kotlin and Swift introduces a new set of challenges.
In this article, we’ll go from a quick recap of StateFlow vs SharedFlow, through the KMP bridge problem, all the way to a working shared ViewModel consumed natively on both Android (Jetpack Compose) and iOS (SwiftUI). We’ll also cover the real gotchas that don’t usually make it into the docs.
The quick lap: StateFlow vs. SharedFlow
Before we hit the KMP complexity, let’s align on the fundamentals.
StateFlow
StateFlow is a hot, stateful flow that always holds a current value. It behaves like an observable variable — any collector immediately receives the latest value upon subscription.
val lapCount: StateFlow<Int> = MutableStateFlow(0)
Use it for UI state: anything the screen needs to display and should survive re-subscriptions — loading state, data models, counters.
It always has a value (requires an initial value), emits only distinct values (no duplicate emissions), and gives new collectors the current value immediately.
SharedFlow
SharedFlow is a hot, event-based flow with no persistent state. It broadcasts events to multiple collectors, but only those actively listening at the time of emission receive it.
val pitStopAlert: SharedFlow<String> = MutableSharedFlow()
Use it for one-time events: navigation, error toasts, dialogs — anything that should fire once and not replay on re-subscription.
Unlike StateFlow, it has no initial value (unless you configure replay), doesn’t deduplicate values, and collectors only receive emissions that happen after they start collecting.
The one-sentence rule
Use
StateFlowwhen your UI needs to know something. UseSharedFlowwhen your UI needs to react to something.
The KMP problem: Flows don’t cross the bridge natively
When you compile a KMP module for iOS, Kotlin/Native comes into play — and Kotlin coroutines and Flows are not natively consumable in Swift.
Swift doesn’t understand StateFlow<T> or SharedFlow<T>. You can’t just call .collect {} from Swift. Expose a raw StateFlow from your shared module and Swift sees something close to a generic Any type — basically useless.
There are a few approaches to bridge this gap:
- KMP-NativeCoroutines — a library that generates Swift-friendly wrappers automatically
- SKIE (by Touchlab) — another powerful option for Swift/Kotlin interop
- Manual wrapping — writing your own
iosMainwrapper usingDisposableHandle
We’ll go with the manual wrapping approach. It’s more verbose, but it gives you full visibility into what’s happening — which matters when something breaks and you need to know why.
The architecture: shared ViewModel
Here’s the structure of our shared module:
shared/
├── commonMain/
│ └── PitStopViewModel.kt ← works for both platforms
└── iosMain/
└── PitStopViewModelWrapper.kt ← Swift-friendly bridge
commonMain: the shared ViewModel
androidx.lifecycle.ViewModel is no longer Android-only. Since lifecycle-viewmodel became a KMP artifact, you can extend it directly in commonMain and get viewModelScope for free on both platforms — no manual scope management needed.
Add the dependency to your version catalog and commonMain:
# libs.versions.toml
[versions]
lifecycleViewmodel = "2.8.0"
[libraries]
lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycleViewmodel" }
// build.gradle.kts (shared module)
commonMain.dependencies {
implementation(libs.lifecycle.viewmodel)
}
Now the ViewModel is clean and platform-agnostic:
// commonMain/PitStopViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class PitStopViewModel : ViewModel() {
// StateFlow: current lap count — always available, latest value
private val _lapCount = MutableStateFlow(0)
val lapCount: StateFlow<Int> = _lapCount.asStateFlow()
// SharedFlow: one-time pit stop alerts — only heard if you're tuned in
private val _pitStopAlert = MutableSharedFlow<String>()
val pitStopAlert: SharedFlow<String> = _pitStopAlert.asSharedFlow()
fun nextLap() {
_lapCount.value++
}
fun triggerPitStop(reason: String) {
viewModelScope.launch {
_pitStopAlert.emit("Pit stop! Reason: $reason")
}
}
fun reset() {
_lapCount.value = 0
}
}
We extend androidx.lifecycle.ViewModel directly in commonMain — no platform-specific code needed. viewModelScope is available out of the box and handles cancellation automatically on both platforms. StateFlow is backed by a MutableStateFlow with an initial value of 0; SharedFlow is backed by a MutableSharedFlow with no replay (default).
Android side: Jetpack Compose
Since PitStopViewModel lives in commonMain and extends androidx.lifecycle.ViewModel, we use it directly in Compose — no wrapper needed.
// androidApp/PitStopScreen.kt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun PitStopScreen() {
val viewModel = viewModel<PitStopViewModel>()
// StateFlow: collected as lifecycle-aware state
val lapCount by viewModel.lapCount.collectAsStateWithLifecycle()
// SharedFlow: collected as side-effect (LaunchedEffect)
var alertMessage by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
viewModel.pitStopAlert.collect { alert ->
alertMessage = alert
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Lap: $lapCount", style = MaterialTheme.typography.headlineLarge)
Spacer(modifier = Modifier.height(16.dp))
alertMessage?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
Button(onClick = { viewModel.nextLap() }) {
Text("Next Lap")
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.triggerPitStop("Tyre wear") }) {
Text("Trigger Pit Stop")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = { viewModel.reset() }) {
Text("Reset")
}
}
}
collectAsStateWithLifecycle() handles StateFlow — it respects the lifecycle and won’t collect in the background. LaunchedEffect(Unit) handles SharedFlow, collecting events and updating local state. A SnackbarHostState or a Channel are valid alternatives depending on your UX needs.
iOS side: the wrapper pattern
Swift can’t collect Kotlin Flows directly, so we write a thin wrapper in iosMain that subscribes to the Flow and calls back into Swift.
// iosMain/PitStopViewModelWrapper.kt
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class PitStopViewModelWrapper {
val viewModel = PitStopViewModel()
// Observe StateFlow: callback fires on every new value
fun observeLapCount(onChange: (Int) -> Unit) {
viewModel.lapCount
.onEach { onChange(it) }
.launchIn(viewModel.viewModelScope)
}
// Observe SharedFlow: callback fires on each event emission
fun observePitStopAlert(onAlert: (String) -> Unit) {
viewModel.pitStopAlert
.onEach { onAlert(it) }
.launchIn(viewModel.viewModelScope)
}
// IMPORTANT: cancel viewModelScope
fun dispose() {
viewModel.viewModelScope.cancel()
}
}
PitStopViewModelWrapper is what Swift interacts with. It instantiates PitStopViewModel directly (no manual scope needed), uses viewModel.viewModelScope for launching collectors, and calls viewModel.clear() for cleanup — which cancels viewModelScope internally.
Now the SwiftUI viewModel holder:
// iosApp/PitStopViewModelHolder.swift
import Foundation
import Shared // your KMP module name
@MainActor
class PitStopViewModelHolder: ObservableObject {
private let wrapper = PitStopViewModelWrapper()
@Published private(set) var lapCount: Int = 0
@Published private(set) var alertMessage: String? = nil
init() {
wrapper.observeLapCount { [weak self] count in
self?.lapCount = Int(truncating: count)
}
wrapper.observePitStopAlert { [weak self] alert in
self?.alertMessage = alert
}
}
func nextLap() {
wrapper.nextLap()
}
func triggerPitStop() {
wrapper.triggerPitStop(reason: "Tyre wear")
}
func reset() {
wrapper.reset()
}
deinit {
wrapper.dispose()
}
}
And the view
// iosApp/PitStopView.swift
import SwiftUI
struct PitStopView: View {
@StateObject private var holder = PitStopViewModelHolder()
var body: some View {
VStack(spacing: 16) {
Text("Lap: \(holder.lapCount)")
.font(.largeTitle)
if let alert = holder.alertMessage {
Text(alert)
.foregroundColor(.red)
}
Button("Next Lap") { holder.nextLap() }
.buttonStyle(.borderedProminent)
Button("Trigger Pit Stop") { holder.triggerPitStop() }
.buttonStyle(.bordered)
Button("Reset") { holder.reset() }
.buttonStyle(.bordered)
}
.padding()
}
}
The Swift ObservableObject pattern bridges Kotlin’s reactive model into SwiftUI’s state system. Kotlin callbacks update @Published properties, SwiftUI re-renders when those change, and deinit calls wrapper.dispose() to cancel the coroutine scope.
The gotchas you’ll actually hit
1. Threading: always use Dispatchers.Main
On iOS, UI updates must happen on the main thread. viewModelScope uses Dispatchers.Main by default, so you’re covered out of the box. But if you ever launch coroutines with a custom scope, make sure it’s also on Main:
// Safe — viewModelScope already uses Dispatchers.Main
viewModelScope.launch {
_pitStopAlert.emit("Pit stop!")
}
If you have CPU-heavy work, do it in withContext(Dispatchers.Default) inside the coroutine, but let the Flow emit back on Main.
2. Memory leaks: always call dispose()
Cleanup is as simple as calling viewModel.clear() — which is what dispose() does. Make sure it’s called from Swift’s deinit:
deinit {
wrapper.dispose()
}
Miss this and you’ll have coroutines running after the view is gone. It’s the kind of bug that doesn’t crash immediately — it just quietly eats memory.
3. SharedFlow replay: mind the default
By default, MutableSharedFlow() has replay = 0. A new collector that subscribes after an emission misses it entirely. That’s intentional for one-shot events — but if you want new collectors to receive the last N events, configure replay:
private val _pitStopAlert = MutableSharedFlow<String>(replay = 1)
Use this sparingly. Replaying navigation commands on re-subscription is a reliable way to introduce hard-to-trace bugs.
4. StateFlow distinctness on iOS
StateFlow skips duplicate emissions. Call _lapCount.value = 5 twice in a row and the second one is dropped. On Android, that’s usually fine. On iOS, it means your Swift callback won’t fire — which can be confusing if you’re expecting it to.
A note on architecture
The approach in this article keeps things intentionally simple. Exposing individual functions like nextLap() and triggerPitStop() directly on the ViewModel works fine for a demo, but in a real MVI setup you’d handle this differently.
In MVI, the ViewModel typically exposes a single dispatch(action: Action) function, where Action is a sealed class covering every possible user interaction:
sealed class Action {
data object NextLap : Action()
data class TriggerPitStop(val reason: String) : Action()
data object Reset : Action()
}
One-time events follow the same pattern — a sealed Event class emitted through SharedFlow:
sealed class Event {
data class PitStopAlert(val message: String) : Event()
}
This keeps the ViewModel’s public API minimal and makes the intent of every interaction explicit. The UI doesn’t need to know how something works — it just dispatches an action and reacts to state or events.
The manual wrapping pattern shown here applies equally to MVI — the structure of what crosses the bridge doesn’t change, just what’s inside it.
Wrapping up
StateFlow for persistent UI state: always holds a value, new collectors get it immediately. SharedFlow for events: fires and forgets, only active collectors receive it. The manual wrapper pattern in iosMain is more boilerplate than KMP-NativeCoroutines or SKIE, but it makes the mechanics visible — and visible mechanics are easier to debug.
Happy coding, and keep it flat out. 🏁
The full demo for this article is available on GitHub.
The KMP Bits app is available on App Store and Google Play — built entirely with KMP.
Comments
Loading comments...