iOS
Swift
VIPER

VIPERアーキテクチャ まとめ

VIPERアーキテクチャとは?

VIPER_img

VIPERアーキテクチャの理念

  • 単一責任の原則のもと適切に分割すること
    • ∴「このクラスは〇〇をするクラスである」と一言で簡単に説明ができる
  • VIPERはView, Interactor, Presenter, Entity, Routerの頭文字を取ってVIPERと呼ぶ
    • ※それぞれの詳しい役割やサンプルコードは後述
  • View, Interactor, Presenter, Entity, Routerはそれぞれprotocolを切り、それに準拠した実装を行う
    • 実際にそれぞれを組み合わせる際は、protocolを介してアクセスすること
      protocolにのみ依存している状態にすること
    • これによりそれぞれの要素の差し替えが容易になり、テストコードが書きやすくなる

VIPERそれぞれの役割

関係

relationship

ざっくりと処理の流れ

  1. Routerで画面の生成に必要な依存性を解決させる
  2. 1.で生成された画面を表示
  3. ViewからイベントをPresenterにお知らせする
    • ライフサイクル
    • ユーザのタップ
    • …などなど
  4. PresenterViewから送られてきたイベントの内容に合わせて以下のような処理を実行する
    • Viewに対して画面の更新依頼を投げる
      • Viewは依頼された通りに画面の更新をする
    • Interactorに対してデータの取得依頼を投げる
      • Interactorは依頼されたデータ取得が完了したらPresenterに通知する
    • Routerに対して画面遷移の依頼を投げる
      • Routerは依頼された画面へ遷移する → 1. へ戻る

命名

役割 プロトコル名 実体名
View {ModuleName}View {ModuleName}ViewController
Interactor {ModuleName}Usecase {ModuleName}Interactor
Presenter {ModuleName}ViewPresentable {ModuleName}ViewPresenter
Entity - ※1 {~~}Entity ※2
Router {ModuleName}Wireframe {ModuleName}Router

※1:Entityはテストの際に差し替える必要がないほどシンプルな構造なのでプロトコル不要
※2:1つのモジュールで複数のEntityを扱うこともあるため

それぞれの役割の詳細

以下ではサンプルコードを元に説明
GitHubのAPIをたたいてリポジトリの一覧を取得し表示する

Router

  • 「画面遷移」と「依存関係の解決」を担当
  • VIPERアーキテクチャの肝であり、他の有名アーキテクチャにないところ
    • 他アーキテクチャではViewに画面遷移の処理もお願いする必要があり、Viewが「画面の更新」と「画面遷移」の2つを担当する必要があった
      Viewのコードの見通しが悪くなりがちだった
    • VIPERでは「画面遷移」の処理をRouterに移管したことでViewの責務を減らせた

Routerのサンプル

RepositoryListRouter.swift
import UIKit

class RepositoryListRouter {

    // 画面遷移のためにViewControllerが必要。initで受け取る
    weak var viewController: UIViewController?

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

    // 依存関係の解決をしている
    static func assembleModules() -> UIViewController {
        let view = RepositoryListViewController()
        let interactor = RepositoryListInteractor()
        let router = RepositoryListRouter(viewController: view)
        // PresenterはView, Interactor, Routerそれぞれ必要なので
        // 生成し、initの引数で渡す
        let presenter = RepositoryListViewPresenter(view: view, interactor: interactor, router: router)

        interactor.output = presenter // Interactorの通知先を設定
        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: RepositoryListViewPresentable!

    @IBOutlet private weak var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        

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

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

    func reloadData() {
        tableView.reloadData() // 画面の更新
    }
}

Presenter

  • Viewから受け取ったイベントを元に「画面の更新処理に関わるロジック」を担当
  • Viewからのイベントの内容によって必要な処理を実施、または別のクラスに依頼する
    • Viewに対して画面の更新依頼を投げる
    • Interactorに対してデータの取得依頼を投げる
    • Routerに対して画面遷移の依頼を投げる
  • Presenterが提供するメソッド名は
    「画面の更新が終わった(viewDidLoad)」「ボタンが押された(hogeButtonDidPush)」
    といった命名にすること
    • ×「(ボタンが押されたので)詳細画面に遷移する(showDetailView)」
  • import UIKit禁止
    • UIがどうなっているかを気にしない

Presenterのサンプル

RepositoryListViewPresenter.swift
import Foundation

class RepositoryListViewPresenter {

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

    init(view: RepositoryListView, interactor: RepositoryListUsecase, router: RepositoryListWireframe) {
        self.view = view
        self.interactor = interactor
        self.router = router
    }
}

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

    func viewDidLoad() {
        interactor.fetchRepositories(keyword: "swift") // Interactorにデータ取得処理を依頼
    }

    func didSelectRow(at indexPath: IndexPath) {
        let repository = interactor.repository(at: indexPath)
        router.showRepositoryDetail(repository) // Routerに画面遷移を依頼
    }

    
}

