🍱

SwiftUIの状態管理の基礎

2022/05/09に公開

概要

2019年のWWDCにおいてSwiftUIが発表されてから約3年が経過し、プロダクトにSwiftUIを導入する方も増えてきたのではないでしょうか。そしてSwiftUIベースのアプリケーション実装において、状態管理をどうするか、という問題があると思います。本稿では私が開発しているアプリケーションにおいて状態管理についてどのように考えているかについて紹介していきます。

状態・状態管理とは

SwiftUIのような宣言的シンタックスを採用しているフレームワークにおいて、大きな関心ごとの一つに状態管理があります。そこでまずは状態状態管理についての定義を確認し、重要な概念であるSingle source of truthについても触れていきます。

状態

状態とは、UIを(再)構築するために必要なデータのことを指しています。
SwiftUIの場合、Textの引数にString型のデータを渡すことで、画面に任意の文字列が表示されるようになります。そしてTextに渡したデータが更新されると、画面の表示も自動で更新されます。

状態には大きく分けて2種類あります。
一つはLocal State、もう一つはShared Stateです。
これらは状態の参照スコープの違いです。

Local State

Local Stateは、複数の画面間で状態を共有しないなど、参照スコープが閉じた状態(データ)のことを指します。例えば以下のisPlayingPlayButtonの内部でしか参照されないので、Local Stateと言えます。

Local State
struct PlayButton: View {
  @State private var isPlaying: Bool = false

  var body: some View {
    Button(isPlaying ? "Pause" : "Play") {
      isPlaying.toggle()
    }
  }
}

Shared State

Shared Stateとは、複数の画面間で状態を共有するなど、参照スコープが広い状態(データ)のことを指します。以下のUserアクターのインスタンスは複数画面で共有されているので、Shared Stateと言えます。

Shared State
@MainActor
final class User: ObservableObject {
  @Published var isLogin = false
}

struct MyPage: View {
  @ObservedObject var user: User
  var body: some View {
    Button {
      user.isLogin.toggle()
    } label: {
      Text(user.isLogin ? "ログアウト" : "ログイン")
    }
  }
}

struct TopPage: View {
  @ObservedObject var user: User
  var body: some View {
    Text(user.isLogin ? "ログイン済み" : "未ログイン")
  }
}

状態管理

状態管理とは、状態の整合性が破綻しないように管理することです。
例えば旅館を予約して予約一覧画面では予約済みのステータスになっているのに、予約確認画面では予約済みになっていない場合、これは状態の整合性が取れておらず、ユーザーを困惑させてしまいます。このような問題を起こさないために状態管理は重要となってきます。

Single source of truth

ここまで状態・状態管理について見てきましたが、これらは 信頼できる唯一の情報源 (Single source of truth: SSOT) という情報システム理論の原則に関連しています。
SSOTとは、すべてのデータが1箇所でのみ作成、編集されるようにすることです。この原則に従うことでデータ(状態)の整合性が破綻せずに、それは信頼できるデータになります。

つまり状態を管理する上で、状態がSSOTであるかどうか意識することが重要です。そしてSSOTになっていれば、ユーザーに間違った情報を伝えてしまう可能性を低くすることができます。

Property Wrapper

SwiftUIは状態管理に関する機能をいくつか提供しており、実装する上でそれらを使い分けることが重要になってきます。まずは@Stateについて見ていきます。

@State

SwiftUIで基本的なProperty Wrapperです。
@Stateでマークしたプロパティは、信頼できる情報源になります。
@StateでマークするとSwiftUIがストレージの管理を引き継ぎ、値を読み書きする手段を提供してくれます。なので@Stateでマークしたインスタンスは値自身ではありません。インスタンスを参照した際はwrappedValueプロパティの値が返されます。

ObservableObject

@Stateと同様に状態を管理する機能として、ObservableObjectがあります。
@Stateと異なる点として、ObservableObjectはprotocolであり、参照型のみ準拠することができます。ObservableObjectは信頼できる情報源を作成し、変更にどう対応するかSwiftUIに教えています。

