Zenn
🐈‍⬛

フルSwiftUIにおける無難なアーキテクチャを求めて

2025/03/21に公開

はじめに

iOSアプリのUIをSwiftUIのみで構成するときに、無難なアーキテクチャが見当たらないので、特にベストではないが自分はこのようにしたというのをお伝えできればと思います

この記事読むモチベーション

  • SwiftUIにロジックを置くのに抵抗がある
  • 適度に責務を分散したい
  • RxSwift + MVVM の構成の代わりを探している
  • SwiftUIでTCAのようなサードパーティ製ライブラリにロックインされるのに抵抗があるという方

今回の要件

  • iOS16+
  • 新規開発

無難なアーキテクチャとはなにか

MVVMはどうか

MVVMを採用するモチベーションとして単一的なデータフロー(Unidirectional Data Flow, UDF)を実現したいというモチベーションがあるかと思います。

View → ViewModel → Model/Repository → ViewModel → View(更新)

このような流れで基本的に単一的なデータフローは守られると思います。しかし、SwiftUIのBindingを利用することでこの方針は簡単に壊れてしまします。

// ViewModel
class LoginViewModel: ObservableObject {
    @Published var email: String = ""
}

// View
TextField("Email", text: $viewModel.email) // ← Viewが直接更新している

このようにしてしまうとViewが直接ViewModelの値を更新してしまします。そのため、ViewModelにInput / Outputのプロトコル(IF)を持たせてもViewがそのルールを破ることでユニットテストでViewModelのテストがパスしたとしてもViewがIFを守らなければルールとしては破綻してしまいますし、意図しない挙動を産むかも知れません。しかしSwiftが双方向Bindingを提供する以上それを頑張って防ぐのではなく許容できる仕組みが必要なのかなと思っています。

少し話が飛ぶかも知れないのですがSwiftUI自体、ロジックを持つこともできますし、SwiftData@AppStorageなどの登場により、本来なら(旧来なのかな)モデル層が取り扱うようなレイヤーまでSwiftUIで操作できるようになります。なんでもSwiftUIに書いてしまえるのですが、ある程度お作法がないとSwiftUIがどんどん肥大化してしまいそうです😭

以上のようなことを踏まえ、SwiftUIの良さを活かしつつ、ある程度お作法に統一感を持たせるにはMVVMより明確な責務やアプローチが必要なのかなと思い、フルSwiftUIにおける無難なアーキテクチャを求める旅が始まりました。

ただ、今回の案件ではViewModelが適切でないと感じただけで、ビジネス要求や開発体制においてViewModelのほうが適切なケースもあると思います。

Storeに単一情報源を集約する

まずはじめに採用したのはFluxのようなアーキテクチャで、単一方向のデータフローを強制する狙いがあります。ViewActionとViewStateがお互いを知らず、ViewActionから取得したデータをStoreに保持し、StoreはViewStateに依存され、ViewStateは変更を監視し自身の@publishedオブジェクトに更新をかけます。ViewStateはViewに依存され、ViewStateの@Publishedな値の更新を検知してViewを更新します。

処理の流れは下記のイメージです

View → ViewAction → Model/Repository → ViewAction → Store → ViewState → View(更新)

StoreがStateに値を渡す部分はこのような感じです

@MainActor
public class SomeStore: ObservableObject {
    /// private(set)で外部から更新できないようにする
    @Published public private(set) var someData: FooObject

    func updateFooData(someData: FooObject) {
        self.someData = FooObject
    }
}

@MainActor
final class SomeViewState: ObservableObject {
    @Published private(set) var someData: FooObject

    public init() {
        /// SomeStoreの変更を監視してsomeDataにセット
        SomeStore.shared.someData
            .assign(to: &$someData)
    }
}

このアプローチだと、Viewを更新するための値の変更はStoreからViewStateに伝えられるので単一方向のデータフローを守ることができます。Storeを保持することで 信頼できる唯一の情報源 (Single source of truth) をもたらすこともできました。SwiftUI(View)側としても差分が発生したら更新する仕事に徹することができるので一定満足感はありました。

要求が増えると徐々に複雑化する

しばらくはStore+ViewStateで運用していたのですが、よくある話で徐々に複雑化して行き、なんだか可読性が悪くなってきました(笑)

ViewActionが肥大化

処理の流れを再掲します

View → ViewAction → Model/Repository → **ViewAction** → Store → ViewState → View(更新)

