[SwiftUI] View アップデートパターン

SwiftUI2021

     

TAGS:

SwiftUI でアプリケーションを作る時の View – DataSource にパターンを列挙して 名付けてみました

環境&対象

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

  • macOS Ventura 13.1 Beta 2
  • Xcode 14.1
  • iOS 16.0

前提

データとビューの関係を整理してみます。

関係性の定義には、@State/@StateObject/@ObservedObject/@Published(/Combine) を使います。

やりたいこと

・データがあります。このデータは、”Source of Truth” であり、マスターデータです。
・データを変更したい人は何らかの方法で、このデータを変更します。
・ビューは、データを表示する時に使用されます。何らかの形でデータが渡されて、ビューがそれを表示します。
・”Source of Truth” は、View に最初に表示された後 変更されるかもしれませんので、表示後も “Source of Truth” の更新に合わせて View も更新することが必要となります。

想定される変化点

アプリケーションの作りによっては以下のような状況の違いがあるかもしれません。

・ビューは、”Source of Truth” の持ち主かもしれませんし、別の人が管理している “Source of Truth” を参照するだけかもしれません。
・データは、Value-type なデータかもしれませんし、Reference-type なデータかもしれません。
・データは、actor のように非同期でしか取得できないモデルで管理されているかもしれません。

@State-SourceOfTruth パターン

@State で定義する変数が、Source of Truth であるパターンです。

@State で持っている変数のデータが、Source of truth です。
アプリの状態を変更するのであれば、この変数(Source of Truth)を変更することになります。

SwiftUI は、@State の変更を検知して、その変数を使用している View を自動でアップデートしてくれます。

以下は、このパターンの例です。

Button の action で @State 変数を変更すると View がアップデートされています。

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/11/22
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var data = "Hello, world!"
    var body: some View {
        VStack {
            Text(data)
            Button(action: {
                data = String(data.reversed())
            }, label: { Text("Reverse")})
        }
        .padding()
    }
}

考察

text の宣言から、@State を削除するとコンパイルエラーになります。

これは、View は、struct で実装されているため mutable 指定しないと、struct 内部から自身を変更することができないからです。

SwiftUI では、View は、SwiftUI の判断で再構築されることがあるため、State で宣言されたデータは、View とは別のライフサイクルで管理されることになります。

@StateObject-SourceOfTruth パターン

StateObject で定義したデータが、Source of Truth であるパターンです。

@StateObject で持っているデータが、Source of truth です。
アプリの状態を変更するのであれば、この変数(Source of Truth)を変更することになります。

MEMO

@State は、Value 型変数にしか使えませんので、Reference 型の変数を使おうとすると、@StateObject が必要になります。

SwiftUI は、@StateObject の状態が変更されたことを検知すると、関連する View をアップデートしてくれます。

実際には、SwiftUI は、@StateObject のなかの @Published でマークされた変数の変更を検知して、その変数を使用している View を自動でアップデートしてくれます。

以下は、このパターンの例です。

Button の action で @StateObject の中の変数を変更すると View がアップデートされています。

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/11/22
//  © 2022  SmallDeskSoftware
//

import SwiftUI

class SourceOfTruth: ObservableObject {
    @Published var data = "Hello, world!"
}

struct ContentView: View {
    @StateObject var sourceOfTruth = SourceOfTruth()
    var body: some View {
        VStack {
            Text(sourceOfTruth.data)
            Button(action: {
                sourceOfTruth.data = String(sourceOfTruth.data.reversed())
            }, label: { Text("Reverse") })
        }
        .padding()
    }
}

考察

試してみると以下のように動作が変わることがわかります。
・SourceOfTruth クラス から @Published を削除すると、表示が更新されない
・View での SourceOfTruth 定義から @StateObject を削除すると、表示が更新されない

つまり、@StateObject, @Published の2つが組み合わされて View が更新されていることがわかります。

@StateObject は、ビューがそのオブジェクトに依存していることの宣言
@Published は、その変数が、外部から参照されていることの宣言
と考えると、わかりやすい気がします。

@State-asViewModel パターン

@State で定義する変数が、(ローカルな)ViewModel として使われるパターンです。

Source of Truth は、どこか別の場所に保持されています。
アプリの状態が変更された時には、別の場所の Source of Truth が変更されます。

何らかの契機で、Source of Truth の変更を @State で定義している変数に反映させます。

@State を更新されると SwiftUI は、依存している View を自動でアップデートしてくれます。

@State と Source of Truth との関連は、SwiftUI にはわかりませんので、自分で sync するコードを書く必要があります。

具体的には、”View が表示される時”、”Source of Truth が更新された時” にそれぞれ Source of Truth を State に反映することが必要となります。

“View が表示される時”は、onAppear で Source of Truth のデータを State 変数に反映させます。

“Source of Truth が更新された時”は、(Source of Truth の定義の仕方によりますが) onChange, onReceive 等で変更の通知を受け取り、State 変数に反映させます。

