@State の deinit に依存した処理の注意点

SwiftUIView で非同期処理を開始した際、View が非表示になったタイミングで不要になった処理はキャンセルしておきたいです。
@State で保持した Classdeinit を活用してキャンセル漏れを防げないか検討したのですが、これは避けた方が良さそうという結論に至ったのでその理由を以下に書いていきます。

前提

例えば以下の実装では、View が非表示になってもカウントは止まりません。

struct ContentView: View {
    var body: some View {
        Text("Counting...")
            .onAppear {
                Task {
                    await startCounting()
                }
            }
    }

    private func startCounting() async {
        do {
            for i in 1...10_000 {
                try await Task.sleep(nanoseconds: NSEC_PER_SEC)
                print(i)
            }
            print("Finish")
        } catch {
            print("Canceled")
        }
    }
}

カウントを止めるには task modifier を利用したり、Task@State で保持して onDisappearcancel を呼ぶ方法があります。

.task {
    await startCounting()
}
@State private var task: Task<Void, Never>?

.onAppear {
    task = Task {
        await startCounting()
    }
}
.onDisappear {
    task?.cancel()
}

task modifier を使う場合は SwiftUI がキャンセルまでしてくれますが、Task { ... } の書き方をする場合は実装者に委ねられます。
すべての Task を漏れなくキャンセルするのは難しいので、仕組みで解決できないか検討しました。

@State で保持した Class の deinit によるキャンセル

以下の TaskBagTask を管理し deinit でキャンセルを呼び出します。 @StateTaskBag を保持することで、View が非表示になった際に TaskBag が破棄され Task がキャンセルされることを期待しています。

final class TaskBag {
  private var task: Task<Void, Never>?

  init() {
    print(“TaskBag: init”)
  }

  deinit {
    print(“TaskBag: deinit”)
    task?.cancel()
  }

  func add(
    priority: TaskPriority? = nil,
    operation: @escaping () async -> Void,
  ) {
    task = Task(priority: priority, operation: operation)
  }
}

struct ContentView: View {
    @State private var taskBag: TaskBag = .init()

    var body: some View {
        Text("Counting...")
            .onAppear {
                taskBag.add {
                    do {
                        for i in 1...10_000 {
                            try await Task.sleep(nanoseconds: NSEC_PER_SEC)
                            print("Counter: \(i)")
                        }
                        print("Counter: Finish")
                    } catch {
                        print("Counter: Canceled")
                    }
                }
            }
    }
}

上記を実行すると、View を非表示にした際に期待通り Task がキャンセルされます。

TaskBag: deinit
Counter: Canceled

では、カウント処理をベタ書きせずメソッドを呼び出す形に変えて実行してみます。

struct ContentView: View {
    @State var taskBag: TaskBag = .init()

    var body: some View {
        Text("Counting...")
            .onAppear {
                taskBag.add {
                    await startCounting()
                }
            }
    }
}

残念ながら View を非表示にしても TaskBag は破棄されずキャンセルされません。 これは Task に渡したクロージャView をキャプチャしており間接的に TaskBag が循環参照しているためです。

簡略的なコードでこの挙動を確認します。

final class TaskBag {
    init() {
        print("TaskBag: init")
    }

    deinit {
        print("TaskBag: deinit")
    }
}

struct View {
    var taskBag: TaskBag = .init()

    func doNothing() {}
}

初めに View を生成し 3秒後に print 出力します。

autoreleasepool {
    print("①")
    var view = View()

    Task {
        try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
        print("②")

        print("③")
    }
}

実行すると、当然 ② の前に TaskBag が破棄されます。

①
TaskBag: init
TaskBag: deinit
②
③

では次に ② と ③ の間で doNothing を呼び出します。

autoreleasepool {
    print("①")
    var view = View()

    Task {
        try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
        print("②")
        view.doNothing()
        print("③")
    }
}

実行すると TaskBag の破棄タイミングが遅くなり ③ の後になることが分かります。

①
TaskBag: init
②
③
TaskBag: deinit

先ほどの実装では 10,000 までのカウントが終了するまで TaskBag が保持され続けることになります。
仮に購読処理やポーリング処理を実行していた場合、それらは明示的にキャンセルされるまでの間ずっと View をキャプチャし続け同時に @State の生存期間も延長させる可能性があります。

まとめ

不要になった非同期処理の後処理を抜け漏れなく行うために @State で保持した Classdeinit を活用してキャンセルを呼びだす TaskBag を検討しましたが、Task がキャプチャする View によって TaskBag が解放されず期待通りにキャンセルされないケースがあることが分かりました。 悩ましいのは、実装時やレビュー時に気づきづらい点です。

よりより方法があれば是非知りたいですが、現時点では onDisappear でキャンセルを呼び出すようにした方が良いかと考えています。