SwiftUI の Button などでトリガーする async なメソッドの実行を、キャンセルのことまで考えて実装する方法 2選
まとめ
Button
の action
で 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
キーワードを付与する必要がありますが、Button
の action
は @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 は解放されない
*/
}
}
}
}
}
このように、Task
の init(priority:operation:)
を Button
の action
で作っておけば、その中の operation
で async なメソッドを呼べるようになります。
しかし、このままではこの Task
は作りっぱなし・実行しっぱなしとなります。もし、上記の例の exec()
の処理が非常に時間のかかるものだった場合、この View がインターフェースから消えた後も、exec()
が実行され続けることになります。この場合、上記の例の ContentView
はその exec()
が終わるまで解放されません。
View がインターフェースから消えたタイミングでこの exec()
[1] の実行はもう不要ということにし、これをキャンセルすることで少しでも早く ContentView
を解放してみましょう。
Task
を保持し、適切なタイミングで cancel()
する
方法1 - 作った 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
の型パラメータである Success
・Failure
の型が変わるたびに定義側の記述も更新する必要があります。
/*
本当は 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()
}
task(id:priority:_:)
で実行するようにし、キャンセル処理を SwiftUI に任せる
方法2 - SwiftUI の View
の task(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 {
// ...
}
}
}
}
Button
の action
内では、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
の型パラメータである Success
・Failure
の型」についても、そもそも 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()
は、例えば AsyncSequence
を for await ... in ...
で無期限時間、値を待つ処理かもしれません。その場合、上記のように意識してキャンセルをしなければいつまでもその View が解放されず残り続けることになります。タスクの適切なキャンセルを意識しましょう。
-
今回のこの
exec()
は、キャンセルに対応したお行儀のよい async なメソッドであるとします。 ↩︎
Discussion