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

Redux の副作用を直感的に管理する Redux Saga を Swift でも使いたい

Redux の副作用を直感的に管理する Redux Saga を Swift でも使いたい

Redux の副作用を直感的に管理する Redux Saga を Swift でも使いたい
iOSDC Japan 2023 #iosdc - fortee.jp
https://fortee.jp/iosdc-japan-2023/proposal/d3c4feb1-0b2c-403b-b47c-6db885bc70d8

download PDF
https://github.com/mitsuharu/iosdc-2023-pamphlet/releases/tag/2023-07-05

Mitsuharu Emoto

September 07, 2023
Tweet

More Decks by Mitsuharu Emoto

Other Decks in Programming

Transcript

  1. Redux の副作用を直感的に管理する Redux Saga を Swift でも使いたい 江本光晴(株式会社ゆめみ) @mitsuharu_e /

    @mitsuharu.bsky.social あなたのお気に入りのアーキテクチャは何ですか。私のお気に入りは Redux Saga です。 Redux Saga 1 は単方向データフローの Redux 2 を拡張し、非同期処理や副作用を直感 的に管理できるようにしたアーキテクチャです。ビジネスロジックなどを Saga にまとめ ることで、責務を明確に分けることができます。 Redux Saga は JavaScript で作成され Web (React )や React Native などの開発でよ く用いられています。同じ宣言的 UI の SwiftUI との相性が期待できます。しかし、残念 なことに Swift で Redux Saga を実装したライブラリはありません。 それならば、自身で作成するしかありません。JavaScript と Swift の言語設計と性質の 違いを考慮しつつ、Swift の言語特性を活かす形で、Redux Saga の主要な機能をどのよ うに実装するかを解説します。Redux Saga の特性や利点を紹介して、iOS アプリ開発に おける Redux Saga の可能性を探求します。 本記事では、Swift だけでなく JavaScript (TypeScript )のソースコードも提示します。 また、Redux Saga の API も挙げますが、詳細説明は省略します。雰囲気を感じてもら う程度で問題ありません。 Redux Saga とは Redux は、JavaScript アプリの状態管理のための予測可能な状態コンテナです。アプリ 全体の状態を一元的に管理ができて、データフローを単純化して管理を容易にします。し かし、Redux は非同期処理や副作用(データフェッチングやデータベースへのアクセスな ど)の管理が設計されていないため、それらの実装方法は明確に定められていません。こ れは Redux の主な弱点の1つとされています。 そこで Redux Saga の登場です。Redux Saga は、非同期処理や副作用を直感的に管理 するライブラリです。Saga はアプリの中で副作用を個別に実行する独立したスレッドの ような動作イメージです。Redux Saga は middleware として設計されているため、 Saga は Action に応じて起動、一時停止、中断ができます。State 全体にアクセスでき、 Action の発行もできます。 同様なライブラリの Redux Thunk と比較すると、コールバック地獄に陥ることなく、非 同期フローを簡単にテスト可能にし、Action を純粋に保つことができます。
  2. Redux Saga のデータフロー たとえば、あるボタンをタップして、ユーザー情報を取得する例を考えましょう。この場 合、タップイベントで「ユーザー情報を取得する」という Action を発行します。 // View などでユーザー情報を取得する

    Action を発行(dispatch)する const onPress = () => { dispatch(requestUser({userId: '1234'})) } 事前に Redux Saga 側で Action と Saga を関連付けておきます。takeEvery は特定の Action が発行されるのを待ち、それが発行されたら Saga を実行します。onPress() で requestUser が発行されたので、関連付けられた requestUserSaga が実行されます。 // Redux Saga の初期設定時に Action に対応する処理を設定しておく function* rootSaga() { // Action "requestUser" が発行されたら、requestUserSaga を実行する yield takeEvery(requestUser, requestUserSaga) } // ユーザー情報の取得を行う副作用 function* requestUserSaga(action) { // たとえば、API からユーザー情報を取得する } 副作用は Saga にまとめて、View は必要な Action を発行するだけです。Redux Saga にしたがっていれば、自然と責務分けが実現されます。私が Redux Saga の好きな特徴 の1つです。
  3. Swift での実装アプローチ Redux Saga の実装や機能は複雑なため、完全再現は目指しません。一部機能の再現およ び実装を目標とします。今回は、middleware, call, take そして takeEvery

    を対象とし ます。middleware は Redux から Redux Saga へ Action を伝える根底部分で、call, take, takeEvery はよく利用される機能です。 元々の JavaScript の実装は Saga にジェネレーター関数を用いていますが、Swift では Swift Concurrency を利用します。Action の発行や監視の非同期制御は Combine で行 います。なお、Redux 本体の実装は既存ライブラリの ReSwift 3 を利用します。 ここで、Redux 本体との接点を最小限に抑え、独立性の高いライブラリを目指します。 Saga としてビジネスロジックを切り離して管理できるので、たとえば新たに優れたアー キテクチャが登場した場合でも、そのアーキテクチャへの入替を容易にするためです。 今回は Xcode 14.3.1 で開発しています。現在も開発中なため、紹介するソースコードは 変更される場合があります。ご了承ください。 Swift で実装する まず Redux Saga の実装において Action の比較が必要になります。ここでいう比較はイ ンスタンス同士の比較ではなく、Action の種類、つまり型での比較です。ReSwift が定義 する Action は空の Protocol で、一般に enum や struct で利用されることが多いです。 enum は型の比較が難しい、struct は実装の過程で継承を利用したいので難しいです(継 承を利用する主な目的は reducer の設計で、その詳細は省略します) 。そのため、今回は class で Action を定義します。 class SagaAction: Action {} 先ほど挙げた例と同様に、ユーザー情報を取得する場合を考えます。 // Action をグループ管理したいので UserAction という中間のクラスを作る class UserAction: SagaAction {} // ユーザー情報を取得する Action final class RequestUser: UserAction { let userID: String init(userID: String) { self.userID = userID } }
  4. middleware を実装する Redux で発行された Action を Redux Saga に伝達させる middleware

    を実装します。 まず Action を Redux Saga 向けに発行するクラスを実装します。クラス名は Channel にしました。このクラスが自作する Redux Saga の中核になります。 final class Channel { public static let shared = Channel() private let subject = PassthroughSubject<SagaAction, Error>() // action を発行する func put(_ action: SagaAction){ subject.send(action) } } この Channel を組み込んだ middleware を実装します。 func createSagaMiddleware<State>() -> Middleware<State> { return { dispatch, getState in return { next in return { action in if let action = action as? SagaAction { Channel.shared.put(action) } return next(action) } } } } この middleware を ReSwift の Store に適用します。Redux のデータフローに介入し て、発行された Action を Redux Saga に伝達させます。 // ReSwift の初期設定を行う関数 func makeAppStore() -> Store<State> { // Saga 用の middleware を作成する let sagaMiddleware: Middleware<State> = createSagaMiddleware() let store = Store<State>( reducer: reducer, state: State.initialState(), middleware: [sagaMiddleware] ) return store }
  5. call を実装する call は Saga の関数と引数を与えて実行するシンプルな関数です。ここで Saga 関数の型 を定義します。Action を引数にした非同期関数です。

    typealias Saga<T> = (SagaAction) async -> T この型を使って call を次のように実装しました。Saga の型定義でジェネリクスを利用し ましたが、開発中のため Any にしました。今後の修正課題です。 @discardableResult func call(_ effect: @escaping Saga<Any>, _ arg: SagaAction) async -> Any { return await effect(arg) } take を実装する take は特定の Action が発行されるのを待ちます。注意する点として Action のインスタ ンスを比較するのではなく、発行された Action の種類(型)を判定します。まずは前述 の Channel に、特定の Action の型を受信する仕組みを実装します。 final class Channel { // ... // deinit などで忘れずに解放する(省略) private var subscriptions = [AnyCancellable]() // 引数で指定した action の型が発行されるまで待つ func take(_ actionType: SagaAction.Type ) -> Future <SagaAction, Never> { return Future { [weak self] promise in guard let self = self else { return } self.subject.filter { type(of: $0) == actionType }.sink { _ in // 必要に応じてエラー処理を行う } receiveValue: { promise(.success($0)) }.store(in: &self.subscriptions) } } }
  6. 追加改修した Channel を利用して take の関数を実装します。この take() を実行する と、引数で指定した Action の型の監視が始まり、検出されるまで待ちます。

    @discardableResult func take(_ actionType: SagaAction.Type) async -> SagaAction { let action = await Channel.shared.take(actionType).value return action } この take() は Redux Saga の起点となる機能の1つです。Action の種類、つまり型で 判断するという処理の設計や実装は、納得するまで何度も作り直しました。今回の実装で 苦労した点です。 takeEvery を実装する takeEvery は特定の Action と Saga を関連付けて、その Action が発行されるたびに指 定した Saga を実行します。前述で作成した take と call を組み合わせて実装します。 func takeEvery( _ actionType: SagaAction.Type, saga: @escaping Saga<Any>) { Task.detached { while true { let action = await take(actionType) await call(saga, action) } } } 無限ループ!?という感覚は正常です。ループの中で Action が発行されるまで待ち、それ が発行されたら Saga を実行するという処理を繰り返します。 自作した Redux Saga を使おう 一連の実装が終わりました。takeEvery を使った簡単な例を紹介します。まずは、実行さ せたい処理を Saga 関数で実装します。オリジナルの実装では Saga 関数を慣習的に xxxSaga と命名することが多いです。Swift でも、その慣習にそって、命名しました。 // ユーザー情報を取得する Saga let requestUserSaga: Saga = { action async in guard let action = action as? RequestUser else { return } // API などで action.userID のユーザー情報を取得する }
  7. 次に takeEvery で Action と Saga を関連付けます。 // Saga を設定する関数

    func setupSaga(){ takeEvery(RequestUser.self, saga: requestUserSaga) } これは前述の makeAppStore() で middleware を設定した後に呼ぶとよいです。 func makeAppStore() -> Store<State> { // ... // store, middleware の設定後に呼ぶ setupSaga() return store } 準備が整いました。適当な View 向けの関数で Action RequestUser を発行する処理を作 成します。今回は MVVM を想定して、適当な ViewModel を用意しました。 final class UserViewModel { // 適当なボタンイベントなどで呼ぶ func requestUser() { store.dispatch(RequestUser(userID: "1234")) } } Redux から発行された Action RequestUser は middleware を通じて Redux Saga へ 伝達されます。そして takeEvery により Saga requestUserSaga が実行されます。View は Action を発行するだけで、実行される処理の責務には関与しません。 評価と考察 Redux Saga の主な機能を再現して、アプリの副作用を Saga にまとめることができまし た。View での処理がとてもシンプルになり満足しています。しかし、まだ対応・修正し たいところも残っています。まだまだ開発途中です。 残りの未実装な機能を実装する Action を enum, struct でも利用できるようにしたい Saga のジェネリクスを適切に対応して、より型安全にする エラー処理やテストコードなどを適切に整備して、安全にする
  8. Redux Saga と SwiftUI SwiftUI を利用した開発では Redux ベースのアーキテクチャとの相性がよいといわれて います。しかしながら、SwiftUI の実装や癖などから

    Apple Platform においては、私は 必ずしもベストマッチだとは言い切れないとも考えています。 私が iOS アプリを個人開発する場合、Redux (ReSwift )+ MVVM でアプリ設計をする ことが多いです。Apple Platform では MVVM の選択が無難だが、Redux の利点も捨て きれないためです。状態は Redux で管理して、副作用などは ViewModel で定義してい ます。今回自作した Redux Saga により、副作用も Redux 側で管理できるようになりま した。ViewModel は Action の発行と状態を View へ渡すだけのシンプルな構造になり、 MVVM でしばしば問題にされる Fat ViewModel は解消されました。 しかし、このアーキテクチャはニッチだと自認しています。全員には勧めません。Redux Saga の学習コストは比較的高いとされています。Redux ベースのアーキテクチャに興味 ある方、プロジェクトの構造を大きく変えずにまずは試したい方、いかがでしょうか。 まとめ 本記事は、JavaScript ベースのライブラリ Redux Saga を Swift で実装する方法につい て解説しました。JavaScript と Swift は言語の設計と性質が異なるため、Redux Saga の完全な再現は難しいです。実際に多くの試作して上手くいかないこともあり、ChatGPT にも相談しました。完全再現は諦めて、その概念を取り入れ、Swift の特性を活かす形で 実装を試みて、ようやく形になりました。 今回は middleware, call, take そして takeEvery の実装を紹介しました。紙面の都合上 から取り上げなかった他の機能 put, fork, selector, takeLeading や takeLatest なども 実装しています。それらの実装を含め、ソースコードは GitHub で公開しています。 https://github.com/mitsuharu/ReSwiftSagaSample 現段階は開発・検証のためのサンプルコードですが、将来的には OSS としてリリースし たいと考えています。Redux をベースとしたアーキテクチャのライブラリ、たとえば ReSwift や TCA などは、すでに多くのアプリで利用されています。今回紹介した Redux Saga も iOS アプリ開発者に興味を持って頂けたら嬉しいです。 1. https://github.com/redux-saga/redux-saga↩ 2. https://github.com/reduxjs/redux↩ 3. https://github.com/ReSwift/ReSwift バージョン 6.1.1 を利用しました↩