🧃

SwiftUI+Reduxを利用したUI実装サンプルにおけるポイント解説

2023/06/15に公開

1. はじめに

以前、新規iOSアプリ開発プロジェクトで候補に上がったアーキテクチャの候補として、UIKit+Reduxを利用したアーキテクチャが候補に上がった事があり、実践で導入するために簡単なサンプルアプリを作成し、実際の肌感を掴む意味も込めて検証を実施した経験がありました。

当時作成したサンプルアプリに関する解説はこちら

https://qiita.com/fumiyasac@github/items/f25465a955afdcb795a2

※ この記事ではReduxの機構を実現するために、ReSwiftというOSSを利用しています。

最近では、業務内でもUIKitを利用したUI実装よりもSwiftUIを利用したUI実装の割合が増えていった事もあり、この機会に改めてある程度複雑なUI構造をとるサンプルアプリをReduxを利用した形で試してみたいという動機から取り組んだものになります。

また、もう1つの動機としてはPoint-Freeが公開しているTCA(The Composable Architecture)をみた際にReduxに近い構成を取っていると私自身感じたこともあり、Reduxの理解を深める事で、こちらもより深くTCAを理解できるのではないかと思いました。

※ こちらの記事は2023/03/07に開催されたYUMEMI.grow Mobile #1での登壇内容を加筆したものになります。

https://speakerdeck.com/fumiyasac0921/swiftui-and-reduxwoli-yong-sitauishi-zhuang-sanpuruniokerupointojie-shuo

2. 今回のサンプル概要&参考資料

この記事で紹介しているサンプルコードについては、async/awaitを利用した擬似的なAPI通信処理を利用して画面表示に必要なデータを取得し、Reduxを利用してデータを画面に表示する処理を中心としたものになります。加えて、一部の画面ではキーワード・カテゴリーによる絞り込み検索機能や、Realmを利用したお気に入り追加処理も実装しています。

※擬似的なAPI通信処理については、json-serverを利用して、ローカル環境で動作する様にしています。

画面要素の細かな表現部分に関しては若干作り込みが甘い点等はあるかと思いますが、お気づきの点等があればご指摘頂けますと幸いです。

2-1. サンプル概要:

動作確認用コード

公開しているGitHubリポジトリはこちらになります。

https://github.com/fumiyasac/SwiftUIAndReduxExample

動き方を収録した動画リンクはこちらになります。

https://www.facebook.com/fumiya.sakai.37/videos/734931384655279

画面キャプチャ

Home画面 検索画面
Home画面 検索画面
ギャラリー風表現画面 プロフィール画面
ギャラリー風表現画面 プロフィール画面

2-2. 参考資料

サンプルコードの開発に取り組んだ当初は、SwiftUIでの実装経験はまだまだ浅かった事もあり、SwiftUI自体の理解に加えて、実装への応用ができる様にするための下準備として、下記に紹介している様なUdemy講座等を活用しました。また、これまではUIKitをベースにしたiOSアプリ開発を経験がある場合には、Reduxを利用してiOSアプリ開発をした際の解説記事等も参考にできると思います。

【活用したUdemy講座】

【SwiftUIとReduxを組み合わせた場合の事例】

【UIKitとReduxを組み合わせた場合の事例】

【TCA(The Composable Architecture)とRedux比較した際の所感等】

TCA(The Composable Architecture)については、実務で利用してはいませんが、個人的に少しずつキャッチアップをしながら試行錯誤している段階ではありますが、とても良くできている印象を持っています。こちらもReduxの流れを汲むアーキテクチャではあるので、それぞれの特徴や類似点・相違点等を諸々まとまった形で資料にできればと考えております。最初にReduxに対する理解をある程度持った状態でリポジトリにあるサンプルを見ていくと、多少は感覚は掴みやすい状態で進めていけると思います。

3. このサンプル実装におけるReduxと各層における処理概要

本記事で紹介しているサンプル実装では、React.jsでも利用されている様なReduxの処理機構を、SwiftUIで表現した様なイメージで作成しています。一見すると、Webアプリとは異なる考え方をするiOSアプリで合うのかな?と感じる方もいらっしゃるかもしれません。ちょっと大雑把な見方にはなってしまいますが、Reactの仮想DOMをSwiftUIのViewに照らし合わせて考える様にしてみると、実は意外とそんなに遠くはない印象を私自身も持つ事ができました。

(※React.jsでReduxを利用した実装に関する解説やイメージについては下記のリンク等を参考にしながら進めていくと良さそうに思います。)

また、Reduxの処理を実現するために必要な各要素については、下記のような形で役割ごとのファイルにて分割してまとめています。

  • State:
    👉 アプリケーション全体ないしは画面UIの状態を表す構造体になります。
    本記事で解説しているサンプルでは、画面単位で画面表示用Stateを定義しています。
  • Store:
    👉 アプリケーション全体の状態(複数の画面表示用State)を一枚岩の様な形で保持しています。
  • Action:
    👉 Storeが保持している状態(対象の画面表示用State)を更新するための唯一の手段で、structで定義しています。
    (重要) Actionの発行はStoreが提供しているstore.dispatch()を実行する形となります。
  • Reducer:
    👉 現在の状態(対象の画面表示用State)とActionの内容から新しい状態を作成する部分で純粋関数として定義しています。
  • Middleware:
    👉 Reducerの実行前後で処理を差し込むための部分で純粋関数として定義しています。
    (重要) 画面表示に必要なMiddleware内部で、API非同期通信処理や内部データ登録処理等を実施する形となります。

3-1. 本サンプルにおけるRedux処理全体像の概略図

Reduxの処理をアプリ内で実現するために、押さえておきたい事項やそれぞれの部分が担っている役割についてまとめていきます。Reduxは下記に示す3つの原則に則った上で状態管理のフローを縛ることによって状態管理を行う形になっています。

  1. Single source of truth.
  2. State is read-only.
  3. Mutations are written as pure function.

下記に示しているのは、サンプル実装におけるReduxのデータフローの図解になりますが、状態(State)の更新はAction経由でしか許可していない点とReducer内の処理によって状態の更新を実行する点と、View側では状態の更新通知を受け取る形になっている点の2点が大きなポイントになるかと思います。

また、下記のような形でReduxの処理を実現するために必要な要素を役割ごとのファイルに分割した上でまとめて、命名によって画面ごとにそれぞれのStateが対応するようにしています。

本サンプルで利用しているReduxの概要図と処理フロー

必要なデータの取得や登録等の処理が実行された際には、View要素からActionが発行され、API通信処理やデータ永続化処理を伴う場合については、Middleware内で処理結果が含まれたActionが再度発行されることになります。その後にReducerへ処理が引き渡された後に、内容が更新された新たなStateが生成されるで、これを元にしてView要素が更新される流れとなることを押さえておくと理解がしやすいと思います。

3-2. Middlewareで実行する処理と各種機能とのつながり

本サンプルにおけるMiddleware内では、画面表示の際に必要なAPI非同期通信処理やデータ永続化を実行する処理を●●●Repository(例. Home画面表示に必要なデータを取得する処理はHomeRepositoryと命名しています。)という単位で、対象の画面に対応するような名前で分割して利用しています。

Middleware内で実行されている処理と結果に応じたAction発行

MiddlewareはReducerの実行前後に処理を追加する仕組みなので、API通信処理結果の成功・失敗に応じて、各状態に応じたActionをdispatch()を利用して発行する様な形となります。こうする事で、API非同期通信処理やデータ永続化を実行する処理をMiddleware内に隠蔽する事ができます。

4. Redux処理機構のおおもとになる部分の概要

SwiftUIを前提とした画面でReduxを利用する際に、処理機構のおおもととなる部分について考えていきます。

4-1. アプリケーション全体の状態を管理するためのStoreに関して

アプリケーション全体の状態を管理するためのStore部分に関しては、ObservableObjectを継承したクラスとし、主に下記2つの機能を提供する様な形としています。

  1. 各画面に対応するStateを更新するために、Storeに対してActionを発行するためのDispatcher機能
  2. 現在のStateとDispatcherによって発行されたActionを元にして、指定されたReducer処理を経由して新しいStateを生成する機能

また、各画面に対応するStateを集約しているAppState(ReduxStateプロトコルに準拠している)の部分については@Publishedで定義し、各画面に対応するStateについては Action発行からのReducer 処理以外では変更を許可しないImmutableな形にしている 点もポイントになるかと思います。

Store.swift
import Foundation

// MARK: - Typealias

// 👉 Dispatcher/Reducer/Middlewareのtypealiasを定義する
// ※おそらくエッセンスとしてはReact等の感じに近くなるイメージとなる
typealias Dispatcher = (Action) -> Void
typealias Reducer<State: ReduxState> = (_ state: State, _ action: Action) -> State
typealias Middleware<StoreState: ReduxState> = (StoreState, Action, @escaping Dispatcher) -> Void

// MARK: - Protocol

protocol ReduxState {}

protocol Action {}

// MARK: - Store

final class Store<StoreState: ReduxState>: ObservableObject {

    // MARK: - Property

    @Published private(set) var state: StoreState
    private var reducer: Reducer<StoreState>
    private var middlewares: [Middleware<StoreState>]

    // MARK: - Initialzer

    init(
        reducer: @escaping Reducer<StoreState>,
        state: StoreState,
        middlewares: [Middleware<StoreState>] = []
    ) {
        self.reducer = reducer
        self.state = state
        self.middlewares = middlewares
    }

    // MARK: - Function

    func dispatch(action: Action) {

        // MEMO: Actionを発行するDispatcherの定義
        // 👉 新しいstateに差し替える処理については、メインスレッドで操作したいのでMainActor内で実行する
        Task { @MainActor in
            self.state = reducer(
                self.state,
                action
            )
        }

        // MEMO: 利用する全てのMiddlewareを適用
        middlewares.forEach { middleware in
            middleware(state, action, dispatch)
        }
    }
}

4-2. Storeクラスへ適用するAppState・AppReducer・Middlewareに関して

先程定義したStoreクラスを初期化する際には、下記のものを渡す必要があります。

  1. @Published private(set) var state: StoreState
    👉 AppState(各画面に対応するStateを集約したもの) を引き渡す必要があります。
  2. private var reducer: Reducer<StoreState>
    👉 AppReducer(各画面に対応するReducer関数を集約したもの) を引き渡す必要があります。
  3. middlewares: [Middleware<StoreState>]
    👉 各画面用Middeleware(各画面に対応するAPI通信やデータ永続化処理用の関数) を引き渡す必要があります。

本サンプルにおけるAppState・AppReducerの処理及び、SwiftUIベースのプロジェクトにおけるStoreの初期化処理についてはは下記の様な形で定義しています。

【AppState.swift】

