学習メモ SwiftでVIPERを覚える必要があったので学習したメモを残しておく
きっかけ
案件にてSwift VIPERを理解する必要があり、ネットで公開されていたVIPERサンプルアプリを元に全くわからない状態から理解を開始しました。
最終的には理解に至るまでのすべてを本記事に書き込んで、将来的にVIPER案件に携わる時の自分用メモとして、そして他の方にとって有意義な知見となることを期待します。
どのようなアーキテクチャーか
-
View
役割:画面描画 -
Interactor
役割:ビジネスロジック -
Presenter
役割:画面(View)から受けたアクションをそれに対応する役割に投げる -
Router
役割:画面(ViewController)を生成し、各依存性(DI)を注入する。画面遷移を行う
上記はそれぞれprotocolを切り、それに準拠した実装を行う
実際にそれぞれを組み合わせる際は、protocolを介してアクセスすること
(protocolにのみ依存している状態にすること)
VIPERの頭文字のEはEntity
を指します。
-
Entity
役割:完全なデータ型
AppDelegateで行われる処理(アプリ起動時に行われる処理)
まずはAppPresenter
とAppRouter
を用意します。
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が採用しているプロトコルAppWireframe
のshowRepositorySearchResultView
を呼び出す。
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のプロトコルRepositorySearchResultPresentation
のpresenter.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があり、コード配布なども行っているので、本家の説明もあわせてご確認ください。
一応、初学者の為に分かりやすく処理の流れや何をやっているか本記事では扱いました。
Discussion