A Swift-first, lifecycle-safe observation bridge for Kotlin Multiplatform ViewModels, SKIE, KMP-NativeCoroutines, and SwiftUI.
@KMPStateObject private var profile = ProfileViewModel()
var body: some View {
ProfileContent(
state: $profile.profileState,
searchText: $profile.searchText,
retry: profile.retry
)
}No shadow Swift ViewModel. No copied business state. No runtime reflection, swizzling, selector discovery, or generated source file.
Evaluate it in five minutes: install the package, explore the DailyPulse app, or read when to choose this bridge.
Kotlin remains the authoritative source of truth while SwiftUI receives native values, bindings, ownership semantics, and precise dependencies.
| Capability | Behavior |
|---|---|
| Native ownership | @KMPStateObject, @KMPObservedObject, and @KMPEnvironmentObject mirror SwiftUI’s ownership language |
| Field-level dependencies | On iOS 17+, only views that read an emitted projected field are invalidated |
| Shared collection | Macro-configured wrappers share one static collector set per Kotlin model |
| Native projection | $viewModel.state returns the current Swift value without exposing .value |
| Safe bindings | Writable Kotlin exports produce Binding; read-only StateFlows remain read-only |
| Deterministic lifetime | Collection, callback, Combine, and NativeFlow cancellation follow SwiftUI identity storage |
| Exporter isolation | SKIE and NativeCoroutines APIs live in separate package products |
| Compile-time configuration | Macros validate imported ViewModel types and state key paths |
The bridge never stores a second copy of emitted business state:
- Kotlin
StateFlowowns the current value. - SKIE exposes synchronous value access and an
AsyncSequence. - A weak per-model registry shares one observation hub across wrappers.
- Equality filtering rejects consecutive duplicates.
- Coalescing unions all dependencies changed in the same main-actor turn.
- SwiftUI reevaluates views that read the affected projected field.
Direct model consumers intentionally receive global invalidation. Custom adapters without a key path also invalidate globally because the affected field cannot be identified safely.
Add this repository through Xcode’s Package Dependencies interface:
https://github.com/sonmbol/KMPObservableBridge.git
Link exactly one primary integration product:
| Project setup | Product |
|---|---|
| SKIE StateFlow | KMPObservableBridgeSKIE |
| KMP-NativeCoroutines | KMPObservableBridgeNative |
| Callbacks, Combine, or custom adapters | KMPObservableBridge |
The core and Native products do not expose SKIE symbols.
Declare this once in the application module when using SKIE:
import shared
import KMPObservableBridgeSKIE
extension SkieSwiftStateFlow: @retroactive KMPValueProperty {}Place the declaration beside the feature that owns the ViewModel:
@KMPObservable(
ProfileViewModel.self,
fields: \.profileState, \.permissionsState
)
extension ProfileViewModel: @retroactive KMPStaticallyObservable {}The fields are ordinary Swift key paths. Renaming a Kotlin export or selecting an incompatible property fails at compile time.
Swift macros cannot inspect members of imported Kotlin classes, so fields must be listed explicitly. This avoids runtime reflection and build-generated Swift files.
Own the ViewModel for one SwiftUI identity:
struct ProfileScreen: View {
@KMPStateObject(
wrappedValue: ProfileViewModel(),
dispose: { $0.clear() }
)
private var profile
var body: some View {
ProfileContent(viewModel: profile)
}
}Observe a model owned elsewhere:
struct ProfileContent: View {
@KMPObservedObject private var profile: ProfileViewModel
init(viewModel: ProfileViewModel) {
_profile = KMPObservedObject(viewModel)
}
var body: some View {
Text($profile.profileState.title)
}
}Inject the same store through the environment without creating another subscription:
ProfileContent()
.kmpEnvironmentObject($profile)
struct ProfileContent: View {
@KMPEnvironmentObject private var profile: ProfileViewModel
}The unprojected property remains the original Kotlin object. Use it for actions:
profile.retry()
profile.selectArticle(id: article.id)The projected store returns native current values:
Text($profile.messageState) // String
ProgressView(value: $profile.progressState) // Double
if $profile.loadingState { // Bool
ProgressView()
}A writable Kotlin export produces a native binding:
TextField("Search", text: $profile.searchText)A read-only StateFlow cannot form a WritableKeyPath, so an unsafe binding
cannot compile. Change immutable state through Kotlin actions.
profile.retry() // Kotlin action
$profile.messageState // String
$profile.loadingState // Bool
$profile.countState // Int
$profile.searchText // Binding<String>
$profile.rawModel // Original Kotlin objectMacros are optional when observation is configured at the wrapper:
@KMPStateObject(state: \.profileState)
private var profile = ProfileViewModel()
@KMPObservedObject(
profile,
states: \.profileState, \.permissionsState
)
private var profileKMPState supports:
- SKIE
AsyncSequenceandStateFlow - KMP-NativeCoroutines
NativeFlow - Combine publishers
- Callback APIs with explicit cancellation
- Custom adapters
- Equatable projections for measured hot paths
@KMPObservedObject(
profile,
state: \.profileState,
changes: .field(\.isLoading)
)
private var profile@KMPStateObject stores its coordinator in SwiftUI identity storage. Moving
or conditionally replacing the view follows normal StateObject lifetime
rules. Visual onDisappear is not treated as destruction.
All wrappers observing the same model identity lease the same hub. The final lease cancels its underlying collectors.
An externally owned wrapper cancels its old lease before observing a new model. Generation tokens suppress emissions racing from the previous model.
SKIE collection, equality validation, dependency delivery, coalescing, and
SwiftUI invalidation remain on MainActor. Foreign callbacks cross actors
once before entering the bridge.
| Platform generation | Invalidation model |
|---|---|
| iOS 17+, macOS 14+, tvOS 17+, watchOS 10+ | Lazy field-level Observation dependencies |
| Earlier supported systems | Correct ObservableObject.objectWillChange fallback |
.coalesced is the default. It schedules at most one flush per main-actor turn
while preserving every changed dependency:
@KMPStateObject private var profile = ProfileViewModel()Event-sensitive consumers can request every accepted emission:
@KMPStateObject(updatePolicy: .immediate)
private var profile = ProfileViewModel()Immediate mode does not allocate a dependency set per emission.
The DailyPulse example contains:
- Macro-declared SKIE StateFlow observation
StateObject,ObservedObject, and environment ownership- Native writable bindings
- KMP-NativeCoroutines
- Callback cancellation
- Combine publisher observation
- Injector-owned ViewModels
- Loading, error, empty, populated, and dark-mode previews
Every example uses a thin live bridge container around a pure SwiftUI presentation view:
KMP ViewModel
↓ thin observation container
Native Swift values + Binding + action closures
↓
Pure SwiftUI presentation
Preview fixtures contain only Swift values. They do not initialize Koin, allocate Kotlin ViewModels, start coroutines, collect flows, or perform network work.
Reference measurements are committed in Benchmarks/RESULTS.md. On the documented M3 Pro release-build run:
| Scenario | Work | Mean |
|---|---|---|
| Immediate delivery | 10,000 emissions | ~0.007 s |
| Store lifecycle | 1,000 create/teardown cycles | ~0.005 s |
| Shared static setup | 1,000 setup/teardown cycles | ~0.007 s |
These are local reference measurements, not universal performance claims. Run the suite on target hardware before setting capacity thresholds.
- Swift 5.9+
- iOS 15+
- macOS 11+
- tvOS 14+
- watchOS 7+
- An exported asynchronous state source, such as SKIE or KMP-NativeCoroutines
Toolchain-specific package manifests select matching SwiftSyntax versions:
| Swift | SwiftSyntax |
|---|---|
| 5.9 | 509 |
| 6.0 | 600 |
| 6.1 | 601 |
| 6.2 | 602 |
All manifests expose the same products and public API.
Release validation covers:
- Unit, lifecycle, cancellation, macro, and dependency-granularity tests
- Strict concurrency with warnings as errors
- Public API compatibility against
1.1.0 - Swift 5.9–6.2 toolchain builds
- iOS device and Apple simulator builds
- SKIE/native symbol-isolation audit
- Real Gradle → SKIE → macro → Xcode application build
- Documentation and whitespace checks
Run the primary checks locally:
swift test
swift test \
-Xswiftc -strict-concurrency=complete \
-Xswiftc -warnings-as-errors
./Scripts/check-api.sh
./Scripts/check-package-manifests.shBuild the real integration fixture without forcing the macro target onto the iOS SDK:
xcodebuild build \
-project Examples/DailyPulse/iosApp/iosApp.xcodeproj \
-scheme iosApp \
-destination 'generic/platform=iOS Simulator'- DailyPulse integration guide
- DocC catalog
- Benchmark methodology
- Evaluation, comparison, and migration guide
- Adoption playbook
- Contributing
- Architecture discussion
- Issue tracker
KMPObservableBridge is available under the MIT License.
Built for Kotlin Multiplatform teams that want SwiftUI to remain SwiftUI.

