Production-ready MVVM for Kotlin Multiplatform — share everything, render natively.
Kova lets you put all of your app's logic — state, ViewModels, use cases, repositories, DI — in
commonMain, and keep only rendering code on each platform: Jetpack Compose on Android, SwiftUI on
iOS. Both UIs observe the same ViewModel with idiomatic, main-thread-safe APIs.
┌─────────────────────── shared (Kotlin) ────────────────────────┐
│ Repositories · Use cases · Koin DI · StateViewModel<S, A> │
│ state: StateFlow<S> actions: EventFlow<A> │
└──────────────┬────────────────────────────────┬────────────────┘
│ collectAsStateWithLifecycle() │ stateNative (generated)
┌──────▼──────┐ ┌──────▼──────┐
│ Compose │ │ SwiftUI │
│ (UI only) │ │ (UI only) │
└─────────────┘ └─────────────┘
Kotlin's Flow, StateFlow and coroutines don't survive the Objective-C bridge: Swift sees opaque
suspending machinery, callbacks land on random threads, and nothing is cancellable. SKIE fixes this
by post-processing the compiled framework with a compiler plugin. Kova takes a different, simpler
route with zero compiler magic:
- A tiny runtime (
kova-core):NativeFlow/NativeStateFlow/NativeSuspend— closure based, always delivered on the main thread, cancellable from Swift, and auto-cancelled with their owning scope.NativeStateFlow.valueis synchronous, so SwiftUI renders the first frame with real state. - A real ViewModel (
kova-viewmodel):StateViewModel<State, Action>built onandroidx.lifecycle.ViewModel(multiplatform). On Android it is a Jetpack ViewModel — config changes,viewModelScope, ComposeviewModel()all work. On iOS,ViewModelHostgives SwiftUI the same lifecycle contract (onCleared, scope cancellation) viadeinit. - One-shot events done right:
EventFlowbuffers actions while the UI is detached and delivers each exactly once — no lost snackbars on rotation, no replayed navigation on re-subscribe. - Codegen where it pays off (
kova-ksp): annotate a ViewModel with@NativeExportand every publicStateFlow/Flow/EventFlowproperty gets a generated<name>Nativeaccessor — only in the iOS source sets, only for what you export. No boilerplate, no framework-wide rewriting, fully debuggable generated Kotlin you can read. - A complete MVVM template (
template/): a working Tasks app where the Compose and SwiftUI screens are line-for-line mirrors over one shared ViewModel — clone it and rename.
| Kova | SKIE | moko-mvvm | |
|---|---|---|---|
| Swift-friendly flows | ✅ generated accessors | ✅ compiler plugin | ✅ manual wrappers |
| Main-thread delivery guaranteed | ✅ | ||
| Real androidx ViewModel base | ✅ multiplatform | — (interop only) | ❌ custom class |
| One-shot event channel built in | ✅ | ❌ | ❌ |
| SwiftUI lifecycle for ViewModels | ✅ ViewModelHost |
❌ | |
| Build impact | tiny KSP step | compiler plugin on every framework build | none |
| Full MVVM app template | ✅ | ❌ | ❌ |
| Artifact | What's inside |
|---|---|
in.sitharaj.kova:kova-core |
NativeFlow, NativeStateFlow, NativeSuspend, EventFlow, Cancellable |
in.sitharaj.kova:kova-viewmodel |
StateViewModel<S, A>, ViewModelHost |
in.sitharaj.kova:kova-annotations |
@NativeExport |
in.sitharaj.kova:kova-ksp |
KSP processor generating <property>Native accessors |
KovaSwift (Swift Package, this repo) |
SwiftUI bridge: ViewModelHolder, Observing, FlowState, asyncStream |
// build.gradle.kts (shared)
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
}
kotlin {
androidTarget()
listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach {
it.binaries.framework {
baseName = "Shared"
isStatic = true
export("in.sitharaj.kova:kova-core:0.1.0")
export("in.sitharaj.kova:kova-viewmodel:0.1.0")
export("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.1")
}
}
sourceSets.commonMain.dependencies {
api("in.sitharaj.kova:kova-core:0.1.0")
api("in.sitharaj.kova:kova-viewmodel:0.1.0")
api("in.sitharaj.kova:kova-annotations:0.1.0")
}
}
dependencies {
add("kspIosArm64", "in.sitharaj.kova:kova-ksp:0.1.0")
add("kspIosSimulatorArm64", "in.sitharaj.kova:kova-ksp:0.1.0")
add("kspIosX64", "in.sitharaj.kova:kova-ksp:0.1.0")
}data class CounterState(val count: Int = 0)
sealed interface CounterAction { data class Toast(val text: String) : CounterAction }
@NativeExport
class CounterViewModel : StateViewModel<CounterState, CounterAction>(CounterState()) {
fun increment() = setState { copy(count = count + 1) }
fun save() = intent { // coroutine in viewModelScope, errors -> onError()
repository.save(currentState.count) // suspend call
sendAction(CounterAction.Toast("Saved!"))
}
}val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
viewModel.actions.collect { action -> /* snackbar, navigation, ... */ }
}struct CounterScreen: View {
@StateObject private var holder = ViewModelHolder { CounterViewModel() }
var body: some View {
Observing(holder.viewModel.stateNative) { state in // generated accessor
Text("Count: \(state.count)")
}
.task {
for await action in stream(holder.viewModel.actionsNative) { /* toast */ }
}
}
}ViewModelHolder, Observing and FlowState live in the KovaSwift Swift Package (this
repo's Package.swift) — add it via SPM. It is framework-agnostic by design;
the only per-app piece is a ~40-line bridge file
(template/iosApp/iosApp/Kova/KovaBridge.swift)
that adapts your framework's NativeStateFlow/ViewModel types to it — copy it once and
you're done.
template/ is a complete, buildable MVVM app (Tasks CRUD):
shared/— model, repository, Koin modules,TasksViewModel(+ unit tests). All logic.androidApp/— Compose UI only.iosApp/— SwiftUI UI only, Xcode project already wired to build the Kotlin framework.
cd template
./gradlew :androidApp:assembleDebug # Android
open iosApp/iosApp.xcodeproj # iOS — just Run
./gradlew :shared:testDebugUnitTest # shared ViewModel testsThe template consumes Kova from source via includeBuild(..); in your own project depend on the
published artifacts and delete that line from settings.gradle.kts.
- State is a single immutable data class per screen, exposed as
StateFlow. - Everything UI-bound arrives on the main thread — subscriptions dispatch to
Dispatchers.Main.immediate, on both platforms, always. - Every subscription has two lifelines: the Swift/Compose side can cancel, and the
viewModelScopecancels on clear. Forgetting one never leaks. - Events are not state: one-shot effects go through
EventFlow, delivered exactly once, buffered while the UI is away. - Nullable flow values are rejected at codegen time (Objective-C generics can't represent them) — model absence inside the state class instead.
On macOS, scripts/publish.sh handles the whole flow with credentials stored in the Keychain (never on disk):
./scripts/publish.sh setup # one-time: Central Portal token + GPG key → Keychain
./scripts/publish.sh local # dry run to ~/.m2
./scripts/publish.sh # signed publish to Maven Central- Kotlin 2.2.21+, KSP 2.2.21-2.0.5, Gradle 8.14+, AGP 8.9+
- Xcode 15+, iOS 16+ deployment target (SwiftUI
NavigationStack) - JDK 17+
@NativeExportfor suspend functions → generatedNativeSuspendaccessors- KSP-generated per-app bridge file (retiring the copied
KovaBridge.swift) - SavedStateHandle support in
StateViewModel - watchOS / tvOS / macOS targets
- Kotlin 2.4 Swift Export backend (true Swift types, no Objective-C bridge) once stable
Apache 2.0 — see LICENSE.