🌾

[Swift] [Combine] ObservableObject ではない @Published の使い所

2022/02/23に公開約12,000字1件のコメント

伝えたいこと

  • ObservableObject を適応した class でなくても @Published は使用できる(前編の内容)
  • 『値を持つことができる』かつ『subscribeできる』という共通点が CurrentValueSubject@Published にはあり、大抵の場合で書き換えが可能である
    • 特に @Published の場合、private(set) な Publisher を記述するときに簡単に書けるようになる(以下、サンプルコード参照)

例えば、)

CurrentValueSubject で書いていたこれや、

class Hoge {
    private let mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    var mogemogePub: AnyPublisher<Int, Never> {
        mogemoge.eraseToAnyPublisher()
    }
}

CurrentValueSubject で書いていたこれが、

class Hoge {
    private let mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    let mogemogePub: AnyPublisher<Int, Never>
    
    init() {
        mogemogePub = mogemoge.eraseToAnyPublisher()
    }
}

@Published を使うと、こう書けるようになります。

class Hoge {
    @Published private(set) var mogemoge: Int = 0
}

はじめに

前編ObservableObject でなくても @Published が使えることは分かったのですが、その使い道を検討します。

@PublishedCurrentValueSubject は似ている件

まずは、前提として押さえてほしいこととして、@PublishedCurrentValueSubject はかなり似ています。

CurrentValueSubject の例

以下を満たすような適当な Hoge クラスを用意します。

  • subscribe 可能な mogemoge
  • mogemoge の値を更新する Setter である setMogemoge()
  • 最新の mogemoge の値を使用する用途があると仮定した printMogemoge()
class Hoge {
    let mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge.value = mogemoge
    }
    
    func printMogemoge() {
        print("mogemoge: \(mogemoge.value)")
    }
}

この場合、Setter からも CurrentValueSubject.value の両方で、mogemoge の値の更新が可能になっています。

let hoge = Hoge()

// subscribe を実施
hoge.mogemoge
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

// Setter から値の更新が可能
hoge.setMogemoge(1)

// CurrentValueSubject のため mogemoge.value = xxx でも値の更新が可能
hoge.mogemoge.value = 2

// 現在の Hoge().mogemoge の値を出力する
hoge.printMogemoge()

// (出力)
// receive: 0
// receive: 1
// receive: 2
// mogemoge: 2

@Published の場合

ここで先ほど CurrentValueSubject で記述していた mogemoge@Published var で書き直すと以下のようになります。

class Hoge {
    // `@Published var` に変更🌟
    @Published var mogemoge: Int = 0
    
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge = mogemoge
    }
    
    func printMogemoge() {
        print("mogemoge: \(mogemoge)")
    }
}
let hoge = Hoge()

// `$` で Publisher としてアクセス可能に🌟
hoge.$mogemoge
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

hoge.setMogemoge(1)

// @Published var mogemoge は外から代入可能🌟
hoge.mogemoge = 2

hoge.printMogemoge()

// (出力)
// receive: 0
// receive: 1
// receive: 2
// mogemoge: 2

このように『値を持つことができる』かつ『subscribeできる』という共通点が CurrentValueSubject@Published にはあり、書き換えが可能です。

subscribe 可能な値を外部から編集されない方法を検討する

上記で説明したように @PublishedCurrentValueSubject は似ているのですが、private のアクセス修飾子をつけてみると挙動の差がでてきます。

例えば先ほどの Hoge クラスについて、『mogemoge は外部からは値を直接編集できないようにする』という条件を加えます。

  • subscribe 可能な mogemoge
  • mogemoge の値を更新する Setter である setMogemoge()
  • 最新の mogemoge の値を使用する用途があると仮定した printMogemoge()
  • mogemoge は外部から値を直接編集できないようにするNew

つまり、mogemogesetMogemoge() のインターフェースでのみ値の変更をさせたいとします。

そのときに、@PublishedCurrentValueSubject で実装しようとすると差が出てきます。

方法1: ❌ CurrentValueSubject の変数に private をつけた場合

まず、CurrentValueSubject の変数に private をつけてみます。

class Hoge {
    // private のアクセス修飾子をつける
    private let mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge.value = mogemoge
    }
    
    func printMogemoge() {
        print("mogemoge: \(mogemoge.value)")
    }
}

確かに以下のように hoge.mogemoge.value = xxx での変更はできなくなります。

しかし、残念ながら subscribe もできなくなりました😭

let hoge = Hoge()

// subscribe できなくなった😭(コンパイルエラー)
hoge.mogemoge // 'mogemoge' is inaccessible due to 'private' protection level
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

hoge.setMogemoge(1)

// hoge.mogemoge.value = xxx で変更できなくなった ← 狙い通り😎
hoge.mogemoge.value = 2 // 'mogemoge' is inaccessible due to 'private' protection level

hoge.printMogemoge()

当たり前の挙動ですね。

次の手として、private(set) を検討します。

