inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

SwiftUIでSingle Source of Truthを達成するための実装方針

こんにちは、スマートバンクでアプリエンジニアをしているロクネムです。

SwiftUIがリリースされた2019年から早3年、プロダクションに導入しているアプリも多くなってきたのではないでしょうか。

弊社の開発している B/43という家計簿プリカアプリ でも、2022年6月ごろから新規画面では積極的にSwiftUIで実装を進めています。

SwiftUIが発表されたWWDC 19の Data Flow Through SwiftUI というセッションでは、SwiftUIにおけるデータフローの原則として「Single Source of Truth」が語られました。

https://developer.apple.com/videos/play/wwdc2019/226/

本記事では、SwiftUIでViewを実装する上で、いかにしてSingle Source of Truthを達成して状態の不整合を起こりにくくしていくのか、B/43における実装方針についてご紹介させていただきたいと思います。

前提事項

iOS 15.0+

ケーススタディ

今回、Viewの実装方針についてご紹介させていただくにあたって、以下のようなプロフィール情報を編集する画面を例として取り上げようと思います。

この画面では、現在のプロフィール情報をフェッチして表示し、アイコン・名前・場所・生年月日のプロフィール情報を編集することができます。

ViewModelによる状態管理

Viewの状態を管理するStateHolderとして、 ObservableObject に準拠したViewModelを使用します。

UIの状態の表現に必要な情報はUIStateという単一のオブジェクトとして定義し、ViewModelにて @Published private(set) var uiState: UIState というpropertyを宣言して管理します。

以下はプロフィール編集画面におけるUIStateの例です。

enum ProfileEditUIState {
    case initial                    // 初期状態
    case loading                    // 読み込み中
    case editing(profile: Profile)  // 編集中
    case saving(profile: Profile)   // 保存中
    case error(Error)               // エラー
}

// 🙆 @MainActorを付与してthreadを意識せずに状態の変化をpublishできるようにする
@MainActor
class ProfileEditViewModel: ObservableObject {
    // 🙆 `private(set)` でView側から直接更新できないようにする
    @Published private(set) var uiState: ProfileEditUIState = .initial
    ...
}

画面表示時にプロフィール情報をAPIから取得して表示し、編集, 保存するという一連の状態を enum ProfileEditUIState の各caseで表現し、 Profile のような付帯する情報はassociated valueとして追加しています。

ここで、「プロフィールの名前が空の場合には保存ボタンをdisabledにする」という新たな状態の管理について考えてみます。

この新たな状態は ProfileEditUIState と互いに関連する状態であるため、以下のように安易に @Published private(set) var isSaveButtonDisabled = false のような ProfileEditUIState とは異なるpropertyとして追加するべきではありません。

@MainActor
class ProfileEditViewModel: ObservableObject {
    @Published private(set) var uiState: ProfileEditUIState = .initial

    // 🙅  
    @Published private(set) var isSaveButtonDisabled = false
    ...
}

これでは、プロフィールの状態を管理している uiState とプロフィールの入力状態に応じたフラグを管理している isSaveButtonDisabled という関連する二つの状態が重複して別々に管理されてしまい、不整合を発生させてしまうリスクがあります。

これを回避するために、 ProfileEditUIState 内で isSaveButtonDisabled をcomputed propertyとして宣言し、 ProfileEditUIStateの状態に応じて適切なBool値を返すような実装をしましょう。

enum ProfileEditUIState {
    case initial
    case loading
    case editing(profile: Profile)
    case saving(profile: Profile)
    case error(Error)

    // 🙆
    var isSaveButtonDisabled: Bool {
        switch self {
        case .initial, .loading, .error: return false
        case .editing(let profile): return profile.name.isEmpty
        case .saving: return true
    }
}

このように、Viewの状態を表現する情報源をUIStateの1箇所に集約することで、データの不整合を防ぎやすくなります。

ステートレスなViewを心がける

UIStateに応じたViewの出しわけを考える際、各状態におけるViewを別Viewとして切り出してあげるとコードの見通しが良くなります。

その際、切り出したViewに対してViewModelを渡したり、View内部で状態を持たせないように心がけましょう。

struct ProfileEditScreen: View {
    ...
    var body: some View {
        ZStack {
            switch viewModel.uiState {
            case .initial: ZStack {}
            case .loading: ProgressView()
            case .editing(let profile), .saving(let profile):
                // 🙆 子ViewへViewModelを渡さない
                ProfileEditContent(
                    profile: profile,
                    isSaveButtonDisabled: viewModel.uiState.isSaveButtonDisabled,
                    onSaveButtonTapped: {
                        Task {
                            await viewModel.onSaveButtonTapped()
                        }
                    }
                )
            case .error(let error): ErrorMessage(error: error)
            }
        }
        ...     
    }
}

private struct ProfileEditContent: View {
    let profile: Profile
    let isSaveButtonDisabled: Bool
    let onSaveButtonTapped: () -> Void

