diff --git a/Assets/architecture.svg b/Assets/architecture.svg new file mode 100644 index 0000000..60bf94c --- /dev/null +++ b/Assets/architecture.svg @@ -0,0 +1,70 @@ + + KMPObservableBridge runtime architecture + Kotlin StateFlow values cross a shared observation hub into field-level SwiftUI dependencies without duplicating state. + + + + + + + + + + + + + + + + + + + + + + + + One source of truth. Native SwiftUI dependencies. + + + + + Kotlin ViewModel + StateFlow<ProfileState> + StateFlow<Permissions> + Kotlin actions + + Authoritative current values + + + + + Observation Bridge + + Shared collector hub + + Equality + coalescing + + Field dependency keys + No copied business state + + + + + + SwiftUI View Graph + + $profile.profileState + + $profile.permissionsState + Only accessed fields invalidate + + + + + SKIE AsyncSequence + Observation + + One collection per model · deterministic cancellation · iOS 15+ fallback + + diff --git a/Assets/bridge-hero.png b/Assets/bridge-hero.png new file mode 100644 index 0000000..86e3489 Binary files /dev/null and b/Assets/bridge-hero.png differ diff --git a/Assets/demo.gif b/Assets/demo.gif new file mode 100644 index 0000000..46cb66b Binary files /dev/null and b/Assets/demo.gif differ diff --git a/Assets/demo.png b/Assets/demo.png index d708901..c8f0881 100644 Binary files a/Assets/demo.png and b/Assets/demo.png differ diff --git a/Benchmarks/RESULTS.md b/Benchmarks/RESULTS.md index 06fc17d..a4fc4e7 100644 --- a/Benchmarks/RESULTS.md +++ b/Benchmarks/RESULTS.md @@ -4,7 +4,7 @@ These numbers are reference measurements, not universal performance claims. Run the committed XCTest performance suite on your target hardware before making capacity decisions. -## 2026-07-23 reference run +## 2026-07-28 reference run | Environment | Value | | --- | --- | @@ -16,10 +16,11 @@ making capacity decisions. | Scenario | Work per measured iteration | Mean | Relative standard deviation | | --- | ---: | ---: | ---: | -| Immediate emissions | 10,000 publisher emissions | 0.007 s | 18.198% | -| Store lifecycle | 1,000 creation/teardown cycles | 0.004 s | 14.365% | +| Immediate emissions | 10,000 publisher emissions | 0.007 s | 28.199% | +| Store lifecycle | 1,000 creation/teardown cycles | 0.005 s | 18.604% | +| Shared static setup | 1,000 setup/teardown cycles | 0.007 s | 28.158% | -The full XCTest run completed two performance tests with zero failures. +The full XCTest run completed three performance tests with zero failures. Variance includes local machine scheduling and should be reduced with dedicated CI hardware before establishing regression thresholds. diff --git a/Examples/DailyPulse/iosApp/README.md b/Examples/DailyPulse/iosApp/README.md index e3eab4c..e6cfec5 100644 --- a/Examples/DailyPulse/iosApp/README.md +++ b/Examples/DailyPulse/iosApp/README.md @@ -29,5 +29,28 @@ iosApp/ | Combine publisher adapter | `CallbackExamples.swift` and `BridgeCallbackPublisher.swift` | | SKIE value reads and owned-model disposal | `SKIEStateFlowInterop.swift` and `KMPInterop.swift` | +## Example architecture + +Each feature demonstrates the same production-friendly boundary: + +```text +KMP ViewModel + ↓ thin bridge container +Native Swift values + Binding + action closures + ↓ +Pure SwiftUI presentation +``` + +The container owns or observes the Kotlin model and performs synchronous value +projection. The presentation view knows nothing about KMP, SKIE, flows, +collectors, or cancellation. This keeps rendering code reusable and makes +previews deterministic. + +Every example includes preview states for the UI it owns, including populated, +loading, empty, callback, Combine, NativeFlow, writable binding, and dark-mode +variants. These previews use immutable Swift fixtures and do not initialize +Koin, allocate Kotlin ViewModels, start coroutines, collect flows, or perform +network requests. + There is no generated Swift source or build-tool plugin. Every ViewModel's typed observation plan is declared locally with `@KMPObservable`. diff --git a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift index 1d1855f..5d8ba3e 100644 --- a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift +++ b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift @@ -28,6 +28,26 @@ struct ContentView: View { struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView() + NavigationView { + List { + ExampleHeader( + title: "SwiftUI Ownership", + subtitle: "StateObject, ObservedObject, and environment.", + systemImage: "rectangle.stack" + ) + ExampleHeader( + title: "SKIE StateFlow", + subtitle: "Macro-checked, field-level observation.", + systemImage: "newspaper" + ) + ExampleHeader( + title: "Explicit Adapters", + subtitle: "Callback and Combine cancellation.", + systemImage: "arrow.triangle.2.circlepath" + ) + } + .navigationTitle("Bridge Examples") + } + .previewDisplayName("Example catalog") } } diff --git a/Examples/DailyPulse/iosApp/iosApp/Examples/ArticleSKIEExample.swift b/Examples/DailyPulse/iosApp/iosApp/Examples/ArticleSKIEExample.swift index d6101e4..019410c 100644 --- a/Examples/DailyPulse/iosApp/iosApp/Examples/ArticleSKIEExample.swift +++ b/Examples/DailyPulse/iosApp/iosApp/Examples/ArticleSKIEExample.swift @@ -30,19 +30,68 @@ private struct ArticleContentView: View { _article = KMPObservedObject(viewModel) } + var body: some View { + ArticleListContent( + isLoading: article.articleState.isLoading, + error: article.articleState.error, + articles: article.articleState.articles.map(ArticleRowModel.init) + ) + } +} + +private struct ArticleRowModel: Identifiable { + let title: String + let description: String + let date: String + let imageURL: URL? + + var id: String { + "\(title)|\(date)" + } + + init(_ article: Article) { + title = article.title + description = article.description_ + date = article.date + imageURL = URL(string: article.imageUrl) + } + + init( + title: String, + description: String, + date: String, + imageURL: URL? + ) { + self.title = title + self.description = description + self.date = date + self.imageURL = imageURL + } +} + +private struct ArticleListContent: View { + let isLoading: Bool + let error: String? + let articles: [ArticleRowModel] + var body: some View { Group { - if article.articleState.isLoading { - ProgressView() - } else if article.articleState.articles.isEmpty { - VStack(spacing: 8) { - Image(systemName: "newspaper").font(.largeTitle) - Text("No Articles").font(.headline) - Text("There are no articles to show.") - .foregroundColor(.secondary) - } + if isLoading { + ProgressView("Loading articles…") + } else if let error { + ArticleUnavailableView( + title: "Unable to Load Articles", + message: error, + systemImage: "exclamationmark.triangle" + ) + } else if articles.isEmpty { + ArticleUnavailableView( + title: "No Articles", + message: "There are no articles to show.", + systemImage: "newspaper" + ) } else { - List(article.articleState.articles, id: \.title) { article in + List(articles) { article in ArticleRow(article: article) } .listStyle(.plain) @@ -51,13 +100,49 @@ private struct ArticleContentView: View { } } +private struct ArticleUnavailableView: View { + let title: String + let message: String + let systemImage: String + + var body: some View { + VStack(spacing: 8) { + Image(systemName: systemImage).font(.largeTitle) + Text(title).font(.headline) + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } +} + private struct ArticleRow: View { - let article: Article + let article: ArticleRowModel var body: some View { VStack(alignment: .leading, spacing: 8) { + AsyncImage(url: article.imageURL) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + } else if phase.error != nil { + Image(systemName: "photo") + .font(.largeTitle) + .foregroundColor(.secondary) + } else { + ProgressView() + } + } + .frame(maxWidth: .infinity) + .frame(height: 200) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + Text(article.title).font(.headline) - Text(article.description_) + Text(article.description) .font(.subheadline) .foregroundColor(.secondary) Text(article.date) @@ -67,3 +152,48 @@ private struct ArticleRow: View { .padding(.vertical, 8) } } + +struct ArticleSKIEExampleView_Previews: PreviewProvider { + private static let articles = [ + ArticleRowModel( + title: "Kotlin state with native SwiftUI rendering", + description: "The preview uses immutable Swift data and never starts Koin or a Kotlin collector.", + date: "Today", + imageURL: URL(string: "https://picsum.photos/800/400") + ), + ArticleRowModel( + title: "Field-level Observation", + description: "Only views reading the changed projected field are invalidated.", + date: "Yesterday", + imageURL: nil + ), + ] + + static var previews: some View { + Group { + NavigationView { + ArticleListContent( + isLoading: false, + error: nil, + articles: articles + ) + .navigationTitle("Macro SKIE") + } + .previewDisplayName("Articles") + + ArticleListContent( + isLoading: true, + error: nil, + articles: [] + ) + .previewDisplayName("Loading") + + ArticleListContent( + isLoading: false, + error: nil, + articles: [] + ) + .previewDisplayName("Empty") + } + } +} diff --git a/Examples/DailyPulse/iosApp/iosApp/Examples/CallbackExamples.swift b/Examples/DailyPulse/iosApp/iosApp/Examples/CallbackExamples.swift index 391b714..77b7926 100644 --- a/Examples/DailyPulse/iosApp/iosApp/Examples/CallbackExamples.swift +++ b/Examples/DailyPulse/iosApp/iosApp/Examples/CallbackExamples.swift @@ -65,13 +65,23 @@ private struct AdapterContent: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - Text(title).font(.title2.bold()) - Text(message) - HStack { - Button("Increment", action: increment) - .primaryExampleButtonStyle() - Button("Reset", action: reset) - .secondaryExampleButtonStyle() + ExampleCard { + ExampleHeader( + title: title, + subtitle: "Explicit adapter with deterministic cancellation.", + systemImage: "arrow.triangle.2.circlepath" + ) + + Divider().padding(.vertical, 4) + + Text(message).font(.headline) + + HStack { + Button("Increment", action: increment) + .primaryExampleButtonStyle() + Button("Reset", action: reset) + .secondaryExampleButtonStyle() + } } Spacer() } @@ -79,3 +89,30 @@ private struct AdapterContent: View { .navigationTitle("\(title) Adapter") } } + +struct CallbackExamplesView_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + AdapterContent( + title: "Callback", + message: "Callback updated to 3", + increment: {}, + reset: {} + ) + } + .previewDisplayName("Callback adapter") + + NavigationView { + AdapterContent( + title: "Combine", + message: "Publisher delivered on MainActor", + increment: {}, + reset: {} + ) + } + .preferredColorScheme(.dark) + .previewDisplayName("Combine adapter") + } + } +} diff --git a/Examples/DailyPulse/iosApp/iosApp/Examples/NativeCoroutinesExample.swift b/Examples/DailyPulse/iosApp/iosApp/Examples/NativeCoroutinesExample.swift index 9715f73..a038981 100644 --- a/Examples/DailyPulse/iosApp/iosApp/Examples/NativeCoroutinesExample.swift +++ b/Examples/DailyPulse/iosApp/iosApp/Examples/NativeCoroutinesExample.swift @@ -13,30 +13,57 @@ struct NativeCoroutinesExampleView: View { var body: some View { NavigationView { - VStack(alignment: .leading, spacing: 16) { - Text("KMP-NativeCoroutines") - .font(.title2) - .fontWeight(.semibold) + NativeCoroutinesContent( + message: example.nativeMessageValue, + increment: example.increment, + reset: example.reset + ) + .navigationTitle("NativeCoroutines") + } + } +} - Text("The ViewModel's canonical NativeFlow is observed explicitly.") - .font(.subheadline) - .foregroundColor(.secondary) +private struct NativeCoroutinesContent: View { + let message: String + let increment: () -> Void + let reset: () -> Void - Text(example.nativeMessageValue) - .font(.headline) + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ExampleCard { + ExampleHeader( + title: "KMP-NativeCoroutines", + subtitle: "The canonical NativeFlow is observed explicitly.", + systemImage: "point.3.connected.trianglepath.dotted" + ) - HStack { - Button("Increment") { - example.increment() - } + Divider().padding(.vertical, 4) + + Text(message).font(.headline) - Button("Reset") { - example.reset() - } + HStack { + Button("Increment", action: increment) + .primaryExampleButtonStyle() + Button("Reset", action: reset) + .secondaryExampleButtonStyle() } } - .padding() + Spacer() + } + .padding() + } +} + +struct NativeCoroutinesExampleView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NativeCoroutinesContent( + message: "Counter updated to 8", + increment: {}, + reset: {} + ) .navigationTitle("NativeCoroutines") } + .previewDisplayName("NativeFlow adapter") } } diff --git a/Examples/DailyPulse/iosApp/iosApp/Examples/OwnershipExamples.swift b/Examples/DailyPulse/iosApp/iosApp/Examples/OwnershipExamples.swift index aa59bf6..ea4789c 100644 --- a/Examples/DailyPulse/iosApp/iosApp/Examples/OwnershipExamples.swift +++ b/Examples/DailyPulse/iosApp/iosApp/Examples/OwnershipExamples.swift @@ -84,31 +84,104 @@ private struct BridgeExampleContentView: View { } var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(title).font(.title2.bold()) - Text(subtitle).foregroundColor(.secondary) - Text("\(example.counterState.count)") - .font(.system(size: 44, weight: .bold, design: .rounded)) - if example.counterState.isLoading { - ProgressView() + BridgeExamplePanel( + title: title, + subtitle: subtitle, + count: example.counterState.count, + isLoading: example.counterState.isLoading, + message: $example.messageState, + searchText: $example.searchText, + kotlinSearchText: $example.searchTextState, + increment: example.increment, + load: example.simulateLoading, + reset: example.reset + ) + } +} + +private struct BridgeExamplePanel: View { + let title: String + let subtitle: String + let count: Int32 + let isLoading: Bool + let message: String + @Binding var searchText: String + let kotlinSearchText: String + let increment: () -> Void + let load: () -> Void + let reset: () -> Void + + var body: some View { + ExampleCard { + ExampleHeader( + title: title, + subtitle: subtitle, + systemImage: "square.stack.3d.up" + ) + + Divider().padding(.vertical, 4) + + HStack(alignment: .firstTextBaseline) { + Text("\(count)") + .font(.system(size: 44, weight: .bold, design: .rounded)) + Spacer() + if isLoading { + ProgressView() + } } - Text($example.messageState).foregroundColor(.secondary) - TextField("Writable Kotlin search text", text: $example.searchText) + + Text(message).foregroundColor(.secondary) + + TextField("Writable Kotlin search text", text: $searchText) .textFieldStyle(.roundedBorder) - HStack(spacing: 4) { - Text("Kotlin value:") - Text($example.searchTextState) - } + + Text("Kotlin value: \(kotlinSearchText)") .font(.caption) .foregroundColor(.secondary) + HStack { - Button("Increment", action: example.increment) + Button("Increment", action: increment) .primaryExampleButtonStyle() - Button("Load", action: example.simulateLoading) + Button("Load", action: load) .secondaryExampleButtonStyle() - Button("Reset", action: example.reset) + Button("Reset", action: reset) .secondaryExampleButtonStyle() } } } } + +private struct OwnershipPanelPreview: View { + @State private var searchText = "SwiftUI" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + BridgeExamplePanel( + title: "StateObject Owner", + subtitle: "The parent owns the Kotlin ViewModel.", + count: 42, + isLoading: false, + message: "Counter updated from Kotlin", + searchText: $searchText, + kotlinSearchText: searchText, + increment: {}, + load: {}, + reset: {} + ) + } + .padding() + } +} + +struct OwnershipExamplesView_Previews: PreviewProvider { + static var previews: some View { + Group { + OwnershipPanelPreview() + .previewDisplayName("Populated") + + OwnershipPanelPreview() + .preferredColorScheme(.dark) + .previewDisplayName("Dark mode") + } + } +} diff --git a/Examples/DailyPulse/iosApp/iosApp/Support/ExampleStyles.swift b/Examples/DailyPulse/iosApp/iosApp/Support/ExampleStyles.swift index ac9a707..7f0a6c3 100644 --- a/Examples/DailyPulse/iosApp/iosApp/Support/ExampleStyles.swift +++ b/Examples/DailyPulse/iosApp/iosApp/Support/ExampleStyles.swift @@ -1,5 +1,45 @@ import SwiftUI +struct ExampleCard: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background( + Color.secondary.opacity(0.08), + in: RoundedRectangle(cornerRadius: 16) + ) + } +} + +struct ExampleHeader: View { + let title: String + let subtitle: String + let systemImage: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } +} + extension View { func primaryExampleButtonStyle() -> some View { padding(.horizontal, 12) diff --git a/Package.swift b/Package.swift index c3b5fb9..980b07c 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/swiftlang/swift-syntax.git", - exact: "602.0.0" + exact: "509.1.1" ), ], targets: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 0000000..1149825 --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,70 @@ +// swift-tools-version: 6.0 + +import PackageDescription +import CompilerPluginSupport + +let package = makePackage(swiftSyntaxVersion: "600.0.1") + +private func makePackage(swiftSyntaxVersion: Version) -> Package { + Package( + name: "KMPObservableBridge", + platforms: [ + .iOS(.v15), .macOS(.v11), .tvOS(.v14), .watchOS(.v7), + ], + products: [ + .library(name: "KMPObservableBridge", targets: ["KMPObservableBridge"]), + .library(name: "KMPObservableBridgeSKIE", targets: ["KMPObservableBridgeSKIE"]), + .library(name: "KMPObservableBridgeNative", targets: ["KMPObservableBridgeNative"]), + ], + dependencies: [ + .package( + url: "https://github.com/swiftlang/swift-syntax.git", + exact: swiftSyntaxVersion + ), + ], + targets: makeTargets() + ) +} + +private func makeTargets() -> [Target] { + [ + .target( + name: "KMPObservableBridge", + dependencies: ["KMPObservableBridgeMacros"] + ), + .target( + name: "KMPObservableBridgeSKIE", + dependencies: ["KMPObservableBridge"] + ), + .target( + name: "KMPObservableBridgeNative", + dependencies: ["KMPObservableBridge"] + ), + .macro( + name: "KMPObservableBridgeMacros", + dependencies: [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + ] + ), + .testTarget( + name: "KMPObservableBridgeTests", + dependencies: ["KMPObservableBridge"] + ), + .testTarget( + name: "KMPObservableBridgeMacroTests", + dependencies: [ + "KMPObservableBridge", + "KMPObservableBridgeSKIE", + "KMPObservableBridgeNative", + "KMPObservableBridgeMacros", + .product( + name: "SwiftSyntaxMacrosTestSupport", + package: "swift-syntax" + ), + ] + ), + ] +} diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift new file mode 100644 index 0000000..0b7c42a --- /dev/null +++ b/Package@swift-6.1.swift @@ -0,0 +1,70 @@ +// swift-tools-version: 6.1 + +import PackageDescription +import CompilerPluginSupport + +let package = makePackage(swiftSyntaxVersion: "601.0.1") + +private func makePackage(swiftSyntaxVersion: Version) -> Package { + Package( + name: "KMPObservableBridge", + platforms: [ + .iOS(.v15), .macOS(.v11), .tvOS(.v14), .watchOS(.v7), + ], + products: [ + .library(name: "KMPObservableBridge", targets: ["KMPObservableBridge"]), + .library(name: "KMPObservableBridgeSKIE", targets: ["KMPObservableBridgeSKIE"]), + .library(name: "KMPObservableBridgeNative", targets: ["KMPObservableBridgeNative"]), + ], + dependencies: [ + .package( + url: "https://github.com/swiftlang/swift-syntax.git", + exact: swiftSyntaxVersion + ), + ], + targets: makeTargets() + ) +} + +private func makeTargets() -> [Target] { + [ + .target( + name: "KMPObservableBridge", + dependencies: ["KMPObservableBridgeMacros"] + ), + .target( + name: "KMPObservableBridgeSKIE", + dependencies: ["KMPObservableBridge"] + ), + .target( + name: "KMPObservableBridgeNative", + dependencies: ["KMPObservableBridge"] + ), + .macro( + name: "KMPObservableBridgeMacros", + dependencies: [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + ] + ), + .testTarget( + name: "KMPObservableBridgeTests", + dependencies: ["KMPObservableBridge"] + ), + .testTarget( + name: "KMPObservableBridgeMacroTests", + dependencies: [ + "KMPObservableBridge", + "KMPObservableBridgeSKIE", + "KMPObservableBridgeNative", + "KMPObservableBridgeMacros", + .product( + name: "SwiftSyntaxMacrosTestSupport", + package: "swift-syntax" + ), + ] + ), + ] +} diff --git a/Package@swift-6.2.swift b/Package@swift-6.2.swift new file mode 100644 index 0000000..7ce121c --- /dev/null +++ b/Package@swift-6.2.swift @@ -0,0 +1,70 @@ +// swift-tools-version: 6.2 + +import PackageDescription +import CompilerPluginSupport + +let package = makePackage(swiftSyntaxVersion: "602.0.0") + +private func makePackage(swiftSyntaxVersion: Version) -> Package { + Package( + name: "KMPObservableBridge", + platforms: [ + .iOS(.v15), .macOS(.v11), .tvOS(.v14), .watchOS(.v7), + ], + products: [ + .library(name: "KMPObservableBridge", targets: ["KMPObservableBridge"]), + .library(name: "KMPObservableBridgeSKIE", targets: ["KMPObservableBridgeSKIE"]), + .library(name: "KMPObservableBridgeNative", targets: ["KMPObservableBridgeNative"]), + ], + dependencies: [ + .package( + url: "https://github.com/swiftlang/swift-syntax.git", + exact: swiftSyntaxVersion + ), + ], + targets: makeTargets() + ) +} + +private func makeTargets() -> [Target] { + [ + .target( + name: "KMPObservableBridge", + dependencies: ["KMPObservableBridgeMacros"] + ), + .target( + name: "KMPObservableBridgeSKIE", + dependencies: ["KMPObservableBridge"] + ), + .target( + name: "KMPObservableBridgeNative", + dependencies: ["KMPObservableBridge"] + ), + .macro( + name: "KMPObservableBridgeMacros", + dependencies: [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + ] + ), + .testTarget( + name: "KMPObservableBridgeTests", + dependencies: ["KMPObservableBridge"] + ), + .testTarget( + name: "KMPObservableBridgeMacroTests", + dependencies: [ + "KMPObservableBridge", + "KMPObservableBridgeSKIE", + "KMPObservableBridgeNative", + "KMPObservableBridgeMacros", + .product( + name: "SwiftSyntaxMacrosTestSupport", + package: "swift-syntax" + ), + ] + ), + ] +} diff --git a/README.md b/README.md index 2509eb4..66d67db 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,111 @@ +
+ # KMPObservableBridge -![KMPObservableBridge — Kotlin State. Native SwiftUI.](Assets/social-preview.png) +### Kotlin state. Native SwiftUI. + +A Swift-first, lifecycle-safe observation bridge for Kotlin Multiplatform +ViewModels, SKIE, KMP-NativeCoroutines, and SwiftUI. + +Kotlin state crossing a luminous bridge into native SwiftUI interfaces [![Swift 5.9+](https://img.shields.io/badge/Swift-5.9%2B-F05138?logo=swift&logoColor=white)](https://swift.org) -[![CI](https://github.com/sonmbol/KMPObservableBridge/actions/workflows/swift.yml/badge.svg)](https://github.com/sonmbol/KMPObservableBridge/actions/workflows/swift.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Platforms](https://img.shields.io/badge/Platforms-iOS%2015%2B%20%7C%20macOS%2011%2B-blue)](#requirements) +[![CI](https://github.com/sonmbol/KMPObservableBridge/actions/workflows/ci.yml/badge.svg)](https://github.com/sonmbol/KMPObservableBridge/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -KMPObservableBridge lets SwiftUI observe real Kotlin Multiplatform ViewModels -without shadow Swift ViewModels. Local Swift macros create statically typed -observation plans: no generated source files, selector discovery, swizzling, -Objective-C interception, or undocumented SKIE ABI lookup. +
```swift @KMPStateObject private var profile = ProfileViewModel() + +var body: some View { + ProfileContent( + state: $profile.profileState, + searchText: $profile.searchText, + retry: profile.retry + ) +} ``` -Kotlin remains the source of truth. One macro declaration per ViewModel lists -its exported state with ordinary Swift key paths, so a renamed or incompatible -property fails the application build. +No shadow Swift ViewModel. No copied business state. No runtime reflection, +swizzling, selector discovery, or generated source file. -## Requirements +## Why KMPObservableBridge? -- Swift 5.9+ -- iOS 15+, macOS 11+, tvOS 14+, or watchOS 7+ -- A KMP framework whose `StateFlow` properties are exposed as Swift - `AsyncSequence`s, such as a framework enhanced by SKIE +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 | Multiple SwiftUI wrappers share one 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 | + +

