From dd77cb2b563b66aa7507dcc0edf4b1551a40736c Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 09:02:21 +0400 Subject: [PATCH 1/9] Add automatic SKIE state discovery --- .../iosApp/iosApp.xcodeproj/project.pbxproj | 23 +- .../iosApp/iosApp/ContentView.swift | 14 +- .../KMPObservableBridge.generated.swift | 39 ++ Package.swift | 7 + README.md | 64 +++- .../KMPObservableBridge/KMPChildObject.swift | 18 +- .../KMPObservableBridge.md | 8 + .../KMPObservedObject.swift | 50 ++- Sources/KMPObservableBridge/KMPState.swift | 43 ++- .../KMPObservableBridge/KMPStateObject.swift | 39 +- .../KMPObservableBridgeGenerator/main.swift | 343 ++++++++++++++++++ .../KMPObservableBridgeTests.swift | 33 ++ 12 files changed, 630 insertions(+), 51 deletions(-) create mode 100644 Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift create mode 100644 Sources/KMPObservableBridgeGenerator/main.swift diff --git a/Examples/DailyPulse/iosApp/iosApp.xcodeproj/project.pbxproj b/Examples/DailyPulse/iosApp/iosApp.xcodeproj/project.pbxproj index 3048b7b..815f56d 100644 --- a/Examples/DailyPulse/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/Examples/DailyPulse/iosApp/iosApp.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 94A532343011F92D0027F770 /* BridgeExampleCombineAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A532333011F92D0027F770 /* BridgeExampleCombineAdapter.swift */; }; 94A532363011FA2D0027F770 /* KMPObservableBridgeSKIE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A532353011FA2D0027F770 /* KMPObservableBridgeSKIE.swift */; }; 94A532383011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A532373011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift */; }; + A10000013020000100000001 /* KMPObservableBridge.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000023020000100000001 /* KMPObservableBridge.generated.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -40,6 +41,7 @@ 94A532333011F92D0027F770 /* BridgeExampleCombineAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeExampleCombineAdapter.swift; sourceTree = ""; }; 94A532353011FA2D0027F770 /* KMPObservableBridgeSKIE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableBridgeSKIE.swift; sourceTree = ""; }; 94A532373011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableBridgeNativeCoroutines.swift; sourceTree = ""; }; + A10000023020000100000001 /* KMPObservableBridge.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableBridge.generated.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -84,6 +86,7 @@ children = ( 058557BA273AAA24004C7B11 /* Assets.xcassets */, 94A532333011F92D0027F770 /* BridgeExampleCombineAdapter.swift */, + A10000033020000100000001 /* Generated */, 94A532353011FA2D0027F770 /* KMPObservableBridgeSKIE.swift */, 94A532373011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift */, 7555FF82242A565900829871 /* ContentView.swift */, @@ -94,6 +97,22 @@ path = iosApp; sourceTree = ""; }; + A10000033020000100000001 /* Generated */ = { + isa = PBXGroup; + children = ( + A10000043020000100000001 /* KMPObservableBridge */, + ); + path = Generated; + sourceTree = ""; + }; + A10000043020000100000001 /* KMPObservableBridge */ = { + isa = PBXGroup; + children = ( + A10000023020000100000001 /* KMPObservableBridge.generated.swift */, + ); + path = KMPObservableBridge; + sourceTree = ""; + }; 7555FFB0242A642200829871 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -188,10 +207,11 @@ outputFileListPaths = ( ); outputPaths = ( + "$(SRCROOT)/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export JAVA_HOME=\"$(/usr/libexec/java_home -v 17)\"\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-daemon\n"; + shellScript = "set -eu\n\nexport JAVA_HOME=\"$(/usr/libexec/java_home -v 17)\"\n\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-daemon\n\nGENERATED_DIR=\"$SRCROOT/iosApp/Generated/KMPObservableBridge\"\nmkdir -p \"$GENERATED_DIR\"\n\nKMP_OBSERVABLE_SDKROOT=\"$SDKROOT\" env -u SDKROOT \\\n xcrun swift run \\\n --package-path \"$SRCROOT/../../..\" \\\n kmp-observable-bridge-generator \\\n --framework \"$SRCROOT/../shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/shared.framework\" \\\n --module shared \\\n --output \"$GENERATED_DIR/KMPObservableBridge.generated.swift\"\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -204,6 +224,7 @@ 94A532343011F92D0027F770 /* BridgeExampleCombineAdapter.swift in Sources */, 94A532363011FA2D0027F770 /* KMPObservableBridgeSKIE.swift in Sources */, 94A532383011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift in Sources */, + A10000013020000100000001 /* KMPObservableBridge.generated.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift index f1a8a4f..ba477e5 100644 --- a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift +++ b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift @@ -96,11 +96,7 @@ private struct ArticleContentView: View { } private struct DirectStateObjectExampleView: View { - @KMPStateObject( - wrappedValue: BridgeExampleViewModel(), - states: \.counterState, \.messageState - ) - private var example + @KMPStateObject private var example = BridgeExampleViewModel() var body: some View { NavigationView { @@ -166,10 +162,7 @@ private struct BridgeExampleChildView: View { @KMPObservedObject private var example: BridgeExampleViewModel init(viewModel: BridgeExampleViewModel) { - _example = KMPObservedObject( - viewModel, - states: \.counterState, \.messageState - ) + _example = KMPObservedObject(viewModel) } var body: some View { @@ -206,8 +199,7 @@ private struct BridgeExampleContentView: View { self.title = title self.subtitle = subtitle _example = KMPObservedObject( - viewModel, - states: \.counterState, \.messageState + viewModel ) } diff --git a/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift b/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift new file mode 100644 index 0000000..f2f1154 --- /dev/null +++ b/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift @@ -0,0 +1,39 @@ +// Generated by KMPObservableBridge. Do not edit. +import KMPObservableBridge +import shared +extension ArticleViewModel: @retroactive KMPAutomaticallyObservable { + public func kmpObserveAutomatically( + notify: @escaping KMPState.Notify, + reportError: @escaping KMPState.ReportError + ) -> KMPObservation { + .group( + KMPState.asyncSequence(\.articleState) + .startObservation( + on: self, + notify: notify, + reportError: reportError + ), + ) + } +} +extension BridgeExampleViewModel: @retroactive KMPAutomaticallyObservable { + public func kmpObserveAutomatically( + notify: @escaping KMPState.Notify, + reportError: @escaping KMPState.ReportError + ) -> KMPObservation { + .group( + KMPState.asyncSequence(\.counterState) + .startObservation( + on: self, + notify: notify, + reportError: reportError + ), + KMPState.asyncSequence(\.messageState) + .startObservation( + on: self, + notify: notify, + reportError: reportError + ), + ) + } +} diff --git a/Package.swift b/Package.swift index fcb5ed5..0b518cc 100644 --- a/Package.swift +++ b/Package.swift @@ -15,11 +15,18 @@ let package = Package( name: "KMPObservableBridge", targets: ["KMPObservableBridge"] ), + .executable( + name: "kmp-observable-bridge-generator", + targets: ["KMPObservableBridgeGenerator"] + ), ], targets: [ .target( name: "KMPObservableBridge" ), + .executableTarget( + name: "KMPObservableBridgeGenerator" + ), .testTarget( name: "KMPObservableBridgeTests", dependencies: ["KMPObservableBridge"] diff --git a/README.md b/README.md index a424c66..8b995be 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,69 @@ let state = profile.profileStateValue This direct path keeps the bridge core independent of the NativeCoroutines Swift package while propagating cancellation to the Kotlin collection. -### Optional automatic ViewModel observation +### Optional automatic SKIE observation + +SKIE users can remove state key paths from the property-wrapper declaration by +running the package's generator after the shared framework is built. The +generator inspects the compiled framework, finds public SKIE `StateFlow` +properties, and emits `KMPAutomaticallyObservable` conformances: + +```swift +@KMPStateObject +private var profile = ProfileViewModel() + +struct DetailView: View { + @KMPObservedObject private var profile: ProfileViewModel + + init(profile: ProfileViewModel) { + _profile = KMPObservedObject(wrappedValue: profile) + } +} +``` + +Add one Run Script phase before Compile Sources. Keep your existing Gradle +framework build first, then invoke the generator: + +```sh +set -eu + +cd "$SRCROOT/.." +./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-daemon + +GENERATED_DIR="$SRCROOT/iosApp/Generated/KMPObservableBridge" +mkdir -p "$GENERATED_DIR" + +KMP_OBSERVABLE_SDKROOT="$SDKROOT" env -u SDKROOT \ + xcrun swift run \ + --package-path "/path/to/KMPObservableBridge" \ + kmp-observable-bridge-generator \ + --framework "$SRCROOT/../shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/shared.framework" \ + --module shared \ + --output "$GENERATED_DIR/KMPObservableBridge.generated.swift" +``` + +Create the `Generated/KMPObservableBridge` group in Xcode once, add +`KMPObservableBridge.generated.swift` to the application target's Compile +Sources, and declare the same file under the Run Script's Output Files. Commit +the generated file so a clean checkout has a valid Xcode build graph; each +build replaces it atomically. Do not edit it manually. + +This mode is optional. Without the generator, all explicit `state:` and +`states:` initializers continue to work exactly as before: + +```swift +@KMPStateObject( + wrappedValue: ProfileViewModel(), + states: \.profileState, \.permissionsState +) +private var profile +``` + +The generator observes every supported public SKIE StateFlow property on each +exported Kotlin class. Use explicit key paths when you intentionally want to +observe only a subset. + +### Optional automatic KMP-NativeCoroutines observation Automatic observation requires an explicit Kotlin contract; Swift cannot discover arbitrary flow properties through Objective-C reflection. Expose one diff --git a/Sources/KMPObservableBridge/KMPChildObject.swift b/Sources/KMPObservableBridge/KMPChildObject.swift index ecc8849..f69e803 100644 --- a/Sources/KMPObservableBridge/KMPChildObject.swift +++ b/Sources/KMPObservableBridge/KMPChildObject.swift @@ -45,14 +45,16 @@ public struct KMPChildObject: @preconcurren ) } - public init( + public init( parent: Parent, child: KeyPath, - states: repeat KeyPath, + states first: KeyPath, + _ states: repeat KeyPath, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) { - let sources = KMPState.asyncSequences(repeat each states) + let sources = [.asyncSequence(first)] + + KMPState.asyncSequences(repeat each states) self.parent = parent childKeyPath = child self.states = sources @@ -231,10 +233,11 @@ public struct KMPOptionalChildObject: ) } - public init( + public init( parent: Parent, child: KeyPath, - states: repeat KeyPath, + states first: KeyPath, + _ states: repeat KeyPath, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) where @@ -247,9 +250,8 @@ public struct KMPOptionalChildObject: parent: parent, parentState: .asyncSequence(child), currentChild: { parent[keyPath: child].value }, - childStates: KMPState.asyncSequences( - repeat each states - ), + childStates: [.asyncSequence(first)] + + KMPState.asyncSequences(repeat each states), updatePolicy: updatePolicy, failurePolicy: failurePolicy ) diff --git a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md index 34fb68c..c5e5fd1 100644 --- a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md +++ b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md @@ -33,6 +33,13 @@ private var profile = ProfileViewModel() The explicit `state:` form remains the universal option. The automatic form removes the key path without requiring a framework-owned Kotlin superclass. +For SKIE frameworks, the optional `kmp-observable-bridge-generator` executable +can inspect the framework after its Gradle build and generate the +``KMPAutomaticallyObservable`` conformances. Add its generated Swift file to +the application target before using the same no-state wrapper syntax. Projects +that do not run the generator continue using the explicit `state:` or `states:` +initializers. + ## Topics ### Ownership @@ -55,5 +62,6 @@ removes the key path without requiring a framework-owned Kotlin superclass. - ``KMPUpdatePolicy`` - ``KMPObservationFailurePolicy`` - ``KMPNativeObservable`` +- ``KMPAutomaticallyObservable`` - ``KMPNativeFlow`` - ``KMPValueProperty`` diff --git a/Sources/KMPObservableBridge/KMPObservedObject.swift b/Sources/KMPObservableBridge/KMPObservedObject.swift index fd8f774..11c8531 100644 --- a/Sources/KMPObservableBridge/KMPObservedObject.swift +++ b/Sources/KMPObservableBridge/KMPObservedObject.swift @@ -20,10 +20,17 @@ public struct KMPObservedObject: @preconcurrency DynamicPr wrappedValue viewModel: ViewModel, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log - ) where ViewModel: KMPNativeObservable { + ) where ViewModel: KMPAutomaticallyObservable { self.init( viewModel, - adapterArray: [.automatic()], + adapterArray: [ + .callback { viewModel, notify, reportError in + viewModel.kmpObserveAutomatically( + notify: notify, + reportError: reportError + ) + }, + ], updatePolicy: updatePolicy, failurePolicy: failurePolicy ) @@ -33,10 +40,17 @@ public struct KMPObservedObject: @preconcurrency DynamicPr _ viewModel: ViewModel, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log - ) where ViewModel: KMPNativeObservable { + ) where ViewModel: KMPAutomaticallyObservable { self.init( viewModel, - adapterArray: [.automatic()], + adapterArray: [ + .callback { viewModel, notify, reportError in + viewModel.kmpObserveAutomatically( + notify: notify, + reportError: reportError + ) + }, + ], updatePolicy: updatePolicy, failurePolicy: failurePolicy ) @@ -73,15 +87,17 @@ public struct KMPObservedObject: @preconcurrency DynamicPr ) } - public init( + public init( wrappedValue viewModel: ViewModel, - states: repeat KeyPath, + states first: KeyPath, + _ states: repeat KeyPath, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) { self.init( viewModel, - states: repeat each states, + adapterArray: [.asyncSequence(first)] + + KMPState.asyncSequences(repeat each states), updatePolicy: updatePolicy, failurePolicy: failurePolicy ) @@ -89,13 +105,14 @@ public struct KMPObservedObject: @preconcurrency DynamicPr public init( wrappedValue viewModel: ViewModel, - adapters: KMPState..., + adapters first: KMPState, + _ adapters: KMPState..., updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) { self.init( viewModel, - adapterArray: adapters, + adapterArray: [first] + adapters, updatePolicy: updatePolicy, failurePolicy: failurePolicy ) @@ -132,17 +149,17 @@ public struct KMPObservedObject: @preconcurrency DynamicPr ) } - public init( + public init( _ viewModel: ViewModel, - states: repeat KeyPath, + states first: KeyPath, + _ states: repeat KeyPath, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) { self.init( viewModel, - adapterArray: KMPState.asyncSequences( - repeat each states - ), + adapterArray: [.asyncSequence(first)] + + KMPState.asyncSequences(repeat each states), updatePolicy: updatePolicy, failurePolicy: failurePolicy ) @@ -150,13 +167,14 @@ public struct KMPObservedObject: @preconcurrency DynamicPr public init( _ viewModel: ViewModel, - adapters: KMPState..., + adapters first: KMPState, + _ adapters: KMPState..., updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) { self.init( viewModel, - adapterArray: adapters, + adapterArray: [first] + adapters, updatePolicy: updatePolicy, failurePolicy: failurePolicy ) diff --git a/Sources/KMPObservableBridge/KMPState.swift b/Sources/KMPObservableBridge/KMPState.swift index af8a9ab..646afb7 100644 --- a/Sources/KMPObservableBridge/KMPState.swift +++ b/Sources/KMPObservableBridge/KMPState.swift @@ -18,7 +18,16 @@ public typealias KMPNativeFlow = ( /// /// The flow's elements are not cached by Swift. They only tell SwiftUI that the /// model's Kotlin-owned properties should be read again. -public protocol KMPNativeObservable: AnyObject { +@MainActor +public protocol KMPAutomaticallyObservable: AnyObject { + func kmpObserveAutomatically( + notify: @escaping KMPState.Notify, + reportError: @escaping KMPState.ReportError + ) -> KMPObservation +} + +@MainActor +public protocol KMPNativeObservable: KMPAutomaticallyObservable { associatedtype KMPObservationOutput associatedtype KMPObservationFailure: Error associatedtype KMPObservationUnit @@ -84,6 +93,13 @@ public final class KMPObservation { public static var empty: KMPObservation { KMPObservation {} } + + /// Combines observations into one idempotent lifecycle handle. + public static func group(_ observations: KMPObservation...) -> KMPObservation { + KMPObservation { + observations.forEach { $0.cancel() } + } + } } public extension KMPObservation { @@ -109,6 +125,17 @@ public struct KMPState { let observe: Observer + /// Starts this state adapter outside a wrapper. + /// + /// This is primarily used by build-time generated model conformances. + public func startObservation( + on viewModel: ViewModel, + notify: @escaping Notify, + reportError: @escaping ReportError + ) -> KMPObservation { + observe(viewModel, notify, reportError) + } + private init(observe: @escaping Observer) { self.observe = observe } @@ -242,3 +269,17 @@ public struct KMPState { Self(observe: observe) } } + +public extension KMPNativeObservable { + @MainActor + func kmpObserveAutomatically( + notify: @escaping KMPState.Notify, + reportError: @escaping KMPState.ReportError + ) -> KMPObservation { + KMPState.automatic().startObservation( + on: self, + notify: notify, + reportError: reportError + ) + } +} diff --git a/Sources/KMPObservableBridge/KMPStateObject.swift b/Sources/KMPObservableBridge/KMPStateObject.swift index 3e5b872..f8927fb 100644 --- a/Sources/KMPObservableBridge/KMPStateObject.swift +++ b/Sources/KMPObservableBridge/KMPStateObject.swift @@ -14,17 +14,24 @@ public struct KMPStateObject: DynamicProperty { storage } - /// Creates an opt-in automatically observable Kotlin model. + /// Creates a model using its build-time generated observation list. public init( wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil - ) where ViewModel: KMPNativeObservable { + ) where ViewModel: KMPAutomaticallyObservable { _storage = StateObject( wrappedValue: KMPViewModelStore( makeViewModel(), - states: [.automatic()], + states: [ + .callback { viewModel, notify, reportError in + viewModel.kmpObserveAutomatically( + notify: notify, + reportError: reportError + ) + }, + ], updatePolicy: updatePolicy, failurePolicy: failurePolicy, ownsModel: true, @@ -77,14 +84,16 @@ public struct KMPStateObject: DynamicProperty { } /// Creates a model and observes any number of heterogeneous streams. - public init( + public init( wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, - states: repeat KeyPath, + states first: KeyPath, + _ states: repeat KeyPath, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil ) { - let sources = KMPState.asyncSequences(repeat each states) + let sources = [.asyncSequence(first)] + + KMPState.asyncSequences(repeat each states) _storage = StateObject( wrappedValue: KMPViewModelStore( makeViewModel(), @@ -100,7 +109,8 @@ public struct KMPStateObject: DynamicProperty { /// Creates a model with advanced or mixed observation adapters. public init( wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, - adapters: KMPState..., + adapters first: KMPState, + _ adapters: KMPState..., updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil @@ -108,7 +118,7 @@ public struct KMPStateObject: DynamicProperty { _storage = StateObject( wrappedValue: KMPViewModelStore( makeViewModel(), - states: adapters, + states: [first] + adapters, updatePolicy: updatePolicy, failurePolicy: failurePolicy, ownsModel: true, @@ -163,15 +173,17 @@ public struct KMPStateObject: DynamicProperty { } /// Resolves a model lazily and observes any number of state streams. - public init( + public init( injector makeInjector: @autoclosure @escaping () -> Injector, viewModel keyPath: KeyPath, - states: repeat KeyPath, + states first: KeyPath, + _ states: repeat KeyPath, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil ) { - let sources = KMPState.asyncSequences(repeat each states) + let sources = [.asyncSequence(first)] + + KMPState.asyncSequences(repeat each states) _storage = StateObject( wrappedValue: KMPViewModelStore( makeInjector()[keyPath: keyPath], @@ -188,7 +200,8 @@ public struct KMPStateObject: DynamicProperty { public init( injector makeInjector: @autoclosure @escaping () -> Injector, viewModel keyPath: KeyPath, - adapters: KMPState..., + adapters first: KMPState, + _ adapters: KMPState..., updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil @@ -196,7 +209,7 @@ public struct KMPStateObject: DynamicProperty { _storage = StateObject( wrappedValue: KMPViewModelStore( makeInjector()[keyPath: keyPath], - states: adapters, + states: [first] + adapters, updatePolicy: updatePolicy, failurePolicy: failurePolicy, ownsModel: true, diff --git a/Sources/KMPObservableBridgeGenerator/main.swift b/Sources/KMPObservableBridgeGenerator/main.swift new file mode 100644 index 0000000..3eb9048 --- /dev/null +++ b/Sources/KMPObservableBridgeGenerator/main.swift @@ -0,0 +1,343 @@ +import Foundation + +private struct SymbolGraph: Decodable { + struct Metadata: Decodable { + struct Version: Decodable { + let major: Int + } + + let formatVersion: Version + } + + struct Symbol: Decodable { + struct Identifier: Decodable { + let precise: String + } + + struct Kind: Decodable { + let identifier: String + } + + struct Names: Decodable { + let title: String + } + + struct Fragment: Decodable { + let kind: String + let spelling: String + let preciseIdentifier: String? + } + + let identifier: Identifier + let kind: Kind + let names: Names + let pathComponents: [String] + let declarationFragments: [Fragment]? + } + + struct Relationship: Decodable { + let kind: String + let source: String + let target: String + } + + let metadata: Metadata + let symbols: [Symbol] + let relationships: [Relationship] +} + +private struct Options { + let symbolGraph: URL? + let framework: URL? + let module: String? + let output: URL + + init(arguments: [String]) throws { + func value(after flag: String) -> String? { + guard let index = arguments.firstIndex(of: flag), + arguments.indices.contains(index + 1) else { + return nil + } + return arguments[index + 1] + } + + guard let output = value(after: "--output") else { + throw GeneratorError.usage + } + + symbolGraph = value(after: "--symbol-graph").map { + URL(fileURLWithPath: $0) + } + framework = value(after: "--framework").map { + URL(fileURLWithPath: $0) + } + module = value(after: "--module") + self.output = URL(fileURLWithPath: output) + + guard (symbolGraph != nil && module != nil) + || framework != nil else { + throw GeneratorError.usage + } + } +} + +private enum GeneratorError: LocalizedError { + case usage + case unsupportedSymbolGraph(Int) + case noStateFlows + case missingEnvironment(String) + case processFailed(String, Int32) + + var errorDescription: String? { + switch self { + case .usage: + return """ + Usage: + kmp-observable-bridge-generator \ + --symbol-graph --module --output + kmp-observable-bridge-generator \ + --framework [--module ] --output + """ + case .unsupportedSymbolGraph(let major): + return "Unsupported symbol graph format major version \(major)." + case .noStateFlows: + return "No public SKIE StateFlow properties were found." + case .missingEnvironment(let name): + return "Required Xcode build environment variable \(name) is missing." + case .processFailed(let command, let status): + return "\(command) failed with exit status \(status)." + } + } +} + +private let supportedStateFlowNames: Set = [ + "SkieSwiftStateFlow", + "SkieSwiftMutableStateFlow", + "SkieSwiftOptionalStateFlow", + "SkieSwiftOptionalMutableStateFlow", +] + +private func run( + _ executable: URL, + arguments: [String], + directory: URL? = nil, + environment: [String: String]? = nil, + captureOutput: Bool = false +) throws -> Data { + let process = Process() + process.executableURL = executable + process.arguments = arguments + process.currentDirectoryURL = directory + if let environment { + process.environment = environment + } + + let pipe = Pipe() + if captureOutput { + process.standardOutput = pipe + } else { + process.standardOutput = FileHandle.standardOutput + } + process.standardError = FileHandle.standardError + try process.run() + let output = captureOutput + ? pipe.fileHandleForReading.readDataToEndOfFile() + : Data() + process.waitUntilExit() + guard process.terminationStatus == EXIT_SUCCESS else { + throw GeneratorError.processFailed( + ([executable.path] + arguments).joined(separator: " "), + process.terminationStatus + ) + } + return output +} + +private func extractSymbolGraph( + framework: URL, + module requestedModule: String? +) throws -> (graph: URL, module: String) { + let module = requestedModule + ?? framework.deletingPathExtension().lastPathComponent + let environment = ProcessInfo.processInfo.environment + guard let sdkRoot = environment["KMP_OBSERVABLE_SDKROOT"] + ?? environment["SDKROOT"] else { + throw GeneratorError.missingEnvironment("SDKROOT") + } + let architecture = environment["NATIVE_ARCH_ACTUAL"] + ?? environment["CURRENT_ARCH"] + ?? environment["ARCHS"]?.split(separator: " ").first.map(String.init) + guard let architecture else { + throw GeneratorError.missingEnvironment( + "NATIVE_ARCH_ACTUAL or CURRENT_ARCH" + ) + } + guard let operatingSystem = environment["LLVM_TARGET_TRIPLE_OS_VERSION"] + else { + throw GeneratorError.missingEnvironment( + "LLVM_TARGET_TRIPLE_OS_VERSION" + ) + } + let suffix = environment["LLVM_TARGET_TRIPLE_SUFFIX"] ?? "" + let target = "\(architecture)-apple-\(operatingSystem)\(suffix)" + let graphDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("KMPObservableBridge-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: graphDirectory, + withIntermediateDirectories: true + ) + + _ = try run( + URL(fileURLWithPath: "/usr/bin/xcrun"), + arguments: [ + "swift-symbolgraph-extract", + "-module-name", module, + "-F", framework.deletingLastPathComponent().path, + "-target", target, + "-sdk", sdkRoot, + "-minimum-access-level", "public", + "-output-dir", graphDirectory.path, + ] + ) + + return ( + graphDirectory.appendingPathComponent("\(module).symbols.json"), + module + ) +} + +private func generate(options: Options) throws { + let input: (graph: URL, module: String) + if let framework = options.framework { + input = try extractSymbolGraph( + framework: framework, + module: options.module + ) + } else if let graph = options.symbolGraph, let module = options.module { + input = (graph, module) + } else { + throw GeneratorError.usage + } + + let data = try Data(contentsOf: input.graph) + let graph = try JSONDecoder().decode(SymbolGraph.self, from: data) + guard graph.metadata.formatVersion.major == 0 else { + throw GeneratorError.unsupportedSymbolGraph( + graph.metadata.formatVersion.major + ) + } + + let symbolsByID = Dictionary( + uniqueKeysWithValues: graph.symbols.map { + ($0.identifier.precise, $0) + } + ) + let ownerByMember = Dictionary( + uniqueKeysWithValues: graph.relationships.compactMap { relationship in + relationship.kind == "memberOf" + ? (relationship.source, relationship.target) + : nil + } + ) + let flowImplementationOwners = Set( + graph.relationships.compactMap { relationship in + relationship.kind == "conformsTo" + && relationship.target.lowercased().contains( + "kotlinx_coroutines_core" + ) + ? relationship.source + : nil + } + ) + + var propertiesByOwner: [String: [String]] = [:] + for symbol in graph.symbols where symbol.kind.identifier == "swift.property" { + let isStateFlow = symbol.declarationFragments?.contains { fragment in + fragment.kind == "typeIdentifier" + && supportedStateFlowNames.contains(fragment.spelling) + } == true + guard isStateFlow, + let ownerID = ownerByMember[symbol.identifier.precise], + let owner = symbolsByID[ownerID], + owner.kind.identifier == "swift.class", + // Kotlin/Native exported classes retain Objective-C symbol IDs. + // Excluding native Swift IDs prevents accidental conformances + // for SKIE's own StateFlow implementation classes. + owner.identifier.precise.hasPrefix("c:objc(cs)"), + !flowImplementationOwners.contains(ownerID) else { + continue + } + + propertiesByOwner[owner.names.title, default: []].append( + symbol.names.title + ) + } + + guard !propertiesByOwner.isEmpty else { + throw GeneratorError.noStateFlows + } + + var source = """ + // Generated by KMPObservableBridge. Do not edit. + import KMPObservableBridge + import \(input.module) + + """ + for owner in propertiesByOwner.keys.sorted() { + let properties = propertiesByOwner[owner, default: []].sorted() + source += """ + extension \(owner): @retroactive KMPAutomaticallyObservable { + public func kmpObserveAutomatically( + notify: @escaping KMPState<\(owner)>.Notify, + reportError: @escaping KMPState<\(owner)>.ReportError + ) -> KMPObservation { + .group( + """ + for property in properties { + source += """ + + KMPState<\(owner)>.asyncSequence(\\.\(property)) + .startObservation( + on: self, + notify: notify, + reportError: reportError + ), + """ + } + source += """ + + ) + } + } + + """ + } + + let directory = options.output.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let temporary = directory.appendingPathComponent( + ".\(options.output.lastPathComponent).\(UUID().uuidString).tmp" + ) + try Data(source.utf8).write(to: temporary, options: .atomic) + if FileManager.default.fileExists(atPath: options.output.path) { + _ = try FileManager.default.replaceItemAt( + options.output, + withItemAt: temporary + ) + } else { + try FileManager.default.moveItem(at: temporary, to: options.output) + } +} + +do { + let options = try Options(arguments: Array(CommandLine.arguments.dropFirst())) + try generate(options: options) +} catch { + FileHandle.standardError.write( + Data("error: \(error.localizedDescription)\n".utf8) + ) + exit(EXIT_FAILURE) +} diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index f5f71ee..eb4a906 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -107,6 +107,23 @@ final class KMPObservableBridgeTests: XCTestCase { } } + /// Mirrors the conformance emitted by the build-time SKIE generator. + private final class GeneratedStyleModel: KMPAutomaticallyObservable { + let state = PassthroughSubject() + + func kmpObserveAutomatically( + notify: @escaping KMPState.Notify, + reportError: @escaping KMPState.ReportError + ) -> KMPObservation { + KMPState.publisher(\.state) + .startObservation( + on: self, + notify: notify, + reportError: reportError + ) + } + } + func testObservationCancellationIsIdempotent() { var cancellationCount = 0 let observation = KMPObservation { @@ -200,6 +217,22 @@ final class KMPObservableBridgeTests: XCTestCase { XCTAssertTrue(observed.wrappedValue === observedModel) } + func testGeneratedAutomaticConformanceSupportsBothWrappers() { + let ownedModel = GeneratedStyleModel() + let observedModel = GeneratedStyleModel() + let owned = KMPStateObject( + wrappedValue: ownedModel, + updatePolicy: .immediate + ) + let observed = KMPObservedObject( + wrappedValue: observedModel, + updatePolicy: .immediate + ) + + XCTAssertTrue(owned.wrappedValue === ownedModel) + XCTAssertTrue(observed.wrappedValue === observedModel) + } + func testRebindingCancelsOldSourceAndSuppressesStaleEmissions() async { let first = Model() let second = Model() From af11cc81d3134cdca10bc80fc50c226265f1440b Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 09:06:09 +0400 Subject: [PATCH 2/9] Keep Xcode project integration unchanged --- .../iosApp/iosApp.xcodeproj/project.pbxproj | 23 +---------- .../iosApp/iosApp/ContentView.swift | 14 +++++-- .../KMPObservableBridge.generated.swift | 39 ------------------- 3 files changed, 12 insertions(+), 64 deletions(-) delete mode 100644 Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift diff --git a/Examples/DailyPulse/iosApp/iosApp.xcodeproj/project.pbxproj b/Examples/DailyPulse/iosApp/iosApp.xcodeproj/project.pbxproj index 815f56d..3048b7b 100644 --- a/Examples/DailyPulse/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/Examples/DailyPulse/iosApp/iosApp.xcodeproj/project.pbxproj @@ -15,7 +15,6 @@ 94A532343011F92D0027F770 /* BridgeExampleCombineAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A532333011F92D0027F770 /* BridgeExampleCombineAdapter.swift */; }; 94A532363011FA2D0027F770 /* KMPObservableBridgeSKIE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A532353011FA2D0027F770 /* KMPObservableBridgeSKIE.swift */; }; 94A532383011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A532373011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift */; }; - A10000013020000100000001 /* KMPObservableBridge.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000023020000100000001 /* KMPObservableBridge.generated.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -41,7 +40,6 @@ 94A532333011F92D0027F770 /* BridgeExampleCombineAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeExampleCombineAdapter.swift; sourceTree = ""; }; 94A532353011FA2D0027F770 /* KMPObservableBridgeSKIE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableBridgeSKIE.swift; sourceTree = ""; }; 94A532373011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableBridgeNativeCoroutines.swift; sourceTree = ""; }; - A10000023020000100000001 /* KMPObservableBridge.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableBridge.generated.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -86,7 +84,6 @@ children = ( 058557BA273AAA24004C7B11 /* Assets.xcassets */, 94A532333011F92D0027F770 /* BridgeExampleCombineAdapter.swift */, - A10000033020000100000001 /* Generated */, 94A532353011FA2D0027F770 /* KMPObservableBridgeSKIE.swift */, 94A532373011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift */, 7555FF82242A565900829871 /* ContentView.swift */, @@ -97,22 +94,6 @@ path = iosApp; sourceTree = ""; }; - A10000033020000100000001 /* Generated */ = { - isa = PBXGroup; - children = ( - A10000043020000100000001 /* KMPObservableBridge */, - ); - path = Generated; - sourceTree = ""; - }; - A10000043020000100000001 /* KMPObservableBridge */ = { - isa = PBXGroup; - children = ( - A10000023020000100000001 /* KMPObservableBridge.generated.swift */, - ); - path = KMPObservableBridge; - sourceTree = ""; - }; 7555FFB0242A642200829871 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -207,11 +188,10 @@ outputFileListPaths = ( ); outputPaths = ( - "$(SRCROOT)/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -eu\n\nexport JAVA_HOME=\"$(/usr/libexec/java_home -v 17)\"\n\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-daemon\n\nGENERATED_DIR=\"$SRCROOT/iosApp/Generated/KMPObservableBridge\"\nmkdir -p \"$GENERATED_DIR\"\n\nKMP_OBSERVABLE_SDKROOT=\"$SDKROOT\" env -u SDKROOT \\\n xcrun swift run \\\n --package-path \"$SRCROOT/../../..\" \\\n kmp-observable-bridge-generator \\\n --framework \"$SRCROOT/../shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/shared.framework\" \\\n --module shared \\\n --output \"$GENERATED_DIR/KMPObservableBridge.generated.swift\"\n"; + shellScript = "export JAVA_HOME=\"$(/usr/libexec/java_home -v 17)\"\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-daemon\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -224,7 +204,6 @@ 94A532343011F92D0027F770 /* BridgeExampleCombineAdapter.swift in Sources */, 94A532363011FA2D0027F770 /* KMPObservableBridgeSKIE.swift in Sources */, 94A532383011FA2D0027F770 /* KMPObservableBridgeNativeCoroutines.swift in Sources */, - A10000013020000100000001 /* KMPObservableBridge.generated.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift index ba477e5..f1a8a4f 100644 --- a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift +++ b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift @@ -96,7 +96,11 @@ private struct ArticleContentView: View { } private struct DirectStateObjectExampleView: View { - @KMPStateObject private var example = BridgeExampleViewModel() + @KMPStateObject( + wrappedValue: BridgeExampleViewModel(), + states: \.counterState, \.messageState + ) + private var example var body: some View { NavigationView { @@ -162,7 +166,10 @@ private struct BridgeExampleChildView: View { @KMPObservedObject private var example: BridgeExampleViewModel init(viewModel: BridgeExampleViewModel) { - _example = KMPObservedObject(viewModel) + _example = KMPObservedObject( + viewModel, + states: \.counterState, \.messageState + ) } var body: some View { @@ -199,7 +206,8 @@ private struct BridgeExampleContentView: View { self.title = title self.subtitle = subtitle _example = KMPObservedObject( - viewModel + viewModel, + states: \.counterState, \.messageState ) } diff --git a/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift b/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift deleted file mode 100644 index f2f1154..0000000 --- a/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift +++ /dev/null @@ -1,39 +0,0 @@ -// Generated by KMPObservableBridge. Do not edit. -import KMPObservableBridge -import shared -extension ArticleViewModel: @retroactive KMPAutomaticallyObservable { - public func kmpObserveAutomatically( - notify: @escaping KMPState.Notify, - reportError: @escaping KMPState.ReportError - ) -> KMPObservation { - .group( - KMPState.asyncSequence(\.articleState) - .startObservation( - on: self, - notify: notify, - reportError: reportError - ), - ) - } -} -extension BridgeExampleViewModel: @retroactive KMPAutomaticallyObservable { - public func kmpObserveAutomatically( - notify: @escaping KMPState.Notify, - reportError: @escaping KMPState.ReportError - ) -> KMPObservation { - .group( - KMPState.asyncSequence(\.counterState) - .startObservation( - on: self, - notify: notify, - reportError: reportError - ), - KMPState.asyncSequence(\.messageState) - .startObservation( - on: self, - notify: notify, - reportError: reportError - ), - ) - } -} From 03b8034a3cd1bffc692b424258060970aeefc968 Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 09:14:08 +0400 Subject: [PATCH 3/9] Automate generated source registration --- .../iosApp/generate-kmp-observation.sh | 25 ++++ .../KMPObservableBridge.generated.swift | 39 ++++++ Package.resolved | 41 ++++++ Package.swift | 13 +- README.md | 48 +++++-- .../KMPObservableBridge.md | 7 +- .../KMPObservableBridgeGenerator/main.swift | 117 +++++++++++++++++- 7 files changed, 276 insertions(+), 14 deletions(-) create mode 100755 Examples/DailyPulse/iosApp/generate-kmp-observation.sh create mode 100644 Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift create mode 100644 Package.resolved diff --git a/Examples/DailyPulse/iosApp/generate-kmp-observation.sh b/Examples/DailyPulse/iosApp/generate-kmp-observation.sh new file mode 100755 index 0000000..f29587d --- /dev/null +++ b/Examples/DailyPulse/iosApp/generate-kmp-observation.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# +# Use this file as the content of an Xcode Run Script phase placed before +# Compile Sources. No generated group or file needs to be added manually. +set -eu + +if [ -z "${JAVA_HOME:-}" ]; then + export JAVA_HOME="$(/usr/libexec/java_home -v 17)" +fi + +cd "$SRCROOT/.." +./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-daemon + +GENERATED_DIR="$SRCROOT/iosApp/Generated/KMPObservableBridge" +mkdir -p "$GENERATED_DIR" + +KMP_OBSERVABLE_SDKROOT="$SDKROOT" env -u SDKROOT \ + xcrun swift run \ + --package-path "$SRCROOT/../../.." \ + kmp-observable-bridge-generator \ + --framework "$SRCROOT/../shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/shared.framework" \ + --module shared \ + --output "$GENERATED_DIR/KMPObservableBridge.generated.swift" \ + --xcode-project "$PROJECT_FILE_PATH" \ + --target "$TARGET_NAME" diff --git a/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift b/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift new file mode 100644 index 0000000..f2f1154 --- /dev/null +++ b/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift @@ -0,0 +1,39 @@ +// Generated by KMPObservableBridge. Do not edit. +import KMPObservableBridge +import shared +extension ArticleViewModel: @retroactive KMPAutomaticallyObservable { + public func kmpObserveAutomatically( + notify: @escaping KMPState.Notify, + reportError: @escaping KMPState.ReportError + ) -> KMPObservation { + .group( + KMPState.asyncSequence(\.articleState) + .startObservation( + on: self, + notify: notify, + reportError: reportError + ), + ) + } +} +extension BridgeExampleViewModel: @retroactive KMPAutomaticallyObservable { + public func kmpObserveAutomatically( + notify: @escaping KMPState.Notify, + reportError: @escaping KMPState.ReportError + ) -> KMPObservation { + .group( + KMPState.asyncSequence(\.counterState) + .startObservation( + on: self, + notify: notify, + reportError: reportError + ), + KMPState.asyncSequence(\.messageState) + .startObservation( + on: self, + notify: notify, + reportError: reportError + ), + ) + } +} diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..d190fe4 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "aexml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tadija/AEXML.git", + "state" : { + "revision" : "38f7d00b23ecd891e1ee656fa6aeebd6ba04ecc3", + "version" : "4.6.1" + } + }, + { + "identity" : "pathkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/PathKit.git", + "state" : { + "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", + "version" : "1.0.1" + } + }, + { + "identity" : "spectre", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/Spectre.git", + "state" : { + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" + } + }, + { + "identity" : "xcodeproj", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/XcodeProj.git", + "state" : { + "revision" : "d257c94360b2145f95b71fddda987a7542c4f947", + "version" : "8.24.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 0b518cc..5df38e4 100644 --- a/Package.swift +++ b/Package.swift @@ -20,12 +20,23 @@ let package = Package( targets: ["KMPObservableBridgeGenerator"] ), ], + dependencies: [ + // Used only by the optional generator executable. The bridge runtime + // remains dependency-free. + .package( + url: "https://github.com/tuist/XcodeProj.git", + exact: "8.24.0" + ), + ], targets: [ .target( name: "KMPObservableBridge" ), .executableTarget( - name: "KMPObservableBridgeGenerator" + name: "KMPObservableBridgeGenerator", + dependencies: [ + .product(name: "XcodeProj", package: "XcodeProj"), + ] ), .testTarget( name: "KMPObservableBridgeTests", diff --git a/README.md b/README.md index 8b995be..4fbc388 100644 --- a/README.md +++ b/README.md @@ -474,8 +474,10 @@ struct DetailView: View { } ``` -Add one Run Script phase before Compile Sources. Keep your existing Gradle -framework build first, then invoke the generator: +Add one Run Script phase before Compile Sources. That is the only Xcode setup: +do not create a group, add a generated file, or edit Compile Sources manually. +Keep the existing Gradle framework build first, then invoke the generator with +the current project and target: ```sh set -eu @@ -492,14 +494,40 @@ KMP_OBSERVABLE_SDKROOT="$SDKROOT" env -u SDKROOT \ kmp-observable-bridge-generator \ --framework "$SRCROOT/../shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/shared.framework" \ --module shared \ - --output "$GENERATED_DIR/KMPObservableBridge.generated.swift" -``` - -Create the `Generated/KMPObservableBridge` group in Xcode once, add -`KMPObservableBridge.generated.swift` to the application target's Compile -Sources, and declare the same file under the Run Script's Output Files. Commit -the generated file so a clean checkout has a valid Xcode build graph; each -build replaces it atomically. Do not edit it manually. + --output "$GENERATED_DIR/KMPObservableBridge.generated.swift" \ + --xcode-project "$PROJECT_FILE_PATH" \ + --target "$TARGET_NAME" +``` + +#### What happens on the first build + +1. Gradle builds the current KMP framework. +2. The generator creates `Generated/KMPObservableBridge` only when needed. +3. It extracts the framework's public Swift symbol graph and finds supported + SKIE StateFlow properties. +4. It writes `KMPObservableBridge.generated.swift` atomically. +5. It safely opens the `.xcodeproj`, creates the matching yellow groups, adds + the file to the selected target's Compile Sources, and saves the project. +6. The first build stops with a clear “build again” message. This is required + because Xcode calculates its Swift source list before Run Scripts execute. +7. On the second and every later build, Xcode already knows the file. The + generator updates its contents but does not rewrite `project.pbxproj`. + +Registration is idempotent: deleting only the generated Swift file causes it +to be recreated; removing its Xcode reference causes it to be registered +again. The script never creates duplicate groups, file references, or Compile +Sources entries. Commit the generated file and the one-time project change so +other developers and CI do not encounter the first-build restart. Do not edit +the generated source manually. + +The `XcodeProj` package is linked only into the optional generator executable. +`KMPObservableBridge`, application binaries, and KMP shared modules do not +link or depend on it. + +The DailyPulse repository includes the complete reusable script at +`Examples/DailyPulse/iosApp/generate-kmp-observation.sh`. Its project and target +arguments come from Xcode's `PROJECT_FILE_PATH` and `TARGET_NAME`, so it does +not hard-code an `.xcodeproj` name or application target. This mode is optional. Without the generator, all explicit `state:` and `states:` initializers continue to work exactly as before: diff --git a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md index c5e5fd1..d1287ec 100644 --- a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md +++ b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md @@ -36,8 +36,11 @@ removes the key path without requiring a framework-owned Kotlin superclass. For SKIE frameworks, the optional `kmp-observable-bridge-generator` executable can inspect the framework after its Gradle build and generate the ``KMPAutomaticallyObservable`` conformances. Add its generated Swift file to -the application target before using the same no-state wrapper syntax. Projects -that do not run the generator continue using the explicit `state:` or `states:` +the application target automatically by passing `--xcode-project` and +`--target`. The initial registration intentionally requires one additional +build because Xcode plans Compile Sources before Run Scripts execute. Later +builds update the generated source without rewriting the project. Projects that +do not run the generator continue using explicit `state:` or `states:` initializers. ## Topics diff --git a/Sources/KMPObservableBridgeGenerator/main.swift b/Sources/KMPObservableBridgeGenerator/main.swift index 3eb9048..5dd9149 100644 --- a/Sources/KMPObservableBridgeGenerator/main.swift +++ b/Sources/KMPObservableBridgeGenerator/main.swift @@ -1,4 +1,6 @@ import Foundation +import PathKit +import XcodeProj private struct SymbolGraph: Decodable { struct Metadata: Decodable { @@ -50,6 +52,8 @@ private struct Options { let symbolGraph: URL? let framework: URL? let module: String? + let xcodeProject: URL? + let target: String? let output: URL init(arguments: [String]) throws { @@ -72,12 +76,19 @@ private struct Options { URL(fileURLWithPath: $0) } module = value(after: "--module") + xcodeProject = value(after: "--xcode-project").map { + URL(fileURLWithPath: $0) + } + target = value(after: "--target") self.output = URL(fileURLWithPath: output) guard (symbolGraph != nil && module != nil) || framework != nil else { throw GeneratorError.usage } + guard (xcodeProject == nil) == (target == nil) else { + throw GeneratorError.usage + } } } @@ -87,6 +98,11 @@ private enum GeneratorError: LocalizedError { case noStateFlows case missingEnvironment(String) case processFailed(String, Int32) + case invalidXcodeProject(String) + case targetNotFound(String) + case outputOutsideProject(String) + case sourcesBuildPhaseNotFound(String) + case projectRegistered(String, String) var errorDescription: String? { switch self { @@ -96,7 +112,8 @@ private enum GeneratorError: LocalizedError { kmp-observable-bridge-generator \ --symbol-graph --module --output kmp-observable-bridge-generator \ - --framework [--module ] --output + --framework [--module ] --output \ + [--xcode-project --target ] """ case .unsupportedSymbolGraph(let major): return "Unsupported symbol graph format major version \(major)." @@ -106,6 +123,20 @@ private enum GeneratorError: LocalizedError { return "Required Xcode build environment variable \(name) is missing." case .processFailed(let command, let status): return "\(command) failed with exit status \(status)." + case .invalidXcodeProject(let path): + return "The Xcode project at \(path) has no root project or main group." + case .targetNotFound(let name): + return "The Xcode project does not contain a native target named \(name)." + case .outputOutsideProject(let path): + return "Generated output must be inside the Xcode project's directory: \(path)" + case .sourcesBuildPhaseNotFound(let target): + return "Target \(target) has no Compile Sources build phase." + case .projectRegistered(let file, let target): + return """ + Registered \(file) with target \(target). Xcode planned this build \ + before registration, so this first build stops intentionally. \ + Build again; future generation is automatic. + """ } } } @@ -330,6 +361,90 @@ private func generate(options: Options) throws { } else { try FileManager.default.moveItem(at: temporary, to: options.output) } + + if let project = options.xcodeProject, let target = options.target { + let changed = try installGeneratedSource( + options.output, + in: project, + targetName: target + ) + if changed { + throw GeneratorError.projectRegistered( + options.output.lastPathComponent, + target + ) + } + } +} + +/// Registers the generated source without requiring the developer to edit the +/// project navigator or Compile Sources. Returns `true` only when the project +/// was changed, so normal builds never rewrite `project.pbxproj`. +private func installGeneratedSource( + _ sourceURL: URL, + in projectURL: URL, + targetName: String +) throws -> Bool { + let projectPath = Path(projectURL.standardizedFileURL.path) + let projectDirectory = projectPath.parent() + let sourcePath = Path(sourceURL.standardizedFileURL.path) + guard sourcePath.string.hasPrefix(projectDirectory.string + "/") else { + throw GeneratorError.outputOutsideProject(sourcePath.string) + } + + let xcodeProject = try XcodeProj(path: projectPath) + guard let project = xcodeProject.pbxproj.rootObject, + let mainGroup = project.mainGroup else { + throw GeneratorError.invalidXcodeProject(projectPath.string) + } + guard let target = project.targets + .compactMap({ $0 as? PBXNativeTarget }) + .first(where: { $0.name == targetName }) else { + throw GeneratorError.targetNotFound(targetName) + } + guard let sources = try target.sourcesBuildPhase() else { + throw GeneratorError.sourcesBuildPhaseNotFound(targetName) + } + + let relativePath = String( + sourcePath.string.dropFirst(projectDirectory.string.count + 1) + ) + let groupComponents = Path(relativePath).parent().components + var group = mainGroup + var changed = false + for component in groupComponents where component != "." { + if let existing = group.group(named: component) { + group = existing + } else { + guard let created = try group.addGroup(named: component).last else { + throw GeneratorError.invalidXcodeProject(projectPath.string) + } + group = created + changed = true + } + } + + let file: PBXFileReference + if let existing = group.file(named: sourcePath.lastComponent) { + file = existing + } else { + file = try group.addFile( + at: sourcePath, + sourceRoot: projectDirectory + ) + changed = true + } + + let alreadyCompiled = try target.sourceFiles().contains { $0 === file } + if !alreadyCompiled { + _ = try sources.add(file: file) + changed = true + } + + if changed { + try xcodeProject.write(path: projectPath) + } + return changed } do { From 206d903de9e166b404fc7f83b4987ad59be6ab32 Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 09:15:31 +0400 Subject: [PATCH 4/9] Revert "Automate generated source registration" This reverts commit 03b8034a3cd1bffc692b424258060970aeefc968. --- .../iosApp/generate-kmp-observation.sh | 25 ---- .../KMPObservableBridge.generated.swift | 39 ------ Package.resolved | 41 ------ Package.swift | 13 +- README.md | 48 ++----- .../KMPObservableBridge.md | 7 +- .../KMPObservableBridgeGenerator/main.swift | 117 +----------------- 7 files changed, 14 insertions(+), 276 deletions(-) delete mode 100755 Examples/DailyPulse/iosApp/generate-kmp-observation.sh delete mode 100644 Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift delete mode 100644 Package.resolved diff --git a/Examples/DailyPulse/iosApp/generate-kmp-observation.sh b/Examples/DailyPulse/iosApp/generate-kmp-observation.sh deleted file mode 100755 index f29587d..0000000 --- a/Examples/DailyPulse/iosApp/generate-kmp-observation.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -# -# Use this file as the content of an Xcode Run Script phase placed before -# Compile Sources. No generated group or file needs to be added manually. -set -eu - -if [ -z "${JAVA_HOME:-}" ]; then - export JAVA_HOME="$(/usr/libexec/java_home -v 17)" -fi - -cd "$SRCROOT/.." -./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-daemon - -GENERATED_DIR="$SRCROOT/iosApp/Generated/KMPObservableBridge" -mkdir -p "$GENERATED_DIR" - -KMP_OBSERVABLE_SDKROOT="$SDKROOT" env -u SDKROOT \ - xcrun swift run \ - --package-path "$SRCROOT/../../.." \ - kmp-observable-bridge-generator \ - --framework "$SRCROOT/../shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/shared.framework" \ - --module shared \ - --output "$GENERATED_DIR/KMPObservableBridge.generated.swift" \ - --xcode-project "$PROJECT_FILE_PATH" \ - --target "$TARGET_NAME" diff --git a/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift b/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift deleted file mode 100644 index f2f1154..0000000 --- a/Examples/DailyPulse/iosApp/iosApp/Generated/KMPObservableBridge/KMPObservableBridge.generated.swift +++ /dev/null @@ -1,39 +0,0 @@ -// Generated by KMPObservableBridge. Do not edit. -import KMPObservableBridge -import shared -extension ArticleViewModel: @retroactive KMPAutomaticallyObservable { - public func kmpObserveAutomatically( - notify: @escaping KMPState.Notify, - reportError: @escaping KMPState.ReportError - ) -> KMPObservation { - .group( - KMPState.asyncSequence(\.articleState) - .startObservation( - on: self, - notify: notify, - reportError: reportError - ), - ) - } -} -extension BridgeExampleViewModel: @retroactive KMPAutomaticallyObservable { - public func kmpObserveAutomatically( - notify: @escaping KMPState.Notify, - reportError: @escaping KMPState.ReportError - ) -> KMPObservation { - .group( - KMPState.asyncSequence(\.counterState) - .startObservation( - on: self, - notify: notify, - reportError: reportError - ), - KMPState.asyncSequence(\.messageState) - .startObservation( - on: self, - notify: notify, - reportError: reportError - ), - ) - } -} diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index d190fe4..0000000 --- a/Package.resolved +++ /dev/null @@ -1,41 +0,0 @@ -{ - "pins" : [ - { - "identity" : "aexml", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tadija/AEXML.git", - "state" : { - "revision" : "38f7d00b23ecd891e1ee656fa6aeebd6ba04ecc3", - "version" : "4.6.1" - } - }, - { - "identity" : "pathkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kylef/PathKit.git", - "state" : { - "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", - "version" : "1.0.1" - } - }, - { - "identity" : "spectre", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kylef/Spectre.git", - "state" : { - "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", - "version" : "0.10.1" - } - }, - { - "identity" : "xcodeproj", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tuist/XcodeProj.git", - "state" : { - "revision" : "d257c94360b2145f95b71fddda987a7542c4f947", - "version" : "8.24.0" - } - } - ], - "version" : 2 -} diff --git a/Package.swift b/Package.swift index 5df38e4..0b518cc 100644 --- a/Package.swift +++ b/Package.swift @@ -20,23 +20,12 @@ let package = Package( targets: ["KMPObservableBridgeGenerator"] ), ], - dependencies: [ - // Used only by the optional generator executable. The bridge runtime - // remains dependency-free. - .package( - url: "https://github.com/tuist/XcodeProj.git", - exact: "8.24.0" - ), - ], targets: [ .target( name: "KMPObservableBridge" ), .executableTarget( - name: "KMPObservableBridgeGenerator", - dependencies: [ - .product(name: "XcodeProj", package: "XcodeProj"), - ] + name: "KMPObservableBridgeGenerator" ), .testTarget( name: "KMPObservableBridgeTests", diff --git a/README.md b/README.md index 4fbc388..8b995be 100644 --- a/README.md +++ b/README.md @@ -474,10 +474,8 @@ struct DetailView: View { } ``` -Add one Run Script phase before Compile Sources. That is the only Xcode setup: -do not create a group, add a generated file, or edit Compile Sources manually. -Keep the existing Gradle framework build first, then invoke the generator with -the current project and target: +Add one Run Script phase before Compile Sources. Keep your existing Gradle +framework build first, then invoke the generator: ```sh set -eu @@ -494,40 +492,14 @@ KMP_OBSERVABLE_SDKROOT="$SDKROOT" env -u SDKROOT \ kmp-observable-bridge-generator \ --framework "$SRCROOT/../shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/shared.framework" \ --module shared \ - --output "$GENERATED_DIR/KMPObservableBridge.generated.swift" \ - --xcode-project "$PROJECT_FILE_PATH" \ - --target "$TARGET_NAME" -``` - -#### What happens on the first build - -1. Gradle builds the current KMP framework. -2. The generator creates `Generated/KMPObservableBridge` only when needed. -3. It extracts the framework's public Swift symbol graph and finds supported - SKIE StateFlow properties. -4. It writes `KMPObservableBridge.generated.swift` atomically. -5. It safely opens the `.xcodeproj`, creates the matching yellow groups, adds - the file to the selected target's Compile Sources, and saves the project. -6. The first build stops with a clear “build again” message. This is required - because Xcode calculates its Swift source list before Run Scripts execute. -7. On the second and every later build, Xcode already knows the file. The - generator updates its contents but does not rewrite `project.pbxproj`. - -Registration is idempotent: deleting only the generated Swift file causes it -to be recreated; removing its Xcode reference causes it to be registered -again. The script never creates duplicate groups, file references, or Compile -Sources entries. Commit the generated file and the one-time project change so -other developers and CI do not encounter the first-build restart. Do not edit -the generated source manually. - -The `XcodeProj` package is linked only into the optional generator executable. -`KMPObservableBridge`, application binaries, and KMP shared modules do not -link or depend on it. - -The DailyPulse repository includes the complete reusable script at -`Examples/DailyPulse/iosApp/generate-kmp-observation.sh`. Its project and target -arguments come from Xcode's `PROJECT_FILE_PATH` and `TARGET_NAME`, so it does -not hard-code an `.xcodeproj` name or application target. + --output "$GENERATED_DIR/KMPObservableBridge.generated.swift" +``` + +Create the `Generated/KMPObservableBridge` group in Xcode once, add +`KMPObservableBridge.generated.swift` to the application target's Compile +Sources, and declare the same file under the Run Script's Output Files. Commit +the generated file so a clean checkout has a valid Xcode build graph; each +build replaces it atomically. Do not edit it manually. This mode is optional. Without the generator, all explicit `state:` and `states:` initializers continue to work exactly as before: diff --git a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md index d1287ec..c5e5fd1 100644 --- a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md +++ b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md @@ -36,11 +36,8 @@ removes the key path without requiring a framework-owned Kotlin superclass. For SKIE frameworks, the optional `kmp-observable-bridge-generator` executable can inspect the framework after its Gradle build and generate the ``KMPAutomaticallyObservable`` conformances. Add its generated Swift file to -the application target automatically by passing `--xcode-project` and -`--target`. The initial registration intentionally requires one additional -build because Xcode plans Compile Sources before Run Scripts execute. Later -builds update the generated source without rewriting the project. Projects that -do not run the generator continue using explicit `state:` or `states:` +the application target before using the same no-state wrapper syntax. Projects +that do not run the generator continue using the explicit `state:` or `states:` initializers. ## Topics diff --git a/Sources/KMPObservableBridgeGenerator/main.swift b/Sources/KMPObservableBridgeGenerator/main.swift index 5dd9149..3eb9048 100644 --- a/Sources/KMPObservableBridgeGenerator/main.swift +++ b/Sources/KMPObservableBridgeGenerator/main.swift @@ -1,6 +1,4 @@ import Foundation -import PathKit -import XcodeProj private struct SymbolGraph: Decodable { struct Metadata: Decodable { @@ -52,8 +50,6 @@ private struct Options { let symbolGraph: URL? let framework: URL? let module: String? - let xcodeProject: URL? - let target: String? let output: URL init(arguments: [String]) throws { @@ -76,19 +72,12 @@ private struct Options { URL(fileURLWithPath: $0) } module = value(after: "--module") - xcodeProject = value(after: "--xcode-project").map { - URL(fileURLWithPath: $0) - } - target = value(after: "--target") self.output = URL(fileURLWithPath: output) guard (symbolGraph != nil && module != nil) || framework != nil else { throw GeneratorError.usage } - guard (xcodeProject == nil) == (target == nil) else { - throw GeneratorError.usage - } } } @@ -98,11 +87,6 @@ private enum GeneratorError: LocalizedError { case noStateFlows case missingEnvironment(String) case processFailed(String, Int32) - case invalidXcodeProject(String) - case targetNotFound(String) - case outputOutsideProject(String) - case sourcesBuildPhaseNotFound(String) - case projectRegistered(String, String) var errorDescription: String? { switch self { @@ -112,8 +96,7 @@ private enum GeneratorError: LocalizedError { kmp-observable-bridge-generator \ --symbol-graph --module --output kmp-observable-bridge-generator \ - --framework [--module ] --output \ - [--xcode-project --target ] + --framework [--module ] --output """ case .unsupportedSymbolGraph(let major): return "Unsupported symbol graph format major version \(major)." @@ -123,20 +106,6 @@ private enum GeneratorError: LocalizedError { return "Required Xcode build environment variable \(name) is missing." case .processFailed(let command, let status): return "\(command) failed with exit status \(status)." - case .invalidXcodeProject(let path): - return "The Xcode project at \(path) has no root project or main group." - case .targetNotFound(let name): - return "The Xcode project does not contain a native target named \(name)." - case .outputOutsideProject(let path): - return "Generated output must be inside the Xcode project's directory: \(path)" - case .sourcesBuildPhaseNotFound(let target): - return "Target \(target) has no Compile Sources build phase." - case .projectRegistered(let file, let target): - return """ - Registered \(file) with target \(target). Xcode planned this build \ - before registration, so this first build stops intentionally. \ - Build again; future generation is automatic. - """ } } } @@ -361,90 +330,6 @@ private func generate(options: Options) throws { } else { try FileManager.default.moveItem(at: temporary, to: options.output) } - - if let project = options.xcodeProject, let target = options.target { - let changed = try installGeneratedSource( - options.output, - in: project, - targetName: target - ) - if changed { - throw GeneratorError.projectRegistered( - options.output.lastPathComponent, - target - ) - } - } -} - -/// Registers the generated source without requiring the developer to edit the -/// project navigator or Compile Sources. Returns `true` only when the project -/// was changed, so normal builds never rewrite `project.pbxproj`. -private func installGeneratedSource( - _ sourceURL: URL, - in projectURL: URL, - targetName: String -) throws -> Bool { - let projectPath = Path(projectURL.standardizedFileURL.path) - let projectDirectory = projectPath.parent() - let sourcePath = Path(sourceURL.standardizedFileURL.path) - guard sourcePath.string.hasPrefix(projectDirectory.string + "/") else { - throw GeneratorError.outputOutsideProject(sourcePath.string) - } - - let xcodeProject = try XcodeProj(path: projectPath) - guard let project = xcodeProject.pbxproj.rootObject, - let mainGroup = project.mainGroup else { - throw GeneratorError.invalidXcodeProject(projectPath.string) - } - guard let target = project.targets - .compactMap({ $0 as? PBXNativeTarget }) - .first(where: { $0.name == targetName }) else { - throw GeneratorError.targetNotFound(targetName) - } - guard let sources = try target.sourcesBuildPhase() else { - throw GeneratorError.sourcesBuildPhaseNotFound(targetName) - } - - let relativePath = String( - sourcePath.string.dropFirst(projectDirectory.string.count + 1) - ) - let groupComponents = Path(relativePath).parent().components - var group = mainGroup - var changed = false - for component in groupComponents where component != "." { - if let existing = group.group(named: component) { - group = existing - } else { - guard let created = try group.addGroup(named: component).last else { - throw GeneratorError.invalidXcodeProject(projectPath.string) - } - group = created - changed = true - } - } - - let file: PBXFileReference - if let existing = group.file(named: sourcePath.lastComponent) { - file = existing - } else { - file = try group.addFile( - at: sourcePath, - sourceRoot: projectDirectory - ) - changed = true - } - - let alreadyCompiled = try target.sourceFiles().contains { $0 === file } - if !alreadyCompiled { - _ = try sources.add(file: file) - changed = true - } - - if changed { - try xcodeProject.write(path: projectPath) - } - return changed } do { From 8989a52b03d69b71830785d40611368fe5c0a343 Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 10:11:07 +0400 Subject: [PATCH 5/9] Add opt-in automatic SKIE state discovery --- .../iosApp/iosApp/ContentView.swift | 31 +- README.md | 96 ++-- .../KMPAutomaticStateFlowRuntime.swift | 467 ++++++++++++++++++ .../KMPObservableBridge.md | 20 +- .../KMPObservedObject.swift | 83 +++- Sources/KMPObservableBridge/KMPPolicies.swift | 10 + .../KMPObservableBridge/KMPStateObject.swift | 46 ++ .../KMPObservableBridge/KMPViewModel.swift | 22 +- .../KMPObservableBridgeTests.swift | 16 + 9 files changed, 707 insertions(+), 84 deletions(-) create mode 100644 Sources/KMPObservableBridge/KMPAutomaticStateFlowRuntime.swift diff --git a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift index f1a8a4f..66e8372 100644 --- a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift +++ b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift @@ -54,7 +54,7 @@ private struct ArticleInjectorExampleView: View { @KMPStateObject( injector: ArticleInjector(), viewModel: \.articleViewModel, - state: \.articleState + observation: .automaticSKIE ) private var article @@ -67,12 +67,13 @@ private struct ArticleInjectorExampleView: View { } private struct ArticleContentView: View { - @KMPObservedObject private var article: ArticleViewModel + @KMPObservedObject(observation: .automaticSKIE) + private var article: ArticleViewModel init(viewModel: ArticleViewModel) { _article = KMPObservedObject( viewModel, - state: \.articleState + observation: .automaticSKIE ) } @@ -96,11 +97,8 @@ private struct ArticleContentView: View { } private struct DirectStateObjectExampleView: View { - @KMPStateObject( - wrappedValue: BridgeExampleViewModel(), - states: \.counterState, \.messageState - ) - private var example + @KMPStateObject(observation: .automaticSKIE) + private var example = BridgeExampleViewModel() var body: some View { NavigationView { @@ -115,11 +113,8 @@ private struct DirectStateObjectExampleView: View { } private struct ObservedObjectExampleView: View { - @KMPStateObject( - wrappedValue: BridgeExampleViewModel(), - states: \.counterState, \.messageState - ) - private var example + @KMPStateObject(observation: .automaticSKIE) + private var example = BridgeExampleViewModel() var body: some View { NavigationView { @@ -163,12 +158,13 @@ private struct BridgeExampleEnvironmentView: View { } private struct BridgeExampleChildView: View { - @KMPObservedObject private var example: BridgeExampleViewModel + @KMPObservedObject(observation: .automaticSKIE) + private var example: BridgeExampleViewModel init(viewModel: BridgeExampleViewModel) { _example = KMPObservedObject( viewModel, - states: \.counterState, \.messageState + observation: .automaticSKIE ) } @@ -193,7 +189,8 @@ private struct BridgeExampleChildView: View { } private struct BridgeExampleContentView: View { - @KMPObservedObject private var example: BridgeExampleViewModel + @KMPObservedObject(observation: .automaticSKIE) + private var example: BridgeExampleViewModel let title: String let subtitle: String @@ -207,7 +204,7 @@ private struct BridgeExampleContentView: View { self.subtitle = subtitle _example = KMPObservedObject( viewModel, - states: \.counterState, \.messageState + observation: .automaticSKIE ) } diff --git a/README.md b/README.md index 8b995be..4988587 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,8 @@ private var profile ```swift struct ProfileContent: View { - @KMPObservedObject private var profile: ProfileViewModel + @KMPObservedObject(observation: .automaticSKIE) + private var profile: ProfileViewModel init(viewModel: ProfileViewModel) { _profile = KMPObservedObject(viewModel, state: \.state) @@ -454,55 +455,38 @@ let state = profile.profileStateValue This direct path keeps the bridge core independent of the NativeCoroutines Swift package while propagating cancellation to the Kotlin collection. -### Optional automatic SKIE observation +### Experimental automatic SKIE observation -SKIE users can remove state key paths from the property-wrapper declaration by -running the package's generator after the shared framework is built. The -generator inspects the compiled framework, finds public SKIE `StateFlow` -properties, and emits `KMPAutomaticallyObservable` conformances: +SKIE users can explicitly opt into omitting state key paths. KMPObservableBridge +installs a guarded Objective-C runtime hook on the Kotlin ViewModel and lazily +observes each `StateFlow` when application code first reads its getter: ```swift -@KMPStateObject +@KMPStateObject(observation: .automaticSKIE) private var profile = ProfileViewModel() struct DetailView: View { @KMPObservedObject private var profile: ProfileViewModel init(profile: ProfileViewModel) { - _profile = KMPObservedObject(wrappedValue: profile) + _profile = KMPObservedObject( + profile, + observation: .automaticSKIE + ) } } ``` -Add one Run Script phase before Compile Sources. Keep your existing Gradle -framework build first, then invoke the generator: - -```sh -set -eu +There is no generated file, build script, Swift extension, `$` registration, +or Kotlin bridge dependency. Kotlin/Native does not export property metadata, +so the bridge does not enumerate or invoke properties speculatively. It wraps +eligible Objective-C getters and inspects only values returned by getters the +view naturally accesses. Non-flow values are ignored, repeated reads are +deduplicated, and a getter returning a replacement flow cancels the old +collection before observing the new identity. -cd "$SRCROOT/.." -./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-daemon - -GENERATED_DIR="$SRCROOT/iosApp/Generated/KMPObservableBridge" -mkdir -p "$GENERATED_DIR" - -KMP_OBSERVABLE_SDKROOT="$SDKROOT" env -u SDKROOT \ - xcrun swift run \ - --package-path "/path/to/KMPObservableBridge" \ - kmp-observable-bridge-generator \ - --framework "$SRCROOT/../shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/shared.framework" \ - --module shared \ - --output "$GENERATED_DIR/KMPObservableBridge.generated.swift" -``` - -Create the `Generated/KMPObservableBridge` group in Xcode once, add -`KMPObservableBridge.generated.swift` to the application target's Compile -Sources, and declare the same file under the Run Script's Output Files. Commit -the generated file so a clean checkout has a valid Xcode build graph; each -build replaces it atomically. Do not edit it manually. - -This mode is optional. Without the generator, all explicit `state:` and -`states:` initializers continue to work exactly as before: +Only accessed flows are observed. Explicit `state:` and `states:` remain the +deterministic fallback and let an application intentionally select a subset: ```swift @KMPStateObject( @@ -512,9 +496,34 @@ This mode is optional. Without the generator, all explicit `state:` and private var profile ``` -The generator observes every supported public SKIE StateFlow property on each -exported Kotlin class. Use explicit key paths when you intentionally want to -observe only a subset. +#### Why automatic SKIE observation is opt-in + +The syntax is convenient, but the mechanism has stricter compatibility and +debugging tradeoffs than explicit key paths: + +- It uses process-wide Objective-C method interception for exported Kotlin + getters. +- It dynamically calls SKIE's generated `SkieColdFlowIterator` ABI, which SKIE + does not document as a stable third-party runtime API. +- A future Kotlin/Native or SKIE release may change generated names or method + signatures. +- Another runtime library could intercept the same getter. +- Only flows whose getters are actually read are observed, so conditional UI + can establish subscriptions at different times. +- Failures caused by an incompatible generated ABI are harder to diagnose than + statically typed `AsyncSequence` key paths. + +The runtime checks the framework image, protocol, iterator class, and required +selectors before activating. If compatibility cannot be established, +discovery safely does nothing. It never invokes unknown Kotlin getters or +methods merely to inspect a model, deduplicates repeated reads, cancels +replaced flows, and treats lifecycle cancellation as non-failure. + +Use `.automaticSKIE` when its reduced declaration ceremony is worth those +tradeoffs and the application's Kotlin/SKIE matrix is tested in CI. Use +explicit `state:` or `states:` for maximum production stability. This path +applies only to SKIE `StateFlow`; KMP-NativeCoroutines uses the separate +structural `NativeFlow` integration below. ### Optional automatic KMP-NativeCoroutines observation @@ -568,11 +577,10 @@ never disposes an externally owned Kotlin model. into writable Swift state. Its canonical flow only invalidates SwiftUI; reads and mutations still go directly to the Kotlin model. -Choose the explicit `state:` form for arbitrary existing models and the -automatic form when the shared model can expose this convention. Achieving -zero-configuration observation without either contract would require a Kotlin -runtime/base class such as KMP-ObservableViewModel; the bridge deliberately -does not pretend Swift reflection can provide that behavior. +Choose the explicit `state:` form for arbitrary existing models and +KMP-NativeCoroutines models without a canonical observation flow. Automatic +SKIE observation uses lazy getter interception rather than Swift reflection: +`Mirror` cannot enumerate Kotlin/Native computed getters. `Examples/DailyPulse` compiles both the SKIE and KMP-NativeCoroutines paths against a generated Kotlin framework. diff --git a/Sources/KMPObservableBridge/KMPAutomaticStateFlowRuntime.swift b/Sources/KMPObservableBridge/KMPAutomaticStateFlowRuntime.swift new file mode 100644 index 0000000..35cf2db --- /dev/null +++ b/Sources/KMPObservableBridge/KMPAutomaticStateFlowRuntime.swift @@ -0,0 +1,467 @@ +#if canImport(ObjectiveC) +import Foundation +import ObjectiveC.runtime + +/// Runtime support for lazily observing SKIE-exported StateFlows. +/// +/// Kotlin/Native does not publish Objective-C property metadata for Kotlin +/// properties. It does, however, publish their getters as Objective-C methods. +/// This runtime wraps eligible getters once per class and inspects only values +/// returned by getters the application naturally calls. It never invokes an +/// unknown Kotlin method speculatively. +@MainActor +enum KMPAutomaticStateFlowRuntime { + static func observe( + _ model: AnyObject, + notify: @escaping @Sendable () -> Void, + reportError: @escaping @Sendable (Error) -> Void + ) -> KMPObservation { + guard let hub = KMPStateFlowRuntimeRegistry.shared.hub(for: model) else { + return .empty + } + + let listener = hub.addListener( + notify: notify, + reportError: reportError + ) + return KMPObservation { + hub.removeListener(listener) + } + } +} + +private final class KMPStateFlowRuntimeRegistry: @unchecked Sendable { + static let shared = KMPStateFlowRuntimeRegistry() + + private struct MethodKey: Hashable { + let type: ObjectIdentifier + let selector: Selector + } + + private let lock = NSRecursiveLock() + private var installedMethods: Set = [] + private var replacementImplementations: [MethodKey: IMP] = [:] + private var associationKey: UInt8 = 0 + + private init() {} + + func hub(for model: AnyObject) -> KMPStateFlowObservationHub? { + lock.withLock { + if let existing = objc_getAssociatedObject( + model, + &associationKey + ) as? KMPStateFlowObservationHub { + return existing + } + + guard + let modelClass: AnyClass = object_getClass(model), + let descriptor = runtimeDescriptor(for: modelClass) + else { + return nil + } + + installInterceptors(on: modelClass, descriptor: descriptor) + let hub = KMPStateFlowObservationHub(descriptor: descriptor) + objc_setAssociatedObject( + model, + &associationKey, + hub, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return hub + } + } + + private func runtimeDescriptor( + for modelClass: AnyClass + ) -> KMPStateFlowRuntimeDescriptor? { + guard let modelImage = class_getImageName(modelClass) else { + return nil + } + + var protocolCount: UInt32 = 0 + guard let protocols = objc_copyProtocolList(&protocolCount) else { + return nil + } + defer { free(UnsafeMutableRawPointer(protocols)) } + + for index in 0.. AnyObject? + let original = unsafeBitCast( + method_getImplementation(method), + to: Getter.self + ) + let replacement: @convention(block) (AnyObject) -> AnyObject? = { + [weak self] object in + let value = original(object, selector) + if let value, + class_conformsToProtocol( + object_getClass(value), + descriptor.stateFlowProtocol + ), + let hub = self?.existingHub(for: object) { + hub.discover( + flow: value, + selector: selector + ) + } + return value + } + + let implementation = imp_implementationWithBlock(replacement) + replacementImplementations[key] = implementation + method_setImplementation(method, implementation) + } + } + + private func existingHub( + for model: AnyObject + ) -> KMPStateFlowObservationHub? { + lock.withLock { + objc_getAssociatedObject( + model, + &associationKey + ) as? KMPStateFlowObservationHub + } + } + + private func isObjectReturningZeroArgumentMethod(_ method: Method) -> Bool { + guard method_getNumberOfArguments(method) == 2 else { + return false + } + let returnType = method_copyReturnType(method) + defer { free(returnType) } + return String(cString: returnType).first == "@" + } +} + +private struct KMPStateFlowRuntimeDescriptor: @unchecked Sendable { + let stateFlowProtocol: Protocol + let iteratorClass: AnyClass +} + +private final class KMPStateFlowObservationHub: @unchecked Sendable { + typealias ListenerID = UUID + + private struct Listener { + let notify: @Sendable () -> Void + let reportError: @Sendable (Error) -> Void + } + + private struct ActiveFlow { + let identity: ObjectIdentifier + let observation: KMPDynamicSKIEObservation + } + + private let descriptor: KMPStateFlowRuntimeDescriptor + private let lock = NSRecursiveLock() + private var listeners: [ListenerID: Listener] = [:] + private var activeFlows: [Selector: ActiveFlow] = [:] + + init(descriptor: KMPStateFlowRuntimeDescriptor) { + self.descriptor = descriptor + } + + deinit { + let observations = lock.withLock { + let current = activeFlows.values.map(\.observation) + activeFlows.removeAll() + listeners.removeAll() + return current + } + observations.forEach { $0.cancel() } + } + + func addListener( + notify: @escaping @Sendable () -> Void, + reportError: @escaping @Sendable (Error) -> Void + ) -> ListenerID { + lock.withLock { + let id = ListenerID() + listeners[id] = Listener( + notify: notify, + reportError: reportError + ) + return id + } + } + + func removeListener(_ id: ListenerID) { + let observations: [KMPDynamicSKIEObservation] = lock.withLock { + listeners.removeValue(forKey: id) + guard listeners.isEmpty else { + return [] + } + let current = activeFlows.values.map(\.observation) + activeFlows.removeAll() + return current + } + observations.forEach { $0.cancel() } + } + + func discover(flow: AnyObject, selector: Selector) { + let identity = ObjectIdentifier(flow) + let oldObservation: KMPDynamicSKIEObservation? = lock.withLock { + if activeFlows[selector]?.identity == identity { + return nil + } + + let old = activeFlows.removeValue(forKey: selector)?.observation + guard !listeners.isEmpty, + let observation = KMPDynamicSKIEObservation( + flow: flow, + iteratorClass: descriptor.iteratorClass, + onValue: { [weak self] in + self?.notifyListeners() + }, + onError: { [weak self] error in + self?.report(error) + } + ) else { + return old + } + activeFlows[selector] = ActiveFlow( + identity: identity, + observation: observation + ) + observation.start() + return old + } + oldObservation?.cancel() + } + + private func notifyListeners() { + let callbacks = lock.withLock { + listeners.values.map(\.notify) + } + callbacks.forEach { $0() } + } + + private func report(_ error: Error) { + let callbacks = lock.withLock { + listeners.values.map(\.reportError) + } + callbacks.forEach { $0(error) } + } +} + +private final class KMPDynamicSKIEObservation: @unchecked Sendable { + private typealias Alloc = @convention(c) ( + AnyClass, + Selector + ) -> AnyObject + private typealias Initialize = @convention(c) ( + AnyObject, + Selector, + AnyObject + ) -> AnyObject + private typealias HasNext = @convention(c) ( + AnyObject, + Selector, + @convention(block) (NSNumber?, NSError?) -> Void + ) -> Void + private typealias Next = @convention(c) ( + AnyObject, + Selector + ) -> AnyObject? + private typealias Cancel = @convention(c) ( + AnyObject, + Selector + ) -> Void + + private let lock = NSRecursiveLock() + private let iterator: AnyObject + private let hasNext: HasNext + private let next: Next + private let cancelIterator: Cancel + private let onValue: @Sendable () -> Void + private let onError: @Sendable (Error) -> Void + private var isCancelled = false + + init?( + flow: AnyObject, + iteratorClass: AnyClass, + onValue: @escaping @Sendable () -> Void, + onError: @escaping @Sendable (Error) -> Void + ) { + let allocSelector = NSSelectorFromString("alloc") + let initializeSelector = NSSelectorFromString("initWithFlow:") + let hasNextSelector = NSSelectorFromString( + "hasNextWithCompletionHandler:" + ) + let nextSelector = NSSelectorFromString("next") + let cancelSelector = NSSelectorFromString("cancel") + + guard + let allocMethod = class_getClassMethod( + iteratorClass, + allocSelector + ), + let initializeMethod = class_getInstanceMethod( + iteratorClass, + initializeSelector + ), + let hasNextMethod = class_getInstanceMethod( + iteratorClass, + hasNextSelector + ), + let nextMethod = class_getInstanceMethod( + iteratorClass, + nextSelector + ), + let cancelMethod = class_getInstanceMethod( + iteratorClass, + cancelSelector + ) + else { + return nil + } + + let allocate = unsafeBitCast( + method_getImplementation(allocMethod), + to: Alloc.self + ) + let initialize = unsafeBitCast( + method_getImplementation(initializeMethod), + to: Initialize.self + ) + let allocated = allocate(iteratorClass, allocSelector) + iterator = initialize(allocated, initializeSelector, flow) + hasNext = unsafeBitCast( + method_getImplementation(hasNextMethod), + to: HasNext.self + ) + next = unsafeBitCast( + method_getImplementation(nextMethod), + to: Next.self + ) + cancelIterator = unsafeBitCast( + method_getImplementation(cancelMethod), + to: Cancel.self + ) + self.onValue = onValue + self.onError = onError + } + + func start() { + requestNext() + } + + func cancel() { + let shouldCancel = lock.withLock { + guard !isCancelled else { + return false + } + isCancelled = true + return true + } + if shouldCancel { + cancelIterator(iterator, NSSelectorFromString("cancel")) + } + } + + private func requestNext() { + guard !lock.withLock({ isCancelled }) else { + return + } + + let completion: @convention(block) (NSNumber?, NSError?) -> Void = { + [weak self] hasNext, error in + guard let self else { + return + } + DispatchQueue.main.async { + self.handle(hasNext: hasNext, error: error) + } + } + hasNext( + iterator, + NSSelectorFromString("hasNextWithCompletionHandler:"), + completion + ) + } + + private func handle(hasNext: NSNumber?, error: NSError?) { + guard !lock.withLock({ isCancelled }) else { + return + } + + if let error { + if !isCancellation(error) { + onError(error) + } + return + } + guard hasNext?.boolValue == true else { + return + } + + _ = next(iterator, NSSelectorFromString("next")) + onValue() + requestNext() + } + + private func isCancellation(_ error: NSError) -> Bool { + if let exception = error.userInfo["KotlinException"] { + let description = String(reflecting: type(of: exception)) + + " " + + String(describing: exception) + return description.contains("CancellationException") + } + return false + } +} +#endif diff --git a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md index c5e5fd1..e5c7c22 100644 --- a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md +++ b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md @@ -10,11 +10,8 @@ external model, and ``KMPEnvironmentObject`` for a projected store injected by an ancestor. ```swift -@KMPStateObject( - wrappedValue: ProfileViewModel(), - state: \.state -) -private var profile +@KMPStateObject(observation: .automaticSKIE) +private var profile = ProfileViewModel() ``` Kotlin remains the source of truth. The bridge observes emissions but never @@ -33,12 +30,12 @@ private var profile = ProfileViewModel() The explicit `state:` form remains the universal option. The automatic form removes the key path without requiring a framework-owned Kotlin superclass. -For SKIE frameworks, the optional `kmp-observable-bridge-generator` executable -can inspect the framework after its Gradle build and generate the -``KMPAutomaticallyObservable`` conformances. Add its generated Swift file to -the application target before using the same no-state wrapper syntax. Projects -that do not run the generator continue using the explicit `state:` or `states:` -initializers. +For SKIE frameworks, explicitly selecting `.automaticSKIE` lets wrappers +lazily discover public StateFlows when their exported getters are naturally +read. This requires no generated file or build script, but is experimental +because it uses Objective-C method interception and SKIE's generated iterator +ABI. The explicit `state:` and `states:` initializers remain the stable +defaults and allow selecting a specific subset. ## Topics @@ -61,6 +58,7 @@ initializers. - ``KMPObservation`` - ``KMPUpdatePolicy`` - ``KMPObservationFailurePolicy`` +- ``KMPAutomaticObservation`` - ``KMPNativeObservable`` - ``KMPAutomaticallyObservable`` - ``KMPNativeFlow`` diff --git a/Sources/KMPObservableBridge/KMPObservedObject.swift b/Sources/KMPObservableBridge/KMPObservedObject.swift index 11c8531..fdddc4f 100644 --- a/Sources/KMPObservableBridge/KMPObservedObject.swift +++ b/Sources/KMPObservableBridge/KMPObservedObject.swift @@ -1,19 +1,66 @@ +import Combine import SwiftUI +@MainActor +private final class KMPObservedObjectHolder: + @preconcurrency ObservableObject +{ + let objectWillChange = ObservableObjectPublisher() + let store: KMPViewModelStore? + private var forwarding: AnyCancellable? + + init(store: KMPViewModelStore?) { + self.store = store + forwarding = store?.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + } +} + /// Observes a KMP ViewModel owned outside this SwiftUI view. @MainActor @propertyWrapper public struct KMPObservedObject: @preconcurrency DynamicProperty { - @StateObject private var storage: KMPViewModelStore - private let input: ViewModel + @StateObject private var holder: KMPObservedObjectHolder + private let input: ViewModel? private let states: [KMPState] public var wrappedValue: ViewModel { - storage.value + requiredStore.value } public var projectedValue: KMPViewModelStore { - storage + requiredStore + } + + /// Declares deferred experimental SKIE observation. + /// + /// Assign the backing wrapper in the enclosing view's initializer. Access + /// before that assignment is a programmer error and traps with a message. + public init( + observation: KMPAutomaticObservation + ) { + input = nil + states = [] + _holder = StateObject( + wrappedValue: KMPObservedObjectHolder(store: nil) + ) + } + + /// Observes an external model using an explicitly selected integration. + public init( + _ viewModel: ViewModel, + observation: KMPAutomaticObservation, + updatePolicy: KMPUpdatePolicy = .coalesced, + failurePolicy: KMPObservationFailurePolicy = .log + ) { + self.init( + viewModel, + adapterArray: [], + updatePolicy: updatePolicy, + failurePolicy: failurePolicy, + automaticStateFlowDiscovery: true + ) } public init( @@ -184,23 +231,39 @@ public struct KMPObservedObject: @preconcurrency DynamicPr _ viewModel: ViewModel, adapterArray: [KMPState], updatePolicy: KMPUpdatePolicy, - failurePolicy: KMPObservationFailurePolicy + failurePolicy: KMPObservationFailurePolicy, + automaticStateFlowDiscovery: Bool = false ) { input = viewModel states = adapterArray - _storage = StateObject( - wrappedValue: KMPViewModelStore( + let store = KMPViewModelStore( viewModel, states: adapterArray, updatePolicy: updatePolicy, failurePolicy: failurePolicy, - ownsModel: false + ownsModel: false, + automaticStateFlowDiscovery: automaticStateFlowDiscovery ) + _holder = StateObject( + wrappedValue: KMPObservedObjectHolder(store: store) ) } public mutating func update() { - _storage.update() - storage.rebind(to: input, states: states) + _holder.update() + guard let input, let store = holder.store else { + return + } + store.rebind(to: input, states: states) + } + + private var requiredStore: KMPViewModelStore { + guard let store = holder.store else { + preconditionFailure( + "KMPObservedObject must be initialized by assigning its " + + "backing storage in the enclosing view initializer." + ) + } + return store } } diff --git a/Sources/KMPObservableBridge/KMPPolicies.swift b/Sources/KMPObservableBridge/KMPPolicies.swift index a4def2c..8a4d0a2 100644 --- a/Sources/KMPObservableBridge/KMPPolicies.swift +++ b/Sources/KMPObservableBridge/KMPPolicies.swift @@ -1,4 +1,14 @@ import Foundation + +/// Selects an optional automatic interoperability mechanism. +public enum KMPAutomaticObservation { + /// Lazily discovers SKIE StateFlows through their Objective-C getters. + /// + /// This is an experimental compatibility mode because it relies on + /// Objective-C method interception and SKIE's generated iterator ABI. + /// Prefer explicit `state:` or `states:` key paths for maximum stability. + case automaticSKIE +} import OSLog /// Controls how frequently state emissions invalidate SwiftUI. diff --git a/Sources/KMPObservableBridge/KMPStateObject.swift b/Sources/KMPObservableBridge/KMPStateObject.swift index f8927fb..2f8840a 100644 --- a/Sources/KMPObservableBridge/KMPStateObject.swift +++ b/Sources/KMPObservableBridge/KMPStateObject.swift @@ -14,6 +14,52 @@ public struct KMPStateObject: DynamicProperty { storage } + /// Creates a model using an explicitly selected automatic integration. + /// + /// `.automaticSKIE` is experimental. Explicit `state:`, `states:`, and + /// `adapters:` initializers are the production-stable alternatives. + public init( + wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, + observation: KMPAutomaticObservation, + updatePolicy: KMPUpdatePolicy = .coalesced, + failurePolicy: KMPObservationFailurePolicy = .log, + dispose: (@MainActor (ViewModel) -> Void)? = nil + ) { + _storage = StateObject( + wrappedValue: KMPViewModelStore( + makeViewModel(), + states: [], + updatePolicy: updatePolicy, + failurePolicy: failurePolicy, + ownsModel: true, + disposer: dispose, + automaticStateFlowDiscovery: true + ) + ) + } + + /// Resolves a model using an explicitly selected automatic integration. + public init( + injector makeInjector: @autoclosure @escaping () -> Injector, + viewModel keyPath: KeyPath, + observation: KMPAutomaticObservation, + updatePolicy: KMPUpdatePolicy = .coalesced, + failurePolicy: KMPObservationFailurePolicy = .log, + dispose: (@MainActor (ViewModel) -> Void)? = nil + ) { + _storage = StateObject( + wrappedValue: KMPViewModelStore( + makeInjector()[keyPath: keyPath], + states: [], + updatePolicy: updatePolicy, + failurePolicy: failurePolicy, + ownsModel: true, + disposer: dispose, + automaticStateFlowDiscovery: true + ) + ) + } + /// Creates a model using its build-time generated observation list. public init( wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, diff --git a/Sources/KMPObservableBridge/KMPViewModel.swift b/Sources/KMPObservableBridge/KMPViewModel.swift index 63abee5..93d36f5 100644 --- a/Sources/KMPObservableBridge/KMPViewModel.swift +++ b/Sources/KMPObservableBridge/KMPViewModel.swift @@ -31,6 +31,7 @@ public final class KMPViewModelStore: @preconcurrency Obse private var pendingChange: Task? private var modernRevision: AnyObject? private let modernObservationEnabled: Bool + private let automaticStateFlowDiscovery: Bool init( _ wrappedValue: ViewModel, @@ -39,12 +40,14 @@ public final class KMPViewModelStore: @preconcurrency Obse failurePolicy: KMPObservationFailurePolicy, ownsModel: Bool, disposer: Disposer? = nil, - modernObservationEnabled: Bool = true + modernObservationEnabled: Bool = true, + automaticStateFlowDiscovery: Bool = false ) { self.wrappedValue = wrappedValue self.updatePolicy = updatePolicy self.failurePolicy = failurePolicy self.modernObservationEnabled = modernObservationEnabled + self.automaticStateFlowDiscovery = automaticStateFlowDiscovery if ownsModel { self.disposer = disposer ?? { model in (model as? any KMPDisposable)?.dispose() @@ -84,7 +87,22 @@ public final class KMPViewModelStore: @preconcurrency Obse generation &+= 1 let activeGeneration = generation - observations = states.map { state in + var sources = states + #if canImport(ObjectiveC) + if automaticStateFlowDiscovery { + sources.append( + .custom { viewModel, notify, reportError in + KMPAutomaticStateFlowRuntime.observe( + viewModel, + notify: notify, + reportError: reportError + ) + } + ) + } + #endif + + observations = sources.map { state in state.observe( wrappedValue, { [weak self] in diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index eb4a906..c693e70 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -217,6 +217,22 @@ final class KMPObservableBridgeTests: XCTestCase { XCTAssertTrue(observed.wrappedValue === observedModel) } + func testExperimentalSKIEObservationRequiresExplicitOptIn() { + let ownedModel = Model() + let observedModel = Model() + let owned = KMPStateObject( + wrappedValue: ownedModel, + observation: .automaticSKIE + ) + let observed = KMPObservedObject( + observedModel, + observation: .automaticSKIE + ) + + XCTAssertTrue(owned.wrappedValue === ownedModel) + XCTAssertTrue(observed.wrappedValue === observedModel) + } + func testGeneratedAutomaticConformanceSupportsBothWrappers() { let ownedModel = GeneratedStyleModel() let observedModel = GeneratedStyleModel() From b067cfe194185413cbf03ad948c79b6b2fe30284 Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 10:13:22 +0400 Subject: [PATCH 6/9] Add explicit no-observation strategy --- README.md | 24 +++++++++++++++++++ .../KMPObservedObject.swift | 9 ++++++- Sources/KMPObservableBridge/KMPPolicies.swift | 7 ++++++ .../KMPObservableBridge/KMPStateObject.swift | 18 ++++++++++++-- .../KMPObservableBridgeTests.swift | 16 +++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4988587..83f150f 100644 --- a/README.md +++ b/README.md @@ -525,6 +525,30 @@ explicit `state:` or `states:` for maximum production stability. This path applies only to SKIE `StateFlow`; KMP-NativeCoroutines uses the separate structural `NativeFlow` integration below. +To use the ownership wrapper without creating any automatic observation, +select `.none` explicitly: + +```swift +@KMPStateObject(observation: .none) +private var profile = ProfileViewModel() +``` + +For an externally owned model, use the same strategy in the declaration and +backing-wrapper initialization: + +```swift +@KMPObservedObject(observation: .none) +private var profile: ProfileViewModel + +init(profile: ProfileViewModel) { + _profile = KMPObservedObject(profile, observation: .none) +} +``` + +`.none` does not invalidate SwiftUI when Kotlin state changes. It is intended +for models observed by another owner, action-only models, previews, or +intentionally static reads. + ### Optional automatic KMP-NativeCoroutines observation Automatic observation requires an explicit Kotlin contract; Swift cannot diff --git a/Sources/KMPObservableBridge/KMPObservedObject.swift b/Sources/KMPObservableBridge/KMPObservedObject.swift index fdddc4f..d669ac1 100644 --- a/Sources/KMPObservableBridge/KMPObservedObject.swift +++ b/Sources/KMPObservableBridge/KMPObservedObject.swift @@ -54,12 +54,19 @@ public struct KMPObservedObject: @preconcurrency DynamicPr updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) { + let enablesAutomaticDiscovery: Bool + switch observation { + case .none: + enablesAutomaticDiscovery = false + case .automaticSKIE: + enablesAutomaticDiscovery = true + } self.init( viewModel, adapterArray: [], updatePolicy: updatePolicy, failurePolicy: failurePolicy, - automaticStateFlowDiscovery: true + automaticStateFlowDiscovery: enablesAutomaticDiscovery ) } diff --git a/Sources/KMPObservableBridge/KMPPolicies.swift b/Sources/KMPObservableBridge/KMPPolicies.swift index 8a4d0a2..d6cc6c8 100644 --- a/Sources/KMPObservableBridge/KMPPolicies.swift +++ b/Sources/KMPObservableBridge/KMPPolicies.swift @@ -2,6 +2,13 @@ import Foundation /// Selects an optional automatic interoperability mechanism. public enum KMPAutomaticObservation { + /// Disables automatic observation. + /// + /// The model remains available through the wrapper, but the bridge creates + /// no automatic subscriptions. Use this when invalidation is handled by + /// another owner or when the model is intentionally read-only. + case none + /// Lazily discovers SKIE StateFlows through their Objective-C getters. /// /// This is an experimental compatibility mode because it relies on diff --git a/Sources/KMPObservableBridge/KMPStateObject.swift b/Sources/KMPObservableBridge/KMPStateObject.swift index 2f8840a..2a2b6ab 100644 --- a/Sources/KMPObservableBridge/KMPStateObject.swift +++ b/Sources/KMPObservableBridge/KMPStateObject.swift @@ -25,6 +25,13 @@ public struct KMPStateObject: DynamicProperty { failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil ) { + let enablesAutomaticDiscovery: Bool + switch observation { + case .none: + enablesAutomaticDiscovery = false + case .automaticSKIE: + enablesAutomaticDiscovery = true + } _storage = StateObject( wrappedValue: KMPViewModelStore( makeViewModel(), @@ -33,7 +40,7 @@ public struct KMPStateObject: DynamicProperty { failurePolicy: failurePolicy, ownsModel: true, disposer: dispose, - automaticStateFlowDiscovery: true + automaticStateFlowDiscovery: enablesAutomaticDiscovery ) ) } @@ -47,6 +54,13 @@ public struct KMPStateObject: DynamicProperty { failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil ) { + let enablesAutomaticDiscovery: Bool + switch observation { + case .none: + enablesAutomaticDiscovery = false + case .automaticSKIE: + enablesAutomaticDiscovery = true + } _storage = StateObject( wrappedValue: KMPViewModelStore( makeInjector()[keyPath: keyPath], @@ -55,7 +69,7 @@ public struct KMPStateObject: DynamicProperty { failurePolicy: failurePolicy, ownsModel: true, disposer: dispose, - automaticStateFlowDiscovery: true + automaticStateFlowDiscovery: enablesAutomaticDiscovery ) ) } diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index c693e70..eb43f99 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -233,6 +233,22 @@ final class KMPObservableBridgeTests: XCTestCase { XCTAssertTrue(observed.wrappedValue === observedModel) } + func testNoneAutomaticObservationExposesModelsWithoutSubscribing() { + let ownedModel = Model() + let observedModel = Model() + let owned = KMPStateObject( + wrappedValue: ownedModel, + observation: .none + ) + let observed = KMPObservedObject( + observedModel, + observation: .none + ) + + XCTAssertTrue(owned.wrappedValue === ownedModel) + XCTAssertTrue(observed.wrappedValue === observedModel) + } + func testGeneratedAutomaticConformanceSupportsBothWrappers() { let ownedModel = GeneratedStyleModel() let observedModel = GeneratedStyleModel() From 4a9a82b8038542b72c2c02dce0bc7618cefb66ed Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 10:35:28 +0400 Subject: [PATCH 7/9] Default to automatic SKIE observation --- .../iosApp/iosApp/ContentView.swift | 33 ++++---------- README.md | 43 +++++++++++-------- .../KMPObservableBridge.md | 15 +++---- .../KMPObservedObject.swift | 28 ++++++++++-- .../KMPObservableBridge/KMPStateObject.swift | 4 +- .../KMPObservableBridgeTests.swift | 12 +++++- 6 files changed, 78 insertions(+), 57 deletions(-) diff --git a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift index 66e8372..99bb030 100644 --- a/Examples/DailyPulse/iosApp/iosApp/ContentView.swift +++ b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift @@ -53,8 +53,7 @@ struct ContentView: View { private struct ArticleInjectorExampleView: View { @KMPStateObject( injector: ArticleInjector(), - viewModel: \.articleViewModel, - observation: .automaticSKIE + viewModel: \.articleViewModel ) private var article @@ -67,14 +66,10 @@ private struct ArticleInjectorExampleView: View { } private struct ArticleContentView: View { - @KMPObservedObject(observation: .automaticSKIE) - private var article: ArticleViewModel + @KMPObservedObject private var article: ArticleViewModel init(viewModel: ArticleViewModel) { - _article = KMPObservedObject( - viewModel, - observation: .automaticSKIE - ) + _article = KMPObservedObject(viewModel) } var body: some View { @@ -97,8 +92,7 @@ private struct ArticleContentView: View { } private struct DirectStateObjectExampleView: View { - @KMPStateObject(observation: .automaticSKIE) - private var example = BridgeExampleViewModel() + @KMPStateObject private var example = BridgeExampleViewModel() var body: some View { NavigationView { @@ -113,8 +107,7 @@ private struct DirectStateObjectExampleView: View { } private struct ObservedObjectExampleView: View { - @KMPStateObject(observation: .automaticSKIE) - private var example = BridgeExampleViewModel() + @KMPStateObject private var example = BridgeExampleViewModel() var body: some View { NavigationView { @@ -158,14 +151,10 @@ private struct BridgeExampleEnvironmentView: View { } private struct BridgeExampleChildView: View { - @KMPObservedObject(observation: .automaticSKIE) - private var example: BridgeExampleViewModel + @KMPObservedObject private var example: BridgeExampleViewModel init(viewModel: BridgeExampleViewModel) { - _example = KMPObservedObject( - viewModel, - observation: .automaticSKIE - ) + _example = KMPObservedObject(viewModel) } var body: some View { @@ -189,8 +178,7 @@ private struct BridgeExampleChildView: View { } private struct BridgeExampleContentView: View { - @KMPObservedObject(observation: .automaticSKIE) - private var example: BridgeExampleViewModel + @KMPObservedObject private var example: BridgeExampleViewModel let title: String let subtitle: String @@ -202,10 +190,7 @@ private struct BridgeExampleContentView: View { ) { self.title = title self.subtitle = subtitle - _example = KMPObservedObject( - viewModel, - observation: .automaticSKIE - ) + _example = KMPObservedObject(viewModel) } var body: some View { diff --git a/README.md b/README.md index 83f150f..f7977bf 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,7 @@ private var profile ```swift struct ProfileContent: View { - @KMPObservedObject(observation: .automaticSKIE) - private var profile: ProfileViewModel + @KMPObservedObject private var profile: ProfileViewModel init(viewModel: ProfileViewModel) { _profile = KMPObservedObject(viewModel, state: \.state) @@ -455,28 +454,34 @@ let state = profile.profileStateValue This direct path keeps the bridge core independent of the NativeCoroutines Swift package while propagating cancellation to the Kotlin collection. -### Experimental automatic SKIE observation +### Automatic SKIE observation -SKIE users can explicitly opt into omitting state key paths. KMPObservableBridge -installs a guarded Objective-C runtime hook on the Kotlin ViewModel and lazily -observes each `StateFlow` when application code first reads its getter: +SKIE users can omit state key paths because `.automaticSKIE` is the default: ```swift -@KMPStateObject(observation: .automaticSKIE) +@KMPStateObject private var profile = ProfileViewModel() struct DetailView: View { @KMPObservedObject private var profile: ProfileViewModel init(profile: ProfileViewModel) { - _profile = KMPObservedObject( - profile, - observation: .automaticSKIE - ) + _profile = KMPObservedObject(profile) } } ``` +The explicit spelling remains available: + +```swift +@KMPStateObject(observation: .automaticSKIE) +private var profile = ProfileViewModel() +``` + +KMPObservableBridge installs a guarded Objective-C runtime hook on the Kotlin +ViewModel and lazily observes each `StateFlow` when application code first +reads its getter. + There is no generated file, build script, Swift extension, `$` registration, or Kotlin bridge dependency. Kotlin/Native does not export property metadata, so the bridge does not enumerate or invoke properties speculatively. It wraps @@ -496,10 +501,10 @@ deterministic fallback and let an application intentionally select a subset: private var profile ``` -#### Why automatic SKIE observation is opt-in +#### Automatic SKIE compatibility considerations -The syntax is convenient, but the mechanism has stricter compatibility and -debugging tradeoffs than explicit key paths: +Automatic SKIE observation is the convenience default, but the mechanism has +stricter compatibility and debugging tradeoffs than explicit key paths: - It uses process-wide Objective-C method interception for exported Kotlin getters. @@ -519,11 +524,11 @@ discovery safely does nothing. It never invokes unknown Kotlin getters or methods merely to inspect a model, deduplicates repeated reads, cancels replaced flows, and treats lifecycle cancellation as non-failure. -Use `.automaticSKIE` when its reduced declaration ceremony is worth those -tradeoffs and the application's Kotlin/SKIE matrix is tested in CI. Use -explicit `state:` or `states:` for maximum production stability. This path -applies only to SKIE `StateFlow`; KMP-NativeCoroutines uses the separate -structural `NativeFlow` integration below. +Test the application's Kotlin/SKIE matrix in CI. Use explicit `state:` or +`states:` for maximum production stability, or `.none` to disable automatic +observation entirely. This path applies only to SKIE `StateFlow`; +KMP-NativeCoroutines uses the separate structural `NativeFlow` integration +below. To use the ownership wrapper without creating any automatic observation, select `.none` explicitly: diff --git a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md index e5c7c22..4439f95 100644 --- a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md +++ b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md @@ -10,8 +10,7 @@ external model, and ``KMPEnvironmentObject`` for a projected store injected by an ancestor. ```swift -@KMPStateObject(observation: .automaticSKIE) -private var profile = ProfileViewModel() +@KMPStateObject private var profile = ProfileViewModel() ``` Kotlin remains the source of truth. The bridge observes emissions but never @@ -30,12 +29,12 @@ private var profile = ProfileViewModel() The explicit `state:` form remains the universal option. The automatic form removes the key path without requiring a framework-owned Kotlin superclass. -For SKIE frameworks, explicitly selecting `.automaticSKIE` lets wrappers -lazily discover public StateFlows when their exported getters are naturally -read. This requires no generated file or build script, but is experimental -because it uses Objective-C method interception and SKIE's generated iterator -ABI. The explicit `state:` and `states:` initializers remain the stable -defaults and allow selecting a specific subset. +For SKIE frameworks, `.automaticSKIE` is the default and lets wrappers lazily +discover public StateFlows when their exported getters are naturally read. +This requires no generated file or build script, but uses Objective-C method +interception and SKIE's generated iterator ABI. The explicit `state:` and +`states:` initializers remain available for maximum stability and selecting a +specific subset; `.none` disables automatic observation. ## Topics diff --git a/Sources/KMPObservableBridge/KMPObservedObject.swift b/Sources/KMPObservableBridge/KMPObservedObject.swift index d669ac1..6040547 100644 --- a/Sources/KMPObservableBridge/KMPObservedObject.swift +++ b/Sources/KMPObservableBridge/KMPObservedObject.swift @@ -33,12 +33,19 @@ public struct KMPObservedObject: @preconcurrency DynamicPr requiredStore } - /// Declares deferred experimental SKIE observation. + /// Declares deferred observation using the default automatic SKIE mode. + /// + /// Assign the backing wrapper in the enclosing view's initializer. + public init() { + self.init(observation: .automaticSKIE) + } + + /// Declares deferred observation with an explicit automatic strategy. /// /// Assign the backing wrapper in the enclosing view's initializer. Access /// before that assignment is a programmer error and traps with a message. public init( - observation: KMPAutomaticObservation + observation: KMPAutomaticObservation = .automaticSKIE ) { input = nil states = [] @@ -47,10 +54,25 @@ public struct KMPObservedObject: @preconcurrency DynamicPr ) } + /// Observes a model using automatic SKIE discovery by default. + public init( + wrappedValue viewModel: ViewModel, + observation: KMPAutomaticObservation = .automaticSKIE, + updatePolicy: KMPUpdatePolicy = .coalesced, + failurePolicy: KMPObservationFailurePolicy = .log + ) { + self.init( + viewModel, + observation: observation, + updatePolicy: updatePolicy, + failurePolicy: failurePolicy + ) + } + /// Observes an external model using an explicitly selected integration. public init( _ viewModel: ViewModel, - observation: KMPAutomaticObservation, + observation: KMPAutomaticObservation = .automaticSKIE, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) { diff --git a/Sources/KMPObservableBridge/KMPStateObject.swift b/Sources/KMPObservableBridge/KMPStateObject.swift index 2a2b6ab..cfa7bd9 100644 --- a/Sources/KMPObservableBridge/KMPStateObject.swift +++ b/Sources/KMPObservableBridge/KMPStateObject.swift @@ -20,7 +20,7 @@ public struct KMPStateObject: DynamicProperty { /// `adapters:` initializers are the production-stable alternatives. public init( wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, - observation: KMPAutomaticObservation, + observation: KMPAutomaticObservation = .automaticSKIE, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil @@ -49,7 +49,7 @@ public struct KMPStateObject: DynamicProperty { public init( injector makeInjector: @autoclosure @escaping () -> Injector, viewModel keyPath: KeyPath, - observation: KMPAutomaticObservation, + observation: KMPAutomaticObservation = .automaticSKIE, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index eb43f99..a0facef 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -217,7 +217,7 @@ final class KMPObservableBridgeTests: XCTestCase { XCTAssertTrue(observed.wrappedValue === observedModel) } - func testExperimentalSKIEObservationRequiresExplicitOptIn() { + func testExplicitAutomaticSKIEStrategy() { let ownedModel = Model() let observedModel = Model() let owned = KMPStateObject( @@ -233,6 +233,16 @@ final class KMPObservableBridgeTests: XCTestCase { XCTAssertTrue(observed.wrappedValue === observedModel) } + func testAutomaticSKIEIsTheDefaultObservationStrategy() { + let ownedModel = Model() + let observedModel = Model() + let owned = KMPStateObject(wrappedValue: ownedModel) + let observed = KMPObservedObject(observedModel) + + XCTAssertTrue(owned.wrappedValue === ownedModel) + XCTAssertTrue(observed.wrappedValue === observedModel) + } + func testNoneAutomaticObservationExposesModelsWithoutSubscribing() { let ownedModel = Model() let observedModel = Model() From f13a9be7df3865a2b09069d67f0c4b85dc143983 Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 10:39:17 +0400 Subject: [PATCH 8/9] Remove obsolete observation generator --- CHANGELOG.md | 3 + Package.swift | 7 - README.md | 12 +- .../KMPObservableBridge/KMPStateObject.swift | 2 +- .../KMPObservableBridgeGenerator/main.swift | 343 ------------------ .../KMPObservableBridgeTests.swift | 2 +- 6 files changed, 11 insertions(+), 358 deletions(-) delete mode 100644 Sources/KMPObservableBridgeGenerator/main.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 318a9e4..0e36e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes follow Keep a Changelog and Semantic Versioning. - Observation-framework delivery with legacy `ObservableObject` fallback. - Structured observation failure policies and system logging. - Strict-concurrency, lifecycle, child, performance, and memory tests. +- Default lazy SKIE StateFlow discovery with an explicit `.none` strategy. ### Changed @@ -21,8 +22,10 @@ All notable changes follow Keep a Changelog and Semantic Versioning. - Observation error closures are represented by `KMPObservationFailurePolicy`. - Coalescing is the default update behavior. +- The package now ships a single library product with no generator executable. ### Removed - Fixed two-, three-, and four-state overloads. - Silent observation-error defaults. +- The obsolete build-time `KMPObservableBridgeGenerator` product and target. diff --git a/Package.swift b/Package.swift index 0b518cc..fcb5ed5 100644 --- a/Package.swift +++ b/Package.swift @@ -15,18 +15,11 @@ let package = Package( name: "KMPObservableBridge", targets: ["KMPObservableBridge"] ), - .executable( - name: "kmp-observable-bridge-generator", - targets: ["KMPObservableBridgeGenerator"] - ), ], targets: [ .target( name: "KMPObservableBridge" ), - .executableTarget( - name: "KMPObservableBridgeGenerator" - ), .testTarget( name: "KMPObservableBridgeTests", dependencies: ["KMPObservableBridge"] diff --git a/README.md b/README.md index f7977bf..ea77f61 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ truth, and SwiftUI continues calling the real Kotlin ViewModel directly. KMP-NativeCoroutines, Combine, callbacks, and custom coroutine adapters.** The core has no Kotlin-side component, superclass, annotation, compiler plugin, -or third-party runtime dependency. Explicit state key paths are intentional: -arbitrary generated KMP streams cannot be discovered reliably through Swift or -Objective-C reflection without framework-specific metadata. +code generator, or third-party runtime dependency. SKIE StateFlows are +discovered lazily through their exported Objective-C getters; explicit state +key paths remain available when deterministic selection is preferred. Keywords: SwiftUI, Kotlin Multiplatform, KMP, KMM, StateFlow, coroutines, Kotlin/Native, iOS, SKIE, KMP-NativeCoroutines, ObservableObject, Observation, @@ -117,7 +117,6 @@ the Kotlin state. struct ProfileScreen: View { @KMPStateObject( wrappedValue: ProfileViewModel(repository: Dependencies.profile), - state: \.state, dispose: { $0.clear() } ) private var viewModel @@ -142,8 +141,9 @@ struct ProfileScreen: View { ``` There is no Swift adapter object and no duplicated `@Published` state. -The default `.coalesced` update policy avoids redundant redraws when several -flows emit in the same main-actor turn. +Automatic SKIE observation and the `.coalesced` update policy are the defaults. +Use an explicit `state:` key path for deterministic selection or +`observation: .none` to create no automatic subscription. ## Installation diff --git a/Sources/KMPObservableBridge/KMPStateObject.swift b/Sources/KMPObservableBridge/KMPStateObject.swift index cfa7bd9..dfd63d2 100644 --- a/Sources/KMPObservableBridge/KMPStateObject.swift +++ b/Sources/KMPObservableBridge/KMPStateObject.swift @@ -74,7 +74,7 @@ public struct KMPStateObject: DynamicProperty { ) } - /// Creates a model using its build-time generated observation list. + /// Creates a model using its explicit automatic-observation contract. public init( wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, updatePolicy: KMPUpdatePolicy = .coalesced, diff --git a/Sources/KMPObservableBridgeGenerator/main.swift b/Sources/KMPObservableBridgeGenerator/main.swift deleted file mode 100644 index 3eb9048..0000000 --- a/Sources/KMPObservableBridgeGenerator/main.swift +++ /dev/null @@ -1,343 +0,0 @@ -import Foundation - -private struct SymbolGraph: Decodable { - struct Metadata: Decodable { - struct Version: Decodable { - let major: Int - } - - let formatVersion: Version - } - - struct Symbol: Decodable { - struct Identifier: Decodable { - let precise: String - } - - struct Kind: Decodable { - let identifier: String - } - - struct Names: Decodable { - let title: String - } - - struct Fragment: Decodable { - let kind: String - let spelling: String - let preciseIdentifier: String? - } - - let identifier: Identifier - let kind: Kind - let names: Names - let pathComponents: [String] - let declarationFragments: [Fragment]? - } - - struct Relationship: Decodable { - let kind: String - let source: String - let target: String - } - - let metadata: Metadata - let symbols: [Symbol] - let relationships: [Relationship] -} - -private struct Options { - let symbolGraph: URL? - let framework: URL? - let module: String? - let output: URL - - init(arguments: [String]) throws { - func value(after flag: String) -> String? { - guard let index = arguments.firstIndex(of: flag), - arguments.indices.contains(index + 1) else { - return nil - } - return arguments[index + 1] - } - - guard let output = value(after: "--output") else { - throw GeneratorError.usage - } - - symbolGraph = value(after: "--symbol-graph").map { - URL(fileURLWithPath: $0) - } - framework = value(after: "--framework").map { - URL(fileURLWithPath: $0) - } - module = value(after: "--module") - self.output = URL(fileURLWithPath: output) - - guard (symbolGraph != nil && module != nil) - || framework != nil else { - throw GeneratorError.usage - } - } -} - -private enum GeneratorError: LocalizedError { - case usage - case unsupportedSymbolGraph(Int) - case noStateFlows - case missingEnvironment(String) - case processFailed(String, Int32) - - var errorDescription: String? { - switch self { - case .usage: - return """ - Usage: - kmp-observable-bridge-generator \ - --symbol-graph --module --output - kmp-observable-bridge-generator \ - --framework [--module ] --output - """ - case .unsupportedSymbolGraph(let major): - return "Unsupported symbol graph format major version \(major)." - case .noStateFlows: - return "No public SKIE StateFlow properties were found." - case .missingEnvironment(let name): - return "Required Xcode build environment variable \(name) is missing." - case .processFailed(let command, let status): - return "\(command) failed with exit status \(status)." - } - } -} - -private let supportedStateFlowNames: Set = [ - "SkieSwiftStateFlow", - "SkieSwiftMutableStateFlow", - "SkieSwiftOptionalStateFlow", - "SkieSwiftOptionalMutableStateFlow", -] - -private func run( - _ executable: URL, - arguments: [String], - directory: URL? = nil, - environment: [String: String]? = nil, - captureOutput: Bool = false -) throws -> Data { - let process = Process() - process.executableURL = executable - process.arguments = arguments - process.currentDirectoryURL = directory - if let environment { - process.environment = environment - } - - let pipe = Pipe() - if captureOutput { - process.standardOutput = pipe - } else { - process.standardOutput = FileHandle.standardOutput - } - process.standardError = FileHandle.standardError - try process.run() - let output = captureOutput - ? pipe.fileHandleForReading.readDataToEndOfFile() - : Data() - process.waitUntilExit() - guard process.terminationStatus == EXIT_SUCCESS else { - throw GeneratorError.processFailed( - ([executable.path] + arguments).joined(separator: " "), - process.terminationStatus - ) - } - return output -} - -private func extractSymbolGraph( - framework: URL, - module requestedModule: String? -) throws -> (graph: URL, module: String) { - let module = requestedModule - ?? framework.deletingPathExtension().lastPathComponent - let environment = ProcessInfo.processInfo.environment - guard let sdkRoot = environment["KMP_OBSERVABLE_SDKROOT"] - ?? environment["SDKROOT"] else { - throw GeneratorError.missingEnvironment("SDKROOT") - } - let architecture = environment["NATIVE_ARCH_ACTUAL"] - ?? environment["CURRENT_ARCH"] - ?? environment["ARCHS"]?.split(separator: " ").first.map(String.init) - guard let architecture else { - throw GeneratorError.missingEnvironment( - "NATIVE_ARCH_ACTUAL or CURRENT_ARCH" - ) - } - guard let operatingSystem = environment["LLVM_TARGET_TRIPLE_OS_VERSION"] - else { - throw GeneratorError.missingEnvironment( - "LLVM_TARGET_TRIPLE_OS_VERSION" - ) - } - let suffix = environment["LLVM_TARGET_TRIPLE_SUFFIX"] ?? "" - let target = "\(architecture)-apple-\(operatingSystem)\(suffix)" - let graphDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("KMPObservableBridge-\(UUID().uuidString)") - try FileManager.default.createDirectory( - at: graphDirectory, - withIntermediateDirectories: true - ) - - _ = try run( - URL(fileURLWithPath: "/usr/bin/xcrun"), - arguments: [ - "swift-symbolgraph-extract", - "-module-name", module, - "-F", framework.deletingLastPathComponent().path, - "-target", target, - "-sdk", sdkRoot, - "-minimum-access-level", "public", - "-output-dir", graphDirectory.path, - ] - ) - - return ( - graphDirectory.appendingPathComponent("\(module).symbols.json"), - module - ) -} - -private func generate(options: Options) throws { - let input: (graph: URL, module: String) - if let framework = options.framework { - input = try extractSymbolGraph( - framework: framework, - module: options.module - ) - } else if let graph = options.symbolGraph, let module = options.module { - input = (graph, module) - } else { - throw GeneratorError.usage - } - - let data = try Data(contentsOf: input.graph) - let graph = try JSONDecoder().decode(SymbolGraph.self, from: data) - guard graph.metadata.formatVersion.major == 0 else { - throw GeneratorError.unsupportedSymbolGraph( - graph.metadata.formatVersion.major - ) - } - - let symbolsByID = Dictionary( - uniqueKeysWithValues: graph.symbols.map { - ($0.identifier.precise, $0) - } - ) - let ownerByMember = Dictionary( - uniqueKeysWithValues: graph.relationships.compactMap { relationship in - relationship.kind == "memberOf" - ? (relationship.source, relationship.target) - : nil - } - ) - let flowImplementationOwners = Set( - graph.relationships.compactMap { relationship in - relationship.kind == "conformsTo" - && relationship.target.lowercased().contains( - "kotlinx_coroutines_core" - ) - ? relationship.source - : nil - } - ) - - var propertiesByOwner: [String: [String]] = [:] - for symbol in graph.symbols where symbol.kind.identifier == "swift.property" { - let isStateFlow = symbol.declarationFragments?.contains { fragment in - fragment.kind == "typeIdentifier" - && supportedStateFlowNames.contains(fragment.spelling) - } == true - guard isStateFlow, - let ownerID = ownerByMember[symbol.identifier.precise], - let owner = symbolsByID[ownerID], - owner.kind.identifier == "swift.class", - // Kotlin/Native exported classes retain Objective-C symbol IDs. - // Excluding native Swift IDs prevents accidental conformances - // for SKIE's own StateFlow implementation classes. - owner.identifier.precise.hasPrefix("c:objc(cs)"), - !flowImplementationOwners.contains(ownerID) else { - continue - } - - propertiesByOwner[owner.names.title, default: []].append( - symbol.names.title - ) - } - - guard !propertiesByOwner.isEmpty else { - throw GeneratorError.noStateFlows - } - - var source = """ - // Generated by KMPObservableBridge. Do not edit. - import KMPObservableBridge - import \(input.module) - - """ - for owner in propertiesByOwner.keys.sorted() { - let properties = propertiesByOwner[owner, default: []].sorted() - source += """ - extension \(owner): @retroactive KMPAutomaticallyObservable { - public func kmpObserveAutomatically( - notify: @escaping KMPState<\(owner)>.Notify, - reportError: @escaping KMPState<\(owner)>.ReportError - ) -> KMPObservation { - .group( - """ - for property in properties { - source += """ - - KMPState<\(owner)>.asyncSequence(\\.\(property)) - .startObservation( - on: self, - notify: notify, - reportError: reportError - ), - """ - } - source += """ - - ) - } - } - - """ - } - - let directory = options.output.deletingLastPathComponent() - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let temporary = directory.appendingPathComponent( - ".\(options.output.lastPathComponent).\(UUID().uuidString).tmp" - ) - try Data(source.utf8).write(to: temporary, options: .atomic) - if FileManager.default.fileExists(atPath: options.output.path) { - _ = try FileManager.default.replaceItemAt( - options.output, - withItemAt: temporary - ) - } else { - try FileManager.default.moveItem(at: temporary, to: options.output) - } -} - -do { - let options = try Options(arguments: Array(CommandLine.arguments.dropFirst())) - try generate(options: options) -} catch { - FileHandle.standardError.write( - Data("error: \(error.localizedDescription)\n".utf8) - ) - exit(EXIT_FAILURE) -} diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index a0facef..b99d4a9 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -107,7 +107,7 @@ final class KMPObservableBridgeTests: XCTestCase { } } - /// Mirrors the conformance emitted by the build-time SKIE generator. + /// Represents an application-defined automatic-observation conformance. private final class GeneratedStyleModel: KMPAutomaticallyObservable { let state = PassthroughSubject() From c8b94e57bf1454b6c158be614bcfe117c2ffbcf8 Mon Sep 17 00:00:00 2001 From: sonmbol Date: Fri, 24 Jul 2026 21:31:01 +0400 Subject: [PATCH 9/9] Simplify observation source routing --- README.md | 54 ++++--- .../KMPObservedObject.swift | 150 ++++-------------- Sources/KMPObservableBridge/KMPState.swift | 4 +- .../KMPObservableBridge/KMPStateObject.swift | 68 +++----- .../KMPObservableBridge/KMPViewModel.swift | 99 ++++++++++-- .../KMPObservableBridgeTests.swift | 24 ++- 6 files changed, 193 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index ea77f61..7e01c9e 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,7 @@ final class ProfileAdapter: ObservableObject { With KMPObservableBridge, SwiftUI owns or observes the real Kotlin ViewModel: ```swift -@KMPStateObject( - wrappedValue: ProfileViewModel(), - state: \.state -) -private var profile +@KMPStateObject private var profile = ProfileViewModel() ``` No shadow `@Published` state, duplicated action methods, or screen-specific @@ -97,7 +93,7 @@ class ProfileViewModel( Loading, content, empty, and domain-error states should live together in this immutable state object. -### 2. Add the SKIE conformance once +### 2. Add nicer state reads (optional) Place this in the iOS application target: @@ -108,8 +104,9 @@ import KMPObservableBridge extension SkieSwiftStateFlow: @retroactive KMPValueProperty {} ``` -This only adds convenient nested reads. It does not copy or take ownership of -the Kotlin state. +This lets Swift read `viewModel.state.isLoading` instead of +`viewModel.state.value.isLoading`. It does not affect observation, copy state, +or take ownership of anything. ### 3. Own and observe the ViewModel @@ -200,7 +197,6 @@ The package supports: ```swift @KMPStateObject( wrappedValue: ProfileViewModel(), - state: \.state, dispose: { $0.clear() } ) private var profile @@ -214,7 +210,6 @@ The model is created lazily and once per SwiftUI view identity. @KMPStateObject( injector: AppInjector(), viewModel: \.profileViewModel, - state: \.state, dispose: { $0.clear() } ) private var profile @@ -227,7 +222,7 @@ struct ProfileContent: View { @KMPObservedObject private var profile: ProfileViewModel init(viewModel: ProfileViewModel) { - _profile = KMPObservedObject(viewModel, state: \.state) + _profile = KMPObservedObject(viewModel) } var body: some View { @@ -436,6 +431,23 @@ updatePolicy: .immediate The convenience `state: \.state` initializer accepts any `AsyncSequence`, not only SKIE. +### How the bridge chooses an observer + +The bridge chooses exactly one observation route when it creates the store. +The routes cannot run together: + +| Model or initializer | Observation route | +| --- | --- | +| Model conforms to `KMPNativeObservable` | Its canonical NativeFlow | +| `state:`, `states:`, or `adapters:` is supplied | Only those explicit sources | +| `observation: .none` | No observation | +| Ordinary no-state initializer | Automatic SKIE discovery | + +This decision happens before any SKIE runtime inspection. A +`KMPNativeObservable` model does not install getter interception or look up a +SKIE iterator. Likewise, an explicit state or adapter never starts automatic +SKIE discovery. + ### KMP-NativeCoroutines KMPObservableBridge understands the exported `NativeFlow` signature directly. @@ -538,12 +550,11 @@ select `.none` explicitly: private var profile = ProfileViewModel() ``` -For an externally owned model, use the same strategy in the declaration and -backing-wrapper initialization: +For an externally owned model, keep the declaration plain and select the +strategy when assigning its backing wrapper: ```swift -@KMPObservedObject(observation: .none) -private var profile: ProfileViewModel +@KMPObservedObject private var profile: ProfileViewModel init(profile: ProfileViewModel) { _profile = KMPObservedObject(profile, observation: .none) @@ -554,11 +565,10 @@ init(profile: ProfileViewModel) { for models observed by another owner, action-only models, previews, or intentionally static reads. -### Optional automatic KMP-NativeCoroutines observation +### Automatic KMP-NativeCoroutines observation -Automatic observation requires an explicit Kotlin contract; Swift cannot -discover arbitrary flow properties through Objective-C reflection. Expose one -canonical NativeFlow that emits whenever any UI-facing state changes: +For KMP-NativeCoroutines, expose one canonical NativeFlow that emits whenever +any UI-facing state changes: ```kotlin class ProfileViewModel : BaseViewModel() { @@ -578,6 +588,10 @@ import KMPObservableBridge extension ProfileViewModel: @retroactive KMPNativeObservable {} ``` +That conformance is also the routing signal. The bridge detects the explicit +NativeFlow contract before it considers the SKIE fallback, so +NativeCoroutines models never pay for SKIE runtime discovery. + The property-wrapper call then matches native SwiftUI: ```swift @@ -593,7 +607,7 @@ struct ProfileView: View { @KMPObservedObject private var profile: ProfileViewModel init(profile: ProfileViewModel) { - _profile = KMPObservedObject(wrappedValue: profile) + _profile = KMPObservedObject(profile) } } ``` diff --git a/Sources/KMPObservableBridge/KMPObservedObject.swift b/Sources/KMPObservableBridge/KMPObservedObject.swift index 6040547..bde0c67 100644 --- a/Sources/KMPObservableBridge/KMPObservedObject.swift +++ b/Sources/KMPObservableBridge/KMPObservedObject.swift @@ -1,60 +1,22 @@ -import Combine import SwiftUI -@MainActor -private final class KMPObservedObjectHolder: - @preconcurrency ObservableObject -{ - let objectWillChange = ObservableObjectPublisher() - let store: KMPViewModelStore? - private var forwarding: AnyCancellable? - - init(store: KMPViewModelStore?) { - self.store = store - forwarding = store?.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - } -} - /// Observes a KMP ViewModel owned outside this SwiftUI view. @MainActor @propertyWrapper public struct KMPObservedObject: @preconcurrency DynamicProperty { - @StateObject private var holder: KMPObservedObjectHolder - private let input: ViewModel? - private let states: [KMPState] + @StateObject private var storage: KMPViewModelStore + private let input: ViewModel + private let source: KMPObservationSource public var wrappedValue: ViewModel { - requiredStore.value + storage.value } public var projectedValue: KMPViewModelStore { - requiredStore - } - - /// Declares deferred observation using the default automatic SKIE mode. - /// - /// Assign the backing wrapper in the enclosing view's initializer. - public init() { - self.init(observation: .automaticSKIE) - } - - /// Declares deferred observation with an explicit automatic strategy. - /// - /// Assign the backing wrapper in the enclosing view's initializer. Access - /// before that assignment is a programmer error and traps with a message. - public init( - observation: KMPAutomaticObservation = .automaticSKIE - ) { - input = nil - states = [] - _holder = StateObject( - wrappedValue: KMPObservedObjectHolder(store: nil) - ) + storage } - /// Observes a model using automatic SKIE discovery by default. + /// Observes a model using its explicit contract or SKIE discovery. public init( wrappedValue viewModel: ViewModel, observation: KMPAutomaticObservation = .automaticSKIE, @@ -69,64 +31,20 @@ public struct KMPObservedObject: @preconcurrency DynamicPr ) } - /// Observes an external model using an explicitly selected integration. + /// Observes an external model without taking ownership. public init( _ viewModel: ViewModel, observation: KMPAutomaticObservation = .automaticSKIE, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log ) { - let enablesAutomaticDiscovery: Bool - switch observation { - case .none: - enablesAutomaticDiscovery = false - case .automaticSKIE: - enablesAutomaticDiscovery = true - } - self.init( - viewModel, - adapterArray: [], - updatePolicy: updatePolicy, - failurePolicy: failurePolicy, - automaticStateFlowDiscovery: enablesAutomaticDiscovery - ) - } - - public init( - wrappedValue viewModel: ViewModel, - updatePolicy: KMPUpdatePolicy = .coalesced, - failurePolicy: KMPObservationFailurePolicy = .log - ) where ViewModel: KMPAutomaticallyObservable { - self.init( - viewModel, - adapterArray: [ - .callback { viewModel, notify, reportError in - viewModel.kmpObserveAutomatically( - notify: notify, - reportError: reportError - ) - }, - ], - updatePolicy: updatePolicy, - failurePolicy: failurePolicy + let source = kmpAutomaticObservationSource( + for: viewModel, + strategy: observation ) - } - - public init( - _ viewModel: ViewModel, - updatePolicy: KMPUpdatePolicy = .coalesced, - failurePolicy: KMPObservationFailurePolicy = .log - ) where ViewModel: KMPAutomaticallyObservable { self.init( viewModel, - adapterArray: [ - .callback { viewModel, notify, reportError in - viewModel.kmpObserveAutomatically( - notify: notify, - reportError: reportError - ) - }, - ], + source: source, updatePolicy: updatePolicy, failurePolicy: failurePolicy ) @@ -260,39 +178,37 @@ public struct KMPObservedObject: @preconcurrency DynamicPr _ viewModel: ViewModel, adapterArray: [KMPState], updatePolicy: KMPUpdatePolicy, - failurePolicy: KMPObservationFailurePolicy, - automaticStateFlowDiscovery: Bool = false + failurePolicy: KMPObservationFailurePolicy + ) { + self.init( + viewModel, + source: .explicit(adapterArray), + updatePolicy: updatePolicy, + failurePolicy: failurePolicy + ) + } + + private init( + _ viewModel: ViewModel, + source: KMPObservationSource, + updatePolicy: KMPUpdatePolicy, + failurePolicy: KMPObservationFailurePolicy ) { input = viewModel - states = adapterArray - let store = KMPViewModelStore( + self.source = source + _storage = StateObject( + wrappedValue: KMPViewModelStore( viewModel, - states: adapterArray, + source: source, updatePolicy: updatePolicy, failurePolicy: failurePolicy, - ownsModel: false, - automaticStateFlowDiscovery: automaticStateFlowDiscovery + ownsModel: false ) - _holder = StateObject( - wrappedValue: KMPObservedObjectHolder(store: store) ) } public mutating func update() { - _holder.update() - guard let input, let store = holder.store else { - return - } - store.rebind(to: input, states: states) - } - - private var requiredStore: KMPViewModelStore { - guard let store = holder.store else { - preconditionFailure( - "KMPObservedObject must be initialized by assigning its " - + "backing storage in the enclosing view initializer." - ) - } - return store + _storage.update() + storage.rebind(to: input, source: source) } } diff --git a/Sources/KMPObservableBridge/KMPState.swift b/Sources/KMPObservableBridge/KMPState.swift index 646afb7..aab0a9a 100644 --- a/Sources/KMPObservableBridge/KMPState.swift +++ b/Sources/KMPObservableBridge/KMPState.swift @@ -21,8 +21,8 @@ public typealias KMPNativeFlow = ( @MainActor public protocol KMPAutomaticallyObservable: AnyObject { func kmpObserveAutomatically( - notify: @escaping KMPState.Notify, - reportError: @escaping KMPState.ReportError + notify: @escaping @Sendable () -> Void, + reportError: @escaping @Sendable (Error) -> Void ) -> KMPObservation } diff --git a/Sources/KMPObservableBridge/KMPStateObject.swift b/Sources/KMPObservableBridge/KMPStateObject.swift index dfd63d2..d14693d 100644 --- a/Sources/KMPObservableBridge/KMPStateObject.swift +++ b/Sources/KMPObservableBridge/KMPStateObject.swift @@ -14,10 +14,10 @@ public struct KMPStateObject: DynamicProperty { storage } - /// Creates a model using an explicitly selected automatic integration. + /// Creates and owns a model using automatic observation by default. /// - /// `.automaticSKIE` is experimental. Explicit `state:`, `states:`, and - /// `adapters:` initializers are the production-stable alternatives. + /// Explicit automatic contracts such as `KMPNativeObservable` are checked + /// first. Other models use SKIE discovery unless `.none` is selected. public init( wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, observation: KMPAutomaticObservation = .automaticSKIE, @@ -25,27 +25,24 @@ public struct KMPStateObject: DynamicProperty { failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil ) { - let enablesAutomaticDiscovery: Bool - switch observation { - case .none: - enablesAutomaticDiscovery = false - case .automaticSKIE: - enablesAutomaticDiscovery = true - } + let viewModel = makeViewModel() + let source = kmpAutomaticObservationSource( + for: viewModel, + strategy: observation + ) _storage = StateObject( wrappedValue: KMPViewModelStore( - makeViewModel(), - states: [], + viewModel, + source: source, updatePolicy: updatePolicy, failurePolicy: failurePolicy, ownsModel: true, - disposer: dispose, - automaticStateFlowDiscovery: enablesAutomaticDiscovery + disposer: dispose ) ) } - /// Resolves a model using an explicitly selected automatic integration. + /// Resolves and owns an injected model using automatic observation. public init( injector makeInjector: @autoclosure @escaping () -> Injector, viewModel keyPath: KeyPath, @@ -54,44 +51,15 @@ public struct KMPStateObject: DynamicProperty { failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil ) { - let enablesAutomaticDiscovery: Bool - switch observation { - case .none: - enablesAutomaticDiscovery = false - case .automaticSKIE: - enablesAutomaticDiscovery = true - } - _storage = StateObject( - wrappedValue: KMPViewModelStore( - makeInjector()[keyPath: keyPath], - states: [], - updatePolicy: updatePolicy, - failurePolicy: failurePolicy, - ownsModel: true, - disposer: dispose, - automaticStateFlowDiscovery: enablesAutomaticDiscovery - ) + let viewModel = makeInjector()[keyPath: keyPath] + let source = kmpAutomaticObservationSource( + for: viewModel, + strategy: observation ) - } - - /// Creates a model using its explicit automatic-observation contract. - public init( - wrappedValue makeViewModel: @autoclosure @escaping () -> ViewModel, - updatePolicy: KMPUpdatePolicy = .coalesced, - failurePolicy: KMPObservationFailurePolicy = .log, - dispose: (@MainActor (ViewModel) -> Void)? = nil - ) where ViewModel: KMPAutomaticallyObservable { _storage = StateObject( wrappedValue: KMPViewModelStore( - makeViewModel(), - states: [ - .callback { viewModel, notify, reportError in - viewModel.kmpObserveAutomatically( - notify: notify, - reportError: reportError - ) - }, - ], + viewModel, + source: source, updatePolicy: updatePolicy, failurePolicy: failurePolicy, ownsModel: true, diff --git a/Sources/KMPObservableBridge/KMPViewModel.swift b/Sources/KMPObservableBridge/KMPViewModel.swift index 93d36f5..03752eb 100644 --- a/Sources/KMPObservableBridge/KMPViewModel.swift +++ b/Sources/KMPObservableBridge/KMPViewModel.swift @@ -12,6 +12,39 @@ private final class KMPModernRevision { } #endif +/// One mutually exclusive observation route selected before a store starts. +/// +/// Keeping this as a single value prevents explicit adapters and runtime SKIE +/// discovery from being enabled together accidentally. +enum KMPObservationSource { + case none + case automaticSKIE + case explicit([KMPState]) +} + +@MainActor +func kmpAutomaticObservationSource( + for viewModel: ViewModel, + strategy: KMPAutomaticObservation +) -> KMPObservationSource { + if case .none = strategy { + return .none + } + + if let automaticModel = viewModel as? any KMPAutomaticallyObservable { + return .explicit([ + .custom { _, notify, reportError in + automaticModel.kmpObserveAutomatically( + notify: notify, + reportError: reportError + ) + }, + ]) + } + + return .automaticSKIE +} + /// Observable storage shared by owning, observed, and environment wrappers. /// /// The store is exposed as a projected value (`$viewModel`) so observation can @@ -31,30 +64,47 @@ public final class KMPViewModelStore: @preconcurrency Obse private var pendingChange: Task? private var modernRevision: AnyObject? private let modernObservationEnabled: Bool - private let automaticStateFlowDiscovery: Bool - init( + convenience init( _ wrappedValue: ViewModel, states: [KMPState], updatePolicy: KMPUpdatePolicy, failurePolicy: KMPObservationFailurePolicy, ownsModel: Bool, disposer: Disposer? = nil, - modernObservationEnabled: Bool = true, - automaticStateFlowDiscovery: Bool = false + modernObservationEnabled: Bool = true + ) { + self.init( + wrappedValue, + source: .explicit(states), + updatePolicy: updatePolicy, + failurePolicy: failurePolicy, + ownsModel: ownsModel, + disposer: disposer, + modernObservationEnabled: modernObservationEnabled + ) + } + + init( + _ wrappedValue: ViewModel, + source: KMPObservationSource, + updatePolicy: KMPUpdatePolicy, + failurePolicy: KMPObservationFailurePolicy, + ownsModel: Bool, + disposer: Disposer? = nil, + modernObservationEnabled: Bool = true ) { self.wrappedValue = wrappedValue self.updatePolicy = updatePolicy self.failurePolicy = failurePolicy self.modernObservationEnabled = modernObservationEnabled - self.automaticStateFlowDiscovery = automaticStateFlowDiscovery if ownsModel { self.disposer = disposer ?? { model in (model as? any KMPDisposable)?.dispose() } } configureModernObservation() - startObserving(states) + startObserving(source) } deinit { @@ -73,36 +123,53 @@ public final class KMPViewModelStore: @preconcurrency Obse /// Rebinds an externally owned model using deterministic teardown ordering. func rebind(to viewModel: ViewModel, states: [KMPState]) { + rebind(to: viewModel, source: .explicit(states)) + } + + /// Rebinds using exactly one observation route. + func rebind( + to viewModel: ViewModel, + source: KMPObservationSource + ) { guard wrappedValue !== viewModel else { return } stopObserving() wrappedValue = viewModel - startObserving(states) + startObserving(source) emitChange() } - private func startObserving(_ states: [KMPState]) { + private func startObserving( + _ source: KMPObservationSource + ) { generation &+= 1 let activeGeneration = generation - var sources = states - #if canImport(ObjectiveC) - if automaticStateFlowDiscovery { - sources.append( + let states: [KMPState] + switch source { + case .none: + states = [] + case .automaticSKIE: + #if canImport(ObjectiveC) + states = [ .custom { viewModel, notify, reportError in KMPAutomaticStateFlowRuntime.observe( viewModel, notify: notify, reportError: reportError ) - } - ) + }, + ] + #else + states = [] + #endif + case .explicit(let explicitStates): + states = explicitStates } - #endif - observations = sources.map { state in + observations = states.map { state in state.observe( wrappedValue, { [weak self] in diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index b99d4a9..23cc9ff 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -85,6 +85,9 @@ final class KMPObservableBridgeTests: XCTestCase { private var onItem: ((Int, @escaping () -> Void, Void) -> Void)? private var onComplete: ((TestError?, Void) -> Void)? private(set) var cancellationCount = 0 + var isObserved: Bool { + onItem != nil + } lazy var state: Flow = { [weak self] onItem, onComplete, _ in self?.onItem = onItem @@ -207,14 +210,26 @@ final class KMPObservableBridgeTests: XCTestCase { withExtendedLifetime(cancellable) {} } - func testAutomaticNativeObservableWrappersNeedNoStateArgument() { + func testAutomaticNativeObservableWrappersUseNativeFlowNotSKIE() { let ownedModel = NativeFlowModel() let observedModel = NativeFlowModel() + guard case .explicit(let routedStates) = + kmpAutomaticObservationSource( + for: ownedModel, + strategy: .automaticSKIE + ) + else { + return XCTFail("Native observable was not routed explicitly") + } + XCTAssertEqual(routedStates.count, 1) + let owned = KMPStateObject(wrappedValue: ownedModel) let observed = KMPObservedObject(observedModel) XCTAssertTrue(owned.wrappedValue === ownedModel) XCTAssertTrue(observed.wrappedValue === observedModel) + XCTAssertTrue(ownedModel.isObserved) + XCTAssertTrue(observedModel.isObserved) } func testExplicitAutomaticSKIEStrategy() { @@ -246,6 +261,7 @@ final class KMPObservableBridgeTests: XCTestCase { func testNoneAutomaticObservationExposesModelsWithoutSubscribing() { let ownedModel = Model() let observedModel = Model() + let nativeModel = NativeFlowModel() let owned = KMPStateObject( wrappedValue: ownedModel, observation: .none @@ -254,9 +270,15 @@ final class KMPObservableBridgeTests: XCTestCase { observedModel, observation: .none ) + let disabledNative = KMPStateObject( + wrappedValue: nativeModel, + observation: .none + ) XCTAssertTrue(owned.wrappedValue === ownedModel) XCTAssertTrue(observed.wrappedValue === observedModel) + XCTAssertTrue(disabledNative.wrappedValue === nativeModel) + XCTAssertFalse(nativeModel.isObserved) } func testGeneratedAutomaticConformanceSupportsBothWrappers() {