
Production-ready MVVM: share entire app logic, main-thread-safe native-friendly flows, real ViewModel lifecycle, buffered one-shot events, and optional codegen generating native accessors.
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:
kova-core): NativeFlow / NativeStateFlow / NativeSuspend — closure
based, always delivered on the main thread, cancellable from Swift, and auto-cancelled with
their owning scope. NativeStateFlow.value is synchronous, so SwiftUI renders the first frame
with real state.kova-viewmodel): StateViewModel<State, Action> built on
androidx.lifecycle.ViewModel (multiplatform). On Android it is a Jetpack ViewModel — config
changes, viewModelScope, Compose viewModel() all work. On iOS, ViewModelHost gives SwiftUI
the same lifecycle contract (onCleared, scope cancellation) via deinit.EventFlow buffers actions while the UI is detached and delivers
each exactly once — no lost snackbars on rotation, no replayed navigation on re-subscribe.kova-ksp): annotate a ViewModel with @NativeExport and every
public StateFlow/Flow/EventFlow property gets a generated <name>Native accessor — only in
the iOS source sets, only for what you export. No boilerplate, no framework-wide rewriting, fully
debuggable generated Kotlin you can read.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.
StateFlow.Dispatchers.Main.immediate, on both platforms, always.viewModelScope cancels on clear. Forgetting one never leaks.EventFlow, delivered exactly once,
buffered while the UI is away.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 CentralNavigationStack)@NativeExport for suspend functions → generated NativeSuspend accessorsKovaBridge.swift)StateViewModel
Apache 2.0 — see LICENSE.
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:
kova-core): NativeFlow / NativeStateFlow / NativeSuspend — closure
based, always delivered on the main thread, cancellable from Swift, and auto-cancelled with
their owning scope. NativeStateFlow.value is synchronous, so SwiftUI renders the first frame
with real state.kova-viewmodel): StateViewModel<State, Action> built on
androidx.lifecycle.ViewModel (multiplatform). On Android it is a Jetpack ViewModel — config
changes, viewModelScope, Compose viewModel() all work. On iOS, ViewModelHost gives SwiftUI
the same lifecycle contract (onCleared, scope cancellation) via deinit.EventFlow buffers actions while the UI is detached and delivers
each exactly once — no lost snackbars on rotation, no replayed navigation on re-subscribe.kova-ksp): annotate a ViewModel with @NativeExport and every
public StateFlow/Flow/EventFlow property gets a generated <name>Native accessor — only in
the iOS source sets, only for what you export. No boilerplate, no framework-wide rewriting, fully
debuggable generated Kotlin you can read.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.
StateFlow.Dispatchers.Main.immediate, on both platforms, always.viewModelScope cancels on clear. Forgetting one never leaks.EventFlow, delivered exactly once,
buffered while the UI is away.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 CentralNavigationStack)@NativeExport for suspend functions → generated NativeSuspend accessorsKovaBridge.swift)StateViewModel
Apache 2.0 — see LICENSE.