【Swift/iOS】VIPERアーキテクチャの実装方法
この記事からわかること
- Swift/iOSアプリの設計
- VIPERアーキテクチャとは?
index
[open]
\ アプリをリリースしました /
環境
- Xcode:26.0.1
- iOS:26
- Swift:6
- macOS:Tahoe 26.0.1
前段
この記事を書くにあたって以下の参考書が非常に参考になりましたので紹介しておきます。
中身がどのようなものか知りたい場合は以下の記事も参考にしてください。
VIPERとは?
「VIPER」はiOSアプリで用いられるアーキテクチャの1種で「Clean Architecture」の思想をiOSアプリ向けに具体化したアーキテクチャパターンになっています。Clean Architectureはビジネスロジック(Entity)と変更が発生しやすいUIやDBなどを完全に切り出すことで、ビジネスロジックに変化が及ばないようにするための思想です。詳細についてはこちらの記事を参考にしてください。
VIPERは以下の5つの頭文字を合わせた名前になっています。中身はClean Architectureに準じたものに紐づいています。
- V = View
- I = Interactor(UseCase層)
- P = Presenter(Interface Adapter層)
- E = Entity(Domain層)
- R = Router(Navigation / DI)
VIPERはUIKit自体の開発で活躍したアーキテクチャになっています。ネットを探してみるとSwift UIでも使っている人もいましたが、Swift UIがだいぶ広がってきたのもあり最近はあまり見かけることが少なくなってきました。なので今回のVIPERの紹介もUIKitベースで紹介していこうと思います。Swift UIならTCAあたりが今はHotなのかもしれないです。
VIPERの使い方
VIPERを実際に使用して「Qiitaの記事を取得・表示するアプリ」を作成したいと思います。VIPER自体は割と大規模なプロジェクト向けのアーキテクチャのため少しオーバーエンジニアリングですが、それぞれの役割や使い方を理解するのを目的として進めていきます。
GitHubに全体のコードも上げているので参考にしてください。
おすすめ記事:GitHub:QiitaVIPERApp
Entity(Domain層)
まずは「Entity(Domain層)」です。ここで定義するのはドメインモデル(アプリ内で扱われるモデル)になります。ドメインモデルは外部のサービスに依存していないオブジェクトとして定義します。そのためQiitaのAPIから取得できるJSONをマッピングするクラスとは別のもので、そちらはDTOとして別の層で定義します。
今回は確実にURLにキャストされたデータも保持することをドメインに含めるため以下のような「Value Class」(QiitaURL)も定義しておきます。
/// ドメインモデル
/// DTOから変換され、アプリ名で扱われるモデル
struct QiitaArticle: Identifiable {
let id: String
let title: String
let body: String
let url: QiitaURL
}
/// ドメインモデルが持つURLオブジェクト
/// 必ずURL形式で存在することができる想定なのでURL型への変換はDTO => Entityで行う
struct QiitaURL {
let value: URL
init?(rawValue: String) {
guard let url = URL(string: rawValue) else { return nil }
self.value = url
}
}
Interactor(UseCase層)
「Interactor(UseCase層)」は結構やることが多いです。
- DTOの定義
- Mapperの実装
- DataSourceの実装
- Repositoryの実装
- Interactorの実装
1.DTOの定義
最初に「DTO(Data Transfer Object)」を定義します。これはQiitaのAPIから取得できるJSONをマッピングするためのクラスになります。このDTOは「Repository」までしか存在が公開されません。Interactor以降(PresenterやViewなど)はEntityに変換されたクラスだけが存在として認識されるようになります。実際に外部サービスなどに依存する部分なので変化がよく発生しやすい場所になります。
/// DTOクラス
/// QiitaのAPIから取得できるJSONがマッピングされるクラス
struct QiitaArticleDTO: Decodable, Identifiable {
let id: String
let title: String
let body: String
let url: String
}
DTOについては以下の記事を参考にしてください。
2.Mapperの実装
「DTO => Entity」に変換するロジックを持った「Mapper」クラスを用意します。別クラスとして切り出しておくことでテストしやすくなります。
/// `DTO → Entity` の変換ロジック
enum QiitaArticleMapper {
static func map(_ dto: QiitaArticleDTO) -> QiitaArticle? {
guard let qiitaURL = QiitaURL(rawValue: dto.url) else { return nil }
return QiitaArticle(
id: dto.id,
title: dto.title,
body: dto.body,
url: qiitaURL,
)
}
}
3.DataSourceの実装
続いて実際にデータを取得してくるための「DataSource」を実装します。ローカルデータベースやAPIなどを用いてデータを取得しDTOに変換します。「DataSource」は「Repository」にラップされるような形になるのでDTO <=> Repositoryだけの仲介役だとイメージすれば良さそうです。
/// APIから実際にデータを取得してDTOに変換するクラス
final class QiitaArticleRemoteDataSourceImpl: QiitaArticleRemoteDataSource {
private let endPoint: URL? = URL(string: "https://qiita.com/api/v2/items")
func fetchArticles() async throws -> [QiitaArticleDTO] {
guard let endPoint else { throw APIError.noEndPoint }
let (data, response) = try await URLSession.shared.data(from: endPoint)
guard let http = response as? HTTPURLResponse,
200..<300 ~= http.statusCode else {
throw APIError.badServerResponse
}
let jsonDecoder = JSONDecoder()
guard let articles = try? jsonDecoder.decode([QiitaArticleDTO].self, from: data) else { throw APIError.badJsonFormat }
return articles
}
}
4.Repositoryの実装
「Repository」はデータの取得先を隠蔽し、ドメインモデルの形式で取得できることだけを保証するクラスです。「DTO => Entity」の変換はMapperを用意しているのでロジック部分はそちらに任せます。
/// `データを取得しアプリで使用するドメインモデル` へ切り替える
final class QiitaArticleRepositoryImpl: QiitaArticleRepository {
private let remote: QiitaArticleRemoteDataSource
init(remote: QiitaArticleRemoteDataSource) {
self.remote = remote
}
func fetchArticles() async throws -> [QiitaArticle] {
let dtoList = try await remote.fetchArticles()
return dtoList.compactMap { QiitaArticleMapper.map($0) }
}
}
5.Interactorの実装
「Interactor(UseCase)」はアプリ固有のUseCase実行する層です。「Interactor」は1クラス = 1UseCaseが基本になっています。例えば以下QiitaListInteractorは「Qiita記事一覧を取得する」だけになります。
final class QiitaListInteractor: QiitaListInteractorProtocol {
weak var output: QiitaListInteractorOutput?
private let repository: QiitaArticleRepository
init(repository: QiitaArticleRepository) {
self.repository = repository
}
func fetchArticles() {
Task {
do {
let articles = try await repository.fetchArticles()
await MainActor.run {
self.output?.didFetchArticles(articles)
}
} catch {
await MainActor.run {
self.output?.didFail(error)
}
}
}
}
}
Presenter(Interface Adapter層)
「Presenter(Interface Adapter層)」はViewからのイベントを受けとりInteractorには処理を依頼Routerには画面遷移を依頼するためのクラスです。ユーザー(UI)が起こしたアクションやライフサイクルなどのイベントを受け取り「何を表示するか」・「どの状態を出すか」・「どの画面に遷移するか」などをハンドリングするのが役割になってきます。このクラスはViewModelのように基本的にUIに絡む状態などは保持しないようにするのが大事になります。
/// Viewからのイベントを受けとりInteractorには処理を依頼Routerには画面遷移を依頼するためのクラス
/// ・何を表示するか
/// ・どの状態を出すか
/// ・どの画面に遷移するか
/// UIに絡む状態などは保持しない(MVVM化はさせない)
/// 持たせる状態はフロー制御やキャッシュ等、ドメインに直接絡まないもの
final class QiitaListPresenter: QiitaListPresenterProtocol {
weak var view: QiitaListViewProtocol?
var interactor: QiitaListInteractorProtocol!
var router: QiitaListRouterProtocol!
weak var viewController: UIViewController?
func viewDidLoad() {
interactor.fetchArticles()
}
func didSelectArticle(_ article: QiitaArticle) {
guard let viewController else { return }
router.navigateToWeb(from: viewController, url: article.url.value)
}
}
extension QiitaListPresenter: QiitaListInteractorOutput {
func didFetchArticles(_ articles: [QiitaArticle]) {
DispatchQueue.main.async {
self.view?.showArticles(articles)
}
}
func didFail(_ error: Error) {
DispatchQueue.main.async {
self.view?.showError(error.localizedDescription)
}
}
}
Router(Navigation / DI)
「Router(Navigation / DI)」は画面の生成と画面遷移処理を主に行うクラスです。また画面を生成するために必要な各層のDI(依存性注入)もここで行います。
/// 責務:`画面の生成と画面遷移処理を行うクラス`
final class QiitaListRouter: QiitaListRouterProtocol {
static func assembleModule() -> UIViewController {
let view = QiitaListViewController()
let presenter = QiitaListPresenter()
let dataSouce = QiitaArticleRemoteDataSourceImpl()
let repository = QiitaArticleRepositoryImpl(remote: dataSouce)
let interactor = QiitaListInteractor(repository: repository)
let router = QiitaListRouter()
view.presenter = presenter
presenter.view = view
presenter.interactor = interactor
presenter.router = router
presenter.viewController = view
interactor.output = presenter
return UINavigationController(rootViewController: view)
}
/// WebViewで表示する場合
func navigateToWeb(from view: UIViewController, url: URL) {
let safari = SFSafariViewController(url: url)
view.present(safari, animated: true)
}
/// 詳細画面に飛ばす場合
func navigateToDetail(from view: UIViewController, article: QiitaArticle) {
let detailVC = QiitaDetailRouter.assembleModule(article: article)
view.navigationController?.pushViewController(detailVC, animated: true)
}
}
View
最後に「View」の実装です。今回はUIKitベースでの実装になるので以下のような形になります。
final class QiitaListViewController: UIViewController {
var presenter: QiitaListPresenterProtocol!
private var articles: [QiitaArticle] = []
private lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
tableView.dataSource = self
tableView.delegate = self
return tableView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupLayout()
presenter.viewDidLoad()
}
private func setupLayout() {
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
extension QiitaListViewController: QiitaListViewProtocol {
func showArticles(_ articles: [QiitaArticle]) {
self.articles = articles
tableView.reloadData()
}
func showError(_ message: String) {
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
extension QiitaListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
articles.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = articles[indexPath.row].title
cell.accessoryType = .disclosureIndicator
return cell
}
}
extension QiitaListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
presenter.didSelectArticle(articles[indexPath.row])
}
}
まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。
ご覧いただきありがとうございました。





