11
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[Swift] MVPの基礎をシンプルなアプリを作りながら説明する

Posted at

はじめに

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ファイルを削除する
AppDelegate.swift
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にそのまま書いても問題なく動きます。

Router.swift
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を用意する

SampleModel.swift
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を用意する

ViewController.swift
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を使用して行います。
中身は後で書きます。

Presenter.swift
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を紐づける

ViewController.swift

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という外部からオブジェクトを注入する手法のようです。

この記事が分かりやすいと思います。参考にさせていただきました。

Router.swift
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の処理となります。

Presenter.swift
extension SamplePresenter: SamplePresenterInput {

    var numberOfItems: Int {
        items.count
    }
    
    func item(index: Int) -> SampleItem {
        items[index]
    }
}

presenterのextension以下に処理を書きます。
SamplePresenterInputプロトコルに準拠したものとなります。
PresenterはitemsとしてModelデータを保持しているので、これで外部からModelを受け取れるようにしています。

ViewController.swift
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)
}
Presenter.swift
// VCからのインプットを受け付ける
extension SamplePresenter: SamplePresenterInput {
    // ~~~~ 省略 ~~~~
    // 以下を追加
    func didSelect(index: Int) {
        output?.update(text: items[index].title) // ・・・②
    }
}
ViewController.swift
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)とほぼ変わりません。

  1. セルがタップされた時①では、タップされたセルのindexをPresenterに通知
  2. PresenterはdidSelectメソッドを通じてindexを受け取り、自身が保持するitemsから対象のものをoutputに渡す
  3. Outputプロトコルのupdateメソッドを通じてtextを受け取り、ラベルに代入し、表示を更新

完成

簡単ではありますが、実装は以上です。

開発の規模が大きくなるほど、ViewControllerがFatになりやすいので、MVPを導入し可読性・保守性の高い実装をしていくよう心掛けていきたいです。

全体のコード

AppDelegate.swift
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.swift
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()
    }
}
SampleItem.swift
// 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
    }
}
ViewController.swift
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
    }
}
Presenter.swift
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)
    }  
}

11
3
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
11
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?