LoginSignup
51
42

More than 1 year has passed since last update.

MVPアーキテクチャについて

Posted at

はじめに

今までは、ずっとMVCを採用してアプリを開発してきましたが
MVC脱却を図るため、今回はMVPに関することを備忘録として残しておきます。

まず、MVPを知る前にMVCの流れを復習していきましょう。

MVC

MVC画像.001.jpeg

API通信の例で簡単に説明すると、

まず、Viewからユーザーのアクションを検知し、そのアクションをControllerに伝えます。
そして、Controllerは必要なModelを取得するためにAPIを叩きます。

その後、APIから必要なModelが返され、Controllerで保持されます。

ControllerからViewModelを渡して
そのModelからViewを更新することによって画面が更新されます。

なぜMVCから脱却したいのか?

MVCだとFatViewControllerになりやすいからです。
iOSDC 2017 前夜祭で「節子、それViewControllerやない…、FatViewControllerや…。」というタイトルで登壇しました!

余談ですが、上記の記事もMVPについて書かれてました!

MVP

MVP画像.001.jpeg

先ほどと同じようにAPI通信の例で説明すると、

まず、Viewからユーザーのアクションを検知します。
そして、Controllerにアクションを伝えてPresenterに知らせます。

Presenterは必要なModelを取得するためにAPIを叩きます。
その後、APIから必要なModelが返され、Presenterで保持されます。

PresenterからControllerModelを渡して
そのModelViewに渡します。

最終的に、ModelからViewを更新することによって画面が更新されます。

Presenterについて

Presenterは、今までMVCでViewControllerが行なってきたことのほとんどを担っています。
APIを叩いたり、Modelを保持したり等ですね。

そのような責務を引き剥がすことによってFatViewControllerを回避しています。

では実際に、コードで確認していきましょう。

実装コード

今回は、Github APIを使用したアプリを例に説明していきます。
ezgif.com-gif-maker.gif

Presenter

まずは、Presenterのコードです。

Presenterの特徴として、

UIがどうなっているかは考慮しない
UIKitをインポートしない
Xcodeじゃなくてもコードが書ける

などが挙げられます。

iOS特有のUIKitSwiftUIなどに依存しないので極端にいうとメモアプリでも書けます。

MVPSearchPresenter.swift
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に関すること以外は書かない
iffor等といった制御構文が入らない

などが挙げられます。

MVPSearchViewController.swift
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を繋げる準備はしましたが、このままだとまだ繋がっていません。

ではどこで繋げていくかというと、画面遷移に関するクラスにて繋げています。
画面遷移に関係あるコードの記述を別ファイルに分けて実装する

Router.swift
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は入力・出力先を変えてテスト可能です。

おわりに

ソースコードはこちらにあげてます。

何かあれば、コメントして下さると有り難いです。

51
42
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
51
42