Help us understand the problem. What is going on with this article?

VIPERアーキテクチャ まとめ

VIPERアーキテクチャとは?

VIPER_img

VIPERアーキテクチャの理念

  • 単一責任の原則のもと適切に分割すること
    • ∴「このクラスは〇〇をするクラスである」と一言で簡単に説明ができる
  • VIPERはView, Interactor, Presenter, Entity, Routerの頭文字を取ってVIPERと呼ぶ
    • ※それぞれの詳しい役割やサンプルコードは後述
  • View, Interactor, Presenter, 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がおすすめ)
hicka04
東京でiOSアプリメインで開発してます
nifty
インターネット接続やブログといったインターネット関連サービスを開発・提供している企業です。
http://www.nifty.co.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした