みなさんUserDefaults
使ってますか?
設定情報などを永続保持するのに便利ですよね
今回は, そんなUserDefaults
をちょっと幸せにするTipsをご紹介します.
Swiftらしく, Observableで, TestableなUserDefaults
Swiftらしく というと違和感を覚える方もいるかもしれませんが, 要は Protocol
や Enum
を駆使して安全で扱いやすくするということです.
UserDefaults
は KVO による値の監視が可能です. しかし, Swift 4 で追加された observe(_:options:changeHandler:)
は UserDefaults
では使えません. 同じようにクロージャで KVO を実装できるようにしたいですよね.
UserDefaults
を使用する関数のテストはどのようにしていますか? とりあえず現在の値を別で保持して, テスト値を入れてテスト, 最後に保持した値で元に戻す, なんてことをしていませんか? 面倒な上にミスが起きやすいですよね. 実際に扱うデータに影響を与えず, テスト値を入れたり値を監視したりするにはどうすればよいでしょうか.
この記事では, これらをどうやって実現/解決するか, 私が実践してる方法を紹介します.
Swiftらしく
Protocol
や Enum
を用いて UserDefaults
のキーを管理する方法は数多く紹介されています.
私は, それらの中でもよく見かける KeyNamespaceable
的なやつを採用しています. 詳細は割愛しますので, 詳しくは「UserDefaults KeyNamespaceable」などで調べてみてください.
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の補完が効くのでスマートになります.
よく UserDefaults
に直接上記 Protocol 群を継承させている例もありますが, Testable にすることなど考えて, 独自の構造体を作っています.
Observable化
冒頭でも触れましたが, UserDefaults
は NSObject
のサブクラスなので, KVO により値を監視することができます.
これで良いといえば良いのですが, Swift 4 で KeyPath
1 が追加されたことで, クロージャ式の KVO メソッドも追加されました. SwiftLint さんにも怒られるし2, こっち使いたいですよね.
ただこの KeyPath
, 使えるのは NSObject
を継承したクラスの @objc dynamic var
メンバのみなんです. つまり, UserDefaults
では使えません.
仲間外れは「ダメ。絶対。」
ということで, NSObjectのSwift拡張を参考に UserDefaults
用の関数を実装したいと思います.
クロージャ式 KVO
まず, Swift 4 で追加されたクロージャ式の ovserve
関数を見てみましょう.
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.Type
と String
に分ければなんとかなりそうです.
UserDefault用NSKeyValueObservationクラスの作成
NSKeyValueObservation
を簡易コピーして UserDefaults
用の UDKeyValueObservation
クラスを作ります. イニシャライザが internal なので, 同じようにUDKeyValueObservedChange
構造体も作る必要があります.
(以降のコードは省略部分も多数あります. 完全版は一番最後にリンクがあります. )
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)
}
}
よく意図がわからない実装を飛ばしつつも, ほぼコピペで NSKeyValueObservation
の KeyPath
を String
に置き換えることができました.
次は, 実際に使う側から実行する observe
関数を実装します.
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
どうしました ?
この挙動, 正しい ですよね ?
正しい, というのは想定される挙動という意味で, 2回通知が来ることではありません. ご存知の方も多いと思いますが, 実はこれ未だに直っていないバグなんです. こちらの記事に最新の情報とともに書かれています.
バグならしょうがない, 2回飛んでくるだけだ, じゃ済まない状況もあります.
なのでとりあえずは, こちらの方法などで抑制4するしかなさそうです.
KeyNamespaceable 対応
String
でクロージャ式 KVO ができるようになったので, 最初の KeyNamespaceable
でも対応しましょう.
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 を用いて一つの配列で管理できるようにするのが良さそうです.
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
に合わせます.
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
も継承させています.
さて, それではまずこの Protocol を UserDefaults
に継承させます. 関数名を合わせているので, 余計な実装は必要ありません.
extension UserDefaults: UserDefaultable {
public func reset() {
guard let bundleId = Bundle.main.bundleIdentifier else { return }
removePersistentDomain(forName: bundleId)
}
}
Fakeクラスの作成
テスト用に, UserDefaults
の Fake クラスを作ります.
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 で値を監視するための修正を加えましょう.
- 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 の時と同様, KeyNamespaceable
も Testable に対応させます.
- 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 にコピペして試してみてください.
-
参考Qiita記事: #keyPathとKeyPath ↩
-
block_based_kvo
ルールにより, Warning が出るようになりました. ↩ -
https://www.youtube.com/watch?v=j0JBvkJu1_U. ご冥福をお祈りいたします. ↩
-
あくまで 抑制 です. タイミングによっては2回飛んでくることもありました. ↩
-
この辺は試行錯誤した末に偶然見つけた方法で, 正しい方法かは自信がありません. 詳しい方がいたら補足していただきたいです. ↩