【Swift/iOS】VIPERアーキテクチャの実装方法

【Swift/iOS】VIPERアーキテクチャの実装方法

この記事からわかること

  • Swift/iOSアプリの設計
  • VIPERアーキテクチャとは?

index

[open]

\ アプリをリリースしました /

みんなの誕生日

友達や家族の誕生日をメモ!通知も届く-みんなの誕生日-

posted withアプリーチ

環境

前段

この記事を書くにあたって以下の参考書が非常に参考になりましたので紹介しておきます。

【Swift】『iOSアプリ設計パターン入門』アーキテクチャを学べるおすすめ参考書

中身がどのようなものか知りたい場合は以下の記事も参考にしてください。

VIPERとは?

VIPER」はiOSアプリで用いられるアーキテクチャの1種で「Clean Architecture」の思想をiOSアプリ向けに具体化したアーキテクチャパターンになっています。Clean Architectureはビジネスロジック(Entity)と変更が発生しやすいUIやDBなどを完全に切り出すことで、ビジネスロジックに変化が及ばないようにするための思想です。詳細についてはこちらの記事を参考にしてください。

VIPERは以下の5つの頭文字を合わせた名前になっています。中身はClean Architectureに準じたものに紐づいています。

VIPERはUIKit自体の開発で活躍したアーキテクチャになっています。ネットを探してみるとSwift UIでも使っている人もいましたが、Swift UIがだいぶ広がってきたのもあり最近はあまり見かけることが少なくなってきました。なので今回のVIPERの紹介もUIKitベースで紹介していこうと思います。Swift UIならTCAあたりが今はHotなのかもしれないです。

VIPERの使い方

VIPERを実際に使用して「Qiitaの記事を取得・表示するアプリ」を作成したいと思います。VIPER自体は割と大規模なプロジェクト向けのアーキテクチャのため少しオーバーエンジニアリングですが、それぞれの役割や使い方を理解するのを目的として進めていきます。

GitHubに全体のコードも上げているので参考にしてください。

おすすめ記事:GitHub:QiitaVIPERApp

Entity(Domain層)

まずは「Entity(Domain層)」です。ここで定義するのはドメインモデル(アプリ内で扱われるモデル)になります。ドメインモデルは外部のサービスに依存していないオブジェクトとして定義します。そのためQiitaのAPIから取得できるJSONをマッピングするクラスとは別のもので、そちらはDTOとして別の層で定義します。

今回は確実にURLにキャストされたデータも保持することをドメインに含めるため以下のような「Value Class」(QiitaURL)も定義しておきます。

/// ドメインモデル
/// DTOから変換され、アプリ名で扱われるモデル
struct QiitaArticle: Identifiable {
    let id: String
    let title: String
    let body: String
    let url: QiitaURL
}

/// ドメインモデルが持つURLオブジェクト
/// 必ずURL形式で存在することができる想定なのでURL型への変換はDTO => Entityで行う
struct QiitaURL {
    let value: URL

    init?(rawValue: String) {
        guard let url = URL(string: rawValue) else { return nil }
        self.value = url
    }
}

Interactor(UseCase層)

Interactor(UseCase層)」は結構やることが多いです。

  1. DTOの定義
  2. Mapperの実装
  3. DataSourceの実装
  4. Repositoryの実装
  5. Interactorの実装

1.DTOの定義

最初に「DTO(Data Transfer Object)」を定義します。これはQiitaのAPIから取得できるJSONをマッピングするためのクラスになります。このDTOは「Repository」までしか存在が公開されません。Interactor以降(PresenterやViewなど)はEntityに変換されたクラスだけが存在として認識されるようになります。実際に外部サービスなどに依存する部分なので変化がよく発生しやすい場所になります。

/// DTOクラス
/// QiitaのAPIから取得できるJSONがマッピングされるクラス
struct QiitaArticleDTO: Decodable, Identifiable {
    let id: String
    let title: String
    let body: String
    let url: String
}

