【iOS・設計】MVPアーキテクチャ【サンプルコード付き】

公開日: 

はじめに

この記事では、前回に引き続き、iOS アプリのアーキテクチャについて紹介します。今回はMVPです。

前回の記事

https://www.yukendev.com/blogs/swift-mvc

アーキテクチャとは?

ていうかたはよかったら下の記事を読んで見てください。

https://www.yukendev.com/blogs/book-ios-architecture

MVP アーキテクチャとは

簡単にいうと MVC のプレゼンテーションロジックを分離したバージョンっていうイメージです。MVC では、View が描画処理に集中し Controller がユーザーの入力の受付やプレゼンテーションロジックを担当していましたが、MVPでは View に描画処理とユーザーの入力の受付を任せ、そして新たにPresenterにプレゼンテーションロジックを任せます。下の画像のようなイメージです。

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

プレゼンテーションロジックの例が分かりにくいかもしれないので簡単な例を挙げると、20 文字以下ならアラートを表示するというバリデーションにおいて、MVC ではバリデーションとアラート表示を全て Controller に書きます。MVP ではバリデーション部分を Presenter に書いて『アラートを出す』という処理を PresenterView に伝えます。これがプレゼンテーションロジックの分離ということです。

2 種類のデータ同期方法

MVP において、それぞれのコンポーネントの間でデータのやり取り(同期)をする必要があるわけですが参照を持ち、直接的にデータの受け渡しをする方法をフロー同期、Observer パターンを使ってデータの受け渡しをする方法をオブザーバー同期と言ったりします。

2 種類の MVP

先ほど説明した 2 種類のデータ同期方法の使い方で MVP は大きく 2 種類に分類されます。Passive ViewSupervising Controllerです。描画更新に Presenter から View へのフロー同期のみを用いるのがPassive View。それに加えて Model から View へのオブザーバー同期を用いるのがSupervising Controllerです。

PassiveView の特徴

メリット

  • フロー同期のみを用いているので処理の流れがわかりやすい デメリット 描画処理が必ず Presenter を介するので処理が冗長になりやすい

SupervisingController の特徴

メリット

  • 共通したデータの同期がしやすい
  • 上位レイヤーが下位レイヤーの参照を持つ必要がない(依存しない) デメリット
  • 処理の流れが追いにくい

どちらが正解ということはないので、アプリの規模やチームの考え方に合わせてどちらかを選定するのが良さそうです。

まとめ

まとめると

  • Model: 各種ビジネスロジックのかたまり
  • View: 画面の描画、入力の受付
  • Presenter: Model と View の仲介役であり、プレゼンテーションロジックを担う

MVP の概要は以上です。

サンプルアプリ

以上の MVP の考え方を用いて簡単な Todo アプリを作りました。今回のサンプルでは PassiveView の MVP を採用しています。また、プロトコルを用いてよりテストがしやすいように意識しました。(MVP でプロトコルを用いてインターフェイスを宣言するのは割とあるあるっぽいです)

ソースコード

https://github.com/yukendev/sampleMVP

View.swift
import UIKit

// MARK: -- View
class ViewController: UIViewController {


    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var addButton: UIButton!
    @IBOutlet weak var tableView: UITableView!

    private var presenter: PresenterInput!

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.delegate = self
        tableView.dataSource = self

        self.presenter = Presenter(view: self, model: Model())
        presenter.viewDidLoad()

    }

    @IBAction func addButtonClicked(_ sender: Any) {
        presenter.didTapAddButton(todoText: textField.text)
    }

}

extension ViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return presenter.numberOfTodo
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell.init(style: .default, reuseIdentifier: "cell")
        cell.textLabel?.text = presenter.todo(forRow: indexPath.row)?.todo
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let todo = presenter.todo(forRow: indexPath.row) else {
            return
        }
        let alert = UIAlertController(title: "\(todo.todo)』を削除します。よろしいですか?", message: "", preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default) { _ in
            // OKボタン
            self.presenter.didTapTodoCell(at: indexPath)
        }
        let cancelAction = UIAlertAction(title: "cancel", style: .cancel)
        alert.addAction(okAction)
        alert.addAction(cancelAction)
        self.present(alert, animated: true, completion: nil)
    }
}


extension ViewController: PresenterOutput {

    func updateTodo() {
        textField.text = ""
        tableView.reloadData()
    }

    func todoValidation(validation: TodoValidation) {
        self.textField.text = ""
        let alert = UIAlertController(title: validation.alert, message: "", preferredStyle: .alert)
        let okButton = UIAlertAction(title: "OK", style: .cancel)
        alert.addAction(okButton)
        self.present(alert, animated: true, completion: nil)
    }
}
Presenter.swift
import Foundation

