9
8

More than 1 year has passed since last update.

CombineとVIPERでアプリを構築する

Posted at

はじめに

iOS13以降、Frameworkとして提供されたCombine。
上記のおかげで外部ライブラリに頼らずにリアクティブプログラミングを使うことができるようになり、アプリを構築する上での選択肢の一つとして考えられている方も多いのではないでしょうか?
では、実際にアプリを作る上でどういった形でCombineを使っていくことになるか...
ということで、今回はCombineとVIPERを使って簡単なアプリ作ってみましたので、作ったアプリを元に処理の流れを記載していきます。

CombineとVIPERとは

まずは、CombineとVIPERについて軽く紹介したいと思います。

※ CombineとVIPERの詳細については、今回の本筋から逸れるため、詳細な説明はいたしません。
詳しくは知りたい方は別記事をご参照いただきますようお願いいたします。

Combineとは

イベントをどのように処理するかを宣言的に記述することができるAPIです。
関数型リアクティブプログラミング(FRP)のフレームワークであり、FRPだと他にはRxSwiftといった外部ライブラリが有名です。
CombineはAppleが提供しているため外部ライブラリを別途追加する必要がないところが嬉しいところです。

Combine | Apple Developer Documentation

VIPERとは

Clean ArchitectureをiOSアプリに特化した構成に変更したアーキテクチャです。
View, Interactor, Presenter, Router, Entityの5つの要素で構成され、それぞれの頭文字を取ってVIPERという名がついています。

要素名 役割
View 画面の表示、更新を実施
ユーザーアクションが来たらPresnterに伝える
Presenter Viewから渡されたイベントを元に、InteractorやRouterに処理の依頼を投げるハブとしての役割を担当
Interactor Presenterからの依頼を元にビジネスロジックを実施
Router 画面遷移を実施
また、遷移時にViewに必要な値を注入するDIを行う
Entity データ構造を定義

VIPERの目的の一つが「依存性を分離」することであり、そのために各要素はそれぞれ必ずprotocolを介してアクセスすることとなっています。
要素の切り替えを容易になったことで簡単にテストコードが記述できます。
また、もう一つの目的として「単一責任の原則」を遵守することがあり、上記で挙げた5つの要素にはその役割を超えた処理は書きません。
上記を守ることで、責務を切り離して検証を容易にします。

iOS Project Architecture: Using VIPER | Cheesecake Labs

CombineとVIPERでアプリを構築

では、本題に入っていきましょう。
今回はGitHub APIを叩いてユーザー一覧を取得する処理の流れを追いながらコードを見ていきます。

参考アプリはGitHubに上げておりますので、こちらも合わせてご参照下さい。
ViperCombineExampleApp

Interactor

APIClientにユーザー一覧をリクエストし、レスポンスをAnyPublisherで返却するようにしています。
.eraseToAnyPublisher()を使ってAnyPublisherに型を変換しています。

UserListInteractor.swift
import Combine

protocol UserListInteractorUseCase: AnyObject {
    func fetch() -> AnyPublisher<[User], Error>
}

final class UserListInteractor {
    private let resource = GitHubUsesApiResource()
}

// MARK: - UserListInteractorUseCase
extension UserListInteractor: UserListInteractorUseCase {
    func fetch() -> AnyPublisher<[User], Error> {
        return Future<[User], Error> { [weak self] promise in
            guard let resource = self?.resource else {
                fatalError()
            }
            ApiClinet.request(resource, completion: { result in
                switch result {
                case .success(let response):
                    promise(.success(response))
                case .failure(let error):
                    promise(.failure(error))
                }
            })
        }
        .eraseToAnyPublisher()
    }
}

Presenter

PresenterではViewInteractor、及びRouterの橋渡しを行います。
inputに対してViewからイベント通知が飛んできますので、inputのイベント発火をトリガーとしてInteractor、またはRouterに処理を依頼します。
Interactorからレスポンスが帰ってきた場合は、outputにデータを流し込むことでView側に更新を知らせます。

