6
9

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 5 years have passed since last update.

[Swift 4~] UserDefaults を Observable で Testable なものにしたい!

Posted at

みなさんUserDefaults使ってますか?
設定情報などを永続保持するのに便利ですよね :thumbsup:

今回は, そんなUserDefaultsをちょっと幸せにするTipsをご紹介します.

Swiftらしく, Observableで, TestableなUserDefaults

Swiftらしく というと違和感を覚える方もいるかもしれませんが, 要は ProtocolEnum を駆使して安全で扱いやすくするということです.

UserDefaultsKVO による値の監視が可能です. しかし, Swift 4 で追加された observe(_:options:changeHandler:)UserDefaults では使えません. 同じようにクロージャで KVO を実装できるようにしたいですよね.

UserDefaults を使用する関数のテストはどのようにしていますか? とりあえず現在の値を別で保持して, テスト値を入れてテスト, 最後に保持した値で元に戻す, なんてことをしていませんか? 面倒な上にミスが起きやすいですよね. 実際に扱うデータに影響を与えず, テスト値を入れたり値を監視したりするにはどうすればよいでしょうか.

この記事では, これらをどうやって実現/解決するか, 私が実践してる方法を紹介します.

Swiftらしく

ProtocolEnum を用いて UserDefaults のキーを管理する方法は数多く紹介されています.
私は, それらの中でもよく見かける KeyNamespaceable 的なやつを採用しています. 詳細は割愛しますので, 詳しくは「UserDefaults KeyNamespaceable」などで調べてみてください.

KeyNamespaceable.swift
protocol KeyNamespaceable {}

extension KeyNamespaceable {
    static func namespace<T: RawRepresentable>(_ key: T) -> String where T.RawValue == String {
        return "\(Self.self)_\(T.self)_\(key.rawValue)"
    }
}

protocol ObjectUserDefaultable: KeyNamespaceable {
    associatedtype ObjectDefaultKey: RawRepresentable
}

extension ObjectUserDefaultable where ObjectDefaultKey.RawValue == String {
    static func set(_ value: Any?, forKey key: ObjectDefaultKey) {
        UserDefaults.standard.set(value, forKey: namespace(key))
    }

    static func object(for key: ObjectDefaultKey) -> Any? {
        return UserDefaults.standard.object(forKey: namespace(key))
    }

    static func remove(for key: ObjectDefaultKey) {
        UserDefaults.standard.removeObject(forKey: namespace(key))
    }
}

public protocol IntegerUserDefaultable: KeyNamespaceable { /*同様の実装*/ }
...
...
使用例
struct MyUserDefaults: ObjectUserDefaultable, BoolUserDefaultable {
    enum ObjectDefaultKey: String {
        case userName
        case appTheme
    }

    enum BoolDefaultKey: String {
        case needsBackup
    }
}

MyUserDefaults.set("krimpedance", forKey: .userName)
let needsBackup = MyUserDefaults.bool(forKey: .needsBackup)

Enum でアクセスすることで, タイポを防ぎつつ, Xcodeの補完が効くのでスマートになります. :v_tone2:
よく UserDefaults に直接上記 Protocol 群を継承させている例もありますが, Testable にすることなど考えて, 独自の構造体を作っています.

Observable化

冒頭でも触れましたが, UserDefaultsNSObject のサブクラスなので, KVO により値を監視することができます.
これで良いといえば良いのですが, Swift 4KeyPath1 が追加されたことで, クロージャ式の KVO メソッドも追加されました. SwiftLint さんにも怒られるし2, こっち使いたいですよね.

ただこの KeyPath, 使えるのは NSObject を継承したクラスの @objc dynamic var メンバのみなんです. つまり, UserDefaultsでは使えません. :innocent:

仲間外れは「ダメ。絶対。」

ということで, NSObjectのSwift拡張を参考に UserDefaults用の関数を実装したいと思います.

クロージャ式 KVO

まず, Swift 4 で追加されたクロージャ式の ovserve 関数を見てみましょう.

[apple/swift]NSObject.swift
public func observe<Value>(_ keyPath: KeyPath<Self, Value>, options: NSKeyValueObservingOptions = [], changeHandler: @escaping (Self, NSKeyValueObservedChange<Value>) -> Void) -> NSKeyValueObservation {
    let result = NSKeyValueObservation(object: self as! NSObject, keyPath: keyPath) { (obj, change) in
        let notification = NSKeyValueObservedChange(kind: change.kind, newValue: change.newValue as? Value, oldValue: change.oldValue as? Value, indexes: change.indexes, isPrior: change.isPrior)
        changeHandler(obj as! Self, notification)
    }
    result.start(options)
    return result
}
使用例
class Hoge {
    let scrollView = UIScrollView()
    var observation: NSKeyValueObservation?
    
