From ab8127604c7f8b5ed9b6369e4d1ec5fb1b39b971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20R=C4=85czy?= Date: Fri, 28 Aug 2020 22:12:29 +0200 Subject: [PATCH] Add ability to subscribe to multiple keys and to prevent propagation (#49) Co-authored-by: Sindre Sorhus --- Sources/Defaults/Defaults.swift | 40 ++-- Sources/Defaults/Observation+Combine.swift | 2 +- Sources/Defaults/Observation.swift | 161 ++++++++++++++++ Sources/Defaults/Reset.swift | 2 +- Tests/DefaultsTests/DefaultsTests.swift | 214 +++++++++++++++++++++ readme.md | 31 +++ 6 files changed, 426 insertions(+), 24 deletions(-) diff --git a/Sources/Defaults/Defaults.swift b/Sources/Defaults/Defaults.swift index b76fd8a..4ae8966 100644 --- a/Sources/Defaults/Defaults.swift +++ b/Sources/Defaults/Defaults.swift @@ -1,12 +1,12 @@ // MIT License © Sindre Sorhus import Foundation -public protocol _DefaultsBaseKey: Defaults.Keys { +public protocol DefaultsBaseKey: Defaults.Keys { var name: String { get } var suite: UserDefaults { get } } -extension _DefaultsBaseKey { +extension DefaultsBaseKey { /// Reset the item back to its default value. public func reset() { suite.removeObject(forKey: name) @@ -14,7 +14,9 @@ extension _DefaultsBaseKey { } public enum Defaults { - public class Keys { + public typealias BaseKey = DefaultsBaseKey + + public class Keys: BaseKey { public typealias Key = Defaults.Key @available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, iOSApplicationExtension 11.0, macOSApplicationExtension 10.13, tvOSApplicationExtension 11.0, watchOSApplicationExtension 4.0, *) @@ -23,22 +25,24 @@ public enum Defaults { @available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, iOSApplicationExtension 11.0, macOSApplicationExtension 10.13, tvOSApplicationExtension 11.0, watchOSApplicationExtension 4.0, *) public typealias NSSecureCodingOptionalKey = Defaults.NSSecureCodingOptionalKey - fileprivate init() {} + public let name: String + public let suite: UserDefaults + + fileprivate init(name: String, suite: UserDefaults) { + self.name = name + self.suite = suite + } } - public final class Key: Keys, _DefaultsBaseKey { - public let name: String + public final class Key: Keys { public let defaultValue: Value - public let suite: UserDefaults /// Create a defaults key. /// The `default` parameter can be left out if the `Value` type is an optional. public init(_ key: String, default defaultValue: Value, suite: UserDefaults = .standard) { - self.name = key self.defaultValue = defaultValue - self.suite = suite - super.init() + super.init(name: key, suite: suite) if (defaultValue as? _DefaultsOptionalType)?.isNil == true { return @@ -54,19 +58,15 @@ public enum Defaults { } @available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, iOSApplicationExtension 11.0, macOSApplicationExtension 10.13, tvOSApplicationExtension 11.0, watchOSApplicationExtension 4.0, *) - public final class NSSecureCodingKey: Keys, _DefaultsBaseKey { - public let name: String + public final class NSSecureCodingKey: Keys { public let defaultValue: Value - public let suite: UserDefaults /// Create a defaults key. /// The `default` parameter can be left out if the `Value` type is an optional. public init(_ key: String, default defaultValue: Value, suite: UserDefaults = .standard) { - self.name = key self.defaultValue = defaultValue - self.suite = suite - super.init() + super.init(name: key, suite: suite) if (defaultValue as? _DefaultsOptionalType)?.isNil == true { return @@ -82,14 +82,10 @@ public enum Defaults { } @available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, iOSApplicationExtension 11.0, macOSApplicationExtension 10.13, tvOSApplicationExtension 11.0, watchOSApplicationExtension 4.0, *) - public final class NSSecureCodingOptionalKey: Keys, _DefaultsBaseKey { - public let name: String - public let suite: UserDefaults - + public final class NSSecureCodingOptionalKey: Keys { /// Create an optional defaults key. public init(_ key: String, suite: UserDefaults = .standard) { - self.name = key - self.suite = suite + super.init(name: key, suite: suite) } } diff --git a/Sources/Defaults/Observation+Combine.swift b/Sources/Defaults/Observation+Combine.swift index 4a27770..d036ced 100644 --- a/Sources/Defaults/Observation+Combine.swift +++ b/Sources/Defaults/Observation+Combine.swift @@ -133,7 +133,7 @@ extension Defaults { */ @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) public static func publisher( - keys: _DefaultsBaseKey..., + keys: Keys..., options: ObservationOptions = [.initial] ) -> AnyPublisher { let initial = Empty(completeImmediately: false).eraseToAnyPublisher() diff --git a/Sources/Defaults/Observation.swift b/Sources/Defaults/Observation.swift index c83d749..0bcb033 100644 --- a/Sources/Defaults/Observation.swift +++ b/Sources/Defaults/Observation.swift @@ -140,6 +140,41 @@ extension Defaults { self.newValue = deserialize(change.newValue, to: Value.self) } } + + private static var preventPropagationThreadDictKey: String { + "\(type(of: Observation.self))_threadUpdatingValuesFlag" + } + + /** + Execute block without triggering events of changes made at defaults keys. + + Example: + ``` + let observer = Defaults.observe(keys: .key1, .key2) { + // … + Defaults.withoutPropagation { + // update some value at .key1 + // this will not be propagated + Defaults[.key1] = 11 + } + // this will be propagated + Defaults[.someKey] = true + } + ``` + + This only works with defaults `observe` or `publisher`. User made KVO will not be affected. + */ + public static func withoutPropagation(block: () -> Void) { + // How does it work? + // KVO observation callbacks are executed right after change is made, + // and run on the same thread as the caller. So it works by storing a flag in current + // thread's dictionary, which is then evaluated in `observeValue` callback + + let key = preventPropagationThreadDictKey + Thread.current.threadDictionary[key] = true + block() + Thread.current.threadDictionary[key] = false + } final class UserDefaultsKeyObservation: NSObject, Observation { typealias Callback = (BaseChange) -> Void @@ -200,11 +235,107 @@ extension Defaults { else { return } + + let key = preventPropagationThreadDictKey + let updatingValuesFlag = (Thread.current.threadDictionary[key] as? Bool) ?? false + guard !updatingValuesFlag else { + return + } callback(BaseChange(change: change)) } } + + private final class CompositeUserDefaultsKeyObservation: NSObject, Observation { + private static var observationContext = 0 + + private final class SuiteKeyPair { + weak var suite: UserDefaults? + let key: String + + init(suite: UserDefaults, key: String) { + self.suite = suite + self.key = key + } + } + + private var observables: [SuiteKeyPair] + private var lifetimeAssociation: LifetimeAssociation? = nil + private let callback: UserDefaultsKeyObservation.Callback + + init(observables: [(suite: UserDefaults, key: String)], callback: @escaping UserDefaultsKeyObservation.Callback) { + self.observables = observables.map { SuiteKeyPair(suite: $0.suite, key: $0.key) } + self.callback = callback + super.init() + } + + deinit { + invalidate() + } + + public func start(options: ObservationOptions) { + for observable in observables { + observable.suite?.addObserver( + self, + forKeyPath: observable.key, + options: options.toNSKeyValueObservingOptions, + context: &type(of: self).observationContext + ) + } + } + + public func invalidate() { + for observable in observables { + observable.suite?.removeObserver(self, forKeyPath: observable.key, context: &type(of: self).observationContext) + observable.suite = nil + } + lifetimeAssociation?.cancel() + } + + public func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self { + lifetimeAssociation = LifetimeAssociation(of: self, with: weaklyHeldObject, deinitHandler: { [weak self] in + self?.invalidate() + }) + + return self + } + + public 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 + context == &type(of: self).observationContext + else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + return + } + + guard + object is UserDefaults, + let change = change + else { + return + } + + let key = preventPropagationThreadDictKey + let updatingValuesFlag = (Thread.current.threadDictionary[key] as? Bool) ?? false + if updatingValuesFlag { + return + } + + callback(BaseChange(change: change)) + } + } + /** Observe a defaults key. @@ -268,6 +399,36 @@ extension Defaults { observation.start(options: options) return observation } + + /** + Observe multiple keys of any type, but without specific information about changes. + + ``` + extension Defaults.Keys { + static let setting1 = Key("setting1", default: false) + static let setting2 = Key("setting2", default: true) + } + + let observer = Defaults.observe(keys: .setting1, .setting2) { + //... + } + ``` + */ + public static func observe( + keys: Keys..., + options: ObservationOptions = [.initial], + handler: @escaping () -> Void + ) -> Observation { + let pairs = keys.map { + (suite: $0.suite, key: $0.name) + } + let compositeObservation = CompositeUserDefaultsKeyObservation(observables: pairs) { _ in + handler() + } + compositeObservation.start(options: options) + + return compositeObservation + } } extension Defaults.ObservationOptions { diff --git a/Sources/Defaults/Reset.swift b/Sources/Defaults/Reset.swift index e467c7f..a81676c 100644 --- a/Sources/Defaults/Reset.swift +++ b/Sources/Defaults/Reset.swift @@ -5,7 +5,7 @@ TODO: When Swift gets support for static key paths, all of this could be simplif ``` extension Defaults { - public static func reset(_ keys: KeyPath...) { + public static func reset(_ keys: KeyPath...) { for key in keys { Keys[keyPath: key].reset() } diff --git a/Tests/DefaultsTests/DefaultsTests.swift b/Tests/DefaultsTests/DefaultsTests.swift index bfa0fa7..74dce7a 100644 --- a/Tests/DefaultsTests/DefaultsTests.swift +++ b/Tests/DefaultsTests/DefaultsTests.swift @@ -452,6 +452,53 @@ final class DefaultsTests: XCTestCase { waitForExpectations(timeout: 10) } + + func testObserveMultipleKeys() { + let key1 = Defaults.Key("observeKey1", default: "x") + let key2 = Defaults.Key("observeKey2", default: true) + let expect = expectation(description: "Observation closure being called") + + var observation: Defaults.Observation! + var counter = 0 + observation = Defaults.observe(keys: key1, key2, options: []) { + counter += 1 + if counter == 2 { + expect.fulfill() + } else if counter > 2 { + XCTFail() + } + } + + Defaults[key1] = "y" + Defaults[key2] = false + observation.invalidate() + + waitForExpectations(timeout: 10) + } + + @available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, iOSApplicationExtension 11.0, macOSApplicationExtension 10.13, tvOSApplicationExtension 11.0, watchOSApplicationExtension 4.0, *) + func testObserveMultipleNSSecureKeys() { + let key1 = Defaults.NSSecureCodingKey("observeNSSecureCodingKey1", default: ExamplePersistentHistory(value: "TestValue")) + let key2 = Defaults.NSSecureCodingKey("observeNSSecureCodingKey2", default: ExamplePersistentHistory(value: "TestValue")) + let expect = expectation(description: "Observation closure being called") + + var observation: Defaults.Observation! + var counter = 0 + observation = Defaults.observe(keys: key1, key2, options: []) { + counter += 1 + if counter == 2 { + expect.fulfill() + } else if counter > 2 { + XCTFail() + } + } + + Defaults[key1] = ExamplePersistentHistory(value: "NewTestValue1") + Defaults[key2] = ExamplePersistentHistory(value: "NewTestValue2") + observation.invalidate() + + waitForExpectations(timeout: 10) + } func testObserveKeyURL() { let fixtureURL = URL(string: "https://sindresorhus.com")! @@ -488,6 +535,173 @@ final class DefaultsTests: XCTestCase { waitForExpectations(timeout: 10) } + + func testObservePreventPropagation() { + let key1 = Defaults.Key("preventPropagation0", default: nil) + let expect = expectation(description: "No infinite recursion") + + var observation: Defaults.Observation! + var wasInside = false + observation = Defaults.observe(key1, options: []) { _ in + XCTAssertFalse(wasInside) + wasInside = true + Defaults.withoutPropagation { + Defaults[key1] = true + } + expect.fulfill() + } + + Defaults[key1] = false + observation.invalidate() + + waitForExpectations(timeout: 10) + } + + func testObservePreventPropagationMultipleKeys() { + let key1 = Defaults.Key("preventPropagation1", default: nil) + let key2 = Defaults.Key("preventPropagation2", default: nil) + let expect = expectation(description: "No infinite recursion") + + var observation: Defaults.Observation! + var wasInside = false + observation = Defaults.observe(keys: key1, key2, options: []) { + XCTAssertFalse(wasInside) + wasInside = true + Defaults.withoutPropagation { + Defaults[key1] = true + } + expect.fulfill() + } + + Defaults[key1] = false + observation.invalidate() + + waitForExpectations(timeout: 10) + } + + /** + This checks if callback is still being called, if value is changed on second thread, + while initial thread is doing some long lasting task. + */ + func testObservePreventPropagationMultipleThreads() { + let key1 = Defaults.Key("preventPropagation3", default: nil) + let expect = expectation(description: "No infinite recursion") + + var observation: Defaults.Observation! + observation = Defaults.observe(key1, options: []) { _ in + Defaults.withoutPropagation { + Defaults[key1]! += 1 + } + print("--- Main Thread: \(Thread.isMainThread)") + if !Thread.isMainThread { + XCTAssert(Defaults[key1]! == 4) + expect.fulfill() + } else { + usleep(100000) + print("--- Release: \(Thread.isMainThread)") + } + } + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + Defaults[key1]! += 1 + } + Defaults[key1] = 1 + observation.invalidate() + + waitForExpectations(timeout: 10) + } + + /** + Check if propagation prevention works across multiple observations + */ + func testObservePreventPropagationMultipleObservations() { + let key1 = Defaults.Key("preventPropagation4", default: nil) + let key2 = Defaults.Key("preventPropagation5", default: nil) + let expect = expectation(description: "No infinite recursion") + + let observation1 = Defaults.observe(key2, options: []) { _ in + XCTFail() + } + let observation2 = Defaults.observe(keys: key1, key2, options: []) { + Defaults.withoutPropagation { + Defaults[key2] = true + } + expect.fulfill() + } + + Defaults[key1] = false + observation1.invalidate() + observation2.invalidate() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObservePreventPropagationCombine() { + let key1 = Defaults.Key("preventPropagation6", default: nil) + let expect = expectation(description: "No infinite recursion") + + var wasInside = false + let cancellable = Defaults.publisher(key1, options: []).sink { _ in + XCTAssertFalse(wasInside) + wasInside = true + Defaults.withoutPropagation { + Defaults[key1] = true + } + expect.fulfill() + } + + Defaults[key1] = false + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObservePreventPropagationMultipleKeysCombine() { + let key1 = Defaults.Key("preventPropagation7", default: nil) + let key2 = Defaults.Key("preventPropagation8", default: nil) + let expect = expectation(description: "No infinite recursion") + + var wasInside = false + let cancellable = Defaults.publisher(keys: key1, key2, options: []).sink { _ in + XCTAssertFalse(wasInside) + wasInside = true + Defaults.withoutPropagation { + Defaults[key1] = true + } + expect.fulfill() + } + + Defaults[key2] = false + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObservePreventPropagationModifiersCombine() { + let key1 = Defaults.Key("preventPropagation9", default: nil) + let expect = expectation(description: "No infinite recursion") + + var wasInside = false + var cancellable: AnyCancellable! + cancellable = Defaults.publisher(key1, options: []) + .receive(on: DispatchQueue.main) + .delay(for: 0.5, scheduler: DispatchQueue.global()) + .sink { _ in + XCTAssertFalse(wasInside) + wasInside = true + Defaults.withoutPropagation { + Defaults[key1] = true + } + expect.fulfill() + cancellable.cancel() + } + + Defaults[key1] = false + + waitForExpectations(timeout: 10) + } func testResetKey() { let defaultFixture1 = "foo1" diff --git a/readme.md b/readme.md index 826196a..bf468ec 100644 --- a/readme.md +++ b/readme.md @@ -239,6 +239,23 @@ Defaults[.isUnicornMode] = true The observation will be valid until `self` is deinitialized. +### Control propagation of change events + +```swift +let observer = Defaults.observe(keys: .key1, .key2) { + // … + Defaults.withoutPropagation { + // update some value at .key1 + // this will not be propagated + Defaults[.key1] = 11 + } + // this will be propagated + Defaults[.someKey] = true +} +``` + +Changes made within `Defaults.withoutPropagation` block, will not be propagated to observation callbacks, and therefore will prevent infinite recursion. + ### Reset keys to their default values ```swift @@ -387,6 +404,14 @@ Observe changes to a key or an optional key. By default, it will also trigger an initial event on creation. This can be useful for setting default values on controls. You can override this behavior with the `options` argument. +#### `Defaults.observe(keys: keys..., options:)` + +Type: `func` + +Observe changes to multiple keys of any type, but without specific information about changes. + +Options same as in `observe` for a single key. + #### `Defaults.publisher(_ key:, options:)` ```swift @@ -475,6 +500,12 @@ Break the lifetime tie created by `tieToLifetime(of:)`, if one exists. The effects of any call to `tieToLifetime(of:)` are reversed. Note however that if the tied-to object has already died, then the observation is already invalid and this method has no logical effect. +#### `Defaults.withoutPropagation(_ block:)` + +Execute block without emitting events of changes made at defaults keys. + +Changes made within the block will not be propagated to observation callbacks. This only works with defaults `observe` or `publisher`. User made KVO will not be affected. + ### `@Default(_ key:)` Get/set a `Defaults` item and also have the view be updated when the value changes.