How to use KVO for UserDefaults in Swift?
Asked Answered
R

5

33

I'm rewriting parts of an app, and found this code:

fileprivate let defaults = UserDefaults.standard

func storeValue(_ value: AnyObject, forKey key:String) {
    defaults.set(value, forKey: key)
    defaults.synchronize()

    NotificationCenter.default.post(name: Notification.Name(rawValue: "persistanceServiceValueChangedNotification"), object: key)
}
func getValueForKey(_ key:String, defaultValue:AnyObject? = nil) -> AnyObject? {
    return defaults.object(forKey: key) as AnyObject? ?? defaultValue
}

When CMD-clicking the line defaults.synchronize() I see that synchronize is planned deprecated. This is written in the code:

/*!
     -synchronize is deprecated and will be marked with the NS_DEPRECATED macro in a future release.

     -synchronize blocks the calling thread until all in-progress set operations have completed. This is no longer necessary. Replacements for previous uses of -synchronize depend on what the intent of calling synchronize was. If you synchronized...
     - ...before reading in order to fetch updated values: remove the synchronize call
     - ...after writing in order to notify another program to read: the other program can use KVO to observe the default without needing to notify
     - ...before exiting in a non-app (command line tool, agent, or daemon) process: call CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication)
     - ...for any other reason: remove the synchronize call
     */

As far as I can interpret, the usage in my case fits the second description: synchronizing after writing, in order to notify others.

It suggests using KVO to ovserve, but how? When I search for this, I find a bunch of slightly older Objective-C-examples. What is the best practice for observing UserDefaults?

Robillard answered 14/5, 2017 at 11:13 Comment(2)
https://mcmap.net/q/452539/-kvo-with-shared-nsuserdefaults-in-swift maybe what you are looking for.Joceline
you can add an observer for UserDefaults.didChangeNotificationComedic
E
58

As of iOS 11 + Swift 4, the recommended way (according to SwiftLint) is using the block-based KVO API.

Example:

Let's say I have an integer value stored in my user defaults and it's called greetingsCount.

First I need to extend UserDefaults with a dynamic var that has the same name as the user defaults key you want to observe:

extension UserDefaults {
    @objc dynamic var greetingsCount: Int {
        return integer(forKey: "greetingsCount")
    }
}

This allows us to later on define the key path for observing, like this:

var observer: NSKeyValueObservation?

init() {
    observer = UserDefaults.standard.observe(\.greetingsCount, options: [.initial, .new], changeHandler: { (defaults, change) in
        // your change logic here
    })
}

And never forget to clean up:

deinit {
    observer?.invalidate()
}
Enaenable answered 17/12, 2017 at 15:13 Comment(14)
Thanks for this. What does that \. before greetingsCount in your observe statement mean?Paolapaolina
@CliftonLabrum it is a new Swift 4 construct called Key Path. More on that here.Enaenable
This does not seem to be working for me in macOS :/. I'm using an App Group user defaults group rather than StandardUserDefaults. The observer closure is called a single time on initialization (giving nil values), then never again when changes occur. Similar issue in the comments here: #44425471Select
@Select thanks for pointing out! I never tried doing this in macOS, only in iOS (as noted). Do you think it's worth filing a radar?Enaenable
This is my swift newbieness showing... With this extension are we now supposed to set/get UserDefaults.standard.greetingsCount instead of using the normal defaults.set(val, forKey: "greetingsCount") method? Just putting this code into my app and still using the set() methods, the observer only gets triggered at initialization, not on change.Vagabondage
This answer is good. I'd like to point out that the method name has to match the userdefaults object name. If you had instead made var myGreetingsCount you'd never get the observer callback.Proceeds
you don't have to cleanup the observer. See: stackoverflow.com/a/46591915Hanseatic
Worked for HostApp-FinderSync communication/data exchange to change watch paths or directoryURLs dynamically.Elkins
Apparently, the newValue and oldValue properties of the changes argument are set only when the type of the declared property matches the underlying type in UserDefaults.Aloisius
Where does the init() part has to go?Reflector
This means all instances of UserDefaults has a greetingsCount when you might only want it in the standard instance rather than a suite or vice versa.Urn
And this is also useless for dynamically created UserDefaults keys.Tortuga
is there a more dynamic way of doing this? Creating a new extension each time is not very scalable.Crooks
@Crooks if you need a scalable solution for hundreds of values, I would strongly recommend using something like Realm. UserDefaults are indeed not the right approach.Enaenable
T
14

From the blog of David Smith http://dscoder.com/defaults.html https://twitter.com/catfish_man/status/674727133017587712

If one process sets a shared default, then notifies another process to read it, then you may be in one of the very few remaining situations that it's useful to call the -synchronize method in: -synchronize acts as a "barrier", in that it provides a guarantee that once it has returned, any other process that reads that default will see the new value rather than the old value.

For applications running on iOS 9.3 and later / macOS Sierra and later, -synchronize is not needed (or recommended) even in this situation, since Key-Value Observation of defaults works between processes now, so the reading process can just watch directly for the value to change. As a result of that, applications running on those operating systems should generally never call synchronize.

So in most likely case you do not need to set to call synchronize. It is automatically handled by KVO.

To do this you need add observer in your classes where you are handling persistanceServiceValueChangedNotification notification. Let say you are setting a key with name "myKey"

Add observer in your class may be viewDidLoad etc

 UserDefaults.standard.addObserver(self, forKeyPath: "myKey", options: NSKeyValueObservingOptions.new, context: nil)

Handle the observer

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {

    //do your changes with for key
}

Also remove your observer in deinit

Tauten answered 14/5, 2017 at 11:31 Comment(0)
E
5

For anyone who will be looking for the answer in the future, didChangeNotification will be posted only if changes are made on the same process, if you would like to receive all updates regardless of the process use KVO.

