RNIアドベントカレンダー2023 4日目 iOS TCAへの移行を行ってみた感想と既存との調整点

RNIアドベントカレンダー2023 4日目

こんにちは。リサーチ・アンド・イノベーションの小川です。 iOSエンジニアとしてCODEアプリの開発を担当しています。



CODE iOSチームの2023年の一番大きかったトピックとしてはTCAへの移行でした。

TCAとは

公式リポジトリ

ここ近年どんどんメジャーになってきているアーキテクチャ

The Composable Architecture の略。pointfree.coが開発。

Reduxとかなり近い構成です。

スマホアプリは画面の生存期間が長く、Webアプリと同等以上にRedux的アーキテクチャの恩恵を受けられると思ったので採用の候補にしました。

移行してみて感じたこと



公式が掲げる 「開発者にとっての使いやすさ」というのを非常に実感しました。

色々利点は感じたのですが特に感じたのは、TCAであれば全ての画面やコンポーネントを同じ形で表現できるということのメリットです。
これにより「驚き最小の原則」が守られると感じました。


また、インターフェースに一貫性があることでコンポーネントを分業で実装することが容易になりました。

実装する際の理解

実装を始めて当初はなかなかその記述の意味が分からず、コピーして試行錯誤していました。

慣れてきた今、以下は改めて認識した事項となっています。

コンポーネントの実装で集中すれば良い点

  • そのコンポーネントが持つ状態を把握し記述する
  • アクションによって状態がどのように変わるかを記述する
    • もし自分の状態が変わらないのであれば何もしないことを記述すれば良い

コンポーネントを結合する際の前提

結合の際、親が子のアクションを利用する部分をEnumで網羅的に記述できるのでどのように連携しているのかの把握もしやすいです。

TCAではEnumを多用しており、associated valueの中身にさらにEnum入れ子にする記述で親と子の関連をシンプルな記述で実現しています。

コンポーネントが状態を持たないシンプルな例

以下のようなカウンターで考えます。

コンポーネントの実装方針は以下になります。

  • 状態
    • なし
  • アクション
    • + ボタン押下
      • 何か変化することはない(ただ実行されることを書くだけ)
    • - ボタン押下
      • 何か変化することはない(ただ実行されることを書くだけ)

具体的には以下のように非常にシンプルなものになります。

public struct ChildViewReducer: Reducer {
    public struct State: Equatable { // 状態はなし

    }

    public enum Action: Equatable { // アクションの定義
        case plus
        case minus
    }

    public var body: some Reducer<State, Action> { // アクションによって状態がどう変化するか
        Reduce { state, action in
            switch action {
            case .plus, .minus: 
                return .none // このコンポーネントの状態は何も変化しない
            }
        }
    }
}

View側の実装は以下です。+ , - ボタンが存在し、アクションを送信するのみです。

public struct ChildView: View {
    public let store: StoreOf<ChildViewReducer>
    public var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            HStack {
                Button("+") {
                    viewStore.send(.plus)
                }
                Button("-") {
                    viewStore.send(.minus)
                }
            }
        }
    }
}

コンポーネントの実装は以下になります。

  • 状態

    • カウントの数字
    • 子供用の状態
  • アクション

    • 子供のアクションを捕捉する
      • + 押下
        • カウントを1増やす
      • - 押下
        • カウントを1減らす

親のアクション定義で子供のアクションを利用する際は case xxx(子供のアクション) と宣言できます。

以下の例では child という名前のアクションに子供のアクションをマッピングしています。

public struct ParentViewReducer: Reducer {
    public struct State: Equatable { // 状態はカウントの数字, 子供用の状態を持つ
        public var count = 0
        public var childState = ChildViewReducer.State()
    }

    public enum Action: Equatable {
        case child(ChildViewReducer.Action) // 子供のアクションを中身とするアクションを定義
    }

    public var body: some Reducer<State, Action> { // 子供のアクションを利用して自分のカウント状態を変更
        Reduce { state, action in
            switch action {
            case .child(.plus):
                state.count += 1
                return .none
            case .child(.minus):
                state.count -= 1
                return .none
            }
        }
    }
}

Viewは以下となります。

コンポーネントを使用する際に scope にて自分が持つ 子供用の状態子供のアクションを中身にとるアクション を指定します。

public struct ParentView: View {
    public let store: StoreOf<ParentViewReducer>
    public var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Text(String(viewStore.count))
                ChildView(store: store.scope(state: \.childState, action: ParentViewReducer.Action.child))
            }
        }
    }
}

このように実装しますが、各々のコンポーネントを一貫したルールで連結できるのがとてもよかったです。

あと、外界とのやり取りはすべてDependencyとして定義する点も一貫性があって理解しやすく、モックデータの用意の意識も必ずすることになるので動作検証もスムーズに行えました。

既存実装との調整について


リプレースにあたり、なかなか全てをいきなり入れ替えるのは難しかったので一部分をTCAに置き換え、徐々にその領域を広げていく方針を取りました。



非TCA実装からTCAデータの更新を行う場合

データ更新のタイミングでNotificationを送信し、それをTCAのrootのViewが受け取ってReducerのActionを呼び出すことで実現しました。

例えば以下のような形です。

.onReceive(NotificationCenter.default.publisher(for: Notification.Name(rawValue: Constants.NotificationName.updateTCA))) { _ in
    viewStore.send(.updateFromOldData)
}

既存画面(UIKit)からのTCA画面呼び出し

こちらはSwiftUI Viewと同様UIHostingControllerで実現しました。

let view = TCAView(store: Store(initialState: .init()) {
    TCAViewReducer()
})
let hostingVc = UIHostingController(rootView: view)

途中にUIKitが挟まる場合

遷移上、途中のステップに既存画面(UIKit)が挟まる場合と挟まらない場合が生じました。

  • パターン1: TCA画面(A) -> TCA画面(B)
  • パターン2: TCA画面(A) -> UIKit画面(X) -> TCA画面(B)

このパターン2についてですが、UIKitの画面(X)に(B)のStoreをpropertyとして持たせ、(A)から(X)に遷移する際に(A)->(B)にscopeしたStoreを持たせることで実現しました。

ちょっと言葉だと分かりづらいですが、以下な感じです。

// AからXの遷移部分のコード
let xViewController = ... 
xViewController.bStore = store.scope(state: \.b, action: AReducer.Action.b)
// xViewController を UIWindowSceneから取得したViewControllerから表示する

// XからBの遷移部分のコード
let view = BView(store: bStore)
let hostingVc = UIHostingController(rootView: view)
self.navigationController?.pushViewController(hostingVc, animated: true)

まとめ

TCAリプレースにより、開発体験がかなり改善されました。

まだ移行完了しているのは一部なので、引き続きTCAへのリプレースを継続しています。


リサーチ・アンド・イノベーションではエンジニアを募集中です。

興味を持っていただけた方はお気軽にご連絡ください!

採用情報 r-n-i.jp