VIPERアーキテクチャとは?
- 公式 : iOS Project Architecture: Using VIPER
- iOSの開発現場で生まれたアーキテクチャである
-
View
,Interactor
,Presenter
,Entity
,Router
の頭文字を取ってVIPERと呼ぶ- ※それぞれの詳しい役割やサンプルコードは後述
VIPERアーキテクチャの理念
-
単一責任の原則のもと適切に分割すること
- ∴「このクラスは〇〇をするクラスである」と一言で簡単に説明ができる
-
View
,Interactor
,Presenter
,Router
はそれぞれprotocolを切り、それに準拠した実装を行う- 実際にそれぞれを組み合わせる際は、protocolを介してアクセスすること
→protocolにのみ依存している状態にすること - これによりそれぞれの要素の差し替えが容易になり、テストコードが書けるようになる
- 実際にそれぞれを組み合わせる際は、protocolを介してアクセスすること
VIPERそれぞれの役割
関係
ざっくりと処理の流れ
-
Router
で画面を生成し、必要な依存性を注入(以下DI)させる - 1.で生成された画面を表示
-
View
からイベントをPresenter
にお知らせする- ライフサイクル
- ユーザのタップ
- …などなど
-
Presenter
はView
から送られてきたイベントの内容に合わせて以下のような処理を実行する-
View
に対して画面の更新依頼を投げる-
View
は依頼された通りに画面の更新をする
-
-
Interactor
に対してデータの取得依頼を投げる-
Interactor
は依頼されたデータ取得が完了したらPresenter
に通知する
-
-
Router
に対して画面遷移の依頼を投げる-
Router
は依頼された画面へ遷移する → 1. へ戻る
-
-
命名
役割 | プロトコル名 | 実体名 |
---|---|---|
View |
{ModuleName}View |
{ModuleName}ViewController |
Presenter |
{ModuleName}Presentation |
{ModuleName}Presenter |
Router |
{ModuleName}Wireframe |
{ModuleName}Router |
Interactor |
{UsecaseName}Usecase ※1 |
{UsecaseName}Interactor ※1 |
Entity |
- ※2 |
{~~}Entity ※3 |
※1:Interactorは利用シーン(ユースケース)に応じてクラス分割するため、画面と1:1ではない
※2:Entityはテストの際に差し替える必要がないほどシンプルな構造なのでプロトコル不要
※3:1つのモジュールで複数のEntityを扱うこともあるため
それぞれの役割の詳細
以下ではサンプルコードを元に説明
GitHubのAPIをたたいてリポジトリの一覧を取得し表示する
Router
- 「画面遷移」と「DI」を担当
- VIPERアーキテクチャの肝であり、他の有名アーキテクチャにないところ
- 他アーキテクチャでは
View
に画面遷移の処理もお願いする必要があり、View
が「画面の更新」と「画面遷移」の2つを担当する必要があった
→View
のコードの見通しが悪くなりがちだった - VIPERでは「画面遷移」の処理を
Router
に移管したことでView
の責務を減らせ、可読性の向上が望める
- 他アーキテクチャでは
RepositorySearchResultRouter.swift
import UIKit
protocol RepositorySearchResultWireframe: AnyObject {
func showRepositoryDetail(_ repository: RepositoryEntity)
}
final class RepositorySearchResultRouter {
// 画面遷移のためにViewControllerが必要。initで受け取る
private unowned let viewController: UIViewController
private init(viewController: UIViewController) {
self.viewController = viewController
}
// DI
static func assembleModules() -> UIViewController {
let view = RepositorySearchResultViewController()
let router = RepositorySearchResultRouter(viewController: view)
let searchRepositoryInteractor = SearchRepositoryInteractor()
// PresenterはView, Interactor, Routerそれぞれ必要なので
// 生成し、initの引数で渡す
let presenter = RepositorySearchResultPresenter(
view: view,
router: router,
searchRepositoryInteractor: searchRepositoryInteractor
)
view.presenter = presenter // ViewにPresenterを設定
return view
}
}
// Routerのプロトコルに準拠する
// 遷移する各画面ごとにメソッドを定義
extension RepositorySearchResultRouter: RepositorySearchResultWireframe {
func showRepositoryDetail(_ repository: RepositoryEntity) {
// 詳細画面のRouterに依存関係の解決を依頼
let detailView = RepositoryDetailRouter.assembleModules(repository: repository)
// 詳細画面に遷移
// ここで、init時に受け取ったViewControllerを使う
viewController.navigationController?.pushViewController(detailView, animated: true)
}
}
View
- 「画面の更新」と「
Presenter
へのイベント通知」を担当 - 「画面の更新」
- ラベルの文言変更
-
UITableView
のリロード - …などなど
- 「
Presenter
へのイベント通知」- ライフサイクル(
viewDidLoad()
,viewWillAppear()
…など) - ボタンのタップ、セルのタップなど
- ライフサイクル(
-
UIView
,UIViewController
が該当- 1ViewControllerに対して1protocolを切る
RepositorySearchResultViewController.swift
import UIKit
protocol RepositorySearchResultView: AnyObject {
func updateRepositories(_ repositories: [RepositoryEntity])
func showErrorAlert()
}
class RepositorySearchResultViewController: UIViewController {
// Presenterへのアクセスはprotocolを介して行う
var presenter: RepositoryListViewPresentation!
private var repositories: [RepositoryEntity] = [] {
didSet {
DispatchQueue.main.async {
self.tableView.reloadData() // 画面の更新
}
}
}
}
// Viewのプロトコルに準拠する
extension RepositoryListViewController: RepositoryListView {
func updateRepositories(_ repositories: [RepositoryEntity]) {
self.repositories = repositories
}
}
extension RepositorySearchResultViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let text = searchBar.text else { return }
// Presenterにイベント通知
presenter.searchButtonDidPush(searchText: text)
searchBar.resignFirstResponder()
}
}
Presenter
-
View
から受け取ったイベントを元に別のクラスに依頼する-
View
に対して画面の更新を依頼する -
Interactor
に対してデータの取得を依頼する -
Router
に対して画面遷移を依頼する
-
-
Presenter
が提供するメソッド名は
「画面の更新が終わった(viewDidLoad
)」「ボタンが押された(hogeButtonDidPush
)」
といった命名にすること- ×「(ボタンが押されたので)詳細画面に遷移する(
showDetailView
)」
- ×「(ボタンが押されたので)詳細画面に遷移する(
-
Presenter
には状態を持たせない (ハブに徹して、依頼する)- 画面表示に必要な状態は
View
に持たせるなど、適切な場所で状態管理をすべき -
Presenter
に状態を持たせるとPresenter
のテストコードが書きづらくなる
- 画面表示に必要な状態は
-
import UIKit
禁止- UIがどうなっているかを気にしない
RepositorySearchResultPresenter.swift
import Foundation
protocol RepositorySearchResultPresentation: AnyObject {
func searchButtonDidPush(searchText: String)
func didSelect(repository: RepositoryEntity)
}
class RepositorySearchResultPresenter {
// View, Interactor, Routerへのアクセスはprotocolを介して行う
// Viewは循環参照にならないよう`weak`プロパティ
private weak var view: RepositoryListView?
private let router: RepositoryListWireframe
private let searchRepositoryInteractor: SearchRepositoryUsecase
init(view: RepositorySearchResultView,
router: RepositorySearchResultWireframe,
searchRepositoryInteractor: SearchRepositoryUsecase) {
self.view = view
self.router = router
self.searchRepositoryInteractor = searchRepositoryInteractor
}
}
// Presenterのプロトコルに準拠する
extension RepositorySearchResultPresenter: RepositorySearchResultPresentation {
func searchButtonDidPush(searchText: String) {
guard !searchText.isEmpty else { return }
// Interactorにデータ取得処理を依頼
// `@escaping`がついているクロージャの場合は循環参照にならないよう`[weak self]`でキャプチャ
searchRepositoryInteractor.fetchRepositories(keyword: searchText) { [weak self] result in
switch result {
case .success(let repositories):
self?.view?.updateRepositories(repositories)
case .failure:
self?.view?.showErrorAlert()
}
}
}
func didSelect(repository: RepositoryEntity) {
router.showRepositoryDetail(repository)
}
}
Interactor
- 「ビジネスロジック」を担当
-
Presenter
から依頼されたビジネスロジックを実施し、結果を返す- 非同期で結果を返すのがおすすめ (UnitテストのためのMock作成が楽になる)
- クロージャ
- Delegate
- Combine, RxSwiftなどのリアクティブプログラミングフレームワーク
- 循環参照にならないよう実装注意
- 非同期で結果を返すのがおすすめ (UnitテストのためのMock作成が楽になる)
-
import UIKit
禁止- UIがどうなっているかを気にしない
SearchRepositoryInteractor.swift
import Foundation
protocol SearchRepositoryUsecase: AnyObject {
func fetchRepositories(keyword: String,
completion: @escaping (Result<[RepositoryEntity], Error>) -> Void)
}
final class SearchRepositoryInteractor {
// GitHubに問い合わせるためのAPIクライアント
// Interactorのテスト時にAPIクライアントをMockに差し替えて任意のレスポンスを返すようにするため
private let client: GitHubRequestable
init(client: GitHubRequestable = GitHubClient()) {
self.client = client
}
}
// Interactorのプロトコルに準拠する
extension SearchRepositoryInteractor: SearchRepositoryUsecase {
func fetchRepositories(keyword: String,
completion: @escaping (Result<[RepositoryEntity], Error>) -> Void) {
let request = GitHubAPI.SearchRepositories(keyword: keyword)
client.send(request: request) { result in
completion(result.map { $0.items })
}
}
}
Entity
- 「データ構造の定義」を担当
- structでデータ構造を定義する
-
import UIKit
禁止- UIがどうなっているかを気にしない
- 基本的にプロパティとイニシャライザのみ定義し、ロジックを持たないようにする
RepositoryEntity.swift
import Foundation
struct RepositoryEntity: Decodable {
let id: Int
let name: String
let fullName: String
let htmlURL: URL
let starCount: Int
let owner: UserEntity
enum CodingKeys: String, CodingKey {
case id
case name
case fullName = "full_name"
case htmlURL = "html_url"
case starCount = "stargazers_count"
case owner
}
}
ディレクトリ構造
├── Application
│ ├── Base.lproj
│ │ └── LaunchScreen.storyboard
│ ├── Delegate
│ │ ├── AppDelegate.swift
│ │ ├── Presenter
│ │ │ └── AppPresenter.swift
│ │ └── Router
│ │ └── AppRouter.swift
│ └── Info.plist
│
├── Modules
│ ├── RepositoryDetail
│ │ ├── Presenter
│ │ │ └── RepositoryDetailViewPresenter.swift
│ │ ├── Router
│ │ │ └── RepositoryDetailRouter.swift
│ │ └── View
│ │ ├── RepositoryDetailViewController.swift
│ │ └── RepositoryDetailViewController.xib
│ └── RepositorySearchResult
│ ├── Presenter
│ │ ├── RepositorySearchResultPresenter.swift
│ │ └── RepositorySearchResultPresenterTests.swift
│ ├── Router
│ │ └── RepositorySearchResultRouter.swift
│ └── View
│ ├── RepositorySearchResultViewController.swift
│ └── SubView
│ ├── RepositoryCell.swift
│ └── RepositoryCell.xib
│
├── Domain
│ └── SearchRepository
│ └── SearchRepositoryInteractor.swift
│
├── Infra
│ └── GitHubClient
│ ├── Enitities
│ │ ├── RepositoryEntity.swift
│ │ ├── SearchResponseEntity.swift
│ │ └── UserEntity.swift
│ ├── GitHubAPI.swift
│ └── GitHubClient.swift
│
├── Extensions
│ ├── ...
│ ├── ...
│
└── Resources
└── Assets.xcassets
├── ...
Unitテスト
- 流れ
- テストしたい対象が依存しているクラスなどのMockを作成
- ↑で作ったクラスなどを使ってテストしたい対象を初期化
- テストしたい対象のメソッドを呼び出すことで起こる挙動を確認
- 注意点
- Mockを自作することで、Mock自体にバグが発生する可能性がある
- protocolに1つメソッドを追加するだけでもテストコードを直す必要があり、大変
- 解決方法
- Mockライブラリ
- Mock生成ツール
RepositoryListPresenterTest.swift
import XCTest
// `@testable import`をするとinternalなクラスやプロトコルにアクセスできるようになる
@testable import ViperSample
class RepositorySearchResultPresenterTests: XCTestCase {
var view: ViewMock!
var router: RouterMock!
var searchRepositoryInteractor: RepositoryInteractorMock!
var presenter: RepositorySearchResultPresenter!
override func setUp() {
super.setUp()
view = .init()
router = .init()
searchRepositoryInteractor = .init()
presenter = .init(
view: view,
router: router,
searchRepositoryInteractor: searchRepositoryInteractor
)
}
func test_searchButtonDidPush() {
// PresenterにsearchButtonDidPushイベントが届いたときの挙動をテスト
// SearchRepositoryInteractorに検索を依頼しているか
// 正常時やエラー時に適切にViewに描画依頼をしているか
XCTContext.runActivity(named: "searchButtonDidPush") { _ in
XCTContext.runActivity(named: "when before called") { _ in
XCTContext.runActivity(named: "`fetchRepositories` is not called") { _ in
XCTAssertEqual(searchRepositoryInteractor.callCount_fetchRepositories, 0)
}
}
XCTContext.runActivity(named: "when after called") { _ in
XCTContext.runActivity(named: "`fetchRepositories` is called") { _ in
presenter.searchButtonDidPush(searchText: "Swift")
XCTAssertEqual(searchRepositoryInteractor.callCount_fetchRepositories, 1)
}
XCTContext.runActivity(named: "when `fetchRepositories` response error") { _ in
setUp()
searchRepositoryInteractor = .init(result: .failure(NSError()))
presenter = .init(view: view,
router: router,
searchRepositoryInteractor: searchRepositoryInteractor)
presenter.searchButtonDidPush(searchText: "Swift")
XCTContext.runActivity(named: "`showErrorMessageView` is called") { _ in
XCTAssertEqual(view.callCount_showErrorAlert, 1)
}
}
XCTContext.runActivity(named: "when `fetchRepositories` response succeed") { _ in
setUp()
let repositories = [
RepositoryEntity(id: 0,
name: "Swift",
fullName: "apple/Swift",
htmlURL: URL(string: "https://github.com/apple/Swift/")!,
starCount: 100000,
owner: UserEntity(id: 0, login: "apple"))
]
searchRepositoryInteractor = .init(result: .success(repositories))
presenter = .init(view: view,
router: router,
searchRepositoryInteractor: searchRepositoryInteractor)
presenter.searchButtonDidPush(searchText: "Swift")
XCTContext.runActivity(named: "`updateRepositories` is called") { _ in
XCTAssertEqual(view.callCount_updateRepositories, 1)
}
}
}
}
}
自作Mockの例
Viewのモック
// Viewのprotocolに準拠
class ViewMock: RepositorySearchResultView {
var callCount_updateRepositories = 0
func updateRepositories(_ repositories: [Repository]) {
// 呼ばれた回数を記録
callCount_updateRepositories += 1
}
}
ファイルの自動生成
ここまでVIPERアーキテクチャにおけるそれぞれの役割や実装方法、テストの方法などを説明してきましたが、
**「必要なファイルが多い!」**と感じた方もいらっしゃると思います。
そこで以下に紹介するようなファイル自動生成ツールを使うことをおすすめします
ボイラープレートを自動生成できることで、実装者による差分をなくしたり、タイポをなくしたりと、工数削減に役立ちます
Generamba
- VIPER用に作られたファイル自動生成ツール
- インストールの手順に従うだけでVIPERのテンプレートもついてくる
- が、デフォルトのテンプレートが使いづらかったため、自分用のVIPERテンプレート作成
- liquidという形式でテンプレートを記述
Kuri
個人的おすすめ
- @bannzai さんが作成したファイル自動生成ツール
- もともとCreanArchitecture用に作られたツール
- テンプレートを工夫すればVIPERでも利用可能
- VIPER用のテンプレート作ってみました
- ymlの設定ファイルやコマンド実行時のオプションを活用することで、生成方法を細かく設定できる
Generambaでは実現できない以下のようなことができる- 生成したいコンポーネントを選択できる
- Interactorは画面と1:1ではないので、以下の要件に柔軟に対応できる
- View, Presenter, Routerだけを生成したい
- Interactorだけを生成したい
- Interactorは画面と1:1ではないので、以下の要件に柔軟に対応できる
- コンポーネントごとに生成先のframeworkを選択できる
- EmbeddedFrameworkを活用しているプロジェクトでも利用可能
- 生成したいコンポーネントを選択できる
- コマンドの詳しい使い方は以下を参考
Combineを使った実装方法について
以下記事にまとめました
https://zenn.dev/hicka04/articles/viper-combine
まとめ
- 単一責任の原則をもとに適切にクラス分割すること
- 画面生成は
Router
で行い、クラス内部でinit()
等をしないこと - 分割されたそれぞれのクラスはprotocolを介してアクセスすること
- Unitテストの際に差し替えできるようにするため
-
View
とRouter
以外はimport UIKit
禁止- ロジックはUIがどうなっているかを気にしない
- Mockを使ってテスト対象に必要なクラスなどを差し替えることでUnitテスト可能
- Mockを自作する際はMock自体にバグを仕込まないように注意
- MockライブラリやMock生成ツールなどを導入することも検討したほうがいい
- ボイラープレートが多いので、ファイルの自動生成ツールを活用して楽をしよう! (Kuriがおすすめ)