各画面に対応するStateを集約しており、おおもとのStoreクラス内で@Published private(set) var state: StoreStateとしているため、各画面に対応するStateもImmutableとなっています。

AppState.swift
import Foundation

// MARK: - AppState

// 👉 アプリ全体のState定義(画面ないしは機能ごとのState定義を集約する部分)

struct AppState: ReduxState {
    // MEMO: Onboarding表示で利用するState
    var onboardingState: OnboardingState = OnboardingState()
    // MEMO: Home画面表示で利用するState
    var homeState: HomeState = HomeState()
    // MEMO: Archive画面表示で利用するState
    var archiveState: ArchiveState = ArchiveState()
    // MEMO: Favorite画面表示で利用するState
    var favoriteState: FavoriteState = FavoriteState()
    // MEMO: Profile画面表示で利用するState
    var profileState: ProfileState = ProfileState()
}

【AppReducer.swift】

各画面に対応するReducerを集約しており、各画面に対応するReducerについても純粋関数の様な形で定義しています。例えば、Onboarding状態の更新をするためのReducer関数についてはfunc onboardingReducer(_ state: OnboardingState, _ action: Action) -> OnboardingStateの様な形で定義して、新しいOnboardingStateを生成する様な形としています。

※ 全体としては、既存のAppStateとActionから、AppReducerを経由して新たなAppStateが生成されるイメージを持って頂けると良いかと思います。

AppReducer.swift
import Foundation

// MARK: - Function

// 👉 AppReducerはそれぞれの画面で利用するReducerを集約している部分

func appReducer(_ state: AppState, _ action: Action) -> AppState {
    var state = state
    // MEMO: OnboardingReducerの適用
    state.onboardingState = onboardingReducer(state.onboardingState, action)
    // MEMO: HomeReducerの適用
    state.homeState = homeReducer(state.homeState, action)
    // MEMO: ArchiveReducerの適用
    state.archiveState = archiveReducer(state.archiveState, action)
    // MEMO: FavoriteReducerの適用
    state.favoriteState = favoriteReducer(state.favoriteState, action)
    // MEMO: ProfileReducerの適用
    state.profileState = profileReducer(state.profileState, action)
    return state
}

【Storeをアプリに適用する】

SwiftUIベースのアプリで、状態管理をStoreを適用する際は下記の様にvar body: some Scene { ... }に適用しています。また、Storeクラスを初期化する際の第3引数には、各画面におけるStateの更新処理時に利用するMiddleware を適用しています。

本サンプルでは、①API非同期通信処理 / ②Realmを利用したデータ永続化処理 / ③UserDefaultを利用したデータ永続化処理を操作するためMiddlewareについても、必要な処理を純粋関数の形で定義し、Store初期化時に適用しています。(UnitTest実施時やPreviewでの表示の際はMockを利用します。)

SwiftUIAndReduxExampleApp.swift
import SwiftUI

@main
struct SwiftUIAndReduxExampleApp: App {

    // MEMO: AppDelegate
    // 👉 本サンプルではNavigationBar/TabBarのAppearance設定をする 
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    // MARK: - Body

    var body: some Scene {

	// 👉 このアプリで利用するStoreを初期化する
        // ※ middlewaresの配列内にAPI通信/Realm/UserDefaultを操作するための関数を追加する
        // ※ UnitTest実行時やPreview画面表示処理内では、middlewaresの関数にはMock用のものを適用する
	let store = Store(
	    reducer: appReducer,
	    state: AppState(),
	    middlewares: [
		// OnBoarding処理用Middleware
		onboardingMiddleware(),
		onboardingCloseMiddleware(),
		// Home処理用Middleware
		homeMiddleware(),
		// Archive処理用Middleware
		archiveMiddleware(),
		addArchiveObjectMiddleware(),
		deleteArchiveObjectMiddleware(),
		// Favorite処理用Middleware
		favoriteMiddleware(),
		// Profile処理用Middleware
		profileMiddleware(),
	    ]
	)

	// 👉 下層のContentViewには`.environmentObject`を経由してstoreを適用する
        WindowGroup {
            ContentView()
                .environmentObject(store)
        }
    }
}

【下層のView要素でStoreに定義した要素を取得する】

おおもとのView要素から下層View要素().environmentObject(store)で渡されたStoreを下記の様な形で取得して利用します。対象の画面要素に対応するStateやView要素内の処理からActionを発行するdispatch関数については、Storeが持っていますので、該当の画面に対応するStateを取得し、その値を更新する際にはdispatch関数を利用するという点がポイントになります。

// 👉 各画面View要素でおおもとからstoreを受け取る部分
@EnvironmentObject var store: Store<AppState>

// 👉 Storeから対象の画面要素に対応するStateを取得する
store.state.homeState

// 👉 View要素内の処理からActionを発行する部分(※本サンプルではProps内の処理で実行する)
store.dispatch(action: ●●●●Action())

【Preview画面表示でMiddlewareのMockを利用した事例】

Previewを表示する場合については、Middlewareを使って実現している①API非同期通信処理 / ②Realmを利用したデータ永続化処理 / ③UserDefaultを利用したデータ永続化処理の部分を、実際の処理ではなくそれぞれのあるべき状態を実現するMockに差し替える事で実現しています。

適用するMiddleware処理をPreview時に分離する

HomeScreen.swift
// MARK: - Preview

struct HomeScreenView_Previews: PreviewProvider {
    static var previews: some View {
        // Success時の画面表示
        let homeSuccessStore = Store(
            reducer: appReducer,
            state: AppState(),
            middlewares: [
	        // 👉 API非同期通信処理が成功した想定のMock関数
                homeMockSuccessMiddleware()
            ]
        )
        HomeScreenView()
            .environmentObject(homeSuccessStore)
            .previewDisplayName("Home Secreen Success Preview")

	// Failure時の画面表示
        let homeFailureStore = Store(
            reducer: appReducer,
            state: AppState(),
            middlewares: [
	        // 👉 API非同期通信処理が失敗した想定のMock関数
                homeMockFailureMiddleware()
            ]
        )
        HomeScreenView()
            .environmentObject(homeFailureStore)
            .previewDisplayName("Home Secreen Failure Preview")
    }
}

5. シンプルな画面から見るRedux処理の流れに関する解説

本サンプルにおける、Redux処理機構を利用した画面表示処理の前提となるのは、

  1. Storeから受け取った画面用State値を反映する
  2. ボタン押下処理等の部分に画面用Stateを変更するAction発行処理を記載する

の2点になります。

この点を踏まえると、画面用State変化とUI変化をうまく結びつけるためには、できるだけ 「Stateの値 = アプリのUI要素の状態」 という形となる様に、State構造やUI関連処理に関する設計をする、すなわち、 「各状態におけるデータとUIのあるべき姿を整理する」 事が重要になると考えております。

というわけで、比較的シンプルな構造の画面内における、画面対応するStateの変化を元にしたRedux処理の事例を2つ例示しました。

5-1. オンボーディング画面表示有無に応じたTabView画面にポップアップ表示

オンボーディング画面表示可否に応じた表示

【画面に対応するState】

OnboardingState.swift
import Foundation

struct OnboardingState: ReduxState, Equatable {

    // MARK: - Property

    // MEMO: オンボーディング表示フラグ
    var showOnboarding: Bool = false

    // MARK: - Equatable

    static func == (lhs: OnboardingState, rhs: OnboardingState) -> Bool {
        return lhs.showOnboarding == rhs.showOnboarding
    }
}

【発行するAction】

OnboardingActions.swift
import Foundation

struct RequestOnboardingAction: Action {}

struct ShowOnboardingAction: Action {}

struct CloseOnboardingAction: Action {}

【Middleware関数】

OnboardingMiddleware.swift
import Foundation

// MARK: - Function

// オンボーディングの表示フラグ値に応じたActionを発行する
func onboardingMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            case _ as RequestOnboardingAction:
            // 👉 RequestOnboardingActionを受け取ったらその後にオンボーディングの表示フラグ値に応じた処理を実行する
            handleOnboardingStatus(dispatch: dispatch)
            default:
                break
        }
    }
}

func onboardingCloseMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            case _ as CloseOnboardingAction:
            // 👉 CloseOnboardingActionを受け取ったらその後にオンボーディングの表示フラグ値を更新する
            changeOnboardingStatus()
            default:
                break
        }
    }
}

// MARK: - Private Function

// 👉 オンボーディングの表示フラグ値を取得し、条件に合致すれば該当するActionを発行するためのメソッド
private func handleOnboardingStatus(dispatch: @escaping Dispatcher) {
    let shouldShowOnboarding = OnboardingRepositoryFactory.create().shouldShowOnboarding()
    if shouldShowOnboarding {
        dispatch(ShowOnboardingAction())
    }
}

// 👉 オンボーディングの表示フラグ値を変更するためのメソッド
private func changeOnboardingStatus() {
    let _ = OnboardingRepositoryFactory.create().changeOnboardingStatusFalse()
}

【Repository層でのUserDefault操作処理】

※UserDefault処理については、SwiftyUserDefaultsを利用して管理しています。

OnboardingRepository.swift
import Foundation
import SwiftyUserDefaults

// MARK: - Protocol

protocol OnboardingRepository {
    func shouldShowOnboarding() -> Bool
    func changeOnboardingStatusFalse()
}

final class OnboardingRepositoryImpl: OnboardingRepository {

    // MARK: - Function

    func shouldShowOnboarding() -> Bool {
        let result = Defaults[\.onboardingStatus]
        return result
    }

    func changeOnboardingStatusFalse() {
        Defaults[\.onboardingStatus] = false
    }
}

// MARK: - Factory

struct OnboardingRepositoryFactory {
    static func create() -> OnboardingRepository {
        return OnboardingRepositoryImpl()
    }
}

【Reducer関数】

OnboardingReducer.swift
import Foundation

func onboardingReducer(_ state: OnboardingState, _ action: Action) -> OnboardingState {
    var state = state
    switch action {
    case _ as ShowOnboardingAction:
        state.showOnboarding = true
    case _ as CloseOnboardingAction:
        state.showOnboarding = false
    default:
        break
    }
    return state
}

【画面View要素】

ContentView.swift
import SwiftUI

struct ContentView: View {

    // MARK: - EnvironmentObject

    // 👉 画面全体用のView要素についても同様に.environmentObjectを利用してstoreを適用する
    @EnvironmentObject var store: Store<AppState>

    private struct Props {
        // Immutableに扱うProperty 👉 画面状態管理用
        let showOnboarding: Bool
        // Action発行用のClosure
        let requestOnboarding: () -> Void
        let closeOnboarding: () -> Void
    }

