Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
15
Help us understand the problem. What is going on with this article?
@zeero

the Composable Architecture の始め方

iOSDCでのyimajoさんの発表など、the Composable Architecture(以下、TCA)が良さそうという評判を聞いて調べてみました。どこから始めていいのか少し迷ったので、公式レポジトリのREADMEにあるBasic UsageをベースにTCAの始め方を解説してみます。
TCAどころかSwiftUIすら勉強し始めなので、間違いなどあるかもしれません。コメントで教えて頂けると嬉しいです!

TCAって何?

TCAはiOSなどのAppleプラットフォームのアプリケーション開発のためのフレームワークです。
Combineを前提としているため、iOSだとiOS13以上が対象となる制約があります。(ちなみにiOS13未満向けにRxSwift版のforkもあるみたいです)
SwiftUIとの親和性が高く、SwiftUIをより使いやすくする機能が充実しています。

TCAが提供する機能については、READMEのWhat is the Composable Architecture?に以下が挙げられています。

  • State Management
    シンプルな値型によるアプリケーションの状態管理の手段を提供します。複数の画面にまたがって状態は共有され、1つの画面での状態の更新はただちに他の画面にも反映されます。

  • Composition
    巨大な機能を小さなコンポーネントにブレイクダウンして独立したモジュールに分離し、それらを簡単にまとめ直して1つの機能に組み上げる手段を提供します。

  • Side Effects
    可能な限り最もテスタビリティが高く、理解しやすい方法で、副作用を伴うアプリケーションの外側の世界とやり取りする手段を提供します。

  • Testing
    TCAを使って実装した機能をテストするだけでなく、多くの部品で構成された機能の統合テストを書いたり、副作用がアプリケーションにどのように影響するかを把握するためのエンドツーエンドのテストを書く手段を提供します。これにより、ビジネスロジックが期待通りに動作していることを強力に保証することができます。

  • Ergonomics
    コンセプトと部品を可能な限り少なくしたシンプルなAPIにより、上記のすべてを達成する手段を提供します。

アーキテクチャ概要

コードを書き始める前に簡単にTCAのアーキテクチャとしての概要を説明したいと思います。

TCAはReduxやFluxといったアーキテクチャと近い構造を持っていて、Store/State/Action/Reducerという型を持っています。またこれらに加えてEffectとEnvironmentという型も定義しています。

まずState/Action/Reducerについて解説します。

  • State: 1つの機能がそのロジックを実行したり、UIを描画したりするために、必要となるデータを定義する。
  • Action: 1つの機能で発生するすべてのアクション(ユーザからのUI操作やユーザへの通知やデータ層からのデータ受け取りなど)を表現する。
  • Reducer: 受け取ったActionに応じてStateを更新するファンクション。Stateを更新するために発生するあらゆる作用(副作用を含む)に対して処理を行う責務をもち、作用からは Effect 型で値が返却されてくることになる。

上記で出てきたEffectがTCAに特有の考え方になっています。

  • Effect
    • 副作用(Side Effect)を含む作用を表現する型
    • Reduxでは副作用はStore/State/Action/Reducerの枠組みの外で扱うべきとなっている(ActionCreatorなどが担当することになる)が、TCAではReducerの中で扱うことができるようになっている。

ReducerがEffectを扱うことができることによって、プレゼンテーション層/ドメイン層/データ層の各層で発生するすべてのアクションを単なるActionとして扱うことができるようになっていることが、TCAの特徴の一つだと思います。

次にEnvironmentです。

  • Environment: 1つの機能において必要となる依存を保持する。

いわゆるDI(Dependency Injection)を提供します。
Basic Usageの中ではあまり本質的な使い方がされてなくてよくわかっていない部分があるのですが、Environmentによってロジックの入れ替えが実現可能となり、テスタビリティ向上やState/Action/Reducerの再利用の促進(よりComposableにできる)に繋がっているのかなーと思っています。

最後にStoreです。

  • Store: State/Action/Reducer/Environmentを一つにまとめ、それらの窓口となる。すべてのActionはStoreに対して送信され、それを受けてStoreはReducerを動かす。Reducerの処理結果で発生したStateの変更は、Storeを経由してViewで監視できるようになっている。

StoreをViewにバインドする時は WithViewStore を使い、ViewStoreというViewにバインドできる型になります。

