【Swift】MVPでNASA APIを使ったアプリを作成してみた

# 目次

[はじめに](#はじめに)
[MVPとは](#mvpとは)
[アプリの概要](#アプリの概要)
[Model](#model)
[View](#view)
[Presenter](#presenter)
[さいごに](#さいごに)

# はじめに
今回はMVPアーキテクチャを用いてNASA APIを使用した簡単なアプリを作成してみました。私自身未だiOSエンジニアを目指して勉強中の身でありますので、不適切な部分や改善した方が良い部分などあるかもしれませんが、少しでも何か参考になれば幸いです。

# MVPとは
MVPとは画面の描画処理とプレゼンテーションロジックとを分離するアーキテクチャで、テスト容易性と作業分担のしやすさを手に入れることが出来ます。
またMVPにはPassive ViewとSupervising Controllerの２つのパターンがあり、これらの違いは責務の分け方にあります。（今回のアプリはPassive Viewで作成しています。）

# アプリの概要
今回はNASA APIを使って、Buttonを押すと写真と写真のタイトルを表示させれる簡単なアプリを作ってみました。（実際にこちらのAPIを使用したアプリをリリースしていますので、ぜひ使ってみてください。https://apps.apple.com/jp/app/gfu/id1627680015 ）
![demo](https://gyazo.com/d4c715a1e763e006afb8ef00d8054c14/raw)

# Model
Modelは**UIに関係しない純粋なドメインロジックやそのデータを持ちます**。画面表示がどのようなものでも共通な、アプリの機能実現のための処理が置かれます。MVPにおけるModelはMVC、MVVMにおけるModelと同じ立ち位置です。

今回のアプリでは、APIの取得とその際に生じるエラーの処理を持たせています。
```swift: Model.swift
import Foundation

// API取得時に使用するクロージャを、ResultHandler型として型エイリアス
typealias ResultHandler<T> = (Result<T,APIError>) -> Void

protocol NasaModelInput {
    func fetchNasa(completion: @escaping ResultHandler<Nasa>)
}

final class NasaModel: NasaModelInput {

    func fetchNasa(completion: @escaping ResultHandler<Nasa>) {
        let urlString = "ここにAPIのURLを記述"
        let url = URL(string: urlString)

        guard let url = url else {
            completion(.failure(.invalidURL))
            return
        }
        let request = URLRequest(url: url)

        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in

            if let error = error {
                completion(.failure(.error(error)))
                return
            }

            guard let data = data else {
                completion(.failure(.server))
                return
            }

            guard let nasaItemJson = try? JSONDecoder().decode(Nasa.self, from: data) else {
                completion(.failure(.invalidJSON))
                return
            }

            completion(.success(nasaItemJson))
        }
        task.resume()
    }
}

public enum APIError: Error, LocalizedError {
    case error(Error)
    case invalidURL
    case server
    case invalidJSON

    public var errorDescription: String {
        switch self {
        case .error(let error):
            return "\(error.localizedDescription)"
        case .invalidURL:
            return "無効なURLです。"
        case .server:
            return "サーバーと通信できません"
        case .invalidJSON:
            return "JSONパースに失敗しました"
        }
    }
}
```

```swift: Nasa.swift
import Foundation

struct Nasa: Codable {
    let imageUrl: URL
    let title: String
    let explanation: String

    // Decode,Encodeする際のキーを変換
    enum CodingKeys: String, CodingKey {
        case imageUrl = "hdurl"
        case title 
        case explanation 
    }

    init(imageUrl: URL, title: String, explanation: String) {
        self.imageUrl = imageUrl
        self.title = title
        self.explanation = explanation
    }
}
```
# View
Viewは**ユーザー操作の受付と、画面表示を担当するコンポーネント**です。iOSのMVPにおいてはView ControllerもViewに含む解釈をします。
Viewは**タップやスワイプなどによるUIイベントを受け付け、Presenterに処理を委譲したり、Modelの処理を呼び出したりします**。
Modelに変更が発生したら、何らかの方法でViewにそれが伝達され、表示内容が更新されます。

今回のアプリでは、receiveButtonを押すと、Presenterに処理を委譲してModelにAPI取得をさせ、受け取った情報からtitleLabelとnasaImageViewにテキストと画像を表示させるようにしています。
```swift: ViewController.swift
import UIKit

class NasaGetViewController: UIViewController {

    @IBOutlet private weak var titlelabel: UILabel!
    @IBOutlet private weak var nasaImageView: UIImageView!
    @IBOutlet private weak var indicator: UIActivityIndicatorView!
    @IBOutlet private weak var detailButton: UIButton!
    
    private var presenter: NasaPresenterInput?

    // Prensenterを注入するメソッド
    func inject(presenter: NasaPresenterInput) {
        self.presenter = presenter
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        indicator.hidesWhenStopped = true
        let model = NasaModel()
        let presenter = NasaPresenter(model: model, view: self)
        inject(presenter: presenter)
    }

    @IBAction func receiveButton(_ sender: Any) {
        indicator.startAnimating()
        presenter?.didTapRecieveButton()
    }

    func showAlert(message: String) {
        let alert = UIAlertController(title: "エラー", message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        self.present(alert, animated: true)
    }
}

extension NasaGetViewController: NasaPresenterOutput {

    func showNasa(nasaItem: Nasa) {
        indicator.stopAnimating()
        titlelabel.text = nasaItem.title
        guard let imageData = try? Data(contentsOf: nasaItem.imageUrl) else {
            showAlert(message: "画像の表示に失敗しました")
            return
        }
        nasaImageView.image = UIImage(data: imageData)
    }

    func hideUIActivityIndicatorView() {
        indicator.stopAnimating()
    }

    func getError(error: APIError) {
        showAlert(message: error.errorDescription)
    }
}
```
# Presenter
Presenterは**ViewとModelの仲介役であり、プレゼンテーションロジックを担い、たいてい1つのVIewにつき1つ作成します**。
仲介役であるためViewとModelの双方を知っていますが、依存関係でどちらを主にするかという考え方は定められておらず、フレームワークなどによって異なります。iOSにおいては、一般的にView Controllerの存在が不可欠なので、「ViewがPresenterを知っている」状態とし、**PresenterからはViewをweak参照で持つようにします**。
```swift: Presenter.swift
import Foundation

protocol NasaPresenterInput {
    var nasaItem: Nasa? { get }
    func didTapRecieveButton()
}

// weak参照で持ちたいため、AnyObjectを継承
protocol NasaPresenterOutput: AnyObject {
    func showNasa(nasaItem: Nasa)
    func hideUIActivityIndicatorView()
    func getError(error: APIError)
}

final class NasaPresenter: NasaPresenterInput {

    private(set) var nasaItem: Nasa?

    private var model: NasaModelInput
    private weak var view: NasaPresenterOutput!

    init(model: NasaModelInput, view: NasaPresenterOutput) {
        self.model = model
        self.view = view
    }

    func didTapRecieveButton() {
        model.fetchNasa(completion: { [weak self] result in
            switch result {
            case .success(let nasaItem):
                self?.nasaItem = nasaItem
                DispatchQueue.main.async {
                    self?.view?.showNasa(nasaItem: nasaItem)
                }

            case .failure(let error):
                DispatchQueue.main.async {
                    self?.view.getError(error: error)
                }
            }
        })
    }
}
```
# さいごに
今回初めてAPIとMVPアーキテクチャを使ったアプリの作成を行いました。まだまだ分からないことがたくさんありますので、ぜひコメントやご指摘など頂けますと幸いでございます🙇‍♂️

# 参考
・iOSアプリ設計パターン入門（<https://peaks.cc/books/iOS_architecture>）