【SwiftUI】ObservableObjectについて(クラス、入れ子、配列など)

SwiftUI

SwiftUIのObservableObjectはViewの再描画に便利ですが、
思ったように動かない事が結構あるのでまとめました。

ObservableObjectとは

ざっくり言うと@Publishedを指定した値を監視し、
Viewを更新してくれる機能です。

流石にこの認識だと詰まるのでもう少し詳しく解説します。
分かっている人は読み飛ばして下さい。

ObservableObjectはプロトコルでありクラスに継承して使用します。
@Publishedを指定した変数の値を監視し、変更された際にViewを再描画します。
再描画を行う為にはView側にObservedObjectまたはEnvironmentObjectとして渡さなければなりません。
上記の指定をしない場合は通常のクラスと同じ扱いで再描画が行われません。

単純なサンプル

struct ObservableObjectTestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(EnvironmentModel())
        }
    }
}

struct ContentView: View {
    
    @ObservedObject var obsModel = ObserveModel()
    @EnvironmentObject var envModel: EnvironmentModel
    
    var body: some View {
        VStack {
            Text("ObsCount:\(self.obsModel.count)")
                .padding()
            Button(action: {
                self.obsModel.count += 1
            }, label: {
                Text("ObsCount Up")
            })
            
            Text("EnvCount:\(self.envModel.count)")
                .padding()
            Button(action: {
                self.envModel.count += 1
            }, label: {
                Text("EnvCount Up")
            })
        }
    }
}
class ObserveModel:ObservableObject{
    @Published var count = 0
}

class EnvironmentModel:ObservableObject{
    @Published var count = 0
}

カウントアップするだけの簡単なサンプルです。
Buttonに再描画を行うコードは書いて居ませんが、
Publishedの変数の値が変わると自動的に再描画されます。非常に便利ですね。

またObservedObjectやPublishedを外すと再描画がされなくなるのが分かると思います。
もちろんカウントアップは行われるので、何かのきっかけで再描画がかかると表示が変わります。
@ObservedObjectを外してObsCount Upを押した後にEnvCount Upを押すと分かりやすいと思います。

配列

Publishedに配列を使用するとどうなるか?
答えはViewの再描画は「行われる」です。

Swiftの配列は値型です。
つまり配列に変更があると値が変わると言うことになります。
なので再描画は行われます。

クラス

変数にクラスを持たせている場合です。
変数の「値」が変わると言うことが重要になって来ます。

クラス型の変数が持っているのは参照先です。
つまり変数に代入が行われ、参照先が変われば再描画は「行われます」。

しかし、参照先の中身がいくら変化しても、
変数が持っている値(参照先)は変更されていないので再描画は「行われません」。

この辺りから引っかかって思い通りに動かなく感じる様になってくると思います。

入れ子

ではObservableObjectにObservableObjectを入れてしまえば良いのではないか?
これは再描画されません。

あくまでもObservedObject、EnvironmentObjectで受け取ったViewが再描画されます。
Publishedで受け取ったクラスは上記ではないのでダメなのです。

Viewに渡す時に親の方も子の方も両方渡して下さい。

ありがちなパターン

実際のアプリ制作では、
ObservableObjectにObservableObjectの配列を持たせるなんて場合はよくあります。
こういった場合はどのように動くのか?どうすれば良いのか?

まずはダメなパターンです

struct ListView: View {
    
    @ObservedObject var listModel = ListModel()
    
    var body: some View {
        VStack {
            List {
                ForEach(self.listModel.listItems){ item in
                    Text(item.text)
                        .foregroundColor(item.glayOut ? .gray:.black)
                        .onTapGesture {
                            item.glayOut.toggle()
                        }
                }
            }
            Button(action: {
                self.listModel.listItems.append(ListItemModel("Amazon"))
            }, label: {
                Text("Add Item")
            })
        }
    }
}
class ListModel:ObservableObject{
    @Published var listItems = [ListItemModel("Google")
                                ,ListItemModel("Apple")
                                ,ListItemModel("Facebook")]
    
}