これらを図にまとめると以下のような関係性になっています。
TCAアーキテクチャ
Viewでの操作はすべてActionに変換されます。
ActionをReducerで処理してStateに反映する単方向のデータ制御を実現しており、ViewStoreを経由してViewにStateの変更が反映されるようになっています。
データ層へのアクセスについては、ReducerまたはEnvironmentを経由してEffectを発行し、非同期でデータ操作を行います。結果はEffectからActionを投げることで、Stateに反映されます。

Basic Usage

ここからはTCAを使ってコードを書いていきます。
Basic Usageに記載されている、数字をインクリメント/デクリメントする画面を作成します。
画面イメージ
「+」ボタンでカウンタがインクリメントされ、「-」ボタンでデクリメントされます。
「Number fact」ボタンを押すとアラートを表示します。アラートではカウンタを含んだ文字列が表示されるようにします。

プロジェクト作成からインストール

まずはXcodeで新規プロジェクトを作成します。「Interface」には「SwiftUI」を選択します。
New Project
TCAをインストールします。
TCAはライブラリとして提供されており、Swift Package Managerを使ってインストールします。
SPM

State/Action/Environment/Reducer

TCAの根幹となるState/Action/Environment/Reducerを作ります。

State

import ComposableArchitecture

struct AppState: Equatable {
  var count = 0
  var numberFactAlert: String?
}

count が画面に表示するカウンタの値です。
numberFactAlert には画面の「Number fact」ボタンを押下した時にアラートで表示する文字列が格納されます。

Action

enum AppAction: Equatable {
  case factAlertDismissed
  case decrementButtonTapped
  case incrementButtonTapped
  case numberFactButtonTapped
  case numberFactResponse(Result<String, ApiError>)
}

struct ApiError: Error, Equatable {}

enumでActionを定義します。各ケースは以下のアクションに対応しています。

  • factAlertDismissed: アラートのボタンを押した時のアクション
  • decrementButtonTapped: マイナスボタンを押した時のアクション
  • incrementButtonTapped: プラスボタンを押した時のアクション
  • numberFactButtonTapped: 「Number fact」ボタンを押した時のアクション
  • numberFactResponse: 「Number fact」ボタンにより発生するEffectの戻りのアクション

factAlertDismissed から numberFactButtonTapped までがプレゼンテーション層から発生するアクションです。
numberFactResponse はドメイン層/データ層から発生するアクションです。Basic Usageではデータ層へのアクセスはないため、ドメイン層からのアクションになりますが、データ層にアクセスした場合も似たような形になると思います。成功/失敗に対応できるように Result をAssociated Valueとして受け取れるようになっています。

Envrionment

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

依存対象を切り出しています。
numberFact はカウンタの値を引数にアラートに表示する文字列を作るクロージャです。

Reducer

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
  switch action {
  case .factAlertDismissed:
    state.numberFactAlert = nil
    return .none

  case .decrementButtonTapped:
    state.count -= 1
    return .none

  case .incrementButtonTapped:
    state.count += 1
    return .none

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

  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への処理」と「実行するべきEffect」について記述します。
ケース文の最後にreturnしている .none はEmptyのEffectです。これによりそのケースでは実行されるべきEffectがないことを示しています。つまりReducerはState, Action, Environmentを引数にしてEffectを返すクロージャと認識すればよいかなと思います。(正確にはクロージャを引数にした.initを持つstructです)

Effectを使っている case .numberFactButtonTapped: について取り上げます。
「Number fact」ボタンが押された際はEffectで非同期的な処理を実行するようになっています。
AppEnvironment で定義した numberFact クロージャを動かし、戻り値のEffectを最終的にはActionに変換しています。Actionに変換することにより再度Reducerが呼び出されます。ドメイン層/データ層からの戻り値を再びReducerで処理することができるようになっています。
(途中の receive(on:) は実行スレッドの指定、 catchToEffect()receive(on:)Publisher に変換された型を再び Effect に戻しています。)

View

SwiftUIのViewへTCAを組み込みます。プロジェクト新規作成時にデフォルトで存在する ContentView を使うように原本から少し改変しています。

import SwiftUI
import ComposableArchitecture

struct ContentView: View {

  let store: Store<AppState, AppAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      VStack {
        HStack {
          Button("−") { viewStore.send(.decrementButtonTapped) }
          Text("\(viewStore.count)")
          Button("+") { viewStore.send(.incrementButtonTapped) }
        }

        Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
      }
      .alert(
        item: viewStore.binding(
          get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
          send: .factAlertDismissed
        ),
        content: { Alert(title: Text($0.title)) }
      )
    }
  }
}