またもう一つ重要なのが@Publishedです。
@PublishedPublisherを公開することで、プロパティを観測可能にするProperty Wrapperです。
Viewとデータの同期についてはSwiftUIが行っているので、実装者は気にする必要はありません。
SwiftUIは変更しようとしている時を知り、すべての変更を1回の更新にまとめます。

SwiftUIにはObservableObjectへの依存関係を作成するために、Viewで使用できるProperty Wrapperが3つあります。

@ObservedObject

@ObservedObjectでプロパティをマークすると、プロパティの変更を監視するようSwiftUIに通知します。またそのプロパティは信頼できる情報源となり、ViewがUIの構築に必要なデータを定義しています。特徴として@ObservedObjectは提供されるインスタンスの所有権を取得するわけではないので、ライフサイクルの管理責任は実装者にあります。そのため複数Viewでデータを共有したい場合に有効です。

しかし@ObservedObjectViewのライフサイクルに紐付かないため、Viewが破棄されたあとも生存し続けてしまいます。あるビューの生存期間のみインスタンスを生かしたいというようなこともあると思います。

@StateObject

そこで@StateObjectを使用します。@StateObjectでプロパティにマークするときは初期値を指定します。するとSwiftUIは初回にbodyを実行する直前にその値をインスタンス化し、Viewのライフサイクル全体でオブジェクトを存続させます。そして@StateObjectでマークしたインスタンスは信頼できる情報源となります。
Viewが不要になると、SwiftUIは@StateObjectでマークしたインスタンスをリリースします。この特性を生かしてルートのView@StateObjectでマークすると、そのインスタンスがアプリのライフサイクルに紐づくグローバルな状態とすることもできます。

@EnvironmentObject

SwiftUIではViewが非常に低負荷なので、理解しやすく再利用しやすい小さなViewを作成することをおすすめしています。ただそうなると階層が深くなり、@ObservableObjectを離れたサブビューに渡すのが面倒になってしまいます。この問題を解決するのが@EnvironmentObjectです。

@EnvironmentObjectを使わない場合 @EnvironmentObjectを使う場合

上記のように@EnvironmentObjectを使わない場合、データを必要としないViewにも渡すことは面倒であり、多くのボイラープレートを生んでしまいます。

ObservableObjectを注入したい親ビューでenvironmentObjectModifierを使用し、
特定のObservableObjectを参照したいすべてのViewで@EnvironmentObjectを使用します。
すると@EnvironmentObjectでマークした箇所に依存関係を作成し値を渡します。そしてObservableObjectに変更が発生した場合、値の変更を追跡するようになります。

@StateとObservableObjectの使い分け

ともに状態管理するための機能として紹介しましたが、これらはどのように使い分けることができるのでしょうか。
私が考える一つの指針としてLocal StateShared Stateかどうかです。
Local Stateの管理には@State、Shared Stateの管理にはObservableObjectを使います。

Local Stateは他のViewから参照されないため、状態のライフサイクルはViewのライフサイクルに紐づけたいです。@Stateでマークした状態はViewのライフサイクルに紐づくため適しています。

Shared Stateは複数のViewが状態を共有するため、状態のライフサイクルはViewのライフサイクルとは切り離したいです。ObservableObjectに準拠するものは参照型であることが制約として定義されています。参照型はメモリ領域のアドレスがプロパティに格納されるため、状態が変更されるとプロパティを参照している他の箇所の状態にも影響を与えます。そのためObservableObjectで状態を共有するには適しています。

参照型の例
class Foo {
  var value: Int = 0
}
var a = Foo()
var b = a
a.value = 2 // この時b.valueも`2`に変更されてしまいます。

ただここで一つ矛盾点があります。
それは状態のライフサイクルはViewのライフサイクルに紐づくから@Stateはローカル状態に適している、という点です。SwiftUIは前述した@StateObjectというProperty Wrapperも提供しており、@StateObjectでマークしたObservableObjectに準拠したオブジェクトのライフサイクルもViewのライフサイクルに紐づきます。

WWDC2020では、ObservableObjectを使うケースとして以下3つが挙げられていました。

  • データのライフサイクルを管理
  • 副作用の処理
  • 既存コンポーネントの統合

