【Swift】MVC(マシマシViewController)をMVPにリファクタリングしながら設計パターンを学んでみた

2021.07.25

今まで個人ではあまり設計パターンというものに触れることなく、いわゆるMVC的な設計ばかりを行なっていましたが、他の設計パターンも試してみたいと思い今回MVPアーキテクチャを学習してみることにしました。

MVC

MVCはよくジョークでMassive View Controlllerと言われたりしますが、これはViewControllerクラスが大規模(Massive)になっていく問題のことを指しています。

この問題は単純にコード量が多いことだけが問題ではなく、様々なロジックがViewControllerに増し増しで乗っかってしまい、抱える責務が多くなってしまっているところにあります。

抱える責務が多くなることで、他のロジックとの依存関係が深くなり、テストがしづらかったり、問題箇所の発見に時間が掛かったり、保守のしにくさに繋がります。

ひとつの器(ViewController)にロジックが増し増しで盛られており、さらに依存関係も深いので、ちょっとした変更にもすぐには対応しにくいような状況になってしまう場合があります。

まさにこれが

  • M = Mashimashi
  • V = View
  • C = Controller

問題です。笑

そして、この問題を解決する為のさまざまな設計パターンが存在しており、今回は設計パターンのひとつMVPを学びながら、マシマシViewControllerのリファクタリングにチャレンジしました。

補足

今回はMVPを学んでいく前提の中で、MVCのデメリットの部分を挙げましたがMVCが悪い設計パターンというものではありません。 個人的にも好きなアーキテクチャですし、MVCのメリットを発揮できるケースも勿論ありますのでその時々によって何が良いかは検討する必要があるかと思われます。

作ったアプリ

  1. アプリを起動すると、APIからビールのデータを取得してtableViewに反映します。
  2. tableViewdidSelectRowするとビールの情報がアラート表示されるシンプルなアプリ

(アラートの用途としてはとても微妙ですがMVPお試しアプリということで大目にみてあげてくださいw)

MVCのコード

とても極端な例ですが、データモデルの構造体以外をViewControllerクラスで書いています。

データモデル構造体

struct Beer: Decodable {
    let name: String
    let tagline: String
    let description: String
}

マシマシViewController

import UIKit

class MashimashiViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView!

    private var beers = [Beer]()

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.delegate = self
        tableView.dataSource = self

        fetchBeers { result in
            switch result {
            case .failure(let error):
                let alert = UIAlertController(title: "Error",
                                              message: "\(error)",
                                              preferredStyle: .alert)
                let alertAction = UIAlertAction(title: "OK", style: .cancel)
                alert.addAction(alertAction)

                DispatchQueue.main.async {
                    self.present(alert, animated: true)
                }

            case .success(let beers):
                self.beers = beers

                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
        }
    }

    private func fetchBeers(completion: @escaping ((Result<[Beer], Error>) -> ())) {
        guard let url = URL(string: "https://api.punkapi.com/v2/beers")
        else { return }
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            let jsonDecoder = JSONDecoder()
            jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase

            guard let data = data,
                  let decodedData = try? jsonDecoder.decode([Beer].self, from: data) else {
                let error = NSError(domain: "parse-error",
                                    code: 1,
                                    userInfo: nil)
                completion(.failure(error))
                return
            }
            completion(.success(decodedData))
        }
        task.resume()
    }
}

    // MARK: - UITable view data source
extension MashimashiViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return beers.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.IdOfBeerList, for: indexPath)
        let beer = beers[indexPath.row]
        cell.textLabel?.text = beer.name
        return cell
    }
}

    // MARK: - UITable view delegate
extension MashimashiViewController: UITableViewDelegate {

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let beer = beers[indexPath.row]
        let alert = UIAlertController(title: beer.name,
                                      message: "\(beer.tagline)\n\n\(beer.description)",
                                      preferredStyle: .alert)
        let alertAction = UIAlertAction(title: "OK", style: .cancel)
        alert.addAction(alertAction)
        present(alert, animated: true)
    }
}

こちらをMVPに切り分けていきます。