    private func mapStateToProps(state: OnboardingState) -> Props {
        Props(
            showOnboarding: state.showOnboarding,
            requestOnboarding: {
                store.dispatch(action: RequestOnboardingAction())
            },
            closeOnboarding: {
                store.dispatch(action: CloseOnboardingAction())
            }
        )
    }

    // MARK: - Body

    var body: some View {
        // 該当画面で利用するState(ここではOnboardingState)をこの画面用のPropsにマッピングする
        let props = mapStateToProps(state: store.state.onboardingState)

        // 表示に必要な値をPropsから取得する
        let onboardingState = mapToshowOnboarding(props: props)

        // 画面用のPropsに応じた画面要素表示処理を実行する
        ZStack {
            // (1) TabView表示要素の配置
            TabView {
                HomeScreenView()
                    .environmentObject(store)
                    .tabItem {
                        VStack {
                            Image(systemName: "house.fill")
                            Text("Home")
                        }
                    }
                    .tag(0)
                ArchiveScreenView()
                    .environmentObject(store)
                    .tabItem {
                        VStack {
                            Image(systemName: "archivebox.fill")
                            Text("Archive")
                        }
                    }.tag(1)
                FavoriteScreenView()
                    .environmentObject(store)
                    .tabItem {
                        VStack {
                            Image(systemName: "bookmark.square.fill")
                            Text("Favorite")
                        }
                    }.tag(2)
                ProfileScreenView()
                    .environmentObject(store)
                    .tabItem {
                        VStack {
                            Image(systemName: "person.crop.circle.fill")
                            Text("Profile")
                        }
                    }.tag(3)
            }
            .accentColor(Color(uiColor: UIColor(code: "#b9d9c3")))
            // (2) 初回起動ダイアログ表示要素の配置
            if onboardingState {
                withAnimation(.linear(duration: 0.3)) {
                    Group {
                        Color.black.opacity(0.64)
                        OnboardingContentsView(closeOnboardingAction: props.closeOnboarding)
                    }
                    .edgesIgnoringSafeArea(.all)
                }
            }
        }
        .onFirstAppear(props.requestOnboarding)
    }

    // MARK: - Private Function

    private func mapToshowOnboarding(props: Props) -> Bool {
        return props.showOnboarding
    }
}

5-2. API非同期通信処理結果に応じた画面表示

API非同期通信処理状態によって状態が変化する画面

【画面に対応するState】

FavoriteState.swift
import Foundation

struct FavoriteState: ReduxState, Equatable {

    // MARK: - Property

    // MEMO: 読み込み中状態
    var isLoading: Bool = false
    // MEMO: エラー状態
    var isError: Bool = false

    // MEMO: Favorite画面で利用する情報として必要なViewObject情報
    var favoritePhotosCardViewObjects: [FavoritePhotosCardViewObject] = []

    // MARK: - Equatable

    static func == (lhs: FavoriteState, rhs: FavoriteState) -> Bool {
        return lhs.isLoading == rhs.isLoading
            && lhs.isError == rhs.isError
            && lhs.favoritePhotosCardViewObjects == rhs.favoritePhotosCardViewObjects
    }
}

【発行するAction】

FavoriteActions.swift
import Foundation

struct RequestFavoriteAction: Action {}

struct SuccessFavoriteAction: Action {
    let favoriteSceneEntities: [FavoriteSceneEntity]
}

struct FailureFavoriteAction: Action {}

【Middleware関数】

FavoriteMiddleware.swift
import Foundation

// MARK: - Function

// APIリクエスト結果に応じたActionを発行する
func favoriteMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            case let action as RequestFavoriteAction:
            // 👉 RequestFavoriteActionを受け取ったらその後にAPIリクエスト処理を実行する
            requestFavoriteScenes(action: action, dispatch: dispatch)
            default:
                break
        }
    }
}

// MARK: - Private Function

// 👉 APIリクエスト処理を実行するためのメソッド
private func requestFavoriteScenes(action: RequestFavoriteAction, dispatch: @escaping Dispatcher) {
    Task { @MainActor in
        do {
            let favoriteResponse = try await FavioriteRepositoryFactory.create().getFavioriteResponse()
            if let favoriteSceneResponse = favoriteResponse as? FavoriteSceneResponse {
                // お望みのレスポンスが取得できた場合は成功時のActionを発行する
                dispatch(SuccessFavoriteAction(favoriteSceneEntities: favoriteSceneResponse.result))
            } else {
                // お望みのレスポンスが取得できなかった場合はErrorをthrowして失敗時のActionを発行する
                throw APIError.error(message: "No FavoriteSceneResponse exists.")
            }
            dump(favoriteResponse)
        } catch APIError.error(let message) {
            // 通信エラーないしはお望みのレスポンスが取得できなかった場合は成功時のActionを発行する
            dispatch(FailureFavoriteAction())
            print(message)
        }
    }
}

【Repository層でのAPI非同期通信処理】

FavioriteRepository.swift
import Foundation

// MARK: - Protocol

protocol FavioriteRepository {
    func getFavioriteResponse() async throws -> FavoriteResponse
}

final class FavioriteRepositoryImpl: FavioriteRepository {

    // MARK: - Function

    func getFavioriteResponse() async throws -> FavoriteResponse {
        return try await ApiClientManager.shared.getFavoriteScenes()
    }    
}

// MARK: - Factory

struct FavioriteRepositoryFactory {
    static func create() -> FavioriteRepository {
        return FavioriteRepositoryImpl()
    }
}

【Reducer関数】

FavoriteReducer.swift
import Foundation

func favoriteReducer(_ state: FavoriteState, _ action: Action) -> FavoriteState {
    var state = state
    switch action {
    case _ as RequestFavoriteAction:
        state.isLoading = true
        state.isError = false
    case let action as SuccessFavoriteAction:
        // MEMO: 画面要素表示用
        state.favoritePhotosCardViewObjects = action.favoriteSceneEntities.map {
            FavoritePhotosCardViewObject(
                id: $0.id,
                photoUrl: URL(string: $0.photoUrl) ?? nil,
                author: $0.author,
                title: $0.title,
                category: $0.category,
                shopName: $0.shopName,
                comment: $0.comment,
                publishedAt: DateLabelFormatter.getDateStringFromAPI(apiDateString: $0.publishedAt)
            )
        }
        state.isLoading = false
        state.isError = false
    case _ as FailureFavoriteAction:
        state.isLoading = false
        state.isError = true
    default:
        break
    }
    return state
}

【画面View要素】

※スワイプ可能なCard型のUI表現については、CollectionViewPagingLayoutを利用しています。

OSSライブラリ「CollectionViewPagingLayout」を利用した表現

FavoriteScreenView.swift
import SwiftUI
import CollectionViewPagingLayout

struct FavoriteScreenView: View {

    // MARK: - Redux

    @EnvironmentObject var store: Store<AppState>

    private struct Props {
        // Immutableに扱うProperty 👉 画面状態管理用
        let isLoading: Bool
        let isError: Bool
        // Immutableに扱うProperty 👉 画面表示要素用
        let favoritePhotosCardViewObjects: [FavoritePhotosCardViewObject]
        // Action発行用のClosure
        let requestFavorite: () -> Void
        let retryFavorite: () -> Void
    }

    private func mapStateToProps(state: FavoriteState) -> Props {
        Props(
            isLoading: state.isLoading,
            isError: state.isError,
            favoritePhotosCardViewObjects: state.favoritePhotosCardViewObjects,
            requestFavorite: {
                store.dispatch(action: RequestFavoriteAction())
            },
            retryFavorite: {
                store.dispatch(action: RequestFavoriteAction())
            }
        )
    }

    // MARK: - Body

    var body: some View {
        // 該当画面で利用するState(ここではHomeState)をこの画面用のPropsにマッピングする
        let props = mapStateToProps(state: store.state.favoriteState)

        // 表示に必要な値をPropsから取得する
        let isLoading = mapToIsLoading(props: props)
        let isError = mapToIsError(props: props)

        // 画面用のPropsに応じた画面要素表示処理を実行する
        NavigationStack {
            Group {
                if isLoading {
                    // ローディング画面を表示
                    ExecutingConnectionView()
                } else if isError {
                    // エラー画面を表示
                    ConnectionErrorView(tapButtonAction: props.retryFavorite)
                } else {
                    // Favorite画面を表示
                    showFavoriteContentsView(props: props)
                }
            }
            .navigationTitle("Favorite")
            .navigationBarTitleDisplayMode(.inline)
            // 画面が表示された際に一度だけAPIリクエストを実行する形にしています。
            .onFirstAppear(props.requestFavorite)
        }
    }

    // MARK: - Private Function

    @ViewBuilder
    private func showFavoriteContentsView(props: Props) -> some View {
        // Propsから表示用のViewObjectを取り出す
        let favoritePhotosCardViewObjects = mapToFavoritePhotosCardViewObjects(props: props)
        FavoriteContentsView(favoritePhotosCardViewObjects: favoritePhotosCardViewObjects)
    }

    private func mapToFavoritePhotosCardViewObjects(props: Props) -> [FavoritePhotosCardViewObject] {
        return props.favoritePhotosCardViewObjects
    }

    private func mapToIsError(props: Props) -> Bool {
        return props.isError
    }

    private func mapToIsLoading(props: Props) -> Bool {
        return props.isLoading
    }
}

6. 他画面におけるRedux処理の流れと補足事項の解説

前述した画面以外も同様にRedux処理を利用している部分に関する処理で特徴的な部分にも触れておきます。画面の構造が複雑なので一見すると難しそうに見えるかもしれませんが、API非同期通信処理を利用した基本的な処理の流れについては、5-2. API非同期通信処理結果に応じた画面表示 と同様になります。

6-1. Home画面処理におけるRedux処理の流れとView構造で特徴的な部分

水平Carousel型のSection表現 WaterfallGrid型のSection表現
水平Carousel型のSection表現 WaterfallGrid型のSection表現

画面構造上は複雑なCarousel表示やGrid表示をするSectionが複数入っているので、一見すると複雑そうでSwiftUIで実装するのは骨が折れそうな印象を受けますが、ScrollViewDragGesture等をうまく活用する事で実現する事ができました。更に、Preview機能も有効活用する事で、仮のデータを適用して構造の変化を見て試しながら開発を進めたりしていくとより捗ると思います。

(※本サンプルでは、大量のデータを表示する画面ではないので、1つのScroll要素内に全てのSection要素を表示する形を取っていますが、画面要素表示の事情によってはUIKitで作る選択もありかと思います。)

また、各Section要素に対応するViewを構築する際の実装ポイントにつきましては、別途下記の記事で内容をまとめています。

https://qiita.com/fumiyasac@github/items/b5b313d9807ff858a73c

【画面に対応するState】

HomeState.swift
import Foundation

