1
2

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 1 year has passed since last update.

MVPでGitHubのAPIを叩いてTableViewに結果を表示する

Last updated at Posted at 2021-05-03

はじめに

MVPアーキテクチャでGitHubのAPIを叩いて結果を表示するアプリを作りました。
MVPはレガシーになってきており、アーキテクチャの主流はMVVMらしいですが、そこを学ぶ前段階としてまずはMVPについて整理しました。

参考にさせて頂いたのは名著「iOSアプリ設計パターン入門」です。

MVPの大まかな構図

image.png

①ViewからUI操作を受けて、Presenterへ通知。
②PresenterがModelを操作。
③PresenterがViewに受け取ったModelの結果を渡す。

これをイメージとして持っておくと理解がしやすいです。

View

まずはViewから。
Viewは描画処理のみを担当します。いつViewが変更されるかなどは知りません。また、PresenterにUI操作の通知を行います。

カスタムCellは省きます。

MVPSearchController.swift
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の情報を返すのが主な役割です。

GitHubPresenter.swift
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です。

Repository.swift
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通信用のシングルトンのクラスです。

GitHubAPI.swift
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クラスです。

Router.swift
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サイトを表示します。

WebViewController.swift
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クラスです。

WebPresenter.swift
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
関義隆、史翔新、田中賢治、松館大輝、鈴木大貴、杉上洋平、加藤寛人

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?