はじめに
本記事は SwiftWednesday Advent Calendar 2023 の1日目の記事です。
SwiftUIの onAppear(perform:)
と task(priority:_:)
モディファイアの違いと使い分けを紹介します。
環境
- OS:macOS Sonoma 14.0(23A344)
- Swift:5.9
結論
先に使い分けの結論を述べます。
- iOS 15未満 →
onAppear
- iOS 15以上
- 同期処理を実行する →
onAppear
- 非同期処理を実行する
- 必ず処理を最後まで実行する →
onAppear
- Viewのライフサイクルによって処理を自動でキャンセルする →
task
- 必ず処理を最後まで実行する →
- 同期処理を実行する →
onAppearとtaskの違い
onAppear
と task
の違いを紹介します。
taskはiOS 15.0+でしか使えない
Viewの表示時に処理を実行する点は、 onAppear
も task
も同様です。
しかし onAppear
はiOS 13.0+で使えるのに対し、 task
はiOS 15.0+でしか使えません。
iOS 15未満をサポートする場合は onAppear
を使うしかありません。
taskは非同期処理をそのまま実行できる
onAppear
は Task { ... }
で括らないと非同期処理を実行できませんが、 task
はそのままで非同期処理を実行できます。
以下は Task.sleep()
で3秒待ったあとにprintする例です。
Text("Foo")
// `onAppear` は `Task { ... }` で括らないと非同期処理を実行できない
.onAppear {
Task {
try await Task.sleep(for: .seconds(3))
print("Foo")
}
}
// `task` はそのまま非同期処理を実行できる
// `Task` と異なり、エラーを握り潰さないのに注意
.task {
try? await Task.sleep(for: .seconds(3))
print("Foo")
}
taskはViewのライフサイクルによって処理をキャンセルする
こちらが task
を使う一番の理由です。
同期処理や投げっぱなしの非同期処理の実行は、 onAppear
を使うほうが名前からViewの表示時に実行することがわかりやすいです。
例として、「トーストを出す」ボタンをタップすると下からニュッと表示するトーストを考えてみます。
こちらのトーストは3秒後に自動で消えますし、「トーストを消す」ボタンをタップしても消えます。ボタンのタップ時にはシュッと消えてほしいのでアニメーションを付けていません。
「3秒後に自動で消える」処理を、トーストの表示時に Task.sleep()
を実行することで実現します。
まずは onAppear
で実装してみます。
import SwiftUI
struct ContentView: View {
@State private var isPresented = false
var body: some View {
VStack {
Button {
withAnimation {
isPresented = true
}
} label: {
Text("トーストを出す")
}
Button {
isPresented = false
} label: {
Text("トーストを消す")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
if isPresented {
Text("トーストだよ")
.padding()
.background(.blue.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.bottom)
.transition(.move(edge: .bottom))
.onAppear {
Task {
do {
print("sleep start")
try await Task.sleep(for: .seconds(3))
print("sleep end")
withAnimation {
isPresented = false
}
} catch {
print("error")
}
}
}
}
}
}
}
実行するとわかりますが、こちらの実装は以下の問題があります。
- 「トーストを出す」ボタンをタップし、トーストを表示する
- 3秒経つ前に「トーストを消す」ボタンをタップし、トーストを消す
- 「トーストを出す」ボタンをタップし、トーストを再表示する
- 1.でトーストを出したときの「3秒後に自動で消える」処理が残っているため、すぐにトーストが消えてしまう
onAppear
内の処理は自動でキャンセルされないため、トーストを連続で表示すると、その分「3秒後に自動で消える」処理が実行されてしまいます。
catch { ... }
内の print("error")
が実行されることはありません。
そこで task
の出番です。
import SwiftUI
struct ContentView: View {
@State private var isPresented = false
var body: some View {
VStack {
Button {
withAnimation {
isPresented = true
}
} label: {
Text("トーストを出す")
}
Button {
isPresented = false
} label: {
Text("トーストを消す")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
if isPresented {
Text("トーストだよ")
.padding()
.background(.blue.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.bottom)
.transition(.move(edge: .bottom))
- .onAppear {
- Task {
- do {
- print("sleep start")
- try await Task.sleep(for: .seconds(3))
- print("sleep end")
- withAnimation {
- isPresented = false
- }
- } catch {
- print("error")
- }
- }
- }
+ .task {
+ do {
+ print("sleep start")
+ try await Task.sleep(for: .seconds(3))
+ print("sleep end")
+ withAnimation {
+ isPresented = false
+ }
+ } catch is CancellationError {
+ print("task cancel")
+ } catch {
+ print("error")
+ }
+ }
}
}
}
}
これで上記の問題が解決します。
- 「トーストを出す」ボタンをタップし、トーストを表示する
- 3秒経つ前に「トーストを消す」ボタンをタップし、トーストを消す
- 「3秒後に自動で消える」処理がキャンセルされる
- 「トーストを出す」ボタンをタップし、トーストを再表示する
- 4.から3秒後にトーストが消える
task
はViewのライフサイクルに寄り添ってくれます。
Viewが消える、またはIDが変わると CancellationError
をスローするので、適切にエラーハンドリングすることでタスクのキャンセルを実現できます。
try? await Task.sleep()
でタスクキャンセルのエラーを握り潰すと、後続の処理が実行されるので注意です。
おまけ: onAppearでtaskを実現する
onAppear
で task
のような自動キャンセルを実現するにはどうすればいいでしょうか?
正解は「 Task
を保持し、 onDisappear
でキャンセルする」です。
task
と完全に同じ動作になるかはわかりませんが、少なくとも今回のユースケースでは問題なさそうです。
import SwiftUI
struct ContentView: View {
@State private var isPresented = false
+ @State private var task: Task<(), Never>?
var body: some View {
VStack {
Button {
withAnimation {
isPresented = true
}
} label: {
Text("トーストを出す")
}
Button {
isPresented = false
} label: {
Text("トーストを消す")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
if isPresented {
Text("トーストだよ")
.padding()
.background(.blue.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.bottom)
.transition(.move(edge: .bottom))
.onAppear {
- Task {
+ task = Task {
do {
print("sleep start")
try await Task.sleep(for: .seconds(3))
print("sleep end")
withAnimation {
isPresented = false
}
} catch is CancellationError {
print("task cancel")
} catch {
print("error")
}
}
}
+ .onDisappear {
+ task?.cancel()
+ }
}
}
}
}
iOS 15未満でタスクの自動キャンセルを実現したい場合は参考にしてください。
おわりに
task
はただの「非同期処理をそのまま実行できる onAppear
」ではないことがわかりました。
task
の特性を理解し、適切なSwift Concurrencyライフを送りましょう
以上 SwiftWednesday Advent Calendar 2023 の1日目の記事でした。
明日は @ojun_9 さんで Xcode 15のPreview Macroにおける依存注入について です。