メドピアでモバイルアプリを開発している小林(@imk2o)です。 惑星直列に興奮しています。
Xcode13.2より、iOS13以降を対象にしたアプリであればSwift Concurrencyを利用できるようになりました。従来RxSwiftやCombineなどのReactive Streamを利用しているプロジェクトのSwift Concurrencyへの移行を進める際、考慮しなければならないことのひとつが「キャンセル」の扱いです。
以下に記載するコードとその挙動は、Swift5.6(Xcode13.4)とSwift5.7(Xcode14 beta)で確認しました。
Taskのキャンセルの仕組み
非同期処理を実行する Task オブジェクトは cancel()
メソッドを持っており、これを明示的に呼び出すことでキャンセルができます。
Task
がキャンセルされると通常 CancellationError
が投げられますが、catchブロック内では Task.isCancelled
の値をチェックする方法が推奨されています。キャンセルされた場合は直ちに return
するほうがよいでしょう。
let task = Task { do { let result = try await someRequest() } catch { guard !Task.isCancelled else { return } // TODO: Handle error } } ... task.cancel()
Combineの AnyCancellable
は破棄のタイミングで自動的にキャンセルされていましたが、 Task
は cancel()
を呼ばない限り、キャンセルされません。
Combineにおいては購読するストリームを Set<AnyCancellable>
などで持っておき、ViewController(以降、VC)やViewModel(以降、VM)の破棄とともにキャンセルされるものと考えていましたが、Swift Concurrencyにおいては、
- VC/VMの破棄とともにキャンセルされる仕組みを導入する
- キャンセルを諦め、割り切る
のどちらで実装するかを考える必要があります。
Taskのキャンセル考慮は意外と険しい
VC/VMクラスのdeinitで cancel()
すればよいかと思いきや、実はいくつかのハマりどころがあるのです。こんなViewModelクラスを考えます。
@MainActor final class EchoViewModel { init(echoService: EchoService) { self.echoService = echoService } deinit { task?.cancel() } // Output @Published private(set) var echoBack = "" private let echoService: EchoService private var task: Task<Void, Never>? // 一定時間経過後にstringをエコーバックする func echo(_ string: String) { task = Task { [unowned self] in do { echoBack = try await echoService.echo(string) } catch { guard !Task.isCancelled else { return } dump(error) } } } }
この echo("Hello!")
を呼び出すと、一定時間後 echoBack
プロパティが "Hello!" に変化するコードです。このときエコーバックされる前に画面を閉じてしまうと、VC/VMは直ちに破棄され、Task
はキャンセルされるか...というとそうはなりません。
Task
ブロック内は [unowned self]
でVMの参照を持たないようにしているのですが、
echoBack = try await echoService.echo(string)
と書くことで、SwiftコンパイラがVMの参照カウントを増やす実行プログラムを生成してしまうのです。 従ってVCは破棄されてもVMは破棄されず、エコーバックされた後に破棄されるため、結果的にキャンセルが発生しません。
このコンパイラ仕様を回避するため、echo()
メソッドを以下のように変えてみます。
// EchoViewModel.swift func echo(_ string: String) { task = Task { [unowned self] in do { let echoBack = try await echoService.echo(string) self.echoBack = echoBack } catch { guard !Task.isCancelled else { return } dump(error) } } }
一度ローカル変数で受けることでコンパイラが生成する実行プログラムが変わり、この場合は画面を閉じるとVC/VMが破棄され、キャンセルが発動します。
実質的に同じ意味のコードを書いているのに挙動が変わるのは困りものですが、実はもっと危険なケースがあります。それは、 非同期処理がキャンセルに対応していない場合 です。
EchoService
の echo()
メソッドがもし以下のような実装だとしたら、例えキャンセルしたとしても無視され、一定時間後にエコーバックしてしまいます。
final class EchoService { private let delay: TimeInterval = 3 func echo(_ string: String) async throws -> String { // FIXME: withTaskCancellationHandlerでキャンセル対応する return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global().asyncAfter(deadline: .now() + delay) { continuation.resume(returning: string) } } } }
このようにキャンセルが考慮されていない非同期メソッドを呼び出してしまうと、先ほどのVMのような実装にしたことで、クラッシュを発生させる要因を生み出してしまいます。
(self.echoBack = echoBack
のところでクラッシュします)
この問題の対処法として、ここまでVMの echo()
の中で Task
を作り、同期/非同期の境界点としていましたが、VMの echo()
を非同期メソッドにし、VC側で Task
を作る形に変更してみます。
// EchoViewModel.swift func echo(_ string: String) async { do { let echoBack = try await echoService.echo(string) self.echoBack = echoBack } catch { guard !Task.isCancelled else { return } dump(error) } }
final class EchoViewController: UIViewController { ... deinit { task?.cancel() } func sendEcho() { task = Task { [unowned self] in await viewModel.echo("Hello!") } }
この場合の画面を閉じたときの挙動は、VCは直ちに破棄されるので Task
はキャンセルされます。VMはコンパイラによって参照カウントが増えており、少なくともVMの echo()
メソッドのスコープにいる間はオブジェクトが生きているため self.echoBack = echoBack
のところでもクラッシュしないので、致命的な問題は回避できそうです。
まとめ
Swift Concurrencyにおいてはキャンセルは明示的に行う必要があること、またいくつかの留意点があることを紹介させていただきました。 ただ、「こんなに厄介ならキャンセルしない」と割り切るのもアリかなと思っています。
非同期処理の多くがUnaryなRest APIの呼び出しである場合、このリクエストをキャンセル可能にしたところで、通信や端末リソースの削減になるかというと微々たるもの...とも言えます。 Swift Concurrencyの設計思想からも、キャンセルはオプション的扱いで、必要であれば使ったらいいよという位置付けなんだろうなと思いました。
Combineと比較されがちですが、どちらにも長所・短所があると思うので適宜使い分けていくのが良いでしょう!
是非読者になってください
メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!
■募集ポジションはこちら
https://medpeer.co.jp/recruit/entry/
■開発環境はこちら