class ListItemModel:ObservableObject,Identifiable{
    @Published var glayOut = false
    let id = UUID()
    let text:String
    
    init(_ text:String){
        self.text = text
    }
}

この場合は、Add Itemボタンでは再描画されますが、
onTapGestureでは再描画されません。

ListViewがObservedObjectで受けて取っているのはListModelだけです。
なのでlistItemsの変化しか検知されません。
listItemsの追加は配列自体に変化が起きるので再描画が起きますが、
ListItemModelの中身の変化ではlistItems自体には変化が起きません。

では、どうすれば良いのか?
答えは、適切なView分割をして必要なObservableObjectを渡す事です。
これは無駄な再描画を省き、処理を軽減することにも繋がります。

struct ListView: View {
    
    @ObservedObject var listModel = ListModel()
    
    var body: some View {
        VStack {
            List {
                ForEach(self.listModel.listItems){ item in
                    ListItemView(listItem: item)
                }
            }
            Button(action: {
                self.listModel.listItems.append(ListItemModel("Amazon"))
            }, label: {
                Text("Add Item")
            })
        }
    }
}


struct ListItemView:View {
    
    @ObservedObject var listItem:ListItemModel
    
    var body: some View{
        Text(self.listItem.text)
            .foregroundColor(self.listItem.glayOut ? .gray:.black)
            .onTapGesture {
                self.listItem.glayOut.toggle()
            }
    }
}

これで上手く動く様になりました。

Viewを分けることで、それぞれで必要なデータをObservedObjectで受けることが出来ました。
ListModelはListViewを再描画し、ListItemModelはListItemViewを再描画します。

また、ListItemModelが変化した際にはListItemView一つだけが再描画されます。
ListItemModelの変化だけでListModelの再描画が動いてしまっては、
無駄にリスト全てが再描画されてしまいます。

objectWillChange

自動で再描画してもらうのが良いとは思いますが、
実際にはクラスやViewの構成を変えたくない場合や、
再描画を抑えて自分で制御したい場合もあります。

そんな場合に使うのがobjectWillChange.send()です。
このメソッドを呼ぶと、値が変わった時の様に再描画がかかります。

先程の例ではこのようにする事で再描画ができます。

struct ListView: View {

    @ObservedObject var listModel = ListModel()

    var body: some View {
        VStack {
            List {
                ForEach(self.listModel.listItems){ item in
                    Text(item.text)
                        .foregroundColor(item.glayOut ? .gray:.black)
                        .onTapGesture {
                            item.glayOut.toggle()
                            self.listModel.objectWillChange.send() //追加
                        }
                }
            }
            Button(action: {
                self.listModel.listItems.append(ListItemModel("Amazon"))
            }, label: {
                Text("Add Item")
            })
        }
    }
}

ただし、入れ子にした場合などに無闇に親のobjectWillChangeを呼ぶと、
本来は子の部分の再描画だけ良い箇所で、無駄に親部分の再描画がかかる事もあるので、
気をつけて使いましょう。
基本的にはPublishedでの再描画で済むように書いた方が良いと思います。

余談

沢山のViewに分割した際に便利なのがEnvironmentObjectです。
一度セットすると、それ以降の子Viewで使うことが出来ます。
ObservedObjectと違いView毎に引数で渡す必要がありません。

ただし、1つのViewで同一クラスを複数使うことは出来ないのなどの制約はあります。

最後に

ObservableObjectの仕様としては、
値の変化の検知や再描画が限定的になるように出来ている様です。

ObservableObjectを入れ子にした際に
値の変化が伝播する様にも作れたのではないかとは思います。

しかし、色々試してみると再描画が抑えられ、
パフォーマンスが良くなる様に出来ている様に感じられます。
またViewを分割することでコードの見通しがよくなったり、
それぞれのViewの責任が分担しやすくなっているのではないでしょうか?

私は言語やフレームワーク設計から、こう言った事が見えてくる時にとても面白く感じます。

コメント

タイトルとURLをコピーしました