struct HomeState: ReduxState, Equatable {

    // MARK: - Property

    // MEMO: 読み込み中状態
    var isLoading: Bool = false
    // MEMO: エラー状態
    var isError: Bool = false

    // MEMO: Home画面で利用する情報として必要なViewObject情報
    // ※ このコードではViewObjectとView表示要素のComponentが1:1対応となる想定で作っています。
    var campaignBannerCarouselViewObjects: [CampaignBannerCarouselViewObject] = []
    var recentNewsCarouselViewObjects: [RecentNewsCarouselViewObject] = []
    var featuredTopicsCarouselViewObjects: [FeaturedTopicsCarouselViewObject] = []
    var trendArticlesGridViewObjects: [TrendArticlesGridViewObject] = []
    var pickupPhotosGridViewObjects: [PickupPhotosGridViewObject] = []
    
    // MARK: - Equatable

    static func == (lhs: HomeState, rhs: HomeState) -> Bool {
        return lhs.isLoading == rhs.isLoading
        && lhs.isError == rhs.isError
        && lhs.campaignBannerCarouselViewObjects == rhs.campaignBannerCarouselViewObjects
        && lhs.recentNewsCarouselViewObjects == rhs.recentNewsCarouselViewObjects
        && lhs.featuredTopicsCarouselViewObjects == rhs.featuredTopicsCarouselViewObjects
        && lhs.trendArticlesGridViewObjects == rhs.trendArticlesGridViewObjects
        && lhs.pickupPhotosGridViewObjects == rhs.pickupPhotosGridViewObjects
    }
}

【発行するAction】

HomeActions.swift
import Foundation

struct RequestHomeAction: Action {}

struct SuccessHomeAction: Action {
    let campaignBannerEntities: [CampaignBannerEntity]
    let recentNewsEntities: [RecentNewsEntity]
    let featuredTopicEntities: [FeaturedTopicEntity]
    let trendArticleEntities: [TrendArticleEntity]
    let pickupPhotoEntities: [PickupPhotoEntity]
}

struct FailureHomeAction: Action {}

【Middleware関数】

HomeMiddleware.swift
import Foundation

// MARK: - Typealias

// 👉 要素表示で利用するレスポンスをまとめるためのtypealias
// ※ convertHomeSectionResponse(homeResponses: [HomeResponse])の戻り値
typealias HomeSectionResponse = (
    campaignBannersResponse: CampaignBannersResponse,
    recentNewsResponse: RecentNewsResponse,
    featuredTopicsResponse: FeaturedTopicsResponse,
    trendArticleResponse: TrendArticleResponse,
    pickupPhotoResponse: PickupPhotoResponse
)

// MARK: - Function

// APIリクエスト結果に応じたActionを発行する
// ※テストコードの場合は検証用のhomeMiddlewareのものに差し替える想定
func homeMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            case let action as RequestHomeAction:
            // 👉 RequestHomeActionを受け取ったらその後にAPIリクエスト処理を実行する
            requestHomeSections(action: action, dispatch: dispatch)
            default:
                break
        }
    }
}

// MARK: - Private Function

// 👉 APIリクエスト処理を実行するためのメソッド
// ※テストコードの場合は想定するStubデータを返すものに差し替える想定
private func requestHomeSections(action: RequestHomeAction, dispatch: @escaping Dispatcher) {
    Task { @MainActor in
        do {
            let homeResponses = try await HomeRepositoryFactory.create().getHomeResponses()
            let homeSectionResponses = try convertHomeSectionResponse(homeResponses: homeResponses)
            // お望みのレスポンスが取得できた場合は成功時のActionを発行する
            dispatch(
                SuccessHomeAction(
                    campaignBannerEntities: homeSectionResponses.campaignBannersResponse.result,
                    recentNewsEntities: homeSectionResponses.recentNewsResponse.result,
                    featuredTopicEntities: homeSectionResponses.featuredTopicsResponse.result,
                    trendArticleEntities: homeSectionResponses.trendArticleResponse.result,
                    pickupPhotoEntities: homeSectionResponses.pickupPhotoResponse.result
                )
            )
            dump(homeResponses)
        } catch APIError.error(let message) {
            // 通信エラーないしはお望みのレスポンスが取得できなかった場合は成功時のActionを発行する
            dispatch(FailureHomeAction())
            print(message)
        }
    }
}

// MARK: - Private Function (Convert from [HomeResponse])

private func convertHomeSectionResponse(homeResponses: [HomeResponse]) throws -> HomeSectionResponse {
    var campaignBannersResponse: CampaignBannersResponse?
    var recentNewsResponse: RecentNewsResponse?
    var featuredTopicsResponse: FeaturedTopicsResponse?
    var trendArticleResponse: TrendArticleResponse?
    var pickupPhotoResponse: PickupPhotoResponse?
    // HomeResponseの中から該当するレスポンスを取り出す
    for homeResponse in homeResponses {
        if let targetCampaignBannersResponse = homeResponse as? CampaignBannersResponse {
            campaignBannersResponse = targetCampaignBannersResponse
        }
        if let targetRecentNewsResponse = homeResponse as? RecentNewsResponse {
            recentNewsResponse = targetRecentNewsResponse
        }
        if let targetFeaturedTopicsResponse = homeResponse as? FeaturedTopicsResponse {
            featuredTopicsResponse = targetFeaturedTopicsResponse
        }
        if let targetTrendArticleResponse = homeResponse as? TrendArticleResponse {
            trendArticleResponse = targetTrendArticleResponse
        }
        if let targetPickupPhotoResponse = homeResponse as? PickupPhotoResponse {
            pickupPhotoResponse = targetPickupPhotoResponse
        }
    }
    // MEMO: どれか1つのレスポンスでも欠けている様な状態ならばAPIErrorとして取り扱う
    guard let campaignBannersResponse = campaignBannersResponse,
          let recentNewsResponse = recentNewsResponse,
          let featuredTopicsResponse = featuredTopicsResponse,
          let trendArticleResponse = trendArticleResponse,
          let pickupPhotoResponse = pickupPhotoResponse else {
        throw APIError.error(message: "No HomeSectionResponse exists.")
    }
    return HomeSectionResponse(
        campaignBannersResponse: campaignBannersResponse,
        recentNewsResponse: recentNewsResponse,
        featuredTopicsResponse: featuredTopicsResponse,
        trendArticleResponse: trendArticleResponse,
        pickupPhotoResponse: pickupPhotoResponse
    )
}

【Repository層でのAPI非同期通信処理】

各Section表示要素を取得するためのAPIエンドポイントがそれぞれ準備されている形となっており、並列処理下であっても表示順番を担保するためにwithThrowingTaskGroupを利用している点にご注意ください。

(※こちらの処理については、RxSwiftを利用した場合のObservable.zipないしは、Combineを利用した場合のPublisher.zipとほとんど同様な処理をしているイメージを持って頂けると良さそうに思います。)

HomeRepository.swift
import Foundation

// MARK: - Protocol

protocol HomeRepository {
    func getHomeResponses() async throws -> [HomeResponse]
}

// MARK: - HomeRepositoryImpl

final class HomeRepositoryImpl: HomeRepository {

    // MARK: - Function

    func getHomeResponses() async throws -> [HomeResponse] {
        var responses: [HomeResponse] = []
        // 👉 エンドポイントの並び順を担保しながらの並列処理を実行する
        // ※ この場合は特に不要ではあるが、備忘録として実施しています。
        // 参考: https://zenn.dev/akkyie/articles/swift-concurrency#%E3%82%BF%E3%82%B9%E3%82%AF%E3%82%B0%E3%83%AB%E3%83%BC%E3%83%97-(task-group)
        try await withThrowingTaskGroup(of: (Int, HomeResponse).self, body: { group in
            var responseDictionary: [Int: HomeResponse] = [:]
            group.addTask {
                return (0, try await ApiClientManager.shared.getCampaignBanners())
            }
            group.addTask {
                return (1, try await ApiClientManager.shared.getRecentNews())
            }
            group.addTask {
                return (2, try await ApiClientManager.shared.getFeaturedTopics())
            }
            group.addTask {
                return (3, try await ApiClientManager.shared.getTrendArticles())
            }
            group.addTask {
                return (4, try await ApiClientManager.shared.getPickupPhotos())
            }
            // 👉 [Int: HomeResponse]のkey値の順番でレスポンスデータを格納する
            for try await (index, response) in group {
                responseDictionary[index] = response
            }
            for (_, response) in responseDictionary.sorted(by: { $0.key < $1.key }) {
                responses.append(response)
            }
            // 👉 エラーハンドリング処理(例. ここではレスポンスが全て空だった場合はエラーとみなす)
            if responses.isEmpty {
                throw APIError.error(message: "All Response about Home is Empty.")
            }
        })
        return responses
    }
}

// MARK: - Factory

struct HomeRepositoryFactory {
    static func create() -> HomeRepository {
        return HomeRepositoryImpl()
    }
}

【Reducer関数】

HomeRepository.swift
import Foundation

func homeReducer(_ state: HomeState, _ action: Action) -> HomeState {
    var state = state
    switch action {
    case _ as RequestHomeAction:
        state.isLoading = true
        state.isError = false
    case let action as SuccessHomeAction:
        // MEMO: 画面要素表示用
        state.campaignBannerCarouselViewObjects = action.campaignBannerEntities.map {
            CampaignBannerCarouselViewObject(
                id: $0.id,
                bannerContentsId: $0.bannerContentsId,
                bannerUrl: URL(string: $0.bannerUrl) ?? nil
            )
        }
        state.recentNewsCarouselViewObjects = action.recentNewsEntities.map {
            RecentNewsCarouselViewObject(
                id: $0.id,
                thumbnailUrl: URL(string: $0.thumbnailUrl) ?? nil,
                title: $0.title,
                newsCategory: $0.newsCategory,
                publishedAt: DateLabelFormatter.getDateStringFromAPI(apiDateString: $0.publishedAt)
            )
        }
        state.featuredTopicsCarouselViewObjects = action.featuredTopicEntities.map {
            FeaturedTopicsCarouselViewObject(
                id: $0.id,
                rating: $0.rating,
                thumbnailUrl: URL(string: $0.thumbnailUrl) ?? nil,
                title: $0.title,
                caption: $0.caption,
                publishedAt: DateLabelFormatter.getDateStringFromAPI(apiDateString: $0.publishedAt)
            )
        }
        state.trendArticlesGridViewObjects = action.trendArticleEntities.map {
            TrendArticlesGridViewObject(
                id: $0.id,
                thumbnailUrl: URL(string: $0.thumbnailUrl) ?? nil,
                title: $0.title,
                introduction:$0.introduction,
                publishedAt: DateLabelFormatter.getDateStringFromAPI(apiDateString: $0.publishedAt)
            )
        }
        state.pickupPhotosGridViewObjects = action.pickupPhotoEntities.map {
            PickupPhotosGridViewObject(
                id: $0.id,
                title: $0.title,
                caption: $0.caption,
                photoUrl: URL(string: $0.photoUrl) ?? nil,
                photoWidth: CGFloat($0.photoWidth),
                photoHeight: CGFloat($0.photoHeight)
            )
        }
        // MEMO: 画面表示ハンドリング用
        state.isLoading = false
        state.isError = false
    case _ as FailureHomeAction:
        state.isLoading = false
        state.isError = true
    default:
        break
    }
    return state
}

