VIPERアーキテクチャ まとめ


VIPERアーキテクチャとは?

VIPER_img


VIPERアーキテクチャの理念



  • 単一責任の原則のもと適切に分割すること


    • ∴「このクラスは〇〇をするクラスである」と一言で簡単に説明ができる



  • VIPERはView, Interactor, Presenter, Entity, Routerの頭文字を取ってVIPERと呼ぶ


    • ※それぞれの詳しい役割やサンプルコードは後述




  • View, Interactor, Presenter, Entity, Routerはそれぞれprotocolを切り、それに準拠した実装を行う


    • 実際にそれぞれを組み合わせる際は、protocolを介してアクセスすること

      protocolにのみ依存している状態にすること

    • これによりそれぞれの要素の差し替えが容易になり、テストコードが書けるようになる




VIPERそれぞれの役割


関係

relationship


ざっくりと処理の流れ



  1. Routerで画面の生成に必要な依存性を注入(以下DI)させる

  2. 1.で生成された画面を表示


  3. ViewからイベントをPresenterにお知らせする


    • ライフサイクル

    • ユーザのタップ

    • …などなど




  4. PresenterViewから送られてきたイベントの内容に合わせて以下のような処理を実行する



    • Viewに対して画面の更新依頼を投げる



      • Viewは依頼された通りに画面の更新をする




    • Interactorに対してデータの取得依頼を投げる



      • Interactorは依頼されたデータ取得が完了したらPresenterに通知する




    • Routerに対して画面遷移の依頼を投げる



      • Routerは依頼された画面へ遷移する → 1. へ戻る






命名

役割
プロトコル名
実体名

View
{ModuleName}View
{ModuleName}ViewController

Presenter
{ModuleName}ViewPresentation
{ModuleName}ViewPresenter

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の責務を減らせた



Routerのサンプル


RepositoryListRouter.swift

import UIKit

class RepositoryListRouter {

// 画面遷移のためにViewControllerが必要。initで受け取る
private unowned let viewController: UIViewController

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

// DI
static func assembleModules() -> UIViewController {
let view = RepositoryListViewController()
let router = RepositoryListRouter(viewController: view)
let historyInteractor = SearchHistoryInteractor()
let repositoryInteractor = SearchRepositoryInteractor()
// PresenterはView, Interactor, Routerそれぞれ必要
// 生成し、initの引数で渡す
let presenter = RepositoryListViewPresenter(view: view,
router: router,
historyInteractor: historyInteractor,
repositoryInteractor: repositoryInteractor)

view.presenter = presenter // ViewにPresenterを設定

return view
}
}

