Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ All notable changes follow Keep a Changelog and Semantic Versioning.
`KMPObservationFailurePolicy`.
- Coalescing is the default update behavior.
- The package now ships a single library product with no generator executable.
- Automatic SKIE compatibility results are cached per model class to avoid
repeated Objective-C protocol scans.
- Debug diagnostics now identify unavailable automatic SKIE runtimes,
successfully discovered StateFlow getters, and incompatible iterator method
shapes.
- Dynamic SKIE iterator methods are validated by Objective-C argument count
before invocation.

### Removed

Expand Down
165 changes: 143 additions & 22 deletions Sources/KMPObservableBridge/KMPAutomaticStateFlowRuntime.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if canImport(ObjectiveC)
import Foundation
import ObjectiveC.runtime
import OSLog

/// Runtime support for lazily observing SKIE-exported StateFlows.
///
Expand Down Expand Up @@ -33,6 +34,11 @@ enum KMPAutomaticStateFlowRuntime {
private final class KMPStateFlowRuntimeRegistry: @unchecked Sendable {
static let shared = KMPStateFlowRuntimeRegistry()

private enum DescriptorCacheEntry {
case available(KMPStateFlowRuntimeDescriptor)
case unavailable
}

private struct MethodKey: Hashable {
let type: ObjectIdentifier
let selector: Selector
Expand All @@ -41,6 +47,9 @@ private final class KMPStateFlowRuntimeRegistry: @unchecked Sendable {
private let lock = NSRecursiveLock()
private var installedMethods: Set<MethodKey> = []
private var replacementImplementations: [MethodKey: IMP] = [:]
private var descriptorCache: [
ObjectIdentifier: DescriptorCacheEntry
] = [:]
private var associationKey: UInt8 = 0

private init() {}
Expand All @@ -56,7 +65,7 @@ private final class KMPStateFlowRuntimeRegistry: @unchecked Sendable {

guard
let modelClass: AnyClass = object_getClass(model),
let descriptor = runtimeDescriptor(for: modelClass)
let descriptor = cachedRuntimeDescriptor(for: modelClass)
else {
return nil
}
Expand All @@ -73,7 +82,32 @@ private final class KMPStateFlowRuntimeRegistry: @unchecked Sendable {
}
}

private func runtimeDescriptor(
private func cachedRuntimeDescriptor(
for modelClass: AnyClass
) -> KMPStateFlowRuntimeDescriptor? {
let key = ObjectIdentifier(modelClass)
if let cached = descriptorCache[key] {
switch cached {
case .available(let descriptor):
return descriptor
case .unavailable:
return nil
}
}

guard let descriptor = resolveRuntimeDescriptor(
for: modelClass
) else {
descriptorCache[key] = .unavailable
logUnavailableRuntime(for: modelClass)
return nil
}

descriptorCache[key] = .available(descriptor)
return descriptor
}

private func resolveRuntimeDescriptor(
for modelClass: AnyClass
) -> KMPStateFlowRuntimeDescriptor? {
guard let modelImage = class_getImageName(modelClass) else {
Expand Down Expand Up @@ -111,6 +145,23 @@ private final class KMPStateFlowRuntimeRegistry: @unchecked Sendable {
return nil
}

private func logUnavailableRuntime(for modelClass: AnyClass) {
#if DEBUG
Logger(
subsystem: "KMPObservableBridge",
category: "AutomaticSKIE"
).warning(
"""
Automatic SKIE observation is unavailable for \
\(String(reflecting: modelClass), privacy: .public). \
No compatible StateFlow protocol and SkieColdFlowIterator were \
found in the model framework. Use state:, states:, adapters:, \
or observation: .none when automatic observation is not expected.
"""
)
#endif
}

private func installInterceptors(
on modelClass: AnyClass,
descriptor: KMPStateFlowRuntimeDescriptor
Expand Down Expand Up @@ -210,6 +261,7 @@ private final class KMPStateFlowObservationHub: @unchecked Sendable {
private let lock = NSRecursiveLock()
private var listeners: [ListenerID: Listener] = [:]
private var activeFlows: [Selector: ActiveFlow] = [:]
private var incompatibleSelectors: Set<Selector> = []

init(descriptor: KMPStateFlowRuntimeDescriptor) {
self.descriptor = descriptor
Expand Down Expand Up @@ -261,28 +313,65 @@ private final class KMPStateFlowObservationHub: @unchecked Sendable {

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 {
!incompatibleSelectors.contains(selector) else {
return old
}
guard let observation = KMPDynamicSKIEObservation(
flow: flow,
iteratorClass: descriptor.iteratorClass,
onValue: { [weak self] in
self?.notifyListeners()
},
onError: { [weak self] error in
self?.report(error)
}
) else {
incompatibleSelectors.insert(selector)
logIncompatibleIterator(selector)
return old
}
activeFlows[selector] = ActiveFlow(
identity: identity,
observation: observation
)
logDiscovery(selector)
observation.start()
return old
}
oldObservation?.cancel()
}

private func logDiscovery(_ selector: Selector) {
#if DEBUG
Logger(
subsystem: "KMPObservableBridge",
category: "AutomaticSKIE"
).debug(
"""
Observing SKIE StateFlow getter \
\(NSStringFromSelector(selector), privacy: .public)
"""
)
#endif
}

private func logIncompatibleIterator(_ selector: Selector) {
#if DEBUG
Logger(
subsystem: "KMPObservableBridge",
category: "AutomaticSKIE"
).warning(
"""
SKIE StateFlow getter \
\(NSStringFromSelector(selector), privacy: .public) was found, \
but SkieColdFlowIterator does not provide the expected Objective-C \
method shapes. Use an explicit state: key path and verify the \
Kotlin/SKIE version combination.
"""
)
#endif
}

private func notifyListeners() {
let callbacks = lock.withLock {
listeners.values.map(\.notify)
Expand Down Expand Up @@ -346,25 +435,31 @@ private final class KMPDynamicSKIEObservation: @unchecked Sendable {
let cancelSelector = NSSelectorFromString("cancel")

guard
let allocMethod = class_getClassMethod(
let allocMethod = kmpMethod(
iteratorClass,
allocSelector
selector: allocSelector,
kind: .class,
expectedArgumentCount: 2
),
let initializeMethod = class_getInstanceMethod(
let initializeMethod = kmpMethod(
iteratorClass,
initializeSelector
selector: initializeSelector,
expectedArgumentCount: 3
),
let hasNextMethod = class_getInstanceMethod(
let hasNextMethod = kmpMethod(
iteratorClass,
hasNextSelector
selector: hasNextSelector,
expectedArgumentCount: 3
),
let nextMethod = class_getInstanceMethod(
let nextMethod = kmpMethod(
iteratorClass,
nextSelector
selector: nextSelector,
expectedArgumentCount: 2
),
let cancelMethod = class_getInstanceMethod(
let cancelMethod = kmpMethod(
iteratorClass,
cancelSelector
selector: cancelSelector,
expectedArgumentCount: 2
)
else {
return nil
Expand Down Expand Up @@ -464,4 +559,30 @@ private final class KMPDynamicSKIEObservation: @unchecked Sendable {
return false
}
}

enum KMPObjectiveCMethodKind {
case instance
case `class`
}

func kmpMethod(
_ type: AnyClass,
selector: Selector,
kind: KMPObjectiveCMethodKind = .instance,
expectedArgumentCount: UInt32
) -> Method? {
let method: Method?
switch kind {
case .instance:
method = class_getInstanceMethod(type, selector)
case .class:
method = class_getClassMethod(type, selector)
}

guard let method,
method_getNumberOfArguments(method) == expectedArgumentCount else {
return nil
}
return method
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,30 @@ final class KMPObservableBridgePerformanceTests: XCTestCase {
}
}
}

func testCachedUnavailableAutomaticObservationSetup() {
_ = KMPViewModelStore(
Model(),
source: .automaticSKIE,
updatePolicy: .coalesced,
failurePolicy: .ignore,
ownsModel: false,
modernObservationEnabled: false
)

measure {
for _ in 0..<1_000 {
autoreleasepool {
_ = KMPViewModelStore(
Model(),
source: .automaticSKIE,
updatePolicy: .coalesced,
failurePolicy: .ignore,
ownsModel: false,
modernObservationEnabled: false
)
}
}
}
}
}
42 changes: 42 additions & 0 deletions Tests/KMPObservableBridgeTests/KMPObservableBridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import SwiftUI
import XCTest
@testable import KMPObservableBridge

#if canImport(ObjectiveC)
private final class ObjectiveCMethodFixture: NSObject {
@objc dynamic func objectValue() -> AnyObject? {
nil
}

@objc dynamic func transformedValue(
for input: AnyObject
) -> AnyObject? {
input
}
}
#endif

@MainActor
final class KMPObservableBridgeTests: XCTestCase {
private final class Model {
Expand Down Expand Up @@ -139,6 +153,34 @@ final class KMPObservableBridgeTests: XCTestCase {
XCTAssertEqual(cancellationCount, 1)
}

#if canImport(ObjectiveC)
func testObjectiveCMethodValidationChecksArgumentCount() {
XCTAssertNotNil(
kmpMethod(
ObjectiveCMethodFixture.self,
selector: #selector(ObjectiveCMethodFixture.objectValue),
expectedArgumentCount: 2
)
)
XCTAssertNil(
kmpMethod(
ObjectiveCMethodFixture.self,
selector: #selector(ObjectiveCMethodFixture.objectValue),
expectedArgumentCount: 3
)
)
XCTAssertNotNil(
kmpMethod(
ObjectiveCMethodFixture.self,
selector: #selector(
ObjectiveCMethodFixture.transformedValue(for:)
),
expectedArgumentCount: 3
)
)
}
#endif

func testObservationCancelsWhenReleased() {
var cancellationCount = 0

Expand Down
Loading