Swift5.1から導入される Property Wrappers ですが、WWDC 2019のセッションで User Defaults とプロパティを関連づける例が紹介されていました。
これをもとにProperty Wrappersの仕組みについてもう一歩踏み込みつつ、より安全な参照を実現する方法を考えてみたいと思います。
任意の型を扱うためのプロトコルを定義する
User Defaultsが標準で扱える型は String
, Int
などのプリミティブな型と、 Array
や Dictionary
のコレクションに制限されていますが、 Data
型に変換ができれば、任意の型を扱うことができます。これらを踏まえ、以下のようなプロトコルを定義します。
protocol UserDefaultConvertible {
init?(with object: Any)
func object() -> Any?
}
User Defaultsと関連づけるProperty Wrappers
User Defaultsと関連づけるProperty WrapperのGeneric Type(下記の例の Value
)は、先述の UserDefaultConvertible
への準拠を要求します。
@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
型の例を示します。
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
に準拠を前提とした以下のような拡張を用意すると良いでしょう。
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が生きますね!)
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の実装のように、以下のようなクラスを用意します。
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
の場合、各 Element
が UserDefaultConvertible
準拠であり、全要素が相互変換できないと Error
にするという仕様が良さそうです。
Array
を UserDefaultConvertible
準拠にする
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
も対応できるはずです。