DTOについては以下の記事を参考にしてください。

2.Mapperの実装

「DTO => Entity」に変換するロジックを持った「Mapper」クラスを用意します。別クラスとして切り出しておくことでテストしやすくなります。

/// `DTO → Entity` の変換ロジック
enum QiitaArticleMapper {
    
    static func map(_ dto: QiitaArticleDTO) -> QiitaArticle? {
        guard let qiitaURL = QiitaURL(rawValue: dto.url) else { return nil }
        return QiitaArticle(
            id: dto.id,
            title: dto.title,
            body: dto.body,
            url: qiitaURL,
        )
    }
}

3.DataSourceの実装

続いて実際にデータを取得してくるための「DataSource」を実装します。ローカルデータベースやAPIなどを用いてデータを取得しDTOに変換します。「DataSource」は「Repository」にラップされるような形になるのでDTO <=> Repositoryだけの仲介役だとイメージすれば良さそうです。

/// APIから実際にデータを取得してDTOに変換するクラス
final class QiitaArticleRemoteDataSourceImpl: QiitaArticleRemoteDataSource {
    
    private let endPoint: URL? = URL(string: "https://qiita.com/api/v2/items")

    func fetchArticles() async throws -> [QiitaArticleDTO] {
        guard let endPoint else { throw APIError.noEndPoint }
        
        let (data, response) = try await URLSession.shared.data(from: endPoint)

        guard let http = response as? HTTPURLResponse,
              200..<300 ~= http.statusCode else {
            throw APIError.badServerResponse
        }
        let jsonDecoder = JSONDecoder()
        guard let articles = try? jsonDecoder.decode([QiitaArticleDTO].self, from: data) else { throw APIError.badJsonFormat }
        return articles
    }
}

4.Repositoryの実装

Repository」はデータの取得先を隠蔽し、ドメインモデルの形式で取得できることだけを保証するクラスです。「DTO => Entity」の変換はMapperを用意しているのでロジック部分はそちらに任せます。

/// `データを取得しアプリで使用するドメインモデル` へ切り替える
final class QiitaArticleRepositoryImpl: QiitaArticleRepository {

    private let remote: QiitaArticleRemoteDataSource

    init(remote: QiitaArticleRemoteDataSource) {
        self.remote = remote
    }

    func fetchArticles() async throws -> [QiitaArticle] {
        let dtoList = try await remote.fetchArticles()
        return dtoList.compactMap { QiitaArticleMapper.map($0) }
    }
}

5.Interactorの実装

Interactor(UseCase)」はアプリ固有のUseCase実行する層です。「Interactor」は1クラス = 1UseCaseが基本になっています。例えば以下QiitaListInteractorは「Qiita記事一覧を取得する」だけになります。

final class QiitaListInteractor: QiitaListInteractorProtocol {
    
    weak var output: QiitaListInteractorOutput?
    private let repository: QiitaArticleRepository
    
    init(repository: QiitaArticleRepository) {
        self.repository = repository
    }
    
    func fetchArticles() {
        Task {
            do {
                let articles = try await repository.fetchArticles()
                await MainActor.run {
                    self.output?.didFetchArticles(articles)
                }
            } catch {
                await MainActor.run {
                    self.output?.didFail(error)
                }
            }
        }
    }
}

Presenter(Interface Adapter層)

Presenter(Interface Adapter層)」はViewからのイベントを受けとりInteractorには処理を依頼Routerには画面遷移を依頼するためのクラスです。ユーザー(UI)が起こしたアクションやライフサイクルなどのイベントを受け取り「何を表示するか」・「どの状態を出すか」・「どの画面に遷移するか」などをハンドリングするのが役割になってきます。このクラスはViewModelのように基本的にUIに絡む状態などは保持しないようにするのが大事になります。