以下の例では、onAppear で Source of Truth からのデータを @State に設定し、外部の Source of Truth が ObservableObject に準拠していて対象の変数が @Published 宣言されているので、onReceive で Source of Truth からの変更通知を受け取り、State 変数に反映しています。

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/11/22
//  © 2022  SmallDeskSoftware
//

import SwiftUI

class SourceOfTruth: ObservableObject {
    @Published var data: String = "Truth"

    func changeSomething() {
        data = String(data.reversed())
    }
}


struct ContentView: View {
    @StateObject var sourceOfTruth = SourceOfTruth()
    @State private var localViewModel = "Hello, world!"
    var body: some View {
        VStack {
            Text(localViewModel)
            Button(action: {
                sourceOfTruth.changeSomething()
            }, label: { Text("Change in Source of Truth")})
        }
        .padding()
        .onAppear {
            localViewModel = sourceOfTruth.data
        }
        .onReceive(sourceOfTruth.$data) { newValue in
            localViewModel = newValue
        }
    }
}

考察

onAppear 部分を消してしまうと、最初に表示された時に State 変数に Source of Truth が反映されない状況になります。
onReceive 部分を消してしまうと、ボタン押下による Source of Truth の変更が、View に反映されない状況になります。

@StateObject-asViewModel パターン

@StateObject で定義する変数が、(ローカルな)ViewModel として使われるパターンです。

MEMO

思考実験として作っていますが、現実的には 採用されにくいパターンだと思います。

View 向けに装飾するロジックが複雑な時には、このようなパターンを採用するのも有効になるかもしれません。

Source of Truth は、どこか別の場所に保持されています。
アプリの状態が変更された時には、別の場所の Source of Truth が変更されます。

何らかの契機で、Source of Truth の変更を @StateObject で定義している変数に反映させます。

@StateObject を更新されると SwiftUI は、依存している View を自動でアップデートしてくれます。

@StateObject と Source of Truth との関連は、SwiftUI にはわかりませんので、自分で変更を反映するようなコードを書く必要があります。

具体的には、”View が表示される時”、”Source of Truth が更新された時” にそれぞれ Source of Truth を StateObject に反映することが必要となります。

“View が表示される時”は、onAppear で Source of Truth のデータを State 変数に反映させるのが簡単です。

“Source of Truth が更新された時”は、(Source of Truth の定義の仕方によりますが) onChange, onReceive 等で変更の通知を受け取り、State 変数に反映させます。

以下の例では、外部の Source of Truth が ObservableObject に準拠していて、対象の変数が @Published 宣言されているので、onReceive で変更通知を受け取り、StateObject 変数に反映しています。
ViewModel としても ObservableObject に準拠したクラスを使用して、View の自動更新を可能にしています。

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/11/22
//  © 2022  SmallDeskSoftware
//

import SwiftUI

class SourceOfTruth: ObservableObject {
    @Published var data: String = "Truth"

    func changeSomething() {
        data = String(data.reversed())
    }
}

class ViewModel: ObservableObject {
    @Published var data: String = "Hello, world!"
}


struct ContentView: View {
    @StateObject var sourceOfTruth = SourceOfTruth()
    @StateObject var viewModel = ViewModel()
    var body: some View {
        VStack {
            Text(viewModel.data)
            Button(action: {
                sourceOfTruth.changeSomething()
            }, label: { Text("Change in Source of Truth")})
        }
        .padding()
        .onAppear {
            viewModel.data = sourceOfTruth.data
        }
        .onReceive(sourceOfTruth.$data) { newValue in
            viewModel.data = newValue
        }
    }
}

Button の action で Source of Truth を変更し、その後 Source of Truth -> ViewModel -> View と順番に変更が伝わっていきます。

@ObservedObject パターン

SwiftUI の View が @ObservedObject で状態を参照している。その状態の変更に応じて、View が 更新されるパターン。

このパターンでは、ObservedObject が Source of Truth であるのか、ViewModel であるのかに分かれます(が、SwiftUI の View にとっては影響ありません。)

@ObservedObject-SourceOfTruth パターン

@ObservedObject として参照しているデータが、View にとっての 表示対象データです。

それが、Source of Truth であるときのパターンです。
アプリの状態が変更されたのであれば、この変数が変更されることになります。

実際には、SwiftUI は、@ObservedOject のなかの @Published でマークされた変数の変更を検知して、その変数を使用している View を自動でアップデートしてくれます。

最初に、ObservedObject が Source of Truth である例です。

Button の action で Source of Truth の中の変数を変更すると View がアップデートされています。Source of Truth は、StateObject として App レベルで定義されています。

@main
struct ViewUpdatePatternsApp: App {
    @StateObject var sourceOfTruth = SourceOfTruth()
    var body: some Scene {
        WindowGroup {
            ContentView(sourceOfTruth: sourceOfTruth)
        }
    }
}

