[SwiftUI] [Combine] Observable/@Published でも ビューが更新されない時

SwiftUI

     
⌛️ < 1 min.

# 自分向けに、思考の過程を記録

ObservableObject と @Published

変更に気づくための仕組みとして、ObservableObject と @Published の組み合わせがあります。

ObservableObject なモデル


class ModelClass:ObservableObject {
    @Published var value:Int = 0
}

上記のようなクラス定義を元に、以下のビューを定義するとします。

ビュー定義


struct ContentView: View {
    @ObservedObject var modelClass:ModelClass
    var body: some View {
        print("body evaluated")  // - body が評価されるタイミングを見るために入れてます
        return VStack {
            Text("Value: \(modelClass.value)")
            Button(action: {
                modelClass.value += 1
            }, label: {
                Text("+1")
            })
        }
        .padding()
    }
}

@Published と付与しているプロパティ value を変更すると、変更されたという通知が送られてきてビューが再描画されます。

ここまでは、非常にシンプルなので、SwiftUI のサンプルにも頻繁に登場してきます。

ネストしたら、通知はこない

わかったつもりになって、アプリケーションの作り込みを始めると、画面が更新されないケースが頻発します。

通知が来ないケース

以下は、通知が来ないケースです。

Nest された ObservableObject なモデル


class SubModelClass:ObservableObject {
    @Published var subValue:Int = 0
}

class ModelClass:ObservableObject {
    @Published var value:Int = 0
    @Published var subModel:SubModelClass = SubModelClass()
}

ビューを以下のようにします。

ビュー定義


struct ContentView: View {
    @ObservedObject var modelClass:ModelClass
    var body: some View {
        print("body evaluated")
        return VStack {
            Spacer()
            Text("Value: \(modelClass.value)")
            Button(action: {
                modelClass.value += 1
            }, label: {
                Text("+1")
            })
            Spacer()
            Text("Value: \(modelClass.subModel.subValue)")
            Button(action: {
                modelClass.subModel.subValue += 1
            }, label: {
                Text("+1@subValue")
            })
            Spacer()
        }
        .padding()
    }
}

動かしてみると、SubValue の値を更新しても、画面は更新されません。

画面がうまく更新されないと悩むケースを単純化するとこのケースが多いと思います。

通知が来るケース/通知が来ないケースを考察

うまく更新通知が来るケースを文字で説明してみると以下のようになります。

ObservableObject で定義したクラスを ObservedObject で受けています。更新を検知したい対象のプロパティには、@Published が付与されているので、変更されると通知されます。

うまく更新通知が来ないケースを改めて見てみると以下のケースです。

ObservableObject で定義したクラスを ObservedObject で受けているところは同じです。その中のプロパティが、リファレンスタイプで、さらに内部にプロパティを持っています。その内部のプロパティが変更された時に通知されることを期待して、ObservableObject をクラスに付与し、@Published をプロパティに付与しています。

うまくいかないケースでは、@Published が2つ登場することがポイントで、さらに、「誰が Publisher を Subscribe しているか」が次のポイントです。

通知がくるケースをもう少し考察

ビューは、@ObservedObject と宣言することで ModelClass の @Published 指定されたプロパティ を Subscribe しています。
Value は、Int であり、変更されるとそのまま検知できますので、Publisher が通知してくれます。

(@Published は、Publisher を作る Property Wrapper です。@ObservedObject というプロパティラッパーを付与することで、この Publisher を subscribe するようになっています。)
[SwiftUI][Combine] @Published は、Publisher を提供する Property Wrapper

通知が来ないケースをもう少し考察

ビューは、@ObservedObject と宣言することで ModelClass のプロパティ を Subscribe していることは同じです。
プロパティ subModel:SubModelClass は、リファレンスタイプであり、その内側(リファレンス先)で変更されても変更が検知できません。そのため 変更通知が発生しません。

SubModelClass も、ObservableObject に準拠させ、プロパティに @Published つけているので、通知してくれても良い気になってくるのですが、Swift 的には、無関係です。
SubModelClass の プロパティ subvalue を subscribe したければ、SubModelClass を subscribe してくれないと通知することはできないのです。

