はじめに
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
に型を変換しています。
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
ではView
とInteractor
、及びRouter
の橋渡しを行います。
input
に対してView
からイベント通知が飛んできますので、input
のイベント発火をトリガーとしてInteractor
、またはRouter
に処理を依頼します。
Interactor
からレスポンスが帰ってきた場合は、output
にデータを流し込むことでView
側に更新を知らせます。
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
やユーザーからのアクションを受け取ったタイミングでpresenter
にinput
を通して更新依頼を投げます。
そして、output
で値の更新を受け取り、各View
の表示内容の更新を行います。
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
また、何か、改善案やご意見等ありましたら、コメントいただけますと幸いです。
どうぞよろしくお願いいたします。