SwiftUIの状態管理の基礎

73401 ワード

概要

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でプロパティにマークするときは初期値を指定します。
すると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に置いてあります。