[SwiftUI][Combine] ObservableObject/@Published の落とし穴 (継承に注意)

ObservableObject, @Published を使用していて期待通りに動かないケースを説明します。
MEMO
この記事で説明しているケースは、macOS Big Sur 11.3 Beta 2 で修正されました。iOS14.5RC でも修正されました。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Big Sur 11.2.2
  • Xcode 12.4
  • iOS 14.4

ObservableObject と @Published

ObservableObject に準拠したクラスのプロパティに @Published と付与します。

ビューの側では、@ObservedObject や @StateObject で扱うと、変更が入った時に、ビューが更新されます。

例えば、以下のコードでは、ボタンを押すと、画面が更新され、変更された値が表示されます。

Example

class DirectConformClass:ObservableObject {
    @Published var directProperty:Int = 0
}

struct ContentView: View {
    @StateObject var object:DirectConformClass = DirectConformClass()
    
    var body: some View {
        VStack {
            Spacer()
            Text("DirectProperty \(object.directProperty)")
            Button(action: {
                object.directProperty += 1
            }, label: {
                Text("DirectProperty +1")
            })
            Spacer()
        }
            .padding()
    }
}
DirectConformClass
DirectConformClass

ObservableObject, @Published, @StateObject の組み合わせで、値が変化した時に画面更新が行われます。

継承したクラスで プロパティに @Published を付与した時

モデルは、クラスなので、継承するときもあるかもしれません。しかし、継承したクラスで @Published を使用すると問題が発生します。

継承したクラスのプロパティに @Published を付与しても、値更新時に 画面更新がなされません。

Example

class DirectConformClass:ObservableObject {
    @Published var directProperty:Int = 0
}

class InheritedConformClass:DirectConformClass {
    @Published var indirectProperty:Int = 0
}

struct ContentView: View {
    @StateObject var object:InheritedConformClass = InheritedConformClass()
    
    var body: some View {
        VStack {
            Spacer()
            Text("DirectProperty \(object.directProperty)")
            Button(action: {
                object.directProperty += 1
            }, label: {
                Text("DirectProperty +1")
            })
            Spacer()
            Text("IndirectProperty \(object.indirectProperty)")
            Button(action: {
                object.indirectProperty += 1
            }, label: {
                Text("IndirectProperty +1")
            })
            Spacer()
        }
            .padding()
    }
}
InheritedConformClass
InheritedConformClass

InheritedConformClass も ObservableObject に 親クラスの DirectConformClass 経由で準拠しているはずですが、@Published を付与したプロパティの変更に対して画面更新がなされません。

ObservableObject は、子孫クラスに対して有効でない!?

設計意図は分かりませんが、ObservableObject に準拠したクラスを親クラスに持っていても、@Published は、有効とならないようです。

回避策

子クラスに改めて、ObservableObject 準拠を指定すると、親クラスですでに準拠しているというエラーとなります。

一番分かりやすい回避策は、該当プロパティを親クラスへ移動してしまうことです。

ただ、設計意図もあり、子クラスに持たせていると思いますので、移動が難しいことも多いはずです。
そのような時の回避策が以下です。

objectWillChange で個別に通知

親クラスは、ObservableObject に準拠しているため、子クラスからも objectWillChange を使うことができます。

ですので、子クラス側で以下のように定義することで、変更時の画面更新が行われます。

回避策

class InheritedConformClass:DirectConformClass {
    @Published var indirectProperty:Int = 0 {
        willSet {
            objectWillChange.send()
        }
    }
}
WorkAround
WorkAround

@Published は意味ないです

コードを見てわかる通り、indirectProperty の値変更検知は、willSet で行われているので @Published を付与している意味は無いです。

以下のことを想定して、付与しておくと良い気がします。

  • 将来的に、子クラスの @Published も 変更通知対象としてくれた時に向けた備忘録
  • 意図として、変更監視対象であることの明示

まとめ:親クラスが準拠している ObservableObject は、子クラスの @Published を更新通知対象としない

親クラスが準拠している ObservableObject は、子クラスの @Published を更新通知対象としない
  • 親クラスが ObservableObject に準拠していても、子クラスでの @Published 付きのプロパティは、変更監視対象とならない
  • 回避策1:該当プロパティを 親クラスへ移動する
  • 回避策2:該当プロパティの willSet で objectWillChange.send() を使い、自ら変更通知する

子クラスの @Published が無視される理由をご存知でしたら、教えてください _o_ → Bug でした。

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です