はじめに
今までは、ずっとMVCを採用してアプリを開発してきましたが
MVC脱却を図るため、今回はMVPに関することを備忘録として残しておきます。
まず、MVPを知る前にMVCの流れを復習していきましょう。
MVC
API通信の例で簡単に説明すると、
まず、Viewからユーザーのアクションを検知し、そのアクションをControllerに伝えます。
そして、Controllerは必要なModelを取得するためにAPIを叩きます。
その後、APIから必要なModelが返され、Controllerで保持されます。
ControllerからViewにModelを渡して
そのModelからViewを更新することによって画面が更新されます。
なぜMVCから脱却したいのか?
MVCだとFatViewControllerになりやすいからです。
iOSDC 2017 前夜祭で「節子、それViewControllerやない…、FatViewControllerや…。」というタイトルで登壇しました!
余談ですが、上記の記事もMVPについて書かれてました!
MVP
先ほどと同じようにAPI通信の例で説明すると、
まず、Viewからユーザーのアクションを検知します。
そして、Controllerにアクションを伝えてPresenterに知らせます。
Presenterは必要なModelを取得するためにAPIを叩きます。
その後、APIから必要なModelが返され、Presenterで保持されます。
PresenterからControllerにModelを渡して
そのModelをViewに渡します。
最終的に、ModelからViewを更新することによって画面が更新されます。
Presenterについて
Presenterは、今までMVCでViewControllerが行なってきたことのほとんどを担っています。
APIを叩いたり、Modelを保持したり等ですね。
そのような責務を引き剥がすことによってFatViewControllerを回避しています。
では実際に、コードで確認していきましょう。
実装コード
今回は、Github APIを使用したアプリを例に説明していきます。

