onmyway133 / blog

🍁 What you don't know is what you haven't learned
https://onmyway133.com/
MIT License
679 stars 33 forks source link

How to make swifty UserDefaults #972

Open onmyway133 opened 6 months ago

onmyway133 commented 6 months ago

We want to have a swifty UserDefaults API that works with subscript and in a type safe manner.

extension Defaults.Keys {
    static let string = Defaults.Key("string", default: "0")
}

XCTAssertEqual(defaults[.string], "0")
defaults[.string] = "1"
XCTAssertEqual(defaults[.string], "1")

UserDefaults plist compatibility

Define Compatible protocol that allows value to be plist compatible

The value parameter can be only property list objects: NSData, NSString, NSNumber, NSDate, NSArray, or NSDictionary. For NSArray and NSDictionary objects, their contents must be property list objects.

public protocol Compatible: Equatable {}

extension Int: Compatible {}
extension String: Compatible {}
extension Bool: Compatible {}
extension Date: Compatible {}
extension Array: Compatible where Element: Compatible {}
extension Dictionary: Compatible where Key: Compatible, Value: Compatible {}

Next, define Defaults that accepts UserDefaults as initialize dependency, so that we can swap UserDefaults.

Key with generic type

We define Key with phantom type Value so we know which value this key is pointing to, this makes it easier to reason about the code.

Since Swift has limitation Static stored properties not supported in generic types, we can't extend our Key with static stored properties, we have to do via computed property

extension Defaults.Key {
    static var string:  Defaults.Key<String> { .init("string", default: "0") }
}

This works, but does not look nice. To workaround this, we define class AnyKey and make our Key class as well and inherited this AnyKey class.

Make a typealias typealias Keys = AnyKey so we can refer to Defaults.Keys when we define our keys.

public class Defaults {
    public var suite: UserDefaults

    public init(suite: UserDefaults = .standard) {
        self.suite = suite
    }

    public subscript<Value: Compatible>(key: Key<Value>) -> Value {
        get {
            if let value = suite.object(forKey: key.name) as? Value {
                return value
            }

            return key.defaultValue
        }
        set {
            suite.set(newValue, forKey: key.name)
        }
    }

    public func exists<Value: Compatible>(key: Key<Value>) -> Bool {
        suite.object(forKey: key.name) != nil
    }
}

extension Defaults {
    public typealias Keys = AnyKey

    public class Key<Value: Compatible>: AnyKey {
        var defaultValue: Value

        public init(_ name: String, default defaultValue: Value) {
            self.defaultValue = defaultValue

            super.init(name: name)
        }
    }

    public class AnyKey {
        var name: String

        init(name: String) {
            self.name = name
        }
    }
}

extension Defaults.AnyKey: Equatable {
    public static func == (lhs: Defaults.AnyKey, rhs: Defaults.AnyKey) -> Bool {
        lhs.name == rhs.name
    }
}

extension Defaults.AnyKey: Hashable {
    public func hash(into hasher: inout Hasher) {
        hasher.combine(name)
    }
}

How about Optional

We can support Optional as well, as long as it's underlying value is compatible. Since the type is defined via Key, we can't accidentally use Optional when the Key has non Optional value

extension Optional: Compatible where Wrapped: Compatible {}

extension Defaults {
    public subscript<Value: Compatible>(key: Key<Optional<Value>>) -> Value? {
        get {
            if let value = suite.object(forKey: key.name) as? Value {
                return value
            }

            return nil
        }
        set {
            if let newValue {
                suite.set(newValue, forKey: key.name)
            } else {
                suite.removeObject(forKey: key.name)
            }
        }
    }
}

extension Defaults.Keys {
    static let optional = Defaults.Key<Int?>("optional.int", default: nil)
}

func testOptional() {
    XCTAssertNil(defaults[.optional])

    defaults[.optional] = 1
    XCTAssertEqual(defaults[.optional], 1)

    defaults[.optional] = nil
    XCTAssertNil(defaults[.optional])
}