SwiftUIの状態管理の基礎
概要
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は、複数の画面間で状態を共有しないなど、参照スコープが閉じた状態(データ)のことを指します。例えば以下のisPlaying
はPlayButton
の内部でしか参照されないので、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と言えます。
@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
です。
@Published
はPublisherを公開することで、プロパティを観測可能にするProperty Wrapperです。
View
とデータの同期についてはSwiftUIが行っているので、実装者は気にする必要はありません。
SwiftUIは変更しようとしている時を知り、すべての変更を1回の更新にまとめます。
SwiftUIにはObservableObject
への依存関係を作成するために、Viewで使用できるProperty Wrapperが3つあります。
@ObservedObject
@ObservedObject
でプロパティをマークすると、プロパティの変更を監視するようSwiftUIに通知します。またそのプロパティは信頼できる情報源となり、View
がUIの構築に必要なデータを定義しています。特徴として@ObservedObject
は提供されるインスタンスの所有権を取得するわけではないので、ライフサイクルの管理責任は実装者にあります。そのため複数View
でデータを共有したい場合に有効です。
しかし@ObservedObject
はView
のライフサイクルに紐付かないため、View
が破棄されたあとも生存し続けてしまいます。あるビューの生存期間のみインスタンスを生かしたいというようなこともあると思います。
@StateObject
そこで@StateObject
を使用します。@StateObject
でプロパティにマークするときは初期値を指定します。するとSwiftUIは初回にbodyを実行する直前にその値をインスタンス化し、Viewのライフサイクル全体でオブジェクトを存続させます。そして@StateObject
でマークしたインスタンスは信頼できる情報源となります。
View
が不要になると、SwiftUIは@StateObject
でマークしたインスタンスをリリースします。この特性を生かしてルートのView
で@StateObject
でマークすると、そのインスタンスがアプリのライフサイクルに紐づくグローバルな状態とすることもできます。
@EnvironmentObject
SwiftUIではView
が非常に低負荷なので、理解しやすく再利用しやすい小さなView
を作成することをおすすめしています。ただそうなると階層が深くなり、@ObservableObject
を離れたサブビューに渡すのが面倒になってしまいます。この問題を解決するのが@EnvironmentObject
です。
@EnvironmentObjectを使わない場合 | @EnvironmentObjectを使う場合 |
---|---|
上記のように@EnvironmentObject
を使わない場合、データを必要としないViewにも渡すことは面倒であり、多くのボイラープレートを生んでしまいます。
ObservableObject
を注入したい親ビューでenvironmentObject
Modifierを使用し、
特定のObservableObjectを参照したいすべてのViewで@EnvironmentObject
を使用します。
すると@EnvironmentObject
でマークした箇所に依存関係を作成し値を渡します。そしてObservableObject
に変更が発生した場合、値の変更を追跡するようになります。
@StateとObservableObjectの使い分け
ともに状態管理するための機能として紹介しましたが、これらはどのように使い分けることができるのでしょうか。
私が考える一つの指針としてLocal StateかShared 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に置いてあります。
データ取得
まずはデータの取得処理を実装していきます。データはリモート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()
}
}
}
searchable
Modifierを使用して、フィルタリングするための検索バーが表示されるようにしています。
そしてフィルタリングするためのキーワードの入力状態を、filterKeyword
というプロパティで管理しています。キーワードの入力状態はこの画面でしか参照されない、つまりLocal Stateなので@State
でマークしています。
ここで一つポイントです。現状selectedSmoothie
とfilterKeyword
という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
も再度評価され正しい状態が表示されるようになります。
フィルタリング
今回は検索バーに入力された文字が含まれているかどうかでフィルタリングするようにします。
今回フィルタリングはプレゼンテーションロジックとみなして実装していきます。
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) }
+ }
}
- ForEach(smoothieState.smoothies) { smoothie in
+ ForEach(
+ SmoothieListPageLogic.filter(
+ with: uiState.filterKeyword,
+ target: smoothieState.smoothies
+ )
+ ) { smoothie in
filterKeyword
に変更が発生した場合、関連する箇所が再構築されるため、SmoothieListPageLogic.filter
が評価され期待通りの表示になっています。
お気に入り
お気に入りの状態は、複数画面で共有されるのでShared Stateであり、最終的に@ObservedObject
を使って依存関係を作成したいためObservableObject
を使って実装します。
@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
で依存関係を定義し、スワイプでお気に入りをできるようにします。
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
で依存関係を定義します。
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を中心に依存関係を作成していくような設計でも問題ないのかなと考えています。
Discussion