MVP

そもそもMVPとは?

  • M = Model
  • V = View
  • P = Presenter

こちらの略で、画面の描画処理とプレゼンテーションロジックを担当する処理を分離したGUIアーキテクチャです。

プレゼンテーションロジック

プレゼンテーションロジックとは、そのアプリケーションとユーザとのやりとりを担当するロジックです。

Passive view方式

MVPといっても幾つかの方式があり、それぞれ若干の違いがありますが今回はフロー同期を行うPassvie Viewを採用していきます。 これ以降のMVPにつきましては、このPassive viewのMVPを指しております。 その他のMVPの各方式について気になる方は是非調べてみてください🍜

Passive View方式のMVPでは、画面の描画処理を担当するView(ViewControllerも含まれる)と、プレゼンテーションロジックのみを専属で担当するPresenterと、その二つ以外を担当してくれるModelに分けられます。 Presenterが登場することで、ビジネスロジックと描画の世界を明確に切り分けることができます。

構成

ユーザーが操作し、ViewからPresenterに入力イベントが渡り、それに応じてPresenterModelに指示を出したりします。Modelから値が返ってくると、PresenterViewOutputを通じて画面描画を指示を出すと流れになります。

フロー同期

上位レイヤーのデータを下位レイヤーに都度セットしてデータを同期する、手続き的な同期方法。 いわばバケツリレーみたいな感じですね。

MVPの各役割分担

マシマシViewControllerで作ったアプリをそれぞれの役割に切り分けながらコードと共に見ていきましょう。

Model

Modelは画面描画とプレゼンテーション以外のドメインロジックを全て担当し、今回はAPI通信を行いビールのデータを取得するロジックを担当しています。

ModelPresenterからのみアクセスされ、Viewとは直接の関わりを持ちません。 そして、他のコンポーネントに依存しない設計なので、Modelのみでビルド可能でテストのしやすさに繋がります。 さらにprotocolで要件を明示しておくことで行うべき役割が明確になっています。

import Foundation

protocol PunkAPIDataModelInput {
    func fetchBeers(completion: @escaping ((Result<[Beer], Error>) -> ()))
}

class PunkAPIDataModel: PunkAPIDataModelInput {

    func fetchBeers(completion: @escaping ((Result<[Beer], Error>) -> ())) {
        guard let url = URL(string: "https://api.punkapi.com/v2/beers")
        else { return }
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            let jsonDecoder = JSONDecoder()
            jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase

            guard let data = data,
                  let decodedData = try? jsonDecoder.decode([Beer].self, from: data) else {
                let error = NSError(domain: "parse-error",
                                    code: 1,
                                    userInfo: nil)
                completion(.failure(error))
                return
            }
            completion(.success(decodedData))
        }
        task.resume()
    }
}

View

Viewの役割としては、

  • Viewは、ユーザーの入力をPresenterに伝え、全てPresenterが受け持つ
  • Presenterの指示によって画面の描画処理を行う

Viewは基本的に全てのユーザーの入力イベントをPresenterに渡します。 入力イベントとは、今回でいうところのセルがタップされたや、ViewControllerライフサイクルDelegateメソッド部分になっており、

  • viewDidLoad()で、presenter.viewDidLoad()
  • tableViewnumberOfRowsInSectionの中でpresenter.numberOfBeers
  • tableViewcellForRowAtの中でpresenter.beer(forRow: indexPath.row)
  • tableViewdidSelectRowAtの中でpresenter.didSelectRowAt(indexPath)

今回はこれらの入力イベントをPresenterに伝えています。

画面描画の処理の部分ですが、extension BeerListViewController: BeerListPresenterOutputの中で、Presenterからのアウトプットを受けて、Viewが画面描画処理をしています。

import UIKit

class BeerListViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView!

    private var presenter: BeerListPresenter!

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.delegate = self
        tableView.dataSource = self
        presenter = BeerListPresenter.init(with: self)
        presenter.viewDidLoad()
    }
}

extension BeerListViewController: BeerListPresenterOutput {

