SugarLite's Incremental KMP Migration (Part 3): Direct Observation with KMP-ObservableViewModel — iOS ViewModels Retire
At the end of Part 2, our answer was “KMP ViewModel + SKIE + a 50-line StateHolder per ViewModel.” We claimed the cost was low — usually under 50 lines of bridging per ViewModel.
We later took that statement back.
The per-VM cost was indeed low, but it was a quantity tax — 23 ViewModels meant 23 StateHolders, each a copy-paste of @Published property mapping, a for await subscription loop, and a manual deinit cancellation. Worse, the KMP ViewModel lifecycle on iOS was left dangling: androidx.lifecycle.ViewModel has no ViewModelStore on iOS, so viewModelScope was never cancelled by the system — only our hand-written deinit cancel() calls kept things tidy. Miss one, and you leak a coroutine.
So when we came across rickclephas’s KMP-ObservableViewModel, we decided to make a third and final bridging upgrade: let SwiftUI observe KMP ViewModels directly, and delete every StateHolder.
This post documents the “zeroing-out” migration: how to configure the libraries, how the code changes, how we executed it in two batches, and the platform-boundary patterns that emerged along the way. As the closing post of the series, the second half also covers what the project looks like after the migration — how new features get built and what the migration bought us.
📱 The Math First: The StateHolder “Quantity Tax”
Part 2 showed our bridging shape:
// OnboardingStateHolder.swift — the Part 2 shape
@MainActor
final class OnboardingStateHolder: ObservableObject {
@Published var state: HomeUiState = .init()
private let viewModel = Shared.OnboardingViewModel()
private var collectionTask: Task<Void, Never>?
func startObserving() {
collectionTask = Task { [weak self] in
for await state in self?.viewModel.uiState ?? AsyncStream<HomeUiState>.empty {
guard let self else { return }
self.state = state
}
}
viewModel.loadData()
}
deinit {
collectionTask?.cancel()
}
}It was indeed “thin,” but it had structural problems:
- Boilerplate repetition. Every ViewModel needed the same
@Published+for await+deinit cancelskeleton, differing only by type. 23 ViewModels meant 23 copies; changing the subscription logic meant touching 23 places. - Dangling lifecycle.
androidx.lifecycle.ViewModelhas no host on iOS (noViewModelStore), soviewModelScopecoroutines were never cancelled with the view. Cleanup depended entirely on our hand-writtendeinit— reliable only if we remembered. - Asymmetric behavior across platforms. On Android,
collectAsStateWithLifecycle()is lifecycle-aware and stops collecting in the background; on iOS, afor awaitloop runs until its Task is cancelled. The same ViewModel behaved differently on the two platforms, which made debugging “why is iOS still refreshing” genuinely confusing.
StateHolders were tolerable, but they were a recurring tax. If a library could let SwiftUI observe StateFlow natively, the tax should be deleted.
🔧 The New Bridge: KMP-ObservableViewModel + NativeCoroutines
KMP-ObservableViewModel is a KMP port of androidx.lifecycle.ViewModel, maintained by rickclephas. It solves two things:
viewModelScopegenuinely works on iOS, with coroutine lifetimes bound to the ViewModel;- It provides
MutableStateFlow(viewModelScope, initialValue), binding state to the ViewModel lifecycle and making that state natively observable from Swift.
KMP-NativeCoroutines is the companion Gradle compiler plugin. Annotate a StateFlow<T> property with @NativeCoroutinesState and the plugin generates a Swift-observable bridge at compile time — SwiftUI property wrappers subscribe directly, and the for await loops plus @Published mapping disappear entirely.
Setup: Four Steps
Step 1 — gradle/libs.versions.toml:
[versions]
androidx-viewmodel = "2.10.0"
kmp-observableviewmodel = "1.0.5"
kmp-nativecoroutines = "1.0.4"
[libraries]
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-viewmodel" }
kmp-observableviewmodel-core = { module = "com.rickclephas.kmp:kmp-observableviewmodel-core", version.ref = "kmp-observableviewmodel" }
[plugins]
kmp-nativecoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "kmp-nativecoroutines" }Step 2 — shared/build.gradle.kts: plugin, dependencies, and framework export:
plugins {
alias(libs.plugins.kmp.nativecoroutines)
// ...
}
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
export(libs.androidx.lifecycle.viewmodel) // visible to iOS
export(libs.kmp.observableviewmodel.core)
}
}
sourceSets {
commonMain.dependencies {
api(libs.androidx.lifecycle.viewmodel)
api(libs.kmp.observableviewmodel.core)
// ...
}
}
}
// Keep generated intermediate types hidden — Swift only sees the annotated API
nativeCoroutines {
exposedSeverity = ExposedSeverity.NONE
}Step 3 — Add the Swift Package https://github.com/rickclephas/KMP-ObservableViewModel.git to the Xcode project, version exact 1.0.5, and link the KMPObservableViewModelSwiftUI product into the app target.
Step 4 — One bridging extension so every Shared.ViewModel subclass conforms to the KMP-ObservableViewModel protocol:
// Extensions/KMPObservableViewModel.swift
import KMPObservableViewModelCore
import Shared
extension Shared.ViewModel: @retroactive KMPObservableViewModelCore.ViewModel { }That’s the entire infrastructure. Everything after this was deleting code.
🏗️ The KMP-Side Pattern Upgrade: Three Changes
Using HomeViewModel as the example, before and after:
// Before: androidx ViewModel + plain MutableStateFlow
class HomeViewModel : androidx.lifecycle.ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
// iOS could only subscribe via SKIE AsyncSequence + a hand-written StateHolder
}// After: KMP-ObservableViewModel + @NativeCoroutinesState
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import com.rickclephas.kmp.observableviewmodel.MutableStateFlow
import com.rickclephas.kmp.observableviewmodel.ViewModel
import com.rickclephas.kmp.observableviewmodel.launch
class HomeViewModel : ViewModel() {
// Critical: must use the observableviewmodel MutableStateFlow(viewModelScope, initial)
private val _uiState = MutableStateFlow(viewModelScope, HomeUiState())
@NativeCoroutinesState
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
startObserving() // viewModelScope.launch { ... }
}
fun loadData() {
viewModelScope.launch { /* business logic */ }
}
}Three changes, all mandatory:
- Superclass: from
androidx.lifecycle.ViewModeltocom.rickclephas.kmp.observableviewmodel.ViewModel; - State container: switch to
MutableStateFlow(viewModelScope, initialValue)— this is the classic pitfall. Stick with the standardkotlinx.coroutines.flow.MutableStateFlowand everything works in Kotlin, but SwiftUI never receives a single update; - Annotation: mark the
StateFlowproperty with@NativeCoroutinesStateso the plugin generates the Swift-observable bridge.
The change is mechanical: all 26 ViewModels were unified into this single pattern, no exceptions.
📱 Swift Side: From a 50-Line StateHolder to a One-Line Property
After the migration, a SwiftUI view looks like this:
import SwiftUI
@preconcurrency import Shared
@preconcurrency import KMPObservableViewModelSwiftUI
struct FoodDetailView: View {
// @StateViewModel replaces @StateObject, owning the KMP ViewModel directly
@StateViewModel private var viewModel = Shared.FoodDetailViewModel()
var body: some View {
Text("\(viewModel.uiState.foodName)")
// ...
}
.task {
try? await viewModel.loadData() // suspend fun → async throws
}
}Key points:
@StateViewModel/@ObservedViewModelreplace@StateObject/@ObservedObject. These property wrappers from KMP-ObservableViewModel let SwiftUI subscribe to the@NativeCoroutinesState-generated observable property directly; subscriptions are cancelled automatically when the view disappears — thedeinit cancellines finally go away.@preconcurrency import. Under Swift 6 strict concurrency,SharedandKMPObservableViewModelSwiftUItrip region-isolation errors;@preconcurrencytells the compiler to treat them under legacy rules. One word, done.- KMP
suspend funs surface asasync throwsin Swift, so call sites usetry? awaitordo/catch. - Passing the same instance to child views: children declare
@ObservedViewModel var viewModel: FooViewModeland wrap the value withObservedViewModel(wrappedValue: viewModel).ContentViewowns a singleHomeViewModeland hands it toHomeView, the record screens, and everyone else — one state instance for the whole app.
| Before | After | |
|---|---|---|
| iOS-side code | @StateObject + StateHolder (~50 lines/VM) | one @StateViewModel property |
| State subscription | manual for await loop + @Published mapping | automatic via property wrapper |
| Unsubscription | manual cancel() in deinit | automatic on view disappearance |
| Lifecycle | no host on iOS, manual cleanup | viewModelScope bound to the ViewModel |
🔄 Two Batches, and the iOS ViewModels Directory Goes to Zero
With the bridge infrastructure in place, the migration ran in two batches to avoid a big bang.
Batch 1: five well-bounded ViewModels as pilots — AICreditViewModel, FoodDetailViewModel, AddMedicationRecordViewModel, EditBloodSugarRecordViewModel, EditExerciseRecordViewModel. This validated two things: @NativeCoroutinesState is production-stable, and deleting iOS bridge shells keeps view changes manageable.
Batch 2: full zeroing-out — 13 files, roughly 2,242 lines of Swift deleted:
| Deleted iOS file | Lines | KMP ViewModel that replaced it |
|---|---|---|
ViewModels/Home/HomeViewModel.swift | 300 | HomeViewModel (360-line rewrite) |
ViewModels/Auth/AuthViewModel.swift | 369 | AuthViewModel (254-line rewrite) |
ViewModels/Onboarding/OnboardingStateHolder.swift | 158 | OnboardingViewModel |
ViewModels/Profile/ReminderSettingsViewModel.swift | 100 | ReminderSettingsViewModel |
ViewModels/Profile/GlycemicResponseViewModel.swift | 440 | GlycemicResponseViewModel (KMP gained friendly/risky calc) |
ViewModels/Profile/HealthKitSettingsViewModel.swift | 170 | HealthDataSyncViewModel |
ViewModels/FoodExercise/FoodRecordingViewModel.swift | 55 | FoodRecordingViewModel (new in KMP, PGRS mapping) |
ViewModels/Recipe/AIRecipeScanViewModel.swift | 73 | RecipeViewModel |
ViewModels/FoodExercise/FoodReferenceViewModel.swift | 120 | FoodExerciseViewModel |
ViewModels/FoodExercise/CustomFoodReferenceViewModel.swift | 123 | AddCustomFoodViewModel |
ViewModels/FoodExercise/ExerciseReferenceViewModel.swift | 85 | FoodExerciseViewModel |
ViewModels/FoodExercise/CustomExerciseReferenceViewModel.swift | 152 | AddCustomExerciseViewModel |
ViewModels/Exercise/ExerciseRecordViewModel.swift | 97 | FoodExerciseViewModel |
When the dust settled, iosApp/BloodSugarApp/ViewModels/ no longer existed. The entire iOS codebase is left with exactly two @StateObjects — the Bluetooth glucometer adapter GlucoseDeviceManager and ToastManager — neither of which is a business ViewModel.
🧩 Platform Boundaries: Three Reusable Patterns
ViewModels can be shared, but platform SDKs can never enter KMP. This migration crystallized that principle into three reusable patterns.
1. AuthSignInHelper: Platform SDKs Only Produce Credentials
Authentication is the easiest place to accidentally drag platform SDKs into shared — Apple/Google Sign-In SDKs are platform-specific, and dragging them in would be a disaster. Our approach: compress the platform SDK’s job down to “produce an idToken”:
// AuthSignInHelper.swift — the platform SDK's only job
enum AuthSignInHelper {
/// Google SDK interaction for an idToken (needs a presenting VC)
@MainActor
static func googleIdToken(presenting viewController: UIViewController) async throws -> String {
let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: viewController)
guard let idToken = result.user.idToken?.tokenString else {
throw AuthError.accountInvalid
}
return idToken
}
/// Extract the idToken from an Apple authorization result
static func appleIdToken(from result: Result<ASAuthorization, Error>) throws -> String {
// ...
return idToken
}
}The business login (token validation, profile fetching, guest-data merging) all lives in Shared.AuthViewModel. iOS views hand the idToken to viewModel.loginWithIdToken(idToken) and are done. The auth state machine, error handling, and guest-migration logic are shared verbatim by both platforms.
2. HealthKitBridgeDelegate: Swift Implements a KMP Protocol
HealthKit was the heaviest migration. KMP defines the protocol, Swift implements it, and Koin injects it back into the shared layer:
// shared/iosMain — protocol defined on the KMP side
interface HealthKitBridgeDelegate {
fun isAvailable(): Boolean
suspend fun checkAuthorizationStatus(): HealthDataAuthorizationStatus
suspend fun requestAuthorization(): Boolean
suspend fun readBloodGlucoseRecords(startTime: Instant, endTime: Instant, uid: String): List<BloodSugarRecord>
}// HealthKitBridgeSetup.swift — Swift implements the KMP protocol
final class HealthKitBridgeDelegateImpl: HealthKitBridgeDelegate {
func isAvailable() -> Bool {
MainActor.assumeIsolated {
HealthKitRepository.shared.isHealthKitAvailable
}
}
func __readBloodGlucoseRecords(
startTime: KotlinInstant,
endTime: KotlinInstant,
uid: String
) async throws -> [Shared.BloodSugarRecord] {
let repository = await HealthKitRepository.shared
let startDate = Date(timeIntervalSince1970: TimeInterval(startTime.epochSeconds))
let endDate = Date(timeIntervalSince1970: TimeInterval(endTime.epochSeconds))
let records = try await repository.importBloodGlucoseRecords(from: startDate, to: endDate, uid: uid)
return records.map { $0.toKmpModel() } // local Swift model → KMP model
}
}Details worth noting:
- Kotlin/Native-exported protocols are conformed to in Swift with a
__prefix andasync throwssignatures (SKIE generates default implementations for the completion-handler variants); - KMP’s
Dispatchers.Mainis the main thread on iOS, so synchronous methods useMainActor.assumeIsolatedto safely touch the@MainActor-isolatedHealthKitRepository; - The protocol’s
BloodSugarRecordmeansShared.BloodSugarRecord(the KMP model), which shares a name with the local Swift model — the explicitShared.prefix is mandatory.
HealthKit permission prompts, record reading, and import dedup stay on the platform layer; HealthDataSyncViewModel only orchestrates. And since Android has its own implementation of the same protocol (Health Connect), both platforms behave identically.
3. DTO Single-Track + Bridge-Friendly Fields
HomeUiState previously maintained two parallel mirrors of Model and DTO. We cut the mirrors — UiState now holds DTOs directly, and Android converts with toModel() at its boundary:
data class TimelineEventDto(
val id: String,
val timestamp: Instant,
/** epoch millis of [timestamp], for direct Swift / ObjC consumption */
val timestampEpochMillis: Long,
val type: TimelineEventType,
// ...
)
data class HomeUiState(
val selectedDateEpochMillis: Long = Clock.System.now().toEpochMilliseconds(),
val bloodSugarUnitIsMgDl: Boolean = false, // enum bypass boolean
val selectedTimeRangeOrdinal: Int = 0, // enum bypass ordinal
val timelineEvents: List<TimelineEventDto> = emptyList(),
// ...
)Every value Swift consumes ships with a “bridge-friendly” twin: kotlin.time.Instant becomes an epochMillis Long, enums get boolean/ordinal companions. Swift renders the data with zero conversion and zero type gymnastics.
⚠️ Pitfall Collection
1. Swift 6 Strict Concurrency: Region-Isolation Errors
Adopting @StateViewModel brought a flood of SendingRisksDataRace and region-isolation errors — neither Shared nor KMPObservableViewModelSwiftUI carries Swift 6 concurrency annotations. The uniform fix: @preconcurrency import, applied consistently across every view in the project.
2. Auto-Refresh in init: Every Rebuild Fires a Network Request
During Batch 1, AICreditViewModel refreshed credits in init, so every view rebuild (navigation back, tab switch) triggered a network call. Fixed in two steps:
- Remove the
initauto-refresh; the view drives loading via.task { try? await viewModel.refresh() }; - Add a 30-second cooldown — automatic refreshes are throttled; only explicit user actions pass
force:
class AICreditViewModel : ViewModel() {
/** Min interval between auto-refreshes: avoids redundant requests on rebuild/navigation */
private val minRefreshIntervalMs = 30_000L
private var lastCreditRefreshAtMs = 0L
private fun shouldSkipAutoRefresh(lastRefreshAtMs: Long): Boolean {
val now = Clock.System.now().toEpochMilliseconds()
return now - lastRefreshAtMs < minRefreshIntervalMs
}
suspend fun refreshCredit(force: Boolean = false) {
val uid = currentUserId() ?: return
if (!force && shouldSkipAutoRefresh(lastCreditRefreshAtMs)) return
// ...
}
}3. Companion Static Maps: Shared Stale Data Across Instances
FoodExerciseUiState kept static PGRS/statistics caches in its companion object. Once multiple views created their own instances via @StateViewModel, they all shared the same static map — data written on screen A polluted screen B, and concurrent writes raced. Fix: static maps became instance fields, one per ViewModel instance.
🏁 Epilogue: The Shape of the Project After the Migration
Three posts, three phases. In hindsight, it’s a remarkably clean “outside-in” sinking route:
| Phase | Date | Post | What was sunk | Platform shape |
|---|---|---|---|---|
| Baseline + data layer | 2026-05 | Part 1 | Supabase Swift SDK → KMP CloudSource; DTOs/Repositories/UseCases | iOS calls the KMP data layer, everything else untouched |
| Business layer | 2026-06 | Part 2 | ViewModels (SKIE bridging), Room KMP, expect/actual, RevenueCat KMP | iOS keeps a 50-line StateHolder shell |
| Observation layer | 2026-07~08 | this post | KMP-ObservableViewModel direct observation, StateHolders deleted | SwiftUI observes KMP ViewModels directly |
The three phases correspond to three levels of sharing: data sharing → business sharing → state sharing. And with each level, the integration cost on top dropped: in Phase 1 iOS still wrote its own DTO conversions; in Phase 2 a 50-line bridge shell sufficed; in Phase 3 even the shell was gone.
Current State: The Code Shape After Migration
A few measurable facts about the post-migration project:
- All 26 ViewModels live in
shared/src/commonMain/. The iOSViewModels/directory deleted in this phase never reappeared, and there’s no sign of “reverse migration” — the shared layer has kept growing steadily since. - The entire iOS codebase has exactly two
@StateObjects left: the Bluetooth glucometer adapterGlucoseDeviceManagerandToastManager. Neither is a business ViewModel — both are legitimate platform adapters / UI utilities. - SwiftUI views uniformly use
@StateViewModel/@ObservedViewModel. The first line of any new view is@StateViewModel private var viewModel = Shared.XXXViewModel()— it’s become muscle memory. - Android Compose reuses the same ViewModels via
viewModel()+collectAsStateWithLifecycle(), zero extra bridging.
In code terms, the two platforms now differ in only two essential ways: the UI layer (SwiftUI vs Compose) and platform adapters (HealthKit vs Health Connect, Apple/Google Sign-In, Bluetooth, notifications). Business logic, state management, local persistence, membership logic, and sync policy — all of it exists exactly once.
New Feature Workflow: KMP-First Is Now the Default
The most convincing evidence that the migration is complete isn’t how many lines were deleted — it’s how new features get built.
Take the “Statistics Report” feature currently in development. It was started after the migration, and its path was:
- Write the PRD;
- Add domain models and calculation use cases in
shared/; - Write the KMP ViewModel — directly in the pattern from this post, no discussion needed:
// shared/src/commonMain/.../viewmodel/StatisticsReportViewModel.kt
data class StatisticsReportUiState(
val isLoading: Boolean = true,
val period: ReportPeriod = ReportPeriod.preset(ReportPeriodPreset.default),
val report: StatisticsReport? = null,
val isComparisonMode: Boolean = false,
val isLoggedIn: Boolean = false,
val isPremium: Boolean = false,
val errorMessage: String? = null,
)
class StatisticsReportViewModel : ViewModel() {
private val _uiState = MutableStateFlow(viewModelScope, StatisticsReportUiState())
@NativeCoroutinesState
val uiState: StateFlow<StatisticsReportUiState> = _uiState.asStateFlow()
init { refresh() }
fun selectPeriod(preset: ReportPeriodPreset) {
if (preset == ReportPeriodPreset.CUSTOM) return
_uiState.update { it.copy(period = ReportPeriod.preset(preset), errorMessage = null) }
refresh()
}
// ...
}- iOS writes only the view:
// iosApp/.../Views/Profile/StatisticsReportView.swift
struct StatisticsReportView: View {
@StateViewModel private var viewModel = StatisticsReportViewModel()
// Pure UI: render viewModel.uiState, call viewModel.selectPeriod(...)
}- Android writes only the view too:
// androidApp/.../ui/screens/profile/StatisticsReportScreen.kt
@Composable
fun StatisticsReportScreen(viewModel: StatisticsReportViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Pure UI: render uiState, call viewModel.selectPeriod(...)
}Notice that steps 4 and 5 involve zero discussion about what to share — the ViewModel and use cases are written in shared once, and both platform views just consume them. This is the post-migration routine: new features default to KMP-first; only UI and platform capabilities are platform-specific.
The Payoff: What This Migration Actually Bought
A single source of truth. Blood sugar dedup, timeline aggregation, unit conversion, PGRS computation, membership checks — exactly one implementation each. No more “iOS fixed the bug, Android forgot to sync” drift. The
UserAccessunified entry point added after the migration (membership/login checks) is a prime example: oneresolveUserAccess()function, one shared decision for both platforms.Behavioral consistency. The same ViewModel means iOS and Android pages behave identically: same loading states, same error handling, same sorting rules. UI tests only need to cover each platform’s view layer.
Development efficiency. Feature logic lands in KMP first and is instantly available on both platforms; platform capabilities are injected via delegates/helpers, so KMP never worries about platform details. The path from PRD to “works on both platforms” is measurably shorter.
Direct code metrics. This migration deleted ~2,242 lines of Swift ViewModel code in one shot, and the
ViewModels/directory vanished. Since then, nearly all new iOS code is views and platform bridges.
📌 Series-Wide Takeaways
Five lessons from the entire journey:
- Incremental, never big-bang. Data layer → business layer → state layer; each phase validated and shipped independently. Any step can be rolled back; you never end up with “half-migrated, broken project.”
- Bridging layers should be as thin as possible — and deleted when possible. StateHolder was a transition, not a destination. Every extra bridge layer is a quantity tax. Once a native solution like KMP-ObservableViewModel exists, zero it out without hesitation.
- Keep platform capabilities in adapters, injected back via protocols. AuthSignInHelper only produces idTokens; HealthKitBridgeDelegate is a KMP protocol implemented in Swift. Platform SDKs never enter
shared, while orchestration is fully shared — that’s the precondition for behavioral consistency. - Pick libraries by community maturity. SKIE, Room KMP, RevenueCat KMP, and KMP-ObservableViewModel are all battle-tested. In the KMP ecosystem, building your own bridge wheels is the biggest waste.
- Migration is the best opportunity to align both platforms. Every time a module sank, we also fixed the behavioral drift between platforms (the static-map sharing issue from this phase). Migration isn’t just moving code — it’s unifying behavior.
Back to the title of the first post: from a pure iOS app to Kotlin Multiplatform. The destination isn’t “both platforms have identical code” — it’s sharing everything worth sharing and keeping only what must be platform-specific. For a team of SugarLite’s size, this is the highest-value cross-platform shape we’ve found.
This post is based on the real migration journey of SugarLite. If you’re interested in KMP cross-platform development, feel free to download the app.
📚 Series Posts & References
- Part 1: SugarLite’s Incremental KMP Migration (Part 1): Sinking the Data Layer & Adapting CI/CD
- Part 2: SugarLite’s Incremental KMP Migration (Part 2): ViewModels, Local Storage & Platform Abstractions
- KMP-ObservableViewModel
- KMP-NativeCoroutines
This post is based on SugarLite’s real migration journey.
Related Content
If you feel that this article has been helpful to you, your appreciation would be greatly welcomed.
Sponsor