😺

学習メモ SwiftでVIPERを覚える必要があったので学習したメモを残しておく

2022/03/02に公開約9,500字

きっかけ

案件にてSwift VIPERを理解する必要があり、ネットで公開されていたVIPERサンプルアプリを元に全くわからない状態から理解を開始しました。

最終的には理解に至るまでのすべてを本記事に書き込んで、将来的にVIPER案件に携わる時の自分用メモとして、そして他の方にとって有意義な知見となることを期待します。

どのようなアーキテクチャーか

  • View
    役割:画面描画

  • Interactor
    役割:ビジネスロジック

  • Presenter
    役割:画面(View)から受けたアクションをそれに対応する役割に投げる

  • Router
    役割:画面(ViewController)を生成し、各依存性(DI)を注入する。画面遷移を行う

上記はそれぞれprotocolを切り、それに準拠した実装を行う

実際にそれぞれを組み合わせる際は、protocolを介してアクセスすること
(protocolにのみ依存している状態にすること)

VIPERの頭文字のEはEntityを指します。

  • Entity
    役割:完全なデータ型

AppDelegateで行われる処理(アプリ起動時に行われる処理)

まずはAppPresenterAppRouterを用意します。

AppPresenter

import Foundation

protocol AppPresentation: AnyObject {
    func didFinishLaunch()
}

final class AppPresenter {
    private let router: AppWireframe
    
    init(router: AppWireframe) {
        self.router = router
    }
}

extension AppPresenter: AppPresentation {
    func didFinishLaunch() {
        router.showRepositorySearchResultView()
    }
}

AppRouter

import UIKit

protocol AppWireframe: AnyObject {
    func showRepositorySearchResultView()
}

final class AppRouter {
    private let window: UIWindow
    
    private init(window: UIWindow) {
        self.window = window
    }
    
    static func assembleModules(window: UIWindow) -> AppPresentation {
        let router = AppRouter(window: window)
        let presenter = AppPresenter(router: router)
        
        return presenter
    }
}

extension AppRouter: AppWireframe {
    func showRepositorySearchResultView() {
        let repositorySearchResultView = RepositorySearchResultRouter.assembleModules()
        let navigationController = UINavigationController(rootViewController: repositorySearchResultView)
        
        window.rootViewController = navigationController
        window.makeKeyAndVisible()
    }
}

(手順1)
AppDelegateでAppRouter型でプロパティ宣言し、appPresenterを返す。
class AppDelegate: UIResponder, UIApplicationDelegate { let appPresenter = AppRouter.assembleModules(window: UIWindow(frame: UIScreen.main.bounds))
(手順2)
AppDelegateでアプリ起動時に呼び出される箇所にて、appPreseneterのプロトコルを呼び出す

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        appPresenter.didFinishLaunch()
        
        return true
    }

(手順3)
呼び出された処理は以下。AppRouterが採用しているプロトコルAppWireframeshowRepositorySearchResultViewを呼び出す。

extension AppPresenter: AppPresentation {
    func didFinishLaunch() {
        router.showRepositorySearchResultView()
    }
}

