kubell Creator's Note

ビジネスチャット「Chatwork」のエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

Redux入門 〜iOSアプリをReduxで作ってみた〜

こんにちは!モバイルアプリケーション開発部のiOSエンジニア、折田 (@orimomo)です。 Chatworkに入社して早一年…。時が経つのがあっという間ですね。

最近は社内でアーキテクチャ刷新の話が出たりしていて、個人的にもアーキテクチャへの関心が高まっています。 そこで、以前から気になっていた「Redux」について、知識ゼロの状態から学んでみることにしました。

今回の記事では、Reduxの概要からサンプルアプリを作ってみるところまで、学んだことをゆるっとご紹介できればと思います。(初学者ゆえ、勘違いしている点などありましたら教えていただけますと幸いです🙇‍♀️)

Reduxとは

概要

Reduxは、State(状態)を管理しやすくするためのライブラリです。 GitHubの他に公式ドキュメントも充実しています。

リリースされたのは2015年8月です。 時代とともにJavaScriptアプリケーションの要件が複雑になり、より多くの状態を管理する必要が出てきたという背景がありました。

Reactとの相性がいいことからWebアプリ開発の文脈で語られることが多いですが、昨今ではモバイルアプリ開発にも応用され、実際に大規模開発でも使われるようになってきています。

ReSwiftをはじめとするiOS向けのRedux系ライブラリも複数存在しますし、最近よく耳にする「The Composable Architecture」と似ている部分もあります。アツいですね👍

全体像とフロー

f:id:cw-orita:20210517165431g:plain

引用元

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で表示されるというシンプルな構成にしました。

f:id:cw-orita:20210517154838p:plain

(アプリ内容から察した方もおられると思いますが、筆者はアニメが好きでして、今期は「スーパーカブ」「聖女の魔力は万能です」「恋と呼ぶには気持ち悪い」などを見ております☺️)

  • ReSwiftライブラリを使っていない
  • API通信を行う(Middlewareを有している)
  • SwiftUIを使っている

あたりが特徴的な部分かなーと思います。 アニメのデータ取得には、Annict Web API を使わせていただきました。

データフローはこんな感じです。

f:id:cw-orita:20210518143236p:plain

実際のコード

各要素のコードをポイントを絞ってご紹介します。 今回のコードはすべて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にはfetchAnimessetAnimesの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は reducerstatemiddlewares プロパティを持っていて、 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属性を付与していること
  • 初期化したstoreenvironmentObjectになっていること

これらにより、Stateの更新をView側で検知して画面を更新することができます。

おわりに

日々iOSアプリを作る中で、状態のオブジェクトがプロジェクトのあちこちに散らばって把握するのに時間がかかったり、状態のテストがしずらかったりという課題感を持っていたので、Reduxのシンプルなデータフローや状態管理は魅力的でした。

アーキテクチャがしっかりしていると、このコードはどこに書くとか、このファイルはどのディレクトリに入れるとかに迷わなくなりますし、コードレビューをする方も楽になったりするので、「レイヤーが細かく分かれているアーキテクチャーいいよな〜」と改めて思いました。

今後は「The Composable Architecture」や「Clean Architecture」など、他のアーキテクチャについても同様に深めていきたい気持ちです。

今回学ぶにあたって、iOS × Reduxに的を絞った文献はそう多くはなく(Web関連の文献がやはり多かった)、以下の文献・教材には特に助けられました。この場を借りて感謝いたします!

この記事が、今後Reduxを学ばれる方の参考になれば嬉しいです😌