search
LoginSignup
6
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

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

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

by tonionagauzzi
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セッションをおすすめします!

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
What you can do with signing up
6
Help us understand the problem. What are the problem?