412
321

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

VIPERアーキテクチャ まとめ

Last updated at Posted at 2018-07-30

VIPERアーキテクチャとは?

VIPER_img

  • 公式 : iOS Project Architecture: Using VIPER
  • iOSの開発現場で生まれたアーキテクチャである
  • View , Interactor , Presenter , Entity , Router の頭文字を取ってVIPERと呼ぶ
    • ※それぞれの詳しい役割やサンプルコードは後述

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}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 の責務を減らせ、可読性の向上が望める

Router のサンプル

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を切る

Viewのサンプル

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がどうなっているかを気にしない

Presenter のサンプル

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などのリアクティブプログラミングフレームワーク
    • 循環参照にならないよう実装注意
  • import UIKit 禁止
    • UIがどうなっているかを気にしない

Interactor のサンプル

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がどうなっているかを気にしない
  • 基本的にプロパティとイニシャライザのみ定義し、ロジックを持たないようにする

Entity のサンプル

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つメソッドを追加するだけでもテストコードを直す必要があり、大変
    • 解決方法

Presenter のUnitテストのサンプル

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用に作られたツール
  • ymlの設定ファイルやコマンド実行時のオプションを活用することで、生成方法を細かく設定できる
    Generambaでは実現できない以下のようなことができる
    • 生成したいコンポーネントを選択できる
      • Interactorは画面と1:1ではないので、以下の要件に柔軟に対応できる
        • View, Presenter, Routerだけを生成したい
        • Interactorだけを生成したい
    • コンポーネントごとに生成先のframeworkを選択できる
      • EmbeddedFrameworkを活用しているプロジェクトでも利用可能
  • コマンドの詳しい使い方は以下を参考

Combineを使った実装方法について

以下記事にまとめました
https://zenn.dev/hicka04/articles/viper-combine

まとめ

  • 単一責任の原則をもとに適切にクラス分割すること
  • 画面生成は Router で行い、クラス内部で init() 等をしないこと
  • 分割されたそれぞれのクラスはprotocolを介してアクセスすること
    • Unitテストの際に差し替えできるようにするため
  • ViewRouter 以外は import UIKit 禁止
    • ロジックはUIがどうなっているかを気にしない
  • Mockを使ってテスト対象に必要なクラスなどを差し替えることでUnitテスト可能
    • Mockを自作する際はMock自体にバグを仕込まないように注意
    • MockライブラリやMock生成ツールなどを導入することも検討したほうがいい
  • ボイラープレートが多いので、ファイルの自動生成ツールを活用して楽をしよう! (Kuriがおすすめ)
412
321
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
412
321

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?