方法2: ❌ CurrentValueSubject の変数に private(set) をつけた場合

mogemogeprivate(set) のアクセス修飾子をつけます。

また、 private(set) let とはできないため、letvar に変更します。

これでなら、subscribe を許可したまま、値の直接編集を制限できそうです。

class Hoge {
    // CurrentValueSubject を private(set) な変数とする
    private(set) var mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge.value = mogemoge
    }
    
    func printMogemoge() {
        print("mogemoge: \(mogemoge.value)")
    }
}

残念ながら、そうはなりませんでした😭

以下をご覧ください。

let hoge = Hoge()

// subscribe できる🌟
hoge.mogemoge
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

hoge.setMogemoge(1)

// private(set) でも `hoge.mogemoge.value = xxx` してもコンパイルにならない😭
hoge.mogemoge.value = 2

hoge.printMogemoge()

// (出力)
// receive: 0
// receive: 1
// mogemoge: 1
// mogemoge: 2

hoge.mogemoge.value = xxx で更新できてしまいました😭

↑こうなる解説(private(set) var の挙動について)

実はこれは当たり前のことで、以下のサンプルコードのように、クラスの更新ができないだけで、そのクラスのもつ変数は更新することができます。

class Moge {
    var piyopiyo: Int = 0
}

class Hoge {
    private(set) var moge: Moge = .init()
}

let hoge = Hoge()

// これはコンパイルエラー
hoge.moge = Moge() // Cannot assign to property: 'moge' setter is inaccessible

// コンパイルエラーとはならない
hoge.moge.piyopiyo = 1

CurrentValueSubject公式ドキュメントをみるとわかるのですが、CurrentValueSubject 実はクラスです。

final class CurrentValueSubject<Output, Failure> where Failure : Error

なので、上記のサンプルコードと同じように private(set) でも値を更新できてしまうのです。

方法3: ⭕️ 別口の AnyPublisher を公開する(その1)

先ほどの CurrentValueSubjectmogemogeprivate のアクセス修飾子をつけることに加えて、mogemoge とは別口で AnyPublisher でラッピングした mogemogePub として公開させます。

方法は以下の 2 種類あるのですが、今回は『init() で設定する』を紹介します。

  • その1:init() で設定する ← こっち
  • その2:Computed property で設定する
class Hoge {
    private let mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    // subscribe 専用の mogemogePub を用意
    let mogemogePub: AnyPublisher<Int, Never>
    
    init() {
        // init() で設定
        mogemogePub = mogemoge.eraseToAnyPublisher()
    }
    
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge.value = mogemoge
    }
    
    func printMogemoge() {
        print("mogemoge: \(mogemoge.value)")
    }
}

mogemoge でのアクセスを制限したまま、mogemogePub を subscribe させます。

let hoge = Hoge()

// mogemogePub なら subscribe できる🌟
hoge.mogemogePub
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

hoge.setMogemoge(1)

// 以下でアクセスできずコンパイルエラー ← 狙い通り😎
// hoge.mogemoge.value = 2 // 'mogemoge' is inaccessible due to 'private' protection level

hoge.printMogemoge()

// (出力)
// receive: 0
// receive: 1
// mogemoge: 1

setMogemoge() のインターフェースでのみ値の変更に制限できて、CurrentValueSubject.value = xxx でのアクセスを禁止することに成功しました。

方法4: ⭕️ 別口の AnyPublisher を公開する(その2)

続いて『Computed property で設定する』の方も紹介します。

ちなみに私はこちらの方が好みです。

  • その1:init() で設定する
  • その2:Computed property で設定する ← こっち
class Hoge {
    private let mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    // init() で設定せずに Computed property にすることも可能(ただし var となる)
    var mogemogePub: AnyPublisher<Int, Never> {
        mogemoge.eraseToAnyPublisher()
    }
    
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge.value = mogemoge
    }
    
    func printMogemoge() {
        print("mogemoge: \(mogemoge.value)")
    }
}

先ほどと同様に、mogemoge でのアクセスを制限したまま、mogemogePub を subscribe させます。

let hoge = Hoge()

// mogemogePub なら subscribe できる🌟
hoge.mogemogePub
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

hoge.setMogemoge(1)

// 以下でアクセスできずコンパイルエラー ← 狙い通り😎
// hoge.mogemoge.value = 2 // 'mogemoge' is inaccessible due to 'private' protection level

hoge.printMogemoge()

// (出力)
// receive: 0
// receive: 1
// mogemoge: 1

方法4 と同様にこれでもいけますね。

方法5: ❌ @Published の変数に private をつけた場合

方法1 を CurrentValueSubject ではなく @Published で書いた場合です。

class Hoge {
    // @Published private をつける🌟
    @Published private var mogemoge: Int = 0
    
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge = mogemoge
    }
    
    func printMogemoge() {
        print("mogemoge: \(mogemoge)")
    }
}

