[Swift] UserDefaultsの機能を隠蔽した保存設定用モデルを作ってみた

2017.12.26

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

モバイルアプリサービス部の中安です。

「起動中のアプリを落としても消えない(永続的に保存される)データ」の実装手段として、UserDefaults の仕組みを使用するのが一般的な方法かと思います。

しかし、この UserDefaultsの実装は、時として思いのほか冗長になりがちです。

理由としては、機能追加や仕様変更などで開発途中にデータの項目を増やす必要が出てきたり、 それに伴って様々な型に対応しなくてはならなくなるなど、次々と付け足されていくからではないでしょうか。

そうならないためにも、UserDefaultsの処理自体はできるだけ隠蔽しておいて、 保守・拡張しやすい形にしておけば、後々の開発時にも迷子にならなくなるのではないかと思います。

ありがちな実装とは

プロジェクトの規模や開発者によって書き方は多種多様かと思いますが、 Swift における UserDefaults周りでよく見かける実装方法例を書き起こしてみます。

extension UserDefaults {
    
    enum Key: String {
        case isFirstLaunch
        case userName
        case selectedHobbies
    }
    
    func registerDefaults() {
        let defaultValues: [Key : Any] = [
            .isFirstLaunch:   true,
            .userName:        "",
            .selectedHobbies: [String](),
        ]
        register(defaults: defaultValues.reduce(into: [String : Any]()) { result, defaultValue in
            result[defaultValue.key.rawValue] = defaultValue.value
        })
    }
    
    // 初回起動かどうか
    var isFirstLaunch: Bool {
        get {
            return bool(forKey: Key.isFirstLaunch.rawValue)
        }
        set {
            set(newValue, forKey: Key.isFirstLaunch.rawValue)
            synchronize()
        }
    }
    
    // ユーザ名
    var userName: String? {
        get {
            return string(forKey: Key.userName.rawValue)
        }
        set {
            set(newValue, forKey: Key.userName.rawValue)
            synchronize()
        }
    }
    
    // 選択された趣味
    var selectedHobbies: [String] {
        get {
            return stringArray(forKey: Key.selectedHobbies.rawValue) ?? []
        }
        set {
            setValue(newValue, forKeyPath: Key.selectedHobbies.rawValue)
            synchronize()
        }
    }
}

UserDefaults自体を extensionして、 保存したい項目を UserDefaults自体に足していく方法です。 自分の過去作ってきたものも含め、他の方のソースを見ていても割とよく採る方法かなと思います。

この方法が決して間違っているとかそういう話ではありませんが、解決させたいことはいくつかあります。

理想的な形は

一つの項目について何行も実装したくない

上記の例だと、1つのプロパティにつき10行ほどを使っています。 項目の数は3つなので30行費やしていることになります。 規模の大きなアプリになってくると、おそらく項目がもっと増えて、 全体として見通しの悪いものになることが考えられます。

理想としては、項目全体がパッと見れるソースのほうが良いのではないでしょうか。

初期登録は隠蔽したい

上記の例では、registerDefaults()というメソッドを用意して、 各項目の初期値を登録しています。

UserDefaults#register(defaults:)をラップしているわけですが、 このメソッドを AppDelegateあたりで噛ましてやらないと、 初期値の登録ができなくなるという事態に陥りがちです。

理想としては、そのような無駄な実装はあらかじめブラックボックス的に行っておいてほしいものです。

設定をカテゴライズしたい

上記の例では userNameというプロパティを定義しています。 これは例えば、作ろうとしているアプリがサーバ側とWebAPI経由でやりとりをしていて、 それに対して認証する際のユーザ名であるとしましょう。

しかし、アプリの仕様が追加され、Twitter や Facebook などのユーザ名を保存しておく必要が出てきたらどうでしょうか。 よくある話かなと思います。

twitterUserNamefacebookUserName という名前にすることで解決する気もしますが、 もう少しキレイにカテゴライズできないものかと思うところです。

結果として

ここまでの「やりたいこと」をまとめると、ソースコードをこういう形に近いものに持っていければいいなと思います。

class AppConfig {
    var isFirstLaunch = true
    var userName = ""
    var selectedHobbies = [String]()
}

class TwitterConfig {
    var userName = ""
}

class FacebookConfig {
    var userName = ""
}

これくらいシンプルに実装できればハッピーですよね!

KVOを使う

「一つの項目について何行も実装したくない」を実現させるために「プロパティを定義するだけで、それが設定項目になる」という設計にしていきたいと思います。

「クラスのプロパティ値が変わったら UserDefaults経由で値が保存される」という動作にするためにKVOの仕組みを利用していきます。

ベースを作成

まずは ベースになるクラスを作成します。

