iOS14では @AppStorage
というUserDefaultsを同期するものが追加されるようです(多分)。
この記事ではそれと似たようなものを作ります。目標は以下の通り。
- ユーザー定義の型も読み書きできるようにする(別記事紹介)
- UserDefaultsの変更を自動で反映する。複数の離れた箇所のプロパティであっても、キー文字列が同じなら値が同期されるようにする
- 変更をCombine.Publisherとして監視できるようにする
最初に実装に必要な前提知識の紹介をして、そのあと具体的なコードを載せています。
Swift Package Managerのライブラリとして公開もしています。
実装に必要な前提知識
UserDefaultsでstructを読み書きする
UserDefaultsでユーザー定義のstructやenumやInt?
なども扱えると便利です。
そのためUserDefaultCompatible
を用意して、以下のコードで読み書きができるようにしています。
public protocol UserDefaultsProtocol : NSObject {
func value<Value : UserDefaultCompatible>(type: Value.Type, forKey key: String, default defaultValue: Value) -> Value
func setValue<Value : UserDefaultCompatible>(_ value: Value, forKey key: String)
}
extension UserDefaults : UserDefaultsProtocol {
public 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
}
public func setValue<Value : UserDefaultCompatible>(_ value: Value, forKey key: String) {
set(value.toUserDefaultObject(), forKey: key)
}
}
長くなるためUserDefaultCompatible
の詳細は省略します。興味のある方は「UserDefaultsをProperty Wrapperでカッコよく使う」のUserDefaultsに読み書き可能な型の作成をご覧ください。(宣伝)
UserDefaultsの変更を監視する
KVOを使うとUserDefaultsの値の変更を監視できます。
// KVOでUserDefaultsの監視開始
userDefaults.addObserver(self, forKeyPath: "key", options: .new, context: nil)
// KVO監視で変更があったときに呼ばれる
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
// 変更された時の処理
}
KeyPathを使うKVOもありますが、キーの文字列で変更を受け取りたいため、昔ながらの方法で監視します。
Combine.Publisherの実装にCurrentValueSubjectを使う
Publisherとして値の変更を受け取れるようにします。地道にSubscriptionなどを実装することも可能ですが、ほとんどの処理をCurrentValueSubject
に移譲すると簡単に実装可能です。
Property Wrapperのコードサンプル
Publisherの実装にCurrentValueSubject
を使っているため、結構コンパクトなコードだと思います。
extension UserDefaults {
public class Publisher<Output : UserDefaultCompatible> : NSObject, Combine.Publisher {
public typealias Failure = Never // エラーは発生しないものとする
private let key: String // キー文字列
private let defaultValue: Output // キー文字列に対応する値がない場合の値
private let userDefaults: UserDefaultsProtocol // UserDefaultsの本体
private let subject: CurrentValueSubject<Output, Never> // CurrentValueSubjectに処理を移譲して簡単に実装する
public var value: Output {
get { subject.value }
set {
if newValue != subject.value {
subject.value = newValue
// 値が設定された時はUserDefaultsに保存する
userDefaults.setValue(newValue, forKey: key)
}
}
}
// テストなどでUserDefaultsに値を保存したくない時に備え、念のためUserDefaultsProtocolを使う
// ただテストを考慮しても、あまり必要ない気がしている
public init(key: String, default defaultValue: Output, userDefaults: UserDefaultsProtocol = UserDefaults.standard) {
self.key = key
self.defaultValue = defaultValue
self.userDefaults = userDefaults
self.subject = .init(userDefaults.value(type: Output.self, forKey: key, default: defaultValue))
super.init()
// KVOでUserDefaultsの監視開始
userDefaults.addObserver(self, forKeyPath: key, options: .new, context: nil)
}
deinit {
// KVO監視終了
userDefaults.removeObserver(self, forKeyPath: key)
}
// KVO監視で変更があったときに呼ばれる
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
// UserDefaultsへのアクセスを減らすため、changeのnewKeyから値を取り出している
//
// `subject.value = userDefaults.value(type: Output.self, forKey: key, default: defaultValue)`
// するだけの方がコードが読みやすく、おそらく問題も起きないと思う。
if keyPath == key {
if let newObject = change?[.newKey] {
value = Output(userDefaultObject: newObject) ?? defaultValue
} else {
value = defaultValue
}
}
}
// Publisherの必須メソッド
// 何も考えずCurrentValueSubjectに処理を移譲する
public func receive<S>(
subscriber: S
) where S : Combine.Subscriber, Failure == S.Failure, Output == S.Input {
subject.receive(subscriber: subscriber)
}
}
}
@propertyWrapper
public struct UserDefault<Value : UserDefaultCompatible> {
private let publisher: UserDefaults.Publisher<Value>
// 最初の引数 `wrappedValue` はプロパティの初期化の値が渡される
// それ以降の引数は `@UserDefault("key", userDefaults: UserDefaults.standard)` としたときの引数が渡される
public init(wrappedValue defaultValue: Value, _ key: String, userDefaults: UserDefaultsProtocol = UserDefaults.standard) {
publisher = .init(key: key, default: defaultValue, userDefaults: userDefaults)
}
// プロパティの値の読み書き時はこれが呼ばれる
public var wrappedValue: Value {
get { publisher.value }
set { publisher.value = newValue }
}
// プロパティに$を付けた時はこれが呼ばれる
public var projectedValue: UserDefaults.Publisher<Value> { publisher }
}
使い方
実践的ではない例ですが、以下があったとして
struct First {
@UserDefault("number")
var number: Int = 10
}
struct Second {
@UserDefault("number")
var number: Int = 10
}
var first = First()
var second = Second()
プロパティの自動同期と監視
first.$number.sink { print(#line, $0) }
first.number = 20
first.number // => 20
second.number // => 20
second.number = 30
first.number // => 30
second.number // => 30
first
second
どちらに値を入れても勝手に同期されます。
sink
では初期値の10に続けて、20,30も出力されます。
assign
let publisher = Publishers.Sequence<[Int], Never>(sequence: [40, 50, 60])
let cancelable = publisher.assign(to: \.value, on: first.$number)
first.number // => 60
second.number // => 60
assignでKeyPath \.value
に値を流すことができます。
sink
には40,50,60が流れてきます。
Combine依存だけで読み書き
let input: PassthroughSubject<Int, Never> = .init()
let cancellable: AnyCancellable = input.assign(to: \.value, on: first.$number)
let output: AnyPublisher<Int, Never> = publisher.eraseToAnyPublisher()
output.sink { print(#line, $0) }
input.send(70)
- 書き込みを
PassthroughSubject
- 読み込みを
AnyPublisher
とするとCombineの依存だけになり(@UserDefault
に依存せず)読み書き可能になります。@UserDefault
をあちこちに書くのは嫌だけど、Combineへの依存はあちこちにあってもOKという考えはありだと思います。
ただAnyPublisher
を使うことはありそうですが、書き込みをPassthroughSubject
でする需要はあるのでしょうか……
assign
時のanyCancellableをどこかで保持する必要があるのが面倒です。
書き込みは、単純なメソッドやクロージャとして公開する方がいいかもしれません。
もしPassthroughSubject
によるinputが使いたい! となった場合はUserDefaults.Publisher
本体にinputを埋め込みAnyCancellableを保持させるほうが楽です。
まとめて定義
もちろんどこかのenumやstructに設定値をまとめて定義することもできます。
キー文字列をtypoしないようになど考えるとまとめて定義して必要なところに渡すほうがいいかもしれません。
終わりに
iOS14がリリースされれば、iOS13以降になってCombineが使える…といいな。
PassthroughSubjectによるinput必要かはまだ悩んでます🤔