Presenter
まずは、Presenterのコードです。
Presenterの特徴として、
・UIがどうなっているかは考慮しない
・UIKitをインポートしない
・Xcodeじゃなくてもコードが書ける
などが挙げられます。
iOS特有のUIKit、SwiftUIなどに依存しないので極端にいうとメモアプリでも書けます。
import Foundation
protocol MVPSearchPresenterInput {
var numberOfItems: Int { get }
func item(index: Int) -> GithubModel
func search(param: String?)
func didSelect(index: Int)
}
protocol MVPSearchPresenterOutput: AnyObject {
func update(loading: Bool)
func update(githubModels: [GithubModel])
func validation(error: ParameterValidationError)
func get(error: Error)
func showWeb(url: URL)
}
final class MVPSearchPresenter {
private weak var output: MVPSearchPresenterOutput!
private var api: GithubAPIProtocol!
private var githubModels: [GithubModel]
init(output: MVPSearchPresenterOutput, api: GithubAPIProtocol = GithubAPI.shared) {
self.output = output
self.api = api
self.githubModels = []
}
}
extension MVPSearchPresenter: MVPSearchPresenterInput {
var numberOfItems: Int {
githubModels.count
}
func item(index: Int) -> GithubModel {
githubModels[index]
}
func search(param: String?) {
if let validationError = ParameterValidationError(param: param) {
output.validation(error: validationError)
return
}
guard let searchText = param else { return }
output.update(loading: true)
self.api.get(searchText: searchText) {[weak self] (result) in
guard let self = self else{ return }
switch result {
case .success(let githubModels):
self.output.update(loading: false)
if githubModels.isEmpty {
self.output.get(error: AppError.emptyApiResponce.error)
return
}
self.githubModels = githubModels
self.output.update(githubModels: githubModels)
case .failure(let error):
self.output.update(loading: false)
self.output.get(error: error)
}
}
}
func didSelect(index: Int) {
guard let githubUrl = URL(string: githubModels[index].urlStr) else {
output.get(error: AppError.getApiData.error)
return
}
output.showWeb(url: githubUrl)
}
}
ではコードを細かく説明していきます。
・コード説明①
まず、ViewControllerとPresenterを繋げないといけません。
PresenterがViewControllerからの入力を受け取り
その結果をViewControllerに出力しないといけないからです。
MVPでは依存性を低くするためにprotocolによって繋げます。
以下が実際にprotocolを定義している部分です↓
// 入力に関するプロトコル
// ViewControllerから送られてくる
protocol MVPSearchPresenterInput {
var numberOfItems: Int { get }
func item(index: Int) -> GithubModel
func search(param: String?)
func didSelect(index: Int)
}
// 出力に関するプロトコル
// ViewControllerに結果を渡す
protocol MVPSearchPresenterOutput: AnyObject {
func update(loading: Bool)
func update(githubModels: [GithubModel])
func validation(error: ParameterValidationError)
func get(error: Error)
func showWeb(url: URL)
}
・コード説明②
Presenterは、Modelを内部で保持します。
更にoutputプロパティを定義し、init時にViewControllerと繋げれるようにしています。
ここで注意すべき点は、ViewControllerとPresenterがお互いに参照し合うので
weakキーワードを付けて循環参照にならないようにしないといけません。
クロージャの中に書く[weak self]についてまとめてみた
final class MVPSearchPresenter {
// ViewControllerとPresenterが参照し合い循環参照が起きるためweakキーワードを付ける
// このoutputがViewControllerのこと
private weak var output: MVPSearchPresenterOutput!
private var api: GithubAPIProtocol!
// Modelを保持する
private var githubModels: [GithubModel]
init(output: MVPSearchPresenterOutput, api: GithubAPIProtocol = GithubAPI.shared) {
self.output = output
self.api = api
self.githubModels = []
}
}
・コード説明③
MVPSearchPresenterInputプロトコルを準拠してViewControllerからの入力を処理します。
Presenterとしては、何でか分からないけど
とりあえず入力が来たから処理して出力したって感じです。
Presenterにとっては、検索バーがタップされたとか画面が遷移したとかは関係なく知らなくても良いのです。
更に、入力・出力する相手も関係ないのでテストがしやすくデザインを決める前にも開発が可能です。
extension MVPSearchPresenter: MVPSearchPresenterInput {
var numberOfItems: Int {
githubModels.count
}
func item(index: Int) -> GithubModel {
githubModels[index]
}
func search(param: String?) {
if let validationError = ParameterValidationError(param: param) {
// ViewControllerに任せる
output.validation(error: validationError)
return
}
guard let searchText = param else { return }
// ViewControllerに任せる
output.update(loading: true)
// API通信
self.api.get(searchText: searchText) {[weak self] (result) in
guard let self = self else{ return }
switch result {
case .success(let githubModels):
// ViewControllerに任せる
self.output.update(loading: false)
if githubModels.isEmpty {
// ViewControllerに任せる
self.output.get(error: AppError.emptyApiResponce.error)
return
}
self.githubModels = githubModels
// ViewControllerに任せる
self.output.update(githubModels: githubModels)
case .failure(let error):
// ViewControllerに任せる
self.output.update(loading: false)
self.output.get(error: error)
}
}
}
func didSelect(index: Int) {
guard let githubUrl = URL(string: githubModels[index].urlStr) else {
// ViewControllerに任せる
output.get(error: AppError.getApiData.error)
return
}
// ViewControllerに任せる
output.showWeb(url: githubUrl)
}
}
ViewController
では、ViewControllerのコードです。
ViewControllerの特徴として、
・Viewに関すること以外は書かない
・ifやfor等といった制御構文が入らない
などが挙げられます。
import UIKit
final class MVPSearchViewController: UIViewController {
@IBOutlet weak private var tableView: UITableView! {
didSet {
tableView.register(UINib(nibName: TableViewCell.className, bundle: nil), forCellReuseIdentifier: TableViewCell.className)
}
}
@IBOutlet weak private var indicator: UIActivityIndicatorView!
private var searchBar = UISearchBar()
private var input: MVPSearchPresenterInput!
func inject(input: MVPSearchPresenterInput) {
self.input = input
}
override func viewDidLoad() {
super.viewDidLoad()
self.navigationItem.titleView = searchBar
searchBar.delegate = self
}
}
extension MVPSearchViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
input.search(param: searchBar.text)
searchBar.resignFirstResponder()
}
}
extension MVPSearchViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
input.numberOfItems
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.className, for: indexPath) as! TableViewCell
let githubModel = input.item(index: indexPath.item)
cell.configure(githubModel: githubModel)
return cell
}
}
extension MVPSearchViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
input.didSelect(index: indexPath.row)
}
}
extension MVPSearchViewController: MVPSearchPresenterOutput {
func update(loading: Bool) {
indicator.animation(isStart: loading)
}
func update(githubModels: [GithubModel]) {
DispatchQueue.main.async {
self.searchBar.text = ""
self.searchBar.resignFirstResponder()
self.tableView.reloadData()
}
}
func validation(error: ParameterValidationError) {
Alert.okAlert(vc: self, title: error.message, message: "")
}
func get(error: Error) {
Alert.okAlert(vc: self, title: error.localizedDescription, message: "")
}
func showWeb(url: URL) {
Router.showWeb(url: url, from: self)
}
}
では細かく説明していきます。
・コード説明①
まず、Presenterと繋げるためにinputプロパティとinjectメソッドを用意します。
この部分で外部からPresenterを繋げます。
// このinputがpresenterのこと
private var input: MVPSearchPresenterInput!
// ここで外部からPresenterを繋げる
func inject(input: MVPSearchPresenterInput) {
self.input = input
}
・コード説明②
今回は、キーボードの検索ボタンを押した時にAPI通信を行い
その結果をTableViewに表示するといったアプリとなっています。
なので検索ボタンを押した時にPresenterに知らせないといけません。
extension MVPSearchViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
// Presenterに知らせる
input.search(param: searchBar.text)
searchBar.resignFirstResponder()
}
}
・コード説明③
Presenterからの結果をViewControllerが受け取らないといけません。
なので、MVPSearchPresenterOutputプロトコルを準拠して
以下のように結果を受け取れるようにします。
extension MVPSearchViewController: MVPSearchPresenterOutput {
func update(loading: Bool) {
// インディケータを回すかどうかを決めている
indicator.animation(isStart: loading)
}
func update(githubModels: [GithubModel]) {
// TableViewの更新など
DispatchQueue.main.async {
self.searchBar.text = ""
self.searchBar.resignFirstResponder()
self.tableView.reloadData()
}
}
func validation(error: ParameterValidationError) {
// アラート表示
Alert.okAlert(vc: self, title: error.message, message: "")
}
func get(error: Error) {
Alert.okAlert(vc: self, title: error.localizedDescription, message: "")
}
func showWeb(url: URL) {
// 画面遷移する
Router.showWeb(url: url, from: self)
}
}
PresenterとViewControllerを繋げる
PresenterとViewControllerを繋げる準備はしましたが、このままだとまだ繋がっていません。
ではどこで繋げていくかというと、画面遷移に関するクラスにて繋げています。
画面遷移に関係あるコードの記述を別ファイルに分けて実装する
final class Router {
static func showMVPSearch(from: UIViewController) {
let mvpSearchVC = UIStoryboard.mvpSearchViewController
// ここでPresenterとViewControllerを繋げている
let presenter = MVPSearchPresenter(output: mvpSearchVC)
mvpSearchVC.inject(input: presenter)
from.show(next: mvpSearchVC)
}
}
MVPを採用してみて
単純にViewControllerの記述量が減ったのが良きですね。
今回のサンプルアプリでも、その恩恵を得られたので
大規模なアプリだと、もっと効果を感じれそうです。
PresenterはViewがどうなろうと関係ないのでアプリの仕様が分かっていれば
すぐに開発できるメリットもありますし、何ならXcodeにも依存していません。
後は、入力・出力先に依存していないのでテストしやすいです。
何か不明な不具合があった時にでも、Presenterは入力・出力先を変えてテスト可能です。
おわりに
ソースコードはこちらにあげてます。
何かあれば、コメントして下さると有り難いです。

