#はじめに
VIPERを勉強したので、基本的なことをまとめていこうと思います。
#GitHub
#VIPERとは
クリーンアーキテクチャーをiOS向けにしたシステムアーキテクチャのこと。
View, Interactor, Presenter, Entity, Routerの頭文字からとった。
システムアーキテクチャーとは、今までのGUIアーキテクチャー(MVC, MVVM, MVP)のように、Viewとその他みたいな考え方ではなく、画面遷移やAPI通信、データ保存などを考慮した設計のこと。
#それぞれの役割
View: ViewとViewController
Interactor: API通信担当
Presenter: 自分以外の中継役
Entity: データそのもの
Router: 画面遷移担当
#特徴
・徹底的な疎結合
→Entity以外全てprotocolで繋ぐ
・Presenterは内部で状態をもたない
→いつどのような入力に対しても必ず同じ出力になる。(Entityの違いはある)
・PresenterのメソッドはViewで起きたものに依存した名前にする
→viewDidLoad, buttonDidTappedなど
・ViewとRouter以外はimport UIKitだめ、絶対
・Interactorはデータを返すだけに徹する
→API通信、端末内保存、メソッドで計算しただけなど関係なく、最後はデータを返すだけ。
他のモジュールからはどのようにデータを返したのかわからなくする。
データの返し方はRxSwift, protocol, closureなど、なんでもいい
・Entityに処理を書かない
→純粋にデータを保持した型
#処理の流れ
1.Routerdえ画面を生成し、DI(依存性注入)させる
2.生成された画面を表示
3.ViewからイベントをPresenterに知らせる
→ライフサイクル、ボタンタップ...
4.PresenterはViewから送られてきたイベントの内容に合わせて以下のような処理をする
・Viewに対して画面の更新依頼する
→Viewは依頼された通りに画面を更新する
・Interactorに対してデータの取得依頼をする
→Interactorは依頼されたデータの取得が完了したらPresenterに通知する
・Routerに対して画面遷移の依頼をする
→Routerは依頼された画面へ遷移する
#View
・画面の更新
ラベルの文字変更
UITableViewのreload
など
・Presenterへのイベント通知担当
ライフサイクル
ボタンのタップ、セルのタップ
など
import UIKit
protocol GitHubSearchView: AnyObject {
func initView()
func startLoading()
func finishLoading()
func reloadTableView(items: [GitHubSearchEntity])
}
final class GitHubSearchViewController: UIViewController {
@IBOutlet private weak var textField: UITextField!
@IBOutlet private weak var searchButton: UIButton!
@IBOutlet private weak var indicator: UIActivityIndicatorView!
@IBOutlet private weak var tableView: UITableView!
// presenterへのアクセスはprotocolを介して行う
private var presenter: GitHubSearchPresentation!
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self
tableView.register(GitHubSearchTableViewCell.nib,
forCellReuseIdentifier: GitHubSearchTableViewCell.identifier)
searchButton.addTarget(self, action: #selector(searchButtonDidTapped), for: .touchUpInside)
// presenterにイベントを通知
presenter.viewDidLoad()
}
func inject(presenter: GitHubSearchPresentation) {
self.presenter = presenter
}
}
// MARK: - @objc func
@objc private extension GitHubSearchViewController {
func searchButtonDidTapped() {
// presenterにイベントを通知
presenter.searchButtonDidTapped(word: textField.text)
}
}
// MARK: - GitHubSearchView
extension GitHubSearchViewController: GitHubSearchView {
func initView() {
DispatchQueue.main.async {
self.tableView.isHidden = true
self.indicator.isHidden = true
}
}
func startLoading() {
DispatchQueue.main.async {
self.tableView.isHidden = true
self.indicator.isHidden = false
}
}
func finishLoading() {
DispatchQueue.main.async {
self.tableView.isHidden = false
self.indicator.isHidden = true
}
}
func reloadTableView(items: [GitHubSearchEntity]) {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
// MARK: - UITableViewDelegate
extension GitHubSearchViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
// presenterにイベントを通知
presenter.selectItem(indexPath: indexPath)
}
}
// MARK: - UITableViewDataSource
extension GitHubSearchViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// presenterにイベントを通知
return presenter.getSearchedItems().count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: GitHubSearchTableViewCell.identifier,
for: indexPath) as! GitHubSearchTableViewCell
// presenterにイベントを通知
let item = presenter.getSearchedItems()[indexPath.row]
cell.configure(gitHubSearch: item)
return cell
}
}
#Interactor
・ビジネスロジック担当(Utility)
・Presenterから依頼されたビジネスロジックを実装し、結果を返す。
delegate, closure, RxSwift...
・import UIKitだめ、絶対
UIを気にしない
import Foundation
protocol GitHubSearchUsecase {
func get(parameters: GitHubSearchParameters,
handler: ResultHandler<[GitHubSearchEntity]>?)
func getSearchedItems() -> [GitHubSearchEntity]
}
// 他のアーキテクチャーでいうUtilityの役割も持つ
final class GitHubSearchInteractor {
private var searchedItems: [GitHubSearchEntity]
init() {
searchedItems = []
}
}
// MARK: - GitHubSearchUsecase
extension GitHubSearchInteractor: GitHubSearchUsecase {
func get(parameters: GitHubSearchParameters,
handler: ResultHandler<[GitHubSearch]>? = nil) {
guard parameters.validation else {
handler?(.failure(.error))
return
}
guard let url = URL(string: "https://api.github.com/search/repositories?\(parameters.queryParameter)") else {
handler?(.failure(.invalidUrl))
return
}
let task = URLSession.shared.dataTask(with: url) { data, _, _ in
guard let data = data,
let gitHubResponse = try? JSONDecoder().decode(GitHubSearchEntityResponse.self, from: data),
let items = gitHubResponse.items else {
handler?(.failure(.error))
return
}
self.searchedItems = items
handler?(.success(items))
}
task.resume()
}
func getSearchedItems() -> [GitHubSearchEntity] {
return searchedItems
}
}
#Presenter
・Viewから受け取ったイベントを元に別クラスに依頼
Viewに対して画面更新を依頼
Interactorに対してデータの取得を依頼
Routerに対して画面遷移を依頼
・Presenterが提供するメソッド名はViewのメソッド名と同じ
viewDidLoad, buttonDidTapped...
・Presenterに状態を持たせない
・import UIKitだめ、絶対
UIを気にしない
import Foundation
protocol GitHubSearchPresentation: AnyObject {
func viewDidLoad()
func searchButtonDidTapped(word: String?)
func selectItem(indexPath: IndexPath)
func getSearchedItems() -> [GitHubSearchEntity]
}
// 他との部品以外はパラメータを持たない
// 他との中継役にだけに徹する
final class GitHubSearchPresenter {
// view, interactor, routerへのアクセスはprotocolを介して行う
// 循環参照しないようにviewだけweak
private weak var view: GitHubSearchView?
private var interactor: GitHubSearchUsecase
private var router: GitHubSearchWireframe
init(view: GitHubSearchView,
interactor: GitHubSearchUsecase,
router: GitHubSearchWireframe) {
self.view = view
self.interactor = interactor
self.router = router
}
}
// MARK: - GithubSearchPresentation
extension GitHubSearchPresenter: GitHubSearchPresentation {
func viewDidLoad() {
view?.initView()
}
func searchButtonDidTapped(word: String?) {
let parameters = GitHubSearchParameters(searchWord: word)
view?.startLoading()
interactor.get(parameters: parameters) { [weak self] result in
guard let self = self else { return }
self.view?.finishLoading()
switch result {
case .success(let items):
self.view?.reloadTableView(items: items)
case .failure(let error):
self.router.showAlert(error: error)
}
}
}
func selectItem(indexPath: IndexPath) {
let gitHubSearchEntity = interactor.getSearchedItems()[indexPath.row]
let initParameters: WebUsecaseInitParameters = .init(entity: gitHubSearchEntity)
router.showWeb(initParameters: initParameters)
}
func getSearchedItems() -> [GitHubSearchEntity] {
return interactor.getSearchedItems()
}
}
#Entity
・データ構造そのもの
・ロジックを持たせない
・import UIKitだめ、絶対
UIを気にしない
//対応がわかりやすいように置き換え
typealias GitHubSearchEntityResponse = GitHubResponse
typealias GitHubSearchEntity = GitHubSearch
typealias GitHubSearchntityError = GitHubError
import Foundation
struct GitHubResponse: Codable {
let items: [GitHubSearch]?
}
struct GitHubSearch: Codable {
let id: Int
let name: String
private let fullName: String
var urlString: String { "https://github.com/\(fullName)" }
enum CodingKeys: String, CodingKey {
case id
case name
case fullName = "full_name"
}
}
#Router
・画面遷移
・依存性注入(後述)
・VIPERの肝
VIPERでは画面遷移の処理をRouterで行うことにより、Viewの責務を減らせて可読性の向上が望める
import UIKit
protocol GitHubSearchWireframe {
func showWeb(initParameters: WebUsecaseInitParameters)
func showAlert(error: Error)
}
final class GitHubSearchRouter {
private unowned let viewController: UIViewController
private init(viewController: UIViewController) {
self.viewController = viewController
}
// Routerが画面遷移を担当しているので、ここに書く
static func assembleModules() -> UIViewController {
let view = UIStoryboard.gitHubSearch.instantiateInitialViewController() as! GitHubSearchViewController
let interactor = GitHubSearchInteractor()
let router = GitHubSearchRouter(viewController: view)
// presenterが中継役なので、全てと繋げる
let presenter = GitHubSearchPresenter(view: view,
interactor: interactor,
router: router)
// viewからpresenterに通知する必要があるため繋ぐ
// viewとpresenterは互いが互いを知っている
view.inject(presenter: presenter)
return view
}
}
// MARK: - GitHubSearchWireframe
extension GitHubSearchRouter: GitHubSearchWireframe {
func showWeb(initParameters: WebUsecaseInitParameters) {
let next = WebRouter.assembleModules(initParameters: initParameters)
viewController.show(next: next)
}
func showAlert(error: Error) {
print(error.localizedDescription)
}
}
#おわりに
その他の処理はGitHubをご覧ください。