(手順4)
手順1でlet appPresenter = AppRouter.assembleModules(window: UIWindow(frame: UIScreen.main.bounds))`のようにRouterから呼び出し、画面を生成し、各依存性を注入する。
そして画面遷移を行う。

extension AppRouter: AppWireframe {
    func showRepositorySearchResultView() {
        let repositorySearchResultView = RepositorySearchResultRouter.assembleModules()
        let navigationController = UINavigationController(rootViewController: repositorySearchResultView)
        
        window.rootViewController = navigationController
        window.makeKeyAndVisible()
    }
}

ここまでで説明するべきこと
各モジュールはクラス名とプロトコル名が以下の様に決まっている。

役割 プロトコル名 実体名(ファイル名)
View {ModuleName}View {ModuleName}ViewController
Presenter {ModuleName}Presentation {ModuleName}Presenter
Router {ModuleName}Wireframe {ModuleName}Router
Interactor {UsecaseName}Usecase {UsecaseName}Interactor
Entity プロトコル不要(テスト不要なぐらい簡素な為) {~~}Entity

なので先ほどは何故か急にWareframeとか出てきたんですよね。

また、次の画面を生成する場合は次の画面のRouterを呼び出し、各パーツの依存性を注入してから、Routerに画面遷移の処理を依頼する形になります。

AppDelegate→AppRouterの流れもそのように画面生成されましたし、
AppDelegateから最初のViewControllerを呼び出す処理も同様に次に表示するべき画面のRouterを呼び出してから全てが始まっています。

最初に表示される画面

AppRouterから呼び出された処理は以下になります。
各モデュールを初期化して、インスタンス生成したPresenterのプロパティにセットしております。
そして、Viewの.presenterにセットして、View(ViewController)を返しております。
ViewControllerを返却して、次の画面を表示することでこの画面が表示されております。

RepositorySearchResultRouter 
    // 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でView(ViewController)のインスタンスを保持する必要があるので、Routerで以下の処理・宣言を入れております。

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

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

あとはViewControllerが表示されるのでRepositorySearchResultViewControllerのライフサイクルが順次に呼び出されるだけです。

こちらのViewControllerはTableViewを表示するViewControllerです。

View(ViewController)でイベントを発生させる

TableViewなのでセルを押下した処理を例にとってみます。
View(ViewController)で発生したイベントはPresenterに通知させ、そこから対応したモデュールを呼び出して処理を行います。

RepositorySearchResultViewController

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // Presenterにイベント通知
        presenter.didSelect(repository: repositories[indexPath.row])
    }

各モデュールへのアクセスはProtocolを介して行うので、PresenterのプロトコルRepositorySearchResultPresentationpresenter.didSelect(repository: repositories[indexPath.row])という書き方になります。
※引数はデータ表示1列のデータです。文字列だけ表示するテーブルならStringだと思って下さい。

Presenter側での呼び出された処理です。
RepositorySearchResultPresenter

    func didSelect(repository: RepositoryEntity) {
        router.showRepositoryDetail(repository)
    }

今回は画面遷移を含む処理になるので、Routerのプロトコルが呼び出されました。

RepositorySearchResultRouter

// Routerのプロトコルに準拠する
// 遷移する各画面ごとにメソッドを定義
extension RepositorySearchResultRouter: RepositorySearchResultWireframe {
    func showRepositoryDetail(_ repository: RepositoryEntity) {
        // 詳細画面のRouterに依存関係の解決を依頼
        let detailView = RepositoryDetailRouter.assembleModules(repository: repository)
        // 詳細画面に遷移
        // ここで、init時に受け取ったViewControllerを使う
        viewController.navigationController?.pushViewController(detailView, animated: true)
    }
}

こちらも同様ですね。Routerで次のRouterのモデュール生成→依存性注入して、次の画面に遷移する。

View(ViewController)でビジネスロジックを活用するべきパターン

遅くなりましたが、最初の表示する画面です。検索窓があり、そこで文字入力してEnterを押下してあげると、WebAPIで結果を取得し、表示されます。

View(ViewController)側でSearchBarでEnterが押下された時の処理を見てみます。
RepositorySearchResultViewController

extension RepositorySearchResultViewController: UISearchBarDelegate {
    
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let text = searchBar.text else { return }
        
        // Presenterにイベント通知
        presenter.searchButtonDidPush(searchText: text)
        
        searchBar.resignFirstResponder()
    }
}

同様にPresenterのプロトコルを呼び出していますね。

RepositorySearchResultPresenter

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

SearchRepositoryInteractor
こちらはInteractor(ビジネスロジック)になります。
通信する処理を投げており、結果が返却されます。

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

先ほどのコードに戻ります。
RepositorySearchResultPresenter

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

成功時はPresenterで持っているview(ViewController)のプロトコル経由で通信結果を元に描画を行います。同様に失敗時もview(ViewController)にアラートメッセージを表示します。

なので、イメージ的には以下のようになっています。

  • (1)View→Presenter→Interactor
  • (2)Interactor→Presenterに戻る(クロージャーを利用)
  • (3)Presenter→Viewに描画処理を呼び出す

参考記事

偉そうに書いておりますが、すべてこちらの記事からコードなど、アプリ挙動など参考にして書かせていただきました。下の記事にgitURLがあり、コード配布なども行っているので、本家の説明もあわせてご確認ください。

一応、初学者の為に分かりやすく処理の流れや何をやっているか本記事では扱いました。

https://qiita.com/hicka04/items/09534b5daffec33b2bec

Discussion

ログインするとコメントできます