🦅

iOSアプリ開発ガイドを考えてみた

に公開

iOSアプリのアーキテクチャは現状正解がなく、アーキテクチャにはいつも悩みます。プロジェクトの状況によって採用するアーキテクチャはさまざまだと思います。この記事では比較的流行に影響されず、私自身がよく採用しているアーキテクチャや開発方針をガイドとしてまとめてみました。

とあるアプリを開発していた時、当時はコードの保守性よりも最速で動くものを作ることを重視していました。それはそれで目的を果たせたのですが、さまざまな処理をどこに書くか明確に決めきれていない部分があり、改修をするたびにそれが複雑化していく状態でした。このままではチーム開発や将来的な機能追加に影響が出ると思い、徐々にリファクタリングを進めているところです。その中でガイドをしっかり作った方がいいなと思い作成したものになります。今後はAIにコーディングを任せることも増えるかと思いますが、その際もアーキテクチャや指針があらかじめ決まっていた方がコードの品質が高まると思っています。

特にアーキテクチャ部分はTCAなど違う考え方もあり難しいところだと思いますが、あくまで一つの例として見ていただけると助かります。また、私自身まだまだ不勉強なところもあると思いますので、お気軽にフィードバックをもらえると嬉しいです。

まず始めにこのガイドの目的や対象などを説明したあと、具体的なアーキテクチャを紹介しています。その次のセクションではコーディング規約、実装の基本方針などを解説しています。気になったところだけでも読んでいただけると幸いです。

目次

1. はじめに

このセクションでは、ガイドの目的と対象読者、前提となる技術スタックを説明します。

ガイドの目的

このガイドは、iOSアプリ開発におけるアーキテクチャ設計、コーディング規約、および開発プラクティスを共有し、開発の指針として役立てることを目的に書きました。例えば以下のような役割を想定しています。

  1. 新しいプロジェクトを開始する際の設計指針
  2. 既存プロジェクトのリファクタリングや拡張時の参考となる
  3. 新しいチームメンバーのキャッチアップ支援
  4. コードレビューの基準を提供する
  5. 開発チーム全体での知識共有と技術的一貫性を促進する

このガイドを参考にすることで、保守性に優れたアプリを開発する助けになれば嬉しいです。ただし、このガイドは絶対的なルールではなく、プロジェクトの特性や要件に応じて柔軟に適用することのがおすすめです。

対象読者

このガイドは、主に以下の方々を対象としています。幅広いiOS開発者の方に読んでもらえると嬉しいです。

初級〜中級iOSエンジニア

  • おすすめセクション: アーキテクチャ概要、コーディング規約、クイックスタートガイド

上級iOSエンジニア

  • おすすめセクション: アーキテクチャの詳細、実装のベストプラクティス、テスト戦略、マイグレーション戦略

クロスプラットフォーム開発者

  • おすすめセクション: アーキテクチャ概要、コーディング規約、よくあるQ&A

前提となる技術スタック

本ガイドで説明するアーキテクチャおよび開発プラクティスは、以下の技術スタックを使用しています。

  1. プログラミング言語: Swift 5.9以降
  2. 最小サポートOS: iOS 15.0以降(一部機能はiOS 16.0以降が必要)
  3. UI Framework: SwiftUIとUIKitのハイブリッドアプローチ
  4. 非同期処理: Swift Concurrencyとasync/await(Combineも部分的に使用)
  5. 依存性管理: Swift Package Manager
  6. テストフレームワーク: XCTest
  7. ネットワーク: URLSession
  8. 永続化: UserDefaults、Realmなど
  9. 開発環境: Xcode 15以降

これらの技術スタックは、Apple公式のベストプラクティスに沿いながら、モダンなSwift開発の魅力を存分に引き出すよう選定しています。特に指定がない場合は、サードパーティライブラリの使用は必要最小限に留めることのがおすすめです。

次のセクションでは、このガイドの核心となるアーキテクチャの全体像と詳細について解説していきます。アーキテクチャをしっかり理解することが、効率的なiOSアプリ開発の土台となります。

2. アーキテクチャ概要

このセクションでは、私が選んだアーキテクチャの基本概念とその理由についてご紹介します。過去の経験を活かし、Clean Architecture、VIPER、MVVMそれぞれの良さを取り入れたハイブリッド方式を採用しました。構成はプレゼンテーション層、ドメイン層、インフラストラクチャ層という3つの層で整理しています。また、SwiftUIを新たに導入しながらも、既存のUIKitとどう上手く共存させていくかについても触れていきます。

採用アーキテクチャの紹介

基本的にはClean Architectureをベースとしつつ、部分的にVIPERやMVVMを取り入れています。また、画面遷移や通知などでUIViewControllerを使いたい場面があるため、Viewを全面的にSwiftUIにはせずUIKitとのハイブリッドにしています。現時点では画面単位でRouterを設計していますが、将来的にCoordinatorパターンへの集約を検討しています。プロジェクトの規模やナビゲーションの複雑性に応じて柔軟に設計を見直す方針です。

上記の図のように3つの主要レイヤーで構成されています。依存関係は常に内側のレイヤー(Domain)に向かっています。

アーキテクチャ選択の背景

  1. VIPERアーキテクチャの採用
    これまで単一のアーキテクチャに限定せず、プロジェクトの規模やUI、ロジックの複雑さなどを考慮してその都度検討してきました。iOS開発におけるClean Architectureの実装例であるVIPERを採用することが多かったです。これは責務の明確な分離により、どこに何が書いてあるかわかりやすいこと、変更に強いコードを維持することを目的としていました。

  2. SwiftUIの導入期
    SwiftUIをアプリ開発に取り入れた初期段階では、既存のVIPERアーキテクチャを維持したまま、ViewControllerの上にSwiftUIを配置する形を採用しました。この段階でのデータフローは View <--> ViewController <--> Presenter の経路を維持していました。この方式により、既存のアーキテクチャを大きく変更せずにSwiftUIを部分的に取れ入れられました。

  3. ハイブリッドアーキテクチャへの移行
    しかし、開発を進めるうちにViewControllerがPresenterとの橋渡しのみを担当する構造は冗長かもしれないと思い始めました。そこでViewControllerとViewの関係を見直し、新たにViewModelとViewをバインディングする方式を採用するに至りました。ただし、NavigationControllerやTabControllerなどUIKitベースの画面遷移を活用するため、ViewControllerは引き続き保持しています。

選択したアーキテクチャ

  1. SwiftUIとUIKitのハイブリッド構成
    SwiftUIとUIKitのハイブリッド構成を採用しています。これにより、SwiftUIの宣言的UIとデータバインディングの利点を活かしつつ、同時にUIKitの画面遷移機能やライフサイクル管理を活用することができます。

  2. ViewModelの役割
    ViewとModelを直接バインディングしてViewModelを使わない構成も考えましたが、ViewModelを間に挟む構成を採用しています。ビジネスロジックとプレゼンテーションロジックの分離をViewModelで行うことで、特にViewのテストがやりやすくなります。ViewModelをモック化することで、SwiftUIのプレビュー機能効果的に活用できます。さらに、将来的なObservationフレームワークへの移行を見据えた設計となっています。

  3. Clean Architectureの原則維持
    MVVMアーキテクチャは基本的にプレゼンテーション層より上位の構成を表しており、ビジネスロジックであるModelについてはあまり細かく規定されていません。Modelの構成については引き続きClean Architectureの考え方を維持しています。

3. クイックスタートガイド

詳細な解説に入る前に、実際にこのアーキテクチャを採用したサンプルの実装方法を説明します。内容だけ知りたい場合は読み飛ばしてください。

前提条件

  • Xcode 15以降
  • Swift 5.9以降
  • iOS 15以降をターゲットとするプロジェクト

ステップ1: プロジェクト構造のセットアップ

新規Xcodeプロジェクトを作成し、以下のフォルダ構造を作成します

