こんにちは!モバイルアプリケーション開発部のiOSエンジニア、折田 (@orimomo)です。 Chatworkに入社して早一年…。時が経つのがあっという間ですね。
最近は社内でアーキテクチャ刷新の話が出たりしていて、個人的にもアーキテクチャへの関心が高まっています。 そこで、以前から気になっていた「Redux」について、知識ゼロの状態から学んでみることにしました。
今回の記事では、Reduxの概要からサンプルアプリを作ってみるところまで、学んだことをゆるっとご紹介できればと思います。(初学者ゆえ、勘違いしている点などありましたら教えていただけますと幸いです🙇♀️)
Reduxとは
概要
Reduxは、State(状態)を管理しやすくするためのライブラリです。 GitHubの他に公式ドキュメントも充実しています。
- GitHub - reduxjs/redux: Predictable state container for JavaScript apps
- Redux - A predictable state container for JavaScript apps. | Redux
リリースされたのは2015年8月です。 時代とともにJavaScriptアプリケーションの要件が複雑になり、より多くの状態を管理する必要が出てきたという背景がありました。
Reactとの相性がいいことからWebアプリ開発の文脈で語られることが多いですが、昨今ではモバイルアプリ開発にも応用され、実際に大規模開発でも使われるようになってきています。
ReSwiftをはじめとするiOS向けのRedux系ライブラリも複数存在しますし、最近よく耳にする「The Composable Architecture」と似ている部分もあります。アツいですね👍
全体像とフロー
Reduxでは、ユーザー行動に基づくAction発行を起点として、一方向にデータが流れていくように設計されています。 上に貼ったアニメーションが一番流れを追いやすかったです。
要素
State
アプリケーション全体の状態を表します。ツリー構造になっており、Storeで一元管理されます。
Action
Stateの変更内容を持っているオブジェクトです。Storeに何らかのイベントが発生したことを伝えます。
Middleware
ActionがReducerに到達する前に、任意の処理を実行する関数です。 非同期のAPI通信で用いることが多く、簡易なアプリケーションであれば使わないケースもあります。
Reducer
現在のStateとディスパッチされたActionから、新しいStateを作成して返す純粋関数です。 Stateと同様、ツリー構造になっています。
Store
アプリケーション内に1つだけ存在する、StateとReducerを保持するオブジェクトです。 Reducerによって新しいStateが生成されたことをView側で検知するための購読機能を持っています。
3原則
Reduxには3つの原則が定義されており、これらに則って状態変化の流れを制限することで、複雑な状態の管理が可能になっています。
Single source of truth(信頼できる唯一の情報源)
アプリケーションの全体のStateは、1つのオブジェクトツリーとして管理され、1つのStoreで保持されます。 これにより、色々な状態のインスタンスがアプリケーションのあちこちに散らばって複雑になるのを防ぎつつ、デバッグも容易にすることができます。
State is read-only(状態は読み取り専用)
Stateを変更する唯一の方法は、変更内容を持ったActionを発行することです。 発行されたActionがReducerにディスパッチされることでのみ、新しいStateは生成されます。 それまでの間、View側で参照している現在のStateは更新されないことが保証されます。
Changes are made with pure functions(変更は純粋関数で行う)
新しいStateを作成するReducerは、純粋関数である必要があります。 純粋関数とは、副作用を発生させない(与えられた要素や関数外の要素を変化させず、戻り値以外の出力を行わない)という特徴があり、Stateの作成処理をシンプルに保ってくれます。
ReduxでiOSアプリを作ってみる
作ったアプリの説明
今期のアニメ一覧を表示してくれる簡単なアプリをお試しで作ってみました。 一覧画面ではアニメタイトルと画像を表示し、セルをタップすることでアニメの公式サイトがWebViewで表示されるというシンプルな構成にしました。
(アプリ内容から察した方もおられると思いますが、筆者はアニメが好きでして、今期は「スーパーカブ」「聖女の魔力は万能です」「恋と呼ぶには気持ち悪い」などを見ております☺️)
- ReSwiftライブラリを使っていない
- API通信を行う(Middlewareを有している)
- SwiftUIを使っている
あたりが特徴的な部分かなーと思います。 アニメのデータ取得には、Annict Web API を使わせていただきました。
データフローはこんな感じです。
実際のコード
各要素のコードをポイントを絞ってご紹介します。 今回のコードはすべてGitHubに載せていますので、詳しくはそちらをご覧いただければと思います。
GitHub - orimomo/ReduxAnimeApp
State
Stateにはツリー構造の最上層にあるAppState
と、それに連なるAnimeState
を定義しています。
AnimeState
のプロパティanimes
には、APIから取得したアニメデータを格納します。
protocol ReduxState { } // 最上層のState struct AppState: ReduxState { var animeState = AnimeState() } struct AnimeState: ReduxState { var animes = [Anime]() }
注目すべきは、それぞれのStateがReduxState
protocolに準拠していることです(protocolの名前は何でも大丈夫です)。
ReduxState
protocolに準拠したインスタンスのみがStateとして扱われます。
Action、Middleware 、Reducer
ActionにはfetchAnimes
とsetAnimes
の2つを定義しています。
Stateと同様に、それぞれのActionがAction
protocolに準拠していて、準拠したインスタンスのみがActionとして判定されます。
protocol Action { } struct FetchAnimes: Action { } struct SetAnimes: Action { let animes: [Anime] }
fetchAnimes
アクションは、アニメ一覧画面が表示されたタイミング.onAppear
で発行され、それをトリガーとしてMiddlewareの中で非同期のAPI通信がおこなわれます。
func animeMiddleware() -> Middleware<AppState> { return { state, action, dispatch in switch action { case _ as FetchAnimes: // 非同期通信する AnimeService().FetchAnimes { result in switch result { // 成功したらsetAnimesアクションを発行する case .success(let animes): if let animes = animes { dispatch(SetAnimes(animes: animes)) } case .failure(let error): print(error.localizedDescription) } } default: break } } }
アニメデータの取得に成功したら、今後はsetAnimes
アクションが発行され、それをトリガーとしてappReducer関数で新しいAnimeState(animes
プロパティにアニメデータの配列が詰められたAnimeState)が生成・返却されます。
// 最上層のReducer func appReducer(_ state: AppState, _ action: Action) -> AppState { var state = state // 下層のReducerを呼び出す state.animeState = animeReducer(state.animeState, action: action) return state } func animeReducer(_ state: AnimeState, action: Action) -> AnimeState { var state = state // 型をチェック switch action { case let action as SetAnimes: state.animes = action.animes default: break } return state }
Reducer関数はツリー構造になっていて、Actionがディスパッチされると最上層のappReducer関数がまず呼び出され、その中で下層のanimeReducer関数が呼び出されます。 関連のないActionでもanimeReducer関数に入ってくる可能性があるので、Actionの型をSwitch文などでチェックすることをお忘れなく。
Store
Storeは reducer
、 state
、 middlewares
プロパティを持っていて、
Middlewareの処理を実行し、ActionをReducerにディスパッチすることで、Stateを更新します。
class Store<StoreState: ReduxState>: ObservableObject { // プロパティ var reducer: Reducer<StoreState> @Published var state: StoreState var middlewares: [Middleware<StoreState>] init(reducer: @escaping Reducer<StoreState>, state: StoreState, middlewares: [Middleware<StoreState>] = []) { self.reducer = reducer self.state = state self.middlewares = middlewares } // アクションを引数に持つdispatch関数 func dispatch(action: Action) { // Stateの更新 DispatchQueue.main.async { self.state = self.reducer(self.state, action) } // Middlewareの実行 middlewares.forEach { middleware in middleware(state, action, dispatch) } } }
Storeの初期化はアプリケーションのエントリーポイントでおこなっており、最上層のViewに初期化したStoreを紐付けることで、どのViewからもアクセスができるようにしています。 こうすることでグローバルなStoreがアプリ内に1つだけ存在することになります。
@main struct ReduxAnimeAppApp: App { var body: some Scene { // Storeの初期化 let store = Store(reducer: appReducer, state: AppState(), middlewares: [animeMiddleware()]) WindowGroup { ContentView().environmentObject(store) } } } struct ContentView: View { @EnvironmentObject var store: Store<AppState> ... }
購読機能については
- Storeクラスが
ObservableObject
に準拠していること - Storeクラス内で
state
プロパティに@Published
属性を付与していること - 初期化した
store
がenvironmentObject
になっていること
これらにより、Stateの更新をView側で検知して画面を更新することができます。
おわりに
日々iOSアプリを作る中で、状態のオブジェクトがプロジェクトのあちこちに散らばって把握するのに時間がかかったり、状態のテストがしずらかったりという課題感を持っていたので、Reduxのシンプルなデータフローや状態管理は魅力的でした。
アーキテクチャがしっかりしていると、このコードはどこに書くとか、このファイルはどのディレクトリに入れるとかに迷わなくなりますし、コードレビューをする方も楽になったりするので、「レイヤーが細かく分かれているアーキテクチャーいいよな〜」と改めて思いました。
今後は「The Composable Architecture」や「Clean Architecture」など、他のアーキテクチャについても同様に深めていきたい気持ちです。
今回学ぶにあたって、iOS × Reduxに的を絞った文献はそう多くはなく(Web関連の文献がやはり多かった)、以下の文献・教材には特に助けられました。この場を借りて感謝いたします!
- Composable SwiftUI Architecture Using Redux | Udemy
- PEAKS(ピークス)|iOSアプリ設計パターン入門
- GitHub - ReSwift/CounterExample: Demo Application of Unidirectional Data Flow in Swift, Built with ReSwift
この記事が、今後Reduxを学ばれる方の参考になれば嬉しいです😌