LoginSignup
4
3

More than 3 years have passed since last update.

Property Wrapper + Combine で UserDefaults を複数のプロパティに同期&監視する

Posted at

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を使っているため、結構コンパクトなコードだと思います。

Publisher.swift
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.swift
@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必要かはまだ悩んでます🤔

4
3
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
4
3