なお、ボタンのアクションの中で、明示的にクラスインスタンスを新しい値で再生成すると画面は更新されます。なぜならば、subModel そのものが更新されたからです。

では、どうすれば?

解決策1:あまりスマートでない

View が ObservedObject として持っているクラスで @Published 指定されているプロパティそのものを、新規インスタンスに置き換えると更新されます。(上記の subModel を入れ替えたケースです)

# スマートな解決策ではありませんが、特定のケースでは QuickHack になるかもしれません。

解決策2:ビューの対応関係を見直す

@ObservedObject/Observable + @Published の組み合わせ自体はきちんと動作します。問題は、ネストされている点です。

問題を少し離れてみると、「現在ビューが受け取っているデータは、ビューが受け取るべき適正な大きさなのか?」という疑問に行き着くはずです。
ネストされているくらいなので、大きな/複雑なデータのはずですが、1つのビューが表示すべきデータがそこまで大きく/複雑 であるということは、ビュー設計の見直しを考慮すべきということに繋がります。

例えば先ほどの例でも、ネスト先のデータを表示するビューには、ネスト先のモデルだけを渡せば良いはずです。

以下は、ビューの単位と渡すデータを見直したものです。

見直したビュー


struct ContentView: View {
    @ObservedObject var modelClass:ModelClass
    var body: some View {
        print("body evaluated")
        return VStack {
            Spacer()
            Text("Value: \(modelClass.value)")
            Button(action: {
                modelClass.value += 1
            }, label: {
                Text("+1")
            })
            Spacer()
            SubModelView(subModel: modelClass.subModel)  // - ModelClass ではなく SubModelClass を渡す。SubModelClass も ObservableObject
            Spacer()
        }
        .padding()
    }
}

struct SubModelView:View {
    @ObservedObject var subModel:SubModelClass  // ObservedObject の対象は、SubModelClass
    var body: some View {
        Text("Value: \(subModel.subValue)") // SubModelClass の subValue は、@Published
        Button(action: {
            subModel.subValue += 1
        }, label: {
            Text("+1@subValue")
        })

    }
}

上記であれば、SubModelClass の値を変更した時に、その値を参照しているビューに変更が通知されます。

通常、ビューが更新されるべき時は自分が表示しているデータが更新された時なので、そのデータを直接持つことは自然な設計のはずです。

もう一歩すすめて:ObservableObject/@Published 指定する/しないをどう判断するかの考察

先の例で、値の更新によってビューが更新されることを防ぎたい時には、ビューの中での @ObservedObject 指定を外すことで、更新されなくなります。

このように ビューの側で 振る舞いを調整(変更に応じて更新するかしないか)は非常に簡単にできます。しかし、モデル側で ObservableObject や @Published が指定されていないと ビュー側で調整できる余地はありません。

ですが、モデル定義の時に、その値の変更に対して ビューが更新されるべきかどうかを判断することは難しいです。つまり、思わぬデータがビュー更新のトリガーになるかもしれないということです。
ということは、データ層の全ての要素について ObservableObject を付与し、全てのプロパティについて @Published を付与しておくことは、合理的な気がします。

更新を無視したいビューは、ObservedObject で受けなければ良いだけです。

このことを考えているところで、CoreData の NSManagedObject は、ObservableObject に 準拠していることを思い出しました。

CoreData での対応も含めて考えると、データ層のクラスを ObservableObject にして、プロパティには @Published を付与しておくのが良さそうです。

まとめ:Observable/@Published でも ビューが更新されない時

Nest した Observable は 変更通知を 伝播してくれない
  • ObservableObject 内での @Published/ObservableObject の nest は、SwiftUI は考慮してくれない
  • View で変更通知を受けたいオブジェクトを @ObservedObject で受ける(そのその側の ObservableObject を受けても更新通知はこない)
  • Model には、ObservableObject, @Published をつけておくと吉。
  • 注:ObservableObject は、クラスにしか付与できません。@Published もクラスのプロパティにしか付与できません。

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

コメントを残す

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