iOSアプリのアーキテクチャ MVVM+Clean Architecture
この記事は先日投稿したiOSアプリ開発ガイドのアーキテクチャ部分を抜き出し、少しわかりやすく書き直したものです。
はじめに
iOSアプリのアーキテクチャは現状正解がなく、アーキテクチャにはいつも悩みます。プロジェクトの状況によって採用するアーキテクチャはさまざまだと思います。この記事では比較的流行に影響されず、私自身がよく採用しているアーキテクチャを改めて記事にしてみました。
とあるアプリを開発していた時、当時はコードの保守性よりも最速で動くものを作ることを重視していました。それはそれで目的を果たせたのですが、さまざまな処理をどこに書くか明確に決めきれていない部分があり、改修をするたびにそれが複雑化していく状態でした。このままではチーム開発や将来的な機能追加に影響が出ると思い、徐々にリファクタリングを進めているところです。
その中でガイドをしっかり作った方がいいなと思い作成したものになります。今後はAIにコーディングを任せることも増えるかと思いますが、その際もアーキテクチャや指針があらかじめ決まっていた方がコードの品質が高まると思っています。
最近はTCAなど違う考え方もあり難しいところだと思いますが、既存のプロジェクトでも徐々に導入しやすいことを重視して採用を決めました。あくまで一つの例として見ていただけると助かります。また、私自身まだまだ不勉強なところもあると思いますので、お気軽にフィードバックをもらえると嬉しいです。
アーキテクチャの概要
基本的にはClean Architectureをベースとしつつ、部分的にVIPERやMVVMを取り入れています。また、画面遷移や通知などでUIViewControllerを使いたい場面があるため、Viewを全面的にSwiftUIにはせずUIKitとのハイブリッドにしています。現時点では画面単位でRouterを設計していますが、将来的にCoordinatorパターンへの集約を検討しています。プロジェクトの規模やナビゲーションの複雑性に応じて柔軟に設計を見直す方針です。
各モジュールの構成図

