【Swift】MVC(マシマシViewController)をMVPにリファクタリングしながら設計パターンを学んでみた
今まで個人ではあまり設計パターンというものに触れることなく、いわゆるMVC的な設計ばかりを行なっていましたが、他の設計パターンも試してみたいと思い今回MVPアーキテクチャを学習してみることにしました。
MVC
MVCはよくジョークでMassive View Controlllerと言われたりしますが、これはViewController
クラスが大規模(Massive)になっていく問題のことを指しています。
この問題は単純にコード量が多いことだけが問題ではなく、様々なロジックがViewController
に増し増しで乗っかってしまい、抱える責務が多くなってしまっているところにあります。
抱える責務が多くなることで、他のロジックとの依存関係が深くなり、テストがしづらかったり、問題箇所の発見に時間が掛かったり、保守のしにくさに繋がります。
ひとつの器(ViewController
)にロジックが増し増しで盛られており、さらに依存関係も深いので、ちょっとした変更にもすぐには対応しにくいような状況になってしまう場合があります。
まさにこれが
- M = Mashimashi
- V = View
- C = Controller
問題です。笑
そして、この問題を解決する為のさまざまな設計パターンが存在しており、今回は設計パターンのひとつMVPを学びながら、マシマシViewControllerのリファクタリングにチャレンジしました。
補足
今回はMVPを学んでいく前提の中で、MVCのデメリットの部分を挙げましたがMVCが悪い設計パターンというものではありません。 個人的にも好きなアーキテクチャですし、MVCのメリットを発揮できるケースも勿論ありますのでその時々によって何が良いかは検討する必要があるかと思われます。
作ったアプリ
- アプリを起動すると、APIからビールのデータを取得して
tableView
に反映します。 tableView
をdidSelectRow
するとビールの情報がアラート表示されるシンプルなアプリ
(アラートの用途としてはとても微妙ですが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
に入力イベントが渡り、それに応じてPresenter
はModel
に指示を出したりします。Model
から値が返ってくると、Presenter
はView
にOutputを通じて画面描画を指示を出すと流れになります。
フロー同期
上位レイヤーのデータを下位レイヤーに都度セットしてデータを同期する、手続き的な同期方法。 いわばバケツリレーみたいな感じですね。
MVPの各役割分担
マシマシViewControllerで作ったアプリをそれぞれの役割に切り分けながらコードと共に見ていきましょう。
Model
Model
は画面描画とプレゼンテーション以外のドメインロジックを全て担当し、今回はAPI通信を行いビールのデータを取得するロジックを担当しています。
Model
はPresenter
からのみアクセスされ、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()
tableView
のnumberOfRowsInSection
の中でpresenter.numberOfBeers
tableView
のcellForRowAt
の中でpresenter.beer(forRow: indexPath.row)
tableView
のdidSelectRowAt
の中で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
Presenter
はView
とModel
の仲介役で、全てのプレゼンテーションロジックを担っており、ビジネスロジックと画面描画を疎結合にする役割を果たしています。
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への愛も深まったような気がします。
参考
- iOSアプリ設計パターン入門
- 「ビジネスロジック」とは何か、どう実装するのか
- https://rakusui.org/swift_arc/
- モバイルアプリアーキテクチャ勉強会
- iOSDC Japan 2017 / 節子、それViewControllerやない...、FatViewControllerや...。
- iOSDC 2017 前夜祭で「節子、それViewControllerやない…、FatViewControllerや…。」というタイトルで登壇しました! #iosdc
- MVPでiOSアプリをつくってみた
- iOSをMVC,MVP,MVVM,Clean Architectureで実装してみた