+ DailyPulse demonstrating SwiftUI ownership, SKIE StateFlow observation, explicit adapters, and NativeCoroutines +

+ +## Architecture + +

+ KMPObservableBridge runtime architecture +

+ +The bridge never stores a second copy of emitted business state: + +1. Kotlin `StateFlow` owns the current value. +2. SKIE exposes synchronous value access and an `AsyncSequence`. +3. A weak per-model registry shares one observation hub across wrappers. +4. Equality filtering rejects consecutive duplicates. +5. Coalescing unions all dependencies changed in the same main-actor turn. +6. SwiftUI reevaluates views that read the affected projected field. -## Installation +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 `https://github.com/sonmbol/KMPObservableBridge.git` as a Swift Package and -link exactly the integration used by the application target: +## Quick start -- `KMPObservableBridgeSKIE` for SKIE StateFlows. -- `KMPObservableBridgeNative` for KMP-NativeCoroutines or structural native - adapters. -- `KMPObservableBridge` only for exporter-neutral explicit APIs. +### 1. Add the package -The native and core products do not expose SKIE factories or SKIE runtime -types. +Add this repository through Xcode’s **Package Dependencies** interface: -### Declare each ViewModel +```text +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. -Place the macros in a normal application source file near the feature or -ViewModel integration: +### 2. Enable native value projection + +Declare this once in the application module when using SKIE: ```swift import shared import KMPObservableBridgeSKIE extension SkieSwiftStateFlow: @retroactive KMPValueProperty {} +``` + +### 3. Declare observable fields +Place the declaration beside the feature that owns the ViewModel: + +```swift @KMPObservable( ProfileViewModel.self, fields: \.profileState, \.permissionsState @@ -57,17 +113,16 @@ extension SkieSwiftStateFlow: @retroactive KMPValueProperty {} extension ProfileViewModel: @retroactive KMPStaticallyObservable {} ``` -Place this extension beside the feature that owns the ViewModel. If multiple -screens share it, declare the conformance once in a shared integration file. -Declare the `SkieSwiftStateFlow` interoperability conformance once per -application module; it allows the projected store to expose the flow's native -current value without `.value`. -SKIE factories exist only in `KMPObservableBridgeSKIE`; they are not visible -to applications that import `KMPObservableBridgeNative`. +The fields are ordinary Swift key paths. Renaming a Kotlin export or selecting +an incompatible property fails at compile time. -## Usage +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 a ViewModel for the lifetime of a SwiftUI identity: +### 4. Use native ownership + +Own the ViewModel for one SwiftUI identity: ```swift struct ProfileScreen: View { @@ -78,93 +133,68 @@ struct ProfileScreen: View { private var profile var body: some View { - ProfileContent(state: $profile.profileState) + ProfileContent(viewModel: profile) } } ``` -Observe a model owned by a parent or dependency container: +Observe a model owned elsewhere: ```swift struct ProfileContent: View { @KMPObservedObject private var profile: ProfileViewModel - init(profile: ProfileViewModel) { - _profile = KMPObservedObject(profile) + init(viewModel: ProfileViewModel) { + _profile = KMPObservedObject(viewModel) } -} -``` - -No lifecycle view modifier is required. `@StateObject` owns the coordinator; -its destruction releases the shared observation lease. This follows -destruction of SwiftUI identity storage, which is intentionally not the same -as visual `onDisappear`. - -### Explicit fallback adapters -The macro conformance is not required when the observation source is supplied -explicitly: - -```swift -@KMPStateObject(state: \.profileState) -private var profile = ProfileViewModel() - -@KMPObservedObject( - profile, - states: \.profileState, - \.permissionsState -) -private var profile + var body: some View { + Text($profile.profileState.title) + } +} ``` -`KMPState` also supports KMP-NativeCoroutines flows, Combine publishers, -callbacks, and custom cancellation adapters. - -### Native state projection and bindings - -The wrapper deliberately keeps the unprojected value as the original Kotlin -object. Its projected store unwraps read-only KMP value containers into native -Swift values: +Inject the same store through the environment without creating another +subscription: ```swift -Text($profile.messageState) +ProfileContent() + .kmpEnvironmentObject($profile) -if $profile.loadingState { - ProgressView() +struct ProfileContent: View { + @KMPEnvironmentObject private var profile: ProfileViewModel } - -Text($profile.countState, format: .number) ``` -This is a direct synchronous read from the Kotlin container; the bridge does -not cache or duplicate the emitted value. Because the unprojected value remains -the original Kotlin object, actions keep their natural syntax: +## Native values and bindings + +The unprojected property remains the original Kotlin object. Use it for +actions: ```swift profile.retry() +profile.selectArticle(id: article.id) ``` -For a genuinely writable exported property, the same projected store creates a -`Binding`: +The projected store returns native current values: ```swift -TextField("Search", text: $profile.searchText) +Text($profile.messageState) // String +ProgressView(value: $profile.progressState) // Double + +if $profile.loadingState { // Bool + ProgressView() +} ``` -Read-only StateFlows cannot form a `WritableKeyPath`, so the compiler refuses -to create an unsafe binding. Update immutable screen state through Kotlin -actions: +A writable Kotlin export produces a native binding: ```swift -Button("Retry") { - profile.retry() -} +TextField("Search", text: $profile.searchText) ``` -The projected store also exposes `rawModel` as an escape hatch for APIs that -need the original imported object. - -The projection contract is: +A read-only StateFlow cannot form a `WritableKeyPath`, so an unsafe binding +cannot compile. Change immutable state through Kotlin actions. ```swift profile.retry() // Kotlin action @@ -175,10 +205,29 @@ $profile.searchText // Binding $profile.rawModel // Original Kotlin object ``` -### Optional hot-path filtering +## Explicit adapters -Whole emitted-state equality is the normal default. A measured hot screen can -invalidate for only one projection: +Macros are optional when observation is configured at the wrapper: + +```swift +@KMPStateObject(state: \.profileState) +private var profile = ProfileViewModel() + +@KMPObservedObject( + profile, + states: \.profileState, \.permissionsState +) +private var profile +``` + +`KMPState` supports: + +- SKIE `AsyncSequence` and `StateFlow` +- KMP-NativeCoroutines `NativeFlow` +- Combine publishers +- Callback APIs with explicit cancellation +- Custom adapters +- Equatable projections for measured hot paths ```swift @KMPObservedObject( @@ -189,47 +238,172 @@ invalidate for only one projection: private var profile ``` -### Update policy +## Rendering and lifecycle guarantees + +### SwiftUI identity + +`@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. + +### Collection sharing -`.coalesced` is the default and combines accepted emissions in one main-actor -turn. Event-like streams can opt into `.immediate`. +All wrappers observing the same model identity lease the same hub. The final +lease cancels its underlying collectors. + +### Rebinding + +An externally owned wrapper cancels its old lease before observing a new +model. Generation tokens suppress emissions racing from the previous model. + +### Main actor + +SKIE collection, equality validation, dependency delivery, coalescing, and +SwiftUI invalidation remain on `MainActor`. Foreign callbacks cross actors +once before entering the bridge. + +### Platform behavior + +| Platform generation | Invalidation model | +| --- | --- | +| iOS 17+, macOS 14+, tvOS 17+, watchOS 10+ | Lazy field-level Observation dependencies | +| Earlier supported systems | Correct `ObservableObject.objectWillChange` fallback | + +## Update policies + +`.coalesced` is the default. It schedules at most one flush per main-actor turn +while preserving every changed dependency: + +```swift +@KMPStateObject private var profile = ProfileViewModel() +``` + +Event-sensitive consumers can request every accepted emission: ```swift @KMPStateObject(updatePolicy: .immediate) private var profile = ProfileViewModel() ``` -## Runtime model +Immediate mode does not allocate a dependency set per emission. + +## Examples and previews -The macro-expanded plan is static metadata. At runtime a main-actor weak registry -keys hubs by `ObjectIdentifier(model)`. +The [DailyPulse example](Examples/DailyPulse/iosApp) contains: -- A model has one collection task per declared flow, even when many views - observe it. -- Every wrapper holds a lightweight listener lease. -- Consecutive equal values are rejected before wrapper invalidation. -- The last lease cancels every collection task. -- Rebinding cancels the old lease first and generation checks reject stale - callbacks. -- Owned models are disposed exactly once; observed and environment models are - never disposed. +- 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 -On iOS 17+, accepted changes mutate a private Observation revision read during -view evaluation. On iOS 15/16, the same coordinator emits -`objectWillChange`. Collection, equality, coalescing, error handling, and -cancellation are shared. +