そこで@State@StateObjectは、

  • @Stateは、画面の状態管理
  • @StateObjectは、副作用の処理を伴う状態管理

という感じで使い分けることを意識しています。

上記を踏まえ、依存関係を作成するために使う機能の使い分けについては以下のようになります。

ここまで状態と状態管理、SwiftUIの状態管理に関する機能について見てきました。ただ説明ばかりだったので、最後に具体的なソースコードを踏まえてどのように状態管理をしていくか見ていきます。

Sample App

例としてスムージーアプリを挙げ、以下のような仕様を持つアプリケーションを例にしていきます。

  • スムージーデータはリモートDBから取得することを想定(実際はモックデータを返しています)
  • スムージの一覧を表示
  • 詳細ページを表示
  • スムージーをフィルタできる
  • スムージーをお気に入りできる
  • お気に入り一覧を表示

サンプルコードもGitHubに置いてあります。
https://github.com/ueshun109/SmoothieApp_iOS

データ取得

まずはデータの取得処理を実装していきます。データはリモートDBから取得することを想定おり、これは副作用を持つ処理です。そのため最終的に@StateObjectを使って依存関係を作成したいためObservableObjectを使って実装していきます。

@MainActor
final class SmoothieState: ObservableObject {
  @Published private(set) var smoothies: [Smoothie] = []
  let dataSource: SmoothieDataSource

  init(dataSource: SmoothieDataSource) {
    self.dataSource = dataSource
  }

  func fetchSmoothies() async {
    do {
      smoothies = try await dataSource.smoothies()
    } catch {
      // TODO: Handle error
    }
  }
}

一覧画面

次に一覧画面を作っていきます。

struct SmoothieListPage: View {
  @StateObject private var smoothieState: SmoothieState = .init(dataSource: .live)
  var body: some View {
    List {
      ForEach(smoothieState.smoothies) { smoothie in
        SmoothieRow(smoothie: smoothie)
      }
    }
    .navigationTitle("Menu")
    .task {
      await smoothieState.fetchSmoothies()
    }
  }
}

struct SmoothieRow: View {
  let smoothie: Smoothie

  var body: some View {
    HStack(alignment: .top) {
      smoothie.image
        .resizable()
        .aspectRatio(contentMode: .fill)
        .frame(width: 60, height: 60)
        .clipShapeForImage()

      VStack(alignment: .leading) {
        Text(smoothie.title)
          .font(.headline)

        Spacer()

        Text(\(smoothie.price)")
          .font(.callout)
          .foregroundStyle(.secondary)
      }
      .padding(.vertical, 8)
    }
  }
}

スムージの一覧データはこの画面でしか使用しないため、@StateObjectで定義しています。

詳細画面への遷移

struct SmoothieListPage: View {
+ @State private var selectedSmoothie: Smoothie.ID?
  @StateObject private var smoothieState: SmoothieState = .init(dataSource: .live)

  var body: some View {
    List {
      ForEach(smoothieState.smoothies) { smoothie in
+       NavigationLink(
+         tag: smoothie.id,
+         selection: $selectedSmoothie
+       ) {
+         // 今回はテキストだけ表示する詳細画面
+         Text(smoothie.title)
+       } label: {
+         SmoothieRow(smoothie: smoothie)
+       }
-       SmoothieRow(smoothie: smoothie)
      }
    }
    .navigationTitle("Menu")
    .task {
      await smoothieState.fetchSmoothies()
    }
  }
}

ここで画面遷移のために選択したスムージーのIDを保持するselectedSmoothieという状態が登場しました。選択したスムージーの状態は、一覧画面のみ関心のある状態なのでLocal Stateであり、@Stateで定義しています。

検索バーの追加

次にスムージの一覧をフィルタするために検索バーを追加していきます。

struct SmoothieListPage: View {
  @State private var selectedSmoothie: Smoothie.ID?
+ @State private var filterKeyword: String = ""
  @StateObject private var smoothieState: SmoothieState = .init(dataSource: .live)

