Quantcast
Channel: メドピア開発者ブログ
Viewing all articles
Browse latest Browse all 216

Swift Concurrencyのキャンセルと向き合う

$
0
0

メドピアでモバイルアプリを開発している小林(@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するほうがよいでしょう。

lettask= Task {
  do {
    letresult=try await someRequest()
  } catch {
    guard!Task.isCancelled else { return }
    // TODO: Handle error
  }
}

...

task.cancel()

Combineの AnyCancellableは破棄のタイミングで自動的にキャンセルされていましたが、 Taskcancel()を呼ばない限り、キャンセルされません。 Combineにおいては購読するストリームを Set<AnyCancellable>などで持っておき、ViewController(以降、VC)やViewModel(以降、VM)の破棄とともにキャンセルされるものと考えていましたが、Swift Concurrencyにおいては、

  1. VC/VMの破棄とともにキャンセルされる仕組みを導入する
  2. キャンセルを諦め、割り切る

のどちらで実装するかを考える必要があります。

Taskのキャンセル考慮は意外と険しい

VC/VMクラスのdeinitで cancel()すればよいかと思いきや、実はいくつかのハマりどころがあるのです。こんなViewModelクラスを考えます。

@MainActorfinalclassEchoViewModel {

    init(echoService:EchoService) {
        self.echoService = echoService
    }
    
    deinit { task?.cancel() }

    // Output@Publishedprivate(set) varechoBack=""privateletechoService:EchoServiceprivatevartask:Task<Void, Never>?

    // 一定時間経過後にstringをエコーバックするfuncecho(_ string:String) {
        task = Task { [unowned self] indo {
                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.swiftfuncecho(_ string:String) {
        task = Task { [unowned self] indo {
                letechoBack=try await echoService.echo(string)
                self.echoBack = echoBack
            } catch {
                guard!Task.isCancelled else { return }
                dump(error)
            }
        }
    }

一度ローカル変数で受けることでコンパイラが生成する実行プログラムが変わり、この場合は画面を閉じるとVC/VMが破棄され、キャンセルが発動します。

実質的に同じ意味のコードを書いているのに挙動が変わるのは困りものですが、実はもっと危険なケースがあります。それは、 非同期処理がキャンセルに対応していない場合です。

EchoServiceecho()メソッドがもし以下のような実装だとしたら、例えキャンセルしたとしても無視され、一定時間後にエコーバックしてしまいます。

finalclassEchoService {
    privateletdelay:TimeInterval=3funcecho(_ string:String) async throws->String {
        // FIXME: withTaskCancellationHandlerでキャンセル対応するreturntry 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.swiftfuncecho(_ string:String) async {
        do {
            letechoBack=try await echoService.echo(string)
            self.echoBack = echoBack
        } catch {
            guard!Task.isCancelled else { return }
            dump(error)
        }
    }
finalclassEchoViewController:UIViewController {
    ...deinit { task?.cancel() }
    
    funcsendEcho() {
        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/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html


Viewing all articles
Browse latest Browse all 216

Latest Images

Trending Articles