フルSwiftUIにおける無難なアーキテクチャを求めて
はじめに
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