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/Examples/DailyPulse/iosApp/iosApp/ContentView.swift b/Examples/DailyPulse/iosApp/iosApp/ContentView.swift index f1a8a4f..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, - state: \.articleState + viewModel: \.articleViewModel ) private var article @@ -70,10 +69,7 @@ private struct ArticleContentView: View { @KMPObservedObject private var article: ArticleViewModel init(viewModel: ArticleViewModel) { - _article = KMPObservedObject( - viewModel, - state: \.articleState - ) + _article = KMPObservedObject(viewModel) } var body: some View { @@ -96,11 +92,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 { @@ -115,11 +107,7 @@ private struct DirectStateObjectExampleView: View { } private struct ObservedObjectExampleView: View { - @KMPStateObject( - wrappedValue: BridgeExampleViewModel(), - states: \.counterState, \.messageState - ) - private var example + @KMPStateObject private var example = BridgeExampleViewModel() var body: some View { NavigationView { @@ -166,10 +154,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 { @@ -205,10 +190,7 @@ private struct BridgeExampleContentView: View { ) { self.title = title self.subtitle = subtitle - _example = KMPObservedObject( - viewModel, - states: \.counterState, \.messageState - ) + _example = KMPObservedObject(viewModel) } var body: some View { diff --git a/README.md b/README.md index a424c66..7e01c9e 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, @@ -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 @@ -117,7 +114,6 @@ the Kotlin state. struct ProfileScreen: View { @KMPStateObject( wrappedValue: ProfileViewModel(repository: Dependencies.profile), - state: \.state, dispose: { $0.clear() } ) private var viewModel @@ -142,8 +138,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 @@ -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. @@ -454,11 +466,109 @@ 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 +### Automatic SKIE observation + +SKIE users can omit state key paths because `.automaticSKIE` is the default: + +```swift +@KMPStateObject +private var profile = ProfileViewModel() + +struct DetailView: View { + @KMPObservedObject private var profile: ProfileViewModel + + init(profile: ProfileViewModel) { + _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 +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. + +Only accessed flows are observed. Explicit `state:` and `states:` remain the +deterministic fallback and let an application intentionally select a subset: + +```swift +@KMPStateObject( + wrappedValue: ProfileViewModel(), + states: \.profileState, \.permissionsState +) +private var profile +``` + +#### Automatic SKIE compatibility considerations + +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. +- 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. + +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: -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: +```swift +@KMPStateObject(observation: .none) +private var profile = ProfileViewModel() +``` + +For an externally owned model, keep the declaration plain and select the +strategy when assigning its backing wrapper: + +```swift +@KMPObservedObject 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. + +### Automatic KMP-NativeCoroutines observation + +For KMP-NativeCoroutines, expose one canonical NativeFlow that emits whenever +any UI-facing state changes: ```kotlin class ProfileViewModel : BaseViewModel() { @@ -478,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 @@ -493,7 +607,7 @@ struct ProfileView: View { @KMPObservedObject private var profile: ProfileViewModel init(profile: ProfileViewModel) { - _profile = KMPObservedObject(wrappedValue: profile) + _profile = KMPObservedObject(profile) } } ``` @@ -506,11 +620,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/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..4439f95 100644 --- a/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md +++ b/Sources/KMPObservableBridge/KMPObservableBridge.docc/KMPObservableBridge.md @@ -10,11 +10,7 @@ external model, and ``KMPEnvironmentObject`` for a projected store injected by an ancestor. ```swift -@KMPStateObject( - wrappedValue: ProfileViewModel(), - state: \.state -) -private var profile +@KMPStateObject private var profile = ProfileViewModel() ``` Kotlin remains the source of truth. The bridge observes emissions but never @@ -33,6 +29,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, `.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 ### Ownership @@ -54,6 +57,8 @@ removes the key path without requiring a framework-owned Kotlin superclass. - ``KMPObservation`` - ``KMPUpdatePolicy`` - ``KMPObservationFailurePolicy`` +- ``KMPAutomaticObservation`` - ``KMPNativeObservable`` +- ``KMPAutomaticallyObservable`` - ``KMPNativeFlow`` - ``KMPValueProperty`` diff --git a/Sources/KMPObservableBridge/KMPObservedObject.swift b/Sources/KMPObservableBridge/KMPObservedObject.swift index fd8f774..bde0c67 100644 --- a/Sources/KMPObservableBridge/KMPObservedObject.swift +++ b/Sources/KMPObservableBridge/KMPObservedObject.swift @@ -6,7 +6,7 @@ import SwiftUI public struct KMPObservedObject: @preconcurrency DynamicProperty { @StateObject private var storage: KMPViewModelStore private let input: ViewModel - private let states: [KMPState] + private let source: KMPObservationSource public var wrappedValue: ViewModel { storage.value @@ -16,27 +16,35 @@ public struct KMPObservedObject: @preconcurrency DynamicPr storage } + /// Observes a model using its explicit contract or SKIE discovery. public init( wrappedValue viewModel: ViewModel, + observation: KMPAutomaticObservation = .automaticSKIE, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log - ) where ViewModel: KMPNativeObservable { + ) { self.init( viewModel, - adapterArray: [.automatic()], + observation: observation, updatePolicy: updatePolicy, failurePolicy: failurePolicy ) } + /// Observes an external model without taking ownership. public init( _ viewModel: ViewModel, + observation: KMPAutomaticObservation = .automaticSKIE, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log - ) where ViewModel: KMPNativeObservable { + ) { + let source = kmpAutomaticObservationSource( + for: viewModel, + strategy: observation + ) self.init( viewModel, - adapterArray: [.automatic()], + source: source, updatePolicy: updatePolicy, failurePolicy: failurePolicy ) @@ -73,15 +81,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 +99,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 +143,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 +161,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 ) @@ -167,13 +179,27 @@ public struct KMPObservedObject: @preconcurrency DynamicPr adapterArray: [KMPState], updatePolicy: KMPUpdatePolicy, 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 + self.source = source _storage = StateObject( wrappedValue: KMPViewModelStore( viewModel, - states: adapterArray, + source: source, updatePolicy: updatePolicy, failurePolicy: failurePolicy, ownsModel: false @@ -183,6 +209,6 @@ public struct KMPObservedObject: @preconcurrency DynamicPr public mutating func update() { _storage.update() - storage.rebind(to: input, states: states) + storage.rebind(to: input, source: source) } } diff --git a/Sources/KMPObservableBridge/KMPPolicies.swift b/Sources/KMPObservableBridge/KMPPolicies.swift index a4def2c..d6cc6c8 100644 --- a/Sources/KMPObservableBridge/KMPPolicies.swift +++ b/Sources/KMPObservableBridge/KMPPolicies.swift @@ -1,4 +1,21 @@ 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 + /// 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/KMPState.swift b/Sources/KMPObservableBridge/KMPState.swift index af8a9ab..aab0a9a 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 @Sendable () -> Void, + reportError: @escaping @Sendable (Error) -> Void + ) -> 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..d14693d 100644 --- a/Sources/KMPObservableBridge/KMPStateObject.swift +++ b/Sources/KMPObservableBridge/KMPStateObject.swift @@ -14,17 +14,52 @@ public struct KMPStateObject: DynamicProperty { storage } - /// Creates an opt-in automatically observable Kotlin model. + /// Creates and owns a model using automatic observation by default. + /// + /// 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, updatePolicy: KMPUpdatePolicy = .coalesced, failurePolicy: KMPObservationFailurePolicy = .log, dispose: (@MainActor (ViewModel) -> Void)? = nil - ) where ViewModel: KMPNativeObservable { + ) { + let viewModel = makeViewModel() + let source = kmpAutomaticObservationSource( + for: viewModel, + strategy: observation + ) _storage = StateObject( wrappedValue: KMPViewModelStore( - makeViewModel(), - states: [.automatic()], + viewModel, + source: source, + updatePolicy: updatePolicy, + failurePolicy: failurePolicy, + ownsModel: true, + disposer: dispose + ) + ) + } + + /// Resolves and owns an injected model using automatic observation. + public init( + injector makeInjector: @autoclosure @escaping () -> Injector, + viewModel keyPath: KeyPath, + observation: KMPAutomaticObservation = .automaticSKIE, + updatePolicy: KMPUpdatePolicy = .coalesced, + failurePolicy: KMPObservationFailurePolicy = .log, + dispose: (@MainActor (ViewModel) -> Void)? = nil + ) { + let viewModel = makeInjector()[keyPath: keyPath] + let source = kmpAutomaticObservationSource( + for: viewModel, + strategy: observation + ) + _storage = StateObject( + wrappedValue: KMPViewModelStore( + viewModel, + source: source, updatePolicy: updatePolicy, failurePolicy: failurePolicy, ownsModel: true, @@ -77,14 +112,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 +137,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 +146,7 @@ public struct KMPStateObject: DynamicProperty { _storage = StateObject( wrappedValue: KMPViewModelStore( makeViewModel(), - states: adapters, + states: [first] + adapters, updatePolicy: updatePolicy, failurePolicy: failurePolicy, ownsModel: true, @@ -163,15 +201,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 +228,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 +237,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/KMPObservableBridge/KMPViewModel.swift b/Sources/KMPObservableBridge/KMPViewModel.swift index 63abee5..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 @@ -32,7 +65,7 @@ public final class KMPViewModelStore: @preconcurrency Obse private var modernRevision: AnyObject? private let modernObservationEnabled: Bool - init( + convenience init( _ wrappedValue: ViewModel, states: [KMPState], updatePolicy: KMPUpdatePolicy, @@ -40,6 +73,26 @@ public final class KMPViewModelStore: @preconcurrency Obse ownsModel: Bool, disposer: Disposer? = nil, 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 @@ -51,7 +104,7 @@ public final class KMPViewModelStore: @preconcurrency Obse } } configureModernObservation() - startObserving(states) + startObserving(source) } deinit { @@ -70,20 +123,52 @@ 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 + 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 + } + observations = states.map { state in state.observe( wrappedValue, diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index f5f71ee..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 @@ -107,6 +110,23 @@ final class KMPObservableBridgeTests: XCTestCase { } } + /// Represents an application-defined automatic-observation conformance. + 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 { @@ -190,12 +210,89 @@ 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() { + 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 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() + let nativeModel = NativeFlowModel() + let owned = KMPStateObject( + wrappedValue: ownedModel, + observation: .none + ) + let observed = KMPObservedObject( + 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() { + 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) }