+ DailyPulse SwiftUI ownership example with Kotlin-backed state, writable binding, child observation, and environment sharing +

-## Development +Every example uses a thin live bridge container around a pure SwiftUI +presentation view: + +```text +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. + +## Performance + +Reference measurements are committed in +[Benchmarks/RESULTS.md](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. + +## Requirements + +- 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. + +## Validation + +CI treats these as release-blocking: + +- 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: ```sh swift test -swift test -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors +swift test \ + -Xswiftc -strict-concurrency=complete \ + -Xswiftc -warnings-as-errors +./Scripts/check-api.sh +./Scripts/check-package-manifests.sh ``` -The DailyPulse app is the end-to-end fixture. CI builds its real SKIE -framework, expands the feature-local observation macros, compiles the iOS -target, and audits production sources for Objective-C interception APIs. +Build the real integration fixture without forcing the macro target onto the +iOS SDK: + +```sh +xcodebuild build \ + -project Examples/DailyPulse/iosApp/iosApp.xcodeproj \ + -scheme iosApp \ + -destination 'generic/platform=iOS Simulator' +``` + +## Documentation + +- [DailyPulse integration guide](Examples/DailyPulse/iosApp/README.md) +- [DocC catalog](Sources/KMPObservableBridge/Documentation/KMPObservableBridge.docc/KMPObservableBridge.md) +- [Benchmark methodology](Benchmarks/RESULTS.md) +- [Issue tracker](https://github.com/sonmbol/KMPObservableBridge/issues) ## License KMPObservableBridge is available under the [MIT License](LICENSE). + +--- + +
+ +Built for Kotlin Multiplatform teams that want SwiftUI to remain SwiftUI. + +
diff --git a/Scripts/check-api.sh b/Scripts/check-api.sh index df92dcd..46117aa 100755 --- a/Scripts/check-api.sh +++ b/Scripts/check-api.sh @@ -9,10 +9,7 @@ then exit 1 fi -if [ -z "${BASELINE_REF:-}" ]; then - echo "API_BASELINE_REF is unset; compatibility comparison starts after 1.0." - exit 0 -fi +BASELINE_REF="${BASELINE_REF:-1.1.0}" git rev-parse --verify "$BASELINE_REF^{commit}" >/dev/null swift package diagnose-api-breaking-changes "$BASELINE_REF" diff --git a/Scripts/check-package-manifests.sh b/Scripts/check-package-manifests.sh new file mode 100755 index 0000000..74db778 --- /dev/null +++ b/Scripts/check-package-manifests.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +manifests=" +Package.swift +Package@swift-6.0.swift +Package@swift-6.1.swift +Package@swift-6.2.swift +" + +for manifest in $manifests; do + for product in \ + KMPObservableBridge \ + KMPObservableBridgeSKIE \ + KMPObservableBridgeNative + do + rg -q "name: \"$product\"" "$manifest" + done + + for target in \ + KMPObservableBridgeMacros \ + KMPObservableBridgeTests \ + KMPObservableBridgeMacroTests + do + rg -q "name: \"$target\"" "$manifest" + done +done + +rg -q 'exact: "509\.' Package.swift +rg -q 'swiftSyntaxVersion: "600\.' Package@swift-6.0.swift +rg -q 'swiftSyntaxVersion: "601\.' Package@swift-6.1.swift +rg -q 'swiftSyntaxVersion: "602\.' Package@swift-6.2.swift diff --git a/Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift b/Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift index 76c7cca..c83a174 100644 --- a/Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift +++ b/Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift @@ -13,7 +13,7 @@ public extension KMPState { static func asyncSequence( _ keyPath: KeyPath ) -> Self { - asyncSequence { $0[keyPath: keyPath] } + asyncSequence(keyPath, everyEmissionFrom: { $0[keyPath: keyPath] }) } /// Observes every emission without equality suppression. @@ -38,7 +38,7 @@ public extension KMPState { _ keyPath: KeyPath, changes select: @escaping @MainActor (Sequence.Element) -> Selection ) -> Self { - Self { viewModel, notify, reportError in + Self(dependency: .field(keyPath)) { viewModel, notify, reportError in let source = viewModel[keyPath: keyPath] let task = Task { @MainActor in var previous: Selection? @@ -83,7 +83,16 @@ public extension KMPState { static func asyncSequence( _ sequence: @escaping @MainActor (ViewModel) -> Sequence ) -> Self { - Self { viewModel, notify, reportError in + asyncSequence(nil, everyEmissionFrom: sequence) + } + + private static func asyncSequence( + _ keyPath: AnyKeyPath?, + everyEmissionFrom sequence: @escaping @MainActor (ViewModel) -> Sequence + ) -> Self { + Self( + dependency: keyPath.map(KMPObservationDependency.field) ?? .global + ) { viewModel, notify, reportError in let source = sequence(viewModel) let task = Task { @MainActor in do { diff --git a/Sources/KMPObservableBridge/Adapters/KMPPublisherState.swift b/Sources/KMPObservableBridge/Adapters/KMPPublisherState.swift index 9c81f9c..4fb1a2d 100644 --- a/Sources/KMPObservableBridge/Adapters/KMPPublisherState.swift +++ b/Sources/KMPObservableBridge/Adapters/KMPPublisherState.swift @@ -1,11 +1,32 @@ import Combine public extension KMPState { + /// Observes a publisher property with field-level dependency tracking. + /// + /// The defaulted second parameter keeps existing `.publisher(\.state)` + /// source syntax while preserving the original closure overload. + static func publisher( + _ keyPath: KeyPath, + tracksFieldDependency: Bool = true + ) -> Self { + makePublisherState( + dependency: tracksFieldDependency ? .field(keyPath) : .global, + publisher: { $0[keyPath: keyPath] } + ) + } + /// Observes a Combine publisher derived from the KMP model. static func publisher( _ publisher: @escaping @MainActor (ViewModel) -> PublisherType ) -> Self { - Self { viewModel, notify, reportError in + makePublisherState(dependency: .global, publisher: publisher) + } + + private static func makePublisherState( + dependency: KMPObservationDependency, + publisher: @escaping @MainActor (ViewModel) -> PublisherType + ) -> Self { + Self(dependency: dependency) { viewModel, notify, reportError in let cancellable = publisher(viewModel).sink( receiveCompletion: { completion in if case .failure(let error) = completion { diff --git a/Sources/KMPObservableBridge/Adapters/NativeCoroutines/KMPNativeFlowState.swift b/Sources/KMPObservableBridge/Adapters/NativeCoroutines/KMPNativeFlowState.swift index 3467f08..ea977bc 100644 --- a/Sources/KMPObservableBridge/Adapters/NativeCoroutines/KMPNativeFlowState.swift +++ b/Sources/KMPObservableBridge/Adapters/NativeCoroutines/KMPNativeFlowState.swift @@ -6,7 +6,8 @@ public extension KMPState { KMPNativeFlow > ) -> Self { - Self { viewModel, notify, reportError in + Self(dependency: .field(keyPath)) { + viewModel, notify, reportError in let flow = viewModel[keyPath: keyPath] let cancel = flow( { _, next, unit in @@ -58,4 +59,16 @@ public extension KMPNativeObservable { reportError: reportError ) } + + static func kmpStartObservation( + on model: Self, + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpObservationPlan.observeDependencies( + on: model, + notifyDependency: notifyDependency, + reportError: reportError + ) + } } diff --git a/Sources/KMPObservableBridge/Core/KMPObservationDependency.swift b/Sources/KMPObservableBridge/Core/KMPObservationDependency.swift new file mode 100644 index 0000000..31c1137 --- /dev/null +++ b/Sources/KMPObservableBridge/Core/KMPObservationDependency.swift @@ -0,0 +1,17 @@ +/// Identifies the SwiftUI dependency affected by an observation emission. +/// +/// This is intentionally internal. Public adapters continue to notify through +/// their existing parameterless callbacks. +enum KMPObservationDependency: Hashable { + case global + case field(AnyKeyPath) +} + +typealias KMPDependencyNotify = + @MainActor @Sendable (KMPObservationDependency) -> Void + +/// A keyed change callback used by statically observable models. +/// +/// `nil` represents a global change. A key path represents one field. +public typealias KMPObservationDependencyNotify = + @MainActor @Sendable (AnyKeyPath?) -> Void diff --git a/Sources/KMPObservableBridge/Core/KMPObservationSource.swift b/Sources/KMPObservableBridge/Core/KMPObservationSource.swift index a182079..d812b77 100644 --- a/Sources/KMPObservableBridge/Core/KMPObservationSource.swift +++ b/Sources/KMPObservableBridge/Core/KMPObservationSource.swift @@ -5,6 +5,13 @@ @MainActor enum KMPObservationSource { case staticPlan(KMPObservationPlan) + case keyed( + @MainActor ( + ViewModel, + @escaping KMPObservationDependencyNotify, + @escaping KMPObservationErrorHandler + ) -> KMPObservation + ) case explicit([KMPState]) } @@ -12,13 +19,11 @@ enum KMPObservationSource { func kmpStaticObservationSource( for _: ViewModel.Type ) -> KMPObservationSource { - .explicit([ - .custom { model, notify, reportError in - ViewModel.kmpStartObservation( - on: model, - notify: notify, - reportError: reportError - ) - }, - ]) + .keyed { model, notifyDependency, reportError in + ViewModel.kmpStartObservation( + on: model, + notifyDependency: notifyDependency, + reportError: reportError + ) + } } diff --git a/Sources/KMPObservableBridge/Core/KMPState.swift b/Sources/KMPObservableBridge/Core/KMPState.swift index 1816e85..96831d8 100644 --- a/Sources/KMPObservableBridge/Core/KMPState.swift +++ b/Sources/KMPObservableBridge/Core/KMPState.swift @@ -12,10 +12,11 @@ public struct KMPState { ) -> KMPObservation let observe: Observer + let dependency: KMPObservationDependency /// Starts this state adapter outside a wrapper. /// - /// This is primarily used by build-time generated model conformances. + /// This is primarily used by macro-expanded model conformances. public func startObservation( on viewModel: ViewModel, notify: @escaping Notify, @@ -24,7 +25,11 @@ public struct KMPState { observe(viewModel, notify, reportError) } - init(observe: @escaping Observer) { + init( + dependency: KMPObservationDependency = .global, + observe: @escaping Observer + ) { + self.dependency = dependency self.observe = observe } } diff --git a/Sources/KMPObservableBridge/Documentation/KMPObservableBridge.docc/KMPObservableBridge.md b/Sources/KMPObservableBridge/Documentation/KMPObservableBridge.docc/KMPObservableBridge.md index 61f83ab..f52c85b 100644 --- a/Sources/KMPObservableBridge/Documentation/KMPObservableBridge.docc/KMPObservableBridge.md +++ b/Sources/KMPObservableBridge/Documentation/KMPObservableBridge.docc/KMPObservableBridge.md @@ -21,6 +21,12 @@ Explicit `state:`, `states:`, NativeFlow, callback, Combine, and custom Hubs are shared per model identity, discard equal consecutive state, and cancel collection after the last SwiftUI identity releases its lease. +On Observation-capable systems, each key-path-backed projected state has a +separate dependency. Reading `$profile.profileState` does not subscribe that +view evaluation to unrelated fields. Direct model access and adapters without +a key path intentionally use a global dependency. Earlier systems retain the +`ObservableObject` fallback. + ## Topics ### Ownership diff --git a/Sources/KMPObservableBridge/Observation/KMPStaticObservation.swift b/Sources/KMPObservableBridge/Observation/KMPStaticObservation.swift index 7749841..46c359d 100644 --- a/Sources/KMPObservableBridge/Observation/KMPStaticObservation.swift +++ b/Sources/KMPObservableBridge/Observation/KMPStaticObservation.swift @@ -9,6 +9,27 @@ public protocol KMPStaticallyObservable: AnyObject { notify: @escaping KMPObservationNotify, reportError: @escaping KMPObservationErrorHandler ) -> KMPObservation + + static func kmpStartObservation( + on model: Self, + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation +} + +public extension KMPStaticallyObservable { + /// Compatibility route for manually implemented 1.1 conformances. + static func kmpStartObservation( + on model: Self, + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpStartObservation( + on: model, + notify: { notifyDependency(nil) }, + reportError: reportError + ) + } } /// A compile-time-checked collection of observation sources for one model. @@ -22,7 +43,7 @@ public struct KMPObservationPlan { func observe( _ model: Model, - notify: @escaping KMPState.Notify, + notify: @escaping KMPDependencyNotify, reportError: @escaping KMPState.ReportError ) -> KMPObservation { KMPStaticObservationRegistry.shared.observe( @@ -39,6 +60,30 @@ public struct KMPObservationPlan { notify: @escaping KMPState.Notify, reportError: @escaping KMPState.ReportError ) -> KMPObservation { - observe(model, notify: notify, reportError: reportError) + observe( + model, + notify: { _ in notify() }, + reportError: reportError + ) + } + + /// Starts the plan while retaining its field dependency keys. + public func observeDependencies( + on model: Model, + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + observe( + model, + notify: { dependency in + switch dependency { + case .global: + notifyDependency(nil) + case .field(let keyPath): + notifyDependency(keyPath) + } + }, + reportError: reportError + ) } } diff --git a/Sources/KMPObservableBridge/Observation/KMPStaticObservationHub.swift b/Sources/KMPObservableBridge/Observation/KMPStaticObservationHub.swift index a3a9029..0b314a4 100644 --- a/Sources/KMPObservableBridge/Observation/KMPStaticObservationHub.swift +++ b/Sources/KMPObservableBridge/Observation/KMPStaticObservationHub.swift @@ -2,7 +2,7 @@ @MainActor final class KMPStaticObservationHub { private struct Listener { - let notify: KMPState.Notify + let notify: KMPDependencyNotify let reportError: KMPState.ReportError } @@ -36,7 +36,7 @@ final class KMPStaticObservationHub { } func addListener( - notify: @escaping KMPState.Notify, + notify: @escaping KMPDependencyNotify, reportError: @escaping KMPState.ReportError ) -> KMPObservation { let id = makeListenerID() @@ -65,7 +65,7 @@ final class KMPStaticObservationHub { state.observe( model, { @MainActor [weak self] in - self?.broadcastNotification() + self?.broadcastNotification(state.dependency) }, { @MainActor [weak self] error in self?.broadcast(error) @@ -74,10 +74,12 @@ final class KMPStaticObservationHub { } } - private func broadcastNotification() { + private func broadcastNotification( + _ dependency: KMPObservationDependency + ) { withBroadcast { for listener in listeners.values { - listener.notify() + listener.notify(dependency) } } } diff --git a/Sources/KMPObservableBridge/Observation/KMPStaticObservationRegistry.swift b/Sources/KMPObservableBridge/Observation/KMPStaticObservationRegistry.swift index e2a3213..648e6b2 100644 --- a/Sources/KMPObservableBridge/Observation/KMPStaticObservationRegistry.swift +++ b/Sources/KMPObservableBridge/Observation/KMPStaticObservationRegistry.swift @@ -13,7 +13,7 @@ final class KMPStaticObservationRegistry { func observe( _ model: Model, plan: KMPObservationPlan, - notify: @escaping KMPState.Notify, + notify: @escaping KMPDependencyNotify, reportError: @escaping KMPState.ReportError ) -> KMPObservation { let key = ObjectIdentifier(model) diff --git a/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift b/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift index eb24cd9..3a00830 100644 --- a/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift +++ b/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift @@ -19,7 +19,10 @@ public final class KMPViewModelStore: @preconcurrency Obse private let updatePolicy: KMPUpdatePolicy private var disposer: Disposer? private var pendingChange: Task? - private var modernRevision: AnyObject? + private var pendingDependencies: Set = [] + private var globalRevision: AnyObject? + private var projectedGlobalRevision: AnyObject? + private var fieldRevisions: [AnyKeyPath: AnyObject] = [:] private let modernObservationEnabled: Bool convenience init( @@ -74,7 +77,7 @@ public final class KMPViewModelStore: @preconcurrency Obse /// Registers modern Observation access and returns the real Kotlin model. public var value: ViewModel { - trackModernAccess() + trackModernAccess(for: .global) return wrappedValue } @@ -94,7 +97,7 @@ public final class KMPViewModelStore: @preconcurrency Obse public subscript( dynamicMember keyPath: KeyPath ) -> Property.Value where Property: KMPValueProperty { - trackModernAccess() + trackModernAccess(for: .field(keyPath)) return wrappedValue[keyPath: keyPath].value } @@ -107,7 +110,8 @@ public final class KMPViewModelStore: @preconcurrency Obse ) -> Binding { Binding( get: { [self] in - wrappedValue[keyPath: keyPath] + trackModernAccess(for: .field(keyPath)) + return wrappedValue[keyPath: keyPath] }, set: { [self] value in wrappedValue[keyPath: keyPath] = value @@ -132,7 +136,7 @@ public final class KMPViewModelStore: @preconcurrency Obse stopObserving() wrappedValue = viewModel startObserving(source) - scheduleChange() + scheduleChange(.global) } private func startObserving( @@ -141,44 +145,78 @@ public final class KMPViewModelStore: @preconcurrency Obse generation &+= 1 let activeGeneration = generation - let states: [KMPState] switch source { case .staticPlan(let plan): - states = [ - .custom { viewModel, notify, reportError in - plan.observe( - viewModel, - notify: notify, - reportError: reportError + observations = [ + plan.observe( + wrappedValue, + notify: { @MainActor [weak self] dependency in + guard + let self, + self.generation == activeGeneration + else { + return + } + self.scheduleChange(dependency) + }, + reportError: makeErrorHandler( + generation: activeGeneration ) - }, + ), + ] + case .keyed(let observe): + observations = [ + observe( + wrappedValue, + { @MainActor [weak self] keyPath in + guard + let self, + self.generation == activeGeneration + else { + return + } + self.scheduleChange( + keyPath.map( + KMPObservationDependency.field + ) ?? .global + ) + }, + makeErrorHandler(generation: activeGeneration) + ), ] case .explicit(let explicitStates): - states = explicitStates + let reportError = makeErrorHandler( + generation: activeGeneration + ) + observations = explicitStates.map { state in + state.observe( + wrappedValue, + { @MainActor [weak self] in + guard + let self, + self.generation == activeGeneration + else { + return + } + self.scheduleChange(state.dependency) + }, + reportError + ) + } } + } - observations = states.map { state in - state.observe( - wrappedValue, - { @MainActor [weak self] in - guard - let self, - self.generation == activeGeneration - else { - return - } - self.scheduleChange() - }, - { @MainActor [weak self] error in - guard - let self, - self.generation == activeGeneration - else { - return - } - self.failurePolicy.report(error) - } - ) + private func makeErrorHandler( + generation activeGeneration: UInt + ) -> KMPObservationErrorHandler { + { @MainActor [weak self] error in + guard + let self, + self.generation == activeGeneration + else { + return + } + self.failurePolicy.report(error) } } @@ -186,16 +224,20 @@ public final class KMPViewModelStore: @preconcurrency Obse generation &+= 1 pendingChange?.cancel() pendingChange = nil + pendingDependencies.removeAll(keepingCapacity: true) let current = observations observations.removeAll(keepingCapacity: false) current.forEach { $0.cancel() } } - private func scheduleChange() { + private func scheduleChange( + _ dependency: KMPObservationDependency + ) { switch updatePolicy { case .immediate: - emitChange() + emitImmediateChange(for: dependency) case .coalesced: + pendingDependencies.insert(dependency) guard pendingChange == nil else { return } @@ -205,16 +247,21 @@ public final class KMPViewModelStore: @preconcurrency Obse return } self.pendingChange = nil - self.emitChange() + let dependencies = self.pendingDependencies + self.pendingDependencies.removeAll(keepingCapacity: true) + self.emitCoalescedChange(for: dependencies) } } } - private func emitChange() { + private func emitImmediateChange( + for dependency: KMPObservationDependency + ) { #if canImport(Observation) if modernObservationEnabled { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), - let revision = modernRevision as? KMPObservationRevision { + let revision = globalRevision as? KMPObservationRevision { + invalidateFieldRevisions(for: dependency) revision.value &+= 1 return } @@ -223,23 +270,90 @@ public final class KMPViewModelStore: @preconcurrency Obse objectWillChange.send() } + private func emitCoalescedChange( + for dependencies: Set + ) { + #if canImport(Observation) + if modernObservationEnabled { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), + let revision = globalRevision as? KMPObservationRevision { + let isGlobal = dependencies.contains(.global) + if isGlobal { + invalidateFieldRevisions(for: .global) + } else { + for dependency in dependencies { + invalidateFieldRevisions(for: dependency) + } + } + revision.value &+= 1 + return + } + } + #endif + objectWillChange.send() + } + + #if canImport(Observation) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func invalidateFieldRevisions( + for dependency: KMPObservationDependency + ) { + switch dependency { + case .global: + let revision = + projectedGlobalRevision as? KMPObservationRevision + revision?.value &+= 1 + case .field(let keyPath): + let revision = + fieldRevisions[keyPath] as? KMPObservationRevision + revision?.value &+= 1 + } + } + + #endif + private func configureModernObservation() { #if canImport(Observation) guard modernObservationEnabled else { return } if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - modernRevision = KMPObservationRevision() + globalRevision = KMPObservationRevision() } #endif } - private func trackModernAccess() { + private func trackModernAccess( + for dependency: KMPObservationDependency + ) { #if canImport(Observation) if modernObservationEnabled { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), - let revision = modernRevision as? KMPObservationRevision { - _ = revision.value + let globalRevision = + globalRevision as? KMPObservationRevision { + switch dependency { + case .global: + _ = globalRevision.value + case .field(let keyPath): + let projectedGlobal: KMPObservationRevision + if let existing = + projectedGlobalRevision as? KMPObservationRevision { + projectedGlobal = existing + } else { + projectedGlobal = KMPObservationRevision() + projectedGlobalRevision = projectedGlobal + } + _ = projectedGlobal.value + let revision: KMPObservationRevision + if let existing = + fieldRevisions[keyPath] as? KMPObservationRevision { + revision = existing + } else { + revision = KMPObservationRevision() + fieldRevisions[keyPath] = revision + } + _ = revision.value + } } } #endif diff --git a/Sources/KMPObservableBridgeMacros/KMPObservableMacro.swift b/Sources/KMPObservableBridgeMacros/KMPObservableMacro.swift index 72ae9cd..b7fe604 100644 --- a/Sources/KMPObservableBridgeMacros/KMPObservableMacro.swift +++ b/Sources/KMPObservableBridgeMacros/KMPObservableMacro.swift @@ -109,6 +109,18 @@ public struct KMPObservableMacro: MemberMacro { reportError: reportError ) } + + public static func kmpStartObservation( + on model: \(model), + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpObservationPlan.observeDependencies( + on: model, + notifyDependency: notifyDependency, + reportError: reportError + ) + } """ ), ] diff --git a/Tests/KMPObservableBridgeMacroTests/KMPObservableBridgeMacroTests.swift b/Tests/KMPObservableBridgeMacroTests/KMPObservableBridgeMacroTests.swift index 4ad53bd..f2a643e 100644 --- a/Tests/KMPObservableBridgeMacroTests/KMPObservableBridgeMacroTests.swift +++ b/Tests/KMPObservableBridgeMacroTests/KMPObservableBridgeMacroTests.swift @@ -38,6 +38,18 @@ final class KMPObservableBridgeMacroTests: XCTestCase { reportError: reportError ) } + + public static func kmpStartObservation( + on model: ProfileViewModel, + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpObservationPlan.observeDependencies( + on: model, + notifyDependency: notifyDependency, + reportError: reportError + ) + } } """, macros: macros diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index 2ef53a3..b6861f1 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -81,6 +81,30 @@ final class KMPObservableBridgeTests: XCTestCase { } } + private final class FieldModel: KMPStaticallyObservable { + let first = ValueStream(0) + let second = ValueStream(0) + + static var kmpObservationPlan: KMPObservationPlan { + KMPObservationPlan( + .equatable(\.first), + .equatable(\.second) + ) + } + + static func kmpStartObservation( + on model: FieldModel, + notify: @escaping KMPObservationNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpObservationPlan.startObservation( + on: model, + notify: notify, + reportError: reportError + ) + } + } + private final class DisposableModel: KMPDisposable { let state = AsyncStream { _ in } private(set) var disposalCount = 0 @@ -94,6 +118,21 @@ final class KMPObservableBridgeTests: XCTestCase { case failed } + private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var storage = 0 + + var value: Int { + lock.withLock { storage } + } + + func increment() { + lock.withLock { + storage += 1 + } + } + } + private final class NativeFlowModel: KMPNativeObservable { typealias Flow = KMPNativeFlow @@ -885,6 +924,118 @@ final class KMPObservableBridgeTests: XCTestCase { await fulfillment(of: [changed], timeout: 1) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func testModernObservationTracksProjectedFieldsIndependently() async { + let model = FieldModel() + let changed = expectation(description: "First field invalidated") + let invalidationCount = LockedCounter() + let store = KMPViewModelStore( + model, + source: .staticPlan(FieldModel.kmpObservationPlan), + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false + ) + + withObservationTracking { + _ = store.first + } onChange: { + invalidationCount.increment() + changed.fulfill() + } + + model.second.update(1) + await settleMainActorTasks() + XCTAssertEqual(invalidationCount.value, 0) + + model.first.update(1) + await fulfillment(of: [changed], timeout: 1) + XCTAssertEqual(invalidationCount.value, 1) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func testGlobalObservationInvalidatesForEveryField() async { + let model = FieldModel() + let changed = expectation(description: "Global dependency invalidated") + let store = KMPViewModelStore( + model, + source: .staticPlan(FieldModel.kmpObservationPlan), + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false + ) + + withObservationTracking { + _ = store.value + } onChange: { + changed.fulfill() + } + + model.second.update(1) + await fulfillment(of: [changed], timeout: 1) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func testCoalescingPreservesIndependentFieldChanges() async { + let model = FieldModel() + let firstChanged = expectation(description: "First invalidated") + let secondChanged = expectation(description: "Second invalidated") + let store = KMPViewModelStore( + model, + source: .staticPlan(FieldModel.kmpObservationPlan), + updatePolicy: .coalesced, + failurePolicy: .ignore, + ownsModel: false + ) + + withObservationTracking { + _ = store.first + } onChange: { + firstChanged.fulfill() + } + withObservationTracking { + _ = store.second + } onChange: { + secondChanged.fulfill() + } + + model.first.update(1) + model.second.update(1) + + await fulfillment( + of: [firstChanged, secondChanged], + timeout: 1 + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func testCustomGlobalAdapterInvalidatesProjectedFields() async { + let model = FieldModel() + var notify: KMPObservationNotify? + let changed = expectation(description: "Projected field invalidated") + let store = KMPViewModelStore( + model, + states: [ + .custom { _, callback, _ in + notify = callback + return .empty + }, + ], + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false + ) + + withObservationTracking { + _ = store.first + } onChange: { + changed.fulfill() + } + + notify?() + await fulfillment(of: [changed], timeout: 1) + } + private func settleMainActorTasks() async { for _ in 0..<4 { await Task.yield()