導入
近年Swift界隈ではCombineやConcurrencyといった非同期プログラミングを実現するためのフレームワークが充実してきました。
RxSwiftやReactiveSwiftなどのライブラリを使っていた人たちはCombineを用い、サードパーティ製ライブラリからの脱却を試みる人は多いのではないでしょうか。私もその一人です。
非同期処理や並行処理、RPはCombineで全て事足りるものの、Concurrencyもうまく活用してよりよい設計ができないかと思っていました。
そんな中、iOSDC2022 Day1のセッション「Swift Concurrency時代のリアクティブプログラミングの基礎理」にて自分なりのベストプラクティスに辿り着くことができました。
使い分け
結論から言うと
Combineを利用するシーン
状態を保持し、その状態変化をローカルスコープを超えて伝える必要がある場合
ex) VMでAPI通信を行い、その状態変化をVCに伝える場合
Concurrencyを利用するシーン
副作用が発生せず、一つの入力に対して一つの出力を行うような非同期処理を扱う場合
ex) API通信を行い、値またはエラーを受け取りたい場合(CombineでいうFutureを利用するシーン)
さて、利用シーンはわかりましたが、なぜこのような使い分けをするのでしょうか。
一つは単純な非同期処理をCombineで書くよりもConcurrencyを使って書く方がシンプルに書けるということだと思います。Combineの場合どうしてもストリームの変換をオペレータで繋ぎsinkでsubscribeをして、storeでsubscriptionを保持するといった一連の流れがボイラープレートのようになってしまって最終的に結構長いコードになることが多いかと思います。
もう一つは個人的な見解も入りますが、RPを適用する範囲を限定することにあると思います。
RPを導入する際のデメリットとしてよく言われるのが学習コストです。
RPを使うと非同期処理などの複雑な処理をオペレーターを使って宣言的に記述することができたり、ローカルスコープを超える状態変化を一元管理できます。
これらをRPを使わずにコードに落とし込む場合delegateやcompletionHandlerを使うことが多いかと思います。
delegateやcompletionHandlerの問題点としては処理を行う場所や状態管理が分断されてしまうことや、複数の非同期処理を扱う際にネストがどんどん深くなっていってしまうことによる可読性低下などがあります。
RPでは処理の流れをストリームで表現し、オペレーターによってストリームに流れてきたイベントを必要に応じて加工しながら最終的に監視対象の状態変化のイベントを通知します。
文字に起こすと大したことはないのですが、実際にこれらを自分のコードにアウトプットできるようになるまではそれなりに時間がかかります。
個人で開発する分には自分がわかっていれば問題ないのですが、チーム開発で扱う際にはなかなかそうもいきません。
メンバー全員がPRに知見があればいいのですが、そうでない場合は任せられる業務の幅がRPを扱える人と扱えない人で大きく変わってきてしまうことです。
正直業務の中だけでRPをマスターすることは難しいです。ただし、プライベートの時間を削ってまで学習することをメンバーに強制することはできません。
とはいえ、コードの品質を上げるというのはエンジニアとしては当然の思考で、そのためにRPを使うことは自然な流れだと思います。
そこで、最初に述べたRPの利用範囲を「状態を保持し、その状態変化をローカルスコープを超えて伝える必要がある場合」 に絞ることによってRPを理解していないと扱えない範囲を減らすというのはチーム開発において有効な手段の一つではないかと思います。(問題を先送りにしているだけかもしれませんが...)
Example
ここまででなぜCombineとConcurrencyを使い分けるのかについて述べました。
続いて単純なAPI通信結果を表示する例をCombineのみを使って記述した例と、Concurrencyを織り交ぜた例を示します。
構成はMVVMパターンとなっており、APIクライアントをViewModelのサービスとして登録して使います。
Combineのみを使った場合
APIクライアント
final class ExampleAPI: API {
...
// 自作APIプロトコル(詳細略)に準拠したrequestを実行してResult型を返すAPIコールメソッド
func fetch(completion: @escaping (Result<Example, Error>) -> Void) {
request { (result: Result<Example, Error>) in
switch result {
case .success(let value):
return completion(.success(value))
case .failure(let error):
return completion(.failure(error))
}
}
}
}
extension ExampleAPI {
func fetch() -> Future<Example, Error> {
Future<Example, Error> { [unowned self] promise in
fetch { result in
switch result {
case .success(let response):
promise(.success(response))
case .failure(let error):
promise(.failure(error))
}
}
}
}
}
ViewModel
final class ExampleViewModel {
private var subscriptions = Set<AnyCancellable>()
private let fetchSubject = PassthroughSubject<Void, Never>()
private struct Service {
let exampleAPI: ExampleAPI
}
private var service: Service
struct State: Equatable {
let example: Example
}
@Published private(set) var state: State?
@Published private(set) var error: Error?
init(exampleAPI: ExampleAPI) {
service = Service(exampleAPI: exampleAPI)
fetchSubject
.setFailureType(to: Error.self)
.flatMap { [weak self] in
self?.service.exampleAPI
.fetch()
.eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher()
}.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error
}
} receiveValue: { [weak self] example in
self?.state = State(example: example)
}.store(in: &subscriptions)
}
func fetch() {
fetchSubject.send()
}
}
ViewController
public final class ExampleViewController: UIViewController {
...
private var viewModel: ExampleViewModel!
private var subscriptions = Set<AnyCancellable>()
...
override public func viewDidLoad() {
super.viewDidLoad()
viewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] value in
// 成功時の処理
}
.store(in: &subscriptions)
viewModel.$error
.receive(on: DispatchQueue.main)
.sink { error in
// 失敗時の処理
}
.store(in: &subscriptions)
viewModel.fetch()
}
}
こちらの例ではAPI通信による非同期プログラミングもViewModelとViewControllerのデータフローの繋ぎもCombineを使って全て表現しました。
Concurrencyを使った場合
APIクライアント
extension ExampleAPI {
func fetch() async throws -> Example {
try await withCheckedThrowingContinuation { continuation in
fetch { continuation.resume(with $0) }
}
}
}
ViewModel
final class ExampleViewModel {
...
@Published private(set) var state: State?
@Published private(set) var error: Error?
...
func fetch() async {
do {
let example = try await service.exampleAPI.fetch()
state = State(example: example)
} catch {
self.error = error
}
}
}
ViewController
public final class ExampleViewController: UIViewController {
...
override public func viewDidLoad() {
super.viewDidLoad()
viewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] value in
// 成功時の処理
}
.store(in: &subscriptions)
viewModel.$error
.receive(on: DispatchQueue.main)
.sink { error in
// 失敗時の処理
}
.store(in: &subscriptions)
Task {
viewModel.fetch()
}
}
}
こちらの例ではAPI通信による非同期プログラミングをConcurrency、ViewModelとViewControllerのデータフローの繋ぎをCombineを使って表現しました。
APIクライアントからは完全にCombineの要素は消えました。ViewModelもViewControllerとの繋ぎにpublished propertyを使ってはいるものの、本筋の部分は一切Combineを使っていません。
また、Combineに比べてAPI通信の部分がかなりシンプルになり、直感的なコードになったのではないでしょうか
おわりに
今回iOSDCのセッションにてRPの使う範囲をどこまでにするのがいいのかという長年の疑問が解決できたことは非常に有益でした。またそれによって自分だけでなく、他の開発者にとってもメリットを生み出せることもわかってきたので今後は積極的にConcurrencyを用いた設計に取り組んでいこうと思います。
すでに個人で作っているプロダクトには導入しているのですが、その中で改めて感じたメリットとしては動的に並行処理の回数が変更されるような場合でした。
RxSwiftだとObservable.zipをつかってObservableを可変的に複数扱うことが簡単にできたのですが、Combineだと標準では固定の数しか扱えないのに不便さを感じていました。
これがConcurrencyをつかうとTaskGroupを使うことで非常にシンプルに実現できました。
ここでは詳細は省きますが、もし同様の不便さを感じている方がいればConcurrencyを取り入れるのもいいかもしれません。