  var body: some View {
    List {
      ForEach(smoothieState.smoothies) { smoothie in
        NavigationLink(
          tag: smoothie.id,
          selection: $selectedSmoothie
        ) {
          Text(smoothie.title)
        } label: {
          SmoothieRow(smoothie: smoothie)
        }
      }
    }
+   .searchable(text: $filterKeyword)
    .navigationTitle("Menu")
    .task {
      await smoothieState.fetchSmoothies()
    }
  }
}

searchableModifierを使用して、フィルタリングするための検索バーが表示されるようにしています。
そしてフィルタリングするためのキーワードの入力状態を、filterKeywordというプロパティで管理しています。キーワードの入力状態はこの画面でしか参照されない、つまりLocal Stateなので@Stateでマークしています。
ここで一つポイントです。現状selectedSmoothiefilterKeywordという2つの画面状態を保持していますが、今後管理する画面状態が増えていった場合プロパティが増えていき見通しが悪くなってしまう可能性があります。そこで私は以下のように画面の状態だけをまとめた構造体を作成することで、画面の状態が構造化されるので可読性も上がると考えています。

struct SmoothieListPage: View {
+ private struct UIState {
+   var selectedSmoothie: Smoothie.ID?
+   var filterKeyword: String = ""
+ }
- @State private var selectedSmoothie: Smoothie.ID?
- @State private var filterKeyword: String = ""
+ @State private var uiState = UIState()
  @StateObject private var smoothieState: SmoothieState = .init(dataSource: .live)

  var body: some View {
    List {
      ForEach(smoothieState.smoothies) { smoothie in
        NavigationLink(
          tag: smoothie.id,
-         selection: $selectedSmoothie
+         selection: $uiState.selectedSmoothie
        ) {
          Text(smoothie.title)
        } label: {
          SmoothieRow(smoothie: smoothie)
        }
      }
    }
-   .searchable(text: $filterKeyword)
+   .searchable(text: $uiState.filterKeyword)
    .navigationTitle("Menu")
    .task {
      await smoothieState.fetchSmoothies()
    }
  }
}

このままフィルタリング処理の実装もしていきたいですが、一旦スキップし後で実装していきます。

販売終了日を表示

ここで販売終了日を表示しなければいけないことに気が付いたので、販売終了日の実装を追加します。
販売終了日はDate型で定義されており、スムージー一覧画面ではyyyy/MM/ddのフォーマットで表示しなければなりません。Date型をyyyy/MM/ddにフォーマットする処理はプレゼンテーションロジックに該当すると考えます。
プレゼンテーションロジックとは、名前の通り画面に関係するロジックのことを指しています。
例えば以下のようなものはプレゼンテーションロジックだと考えています。

