↩️

SwiftUI の Button などでトリガーする async なメソッドの実行を、キャンセルのことまで考えて実装する方法 2選

2023/05/08に公開

まとめ

Buttonaction で async なメソッドはそのままでは呼べない

SwiftUI において、例えばユーザーが Button をトリガーしたときに実行される action で、async なメソッドを呼びたいときがあります。

import SwiftUI

struct ContentView: View {
    private func exec() async throws { /* ... */ }
    
    var body: some View {
        VStack {
            Button("Action!") {
                try exec() // ❌ 'async' call in a function that does not support concurrency
            }
        }
    }
}

async なメソッドを呼ぶ際には await キーワードを付与する必要がありますが、Buttonaction@escaping () -> Void であるため、async/await を使える状況を作る必要があります。

import SwiftUI

struct ContentView: View {
    private func exec() async throws { /* ... */ }
    
    var body: some View {
        VStack {
            Button("Action!") { // ❌ Cannot pass function of type '() async throws -> ()' to parameter expecting synchronous function type
                try await exec()
            }
        }
    }
}

その場で Task を作る例(作りっぱなし)

import SwiftUI

struct ContentView: View {
    private func exec() async throws { /* ... */ }
    
    var body: some View {
        VStack {
            Button("Action!") {
                Task {
                    try await exec()
		    /*
		     exec() の実行に10秒かかると仮定すると、
		     たとえ ContentView を閉じても
		     Button をタップしてから10秒は
		     ContentView は解放されない
		    */
                }
            }
        }
    }
}

このように、Taskinit(priority:operation:)Buttonaction で作っておけば、その中の operation で async なメソッドを呼べるようになります。

しかし、このままではこの Task は作りっぱなし・実行しっぱなしとなります。もし、上記の例の exec() の処理が非常に時間のかかるものだった場合、この View がインターフェースから消えた後も、exec() が実行され続けることになります。この場合、上記の例の ContentView はその exec() が終わるまで解放されません。

View がインターフェースから消えたタイミングでこの exec()[1] の実行はもう不要ということにし、これをキャンセルすることで少しでも早く ContentView を解放してみましょう。

方法1 - 作った Task を保持し、適切なタイミングで cancel() する

View がインターフェースから消えたタイミングで onDisappear(perform:)perform が実行されることを利用します。
Task を作るときはそれを保持しておき(下記の例でいう currentTask)、onDisappear(perform:)perform でそれの cancel() を呼びます。

import SwiftUI

struct ContentView: View {
    private func exec() async throws { /* ... */ }
    
    @State private var currentTask: Task<(), Never>?
    
    var body: some View {
        VStack {
            Button("Action!") {
                currentTask?.cancel() // 複数回連続で Button のアクションがトリガーされた(タップされたなど)とき、ひとつ前のアクションをキャンセルさせる
                currentTask = Task {
                    do {
                        try await exec()
                    } catch {
                        // ...
                    }
                }
            }
        }
        .onDisappear {
            currentTask?.cancel()
        }
    }
}

非常にわかりやすいですが、Task のイニシャライザは operation がエラーを投げるかどうかによって、throwing なものnonthrowing なものとで、使われるイニシャライザが変わってしまいます。これにより、do-catch 文を書くべきだったのにそれを忘れてもコンパイルできたり、保持している Task の型パラメータである SuccessFailure の型が変わるたびに定義側の記述も更新する必要があります。

/*
 本当は exec() のエラーをキャッチして処理を行いたかったが、
 コンパイルエラーにはならないので記述を忘れたままになった
*/

var currentTask: Task<(), Error>?
currentTask = Task {
    try await exec()
}
/*
 型が `Task<(), Never>` ではなく `Task<(), Error>` なので、
 定義側の記述も更新しなければいけない
*/

var currentTask: Task<(), Never>?
currentTask = Task { // ❌ No exact matches in call to initializer 
    try await exec()
}

方法2 - task(id:priority:_:) で実行するようにし、キャンセル処理を SwiftUI に任せる

SwiftUI の Viewtask(id:priority:_:) は、以下のような特徴があり、これらを利用します。

import SwiftUI

struct ContentView: View {
    private func exec() async throws { /* ... */ }
    
    @State private var triggers: Bool?
    
    var body: some View {
        VStack {
            Button("Action!") {
                if triggers == nil {
                    triggers = true
                } else {
                    triggers?.toggle()
                }
            }
        }
        .task(id: triggers) {
            guard triggers != nil else { return }
            do {
                try await exec()
            } catch {
                // ...
            }
        }
    }
}

Buttonaction 内では、async なメソッドを呼ぶ処理を直接書くのではなく、適当な Equatable な値を変更する処理を書きます(上記の例では Button のアクションがトリガーされる(タップされるなど)たびに、Bool の値を反転させています)。
その Equatable な値を task(id:priority:_:)id に渡すことで、この id の値が変化したときに action が実行されるようにします。
ただし、task(id:priority:_:)actionこの View が表示されるタイミングでも一度実行されるため、それと区別するために Optional を用いて nil であれば早期 return するようにしています。

こちらの方法は、先述の「方法1」にあった、「do-catch 文を書くべきだったのにそれを忘れた」が起きません。task(id:priority:_:)action は「nonthrowing なもの」しか用意されていないためです。また、「Task の型パラメータである SuccessFailure の型」についても、そもそも Task を自ら保持するように記述するわけではないのでこの問題自体が存在しません。

一方、task(id:priority:_:)iOS 15.0+、macOS 12.0+、Mac Catalyst 15.0+、tvOS 15.0+、watchOS 8.0+ でのみ利用できます

task(id:priority:_:)id に持たせる Equatable な値に工夫を持たせて、上記の例よりわかりやすく共通理解の得られやすい方法を見つけるのも良いでしょう。

async なメソッドに適切にキャンセルを伝えよう

今回の例に出てきた async なメソッドである exec() は、例えば AsyncSequencefor await ... in ... で無期限時間、値を待つ処理かもしれません。その場合、上記のように意識してキャンセルをしなければいつまでもその View が解放されず残り続けることになります。タスクの適切なキャンセルを意識しましょう。

脚注
  1. 今回のこの exec() は、キャンセルに対応したお行儀のよい async なメソッドであるとします。 ↩︎

Discussion