Apple doc

This notification isn't posted when changes are made outside the current process, or when ubiquitous defaults change. You can use key-value observing to register observers for specific keys of interest in order to be notified of all updates, regardless of whether changes are made within or outside the current process.

Here is a link to demo Xcode project which shows how to setup block based KVO on UserDefaults.

Electronegative answered 4/8, 2018 at 23:20 Comment(0)
H
4

Swift 4 version made with reusable types:

File: KeyValueObserver.swift - General purpose reusable KVO observer (for cases where pure Swift observables can't be used).

public final class KeyValueObserver<ValueType: Any>: NSObject, Observable {

   public typealias ChangeCallback = (KeyValueObserverResult<ValueType>) -> Void

   private var context = 0 // Value don't reaaly matter. Only address is important.
   private var object: NSObject
   private var keyPath: String
   private var callback: ChangeCallback

   public var isSuspended = false

   public init(object: NSObject, keyPath: String, options: NSKeyValueObservingOptions = .new,
               callback: @escaping ChangeCallback) {
      self.object = object
      self.keyPath = keyPath
      self.callback = callback
      super.init()
      object.addObserver(self, forKeyPath: keyPath, options: options, context: &context)
   }

   deinit {
      dispose()
   }

   public func dispose() {
      object.removeObserver(self, forKeyPath: keyPath, context: &context)
   }

   public static func observeNew<T>(object: NSObject, keyPath: String,
      callback: @escaping (T) -> Void) -> Observable {
      let observer = KeyValueObserver<T>(object: object, keyPath: keyPath, options: .new) { result in
         if let value = result.valueNew {
            callback(value)
         }
      }
      return observer
   }

   public override func observeValue(forKeyPath keyPath: String?, of object: Any?,
                                     change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
      if context == &self.context && keyPath == self.keyPath {
         if !isSuspended, let change = change, let result = KeyValueObserverResult<ValueType>(change: change) {
            callback(result)
         }
      } else {
         super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
      }
   }
}

File: KeyValueObserverResult.swift – Helper type to keep KVO observation data.

public struct KeyValueObserverResult<T: Any> {

   public private(set) var change: [NSKeyValueChangeKey: Any]

   public private(set) var kind: NSKeyValueChange

   init?(change: [NSKeyValueChangeKey: Any]) {
      self.change = change
      guard
         let changeKindNumberValue = change[.kindKey] as? NSNumber,
         let changeKindEnumValue = NSKeyValueChange(rawValue: changeKindNumberValue.uintValue) else {
            return nil
      }
      kind = changeKindEnumValue
   }

   // MARK: -

   public var valueNew: T? {
      return change[.newKey] as? T
   }

   public var valueOld: T? {
      return change[.oldKey] as? T
   }

   var isPrior: Bool {
      return (change[.notificationIsPriorKey] as? NSNumber)?.boolValue ?? false
   }

   var indexes: NSIndexSet? {
      return change[.indexesKey] as? NSIndexSet
   }
}

File: Observable.swift - Propocol to suspend/resume and dispose observer.

public protocol Observable {
   var isSuspended: Bool { get set }
   func dispose()
}

extension Array where Element == Observable {

   public func suspend() {
      forEach {
         var observer = $0
         observer.isSuspended = true
      }
   }

   public func resume() {
      forEach {
         var observer = $0
         observer.isSuspended = false
      }
   }
}

File: UserDefaults.swift - Convenience extension to user defaults.

extension UserDefaults {

   public func observe<T: Any>(key: String, callback: @escaping (T) -> Void) -> Observable {
      let result = KeyValueObserver<T>.observeNew(object: self, keyPath: key) {
         callback($0)
      }
      return result
   }

   public func observeString(key: String, callback: @escaping (String) -> Void) -> Observable {
      return observe(key: key, callback: callback)
   }

}

Usage:

class MyClass {

    private var observables: [Observable] = []

    // IMPORTANT: DON'T use DOT `.` in key.
    // DOT `.` used to define `KeyPath` and this is what we don't need here.
    private let key = "app-some:test_key"

    func setupHandlers() {
       observables.append(UserDefaults.standard.observeString(key: key) {
          print($0) // Will print `AAA` and then `BBB`.
       })
    }

    func doSomething() {
       UserDefaults.standard.set("AAA", forKey: key)
       UserDefaults.standard.set("BBB", forKey: key)
    }
}

Updating defaults from Command line:

# Running shell command below while sample code above is running will print `CCC`
defaults write com.my.bundleID app-some:test_key CCC
Handhold answered 26/2, 2019 at 15:44 Comment(1)
This design loses the power of the context param - which allows subclasses to add more keys with the same context the parent is using to result in the same actions being called.Urn
E
3

As of iOS 13, there is now a cooler way to do this, using Combine:

import Foundation
import Combine

extension UserDefaults {
    /// Observe UserDefaults for changes at the supplied KeyPath.
    ///
    /// Note: first, extend UserDefaults with an `@objc dynamic` variable
    /// to create a KeyPath.
    ///
    /// - Parameters:
    ///   - keyPath: the KeyPath to observe for changes.
    ///   - handler: closure to run when/if the value changes.
    public func observe<T>(
        _ keyPath: KeyPath<UserDefaults, T>,
        handler: @escaping (T) -> Void)
    {
        let subscriber = Subscribers.Sink<T, Never> { _ in }
            receiveValue: { newValue in
                handler(newValue)
            }
        
        self.publisher(for: keyPath, options: [.initial, .new])
            .subscribe(subscriber)
    }
}
Errata answered 19/3, 2021 at 11:18 Comment(1)
you should at least add an example.Cocklebur

© 2022 - 2024 — McMap. All rights reserved.