Last active
February 8, 2024 18:46
-
-
Save cfloisand/ba9eb5b661a7dda494bb45f28cdb7e0a to your computer and use it in GitHub Desktop.
A better UserDefaults in Swift using Key-Value Observing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
//# MARK: - FSUserDefaults | |
class FSUserDefaults: NSObject { | |
private var _observer: FSDefaultsObserver! | |
override init() { | |
super.init() | |
_observer = FSDefaultsObserver(object: self) | |
} | |
} | |
//# MARK: - FSDefaultsObserver | |
fileprivate class FSDefaultsObserver: NSObject { | |
@objc weak var observedObject: NSObject! | |
var observations: [(String, UnsafeMutableRawPointer)] = [] | |
enum ObjectType { | |
case string, array, dictionary, data, url | |
case any | |
} | |
enum ValueType: Character { | |
case boolean = "b" | |
case integer = "i" | |
case float = "f" | |
case double = "d" | |
case url = "u" | |
case object = "@" | |
} | |
let contextPointerSize = MemoryLayout<ValueType.RawValue>.size | |
let contextPointerAlignment = MemoryLayout<ValueType.RawValue>.alignment | |
init(object: NSObject) { | |
observedObject = object | |
super.init() | |
let keys = UserDefaults.standard.dictionaryRepresentation().keys | |
let existingKeyPaths = keys.filter({ key -> Bool in | |
return key.hasPrefix(#keyPath(observedObject)) | |
}) | |
var propertyCount: UInt32 = 0 | |
if let properties = class_copyPropertyList(object_getClass(observedObject), &propertyCount) { | |
for idx in 0..<propertyCount { | |
let property = properties[Int(idx)] | |
let name = String(cString: property_getName(property)) | |
let attr = String(cString: property_getAttributes(property)!) | |
var keyPath = #keyPath(observedObject) | |
keyPath = keyPath.appending(".").appending(name) | |
var value: Any? | |
let typeIdx = attr.index(attr.startIndex, offsetBy: 1) | |
let type = attr[typeIdx] | |
let context = UnsafeMutableRawPointer.allocate(bytes: contextPointerSize, alignedTo: contextPointerAlignment) | |
switch type { | |
case "c", "B": | |
context.storeBytes(of: ValueType.boolean, as: ValueType.self) | |
value = UserDefaults.standard.bool(forKey: keyPath) | |
case "s", "i", "l", "q", "S", "I", "L", "Q": | |
context.storeBytes(of: ValueType.integer, as: ValueType.self) | |
value = UserDefaults.standard.integer(forKey: keyPath) | |
case "f": | |
context.storeBytes(of: ValueType.float, as: ValueType.self) | |
value = UserDefaults.standard.float(forKey: keyPath) | |
case "d": | |
context.storeBytes(of: ValueType.double, as: ValueType.self) | |
value = UserDefaults.standard.double(forKey: keyPath) | |
case "@": | |
let objectType = __objectType(attr) | |
if objectType == .url { | |
context.storeBytes(of: ValueType.url, as: ValueType.self) | |
} else { | |
context.storeBytes(of: ValueType.object, as: ValueType.self) | |
} | |
switch objectType { | |
case .string: value = UserDefaults.standard.string(forKey: keyPath) | |
case .array: value = UserDefaults.standard.array(forKey: keyPath) | |
case .dictionary: value = UserDefaults.standard.dictionary(forKey: keyPath) | |
case .data: value = UserDefaults.standard.data(forKey: keyPath) | |
case .url: value = UserDefaults.standard.url(forKey: keyPath) | |
case .any: value = UserDefaults.standard.object(forKey: keyPath) | |
} | |
default: | |
assertionFailure("[FSUserDefaults] Unhandled property type.") | |
} | |
// NOTE(christian): If current property's key path is not contained in the UserDefaults' dictionary | |
// representation, it has not yet been set. In this case, don't set it's value in order to preserve | |
// default/init values. | |
if let val = value, existingKeyPaths.contains(keyPath) { | |
observedObject.setValue(val, forKey: name) | |
} | |
addObserver(self, forKeyPath: keyPath, options: [.new], context: context) | |
observations.append((keyPath, context)) | |
} | |
free(properties) | |
} | |
} | |
deinit { | |
for obs in observations { | |
removeObserver(self, forKeyPath: obs.0, context: obs.1) | |
obs.1.deallocate(bytes: contextPointerSize, alignedTo: contextPointerAlignment) | |
} | |
} | |
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { | |
if let type = context?.load(as: ValueType.self) { | |
let value = change![.newKey] | |
switch type { | |
case .boolean: UserDefaults.standard.set(value as! Bool, forKey: keyPath!) | |
case .integer: UserDefaults.standard.set(value as! Int, forKey: keyPath!) | |
case .float: UserDefaults.standard.set(value as! Float, forKey: keyPath!) | |
case .double: UserDefaults.standard.set(value as! Double, forKey: keyPath!) | |
case .url: UserDefaults.standard.set((value as! URL), forKey: keyPath!) | |
case .object: UserDefaults.standard.set(value, forKey: keyPath!) | |
} | |
} else { | |
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) | |
} | |
} | |
private func __objectType(_ attr: String) -> ObjectType { | |
if attr.contains("NSString") { | |
return .string | |
} else if attr.contains("NSArray") { | |
return .array | |
} else if attr.contains("NSDictionary") { | |
return .dictionary | |
} else if attr.contains("NSData") { | |
return .data | |
} else if attr.contains("NSURL") { | |
return .url | |
} else { | |
return .any | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment