はじめに
MVPアーキテクチャについて学習をしたので、アウトプットも兼ねてミニマムなアプリを作ってみました。
これからMVPの学習を始めるという方にも分かるように、簡単な説明も書いておきます。
※実装の流れに沿ってコードも載せているので、記事が冗長に感じるかもしれません。
最後に完成した全体のものも載せてますので、道中はどうでも良い方はそちらをご覧ください。
MVPとは
Model-View-Presenterを軸とした設計のパターンのことを言います。
こちらの記事がとても分かりやすく解説されていました。いつもありがとうございます。
つまり、なんでもかんでも処理を詰め込まれているViewControllerから、データの内容(取得、修正、その他)に関わる処理を切り離すことを目的としている設計方針となっています。
MVPの特徴
ViewControllerの責務を上述の通り減らすと、どのような状態になるかというと・・・
- ViewControllerはModelに依存しない
- ViewControllerはUtility(APICallerなど)に依存しない
ということが大きな特徴かと思います。
例えばItems
というModelと、APICaller
というクラスを作っていたとします。
MVCであればViewController内で、以下のような記述をすることになります。
// データの格納
// Modelに依存している
var Items: [Item] = Items.createItems()
// データの取得
// APICallerに依存している
func reloadData() {
APICaller.shared.get() { 処理 }
}
これらはViewContrllerが依存している状態という事になります。
MVPではこのようなViewContrllerの依存関係を減らすよう、意識する必要があります。
Presenterに任せる
つまり、ViewControllerはViewの表示に関わる部分以外のことは知らない
ということになります。
それ以外の部分はPresenter
というクラスを用意しそちらに任せます。
逆にPresenterクラスは、データは取り扱うがViewの表示に関わることはせず、Controllerに渡したデータがどう扱われているかは知らない
ということになります。
実装
このような実装をします。
内容はテーブルビューがに表示されている文字列をタップすると、ラベルにその文字列が表示されるといったシンプルなものです。
環境
Xcode 12.5.1
プレビュー
![]() |
準備①:コードでアプリを立ち上げる
以前私が書いた記事です。
[Swift] 画面遷移をコードで記述する
まず、準備としてアプリの立ち上げをコードで記述します。
理由は後述しますが、ViewController
を後に出てくるPresenter
と疎結合にするためです。
大まかな設定は以下となります。
-
Info.plist
->Main storyboard file base name
のmainを空白にする -
Info.plist
からApplication Scene Manifest
の部分を削除する - AppDelegateファイルで画面表示の設定をする
- SceneDelegateファイルを削除する
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
self.window = window
Router.showRoot(window: window)
return true
}
}
続いて画面遷移処理をRouterクラスに記述します。
RouterではなくAppDelegateにそのまま書いても問題なく動きます。
import UIKit
final class Router {
static func showRoot(window: UIWindow?) {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let vc = storyboard.instantiateInitialViewController() as! ViewController
window?.rootViewController = vc
window?.makeKeyAndVisible()
}
}
NavigationControllerの有無はお好みで。
実行すればViewControllerが表示されます。
準備②:Modelを用意する
import Foundation
struct SampleItem {
let title: String
static func createItems() -> [SampleItem] {
let items = [
SampleItem(title: "Apple"),
SampleItem(title: "Banana"),
SampleItem(title: "Orange"),
SampleItem(title: "Grape"),
SampleItem(title: "Strawberry")
]
return items
}
}
文字列を取得したいだけなので、こんな感じで。
準備③:LabelとTableViewを用意する
import UIKit
class ViewController: UIViewController {
private var outputText: String = "No Selected"
@IBOutlet private weak var outputLabel: UILabel! {
didSet {
outputLabel.text = outputText
}
}
@IBOutlet private weak var tableView: UITableView! {
didSet {
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.delegate = self
tableView.dataSource = self
}
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
return cell
}
}
extension ViewController: UITableViewDelegate {
// didSelectRowAt
}
とりあえずこれで最低限の表示はできました。
ではここからデータに関わる処理をMVPで書いていきます。
1) 入出力のためのProtocol、Presenterを用意する
// 入力
protocol SamplePresenterInput: AnyObject {
}
// 出力
protocol SamplePresenterOutput: AnyObject {
}
疎結合にするために、データの受け渡しはProtocolを使用して行います。
中身は後で書きます。
final class SamplePresenter {
private weak var output: SamplePresenterOutput?
// ↓データはここで保持
private var items: [SampleItem] = SampleItem.createItems()
init(output: SamplePresenterOutput) {
self.output = output
}
}
// PresenterはInputプロトコルに準拠し、入力処理を受け付ける
extension SamplePresenter: SamplePresenterInput {
}
イニシャライザ時に、SamplePresenterOutput
に準拠しているオブジェクト(ここではViewController)を代入することで、アウトプット先を指定している。
2) PresenterとViewControllerを紐づける
class ViewController: UIViewController {
// ViewController内に以下を定義する
// ~~~ 省略 ~~~
private var presenter: SamplePresenterInput!
func inject(presenter: SamplePresenterInput)
self.presenter = presenter
}
}
// ViewControllerはOutputプロトコルに準拠し、出力処理を受け付ける
extension ViewController: SamplePresenterOutput {
}
Presenterに通知するために変数presenterを用意。
外部からPresenter()インスタンスを代入できるようにinject
メソッドも用意します。
これはViewControllerにPresenterを登録する雛形的な書き方です。
Dependency Injection
という外部からオブジェクトを注入する手法のようです。
この記事が分かりやすいと思います。参考にさせていただきました。
import UIKit
final class Router {
static func showRoot(window: UIWindow?) {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let vc = storyboard.instantiateInitialViewController() as! ViewController
// 以下の2行を追加
let presenter = SamplePresenter(output: vc)
vc.inject(presenter: presenter)
window?.rootViewController = vc
window?.makeKeyAndVisible()
}
}
Routerで先ほどViewControllerで用意したinjectメソッドを呼び出します。
このようにすることで、ViewControllerとPresenterを直接依存させずに、プロトコルを通した疎結合な形で結びつけています。
3) Presenterが保持しているデータをTableViewに表示する
では、次にViewControllerが持つTableViewに情報を与えていきます。
Cellに表示させたい文字列はModelが持っており、そのModelの情報はPresenterが持っています。
なのでViewControllerは、
- PresenterからModelを受け取る
ということが必要になります。
では先ほど保留していたProtocolに処理を書いていきます。
protocol SamplePresenterInput: AnyObject {
var numberOfItems: Int { get }
func item(index: Int) -> SampleItem
}
ViewController側からPresenterに、「情報くれよ」と通知することになるので、Inputの処理となります。
extension SamplePresenter: SamplePresenterInput {
var numberOfItems: Int {
items.count
}
func item(index: Int) -> SampleItem {
items[index]
}
}
presenterのextension以下に処理を書きます。
SamplePresenterInputプロトコルに準拠したものとなります。
PresenterはitemsとしてModelデータを保持しているので、これで外部からModelを受け取れるようにしています。
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
presenter.numberOfItems // ・・・①
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let item = presenter.item(index: indexPath.row) // ・・・②
cell.textLabel?.text = item.title // ・・・③
return cell
}
}
上記の3箇所を追記or修正します。
これでTableViewにSamplePresenterが持っているitemsの情報を表示することが出来ました。
①はInputプロトコルを通じてextension SamplePresenter: SamplePresenterInput
に記載した、numberOfItems
プロパティにアクセスしています。
②も同じように、Inputプロトコルによってitemメソッドを呼び、引数の数値を元に、対象のModelを取得しています。
4) Cellをタップした時の処理を書く
最後に、TableViewのセルをタップしたときの処理を実装します。
// 入力
protocol SamplePresenterInput: AnyObject {
var numberOfItems: Int { get }
func item(index: Int) -> SampleItem
func didSelect(index: Int) // ・・・この行を追加
}
// 出力
protocol SamplePresenterOutput: AnyObject {
func update(text: String)
}
// VCからのインプットを受け付ける
extension SamplePresenter: SamplePresenterInput {
// ~~~~ 省略 ~~~~
// 以下を追加
func didSelect(index: Int) {
output?.update(text: items[index].title) // ・・・②
}
}
extension ViewController: UITableViewDelegate {
// didSelectRowAtを追加
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
presenter.didSelect(index: indexPath.row) // ・・・①
}
}
// Presenterからの出力を受け取る
extension ViewController: SamplePresenterOutput {
func update(text: String) {
outputLabel.text = text // ・・・③
}
}
これでラベルの表示がタップしたセルのものになったと思います。
やっていることは3)とほぼ変わりません。
- セルがタップされた時①では、タップされたセルのindexをPresenterに通知
- PresenterはdidSelectメソッドを通じてindexを受け取り、自身が保持するitemsから対象のものをoutputに渡す
- Outputプロトコルのupdateメソッドを通じてtextを受け取り、ラベルに代入し、表示を更新
完成
簡単ではありますが、実装は以上です。
開発の規模が大きくなるほど、ViewControllerがFatになりやすいので、MVPを導入し可読性・保守性の高い実装をしていくよう心掛けていきたいです。
全体のコード
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
self.window = window
Router.showRoot(window: window)
return true
}
}
import UIKit
final class Router {
static func showRoot(window: UIWindow?) {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let vc = storyboard.instantiateInitialViewController() as! ViewController
let presenter = SamplePresenter(output: vc)
vc.inject(presenter: presenter)
window?.rootViewController = vc
window?.makeKeyAndVisible()
}
}
// Model
import Foundation
struct SampleItem {
let title: String
static func createItems() -> [SampleItem] {
let items = [
SampleItem(title: "Apple"),
SampleItem(title: "Banana"),
SampleItem(title: "Orange"),
SampleItem(title: "Grape"),
SampleItem(title: "Strawberry")
]
return items
}
}
import UIKit
class ViewController: UIViewController {
private var outputText: String = "No Selected"
@IBOutlet private weak var outputLabel: UILabel! {
didSet {
outputLabel.text = outputText
}
}
@IBOutlet private weak var tableView: UITableView! {
didSet {
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.delegate = self
tableView.dataSource = self
}
}
private var presenter: SamplePresenterInput!
func inject(presenter: SamplePresenterInput) {
self.presenter = presenter
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
presenter.numberOfItems
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let item = presenter.item(index: indexPath.row)
cell.textLabel?.text = item.title
return cell
}
}
extension ViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
presenter.didSelect(index: indexPath.row)
}
}
extension ViewController: SamplePresenterOutput {
func update(text: String) {
outputLabel.text = text
}
}
import Foundation
protocol SamplePresenterInput: AnyObject {
var numberOfItems: Int { get }
func item(index: Int) -> SampleItem
func didSelect(index: Int)
}
protocol SamplePresenterOutput: AnyObject {
func update(text: String)
}
final class SamplePresenter {
private weak var output: SamplePresenterOutput?
private var items: [SampleItem] = SampleItem.createItems()
init(output: SamplePresenterOutput) {
self.output = output
}
}
extension SamplePresenter: SamplePresenterInput {
var numberOfItems: Int {
items.count
}
func item(index: Int) -> SampleItem {
items[index]
}
func didSelect(index: Int) {
output?.update(text: items[index].title)
}
}