iOSプログラマなら誰もが3回は作っているUserDefaultsをいい感じに使う方法の解説です。
今回はProperty Wrapperを活用します。目標は以下のとおり。
- あまりコードを書かずに使える
- UserDefaultsで元々使える型も使えない型も同じように使える
- 型安全に使える(キャストせずに済む)
- Optionalも扱う(UserDefaultsの
integer(forKey:)
は値がないときnilではなく0を返してしまう) - Swift5.1以降対応
- iOS11以降対応
カッコよく使う例
アプリごとに用意するUserDefaultsアクセス部分の例です。
struct User : Codable, UserDefaultCompatible {
var name: String
}
struct Settings {
@UserDefault("user", default: User(name: "abc"))
var user: User
@UserDefault("userOptional", default: nil)
var userOptional: User?
@UserDefault("users", default: [])
var users: [User]
@UserDefault("userDictionary", default: [:])
var userDictionary: [String: User]
@UserDefault("int", default: 5)
var int: Int
@UserDefault("intOptional", default: nil)
var intOptional: Int?
}
UserはCodableを実装した型です。
もちろんIntなどUserDefaultsが元々対応している型も保存できます。
Array, Dictionary, Optionalも扱うことができます。
- キー文字列の指定
- デフォルト値の指定
- プロパティの定義(名前と型の指定)
という最小限の実装でUserDefaultsを扱うことができます。
ソースコード
説明はいらないソース見せろという場合はPlaygroundに貼り付けられるコードをコピペするか、UserDefaultCompatible と UserDefaultPropertyWrapper をご確認ください。
記事では省略している部分も全て記載しています。
UserDefaultPropertyWrapperをSwiftPMで取り込んでもOKです。
UserDefaultsをカッコよく使う部品を作る
工程は大きく分けてふたつあります。
- UserDefaultsに読み書き可能な型の作成
- UserDefaultsにアクセスするProperty Wrapperを作成
UserDefaultsに読み書き可能な型の作成
UserDefaultsに読み書き可能な型(protocol)と、実際にその型を読み書きするコードは以下となります。
// このProtocolに準拠していればUserDefaultsに読み書きできる
protocol UserDefaultCompatible {
init?(userDefaultObject: Any)
func toUserDefaultObject() -> Any?
}
extension UserDefaults {
// 読み込み
func value<Value : UserDefaultCompatible>(type: Value.Type = Value.self, forKey key: String, default defaultValue: Value) -> Value {
guard let object = object(forKey: key) else { return defaultValue }
return Value(userDefaultObject: object) ?? defaultValue
}
// 書き込み
func setValue<Value : UserDefaultCompatible>(_ value: Value, forKey key: String) {
set(value.toUserDefaultObject(), forKey: key)
}
}
UserDefaultCompatible
を通して、UserDefaultsが標準で読み書き可能な型に変換することで任意の方を読み書きできるようにします。
補足としてfunc value(type:forKey:default:) -> Value
は引数のdefaultまたは戻り値で型を確定させれば、type引数が省略可能なようにしています。
Codable/NSCoding対応
ここからUserDefaultsが標準で読み書き可能な型に変換する部分を作っていきます。
CodableはDataに変換可能です。
DataはUserDefaults読み書き可能なため、これを利用すればCodable準拠のstruct/enumを簡単に保存できます。
NSCodingもDataに変換可能なため、同様のことが可能です。
extension UserDefaultCompatible where Self : Codable {
init?(userDefaultObject: Any) {
// Data型で保存されているはず
guard let data = userDefaultObject as? Data else { return nil }
do {
self = try JSONDecoder().decode(Self.self, from: data)
} catch {
return nil
}
}
func toUserDefaultObject() -> Any? {
// Data型に変換する
try? JSONEncoder().encode(self)
}
}
// NSCodingのコードは省略
UserDefaultsが元々対応している型の対応
Int
などUserDefaultsの標準機能で読み書き可能な型もUserDefaultCompatibleとして読み書きできると実装が楽になります。
Int
はCodableに準拠しているため簡単にUserDefaultCompatibleに準拠させられます。
extension Int : UserDefaultCompatible {}
しかしこの場合IntではなくJSONのDataに変換して保存するため integer(forKey:)
で値が取り出せなくなります。
互換性を考慮する場合は以下の実装が必要です。
extension Int : UserDefaultCompatible {
init?(userDefaultObject: Any) {
// Self(ここではInt)にキャストする
guard let userDefaultObject = userDefaultObject as? Self else { return nil }
self = userDefaultObject
}
func toUserDefaultObject() -> Any? {
// 保存時に変換する必要がないためselfをそのまま返す
self
}
}
// `Double` `Float` `Bool` `String` `Date` `Data` は `Int` と同じコードで対応可能。
// コードは省略。
// URLは特殊
extension URL : UserDefaultCompatible {
init?(userDefaultObject: Any) {
// NSKeyedArchiverでData化した値が保存されているはず
guard let userDefaultObject = userDefaultObject as? Data else { return nil }
guard let url = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(userDefaultObject) as? URL else { return nil }
self = url
}
func toUserDefaultObject() -> Any? {
// NSKeyedArchiverでDataに変換する
try? NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: false)
}
}
URL
だけは特殊です。
内部で NSKeyedArchiver/NSKeyedUnarchiver を使用しているらしくfunc url(forKey defaultName: String) -> URL?
で読めるようにするにはNSKeydArchiver/Unarchiverを使うようにします。
Array/Dictionaryの対応
ArrayとDictionaryの要素がUserDefaultsに対応した型の場合、そのArrayとDictionaryも読み書き可能です。
そのため要素がUserDefaultCompatibleであれば、要素を変換することで読み書き可能になります。
extension Array : UserDefaultCompatible where Element : UserDefaultCompatible {
private struct UserDefaultCompatibleError : Error {}
init?(userDefaultObject: Any) {
guard let objects = userDefaultObject as? [Any] else { return nil }
do {
let values = try objects.map { (object: Any) -> Element in
if let element = Element(userDefaultObject: object) {
return element
} else {
throw UserDefaultCompatibleError()
}
}
self = values
} catch {
return nil
}
}
func toUserDefaultObject() -> Any? {
map { $0.toUserDefaultObject() }
}
}
// Dictionaryも似たようなコードで対応可能。コードは省略
Optionalの対応
Optionalの要素がUserDefaultsに対応した型の場合、そのOptionalも読み書き可能です。
そのため要素がUserDefaultCompatibleであれば、要素を変換することで読み書き可能になります。
extension Optional : UserDefaultCompatible where Wrapped : UserDefaultCompatible {
init?(userDefaultObject: Any) {
self = Wrapped(userDefaultObject: userDefaultObject)
}
func toUserDefaultObject() -> Any? {
flatMap { $0.toUserDefaultObject() }
}
}
UserDefaultsにアクセスするProperty Wrapperを作成
最後に、UserDefaultCompatibleを使い実際にUserDefaultsを読み書きするProperty Wrapperを作成します。
このProperty Wrapperは以下の機能を持ちます。
- UserDefaultsのキー文字列の指定
- キーに対応する値がない場合のデフォルト値の指定
- UserDefaultsの読み書き
@propertyWrapper
struct UserDefault<Value : UserDefaultCompatible> {
private let key: String
private let defaultValue: Value
// 初期化時にキー文字列とデフォルト値を受け取る
init(_ key: String, default defaultValue: Value) {
self.key = key
self.defaultValue = defaultValue
}
// PropertyWrapperでラップしたプロパティへのアクセスは
// wrapperdValueプロパティへのアクセスに自動で置き換えられる
var wrappedValue: Value {
get {
UserDefaults.standard.value(type: Value.self, forKey: key, default: defaultValue)
}
set {
UserDefaults.standard.setValue(newValue, forKey: key)
}
}
}
まとめ
以上により、カッコよく使う実装が完成しました。
サンプルでは一部省略していますが
- UserDefaultsが元々対応している
Int
Double
Float
Bool
String
Date
Data
-
Codable
NSCoding
に準拠した型 - 上記を要素にもつ
Array
Dictionary
Optional
に対応しているため、そうそう困ることはないでしょう。
補足
検討① キーはただの文字列
UserDefaultsに保存するときのキーはただの文字列を使用します。キー文字列を複数回タイプすることはほぼないため、別の型を用意するほどではないと考えました。
ただSettingsを分割してUserSettingsやFuntionSettingsなどを用意する場合は、キー文字列が重複していないかすぐ確認できるように、特定の型で一箇所にまとめておいてもよいかもしれません。
検討② registerDefaultsは使わない
UserDefaultsにはfunc register(defaults registrationDictionary: [String : Any])
という便利機能があります。
初期値をDictionaryでまとめて渡すと、値がないときだけ書き込んでくれます。初回起動か気にせず呼び出せますし、バージョンアップ時は追加の項目だけ書き込んでくれます。
これに対応する機能も作りたかったのですが、キー文字列に対応する値の型を強制する(対応しない型ならコンパイルエラーにする)方法がわからなかったためProperty Wrapperに初期値を渡す作りにしました。
検討③ UnitTestをどうするか
上記例のstruct Settings
自体のテストは不要と考えています。そんなに不具合はないだろうと楽観視。
var Settings.user: User
を使うテストをする場合は
protocol UserSetting {
var user: User { get set }
}
を用意してUnitTestではMockを渡すくらいでしょうか?
検討④ [Int?]
は使えません
実際動かしてみたところ「property list形式ではありません」といった感じのエラーが出てしまいました。(保存できるのはprroperty list形式なんですね。protocolの名前をそれっぽいものにしてもいいかも)
NSNullを使えばいけるかもしれませんが Int?
でnilを保存するときにNSNullになっても面倒だし非対応ということにしました。
var projectedValue
Property WrapperにはprojectedValueという(正式名称かは知らないけど)面白い機能があります。
var projectedValue: Int { 3 }
と実装した場合 settings.$user
とすると 3
が取得できます(戻り値の型は何でもいいらしい)。
iOS13以降限定でCombineの何かを返せば面白いかと思いましたが、その場合そもそもProperty Wrapperを使う必要もないなと思ったので今回この機能は使いませんでした。