Refactor internal `UserDefaults` observation class (#181)

Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
This commit is contained in:
hank121314 2024-08-02 20:49:53 +08:00 committed by GitHub
parent b8c1e7c869
commit bf717462d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 125 additions and 235 deletions

View File

@ -249,11 +249,11 @@ final class iCloudSynchronizer {
@Atomic(value: []) private var remoteSyncingKeys: Set<Defaults.Keys> @Atomic(value: []) private var remoteSyncingKeys: Set<Defaults.Keys>
// TODO: Replace it with async stream when Swift supports custom executors. // TODO: Replace it with async stream when Swift supports custom executors.
private lazy var localKeysMonitor: Defaults.CompositeUserDefaultsAnyKeyObservation = .init { [weak self] observable in private lazy var localKeysMonitor: Defaults.CompositeDefaultsObservation = .init { [weak self] pair, _ in
guard guard
let self, let self,
let suite = observable.suite, let suite = pair.suite,
let key = keys.first(where: { $0.name == observable.key && $0.suite == suite }), let key = keys.first(where: { $0.name == pair.key && $0.suite == suite }),
// Prevent triggering local observation when syncing from remote. // Prevent triggering local observation when syncing from remote.
!remoteSyncingKeys.contains(key) !remoteSyncingKeys.contains(key)
else { else {
@ -273,7 +273,7 @@ final class iCloudSynchronizer {
self.keys.formUnion(keys) self.keys.formUnion(keys)
syncWithoutWaiting(keys) syncWithoutWaiting(keys)
for key in keys { for key in keys {
localKeysMonitor.addObserver(key) localKeysMonitor.add(key: key)
} }
} }
@ -283,7 +283,7 @@ final class iCloudSynchronizer {
func remove(_ keys: [Defaults.Keys]) { func remove(_ keys: [Defaults.Keys]) {
self.keys.subtract(keys) self.keys.subtract(keys)
for key in keys { for key in keys {
localKeysMonitor.removeObserver(key) localKeysMonitor.remove(key: key)
} }
} }

View File

@ -239,7 +239,7 @@ extension Defaults {
initial: Bool = true initial: Bool = true
) -> AsyncStream<Value> { // TODO: Make this `some AsyncSequence<Value>` when Swift 6 is out. ) -> AsyncStream<Value> { // TODO: Make this `some AsyncSequence<Value>` when Swift 6 is out.
.init { continuation in .init { continuation in
let observation = UserDefaultsKeyObservation2(object: key.suite, key: key.name) { change in let observation = DefaultsObservation(object: key.suite, key: key.name) { _, change in
// TODO: Use the `.deserialize` method directly. // TODO: Use the `.deserialize` method directly.
let value = KeyChange(change: change, defaultValue: key.defaultValue).newValue let value = KeyChange(change: change, defaultValue: key.defaultValue).newValue
continuation.yield(value) continuation.yield(value)
@ -275,7 +275,7 @@ extension Defaults {
) -> AsyncStream<Void> { // TODO: Make this `some AsyncSequence<Value>` when Swift 6 is out. ) -> AsyncStream<Void> { // TODO: Make this `some AsyncSequence<Value>` when Swift 6 is out.
.init { continuation in .init { continuation in
let observations = keys.indexed().map { index, key in let observations = keys.indexed().map { index, key in
let observation = UserDefaultsKeyObservation2(object: key.suite, key: key.name) { _ in let observation = DefaultsObservation(object: key.suite, key: key.name) { _, _ in
continuation.yield() continuation.yield()
} }

View File

@ -7,16 +7,16 @@ extension Defaults {
*/ */
final class DefaultsSubscription<SubscriberType: Subscriber>: Subscription where SubscriberType.Input == BaseChange { final class DefaultsSubscription<SubscriberType: Subscriber>: Subscription where SubscriberType.Input == BaseChange {
private var subscriber: SubscriberType? private var subscriber: SubscriberType?
private var observation: UserDefaultsKeyObservation? private var observation: DefaultsObservationWithLifeTime?
private let options: ObservationOptions private let options: ObservationOptions
init(subscriber: SubscriberType, suite: UserDefaults, key: String, options: ObservationOptions) { init(subscriber: SubscriberType, suite: UserDefaults, key: String, options: ObservationOptions) {
self.subscriber = subscriber self.subscriber = subscriber
self.options = options self.options = options
self.observation = UserDefaultsKeyObservation( self.observation = DefaultsObservationWithLifeTime(
object: suite, object: suite,
key: key, key: key,
callback: observationCallback(_:) observationCallback
) )
} }
@ -33,7 +33,7 @@ extension Defaults {
observation?.start(options: options) observation?.start(options: options)
} }
private func observationCallback(_ change: BaseChange) { private func observationCallback(_: SuiteKeyPair, change: BaseChange) {
_ = subscriber?.receive(change) _ = subscriber?.receive(change)
} }
} }

View File

@ -119,145 +119,6 @@ extension Defaults {
Thread.current.threadDictionary[key] = false Thread.current.threadDictionary[key] = false
} }
final class UserDefaultsKeyObservation: NSObject, Observation {
typealias Callback = (BaseChange) -> Void
private weak var object: UserDefaults?
private let key: String
private let callback: Callback
private var isObserving = false
init(object: UserDefaults, key: String, callback: @escaping Callback) {
self.object = object
self.key = key
self.callback = callback
}
deinit {
invalidate()
}
func start(options: ObservationOptions) {
object?.addObserver(self, forKeyPath: key, options: options.toNSKeyValueObservingOptions, context: nil)
isObserving = true
}
func invalidate() {
if isObserving {
object?.removeObserver(self, forKeyPath: key, context: nil)
isObserving = false
}
object = nil
lifetimeAssociation?.cancel()
}
private var lifetimeAssociation: LifetimeAssociation?
func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self {
// swiftlint:disable:next trailing_closure
lifetimeAssociation = LifetimeAssociation(of: self, with: weaklyHeldObject, deinitHandler: { [weak self] in
self?.invalidate()
})
return self
}
func removeLifetimeTie() {
lifetimeAssociation?.cancel()
}
// swiftlint:disable:next block_based_kvo
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection
context: UnsafeMutableRawPointer?
) {
guard let selfObject = self.object else {
invalidate()
return
}
guard
selfObject == (object as? NSObject),
let change
else {
return
}
let key = preventPropagationThreadDictionaryKey
let updatingValuesFlag = (Thread.current.threadDictionary[key] as? Bool) ?? false
guard !updatingValuesFlag else {
return
}
callback(BaseChange(change: change))
}
}
// Same as the above, but without the lifetime utilities, which slows down invalidation and we don't need them for `.updates()`.
final class UserDefaultsKeyObservation2: NSObject {
typealias Callback = (BaseChange) -> Void
private weak var object: UserDefaults?
private let key: String
private let callback: Callback
private var isObserving = false
init(object: UserDefaults, key: String, callback: @escaping Callback) {
self.object = object
self.key = key
self.callback = callback
}
deinit {
invalidate()
}
func start(options: ObservationOptions) {
object?.addObserver(self, forKeyPath: key, options: options.toNSKeyValueObservingOptions, context: nil)
isObserving = true
}
func invalidate() {
if isObserving {
object?.removeObserver(self, forKeyPath: key, context: nil)
isObserving = false
}
object = nil
}
// swiftlint:disable:next block_based_kvo
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection
context: UnsafeMutableRawPointer?
) {
guard let selfObject = self.object else {
invalidate()
return
}
guard
selfObject == (object as? NSObject),
let change
else {
return
}
let key = preventPropagationThreadDictionaryKey
let updatingValuesFlag = (Thread.current.threadDictionary[key] as? Bool) ?? false
guard !updatingValuesFlag else {
return
}
callback(BaseChange(change: change))
}
}
final class SuiteKeyPair: Hashable { final class SuiteKeyPair: Hashable {
weak var suite: UserDefaults? weak var suite: UserDefaults?
let key: String let key: String
@ -278,17 +139,30 @@ extension Defaults {
} }
} }
private final class CompositeUserDefaultsKeyObservation: NSObject, Observation { /**
private static var observationContext = 0 Standard observation for `Defaults`.
The only class which handle the low level observation.
*/
final class DefaultsObservation: NSObject {
typealias Callback = (SuiteKeyPair, BaseChange) -> Void
private var observables: [SuiteKeyPair] static var observationContext = 0
private var lifetimeAssociation: LifetimeAssociation? private weak var suite: UserDefaults?
private let callback: UserDefaultsKeyObservation.Callback private let name: String
private let callback: Callback
private var isObserving = false
private let lock: Lock = .make()
init(observables: [(suite: UserDefaults, key: String)], callback: @escaping UserDefaultsKeyObservation.Callback) { init(object: UserDefaults, key: String, _ callback: @escaping Callback) {
self.observables = observables.map { SuiteKeyPair(suite: $0.suite, key: $0.key) } self.suite = object
self.name = key
self.callback = callback
}
init(key: Defaults._AnyKey, _ callback: @escaping Callback) {
self.suite = key.suite
self.name = key.name
self.callback = callback self.callback = callback
super.init()
} }
deinit { deinit {
@ -296,36 +170,24 @@ extension Defaults {
} }
func start(options: ObservationOptions) { func start(options: ObservationOptions) {
for observable in observables { lock.with {
observable.suite?.addObserver( guard !isObserving else {
self, return
forKeyPath: observable.key, }
options: options.toNSKeyValueObservingOptions, suite?.addObserver(self, forKeyPath: name, options: options.toNSKeyValueObservingOptions, context: &Self.observationContext)
context: &Self.observationContext isObserving = true
)
} }
} }
func invalidate() { func invalidate() {
for observable in observables { lock.with {
observable.suite?.removeObserver(self, forKeyPath: observable.key, context: &Self.observationContext) guard isObserving else {
observable.suite = nil return
} }
suite?.removeObserver(self, forKeyPath: name)
lifetimeAssociation?.cancel() isObserving = false
suite = nil
} }
func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self {
// swiftlint:disable:next trailing_closure
lifetimeAssociation = LifetimeAssociation(of: self, with: weaklyHeldObject, deinitHandler: { [weak self] in
self?.invalidate()
})
return self
}
func removeLifetimeTie() {
lifetimeAssociation?.cancel()
} }
// swiftlint:disable:next block_based_kvo // swiftlint:disable:next block_based_kvo
@ -342,8 +204,13 @@ extension Defaults {
return return
} }
guard let selfSuite = suite else {
invalidate()
return
}
guard guard
object is UserDefaults, selfSuite == (object as? UserDefaults),
let change let change
else { else {
return return
@ -355,39 +222,36 @@ extension Defaults {
return return
} }
callback(BaseChange(change: change)) callback(SuiteKeyPair(suite: selfSuite, key: name), BaseChange(change: change))
} }
} }
final class CompositeUserDefaultsAnyKeyObservation: NSObject, Observation { /**
typealias Callback = (SuiteKeyPair) -> Void Observation that wraps `DefaultsObservation` and adds a lifetime association.
private static var observationContext = 1 */
final class DefaultsObservationWithLifeTime: Observation {
private var observables: Set<SuiteKeyPair> = [] private var observation: DefaultsObservation
private var lifetimeAssociation: LifetimeAssociation? private var lifetimeAssociation: LifetimeAssociation?
private let callback: CompositeUserDefaultsAnyKeyObservation.Callback
init(_ callback: @escaping CompositeUserDefaultsAnyKeyObservation.Callback) { init(object: UserDefaults, key: String, _ callback: @escaping DefaultsObservation.Callback) {
self.callback = callback self.observation = .init(object: object, key: key, callback)
} }
func addObserver(_ key: Defaults._AnyKey, options: ObservationOptions = []) { init(key: Defaults._AnyKey, _ callback: @escaping DefaultsObservation.Callback) {
let keyPair: SuiteKeyPair = .init(suite: key.suite, key: key.name) self.observation = .init(key: key, callback)
let (inserted, observable) = observables.insert(keyPair)
guard inserted else {
return
} }
observable.suite?.addObserver(self, forKeyPath: observable.key, options: options.toNSKeyValueObservingOptions, context: &Self.observationContext) deinit {
invalidate()
} }
func removeObserver(_ key: Defaults._AnyKey) { func start(options: ObservationOptions) {
let keyPair: SuiteKeyPair = .init(suite: key.suite, key: key.name) observation.start(options: options)
guard let observable = observables.remove(keyPair) else {
return
} }
observable.suite?.removeObserver(self, forKeyPath: observable.key, context: &Self.observationContext) func invalidate() {
observation.invalidate()
lifetimeAssociation?.cancel()
} }
@discardableResult @discardableResult
@ -403,40 +267,63 @@ extension Defaults {
func removeLifetimeTie() { func removeLifetimeTie() {
lifetimeAssociation?.cancel() lifetimeAssociation?.cancel()
} }
func invalidate() {
for observable in observables {
observable.suite?.removeObserver(self, forKeyPath: observable.key, context: &Self.observationContext)
observable.suite = nil
} }
observables.removeAll() /**
Observation that manages multiple `DefaultsObservation`.
Can add or remove the observed key dynamically.
*/
final class CompositeDefaultsObservation: Observation {
private var observations: Set<DefaultsObservation> = []
private let callback: DefaultsObservation.Callback
private var lifetimeAssociation: LifetimeAssociation?
init(_ callback: @escaping DefaultsObservation.Callback) {
self.callback = callback
}
deinit {
invalidate()
}
func start(options: ObservationOptions) {
observations.forEach { $0.start(options: options) }
}
func invalidate() {
observations.forEach { $0.invalidate() }
lifetimeAssociation?.cancel() lifetimeAssociation?.cancel()
} }
// swiftlint:disable:next block_based_kvo func add(key: Defaults._AnyKey, options: ObservationOptions = []) {
override func observeValue( let (isInserted, observation) = observations.insert(DefaultsObservation(key: key, callback))
forKeyPath keyPath: String?, guard isInserted else {
of object: Any?,
change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection
context: UnsafeMutableRawPointer?
) {
guard
context == &Self.observationContext
else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return return
} }
guard observation.start(options: options)
let object = object as? UserDefaults, }
let keyPath,
let observable = observables.first(where: { $0.key == keyPath && $0.suite == object }) func remove(key: Defaults._AnyKey) {
else { guard let observation = observations.remove(DefaultsObservation(key: key, self.callback)) else {
return return
} }
callback(observable) observation.invalidate()
}
@discardableResult
func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self {
// swiftlint:disable:next trailing_closure
lifetimeAssociation = LifetimeAssociation(of: self, with: weaklyHeldObject, deinitHandler: { [weak self] in
self?.invalidate()
})
return self
}
func removeLifetimeTie() {
lifetimeAssociation?.cancel()
} }
} }
@ -461,7 +348,7 @@ extension Defaults {
options: ObservationOptions = [.initial], options: ObservationOptions = [.initial],
handler: @escaping (KeyChange<Value>) -> Void handler: @escaping (KeyChange<Value>) -> Void
) -> some Observation { ) -> some Observation {
let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in let observation = DefaultsObservationWithLifeTime(key: key) { _, change in
handler( handler(
KeyChange(change: change, defaultValue: key.defaultValue) KeyChange(change: change, defaultValue: key.defaultValue)
) )
@ -491,12 +378,14 @@ extension Defaults {
options: ObservationOptions = [.initial], options: ObservationOptions = [.initial],
handler: @escaping () -> Void handler: @escaping () -> Void
) -> some Observation { ) -> some Observation {
let pairs = keys.map { let compositeObservation = CompositeDefaultsObservation { _, _ in
(suite: $0.suite, key: $0.name)
}
let compositeObservation = CompositeUserDefaultsKeyObservation(observables: pairs) { _ in
handler() handler()
} }
for key in keys {
compositeObservation.add(key: key)
}
compositeObservation.start(options: options) compositeObservation.start(options: options)
return compositeObservation return compositeObservation

View File

@ -29,6 +29,7 @@ struct ContentView: View {
} }
} }
final class DefaultsSwiftUITests: XCTestCase { final class DefaultsSwiftUITests: XCTestCase {
override func setUp() { override func setUp() {
super.setUp() super.setUp()