11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

UserDefaultsをProperty Wrapperでカッコよく使う

Last updated at Posted at 2020-05-08

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に貼り付けられるコードをコピペするか、UserDefaultCompatibleUserDefaultPropertyWrapper をご確認ください。
記事では省略している部分も全て記載しています。

UserDefaultPropertyWrapperをSwiftPMで取り込んでもOKです。

UserDefaultsをカッコよく使う部品を作る

工程は大きく分けてふたつあります。

  1. UserDefaultsに読み書き可能な型の作成
  2. 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を使う必要もないなと思ったので今回この機能は使いませんでした。

11
5
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
11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?