MVPアーキテクチャを勉強中です。
今回はGitHubAPIを叩いてTableViewに表示するサンプルを
MVPを用いて作成します。
自身の備忘録として残しておきます。
まず、オーソドックスなMVCアーキテクチャをおさらい。
※API通信の例で簡単に説明します。
まず、Viewからユーザーのアクションを検知し、そのアクションをControllerに伝えます。
そして、Controllerは必要なModelを取得するためにAPIを叩きます。
その後、APIから必要なModelが返され、Controllerで保持されます。
ControllerからViewにModelを渡して
そのModelからViewを更新することによって画面が更新されます。
##MVCからMVPに移行する理由
結論、MVCはViewControllerに処理を詰め込みすぎて肥大し
俗に言うFatViewControllerになりやすいからです。
##MVP(Model,View,Presenter)
MVPの場合、まずViewからユーザーのアクションを検知します。
そして、Controllerにアクションを伝えてPresenterに知らせます。
Presenterは必要なModelを取得するためにAPIを叩きます。
その後、APIから必要なModelが返され、Presenterで保持されます。
PresenterからControllerにModelを渡して
そのModelをViewに渡します。
最終的に、ModelからViewを更新することによって画面が更新されます。
##Presenterとは?
Presenterは、今までMVCでViewControllerが行なってきたことのほとんどを担っており、ControllerとModelの橋渡し的存在です。
このように責務を分け合うことによってFatViewControllerを回避しています。
##実装コード
GitHubAPIを叩いて、TableViewに表示するサンプルです。
##Presenter
Presenterの特徴は下記だと考えます。
・UIはノータッチ
・UIKitはインポートしない
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に出力するからです。
#// 入力に関するプロトコル
#// 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)
}
また、MVCではViewControllerで保持していたModelを
MVPではPresenterで保持します。
更にoutputプロパティを定義し、init時にViewControllerと繋げれるようにしています。
※注意点ですが、ViewControllerとPresenterがお互いに参照し合うので
weakキーワードを付けて循環参照を防ぎます。
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の特徴として、
・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に知らせます。
そして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を繋げます。
つなげるコードは画面遷移に関するRouterクラスで実装しています。
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)
}
}
##おわりに
やはり大きなメリットとしてはViewControllerでの処理が減ったのが大きいと感じました。
また、Presenterは他に依存しないため使い回しが効くとも思います。
ソースコードは下記にまとめています。
https://github.com/taro-ken/GitHubSearchMVP