SampleApp/
├── Presentation/          // UI層
│   ├── Views/             // SwiftUI Views
│   ├── ViewModels/        // ViewModels
│   └── Routers/           // 画面遷移
├── Domain/                // ビジネスロジック層
│   ├── UseCases/          // ユースケース
│   ├── Entities/          // ドメインモデル
│   └── Repositories/      // リポジトリインターフェース
└── Infrastructure/        // データアクセス層
    ├── Repositories/      // リポジトリ実装
    ├── DataSources/       // データソース
    └── DTOs/              // データ転送オブジェクト

ステップ2: ドメイン層の実装

まず、シンプルなTodoアプリを例にドメインモデルとリポジトリインターフェースを定義します。

// Domain/Entities/TodoItem.swift
struct TodoItem: Identifiable {
    let id: String
    var title: String
    var isCompleted: Bool
}

// Domain/Repositories/TodoRepository.swift
protocol TodoRepository {
    func getTodoItems() async throws -> [TodoItem]
    func addTodoItem(_ item: TodoItem) async throws
    func updateTodoItem(_ item: TodoItem) async throws
    func deleteTodoItem(id: String) async throws
}

次に、ユースケースを実装します。

// Domain/UseCases/TodoUseCase.swift
protocol TodoUseCase {
    func fetchTodoItems() async throws -> [TodoItem]
    func createTodoItem(title: String) async throws
    func toggleTodoItem(_ item: TodoItem) async throws
    func removeTodoItem(id: String) async throws
}

// Domain/UseCases/TodoInteractor.swift
actor TodoInteractor: TodoUseCase {
    private let repository: TodoRepository
    
    init(repository: TodoRepository) {
        self.repository = repository
    }
    
    func fetchTodoItems() async throws -> [TodoItem] {
        return try await repository.getTodoItems()
    }
    
    func createTodoItem(title: String) async throws {
        let newItem = TodoItem(id: UUID().uuidString, title: title, isCompleted: false)
        try await repository.addTodoItem(newItem)
    }
    
    func toggleTodoItem(_ item: TodoItem) async throws {
        var updatedItem = item
        updatedItem.isCompleted = !item.isCompleted
        try await repository.updateTodoItem(updatedItem)
    }
    
    func removeTodoItem(id: String) async throws {
        try await repository.deleteTodoItem(id: id)
    }
}

ステップ3: インフラストラクチャ層の実装

リポジトリとデータソースを実装します。シンプルさのため、メモリストレージを使用します。

// Infrastructure/DataSources/LocalTodoDataSource.swift
class LocalTodoDataSource {
    private var todoItems: [TodoItem] = []
    
    func getTodoItems() async -> [TodoItem] {
        return todoItems
    }
    
    func addTodoItem(_ item: TodoItem) async {
        todoItems.append(item)
    }
    
    func updateTodoItem(_ item: TodoItem) async {
        if let index = todoItems.firstIndex(where: { $0.id == item.id }) {
            todoItems[index] = item
        }
    }
    
    func deleteTodoItem(id: String) async {
        todoItems.removeAll { $0.id == id }
    }
}

// Infrastructure/Repositories/TodoRepositoryImpl.swift
class TodoRepositoryImpl: TodoRepository {
    private let dataSource: LocalTodoDataSource
    
    init(dataSource: LocalTodoDataSource) {
        self.dataSource = dataSource
    }
    
    func getTodoItems() async throws -> [TodoItem] {
        return await dataSource.getTodoItems()
    }
    
    func addTodoItem(_ item: TodoItem) async throws {
        await dataSource.addTodoItem(item)
    }
    
    func updateTodoItem(_ item: TodoItem) async throws {
        await dataSource.updateTodoItem(item)
    }
    
    func deleteTodoItem(id: String) async throws {
        await dataSource.deleteTodoItem(id: id)
    }
}

ステップ4: プレゼンテーション層の実装

まず、ViewModelを定義します。

// Presentation/ViewModels/TodoListViewModel.swift
protocol TodoListViewModel: ObservableObject {
    var todoItems: [TodoItem] { get }
    var isLoading: Bool { get }
    var errorMessage: String? { get }
    
    func onAppear()
    func addTodoItem(title: String)
    func toggleTodoItem(_ item: TodoItem)
    func deleteTodoItem(id: String)
}

// Presentation/ViewModels/TodoListViewModelImpl.swift
@MainActor
class TodoListViewModelImpl: TodoListViewModel {
    @Published var todoItems: [TodoItem] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
    
    private let useCase: TodoUseCase
    
    init(useCase: TodoUseCase) {
        self.useCase = useCase
    }
    
    func onAppear() {
        isLoading = true
        errorMessage = nil
        
        Task {
            do {
                todoItems = try await useCase.fetchTodoItems()
            } catch {
                errorMessage = error.localizedDescription
            }
            isLoading = false
        }
    }
    