    setUp() {
        observation = scrollView.observe(\.contentOffset) { view, change in
            print("move to (x: \(change.newValue!.x), y: \(change.newValue!.y))")
        }
    }

    tearDown() {
        observation.invalidate()
        // もしくは
        // observation = nil
        // もしくは自身(Hoge)の解放
    }
}

これまでの addObserver(...)NSKeyValueObservation クラスで代行して, 内部で変形, クロージャを実行しているようです. そして, 返り値でこのクラスのオブジェクトを返しているため, 「オブジェクトの生存期間 = KayPath の監視期間」となり, 扱いやすくなってます.

KeyPath はメンバ名とその型情報がわかるようになっているので, これを Value.TypeString に分ければなんとかなりそうです. :sunglasses:

UserDefault用NSKeyValueObservationクラスの作成

NSKeyValueObservation を簡易コピーして UserDefaults 用の UDKeyValueObservation クラスを作ります. イニシャライザが internal なので, 同じようにUDKeyValueObservedChange構造体も作る必要があります.

(以降のコードは省略部分も多数あります. 完全版は一番最後にリンクがあります. )

UDKeyValueObservation.swift
struct UDKeyValueObservedChange<Value> {
    typealias Kind = NSKeyValueChange
    let kind: Kind
    let newValue: Value?
    let oldValue: Value?
    let indexes: IndexSet?
    let isPrior:Bool
}

class UDKeyValueObservation: NSObject {
    // 循環参照を避けるために監視オブジェクトは弱参照
    weak var object: NSObject?
    // 値変更時に実行するクロージャ(初期化時のクロージャ)
    let callback: (NSObject, UDKeyValueObservedChange<Any>) -> Void
    // 監視するUserDefaultsのキー
    let defaultName: String

    // 本家では KeyPath → String 変換が行われる
    fileprivate init(object: NSObject, defaultName: String, callback: @escaping (NSObject, UDKeyValueObservedChange<Any>) -> Void) {
        self.object = object
        self.defaultName = defaultName
        self.callback = callback
    }

    // 値の変更はまずここに通知される
    // 本家では, 黒魔術(method_exchangeImplementations)で独自関数と入れ替えてる
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
        guard
            let ourObject = self.object,
            let change = change,
            object as? NSObject == ourObject,
            keyPath == defaultName
        else { return }

        // [NSKeyValueChangeKey: Any] => UDKeyValueObservedChange
        let rawKind = change[.kindKey] as! UInt
        let kind = NSKeyValueChange(rawValue: rawKind)!
        let notification = UDKeyValueObservedChange(kind: kind,
                                          newValue: change[.newKey],
                                          oldValue: change[.oldKey],
                                          indexes: change[.indexesKey] as! IndexSet?,
                                          isPrior: change[.notificationIsPriorKey] as? Bool ?? false)
        // クロージャを実行
        callback(ourObject, notification)
    }

    // 監視を開始
    // イニシャライザで行わないのは, options に .initial が含まれていた場合, 自身の初期化前に通知されてしまうため(だと思われる).
    fileprivate func start(_ options: NSKeyValueObservingOptions) {
        object?.addObserver(self, forKeyPath: defaultName, options: options, context: nil)
    }

    // 監視を解除.
    // deinit 時に自動で呼ばれると言いつつそれぞれ解除処理してる.
    func invalidate() {
        object?.removeObserver(self, forKeyPath: defaultName, context: nil)
        object = nil
    }

    deinit {
        object?.removeObserver(self, forKeyPath: defaultName, context: nil)
    }
}

よく意図がわからない実装を飛ばしつつも, ほぼコピペで NSKeyValueObservationKeyPathString に置き換えることができました.
次は, 実際に使う側から実行する observe 関数を実装します.

UIKeyValueObservation.swift
typealias UDChangeHandler<O, V> = (O, UDKeyValueObservedChange<V>) -> Void

// KeyPathの方は _KeyValueCodingAndObserving プロトコルが定義されている
protocol UDKeyValueCodingAndObserving {}

extension UDKeyValueCodingAndObserving {
    func observe<Value>(_ type: Value.Type, forKey defaultName: String, options: NSKeyValueObservingOptions = [], changeHandler: @escaping UDChangeHandler<Self, Value>) -> UDKeyValueObservation {
        let result = UDKeyValueObservation(object: self as! NSObject, defaultName: defaultName) { obj, change in
            let notification = UDKeyValueObservedChange(kind: change.kind, newValue: change.newValue as? Value, oldValue: change.oldValue as? Value, indexes: change.indexes, isPrior: change.isPrior)
            changeHandler(obj as! Self, notification)
        }
        result.start(options)
        return result
    }
}

