LoginSignup
11
6

More than 1 year has passed since last update.

The Composable Architecture(TCA)の紹介と少し使った所感

Last updated at Posted at 2021-06-13
1 / 25

概要

The Composable Architectureという良さげなアーキテクチャがあると聞いて、iOSの簡単なアプリをSwiftUIで書いてみました。


作ったもの

https://github.com/tonionagauzzi/SwiftUITCASample
todo.gif

よくチュートリアルで作るTODOアプリです。

実は、Qiitaにほぼ同じことをされた先人さんがいました…(書く段階で知った。笑)
オリジナル性は無いですが、社内勉強会向けの資料なので、ご容赦ください!


The Composable Architectureの説明

全体図

出典:The Composable Architecture — Visualize Data Flows With a Diagram


各コンポーネントの説明をしていきます。

  • State
  • Action
  • Environment
  • Reducer
  • Store
    • 先ほどの図でState、Action、Reducer、Effectを囲んでいるのがStoreです。
  • Effect

State

UIに表示する内容を定義します。

ContentView.swift
// ToDo1個分のState。
struct ToDoState: Equatable, Identifiable {
    let id: UUID
    var description = ""
    var isCompleted = false
}

// アプリ全体のState。ToDoの配列。
struct AppState: Equatable {
    var todoStates: [ToDoState] = []
}

たとえば1つの画面でこれらを更新すると、他の画面にも即反映されます。


Action

タップやデータ受信などのイベントを定義します。

ContentView.swift
// ToDo1個に対して発生するイベント。
enum ToDoAction: Equatable {
    case checkTapped
    case textChanged(String)
    case removed
}

// アプリ全体に対して発生するイベント。TODOのは何番目に何を送るかを指定。
enum AppAction: Equatable {
    case todo(index: Int, action: ToDoAction)
    case addButtonTapped
}

Stateもそうですが、Equatableに準拠することでテストが容易になります(後述)。


Environment

依存関係を外部から注入します。DIです。今回はあまり活用しませんでした。

ContentView.swift
struct ToDoEnvironment {
}

struct AppEnvironment {
    var uuid: () -> UUID = UUID.init
}

DIとはなんぞやについては別記事を参照。Androidの記事ですが。


Reducer

Actionを受けてStateを更新する役割です。

ContentView.swift
let todoReducer = Reducer<ToDoState, ToDoAction, ToDoEnvironment> {
    state, action, environment in
    switch action {
    case .checkTapped:
        state.isCompleted.toggle()
        return .none
    case .textChanged(let text):
        state.description = text
        return .none
    case .removed:
        return .none
    }
}

let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
    todoReducer.forEach(
        state: \AppState.todoStates,
        action: /AppAction.todo(index:action:),
        environment: { _ in ToDoEnvironment() }
    ),
    Reducer { state, action, environment in
        switch action {
        case .todo(index: _, action: .checkTapped):
            state.todoStates = state.todoStates
                .enumerated()
                .sorted {
                    $0.element.description.lowercased()
                        < $1.element.description.lowercased()
                }
                .sorted {
                    !$0.element.isCompleted && $1.element.isCompleted
                }
                .map (\.element)
            return .none
        case .todo(index: let index, action: .removed):
            state.todoStates.remove(at: index)
            return .none
        case .todo(index: let index, action: let action):
            return .none
        case .addButtonTapped:
            state.todoStates.insert(
                ToDoState(id: environment.uuid()), at: state.todoStates.count
            )
            return .none
        }
    }
)
.debug()

ユーザー入力も処理結果も含めた全てのアクションがここへやって来ます。


Store(ViewStore)

State、Action、そしてReducerの1セットをStoreと呼びます。Viewを構築する役割です。

ContentView.swift
let todoStore: Store<ToDoState, ToDoAction>

let appStore: Store<AppState, AppAction>

Storeは、Stateの変更を全て監視してViewを再レンダリングします。

また、Viewから発生したActionをReducerに渡す役割もあります。


Effect

さて、今回は単純なサンプルなので、Reducerでは全部return .noneしていました。

しかし、本来Reducerは必要に応じてEffectを使います。

こちらの例を使って説明します。


struct AppEnvironment {
    var mainQueue: AnySchedulerOf<DispatchQueue>
    var numberFact: (Int) -> Effect<String, ApiError>
}

↑まず、EnvironmentのnumberFactで時間のかかる処理をクロージャとして注入します。

処理はUIと非同期で実行したいので、実行スレッドの情報をmainQueueで渡します。