お気づきかもしれませんが ViewActionがRepositoryからデータを取得した結果をStoreに更新する処理も持っているので、ViewActionが二回でてきます(笑)レスポンスが増えるたびにViewActionが肥大化します。そもそもActionは処理のハンドリングをしたいだけなのでそもそも命名に実際の処理が伴っていませんでした。

TrackingやLoggingなどをどこで取り扱うか

以下は、なんの変哲もないAPI処理ですが、ユニットテストの観点で考えたときにこの関数がテストすべき項目が複雑に感じるようになってきました。本来ならUseCaseのモックを差し込んでレスポンスの成功時と失敗時の挙動をチェックできれば良さそうなのですがトラッキングやロギングが挟まることにより、本来の関心から外れてしまいます。

func callSomeAPI() -> SomeResponse {
  do {
        let response = try await someUseCase.fetchFoo()
        /// TrackingRequestのテストを書くかどうか
        TrackingRequest.send(.trackSomeEvent, params: response)
        ... 省略
        
        return response
  } catch {
        /// LogingServiceのテストを書くかどうか
        LoggingService.info(error.localizedDescription, level: .error)
  }
}

これが増えてくるとcallSomeAPIという命名も破綻してきそうです。実際のコードはもう少し複雑で責務オーバー気味だったのでアーキテクチャを再考することになります

コア機能に対しReduxを使う

アプリのコア機能はロジックが集中して可読性が低くなる傾向があると思います。例として挙げると会員(プレミアム会員)限定機能や、割引、特典などのコア機能(ドメイン)を含むとそれらを出し分けたり割引価格の計算ロジックが複雑だったり。。。

そこで、複雑性の緩和のため、アプリのコア機能に焦点を絞って設計を見直すことにしました。
鍵となったのは責務の分散でした。もともとFluxのような設計で進めていたのでAPICall時の副作用(Logの管理やTracking送信など)をどこで取り扱うかに焦点を絞り、ReduxのアプローチにMiddlewareというものがあったなということもあり、Reduxを採用してみることにした図が以下です。

単一的なデータフローで処理が流れていくのは前述のViewState+Storeのパターンと変わりません

ViewState+Storeのパターン
View → ViewAction → Model/Repository → **ViewAction** → Store → ViewState → View(更新)

ViewState+Store with Redux
View → ViewState → ActionDispathcer → Store.dispatch() → MiddleWare → Reducer → Store → ViewState → View(更新)

しかし登場人物が増えているので説明を加えていきます

ActionDispatcher

ざっくりいうとこのようなprotocolで構成されます
ReduxAction:Reduxの「アクション」に相当する
Dispatchable:アクションを「dispatch(実行)」できるもの
ActionDispatcher:Reduxの store.dispatch(action) にあたる、dispatch処理を実装するプロトコル

@MainActor
protocol Dispatchable {
    associatedtype ActionType: ReduxAction
    func dispatch(_ action: ActionType) async
}

/// **ReduxのAction**
protocol ReduxAction: Sendable {}

/// **Reduxの `dispatch` を実装するプロトコル**
protocol ActionDispatcher: Sendable {
    associatedtype ActionType: ReduxAction
    func dispatch(_ action: ActionType) async
}

これらは後述するStore.dispatch()を利用するために定義されたもので、以下のように利用します

@MainActor
final class SomeActionDispatcher: ActionDispatcher {
    func dispatch(_ action: FooAction) async {
        await SomeStore.shared.dispatch(action)
    }
}

続いてViewState

@MainActor
final class SomeViewState<D: ActionDispatcher>: ObservableObject where D.ActionType == FooAction {
    private let actionDispatcher: D

    private var cancellables = Set<AnyCancellable>()

    /// **各ページごとに `selectedLabels` を個別管理**
    public init(actionDispatcher: D) {
        self.actionDispatcher = actionDispatcher
    }

    public func dispatch(_ action: FooAction) {
        Task {
            await actionDispatcher.dispatch(action)
        }
    }
}

これでSwiftUIから、someViewState.dispatch(.fetchFooList)のようにActionをDispatchすることができます。

Middleware + Reducer

Store.dispatchの処理で、MiddlewareとReducerという処理が挟まるのですが順番に説明していきます

まず、MiddlewareはReduxの設計では副作用を取り扱う仕組みです。主にReducerに届く前に割り込んで、処理を追加できます。