extension UserDefaults: UDKeyValueCodingAndObserving {}

こちらもいい感じに実装できました. ちゃんと動くか確認してみましょう.

確認
class Hoge {
    var observation: UDKeyValueObservation?

    init() {
        observation = UserDefaults.standard.observe(String.self, forKey: "key-1", options: [.new, .old]) { _, change in
            print(change)
        }
    }
}

var hoge = Hoge()
print("1")
UserDefaults.standard.set("hoge", forKey: "key-1")
print("2")
UserDefaults.standard.set("hoge", forKey: "key-1")
print("3")
UserDefaults.standard.set("fuga", forKey: "key-1")
print("4")
UserDefaults.standard.set(nil, forKey: "key-2")
print("5")
hoge.observation = nil
UserDefaults.standard.set("hoge", forKey: "key-1")

// 1
// Hoge Optional("hoge") nil
// Hoge Optional("hoge") nil
// 2
// 3
// Hoge Optional("fuga") Optional("hoge")
// Hoge Optional("fuga") Optional("hoge")
// 4
// Hoge nil Optional("fuga")
// Hoge nil Optional("fuga")
// 5

はい, できてる !! 3

どうしました ?
この挙動, 正しい ですよね ? :innocent:

正しい, というのは想定される挙動という意味で, 2回通知が来ることではありません. ご存知の方も多いと思いますが, 実はこれ未だに直っていないバグなんです. こちらの記事に最新の情報とともに書かれています.
バグならしょうがない, 2回飛んでくるだけだ, じゃ済まない状況もあります. :joy:
なのでとりあえずは, こちらの方法などで抑制4するしかなさそうです.

KeyNamespaceable 対応

String でクロージャ式 KVO ができるようになったので, 最初の KeyNamespaceable でも対応しましょう.

KeyNamespaceable.swift
extension ObjectUserDefaultable {
    static func ovserve<Value>(_ type: Value.Type, forKey key: ObjectDefaultKey, changeHandler: @escaping UDChangeHandler<UserDefaults, Value>) -> UDKeyValueObservation {
        let key = namespace(key)
        return UserDefaults.standard.observe(type, forKey: key, changeHandler: changeHandler)
    }
}

extension IntegerUserDefaultable { /* 同様の実装 */ }
...
...
使用例
let observation = MyUserDefaults.observe(String.self, forKey: .userName) { defaults, change in
    print(change)
}
// 型が固定のキーは上記の第一引数は無くても良い
let observation2 = MyUserDefaults.observe(forKey: .needsBackup) { defaults, change in
    print(change)
}

いい感じですね. これで UserDefaults もクロージャ式 KVO の仲間入りです.

Plus Ultra

一つのクラスで複数の KVO 監視を行うとき, NSKeyValueObservation オブジェクトは配列で保持することがあります. 今回は, UDKeyValueObservation という別のクラスを作成したため, 配列を2つ用意することになります. これを回避するために Plotocol を用いて一つの配列で管理できるようにするのが良さそうです.

KeyValueObservatationable.swift
protocol KeyValueObservationable {
    // 外から見えるのはこれだけでよい
    func invalidate()
}

extension NSKeyValueObservation: KeyValueObservationable {}
extension UDKeyValueObservation: KeyValueObservationable {}

// 使用例
var observations = [KeyValueObservationable]()
let observationNS = scrollView.observe(\.contentOffset) { ... }
let observationUD = userDefaults.observe("userName") { ... }
observations.append(contentsOf: [observationNS, observationUD])

Testable化

実際に扱う値や他のテストに影響を与えずに, UserDefaultsを操作するためには, 以下のようにする必要があります.

class Hoge {
    let defaults: UserDefaultable

    init(defaults: UserDefaultable) {
        self.defaults = defaults
    }

    func save(value: Int) {
        defaults.set(value, forKey: "key")
    }
}

// 通常時
let hoge = Hoge(defaults: UserDefaults.standard)
// テスト時
let hoge = Hoge(defaults: UserDefaultsFake())

テスト駆動開発などでよく見ますね.
共通の Protocol ( UserDefaultable ) を用意し, 仮データを入れたり, 書込/読込を監視したりするクラス( UserDefaultsFake )を作成し, 依存性注入( init(defaults:) )により対象を切り替えます.

Protocol の準備

余計な実装を省くために, 関数名などは UserDefaults に合わせます.

UserDefaultable.swift
public protocol UserDefaultable: UDKeyValueCodingAndObserving {
    func object(forKey defaultName: String) -> Any?
    func string(forKey defaultName: String) -> String?
    ...

    func set(_ value: Any?, forKey defaultName: String)
    func set(_ value: Int, forKey defaultName: String)
    ...

    func removeObject(forKey defaultName: String)