【画面View要素】

Home画面における各Section表示部分のView構造の概要は下記の通りになります。DragGestureScrollViewの併用した応用的な画面になりますが、実は意外にも現在SwiftUIで利用できるものを上手に組み合わせる事で実現できる表現もあると思います。

Drag処理に伴って回転&奥行きのある無限循環型Carouselを実現するDragGesture活用例

Drag処理と連動した中央寄せ型のCarouselを実現するDragGesture活用例

LazyHStackとScrollViewで作成するシンプルなCarousel表現

基本的なLazyVGridを利用したGrid

HStackと2つのVStackを並べて合計の高さを基準としたロジックを元に構築したGrid

HomeScreenView.swift
import SwiftUI

struct HomeScreenView: View {

    // MARK: - Redux

    @EnvironmentObject var store: Store<AppState>

    private struct Props {
        // Immutableに扱うProperty 👉 画面状態管理用
        let isLoading: Bool
        let isError: Bool
        // Immutableに扱うProperty 👉 画面表示要素用
        let campaignBannerCarouselViewObjects: [CampaignBannerCarouselViewObject]
        let recentNewsCarouselViewObjects: [RecentNewsCarouselViewObject]
        let featuredTopicsCarouselViewObjects: [FeaturedTopicsCarouselViewObject]
        let trendArticlesGridViewObjects: [TrendArticlesGridViewObject]
        let pickupPhotosGridViewObjects: [PickupPhotosGridViewObject]
        // Action発行用のClosure
        let requestHome: () -> Void
        let retryHome: () -> Void
    }

    private func mapStateToProps(state: HomeState) -> Props {
        Props(
            isLoading: state.isLoading,
            isError: state.isError,
            campaignBannerCarouselViewObjects: state.campaignBannerCarouselViewObjects,
            recentNewsCarouselViewObjects: state.recentNewsCarouselViewObjects,
            featuredTopicsCarouselViewObjects: state.featuredTopicsCarouselViewObjects,
            trendArticlesGridViewObjects: state.trendArticlesGridViewObjects,
            pickupPhotosGridViewObjects: state.pickupPhotosGridViewObjects,
            requestHome: {
                store.dispatch(action: RequestHomeAction())
            },
            retryHome: {
                store.dispatch(action: RequestHomeAction())
            }
        )
    }

    // MARK: - body

    var body: some View {
        // 該当画面で利用するState(ここではHomeState)をこの画面用のPropsにマッピングする
        let props = mapStateToProps(state: store.state.homeState)

        // 表示に必要な値をPropsから取得する
        let isLoading = mapToIsLoading(props: props)
        let isError = mapToIsError(props: props)

        // 画面用のPropsに応じた画面要素表示処理を実行する
        NavigationStack {
            Group {
                if isLoading {
                    // ローディング画面を表示
                    ExecutingConnectionView()
                } else if isError {
                    // エラー画面を表示
                    ConnectionErrorView(tapButtonAction: props.retryHome)
                } else {
                    // HomeContentsView(それぞれのSection要素を集約している画面要素)を表示
                    showHomeContentsView(props: props)
                }
            }
            .navigationTitle("Home")
            .navigationBarTitleDisplayMode(.inline)
            // 画面が表示された際に一度だけAPIリクエストを実行する形にしています。
            .onFirstAppear(props.requestHome)
        }
    }

    // MARK: - Private Function

    @ViewBuilder
    private func showHomeContentsView(props: Props) -> some View {
        // Propsから各Section表示用のViewObjectを取り出す
        let campaignBannerCarouselViewObjects = mapToCampaignBannerCarouselViewObjects(props: props)
        let recentNewsCarouselViewObjects = mapToRecentNewsCarouselViewObjects(props: props)
        let featuredTopicsCarouselViewObjects = mapToFeaturedTopicsCarouselViewObjects(props: props)
        let trendArticlesGridViewObjects = mapToTrendArticlesGridViewObjects(props: props)
        let pickupPhotoGridViewObjects = mapToPickupPhotosGridViewObjects(props: props)
        // 各Sectionに該当するView要素に表示に必要なViewObjectを反映する
        HomeContentsView(
            campaignBannerCarouselViewObjects: campaignBannerCarouselViewObjects,
            recentNewsCarouselViewObjects: recentNewsCarouselViewObjects,
            featuredTopicsCarouselViewObjects: featuredTopicsCarouselViewObjects,
            trendArticlesGridViewObjects: trendArticlesGridViewObjects,
            pickupPhotosGridViewObjects: pickupPhotoGridViewObjects
        )
    }

    private func mapToCampaignBannerCarouselViewObjects(props: Props) -> [CampaignBannerCarouselViewObject] {
        return props.campaignBannerCarouselViewObjects
    }

        // 👉 残りの必要な値も同様に、`mapTo●●●●(props: Props)`の様なメソッドを作成して、必要なState内の値を割り当てる
    // ... 処理の詳細については割愛 ...
}

6-2. Profile画面処理におけるView構造で特徴的な部分

GeometryReaderを利用した表現 簡易的なタブ切り替え表現
簡易的なタブ切り替え表現 GeometryReaderを利用した表現

スクロール量に応じてヘッダー部分に配置したサムネイル画像の拡大縮小比が変化する表現(Streachy Headerとも呼ばれる表現)や、タブ型のUIを利用した表示コンテンツを切り替える表現については、UIKitを利用した場合でも一手間が必要な表現の事例になると思います。

SwiftUIを上手に利用することで、View要素の設計次第では、シンプルなものであればUIKitを利用する場合に比べてわかりやすい形で構築可能な場合もあるかと思います。

Profile画面における特徴的なUI表現をする部分

GeometryReaderを使用したStreachy Headerを実現する方法を解説した記事については、英語にはなりますが下記の記事が参考になりました。

https://medium.com/swlh/swiftui-create-a-stretchable-header-with-parallax-scrolling-4a98faeeb262

※ 今回は記事の関係で、Redux処理部分はHome画面表示とほぼ同様の方針なので割愛し、画面View要素のみを記載しています。

【画面View要素】

ProfileScreenView.swift
import SwiftUI

struct ProfileScreenView: View {

    // MARK: - Redux

    @EnvironmentObject var store: Store<AppState>

    private struct Props {
        // Immutableに扱うProperty 👉 画面状態管理用
        let isLoading: Bool
        let isError: Bool
        // Immutableに扱うProperty 👉 画面表示要素用
        let backgroundImageUrl: URL?
        let profilePersonalViewObject: ProfilePersonalViewObject?
        let profileSelfIntroductionViewObject: ProfileSelfIntroductionViewObject?
        let profilePointsAndHistoryViewObject: ProfilePointsAndHistoryViewObject?
        let profileSocialMediaViewObject: ProfileSocialMediaViewObject?
        let profileInformationViewObject: ProfileInformationViewObject?
        // Action発行用のClosure
        let requestProfile: () -> Void
        let retryProfile: () -> Void
    }

    private func mapStateToProps(state: ProfileState) -> Props {
        Props(
            isLoading: state.isLoading,
            isError: state.isError,
            backgroundImageUrl: state.backgroundImageUrl,
            profilePersonalViewObject: state.profilePersonalViewObject,
            profileSelfIntroductionViewObject: state.profileSelfIntroductionViewObject,
            profilePointsAndHistoryViewObject: state.profilePointsAndHistoryViewObject,
            profileSocialMediaViewObject: state.profileSocialMediaViewObject,
            profileInformationViewObject: state.profileInformationViewObject,
            requestProfile: {
                store.dispatch(action: RequestProfileAction())
            },
            retryProfile: {
                store.dispatch(action: RequestProfileAction())
            }
        )
    }

    // MARK: - body

    var body: some View {
        // 該当画面で利用するState(ここではHomeState)をこの画面用のPropsにマッピングする
        let props = mapStateToProps(state: store.state.profileState)

        // 表示に必要な値をPropsから取得する
        let isLoading = mapToIsLoading(props: props)
        let isError = mapToIsError(props: props)

        // 画面用のPropsに応じた画面要素表示処理を実行する
        NavigationStack {
            Group {
                if isLoading {
                    // ローディング画面を表示
                    ExecutingConnectionView()
                } else if isError {
                    // エラー画面を表示
                    ConnectionErrorView(tapButtonAction: props.retryProfile)
                } else {
                    // ProfileContentsView(それぞれのSection要素を集約している画面要素)を表示
                    showProfileContentsView(props: props)
                }
            }
            .navigationTitle("Profile")
            .navigationBarTitleDisplayMode(.inline)
            // 👉 SafeAreaまで表示領域を伸ばす(これをするとサムネイル画像が綺麗に収まる)
            .edgesIgnoringSafeArea(.top)
            // 👉 NavigationBarを隠すか否か際の設定
            // ※ GeometryReaderを用いたParallax表現時には、NavigationBarで上部が隠れてしまうため、この様な形としています。
            .navigationBarHidden(true)
            // 画面が表示された際に一度だけAPIリクエストを実行する形にしています。
            .onFirstAppear(props.requestProfile)
        }
    }

    // MARK: - Private Function

    @ViewBuilder
    private func showProfileContentsView(props: Props) -> some View {
        // Propsから各Section表示用のViewObjectを取り出す
        if let backgroundImageUrl = mapToBackgroundImageUrl(props: props),
           let profilePersonalViewObject = mapToProfilePersonalViewObject(props: props),
           let profileSelfIntroductionViewObject = mapToProfileSelfIntroductionViewObject(props: props),
           let profilePointsAndHistoryViewObject = mapToProfilePointsAndHistoryViewObject(props: props),
           let profileSocialMediaViewObject = mapToProfileSocialMediaViewObject(props: props),
           let profileInformationViewObject = mapToProfileInformationViewObject(props: props) {
            // 表示に必要な要素がすべて揃っていた場合はProfileContentsViewを表示させる
            ProfileContentsView(
                backgroundImageUrl: backgroundImageUrl,
                profilePersonalViewObject: profilePersonalViewObject,
                profileSelfIntroductionViewObject: profileSelfIntroductionViewObject,
                profilePointsAndHistoryViewObject: profilePointsAndHistoryViewObject,
                profileSocialMediaViewObject: profileSocialMediaViewObject,
                profileInformationViewObject: profileInformationViewObject
            )
        } else {
            // 少なくとも1つnilになる物があった場合はEmptyViewを表示させる
            VStack {
                EmptyView()
            }
        }
    }