    var body: some View {
        VStack {
            ...
            Button {
                onSaveButtonTapped()
            } label: {
                Text("Save")
            }
            .disabled(isSaveButtonDisabled)
        }
    }
}

しかし、 Binding による双方向バインディングをサポートしているSwiftUIではそれが難しいケースが存在します。

例えば、子ViewがTextFieldを持っているとします。

TextFieldから入力されたテキスト情報を受け取るためには、TextFieldの引数にBindingを渡してあげる必要があります。

private struct ProfileEditContent: View {
    @Binding private var name: String
    ...

    var body: some View {
        VStack {
            TextField(
                "Your Name",
                $name
            )
            ...
        }
    }
}

これでは親から子へさらにBindingを渡す必要があり、さらにそのためにはViewModelのUIStateとは別な @Published のpropertyが必要になり、プロフィール情報の整合性の維持が難しくなります。

このような場合は、 Binding.init(get:set:) を用いてgetterとsetterにてそれぞれ値をハンドリングするようにしてあげましょう。

struct ProfileEditScreen: View {
    ...
    var body: some View {
        ZStack {
            ...
            case .editing(let profile), .saving(let profile):
                ProfileEditContent(
                    profile: profile,
                    isSaveButtonDisabled: viewModel.uiState.isSaveButtonDisabled,
                    onNameChanged: { name in
                        // 🙆 ViewModelへテキスト変更イベントを伝播
                        viewModel.onNameChanged(name: name)
                    },
                    onSaveButtonTapped: {
                        Task {
                            await viewModel.onSaveButtonTapped()
                        }
                    }
                )
            ...
            }
        }
        ...     
    }
}

private struct ProfileEditContent: View {
    let profile: Profile
    let isSaveButtonDisabled: Bool
    let onNameChanged: (String) -> Void
    let onSaveButtonTapped: () -> Void

    var body: some View {
        VStack {
            TextField(
                "Your Name",
                text: .init(get: {
                    // 🙆 getterでは表示するテキストを返す
                    profile.name
                }, set: { newValue in
                    // 🙆 setterではイベントを親Viewへ伝播する
                    onNameChanged(newValue)
                })
            )
                    ...
        }
    }
}

@MainActor
class ProfileEditViewModel: ObservableObject {
    @Published private(set) var uiState: ProfileEditUIState = .initial
    ...
    // 🙆 UIStateを更新
    func onNameChanged(name: String) {
        guard case .editing(var profile) = uiState else { return }

        profile.name = name
        uiState = .editing(profile: profile)
    }
}

このようにViewをステートレスに保ち、状態の流れを単方向に保つことで、以下のようなメリットが得られます。

  • Single Source of Truthの達成
    • 状態を複製するのではなく移動させることで、Single Source of Truthを達成することができます。状態の不整合の発生を防ぎやすくなり、バグを防ぐのに役立ちます。
  • 再利用性の向上
    • 他画面でのViewの使い周しが容易になります。
  • 処理の割り込みが可能
    • 親Viewは子Viewの状態の変更前に処理を挟むことが可能で、状態を変更するかイベントを無視するかの判断が可能になります。
  • Previewの表示が容易
    • ViewModelのような大きな状態管理オブジェクトを渡さないことで、Previewを表示する際に用意するパラメータが少なく済みます。

複数のNavigationの管理は一箇所にまとめる

SwiftUIでプッシュ遷移させたい場合はNavigationLinkを使用します。

NavigationLinkへ渡す引数の label にはタップ時に遷移処理を発火するViewを、 destination には遷移先のViewを指定します。

List内での不安な挙動もあるので、 label には EmptyView を指定しつつ、 isActive で遷移するタイミングを管理する使用方法に揃えるのが良いでしょう。

