🤢

@PublishedをSwiftUI以外で使ってはいけない

2022/10/23に公開

SwiftUIで使われる@PublishedCurrentValueSubjectの代わりに使えるというような記事を見かけたので、それはやめておいた方が良いということを書いておきます。

イベントのタイミングの違い

これが唯一の理由ではあるのですが、@PublishedCurrentValueSubjectでは、Property Wrapperとクラスの違いだけではなく、Publisherとしてのイベントを発行する時のタイミングの違いがあります。@Publishedは「値が変更される前(willSet)」でCurrentValueSubjectは「値が変更された後(didSet)」です。

コードを書いて動作を確認してみます。(Playgroundに貼り付ければそのまま実行できます)

import Combine

class Sample {
    @Published var publishedValue : Int = 0
    let currentValue: CurrentValueSubject<Int, Never> = .init(0)
    
    func increment() {
        publishedValue += 1
        currentValue.value += 1
    }
}

let sample = Sample()
var cancellables = Set<AnyCancellable>()

print("値の購読開始")

sample.$publishedValue.sink { received in
    print("published received:\(received) property:\(sample.publishedValue)")
}.store(in: &cancellables)

sample.currentValue.sink { received in
    print("currentValue received:\(received) property:\(sample.currentValue.value)")
}

print("値の変更")

sample.increment()

実行すると、以下のように出力されます。

値の購読開始
published received:0 property:0
currentValue received:0 property:0
値の変更
published received:1 property:0
currentValue received:1 property:1

@Publishedだとsinkでイベントを受け取っている時点では、まだプロパティの値が変わっていないことがわかります。

なぜ@Publishedが値がセットされる前にイベントを発行するのかといえば、SwiftUIで必要だからというのが大きな理由になると思います。

SwiftUIのUI更新の挙動

SwiftUIでは、監視対象となるObservableObjectに適合させたクラスのobjectWillChangeを呼び出すと、UIを更新する「予約」だけがされ、次のUI更新タイミングまで待ってから実際に必要な値を読み出しUIが更新されます。名前にもあるとおり「Will」なので、UI更新に必要な値が変更される「前」に呼び出すことが求められています。

ObservableObjectに適合させたクラスのプロパティに@Publishedをつけると、そのプロパティの値の変更があったら自動でobjectWillChangeが呼び出されるようになっています。SwiftUI的に監視する値が変更される前にそのタイミングを受け取る必要があるから、@Publishedも値の変更前にイベントを発行するわけです。

SwiftUI以外で@Publishedを使った場合

@Publishedのプロパティは$をつけることでPublisherを取得して購読することができます。
ただその時、値が変更される時のイベントを変更前のタイミングでしか受け取れないということは、イベントを受け取った側の処理にかなり気をつける必要があります。SwiftUIの場合はSwiftUIの中で処理が完結していて外部に影響は及びませんが、SwiftUI以外ではそうではありません。

sinkなどのクロージャ内では自由に処理を書くことができますし、Publisherを別のプロパティでラップしたり他のSubjectでSubscribeした後は元が@Publishedであったことが分からなくなります。その@Publishedが隠蔽されたインターフェースを見て、イベントを受け取った時に「値が変更された」という意識でコードを書くと、アプリ全体としては変更前の値を保持している状態なので問題を引き起こすことがあります。

SwiftUIのような特殊な事情がなければ、ただ簡単に書けるからというだけで@Publishedを選ぶべきではないと思います。

CurrentValueSubjectをProperty Wrapperで使う

とは言ってもCurrentValueSubjectをそのままで使うのはちょっとめんどくさいということであれば、Property Wrapperにして@Publishedっぽく使えるようにする手もあります。

例えば、以下のようにProperty Wrapperを定義します。

import Combine

@propertyWrapper class CurrentValue<Value> {
    init(wrappedValue: Value) {
        self.projectedValue = .init(wrappedValue)
    }

    var wrappedValue: Value {
        get { projectedValue.value }
        set { projectedValue.value = newValue }
    }

    let projectedValue: CurrentValueSubject<Value, Never>
}

そうすると、プロパティに@CurrentValueをつけることで内部的にCurrentValueSubjectで保持され、projectedValueでCurrentValueSubjectそのものにアクセスできるので、以下のように使うことができます。

class Sample {
    @CurrentValue var value: Int = 0
}

let sample = Sample()

let cancellable = sample.$value.sink { value in
    print("received:\(value) property:\(sample.value)")
}

sample.value = 1

実行すると、以下のように出力されます。

received:0 property:0
received:1 property:1

Discussion