protocol PresenterInput {
    var numberOfTodo: Int { get }
    func todo(forRow  row: Int)  -> Todo?
    func didTapAddButton(todoText: String?)
    func didTapTodoCell(at indexPath: IndexPath)
    func viewDidLoad()
}

protocol PresenterOutput: AnyObject {
    func todoValidation(validation: TodoValidation)
    func updateTodo()
}

// MARK: -- Presenter
final class Presenter: PresenterInput {

    private(set) var todoList: [Todo] = []

    var numberOfTodo: Int {
        return todoList.count
    }

    private weak var view: PresenterOutput!
    private var model: ModelInput

    init(view: PresenterOutput, model: ModelInput) {
        self.view = view
        self.model = model
    }

    func todo(forRow row: Int) -> Todo? {
        guard row < todoList.count else { return nil }
        return todoList[row]
    }

    func viewDidLoad() {
        model.getTodo() { todoList in
            self.todoList = todoList
            self.view.updateTodo()
        }
    }

    func didTapAddButton(todoText: String?) {
        // 空白バリデーション
        guard !(todoText ?? "").isEmpty else {
            self.view.todoValidation(validation: .blank)
            return
        }

        // 文字数バリデーション
        if todoText!.count > 20 {
            self.view.todoValidation(validation: .overMaximumCharacters)
            return
        }

        // todoを保存
        let todo = Todo(todo: todoText!)
        self.model.addTodo(todo: todo) { todoList in
            // 保存した後の処理
            self.todoList = todoList
            self.view.updateTodo()
        }
    }

    func didTapTodoCell(at indexPath: IndexPath) {
        let deleteTodo = self.todoList[indexPath.row]
        model.deleteTodo(todo: deleteTodo) { todoList in
            // 削除した後の処理
            self.todoList = todoList
            self.view.updateTodo()
        }
    }
}
Model.swift
import Foundation

protocol ModelInput {
    func getTodo(completion: @escaping ([Todo]) -> Void)
    func addTodo(todo: Todo, completion: @escaping ([Todo]) -> Void)
    func deleteTodo(todo: Todo, completion: @escaping ([Todo]) -> Void)
}

// MARK: -- Model
final class Model: ModelInput {

    private let key = "todo"

    func getTodo(completion: @escaping ([Todo]) -> Void) {
        completion(getTodoList())
    }

    func addTodo(todo: Todo, completion: @escaping ([Todo]) -> Void) {
        var todoList = getTodoList()
        todoList.append(todo)
        saveTodoList(todo: todoList)
        completion(todoList)
    }

    func deleteTodo(todo: Todo, completion: @escaping ([Todo]) -> Void) {
        let todoList = getTodoList()
        let newTodoList = todoList.filter { $0.id != todo.id }
        saveTodoList(todo: newTodoList)
        completion(newTodoList)
    }
    private func getTodoList() -> [Todo] {
        guard let data = UserDefaults.standard.data(forKey: key) else {
            return []
        }
        guard let array = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? [Todo]
        else {
            return []
        }
        return array
    }
    private func saveTodoList(todo: [Todo]) {
        guard let data = try? NSKeyedArchiver.archivedData(withRootObject: todo,
                                                           requiringSecureCoding: false)
        else {
            return
        }
        UserDefaults.standard.set(data, forKey: key)
        UserDefaults.standard.synchronize()
    }
}
Todo.swift
import Foundation


enum TodoValidation {

    case blank
    case overMaximumCharacters
    var alert: String {
        switch self {
        case .blank: return "文字を入力してください"
        case .overMaximumCharacters: return "20文字以内にしてください"
        }
    }
}

final class Todo: NSObject, NSCoding {

    var id: String
    var todo: String

    init(todo: String) {
        self.id = UUID.init().uuidString
        self.todo = todo
    }

    func encode(with coder: NSCoder) {
        coder.encode(id, forKey: "id")
        coder.encode(todo, forKey: "todo")
    }

    init?(coder: NSCoder) {
        id = (coder.decodeObject(forKey: "id") as? String) ?? ""
        todo = (coder.decodeObject(forKey: "todo") as? String) ?? ""
    }
}

より実用的なアプリにするために、UserDefaults を使って永続的にデータを保存できるようにしました。Presenter にプレゼンテーションロジックを持たせるというのはもちろんなんですが、それを考えるのがなかなかに難しかったので、View は描画と入力処理、Model に各種ロジックを持たせる意識で作っています。機能が少ないなアプリなのであまり難しくはなかったですが、MVP の理解がとても深まったのでヨシ。

最後に

今回はMVPについて解説しました。『関心の分離』『テストのしやすさ』『作業分担のしやすさ』、考えることが多いからこそいろんなアーキテクチャが生まれるんだなって感じました。

では、Bye