struct FactAlert: Identifiable {
  var title: String
  var id: String { self.title }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView(
      store: Store(
        initialState: AppState(),
        reducer: appReducer,
        environment: AppEnvironment(
          mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
          numberFact: { number in
            Effect(value: "\(number) is a good number Brent")
          }
        )
      )
    )
  }
}

通常のSwiftUIで出てこないTCA特有の要素について解説していきます。

WithViewStore(self.store) { viewStore in }

WithViewStore でViewにStoreを組み込みます。
WithViewStore はSwiftUIの View を返すので、 var body: some View {} の中で使うことができます。

Button("−") { viewStore.send(.decrementButtonTapped) }

viewStore.send() でActionを送ります。
ここでは「-」ボタンを押した時に .decrementButtonTapped Actionを送るように設定しています。

Text("\(viewStore.count)")

viewStore のプロパティとしてStateのプロパティにアクセスできます。
Stateが更新されれば自動的にViewが更新されます。
SwiftUIだと値をバインドする時にはプロパティラッパーを使うことになると思うのですが、単なるViewStoreのプロパティとして書けるので簡単ですね。
(StoreからStateにプロパティが置き換わっているのがどうやって実現しているのかはまだよくわかっていません...🤔 )

viewStore.binding(get:send:)

viewStore.binding(get:send:) でSwiftUIの Binding を提供して、ViewとViewStoreの間に双方向のバインディングを実現します。ViewStore→View方向のバインディングは get: で指定してStateが引数として渡され、Viewへの出力内容を設定できます。View→ViewStore方向のバインディングは send: で指定し、Viewの操作をトリガーにActionを送ることができます。

ここでは alert(item:content:)item: Binding<FactAlert?> を作るために使われています。
ViewStore→View方向では、Stateの numberFactAlert をAlertで使用する FactAlert struct に変換しています。Stateで保持しているドメイン層のモデルをプレゼンテーション層のモデル( FactAlert )に変換しているイメージかなと思います。
View→ViewStore方向では、アラートでボタンを押した時のアクションを設定しています。

Store(initialState:reducer:environment:)

ContentView_Previews でプレビューできるようにStoreを作っています。 environment: で指定している AppEnvironment(mainQueue:numberFact)numberFact:Effect<String> を返すクロージャを指定し、ようやくここでアラートにどういった文字を表示するのかが決まります。

App

最後にAppでの ContentView 生成箇所を修正して完了です。

import SwiftUI
import ComposableArchitecture

@main
struct TCABasicUsageApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView(
        store: Store(
          initialState: AppState(),
          reducer: appReducer,
          environment: AppEnvironment(
            mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
            numberFact: { number in
              Effect(value: "\(number) is a good number Brent")
            }
          )
        )
      )
    }
  }
}

やっていることはViewの ContentView_Previews と同じです。

これで実行すれば画面が表示できると思います。お疲れ様でした!🎉

感想

TCA触ってみた感想です。

  • 単方向制御がTCAの中で完結している
    • プレゼンテーション層からの入力も、副作用を伴うデータ層からの入力もActionに変換されるので単方向制御が実現しやすい。
      • ReducerからもEffectを介してActionを発行できるので、ドメイン層内のアクションにも対応できる。
    • 非同期の処理をEffectで表現して、CombineとState/Reducer/Actionの世界をうまく繋げている。
  • SwiftUIとの親和性が高い
    • Viewでの値監視についてプロパティラッパーを意識しないで済むので$アクセスが不要になる。単なるViewStoreのプロパティとしてアクセスすればよくなるのでSwiftUIがより簡単になる。

煩雑になりがちな処理・データの流れが明快で、すごく洗練されたアーキテクチャになっているなと思いました。iOS13以上の制約がクリアできるならぜひ使いたいです。

あと、the Composable ArchitechtureのComposableたる所以である combine / pullBack についてはBasic Usageでは使われておらず、この記事では書いていません。時間があればまたどこかで書きたいなと思っています。

最後までご覧頂きありがとうございました🙇

参考リンク

15
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
zeero

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
15
Help us understand the problem. What is going on with this article?