    private func mapToBackgroundImageUrl(props: Props) -> URL? {
        return props.backgroundImageUrl
    }

        // 👉 残りの必要な値も同様に、`mapTo●●●●(props: Props)`の様なメソッドを作成して、必要なState内の値を割り当てる
    // ... 処理の詳細については割愛 ...
}

6-3. 絞り込み検索画面から見るRedux処理のポイント部分の抜粋

特に何も検索条件を指定しない時 検索キーワード指定&カテゴリーで絞り込んだ時
特に何も検索条件を指定しない時 検索キーワード指定&カテゴリーで絞り込んだ時

この画面については、自由入力可能な検索キーワードとテキストフィールド下に横一列に並んでいるカテゴリーを選択する事で、一覧表示される内容を条件に合致するものだけに絞り込む機能を持っています。

また、一覧表示している部分については、右上のハートマークを押下するとアプリ内部のデータベース(本サンプルではRealmを利用しています)に保存される機能も持っています。

API非同期通信処理とデータ永続化処理を組み合わせる様な処理は、状態管理設計が難しい画面の1つになりますが、本サンプルでは下記の図解に示している様な方針で画面表示に関する処理を組み立てています。

Archive画面における特徴的なUI表現をする部分

※ 今回は記事の関係で、画面に対応するState / Middleware関数 / Reducer関数 / 画面View要素を記載しています。

【画面に対応するState】

HomeState.swift
import Foundation

struct ArchiveState: ReduxState, Equatable {

    // MARK: - Property

    // MEMO: 読み込み中状態
    var isLoading: Bool = false
    // MEMO: エラー状態
    var isError: Bool = false

    // MEMO: 検索用に必要なパラメーター値
    var inputText: String = ""
    var selectedCategory: String = ""

    // MEMO: Archive画面で利用する情報として必要なViewObject情報
    // ※ このコードではViewObjectとView表示要素のComponentが1:1対応となる想定で作っています。
    var archiveCellViewObjects: [ArchiveCellViewObject] = []

    // MARK: - Equatable

    static func == (lhs: ArchiveState, rhs: ArchiveState) -> Bool {
        return lhs.isLoading == rhs.isLoading
        && lhs.isError == rhs.isError
        && lhs.inputText == rhs.inputText
        && lhs.selectedCategory == rhs.selectedCategory
        && lhs.archiveCellViewObjects == rhs.archiveCellViewObjects
    }
}

【Middleware関数】

ArchiveMiddleware.swift
import Foundation

// MARK: - Function (Production)

// APIリクエスト結果に応じたActionを発行する
func archiveMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            // 👉 選択カテゴリー・入力テキスト値の変更を受け取ったらその後にAPIリクエスト処理を実行する
            // 複合条件の処理をするために現在Stateに格納されている値も利用する
            case let action as RequestArchiveWithInputTextAction:
            let selectedCategory = state.archiveState.selectedCategory
            requestArchiveScenes(
                inputText: action.inputText,
                selectedCategory: selectedCategory,
                dispatch: dispatch
            )
            case let action as RequestArchiveWithSelectedCategoryAction:
            let inputText = state.archiveState.inputText
            requestArchiveScenes(
                inputText: inputText,
                selectedCategory: action.selectedCategory,
                dispatch: dispatch
            )
            case _ as RequestArchiveWithNoConditionsAction:
            requestArchiveScenes(
                inputText: "",
                selectedCategory: "",
                dispatch: dispatch
            )
            default:
                break
        }
    }
}

// Archiveデータ登録処理を実行する
func addArchiveObjectMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            case let action as AddArchiveObjectAction:
            addArchiveObjectToRealm(archiveCellViewObject: action.archiveCellViewObject)
            default:
                break
        }
    }
}

// Archiveデータ削除処理を実行する
func deleteArchiveObjectMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            case let action as DeleteArchiveObjectAction:
            deleteArchiveObjectFromRealm(archiveCellViewObject: action.archiveCellViewObject)
            default:
                break
        }
    }
}

// MARK: - Private Function (Production)

// 👉 APIリクエスト処理を実行するためのメソッド
private func requestArchiveScenes(inputText: String, selectedCategory: String, dispatch: @escaping Dispatcher) {
    Task { @MainActor in
        do {
            // 👉 Realm内に登録されているデータのIDだけを詰め込んだ配列に変換する
            let storedIds = StoredArchiveDataRepositoryFactory.create().getAllObjectsFromRealm()
                .map { $0.id }
            // 👉 Realm内に登録されているデータのIDだけを詰め込んだ配列に変換する
            // 🌟 最終的にViewObjectに変換をするのはArchiveReducerで実行する
            let archiveResponse = try await RequestArchiveRepositoryFactory.create().getArchiveResponse(keyword: inputText, category: selectedCategory)
            if let archiveSceneResponse = archiveResponse as? ArchiveSceneResponse {
                // お望みのレスポンスが取得できた場合は成功時のActionを発行する
                dispatch(
                    SuccessArchiveAction(
                        archiveSceneEntities: archiveSceneResponse.result,
                        storedIds: storedIds
                    )
                )
            } else {
                // お望みのレスポンスが取得できなかった場合はErrorをthrowして失敗時のActionを発行する
                throw APIError.error(message: "No FavoriteSceneResponse exists.")
            }
        } catch APIError.error(let message) {
            // 通信エラーないしはお望みのレスポンスが取得できなかった場合は成功時のActionを発行する
            dispatch(FailureArchiveAction())
        }
    }
}

// 👉 対象のViewObjectの内容をRealmへ登録するためのメソッド
private func addArchiveObjectToRealm(archiveCellViewObject: ArchiveCellViewObject) {
    StoredArchiveDataRepositoryFactory.create().createToRealm(archiveCellViewObject: archiveCellViewObject)
}

// 👉 対象のViewObjectの内容をRealmから削除するためのメソッド
private func deleteArchiveObjectFromRealm(archiveCellViewObject: ArchiveCellViewObject) {
    StoredArchiveDataRepositoryFactory.create().deleteFromRealm(archiveCellViewObject: archiveCellViewObject)
}

【Reducer関数】

ArchiveReducer.swift
func archiveReducer(_ state: ArchiveState, _ action: Action) -> ArchiveState {
    var state = state
    switch action {
    case let action as RequestArchiveWithInputTextAction:
        state.isLoading = true
        state.isError = false
        state.inputText = action.inputText
    case let action as RequestArchiveWithSelectedCategoryAction:
        state.isLoading = true
        state.isError = false
        state.selectedCategory = action.selectedCategory
    case _ as RequestArchiveWithNoConditionsAction:
        state.isLoading = true
        state.isError = false
        state.inputText = ""
        state.selectedCategory = ""
    case let action as SuccessArchiveAction:
        // MEMO: 画面要素表示用
        let storedIds = action.storedIds
        state.archiveCellViewObjects = action.archiveSceneEntities.map {
            ArchiveCellViewObject(
                id: $0.id,
                photoUrl: URL(string: $0.photoUrl) ?? nil,
                category: $0.category,
                dishName: $0.dishName,
                shopName: $0.shopName,
                introduction: $0.introduction,
                isStored: storedIds.contains($0.id)
            )
        }
        state.isLoading = false
        state.isError = false
    case _ as FailureArchiveAction:
        state.isLoading = false
        state.isError = true
    default:
        break
    }
    return state
}

【画面View要素】

ArchiveScreenView.swift
struct ArchiveScreenView: View {

    // MARK: - Redux

    @EnvironmentObject var store: Store<AppState>

    private struct Props {
        // Immutableに扱うProperty 👉 画面状態管理用
        let isLoading: Bool
        let isError: Bool
        let inputText: String
        let selectedCategory: String
        // Immutableに扱うProperty 👉 画面表示要素用
        let archiveCellViewObjects: [ArchiveCellViewObject]
        // Action発行用のClosure
        let requestArchiveWithSelecedCategory: (String) -> Void
        let requestArchiveWithInputText: (String) -> Void
        let requestArchiveWithNoConditions: () -> Void
        let addToDatabase: (ArchiveCellViewObject) -> Void
        let removeFromDatabase: (ArchiveCellViewObject) -> Void
        let requestArchive: () -> Void
        let retryArchive: () -> Void
    }

    private func mapStateToProps(state: ArchiveState) -> Props {
        Props(
            isLoading: state.isLoading,
            isError: state.isError,
            inputText: state.inputText,
            selectedCategory: state.selectedCategory,
            archiveCellViewObjects: state.archiveCellViewObjects,
            requestArchiveWithSelecedCategory: { selectedCategory in
                store.dispatch(action: RequestArchiveWithSelectedCategoryAction(selectedCategory: selectedCategory))
            },
            requestArchiveWithInputText: { inputText in
                store.dispatch(action: RequestArchiveWithInputTextAction(inputText: inputText))
            },
            requestArchiveWithNoConditions: {
                store.dispatch(action: RequestArchiveWithNoConditionsAction())
            },
            addToDatabase: { archiveCellViewObject in
                store.dispatch(action: AddArchiveObjectAction(archiveCellViewObject: archiveCellViewObject))
            },
            removeFromDatabase: { archiveCellViewObject in
                store.dispatch(action: DeleteArchiveObjectAction(archiveCellViewObject: archiveCellViewObject))
            },
            requestArchive: {
                store.dispatch(action: RequestArchiveWithNoConditionsAction())
            },
            retryArchive: {
                store.dispatch(action: RequestArchiveWithNoConditionsAction())
            }
        )
    }

    // MARK: - Body

    var body: some View {
        // 該当画面で利用するState(ここではArchiveState)をこの画面用のPropsにマッピングする
        let props = mapStateToProps(state: store.state.archiveState)

        // 表示に必要な値をPropsから取得する
        let isLoading = mapToIsLoading(props: props)
        let isError = mapToIsError(props: props)

        // 画面用のPropsに応じた画面要素表示処理を実行する
        NavigationStack {
            VStack(spacing: 0.0) {
                // (1) 検索機能部分
                Group {
                    showArchiveFreewordView(props: props)
                    showArchiveCategoryView(props: props)
                    showArchiveCurrentCountView(props: props)
                }
                // (2) 一覧データ表示部分
                Group {
                    if isLoading {
                        // ローディング画面を表示
                        ExecutingConnectionView()
                    } else if isError {
                        // エラー画面を表示
                        ConnectionErrorView(tapButtonAction: props.retryArchive)
                    } else {
                        // ArchiveContentsViewを表示
                        showArchiveContentsView(props: props)
                    }
                }
            }
            .navigationTitle("Archive")
            .navigationBarTitleDisplayMode(.inline)
            // 画面が表示された際に一度だけAPIリクエストを実行する形にしています。
            .onFirstAppear(props.requestArchive)
        }
    }
    
