From 049611e21304e663aa76ddda547e9b38cf01666b Mon Sep 17 00:00:00 2001 From: sonmbol Date: Wed, 5 Aug 2026 10:39:49 +0400 Subject: [PATCH 1/3] feat: add demand-driven field observation --- README.md | 25 +++- .../Core/KMPObservationSource.swift | 6 +- .../Macros/KMPMacros.swift | 18 ++- .../Observation/KMPDemandObservationHub.swift | 141 ++++++++++++++++++ .../KMPDemandObservationRegistry.swift | 51 +++++++ .../Observation/KMPStaticObservation.swift | 15 ++ .../Storage/KMPFieldObservationSlot.swift | 24 +++ .../Storage/KMPViewModelStore.swift | 82 +++++++++- .../KMPObservableMacro.swift | 47 ++++-- .../KMPObservableBridgeMacroTests.swift | 38 +++++ .../KMPObservableBridgeTests.swift | 78 +++++++++- 11 files changed, 501 insertions(+), 24 deletions(-) create mode 100644 Sources/KMPObservableBridge/Observation/KMPDemandObservationHub.swift create mode 100644 Sources/KMPObservableBridge/Observation/KMPDemandObservationRegistry.swift create mode 100644 Sources/KMPObservableBridge/Storage/KMPFieldObservationSlot.swift diff --git a/README.md b/README.md index b4477d4..65a3304 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,22 @@ import KMPObservableBridgeSKIE extension SkieSwiftStateFlow: @retroactive KMPValueProperty {} ``` -### 3. Declare observable fields +### 3. Enable observation -Place the declaration beside the feature that owns the ViewModel: +For the smallest setup, attach the macro without arguments: + +```swift +@KMPObservable +extension ProfileViewModel: @retroactive KMPStaticallyObservable {} +``` + +Fields are discovered on demand when they are read through the projected +store, for example `$profile.profileState`. Swift supplies the typed key path +to the dynamic-member subscript at compile time; the bridge does not use +reflection or inspect the imported Kotlin declaration. The first read starts +one shared collector for that field, and unread fields allocate no collector. + +For eager observation, list the fields explicitly: ```swift @KMPObservable( @@ -120,9 +133,11 @@ extension ProfileViewModel: @retroactive KMPStaticallyObservable {} The fields are ordinary Swift key paths. Renaming a Kotlin export or selecting an incompatible property fails at compile time. -Swift macros cannot inspect members of imported Kotlin classes, so fields must -be listed explicitly. This avoids runtime reflection and build-generated Swift -files. +Swift macros cannot enumerate members of imported Kotlin classes. The +argument-free form solves that limitation through compile-time key paths at +each projected read; the explicit form remains available when observation must +begin before a field is read. Both forms avoid runtime reflection and +build-generated Swift files. ### 4. Use native ownership diff --git a/Sources/KMPObservableBridge/Core/KMPObservationSource.swift b/Sources/KMPObservableBridge/Core/KMPObservationSource.swift index d812b77..19445c1 100644 --- a/Sources/KMPObservableBridge/Core/KMPObservationSource.swift +++ b/Sources/KMPObservableBridge/Core/KMPObservationSource.swift @@ -4,6 +4,7 @@ /// same Kotlin state concurrently. @MainActor enum KMPObservationSource { + case demandDriven case staticPlan(KMPObservationPlan) case keyed( @MainActor ( @@ -19,7 +20,10 @@ enum KMPObservationSource { func kmpStaticObservationSource( for _: ViewModel.Type ) -> KMPObservationSource { - .keyed { model, notifyDependency, reportError in + guard ViewModel.kmpObservationStrategy == .explicit else { + return .demandDriven + } + return .keyed { model, notifyDependency, reportError in ViewModel.kmpStartObservation( on: model, notifyDependency: notifyDependency, diff --git a/Sources/KMPObservableBridge/Macros/KMPMacros.swift b/Sources/KMPObservableBridge/Macros/KMPMacros.swift index d2aed45..b3fa1a6 100644 --- a/Sources/KMPObservableBridge/Macros/KMPMacros.swift +++ b/Sources/KMPObservableBridge/Macros/KMPMacros.swift @@ -1,5 +1,19 @@ -/// Generates the static observation conformance for one imported KMP -/// ViewModel. +/// Enables demand-driven observation for an imported KMP ViewModel. +/// +/// A collector starts lazily the first time a projected StateFlow property is +/// read and is shared by all stores observing the same model and key path. +/// +/// ```swift +/// @KMPObservable +/// extension ProfileViewModel: @retroactive KMPStaticallyObservable {} +/// ``` +@attached(member, names: named(kmpObservationStrategy), named(kmpObservationPlan), named(kmpStartObservation)) +public macro KMPObservable() = #externalMacro( + module: "KMPObservableBridgeMacros", + type: "KMPObservableMacro" +) + +/// Generates an eager static observation plan for an imported KMP ViewModel. /// /// List the SKIE `StateFlow` properties that drive SwiftUI. The macro expands /// the concise key paths into statically typed observation routes: diff --git a/Sources/KMPObservableBridge/Observation/KMPDemandObservationHub.swift b/Sources/KMPObservableBridge/Observation/KMPDemandObservationHub.swift new file mode 100644 index 0000000..a775078 --- /dev/null +++ b/Sources/KMPObservableBridge/Observation/KMPDemandObservationHub.swift @@ -0,0 +1,141 @@ +/// Owns one collector for every field demanded by at least one store. +@MainActor +final class KMPDemandObservationHub { + private struct Listener { + let notify: KMPObservationNotify + let reportError: KMPObservationErrorHandler + } + + private final class Field { + var listeners: [UInt: Listener] = [:] + var observation: KMPObservation? + var broadcastDepth = 0 + var pendingRemovals: Set? + } + + private let model: Model + private let registryKey: ObjectIdentifier + private var fields: [AnyKeyPath: Field] = [:] + private var nextListenerID: UInt = 0 + + init(model: Model, registryKey: ObjectIdentifier) { + self.model = model + self.registryKey = registryKey + } + + deinit { + MainActor.assumeIsolated { + fields.values.forEach { $0.observation?.cancel() } + KMPDemandObservationRegistry.shared.removeHub( + for: registryKey, + matching: self + ) + } + } + + func addListener( + for state: KMPState, + notify: @escaping KMPObservationNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + guard case .field(let keyPath) = state.dependency else { + return state.startObservation( + on: model, + notify: notify, + reportError: reportError + ) + } + + let field = fields[keyPath] ?? { + let field = Field() + fields[keyPath] = field + return field + }() + let listenerID = makeListenerID() + field.listeners[listenerID] = Listener( + notify: notify, + reportError: reportError + ) + + if field.observation == nil { + field.observation = state.startObservation( + on: model, + notify: { @MainActor [weak self] in + self?.broadcastChange(for: keyPath) + }, + reportError: { @MainActor [weak self] error in + self?.broadcast(error, for: keyPath) + } + ) + } + + return KMPObservation { [self] in + removeListener(listenerID, for: keyPath) + } + } + + private func makeListenerID() -> UInt { + repeat { + nextListenerID &+= 1 + } while fields.values.contains(where: { + $0.listeners[nextListenerID] != nil + }) + return nextListenerID + } + + private func broadcastChange(for keyPath: AnyKeyPath) { + broadcast(for: keyPath) { $0.notify() } + } + + private func broadcast(_ error: Error, for keyPath: AnyKeyPath) { + broadcast(for: keyPath) { $0.reportError(error) } + } + + private func broadcast( + for keyPath: AnyKeyPath, + _ action: (Listener) -> Void + ) { + guard let field = fields[keyPath] else { + return + } + field.broadcastDepth += 1 + for listener in field.listeners.values { + action(listener) + } + field.broadcastDepth -= 1 + + guard field.broadcastDepth == 0, let removals = field.pendingRemovals else { + return + } + field.pendingRemovals = nil + removals.forEach { field.listeners.removeValue(forKey: $0) } + stopFieldIfUnobserved(field, keyPath: keyPath) + } + + private func removeListener(_ id: UInt, for keyPath: AnyKeyPath) { + guard let field = fields[keyPath] else { + return + } + guard field.broadcastDepth == 0 else { + if field.pendingRemovals == nil { + field.pendingRemovals = [] + } + field.pendingRemovals?.insert(id) + return + } + field.listeners.removeValue(forKey: id) + stopFieldIfUnobserved(field, keyPath: keyPath) + } + + private func stopFieldIfUnobserved( + _ field: Field, + keyPath: AnyKeyPath + ) { + guard field.listeners.isEmpty else { + return + } + field.observation?.cancel() + field.observation = nil + fields.removeValue(forKey: keyPath) + } +} diff --git a/Sources/KMPObservableBridge/Observation/KMPDemandObservationRegistry.swift b/Sources/KMPObservableBridge/Observation/KMPDemandObservationRegistry.swift new file mode 100644 index 0000000..2705c64 --- /dev/null +++ b/Sources/KMPObservableBridge/Observation/KMPDemandObservationRegistry.swift @@ -0,0 +1,51 @@ +/// Shares lazily activated field collectors between every store for a model. +@MainActor +final class KMPDemandObservationRegistry { + static let shared = KMPDemandObservationRegistry() + + private final class WeakHub { + weak var value: AnyObject? + } + + private var hubs: [ObjectIdentifier: WeakHub] = [:] + + private init() {} + + func observe( + _ model: Model, + state: KMPState, + notify: @escaping KMPObservationNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + let identity = ObjectIdentifier(model) + let hub: KMPDemandObservationHub + + if let existing = hubs[identity]?.value as? KMPDemandObservationHub { + hub = existing + } else { + hub = KMPDemandObservationHub( + model: model, + registryKey: identity + ) + let weakHub = WeakHub() + weakHub.value = hub + hubs[identity] = weakHub + } + + return hub.addListener( + for: state, + notify: notify, + reportError: reportError + ) + } + + func removeHub( + for key: ObjectIdentifier, + matching hub: AnyObject + ) { + guard hubs[key]?.value === hub else { + return + } + hubs.removeValue(forKey: key) + } +} diff --git a/Sources/KMPObservableBridge/Observation/KMPStaticObservation.swift b/Sources/KMPObservableBridge/Observation/KMPStaticObservation.swift index 46c359d..227f08d 100644 --- a/Sources/KMPObservableBridge/Observation/KMPStaticObservation.swift +++ b/Sources/KMPObservableBridge/Observation/KMPStaticObservation.swift @@ -4,6 +4,8 @@ /// Kotlin framework. Applications can also write one manually. @MainActor public protocol KMPStaticallyObservable: AnyObject { + static var kmpObservationStrategy: KMPObservationStrategy { get } + static func kmpStartObservation( on model: Self, notify: @escaping KMPObservationNotify, @@ -18,6 +20,10 @@ public protocol KMPStaticallyObservable: AnyObject { } public extension KMPStaticallyObservable { + static var kmpObservationStrategy: KMPObservationStrategy { + .explicit + } + /// Compatibility route for manually implemented 1.1 conformances. static func kmpStartObservation( on model: Self, @@ -32,6 +38,15 @@ public extension KMPStaticallyObservable { } } +/// Selects when a statically observable model starts its field collectors. +public enum KMPObservationStrategy: Sendable { + /// Starts the compiler-checked plan when the wrapper is realized. + case explicit + + /// Starts each supported field when its projected value is first read. + case demandDriven +} + /// A compile-time-checked collection of observation sources for one model. @MainActor public struct KMPObservationPlan { diff --git a/Sources/KMPObservableBridge/Storage/KMPFieldObservationSlot.swift b/Sources/KMPObservableBridge/Storage/KMPFieldObservationSlot.swift new file mode 100644 index 0000000..44fd1ec --- /dev/null +++ b/Sources/KMPObservableBridge/Storage/KMPFieldObservationSlot.swift @@ -0,0 +1,24 @@ +/// Fuses a field's SwiftUI dependency cell with its optional demand lease. +@MainActor +final class KMPFieldObservationSlot { + var revision: AnyObject? + private var observation: KMPObservation? + + func activate(_ makeObservation: () -> KMPObservation) { + guard observation == nil else { + return + } + observation = makeObservation() + } + + func cancel() { + observation?.cancel() + observation = nil + } + + deinit { + MainActor.assumeIsolated { + observation?.cancel() + } + } +} diff --git a/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift b/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift index 3a00830..313cdd8 100644 --- a/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift +++ b/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift @@ -22,8 +22,9 @@ public final class KMPViewModelStore: @preconcurrency Obse private var pendingDependencies: Set = [] private var globalRevision: AnyObject? private var projectedGlobalRevision: AnyObject? - private var fieldRevisions: [AnyKeyPath: AnyObject] = [:] + private var fieldSlots: [AnyKeyPath: KMPFieldObservationSlot] = [:] private let modernObservationEnabled: Bool + private var demandObservationEnabled = false convenience init( _ wrappedValue: ViewModel, @@ -94,6 +95,30 @@ public final class KMPViewModelStore: @preconcurrency Obse /// The projected store deliberately performs the container-to-value /// conversion so SwiftUI APIs receive native `String`, `Bool`, numeric, or /// domain values without exposing the interop container's `.value`. + public subscript( + dynamicMember keyPath: KeyPath + ) -> Property.Value where + Property: AsyncSequence & KMPValueProperty, + Property.Element == Property.Value, + Property.Element: Equatable + { + activateDemandObservation(.equatable(keyPath), for: keyPath) + trackModernAccess(for: .field(keyPath)) + return wrappedValue[keyPath: keyPath].value + } + + /// Lazily observes a non-equatable current-value async sequence. + public subscript( + dynamicMember keyPath: KeyPath + ) -> Property.Value where + Property: AsyncSequence & KMPValueProperty, + Property.Element == Property.Value + { + activateDemandObservation(.everyEmission(keyPath), for: keyPath) + trackModernAccess(for: .field(keyPath)) + return wrappedValue[keyPath: keyPath].value + } + public subscript( dynamicMember keyPath: KeyPath ) -> Property.Value where Property: KMPValueProperty { @@ -146,7 +171,10 @@ public final class KMPViewModelStore: @preconcurrency Obse let activeGeneration = generation switch source { + case .demandDriven: + demandObservationEnabled = true case .staticPlan(let plan): + demandObservationEnabled = false observations = [ plan.observe( wrappedValue, @@ -165,6 +193,7 @@ public final class KMPViewModelStore: @preconcurrency Obse ), ] case .keyed(let observe): + demandObservationEnabled = false observations = [ observe( wrappedValue, @@ -185,6 +214,7 @@ public final class KMPViewModelStore: @preconcurrency Obse ), ] case .explicit(let explicitStates): + demandObservationEnabled = false let reportError = makeErrorHandler( generation: activeGeneration ) @@ -225,11 +255,54 @@ public final class KMPViewModelStore: @preconcurrency Obse pendingChange?.cancel() pendingChange = nil pendingDependencies.removeAll(keepingCapacity: true) + demandObservationEnabled = false + fieldSlots.values.forEach { $0.cancel() } let current = observations observations.removeAll(keepingCapacity: false) current.forEach { $0.cancel() } } + private func activateDemandObservation( + _ state: KMPState, + for keyPath: KeyPath + ) { + guard demandObservationEnabled else { + return + } + let slot = fieldSlot(for: keyPath) + let activeGeneration = generation + slot.activate { [weak self] in + guard let self else { + return .empty + } + return KMPDemandObservationRegistry.shared.observe( + wrappedValue, + state: state, + notify: { @MainActor [weak self] in + guard + let self, + self.generation == activeGeneration + else { + return + } + self.scheduleChange(.field(keyPath)) + }, + reportError: makeErrorHandler( + generation: activeGeneration + ) + ) + } + } + + private func fieldSlot(for keyPath: AnyKeyPath) -> KMPFieldObservationSlot { + if let existing = fieldSlots[keyPath] { + return existing + } + let slot = KMPFieldObservationSlot() + fieldSlots[keyPath] = slot + return slot + } + private func scheduleChange( _ dependency: KMPObservationDependency ) { @@ -305,7 +378,7 @@ public final class KMPViewModelStore: @preconcurrency Obse revision?.value &+= 1 case .field(let keyPath): let revision = - fieldRevisions[keyPath] as? KMPObservationRevision + fieldSlots[keyPath]?.revision as? KMPObservationRevision revision?.value &+= 1 } } @@ -344,13 +417,14 @@ public final class KMPViewModelStore: @preconcurrency Obse projectedGlobalRevision = projectedGlobal } _ = projectedGlobal.value + let slot = fieldSlot(for: keyPath) let revision: KMPObservationRevision if let existing = - fieldRevisions[keyPath] as? KMPObservationRevision { + slot.revision as? KMPObservationRevision { revision = existing } else { revision = KMPObservationRevision() - fieldRevisions[keyPath] = revision + slot.revision = revision } _ = revision.value } diff --git a/Sources/KMPObservableBridgeMacros/KMPObservableMacro.swift b/Sources/KMPObservableBridgeMacros/KMPObservableMacro.swift index b7fe604..dd457c9 100644 --- a/Sources/KMPObservableBridgeMacros/KMPObservableMacro.swift +++ b/Sources/KMPObservableBridgeMacros/KMPObservableMacro.swift @@ -31,20 +31,11 @@ public struct KMPObservableMacro: MemberMacro { ) return [] } + let model = extensionDecl.extendedType.trimmedDescription guard case .argumentList(let arguments) = node.arguments, let modelArgument = arguments.first else { - context.diagnose( - Diagnostic( - node: Syntax(node), - message: KMPMacroDiagnostic( - message: "@KMPObservable requires ViewModel.self and at least one field." - ) - ) - ) - return [] + return demandDrivenMembers(for: model) } - - let model = extensionDecl.extendedType.trimmedDescription let declaredModel = modelArgument.expression.trimmedDescription guard declaredModel == "\(model).self" else { context.diagnose( @@ -125,6 +116,40 @@ public struct KMPObservableMacro: MemberMacro { ), ] } + + private static func demandDrivenMembers( + for model: String + ) -> [DeclSyntax] { + [ + DeclSyntax( + stringLiteral: """ + public static var kmpObservationStrategy: KMPObservationStrategy { + .demandDriven + } + + public static var kmpObservationPlan: KMPObservationPlan<\(model)> { + KMPObservationPlan() + } + + public static func kmpStartObservation( + on model: \(model), + notify: @escaping KMPObservationNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + .empty + } + + public static func kmpStartObservation( + on model: \(model), + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + .empty + } + """ + ), + ] + } } @main diff --git a/Tests/KMPObservableBridgeMacroTests/KMPObservableBridgeMacroTests.swift b/Tests/KMPObservableBridgeMacroTests/KMPObservableBridgeMacroTests.swift index f2a643e..50179f6 100644 --- a/Tests/KMPObservableBridgeMacroTests/KMPObservableBridgeMacroTests.swift +++ b/Tests/KMPObservableBridgeMacroTests/KMPObservableBridgeMacroTests.swift @@ -55,4 +55,42 @@ final class KMPObservableBridgeMacroTests: XCTestCase { macros: macros ) } + + func testDemandDrivenObservableExpansion() { + assertMacroExpansion( + """ + @KMPObservable + extension ProfileViewModel: @retroactive KMPStaticallyObservable {} + """, + expandedSource: """ + extension ProfileViewModel: @retroactive KMPStaticallyObservable { + + public static var kmpObservationStrategy: KMPObservationStrategy { + .demandDriven + } + + public static var kmpObservationPlan: KMPObservationPlan { + KMPObservationPlan() + } + + public static func kmpStartObservation( + on model: ProfileViewModel, + notify: @escaping KMPObservationNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + .empty + } + + public static func kmpStartObservation( + on model: ProfileViewModel, + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + .empty + } + } + """, + macros: macros + ) + } } diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index cdcd81c..08f6731 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -49,6 +49,7 @@ final class KMPObservableBridgeTests: XCTestCase { private var currentValue: Element private let stream: AsyncStream private let continuation: AsyncStream.Continuation + private(set) var iteratorCount = 0 var value: Element { lock.withLock { currentValue } @@ -69,7 +70,8 @@ final class KMPObservableBridgeTests: XCTestCase { } func makeAsyncIterator() -> AsyncStream.Iterator { - stream.makeAsyncIterator() + iteratorCount += 1 + return stream.makeAsyncIterator() } } @@ -105,6 +107,23 @@ final class KMPObservableBridgeTests: XCTestCase { } } + private final class DemandModel: KMPStaticallyObservable { + let first = ValueStream(0) + let second = ValueStream(0) + + static var kmpObservationStrategy: KMPObservationStrategy { + .demandDriven + } + + static func kmpStartObservation( + on model: DemandModel, + notify: @escaping KMPObservationNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + .empty + } + } + private final class DisposableModel: KMPDisposable { let state = AsyncStream { _ in } private(set) var disposalCount = 0 @@ -397,6 +416,63 @@ final class KMPObservableBridgeTests: XCTestCase { XCTAssertTrue(observed.wrappedValue === observedModel) } + func testDemandDrivenStoreStartsOnlyAccessedField() async { + let model = DemandModel() + let store = KMPViewModelStore( + model, + source: .demandDriven, + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false, + modernObservationEnabled: false + ) + var changes = 0 + let cancellable = store.objectWillChange.sink { changes += 1 } + + XCTAssertEqual(model.first.iteratorCount, 0) + XCTAssertEqual(model.second.iteratorCount, 0) + + _ = store.first + await settleMainActorTasks() + XCTAssertEqual(model.first.iteratorCount, 1) + XCTAssertEqual(model.second.iteratorCount, 0) + + model.first.update(1) + for _ in 0..<20 where changes == 0 { + try? await Task.sleep(nanoseconds: 1_000_000) + } + XCTAssertEqual(changes, 1) + withExtendedLifetime(cancellable) {} + } + + func testDemandDrivenStoresShareAccessedFieldCollector() async { + let model = DemandModel() + let first = KMPViewModelStore( + model, + source: .demandDriven, + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false, + modernObservationEnabled: false + ) + let second = KMPViewModelStore( + model, + source: .demandDriven, + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false, + modernObservationEnabled: false + ) + + _ = first.first + _ = second.first + await settleMainActorTasks() + + XCTAssertEqual(model.first.iteratorCount, 1) + XCTAssertEqual(model.second.iteratorCount, 0) + withExtendedLifetime((first, second)) {} + } + func testStaticHubSharesCollectionAndSuppressesDuplicates() async { let model = SharedModel() var firstChanges = 0 From c8e6a86f17bc98f6b76590074c92181a872efb06 Mon Sep 17 00:00:00 2001 From: sonmbol Date: Wed, 5 Aug 2026 10:53:35 +0400 Subject: [PATCH 2/3] docs: add demand-driven iOS example --- Examples/DailyPulse/iosApp/README.md | 10 +++-- .../iosApp/Examples/ArticleSKIEExample.swift | 22 ++++++---- README.md | 41 +++++++++++++++++++ 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/Examples/DailyPulse/iosApp/README.md b/Examples/DailyPulse/iosApp/README.md index e6cfec5..ec7231a 100644 --- a/Examples/DailyPulse/iosApp/README.md +++ b/Examples/DailyPulse/iosApp/README.md @@ -21,7 +21,8 @@ iosApp/ | KMP/iOS design | Example | | --- | --- | -| Macro-declared SKIE `StateFlow` | `ArticleSKIEExample.swift` and `OwnershipExamples.swift` | +| Demand-driven SKIE `StateFlow` | `ArticleSKIEExample.swift` | +| Explicit eager SKIE fields | `OwnershipExamples.swift` | | `StateObject`, `ObservedObject`, environment ownership | `OwnershipExamples.swift` | | Writable Kotlin property as SwiftUI `Binding` | `OwnershipExamples.swift` | | KMP-NativeCoroutines `NativeFlow` | `NativeCoroutinesExample.swift` | @@ -52,5 +53,8 @@ variants. These previews use immutable Swift fixtures and do not initialize Koin, allocate Kotlin ViewModels, start coroutines, collect flows, or perform network requests. -There is no generated Swift source or build-tool plugin. Every ViewModel's -typed observation plan is declared locally with `@KMPObservable`. +There is no generated Swift source or build-tool plugin. `ArticleSKIEExample` +uses argument-free `@KMPObservable` and starts its `articleState` collector on +the first `$article.articleState` read. `OwnershipExamples` lists fields +explicitly to demonstrate eager observation. Both modes are compile-time typed +and use the same ownership wrappers. diff --git a/Examples/DailyPulse/iosApp/iosApp/Examples/ArticleSKIEExample.swift b/Examples/DailyPulse/iosApp/iosApp/Examples/ArticleSKIEExample.swift index 019410c..862c89f 100644 --- a/Examples/DailyPulse/iosApp/iosApp/Examples/ArticleSKIEExample.swift +++ b/Examples/DailyPulse/iosApp/iosApp/Examples/ArticleSKIEExample.swift @@ -2,10 +2,9 @@ import SwiftUI import shared import KMPObservableBridgeSKIE -@KMPObservable( - ArticleViewModel.self, - fields: \.articleState -) +// No field list is required. A collector starts when `$article.articleState` +// is first read and is shared by every wrapper observing this ViewModel. +@KMPObservable extension ArticleViewModel: @retroactive KMPStaticallyObservable {} struct ArticleInjectorExampleView: View { @@ -18,7 +17,7 @@ struct ArticleInjectorExampleView: View { var body: some View { NavigationView { ArticleContentView(viewModel: article) - .navigationTitle("Macro SKIE") + .navigationTitle("Demand-Driven SKIE") } } } @@ -31,10 +30,15 @@ private struct ArticleContentView: View { } var body: some View { + // Projected access gives the bridge a compile-time key path. It reads + // the authoritative current value directly from Kotlin and lazily + // activates observation for this field—without runtime reflection. + let state = $article.articleState + ArticleListContent( - isLoading: article.articleState.isLoading, - error: article.articleState.error, - articles: article.articleState.articles.map(ArticleRowModel.init) + isLoading: state.isLoading, + error: state.error, + articles: state.articles.map(ArticleRowModel.init) ) } } @@ -177,7 +181,7 @@ struct ArticleSKIEExampleView_Previews: PreviewProvider { error: nil, articles: articles ) - .navigationTitle("Macro SKIE") + .navigationTitle("Demand-Driven SKIE") } .previewDisplayName("Articles") diff --git a/README.md b/README.md index 65a3304..53577c1 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,47 @@ each projected read; the explicit form remains available when observation must begin before a field is read. Both forms avoid runtime reflection and build-generated Swift files. +### Demand-driven iOS example + +```swift +import SwiftUI +import shared +import KMPObservableBridgeSKIE + +@KMPObservable +extension ArticleViewModel: @retroactive KMPStaticallyObservable {} + +struct ArticleScreen: View { + @KMPStateObject private var viewModel = ArticleViewModel() + + var body: some View { + // The first read creates the typed key path and starts one shared + // collector. Kotlin remains the only current-value storage. + let state = $viewModel.articleState + + List(state.articles, id: \.title) { article in + Text(article.title) + } + .overlay { + if state.isLoading { + ProgressView() + } + } + } +} +``` + +Use projected access for demand-driven StateFlow values: + +```swift +let state = $viewModel.articleState // observed current value +``` + +The leading `$` selects the bridge's projected store; it does not create a +`Binding` for a read-only StateFlow. Writable exported Swift properties still +use the same projected store to produce a native `Binding`, such as +`TextField("Search", text: $viewModel.searchText)`. + ### 4. Use native ownership Own the ViewModel for one SwiftUI identity: From 55b6c18296c524455f4571fa59c31bb6159c501c Mon Sep 17 00:00:00 2001 From: sonmbol Date: Wed, 5 Aug 2026 11:29:54 +0400 Subject: [PATCH 3/3] perf: harden demand-driven observation --- README.md | 3 + .../Adapters/KMPAsyncSequenceState.swift | 41 +++++ .../Observation/KMPDemandObservationHub.swift | 31 +++- .../Storage/KMPViewModelStore.swift | 13 +- .../KMPObservableBridgeTests.swift | 159 +++++++++++++++++- 5 files changed, 235 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 53577c1..d67aa64 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,9 @@ store, for example `$profile.profileState`. Swift supplies the typed key path to the dynamic-member subscript at compile time; the bridge does not use reflection or inspect the imported Kotlin declaration. The first read starts one shared collector for that field, and unread fields allocate no collector. +For `Equatable` StateFlow values, duplicate suppression is seeded from the +exact synchronous value returned to the first body evaluation, so SKIE's +initial replay does not cause a redundant render pass. For eager observation, list the fields explicitly: diff --git a/Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift b/Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift index c83a174..7870666 100644 --- a/Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift +++ b/Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift @@ -30,6 +30,47 @@ public extension KMPState { asyncSequence(keyPath, changes: { $0 }) } + /// Observes a current-value sequence after seeding duplicate suppression + /// from the value synchronously read by the projected store. + /// + /// SKIE StateFlow replays its current value to every new collector. Without + /// this seed, the first projected read would schedule a redundant SwiftUI + /// invalidation for a value that the same body evaluation already used. + internal static func demandEquatable( + _ keyPath: KeyPath, + initialValue: Sequence.Element + ) -> Self where + Sequence: AsyncSequence & KMPValueProperty, + Sequence.Element == Sequence.Value, + Sequence.Element: Equatable + { + Self(dependency: .field(keyPath)) { viewModel, notify, reportError in + let source = viewModel[keyPath: keyPath] + let task = Task { @MainActor in + var previous = initialValue + + do { + for try await element in source { + try Task.checkCancellation() + guard previous != element else { + continue + } + previous = element + notify() + } + } catch is CancellationError { + // Expected lifecycle termination. + } catch { + reportError(error) + } + } + + return KMPObservation { + task.cancel() + } + } + } + /// Invalidates only when a selected value changes. static func asyncSequence< Sequence: AsyncSequence, diff --git a/Sources/KMPObservableBridge/Observation/KMPDemandObservationHub.swift b/Sources/KMPObservableBridge/Observation/KMPDemandObservationHub.swift index a775078..889d54b 100644 --- a/Sources/KMPObservableBridge/Observation/KMPDemandObservationHub.swift +++ b/Sources/KMPObservableBridge/Observation/KMPDemandObservationHub.swift @@ -10,6 +10,7 @@ final class KMPDemandObservationHub { var listeners: [UInt: Listener] = [:] var observation: KMPObservation? var broadcastDepth = 0 + var pendingAdditions: [UInt: Listener]? var pendingRemovals: Set? } @@ -51,11 +52,19 @@ final class KMPDemandObservationHub { fields[keyPath] = field return field }() - let listenerID = makeListenerID() - field.listeners[listenerID] = Listener( + let listenerID = makeListenerID(for: field) + let listener = Listener( notify: notify, reportError: reportError ) + if field.broadcastDepth == 0 { + field.listeners[listenerID] = listener + } else { + if field.pendingAdditions == nil { + field.pendingAdditions = [:] + } + field.pendingAdditions?[listenerID] = listener + } if field.observation == nil { field.observation = state.startObservation( @@ -74,12 +83,11 @@ final class KMPDemandObservationHub { } } - private func makeListenerID() -> UInt { + private func makeListenerID(for field: Field) -> UInt { repeat { nextListenerID &+= 1 - } while fields.values.contains(where: { - $0.listeners[nextListenerID] != nil - }) + } while field.listeners[nextListenerID] != nil || + field.pendingAdditions?[nextListenerID] != nil return nextListenerID } @@ -104,7 +112,16 @@ final class KMPDemandObservationHub { } field.broadcastDepth -= 1 - guard field.broadcastDepth == 0, let removals = field.pendingRemovals else { + guard field.broadcastDepth == 0 else { + return + } + if let additions = field.pendingAdditions { + field.pendingAdditions = nil + for (id, listener) in additions { + field.listeners[id] = listener + } + } + guard let removals = field.pendingRemovals else { return } field.pendingRemovals = nil diff --git a/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift b/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift index 313cdd8..b475b63 100644 --- a/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift +++ b/Sources/KMPObservableBridge/Storage/KMPViewModelStore.swift @@ -102,9 +102,13 @@ public final class KMPViewModelStore: @preconcurrency Obse Property.Element == Property.Value, Property.Element: Equatable { - activateDemandObservation(.equatable(keyPath), for: keyPath) + let value = wrappedValue[keyPath: keyPath].value trackModernAccess(for: .field(keyPath)) - return wrappedValue[keyPath: keyPath].value + activateDemandObservation( + .demandEquatable(keyPath, initialValue: value), + for: keyPath + ) + return value } /// Lazily observes a non-equatable current-value async sequence. @@ -114,9 +118,10 @@ public final class KMPViewModelStore: @preconcurrency Obse Property: AsyncSequence & KMPValueProperty, Property.Element == Property.Value { - activateDemandObservation(.everyEmission(keyPath), for: keyPath) + let value = wrappedValue[keyPath: keyPath].value trackModernAccess(for: .field(keyPath)) - return wrappedValue[keyPath: keyPath].value + activateDemandObservation(.everyEmission(keyPath), for: keyPath) + return value } public subscript( diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift index 08f6731..34132a8 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift @@ -49,7 +49,11 @@ final class KMPObservableBridgeTests: XCTestCase { private var currentValue: Element private let stream: AsyncStream private let continuation: AsyncStream.Continuation + private let terminationCounter: LockedCounter private(set) var iteratorCount = 0 + var cancellationCount: Int { + terminationCounter.value + } var value: Element { lock.withLock { currentValue } @@ -57,8 +61,17 @@ final class KMPObservableBridgeTests: XCTestCase { init(_ value: Element) { currentValue = value + let terminationCounter = LockedCounter() + self.terminationCounter = terminationCounter var captured: AsyncStream.Continuation? - stream = AsyncStream { captured = $0 } + stream = AsyncStream { + captured = $0 + $0.onTermination = { reason in + if case .cancelled = reason { + terminationCounter.increment() + } + } + } continuation = captured! } @@ -433,9 +446,11 @@ final class KMPObservableBridgeTests: XCTestCase { XCTAssertEqual(model.second.iteratorCount, 0) _ = store.first + model.first.update(0) await settleMainActorTasks() XCTAssertEqual(model.first.iteratorCount, 1) XCTAssertEqual(model.second.iteratorCount, 0) + XCTAssertEqual(changes, 0) model.first.update(1) for _ in 0..<20 where changes == 0 { @@ -473,6 +488,115 @@ final class KMPObservableBridgeTests: XCTestCase { withExtendedLifetime((first, second)) {} } + func testDemandDrivenCollectorStopsAfterFinalStore() async { + let model = DemandModel() + var first: KMPViewModelStore? = KMPViewModelStore( + model, + source: .demandDriven, + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false, + modernObservationEnabled: false + ) + var second: KMPViewModelStore? = KMPViewModelStore( + model, + source: .demandDriven, + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false, + modernObservationEnabled: false + ) + + _ = first?.first + _ = second?.first + await settleMainActorTasks() + XCTAssertEqual(model.first.iteratorCount, 1) + + first = nil + await settleMainActorTasks() + XCTAssertEqual(model.first.cancellationCount, 0) + + second = nil + await settleMainActorTasks() + XCTAssertEqual(model.first.cancellationCount, 1) + } + + func testDemandDrivenRebindingSuppressesOldModel() async { + let oldModel = DemandModel() + let newModel = DemandModel() + let store = KMPViewModelStore( + oldModel, + source: .demandDriven, + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false, + modernObservationEnabled: false + ) + var changes = 0 + let cancellable = store.objectWillChange.sink { changes += 1 } + + _ = store.first + await settleMainActorTasks() + store.rebind(to: newModel, source: .demandDriven) + changes = 0 + _ = store.first + await settleMainActorTasks() + + oldModel.first.update(1) + await settleMainActorTasks() + XCTAssertEqual(changes, 0) + + newModel.first.update(1) + for _ in 0..<20 where changes == 0 { + try? await Task.sleep(nanoseconds: 1_000_000) + } + XCTAssertEqual(changes, 1) + XCTAssertEqual(oldModel.first.cancellationCount, 1) + XCTAssertEqual(newModel.first.iteratorCount, 1) + withExtendedLifetime(cancellable) {} + } + + func testDemandHubDefersReentrantListenerAddition() async { + let model = DemandModel() + let state = KMPState.everyEmission(\.first) + var firstChanges = 0 + var secondChanges = 0 + var secondObservation: KMPObservation? + let firstObservation = KMPDemandObservationRegistry.shared.observe( + model, + state: state, + notify: { + firstChanges += 1 + if secondObservation == nil { + secondObservation = + KMPDemandObservationRegistry.shared.observe( + model, + state: state, + notify: { secondChanges += 1 }, + reportError: { _ in } + ) + } + }, + reportError: { _ in } + ) + + await settleMainActorTasks() + model.first.update(1) + for _ in 0..<20 where firstChanges < 1 { + try? await Task.sleep(nanoseconds: 1_000_000) + } + XCTAssertEqual(firstChanges, 1) + XCTAssertEqual(secondChanges, 0) + + model.first.update(2) + for _ in 0..<20 where firstChanges < 2 || secondChanges < 1 { + try? await Task.sleep(nanoseconds: 1_000_000) + } + XCTAssertEqual(firstChanges, 2) + XCTAssertEqual(secondChanges, 1) + withExtendedLifetime((firstObservation, secondObservation)) {} + } + func testStaticHubSharesCollectionAndSuppressesDuplicates() async { let model = SharedModel() var firstChanges = 0 @@ -1029,6 +1153,39 @@ final class KMPObservableBridgeTests: XCTestCase { XCTAssertEqual(invalidationCount.value, 1) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func testDemandDrivenObservationTracksOnlyAccessedField() async { + let model = DemandModel() + let firstChanged = expectation( + description: "Demanded field invalidated" + ) + let invalidationCount = LockedCounter() + let store = KMPViewModelStore( + model, + source: .demandDriven, + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false + ) + + withObservationTracking { + _ = store.first + } onChange: { + invalidationCount.increment() + firstChanged.fulfill() + } + _ = store.second + await settleMainActorTasks() + + model.second.update(1) + await settleMainActorTasks() + XCTAssertEqual(invalidationCount.value, 0) + + model.first.update(1) + await fulfillment(of: [firstChanged], timeout: 1) + XCTAssertEqual(invalidationCount.value, 1) + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func testGlobalObservationInvalidatesForEveryField() async { let model = FieldModel()