diff --git a/Defaults.xcodeproj/xcshareddata/xcschemes/Defaults-iOS.xcscheme b/Defaults.xcodeproj/xcshareddata/xcschemes/Defaults-iOS.xcscheme index 799f230..2e6d761 100644 --- a/Defaults.xcodeproj/xcshareddata/xcschemes/Defaults-iOS.xcscheme +++ b/Defaults.xcodeproj/xcshareddata/xcschemes/Defaults-iOS.xcscheme @@ -27,6 +27,15 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + - - - - - - - - Self + + /** + Break the lifetime tie created by `tieToLifetime(of:)`, if one exists. + + - Postcondition: The effects of any call to `tieToLifetime(of:)` are reversed. + - Note: If the tied-to object has already died, then self is considered to be invalidated, and this method has no logical effect. + */ + func removeLifetimeTie() } extension Defaults { @@ -95,6 +115,20 @@ extension Defaults { public func invalidate() { object?.removeObserver(self, forKeyPath: key, context: nil) object = nil + lifetimeAssociation?.cancel() + } + + private var lifetimeAssociation: LifetimeAssociation? = nil + + 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 @@ -104,8 +138,12 @@ extension Defaults { change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection context: UnsafeMutableRawPointer? ) { + guard let selfObject = self.object else { + invalidate() + return + } + guard - let selfObject = self.object, selfObject == object as? NSObject, let change = change else { diff --git a/Sources/Defaults/util.swift b/Sources/Defaults/util.swift index 1cf1626..714237b 100644 --- a/Sources/Defaults/util.swift +++ b/Sources/Defaults/util.swift @@ -17,3 +17,99 @@ extension Decodable { self.init(jsonData: data) } } + +final class AssociatedObject { + subscript(index: Any) -> T? { + get { + return objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as! T? + } set { + objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +/** +Causes a given target object to live at least as long as a given owner object. +*/ +final class LifetimeAssociation { + private class ObjectLifetimeTracker { + var object: AnyObject? + var deinitHandler: () -> Void + + init(for weaklyHeldObject: AnyObject, deinitHandler: @escaping () -> Void) { + self.object = weaklyHeldObject + self.deinitHandler = deinitHandler + } + + deinit { + deinitHandler() + } + } + + private static let associatedObjects = AssociatedObject<[ObjectLifetimeTracker]>() + private weak var wrappedObject: ObjectLifetimeTracker? + private weak var owner: AnyObject? + + /** + Causes the given target object to live at least as long as either the given owner object or the resulting `LifetimeAssociation`, whichever is deallocated first. + + When either the owner or the new `LifetimeAssociation` is destroyed, the given deinit handler, if any, is called. + + ``` + class Ghost { + var association: LifetimeAssociation? + + func haunt(_ host: Furniture) { + association = LifetimeAssociation(of: self, with: host) { [weak self] in + // Host has been deinitialized + self?.haunt(seekHost()) + } + } + } + + let piano = Piano() + Ghost().haunt(piano) + // The Ghost will remain alive as long as `piano` remains alive. + ``` + + - Parameter target: The object whose lifetime will be extended. + - Parameter owner: The object whose lifetime extends the target object's lifetime. + - Parameter deinitHandler: An optional closure to call when either `owner` or the resulting `LifetimeAssociation` is deallocated. + */ + init(of target: AnyObject, with owner: AnyObject, deinitHandler: @escaping () -> Void = { }) { + let wrappedObject = ObjectLifetimeTracker(for: target, deinitHandler: deinitHandler) + + let associatedObjects = LifetimeAssociation.associatedObjects[owner] ?? [] + LifetimeAssociation.associatedObjects[owner] = associatedObjects + [wrappedObject] + + self.wrappedObject = wrappedObject + self.owner = owner + } + + /** + Invalidates the association, unlinking the target object's lifetime from that of the owner object. The provided deinit handler is not called. + */ + func cancel() { + wrappedObject?.deinitHandler = {} + invalidate() + } + + deinit { + invalidate() + } + + private func invalidate() { + guard + let owner = owner, + let wrappedObject = wrappedObject, + var associatedObjects = LifetimeAssociation.associatedObjects[owner], + let wrappedObjectAssociationIndex = associatedObjects.firstIndex(where: { $0 === wrappedObject }) + else { + return + } + + associatedObjects.remove(at: wrappedObjectAssociationIndex) + LifetimeAssociation.associatedObjects[owner] = associatedObjects + self.owner = nil + } +} diff --git a/Tests/DefaultsTests/DefaultsTests.swift b/Tests/DefaultsTests/DefaultsTests.swift index 358f33d..66fdccf 100644 --- a/Tests/DefaultsTests/DefaultsTests.swift +++ b/Tests/DefaultsTests/DefaultsTests.swift @@ -256,4 +256,38 @@ final class DefaultsTests: XCTestCase { XCTAssertEqual(Defaults[key2], nil) XCTAssertEqual(Defaults[key3], newString3) } + + func testObserveWithLifetimeTie() { + let key = Defaults.Key("lifetimeTie", default: false) + let expect = expectation(description: "Observation closure being called") + + weak var observation: DefaultsObservation! + observation = Defaults.observe(key, options: []) { change in + observation.invalidate() + expect.fulfill() + }.tieToLifetime(of: self) + + Defaults[key] = true + + waitForExpectations(timeout: 10) + } + + func testObserveWithLifetimeTieManualBreak() { + let key = Defaults.Key("lifetimeTieManualBreak", default: false) + + weak var observation: DefaultsObservation? = Defaults.observe(key, options: []) { _ in }.tieToLifetime(of: self) + observation!.removeLifetimeTie() + + for i in 1...10 { + if observation == nil { + break + } + + sleep(1) + + if i == 10 { + XCTFail() + } + } + } } diff --git a/readme.md b/readme.md index c387897..01072e2 100644 --- a/readme.md +++ b/readme.md @@ -142,6 +142,27 @@ Defaults[.isUnicornMode] = true In contrast to the native `UserDefaults` key observation, here you receive a strongly-typed change object. +### Invalidate observations automatically + +```swift +extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: false) +} + +final class Foo { + init() { + Defaults.observe(.isUnicornMode) { change in + print(change.oldValue) + print(change.newValue) + }.tieToLifetime(of: self) + } +} + +Defaults[.isUnicornMode] = true +``` + +The observation will be valid until `self` is deinitialized. + ### Reset keys to their default values ```swift @@ -284,6 +305,47 @@ Type: `func` Remove all entries from the `UserDefaults` suite. +### `DefaultsObservation` + +Type: `protocol` + +Represents an observation of a defaults key. + +#### `DefaultsObservation.invalidate` + +```swift +DefaultsObservation.invalidate() +``` + +Type: `func` + +Invalidate the observation. + +#### `DefaultsObservation.tieToLifetime` + +```swift +@discardableResult +DefaultsObservation.tieToLifetime(of weaklyHeldObject: AnyObject) -> Self +``` + +Type: `func` + +Keep the observation alive for as long as, and no longer than, another object exists. + +When `weaklyHeldObject` is deinitialized, the observation is invalidated automatically. + +#### `DefaultsObservation.removeLifetimeTie` + +```swift +DefaultsObservation.removeLifetimeTie() +``` + +Type: `func` + +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. + ## FAQ