$30 off During Our Annual Pro Sale. View Details »

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

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

YUMEMI.grow Mobile #1での登壇資料になります。

以前経験したUIKitベースのプロジェクトにおいて利用するアーキテクチャの候補としてReduxが候補に上がり、その前段としてUIKitベースのUI実装とReduxを組み合わせたサンプルの開発経験がありました。

最近は業務内でもUIKitよりもSwiftUIの比重が高くなる事が見込まれたので、この機会にある程度複雑な動きや構造をとる様なUI実装とセットにして考えてみたいと思っていました。

また、Point-Freeが公開している「The Composable Architecture (TCA)」に関してもReduxに近い構成を取っていたので、その理解を深めることでTCAの理解に繋がると感じました。

今回は、SwiftUI製の簡単なAPI通信を伴う画面表示処理や表示データの絞込み検索機能がある簡易サンプルを作成した際の検証備忘録として、個人的にポイントと感じた部分をピックアップしてまとめたものになります。
(詳細については後程記事にして公開する予定です。)

【実装サンプルはこちら】
https://github.com/fumiyasac/SwiftUIAndReduxExample

Fumiya Sakai

March 07, 2023
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. Explanation & Point about SwiftUI + Redux Example
    YUMEMI.grow Mobile #1
    2023/03/07
    Fumiya Sakai

    View Slide

  2. 自己紹介
    ・Fumiya Sakai
    ・Mobile Application Engineer
    アカウント:
    ・Twitter: https://twitter.com/fumiyasac

    ・Facebook: https://www.facebook.com/fumiya.sakai.37

    ・Github: https://github.com/fumiyasac

    ・Qiita: https://qiita.com/fumiyasac@github
    発表者:
    ・Born on September 21, 1984
    これまでの歩み:
    Web Designer
    2008 ~ 2010
    Web Engineer
    2012 ~ 2016
    App Engineer
    2017 ~ Now
    iOS / Android / sometimes Flutter

    View Slide

  3. iOSのUI実装本を執筆しています!
    書籍に掲載したサンプルのバージョンアップや続編等に現在着手中です。
    少しの工夫で実現できるTIPS集やライブラリ表現の活用集をはじめとした、iOSア
    プリ開発の中でも特にUI実装やUIKitを利用した画面の中で特徴を与える様な表現
    という題材に焦点を当てた書籍となっております。
    現在は電子書籍版のみとなります。 こちらは全て¥1,000となっております。
    https://just1factory.booth.pm/
    概要:
    https://book-tech.com/
    価格:
    📖 Booth
    📖 Book Tech

    View Slide

  4. UI実装であると嬉しいレシピブックの最新情報
    UI実装であると嬉しいレシピブックVol.3として昨年10月に商業化しました!
    Still

    WIP
    これまでの同人誌として頒布したものに加えて、Vol.1及びVol.2に頒布したものの
    中で書籍に載せきれなかったものや表現や動きが特徴的でユーザーにもほんの少し
    遊び心を与える様なUI実装を紹介したものをVol.3としています。
    概要:
    これからの構想:
    こちらで購入可能です:
    Amazon / Google Play / Apple Books / KINOKUNIYA / Rakuten BOOKS etc..
    🏊 iOS: SwiftUIを利用したUI実装や動画関連の実装
    🏊 Android: Jetpack Composeの基本やその他気になるUI表現の考察

    View Slide

  5. 今回のスライドにつきまして
    SwiftUI製のUI実装における状態管理でReduxを利用した際の検証備忘録です
    今回紹介するのは、APIから取得したデータを画面表示したり、簡単な絞り込み検索を盛り込んだサンプルになります。
    1. 以前にUIKit&Reduxを利用したUI実装サンプル作成経験があった:
    以前経験したUIKitベースのプロジェクトにおいて利用するアーキテクチャの候補としてReduxが候補に上がり、その前段として
    UIKitベースのUI実装とReduxを組み合わせたサンプルの開発経験がありました。
    2. SwiftUIを利用したUI実装経験を通して試してみたかった:
    最近は業務内でもUIKitよりもSwiftUIの比重が高くなる事が見込まれたので、この機会にある程度複雑な動きや構造をとる様な
    UI実装とセットにして考えてみたいと思っていた事も動機の1つでした。
    3. The Composable Architecture (TCA) をより理解するための布石:
    Point-Freeが公開している「The Composable Architecture (TCA)」に関してもReduxに近い構成を取っていたので、その理解を
    深めることでTCAの理解に繋がると感じました。※この部分は当時は一番大きい理由だったかもしれないです…

    View Slide

  6. 余談: UIKitベースのUI実装とReduxを組み合わせた事例
    このサンプルはOSSライブラリ「ReSwift」を利用してReduxを実現しています
    (参考2)上記UI実装サンプルリポジトリ : https://github.com/fumiyasac/ReduxSampleSwift
    (参考1)ReduxとSwiftの組み合わせを利用したUIサンプル事例紹介 : https://qiita.com/fumiyasac@github/items/f25465a955afdcb795a2

    View Slide

  7. 今回の解説で利用するサンプル実装概要
    今回はReact等に近いイメージのRedux処理機構を自前で準備して作成しました
    (参考)上記UI実装サンプルリポジトリ : https://github.com/fumiyasac/SwiftUIAndReduxExample
    少し複雑なLayout 検索&お気に入り CardSwipe型のUI GeometryReader利用

    View Slide

  8. このサンプル実装におけるReduxと各層での処理
    今回は画面ごとにそれぞれのStateが対応している形を取っている点がポイント
    1. Store:
    アプリケーション全体の状態(複数の画面表示用State)を一枚岩の様な形で保持する。
    2. Action:
    Storeが保持している状態(対象の画面表示用State)を更新するための唯一の手段でstructで定義する。

    (ポイント)Actionの発行はStoreが提供しているstore.dispatch()を実行する形となります。
    3. Reducer:
    現在の状態(対象の画面表示用State)とActionの内容から新しい状態を作成する部分で純粋関数として定義する。
    4. Middleware:
    Reducerの実行前後で処理を差し込むための部分で純粋関数として定義する。

    (ポイント)画面表示に必要なMiddleware内部で、API非同期通信処理や内部データ登録処理等を実施する形となります。

    View Slide

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

    View Slide

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

    View Slide

  11. このサンプル実装におけるReduxと各層での処理
    今回は画面ごとにそれぞれのStateが対応している形を取っている点がポイント
    1. Storeから受け取った画面用State値を反映する:
    2. ボタン押下処理等の部分に画面用Stateを変更するAction発行処理を記載する:
    画面用State変化とUI変化をうまく結びつけるためには、できる
    だけ「Stateの値 = アプリのUI要素の状態」という形となる様
    に、State構造やUI関連処理に関する設計をする点がポイントに
    なると考えております。

    すなわち、「各状態におけるデータとUIのあるべき姿を整理す
    る」 点が重要になると思います。 Loading Success Failure
    1. 画面が表示されたタイミングでAPIリクエスト用Action発行
    例. Favorite画面における画面表示時の表示パターン
    2. 処理結果に応じて変更されたStateに応じた画面表示を実施

    View Slide

  12. アプリ全体の状態管理をするためのStore定義
    // MARK: - Typealias

    // 👉 Dispatcher・Reducer・Middlewareのtypealiasを定義する

    typealias Dispatcher = (Action) -> Void

    typealias Reducer = (_ state: State, _ action: Action) -> State

    typealias Middleware = (StoreState, Action, @escaping Dispatcher) -> Void
    final class Store: ObservableObject {

    @Published private(set) var state: StoreState

    private var reducer: Reducer

    private var middlewares: [Middleware]

    init(reducer: @escaping Reducer, state: StoreState, middlewares: [Middleware] = []) {

    self.reducer = reducer

    self.state = state

    self.middlewares = middlewares

    }

    func dispatch(action: Action) {

    // MEMO: Actionを発行するDispatcherの定義

    Task { @MainActor in

    self.state = reducer(

    self.state,

    action

    )

    }

    // MEMO: 利用する全てのMiddlewareを適用

    middlewares.forEach { middleware in

    middleware(state, action, dispatch)

    }

    }

    }
    // MARK: - Protocol

    protocol ReduxState {}

    // 👉 このProtocolにを準拠した各画面に対応するActionをStructで定義する形になる

    protocol Action {}
    各画面に対応するStateはImmutableな形になる
    その他Store内に定義しているTypealiasやProtocol類

    View Slide

  13. 一番おおもとの画面にStoreを適用して各画面へ伝える
    @main

    struct SwiftUIAndReduxExampleApp: App {

    // MARK: - Body

    var body: some Scene {

    // 👉 このアプリで利用するStoreを初期化する

    // ※ middlewaresの配列内にAPI通信/Realm/UserDefaultを操作するための関数を追加する

    // ※ TestCodeやPreview画面ではmiddlewaresの関数にはMockを適用する

    let store = Store(

    reducer: appReducer,

    state: AppState(),

    middlewares: [

    // MEMO: 正規の処理を実行するMiddlewareを登録する

    // OnBoarding/Home/Archive/Favorite/Profile用Middleware

    ]

    )

    // 👉 ContentViewには.environmentObjectを経由してstoreを適用する

    WindowGroup {

    ContentView()

    .environmentObject(store)

    }

    }

    }
    // OnBoarding

    onboardingMiddleware(), onboardingCloseMiddleware(),

    // Home

    homeMiddleware(),

    // Archive

    archiveMiddleware(), addArchiveObjectMiddleware(), deleteArchiveObjectMiddleware(),

    // Favorite

    favoriteMiddleware(),

    // Profile

    profileMiddleware(),
    API通信やデータ永続化処理経由でActionを発行するための関数
    Storeを下層Viewへ渡す
    @EnvironmentObject var store: Store
    下層View画面要素でStoreを利用したい場合はEnvironmentObject経由で利用する

    View Slide

  14. シンプルな画面から紐解くState&Action定義部分
    Loading Success Failure
    struct RequestFavoriteAction: Action {}

    struct SuccessFavoriteAction: Action {

    let favoriteSceneEntities: [FavoriteSceneEntity]

    }

    struct FailureFavoriteAction: Action {}
    1. 画面が表示されたタイミングでAPIリクエスト用Action発行
    おさらい. Favorite画面における画面表示時の表示パターン
    2. 処理結果に応じて変更されたStateに応じた画面表示を実施
    1. Action定義例
    2. State定義例
    struct FavoriteState: ReduxState, Equatable {

    // MEMO: 読み込み中状態

    var isLoading: Bool = false

    // MEMO: エラー状態

    var isError: Bool = false

    // MEMO: Favorite画面で利用する情報として必要なViewObject情報

    var favoritePhotosCardViewObjects: [FavoritePhotosCardViewObject] = []

    static func == (lhs: FavoriteState, rhs: FavoriteState) -> Bool {

    return lhs.isLoading == rhs.isLoading

    && lhs.isError == rhs.isError

    && lhs.favoritePhotosCardViewObjects == rhs.favoritePhotosCardViewObjects

    }

    }
    画面表示要素との関連部分
    Action Protocolへ準拠

    View Slide

  15. シンプルな画面から紐解くReducer定義部分
    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:

    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

    }
    3. Reducer定義例
    現在のStateを受け取って新しいStateを生成する処理
    favoriteMiddleware() -> Middleware ① RequestFavorite実行時:
    - 新しいFavoriteStateが生成

     👉 Loading状態表示

    - favoriteMiddleware()も同時に実行
    ② favoriteMiddleware()実行結果:
    - 結果に応じたUI表示処理

     👉 成功:お気に入り一覧画面表示

     👉 失敗:共通エラー画面表示
    View要素に必要なObjectに置き換える
    Middlewareの処理結果に応じて実行されるReducer内処理

    View Slide

  16. // APIリクエスト結果に応じたActionを発行する

    func favoriteMiddleware() -> Middleware {

    return { state, action, dispatch in

    switch action {

    case let action as RequestFavoriteAction:

    // 👉 RequestFavoriteActionを受け取ったらその後にAPIリクエスト処理を実行する

    requestFavoriteScenes(action: action, dispatch: dispatch)

    default:

    break

    }

    }

    }
    シンプルな画面から紐解くMiddleware定義部分
    4. Middleware定義例
    画面をLoading状態にするActionを発行を受け取ったらAPIリクエスト処理を実行
    // 👉 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 {

    dispatch(SuccessFavoriteAction(favoriteSceneEntities: favoriteSceneResponse.result))

    } else {

    throw APIError.error(message: "No FavoriteSceneResponse exists.")

    }

    dump(favoriteResponse)

    } catch APIError.error(let message) {

    dispatch(FailureFavoriteAction())

    }

    }

    }
    async / awaitをベースとしたAPIリクエスト処理
    favoriteMiddleware()実行結果:
    - 結果に応じたUI表示処理

     👉 成功:お気に入り一覧画面表示

    SuccessFavoriteAction

     👉 失敗:共通エラー画面表示

    FailureFavoriteAction
    成功 / 失敗に応じて発行するActionが異なる

    View Slide

  17. シンプルな画面から紐解くView定義部分
    5. View要素定義例
    @EnvironmentObject var store: Store

    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()) }

    )

    }
    var body: some View {

    // 該当画面で利用するStateをこの画面用のPropsにマッピングする

    let props = mapStateToProps(state: store.state.favoriteState)

    // 表示に必要な値をPropsから取得する

    let isLoading = mapToIsLoading(props: props)

    let isError = mapToIsError(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)

    }

    }
    受け取ったStateで画面表示に必要なものを詰め直す
    画面表示に必要なものを抜き出して利用する

    View Slide

  18. このサンプルで利用しているUI実装に関する紹介
    DragGestureを活用したCarousel型UI表現等に関する実装方針を紹介しています
    (参考)SwiftUIで作る「Drag処理を利用したCarousel型UI」と「Pinterest風GridレイアウトUI」の実装例とポイントまとめ :

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

    View Slide

  19. このサンプルで利用しているUI実装に関する補足(1)

    View Slide

  20. このサンプルで利用しているUI実装に関する補足(2)

    View Slide

  21. シンプルな画面から紐解く簡単なUnitTest事例(1)
    // MEMO: Quick+NimbleをベースにしたUnitTestを実行する

    // ※注意: Middlewareを直接適用するのではなく、Middlewareで起こるActionに近い形を作ることにしています。

    describe("#Favorite画面表示が成功する場合のテストケース") {

    let store = Store(

    reducer: appReducer,

    state: AppState(),

    middlewares: []

    )

    // 👉 このサンプルではAppStateで`@Published`を利用しているので、AppStateを記録対象とする

    var favoriteStateRecorder: Recorder!

    context("表示するデータ取得処理が成功する場合") {

    beforeEach {

    favoriteStateRecorder = store.$state.record()

    }

    afterEach {

    favoriteStateRecorder = nil

    }

    // 👉 Middlewareで実行するAPIリクエストが成功した際に想定されるActionを発行する

    store.dispatch(

    action: SuccessFavoriteAction(

    favoriteSceneEntities: getFavoriteSceneEntities()

    )

    )

    … 対象のレスポンスデータをView表示用のObjectに正しくマッピングされる事を確認 …

    }

    }
    @testable import SwiftUIAndReduxExample

    import XCTest

    import Combine

    import CombineExpectations

    import Nimble

    import Quick

    final class FavoriteStateTest: QuickSpec {

    // MARK: - Override

    override func spec() {

    … 成功・失敗のテストケースを追記する …

    }

    }
    👉 AppStateが @Published で定義
    AppStateで各画面で利用するState値を
    集約しているのでAction発行後に対象の
    Stateに正しい値変化が起こっている事
    をテストする方針
    CombineExpectationを利用して値変化を監視する

    View Slide

  22. シンプルな画面から紐解く簡単なUnitTest事例(2)
    // 対象のState値が変化することを確認する

    // ※ favoriteStateはImmutable / Recorderで対象秒間における値変化を全て保持している

    it("favoriteStateに想定している値が格納された状態であること") {

    // timeout部分で0.16秒後の変化を見る

    let favoriteStateRecorderResult = try! self.wait(for: favoriteStateRecorder.availableElements, timeout: 0.16)

    // 0.16秒間の変化を見て、最後の値が変化していることを確認する

    let targetResult = favoriteStateRecorderResult.last!

    // 👉 特徴的なテストケースをいくつか準備する(このテストコードで返却されるのは仮のデータではあるものの該当Stateにマッピングされる想定)

    let favoriteState = targetResult.favoriteState

    // favoritePhotosCardViewObjects

    let favoritePhotosCardViewObjects = favoriteState.favoritePhotosCardViewObjects

    let firstFavoritePhotosCardViewObject = favoritePhotosCardViewObjects.first

    // 編集部が選ぶお気に入りのグルメは合計12件取得できること

    expect(favoritePhotosCardViewObjects.count).to(equal(12))

    // 最初のidが正しい値であること

    expect(firstFavoritePhotosCardViewObject?.id).to(equal(1))

    // 最初のtitleが正しい値であること

    expect(firstFavoritePhotosCardViewObject?.title).to(equal("気になる一皿シリーズNo.1"))

    }
    対象のレスポンスデータをView表示用のObjectに正しくマッピングされる事を確認
    (参考)CombineExpectations : ※このライブラリを利用することによってAppStateの値変化を記録している点がポイントになります。

    https://github.com/groue/CombineExpectations

    View Slide

  23. 今回参考にしたUdemy講座と学習の記録
    当時はまだSwiftUIに関する理解が浅かったのでまずはインプットをしました
    Composable SwiftUI Architecture Using Redux :

    https://www.udemy.com/course/composable-swiftui-architecture-using-redux/
    SwiftUI 2 - Build Netflix Clone :

    https://www.udemy.com/course/swiftui-netflix/
    1. 利用したUdemy講座(現在では少しバージョンが古いです):
    2. Twitterで共有した学習ノート事例:
    👉 ReduxをSwiftUIで実現する際の参考
    👉 少し複雑な構造を実現する際の参考
    Composable SwiftUI Architecture Using Redux の学習記録 :

    https://twitter.com/fumiyasac/status/1582883611681861632
    SwiftUI 2 - Build Netflix Clone の学習記録 :

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

    View Slide

  24. The Composable Architectureを理解するための布石
    明確な違いはあるけれども設計思想や実装方針を理解する際に役立ちました
    ノート公開リンク: https://twitter.com/fumiyasac/status/1592062777388339204

    View Slide

  25. まとめ
    予想以上にSwiftUIと相性が良くView関連部分をシンプルにできる余地もある
    1. 画面が取り得る状態設計&画面に必要なComponent要素を設計する際が考えやすくなった:
    Redux自体は決して学習コストが低いわけではありませんが、理解ができるとView構造をいわゆる「受け身でシンプル」な形にす
    ることもでき、更に「Stateの値 = アプリのUI要素の状態」の様な形が作れるとより画面設計が考えやすくなると思います。
    2. 単一方向のデータフロー機構を自前でかつシンプルな形で実現可能な点は嬉しい:
    API通信処理やデータ永続化処理を利用する際は、Middleware(副作用)から再度Actionを発行する必要があるものの、基本的に
    は「ViewからのAction発行→Reducerを経由した新しいState生成→Stateに応じたView描画」の流れは変わらない点がポイントに
    なります。※場合によってはReduxに合わせにくい画面もあるので、その際はもちろん注意が必要になります。
    3. 実際にTCAに取り組んでいく際にも理解の助けになった様に感じる:
    TCAはReduxやElmのアイデアを取り入れているので、相違点はあるが基本的な処理や考え方を理解する際には参考にできます。

    (参考1)Redux入門 〜iOSアプリをReduxで作ってみた〜 : https://creators-note.chatwork.com/entry/2021/05/20/100000

    (参考2)TCAと比較するためにReduxを学ぶ : https://zenn.dev/kalupas226/scraps/768b3530bc05af

    View Slide

  26. Thank you for listening !
    後付けにはなってしまいますが、後程記事にもまとめて公開できればと考えております。

    View Slide