onAppearとtaskの違いと使い分け(SwiftUI)

## はじめに

本記事は [SwiftWednesday Advent Calendar 2023](https://qiita.com/advent-calendar/2023/swift-wednesday) の1日目の記事です。

SwiftUIの [`onAppear(perform:)`](https://developer.apple.com/documentation/swiftui/view/onappear(perform:)) と [`task(priority:_:)`](https://developer.apple.com/documentation/swiftui/view/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する例です。

```swift
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秒後に自動で消えますし、「トーストを消す」ボタンをタップしても消えます。ボタンのタップ時にはシュッと消えてほしいのでアニメーションを付けていません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/1146ef12-ff9a-e183-ad5b-d73ae8a0e1e5.png)

「3秒後に自動で消える」処理を、トーストの表示時に `Task.sleep()` を実行することで実現します。

まずは `onAppear` で実装してみます。

```swift:ContentView.swift
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")
                            }
                        }
                    }
            }
        }
    }
}
```

![on_appear.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/db8c5009-f195-2996-cea2-6ce3e9bbe0f9.gif)

実行するとわかりますが、こちらの実装は以下の問題があります。

1. 「トーストを出す」ボタンをタップし、トーストを表示する
2. 3秒経つ前に「トーストを消す」ボタンをタップし、トーストを消す
3. 「トーストを出す」ボタンをタップし、トーストを再表示する
4. 1.でトーストを出したときの「3秒後に自動で消える」処理が残っているため、すぐにトーストが消えてしまう

`onAppear` 内の処理は自動でキャンセルされないため、トーストを連続で表示すると、その分「3秒後に自動で消える」処理が実行されてしまいます。
`catch { ... }` 内の `print("error")` が実行されることはありません。

そこで `task` の出番です。

```diff_swift:ContentView.swift
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")
+                       }
+                   }
            }
        }
    }
}
```

![task.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/6ff057bf-9057-7c45-86ca-740cc652c571.gif)

これで上記の問題が解決します。

1. 「トーストを出す」ボタンをタップし、トーストを表示する
2. 3秒経つ前に「トーストを消す」ボタンをタップし、トーストを消す
3. 「3秒後に自動で消える」処理がキャンセルされる
4. 「トーストを出す」ボタンをタップし、トーストを再表示する
5. 4.から3秒後にトーストが消える

`task` はViewのライフサイクルに寄り添ってくれます。
Viewが消える、またはIDが変わると [`CancellationError`](https://developer.apple.com/documentation/swift/cancellationerror) をスローするので、適切にエラーハンドリングすることでタスクのキャンセルを実現できます。

`try? await Task.sleep()` でタスクキャンセルのエラーを握り潰すと、後続の処理が実行されるので注意です。

## おまけ: onAppearでtaskを実現する

`onAppear` で `task` のような自動キャンセルを実現するにはどうすればいいでしょうか？

正解は「 `Task` を保持し、 `onDisappear` でキャンセルする」です。
`task` と完全に同じ動作になるかはわかりませんが、少なくとも今回のユースケースでは問題なさそうです。

```diff_swift:ContentView.swift
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ライフを送りましょう :relaxed:

以上 [SwiftWednesday Advent Calendar 2023](https://qiita.com/advent-calendar/2023/swift-wednesday) の1日目の記事でした。
明日は @ojun_9 さんで [Xcode 15のPreview Macroにおける依存注入について](https://zenn.dev/dena/articles/9165f8c4e12acc) です。