ReducerはStoreの更新に専念してもらいたいのでReducerで処理する内容は例えば以下です

  • API Call
  • Tracking
  • Analytics
          ┌────────────┐
          │   View     │
          └────┬───────┘
               │ dispatch(Action)
               ▼
         ┌─────────────┐
         │ Middleware① │  ← 割り込みポイント
         └────┬────────┘
               ▼
         ┌─────────────┐
         │ Middleware② │
         └────┬────────┘
               ▼
         ┌─────────────┐
         │  Reducer     │ ← 状態更新ロジック
         └────┬────────┘
               ▼
          ┌────────────┐
          │   Store     │ ← @Published状態管理
          └────────────┘

MiddleWare①で、APICallを行い、その後Middleware②でトラッキングを挟んで、最終的にReducerでStoreへデータの格納を行ったり、ローディングの状態を変更したりします

MiddleWareを挟むことでTrackingやLoggingなどをどこで取り扱うかを明確にすることができました

Storeの実装としてはこのような感じでMiddlewareを再帰的にキャッチして処理できるようにしています

    func dispatch(_ action: CatAction) async {
        await recursiveDispatch(action)
    }

    private func recursiveDispatch(_ newAction: CatAction) async {
        let currentState = createCatState()
        let newState = catReducer(action: newAction, state: currentState)
        apply(state: newState)

        await middleware.handleAction(
            action: newAction,
            state: newState,
            dispatch: { await self.recursiveDispatch($0) }
        )
    }

middlewareでは、トラッキングやロギングがcallされたかどうかだけ分かるようにしてテストがしやすくなりました。

@MainActor
public class SomeMiddlewareImpl: SomeMiddleware {
    private let useCase: SomeUIUseCase

    public init(useCase: SomeUIUseCase) {
        self.useCase = useCase
    }

    public func handleAction(
        action: SomeAction,
        state: FooStoreState,
        dispatch: @escaping @MainActor @Sendable (SomeAction) async -> Void
    ) async {
        switch action {
        case let .fetchSomeList():
            do {
                await dispatch(.setLoadingState(state: .loading))
                let response = try await useCase.getSomeList(params: query)

                await dispatch(
                    .setSomeListResult(
                        query: query,
                        someResult: response
                    )
                )
                await dispatch(.setLoadingState(state: .success))
            } catch {
                await dispatch(.setLoadingState(state: .error))
                await dispatch(.logging(msg: error.localizedDiscription)
            }
        ...default:
            break
        }
    }
}

Reducerは純粋関数にするため、以下の2つの条件を満たしています。

  • 同じ引数に対して常に同じ結果を返す
  • 副作用を持たない
func catReducer(action: CatAction, state: CatStoreState) -> CatStoreState {
    var newState = state
    switch action {
    case .fetchCat:
        break // Middlewareに任せる
    case .setCat(let cat):
        newState.cat = cat
    case .setLoadingState(let loadingState):
        newState.loadingState = loadingState
    }
    return newState
}


このように単一方向のデータフローをを採用しつつ、APICallなどの副作用をMiddlewareに分散させることで、ロジックの肥大化を防ぎつつ、責務を分離しています。

ただReduxを採用するハードルが高かったり、そもそもそこまでビジネス要求が高くない画面に対して積極的にReduxを採用する必要はないと思っていて、画面や機能ごとにReduxを採用せずに運用できるよう考えています。

再掲になりますがこちらのパターンはどちらも単一方向のフローで、Storeの値をViewStateが購読するものになっているので、ActionDispatcherからStore.dispatchまでの処理をまるっと置き換え直すことが可能です

ViewState+Storeのパターン
View → ViewAction → Model/Repository → **ViewAction** → Store → ViewState → View(更新)

((( で囲った部分を別のものに置き換えることが可能

ViewState+Store with Redux
View → ViewState → ((( ActionDispathcer → Store.dispatch() → MiddleWare → Reducer))) → Store → ViewState → View(更新)

まだ運用中なので、これからもSwiftUIのアップデートとともに設計も変わっていくかと思います

今回のサンプルはgithubにあげておきますので興味があるかた触ってみてください。

まとめ

  • SwiftUIの双方向Bindingを頑張って防ぐのではなく、StoreやReduxなどを採用することでどこに書くのかをゆるく制約することでカバー(100%双方向バインディングを拒絶する仕組みは考えず、ああ、ここにこうやって書けばいいのねっていう場所を用意する)
  • StoreをCombineで購読させるのは良さそう(信頼できる唯一の情報源という意味で)
  • Reduxを採用したがどのようにStoreを更新するかはケースバイケース。Reduxを差し替えたくなったらViewStateとStoreを残してあとは置き換えられるようにしておく
  • タイトルの回収となりますが、一旦この辺が落とし所で無難なのかなと

一旦以上です。またアップデートあれば追記します。

Discussion

ログインするとコメントできます