例えば、誕生日入力画面へのプッシュ遷移の実装は以下のようになります。

@MainActor
class ProfileEditViewModel: ObservableObject {
    @Published private(set) var uiState: ProfileEditUIState = .initial
    // 😢 双方向バインディングのため private(set) にできない
    @Published var isBirthdayInputScreenActive = false
    ...
    func onBirthdayTapped() {
        isBirthdayInputScreenActive = true
    }
}

struct ProfileEditScreen: View {
    ...
    var body: some View {
        ZStack {
            NavigationLink(
                destination: BirthdayInputScreen(),
                isActive: $viewModel.isBirthdayInputScreenActive,
                label: { EmptyView() }
            )
            ...
        }
        ...
    }
}

プッシュ遷移する処理が1つだけであれば isBirthdayInputScreenActive のようなフラグを用意して管理するという上記実装で十分そうです。 双方向バインディングのため isBirthdayInputScreenActiveprivate (set) にできないのでViewのどこからでも変更可能であるという点がやや不安要素ではありますが、十分許容できるでしょう。

しかし、遷移先が複数になっていくことを考えてみると、その複雑度は一気に増してしまいます。

例えば、誕生日入力画面に加えて、位置情報入力画面へのプッシュ遷移も実装するとします。

@MainActor
class ProfileEditViewModel: ObservableObject {
    @Published private(set) var uiState: ProfileEditUIState = .initial
    @Published var isBirthdayInputScreenActive = false
    @Published var isLocationInputScreenActive = false
    ...
    func onBirthdayTapped() {
        isBirthdayInputScreenActive = true
    }
    func onLocationTapped() {
        isLocationInputScreenActive = true
    }
}

struct ProfileEditScreen: View {
    ...
    var body: some View {
        ZStack {
            NavigationLink(
                destination: BirthdayInputScreen(),
                isActive: $viewModel.isBirthdayInputScreenActive,
                label: { EmptyView() }
            )
            NavigationLink(
                destination: LocationInputScreen(),
                isActive: $viewModel.isLocationInputScreenActive,
                label: { EmptyView() }
            )
            ...
        }
        ...
    }
}

位置情報入力画面への遷移を管理する isLocationInputScreenActive が新たに増えました。

isBirthdayInputScreenActiveisLocationInputScreenActive はいずれかがtrueの場合、もう一方は必ずfalseとなるべきです。

このような暗黙的な関係が生まれてしまっている時点で状態管理のコストが上がっていることが理解できるかと思います。さらに遷移先が増えていくと尚のことです。

これを防ぐべく、NavigationDestination という単一のenumを用意して遷移先を各caseで表現し、ViewModelにて @Published private(set) var navigationDestination: NavigationDestination? というpropertyを宣言して管理します。

NavigationLinkのisActive へは Binding.init(get:set:) を渡してgetterとsetterにてそれぞれ値をハンドリングするようにします。 getterではViewModelで公開している NavigationDestination が該当の遷移先かどうかのBool値を、setterでは false がセットされた場合にViewModelの onNavigationDismiss を呼び出してViewModelの管理する NavigationDestination の状態が nil となるように更新してあげます。

enum ProfileEditNavigationDestination {
    case birthdayInput
    case locationInput
}

@MainActor
class ProfileEditViewModel: ObservableObject {
    // 🙆 単一のpropertyでプッシュの遷移先を管理する
    @Published private(set) var navigationDestination: ProfileEditNavigationDestination?
    ...
    func onNavigationDismiss() {
        navigationDestination = nil
    }
}

struct ProfileEditScreen: View {
    ...
    var body: some View {
        ZStack {
            NavigationLink(
                destination: BirthdayInputScreen(),
                isActive: .init(get: {
                    viewModel.navigationDestination == .birthdayInput
                }, set: { newValue in
                    if newValue == false {
                        viewModel.onNavigationDismiss()
                    }
                }),
                label: { EmptyView() }
            )
            NavigationLink(
                destination: LocationnInputScreen(),
                isActive: .init(get: {
                    viewModel.navigationDestination == .locationInput
                }, set: { newValue in
                    if newValue == false {
                        viewModel.onNavigationDismiss()
                    }
                }),
                label: { EmptyView() }
            )
            ...
        }
    }
}

これにより、画面遷移の状態を排他的に1つのpropertyで管理することができ、さらにはBindingによる双方向バインディングが不要となったため private (set) のアクセス修飾子を付与することができます。