/// Viewからのイベントを受けとりInteractorには処理を依頼Routerには画面遷移を依頼するためのクラス
/// ・何を表示するか
/// ・どの状態を出すか
/// ・どの画面に遷移するか
/// UIに絡む状態などは保持しない(MVVM化はさせない)
/// 持たせる状態はフロー制御やキャッシュ等、ドメインに直接絡まないもの
final class QiitaListPresenter: QiitaListPresenterProtocol {
    weak var view: QiitaListViewProtocol?
    var interactor: QiitaListInteractorProtocol!
    var router: QiitaListRouterProtocol!
    weak var viewController: UIViewController?
    
    func viewDidLoad() {
        interactor.fetchArticles()
    }
    
    func didSelectArticle(_ article: QiitaArticle) {
        guard let viewController else { return }
        router.navigateToWeb(from: viewController, url: article.url.value)
    }
}

extension QiitaListPresenter: QiitaListInteractorOutput {
    func didFetchArticles(_ articles: [QiitaArticle]) {
        DispatchQueue.main.async {
            self.view?.showArticles(articles)
        }
    }
    func didFail(_ error: Error) {
        DispatchQueue.main.async {
            self.view?.showError(error.localizedDescription)
        }
    }
}

Router(Navigation / DI)

Router(Navigation / DI)」は画面の生成と画面遷移処理を主に行うクラスです。また画面を生成するために必要な各層のDI(依存性注入)もここで行います。

/// 責務:`画面の生成と画面遷移処理を行うクラス`
final class QiitaListRouter: QiitaListRouterProtocol {
    
    static func assembleModule() -> UIViewController {
        let view = QiitaListViewController()
        let presenter = QiitaListPresenter()
        let dataSouce = QiitaArticleRemoteDataSourceImpl()
        let repository = QiitaArticleRepositoryImpl(remote: dataSouce)
        let interactor = QiitaListInteractor(repository: repository)
        let router = QiitaListRouter()
        
        view.presenter = presenter
        presenter.view = view
        presenter.interactor = interactor
        presenter.router = router
        presenter.viewController = view
        interactor.output = presenter
        
        return UINavigationController(rootViewController: view)
    }
    
    /// WebViewで表示する場合
    func navigateToWeb(from view: UIViewController, url: URL) {
        let safari = SFSafariViewController(url: url)
        view.present(safari, animated: true)
    }

    /// 詳細画面に飛ばす場合
    func navigateToDetail(from view: UIViewController, article: QiitaArticle) {
        let detailVC = QiitaDetailRouter.assembleModule(article: article)
        view.navigationController?.pushViewController(detailVC, animated: true)
    }
}

View

最後に「View」の実装です。今回はUIKitベースでの実装になるので以下のような形になります。

final class QiitaListViewController: UIViewController {
    
    var presenter: QiitaListPresenterProtocol!
    private var articles: [QiitaArticle] = []
    
    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
        tableView.dataSource = self
        tableView.delegate = self
        return tableView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground 
        setupLayout()
        presenter.viewDidLoad()
    }
    
    
    private func setupLayout() {
        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
}

extension QiitaListViewController: QiitaListViewProtocol {
    func showArticles(_ articles: [QiitaArticle]) {
        self.articles = articles
        tableView.reloadData()
    }
    func showError(_ message: String) {
        let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }
}

extension QiitaListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        articles.count
    }    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = articles[indexPath.row].title
        cell.accessoryType = .disclosureIndicator
        return cell
    }
}

extension QiitaListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        presenter.didSelectArticle(articles[indexPath.row])
    }
}

まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。

ご覧いただきありがとうございました。

Search Box

Sponsor

ProFile

ame

趣味:読書,プログラミング学習,サイト制作,ブログ

IT嫌いを克服するためにITパスを取得しようと勉強してからサイト制作が趣味に変わりました笑今はCMSを使わずこのサイトを完全自作でサイト運営中〜

New Article

index