はじめに
TCAとはTheComposableArchitectureの略です。
一言でいうと状態管理とビジネスロジックをモジュール化して簡潔にするアーキテクチャ用のライブラリです。話には聞いていたのですが触れたことがないため試しにチュートリアルを行ってみました。
結論
チュートリアルの完成度が高く、TCA入門にはこれが最適だと感じました。
公式ドキュメント
そもそもTCAとは
「SwiftUI版のRedux」 です。
Reduxは、Flux のアーキテクチャを、副作用のない純粋関数によって実現するアーキテクチャー であり、ライブラリです。
下記記事を引用させていただきました。とても参考になった記事です。
チュートリアル
早速チュートリアルを始めてみます。まずはライブラリを導入します。
Reducerを作成する
Composable Architecture で機能が構築される基本単位は、Reducer()マクロとReducerプロトコルです。
最も重要なのは、機能のコアロジックと動作は、SwiftUI ビューに言及することなく完全に分離して構築できるため、分離して開発しやすくなり、再利用しやすくなり、テストしやすくなることです。
import SwiftUI
import ComposableArchitecture
@Reducer
struct CounterFeature {
@ObservableState
struct State {
var count = 0
}
enum Action {
// ユーザーが実際に行う操作に基づいて命名
case decrementButtonTapped
case incrementButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
}
}
}
}
struct ContentView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
HStack {
Button("-") {
store.send(.decrementButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
Button("+") {
store.send(.incrementButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
}
}
}
}
#Preview {
ContentView(
store: Store(initialState: CounterFeature.State()) {
CounterFeature()
}
)
}
side effectsを追加する
side effectsは、機能開発において最も重要な側面です。API リクエストの作成、ファイル システムとのやり取り、非同期の実行など、外部との通信を可能にするものです。
@Reducer
struct CounterFeature {
@ObservableState
struct State {
var count = 0
var fact: String?
var isLoading = false
}
enum Action {
// ユーザーが実際に行う操作に基づいて命名
case decrementButtonTapped
case incrementButtonTapped
case factButtonTapped
// APIレスポンスもActionとする
case factResponse(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
state.fact = nil
return .none
case .factButtonTapped:
state.fact = nil
state.isLoading = true
// runで非同期処理を実行
// NumbersAPI : 数字にまつわるトリビアを取得する
// ※http通信なのでinfo.plistでATSS許可する
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared
.data(from: URL(string: "http://numbersapi.com/\(count)")!)
let fact = String(decoding: data, as: UTF8.self)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.fact = fact
state.isLoading = false
return .none
case .incrementButtonTapped:
state.count += 1
state.fact = nil
return .none
}
}
}
}
struct ContentView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
HStack {
Button("-") {
store.send(.decrementButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
Button("+") {
store.send(.incrementButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
}
Button("Fetch") {
store.send(.factButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
if store.isLoading {
ProgressView()
} else if let fact = store.fact {
// 数字トリビア表示
Text(fact)
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
}
}
}
}
#Preview {
ContentView(
store: Store(initialState: CounterFeature.State()) {
CounterFeature()
}
)
}
タイマーの追加
1秒毎にカウントが1増える機能を追加します。
@Reducer
struct CounterFeature {
@ObservableState
struct State {
var count = 0
var fact: String?
var isLoading = false
var isTimerRunning = false
}
enum Action {
// ユーザーが実際に行う操作に基づいて命名
case decrementButtonTapped
case incrementButtonTapped
case factButtonTapped
// APIレスポンスもActionとする
case factResponse(String)
// タイマー
case timerTick
case toggleTimerButtonTapped
}
// エフェクトのキャンセルに使用する
enum CancelID { case timer }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
state.fact = nil
return .none
case .incrementButtonTapped:
state.count += 1
state.fact = nil
return .none
case .factButtonTapped:
state.fact = nil
state.isLoading = true
// runで非同期処理を実行
// NumbersAPI : 数字にまつわるトリビアを取得する
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared
.data(from: URL(string: "http://numbersapi.com/\(count)")!)
let fact = String(decoding: data, as: UTF8.self)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.fact = fact
state.isLoading = false
return .none
case .timerTick:
state.count += 1
state.fact = nil
return .none
case .toggleTimerButtonTapped:
state.isTimerRunning.toggle()
if state.isTimerRunning {
return .run { send in
while true {
try await Task.sleep(for: .seconds(1))
// タイマータスクの処理を呼ぶ
await send(.timerTick)
}
}
.cancellable(id: CancelID.timer)
} else {
return .cancel(id: CancelID.timer)
}
}
}
}
}
struct ContentView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
HStack {
Button("-") {
store.send(.decrementButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
Button("+") {
store.send(.incrementButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
}
Button(store.isTimerRunning ? "Stop timer" : "Start timer") {
store.send(.toggleTimerButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
Button("fact") {
store.send(.factButtonTapped)
}
.font(.largeTitle)
.padding()
.background(Color.black.opacity(0.1))
.cornerRadius(10)
if store.isLoading {
ProgressView()
} else if let fact = store.fact {
// 数字トリビア表示
Text(fact)
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
}
}
}
}
#Preview {
ContentView(
store: Store(initialState: CounterFeature.State()) {
CounterFeature()
}
)
}
最後に
私の働いている会社で経験の有無を問わず採用を行っています。
興味のある方は是非カジュアル面談から応募してみてください!