alertsheet の表示も同様の対応ができると良いでしょう。

enum ProfileEditAlertKind {
    case saveFailure
    case cancelConfirmation

    var title: String {
        switch self {
        case .saveFailure: return "..."
        case .cancelConfirmation: return "..."
        }
    }
}

enum ProfileEditSheetItem: String, Identifiable {
    case photoPicker
    case socialLink

    var id: String { rawValue }
}

@MainActor
class ProfileEditViewModel: ObservableObject {
    @Published private(set) var alertKind: ProfileEditAlertKind?
    @Published private(set) var sheetItem: ProfileEditSheetItem?
    ...
    func onAlertDismiss() {
        alertKind = nil
    }
    func onSheetDismiss() {
        sheetItem = nil
    }
}

struct ProfileEditScreen: View {
    ...
    var body: some View {
        ZStack {
            ...
        }
        .padding(16)
        .background(Color(.systemBackground), ignoresSafeAreaEdges: .all)
        .alert(
            viewModel.alertKind?.title ?? "",
            isPresented: .init(get: {
                viewModel.alertKind != nil
            }, set: { newValue in
                if newValue == false {
                    viewModel.onAlertDismiss()
                }
            }),
            presenting: viewModel.alertKind
        ) { alertKind in
            switch alertKind {
            case .saveFailure: ...
            case .cancelConfirmation: ...
            }
        } message: { alertKind in
            switch alertKind {
            case .saveFailure: ...
            case .cancelConfirmation: ...
            }
        }
        .sheet(item: .init(get: {
            viewModel.sheetItem
        }, set: { newValue in
            if newValue == nil {
                viewModel.onSheetDismiss()
            }
        })) { item in
            switch item {
            case .photoPicker: ...
            case .socialLink: ...
            }
        }
        ...
    }
}

同じような課題へのアプローチとして swiftui-navigation というライブラリの導入も考えられますが、独自のシンタックスが増えることへの懸念とiOS 16から追加された NavigationStack への移行を考えて、B/43では導入を見送っています。

画面遷移周りの状態はUIStateに含めないのか

UIStateという唯一のpropertyで状態を管理したいモチベーションの源泉は、不整合を発生させたくないという部分にあります。
不整合は、関連するUIの状態を異なるpropertyで管理しようとしたときに発生する可能性が初めて生じます。
よって、関連しない状態については別々なpropertyで管理しても問題はなく、むしろ影響範囲の明文化や更新の容易性という観点からは好ましいと考えられます。
つまり、「画面を描画する上で必要な状態」はUIStateで管理し、「画面遷移で必要な状態」は別propertyで管理する、という方針としています。

※「画面を描画する上で必要な状態」についても必ずしも一つのUIStateで管理するべきというわけではなく、関心ごとが異なる状態は別propertyへ切り出すという実装も考えられます。

まとめ

今回はSwiftUIでViewを実装していく上で、いかに不整合の発生しづらいように状態を管理できるか、という部分にフォーカスした実装方針についてご紹介させていただきました。

読んでいて気づいた方もいらっしゃるかもしれませんが、この方針はAndroidの UIレイヤのアーキテクチャガイド および 状態ホイスティング の考え方を踏襲したものとなっています。

そもそもJetpack Composeでは双方向バインディングをサポートしておらず、双方向バインディングを前提としたUIフレームワークであるSwiftUIにおいて、完全に倣おうとするとやや過剰な書き方になってしまうケースは存在しています。

今回ご紹介した実装方針はあくまで複雑化していく状態を不整合なく管理する上でどのような対応が考えられるかを記しており、単純な画面で状態の不整合の余地も小さければ双方向バインディングで実装してしまっても良いでしょう。

改めまして、本記事がSwiftUIでViewを実装する上での状態管理の考え方の参考になれば幸いです。

[PR] 採用のお知らせ

スマートバンクでは一緒にB/43のアプリを開発していくメンバーを募集しています! 少しお話を聞いてみたいという方も、カジュアル面談を受け付けていますので、ぜひお気軽にご応募ください💪

smartbank.co.jp

また、つい先日アプリエンジニアチーム回のPodcastを公開しましたので、開発メンバーについて気になる👀という方はぜひお聴きください 🎶 smartbank.co.jp

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.