Add ability to subscribe to multiple keys and to prevent propagation (#49)

Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
This commit is contained in:
Kacper Rączy 2020-08-28 22:12:29 +02:00 committed by GitHub
parent c9033c70cf
commit ab8127604c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 426 additions and 24 deletions

View File

@ -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<Value: Codable>: Keys, _DefaultsBaseKey {
public let name: String
public final class Key<Value: Codable>: 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<Value: NSSecureCoding>: Keys, _DefaultsBaseKey {
public let name: String
public final class NSSecureCodingKey<Value: NSSecureCoding>: 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<Value: NSSecureCoding>: Keys, _DefaultsBaseKey {
public let name: String
public let suite: UserDefaults
public final class NSSecureCodingOptionalKey<Value: NSSecureCoding>: 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)
}
}

View File

@ -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<Void, Never> {
let initial = Empty<Void, Never>(completeImmediately: false).eraseToAnyPublisher()

View File

@ -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<Bool>("setting1", default: false)
static let setting2 = Key<Bool>("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 {

View File

@ -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<Keys, _DefaultsBaseKey>...) {
public static func reset(_ keys: KeyPath<Keys, DefaultsBaseKey>...) {
for key in keys {
Keys[keyPath: key].reset()
}

View File

@ -452,6 +452,53 @@ final class DefaultsTests: XCTestCase {
waitForExpectations(timeout: 10)
}
func testObserveMultipleKeys() {
let key1 = Defaults.Key<String>("observeKey1", default: "x")
let key2 = Defaults.Key<Bool>("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<ExamplePersistentHistory>("observeNSSecureCodingKey1", default: ExamplePersistentHistory(value: "TestValue"))
let key2 = Defaults.NSSecureCodingKey<ExamplePersistentHistory>("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<Bool?>("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<Bool?>("preventPropagation1", default: nil)
let key2 = Defaults.Key<Bool?>("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<Int?>("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<Bool?>("preventPropagation4", default: nil)
let key2 = Defaults.Key<Bool?>("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<Bool?>("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<Bool?>("preventPropagation7", default: nil)
let key2 = Defaults.Key<Bool?>("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<Bool?>("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"

View File

@ -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.