    // これだけ自前で用意
    func reset()
}

途中省略していますが, 取得/保存用の関数があれば十分でしょう. UserDefaults の初期化だけは面倒なので, 独自で用意しています. また, 先ほどの UDKeyValueCodingAndObserving も継承させています.

さて, それではまずこの ProtocolUserDefaults に継承させます. 関数名を合わせているので, 余計な実装は必要ありません.

UserDefaultable.swift
extension UserDefaults: UserDefaultable {
    public func reset() {
        guard let bundleId = Bundle.main.bundleIdentifier else { return }
        removePersistentDomain(forName: bundleId)
    }
}

Fakeクラスの作成

テスト用に, UserDefaultsFake クラスを作ります.

UserDefaultsFake.swift
public class UserDefaultsFake: UserDefaultable {
    private var values = [String: Any]() // 値格納用

    // MARK: Getter --------------

    func object(forKey defaultName: String) -> Any? {
        return values[defaultName]
    }

    func string(forKey defaultName: String) -> String? {
        return values[defaultName] as? String
    }

    ...

    // MARK: Setter --------------

    func set(_ value: Any?, forKey defaultName: String) {
        guard let val = value else { removeObject(forKey: defaultName); return }
        values[defaultName] = val
    }

    func set(_ value: Int, forKey defaultName: String) {
        values[defaultName] = value
    }

    ...

    func reset() {
        values = [:]
    }
}

実にシンプルな実装です. ただ, このままだと KVO で値を監視できませんね.
KVO で値を監視するための修正を加えましょう.

UserDefaultsFake.swift
- public class UserDefaultsFake: UserDefaultable {
+ public class UserDefaultsFake: NSObject, UserDefaultable {
...
      func set(_ value: Int, forKey defaultName: String) {
+         willChangeValue(forKey: defaultName)
          values[defaultName] = value
+         didChangeValue(forKey: defaultName)
      }
...
+     override public func value(forKey key: String) -> Any? {
+         return values[key]
+     }
...

まず, 基本として NSObject のサブクラスにします.
willChangeValue, didChangeValue はセットで呼ぶことで, 自身を addObserver したクラスの obsreveValue 関数がトリガーされます.
ただ, これだけだと唯一のメンバである values しか監視できません. そのため, value(forKey:) をオーバーライドして, values の各キーを見るように書き換えてあげます. 5

KeyNamespaceable 対応

Observable の時と同様, KeyNamespaceableTestable に対応させます.

KeyNamespaceable.swift
-     static func set(_ value: Any?, forKey key: ObjectDefaultKey) {
-         UserDefaults.standard.set(value, forKey: namespace(key))
+     static func set(_ value: Any?, forKey key: ObjectDefaultKey, source: UserDefaultable = UserDefaults.standard) {
+         source.set(value, forKey: namespace(key))
      }

      static func observe<Value>(_ type: Value.Type,
                                 forKey key: ObjectDefaultKey,
+                                source: UserDefaultable = UserDefaults.standard,
                                 options: NSKeyValueObservingOptions = [],
-                                changeHandler: @escaping UDChangeHandler<UserDefaults, Value>) -> UDKeyValueObservation {
-         return UserDefaults.standard.observe(type, forKey: namespace(key), options: options, changeHandler: changeHandler)
+                                changeHandler: @escaping UDChangeHandler<UserDefaultable, Value>) -> UDKeyValueObservation {
+         return source.observe(type, forKey: namespace(key), options: options, changeHandler: changeHandler)
    }

source: UserDefaultable という引数をそれぞれ追加しました. これも依存性注入ですね. 自家製の小さいアプリや, プロジェクトによってはテストを書かないこともあるので, デフォルト値に UserDefaults.standard を設定してみました.

class Hoge {
    let defaults: UserDefaultable

    init(defaults: UserDefaultable) {
        self.defaults = defaults
    }

    func save(value: Int) {
        MyUserDefaults.set(value, forKey: .hogeKey, source: defaults)
    }
}

良さそうですね.

不完全燃焼の部分もありますが, これで Swiftらしく, Observableで, TestableなUserDefaults の完成です.

完成物

Gistにまとめました. Playground にコピペして試してみてください.

  1. 参考Qiita記事: #keyPathとKeyPath

  2. block_based_kvo ルールにより, Warning が出るようになりました.

  3. https://www.youtube.com/watch?v=j0JBvkJu1_U. ご冥福をお祈りいたします.

  4. あくまで 抑制 です. タイミングによっては2回飛んでくることもありました.

  5. この辺は試行錯誤した末に偶然見つけた方法で, 正しい方法かは自信がありません. 詳しい方がいたら補足していただきたいです. :bow_tone1:

6
9
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
6
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?