LoginSignup
38
22

More than 3 years have passed since last update.

Swift5.1のProperty Wrappersでより安全なUser Defaults参照を実現する

Last updated at Posted at 2019-06-26

Swift5.1から導入される Property Wrappers ですが、WWDC 2019のセッションで User Defaults とプロパティを関連づける例が紹介されていました。

これをもとにProperty Wrappersの仕組みについてもう一歩踏み込みつつ、より安全な参照を実現する方法を考えてみたいと思います。

任意の型を扱うためのプロトコルを定義する

User Defaultsが標準で扱える型は String, Int などのプリミティブな型と、 ArrayDictionary のコレクションに制限されていますが、 Data 型に変換ができれば、任意の型を扱うことができます。これらを踏まえ、以下のようなプロトコルを定義します。

UserDefaultConvertible.swift
protocol UserDefaultConvertible {
    init?(with object: Any)
    func object() -> Any?
}

User Defaultsと関連づけるProperty Wrappers

User Defaultsと関連づけるProperty WrapperのGeneric Type(下記の例の Value)は、先述の UserDefaultConvertible への準拠を要求します。

UserDefaultPropertyWrapper.swift
@propertyWrapper
struct UserDefault<Value: UserDefaultConvertible> {
    let key: String
    let defaultValue: Value

    init(_ key: String, defaultValue: Value) {
        self.key = key
        self.defaultValue = defaultValue
    }

    var value: Value {
        get {
            if
                let object = UserDefaults.standard.object(forKey: self.key),
                let value = Value(with: object)
            {
                return value
            } else {
                return self.defaultValue
            }
        } set {
            if let object = newValue.object() {
                UserDefaults.standard.set(object, forKey: self.key)
            } else {
                UserDefaults.standard.removeObject(forKey: self.key)
            }
        }
    }
}

あとは対応したい型に対して UserDefaultConvertible への準拠を行なっていくだけです。 Int 型の例を示します。

Int+UserDefaultConvertible.swift
extension Int: UserDefaultConvertible {
    init?(with object: Any) {
        guard let value = object as? Int else {
            return nil
        }
        self = value
    }

    func object() -> Any? {
        return self
    }
}

任意の型の UserDefaultConvertible 準拠は、愚直に実装するより Codable に準拠を前提とした以下のような拡張を用意すると良いでしょう。

UserDefaultConvertible.swift
extension UserDefaultConvertible where Self: Codable {
    init?(with object: Any) {
        guard
            let data = object as? Data,
            let value = try? JSONDecoder().decode(Self.self, from: data)
        else {
            return nil
        }

        self = value
    }

    func object() -> Any? {
        return try? JSONEncoder().encode(self)
    }
}

Optional も扱えるようにしておきましょう。
(こういう場面でConditional Conformanceが生きますね!)

Optional+UserDefaultConvertible.swift
extension Optional: UserDefaultConvertible where Wrapped: UserDefaultConvertible {
    init?(with object: Any) {
        guard let value = Wrapped(with: object) else {
            return nil
        }
        self = .some(value)
    }

    func object() -> Any? {
        switch self {
        case .some(let value):
            return value.object()
        case .none:
            return nil
        }
    }
}

ここまでの実装でできること

さて、これでどのような書き方ができるかというと...

// User Defaultsへのストアため Codable, UserDefaultConvertible に準拠
struct UserProfile: Codable, UserDefaultConvertible {
    var firstName: String
    var lastName: String

    enum Gender: String, Codable {
        case male
        case female
        case other
    }
    var gender: Gender
}

class App {
    static let shared = App()
    private init() {}

    // Data型に変換し、User Defaultsに "loginUserProfile" という名前でストア
    @UserDefault("loginUserProfile", defaultValue: nil)
    var userProfile: UserProfile?
}

// Usage
if let userProfile = App.shared.userProfile {
    ...
}

任意の型を User Defaultsに保存・参照できるようになりました!

型安全かつtypoしにくい設計にする

いまの設計だと、User Defaultsへの参照キーは文字列リテラルで指定することになるため、以下の懸念点があります。

  • 異なる情報に対し、同じ名前の参照キーを命名できてしまう
  • ストアされている型と異なる型を指定して取り出しができてしまう(失敗する)

そこでSwiftyUserDefaultsで採られている方法を参考に、より安全な設計に変更してみます。

値の型情報を持つキー型を定義する

SwiftyUserDefaultsの実装のように、以下のようなクラスを用意します。

UserDefaultTypedKey.swift
class UserDefaultTypedKeys {
    init() {}
}

class UserDefaultTypedKey<T>: UserDefaultTypedKeys {
    let key: String

    init(_ key: String) {
        self.key = key
        super.init()
    }
}

UserDefaultTypedKey<T> に対応したProperty Wrapperに修正する

String で持っていた参照キーを UserDefaultTypedKey<T> に変更します。

@propertyWrapper
struct UserDefault<Value: UserDefaultConvertible> {
    let typedKey: UserDefaultTypedKey<Value>
    let defaultValue: Value

    init(_ typedKey: UserDefaultTypedKey<Value>, defaultValue: Value) {
        self.typedKey = typedKey
        self.defaultValue = defaultValue
    }

    var value: Value {
        get {
            if
                let object = UserDefaults.standard.object(forKey: self.typedKey.key),
                let value = Value(with: object)
            {
                return value
            } else {
                return self.defaultValue
            }
        } set {
            if let object = newValue.object() {
                UserDefaults.standard.set(object, forKey: self.typedKey.key)
            } else {
                UserDefaults.standard.removeObject(forKey: self.typedKey.key)
            }
        }
    }
}

参照キーの定義と利用

UserDefaultTypedKeys を拡張し、User Defaultsへの参照キーを static なプロパティで定義します。その際Genericパラメータに値の型、イニシャライザに実際の参照キーを指定します。

extension UserDefaultTypedKeys {
    static let loginUserProfile = UserDefaultTypedKey<UserProfile?>("loginUserProfile")
}

これで必要なものは整いました。
先ほどの実装例は以下のようになります。

class App {
    static let shared = App()
    private init() {}

    @UserDefault(.loginUserProfile, defaultValue: nil)
    var userProfile: UserProfile?
}

微々たる変化ではありますが、

  • 文字列リテラルで記述する必要がない(補完が効く!)
  • 型が一致していないとコンパイルエラーになる

という効果が生まれます。
UserDefaultTypedKeys の中でキー重複のない、型制約のついたUser Defaultsが表現できますね!

おまけ

Array の場合、各 ElementUserDefaultConvertible 準拠であり、全要素が相互変換できないと Error にするという仕様が良さそうです。

ArrayUserDefaultConvertible 準拠にする

Array+UserDefaultConvertible.swift
extension Array: UserDefaultConvertible where Element: UserDefaultConvertible {
    private struct Error: Swift.Error {}

    init?(with object: Any) {
        guard let array = object as? [Any] else {
            return nil
        }

        guard let value = try? array.map({ (object) -> Element in
            if let element = Element(with: object) {
                return element
            } else {
                throw Error()
            }
        }) else {
            return nil
        }

        self = value
    }

    func object() -> Any? {
        return try? self.map { (element) -> Any in
            if let object = element.object() {
                return object
            } else {
                throw Error()
            }
        }
    }
}

同じ要領で Dictionary も対応できるはずです。

38
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
22