【SwiftUI】TabView の selection: Int を使う際の注意点(@ObservedObject と @StateObject の違い)

TabView の選択中のタブを取得・設定するには、以下のように selection: Int プロパティを利用します。

@State var selection: Int = 0
var body: some View {
    TabView(selection: $selection) {
        HomeView().tabItem {
            Text("ホーム")
        }.tag(0)
        MyPageView().tabItem {
            Text("マイページ")
        }.tag(1)
        SettingsView().tabItem {
            Text("設定")
        }.tag(2)
    }
}

これだけであれば特に問題は起きません。

実際のアプリではもう少し画面要素もデータの持ち方も複雑になり、TabView の 各View の画面プロパティは、ViewModelPresenter などの ObservableObject プロトコルに準拠した class を定義し、View と紐づけることが多いかと思います。

TabView の selection を使用する時に、この ViewModel(or Presenter)の持ち方を注意しなければならない場合があります。

ポイントは @ObservedObject@StateObject の理解にあります。

筆者がハマってしまった実例を元に解説していきます。

@ObservedObject と @StateObject の違い

先ず、@ObservedObject と @StateObject の違いを確認しましょう。

@ObservedObject は親Viewのプロパティに更新があるとリセットされるのに対して、@StateObject は値を保持したままになります。

検証例

例えば、下記の例ですと、親Viewの counter が変化すると、@ObservedObject の counter は 0 に戻り、@StateObject の counter は保持されたままになります。

以下のコードは実際に動作するので試してみて下さい。

struct ContentView: View {
    
    @State var counter = 0
    
    var body: some View {
        VStack {
            VStack {
                Button("タップしてカウント(親View)") {
                    counter += 1
                }
                Text("親ViewのCount:\(counter)")
            }
            ChildView(observedViewModel: .init(), stateViewModel: .init())
        }
    }
}

struct ChildView: View {
    
    @ObservedObject var observedViewModel: ViewModel
    @StateObject var stateViewModel: ViewModel
    
    var body: some View {
        VStack {
            Button("タップしてカウント(@ObservedObject)") {
                observedViewModel.counter += 1
            }
            Button("タップしてカウント(@StateObject)") {
                stateViewModel.counter += 1
            }
            Text("@ObservedObjectのCount:\(observedViewModel.counter)")
            Text("@StateObjectのCount:\(stateViewModel.counter)")
        }
    }
}

class ViewModel: ObservableObject {
    @Published var counter = 0
}

この違いを良く理解していなかったが為に、TabView の selection を使用した際に問題が発生し、原因究明に時間を要してしまいました。

@ObservedObject か @StateObject のどちらを適用するかよく考えよう

前述の通り、@ObservedObject は 親View の変更によってリセットされ、@StateObject は保持されます。

そのため、親View の変更によって子Viewが再描画される際にデータをリセットされてよい、若しくはリセットして欲しい時@ObservedObject を、リセットされてほしくない場合@StateObject を選択することになります。

この特性から、TabView の selection に ViewModel の @Published プロパティを紐づけると、タブの選択時に selection の値が変化するため、各タブの子View に影響を与えます

当初、子View の ViewModel を @ObservedObject としていたのですが、タブを切り替えると処理が途中で終わってしまい、画面が描画されないという現象に悩まされました。

この時の原因は、onAppear() で Modelクラスがデータのロードを開始した後に、ViewModel のイニシャライザーで Model クラスのインスタンスが再作成され、ロードが完了した際に 既に解放されてしまった Model インスタンスが ViewModel に応答を返そうとしたところ nil 参照で処理が中断したためでした。

あくまで一例ですが、大体以下のような流れで発生しました。

子View の処理の流れ

View が ViewModel を @ObservedObject で保持し、ViewModel は Model を持ち、Model は weak で ViewModel を参照しています。

struct HomeView: View {
    @ObservedObject var viewModel: HomeViewModel
    var body: some View {
        〜
    }
    .onAppear() {
        viewModel.viewAppear()
    }
}

class HomeViewModel: ObservableObject {
    var model: HomeModel
    init() {
        model = HomeModel()
        model.viewModel = self
    }
    
    func viewAppear() {
        model.fetchData()
    }

    func didFetchData() {
        〜
    }
}

class HomeModel {
    weak var viewModel: HomeViewModel?

    func fetchData() {
        DataRepository.fetch(
            fetched: { [weak self] data in
                self?.viewModel?.didFetchData()
            }
            error: {〜}
        )
    }
}

タブが選択され、View の再描画により、onAppear() が呼ばれ、ViewModel の viewAppear() から Model の fetchData() がコールされます。

しかし、その後、ViewModel がリセットされ、init() で Model が再生成されます。

データのフェッチが成功し、Model から ViewModel へ didFetchData() で応答を返そうとしますが、fetchData() の呼び出し元の Model インスタンスは解放されてしまっているので [weak self] が nil となっており、didFetchData() を呼び出す前に処理が終わってしまった、という流れでした。

ViewModel を @StateObject にして解決

結論としては、ViewModel を @ObservedObject から @StateObject に変更し ViewModel と Model の再生成を防ぐことで問題は解決しました。

@StateObject var viewModel: HomeViewModel

しかし、@ObservedObject 自体が悪いわけではありません。

データの保持が必要ない場合は @ObservedObject として保持している方がメモリ使用の観点からもスマートです。

アプリの仕様や画面の設計によって、@ObservedObject とするか @StateObject とするかはよく検討する必要があります

今回は TabView の selection の利用とデータの持ち方が起因の不具合でしたが、様々な場面で起こりうる問題かと思います。

以上