class SourceOfTruth: ObservableObject {
    @Published var data: String = "Truth"

    func changeSomething() {
        data = String(data.reversed())
    }
}

struct ContentView: View {
    @ObservedObject var sourceOfTruth: SourceOfTruth
    var body: some View {
        VStack {
            Text(sourceOfTruth.data)
            Button(action: {
                sourceOfTruth.changeSomething()
            }, label: { Text("Change in Source of Truth")})
        }
        .padding()
    }
}

考察

Source of Truth を直接参照しているので、onAppear や onReceive 等での更新が不要になっています。
コード的には、@StateObject-SourceOfTruth とほとんど同じです。SourceOfTruth の所有者が自分であるかどうかという違いです。

@ObservedObject-asViewModel パターン

@ObservedObject として参照しているデータが、View にとっての 表示対象データです。

それが、Source of Truth では”ない”ときのパターンです。

アプリの状態が変更されたのであれば、Source of Truth が変更されます。その後、この変数が変更されることになります。
(ここでは、ObservedObject 自身が、Source of Truth の変更を検知してアップデートしている前提です。)

@main
struct ViewUpdatePatternsApp: App {
    @StateObject var sourceOfTruth = SourceOfTruth()
    var body: some Scene {
        WindowGroup {
            ContentView(sourceOfTruth: sourceOfTruth)
        }
    }
}

class SourceOfTruth: ObservableObject {
    @Published var data: String = "Truth"

    func changeSomething() {
        data = String(data.reversed())
    }
}

class ViewModel: ObservableObject {
    @Published var data: String = "Hello, world!"
    var cancellable: AnyCancellable? = nil
    func setSoT(_ sot: SourceOfTruth) {
        cancellable =  sot.$data
            .sink { newValue in
                self.data = newValue
            }
    }

}

struct ContentView: View {
    @ObservedObject var sourceOfTruth: SourceOfTruth
    @ObservedObject var viewModel: ViewModel
    var body: some View {
        VStack {
            Text(viewModel.data)
            Button(action: {
                sourceOfTruth.changeSomething()
            }, label: { Text("Change in Source of Truth")})
        }
        .padding()
        .onAppear {
            viewModel.setSoT(sourceOfTruth)
        }
    }
}

考察

onAppear で ViewModel が Source of Truth の変更に追従して自身を更新するように設定し、Source of Truth -> ViewModel へと変更を伝播するようにしています。

上記の例では、View の onAppear で Source Of Truth -> ViewModel の変更通知設定を行っていますが、View の外部で行われることもありそうです。

actor とのやりとり

今後、データの整合性を保つために、actor を導入するケースも増えると考えられます。

Source of Truth が actor になったときにどうなるかを考えてみました。

MEMO

struct 定義した Source of Truth が actor になる必要はない/なることはできないので、Source of Truth が struct 相当のケースは対象外です。

StateObject-SourceOfTruth パターン

actor のプロパティへのアクセスは、async になります。つまり、View に表示するデータを同期的に取得できず困ります。
例えば、State-asViewModel パターンに変更するなどして、ViewModel 的な要素を解する必要があります。

State-asViewModel パターン

actor と相性が良い(?) です。非同期でしか取得できない場合、取得できるまでに表示するダミーのようなデータが必要となります。

StateObject-asViewModel パターン

State-asViewModel パターンと似ています。
actor であっても、特に困りません。ダミーデータが必要となる点も同じです。

ObservedObject-SourceOfTruth パターン

StateObject-SourceOfTruth パターンと同じです。表示データを async で取得しなければいけないので、困ります。

ObservedObject-asViewModel パターン

State-asViewModel, StateObject-asViewModel と似ています。相性良いです。

ポイントは、actor からのデータ取得が非同期(async) に変わる点です。

View は、要求した時点で なんらかの表示データが必要なので、await するという選択肢はありません。

つまり Source of Truth が actor である時には、ViewModel 的な要素が必要になります。

注意:ネストしているモデルは別の検討が必要です

複雑なモデルになってくると、ObservedObject をネストさせて、その中のプロパティに、Published をつけたくなります。

ですが、SwiftUI が変更を検知できるのは @ObservedObject が付与されたクラスが直接持っている @Published 指定されたプロパティだけです。

ネストしたデータ構造とビューの関係は、きちんと設計/検討することが必要です。

まとめ

Source of Truth と View との関係まとめ

Source of Truth と View との関係まとめ
  • Value-type は、@State を使う
  • Reference-type は、@StateObject/@ObservedObject を使う
  • Source of Truth を直接参照すると、既存の更新の仕組みで View がアップデートされる
  • ViewModel を導入した時には、Source of Truth と同期する仕組みが必要
  • 同期するには、onAppear/onChange/onReceive 等を使用する
  • actor からは、非同期(async) にしか取得できないので、ViewModel (的な要素が)必須

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

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

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