  • データを加工する(e.g. Date型をString型に変換 etc.)
  • 既存の状態から新しい状態を生成する(e.g. 文字色について、ある条件を満たしている場合は赤色そうでない場合は青色にする etc.)
  • ユーザーイベントに応じて、期待されるタスクを実行する

ユーザーイベントに応じて、期待されるタスクを実行する

これについてはSwiftUIのコンポーネント(Button etc.)がカバーしてくれているので考える必要はなさそうです。

データの加工や新しい状態の生成のロジックは共通点を持っていると考えられます。それは共に純粋関数 で表すことができるということです。
例えばDate型をString型に変換するロジックを書いてみます。

enum SmoothieListPageLogic {
  static func toString(from date: Date) -> String {
    let formatter: DateFormatter = .slash
    return formatter.string(from: date)
  }
}

上記関数をSwiftUIでは以下のように実行することで状態が加工された状態でレンダリングされます。

Text("~ \(SmoothieListPageLogic.toString(from: smoothie.endDate))")
  .font(.callout)
  .foregroundStyle(.secondary)

またsmoothieという状態に変更があると関連する箇所が再レンダリングされるので、その際にtoStringも再度評価され正しい状態が表示されるようになります。

フィルタリング

今回は検索バーに入力された文字が含まれているかどうかでフィルタリングするようにします。
今回フィルタリングはプレゼンテーションロジックとみなして実装していきます。

SmoothieListPageLogic
enum SmoothieListPageLogic {
  static func toString(from date: Date) -> String {
    let formatter: DateFormatter = .slash
    return formatter.string(from: date)
  }

+ static func filter(with keyword: String, target: [Smoothie]) -> [Smoothie] {
+   guard !keyword.isEmpty else { return target }
+   return target.filter { $0.title.contains(keyword) }
+ }
}
SmoothieListPage
- ForEach(smoothieState.smoothies) { smoothie in
+ ForEach(
+   SmoothieListPageLogic.filter(
+     with: uiState.filterKeyword,
+     target: smoothieState.smoothies
+   )
+ ) { smoothie in

filterKeywordに変更が発生した場合、関連する箇所が再構築されるため、SmoothieListPageLogic.filterが評価され期待通りの表示になっています。

お気に入り

お気に入りの状態は、複数画面で共有されるのでShared Stateであり、最終的に@ObservedObjectを使って依存関係を作成したいためObservableObjectを使って実装します。

FavoriteSmoothieState
@MainActor
final class FavoriteSmoothieState: ObservableObject {
  @Published var favoriteSmoothies: Set<Smoothie> = []

  func update(_ smoothie: Smoothie) {
    if favoriteSmoothies.contains(smoothie) {
      favoriteSmoothies.remove(smoothie)
    } else {
      favoriteSmoothies.insert(smoothie)
    }
  }
}

favoriteSmoothiesが信頼できる情報源であるため、ここではBindingなどでの外部からの変更を許可しています。

そして以下のように@ObservedObjectで依存関係を定義し、スワイプでお気に入りをできるようにします。

SmoothieListPage
struct SmoothieListPage: View {
  private struct UIState {
    var selectedSmoothie: Smoothie.ID?
    var filterKeyword: String = ""
  }

  @State private var uiState = UIState()
  @StateObject private var smoothieState: SmoothieState = .init(dataSource: .live)
+ @ObservedObject private var favoriteSmoothieState: FavoriteSmoothieState

+ init(favoriteSmoothieState: FavoriteSmoothieState) {
+   self.favoriteSmoothieState = favoriteSmoothieState
+ }
  var body: some View {
    List {
      ForEach(
        SmoothieListPageLogic.filter(
          with: uiState.filterKeyword,
          target: smoothieState.smoothies
        )
      ) { smoothie in
        NavigationLink(
          tag: smoothie.id,
          selection: $uiState.selectedSmoothie
        ) {
          Text(smoothie.title)
        } label: {
          SmoothieRow(smoothie: smoothie)
        }
+       .swipeActions(edge: .trailing) {
+         Button {
+           favoriteSmoothieState.update(smoothie)
+         } label: {
+           favoriteSmoothieState.favoriteSmoothies.contains(smoothie)
+             ? Image(systemName: "heart.slash")
+             : Image(systemName: "heart")
+         }
+         .tint(.pink)
        }
      }
    }
    .searchable(text: $uiState.filterKeyword)
    .navigationTitle("Menu")
    .task {
      await smoothieState.fetchSmoothies()
    }
  }
}

またお気に入り一覧ページでも、お気に入り状態を参照したいため@ObservedObjectで依存関係を定義します。

FavoritesPage
struct FavoritesPage: View {
  @ObservedObject private var favoriteSmoothieState: FavoriteSmoothieState

  init(favoriteSmoothieState: FavoriteSmoothieState) {
    self.favoriteSmoothieState = favoriteSmoothieState
  }
  var body: some View {
    // 省略
  }
}

これで一通り実装は完了です。

まとめ

本記事では状態(Local State / Shared State)・状態管理に関する説明を行い、
状態を管理する機能として、

  • @State
  • ObservableObject(厳密にはCombineだが。。)

依存関係を作成する機能として、

  • @StateObject
  • @ObservedObject
  • @EnvironmentObject

があることを見てきました。

そして上記を踏まえてどのように実装していくかをサンプルアプリを通して見てきました。

サンプルアプリのおけるスムージーの一覧を表示する、SmoothieListPageの依存関係は最終的に以下のようになりました。

小・中規模且つJSON色付けがメインのアプリなら、今回紹介したサンプルアプリのようにViewを中心に依存関係を作成していくような設計でも問題ないのかなと考えています。

参考URL

GitHubで編集を提案

Discussion