こんにちは!だーはまです。
この記事では GitHubリポジトリ一覧を表示するサンプルアプリを題材に、TCA→MVVM のマイグレーションにかかるコスト を考えていきます。
今回の対象は 状態管理のみ です。DIやテスト,画面遷移についての比較は行なっていません mm (需要ありそうな今後比較します)
サンプルアプリ (コード) |
---|
はじめに
TCAの良さを実感し、TCAを採用したい!と思っているiOSエンジニアもいらっしゃると思います。
しかし、TCAを採用を検討する際 "TCAがOSSであるため数年後も使い続けられるか不安" という点がデメリットとしてあがるはずです。
そこで今回は TCAが使えなくなった時 のことを想定し TCA->MVVM のマイグレーションにかかるコストを検討します。この記事を、TCA採用のコストを検討する際の判断材料にしてもらえたらと思います。
目的
- TCAの採用コストを計測すること
- TCA->MVVMのマイグレーションコストが低ければ TCAの採用コストは下がり、TCAを採用しやすくなる
- TCA, MVVM のメリデメを再検討すること
TCA と MVVM について
TCA
TCAは、Point-Freeが提供するReduxのようなiOSアプリ開発フレームワークです。State(状態)
, Action
, Reducer
, Environment(依存関係)
, Effect(ドメインロジック)
で構成され、Reducerで状態遷移を管理します。Effectにより非同期処理や副作用を制御し、依存関係の注入によりテストも容易です。小さなReducerをスコープ化して再利用可能なComposableな設計が特徴で、大規模アプリでもスケーラブルかつ保守性の高いコードを実現しています。
MVVM
MVVM(Model
-View
-ViewModel
)は、UIロジックを整理し、保守性やテスト性を向上させるアーキテクチャパターンです。多くのプロジェクトで採用されている人気なアーキテクチャです。
Modelはデータやビジネスロジックを管理し、ViewModelはそのデータをUIで使いやすい形に変換します。Viewはユーザーインターフェースで、ユーザーの操作をViewModelに伝え、ViewModelの状態を表示します。
Swiftでは、CombineやSwiftUIのデータバインディング(@Publishedや@StateObjectなど)を活用して、ViewModelの状態変更をUIに自動反映できます。この仕組みにより、UIロジックとビジネスロジックの分離が可能になり、コードの再利用性とテストのしやすさが向上します。
(以前僕が書いた記事を貼っときます)
TCAで実装
GitHubのリポジトリ一覧表示のサンプルアプリを作成していきます。
import ComposableArchitecture
import Foundation
@Reducer
struct ItemList {
enum LoadingState: Equatable {
case idle
case loading
case loaded(SearchRepositoriesResponse)
case error(String?) // 今回はエラーハンドリング無し
}
@ObservableState
struct State: Equatable {
var loading: LoadingState = .idle
}
enum Action: Sendable {
case onAppear
case itemCellTapped(Int)
case loaded(SearchRepositoriesResponse)
}
@Dependency(\.mainQueue) var mainQueue
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.loading = .loading
// ① : API通信は副作用として .run を使い実行する
// .run 内は非同期処理で実装しないといけない
return .run { send in
try await self.mainQueue.sleep(for: .seconds(2.0)) // API通信を想定し2sのタイムラグを再現
let repositories: SearchRepositoriesResponse = .init(items: [.stub(), .stub(), .stub(), .stub(), .stub()])
await send(.loaded(repositories))
}
// ② : state の更新は .run 内では行えない設計になっている → API通信 と 状態の更新 を分けて実装できる
case .loaded(let repositories):
state.loading = .loaded(repositories)
return .none
case .itemCellTapped(let index):
switch state.loading {
case .loaded(let repositories):
let repository = repositories.items[index]
print(repository)
return .none
case .idle, .loading, .error:
return .none
}
}
}
}
}
まずは Action, State, Reducer の実装についてです。軽くですが解説していきます。
-
Action
- enum を用いる
- 列挙するのは
ユーザーアクション
+状態変更用のメソッド
- ユーザーアクション : ボタンのタップ, 文字入力, SwiftUIのライフサイクルによるデータ読み込み etc...
- 状態変更用のメソッド : ローディング結果(Success or Failure)をもとに状態を更新する
enum Action: Sendable {
case onAppear
case itemCellTapped(Int)
case loaded(SearchRepositoriesResponse)
}
-
State
- struct を用いる
- Equatable に準拠しておく
- 名前(State)の通り、Viewの状態を記述していく
- 今回は LoadingState として ローディングの状態(idle, loading, loaded, error) と ローディング結果(SearchRepositoriesResponse, (errorの)String) を合わせて定義している
- struct を用いる
enum LoadingState: Equatable {
case idle
case loading
case loaded(SearchRepositoriesResponse)
case error(String?) // 今回はエラーハンドリング無し
}
@ObservableState
struct State: Equatable {
var loading: LoadingState = .idle
}
-
Reducer
- Action毎に処理を記述していく
- 基本的に Action に応じて State を更新していく
- Action-State の関係が明確になるのでコードリィーディングしやすい
- 非同期で実行したい重い処理(API通信やUserDefaultsへのアクセスなど)は、Effectである
.run {}
を用いて実行する- Action で定義した状態変更用のメソッド内でStateを書き換えることで、非同期処理 と (メインスレッドで同期的に行うべきである)状態の更新 を分けられる(コード-①,②)
- Action毎に処理を記述していく
@Dependency(\.mainQueue) var mainQueue
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.loading = .loading
// ① : API通信は副作用として .run を使い実行する
// .run 内は非同期処理で実装しないといけない
return .run { send in
try await self.mainQueue.sleep(for: .seconds(2.0)) // API通信を想定し2sのタイムラグを再現
let repositories: SearchRepositoriesResponse = .init(items: [.stub(), .stub(), .stub(), .stub(), .stub()])
await send(.loaded(repositories))
}
// ② : state の更新は .run 内では行えない設計になっている → API通信 と 状態の更新 を分けて実装できる
case .loaded(let repositories):
state.loading = .loaded(repositories)
return .none
case .itemCellTapped(let index):
switch state.loading {
case .loaded(let repositories):
let repository = repositories.items[index]
print(repository)
return .none
case .idle, .loading, .error:
return .none
}
}
}
}
続いて View
の実装についてです
-
View
-
@Bindable var store: StoreOf<ItemList>
でItemList(Action,State,Reducer)を呼び出す - ReducerにてStateが更新されると、暗黙的にViewで更新を検知します
- @Observableを使わなくてもデータバインディングできる→MVVMとの違い
-
import SwiftUI
import ComposableArchitecture
struct TCAItemListView: View {
@Bindable var store: StoreOf<ItemList>
var body: some View {
switch store.state.loading {
case .idle:
Text("Init TCAItemListView...")
.onAppear {
store.send(.onAppear)
}
case .loading:
VStack(spacing: 24) {
Text("Loading...")
ProgressView()
}
case .loaded(let repositories):
List {
ForEach(0..<repositories.items.count, id: \.self) { i in
ItemCell(reposiotry: repositories.items[i])
.onTapGesture {
store.send(.itemCellTapped(i))
}
}
}
case .error(_):
Text("Error!")
}
}
/// GitHubのリポジトリセル
private func ItemCell(reposiotry: GithubRepository) -> some View {
HStack {
Image("github-icon")
.resizable()
.frame(width: 60.0, height: 60.0, alignment: .center)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 12) {
Text(reposiotry.fullName)
Text(reposiotry.description)
}
}
}
}
#Preview {
TCAItemListView(store: Store(initialState: ItemList.State()) {
ItemList()
})
}
MVVMへのマイグレーション
結論から言うと、Effect(非同期処理)以外は簡単に移行可能でした。
今回のサンプルアプリが小規模であったことが起因している可能性はありますが、Effect以外はほとんど脳死で移行できました。
Reducer -> ViewModel
まずはViewModelのコードです
import Observation
import Foundation
@Observable
final class ItemListViewModel {
// TCAと共通
enum LoadingState: Equatable {
case idle
case loading
case loaded(SearchRepositoriesResponse)
case error(String?) // 今回はエラーハンドリング無し
}
// TCAのState
var loading: LoadingState = .idle
// TCAのAction
func onAppear() {
// ! スレッド管理を自分でする必要がある
Task.detached {
sleep(2)
// TCAのReducer(内の処理)
let repositories: SearchRepositoriesResponse = .init(items: [.stub(), .stub(), .stub(), .stub(), .stub()])
self.loading = .loaded(repositories) // TCAでは Action.loaded(SearchRepositoriesResponse) を
}
}
func itemCellTapped(index: Int) {
switch loading {
case .loaded(let repositories):
let repository = repositories.items[index]
print(repository)
case .idle, .loading, .error:
let _ = "" // 空実装
}
}
}
State
TCAのStateはViewModelのプロパティとして宣言します
@Observable を採用している場合、TCAからの移行コストはほぼゼロです
(@Observable を採用していない場合でも、@Publishedを使うくらいのコストなのでほぼゼロです)
// TCA
@ObservableState
struct State: Equatable {
var loading: LoadingState = .idle
}
👇
// MVVM
var loading: LoadingState = .idle
Action
TCAのAction は ViewModelのメソッドにて代替します
MVVMのメリット
メソッド名や引数などを、特にTCAから変えることなくMVVMへマイグレーションできています。
また、TCAで状態更新用に定義していた Action.loaded
を、MVVMでは定義しなくても良くなります。MVVMでは、"非同期処理と状態更新用のActionを分ける" と言うTCAならではの制約を無視できるからです。
MVVMのデメリット
しかし、MVVMでは(上記のTCAならではの制約がなくなったため)スレッド管理に力を入れなければなりません。
非同期処理を実行する際に Task.detached
を用いて別スレッドを立てる必要があります。Task.detached
を使わない場合でもビルド可能ですが、メインスレッド上で処理が実行されるため sleep(2)
にてメインスレッドの処理が2秒間止まってしまいます。
このように、TCAでは考えなくても良かったスレッド管理 について考える必要があります。(Swift6対応を見据えると、スレッド管理、もっと言うとデータ競合を考えた設計や実装が求められるため、TCAを採用することで"力を貸してもらう"ことも選択としてありなのかもしれないです)
しかし、スレッド管理の設計力や実装力は TCA,MVVM関係なく必要なため、どのような技術選定をしても力をつけておくべきだと考えています。
// TCA
Reduce { state, action in
switch action {
case .onAppear:
state.loading = .loading
// ① : API通信は副作用として .run を使い実行する
// .run 内は非同期処理で実装しないといけない
return .run { send in
try await self.mainQueue.sleep(for: .seconds(2.0)) // API通信を想定し2sのタイムラグを再現
let repositories: SearchRepositoriesResponse = .init(items: [.stub(), .stub(), .stub(), .stub(), .stub()])
await send(.loaded(repositories))
}
// ② : state の更新は .run 内では行えない設計になっている → API通信 と 状態の更新 を分けて実装できる
case .loaded(let repositories):
state.loading = .loaded(repositories)
return .none
case .itemCellTapped(let index):
switch state.loading {
case .loaded(let repositories):
let repository = repositories.items[index]
print(repository)
return .none
case .idle, .loading, .error:
return .none
}
}
}
👇
// MVVM
func onAppear() {
// ! スレッド管理を自分でする必要がある → TCAからの移行コストはこれくらい
Task.detached {
sleep(2)
// TCAのReducer(内の処理)
let repositories: SearchRepositoriesResponse = .init(items: [.stub(), .stub(), .stub(), .stub(), .stub()])
self.loading = .loaded(repositories) // TCAでは Action.loaded(SearchRepositoriesResponse) にて状態更新していたがMVVMでは不要
}
}
func itemCellTapped(index: Int) {
switch loading {
case .loaded(let repositories):
let repository = repositories.items[index]
print(repository)
case .idle, .loading, .error:
let _ = "" // 空実装
}
}
ViewModel の補足
kickstarter の Input,Output を用いたViewModelTypeを採用するとよりTCAライクな実装になります
import Foundation
// MARK: - Input
protocol ItemListViewModelInput {
// TCAでActionを列挙したのと同じようにprotocolで列挙できる→可読性上がる
func onAppear()
func itemCellTapped(index: Int)
}
// MARK: - Output
protocol ItemListViewModelOutput {
// Inputと同じ効果あり
var loading: ItemListViewModel.LoadingState { get }
}
// MARK: - ViewModelType
// ViewModel に準拠させることで、Viewでは vm.input.〜, vm.output.〜 のように アクセスするinput,outputを明示的に指定可能となる
protocol ItemListViewModelType {
var inputs: ItemListViewModelInput { get }
var outputs: ItemListViewModelOutput { get }
}
View
import SwiftUI
struct ItemListView: View {
@State var vm: ItemListViewModel = .init() // TCAと異なる
var body: some View {
switch vm.loading {
case .idle:
Text("Init ItemListView...")
.onAppear {
vm.onAppear()
}
case .loading:
VStack(spacing: 24) {
Text("Loading...")
ProgressView()
}
case .loaded(let repositories):
List {
ForEach(0..<repositories.items.count, id: \.self) { i in
ItemCell(reposiotry: repositories.items[i])
.onTapGesture {
vm.itemCellTapped(index: i)
}
}
}
case .error(_):
Text("Error!")
}
}
/// GitHubのリポジトリセル
private func ItemCell(reposiotry: GithubRepository) -> some View {
HStack {
Image("github-icon")
.resizable()
.frame(width: 60.0, height: 60.0, alignment: .center)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 12) {
Text(reposiotry.fullName)
Text(reposiotry.description)
}
}
}
}
#Preview {
ItemListView()
}
ほぼ変わりません!
強いて挙げるとするなら、データバインディングにCombine(@State)を使うこと と vmのプロパティやメソッドを呼び出す時の文法が若干変わること です。ほとんど移行コストにはならないはずです。
// TCA
@Bindable var store: StoreOf<ItemList>
👇
// MVVM
@State var vm: ItemListViewModel = .init() // TCAと異なる
// TCA
switch store.state.loading { } // プロパティ
store.send(.onAppear) // メソッド
👇
// MVVM
switch vm.loading { }
vm.onAppear()
まとめ
TCA->MVVMのマイグレーションについて、非同期処理のスレッド管理に関しては移行する際のコストがかかりそう。それ以外は特にコストかからない。という結果になりました。(あくまで今回実装したような小規模なサンプルアプリを対象とした結果です)
余談ですが、MVVM->TCA に関して、以下のようなポイントを意識していない場合は少しコスト高くなるはずです。
- チームにTCAの知見を持つメンバーがいるか
- MVVMにて、View と Model を明確に分けられているか
- Model(もっと言うとドメインレイヤー)を分けられていない場合は、TCAのEffectを再実装することになりコスト高くなりそうです
- State, Action を明確に分けられているか
- 本記事で紹介した ViewModelType を採用していれば大丈夫そうです
- スレッド管理を意識した実装になっているか