@objcMembers class StorableConfig: NSObject {

}

名前は「保存可能な設定」ということで、"StorableConfig" という名前にしました。 (ここは適宜いい感じの名前にしてください。)

KVOを使うのでプロパティは「dynamic var」を使用することになります。 ですので NSObjectを基底クラスにして「@objcMembers」を指定してあげます。

設定用の各クラスは以下のように StorableConfigを継承し、 各プロパティに dynamicを追記することになります。

class AppConfig: StorableConfig {
    dynamic var isFirstLaunch = true
    dynamic var userName = ""
    dynamic var selectedHobbies = [String]()
}

class TwitterConfig: StorableConfig {
    dynamic var userName = ""
}

class FacebookConfig: StorableConfig {
    dynamic var userName = ""
}

プロパティを取得する

KVOで監視する対象を決めるために、クラスのプロパティ名を取得する必要があります。

これには Mirrorのリフレクション機能を利用すれば簡単に実現することができます。

@objcMembers class StorableConfig: NSObject {
    
    private var propertyNames: [String] {
        return Mirror(reflecting: self).children
        .flatMap {
            return $0.label
        }
    }
}

先ほどの AppConfigであれば、

["isFirstLaunch", "userName", "selectedHobbies"]

と返されます。

プロパティの値変更を監視する

取得したプロパティに対して、値変更の監視を追加する処理を書きます。

@objcMembers class StorableConfig: NSObject {
    
    override init() {
        super.init()
        addObserversForProperties()
    }
    
    private func addObserversForProperties() {
        propertyNames.forEach { propertyName in
            addObserver(self, forKeyPath: propertyName, options: .new, context: nil)
        }
    }
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        guard let propertyName = keyPath else { return }
        
        // ここは後述
    }
}

こちらはKVOの基本的な組み方になっているかと思います。 これでプロパティの値が変わるたびにハンドリングできるようになりました。

UserDefaults に保存するキー

さて、ここで事前に「やりたいこと」の中の「設定をカテゴライズしたい」を実現するための一工夫を加えたいと思います。

例でいうところの AppConfig、TwitterConfig、FacebookConfig にはそれぞれ userNameというプロパティがあり、 "userName" というキーでそのまま UserDefaultsに保存すると衝突して、意図しない書き換えが起きてしまいます。

なので、自動的に衝突をさけるために下記のような実装を施しました。

@objcMembers class StorableConfig: NSObject {
    
    private var domain: String {
        let bundleIdentifier = Bundle.main.bundleIdentifier!
        let className = String(describing: type(of: self))
        return "\(bundleIdentifier).\(className)"
    }
    
    private func userDefaultsKey(for propertyName: String) -> String {
        return "\(domain).\(propertyName)"
    }
}

userDefaultsKey(for:)を使用すると、 "(バンドルID)+(クラス名)+(プロパティ名)"という形式で文字列を返すようにしました。 これを UserDefaultsのキーにすることにします。

バンドルIDまで含める必要があるかどうかは、規模や組み方によりますので適宜変えて欲しいところですが、 これで下記のようにキー名が重複・衝突することはなくなるかと思います。

// バンドルIDが jp.classmethod.testapp の場合
[
    "jp.classmethod.testapp.AppConfig.userName": "nakayasu"
    "jp.classmethod.testapp.TwitterConfig.userName": "nkysu1"
    "jp.classmethod.testapp.FacebookConfig.userName": "nakayasu.yuichi"
]

保存を行う

先ほど「ここは後述」としていた、プロパティ変更時の処理を下記のように書きます。

@objcMembers class StorableConfig: NSObject {
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        guard let propertyName = keyPath else { return }
        
        let key = userDefaultsKey(for: propertyName)
        let newValue = change?[.newKey]
        UserDefaults.standard.set(newValue, forKey: key)
        UserDefaults.standard.synchronize()
    }
}

これで UserDefaultsの値がプロパティ変更と同時に変更されることになりました。

取得への考慮

ここまででなかなか便利なクラスになったかと思いますが、 残念ながらひとつ考慮漏れの問題点があります。

たとえば AppConfigを介して「初回起動かどうか」を取得するためには下記のように書きます。

class AppConfig: StorableConfig {
    dynamic var isFirstLaunch = true
    dynamic var userName = ""
    dynamic var selectedHobbies = [String]()
}

let appConfig = AppConfig()
let isFirstLaunch = appConfig.isFirstLaunch
        
print("初回起動かどうか・・・\(isFirstLaunch)")

appConfig.isFirstLaunch = false

最初は初回起動なので true と返ってきます。なので print() しているところは "true" と出力されます。

その後に false を代入しているので、UserDefaultsの値も同時に false と書き換えられます。 なので、次回以降起動したときには "false" と出力されるのが正しい動きですが、 残念ながら次回以降も "true" と出力されます。

