Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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. 自己紹介 ・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
  2. このサンプル実装におけるReduxと各層での処理 今回は画面ごとにそれぞれのStateが対応している形を取っている点がポイント 1. Store: アプリケーション全体の状態(複数の画面表示用State)を一枚岩の様な形で保持する。 2. Action: Storeが保持している状態(対象の画面表示用State)を更新するための唯一の手段でstructで定義する。 (ポイント)Actionの発行はStoreが提供しているstore.dispatch()を実行する形となります。 3.

    Reducer: 現在の状態(対象の画面表示用State)とActionの内容から新しい状態を作成する部分で純粋関数として定義する。 4. Middleware: Reducerの実行前後で処理を差し込むための部分で純粋関数として定義する。 (ポイント)画面表示に必要なMiddleware内部で、API非同期通信処理や内部データ登録処理等を実施する形となります。
  3. このサンプル実装におけるReduxと各層での処理 今回は画面ごとにそれぞれのStateが対応している形を取っている点がポイント 1. Storeから受け取った画面用State値を反映する: 2. ボタン押下処理等の部分に画面用Stateを変更するAction発行処理を記載する: 画面用State変化とUI変化をうまく結びつけるためには、できる だけ「Stateの値 = アプリのUI要素の状態」という形となる様

    に、State構造やUI関連処理に関する設計をする点がポイントに なると考えております。 すなわち、「各状態におけるデータとUIのあるべき姿を整理す る」 点が重要になると思います。 Loading Success Failure 1. 画面が表示されたタイミングでAPIリクエスト用Action発行 例. Favorite画面における画面表示時の表示パターン 2. 処理結果に応じて変更されたStateに応じた画面表示を実施
  4. アプリ全体の状態管理をするためのStore定義 // MARK: - Typealias // 👉 Dispatcher・Reducer・Middlewareのtypealiasを定義する typealias Dispatcher

    = (Action) -> Void typealias Reducer<State: ReduxState> = (_ state: State, _ action: Action) -> State typealias Middleware<StoreState: ReduxState> = (StoreState, Action, @escaping Dispatcher) -> Void final class Store<StoreState: ReduxState>: ObservableObject { @Published private(set) var state: StoreState private var reducer: Reducer<StoreState> private var middlewares: [Middleware<StoreState>] init(reducer: @escaping Reducer<StoreState>, state: StoreState, middlewares: [Middleware<StoreState>] = []) { 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類
  5. 一番おおもとの画面に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<AppState> 下層View画面要素でStoreを利用したい場合はEnvironmentObject経由で利用する
  6. シンプルな画面から紐解く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へ準拠
  7. シンプルな画面から紐解く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<AppState> ① RequestFavorite実行時: - 新しいFavoriteStateが生成  👉 Loading状態表示 - favoriteMiddleware()も同時に実行 ② favoriteMiddleware()実行結果: - 結果に応じたUI表示処理  👉 成功:お気に入り一覧画面表示  👉 失敗:共通エラー画面表示 View要素に必要なObjectに置き換える Middlewareの処理結果に応じて実行されるReducer内処理
  8. // 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 } } } シンプルな画面から紐解く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が異なる
  9. シンプルな画面から紐解くView定義部分 5. View要素定義例 @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()) } ) } 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で画面表示に必要なものを詰め直す 画面表示に必要なものを抜き出して利用する
  10. シンプルな画面から紐解く簡単な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<AppState, Never>! 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を利用して値変化を監視する
  11. シンプルな画面から紐解く簡単な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
  12. 今回参考にした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
  13. まとめ 予想以上に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