Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

自己紹介 ・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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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表現の考察

Slide 5

Slide 5 text

今回のスライドにつきまして 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の理解に繋がると感じました。※この部分は当時は一番大きい理由だったかもしれないです…

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

このサンプル実装におけるReduxと各層での処理 今回は画面ごとにそれぞれのStateが対応している形を取っている点がポイント 1. Store: アプリケーション全体の状態(複数の画面表示用State)を一枚岩の様な形で保持する。 2. Action: Storeが保持している状態(対象の画面表示用State)を更新するための唯一の手段でstructで定義する。 (ポイント)Actionの発行はStoreが提供しているstore.dispatch()を実行する形となります。 3. Reducer: 現在の状態(対象の画面表示用State)とActionの内容から新しい状態を作成する部分で純粋関数として定義する。 4. Middleware: Reducerの実行前後で処理を差し込むための部分で純粋関数として定義する。 (ポイント)画面表示に必要なMiddleware内部で、API非同期通信処理や内部データ登録処理等を実施する形となります。

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

このサンプル実装におけるReduxと各層での処理 今回は画面ごとにそれぞれのStateが対応している形を取っている点がポイント 1. Storeから受け取った画面用State値を反映する: 2. ボタン押下処理等の部分に画面用Stateを変更するAction発行処理を記載する: 画面用State変化とUI変化をうまく結びつけるためには、できる だけ「Stateの値 = アプリのUI要素の状態」という形となる様 に、State構造やUI関連処理に関する設計をする点がポイントに なると考えております。 すなわち、「各状態におけるデータとUIのあるべき姿を整理す る」 点が重要になると思います。 Loading Success Failure 1. 画面が表示されたタイミングでAPIリクエスト用Action発行 例. Favorite画面における画面表示時の表示パターン 2. 処理結果に応じて変更されたStateに応じた画面表示を実施

Slide 12

Slide 12 text

アプリ全体の状態管理をするための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類

Slide 13

Slide 13 text

一番おおもとの画面に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経由で利用する

Slide 14

Slide 14 text

シンプルな画面から紐解く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へ準拠

Slide 15

Slide 15 text

シンプルな画面から紐解く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内処理

Slide 16

Slide 16 text

// 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が異なる

Slide 17

Slide 17 text

シンプルな画面から紐解く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で画面表示に必要なものを詰め直す 画面表示に必要なものを抜き出して利用する

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

シンプルな画面から紐解く簡単な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を利用して値変化を監視する

Slide 22

Slide 22 text

シンプルな画面から紐解く簡単な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

Slide 23

Slide 23 text

今回参考にした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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

まとめ 予想以上に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

Slide 26

Slide 26 text

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