From 0b074fd437db061b7b0ba80379c94e771e1b9feb Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Mon, 9 Jun 2025 13:27:44 +0200 Subject: [PATCH 1/8] Rename ViewModelScopeImpl to NativeViewModelScope --- .../NativeViewModelScope.kt | 52 ++++++++++++++++++ .../kmp/observableviewmodel/StateFlow.kt | 6 +- .../kmp/observableviewmodel/ViewModelScope.kt | 55 +------------------ 3 files changed, 57 insertions(+), 56 deletions(-) create mode 100644 kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt new file mode 100644 index 0000000..30578f3 --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt @@ -0,0 +1,52 @@ +package com.rickclephas.kmp.observableviewmodel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import platform.darwin.NSObject + +/** + * Implementation of [ViewModelScope] for Apple platforms. + * @property coroutineScope The [CoroutineScope] associated with the [ViewModel]. + */ +internal class NativeViewModelScope internal constructor( + val coroutineScope: CoroutineScope +): NSObject(), ViewModelScope { + + private val _subscriptionCount = MutableStateFlow(0) + /** + * A [StateFlow] that emits the number of subscribers to the [ViewModel]. + */ + val subscriptionCount: StateFlow = _subscriptionCount.asStateFlow() + + override fun increaseSubscriptionCount() { + _subscriptionCount.update { it + 1 } + } + + override fun decreaseSubscriptionCount() { + _subscriptionCount.update { it - 1 } + } + + private var sendObjectWillChange: (() -> Unit)? = null + + override fun setSendObjectWillChange(sendObjectWillChange: () -> Unit) { + if (this.sendObjectWillChange != null) { + throw IllegalStateException("ViewModel can't be wrapped more than once") + } + this.sendObjectWillChange = sendObjectWillChange + } + + /** + * Invokes the object will change listener set by [setSendObjectWillChange]. + */ + fun sendObjectWillChange() { + sendObjectWillChange?.invoke() + } +} + +/** + * Casts `this` [ViewModelScope] to a [NativeViewModelScope]. + */ +internal inline fun ViewModelScope.asNative(): NativeViewModelScope = this as NativeViewModelScope diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt index dfd9f30..2ff287e 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt @@ -11,7 +11,7 @@ import kotlin.coroutines.EmptyCoroutineContext public actual fun MutableStateFlow( viewModelScope: ViewModelScope, value: T -): MutableStateFlow = MutableStateFlowImpl(viewModelScope.asImpl(), MutableStateFlow(value)) +): MutableStateFlow = MutableStateFlowImpl(viewModelScope.asNative(), MutableStateFlow(value)) /** * A [MutableStateFlow] that triggers [ViewModelScopeImpl.sendObjectWillChange] @@ -19,7 +19,7 @@ public actual fun MutableStateFlow( */ @OptIn(ExperimentalForInheritanceCoroutinesApi::class) private class MutableStateFlowImpl( - private val viewModelScope: ViewModelScopeImpl, + private val viewModelScope: NativeViewModelScope, private val stateFlow: MutableStateFlow ): MutableStateFlow { @@ -97,7 +97,7 @@ public actual fun Flow.stateIn( ): StateFlow { // Similar to kotlinx.coroutines, but using our custom MutableStateFlowImpl and CoroutineContext logic. // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/operators/Share.kt#L135 - val scope = viewModelScope.asImpl() + val scope = viewModelScope.asNative() val state = MutableStateFlowImpl(scope, MutableStateFlow(initialValue)) val job = scope.coroutineScope.launchSharing(EmptyCoroutineContext, this, state, started, initialValue) return ReadonlyStateFlow(state, job) diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt index ef143b0..2136156 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt @@ -2,11 +2,6 @@ package com.rickclephas.kmp.observableviewmodel import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelScopeProtocol import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import platform.darwin.NSObject /** * Holds the [CoroutineScope] of a [ViewModel]. @@ -18,56 +13,10 @@ public actual typealias ViewModelScope = KMPOVMViewModelScopeProtocol * Creates a new [ViewModelScope] for the provided [coroutineScope]. */ internal actual fun ViewModelScope(coroutineScope: CoroutineScope): ViewModelScope = - ViewModelScopeImpl(coroutineScope) + NativeViewModelScope(coroutineScope) /** * Gets the [CoroutineScope] associated with the [ViewModel] of `this` [ViewModelScope]. */ public actual val ViewModelScope.coroutineScope: CoroutineScope - get() = asImpl().coroutineScope - -/** - * Casts `this` [ViewModelScope] to a [ViewModelScopeImpl]. - */ -@InternalKMPObservableViewModelApi -public inline fun ViewModelScope.asImpl(): ViewModelScopeImpl = this as ViewModelScopeImpl - -/** - * Implementation of [ViewModelScope]. - * @property coroutineScope The [CoroutineScope] associated with the [ViewModel]. - */ -@InternalKMPObservableViewModelApi -public class ViewModelScopeImpl internal constructor( - public val coroutineScope: CoroutineScope -): NSObject(), ViewModelScope { - - private val _subscriptionCount = MutableStateFlow(0) - /** - * A [StateFlow] that emits the number of subscribers to the [ViewModel]. - */ - public val subscriptionCount: StateFlow = _subscriptionCount.asStateFlow() - - override fun increaseSubscriptionCount() { - _subscriptionCount.update { it + 1 } - } - - override fun decreaseSubscriptionCount() { - _subscriptionCount.update { it - 1 } - } - - private var sendObjectWillChange: (() -> Unit)? = null - - override fun setSendObjectWillChange(sendObjectWillChange: () -> Unit) { - if (this.sendObjectWillChange != null) { - throw IllegalStateException("ViewModel can't be wrapped more than once") - } - this.sendObjectWillChange = sendObjectWillChange - } - - /** - * Invokes the object will change listener set by [setSendObjectWillChange]. - */ - public fun sendObjectWillChange() { - sendObjectWillChange?.invoke() - } -} + get() = asNative().coroutineScope From 599b04df21e1cc7c156c1036d8180f18a1b55c35 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Mon, 9 Jun 2025 13:40:49 +0200 Subject: [PATCH 2/8] Rename MutableStateFlowImpl to ObservableMutableStateFlow --- .../CombinedSubscriptionCount.kt | 35 +++++++ .../ObservableStateFlow.kt | 69 ++++++++++++++ .../kmp/observableviewmodel/StateFlow.kt | 91 +------------------ 3 files changed, 107 insertions(+), 88 deletions(-) create mode 100644 kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt create mode 100644 kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt new file mode 100644 index 0000000..51f05e1 --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt @@ -0,0 +1,35 @@ +package com.rickclephas.kmp.observableviewmodel + +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +/** + * A [StateFlow] that combines the subscription counts of a [NativeViewModelScope] and a [StateFlow]. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class CombinedSubscriptionCount private constructor( + private val viewModelScopeCount: StateFlow, + private val stateFlowCount: StateFlow +): StateFlow { + + constructor(viewModelScope: NativeViewModelScope, stateFlow: MutableStateFlow<*>) : this( + viewModelScopeCount = viewModelScope.subscriptionCount, + stateFlowCount = stateFlow.subscriptionCount + ) + + override val value: Int + get() = viewModelScopeCount.value + stateFlowCount.value + + override val replayCache: List + get() = listOf(value) + + override suspend fun collect(collector: FlowCollector): Nothing { + viewModelScopeCount.combine(stateFlowCount) { viewModelScopeCount, stateFlowCount -> + viewModelScopeCount + stateFlowCount + }.collect(collector) + throw IllegalStateException("CombinedSubscriptionCount.collect should never complete") + } +} diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt new file mode 100644 index 0000000..d1b0602 --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt @@ -0,0 +1,69 @@ +package com.rickclephas.kmp.observableviewmodel + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * A [MutableStateFlow] that triggers [NativeViewModelScope.sendObjectWillChange] + * and accounts for the [NativeViewModelScope.subscriptionCount]. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class ObservableMutableStateFlow( + private val viewModelScope: NativeViewModelScope, + private val stateFlow: MutableStateFlow +): MutableStateFlow { + + override var value: T + get() = stateFlow.value + set(value) { + if (stateFlow.value != value) { + viewModelScope.sendObjectWillChange() + } + stateFlow.value = value + } + + override val replayCache: List + get() = stateFlow.replayCache + + override val subscriptionCount: StateFlow = CombinedSubscriptionCount(viewModelScope, stateFlow) + + override suspend fun collect(collector: FlowCollector): Nothing = + stateFlow.collect(collector) + + override fun compareAndSet(expect: T, update: T): Boolean { + if (stateFlow.value == expect && expect != update) { + viewModelScope.sendObjectWillChange() + } + return stateFlow.compareAndSet(expect, update) + } + + @ExperimentalCoroutinesApi + override fun resetReplayCache() = stateFlow.resetReplayCache() + + // Same implementation as in StateFlowImpl, but we need to go through our own value property. + // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L369 + override fun tryEmit(value: T): Boolean { + this.value = value + return true + } + + // Same implementation as in StateFlowImpl, but we need to go through our own value property. + // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L374 + override suspend fun emit(value: T) { + this.value = value + } +} + +/** + * A [StateFlow] backed by an [ObservableMutableStateFlow] optionally holding a reference to a [Job]. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class ObservableStateFlow( + internal val flow: ObservableMutableStateFlow, + @Suppress("unused") + private val job: Job? = null +): StateFlow by flow diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt index 2ff287e..b66c996 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt @@ -11,81 +11,7 @@ import kotlin.coroutines.EmptyCoroutineContext public actual fun MutableStateFlow( viewModelScope: ViewModelScope, value: T -): MutableStateFlow = MutableStateFlowImpl(viewModelScope.asNative(), MutableStateFlow(value)) - -/** - * A [MutableStateFlow] that triggers [ViewModelScopeImpl.sendObjectWillChange] - * and accounts for the [ViewModelScopeImpl.subscriptionCount]. - */ -@OptIn(ExperimentalForInheritanceCoroutinesApi::class) -private class MutableStateFlowImpl( - private val viewModelScope: NativeViewModelScope, - private val stateFlow: MutableStateFlow -): MutableStateFlow { - - override var value: T - get() = stateFlow.value - set(value) { - if (stateFlow.value != value) { - viewModelScope.sendObjectWillChange() - } - stateFlow.value = value - } - - override val replayCache: List - get() = stateFlow.replayCache - - override val subscriptionCount: StateFlow = - SubscriptionCountFlow(viewModelScope.subscriptionCount, stateFlow.subscriptionCount) - - override suspend fun collect(collector: FlowCollector): Nothing = - stateFlow.collect(collector) - - override fun compareAndSet(expect: T, update: T): Boolean { - if (stateFlow.value == expect && expect != update) { - viewModelScope.sendObjectWillChange() - } - return stateFlow.compareAndSet(expect, update) - } - - @ExperimentalCoroutinesApi - override fun resetReplayCache() = stateFlow.resetReplayCache() - - // Same implementation as in StateFlowImpl, but we need to go through our own value property. - // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L369 - override fun tryEmit(value: T): Boolean { - this.value = value - return true - } - - // Same implementation as in StateFlowImpl, but we need to go through our own value property. - // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L374 - override suspend fun emit(value: T) { - this.value = value - } -} - -/** - * A [StateFlow] that combines the subscription counts of a [ViewModelScopeImpl] and [StateFlow]. - */ -@OptIn(ExperimentalForInheritanceCoroutinesApi::class) -private class SubscriptionCountFlow( - private val viewModelScopeSubscriptionCount: StateFlow, - private val stateFlowSubscriptionCount: StateFlow -): StateFlow { - override val value: Int - get() = viewModelScopeSubscriptionCount.value + stateFlowSubscriptionCount.value - - override val replayCache: List - get() = listOf(value) - - override suspend fun collect(collector: FlowCollector): Nothing { - viewModelScopeSubscriptionCount.combine(stateFlowSubscriptionCount) { count1, count2 -> - count1 + count2 - }.collect(collector) - throw IllegalStateException("SubscriptionCountFlow collect completed") - } -} +): MutableStateFlow = ObservableMutableStateFlow(viewModelScope.asNative(), MutableStateFlow(value)) /** * @see kotlinx.coroutines.flow.stateIn @@ -98,9 +24,9 @@ public actual fun Flow.stateIn( // Similar to kotlinx.coroutines, but using our custom MutableStateFlowImpl and CoroutineContext logic. // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/operators/Share.kt#L135 val scope = viewModelScope.asNative() - val state = MutableStateFlowImpl(scope, MutableStateFlow(initialValue)) + val state = ObservableMutableStateFlow(scope, MutableStateFlow(initialValue)) val job = scope.coroutineScope.launchSharing(EmptyCoroutineContext, this, state, started, initialValue) - return ReadonlyStateFlow(state, job) + return ObservableStateFlow(state, job) } /** @@ -136,14 +62,3 @@ private fun CoroutineScope.launchSharing( } } } - -/** - * Similar to the kotlinx.coroutines implementation, used to return a read-only StateFlow with an optional Job. - * https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/operators/Share.kt#L379 - */ -@OptIn(ExperimentalForInheritanceCoroutinesApi::class) -private class ReadonlyStateFlow( - flow: StateFlow, - @Suppress("unused") - private val job: Job? -): StateFlow by flow From 2dc37a2d14ae9388dda8fa06848d7a92beeae73a Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Mon, 9 Jun 2025 14:00:05 +0200 Subject: [PATCH 3/8] Introduce SubscriptionCount class --- .../ObservableViewModelPublisher.swift | 27 +++++++++---------- .../KMPOVMSubscriptionCount.h | 23 ++++++++++++++++ .../KMPOVMViewModelScope.h | 9 +++++-- .../KMPObservableViewModelCoreObjC.h | 1 + .../CombinedSubscriptionCount.kt | 2 +- .../NativeViewModelScope.kt | 19 ++----------- .../observableviewmodel/SubscriptionCount.kt | 20 ++++++++++++++ .../KMPObservableViewModelCoreObjC.def | 2 +- 8 files changed, 68 insertions(+), 35 deletions(-) create mode 100644 KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h create mode 100644 kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/SubscriptionCount.kt diff --git a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift index 4e194a5..8341c7b 100644 --- a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift +++ b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift @@ -14,12 +14,14 @@ public final class ObservableViewModelPublisher: Publisher { public typealias Failure = Never internal weak var viewModel: (any ViewModel)? + private let subscriptionCount: any SubscriptionCount private let publisher = ObservableObjectPublisher() private var objectWillChangeCancellable: AnyCancellable? = nil internal init(_ viewModel: any ViewModel, _ objectWillChange: ObservableObjectPublisher) { self.viewModel = viewModel + self.subscriptionCount = viewModel.viewModelScope.subscriptionCount viewModel.viewModelScope.setSendObjectWillChange { [weak self] in self?.publisher.send() } @@ -29,8 +31,8 @@ public final class ObservableViewModelPublisher: Publisher { } public func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Void == S.Input { - viewModel?.viewModelScope.increaseSubscriptionCount() - publisher.receive(subscriber: ObservableViewModelSubscriber(self, subscriber)) + subscriptionCount.increase() + publisher.receive(subscriber: ObservableViewModelSubscriber(subscriptionCount, subscriber)) } deinit { @@ -47,16 +49,16 @@ private class ObservableViewModelSubscriber: Subscriber where S : Subscriber, typealias Input = Void typealias Failure = Never - private let publisher: ObservableViewModelPublisher + private let subscriptionCount: any SubscriptionCount private let subscriber: S - init(_ publisher: ObservableViewModelPublisher, _ subscriber: S) { - self.publisher = publisher + init(_ subscriptionCount: any SubscriptionCount, _ subscriber: S) { + self.subscriptionCount = subscriptionCount self.subscriber = subscriber } func receive(subscription: Subscription) { - subscriber.receive(subscription: ObservableViewModelSubscription(publisher, subscription)) + subscriber.receive(subscription: ObservableViewModelSubscription(subscriptionCount, subscription)) } func receive(_ input: Void) -> Subscribers.Demand { @@ -71,11 +73,11 @@ private class ObservableViewModelSubscriber: Subscriber where S : Subscriber, /// Subscription for `ObservableViewModelPublisher` that decreases the subscription count upon cancellation. private class ObservableViewModelSubscription: Subscription { - private let publisher: ObservableViewModelPublisher + private var subscriptionCount: (any SubscriptionCount)? private let subscription: Subscription - init(_ publisher: ObservableViewModelPublisher, _ subscription: Subscription) { - self.publisher = publisher + init(_ subscriptionCount: any SubscriptionCount, _ subscription: Subscription) { + self.subscriptionCount = subscriptionCount self.subscription = subscription } @@ -83,12 +85,9 @@ private class ObservableViewModelSubscription: Subscription { subscription.request(demand) } - private var cancelled = false - func cancel() { subscription.cancel() - guard !cancelled else { return } - cancelled = true - publisher.viewModel?.viewModelScope.decreaseSubscriptionCount() + subscriptionCount?.decrease() + subscriptionCount = nil } } diff --git a/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h b/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h new file mode 100644 index 0000000..bc83b66 --- /dev/null +++ b/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h @@ -0,0 +1,23 @@ +// +// KMPOVMSubscriptionCount.h +// KMPObservableViewModel +// +// Created by Rick Clephas on 09/06/2025. +// + +#ifndef KMPOVMSubscriptionCount_h +#define KMPOVMSubscriptionCount_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +__attribute__((swift_name("SubscriptionCount"))) +@protocol KMPOVMSubscriptionCount +- (void)increase; +- (void)decrease; +@end + +NS_ASSUME_NONNULL_END + +#endif /* KMPOVMSubscriptionCount_h */ diff --git a/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h b/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h index baab6cc..5954eff 100644 --- a/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h +++ b/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h @@ -9,12 +9,17 @@ #define KMPOVMViewModelScope_h #import +#import "KMPOVMSubscriptionCount.h" + +NS_ASSUME_NONNULL_BEGIN __attribute__((swift_name("ViewModelScope"))) @protocol KMPOVMViewModelScope -- (void)increaseSubscriptionCount; -- (void)decreaseSubscriptionCount; +@property (readonly) id subscriptionCount; + - (void)setSendObjectWillChange:(void (^ _Nonnull)(void))sendObjectWillChange; @end +NS_ASSUME_NONNULL_END + #endif /* KMPOVMViewModelScope_h */ diff --git a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h index 2b2e329..024ea68 100644 --- a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h +++ b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h @@ -5,4 +5,5 @@ // Created by Rick Clephas on 27/11/2022. // +#import "KMPOVMSubscriptionCount.h" #import "KMPOVMViewModelScope.h" diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt index 51f05e1..2939dfd 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt @@ -16,7 +16,7 @@ internal class CombinedSubscriptionCount private constructor( ): StateFlow { constructor(viewModelScope: NativeViewModelScope, stateFlow: MutableStateFlow<*>) : this( - viewModelScopeCount = viewModelScope.subscriptionCount, + viewModelScopeCount = viewModelScope.subscriptionCount().stateFlow, stateFlowCount = stateFlow.subscriptionCount ) diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt index 30578f3..e361ecc 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt @@ -1,10 +1,6 @@ package com.rickclephas.kmp.observableviewmodel import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import platform.darwin.NSObject /** @@ -15,19 +11,8 @@ internal class NativeViewModelScope internal constructor( val coroutineScope: CoroutineScope ): NSObject(), ViewModelScope { - private val _subscriptionCount = MutableStateFlow(0) - /** - * A [StateFlow] that emits the number of subscribers to the [ViewModel]. - */ - val subscriptionCount: StateFlow = _subscriptionCount.asStateFlow() - - override fun increaseSubscriptionCount() { - _subscriptionCount.update { it + 1 } - } - - override fun decreaseSubscriptionCount() { - _subscriptionCount.update { it - 1 } - } + private val _subscriptionCount = SubscriptionCount() + override fun subscriptionCount(): SubscriptionCount = _subscriptionCount private var sendObjectWillChange: (() -> Unit)? = null diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/SubscriptionCount.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/SubscriptionCount.kt new file mode 100644 index 0000000..01ac402 --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/SubscriptionCount.kt @@ -0,0 +1,20 @@ +package com.rickclephas.kmp.observableviewmodel + +import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMSubscriptionCountProtocol +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import platform.darwin.NSObject + +/** + * [KMPOVMSubscriptionCountProtocol] implementation that uses a [StateFlow] to store the subscription count. + */ +internal class SubscriptionCount: NSObject(), KMPOVMSubscriptionCountProtocol { + + private val _stateFlow = MutableStateFlow(0) + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + override fun increase(): Unit = _stateFlow.update { it + 1 } + override fun decrease(): Unit = _stateFlow.update { it - 1 } +} diff --git a/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def b/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def index 96925fb..7100b54 100644 --- a/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def +++ b/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def @@ -1,3 +1,3 @@ language = Objective-C package = com.rickclephas.kmp.observableviewmodel.objc -headers = KMPOVMViewModelScope.h +headers = KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h From f5ad567d0c4ce1466a8422a2f6e32ec8538e26a9 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Mon, 9 Jun 2025 16:13:13 +0200 Subject: [PATCH 4/8] Store ObservableViewModelPublisher inside NativeViewModelScope --- .../ObservableViewModel.swift | 11 +++------- .../ObservableViewModelPublisher.swift | 20 ++++++++--------- .../ObservableViewModelPublishers.swift | 2 +- KMPObservableViewModelCore/ViewModel.swift | 13 +++++++++++ .../KMPOVMPublisher.h | 22 +++++++++++++++++++ .../KMPOVMViewModelScope.h | 4 ++-- .../KMPObservableViewModelCoreObjC.h | 1 + .../NativeViewModelScope.kt | 20 +++++------------ .../ObservableStateFlow.kt | 6 ++--- .../KMPObservableViewModelCoreObjC.def | 2 +- 10 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 KMPObservableViewModelCoreObjC/KMPOVMPublisher.h diff --git a/KMPObservableViewModelCore/ObservableViewModel.swift b/KMPObservableViewModelCore/ObservableViewModel.swift index 6990b9e..daf88f6 100644 --- a/KMPObservableViewModelCore/ObservableViewModel.swift +++ b/KMPObservableViewModelCore/ObservableViewModel.swift @@ -13,8 +13,7 @@ import KMPObservableViewModelCoreObjC public func observableViewModel( for viewModel: VM ) -> ObservableViewModel { - let publishers = observableViewModelPublishers(for: viewModel) - return ObservableViewModel(publishers, viewModel) + return ObservableViewModel(viewModel) } /// Gets an `ObservableObject` for the specified `ViewModel`. @@ -35,13 +34,9 @@ public final class ObservableViewModel: ObservableObject, Hashabl /// The observed `ViewModel`. public let viewModel: VM - /// Holds a strong reference to the publishers - private let publishers: ObservableViewModelPublishers - - internal init(_ publishers: ObservableViewModelPublishers, _ viewModel: VM) { - objectWillChange = publishers.publisher + internal init(_ viewModel: VM) { + objectWillChange = viewModel.viewModelWillChange self.viewModel = viewModel - self.publishers = publishers } public static func == (lhs: ObservableViewModel, rhs: ObservableViewModel) -> Bool { diff --git a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift index 8341c7b..856b37d 100644 --- a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift +++ b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift @@ -9,25 +9,19 @@ import Combine import KMPObservableViewModelCoreObjC /// Publisher for `ObservableViewModel` that connects to the `ViewModelScope`. -public final class ObservableViewModelPublisher: Publisher { +public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservableViewModelCoreObjC.Publisher { public typealias Output = Void public typealias Failure = Never internal weak var viewModel: (any ViewModel)? - private let subscriptionCount: any SubscriptionCount - private let publisher = ObservableObjectPublisher() - private var objectWillChangeCancellable: AnyCancellable? = nil + private let publisher: ObservableObjectPublisher + private let subscriptionCount: any SubscriptionCount - internal init(_ viewModel: any ViewModel, _ objectWillChange: ObservableObjectPublisher) { + internal init(_ viewModel: any ViewModel) { self.viewModel = viewModel + self.publisher = viewModel.objectWillChange self.subscriptionCount = viewModel.viewModelScope.subscriptionCount - viewModel.viewModelScope.setSendObjectWillChange { [weak self] in - self?.publisher.send() - } - objectWillChangeCancellable = objectWillChange.sink { [weak self] _ in - self?.publisher.send() - } } public func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Void == S.Input { @@ -35,6 +29,10 @@ public final class ObservableViewModelPublisher: Publisher { publisher.receive(subscriber: ObservableViewModelSubscriber(subscriptionCount, subscriber)) } + public func send() { + publisher.send() + } + deinit { guard let viewModel else { return } if let cancellable = viewModel as? Cancellable { diff --git a/KMPObservableViewModelCore/ObservableViewModelPublishers.swift b/KMPObservableViewModelCore/ObservableViewModelPublishers.swift index 4142612..b14869c 100644 --- a/KMPObservableViewModelCore/ObservableViewModelPublishers.swift +++ b/KMPObservableViewModelCore/ObservableViewModelPublishers.swift @@ -26,7 +26,7 @@ internal func observableViewModelPublishers( fatalError("ObservableViewModel has been deallocated") }() } else { - let publisher = ObservableViewModelPublisher(viewModel, viewModel.objectWillChange) + let publisher = ObservableViewModelPublisher(viewModel) publishers = ObservableViewModelPublishers(publisher) let object = WeakObservableViewModelPublishers(publishers) objc_setAssociatedObject(viewModel, &observableViewModelPublishersKey, object, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) diff --git a/KMPObservableViewModelCore/ViewModel.swift b/KMPObservableViewModelCore/ViewModel.swift index 4245db9..3e2e9c4 100644 --- a/KMPObservableViewModelCore/ViewModel.swift +++ b/KMPObservableViewModelCore/ViewModel.swift @@ -12,7 +12,20 @@ import KMPObservableViewModelCoreObjC public protocol ViewModel: ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher { /// The `ViewModelScope` of this `ViewModel`. var viewModelScope: ViewModelScope { get } + /// An `ObservableViewModelPublisher` that emits before this `ViewModel` has changed. + var viewModelWillChange: ObservableViewModelPublisher { get } /// Internal KMP-ObservableViewModel function used to clear the ViewModel. /// - Warning: You should NOT call this yourself! func clear() } + +public extension ViewModel { + var viewModelWillChange: ObservableViewModelPublisher { + if let publisher = viewModelScope.publisher { + return publisher as! ObservableViewModelPublisher + } + let publisher = ObservableViewModelPublisher(self) + viewModelScope.publisher = publisher + return publisher + } +} diff --git a/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h b/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h new file mode 100644 index 0000000..675bb02 --- /dev/null +++ b/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h @@ -0,0 +1,22 @@ +// +// KMPOVMPublisher.h +// KMPObservableViewModel +// +// Created by Rick Clephas on 09/06/2025. +// + +#ifndef KMPOVMPublisher_h +#define KMPOVMPublisher_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +__attribute__((swift_name("Publisher"))) +@protocol KMPOVMPublisher +- (void)send; +@end + +NS_ASSUME_NONNULL_END + +#endif /* KMPOVMPublisher_h */ diff --git a/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h b/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h index 5954eff..57dc93b 100644 --- a/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h +++ b/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h @@ -9,6 +9,7 @@ #define KMPOVMViewModelScope_h #import +#import "KMPOVMPublisher.h" #import "KMPOVMSubscriptionCount.h" NS_ASSUME_NONNULL_BEGIN @@ -16,8 +17,7 @@ NS_ASSUME_NONNULL_BEGIN __attribute__((swift_name("ViewModelScope"))) @protocol KMPOVMViewModelScope @property (readonly) id subscriptionCount; - -- (void)setSendObjectWillChange:(void (^ _Nonnull)(void))sendObjectWillChange; +@property id _Nullable publisher; @end NS_ASSUME_NONNULL_END diff --git a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h index 024ea68..f7c0985 100644 --- a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h +++ b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h @@ -5,5 +5,6 @@ // Created by Rick Clephas on 27/11/2022. // +#import "KMPOVMPublisher.h" #import "KMPOVMSubscriptionCount.h" #import "KMPOVMViewModelScope.h" diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt index e361ecc..23470c6 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt @@ -1,5 +1,6 @@ package com.rickclephas.kmp.observableviewmodel +import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMPublisherProtocol import kotlinx.coroutines.CoroutineScope import platform.darwin.NSObject @@ -14,20 +15,11 @@ internal class NativeViewModelScope internal constructor( private val _subscriptionCount = SubscriptionCount() override fun subscriptionCount(): SubscriptionCount = _subscriptionCount - private var sendObjectWillChange: (() -> Unit)? = null - - override fun setSendObjectWillChange(sendObjectWillChange: () -> Unit) { - if (this.sendObjectWillChange != null) { - throw IllegalStateException("ViewModel can't be wrapped more than once") - } - this.sendObjectWillChange = sendObjectWillChange - } - - /** - * Invokes the object will change listener set by [setSendObjectWillChange]. - */ - fun sendObjectWillChange() { - sendObjectWillChange?.invoke() + private var _publisher: KMPOVMPublisherProtocol? = null + override fun publisher(): KMPOVMPublisherProtocol? = _publisher + override fun setPublisher(publisher: KMPOVMPublisherProtocol?) { + if (_publisher != null) throw IllegalStateException("ViewModel can't be initialized more than once") + _publisher = publisher } } diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt index d1b0602..89db2a7 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** - * A [MutableStateFlow] that triggers [NativeViewModelScope.sendObjectWillChange] + * A [MutableStateFlow] that triggers [NativeViewModelScope.publisher] * and accounts for the [NativeViewModelScope.subscriptionCount]. */ @OptIn(ExperimentalForInheritanceCoroutinesApi::class) @@ -21,7 +21,7 @@ internal class ObservableMutableStateFlow( get() = stateFlow.value set(value) { if (stateFlow.value != value) { - viewModelScope.sendObjectWillChange() + viewModelScope.publisher?.send() } stateFlow.value = value } @@ -36,7 +36,7 @@ internal class ObservableMutableStateFlow( override fun compareAndSet(expect: T, update: T): Boolean { if (stateFlow.value == expect && expect != update) { - viewModelScope.sendObjectWillChange() + viewModelScope.publisher?.send() } return stateFlow.compareAndSet(expect, update) } diff --git a/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def b/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def index 7100b54..b830088 100644 --- a/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def +++ b/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def @@ -1,3 +1,3 @@ language = Objective-C package = com.rickclephas.kmp.observableviewmodel.objc -headers = KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h +headers = KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h From e69ef983a7a99dc1a27d9c5d7ad9705885fb542a Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Mon, 9 Jun 2025 17:08:32 +0200 Subject: [PATCH 5/8] Add ViewModelCancellable to manage cancellation/clear logic --- .../ObservableViewModel.swift | 4 ++ .../ObservableViewModelPublisher.swift | 11 +----- .../ViewModelCancellable.swift | 37 +++++++++++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 KMPObservableViewModelCore/ViewModelCancellable.swift diff --git a/KMPObservableViewModelCore/ObservableViewModel.swift b/KMPObservableViewModelCore/ObservableViewModel.swift index daf88f6..c855c77 100644 --- a/KMPObservableViewModelCore/ObservableViewModel.swift +++ b/KMPObservableViewModelCore/ObservableViewModel.swift @@ -34,9 +34,13 @@ public final class ObservableViewModel: ObservableObject, Hashabl /// The observed `ViewModel`. public let viewModel: VM + /// Holds a strong reference to the cancellable + private let cancellable: AnyCancellable + internal init(_ viewModel: VM) { objectWillChange = viewModel.viewModelWillChange self.viewModel = viewModel + cancellable = ViewModelCancellable.get(for: viewModel) } public static func == (lhs: ObservableViewModel, rhs: ObservableViewModel) -> Bool { diff --git a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift index 856b37d..898aac2 100644 --- a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift +++ b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift @@ -13,13 +13,12 @@ public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservabl public typealias Output = Void public typealias Failure = Never - internal weak var viewModel: (any ViewModel)? + internal let cancellable = ViewModelCancellable() private let publisher: ObservableObjectPublisher private let subscriptionCount: any SubscriptionCount internal init(_ viewModel: any ViewModel) { - self.viewModel = viewModel self.publisher = viewModel.objectWillChange self.subscriptionCount = viewModel.viewModelScope.subscriptionCount } @@ -32,14 +31,6 @@ public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservabl public func send() { publisher.send() } - - deinit { - guard let viewModel else { return } - if let cancellable = viewModel as? Cancellable { - cancellable.cancel() - } - viewModel.clear() - } } /// Subscriber for `ObservableViewModelPublisher` that creates `ObservableViewModelSubscription`s. diff --git a/KMPObservableViewModelCore/ViewModelCancellable.swift b/KMPObservableViewModelCore/ViewModelCancellable.swift new file mode 100644 index 0000000..dcdf8fa --- /dev/null +++ b/KMPObservableViewModelCore/ViewModelCancellable.swift @@ -0,0 +1,37 @@ +// +// ViewModelCancellable.swift +// KMPObservableViewModel +// +// Created by Rick Clephas on 09/06/2025. +// + +import Combine + +/// Helper object that provides a weakly cached `Cancellable` for a `ViewModel`. +internal class ViewModelCancellable { + + private var didInit = false + private weak var cancellable: AnyCancellable? + + private func get(_ viewModel: any ViewModel) -> AnyCancellable { + guard didInit else { + let cancellable = AnyCancellable { + if let cancellable = viewModel as? Cancellable { + cancellable.cancel() + } + viewModel.clear() + } + self.cancellable = cancellable + didInit = true + return cancellable + } + guard let cancellable = self.cancellable else { + fatalError("ObservableViewModel for \(viewModel) has been deallocated") + } + return cancellable + } + + static func get(for viewModel: any ViewModel) -> AnyCancellable { + viewModel.viewModelWillChange.cancellable.get(viewModel) + } +} From 503185f8ddde54437d40e10c146aa6b7f40ca697 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Mon, 9 Jun 2025 17:34:44 +0200 Subject: [PATCH 6/8] Store childCancellables inside ViewModelCancellable --- .../ChildViewModels.swift | 22 ++++++------------- .../ViewModelCancellable.swift | 16 ++++++++++++++ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/KMPObservableViewModelCore/ChildViewModels.swift b/KMPObservableViewModelCore/ChildViewModels.swift index 64aeaf2..336bc3c 100644 --- a/KMPObservableViewModelCore/ChildViewModels.swift +++ b/KMPObservableViewModelCore/ChildViewModels.swift @@ -9,19 +9,11 @@ import KMPObservableViewModelCoreObjC public extension ViewModel { - private func setChildViewModelPublishers(_ keyPath: AnyKeyPath, _ publishers: AnyHashable?) { - if let publishers = publishers { - observableViewModelPublishers(for: self).childPublishers[keyPath] = publishers - } else { - observableViewModelPublishers(for: self).childPublishers.removeValue(forKey: keyPath) - } - } - private func setChildViewModel( _ viewModel: VM?, at keyPath: AnyKeyPath ) { - setChildViewModelPublishers(keyPath, observableViewModelPublishers(for: viewModel)) + viewModelWillChange.cancellable.setChildCancellables(keyPath, ViewModelCancellable.get(for: viewModel)) } /// Stores a reference to the `ObservableObject` for the specified child `ViewModel`. @@ -48,8 +40,8 @@ public extension ViewModel { _ viewModels: [VM?]?, at keyPath: AnyKeyPath ) { - setChildViewModelPublishers(keyPath, viewModels?.map { viewModel in - observableViewModelPublishers(for: viewModel) + viewModelWillChange.cancellable.setChildCancellables(keyPath, viewModels?.map { viewModel in + ViewModelCancellable.get(for: viewModel) }) } @@ -95,8 +87,8 @@ public extension ViewModel { _ viewModels: Set?, at keyPath: AnyKeyPath ) { - setChildViewModelPublishers(keyPath, viewModels?.map { viewModel in - observableViewModelPublishers(for: viewModel) + viewModelWillChange.cancellable.setChildCancellables(keyPath, viewModels?.map { viewModel in + ViewModelCancellable.get(for: viewModel) }) } @@ -142,8 +134,8 @@ public extension ViewModel { _ viewModels: [Key : VM?]?, at keyPath: AnyKeyPath ) { - setChildViewModelPublishers(keyPath, viewModels?.mapValues { viewModel in - observableViewModelPublishers(for: viewModel) + viewModelWillChange.cancellable.setChildCancellables(keyPath, viewModels?.mapValues { viewModel in + ViewModelCancellable.get(for: viewModel) }) } diff --git a/KMPObservableViewModelCore/ViewModelCancellable.swift b/KMPObservableViewModelCore/ViewModelCancellable.swift index dcdf8fa..8e252cf 100644 --- a/KMPObservableViewModelCore/ViewModelCancellable.swift +++ b/KMPObservableViewModelCore/ViewModelCancellable.swift @@ -12,6 +12,7 @@ internal class ViewModelCancellable { private var didInit = false private weak var cancellable: AnyCancellable? + private var childCancellables: Dictionary? = [:] private func get(_ viewModel: any ViewModel) -> AnyCancellable { guard didInit else { @@ -19,6 +20,7 @@ internal class ViewModelCancellable { if let cancellable = viewModel as? Cancellable { cancellable.cancel() } + self.childCancellables = nil viewModel.clear() } self.cancellable = cancellable @@ -31,7 +33,21 @@ internal class ViewModelCancellable { return cancellable } + func setChildCancellables(_ keyPath: AnyKeyPath, _ cancellables: AnyHashable?) { + if let cancellables = cancellables { + childCancellables?[keyPath] = cancellables + } else { + childCancellables?.removeValue(forKey: keyPath) + } + } + static func get(for viewModel: any ViewModel) -> AnyCancellable { viewModel.viewModelWillChange.cancellable.get(viewModel) } + + static func get(for viewModel: (any ViewModel)?) -> AnyCancellable? { + guard let viewModel else { return nil } + let cancellable = get(for: viewModel) + return cancellable + } } From d75ad9c802062f8ac8743f7e21fa143517ee0890 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Mon, 9 Jun 2025 17:34:51 +0200 Subject: [PATCH 7/8] Remove unused ObservableViewModelPublishers --- .../ObservableViewModelPublishers.swift | 62 ------------------- 1 file changed, 62 deletions(-) delete mode 100644 KMPObservableViewModelCore/ObservableViewModelPublishers.swift diff --git a/KMPObservableViewModelCore/ObservableViewModelPublishers.swift b/KMPObservableViewModelCore/ObservableViewModelPublishers.swift deleted file mode 100644 index b14869c..0000000 --- a/KMPObservableViewModelCore/ObservableViewModelPublishers.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// ObservableViewModelPublishers.swift -// KMPObservableViewModelCore -// -// Created by Rick Clephas on 20/09/2023. -// - -import Foundation - -private var observableViewModelPublishersKey: Void? - -private class WeakObservableViewModelPublishers { - weak var publishers: ObservableViewModelPublishers? - init(_ publishers: ObservableViewModelPublishers) { - self.publishers = publishers - } -} - -/// Gets the `ObservableViewModelPublishers` for the specified `viewModel`. -internal func observableViewModelPublishers( - for viewModel: VM -) -> ObservableViewModelPublishers { - let publishers: ObservableViewModelPublishers - if let object = objc_getAssociatedObject(viewModel, &observableViewModelPublishersKey) { - publishers = (object as! WeakObservableViewModelPublishers).publishers ?? { - fatalError("ObservableViewModel has been deallocated") - }() - } else { - let publisher = ObservableViewModelPublisher(viewModel) - publishers = ObservableViewModelPublishers(publisher) - let object = WeakObservableViewModelPublishers(publishers) - objc_setAssociatedObject(viewModel, &observableViewModelPublishersKey, object, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - return publishers -} - -/// Gets the `ObservableViewModelPublishers` for the specified `viewModel`. -internal func observableViewModelPublishers( - for viewModel: VM? -) -> ObservableViewModelPublishers? { - guard let viewModel = viewModel else { return nil } - let observableViewModelPublishers = observableViewModelPublishers(for: viewModel) - return observableViewModelPublishers -} - -/// Helper object that keeps strong references to the `ObservableViewModelPublisher`s. -internal final class ObservableViewModelPublishers: Hashable { - let publisher: ObservableViewModelPublisher - var childPublishers: Dictionary = [:] - - init(_ publisher: ObservableViewModelPublisher) { - self.publisher = publisher - } - - static func == (lhs: ObservableViewModelPublishers, rhs: ObservableViewModelPublishers) -> Bool { - return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} From 8353e0ee63edc9733cd3d98b3c44fd89cfa354b3 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Thu, 12 Jun 2025 19:15:51 +0200 Subject: [PATCH 8/8] Update documentation comments --- KMPObservableViewModelCore/ViewModel.swift | 2 +- KMPObservableViewModelCore/ViewModelCancellable.swift | 2 +- KMPObservableViewModelCoreObjC/KMPOVMPublisher.h | 2 +- KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h | 2 +- .../kmp/observableviewmodel/ObservableStateFlow.kt | 7 +++++-- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/KMPObservableViewModelCore/ViewModel.swift b/KMPObservableViewModelCore/ViewModel.swift index 3e2e9c4..25adaa4 100644 --- a/KMPObservableViewModelCore/ViewModel.swift +++ b/KMPObservableViewModelCore/ViewModel.swift @@ -8,7 +8,7 @@ import Combine import KMPObservableViewModelCoreObjC -/// A Kotlin Multiplatform Mobile ViewModel. +/// A Kotlin Multiplatform ViewModel. public protocol ViewModel: ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher { /// The `ViewModelScope` of this `ViewModel`. var viewModelScope: ViewModelScope { get } diff --git a/KMPObservableViewModelCore/ViewModelCancellable.swift b/KMPObservableViewModelCore/ViewModelCancellable.swift index 8e252cf..b833b9a 100644 --- a/KMPObservableViewModelCore/ViewModelCancellable.swift +++ b/KMPObservableViewModelCore/ViewModelCancellable.swift @@ -1,6 +1,6 @@ // // ViewModelCancellable.swift -// KMPObservableViewModel +// KMPObservableViewModelCore // // Created by Rick Clephas on 09/06/2025. // diff --git a/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h b/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h index 675bb02..8669fa7 100644 --- a/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h +++ b/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h @@ -1,6 +1,6 @@ // // KMPOVMPublisher.h -// KMPObservableViewModel +// KMPObservableViewModelCoreObjC // // Created by Rick Clephas on 09/06/2025. // diff --git a/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h b/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h index bc83b66..a3da31c 100644 --- a/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h +++ b/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h @@ -1,6 +1,6 @@ // // KMPOVMSubscriptionCount.h -// KMPObservableViewModel +// KMPObservableViewModelCoreObjC // // Created by Rick Clephas on 09/06/2025. // diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt index 89db2a7..6d7857a 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt @@ -8,8 +8,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** - * A [MutableStateFlow] that triggers [NativeViewModelScope.publisher] - * and accounts for the [NativeViewModelScope.subscriptionCount]. + * A [MutableStateFlow] wrapper that emits state change events through the [NativeViewModelScope] + * and it accounts for the [NativeViewModelScope.subscriptionCount]. */ @OptIn(ExperimentalForInheritanceCoroutinesApi::class) internal class ObservableMutableStateFlow( @@ -29,6 +29,9 @@ internal class ObservableMutableStateFlow( override val replayCache: List get() = stateFlow.replayCache + /** + * The combined subscription count from the [NativeViewModelScope] and the actual [StateFlow]. + */ override val subscriptionCount: StateFlow = CombinedSubscriptionCount(viewModelScope, stateFlow) override suspend fun collect(collector: FlowCollector): Nothing =