    // MARK: - Private Function

    @ViewBuilder
    private func showArchiveFreewordView(props: Props) -> some View {
        let isLoading = mapToIsLoading(props: props)
        let inputText = mapToInputText(props: props)
        ArchiveFreewordView(
            inputText: inputText,
            isLoading: isLoading,
            submitAction: { text in
                props.requestArchiveWithInputText(text)
            },
            cancelAction: {
                props.requestArchiveWithInputText("")
            }
        )
        // 👉 ここはクリアボタン押下時にTextFieldの中身をリセットしたいが為にやむなくこの形にしています...😢
        // 参考: https://swiftui-lab.com/swiftui-id/
        .id(UUID())
    }

    @ViewBuilder
    private func showArchiveCategoryView(props: Props) -> some View {
        let selectedCategory = mapToSelectedCategory(props: props)
        ArchiveCategoryView(
            selectedCategory: selectedCategory,
            tapCategoryChipAction: { category in
                props.requestArchiveWithSelecedCategory(category)
            }
        )
    }

    @ViewBuilder
    private func showArchiveCurrentCountView(props: Props) -> some View {
        let currentCount = mapToCurrentCount(props: props)
        ArchiveCurrentCountView(
            currentCount: currentCount,
            tapAllClearAction: {
                props.requestArchiveWithNoConditions()
                // 👉 キーボードを閉じるための処理(リセットボタンを押す際には全ての条件検索キャンセルされたとみなす)
                UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
            }
        )
    }

    @ViewBuilder
    private func showArchiveContentsView(props: Props) -> some View {
        // Propsから表示用のViewObjectを取り出す
        let archiveCellViewObjects = mapToArchiveCellViewObjects(props: props)
        let targetKeyword = mapToInputText(props: props)
        let targetCategory = mapToSelectedCategory(props: props)
        let currentCount = mapToCurrentCount(props: props)

        if currentCount == 0 {
            ArchiveEmptyView()
        } else {
            ArchiveContentsView(
                archiveCellViewObjects: archiveCellViewObjects,
                targetKeyword: targetKeyword,
                targetCategory: targetCategory,
                tapIsStoredButtonAction: { viewObject, isStored in
                    if isStored {
                        props.addToDatabase(viewObject)
                    } else {
                        props.removeFromDatabase(viewObject)
                    }
                }
            )
        }
    }

    private func mapToCurrentCount(props: Props) -> Int {
        return props.archiveCellViewObjects.count
    }

        // 👉 残りの必要な値も同様に、`mapTo●●●●(props: Props)`の様なメソッドを作成して、必要なState内の値を割り当てる
    // ... 処理の詳細については割愛 ...
}

【補足: お気に入り状態可否ボタンを持つView要素部分】

お気に入り状態を変更するボタンを押下した際の処理についてはTapIsStoredButtonAction = (Bool) -> Voidで定義されているClosureを介して、お気に入り状態に登録または解除をするActionを発行し、ArchiveMiddleware内にて下記の処理を実行する様な流れとなります。

  • 登録時: func addArchiveObjectToRealm(archiveCellViewObject: ArchiveCellViewObject)
  • 削除時: func deleteArchiveObjectFromRealm(archiveCellViewObject: ArchiveCellViewObject)

お気に入り登録または削除処理が実行された際は、今回の仕様においてはView全体のレンダリング処理は特に不要ではあったので、対象のCell要素におけるViewの状態を更新する、すなわち 「登録or削除を実行した際はArchiveCellView.swift内の@State private var isStored: Boolの値を更新してこのView要素でのみ再度Viewのレンダリング処理を実行して状態を反映する」 という方針を取っています。

※この部分については、画面の整合性をとるためにこれまでのRedux処理と比較すると少しイレギュラーな形でもあります。

import SwiftUI
import Kingfisher

struct ArchiveCellView: View {

    // MARK: - Typealias

    typealias TapIsStoredButtonAction = (Bool) -> Void

    // MARK: - Property

    // ※ View要素のStyle定義をするためのPropery定義は省略しています。

    private var viewObject: ArchiveCellViewObject
    private var targetKeyword: String
    private var targetCategory: String
    private var tapIsStoredButtonAction: ArchiveCellView.TapIsStoredButtonAction

    // Favoriteボタン(ハート型ボタン要素)の状態を管理するための変数
    @State private var isStored: Bool = false

    // MARK: - Initializer

    init(
        viewObject: ArchiveCellViewObject,
        targetKeyword: String,
        targetCategory: String,
        tapIsStoredButtonAction: @escaping ArchiveCellView.TapIsStoredButtonAction
    ) {
        self.viewObject = viewObject
        self.targetKeyword = targetKeyword
        self.targetCategory = targetCategory
        self.tapIsStoredButtonAction = tapIsStoredButtonAction
        
        // イニシャライザ内で「_(変数名)」値を代入することでState値の初期化を実行する
        _isStored = State(initialValue: viewObject.isStored)
    }

    // MARK: - Body

    var body: some View {
        VStack(alignment: .leading, spacing: 0.0) {
            // 1. メインの情報表示部分
            HStack(spacing: 0.0) {
                // 1-(1). サムネイル用画像表示
                KFImage(viewObject.photoUrl)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .clipped()
                    .cornerRadius(4.0)
                    .frame(width: 64.0, height: 64.0)
                    .background(
                        RoundedRectangle(cornerRadius: 4.0)
                            .stroke(cellThumbnailRoundRectangleColor)
                    )
                // 1-(2). 基本情報表示
                VStack(alignment: .leading) {
                    // 1-(2)-①. 料理名表示
                    Text(getAttributeBy(taregtText: viewObject.dishName, targetKeyword: targetKeyword))
                        .font(cellTitleFont)
                        .foregroundColor(cellTitleColor)
                    // 1-(2)-②. 料理カテゴリー表示
                    Group {
                        Text("Category: ") + Text(getAttributeBy(taregtText: viewObject.category, targetCategory: targetCategory))
                    }
                    .font(cellCategoryFont)
                    .foregroundColor(cellCategoryColor)
                    .padding([.top], -8.0)
                    // 1-(2)-③. お店名表示
                    Group {
                        Text("Shop: ") + Text(getAttributeBy(taregtText: viewObject.shopName, targetKeyword: targetKeyword))
                    }
                    .font(cellShopNameFont)
                    .foregroundColor(cellShopNameColor)
                    .padding([.top], -8.0)
                }
                .padding([.leading], 12.0)
                // 1-(3). Spacer
                Spacer()
                // 1-(4). お気に入りボタン
                Button(action: {
                    // 処理概要
                    // 👉 引き渡されたViewObject(ArchiveCellViewObject)のisStoredを @State に入れる
                    // 👉 ButtonがタップされたらisStoredのBool値が反転する
                    // 👉 このViewの @State が更新されるのでこのView内のお気に入り要素が変化する(全体の再レンダリングは実施しない)
                    isStored = !isStored
                    tapIsStoredButtonAction(isStored)
                }, label: {
                    if isStored {
                        Image(systemName: "heart.fill")
                    } else {
                        Image(systemName: "heart")
                    }
                })
                .foregroundColor(cellStockActiveButtonColor)
                .buttonStyle(PlainButtonStyle())
                .frame(width: 24.0, height: 32.0)
            }
            // 2. 概要テキストの情報表示部分
            HStack(spacing: 0.0) {
                Text(getAttributeBy(taregtText: viewObject.introduction, targetKeyword: targetKeyword))
                    .font(cellIntroductionFont)
                    .foregroundColor(cellIntroductionColor)
                    .padding([.vertical], 6.0)

            }
            // 3. 下側Divider
            Divider()
                .background(cellBorderColor)
        }
        .padding([.top], 4.0)
        .padding([.leading, .trailing], 12.0)
    }

    // MARK: - Private Function

    // 対象キーワードが含まれている文字列に対してテキストハイライトを指定する(json-serverの仕様に則った検索)
    // 参考: https://ios-docs.dev/attributedstring/
    private func getAttributeBy(taregtText: String, targetKeyword: String) -> AttributedString {
        var attributedString = AttributedString(taregtText)
        if let range = attributedString.range(of: targetKeyword) {
            attributedString[range].foregroundColor = highlightTextColor
            attributedString[range].backgroundColor = highlightTextKeywordBackgroundColor
        }
        return attributedString
    }

    private func getAttributeBy(taregtText: String, targetCategory: String) -> AttributedString {
        var attributedString = AttributedString(taregtText)
        if let range = attributedString.range(of: targetCategory) {
            attributedString[range].foregroundColor = highlightTextColor
            attributedString[range].backgroundColor = highlightTextCategoryBackgroundColor
        }
        return attributedString
    }
}

7. UnitTestを利用して各画面に対応するStateの変化を確認する

本サンプルでは、画面状態管理のおおもとになるStore.swift内で各画面に対応するStateをまとめて管理しているAppState部分については@Publishedを利用し、かつImmutableな形で定義されているため、State変化処理が正しく実行されている事を確認するUnitTestのために下記の2点を踏まえた形にしました。

  1. Middleware自体及びその内部で利用するRepository処理をMock化して利用する。
  2. @Publishedの値変化を確認するためにCombineの変化を取得可能な状態にする。

よって、UnitTestの基本形はQuickNimbleを利用し、「初期State → Action発行 → API処理が伴う部分ではMiddleware処理実行時に準ずるActionを発行 → 新規State」 とすることで、Reducerでの処理が正しく実行されているかを見る方針としました。

また、各画面に対応するStateの変化を見るために、「CombineExpectations」 というOSSを利用して@Publishedの前後の値変化を記録している点がポイントになるかと思います。

(※本サンプルではほとんどがAPI非同期通信処理が中心のものなので、変化後の値が正しい事が確認できればOKとしています。)

https://github.com/groue/CombineExpectations

7-1. Repository層&Middleware層で適用するMockに関して

Preview画面やUnitTest実行コードでは、API通信部分やデータ永続化が関係するMiddleware層&Repository層の処理については、実際の振る舞いを模したMock用のクラスやメソッドを別途に用意して適用しています。

Home画面で利用するMiddleware関数&Repositoryクラスに関するMock事例

Home画面については実質的には、API非同期通信処理結果で画面表示が決まる画面なので、成功時と失敗時の2通りを考えれば良さそうに思います。

HomeRepository.swiftのMock化】

