【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
に書いて『アラートを出す』という処理を Presenter
が View
に伝えます。これがプレゼンテーションロジックの分離ということです。
2 種類のデータ同期方法
MVP において、それぞれのコンポーネントの間でデータのやり取り(同期)をする必要があるわけですが参照を持ち、直接的にデータの受け渡しをする方法をフロー同期、Observer パターンを使ってデータの受け渡しをする方法をオブザーバー同期と言ったりします。
2 種類の MVP
先ほど説明した 2 種類のデータ同期方法の使い方で MVP は大きく 2 種類に分類されます。Passive ViewとSupervising 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
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)
}
}
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()
}
}
}
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()
}
}
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