    func addTodoItem(title: String) {
        guard !title.isEmpty else { return }
        
        Task {
            do {
                try await useCase.createTodoItem(title: title)
                todoItems = try await useCase.fetchTodoItems()
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
    
    func toggleTodoItem(_ item: TodoItem) {
        Task {
            do {
                try await useCase.toggleTodoItem(item)
                todoItems = try await useCase.fetchTodoItems()
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
    
    func deleteTodoItem(id: String) {
        Task {
            do {
                try await useCase.removeTodoItem(id: id)
                todoItems = try await useCase.fetchTodoItems()
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
}

次に、View部分を実装します。

// Presentation/Views/TodoListView.swift
struct TodoListView<ViewModel: TodoListViewModel>: View {
    @ObservedObject var viewModel: ViewModel
    @State private var newTodoTitle = ""
    
    var body: some View {
        ZStack {
            List {
                Section(header: Text("新しいタスク")) {
                    HStack {
                        TextField("タスクを入力", text: $newTodoTitle)
                        Button(action: {
                            viewModel.addTodoItem(title: newTodoTitle)
                            newTodoTitle = ""
                        }) {
                            Image(systemName: "plus.circle.fill")
                        }
                        .disabled(newTodoTitle.isEmpty)
                    }
                }
                    
                Section(header: Text("タスク一覧")) {
                    if viewModel.todoItems.isEmpty && !viewModel.isLoading {
                        Text("タスクがありません")
                            .foregroundColor(.gray)
                                .italic()
                    } else {
                        ForEach(viewModel.todoItems) { item in
                            HStack {
                                Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
                                        .foregroundColor(item.isCompleted ? .green : .gray)
                                    .onTapGesture {
                                        viewModel.toggleTodoItem(item)
                                    }
                                
                                    Text(item.title)
                                    .strikethrough(item.isCompleted)
                                    .foregroundColor(item.isCompleted ? .gray : .primary)
                            }
                            .swipeActions {
                                Button(role: .destructive) {
                                    viewModel.deleteTodoItem(id: item.id)
                                } label: {
                                    Label("削除", systemImage: "trash")
                                }
                            }
                        }
                    }
                }
            }
                
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .navigationTitle("ToDo")
        .alert("エラー", isPresented: Binding<Bool>(
            get: { viewModel.errorMessage != nil },
            set: { if !$0 { viewModel.errorMessage = nil } }
        )) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(viewModel.errorMessage ?? "")
        }
        .onAppear {
            viewModel.onAppear()
        }
    }
}

// Presentation/Views/TodoListViewController.swift
class TodoListViewController<ViewModel: TodoListViewModel>: UIViewController {
    var viewModel: ViewModel?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupSwiftUiView()
    }

    // 実際にはViewControllerのExtensionなどで共通化するのもあり
    private func setupSwiftUiView() {
        guard let viewModel else {
            return
        }

        let todoListView = TodoListView(viewModel: viewModel)
        let parentView = parent ?? view!

        let hostingController = UIHostingController(rootView: todoListView)
        parentView.addSubview(hostingController.view)
        hostingController.view.backgroundColor = .clear
        hostingController.view?.translatesAutoresizingMaskIntoConstraints = false
        hostingController.view.clipsToBounds = true

        NSLayoutConstraint.activate([
            hostingController.view.topAnchor.constraint(equalTo: parentView.topAnchor),
            hostingController.view.leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: parentView.trailingAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: parentView.bottomAnchor)
        ])
        hostingController.didMove(toParent: self)
    }

ステップ5: 依存性注入と組み立て

最後に、各レイヤーを接続し、アプリを起動します。

// Presentation/TodoListRouter.swift
class TodoListRouter {

    private unowned let viewController: UINavigationController

    private init(viewController: UINavigationController) {
        self.viewController = viewController
    }

    static func assembleModules() -> some View {
        // データソースの生成
        let dataSource = LocalTodoDataSource()
        
        // リポジトリの生成
        let repository = TodoRepositoryImpl(dataSource: dataSource)
        
        // ユースケースの生成
        let useCase = TodoInteractor(repository: repository)
        
        // ViewModelの生成
        let viewModel = TodoListViewModelImpl(router: router, useCase: useCase)

        let viewController = TodoListViewController()
        viewController.viewModel = viewModel

        // Viewの生成と返却
        return viewController
    }
}

// SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        // Windowを作成してViewControllerを表示
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.backgroundColor = .systemBackground
            self.window = window
            window.makeKeyAndVisible()
        }
        self.window?.rootViewController = TodoListRouter.assembleModules()
    }

まとめ

クイックスタートガイドでは、Clean Architecture + MVVMパターンを基にした基本的なTodoアプリを作成してみました。ここでのポイントは次の通りです。

  1. レイヤーの分離: ドメイン層、インフラストラクチャ層、プレゼンテーション層を分離
  2. 依存関係の方向: 外側から内側へ(ViewModelはUseCaseに、UseCaseはRepositoryに依存します)
  3. インターフェースによる抽象化: リポジトリとViewModelはプロトコルを通じて抽象化
  4. 非同期処理: Swift Concurrencyの活用
  5. 状態管理: ObservableObjectとPublishedプロパティを使用したSwiftUIとの統合

実際のアプリ開発では、エラー処理、テスト、ネットワーク通信、データベース保存など、さらに考慮すべき点があります。これらについては、ガイドの各セクションで詳しく解説しています。

このサンプルアプリを足がかりに、ガイドで紹介しているアーキテクチャパターンを理解し、より複雑なアプリ開発にも応用していただければと思います。

4. アーキテクチャの詳細

このセクションでは、アーキテクチャを構成する各コンポーネント(View、ViewModel、Router、UseCase、Repository、DataSource)の役割と相互のつながりを詳しく見ていきます。コンポーネント間でどのように呼び出しが行われ、データがどう流れるのかを図とコード例を交えて解説します。また、依存関係の向きや責務をきちんと分ける原則についても分かりやすく説明していきます。

各コンポーネントの役割と呼び出しの関係

1. View:

  • Viewはユーザーインターフェースを表示し、ユーザーの操作を受け取ります。
  • ViewはViewModelのプロパティを監視し、状態の変化に応じてUIを更新します。
  • ユーザーの操作に応じて、ViewModelのメソッドを呼び出します。
  • 通常、1つのViewに対して1つのViewModelが対応します。
  • ただし、画面の一部など小さなViewにはViewModelはなく、親Viewから値を渡します。
  • Viewは原則として定義と実装を分割せず、実装のみとします。
  • 1画面につきUIViewControllerのサブクラスとSwiftUIのView構造体で構成されます。
  • SwiftUIのViewをUIHostingViewControllerを通じて、UIViewControllerのviewのサブビューとして表示します。
  • View及びViewControllerがViewModelの参照を持ちます。

実装例: View

struct SampleView<ViewModel: SampleViewModel>: View {
    @StateObject var viewModel: ViewModel

    init(viewModel: ViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }

    var body: some View {
        VStack {
            Text("\$(viewModel.count)")
        }.onAppear {
            viewModel.onAppear()
        }
        .onDisappear {
            viewModel.onDisappear()
        }
    }

実装例: ViewController

class SampleViewController<ViewModel: SampleViewModel>: UIViewController {
    var viewModel: ViewModel?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupSwiftUiView()
    }
    private func setupSwiftUiView() {
        guard let viewModel else {
            return
        }

        let sampleView = SampleListView(viewModel: viewModel)
        let parentView = parent ?? view!

        let hostingController = UIHostingController(rootView: sampleView)
        parentView.addSubview(hostingController.view)
        hostingController.view.backgroundColor = .clear
        hostingController.view?.translatesAutoresizingMaskIntoConstraints = false
        hostingController.view.clipsToBounds = true

        NSLayoutConstraint.activate([
            hostingController.view.topAnchor.constraint(equalTo: parentView.topAnchor),
            hostingController.view.leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: parentView.trailingAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: parentView.bottomAnchor)
        ])
        hostingController.didMove(toParent: self)
    }

2. ViewModel:

  • ViewModelはViewからのイベントを受け取りとビジネスロジックの実行をするUseCaseを呼び出しを行います
  • ViewとViewModelとの連携はSwiftUIとCombineフレームワークの機能を用いてバインディングします(@Published, @ObservedObject, @StateObject)
  • 対応OSがiOS 17以上のみになればObservationになる予定です
  • 1つのViewModelは複数のUseCaseを利用することができます
  • 各UseCaseは特定のビジネスロジックや機能を担当するため、ViewModelは必要に応じて適切なUseCaseを呼び出します
  • ViewModelはインターフェースと実装を分離し、Viewはインターフェースにのみ依存します

実装例

@MainActor
protocol PersonListViewModel: ObservableObject {
    // MARK: - Published Properties
    var persons: [Person] { get }
    var errorMessage: String? { get }

    // MARK: - Lifecycle Methods
    func onAppear()
    func onDisappear()

    // MARK: - User Actions
    func backButtonTapped()
}

// MARK: - PersonListViewModel Implementation
@MainActor
class PersonListViewModelImpl: PersonListViewModel {
    // MARK: - Published Properties
    @Published var persons: [Person] = []
    @Published var errorMessage: String?

    // MARK: - Dependencies
    private let router: PersonListWireframe
    private let interactor: PersonListUseCase
    private let registrationInteractor: RegistrationStateUseCase

    private var checkLoginTask: Task<Void, Never>?

    // MARK: - Initialization
    init(router: PersonListWireframe,
         interactor: PersonListUseCase,
         registrationInteractor: RegistrationStateUseCase) {
        self.router = router
        self.interactor = interactor
        self.registrationInteractor = registrationInteractor
    }

    // MARK: - Lifecycle Methods
    func onAppear() {
        fetchData()
    }

    func onDisappear() {
        checkLoginTask?.cancel()
    }

    // MARK: - User Actions
    func backButtonTapped() {
        router.backToTop()
    }

    // MARK: - Private Methods
    private func checkLogin() {
        // 画面が非表示になるタイミングでタスクをキャンセル
        checkLoginTask = Task {
            let item = await interactor.checkIn()
            guard let item else {
                return
            }
            router.showLoginStampView(for: item)
        }
    }

    private func fetchData() {
        Task {
            do {
                let persons = try await interactor.fetchPersons()

                // Update published properties
                self.persons = fetchedPersons
            } catch {
                errorMessage = AppGeneralError.connectionError.localizedDescription
            }
        }
    }
}

3. Router:

  • Routerはモジュール間の依存性の管理と画面遷移を行います
  • ViewやViewModel、UseCaseなどを生成し、それぞれのモジュールに注入します
  • RouterはViewModelから呼び出され画面遷移の実行を行います

実装例

@MainActor
protocol SampleWireframe: AnyObject {
    func backToTop()
}

@MainActor
class SampleRouter {
    private unowned let viewController: UINavigationController

    private init(viewController: UINavigationController) {
        self.viewController = viewController
    }

    // 各モジュールに注入する実装クラスを生成
    static func assembleModules() -> UIViewController {
        let viewController = SampleViewController<SampleViewModelImpl>()
        let navigationViewController = UINavigationController(rootViewController: viewController)
        let router = SampleRouter(viewController: navigationViewController)
        let interactor = SampleInteractor()
        let registrationInteractor = RegistrationStateInteractor()
        let viewModel = SampleViewModelImpl(router: router, interactor: interactor, registrationInteractor: registrationInteractor)
        viewController.viewModel = viewModel
        return navigationViewController
    }

    func backToTop() {
        viewController.popViewController(animated: true)
    }
}

4. UseCase:

  • UseCaseは表示に依存しないビジネスロジックを実行します
  • UseCaseは他のコンポーネントを呼び出しながら、データの取得や管理、計算、ネットワークアクセスなどを行います
  • UseCaseが行う作業は通常1つの処理に焦点を当てています。例えば特定の種類のデータの取得や加工などです
  • UseCaseは、具体的なタスクを実行するために、RepositoryやService、Adapterを呼び出します
  • UseCaseの各メソッドはSwift Concurrencyで実装します
  • エラーが発生する可能性があるメソッド定義にはthrowsを付けます
  • UseCase以下の層で発生したエラーはアプリで定義したエラー型に変換してViewModel以上の層に伝えます

実装例

protocol RegistrationStateUseCase {
    func fetchUserRegisteredState() async -> UserRegisteredState?
    func fetchUserInfo() async -> UserInfo?
}

actor RegistrationStateInteractor {
    let repository = UserRepository()
}

extension RegistrationStateInteractor: RegistrationStateUseCase {
    func fetchUserRegisteredState() async throws -> UserRegisteredState {
        return try await repository.userRegisteredState()
    }

    func fetchUserInfo() async -> UserInfo? {
        return try? await repository.fetchUserInfo()
    }
}


5. UseCase → Repository (1:1)

  • データの永続化や取得に関する操作を行います
  • 通常、1つのUseCaseは1つの特定のRepositoryとやり取りします
  • これは、UseCaseが特定のデータドメインに焦点を当てているためです
  • Repositoryはデータの読み書きに集中し、それ以上のビジネスロジックはUseCaseやServiceが担当します
  • Repositoryはデータの取得元に依存しないロジックを担当します。Repositoryが直接ネットワークアクセスを行うことはなく、それらの処理はDataSourceが行います

6. Repository → DataSource (1:N)

  • Repositoryは、データの取得や保存のために1つ以上のDataSourceを利用します
  • DataSourceは、特定のデータソース(ローカルデータベース、リモートAPI等)とのインタラクションを担当します
  • Repositoryは、必要に応じて適切なDataSourceを選択し、データの取得や保存を行います

7. DataSource

  • 具体的なデータストレージ(データベース、APIなど)とのやり取りを抽象化します
  • 通常、LocalDataSourceとRemoteDataSourceに分かれます
  • データの取得、保存、更新、削除(CRUD操作)を行います
  • データの変換(APIレスポンスやデータベース形式からドメインモデルへの変換)も担当します

8. UseCase → Service (1:N)

  • 特定のビジネスロジックや計算を行います
  • 1つのUseCaseは必要に応じて複数のServiceを利用することができます
  • Serviceは再利用可能なビジネスロジックを提供するため、UseCaseは複数のServiceを組み合わせて複雑な操作を実行できます

9. UseCase → Adapter (1:1)

  • 外部システムやライブラリとのインターフェースを提供します
  • これにより、外部システムやライブラリの具体的な実装を、アプリの他の部分から隠蔽します
  • UseCaseは通常、特定の外部システムやライブラリと対話するために1つのAdapterを使用します
  • これにより外部依存性を抽象化し、UseCaseの実装を簡潔に保ちます
  • AdaptorはそのインターフェースをAdaptorInterface、実装をAdaptorとし、UseCaseはインターフェースにのみ依存します

取得したデータの加工について

DataSource層

DataSource層では、主に「生のデータ」を扱います。ここでの処理や変換は、通常以下のようなものに限定されます

  • APIレスポンスのパース(JSON → DTOオブジェクト)
  • データベースの結果をアプリで使用可能なオブジェクトに変換
  • データの基本的なフィルタリングや並べ替え

Repository層

Repository層は、DataSourceから取得したデータに対して、より高度な処理や変換を行う適切な場所です

  • 複数のDataSourceからのデータの統合
  • ドメインモデルへの変換
  • キャッシュロジックの実装
  • データの整合性チェック
  • ビジネスルールに基づいたデータのフィルタリングや加工

UseCase層

UseCase層では、さらに高度なビジネスロジックに基づいた処理や変換を行います

  • 複数のRepositoryからのデータの組み合わせ
  • 複雑なビジネスルールの適用
  • ユーザーの権限に基づいたデータのフィルタリング

MVVM Clean Architectureの利点

1. 依存の方向

  • 呼び出しの流れは、一般的に外側のレイヤー(View)から内側のレイヤー(Repository, Service)に向かいます
  • 内側のレイヤーは、外側のレイヤーに依存しません。これにより、内側のコンポーネントの再利用性と保守性が向上します

2. 抽象化とインターフェース

  • UseCaseは通常、Repository、Service、Adapter抽象インターフェースに依存します
  • これにより、具体的な実装の詳細から UseCaseを分離し、テストやモック化を容易にします

3. 責務の分離

  • 各コンポーネントは明確に定義された責任を持ちます
  • ViewModelはUIの状態管理、UseCaseはビジネスロジック、Repositoryはデータ永続化など
  • 1:1関係(例:View → ViewModel)は、各コンポーネントの責任を明確に分離します
  • これにより、コードの保守性とテスト容易性が向上します

4. データの流れ

  • データは一般的に、Repository/DataSource → UseCase → ViewModel → View の順に流れます。
  • ユーザーの入力は逆方向に流れ、最終的にRepository/DataSourceでのデータ更新につながることがあります

5. 柔軟性と拡張性

  • この構造により、各コンポーネントを独立して変更や拡張することが可能になります
  • 例えば、データソースを変更する場合、Repositoryの実装のみを変更すれば、他のコンポーネントには影響しません
  • 1:N関係(例:ViewModel → UseCase)により、新しい機能や要件を追加する際の柔軟性が高まります
  • 新しいUseCaseを追加するだけで、既存のViewModelに新しい機能を統合できます

6. 再利用性

  • Service、Repository、DataSourceなどの下位レイヤーコンポーネントは、複数のUseCaseから再利用できます
  • これにより、コードの重複を減らし、一貫性を保つことができます

これにより各コンポーネントの責任が明確に分離され、アプリの保守性、テスト容易性、拡張性を確保できます。また、依存性の方向が制御されることで、変更の影響範囲を最小限に抑えることができます。

一見すると、各層の分離が過剰であるという印象を抱くかもしれません。特に小規模なアプリでは実装する処理があまりない層もあると思います。近年オフラインファーストなアプリや複雑なUI制御が必要なアプリなど多様な要求が増えています。そんなとき、それぞれの層をしっかり分離したこのようなアーキテクチャが活きてくると思います。

また、AIコーディングでは責任の分離がしっかり行われていて、参照する必要があるコードやコンテキストが少ない方が品質の高いコードを生成できる傾向があります。層が増えることにより単純なコード量は増えてしまいますが、最終的なコード品質を高めるために必要なことだと考えています。

5. コーディング規約

コーディング規約はチーム内でしっかり決められておらず、メンバーの意識の差がコードに表れやすかったりします。また一人で開発していても、一貫性のあるコードは数ヶ月後の自分のためになります。できるだけ人に依存せずコードの品質を保つためにもある程度は規約を決めておくことをお勧めします。

このセクションでは、一貫性のあるコードベースを維持するための命名規則、ファイル構成、コードスタイルのガイドラインについて、私が採用しているものを紹介します。また、SwiftUIコンポーネントの設計パターンや再利用可能なコンポーネントの作成方法も含まれています。これらの規約に従うことで、可読性の高い保守しやすいコードを実現します。

命名規則

命名は明確でわかりやすく、その目的や役割を反映することが重要です。以下の命名規則を設定することを推奨します。

一般的な命名原則

  • 明確性を優先: 短縮形や曖昧な名前を避け、目的が明確に伝わる名前を使用します
  • Apple標準に準拠: SwiftおよびiOSの標準的な命名慣習に従います
  • 一貫性を保つ: 同様の概念には同様の命名パターンを使用します

型の命名

  • クラス/構造体/列挙型: UpperCamelCase(例: PersonViewController, UserProfile, NetworkError
  • プロトコル: UpperCamelCase、役割を表す名詞または形容詞を使用(例: Equatable, Collection, PersonListViewModel
  • 型エイリアス: UpperCamelCase(例: RequestHandler, CompletionResult

変数・プロパティ・定数の命名

  • 変数/プロパティ: lowerCamelCase(例: userName, emailAddress
  • 静的変数/クラス変数: lowerCamelCase(例: shared, defaultConfiguration
  • プライベート変数: lowerCamelCase、原則アンダースコアなしで始める
  • 列挙型のケース: lowerCamelCase(例: case success, case networkError
  • 定数: lowerCamelCase(例: let maximumRetryCount = 3

関数・メソッドの命名

  • 関数/メソッド: lowerCamelCase、動詞で始める(例: fetchData(), calculateTotal(), didTapSubmitButton()
  • UIイベントハンドラ: 適切な接頭辞を使用(例: didTap..., didSelect..., willAppear...

アーキテクチャ固有の命名

  • ViewModel: 末尾にViewModelを付ける(例: ProfileViewModel, LoginViewModel
  • UseCase/Interactor: 末尾にUseCaseまたはInteractorを付ける(例: FetchUserUseCase, AuthenticationInteractor
  • Repository: 末尾にRepositoryを付ける(例: UserRepository, ProductRepository
  • DataSource: 末尾にDataSourceを付ける(例: RemoteUserDataSource, LocalProductDataSource
  • Router/Wireframe: 末尾にRouterまたはWireframeを付ける(例: ProfileRouter, AuthenticationWireframe

ファイル名

  • ファイル名はその中で定義される主要な型と一致させる(例: UserViewModel.swift, ProductRepository.swift
  • 1つのファイルには基本的に1つの主要な型のみを定義する
  • 関連する拡張は別ファイルに分け、目的を明示する(例: UIView+Layout.swift

ファイル構成

プロジェクトの整理と保守を容易にするために、以下のファイル構成を採用しています。

プロジェクト構造

プロジェクトは機能モジュールごとに整理することで、関連コードの把握や保守が容易になります。

ProjectName/
├── Application/                // アプリ全体に関するファイル
│   ├── AppDelegate.swift
│   ├── SceneDelegate.swift
│   ├── AppCoordinator.swift
│   └── AppConfiguration.swift
├── Presentation/              // プレゼンテーション層
│   ├── Common/                // 共通のUIコンポーネント
│   │   ├── Views/
│   │   └── Extensions/
│   ├── Authentication/        // 機能モジュール1
│   │   ├── Views/
│   │   ├── ViewModels/
│   │   └── Routers/
│   └── Profile/               // 機能モジュール2
│       ├── Views/
│       ├── ViewModels/
│       └── Routers/
├── Domain/                    // ドメイン層
│   ├── Entities/              // ドメインモデル
│   ├── UseCases/              // ユースケース
│   └── Repositories/          // リポジトリインターフェース
├── Infrastructure/            // インフラストラクチャ層
│   ├── Repositories/          // リポジトリ実装
│   ├── DataSources/           // データソース
│   │   ├── Remote/            // API、クラウドサービスなど
│   │   └── Local/             // CoreData、UserDefaultsなど
│   ├── Network/               // ネットワークの共通コンポーネント
│   ├── Database/              // データベースの共通コンポーネント
│   ├── Services/              // 外部サービス連携
│   └── DTOs/                  // データ転送オブジェクト
└── Resources/                 // リソースファイル
    ├── Assets.xcassets
    ├── Localizable.strings
    └── Info.plist

ファイル内の構造

各ファイル内では、可読性と一貫性のために、以下の順序でコードを構成しています。

ViewControllerの例

class ProfileViewController: UIViewController {
    // MARK: - IBOutlets
    
    // MARK: - Properties
    
    // MARK: - Lifecycle Methods
    
    // MARK: - UI Setup
    
    // MARK: - User Actions
    
    // MARK: - Private Methods
}

ViewModelの例

protocol ProfileViewModel: ObservableObject {
    // プロトコル定義
}

class ProfileViewModelImpl: ProfileViewModel {
    // MARK: - Published Properties
    
    // MARK: - Dependencies
    
    // MARK: - Initialization
    
    // MARK: - Public Methods
    
    // MARK: - Private Methods
}

コードスタイル

コード品質と一貫性を維持するため、以下のコードスタイルガイドラインを採用しています。なお、人が書くにしてもAIに任せるにしても、揺れが生じることは避けられないため、SwiftLintで自動チェックするのがおすすめです。そうすることでレビューの手間も省けます。

インデントとフォーマット

  • インデントには4スペースを使用(タブではなく)
  • 各行の長さは120文字以内に制限
  • 開始括弧は同じ行に、終了括弧は新しい行に配置
  • メソッド間には1行の空行を入れる
  • 関連するプロパティやメソッドをグループ化する場合は適切に空行を挿入

コメント

  • できる限りコードから処理内容が理解しやすいコードを書き、必要な場合のみコメントを追加
  • 複雑なロジックや非明示的な意図がある場合は必ずコメントで説明
  • // MARK: - を使用してコードセクションを論理的に分割
  • 公開API(特にプロトコル)にはDocCコメントスタイルを使用
  • ちなみにDocCコメントはXcodeで[⌘]+[⌥]+[/]のショートカットを活用すると簡単に書けます。
/// ユーザー認証を管理するプロトコル
/// - Note: このプロトコルは認証フローの全責任を負います
protocol AuthenticationUseCase {
    /// ユーザーログインを試行します
    /// - Parameters:
    ///   - username: ユーザー名
    ///   - password: パスワード
    /// - Returns: 認証結果を表すResult型
    /// - Throws: 認証プロセス中にエラーが発生した場合
    func login(username: String, password: String) async throws -> AuthResult
}

言語機能の利用

  • 可能な限り、最新のSwift言語機能を活用(例: async/await, Result型, Optionalバインディング)
  • 型推論が明確な場合は利用し、コードを簡潔に保つ
  • force unwrap (!) は避け、適切なオプショナルバインディングやガード文を使用
  • guard文を使用して早期リターンを促進し、ネストを減らす
  • 拡張機能を適切に使用して、関連する機能ごとに型を分割
  • プロパティへのアクセス制御を適切に設定(private, internal, public)

SwiftUIのベストプラクティス

  • 複雑なViewは小さなサブビューに分割
  • 複雑なレイアウトには適切な抽象化を使用(例: ViewBuilder, ChildViews)
  • 再利用可能なコンポーネントは別のファイルに分離
  • PreviewProviderを活用して開発時のフィードバックループを短縮
struct ProfileView: View {
    @ObservedObject var viewModel: ProfileViewModel
    
    var body: some View {
        VStack {
            headerView
            contentView
            if viewModel.isEditing {
                editingControls
            }
        }
    }
    
    // サブビューに分割
    private var headerView: some View {
        // ヘッダービューの実装
    }
    
    private var contentView: some View {
        // コンテンツビューの実装
    }
    
    private var editingControls: some View {
        // 編集コントロールの実装
    }
}

// プレビュー
struct ProfileView_Previews: PreviewProvider {
    static var previews: some View {
        ProfileView(viewModel: MockProfileViewModel())
    }
}

SwiftUIコンポーネント設計のベストプラクティス

再利用可能なコンポーネント設計

  1. ViewModifier パターンの活用:

    // カスタムスタイルをViewModifierとして定義
    struct PrimaryButtonStyle: ViewModifier {
        func body(content: Content) -> some View {
            content
                .padding()
                .background(Color.blue)
                .foregroundColor(.white)
                .cornerRadius(8)
        }
    }
    
    // 拡張メソッドとして提供
    extension View {
        func primaryButtonStyle() -> some View {
            self.modifier(PrimaryButtonStyle())
        }
    }
    
    // 使用例
    Button("保存") {
        viewModel.save()
    }
    .primaryButtonStyle()
    
  2. コンポーネントのパラメータ化:

    struct CustomCard<Content: View>: View {
        // カスタマイズ可能なパラメータ
        let title: String
        let cornerRadius: CGFloat
        let shadowRadius: CGFloat
        let content: Content
        
        init(
            title: String,
            cornerRadius: CGFloat = 8,
            shadowRadius: CGFloat = 4,
            @ViewBuilder content: () -> Content
        ) {
            self.title = title
            self.cornerRadius = cornerRadius
            self.shadowRadius = shadowRadius
            self.content = content()
        }
        
        var body: some View {
            VStack(alignment: .leading) {
                Text(title)
                    .font(.headline)
                
                content
            }
            .padding()
            .background(Color.white)
            .cornerRadius(cornerRadius)
            .shadow(radius: shadowRadius)
        }
    }
    
    // 使用例
    CustomCard(title: "プロフィール情報") {
        VStack {
            Text("名前: \(user.name)")
            Text("メール: \(user.email)")
        }
    }
    

コンポーネントの分割戦略

ビューの複雑さを管理するため、以下の原則を採用しています。

  1. 単一責任の原則:

    • 各ビューコンポーネントは1つの明確な責任を持つべき
    • 50行以上のビューは分割を検討する
  2. 階層的な分割:

    struct ProfileView: View {
        @ObservedObject var viewModel: ProfileViewModel
        
        var body: some View {
            VStack {
                ProfileHeaderView(user: viewModel.user)
                
                if viewModel.isEditing {
                    ProfileEditForm(
                        name: $viewModel.name,
                        email: $viewModel.email,
                        onSave: viewModel.saveProfile
                    )
                } else {
                    ProfileInfoView(user: viewModel.user)
                }
                
                ProfileActionButtons(
                    isEditing: $viewModel.isEditing,
                    onEdit: viewModel.startEditing,
                    onLogout: viewModel.logout
                )
            }
        }
    }
    
  3. カスタムコンテナコンポーネント:

    struct ContentSection<Content: View>: View {
        let title: String
        let systemImage: String
        let content: Content
        
        init(
            title: String,
            systemImage: String,
            @ViewBuilder content: () -> Content
        ) {
            self.title = title
            self.systemImage = systemImage
            self.content = content()
        }
        
        var body: some View {
            VStack(alignment: .leading, spacing: 12) {
                HStack {
                    Image(systemName: systemImage)
                    Text(title).font(.headline)
                }
                
                content
                    .padding(.leading, 8)
            }
            .padding()
            .background(Color(.systemGray6))
            .cornerRadius(8)
        }
    }
    
    // 使用例
    ContentSection(title: "連絡先", systemImage: "person.crop.circle") {
        VStack(alignment: .leading) {
            Text("電話: \(user.phone)")
            Text("メール: \(user.email)")
        }
    }
    

プレビューの効果的な活用

SwiftUI Previewは大幅な開発効率アップにつながります。Viewに状態がある場合は、それぞれの状態に応じたプレビューを用意するのがおすすめです。

struct UserProfileView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            // 通常状態
            UserProfileView(viewModel: PreviewProfileViewModel(state: .loaded))
                .previewDisplayName("通常状態")
            
            // 読み込み中
            UserProfileView(viewModel: PreviewProfileViewModel(state: .loading))
                .previewDisplayName("読み込み中")
            
            // エラー状態
            UserProfileView(viewModel: PreviewProfileViewModel(state: .error))
                .previewDisplayName("エラー状態")
            
            // ダークモード
            UserProfileView(viewModel: PreviewProfileViewModel(state: .loaded))
                .preferredColorScheme(.dark)
                .previewDisplayName("ダークモード")
            
            // 異なるデバイス
            UserProfileView(viewModel: PreviewProfileViewModel(state: .loaded))
                .previewDevice("iPhone SE (3rd generation)")
                .previewDisplayName("iPhone SE")
        }
    }
}

// プレビュー用のモックViewModel
class PreviewProfileViewModel: ProfileViewModel {
    enum PreviewState {
        case loading, loaded, error
    }
    
    @Published var isLoading: Bool
    @Published var user: User?
    @Published var errorMessage: String?
    
    init(state: PreviewState) {
        switch state {
        case .loading:
            self.isLoading = true
            self.user = nil
            self.errorMessage = nil
        case .loaded:
            self.isLoading = false
            self.user = User(id: "1", name: "山田太郎", email: "yamada@example.com")
            self.errorMessage = nil
        case .error:
            self.isLoading = false
            self.user = nil
            self.errorMessage = "ユーザー情報の読み込みに失敗しました"
        }
    }
    
    // プレビュー用のダミー実装
    func loadProfile() {}
    func saveProfile() {}
    func logout() {}
}

これらのベストプラクティスを適用することで、保守性が高く、再利用可能なSwiftUIコンポーネントを設計できます。

コーディング規約を維持するためのヒント

規約を定義するのは簡単ですが、長期間維持するのはなかなか大変です。以下のようなことを心がけて負担にならないコーディング環境を作っていきたいですね。

  • 自動化できるものは自動化する - どんどんSwiftLint、git hooksなどのツールを使いましょう!
  • 規約の「なぜ」を共有する - 単なるルールではなく、背後にある理由を理解する
  • 定期的な見直し - 開発環境が変わる時や新規開発時にはSwiftLintのルールを精査する
  • 柔軟性を保つ - プロジェクトの成長に合わせて規約も進化させる

コーディング規約は開発者の創造性を制限するためではなく、チーム全体の生産性と品質を向上させるためのものだと思っています。最終的には、「コードを書く人」ではなく「コードを読む人」のためのものだという視点が重要です。今日コードを書いたあなた自身も明日からはそのコードを読む人になります。

6. 実装のベストプラクティス

このセクションでは、非同期処理、エラーハンドリング、状態管理、依存性注入に関する具体的な実装手法を書いています。Swift Concurrencyの活用方法、レイヤー間のエラー伝播、SwiftUIにおける状態管理のアプローチなど、品質の高いコードを書くためのプラクティスを紹介します。

非同期処理のアプローチ

現代のアプリでは、ネットワークリクエスト、データベース操作、重い計算処理など、多くの非同期処理が必要です。私のプロジェクトでは、Swift Concurrencyを主要な非同期処理メカニズムとして採用しています。

Swift Concurrencyの使用

  1. 基本原則:

    • 非同期操作にはasync/awaitを使用し、コールバックやCompletionハンドラーを避ける
    • 長時間実行される処理や並行処理にはTaskを使用
    • UI更新は常に@MainActorでラップし、スレッドの安全性を確保
  2. レイヤー間の非同期処理:

    // UseCase層での実装
    protocol UserUseCase {
        func fetchUserProfile(userId: String) async throws -> UserProfile
    }
    
    // Repository層での実装
    protocol UserRepository {
        func getUser(id: String) async throws -> User
    }
    
    // DataSource層での実装
    class RemoteUserDataSource {
        func fetchUser(id: String) async throws -> UserDTO {
            let url = URL(string: "https://api.example.com/users/\(id)")!
            let (data, response) = try await URLSession.shared.data(from: url)
            
            guard let httpResponse = response as? HTTPURLResponse,
                  httpResponse.statusCode == 200 else {
                throw NetworkError.invalidResponse
            }
            
            return try JSONDecoder().decode(UserDTO.self, from: data)
        }
    }
    
  3. タスクのキャンセル:

    class ProfileViewModel: ObservableObject {
        private var profileTask: Task<Void, Never>?
        
        func onAppear() {
            // 既存のタスクをキャンセル
            profileTask?.cancel()
            
            // 新しいタスクを開始
            profileTask = Task {
                do {
                    let profile = try await userUseCase.fetchUserProfile(userId: currentUserId)
                    // タスクがキャンセルされていないことを確認
                    if !Task.isCancelled {
                        await MainActor.run {
                            self.userProfile = profile
                        }
                    }
                } catch {
                    if !Task.isCancelled {
                        await MainActor.run {
                            self.error = error.localizedDescription
                        }
                    }
                }
            }
        }
        
        func onDisappear() {
            // 画面から離れるときにタスクをキャンセル
            profileTask?.cancel()
        }
    }
    
  4. Combineとの使い分け:

    • @Publishedプロパティとの連携や宣言的なデータフローが必要な場合はCombineも使用します
    • ただし複雑なネットワークリクエストやデータベース操作にはasync/awaitを優先します
    class SearchViewModel: ObservableObject {
        @Published var searchQuery = ""
        @Published var results: [SearchResult] = []
        
        private var cancellables = Set<AnyCancellable>()
        
        init(searchUseCase: SearchUseCase) {
            // 検索クエリの変更を監視し、結果を取得
            $searchQuery
                .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
                .removeDuplicates()
                .filter { !$0.isEmpty }
                .sink { [weak self] query in
                    self?.performSearch(query: query)
                }
                .store(in: &cancellables)
        }
        
        private func performSearch(query: String) {
            Task {
                do {
                    let searchResults = try await searchUseCase.search(query: query)
                    await MainActor.run {
                        self.results = searchResults
                    }
                } catch {
                    // エラー処理
                }
            }
        }
    }
    
  5. Actor使用のガイドライン:

    • 共有状態を保護するにはactorを使用します
    • UseCaseの実装にはactorを使用することを推奨します
    actor UserInteractor: UserUseCase {
        private let repository: UserRepository
        
        init(repository: UserRepository) {
            self.repository = repository
        }
        
        func fetchUserProfile(userId: String) async throws -> UserProfile {
            let user = try await repository.getUser(id: userId)
            let preferences = try await repository.getUserPreferences(userId: userId)
            return UserProfile(user: user, preferences: preferences)
        }
    }
    

エラーハンドリング戦略

エラーハンドリングは堅牢なアプリの鍵です。エラーには不具合の原因調査の材料としての役割と、ユーザーに対する情報提供の2つの役割があります。各種フレームワークでのエラーには多種多様なものがありますが、ユーザーにそのまま表示するには適さないものも多いです。内部処理で発生したエラーはログに出力するなどして、ユーザーに表示するまでにアプリ実装で管理できる独自のエラー型に変換するようにしています。

内部用エラーと表示用エラーをそれぞれ定義して、層ごとに変換するアプローチもいいかもしれません。どの程度変換の層を作るかは、アプリの規模や発生するエラーの種類によって変わってくると思います。

エラー型の設計

  1. エラーの定義
    アプリ内で発生するエラーは全て、最終的にはAppErrorに準拠した形になるようにエラーを定義します。例えばURLSessionで発生するエラー、Firebaseの認証で発生するエラーなど、原因を個別に特定したいエラーとユーザーに通知したいエラーはここで全て定義します。
// アプリ全体で使用される一般的なエラー型
protocol AppError: Error {
    case unknown
}

enum AppNetworkError: AppError {
    case offline
    case timeout
    case unknown
}

extension AppNetworkError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .offline:
        	// 実際にはLocalizedStringを使用する
            return "インターネット接続がありません"
        case .timeout:
            return "リクエストがタイムアウトしました"
        case .unknown:
            return "不明なエラーが発生しました"
        }
    }
}

enum AppAuthError: AppError {
    case sessionExpired
    case invalidCredentials
}

extension AppAuthError: LocalizedError {
    var errorDescription: String? {
        case .sessionExpired:
            return "セッションが期限切れです。再ログインしてください"
        case .invalidCredentials:
            return "認証に失敗しました"
    }
}
  1. エラー変換のパターン
    アプリで定義した以外のエラーが発生する箇所では、できるだけ速やかにアプリ独自のエラーに変換します。
    生のエラー情報が必要な場合はここでログ出力などを行います。
class UserRepositoryImpl: UserRepository {
    private let remoteDataSource: RemoteUserDataSource
   
    init(remoteDataSource: RemoteUserDataSource) {
       self.remoteDataSource = remoteDataSource
    }
   
    func getUser(id: String) async throws -> User {
       do {
            let userDTO = try await remoteDataSource.fetchUser(id: id)
           return userDTO.toDomain()
       } catch let error as NSError {
           switch error.code {
            case NSURLErrorNotConnectedToInternet:
               throw AppNetworkError.offline
           case NSURLErrorTimedOut:
               throw AppNetworkError.timeout
           default:
               throw AppNetworkError.unknown
           }
        } catch {
           throw AppError.unknown
       }
   }
}

エラーハンドリングの実装

  1. UseCase層でエラーを変換

    actor ProfileInteractor: ProfileUseCase {
        private let userRepository: UserRepository
        
        init(userRepository: UserRepository) {
            self.userRepository = userRepository
        }
        
        func getUserProfile(userId: String) async throws -> UserProfile {
            do {
                let user = try await userRepository.getUser(id: userId)
                return UserProfile(user: user)
            } catch let appError as AppError {
                // アプリ固有のエラーはそのまま伝播
                throw appError
            } catch let authError as AuthenticationError {
                // 認証エラーを適切なAppErrorに変換
                switch authError {
                case .sessionExpired:
                    throw AppAuthError.sessionExpired
                case .invalidCredentials:
                    throw AppAuthError.invalidCredentials
                }
            } catch {
                // 未知のエラーを一般的なエラーに変換
                throw AppError.unexpectedError(description: error.localizedDescription)
            }
        }
    }
    
  2. ViewModel層でのエラー表示

    @MainActor
    class ProfileViewModel: ObservableObject {
        @Published var profile: UserProfile?
        @Published var isLoading = false
        @Published var errorMessage: String?
        @Published var showErrorAlert = false
        
        private let useCase: ProfileUseCase
        
        init(useCase: ProfileUseCase) {
            self.useCase = useCase
        }
        
        func loadProfile(userId: String) {
            isLoading = true
            errorMessage = nil
            
            Task {
                do {
                    profile = try await useCase.getUserProfile(userId: userId)
                } catch let appError as AppError {
                    errorMessage = appError.localizedDescription
                    showErrorAlert = true
                } catch {
                    errorMessage = "予期せぬエラーが発生しました"
                    showErrorAlert = true
                }
                
                isLoading = false
            }
        }
    }
    
  3. View層でのエラー表示

    struct ProfileView: View {
        @ObservedObject var viewModel: ProfileViewModel
        
        var body: some View {
            VStack {
                if viewModel.isLoading {
                    ProgressView()
                } else if let profile = viewModel.profile {
                    ProfileContent(profile: profile)
                } else {
                    Text("プロフィール情報を読み込めません")
                }
            }
            .alert("エラー", isPresented: $viewModel.showErrorAlert) {
                Button("OK", role: .cancel) {}
            } message: {
                Text(viewModel.errorMessage ?? "")
            }
            .onAppear {
                viewModel.loadProfile(userId: "current_user")
            }
        }
    }
    

状態管理

SwiftUIとの統合におけるUI状態の管理は、アプリの応答性と信頼性に直接影響します。状態管理は以下のようにしています。

ViewModelにおける状態管理

  1. 基本的な状態プロパティ

ViewModelをObservableObjectとして定義し、その実装クラスでは各プロパティを@Publishedなプロパティとして定義します。

@MainActor
protocol ContentViewModel: ObservableObject {
    // UI状態
    var isLoading: Bool { get }
    var showError: Bool { get }
    var errorMessage: String? { get }
   
    // ドメインデータ
    var items: [ItemModel] { get }
    var selectedItemId: String? { get }
}

@MainActor
class ContentViewModelImpl: ContentViewModel {
   // UI状態
   @Published var isLoading = false
   @Published var showError = false
   @Published var errorMessage: String?
   
   // ドメインデータ
   @Published var items: [ItemModel] = []
   @Published var selectedItemId: String?
}
  1. 状態の派生プロパティ

計算プロパティがViewに表示する状態となる場合もあります。

// Protocolは省略...

@MainActor
class ItemListViewModelImpl: ObservableObject {
    @Published var items: [Item] = []
    @Published var searchQuery = ""
    @Published var isLoading = false

    // 検索クエリに基づいてフィルタリングされたアイテムを計算
    var filteredItems: [Item] {
        guard !searchQuery.isEmpty else { return items }
        return items.filter { $0.title.localizedCaseInsensitiveContains(searchQuery) }
    }
   
    // UIの状態を決定する派生プロパティ
    var isEmpty: Bool { items.isEmpty && !isLoading }
    var showNoResults: Bool { !searchQuery.isEmpty && filteredItems.isEmpty && !isLoading }
}
  1. 複雑な状態のカプセル化:

状態のロジックが複雑になる場合はViewModelから分離して独自の型を作ります。

// 複雑な状態を表す構造体
struct CheckoutState {
    var deliveryAddress: Address?
    var paymentMethod: PaymentMethod?
    var items: [CartItem]
    var promoCode: String?
    var isPromoCodeValid: Bool = false
    
    var canProceedToPayment: Bool {
        return deliveryAddress != nil
    }
    
    var canCompleteOrder: Bool {
        return deliveryAddress != nil && 
               paymentMethod != nil && 
               !items.isEmpty
    }
    
    var totalAmount: Decimal {
        var total = items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
        if isPromoCodeValid {
            total = total * 0.9 // 10%割引
        }
        return total
    }
}

// この状態をViewModelで使用
class CheckoutViewModel: ObservableObject {
    @Published var state: CheckoutState
    
    init(initialItems: [CartItem]) {
        self.state = CheckoutState(items: initialItems)
    }
    
    func updateDeliveryAddress(_ address: Address) {
        state.deliveryAddress = address
    }
    
    // その他のメソッド...
}

依存性注入の方針

依存性注入は、コードの結合度を低くし、テスト容易性を向上させる重要な設計パターンです。私のプロジェクトでは以下の依存性注入アプローチを採用しています。

依存性注入の基本原則

  1. コンストラクタインジェクション:

    • 依存オブジェクトはコンストラクタを通じて提供します
    • これにより依存関係が明示的になり、オブジェクト生成時に必要な依存がすべて揃います
    class UserRepositoryImpl: UserRepository {
        private let remoteDataSource: RemoteUserDataSource
        private let localDataSource: LocalUserDataSource
        
        init(remoteDataSource: RemoteUserDataSource, localDataSource: LocalUserDataSource) {
            self.remoteDataSource = remoteDataSource
            self.localDataSource = localDataSource
        }
        
        // メソッド実装...
    }
    
  2. プロトコル指向設計

    • 具体的な実装ではなく、プロトコル(インターフェース)に依存します
    • これにより、実装の詳細から分離され、テスト時にモックオブジェクトで置き換えやすくなります
    protocol UserRepository {
        func getUser(id: String) async throws -> User
        func saveUser(_ user: User) async throws
    }
    
    // ViewModel: 具体的な実装ではなくプロトコルに依存
    class ProfileViewModel {
        private let userRepository: UserRepository
        
        init(userRepository: UserRepository) {
            self.userRepository = userRepository
        }
        
        // メソッド実装...
    }
    

依存性注入の実装

  1. Router/Coordinatorによる注入
    • 各画面の依存関係はRouter(またはCoordinator)で組み立てます
    • 依存の解決は遷移先のRouterのassembleModulesメソッドで行い、ViewControllerを返します
    • 生成されたViewControllerを用いて、遷移元のRouterから画面遷移を行います
@MainActor
class ProfileRouter {
    private unowned let viewController: UIViewController
    
    init(viewController: UIViewController) {
        self.viewController = viewController
    }
    
    static func assembleModules(for userId: String) -> UIViewController {
        // データソースの生成
        let remoteDataSource = RemoteUserDataSourceImpl(apiClient: ApiClient.shared)
        let localDataSource = LocalUserDataSourceImpl(database: Database.shared)
        
        // リポジトリの生成
        let userRepository = UserRepositoryImpl(
            remoteDataSource: remoteDataSource,
            localDataSource: localDataSource
        )
        
        // UseCaseの生成
        let profileUseCase = ProfileInteractor(userRepository: userRepository)
        
        // ViewControllerの生成
        let viewController = ProfileViewController<viewModel: ProfileViewModelImpl>()
         // ViewModelの生成
        let router = ProfileRouter(viewController: viewController)
        let viewModel = ProfileViewModelImpl(
            router: router,
            viewController: viewController,
            profileUseCase: profileUseCase
        )
        
        viewController.viewModel = viewModel
        return viewController
    }
}

@MainActor
class HomeRouter {
    private unowned let viewController: UIViewController
    
    init(viewController: UIViewControllerController) {
        self.viewController = viewController
    }
     func showProfile(for userId: String) {
        // 遷移先のViewControllerを生成
        ProfileRouter.assembleModules(for: userId)
        // 画面遷移(Push遷移の場合)
        viewController.navigationController?.pushViewController(viewController, animated: true)
    }
}   
  1. ファクトリクラスによる依存関係の構築:

依存関係が複雑な場合は構築をファクトリとして独立させるのも有効です。

enum ProfileModuleFactory {
    static func create(userId: String) -> UIViewController {
        let viewController = ProfileViewController<ProfileViewModelImpl>()
        let router = ProfileRouter(viewController: viewController)
        let useCase = makeProfileUseCase(userId: userId)
        let viewModel = Impl(router: router, useCase: useCase)
        viewController.viewModel = viewModel
        return viewModel
    }

    private static func makeUserRepository() -> UserRepository {
        let apiClient = ApiClient.shared
        let database = Database.shared
        
        let remoteDataSource = RemoteUserDataSourceImpl(apiClient: apiClient)
        let localDataSource = LocalUserDataSourceImpl(database: database)
        
        return UserRepositoryImpl(
            remoteDataSource: remoteDataSource,
            localDataSource: localDataSource
        )
    }
    
    private static func makeProfileUseCase(userId: String) -> ProfileUseCase {
        let repository = makeUserRepository()
        return ProfileInteractor(userId: String, userRepository: repository)
    }
}

// 使用例
@MainActor
class UserRouter {
    // ...
    
    func showProfile(for userId: String) {
        let viewController = ProfileModuleFactory.create(userId: userId)
        navigationController.pushViewController(viewController, animated: true)
    }
}   

まとめと次のステップ

ここまでで、Clean ArchitectureベースのiOSアプリの基本的な設計と実装方法について説明しました。アーキテクチャの概要から始まり、各レイヤーとコンポーネントの詳細、コーディング規約、そして実装のベストプラクティスまでを解説しました。

これらの知識を活用することで、保守性と拡張性の高いiOSアプリの基盤を構築の手助けになればと思います。次のパートでは、この基盤をさらに強化するためのテスト戦略、セキュリティとパフォーマンスの最適化、プロジェクト管理のベストプラクティス、そして既存アプリのマイグレーション戦略について解説します。

Discussion