0
1

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アーキテクチャでGitHubAPIを叩く

Posted at

MVPアーキテクチャを勉強中です。

今回はGitHubAPIを叩いてTableViewに表示するサンプルを
MVPを用いて作成します。

自身の備忘録として残しておきます。

まず、オーソドックスなMVCアーキテクチャをおさらい。

##MVC(Model,View,Controller)
MVC-1.jpg

※API通信の例で簡単に説明します。

まず、Viewからユーザーのアクションを検知し、そのアクションをControllerに伝えます。

そして、Controllerは必要なModelを取得するためにAPIを叩きます。
その後、APIから必要なModelが返され、Controllerで保持されます。

ControllerからViewにModelを渡して
そのModelからViewを更新することによって画面が更新されます。

##MVCからMVPに移行する理由

結論、MVCはViewControllerに処理を詰め込みすぎて肥大し
俗に言うFatViewControllerになりやすいからです。

##MVP(Model,View,Presenter)

MVP-1.jpg

MVPの場合、まずViewからユーザーのアクションを検知します。
そして、Controllerにアクションを伝えてPresenterに知らせます。

Presenterは必要なModelを取得するためにAPIを叩きます。
その後、APIから必要なModelが返され、Presenterで保持されます。

PresenterからControllerにModelを渡して
そのModelをViewに渡します。

最終的に、ModelからViewを更新することによって画面が更新されます。

##Presenterとは?

Presenterは、今までMVCでViewControllerが行なってきたことのほとんどを担っており、ControllerとModelの橋渡し的存在です。

このように責務を分け合うことによってFatViewControllerを回避しています。

##実装コード

ezgif-4-6912d03e5d.gif

GitHubAPIを叩いて、TableViewに表示するサンプルです。

##Presenter

Presenterの特徴は下記だと考えます。

・UIはノータッチ
・UIKitはインポートしない

MVPSearchPresenter.swift
import Foundation

protocol MVPSearchPresenterInput {
    var numberOfItems: Int { get }
    func item(index: Int) -> GithubModel
    func search(param: String?)
    func didSelect(index: Int)
}

protocol MVPSearchPresenterOutput: AnyObject {
    func update(loading: Bool)
    func update(githubModels: [GithubModel])
    func validation(error: ParameterValidationError)
    func get(error: Error)
    func showWeb(url: URL)
}

final class MVPSearchPresenter {
    private weak var output: MVPSearchPresenterOutput!
    private var api: GithubAPIProtocol!
    private var githubModels: [GithubModel]

    init(output: MVPSearchPresenterOutput, api: GithubAPIProtocol = GithubAPI.shared) {
        self.output = output
        self.api = api
        self.githubModels = []
    }
}

extension MVPSearchPresenter: MVPSearchPresenterInput {
    var numberOfItems: Int {
        githubModels.count
    }

    func item(index: Int) -> GithubModel {
        githubModels[index]
    }

    func search(param: String?) {
        if let validationError = ParameterValidationError(param: param) {
            output.validation(error: validationError)
            return
        }
        guard let searchText = param else { return }

        output.update(loading: true)

        self.api.get(searchText: searchText) {[weak self] (result) in
            guard let self = self else{ return }
            switch result {
            case .success(let githubModels):
                self.output.update(loading: false)

                if githubModels.isEmpty {
                    self.output.get(error: AppError.emptyApiResponce.error)
                    return
                }
                self.githubModels = githubModels
                self.output.update(githubModels: githubModels)

            case .failure(let error):
                self.output.update(loading: false)
                self.output.get(error: error)
            }
        }
    }

    func didSelect(index: Int) {
        guard let githubUrl = URL(string: githubModels[index].urlStr) else {
            output.get(error: AppError.getApiData.error)
            return
        }
        output.showWeb(url: githubUrl)
    }
}

##コード説明

ます、ViewControllerとPresenterを繋ぎます。

PresenterがViewControllerからの入力を受け取り
その結果をViewControllerに出力するからです。

#// 入力に関するプロトコル
#// ViewControllerから送られてくる
protocol MVPSearchPresenterInput {
    var numberOfItems: Int { get }
    func item(index: Int) -> GithubModel
    func search(param: String?)
    func didSelect(index: Int)
}

#// 出力に関するプロトコル
#// ViewControllerに結果を渡す
protocol MVPSearchPresenterOutput: AnyObject {
    func update(loading: Bool)
    func update(githubModels: [GithubModel])
    func validation(error: ParameterValidationError)
    func get(error: Error)
    func showWeb(url: URL)
}

また、MVCではViewControllerで保持していたModelを
MVPではPresenterで保持します。

更にoutputプロパティを定義し、init時にViewControllerと繋げれるようにしています。

※注意点ですが、ViewControllerとPresenterがお互いに参照し合うので
weakキーワードを付けて循環参照を防ぎます。

final class MVPSearchPresenter {
   # // ViewControllerとPresenterが参照し合い循環参照が起きるためweakキーワードを付ける
    #// このoutputがViewControllerのこと
    private weak var output: MVPSearchPresenterOutput!
    private var api: GithubAPIProtocol!
    #// Modelを保持する
    private var githubModels: [GithubModel]

    init(output: MVPSearchPresenterOutput, api: GithubAPIProtocol = GithubAPI.shared) {
        self.output = output
        self.api = api
        self.githubModels = []
    }
}

そしてMVPSearchPresenterInputプロトコルを準拠してViewControllerからの入力を処理します。

Presenterとしては、とりあえず入力が来たから処理→出力したというだけです。

Presenterにとっては、検索バーがタップされたとか画面が遷移したとかはノータッチなのが特徴です。

extension MVPSearchPresenter: MVPSearchPresenterInput {
    var numberOfItems: Int {
        githubModels.count
    }

    func item(index: Int) -> GithubModel {
        githubModels[index]
    }

    func search(param: String?) {
        if let validationError = ParameterValidationError(param: param) {
           # // ViewControllerに任せる
            output.validation(error: validationError)
            return
        }
        guard let searchText = param else { return }
       # // ViewControllerに任せる
        output.update(loading: true)
       # // API通信
        self.api.get(searchText: searchText) {[weak self] (result) in
            guard let self = self else{ return }
            switch result {
            case .success(let githubModels):
               # // ViewControllerに任せる
                self.output.update(loading: false)

                if githubModels.isEmpty {
                  #  // ViewControllerに任せる
                    self.output.get(error: AppError.emptyApiResponce.error)
                    return
                }
                self.githubModels = githubModels
               # // ViewControllerに任せる
                self.output.update(githubModels: githubModels)

            case .failure(let error):
              #  // ViewControllerに任せる
                self.output.update(loading: false)
                self.output.get(error: error)
            }
        }
    }

    func didSelect(index: Int) {
        guard let githubUrl = URL(string: githubModels[index].urlStr) else {
           # // ViewControllerに任せる
            output.get(error: AppError.getApiData.error)
            return
        }
       # // ViewControllerに任せる
        output.showWeb(url: githubUrl)
    }
}

##ViewController

ViewControllerの特徴として、

・Viewに関すること以外は書かない
・ifやfor等といった制御構文が入らない

だと考えます。

MVPSearchViewController.swift
import UIKit

final class MVPSearchViewController: UIViewController {
    @IBOutlet weak private var tableView: UITableView! {
        didSet {
            tableView.register(UINib(nibName: TableViewCell.className, bundle: nil), forCellReuseIdentifier: TableViewCell.className)
        }
    }
    @IBOutlet weak private var indicator: UIActivityIndicatorView!

    private var searchBar = UISearchBar()

    private var input: MVPSearchPresenterInput!
    func inject(input: MVPSearchPresenterInput) {
        self.input = input
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.navigationItem.titleView = searchBar
        searchBar.delegate = self
    }
}

extension MVPSearchViewController: UISearchBarDelegate {
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        input.search(param: searchBar.text)
        searchBar.resignFirstResponder()
    }
}

extension MVPSearchViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        input.numberOfItems
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.className, for: indexPath) as! TableViewCell
        let githubModel = input.item(index: indexPath.item)
        cell.configure(githubModel: githubModel)
        return cell
    }
}

extension MVPSearchViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        input.didSelect(index: indexPath.row)
    }
}

extension MVPSearchViewController: MVPSearchPresenterOutput {
    func update(loading: Bool) {
        indicator.animation(isStart: loading)
    }

    func update(githubModels: [GithubModel]) {
        DispatchQueue.main.async {
            self.searchBar.text = ""
            self.searchBar.resignFirstResponder()
            self.tableView.reloadData()
        }
    }

    func validation(error: ParameterValidationError) {
        Alert.okAlert(vc: self, title: error.message, message: "")
    }

    func get(error: Error) {
        Alert.okAlert(vc: self, title: error.localizedDescription, message: "")
    }

    func showWeb(url: URL) {
        Router.showWeb(url: url, from: self)
    }
}

まず、Presenterと繋げるためにinputプロパティとinjectメソッドを用意します。
この部分で外部からPresenterを繋げます。

  #// このinputがpresenterのこと
    private var input: MVPSearchPresenterInput!
   # // ここで外部からPresenterを繋げる
    func inject(input: MVPSearchPresenterInput) {
        self.input = input
    }

今回は、キーボードの検索ボタンを押した時にAPI通信を行い
その結果をTableViewに表示したいので
検索ボタンを押した時にPresenterに知らせます。

そしてPresenterからの結果をViewControllerが受け取るため
MVPSearchPresenterOutputプロトコルを準拠して
以下のように結果を受け取れるようにします。

extension MVPSearchViewController: MVPSearchPresenterOutput {
    func update(loading: Bool) {
        #// インディケータを回すかどうかを決めている
        indicator.animation(isStart: loading)
    }

    func update(githubModels: [GithubModel]) {
       # // TableViewの更新など
        DispatchQueue.main.async {
            self.searchBar.text = ""
            self.searchBar.resignFirstResponder()
            self.tableView.reloadData()
        }
    }

    func validation(error: ParameterValidationError) {
        #// アラート表示
        Alert.okAlert(vc: self, title: error.message, message: "")
    }

    func get(error: Error) {
        Alert.okAlert(vc: self, title: error.localizedDescription, message: "")
    }

    func showWeb(url: URL) {
       # // 画面遷移する
        Router.showWeb(url: url, from: self)
    }
}

##PresenterとViewControllerを繋げる
PresenterとViewControllerを繋げます。

つなげるコードは画面遷移に関するRouterクラスで実装しています。

Router.swift
final class Router {
    static func showMVPSearch(from: UIViewController) {
        let mvpSearchVC = UIStoryboard.mvpSearchViewController
        #// ここでPresenterとViewControllerを繋げている
        let presenter = MVPSearchPresenter(output: mvpSearchVC)
        mvpSearchVC.inject(input: presenter)
        from.show(next: mvpSearchVC)
    }
}


##おわりに

やはり大きなメリットとしてはViewControllerでの処理が減ったのが大きいと感じました。

また、Presenterは他に依存しないため使い回しが効くとも思います。

ソースコードは下記にまとめています。
https://github.com/taro-ken/GitHubSearchMVP

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?