はじめに
MVPアーキテクチャでGitHubのAPIを叩いて結果を表示するアプリを作りました。
MVPはレガシーになってきており、アーキテクチャの主流はMVVMらしいですが、そこを学ぶ前段階としてまずはMVPについて整理しました。
参考にさせて頂いたのは名著「iOSアプリ設計パターン入門」です。
MVPの大まかな構図
①ViewからUI操作を受けて、Presenterへ通知。
②PresenterがModelを操作。
③PresenterがViewに受け取ったModelの結果を渡す。
これをイメージとして持っておくと理解がしやすいです。
View
まずはViewから。
Viewは描画処理のみを担当します。いつViewが変更されるかなどは知りません。また、PresenterにUI操作の通知を行います。
カスタムCellは省きます。
import UIKit
final class MVPSearchController: UIViewController {
@IBOutlet private weak var indicator: UIActivityIndicatorView!
@IBOutlet private weak var tableView: UITableView! {
didSet {
tableView.register(UINib(nibName: "RepoCell", bundle: nil), forCellReuseIdentifier: "RepoCell")
tableView.dataSource = self
tableView.delegate = self
}
}
@IBOutlet private weak var searchText: UITextField!
override func viewDidLoad() {
super.viewDidLoad()
tableView.isHidden = true
indicator.isHidden = true
}
// Presenterのインスタンスを保持
private var presenter: GitHubPresenterInput!
// Routerに繋いでもらう処理がある
func inject(presenter: GitHubPresenterInput) {
// Presenterと繋がる
self.presenter = presenter
}
// 検索ボタンタップ
@IBAction func search(_ sender: Any) {
guard let searchText = searchText.text else { return }
// Presenterに通知
presenter.search(with: searchText)
}
}
extension MVPSearchController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// Presenterに通知
presenter.selected(index: indexPath.row)
}
}
extension MVPSearchController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// Presenterに通知
return presenter.numberOfItems
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "RepoCell") as? RepoCell else
{ return UITableViewCell() }
let repositoly = presenter.getRepo(index: indexPath.row)
cell.configure(repositoly: repositoly)
return cell
}
}
extension MVPSearchController: GitHubPresenterOutput {
func upDataRepsitory(_ repository: [Repository]) {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
func upData(load: Bool) {
DispatchQueue.main.async {
self.tableView.isHidden = load
self.indicator.isHidden = !load
}
}
func get(error: Error) {
print(error.localizedDescription)
}
func showWeb(Repositoly: Repository) {
DispatchQueue.main.async {
Router.shared.showWeb(from: self, repositoly: Repositoly)
}
}
}
UI操作をPresenterに通知して結果を受け取っています。先ほどの構図通りです。
また、ここでのポイントは具体的な描画処理をProtocolとして切り分けている所です。
GitHubPresenterOutputの処理が呼ばれるタイミングはロジックを担当するPresenterの役割なので、Viewが知る必要はないため、中身だけ実装しておきます。
Presenter
続いて、Presenterです。
Presenterはロジック部分を担当します。Viewからの通知を受けて、Modelの情報を返すのが主な役割です。
import Foundation
// アクションを受け入れる用のプロトコル
protocol GitHubPresenterInput {
var numberOfItems: Int { get }
func getRepo(index: Int) -> Repository
func selected(index: Int)
func search(with text: String?)
}
// アクションを加える用のプロトコル
protocol GitHubPresenterOutput: AnyObject {
func upDataRepsitory(_ repository: [Repository])
func upData(load: Bool)
func get(error: Error)
func showWeb(Repositoly: Repository)
}
class GitHubPresenter {
// アクションを加えるプロトコルに準拠したインスタンスを保持(Viewを保持)
private weak var output: GitHubPresenterOutput!
// API通信用に準拠したインスタンスを取得
private var api: GitHubAPIProtocol
// モデルを保持
private var repositoly: [Repository]
init(output: GitHubPresenterOutput, api: GitHubAPIProtocol = GitHubAPI.shared, repositoly: [Repository] = []) {
self.output = output
self.api = api
self.repositoly = repositoly
}
}
// PresenterはInputを受けてOutputするイメージ(外から通知を受けて外に通知を出す)
// outputが具体的に何をしてるかは知らなくても良い
extension GitHubPresenter: GitHubPresenterInput {
// 数を数えて!という通知に対して
var numberOfItems: Int { return repositoly.count }
// 指定されたindexのレポジトリを教えて!という通知に対して
func getRepo(index: Int) -> Repository {
return repositoly[index]
}
// 指定されたindexのレポジトリが選択された!という通知に対して
// webを開く通知をする
func selected(index: Int) {
self.output.showWeb(Repositoly: repositoly[index])
}
// 指定されたtextをもとに検索して!
func search(with text: String?) {
guard let text = text, !text.isEmpty else { return }
// ロード中を通知
self.output.upData(load: true)
self.api.getRepository(searchWord: text) { [weak self] result in
// ロード終了を通知
self?.output.upData(load: false)
// モデルを操作してViewに結果を渡す
switch result {
case .success(let repositoly):
self?.repositoly = repositoly
self?.output.upDataRepsitory(repositoly)
case .failure(let error):
self?.output.get(error: error)
}
}
}
}
さきほどの構図通り、PresenterはViewを変数outputとして保持し、Modelを変数repositolyとして保持していますね。ViewがPresenterに対して通知するときに呼び出されるGitHubPresenterInputプロトコルのメソッドに応じて、Viewに対して描画指示を出しています。
output(View)が行う具体的な処理内容の記述はViewのファイルに記述してます。
Model
続いてはModelです。
import Foundation
struct Item: Codable {
let items: [Repository]?
}
struct Repository: Codable {
let fullName: String
var urlStr: String {
return "https://github.com/\(fullName)"
}
enum CodingKeys: String, CodingKey {
case fullName = "full_name"
}
}
Modelにはロジックは持たせていません。そのかわり、API通信用のプロトコルに準拠させた新たなクラスを作り、そこに通信用の処理を記述します。Modelは値の受け渡し専用みたいな感じです。
そうすることで責務の切り分けができます。
通信用のクラス
続いてはAPI通信用のシングルトンのクラスです。
import Foundation
enum GitHubError: Error {
case error
}
// 実際に検索処理をするプロトコル
protocol GitHubAPIProtocol {
func getRepository(searchWord: String, completion: ((Result<[Repository], GitHubError>) -> Void)?)
}
final class GitHubAPI: GitHubAPIProtocol {
static let shared = GitHubAPI()
private init() {}
func getRepository(searchWord: String, completion: ((Result<[Repository], GitHubError>) -> Void)?) {
guard searchWord.count >= 0 else {
completion?(.failure(.error))
return
}
guard let url = URL(string: "https://api.github.com/search/repositories?q=\(searchWord)&sort=stars") else { return }
// API通信
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
guard let data = data else { return }
guard let repositolies = try? JSONDecoder().decode(Item.self, from: data) else { return }
guard let repositoly = repositolies.items else { return }
if error != nil {
completion?(.failure(.error))
return
}
completion?(.success(repositoly))
}
// 検索処理実行
task.resume()
}
}
GitHubAPIProtocolに準拠させて、デリゲートメソッドの中身はGitHubAPI内に記述しています。「iOSアプリ設計パターン入門」ではModel内にこの処理を記述しています。その方が先ほどの構図通りですが、責務を切り分けるために、シングルトンクラスを用意しました。
Presenterでデータを取得したいときは、GitHubAPIクラスのgetRepository()を呼んで、取得します。
Router
続いては、画面遷移の処理をまとめるRouterクラスです。
final class Router {
static let shared = Router()
private init() {}
private var window: UIWindow?
// MVPSearchViewControllerを表示
func showRoot(window: UIWindow) {
guard let vc = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? MVPSearchController else { return }
// vcとpresenterを参照させ合う(presenterは弱参照でvcを保持)
let presenter = GitHubPresenter(output: vc)
vc.inject(presenter: presenter)
let nav = UINavigationController(rootViewController: vc)
window.rootViewController = nav
window.makeKeyAndVisible()
self.window = window
}
// 次の画面へ遷移
func show(from: UIViewController, to: UIViewController) {
if let nav = from.navigationController {
nav.pushViewController(to, animated: true)
} else {
from.present(to, animated: true, completion: nil)
}
}
// show()を使ってWebViewControllerに画面遷移
func showWeb(from: UIViewController, repositoly: Repository) {
guard let webVc = UIStoryboard(name: "Web", bundle: nil).instantiateInitialViewController() as? WebViewController else { return }
let presenter = WebPresenter(output: webVc, repositoly: repositoly)
webVc.inject(presenter: presenter)
show(from: from, to: webVc)
}
}
AppDelegateでshowRoot()を呼んで、MVPSearchViewControllerを表示します。
showRoot()では、MVPSearchViewControllerのインスタンスを生成し、inject()を呼んで,
ViewであるMVPSearchViewControllerとPresenterを繋ぎます。そして、ナビゲーションコントローラーから遷移してMVPSearchViewControllerが表示されるという処理が記述されてます。
showWeb()では、WebViewControllerのインスタンスを生成し、inject()を呼んで、ViewであるWebViewControllerとWebPresenterを繋ぎます。そして、画面遷移するメソッドであるshow()を使ってWebViewControllerに遷移するという処理が記述されてます。
View(2)
続いては、WebViewControllerですね。ここではレポジトリのURLを叩いてWebサイトを表示します。
import UIKit
import WebKit
final class WebViewController: UIViewController {
@IBOutlet private weak var webView: UIWebView!
// presenterのインスタンスを保持
private var presenter: WebPresenter!
override func viewDidLoad() {
super.viewDidLoad()
// viewDidLoaded()の中身をVCが知る必要はない
presenter.viewDidLoaded()
}
func inject(presenter: WebPresenter) {
self.presenter = presenter
}
}
extension WebViewController: WebPresenterOutput {
func load(request: URLRequest) {
DispatchQueue.main.async {
self.webView.loadRequest(request)
}
}
}
PresenterにviewDidLoaded()で通知して、処理を仰ぎます。そうすると、Presenterはload()を呼んで、Webサイトを表示します。
Presenter(2)
続いては、WebPresenterクラスです。
import Foundation
// アクションを受け入れるプロトコル
protocol WebPresenterInput {
func viewDidLoaded()
}
// アクションを加えるプロトコル
protocol WebPresenterOutput: AnyObject {
func load(request: URLRequest)
}
final class WebPresenter {
private weak var output: WebPresenterOutput!
private var repositoly: Repository
init(output: WebPresenterOutput, repositoly: Repository) {
self.output = output
self.repositoly = repositoly
}
}
extension WebPresenter: WebPresenterInput {
func viewDidLoaded() {
guard let url = URL(string: repositoly.urlStr) else { return }
output.load(request: URLRequest(url: url))
}
}
viewDidloaded()の中身はここで記述してあげます。ViewはviewDidloaded()を使って通知するだけなので中身を知る必要がありませんでした。
以上が、MVPの基本的な扱い方になります。
参考
『iOSアプリ設計パターン 入門』 第6章 MVP
関義隆、史翔新、田中賢治、松館大輝、鈴木大貴、杉上洋平、加藤寛人