アーキテクチャ選択の背景
具体的なアーキテクチャの紹介に入る前に、このアーキテクチャを採用した背景を説明します。
-
VIPERアーキテクチャ期
これまで単一のアーキテクチャに限定せず、プロジェクトの規模やUI、ロジックの複雑さなどを考慮してその都度検討してきました。iOS開発におけるClean Architectureの実装例であるVIPERを採用することが多かったです。これは責務の明確な分離により、どこに何が書いてあるかわかりやすいこと、変更に強いコードを維持することを目的としていました。 -
SwiftUIを導入
SwiftUIをアプリ開発に取り入れた初期段階では、既存のVIPERアーキテクチャを維持したまま、ViewControllerの上にSwiftUIを配置する形を採用しました。この段階でのデータフローはView <--> ViewController <--> Presenterの経路を維持していました。この方式により、既存のアーキテクチャを大きく変更せずにSwiftUIを部分的に取れ入れられました。 -
ハイブリッドアーキテクチャへの移行
しかし、開発を進めるうちにViewControllerがPresenterとの橋渡しのみを担当する構造は冗長かもしれないと思い始めました。そこでViewControllerとViewの関係を見直し、新たにViewModelとViewをバインディングする方式を採用するに至りました。ただし、NavigationControllerやTabControllerなどUIKitベースの画面遷移を活用するため、ViewControllerは引き続き保持しています。
アーキテクチャの特徴
-
SwiftUIとUIKitのハイブリッド構成
画面遷移や画面自体はUIKitを維持しており、UIViewControllerの上にSwiftUIのViewを貼るような形にしています。これにはいくつか理由があります。
- UIViewControllerのライフサイクルメソッドが方がSwiftUIよりも多く、細かい制御が可能なこと
- 現時点ではSwiftUIのViewからではなくUIViewControllerのモーダルとして使われることを想定しているクラスが存在すること(例えばSFSafariViewControllerなど)
- UINavigationControllerやUITabBarControllerによる画面遷移に慣れていること
ただし、ViewControllerで行う処理がほとんどない場合も多く、ViewControllerの実装メソッドは少ないです。Viewの更新やイベントハンドリングも後述のViewModelとView間で行っています。
-
Presentation層の中心としてViewModelを採用
ViewとModelを直接バインディングしてViewModelを使わない構成も考えましたが、View <----> ViewModel <----> ModelというようにViewModelを間に挟む構成を採用しています。ビジネスロジックとプレゼンテーションロジックの分離をViewModelで行うことで、SwiftUIのプレビュー機能を活用できます。さらに、将来的なObservationフレームワークへの移行を見据えた設計としています。 -
Model以下はClean Architectureの原則を維持
ここまでは一般的なMVVMに近いと思うのですが、VIPERやClean Architectureで便利だった点はそのまま採用しています。MVVMアーキテクチャは基本的にプレゼンテーション層より上位の構成を表しており、ビジネスロジックであるModelについてはあまり細かく規定されていません。Model以下の構成については引き続きClean Architectureを採用するのが妥当との考えです。
各モジュールの詳細
基本的な構成は先ほど説明した通りですが、より具体的に各コンポーネント(View、ViewModel、Router、UseCase、Repository、DataSource)の役割と相互のつながりを説明します。
1. View
ViewはUIの表示とユーザー操作をViewModelに受け渡す役割を持ちます。UIの更新はViewModelのプロパティとバインディングし、ViewModelの状態変化に応じて画面を更新します。ユーザー操作を受け取るとViewModelの対応するメソッドを呼び出し、処理をViewModelに移譲します。
通常、1つのViewに対して1つのViewModelが対応します。ただし、画面の一部など小さなViewにはViewModelはなく、親Viewから値を渡します。Viewは原則として定義と実装を分割せず、実装のみです。1画面は一つのViewControllerとSwiftUIのView構造体で構成されます。
SwiftUIのViewをUIHostingViewControllerを通じて、UIViewControllerのviewのサブビューとして表示します。View及びViewControllerがViewModelの参照を持ちます。
実装例: View
struct SampleView<ViewModel: SampleViewModel>: View {
@StateObject var viewModel: ViewModel
// ジェネリクスを用いてプロトコルに合致したViewModelを受け取ります。
// 本物のViewModelやプレビューに特化したViewModelなど異なるViewModel実装を使うことが可能です。
init(viewModel: ViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
VStack {
// ViewModelのcountプロパティの内容をリアルタイムで表示
Text("\$(viewModel.count)")
Button(action: {
// ユーザー操作をViewModelに伝える
viewModel.backButtonDidTap()
}, label: {
Text("Back")
})
}.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
// Auto LayoutでViewController全体に表示
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を呼び出します。Viewからのイベントを受け取り、UseCaseを呼び出し、その結果に応じて自身のプロパティを更新するのがViewModelの役割です。
また、ユーザー操作によってRouterの画面遷移メソッドを呼ぶ場合もあります。
ViewModelはインターフェースと実装を分離し、Viewはインターフェースにのみ依存します。
実装例
@MainActor
protocol SampleViewModel: ObservableObject {
// MARK: - Published Properties
var persons: [Person] { get }
var errorMessage: String? { get }
// MARK: - Lifecycle Methods
func onAppear()
func onDisappear()
// MARK: - User Actions
func backButtonDidTap()
}
// MARK: - PersonListViewModel Implementation
@MainActor
class PersonListViewModelImpl: PersonListViewModel {
// MARK: - Published Properties
// @Publishedが付いているプロパティはSwiftUIに更新を伝えることが可能
@Published var persons: [Person] = []
@Published var errorMessage: String?
// MARK: - Dependencies
private let router: PersonListWireframe
private let interactor: PersonListUseCase
private let registrationInteractor: RegistrationStateUseCase
// 画面非表示時にTaskをキャンセルするためにTaskを保持する
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 backButtonDidTap() {
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はモジュール間の依存性の管理と画面遷移を行います。自身のモジュールが表示される時に遷移元から呼ばれる役割と、自身のモジュールから他モジュールへの遷移時にViewModelから呼ばれる役割があります。
Router生成時にViewやViewModel、UseCaseなどの実体を生成し、それぞれのモジュールに注入します。
画面遷移先のRouterがViewControllerを生成し、遷移元のRouterで画面遷移を行います。
実装例
@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は表示に依存しないビジネスロジックを実行します。Repositoryなど他のコンポーネントを呼び出しながら、データの取得や管理、計算、ネットワークアクセスなどを行います。UseCaseが行う作業は通常1つの処理に焦点を当てています。例えば特定の種類のデータの取得や加工などです。
UseCaseは、具体的なタスクを実行するために、さらにRepositoryやService、Adapterを呼び出します。データの取得はRepositoryから行います。再利用が想定される計算ロジックや複雑な計算はServiceで実装してUseCaseから呼び出します。外部ライブラリやOS機能に依存する特定の処理はAdapterで行います。
UseCaseの各メソッドはSwift Concurrencyで実装します。エラーが発生する可能性があるメソッド定義にはthrowsを付けます。UseCase以下の層で発生したエラーはアプリで定義したエラー型に変換してViewModel以上の層に伝えます。
実装例
protocol RegistrationStateUseCase {
func fetchUserRegisteredState() async -> UserRegisteredState?
func fetchUserInfo() async -> UserInfo?
}
actor RegistrationStateInteractor {
// Repositoryのインターフェース
let repository: RegistoryRepository
// Repositoryの実装は生成時に注入する
init(repository: RegistrationRepository) {
self.repository = repository
}
}
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)
Repositoryはデータの永続化や取得に関する操作を行います。Repositoryの実装単位はデータの種類ごとに1つです。例えばユーザー情報を扱うRepositoryはUserRepository、ToDoアプリを例にするとToDoRepositoryなどです。通常、1つのUseCaseは1つの特定のRepositoryとやり取りします。これは、UseCaseが特定の種類のデータに焦点を当てているためです。
Repositoryはデータの読み書きに集中し、それ以上のビジネスロジックはUseCaseやServiceが担当します。Repositoryそのものはデータの取得元に依存しないロジックを担当します。つまりRepositoryが直接ネットワークアクセスを行うことはなく、それらの処理はDataSourceの役割です。データの種類やキャッシュ制御などを行い、どのDataSourceから値を取得するかを決定するのはRepositoryです。
6. Repository → DataSource (1:N)
DataSourceは実際にAPIやデータベースとのデータのやりとりを行います。DataSourceはAPIから値を取得するApiDataSourceなど、データの種類よりも取得方法単位で実装します。例えばAPIから情報を取得するDataSourceであればHTTPリクエストを行う処理などを実装します。データベース操作を行うDataSourceであればデータの取得、保存、更新、削除(CRUD操作)を行います。また、取得元に依存した形式からのデータ変換(APIレスポンスやデータベース形式からドメインモデルへの変換)も担当します。
7. UseCase → Service (1:N)
ServiceはUseCaseで実装するには複雑なロジックや再利用可能なロジックなど、特定のビジネスロジックや計算を行います。1つのUseCaseは必要に応じて複数のServiceを利用することができます。例えば料金計算ロジックや複雑な文字列加工などを行います。
8. UseCase → Adapter (1:1)
Adapterは外部システムやライブラリとのインターフェースを実装します。これにより、外部システムやライブラリの具体的な実装を、UseCaseから隠蔽します。例えば音声再生や音声認識など外部機能に依存する機能を実装します。インターフェースをAdaptorInterface、実装をAdaptorとし、UseCaseはインターフェースにのみ依存します。
MVVM Clean Architectureの利点
1. 依存の方向
- 呼び出しの流れは外側のレイヤー(View)から内側のレイヤー(Repository, Service)に向かいます。
- 内側のレイヤーは外側のレイヤーに依存しません。これにより、内側のコンポーネントの再利用性と保守性が向上します。
- ただし、RepositoryImplはそのインターフェースであるRepositoryには依存します。
2. データの流れ
データはRepository/DataSource → UseCase → ViewModel → View の順に流れます。
ユーザーの入力は逆方向に流れ、最終的にRepository/DataSourceでのデータ更新につながることがあります。
3. 責務の分離
UseCaseは通常、Repository、Service、Adapter抽象インターフェースに依存します。各コンポーネントは明確に定義された責任を持ちます。ViewModelはUIの状態管理、UseCaseはビジネスロジック、Repositoryはデータ永続化などです。
4. 柔軟性と拡張性
この構造により、各コンポーネントを独立して変更や拡張することが可能になります。例えば、データの取得先を変更する場合、DataSourceとRepositoryの実装のみを変更すれば、他のコンポーネントには影響しません。
5. 再利用性
Service、Repository、DataSourceなどの下位レイヤーコンポーネントは、複数のUseCaseから再利用できます。UseCaseに様々な実装が集中することがなくなります。
まとめ
以上が私が採用しているiOSアプリのアーキテクチャの紹介です。
一見すると、各層の分離が過剰であるという印象を抱くかもしれません。特に小規模なアプリでは実装する処理があまりない層もあると思います。近年オフラインファーストなアプリや複雑なUI制御が必要なアプリなど多様な要求が増えています。そんなとき、それぞれの層をしっかり分離したこのようなアーキテクチャが活きてくると思います。
また、AIコーディングでは責任の分離がしっかり行われていて、参照する必要があるコードやコンテキストが少ない方が品質の高いコードを生成できる傾向があります。層が増えることにより単純なコード量は増えてしまいますが、最終的なコード品質を高めるために必要なことだと考えています。
Discussion