// Routerのプロトコルに準拠する
// 遷移する各画面ごとにメソッドを定義
extension RepositoryListRouter: RepositoryListWireframe {

func showRepositoryDetail(_ repository: Repository) {
// 詳細画面の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を切る



Viewのサンプル


RepositoryListViewController.swift

import UIKit

class RepositoryListViewController: UIViewController {

// Presenterへのアクセスはprotocolを介して行う
var presenter: RepositoryListViewPresentation!

@IBOutlet private weak var tableView: UITableView!

private var repositories: [Repository] = [] {
didSet {
DispatchQueue.main.async {
self.tableView.reloadData() // 画面の更新

if self.refreshControl.isRefreshing {
self.refreshControl.endRefreshing()
}
}
}
}

override func viewDidLoad() {
super.viewDidLoad()

presenter.viewDidLoad() // Viewの読み込みが完了したことを通知
}
}

// Viewのプロトコルに準拠する
extension RepositoryListViewController: RepositoryListView {

func updateRepositories(_ repositories: [Repository]) {
self.repositories = repositories
}
}



Presenter



  • Viewから受け取ったイベントを元に「画面の更新処理に関わるロジック」を担当


  • Viewからのイベントの内容によって必要な処理を実施、または別のクラスに依頼する



    • Viewに対して画面の更新依頼を投げる


    • Interactorに対してデータの取得依頼を投げる


    • Routerに対して画面遷移の依頼を投げる




  • Presenterが提供するメソッド名は

    「画面の更新が終わった(viewDidLoad)」「ボタンが押された(hogeButtonDidPush)」

    といった命名にすること


    • ×「(ボタンが押されたので)詳細画面に遷移する(showDetailView)」




  • import UIKit禁止


    • UIがどうなっているかを気にしない



Presenterのサンプル


RepositoryListViewPresenter.swift

import Foundation

class RepositoryListViewPresenter {

// View, Interactor, Routerへのアクセスはprotocolを介して行う
private weak var view: RepositoryListView?
private let router: RepositoryListWireframe
private let historyInteractor: SearchHistoryUsecase
private let repositoryInteractor: SearchRepositoryUsecase

private var searchText: String = "" {
didSet {

// Interactorにデータ取得処理を依頼
// 循環参照にならないよう`[weak self]`でキャプチャ
repositoryInteractor.fetchRepositories(keyword: searchText) { [weak self] result in
switch result {
case .success(let repositories):
self?.repositories = repositories
case .failure:
self?.repositories.removeAll()
self?.view?.showErrorMessageView(reason: "エラーが発生しました")
}
}
}
}
private var repositories: [Repository] = [] {
didSet {
view?.updateRepositories(repositories)
}
}

init(view: RepositoryListView,
router: RepositoryListWireframe,
historyInteractor: SearchHistoryUsecase,
repositoryInteractor: SearchRepositoryUsecase) {
self.view = view
self.router = router
self.historyInteractor = historyInteractor
self.repositoryInteractor = repositoryInteractor
}
}

// Presenterのプロトコルに準拠する
extension RepositoryListViewPresenter: RepositoryListViewPresentation {

func viewDidLoad() {
// Interactorにデータ取得処理を依頼
historyInteractor.loadLastSeachText { result in
switch result {
case .success(let searchText):
self.searchText = searchText
case .failure:
break
}
}
}

func didSelectRow(at indexPath: IndexPath) {
guard indexPath.row < repositories.count else { return }

let repository = repositories[indexPath.row]
router.showRepositoryDetail(repository) // Routerに画面遷移を依頼
}


}



Interactor


  • 「データに関わるロジック」を担当(取得、加工、保存など)


  • Presenterから依頼されたデータを取得し返す


    • 取得が完了したらPresenterに通知


      • クロージャ(おすすめ)、またはDelegate経由で返す

      • 戻り値で返さないほうが、UnitテストのためのMock作成が楽になる

      • 循環参照にならないよう実装注意



    • WebAPI、バンドルされたファイル、ローカルに保存されているデータなど




  • import UIKit禁止


    • UIがどうなっているかを気にしない



Interactorのサンプル


SearchRepositoryInteractor.swift

import Foundation

protocol SearchRepositoryUsecase: AnyObject {

func fetchRepositories(keyword: String,
completion: @escaping (Result<[Repository], 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<[Repository], Error>) -> Void) {
let request = GitHubAPI.SearchRepositories(keyword: keyword)
client.send(request: request) { result in
completion(result.map { $0.items })
}
}
}



Entity


  • 「データ構造の定義」を担当

  • structでデータ構造を定義する


  • import UIKit禁止


    • UIがどうなっているかを気にしない



  • 基本ロジックを持たないようにする(※Entityにロジックを持つとテストが書きづらくなるため)

    以下のみを定義する


    • プロパティ

    • init



Entityのサンプル


Repository.swift

import Foundation

struct Repository: Decodable {

let id: Int
let name: String
let fullName: String
let htmlURL: URL
let starCount: Int
let owner: User

enum CodingKeys: String, CodingKey {
case id
case name
case fullName = "full_name"
case htmlURL = "html_url"
case starCount = "stargazers_count"
case owner
}
}



ディレクトリ構造

├── application

│   ├── AppDelegate.swift
│   ├── Base.lproj
│   │   └── LaunchScreen.storyboard
│   └── Info.plist
├── modules // modules配下にモジュールを作成する
│   ├── Root
│   │ └── RootRouter.swift
│   ├── RepositoryList
│   │   ├── View
│   │   │ ├── RepositoryListViewController.swift
│   │   │ ├── RepositoryListViewController.xib
│   │   │ └── SubView
│   │   │ ├── RepositoryCell.swift
│   │   │ └── RepositoryCell.xib
│   │   ├── Presenter
│   │   │   ├── RepositoryListViewPresenter.swift
│   │   │   └── RepositoryListViewPresenterTests.swift // 同じディレクトリにテストを置くとテストが書かれているかわかりやすい
│   │   └── Router
│   │      └── RepositoryListRouter.swift
│ ├── ...

├── data // Interactor等のデータ周りのロジックは画面に依存しないので別ディレクトリ
│   ├── api
│   │   └── GitHubClient
│   │   ├── ...
│   │
│   └── Interactor
│   ├── SearchHistory
│   │   └── SearchHistoryInteractor.swift
│   └── SearchRepository
│   └── SearchRepositoryInteractor.swift
├── enitities // 公式ではmodelという命名だが、entityのほうが誤解がない
│   ├── Repository.swift
│   ├── ...

├── extensions
│   ├── UIView+extension.swift
│   ├── ...

└── resources
└── Assets.xcassets
├── ...


Unitテスト


  • 流れ


    • テストしたい対象が依存しているクラスなどのMockを作成

    • ↑で作ったクラスなどを使ってテストしたい対象を初期化

    • テストしたい対象のメソッドを呼び出すことで起こる挙動を確認



  • 注意点


    • Mockを自作することで、Mock自体にバグが発生する可能性がある


    • protocolに1つメソッドを追加するだけでもテストコードを直す必要があり、大変

    • 解決方法





PresenterのUnitテストのサンプル


RepositoryListPresenterTest.swift

import XCTest

// `@testable import`をするとinternalなクラスやプロトコルにアクセスできるようになる
@testable import viper_sample

class RepositoryListPresenterTest: XCTestCase {

// 依存するクラスの初期化
let view = ViewMock()
let interactor = InteractorMock()
let router = RouterMock()
var presenter: RepositoryListViewPresenter!

override func setUp() {
super.setUp()

presenter = RepositoryListViewPresenter(view: view, interactor: interactor, router: router)
}

func test_viewDidLoad() {
// PresenterにviewDidLoadのイベントが届いたときの挙動をテスト
// Interactorに最後の検索文字列を取得するよう依頼する実装にしたので、
// 正しく依頼されているかチェック
XCTContext.runActivity(named: "viewDidLoad") { _ in
XCTContext.runActivity(named: "when before called") { _ in
XCTContext.runActivity(named: "`loadLastSeachText` is not called") { _ in
XCTAssertEqual(historyInteractor.callCount_loadLastSeachText, 0)
}
}

XCTContext.runActivity(named: "when after called") { _ in
presenter.viewDidLoad()

XCTContext.runActivity(named: "`loadLastSeachText` is called") { _ in
XCTAssertEqual(historyInteractor.callCount_loadLastSeachText, 1)
}
}
}
}
}


モックは以下のように作る


Viewのモック

// Viewのprotocolに準拠

class ViewMock: RepositoryListView {

var callCount_updateRepositories = 0
func updateRepositories(_ repositories: [Repository]) {
// 呼ばれた回数を記録
callCount_updateRepositories += 1
}
}



ファイルの自動生成

ここまでVIPERアーキテクチャにおけるそれぞれの役割や実装方法、テストの方法などを説明してきましたが、

「必要なファイルが多い!」と感じた方もいらっしゃると思います。

そこで以下に紹介するようなファイル自動生成ツールを使うことをおすすめします

ボイラープレートを自動生成できることで、実装者による差分をなくしたり、タイポをなくしたりと、工数削減に役立ちます


Generamba


  • VIPER用に作られたファイル自動生成ツール

  • インストールの手順に従うだけでVIPERのテンプレートもついてくる


    • が、デフォルトのテンプレートが使いづらかったため、自分用のVIPERテンプレート作成


    • liquidという形式でテンプレートを記述




Kuri

個人的おすすめ


まとめ


  • 単一責任の原則をもとに適切にクラス分割すること

  • DIはRouterで行い、クラス内部でinit()等をしないこと

  • 分割されたそれぞれのクラスはprotocolを介してアクセスすること


    • Unitテストの際に差し替えできるようにするため




  • ViewRouter以外はimport UIKit禁止


    • ロジックはUIがどうなっているかを気にしない



  • Mockを使ってテスト対象に必要なクラスなどを差し替えることでUnitテスト可能


    • Mockを自作する際はMock自体にバグを仕込まないように注意

    • MockライブラリやMock生成ツールなどを導入することも検討したほうがいい



  • ボイラープレートが多いので、ファイルの自動生成ツールを活用して楽をしよう!(Kuriがおすすめ)