let appView = AppView(
  store: Store(
    initialState: AppState(),
    reducer: appReducer,
    environment: AppEnvironment(
      mainQueue: .main,
      numberFact: { number in Effect(value: "\(number) is a good number Brent") }
    )
  )
)

↑これが渡し元です。アプリ全体のStore初期化時に、依存関係を注入しています。


case .numberFactButtonTapped:
    return environment.numberFact(state.count)
        .receive(on: environment.mainQueue)
        .catchToEffect()
        .map(AppAction.numberFactResponse)

↑そしてReducerでは、受け取った処理を行いつつ、非同期に監視します。何かしらreceiveしたら、Reducer自身に対して新たなActionを発行します。

[NOTE] ちなみに、returnの型はEffect<AppAction, Never>型となっています。


case let .numberFactResponse(.success(fact)):
    state.numberFactAlert = fact
    return .none

case .numberFactResponse(.failure):
    state.numberFactAlert = "Could not load a number fact :("
    return .none

↑Reducerは新たなActionを受け取ったら、stateを更新します。成功/失敗の2種類あります。


このようにして、副作用と呼ばれるものを外部に出さずReducer内で完結させられるのが、Reduxなどには無いThe Composable Architectureの特徴です。


TCAの強み

  • State management
  • Composition
  • Side effects
  • Testing
  • Ergonomics

出典:What is the Composable Architecture?


一言で言えば、画面をまたいで状態を値型で共有でき、小機能単位でStoreを分割でき、Actionの副作用をReducer内部で扱うことができ、ビジネスロジックのテストが容易で、これらすべてを短く人間工学的なコードで書ける、といったところでしょう。


テストのしやすさ

ToDoリストに削除機能を追加したコミットです。

テストに注目します。

SwiftUITCASampleTests.swift
    func testRemoveToDo() {
        let (uuid1, uuid2) = (UUID.init(), UUID.init())
        let store = TestStore(
            initialState: AppState(
                todoStates: [
                    ToDoState(
                        id: uuid1,
                        description: "ToDo 1",
                        isCompleted: false
                    ),
                    ToDoState(
                        id: uuid2,
                        description: "ToDo 2",
                        isCompleted: true
                    )
                ]
            ),
            reducer: appReducer,
            environment: AppEnvironment()
        )
        store.assert(
            .send(.todo(index: 0, action: .removed)) { expected in
                expected.todoStates = [
                    ToDoState(
                        id: uuid2,
                        description: "ToDo 2",
                        isCompleted: true
                    )
                ]
            }
        )
    }

初期initialStateと、送るactionとを指定し、実行後のexpectedが期待通りのStateであることを確認するテストですが、非常に読みやすく書けました。

また、サーバーやDBを使い分けるような大きなアプリになったとき、MockEnvironmentみたいなダミーをenvironmentに注入すれば、ビジネスロジックだけをテストすることも簡単です。


補足

Swift UIとCombineに依存しているため、iOS 13、macOS 10.15、Mac Catalyst 13、tvOS 13、および watchOS 6以上でなければ使えませんが、古いOS向けにReactiveSwiftRxSwiftのForkも一応用意されています。


所感

クリーンアーキテクチャーの記事を書いたのがちょうど2年前です。ってか、The Clean ArchitectureもTCAですね笑

今回のTCAを使ってみて、機能追加のしやすさやモジュール分割の自由度など拡張性の高さを感じました。この勢いで、もう少し複雑なアプリも作ってみたいですね。


一方、現状はApple製の新しめOS向けに特化したアーキテクチャなので、X-Platform前提の時点で難しかったり、古いOSのサポートを切れない実案件では選定しにくいという課題はあります。

実際、大規模案件では今でもMVVMやクリーンなほうのTCAを扱うことが多いです。

が、新しいアーキテクチャがあちこちで使われ出してるなと感じたら、基礎だけでも素早く学ぶよう個人的に心掛けています。

なぜかというと、人の書いたコードを読む抵抗が減るからです。


昨今、1から何かを発明する開発スタイルは淘汰され、既にあるものを繋ぎ合わせてニーズを実現するのが、いろんな分野で主流になって来てると感じます(※諸説あり)。

人の書いたコードやAPI仕様書を見たとき、いちいちアレルギーを発症していたらやっていけないので、「これはこういう思想でこうなんだな」とすぐに見抜くために、自分の中にパターン化された前提知識をいくつも持っていたいと思います。

と、良くない方向にポエム化して来たので、オチをつけましょう。最小機能単位で切り出しと疎結合ができるコンポーザブルアーキテクチャは、まさにこの切り貼りの時代にうってつけな感じですね!


参考記事

※もっとよく知りたい方は、これらの素晴らしい記事orセッションをおすすめします!

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6