Contents

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.

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:

  1. Boilerplate repetition. Every ViewModel needed the same @Published + for await + deinit cancel skeleton, differing only by type. 23 ViewModels meant 23 copies; changing the subscription logic meant touching 23 places.
  2. Dangling lifecycle. androidx.lifecycle.ViewModel has no host on iOS (no ViewModelStore), so viewModelScope coroutines were never cancelled with the view. Cleanup depended entirely on our hand-written deinit — reliable only if we remembered.
  3. Asymmetric behavior across platforms. On Android, collectAsStateWithLifecycle() is lifecycle-aware and stops collecting in the background; on iOS, a for await loop 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.

KMP-ObservableViewModel is a KMP port of androidx.lifecycle.ViewModel, maintained by rickclephas. It solves two things:

  • viewModelScope genuinely 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.

Step 1gradle/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 2shared/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.

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:

  1. Superclass: from androidx.lifecycle.ViewModel to com.rickclephas.kmp.observableviewmodel.ViewModel;
  2. State container: switch to MutableStateFlow(viewModelScope, initialValue) — this is the classic pitfall. Stick with the standard kotlinx.coroutines.flow.MutableStateFlow and everything works in Kotlin, but SwiftUI never receives a single update;
  3. Annotation: mark the StateFlow property with @NativeCoroutinesState so the plugin generates the Swift-observable bridge.

The change is mechanical: all 26 ViewModels were unified into this single pattern, no exceptions.

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 / @ObservedViewModel replace @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 — the deinit cancel lines finally go away.
  • @preconcurrency import. Under Swift 6 strict concurrency, Shared and KMPObservableViewModelSwiftUI trip region-isolation errors; @preconcurrency tells the compiler to treat them under legacy rules. One word, done.
  • KMP suspend funs surface as async throws in Swift, so call sites use try? await or do/catch.
  • Passing the same instance to child views: children declare @ObservedViewModel var viewModel: FooViewModel and wrap the value with ObservedViewModel(wrappedValue: viewModel). ContentView owns a single HomeViewModel and hands it to HomeView, the record screens, and everyone else — one state instance for the whole app.
BeforeAfter
iOS-side code@StateObject + StateHolder (~50 lines/VM)one @StateViewModel property
State subscriptionmanual for await loop + @Published mappingautomatic via property wrapper
Unsubscriptionmanual cancel() in deinitautomatic on view disappearance
Lifecycleno host on iOS, manual cleanupviewModelScope bound to the ViewModel

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 fileLinesKMP ViewModel that replaced it
ViewModels/Home/HomeViewModel.swift300HomeViewModel (360-line rewrite)
ViewModels/Auth/AuthViewModel.swift369AuthViewModel (254-line rewrite)
ViewModels/Onboarding/OnboardingStateHolder.swift158OnboardingViewModel
ViewModels/Profile/ReminderSettingsViewModel.swift100ReminderSettingsViewModel
ViewModels/Profile/GlycemicResponseViewModel.swift440GlycemicResponseViewModel (KMP gained friendly/risky calc)
ViewModels/Profile/HealthKitSettingsViewModel.swift170HealthDataSyncViewModel
ViewModels/FoodExercise/FoodRecordingViewModel.swift55FoodRecordingViewModel (new in KMP, PGRS mapping)
ViewModels/Recipe/AIRecipeScanViewModel.swift73RecipeViewModel
ViewModels/FoodExercise/FoodReferenceViewModel.swift120FoodExerciseViewModel
ViewModels/FoodExercise/CustomFoodReferenceViewModel.swift123AddCustomFoodViewModel
ViewModels/FoodExercise/ExerciseReferenceViewModel.swift85FoodExerciseViewModel
ViewModels/FoodExercise/CustomExerciseReferenceViewModel.swift152AddCustomExerciseViewModel
ViewModels/Exercise/ExerciseRecordViewModel.swift97FoodExerciseViewModel

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.

ViewModels can be shared, but platform SDKs can never enter KMP. This migration crystallized that principle into three reusable patterns.

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.

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 and async throws signatures (SKIE generates default implementations for the completion-handler variants);
  • KMP’s Dispatchers.Main is the main thread on iOS, so synchronous methods use MainActor.assumeIsolated to safely touch the @MainActor-isolated HealthKitRepository;
  • The protocol’s BloodSugarRecord means Shared.BloodSugarRecord (the KMP model), which shares a name with the local Swift model — the explicit Shared. 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.

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.

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.

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 init auto-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
        // ...
    }
}

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.

Three posts, three phases. In hindsight, it’s a remarkably clean “outside-in” sinking route:

PhaseDatePostWhat was sunkPlatform shape
Baseline + data layer2026-05Part 1Supabase Swift SDK → KMP CloudSource; DTOs/Repositories/UseCasesiOS calls the KMP data layer, everything else untouched
Business layer2026-06Part 2ViewModels (SKIE bridging), Room KMP, expect/actual, RevenueCat KMPiOS keeps a 50-line StateHolder shell
Observation layer2026-07~08this postKMP-ObservableViewModel direct observation, StateHolders deletedSwiftUI 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.

A few measurable facts about the post-migration project:

  1. All 26 ViewModels live in shared/src/commonMain/. The iOS ViewModels/ directory deleted in this phase never reappeared, and there’s no sign of “reverse migration” — the shared layer has kept growing steadily since.
  2. The entire iOS codebase has exactly two @StateObjects left: the Bluetooth glucometer adapter GlucoseDeviceManager and ToastManager. Neither is a business ViewModel — both are legitimate platform adapters / UI utilities.
  3. 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.
  4. 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.

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:

  1. Write the PRD;
  2. Add domain models and calculation use cases in shared/;
  3. 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()
    }
    // ...
}
  1. 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(...)
}
  1. 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.

  1. 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 UserAccess unified entry point added after the migration (membership/login checks) is a prime example: one resolveUserAccess() function, one shared decision for both platforms.

  2. 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.

  3. 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.

  4. 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.

Five lessons from the entire journey:

  1. 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.”
  2. 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.
  3. 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.
  4. 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.
  5. 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.


This post is based on SugarLite’s real migration journey.

Related Content