axiom-swiftui-architecture について
ロジックの配置やテスト可能なアプリの構成を検討する開発者向けの SwiftUI アーキテクチャリファレンスです。Apple 標準パターン、MVVM、TCA、Coordinator を比較し、リファクタリングも解説します。
主な機能
- 01アーキテクチャ選択の判断ツリー
- 02Apple 標準の @Observable パターン
- 03MVVM と TCA の比較指針
- 04Coordinator によるナビゲーションとディープリンク
- 05ビュー内ロジックのリファクタリング手順
活用シーン
- 01新しい SwiftUI アプリのパターン選択
- 02ビューからビジネスロジックを抽出
- 03ナビゲーションロジックのテスト可能化
- 04チーム開発向けのプレゼンテーションロジック整理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftui-architectureインストール後、Claude Code で/axiom-swiftui-architectureを入力して読み込まれたか確認します。
詳細
SwiftUI アーキテクチャ
このスキルを使用する場面
次のような場合にこのスキルを使用してください:
- SwiftUI のビュー ファイルにあるロジックを切り出したい
- MVVM、TCA、標準の SwiftUI パターン、Coordinator のどれを選ぶか検討している
- 関心事を分離するためにビューをリファクタリングしている
- SwiftUI コードをテスト可能にしたい
- 「このコードはどこに置けばよいか」と悩んでいる
- どのプロパティラッパーを使うか判断している(@State、@Environment、@Bindable)
- チームで開発するために SwiftUI のコードベースを整理している
プロンプトの例
| 質問の例 | このスキルが役立つ理由 |
|---|---|
| 「モデルビューのファイルにロジック関連のコードがかなりあります。どう切り出せばよいですか?」 | ロジックの配置先を判断する決定木を含むリファクタリングのワークフローを提示します |
| 「MVVM、TCA、Apple の標準パターンのどれを使うべきですか?」 | アプリの複雑さ、チームの規模、テスト容易性の要件に基づいた判断基準を示します |
| 「SwiftUI コードをテスト可能にするにはどうすればよいですか?」 | SwiftUI のインポートなしでテストできる分離パターンを紹介します |
| 「フォーマッターや計算処理はどこに置けばよいですか?」 | アンチパターンのセクションで、ビューの body 内にロジックを置くことを防ぎます |
| 「どのプロパティラッパーを使えばよいですか?」 | @State、@Environment、@Bindable、または通常のプロパティを選ぶための決定木を示します |
アーキテクチャのクイック決定木
What's driving your architecture choice?
│
├─ Starting fresh, small/medium app, want Apple's patterns?
│ └─ Use Apple's Native Patterns (Part 1)
│ - @Observable models for business logic
│ - State-as-Bridge for async boundaries
│ - Property wrapper decision tree
│
├─ Familiar with MVVM from UIKit?
│ └─ Use MVVM Pattern (Part 2)
│ - ViewModels as presentation adapters
│ - Clear View/ViewModel/Model separation
│ - Works well with @Observable
│
├─ Complex app, need rigorous testability, team consistency?
│ └─ Consider TCA (Part 3)
│ - State/Action/Reducer/Store architecture
│ - Excellent testing story
│ - Learning curve + boilerplate trade-off
│
└─ Complex navigation, deep linking, multiple entry points?
└─ Add Coordinator Pattern (Part 4)
- Can combine with any of the above
- Extracts navigation logic from views
- NavigationPath + Coordinator objects
パート 1:Apple のネイティブパターン(iOS 26+)
基本原則
「データモデルは、データと、そのデータを操作するビューを分離します。この分離によってモジュール性が高まり、テスト容易性が向上し、アプリの仕組みを理解しやすくなります。」 — Apple Developer Documentation
Apple の最新の SwiftUI パターン(WWDC 2023-2025)の中心となるのは次のとおりです:
- データモデルに @Observable を使用する(ObservableObject に代わるもの)
- 非同期処理の境界に State-as-Bridge を使用する(WWDC 2025)
- 3 つのプロパティラッパー:@State、@Environment、@Bindable
- アニメーションのために UI 更新を同期的に行う
State-as-Bridge パターン
問題
非同期関数が中断ポイントを作り、アニメーションを妨げることがあります:
// ❌ Problematic: Animation might miss frame deadline
struct ColorExtractorView: View {
@State private var isLoading = false
var body: some View {
Button("Extract Colors") {
Task {
isLoading = true // Synchronous ✅
await extractColors() // ⚠️ Suspension point!
isLoading = false // ❌ Might happen too late
}
}
.scaleEffect(isLoading ? 1.5 : 1.0) // ⚠️ Animation timing uncertain
}
}
解決策:State をブリッジとして使用する
「時間に敏感な変更が必要な UI コードと、長時間実行される非同期ロジックとの境界を見つけます。」
// ✅ Correct: State bridges UI and async code
@Observable
class ColorExtractor {
var isLoading = false
var colors: [Color] = []
func extract(from image: UIImage) async {
// This method is async and can live in the model
let extracted = await heavyComputation(image)
// Synchronous mutation for UI update
self.colors = extracted
}
}
struct ColorExtractorView: View {
let extractor: ColorExtractor
var body: some View {
Button("Extract Colors") {
// Synchronous state change for animation
withAnimation {
extractor.isLoading = true
}
// Launch async work
Task {
await extractor.extract(from: currentImage)
// Synchronous state change for animation
withAnimation {
extractor.isLoading = false
}
}
}
.scaleEffect(extractor.isLoading ? 1.5 : 1.0)
}
}
メリット:
- UI ロジックが同期的に保たれる(アニメーションが正しく動作します)
- 非同期コードがモデル内に置かれる(SwiftUI なしでテストできます)
- 時間に敏感な UI と長時間実行される処理との境界が明確になります
プロパティラッパーの決定木
答えるべき質問は 3 つだけです:
Which property wrapper should I use?
│
├─ Does this model need to be STATE OF THE VIEW ITSELF?
│ └─ YES → Use @State
│ Examples: Form inputs, local toggles, sheet presentations
│ Lifetime: Managed by the view's lifetime
│
├─ Does this model need to be part of the GLOBAL ENVIRONMENT?
│ └─ YES → Use @Environment
│ Examples: User account, app settings, dependency injection
│ Lifetime: Lives at app/scene level
│
├─ Does this model JUST NEED BINDINGS?
│ └─ YES → Use @Bindable
│ Examples: Editing a model passed from parent
│ Lightweight: Only enables $ syntax for bindings
│
└─ NONE OF THE ABOVE?
└─ Use as plain property
Examples: Immutable data, parent-owned models
No wrapper needed: @Observable handles observation
例
// ✅ @State — View owns the model
struct DonutEditor: View {
@State private var donutToAdd = Donut() // View's own state
var body: some View {
TextField("Name", text: $donutToAdd.name)
}
}
// ✅ @Environment — App-wide model
struct MenuView: View {
@Environment(Account.self) private var account // Global
var body: some View {
Text("Welcome, \(account.userName)")
}
}
// ✅ @Bindable — Need bindings to parent-owned model
struct DonutRow: View {
@Bindable var donut: Donut // Parent owns it
var body: some View {
TextField("Name", text: $donut.name) // Need binding
}
}
// ✅ Plain property — Just reading
struct DonutRow: View {
let donut: Donut // Parent owns, no binding needed
var body: some View {
Text(donut.name) // Just reading
}
}
@Observable モデルパターン
UI の更新をトリガーする必要があるビジネスロジックには @Observable を使用します:
// ✅ Domain model with business logic
@Observable
class FoodTruckModel {
var orders: [Order] = []
var donuts = Donut.all
var orderCount: Int {
orders.count // Computed properties work automatically
}
func addDonut() {
donuts.append(Donut())
}
}
// ✅ View automatically tracks accessed properties
struct DonutMenu: View {
let model: FoodTruckModel // No wrapper needed!
var body: some View {
List {
Section("Donuts") {
ForEach(model.donuts) { donut in
Text(donut.name) // Tracks model.donuts
}
Button("Add") {
model.addDonut()
}
}
Section("Orders") {
Text("Count: \(model.orderCount)") // Tracks model.orders
}
}
}
}
仕組み(WWDC 2023/10149):
- SwiftUI は
bodyの実行中にアクセスされたプロパティを追跡します - 変更されたときにビューの更新をトリガーするのは、それらのプロパティだけです
- 依存関係を細かく追跡することで、パフォーマンスが向上します
ViewModel アダプターパターン
フィルタリング、並べ替え、またはビュー固有のロジックが必要な場合は、ViewModel をプレゼンテーションアダプターとして使用します:
// ✅ ViewModel as presentation adapter
@Observable
class PetStoreViewModel {
let petStore: PetStore // Domain model
var searchText: String = ""
// View-specific computed property
var filteredPets: [Pet] {
guard !searchText.isEmpty else { return petStore.myPets }
return petStore.myPets.filter { $0.name.contains(searchText) }
}
}
struct PetListView: View {
@Bindable var viewModel: PetStoreViewModel
var body: some View {
List {
ForEach(viewModel.filteredPets) { pet in
PetRowView(pet: pet)
}
}
.searchable(text: $viewModel.searchText)
}
}
ViewModel アダプターを使用する場面:
- 表示用のフィルタリング、並べ替え、グループ化
- プレゼンテーション用のフォーマット(ただし、負荷の高い計算は行いません)
- ドメインモデルに属さない、ビュー固有の状態
- ドメインモデルと SwiftUI の慣例との橋渡し
ViewModel を使用しない場面:
- モデルのデータを表示するだけのシンプルなビュー
- ドメインモデルに属するロジック
- 「パターンの純粋さ」だけを理由とした過剰な切り出し
パート 2:MVVM パターン
MVVM を使用する場面
次のような場合、MVVM(Model-View-ViewModel)が適しています:
✅ UIKit で使い慣れている — チームが導入しやすくなります
✅ View と ViewModel を明確に分離したい — 契約が明確になります
✅ プレゼンテーションロジックが複雑 — 複数のフィルタリングや並べ替え処理があります
✅ UIKit から移行している — なじみのある考え方を使えます
❌ 次の場合は MVVM を避けてください:
- ビューがシンプル(データを表示するだけ)
- SwiftUI で新規に始める(Apple のパターンのほうがシンプルです)
- 不要な抽象化レイヤーを作ることになる
SwiftUI における MVVM の構成
// Model — Domain data and business logic
struct Pet: Identifiable {
let id: UUID
var name: String
var kind: Kind
var trick: String
var hasAward: Bool = false
mutating func giveAward() {
hasAward = true
}
}
// ViewModel — Presentation logic
@Observable
class PetListViewModel {
private let petStore: PetStore
var pets: [Pet] { petStore.myPets }
var searchText: String = ""
var selectedSort: SortOption = .name
var filteredSortedPets: [Pet] {
let filtered = pets.filter { pet in
searchText.isEmpty || pet.name.contains(searchText)
}
return filtered.sorted { lhs, rhs in
switch selectedSort {
case .name: lhs.name < rhs.name
case .kind: lhs.kind.rawValue < rhs.kind.rawValue
}
}
}
init(petStore: PetStore) {
self.petStore = petStore
}
func awardPet(_ pet: Pet) {
petStore.awardPet(pet.id)
}
}
// View — UI only
struct PetListView: View {
@Bindable var viewModel: PetListViewModel
var body: some View {
List {
ForEach(viewModel.filteredSortedPets) { pet in
PetRow(pet: pet) {
viewModel.awardPet(pet)
}
}
}
.searchable(text: $viewModel.searchText)
}
}
SwiftUI における MVVM のよくある誤り
❌ 間違い 1:View と ViewModel で @Observable を重複させる
// ❌ @State + @Observable is redundant — @State creates its own storage
struct MyView: View {
@State private var viewModel = MyViewModel() // ❌ Redundant wrapper
}
// ✅ Pass @Observable directly — use @State only if the view OWNS the lifecycle
struct MyView: View {
let viewModel: MyViewModel // ✅ Or @State if view creates it
}
❌ 間違い 2:巨大な ViewModel
// ❌ Don't do this
@Observable
class AppViewModel {
// Settings
var isDarkMode = false
var notificationsEnabled = true
// User
var userName = ""
var userEmail = ""
// Content
var posts: [Post] = []
var comments: [Comment] = []
// ... 50 more properties
}
// ✅ Correct: Separate concerns
@Observable
class SettingsViewModel {
var isDarkMode = false
var notificationsEnabled = true
}
@Observable
class UserProfileViewModel {
var user: User
}
@Observable
class FeedViewModel {
var posts: [Post] = []
}
❌ 間違い 3:ViewModel にビジネスロジックを記述する
// ❌ Business rules belong in the Model, not the ViewModel
@Observable class OrderViewModel {
func calculateDiscount(for order: Order) -> Double { /* ... */ } // ❌ Business logic
}
// ✅ Model owns business logic; ViewModel only formats for display
struct Order {
func calculateDiscount() -> Double { /* business rules */ }
}
@Observable class OrderViewModel {
let order: Order
var displayDiscount: String {
"$\(order.calculateDiscount(), specifier: "%.2f")" // ✅ Just formatting
}
}
パート 3:TCA(Composable Architecture)
TCA を検討するタイミング
TCA は Point-Free によるサードパーティ製のアーキテクチャです。次の場合に検討してください。
✅ 厳密なテスト容易性が重要 — TestStore により、テストを決定論的に実行できます
✅ 大規模なチームで一貫性が必要 — 厳格なパターンにより、ばらつきを抑えられます
✅ 複雑な状態管理 — 副作用、依存関係、コンポジション
✅ Redux に似たパターンを重視する — 単方向のデータフロー
❌ 次の場合は TCA を避けてください:
- 小規模なアプリやプロトタイプ(オーバーヘッドが大きすぎます)
- チームが関数型プログラミングに不慣れ
- 迅速な反復が必要(定型コードにより開発が遅くなります)
- 依存関係を最小限にしたい
TCA の主要な概念
TCA には 4 つの構成要素があります。State(データ)、Action(イベント)、Reducer(状態の変化)、Store(実行時のエンジン)です。単一の機能での使用例を以下に示します。
@Reducer
struct CounterFeature {
// STATE — Data your feature needs
@ObservableState
struct State {
var count = 0
var fact: String?
var isLoading = false
}
// ACTION — All possible events
enum Action {
case incrementButtonTapped
case decrementButtonTapped
case factButtonTapped
case factResponse(String)
}
// REDUCER — How state evolves in response to actions
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
case .factButtonTapped:
state.isLoading = true
return .run { [count = state.count] send in
let fact = try await numberFact(count)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.isLoading = false
state.fact = fact
return .none
}
}
}
}
// STORE — Runtime that receives actions and executes reducer
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
Button("Increment") { store.send(.incrementButtonTapped) }
}
}
}
TCA のトレードオフ
✅ メリット
| メリット | 説明 |
|---|---|
| テスト容易性 | TestStore により、テストを決定論的かつ網羅的に実行できます |
| 一貫性 | すべての機能で同じパターンを使うことで、認知負荷を軽減できます |
| コンポジション | 小さな Reducer を組み合わせて、より大きな機能を構成できます |
| 副作用 | 構造化された効果管理(ネットワーク通信、タイマーなど) |
❌ コスト
| コスト | 説明 |
|---|---|
| 定型コード | 機能ごとに State/Action/Reducer が必要です |
| 学習曲線 | 関数型プログラミングの概念(効果、依存関係)が必要です |
| 依存関係 | Apple がサポートしていないサードパーティ製ライブラリです |
| 反復速度 | 単純な機能でも、より多くのコードを書く必要があります |
Apple のパターンより TCA を選ぶ場合
| シナリオ | 推奨 |
|---|---|
| 小規模なアプリ(画面数 10 未満) | Apple のパターン(よりシンプル) |
| 中規模のアプリ、経験豊富なチーム | テスト容易性を優先するなら TCA |
| 大規模なアプリ、複数のチーム | 一貫性を確保するため TCA |
| 迅速なプロトタイピング | Apple のパターン(より高速) |
| ミッションクリティカル(銀行、医療) | 厳密なテストのため TCA |
パート 4:Coordinator パターン
Coordinator を使う場面
Coordinator はナビゲーションロジックをビューから切り離します。次の場合に使用してください。
✅ 複雑なナビゲーション — 複数の経路、条件付きフロー
✅ ディープリンク — URL による、任意の画面へのナビゲーション
✅ 複数のエントリーポイント — 異なるコンテキストから同じ画面に遷移
✅ テスト可能なナビゲーション — ナビゲーションを UI から分離
SwiftUI での Coordinator の実装
// Minimal coordinator — Route enum + @Observable coordinator + NavigationStack binding
enum Route: Hashable {
case detail(Pet)
case settings
}
@Observable
class AppCoordinator {
var path: [Route] = []
func showDetail(for pet: Pet) { path.append(.detail(pet)) }
func popToRoot() { path.removeAll() }
}
// Root view binds NavigationStack to coordinator's path
struct AppView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
PetListView(coordinator: coordinator)
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let pet): PetDetailView(pet: pet, coordinator: coordinator)
case .settings: SettingsView(coordinator: coordinator)
}
}
}
}
}
.onOpenURL と URL からルートへの解析を使って、ディープリンクを追加します。
// Add to AppCoordinator
func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
// Parse URL path into routes
if components.path.hasPrefix("/pets/"), let id = components.path.split(separator: "/").last {
path = [.detail(loadPet(id: String(id)))]
}
}
// Add to AppView's body
.onOpenURL { url in coordinator.handleDeepLink(url) }
Coordinator は SwiftUI を使わずにテストできます。パスの状態を直接検証します。
func testDeepLink() {
let coordinator = AppCoordinator()
coordinator.handleDeepLink(URL(string: "myapp://pets/123")!)
XCTAssertEqual(coordinator.path.count, 1) // Navigated to detail
}
状態復元、高度な URL ルーティング、タブベースの調整については、構成には axiom-swiftui-nav のパターン 7(Coordinator)、URL ベースのディープリンクにはパターン 1b を参照してください。
Coordinator とアーキテクチャの組み合わせ
Coordinator はどのアーキテクチャとも組み合わせられます。
| パターン | Coordinator の役割 |
|---|---|
| Apple Native | Coordinator がパスを管理し、@Observable モデルがデータを管理 |
| MVVM | Coordinator がパスを管理し、ViewModel がプレゼンテーションを管理 |
| TCA | Coordinator がパスを管理し、Reducer が機能を管理 |
パート 5:リファクタリングのワークフロー
ステップ 1:ビュー内のロジックを特定する
ビューに対して、次のチェックリストを実行します。
View の body に含まれるもの:
- DateFormatter、NumberFormatter の生成
- 計算またはデータ変換
- API 呼び出しまたは非同期処理
- ビジネスルール(割引、検証など)
- データのフィルタリングまたはソート
- 負荷の高い文字列操作
- 複雑なロジックを含む Task { }
これらが 1 つでも含まれている場合、そのロジックは別の場所に移すべきです。
ステップ 2:適切なレイヤーに切り出す
次の判断ツリーを使用します。
Where does this logic belong?
│
├─ Pure domain logic (discounts, validation, business rules)?
│ └─ Extract to Model
│ Example: Order.calculateDiscount()
│
├─ Presentation logic (filtering, sorting, formatting)?
│ └─ Extract to ViewModel or computed property
│ Example: filteredItems, displayPrice
│
├─ External side effects (API, database, file system)?
│ └─ Extract to Service
│ Example: APIClient, DatabaseManager
│
└─ Just expensive computation?
└─ Cache with @State or create once
Example: let formatter = DateFormatter()
例:ビューからロジックをリファクタリングする
// ❌ Before: Logic in view body
struct OrderListView: View {
let orders: [Order]
var body: some View {
let formatter = NumberFormatter() // ❌ Created every render
formatter.numberStyle = .currency
let discounted = orders.filter { order in // ❌ Computed every render
let discount = order.total * 0.1 // ❌ Business logic in view
return discount > 10.0
}
return List(discounted) { order in
Text(formatter.string(from: order.total)!) // ❌ Force unwrap
}
}
}
// ✅ After: Logic extracted
// Model — Business logic
struct Order {
let id: UUID
let total: Decimal
var discount: Decimal {
total * 0.1
}
var qualifiesForDiscount: Bool {
discount > 10.0
}
}
// ViewModel — Presentation logic
@Observable
class OrderListViewModel {
let orders: [Order]
private let formatter: NumberFormatter // ✅ Created once
var discountedOrders: [Order] { // ✅ Computed property
orders.filter { $0.qualifiesForDiscount }
}
init(orders: [Order]) {
self.orders = orders
self.formatter = NumberFormatter()
formatter.numberStyle = .currency
}
func formattedTotal(_ order: Order) -> String {
formatter.string(from: order.total as NSNumber) ?? "$0.00"
}
}
// View — UI only
struct OrderListView: View {
let viewModel: OrderListViewModel
var body: some View {
List(viewModel.discountedOrders) { order in
Text(viewModel.formattedTotal(order))
}
}
}
ステップ 3:テスト容易性を確認する
次の条件を満たしていれば、リファクタリングは成功です。
// ✅ Can test without importing SwiftUI
import XCTest
final class OrderTests: XCTestCase {
func testDiscountCalculation() {
let order = Order(id: UUID(), total: 100)
XCTAssertEqual(order.discount, 10)
}
func testQualifiesForDiscount() {
let order = Order(id: UUID(), total: 100)
XCTAssertTrue(order.qualifiesForDiscount)
}
}
final class OrderViewModelTests: XCTestCase {
func testFilteredOrders() {
let orders = [
Order(id: UUID(), total: 50), // Discount: 5 ❌
Order(id: UUID(), total: 200), // Discount: 20 ✅
]
let viewModel = OrderListViewModel(orders: orders)
XCTAssertEqual(viewModel.discountedOrders.count, 1)
}
}
Step 4: ビューのバインディングを更新する
抽出後、プロパティラッパーを更新します。
// Before refactoring
struct OrderListView: View {
@State private var orders: [Order] = [] // View owned
// ... logic in body
}
// After refactoring
struct OrderListView: View {
@State private var viewModel: OrderListViewModel // View owns ViewModel
init(orders: [Order]) {
_viewModel = State(initialValue: OrderListViewModel(orders: orders))
}
}
// Or if parent owns it
struct OrderListView: View {
let viewModel: OrderListViewModel // Parent owns, just reading
}
// Or if need bindings
struct OrderListView: View {
@Bindable var viewModel: OrderListViewModel // Parent owns, need $
}
アンチパターン(絶対にしないでください)
❌ アンチパターン 1:ビュー本体にロジックを書く
// ❌ Don't do this
struct ProductListView: View {
let products: [Product]
var body: some View {
let formatter = NumberFormatter() // ❌ Created every render!
formatter.numberStyle = .currency
let sorted = products.sorted { $0.price > $1.price } // ❌ Sorted every render!
return List(sorted) { product in
Text("\(product.name): \(formatter.string(from: product.price)!)")
}
}
}
問題点:
formatterがレンダリングのたびに作成されます(パフォーマンス)sortedがレンダリングのたびに計算されます(パフォーマンス)- ビジネスロジック(
sorted)がビューに置かれています(テストできません) - 強制アンラップ('!')によってクラッシュする可能性があります
// ✅ Correct
@Observable
class ProductListViewModel {
let products: [Product]
private let formatter = NumberFormatter()
var sortedProducts: [Product] {
products.sorted { $0.price > $1.price }
}
init(products: [Product]) {
self.products = products
formatter.numberStyle = .currency
}
func formattedPrice(_ product: Product) -> String {
formatter.string(from: product.price as NSNumber) ?? "$0.00"
}
}
struct ProductListView: View {
let viewModel: ProductListViewModel
var body: some View {
List(viewModel.sortedProducts) { product in
Text("\(product.name): \(viewModel.formattedPrice(product))")
}
}
}
❌ アンチパターン 2:境界のない非同期コード
上記のパート 1 にある State-as-Bridge パターンを参照してください。UI の状態変更は(withAnimation 内で)同期的に行い、非同期処理は別途 Task で開始します。
❌ アンチパターン 3:誤ったプロパティラッパー
// ❌ Don't use @State for passed-in models
struct DetailView: View {
@State var item: Item // ❌ Creates a copy, loses parent changes
}
// ✅ Correct: No wrapper for passed-in models
struct DetailView: View {
let item: Item // ✅ Or @Bindable if you need $item
}
// ❌ Don't use @Environment for view-local state
struct FormView: View {
@Environment(FormData.self) var formData // ❌ Overkill for local form
}
// ✅ Correct: @State for view-local
struct FormView: View {
@State private var formData = FormData() // ✅ View owns it
}
❌ アンチパターン 4:巨大な ViewModel
上記のパート 2 にある MVVM の間違い 2 を参照してください。関心ごとごとに分割し、別々の ViewModel にします。
❌ アンチパターン 5:@Observable 内での @AppStorage
@Observable クラス内で @AppStorage を使用しないでください。監視が気づかれないまま壊れます。@AppStorage は SwiftUI ビュー向けに設計されたプロパティラッパーであり、モデルクラス向けではありません。
// ❌ BROKEN — @AppStorage silently breaks @Observable
@Observable
class Settings {
@AppStorage("theme") var theme = "light" // Changes won't trigger view updates
}
// ✅ Read @AppStorage in view, pass to model
struct SettingsView: View {
@AppStorage("theme") private var theme = "light"
// ...
}
❌ アンチパターン 6:ビュー本体での Binding(get:set:)
ビュー本体で Binding(get:set:) を作成すると、評価のたびに新しいバインディングが作成され、SwiftUI のアイデンティティ追跡が壊れます。
// ❌ New Binding created every body evaluation
var body: some View {
TextField("Name", text: Binding(
get: { model.name },
set: { model.name = $0 }
))
}
// ✅ Use @Bindable or computed binding
var body: some View {
@Bindable var model = model
TextField("Name", text: $model.name)
}
❌ アンチパターン 7:クロージャー内での循環状態
.sheet、.fullScreenCover、NavigationStack の遷移先、.popover などの @ViewBuilder クロージャーは、親の状態が変わるたびに再評価されます。子のコールバックが、子の初期化パラメーターとして渡された同じ親の @State を変更すると、子はライフサイクルの途中で変更後の値を使って再初期化されます。
// ❌ Callback mutates the same state passed as init param
.sheet(item: $sheetItem) { _ in
ChildView(
savedResponse: cachedResponse, // ❌ Parent state as init param
onSuccess: { cachedResponse = $0 } // ❌ Mutates same state
)
}
// ✅ Don't pass state that callbacks will mutate
.sheet(item: $sheetItem) { _ in
ChildView(
onSuccess: { cachedResponse = $0 } // Update parent, but don't read it back
)
}
問題点:
- コールバックが、クロージャーが依存する親の状態を変更します
- 親が再評価され、変更された値を使ってクロージャーも再評価されます
- 子が読み込み状態やアニメーション状態を何も表示せずにスキップします。クラッシュはせず、動作が誤るだけです
修正方法:(1)変更される状態を初期化パラメーターとして渡し戻さないでください。(2)子の表示ロジック用に別の @State を使用します。(3)子自身がデータソースに問い合わせるようにします。診断ワークフローの詳細については、axiom-swiftui-debugging の根本原因 5 を参照してください。
コードレビューのチェックリスト
SwiftUI コードをマージする前に、次の点を確認してください。
ビュー
- ビュー本体には UI コード(Text、Button、List など)だけが含まれている
- ビュー本体でフォーマッターを作成していない
- ビュー本体で計算や変換を行っていない
- ビュー本体で API 呼び出しやデータベースクエリを実行していない
- ビュー本体にビジネスルールがない
ロジックの分離
- ビジネスロジックはモデルにある(SwiftUI なしでテストできる)
- 表示ロジックは ViewModel または計算プロパティにある
- 副作用はサービスまたはモデルのメソッドにある
- 負荷の高い計算はキャッシュするか、一度だけ計算する
プロパティラッパー
- ビューが所有するモデルには @State を使用する
- アプリ全体のモデルには @Environment を使用する
- バインディングが必要な場合は @Bindable を使用する
- 読み取りのみの場合はラッパーを使用しない
アニメーションと非同期処理
- アニメーション対象の状態変更は同期的に行う
- 非同期処理との境界には State-as-Bridge パターンを使用する
withAnimation { }ブロックの間にawaitを置かない
テスト容易性
- SwiftUI をインポートせずにビジネスロジックをテストできる
- ビューをレンダリングせずに ViewModel をテストできる
- (Coordinator を使用する場合)ナビゲーションロジックが分離されている
プレッシャーのかかる状況
シナリオ 1:「とりあえずビューに入れておいて」
プレッシャー
マネージャー:「金曜日までにこの機能が必要です。とりあえずロジックをビューに入れてください。あとでリファクタリングします。」
危険信号
次のような言葉を聞いたら注意してください。
- ❌ 「あとでリファクタリングします」(返済されることのない技術的負債)
- ❌ 「ビューは 1 つだけです」(ビューは増えていきます)
- ❌ 「アーキテクチャに時間をかける余裕はありません」(あとでさらにコストがかかります)
所要時間の比較
選択肢 A:ロジックをビューに入れる
- ビューに機能を実装:2 時間
- テストできないと気づく:1 時間
- それでもテストを試みる:2 時間
- あきらめて手動テストだけでリリースする:0 時間
- 合計:5 時間、テスト 0 件
選択肢 B:ロジックを適切に抽出する
- モデル/ViewModel を作成:30 分
- ロジックを分離して機能を実装:2 時間
- テストを作成:1 時間
- 合計:3.5 時間、テストカバレッジ 100%
プロフェッショナルな方法で反論する
Step 1: 期限を認識する
「金曜日が期限なのは理解しています。適切に分離したほうが、実際には速くなる理由をご説明します。」
Step 2: 所要時間を比較して示す
「ロジックをビューに入れると、テストなしで5時間かかります。適切に抽出すれば、テスト込みで3.5時間です。1.5時間節約できるうえに、テストも作成できます。」
Step 3: 妥協案を提示する
「本当に時間がないなら、今すぐ80%を抽出して、残りの20%はチケットを作成して技術的負債として記録できます。ただし、抽出を完全に省略するのはやめましょう。」
Step 4: 強行するよう求められた場合は記録する
// TODO: TECH DEBT - Extract business logic to ViewModel
// Ticket: PROJ-123
// Added: 2025-12-14
// Reason: Deadline pressure from manager
// Estimated refactor time: 2 hours
省略してもよい場合
抽出を省略するのは、次の場合に限ります。
- 使い捨てのプロトタイプである(来週には削除する)
- リファクタリングのための時間が明示的に確保されている(チケットが予定されている)
- ビューが20行を超えて増えることはない
シナリオ 2:「TCA は過剰です。素の SwiftUI を使いましょう」
プレッシャー
テックリード:「TCA はこのプロジェクトには複雑すぎます。@Observable を使った素の SwiftUI にしましょう。」
判断基準
次の質問を検討してください。
| 質問 | TCA | 素の SwiftUI |
|---|---|---|
| テスト可能性は重要ですか(医療、金融など)? | ✅ | ❌ |
| 画面は5つ未満ですか? | ❌ | ✅ |
| チームは関数型プログラミングに精通していますか? | ✅ | ❌ |
| 迅速なプロトタイピングが必要ですか? | ❌ | ✅ |
| 大規模なチーム全体での一貫性は重要ですか? | ✅ | ❌ |
| 複雑な副作用(ソケット、タイマーなど)がありますか? | ✅ | ~ |
推奨マトリックス:
- TCA のチェックが4つ以上 → TCA を使います
- 素の SwiftUI のチェックが4つ以上 → 素の SwiftUI を使います
- 同数 → まず素の SwiftUI から始め、必要になったら TCA に移行します
反論する方法
TCA に賛成する場合:
「TCA が重く感じられるのは理解しています。しかし、私たちは銀行アプリを開発しています。TestStore を使えば、リリース前にバグを見つけられる徹底的なテストができます。2年間の保守を考えれば、2週間の学習期間には価値があります。」
TCA に反対する場合:
「TCA が強力なのは認めますが、私たちは毎週機能のプロトタイプを作っています。定型コードが作業を遅らせます。まずは @Observable を使い、機能に開発する価値があるとわかったら TCA に移行しましょう。」
シナリオ 3:「リファクタリングには時間がかかりすぎます」
プレッシャー
PM:「今月は3つの機能をリリースする予定です。既存のビューのリファクタリングに2週間もかけられません。」
段階的な抽出戦略
すべてを一度にリファクタリングする必要はありません。
第1週:ビューを1つ抽出する
- 最も問題の多いビューを選ぶ(ロジックが多いもの)
- ViewModel に抽出する
- テストを書く
- 時間:4時間
第2週:ビューを2つ抽出する
- これで参考にできるパターンができます
- 第1週より速く進められます
- 時間:6時間
第3週:新機能には適切なアーキテクチャを使う
- まだ古いコードはリファクタリングしない
- すべての新しいコードでこのパターンに従う
- 時間:0時間(これまでと同じ)
第2か月:ファイルに手を入れる際に段階的にリファクタリングする
- 古いビューのバグを修正するときにリファクタリングする
- 古いビューに機能を追加するときにリファクタリングする
- 時間:機能開発の作業に分散
反論する方法
「機能開発を2週間止める提案ではありません。次のように進める提案です。
- 第1週:最も問題の多いビュー(500行ある OrdersView)を抽出する
- 第2週:問題の多いビューをさらに2つ抽出する
- 以降、すべての新機能で適切なアーキテクチャを使う
- 古いビューは、いずれ手を入れるときにリファクタリングする
最初に10時間かかりますが、以降は機能ごとに2時間以上を節約できます。」
実際の効果
Before:ビュー内のロジック
// 😰 200 lines of pain
struct OrderListView: View {
@State private var orders: [Order] = []
@State private var searchText = ""
@State private var selectedFilter: FilterType = .all
var body: some View {
// ❌ Formatters created every render
let currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currency
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
// ❌ Business logic in view
let filtered = orders.filter { order in
if !searchText.isEmpty && !order.customerName.contains(searchText) {
return false
}
switch selectedFilter {
case .all: return true
case .pending: return !order.isCompleted
case .completed: return order.isCompleted
case .highValue: return order.total > 1000
}
}
// ❌ More business logic
let sorted = filtered.sorted { lhs, rhs in
if selectedFilter == .highValue {
return lhs.total > rhs.total
} else {
return lhs.date > rhs.date
}
}
return List(sorted) { order in
VStack(alignment: .leading) {
Text(order.customerName)
Text(currencyFormatter.string(from: order.total as NSNumber)!)
Text(dateFormatter.string(from: order.date))
if order.isCompleted {
Image(systemName: "checkmark.circle.fill")
} else {
Button("Complete") {
// ❌ Async logic in view
Task {
do {
try await completeOrder(order)
await loadOrders()
} catch {
print(error) // ❌ No error handling
}
}
}
}
}
}
.searchable(text: $searchText)
.task {
await loadOrders()
}
}
func loadOrders() async {
// ❌ API call in view
// ... 50 more lines
}
func completeOrder(_ order: Order) async throws {
// ❌ API call in view
// ... 30 more lines
}
}
問題点:
- 1つのファイルに200行以上ある
- レンダリングのたびにフォーマッターが作成される(パフォーマンス)
- ビジネスロジックをテストできない
- エラー処理がない
- 理解しにくい
After:適切なアーキテクチャ
// Model — 30 lines
struct Order {
let id: UUID
let customerName: String
let total: Decimal
let date: Date
var isCompleted: Bool
var isHighValue: Bool {
total > 1000
}
}
// ViewModel — 60 lines
@Observable
class OrderListViewModel {
private let orderService: OrderService
private let currencyFormatter = NumberFormatter()
private let dateFormatter = DateFormatter()
var orders: [Order] = []
var searchText = ""
var selectedFilter: FilterType = .all
var error: Error?
var filteredOrders: [Order] {
orders
.filter(matchesSearch)
.filter(matchesFilter)
.sorted(by: sortComparator)
}
init(orderService: OrderService) {
self.orderService = orderService
currencyFormatter.numberStyle = .currency
dateFormatter.dateStyle = .medium
}
func loadOrders() async {
do {
orders = try await orderService.fetchOrders()
} catch {
self.error = error
}
}
func completeOrder(_ order: Order) async {
do {
try await orderService.complete(order.id)
await loadOrders()
} catch {
self.error = error
}
}
func formattedTotal(_ order: Order) -> String {
currencyFormatter.string(from: order.total as NSNumber) ?? "$0.00"
}
func formattedDate(_ order: Order) -> String {
dateFormatter.string(from: order.date)
}
private func matchesSearch(_ order: Order) -> Bool {
searchText.isEmpty || order.customerName.contains(searchText)
}
private func matchesFilter(_ order: Order) -> Bool {
switch selectedFilter {
case .all: true
case .pending: !order.isCompleted
case .completed: order.isCompleted
case .highValue: order.isHighValue
}
}
private func sortComparator(_ lhs: Order, _ rhs: Order) -> Bool {
selectedFilter == .highValue
? lhs.total > rhs.total
: lhs.date > rhs.date
}
}
// View — 40 lines
struct OrderListView: View {
@Bindable var viewModel: OrderListViewModel
var body: some View {
List(viewModel.filteredOrders) { order in
OrderRow(order: order, viewModel: viewModel)
}
.searchable(text: $viewModel.searchText)
.task {
await viewModel.loadOrders()
}
.alert("Error", error: $viewModel.error) { }
}
}
struct OrderRow: View {
let order: Order
let viewModel: OrderListViewModel
var body: some View {
VStack(alignment: .leading) {
Text(order.customerName)
Text(viewModel.formattedTotal(order))
Text(viewModel.formattedDate(order))
if order.isCompleted {
Image(systemName: "checkmark.circle.fill")
} else {
Button("Complete") {
Task {
await viewModel.completeOrder(order)
}
}
}
}
}
}
// Tests — 100 lines
final class OrderViewModelTests: XCTestCase {
func testFilterBySearch() async {
let viewModel = OrderListViewModel(orderService: MockOrderService())
await viewModel.loadOrders()
viewModel.searchText = "John"
XCTAssertEqual(viewModel.filteredOrders.count, 1)
}
func testFilterByHighValue() async {
let viewModel = OrderListViewModel(orderService: MockOrderService())
await viewModel.loadOrders()
viewModel.selectedFilter = .highValue
XCTAssertTrue(viewModel.filteredOrders.allSatisfy { $0.isHighValue })
}
// ... 10 more tests
}
メリット:
- ビュー:40行(以前は200行)
- ViewModel:SwiftUI を使わずに完全にテスト可能
- モデル:純粋なビジネスロジック
- フォーマッター:レンダリングのたびではなく、一度だけ作成
- エラー処理:アラートを使った適切な処理
- テスト:すべてのロジックを網羅する10件以上のテスト
Resources
WWDC: 2025-266, 2024-10150, 2023-10149, 2023-10160
Docs: /swiftui/managing-model-data-in-your-app
External: github.com/pointfreeco/swift-composable-architecture
Platforms: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+ Xcode: 26+ Status: Production-ready (v1.0)