HomeRepository.swift
import Foundation

// MARK: - Protocol

protocol HomeRepository {
    func getHomeResponses() async throws -> [HomeResponse]
}

// MARK: - HomeRepositoryImpl

final class HomeRepositoryImpl: HomeRepository {

    // MARK: - Function

    func getHomeResponses() async throws -> [HomeResponse] {
        var responses: [HomeResponse] = []
        // 👉 async/awaitを利用してAPI非同期通信処理を実行する
        // ... 処理の詳細については割愛 ...
        return responses
    }
}

// MARK: - MockSuccessHomeRepositoryImpl

final class MockSuccessHomeRepositoryImpl: HomeRepository {

    // MARK: - Function

    func getHomeResponses() async throws -> [HomeResponse] {
        // 👉 Project内にBundleしたJSONデータを取得する
	// ... 処理の詳細については割愛 ...
	return [
            getCampaignBannersResponse(),
            getRecentNewsResponse(),
            getFeaturedTopicsResponse(),
            getTrendArticleResponse(),
            getPickupPhotoResponse()
        ]
    }
}

// MARK: - Factory

struct HomeRepositoryFactory {
    static func create() -> HomeRepository {
        return HomeRepositoryImpl()
    }
}

struct MockSuccessHomeRepositoryFactory {
    static func create() -> HomeRepository {
        return MockSuccessHomeRepositoryImpl()
    }
}

HomeMiddleware.swiftで定義したMock関数】

※ こちらはPreview画面表示でも利用しているので、成功&失敗用それぞれの関数を定義しています。

HomeMiddleware.swift
import Foundation

// MARK: - Function (Mock for Success)

// テストコードで利用するAPIリクエスト結果に応じたActionを発行する(Success時)
func homeMockSuccessMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            case let action as RequestHomeAction:
            mockSuccessRequestHomeSections(action: action, dispatch: dispatch)
            default:
                break
        }
    }
}

// MARK: - Function (Mock for Failure)

// テストコードで利用するAPIリクエスト結果に応じたActionを発行する(Failure時)
func homeMockFailureMiddleware() -> Middleware<AppState> {
    return { state, action, dispatch in
        switch action {
            case let action as RequestHomeAction:
            mockFailureRequestHomeSections(action: action, dispatch: dispatch)
            default:
                break
        }
    }
}

// MARK: - Private Function (Dispatch Action Success/Failure)

// 👉 成功時のAPIリクエストを想定した処理を実行するためのメソッド
private func mockSuccessRequestHomeSections(action: RequestHomeAction, dispatch: @escaping Dispatcher) {
    Task { @MainActor in
        let _ = try await Task.sleep(for: .seconds(0.64))
        let homeResponses = try await MockSuccessHomeRepositoryFactory.create().getHomeResponses()
        let homeSectionResponses = try convertHomeSectionResponse(homeResponses: homeResponses)
        dispatch(
            SuccessHomeAction(
                campaignBannerEntities: homeSectionResponses.campaignBannersResponse.result,
                recentNewsEntities: homeSectionResponses.recentNewsResponse.result,
                featuredTopicEntities: homeSectionResponses.featuredTopicsResponse.result,
                trendArticleEntities: homeSectionResponses.trendArticleResponse.result,
                pickupPhotoEntities: homeSectionResponses.pickupPhotoResponse.result
            )
        )
    }
}

// 👉 失敗時のAPIリクエストを想定した処理を実行するためのメソッド
private func mockFailureRequestHomeSections(action: RequestHomeAction, dispatch: @escaping Dispatcher) {
    Task { @MainActor in
        let _ = try await Task.sleep(for: .seconds(0.64))
        dispatch(FailureHomeAction())
    }
}

7-2. Home画面で利用するState変化のテストコード例

【HomeStateの変化を確認するテストコード例】

HomeStateTest.swift
@testable import SwiftUIAndReduxExample

import XCTest
import Combine
import CombineExpectations
import Nimble
import Quick

// MEMO: CombineExpectationsを利用してUnitTestを作成する
// https://github.com/groue/CombineExpectations#usage

final class HomeStateTest: QuickSpec {

    // MARK: - Override

    override func spec() {

        // MEMO: Quick+NimbleをベースにしたUnitTestを実行する
        // ※注意: Middlewareを直接適用するのではなく、Middlewareで起こるActionに近い形を作ることにしています。
        describe("#Home画面表示が成功する場合のテストケース") {
            // 👉 storeをインスタンス化する際に、想定するMiddlewareのMockを適用する
            let store = Store(
                reducer: appReducer,
                state: AppState(),
                middlewares: []
            )
            // CombineExpectationを利用してAppStateの変化を記録するようにしたい
            // 👉 このサンプルではAppStateで`@Published`を利用しているので、AppStateを記録対象とする
            var homeStateRecorder: Recorder<AppState, Never>!
            context("表示するデータ取得処理が成功する場合") {
                beforeEach {
                    homeStateRecorder = store.$state.record()
                }
                afterEach {
                    homeStateRecorder = nil
                }
                // 👉 Middlewareで実行するAPIリクエストが成功した際に想定されるActionを発行する
		// 👉 Project内にBundleしたJSONデータを割り当てている
                store.dispatch(
                    action: SuccessHomeAction(
                        campaignBannerEntities: getCampaignBannerEntities(),
                        recentNewsEntities: getRecentNewsRecentNewsEntities(),
                        featuredTopicEntities: getFeaturedTopicEntities(),
                        trendArticleEntities: getTrendArticleEntities(),
                        pickupPhotoEntities: getPickupPhotoEntities()
                    )
                )
                // 対象のState値が変化することを確認する
                // ※ homeStateはImmutable / Recorderで対象秒間における値変化を全て保持している
                it("homeStateに想定している値が格納された状態であること") {
                    // timeout部分で0.16秒後の変化を見る
                    let homeStateRecorderResult = try! self.wait(for: homeStateRecorder.availableElements, timeout: 0.16)
                    // 0.16秒間の変化を見て、最後の値が変化していることを確認する
                    let targetResult = homeStateRecorderResult.last!
                    // 👉 特徴的なテストケースをいくつか準備する(このテストコードで返却されるのは仮のデータではあるものの該当Stateにマッピングされる想定)
                    let homeState = targetResult.homeState
                    // (1) CampaignBannerCarouselViewObject
                    let campaignBannerCarouselViewObjects = homeState.campaignBannerCarouselViewObjects
                    let firstCampaignBannerCarouselViewObject = campaignBannerCarouselViewObjects.first
                    // 季節の特集コンテンツ一覧は合計6件取得できること
                    expect(campaignBannerCarouselViewObjects.count).to(equal(6))
                    // 1番目のidが正しい値であること
                    expect(firstCampaignBannerCarouselViewObject?.id).to(equal(1))
                    // 1番目のbannerContentsIdが正しい値であること
                    expect(firstCampaignBannerCarouselViewObject?.bannerContentsId).to(equal(1001))
                    // (2) RecentNewsCarouselViewObject
                    let recentNewsCarouselViewObjects = homeState.recentNewsCarouselViewObjects
                    let lastCampaignBannerCarouselViewObject = recentNewsCarouselViewObjects.last
                    // 最新のお知らせは合計12件取得できること
                    expect(recentNewsCarouselViewObjects.count).to(equal(12))
                    // 最後のidが正しい値であること
                    expect(lastCampaignBannerCarouselViewObject?.id).to(equal(12))
                    // 最後のtitleが正しい値であること
                    expect(lastCampaignBannerCarouselViewObject?.title).to(equal("美味しいみかんの年末年始の対応について"))
                }
            }
        }

        describe("#Home画面表示が失敗する場合のテストケース") {
            let store = Store(
                reducer: appReducer,
                state: AppState(),
                middlewares: []
            )
            var homeStateRecorder: Recorder<AppState, Never>!
            context("画面で表示するデータ取得処理が失敗した場合") {
                beforeEach {
                    homeStateRecorder = store.$state.record()
                }
                afterEach {
                    homeStateRecorder = nil
                }
                store.dispatch(action: FailureHomeAction())
                it("homeStateのisErrorがtrueとなること") {
                    let homeStateRecorderResult = try! self.wait(for: homeStateRecorder.availableElements, timeout: 0.16)
                    let targetResult = homeStateRecorderResult.last!
                    let homeState = targetResult.homeState
                    let isError = homeState.isError
                    expect(isError).to(equal(true))
                }
            }
        }
    }
}

8. まとめ

下記に示しているノートについては、本サンプルで利用しているUI実装のアイデアや盛り込みたい機能イメージを雑に書いたものになります。

設計時のメモ書き

今回は1つの画面内に複数Sectionが入るものやUI実装イメージが湧きにくいものに加えて、API関連処理部分でasync/awaitを利用することもあったので、自分が 「ここはハマりそうかも...?」「UIの形や表現を自分の言葉でまとめておこう」 と感じた部分を中心にメモとして残しています。

簡単なサンプルアプリ開発を通じて感じた所感としましては、予想以上にSwiftUIと相性が良くView関連部分における実装を比較的シンプルな形にできる余地もありそうだという印象を持ちました。

Redux自体は決して学習コストが低いわけではありませんが、理解ができると各画面用Viewを構成する元となるComponent用のView構造をいわゆる 「受け身でシンプル」 な形にすることもでき、更に 「Stateの値 = アプリのUI要素の状態」 の様な形が作れるとより画面設計が考えやすくなると思います。

また、API通信処理やデータ永続化処理を利用する際は、Middleware(副作用)から再度Actionを発行する必要があるものの、基本的には 「ViewからのAction発行 → Reducerを経由した新しいState生成 → Stateに応じたView描画」 の流れは変わらない点や、単一方向のデータフロー機構を自前でかつシンプルな形で実現可能な点は、状態管理の戦略においては嬉しいポイントになり得るかと感じております。

※場合によってはReduxに合わせにくい画面もあるので、その際はもちろん注意が必要になります。

加えて、実際にTCAに取り組んでいく際にも、Reduxでの実装に取り組んだ経験は、理解の助けになった様に感じる事も多かった様に思います。

(TCAはReduxやElmのアイデアを取り入れているので、相違点はあるが基本的な処理や考え方を理解する際には参考にできる余地は多くある様にも感じております。)

【SwiftUI+Reduxとの組み合わせに関するインプット】

https://twitter.com/fumiyasac/status/1582883611681861632

【ReduxとTCAの共通点・相違点の調査に関するインプット】

https://twitter.com/fumiyasac/status/1592062777388339204

UIKitを利用した実装でもReduxを利用した経験はあるものの、TCAに関してはまだそれ程明るいわけではないので、この部分は私の方でも更に内容やポイント等を精査して、今後とも追いかけていければと感じております。

Discussion