pinNest アーキテクチャ
pinNest アーキテクチャ
概要
pinNest は The Composable Architecture(TCA) を採用しています。
TCA は Point-Free が開発した SwiftUI 向けのアーキテクチャフレームワークであり、単方向データフロー・テスタビリティ・モジュール分割を軸に設計されています。
基本原則
| 原則 | 説明 |
|---|---|
| 単方向データフロー | State → View → Action → Reducer → State の一方向サイクルで状態変化を管理する |
| 不変 State | アプリの状態は単一の値型(struct)で表現し、副作用は Effect に隔離する |
| テスタビリティ | TestStore を用いてビジネスロジックを副作用込みでテストできる |
| コンポーザビリティ | 小さな Reducer を Scope / .forEach などで合成し、機能を段階的に組み立てる |
TCA の構成要素
┌─────────────────────────────────┐
│ View │ ← SwiftUI View
│ observe { store.someState } │
└────────────┬────────────────────┘
│ send(.someAction)
▼
┌─────────────────────────────────┐
│ Store │ ← 状態保持・Reducer 実行・Effect 管理
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Reducer │ ← 純粋関数: (State, Action) → Effect
│ @Reducer struct FeatureReducer │
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Effect │ ← 非同期処理・副作用 (Swift Concurrency)
└─────────────────────────────────┘
各要素の役割
| 要素 | 役割 |
|---|---|
State |
画面や機能の状態を保持する値型(struct) |
Action |
ユーザー操作・システムイベント・Effect の結果を表す enum |
Reducer |
State と Action を受け取り、新しい State と Effect を返す純粋関数 |
Effect |
非同期処理・API 通信・通知などの副作用。完了後に Action を返す |
Store |
State を保持し、Action を受け取って Reducer を実行するランタイム |
ディレクトリ構成
pinNest/
├── App/
│ ├── pinNestApp.swift # @main エントリポイント、ルート Store の生成
│ └── AppReducer.swift # アプリ全体のルート Reducer・NavigationStack
│
├── Features/ # 機能単位のモジュール群
│ ├── PinList/
│ │ ├── PinListView.swift # ホーム画面(グリッド / リスト・種別フィルタ)
│ │ └── PinListReducer.swift
│ ├── PinCreate/
│ │ ├── PinCreateView.swift # ピン作成画面(種別選択 → コンテンツ入力)
│ │ └── PinCreateReducer.swift
│ ├── PinDetail/
│ │ ├── PinDetailView.swift # ピン詳細画面(種別ごとの UI)
│ │ └── PinDetailReducer.swift
│ ├── Collection/
│ │ ├── CollectionListView.swift
│ │ ├── CollectionListReducer.swift
│ │ ├── CollectionDetailView.swift
│ │ └── CollectionDetailReducer.swift
│ ├── Search/
│ │ ├── SearchView.swift
│ │ └── SearchReducer.swift
│ └── Settings/
│ ├── SettingsView.swift
│ └── SettingsReducer.swift
│
├── ShareExtension/ # Share Extension ターゲット(別 Xcode Target)
│ ├── ShareView.swift # 他アプリからの共有受け取り UI
│ └── ShareReducer.swift
│
├── Shared/ # 複数 Feature で共有するコード
│ ├── Models/
│ │ ├── Pin.swift # @Model(SwiftData)
│ │ ├── ContentType.swift # enum(url / image / video / pdf / text)
│ │ ├── PinCollection.swift # @Model(SwiftData)
│ │ └── Tag.swift # @Model(SwiftData)
│ ├── Clients/
│ │ ├── PinClient.swift # CRUD 操作の Dependency
│ │ └── MetadataClient.swift # LPMetadataProvider ラッパーの Dependency
│ └── Extensions/
│
└── docs/ # プロジェクトドキュメント
Reducer の実装例
import ComposableArchitecture
@Reducer
struct FeatureAReducer {
// MARK: - State
@ObservableState
struct State: Equatable {
var items: [Item] = []
var isLoading: Bool = false
}
// MARK: - Action
enum Action {
case onAppear
case fetchItemsResponse(Result<[Item], Error>)
case itemTapped(Item)
}
// MARK: - Dependencies
@Dependency(\.apiClient) var apiClient
// MARK: - Reducer Body
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
return .run { send in
await send(.fetchItemsResponse(
Result { try await apiClient.fetchItems() }
))
}
case let .fetchItemsResponse(.success(items)):
state.isLoading = false
state.items = items
return .none
case .fetchItemsResponse(.failure):
state.isLoading = false
return .none
case .itemTapped:
return .none
}
}
}
}
View の実装例
import ComposableArchitecture
import SwiftUI
struct FeatureAView: View {
// @Bindable により State の変化を自動的に購読
@Bindable var store: StoreOf<FeatureAReducer>
var body: some View {
List(store.items) { item in
Text(item.title)
.onTapGesture {
store.send(.itemTapped(item))
}
}
.overlay {
if store.isLoading {
ProgressView()
}
}
.onAppear {
store.send(.onAppear)
}
}
}
依存性の注入(Dependency)
TCA の @Dependency を用いて副作用を持つ処理を注入可能な形で管理します。
これにより、本番・テスト・プレビュー環境で異なる実装を差し替えられます。
// 定義
extension DependencyValues {
var apiClient: APIClient {
get { self[APIClient.self] }
set { self[APIClient.self] = newValue }
}
}
// テスト時の差し替え
let store = TestStore(initialState: FeatureAReducer.State()) {
FeatureAReducer()
} withDependencies: {
$0.apiClient = .mock // モック実装に差し替え
}
テスト方針
TestStoreを使用し、Actionの送信に対してStateの変化を逐一検証する- Effect が返す
Actionもawait store.receive(...)で検証する - 副作用は
@Dependency経由で注入し、テストではモックに差し替える
import Testing
import ComposableArchitecture
@Suite("FeatureA")
struct FeatureAReducerTests {
@Test("onAppear でアイテムが取得される")
func fetchItemsOnAppear() async {
let mockItems = [Item(id: 1, title: "テスト")]
let store = TestStore(initialState: FeatureAReducer.State()) {
FeatureAReducer()
} withDependencies: {
$0.apiClient.fetchItems = { mockItems }
}
await store.send(.onAppear) {
$0.isLoading = true
}
await store.receive(.fetchItemsResponse(.success(mockItems))) {
$0.isLoading = false
$0.items = mockItems
}
}
}
画面遷移
画面遷移は TCA の Navigation 機能(@Presents / NavigationStack との統合)で管理します。
@Reducer
struct AppReducer {
@ObservableState
struct State {
var featureA = FeatureAReducer.State()
@Presents var destination: Destination.State?
}
enum Action {
case featureA(FeatureAReducer.Action)
case destination(PresentationAction<Destination.Action>)
}
@Reducer
enum Destination {
case featureB(FeatureBReducer)
}
var body: some ReducerOf<Self> {
Scope(state: \.featureA, action: \.featureA) {
FeatureAReducer()
}
.ifLet(\.$destination, action: \.destination)
}
}
データ層(SwiftData)
Pin/PinCollection/Tagは@Modelマクロで定義し、SwiftData で永続化するModelContainerはPinClientの内部で保持し、@Dependency(\.pinClient)経由で各 Reducer に注入する- Share Extension とホストアプリは App Group を通じて同一の
ModelContainerストア(group.<bundle-id>.pinNest)を共有する
// Shared/Clients/PinClient.swift
struct PinClient {
var fetchAll: @Sendable () async throws -> [Pin]
var create: @Sendable (Pin) async throws -> Void
var update: @Sendable (Pin) async throws -> Void
var delete: @Sendable (Pin) async throws -> Void
}
extension DependencyValues {
var pinClient: PinClient {
get { self[PinClient.self] }
set { self[PinClient.self] = newValue }
}
}
URL メタデータ取得(MetadataClient)
LPMetadataProviderをラップしたMetadataClientを@Dependencyとして注入する- 取得した
og:imageはアプリコンテナ(FileManager)にキャッシュし、Pinには画像ファイルのパスを保存する - Share Extension からも同一の
MetadataClientを再利用できるようShared/Clients/に配置する
// Shared/Clients/MetadataClient.swift
struct MetadataClient {
var fetch: @Sendable (URL) async throws -> URLMetadata
}
struct URLMetadata: Equatable {
var title: String?
var thumbnailData: Data?
var faviconData: Data?
}
Share Extension アーキテクチャ
- Share Extension は 別 Xcode Target として実装する
ShareReducerがNSExtensionContextからNSItemProviderを受け取り、コンテンツ種別を判定してPinClient.createを呼ぶ- Extension 内では SwiftUI + TCA を使用し、
ShareView/ShareReducerのみで完結させる(ホストアプリの Feature には依存しない)