UserListPresenter.swift
protocol UserListPresenterInput: AnyObject {
    var viewDidLoadTrigger: PassthroughSubject<Void, Never> { get }
    var didSelectUserTrigger: PassthroughSubject<User, Never> { get }
}
protocol UserListPresenterOutput: AnyObject {
    var users: CurrentValueSubject<[User], Error> { get }
}
protocol UserListPresenterInterface {
    var input: UserListPresenterInput? { get }
    var output: UserListPresenterOutput? { get }
}

final class UserListPresenter: UserListPresenterInterface, UserListPresenterInput, UserListPresenterOutput {
    // MARK: - Inputs
    var viewDidLoadTrigger = PassthroughSubject<Void, Never>()
    var didSelectUserTrigger = PassthroughSubject<User, Never>()

    // MARK: - Outputs
    var users = CurrentValueSubject<[User], Error>([])

    // MARK: - Constants
    private let interactor: UserListInteractorUseCase
    private let router: UserListWireframe

    // MARK: - Variables
    weak var input: UserListPresenterInput? { return self 
    weak var output: UserListPresenterOutput? { return self }
    private var cancellables = [AnyCancellable]()

    // MARK: - Lifecycle Methods
    init(interactor: UserListInteractorUseCase,
         router: UserListWireframe) {
        self.interactor = interactor
        self.router = router
        bind()
    }
}

// MARK: - Private Methods
private extension UserListPresenter {
    private func bind() {
        // interactorがnilになる可能性があるので、一度compactMapでnilを除外した後に処理を実行
        viewDidLoadTrigger
            .compactMap({ [weak self] in self?.interactor.fetch() })
            .flatMap({ $0 })
            .sink(receiveCompletion: { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.users.send(completion: .failure(error))
                }
            }, receiveValue: { [weak self] value in
                self?.users.send(value)
            })
            .store(in: &cancellables)

        didSelectUserTrigger
            .sink(receiveValue: { [weak self] user in
                self?.router.showRepositoryList(user)
            })
            .store(in: &cancellables)
    }
}

View

Lifecycleやユーザーからのアクションを受け取ったタイミングでpresenterinputを通して更新依頼を投げます。
そして、outputで値の更新を受け取り、各Viewの表示内容の更新を行います。

UserListViewController.swift
class UserListViewController: UIViewController {

    // MARK: - Variables
    var presenter: UserListPresenterInterface!
    private var users: [User] = []
    private var cancellables = [AnyCancellable]()

    // MARK: - Outlets
    @IBOutlet private weak var tableView: UITableView!

    // MARK: - Lifecycle Metohds
    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
        presenter.input?.viewDidLoadTrigger.send(())
    }
}

// MARK: - Priavte Methods
private extension UserListViewController {
    /// bind
    func bind() {
        // UIを更新するのでmainスレッドを指定する
        presenter.output?.users
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    print("error: \(error)")
                }
            }, receiveValue: { [weak self] value in
                self?.users = value
                self?.tableView.reloadData()
            })
            .store(in: &cancellables)
    }
}

// MARK: - UITableViewDataSource
extension UserListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return users.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell", for: indexPath) as? UserCell else {
            return UITableViewCell()
        }
        cell.configure(with: users[indexPath.row])
        return cell
    }
}

// MARK: - UITableViewDelegate
extension UserListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        presenter.input?.didSelectUserTrigger.send(users[indexPath.row])
    }
}

アプリを作ってみて

Combineを使用すること処理の流れが追いやすくなっているかと思います。
また、一つのトリガーに対して複数の処理を記載する場合に、処理を分けて記載することができるので余計な分岐などが減ってより見やすくなるなると思います。
もちろん、RxSwiftでも同等のことができるかと思いますが、純正APIであること、また、今後SwiftUIを使っていく場合はCombineがより強力な武器になっていくと思いまいますので、ぜひ皆様もお試しいただけると良いかと思います。

最後に

改めてにはなりますが、GitHubにサンプルアプリを載せていますので、気になった方はこちらも合わせてご参照いただけますと幸いです。
ViperCombineExampleApp

また、何か、改善案やご意見等ありましたら、コメントいただけますと幸いです。
どうぞよろしくお願いいたします。

9
8
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
9
8