目次
はじめに
MVPとは
アプリの概要
Model
View
Presenter
さいごに
はじめに
今回はMVPアーキテクチャを用いてNASA APIを使用した簡単なアプリを作成してみました。私自身未だiOSエンジニアを目指して勉強中の身でありますので、不適切な部分や改善した方が良い部分などあるかもしれませんが、少しでも何か参考になれば幸いです。
MVPとは
MVPとは画面の描画処理とプレゼンテーションロジックとを分離するアーキテクチャで、テスト容易性と作業分担のしやすさを手に入れることが出来ます。
またMVPにはPassive ViewとSupervising Controllerの2つのパターンがあり、これらの違いは責務の分け方にあります。(今回のアプリはPassive Viewで作成しています。)
アプリの概要
今回はNASA APIを使って、Buttonを押すと写真と写真のタイトルを表示させれる簡単なアプリを作ってみました。(実際にこちらのAPIを使用したアプリをリリースしていますので、ぜひ使ってみてください。https://apps.apple.com/jp/app/gfu/id1627680015 )
Model
ModelはUIに関係しない純粋なドメインロジックやそのデータを持ちます。画面表示がどのようなものでも共通な、アプリの機能実現のための処理が置かれます。MVPにおけるModelはMVC、MVVMにおけるModelと同じ立ち位置です。
今回のアプリでは、APIの取得とその際に生じるエラーの処理を持たせています。
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パースに失敗しました"
}
}
}
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にテキストと画像を表示させるようにしています。
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参照で持つようにします。
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)