はじめに
今までは、ずっと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は入力・出力先を変えてテスト可能です。
おわりに
ソースコードはこちらにあげてます。
何かあれば、コメントして下さると有り難いです。