本記事は 技術書典15 で無料配布する同人誌「ゆめみ大技林 '23 (2)」の寄稿です。加筆や修正などがある場合はこの記事で行います。
あなたのお気に入りのアーキテクチャは何ですか。私のお気に入りは 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 も挙げますが、詳細説明は省略します。雰囲気を感じてもらう程度で問題ありません。
本記事は iOSDC Japan 2023 のパンフレットに寄稿した記事 3 を底本として、加筆・訂正を行なったものになります。
免責事項
本書に記載された内容は、情報の提供のみを目的としています。これらの情報の運用は、ご自身の責任と判断によって行なってください。情報の運用の結果について、著者は責任を負わないものとします。
商標、登録商標について
本記事に記載される製品の名称は、各社の商標または登録商標です。本文中では、™、® などのマークは省略しています。
Redux そして Redux Saga へ
Redux は状態管理のための予測可能な状態コンテナです。アプリ全体の状態を一元的に管理ができ、データフローを単方向化して管理を容易にします。次の3つの原則 4 に基づいて、データフローを構築します。
- Single source of truth(信頼できる唯一の情報源)
- State is read-only(状態は読み取り専用)
- Changes are made with pure functions(変更は純粋関数で行う)
アプリケーションの状態 State を一元的に管理する1つの Store を持ちます。状態を変更する際は、リクエストや命令を示す Action を発行(dispatch)します。この Action にさまざまな機能を追加するのが Middleware です。そして純粋関数で定義された Reducer を用いて Action から新しい状態を生成し、Store を経由して View に適用させます。
Redux が抱える問題
Redux はデータフェッチやデータベース処理などといった副作用や非同期処理の管理が明示的に含まれていません。それらの実装方法は開発者の判断に任されます。では、どこで副作用を実行するのがよいでしょうか。
-
Action の発行前に実行する
事前に副作用を実行し、その結果を Action に渡します。このアプローチは Redux の範囲外に別のアーキテクチャを導入することになり、ソースコードの散乱をもたらすでしょう。
-
Reducer で実行する
State を変更する際に副作用を実行することは1つの選択肢ですが、Reducer は純粋関数として設計されています。したがって、副作用を実行する場所ではありません。
-
Middleware で実行する
もともと Middleware は機能拡張のために導入されたので、副作用の実行に適しています。しかし、副作用の実装や修正でアーキテクチャのコアでもある Middleware を頻繁に触ることは、安全・安定性に影響します。また Middleware の肥大化は可読性を低下させます。
元々シンプルであったデータフローは、副作用の管理を考慮すると複雑化してしまいます。これは Redux の問題点の1つとされます。それに対処するため、改良手法が提案されています。
Redux Thunk
Redux Thunk は、一部の Action を関数(Thunk)として扱うことによって、副作用や非同期処理を実行するライブラリです。ここでいう Thunk は遅延評価・非同期処理を指します。従来の Redux の Middleware に追加して利用します。なお、Redux Thunk は Redux を拡張するライブラリですが、Redux に適用後はアーキテクチャ名としても呼ばれます。
この設計はとてもシンプルです。Redux Thunk の Middleware の中で、発行された Action が関数型であるかを判定します。もし関数型ならば関数を実行する、そうでなければ従来のフローに進みます。
// Redux Thunk の Middleware
const thunkMiddleware = ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
// 発行された action が関数型なので、そのまま実行する
return action(dispatch, getState)
}
return next(action)
}
シンプルである一方、複雑な処理を扱う際には連続した Action の発行が必要になり、それがコールバック地獄や不要な再レンダリングの要因になります。また、Thunk の導入により、Action が本来の役割から逸脱するという問題もあります。
なお、ReSwift-Thunk 5 という Swift で実装されたライブラリが存在します。私もしばしば利用しています。副作用を Redux 側で管理して簡単に扱えるので便利です。しかし、関数としての Action、従来の Action が混在してしまい、ソースコードの可読性は怪しくなります。
Redux Saga
Redux Saga は、Redux で非同期処理や副作用を直感的に管理するライブラリです。Saga はアプリの中で副作用を個別に実行する独立したスレッドのようなイメージです6。Redux Saga は Middleware として設計されているため、Saga は Action に応じて起動、一時停止、中断ができます。State 全体にアクセスでき、Action の発行もできます。
たとえば、あるボタンをタップして、ユーザー情報を取得する例を考えましょう。この場合、タップイベントで「ユーザー情報を取得する」という Action requestUser
を発行します。
// View などでユーザー情報を取得する Action を発行(dispatch)する
const onPress = () => {
dispatch(requestUser({userID: '1234'}))
}
Redux Saga の takeEvery を利用して、Action と Saga を関連付けます。takeEvery は特定の Action が発行されるのを待ち、それが発行されたら Saga を実行します。ユーザー情報を取得する Saga はジェネレーター関数で作成します。
// ユーザー情報の取得を行う副作用
function* requestUserSaga(action) {
// たとえば API からユーザー情報を取得する
}
そして、作成した Saga と Action を takeEvery で関連付けます。これにより onPress() で requestUser
が発行されるたびに requestUserSaga
が実行されます。
// Redux および Redux Saga の初期設定時に呼ぶ
function* rootSaga() {
// Action "requestUser" が発行されたら、requestUserSaga を実行する
yield takeEvery(requestUser, requestUserSaga)
}
副作用は Saga にまとめて、View は必要な Action を発行するだけです。Redux Saga にしたがっていれば、自然と責務分けが実現されます。また、副作用は関数で定義されるので、テストも比較的簡単です。これらが、私が Redux Saga を好きな理由の1つです。
一般的に副作用の実装はアーキテクチャにロックインされてしまいます。しかしながら、Redux Saga は副作用を関数で定義するので、他のアーキテクチャへの移植性は比較的高いというメリットもあります。Redux Saga の深淵を覗いていなければ。
そんな Redux Saga ですが Swift での実装はありません。ないならば作りましょう、そうエンジニアならね。
Swift での実装アプローチ
従来の Redux Saga の実装や機能は複雑なため、完全再現は目指しません。一部機能の再現および実装を目標とします。今回は、middleware、call、fork、take そして takeEvery を紹介します。middleware は Redux から Redux Saga へ Action を伝える根底部分で、call、fork、take、takeEvery はよく利用される機能です。
従来実装は Saga にジェネレーター関数を用いていますが、Swift では Swift Concurrency を利用します。Action の発行や監視の非同期制御は Combine で行います。対象は iOS 13 以上になります。なお、Redux 本体の実装は既存ライブラリの ReSwift 7 を利用します。
また、Redux 本体との接点を最小限に抑えて独立性の高いライブラリを目指します。副作用を Saga に切離して管理することで、アーキテクチャの仕様に副作用の実装方法をなるべくロックインさせないためです。たとえば、新たに優れたアーキテクチャが登場した場合でも、そのアーキテクチャへの入替を容易にさせます。
今回は Xcode 14.3.1 で開発しています(Xcode 15.0.1 でも動作確認しています)。現在も開発中のため、紹介するソースコードは変更される場合があります。ご了承ください。
Swift で実装する
Redux Saga の実装において Action の比較が重要になります。ここでいう比較はインスタンス同士の比較ではなく、Action の種類、つまり、型の比較です。ReSwift では Action に enum や struct がしばしば利用されます。enum は要素や引数を含む型比較が難しいため、struct で Action を定義します。先ほど挙げた例と同様に、ユーザー情報を取得する場合を考えます。
struct RequestUser: Action { let userID: String }
Middleware を実装する
Redux で発行された Action を Redux Saga に伝達させる middleware を実装します。まず Action を Redux Saga 向けに発行するクラスを実装します。クラス名は Bridge にしました。このクラスが今回自作する Redux Saga の中核になります。
final class Bridge {
static let shared = Bridge()
private let subject = PassthroughSubject<Action, Error>()
private init() {}
func put(_ action: Action){
subject.send(action)
}
}
この Bridge を組み込んだ middleware を実装します。
func createSagaMiddleware<State>() -> Middleware<State> {
return { dispatch, getState in
return { next in
return { action in
Bridge.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
}
call を実装する
call は Saga の関数とその関数の引数を与えて実行するシンプルな非同期関数です。ここで Saga 関数の型を定義します。Action を引数にした非同期関数です。
typealias Saga<T> = (Action) async throws -> T
この関数型を使って call を次のように実装しました。関数の戻り値は利用しない場合もあるので @discardableResult を付けました。
@discardableResult
func call<T>(_ effect: @escaping Saga<T>,
_ arg: Action) async rethrows -> T {
return try await effect(arg)
}
fork を実装する
fork は call と同様に与えられた関数とその引数を実行します。異なる点は引数の関数が完了するまで待たないという点です。そのため、本来は async 不要ですが、従来実装が call や fork でも yield を付けているため、外観を揃えるために追加しました。
func fork<T>(_ effect: @escaping Saga<T>,
_ arg: Action) async rethrows -> Void {
Task.detached{
let _ = try await effect(arg)
}
}
take を実装する
take は特定の Action が発行されるのを待ちます。発行されたら、その Action を戻り値で返します。注意する点として Action のインスタンスを判定するのではなく、発行された Action の種類(型)を判定します。
まずは前述の Bridge に、特定の Action の型を受信する仕組みを実装します。なお、紹介するコードは iOS 15 以上向けのコードです。iOS 13 および 14 は従来のコールバック実装と withCheckedContinuation を利用します(本記事では省略します)。
final class Bridge {
// ...
private var subscriptions = [AnyCancellable]()
deinit {
subscriptions.forEach { $0.cancel() }
}
// 引数で指定した action の型が発行されるまで待つ
func take(_ actionType: Action.Type ) -> Future<Action, Never> {
return Future { [weak self] promise in
guard let self else { return }
self.subject.filter {
type(of: $0) == actionType
}.sink { _ in
// 必要に応じてエラー処理を行う
} receiveValue: {
promise(.success($0))
}.store(in: &self.subscriptions)
}
}
}
追加改修した Bridge を利用して take の関数を実装します。この take を実行すると、引数で指定した Action の型の監視が始まり、検出されるまで待ちます。
@discardableResult
func take(_ actionType: Action.Type) async -> Action {
let action = await Bridge.shared.take(actionType).value
return action
}
この take は Redux Saga の起点となる機能の1つです。Action の型で判定するという処理の設計や実装は、納得するまで何度も作り直しました。
takeEvery を実装する
takeEvery は特定の Action と Saga を関連付けて、その Action が発行されるたびに指定した Saga を実行します。一般に、この Saga と Action は関数とその引数の関係になります。前述で作成した take と fork を組み合わせて実装します。
func takeEvery<T>(_ actionType: Action.Type,
saga: @escaping Saga<T>) async {
Task.detached {
while true {
let action = await take(actionType)
try? await fork(saga, action)
}
}
}
無限ループ!?という感覚は正常です。ループの中で Action が発行されるまで待ち、それが発行されたら Saga を実行します。そして、次の発行を待つという処理を繰り返します。
Action が短期間で連続して発行されたら、処理が多重実行されると心配されるでしょう。不要な多重実行を避けるため、先に発行された Action の処理実行を優先する takeLeading や、逆に最新の発行を優先する takeLatest も用意されています(本記事では省略します)。
自作した Redux Saga を使おう
一連の実装が終わりました。自作した Redux Saga を使った簡単な例を紹介します。
ユーザー情報を取得する
まずは、実行させたい処理を Saga 関数で実装します。従来実装では Saga 関数を慣習的に xxxSaga と命名することが多いです。Swift でも、その慣習にそって、命名しました。
// ユーザー情報を取得する Saga
let requestUserSaga: Saga = { action async in
guard let action = action as? RequestUser else { return }
// API などで action.userID のユーザー情報を取得する(省略)
}
次に takeEvery で Action と Saga を関連付けます。
let appSage: Saga = { _ in
await takeEvery(RequestUser.self, saga: requestUserSaga)
}
これは前述の makeAppStore() で store や Middleware を設定した後に呼ぶとよいです。
func makeAppStore() -> Store<State> {
// ... store、Middleware の設定を行う
Task.detached {
try? await fork(appSage)
}
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 を利用して、トースト(スナックバー)を表示させましょう。トーストの表示には AlertToast 8 を利用します。まずは表示のための ViewModel、ViewModifier を準備します。
final class ToastViewModel: ObservableObject {
static let shared = ToastViewModel() // シングルトンで利用する
@Published var showToast: Bool = false
private(set) var message: String = ""
private init() {}
@MainActor func showToast(message: String) {
self.showToast = true
self.message = message
}
}
import AlertToast
struct ToastModifier: ViewModifier {
@ObservedObject private var viewModel = ToastViewModel.shared
func body(content: Content) -> some View {
content
.toast(isPresenting: $viewModel.showToast) {
AlertToast(type: .regular, title: viewModel.message)
}
}
}
作成した ToastModifier を Root に対応する View へ適用させます。
struct RootView: View {
var body: some View {
NavigationView { /* ... */ }.modifier(ToastModifier())
}
}
ToastViewModel の showToast(message:) を実行すれば、トーストが表示されます。これを Saga で実行させます。トースト表示用の Action と Saga を作成します。Action はトーストで表示される文字列をもつ構造体で定義します。なお、Saga を実行するだけなので、この Action に対応する State や Reducer は不要です。
struct ShowToast: Action {
let message: String
}
Saga で ToastViewModel の関数を実行します。ここで定義する toastSaga は前例と同様に makeAppStore() で呼ぶとよいです。
let toastSaga: Saga = { _ in
await takeEvery(ShowToast.self, saga: showToastSaga)
}
let showToastSaga: Saga = { action async in
guard let action = action as? ShowToast else {
return
}
let toastViewModel = ToastViewModel.shared
await toastViewModel.showToast(message: action.message)
}
さて準備ができたので、任意の ViewModel の関数で Action を発行しましょう。
final class UserViewModel {
func showToast() {
store.dispatch(ShowToast(message: "Toast is shown!"))
}
}
このように Redux Saga は API リクエストだけではなく、View コンポーネントの制御にも利用できます。なお、この実装例は異なる表示命令が多発した場合の排他処理が不十分です。明確に制御する場合は、State も併用して、キュー制御を組み込むことになるでしょう。今回は省略しました。
評価と考察
Redux Saga の主な機能を再現して、アプリの副作用を Saga にまとめることができました。View での処理がとてもシンプルになり満足しています。しかし、まだ対応・修正したいところも残っています。ひとまず形になったものの、見ていると気になるところが出てきます。
- 残りの未実装な Redux Saga の機能を実装する
- 足りていないエラー処理やテストコードを適切に整備して、安全にする
Redux Saga と iOS アプリ開発
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 の特性を活かす形で実装を試みて、ようやく形になりました。
OSS として
今回は自作した Redux Saga の middleware、call、fork、take そして takeEvery の実装を紹介しました。紙面の都合上から取り上げなかった他の機能 put、selector、takeLeading や takeLatest なども実装しています。それらの実装を含め、ソースコードは GitHub で公開しています。気になるところがあれば issue や PR を歓迎します。
https://github.com/mitsuharu/ReSwift-Saga
本記事で紹介した実装例はリポジトリ内の Example で実装しています。また、Swift Package Manager に対応しているので、ライブラリとして導入可能です。ご興味があれば、お試しください。Redux をベースとしたアーキテクチャのライブラリ、たとえば ReSwift や TCA などは、すでに多くのアプリで利用されています。今回紹介した Redux Saga も iOS アプリ開発者に興味を持っていただけたら嬉しいです。
-
https://fortee.jp/iosdc-japan-2023/proposal/d3c4feb1-0b2c-403b-b47c-6db885bc70d8 ↩
-
https://redux.js.org/understanding/thinking-in-redux/three-principles ↩
-
Saga(サーガ)は分散トランザクションにおけるデザインパターンの1つで、マイクロサービスにおけるデータや状態の整合性を管理します。 https://microservices.io/patterns/data/saga.html ↩
-
https://github.com/ReSwift/ReSwift バージョン 6.1.1 を利用しました ↩