9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【SwiftUI】TCAからMVVMへのマイグレーションを考える

Last updated at Posted at 2024-12-14

こんにちは!だーはまです。

この記事では 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のリポジトリ一覧表示のサンプルアプリを作成していきます。

ItemList.swift
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)をもとに状態を更新する
ItemList.swift
    enum Action: Sendable {
        case onAppear
        case itemCellTapped(Int)
        case loaded(SearchRepositoriesResponse)
    }

  • State
    • struct を用いる
      • Equatable に準拠しておく
    • 名前(State)の通り、Viewの状態を記述していく
      • 今回は LoadingState として ローディングの状態(idle, loading, loaded, error) と ローディング結果(SearchRepositoriesResponse, (errorの)String) を合わせて定義している
ItemList.swift
    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を書き換えることで、非同期処理 と (メインスレッドで同期的に行うべきである)状態の更新 を分けられる(コード-①,②)
ItemList.swift
    @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との違い
TCAItemListView.swift
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のコードです

ItemListViewModel.swift
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->MVVM(State).swift
    // 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->MVVM(Action,Reducer).swift
    // 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ライクな実装になります

ItemListViewModelType.swift
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

ItemListView.swift
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->MVVM(View).swift
    // TCA
    @Bindable var store: StoreOf<ItemList>
    
    👇
    
    // MVVM
    @State var vm: ItemListViewModel = .init()   // TCAと異なる
TCA->MVVM(View).swift
    // 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 を採用していれば大丈夫そうです
  • スレッド管理を意識した実装になっているか
9
1
0

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
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?