// Interactorからの通知に関するプロトコルに準拠する
extension RepositoryListViewPresenter: RepositoryListInteractorOutput {

    func fetchRepositoriesDidFinish() {
        view?.reloadData() // データ取得が完了したら画面の更新を依頼
    }
}

Interactor

  • 「データ関わるロジック」を担当(取得、加工、保存など)
  • Presenterから依頼されたデータを取得し返す
    • WebAPIから取得する場合などの時間がかかる取得処理の場合は、取得が完了したことをPresenterに通知
    • 取得先はWebAPIだけではなく、バンドルされたファイルなども
  • import UIKit禁止
    • UIがどうなっているかを気にしない

Interactorのサンプル

RepositoryListInteractor.swift
import Foundation

class RepositoryListInteractor {

    // 取得処理が完了したことを通知はprotocolを介して行う
    weak var output: RepositoryListInteractorOutput?

    private var repositories: [Repository] = []
}

// Interactorのプロトコルに準拠する
extension RepositoryListInteractor: RepositoryListUsecase {

    // そのままデータを返すパターン
    var numberOfRepositories: Int {
        return repositories.count
    }

    func repository(at indexPath: IndexPath) -> Repository {
        return repositories[indexPath.row]
    }

    // 時間がかかるパターン
    func fetchRepositories(keyword: String) {
        let request = GitHubAPI.SearchRepositories(keyword: keyword)

        let client = GitHubClient()
        client.send(request: request) { result in
            switch result {
            case .success(let response):
                self.repositories += response.items
                DispatchQueue.main.async {

                    // 取得完了したことを通知
                    self.output?.fetchRepositoriesDidFinish()
                }
            case .failure:
                break
            }
        }
    }
}

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
    }
}

ディレクトリ構造

├── AppDelegate.swift
├── Assets.xcassets
├── Info.plist
├── RootRouter.swift
├── enitities // 公式ではmodelという命名だが、entityのほうが誤解がないのでこうしている
│   ├── Repository.swift
│   ├── ...
└── modules  // modules配下にモジュールを作成する
    ├── RepositoryList
    │   ├── Interactor
    │   │   └── RepositoryListInteractor.swift
    │   ├── Interface
    │   │   └── RepositoryListInterface.swift
    │   ├── Presenter
    │   │   └── RepositoryListPresenter.swift
    │   ├── Router
    │   │   └── RepositoryListRouter.swift
    │   └── View
    │       ├── RepositoryListViewController.swift
    │       ├── RepositoryListViewController.xib
    │       └── SubView
    │           ├── RepositoryResultCell.swift
    │           └── RepositoryResultCell.xib
    ├── ...

Unitテスト

  • 流れ
    • テストしたい対象が依存しているクラスなどのMockを作成
    • ↑で作ったクラスなどを使ってテストしたい対象を初期化
    • テストしたい対象のメソッドを呼び出すことで起こる挙動を確認
  • 注意点
    • Mockを自作することで、Mock自体にバグが発生する可能性がある
    • protocolに1つメソッドを追加するだけでもテストコードを直す必要があり、大変
    • Mockライブラリを使うことで解決できることが多い

PresenterのUnitテストのサンプル

RepositoryListPresenterTest.swift
import XCTest

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にリポジトリ一覧を取得するよう依頼する実装にしたので、
        // 正しく依頼されているかチェック
        XCTAssertFalse(interactor.isCalled_fetchRepositories)
        presenter.viewDidLoad()
        XCTAssertTrue(interactor.isCalled_fetchRepositories)
    }

    func test_fetchRepositoriesDidFinish() {
        // Presenterにリポジトリ一覧の取得完了イベントが届いたときの挙動テスト
        // イベントを受け取ったらViewに再描画を依頼するよう実装したので、
        // 正しく依頼されているかチェック
        XCTAssertFalse(view.isCalled_reloadData)
        presenter.fetchRepositoriesDidFinish()
        XCTAssertTrue(view.isCalled_reloadData)
    }
}

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

Viewのモック
// Viewのprotocolに準拠
class ViewMock: RepositoryListView {

    var isCalled_reloadData = false

    func reloadData() {
        // メソッドが呼ばれたことを記録
        isCalled_reloadData = true
    }
}

まとめ

  • 単一責任の原則をもとに適切にクラス分割すること
  • 依存性の解決はRouterで行い、クラス内部でinit()等をしないこと
  • 分割されたそれぞれのクラスはprotocolを介してアクセスすること
    • Unitテストの際に差し替えできるようにするため
  • ViewRouter以外はimport UIKit禁止
    • UIがどうなっているかを気にしない
  • Mockを使ってテスト対象に必要なクラスなどを差し替えることでUnitテスト可能
    • Mockを自作する際はMock自体にバグを仕込まないように注意
    • Mockライブラリを導入することも検討したほうがいい