    func didFetch(_ beers: [Beer]) {
        DispatchQueue.main.async {
            self.tableView.reloadData()
        }
    }

    func didFailToFetchBeer(with error: Error) {
        UIAlertController.present(on: self,
                                  title: "Error",
                                  messsage: "\(error)",
                                  cancelActionTitle: "OK",
                                  shouldWorkOnMainThread: true)
    }

    func didPrepareInfomation(of beer: Beer) {
        UIAlertController.present(on: self,
                                  title: beer.name,
                                  messsage: "\(beer.tagline)\n\n\(beer.description)",
                                  cancelActionTitle: "OK")
    }
}

    // MARK: - UITableView DataSource
extension BeerListViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return presenter.numberOfBeers
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.IdOfBeerList,
                                                 for: indexPath)
        let beer = presenter.beer(forRow: indexPath.row)
        cell.textLabel?.text = beer?.name
        return cell
    }
}

    // MARK: - UITableView Delegate
extension BeerListViewController: UITableViewDelegate {

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        presenter.didSelectRowAt(indexPath)
    }
}

Presenter

PresenterViewModelの仲介役で、全てのプレゼンテーションロジックを担っており、ビジネスロジックと画面描画を疎結合にする役割を果たしています。

  • Presenterは画面描画処理には関与しないのでUIKitをインポートしておりません。
  • BeerListPresenterInputでは、protocolで入力要件を明確にしております。
  • BeerListPresenterOutputでは、protocolを介してViewへの画面描画の指示を行っています。
  • ViewがPresenterを知っている」状態としてPresenterからはViewをweak参照で持つようにします
  • Presenterで実際に通信を行ってくれるモデルのインスタンスを保持しています。
protocol BeerListPresenterInput {
    var numberOfBeers: Int { get }
    func beer(forRow row: Int) -> Beer?
    func viewDidLoad()
    func didSelectRowAt(_ indexPath: IndexPath)
}

protocol BeerListPresenterOutput: AnyObject {
    func didFetch(_ beers: [Beer])
    func didFailToFetchBeer(with error: Error)
    func didPrepareInfomation(of beer: Beer)
}

class BeerListPresenter: BeerListPresenterInput {

    private(set) var beers = [Beer]()

    private weak var view: BeerListPresenterOutput?
    private var dataModel: PunkAPIDataModelInput

    init(with view: BeerListPresenterOutput) {
        self.view = view
        self.dataModel = PunkAPIDataModel()
    }

    var numberOfBeers: Int {
        return beers.count
    }

    func beer(forRow row: Int) -> Beer? {
        if row >= beers.count {
            return nil
        }
        return beers[row]
    }

    func viewDidLoad() {
        dataModel.fetchBeers { [weak self] result in
            switch result {
            case .failure(let error):
                self?.view?.didFailToFetchBeer(with: error)
            case .success(let loadedBeer):
                self?.beers = loadedBeer
                guard let beers = self?.beers
                else { fatalError() }
                self?.view?.didFetch(beers)
            }
        }
    }

    func didSelectRowAt(_ indexPath: IndexPath) {
        guard let beer = beer(forRow: indexPath.row)
        else { return }
        view?.didPrepareInfomation(of: beer)
    }
}

これでViewControllerをMVPとして切り分けることができました!

おわりに

アーキテクチャの入門編として、MVPにチャレンジをしました。 今回は画面遷移もなく、とても短機能なシンプルなアプリだったのであまりMVPの真骨頂に触れることはできておりませんが、MVPアーキテクチャの雰囲気は感じれたのではないかと思います。

今回、iOSアプリ設計パターン入門をはじめ、様々な記事をみながら自分自身で噛み砕きながら解釈し、学習を進めたのでもし間違いや改善点などありましたら優しく教えていただけると幸いです。

MVPではよく「テストがしやすい」というフレーズが出てくるのですが、設計上テストがしやすい作りでも本人がテストができなければ何も意味がないのでこれからテストについての学びも深めていきたいと思います。

そして、MVPを勉強していったことで、MVCのありがたみも感じ、MVCへの愛も深まったような気がします。

参考