はじめに
こんにちは、LINEのiOSエンジニアの平井亨武です。LINE Communication Service開発チームに所属して、主にLINE appのiOS版の開発を担当しています。
LINEをはじめとするコンテンツやメディアを扱うアプリケーションでは、クライアントとサーバ間でファイルをアップロード/ダウンロードするのに長時間を要することがあります。このような場面では、プログレスバーなどを用いてユーザに進捗状況を逐一伝えながら処理を進行することがあります。
Swiftにおいてこの類の機能を提供するAPIは、従来progress handlerとcompletion handlerを取る関数として定義する方法が一般的でした。しかし近年CombineやSwift Concurrencyなどの登場により、非同期処理の選択肢が大きく増えベストプラクティスも大きく変化しました。そこで本記事では、Swiftにおいてprogress handlingを伴う非同期処理の関数定義のしかたについて、どの書き方が有利であるのか整理してみたいと思います。本記事では主に呼び出し側から見た使いやすさや実装における安全性について議論します。非同期処理関数自体の実装のしやすさについては考慮しません。
想定するシチュエーション
Swiftにおいて、大きなファイルをアップロードするための関数を定義します。アップロード関数から得られる結果の文字列を、showResult(text:)
関数を用いて表示することにします。progressは整数のパーセント値で渡され、progressを受け取った時はshowProgress(percent:)
関数によってユーザに進捗状況を表示することとします。アップロードは失敗する可能性があり、エラーが発生した場合はshowError(:)
関数によってユーザにエラーの内容を表示することとします。
func showProgress(percent: Double) {} // 進捗状況を表示する関数
func showResult(text: String) {} // アップロード結果(String)を表示する関数
func showError(_: Error) {} // アップロードで発生したエラーを表示する関数
定義方法とその比較
このアップロード関数を定義する方法を5つほど考えてみましょう。関数の宣言と、呼び出し側のコードをそれぞれ見ていきます。
1) progress handlerとcompletion handlerによる方法
// 宣言
protocol Uploader {
func upload(progressHandler: (_ percent: Double) -> Void, completionHandler: (Result<String, Error>) -> Void)
}
// 呼び出し
func callUpload(uploader: Uploader) {
uploader.upload(
progressHandler: { percent in showProgress(percent: percent) },
completionHandler: { result in
switch result {
case .success(let text):
showResult(text: text)
case .failure(let error):
showError(error)
}
}
)
}
引数としてprogressHandler
とcompletionHandler
を取ります。Swift Concurrency登場以前からライブラリに依存せず記述できる伝統的なスタイルです。upload完了以降の処理は全てcompletionHandler
に記述する必要があり、コールバック地獄の原因になります。upload関数自体は例外を投げることができないので、Result型を用いてエラーハンドリングをします。
また、この記法はアップロードのキャンセル手段を提供しないので、キャンセルは別途実装することになります。
2) Progressとcompletion handlerによる方法
// 宣言
protocol Uploader {
func upload(progress: Progress, completionHandler: (Result<String, Error>) -> Void)
}
// 呼び出し
func callUpload(uploader: Uploader) {
let progress = Progress()
progress.observe(\.fractionCompleted) { _, change in
guard let newValue = change.newValue else { return }
showProgress(percent: newValue * 100) // 0.0-1.0の値を百分率に変換する
}
uploader.upload(progress: progress) { result in
switch result {
case .success(let text):
showResult(text: text)
case .failure(let error):
showError(error)
}
}
}
同じくSwift Concurrency登場以前から利用されるcompletion handlerを使った記法ですが、progressはFoundationのProgress(Objective-CではNSProgress)オブジェクトを介して監視します。progress handlerを渡す方法と比較して、progressオブジェクトを介してアップロードのキャンセルを指示できるなどの利点があります。
3) Combineによってprogressと完了を配信する方法
// 宣言
protocol Uploader {
func upload() -> AnyPublisher<Status, Error>
}
enum Status {
case progress(percent: Double)
case complete(text: String)
}
// 呼び出し
func callUpload(uploader: Uploader) {
let publisher = uploader.uploadCombine()
publisher
.receive(on: DispatchQueue.main) // mainスレッドでイベントを受ける
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
showError(error)
}
},
receiveValue: { status in
switch status {
case .progress(let percent):
showProgress(percent: percent)
case .complete(let text):
showResult(text: text)
}
}
)
}
progressおよび最終的な結果をassociated valueつきのenum、Status
によって配信し、呼び出し側でpublisherをsubscribeします。正常にアップロードが終了した場合の結果のハンドリングがreceiveValue
内にある一方で、エラーが発生した場合のハンドリングがreceiveCompletion
内に書かれており、散らばった印象を受けます。これは、CombineのSubscribers.Completion.finished
がassociated valueを持たないためです。
また、showResultもしくはshowErrorがアップロード処理終了時に呼ばれることを呼び出し元で保証できないことも問題です。.complete
イベントが配信されず、ただ.finished
がcompletionとして配信されて関数が終了することも原理的には想定されます。.complete
の後に.progress
が配信されないことも保証されていません。
利点としては、特定のスレッドでイベントを受け取ることが容易なことが挙げられます。例えばmainスレッドでイベントを受け取りたいときに、スレッドを指定するためにイベントをハンドリングするコードのネストが深くなることはありません。また、publisherから流れてきた値に何かしらの加工をしてからsinkすることも容易です。
アップロードのキャンセルは、Cancellable
に準拠したpublisher対してcancel()を呼ぶことによって行います。
4) AsyncThrowingStreamによってprogressと完了を配信する方法
// 宣言
protocol Uploader {
func upload() -> AsyncThrowingStream<Status, Error>
}
enum Status {
case progress(percent: Double)
case complete(text: String)
}
// 呼び出し
func callUpload(uploader: Uploader) {
let stream = uploader.upload()
do {
for try await status in stream {
switch status {
case .progress(let percent):
showProgress(percent: percent)
case .complete(let text):
showResult(text: text)
}
}
}
catch {
showError(error)
}
}
Combineによる方法と同様に、progressおよび最終的な結果をassociated valueつきのenum、Status
によって配信し、呼び出し側でstreamを非同期的に監視します。do/catch構文によるエラーハンドリングが可能であり、Combineによる方法に比べて呼び出し側のコードがすっきりした印象です。showResultもしくはshowErrorがアップロード処理終了時に呼ばれることを保証できない点はCombineによる方法と同様です。アップロード完了以降の処理は.complete
caseブロック内に全て記述する必要があります。
アップロードのキャンセルは、AsyncStreamを監視しているTaskをキャンセルすることで行う方法が一般的です。
5) async関数による方法
// 宣言
protocol Uploader {
func upload(progressHandler: (_ percent: Double) -> Void) async throws -> String
}
// 呼び出し
func callUpload(uploader: Uploader) {
Task {
do {
let result = try await uploader.upload {
showProgress(percent: $0)
}
showResult(text: result)
}
catch {
showError(error)
}
}
}
引数としてprogressHandler
を取り、アップロードの結果を非同期に返す関数となっています。upload完了以降の処理はネストせずに下に順に記述できるため、処理のフローがわかりやすくなっています。また、エラーハンドリングもSwiftで最も一般的なdo/catch構文によって行うことができます。非同期関数の最終的な実行結果に強い関心がある、もしくはprogressHandler
の処理がさほど重要でない場合に適した選択肢となりそうです。
アップロードのキャンセルは、upload関数を実行しているTaskをキャンセルすることによって行います。
まとめ
以上5種類の関数の定義方法を紹介しました。大きく分けると、1, 2番はcompletion handlerを用いる伝統的な定義方法、3, 4番はイベント駆動による定義方法、5番はSwift Concurrencyのasync関数による定義方法と考えることができます。completion handlerによる定義方法はcallback地獄の要因となるため、レガシーなOSをサポートする場合を除き新しく採用する意義は薄いでしょう。
イベント駆動による定義方法は、呼び出し側が既にイベント駆動アーキテクチャによって記述されている場合は相性がよいと考えられます。progressおよび処理の終了を複数の箇所で扱うことも容易です。ただし、処理終了時に何かしらの値が返される、もしくは例外が投げられることをコードレベルで保証できないという問題点があります。
async関数による定義方法では、関数の完了以降のコードはネストせずに下に順に記述できるため、処理のフローがわかりやすくなる利点があります。また、処理が終了する際に、例外が投げられるもしくは値が返されることがコードレベルで保証されています。
どの定義方法が最適であるかは実際の状況に強く依存するため一概に結論を下すことは難しいですが、多くのケースでは5番のasync関数による定義が好ましいと考えられます。処理の最終的な結果に強い関心があり、それを使って何か後続の処理を行う場合は5番の定義方法がよく適合するでしょう。処理が終了する際に、例外が投げられるもしくは値が返されることがコードレベルで保証されていることも大きな利点です。progressのハンドリングにも強い関心がある場合や、処理の終了を複数の箇所で扱う必要がある場合は、3番のCombineによる方法や4番のAsyncThrowingStreamによる方法も良い選択肢になると考えられます。