概要
The Composable Architectureという良さげなアーキテクチャがあると聞いて、iOSの簡単なアプリをSwiftUIで書いてみました。
作ったもの
https://github.com/tonionagauzzi/SwiftUITCASample
よくチュートリアルで作る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に表示する内容を定義します。
// ToDo1個分のState。
struct ToDoState: Equatable, Identifiable {
let id: UUID
var description = ""
var isCompleted = false
}
// アプリ全体のState。ToDoの配列。
struct AppState: Equatable {
var todoStates: [ToDoState] = []
}
たとえば1つの画面でこれらを更新すると、他の画面にも即反映されます。
Action
タップやデータ受信などのイベントを定義します。
// 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です。今回はあまり活用しませんでした。
struct ToDoEnvironment {
}
struct AppEnvironment {
var uuid: () -> UUID = UUID.init
}
DIとはなんぞやについては別記事を参照。Androidの記事ですが。
Reducer
Actionを受けてStateを更新する役割です。
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を構築する役割です。
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
一言で言えば、画面をまたいで状態を値型で共有でき、小機能単位でStoreを分割でき、Actionの副作用をReducer内部で扱うことができ、ビジネスロジックのテストが容易で、これらすべてを短く人間工学的なコードで書ける、といったところでしょう。
テストのしやすさ
ToDoリストに削除機能を追加したコミットです。
テストに注目します。
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向けにReactiveSwiftとRxSwiftのForkも一応用意されています。
所感
クリーンアーキテクチャーの記事を書いたのがちょうど2年前です。ってか、The Clean ArchitectureもTCAですね笑
今回のTCAを使ってみて、機能追加のしやすさやモジュール分割の自由度など拡張性の高さを感じました。この勢いで、もう少し複雑なアプリも作ってみたいですね。
一方、現状はApple製の新しめOS向けに特化したアーキテクチャなので、X-Platform前提の時点で難しかったり、古いOSのサポートを切れない実案件では選定しにくいという課題はあります。
実際、大規模案件では今でもMVVMやクリーンなほうのTCAを扱うことが多いです。
が、新しいアーキテクチャがあちこちで使われ出してるなと感じたら、基礎だけでも素早く学ぶよう個人的に心掛けています。
なぜかというと、人の書いたコードを読む抵抗が減るからです。
昨今、1から何かを発明する開発スタイルは淘汰され、既にあるものを繋ぎ合わせてニーズを実現するのが、いろんな分野で主流になって来てると感じます(※諸説あり)。
人の書いたコードやAPI仕様書を見たとき、いちいちアレルギーを発症していたらやっていけないので、「これはこういう思想でこうなんだな」とすぐに見抜くために、自分の中にパターン化された前提知識をいくつも持っていたいと思います。
と、良くない方向にポエム化して来たので、オチをつけましょう。最小機能単位で切り出しと疎結合ができるコンポーザブルアーキテクチャは、まさにこの切り貼りの時代にうってつけな感じですね!
参考記事
※もっとよく知りたい方は、これらの素晴らしい記事orセッションをおすすめします!