もちろん、方法1 と同様に subscribe できなくなります😭

let hoge = Hoge()

// subscribe できなくなった😭(コンパイルエラー)
hoge.$mogemoge // '$mogemoge' is inaccessible due to 'private' protection level
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

hoge.setMogemoge(1)

// mogemoge = xxx で変更できなくなった ← 狙い通り😎
hoge.mogemoge = 2 // 'mogemoge' is inaccessible due to 'private' protection level

hoge.printMogemoge()

方法6: ⭕️ @Published の変数に private(set) をつけた場合

方法2 を CurrentValueSubject ではなく @Published で書いた場合です。

class Hoge {
    // @Published private(set) をつける🌟
    @Published private(set) var mogemoge: Int = 0
    
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge = mogemoge
    }
    
    func printMogemoge() {
        print("mogemoge: \(mogemoge)")
    }
}

どうせ、方法2 の CurrentValueSubject みたくうまくいかないのだろうと思ったのですが、これがうまくいきます。

let hoge = Hoge()

// @Published private(set) なら subscribe できる🌟
hoge.$mogemoge
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

hoge.setMogemoge(1)

// 以下は狙い通りコンパイルエラー😎
// hoge.mogemoge = 2 // Cannot assign to property: 'mogemoge' setter is inaccessible

hoge.printMogemoge()

// (出力)
// receive: 0
// receive: 1
// mogemoge: 1

CurrentValueSubject の場合は mogemoge.value の値を更新していたので、private(set) var では制限できませんでしたが、@Published の場合は、mogemoge の値を直接編集しに行っているので、ちゃんと制限できるようです。

これであれば、方法3 や方法4 のように別口の AnyPublisher を公開する必要もないので、シンプルに実装できますね。

この辺のアクセス制御は CurrentValueSubject にはできない @Published の使い道となります。

他にも、CurrentValueSubject の記述がシンプルになる例があるかもしれませんね。

PassthroughSubject@Published で書き直せるのか?

CurrentValueSubject の方法4 で行っていたことを PassthroughSubject で書き直してみます。

class Hoge {
    private let mogemoge = PassthroughSubject<Int, Never>()
    
    var mogemogePub: AnyPublisher<Int, Never> {
        mogemoge.eraseToAnyPublisher()
    }
    
    // `setMogemoge()` ではなく `sendMogemoge()` が適切だと思いますが。
    func setMogemoge(_ mogemoge: Int) {
        self.mogemoge.send(mogemoge)
    }
    
    // PassthroughSubject では値を保持できないのでこれに相当する処理が記述できない
    // func printMogemoge() {
    //     print("mogemoge: \(mogemoge.value)")
    // }
}

let hoge = Hoge()

hoge.mogemogePub
    .sink { print("receive: \($0)") }
    .store(in: &cancellables)

hoge.setMogemoge(1)

// (出力)
// receive: 1

これを方法6 の @Published の変数に private(set) で書いたバージョンで書き直せるかどうかについて検討します。

個人的には PassthroughSubject は現在値を保有していないため、CurrentValueSubject@Published と全く性質の異なるものであり、単に記述量が減るからといって、@Published に書き直したりはしないほうが適切だと思います。

結論

  • ObservableObject を適応した class でなくても @Published は使用できる(前編の内容)
  • 『値を持つことができる』かつ『subscribeできる』という共通点が CurrentValueSubject@Published にはあり、大抵の場合で書き換えが可能である
    • 特に @Published の場合、private(set) な Publisher を記述するときに簡単に書けるようになる(以下、サンプルコード参照)

例えば、)

CurrentValueSubject で書いていたこれや、

class Hoge {
    private let mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    var mogemogePub: AnyPublisher<Int, Never> {
        mogemoge.eraseToAnyPublisher()
    }
}

CurrentValueSubject で書いていたこれが、

class Hoge {
    private let mogemoge: CurrentValueSubject<Int, Never> = .init(0)
    
    let mogemogePub: AnyPublisher<Int, Never>
    
    init() {
        mogemogePub = mogemoge.eraseToAnyPublisher()
    }
}

@Published を使うと、こう書けるようになります。

class Hoge {
    @Published private(set) var mogemoge: Int = 0
}

以上になります。

GitHubで編集を提案

Discussion

CurrentValueSubject@Publishedの大きな違いに、イベントが発行されるタイミングの違いがあります。CurrentValueSubjectは値がセットされた後ですが@Publishedは値がセットされる前です。なのでCurrentValueSubjectと同じ挙動をすると思って@Publishedを使うと、イベントを受け取った時にプロパティの値が変わっていなくて問題が起きる可能性があります。

SwiftUIとは関係ないところで@Publishedのような使い勝手を求めるのであれば、 https://gist.github.com/objective-audio/924f4d199ca9a1d50b4060516a523e36 のような感じでCurrentValueSubjectをラップしたpropertyWrapperを定義すると良いのではないでしょうか。

ログインするとコメントできます