これはあくまで AppConfigオブジェクトは自らの値を返すにすぎない状態であるからに他なりません。 AppConfigクラスでは isFirstLaunch は true で初期化されているからですね。

つまり、オブジェクトが初期化される時に、UserDefaultsの値をオブジェクトのプロパティ値に代入しておかなければなりません。

@objcMembers class StorableConfig: NSObject {
    
    override init() {
        super.init()
        setUserDefaultsValueToProperties()
        addObserversForProperties()
    }
    
    private func setUserDefaultsValueToProperties() {
        propertyNames.forEach { propertyName in
            let key = userDefaultsKey(for: propertyName)
            let value = UserDefaults.standard.object(forKey: key)
            setValue(value, forKey: propertyName)
        }
    }
}

KVOの監視が開始される前に、UserDefaultsの値を各プロパティに代入しておくことがミソです。

これで前述の問題は解消し、"false" と出力されるべき条件で "false" と返されることになります。

初期値を設定

さて、「やりたいこと」の残った最後の「初期登録は隠蔽したい」に手をつけます。

「クラスで定義しているプロパティの値 = 初期登録の値」という設計にするならば、 子クラスに変更をしなくても、親クラスに処理を加えるだけでこれは実現できます。

@objcMembers class StorableConfig: NSObject {
    
    override init() {
        super.init()
        registerDefaultValues()
        setUserDefaultsValueToProperties()
        addObserversForProperties()
    }
    
    private func registerDefaultValues() {
        UserDefaults.standard.register(defaults: propertyNames.reduce(into: [String : Any]()) { result, propertyName in
            if let value = value(forKey: propertyName) {
                result[userDefaultsKey(for: propertyName)] = value
            }
        })
    }
}

オブジェクト生成時の値が初期登録すべき値なので、 前項の setUserDefaultsValueToProperties() が呼ばれる前に初期登録の処理を入れておきます。

UserDefaultsの register(defaults:) は以降の値変更に影響を受けないので、 初めにオブジェクトが生成される時のみ登録が行われることになります。

これで、わざわざ別途登録処理を書く必要がなくなります。

まとめ

最初に示した「ありがちな実装」の約50行超のソースコードが、 機能は変わらずに5行で収まるようになりました。

class AppConfig: StorableConfig {
    dynamic var isFirstLaunch = true
    dynamic var userName = ""
    dynamic var selectedHobbies = [String]()
}

継承してプロパティを定義するだけ・・というのも拡張性が高くなっているのではないでしょうか。

この他、同じようなことをするための色々なアイデア・方法はあるとは思いますが、 どなたかの参考になればと思います。

最後に参考用にソースコードを掲載しておきます

ソースコード

import Foundation

@objcMembers class StorableConfig: NSObject {
    
    override init() {
        super.init()
        registerDefaultValues()
        setUserDefaultsValueToProperties()
        addObserversForProperties()
    }
    
    private var propertyNames: [String] {
        return Mirror(reflecting: self).children.flatMap {
            return $0.label
        }
    }
    
    private func addObserversForProperties() {
        propertyNames.forEach { propertyName in
            addObserver(self, forKeyPath: propertyName, options: .new, context: nil)
        }
    }
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        guard let propertyName = keyPath else { return }
        
        let key = userDefaultsKey(for: propertyName)
        let newValue = change?[.newKey]
        UserDefaults.standard.set(newValue, forKey: key)
        UserDefaults.standard.synchronize()
    }
    
    private var domain: String {
        let bundleIdentifier = Bundle.main.bundleIdentifier!
        let className = String(describing: type(of: self))
        return "\(bundleIdentifier).\(className)"
    }
    
    private func userDefaultsKey(for propertyName: String) -> String {
        return "\(domain).\(propertyName)"
    }
    
    private func setUserDefaultsValueToProperties() {
        propertyNames.forEach { propertyName in
            let key = userDefaultsKey(for: propertyName)
            let value = UserDefaults.standard.object(forKey: key)
            setValue(value, forKey: propertyName)
        }
    }
    
    private func registerDefaultValues() {
        UserDefaults.standard.register(defaults: propertyNames.reduce(into: [String : Any]()) { result, propertyName in
            if let value = value(forKey: propertyName) {
                result[userDefaultsKey(for: propertyName)] = value
            }
        })
    }
}


class AppConfig: StorableConfig {
    dynamic var isFirstLaunch = true
    dynamic var userName = ""
    dynamic var selectedHobbies = [String]()
}
 
class TwitterConfig: StorableConfig {
    dynamic var userName = ""
}
 
class FacebookConfig: StorableConfig {
    dynamic var userName = ""
}