🦋

SwiftUI: ObservationでTaskを保持する時の注意事項

2024/12/31に公開

SwiftUI × Swift Concurrencyでの開発も徐々に浸透してきた頃だと思いますが、慣れてきたなと油断していたら気づかない間に罠にハマっていたので失敗を共有しておこうと思います。

問題

あるViewが表示されたら何かしらのAsyncStreamfor await inで購読し、Viewが閉じられたら購読を止めるような実装がしたいとします。その場合、Observable classinitで購読のTaskを開始し、deinitTaskをキャンセルするようにしていると意図通りにdeinitが発火しない可能性があります。

ダメな実装例
import Combine
import SwiftUI
import Observation

struct ContentView: View {
    @State private var model = ContentModel()

    var body: some View {
        VStack {
            Text(model.count.description)
            Button {
                Task {
                    await model.countUp()
                }
            } label: {
                Text("+")
            }
        }
        .onAppear {
            print("onAppear")
        }
        .onDisappear {
            print("onDisappear")
        }
    }
}

@MainActor @Observable class ContentModel {
    private let countService = CountService()
    @ObservationIgnored private var task: Task<Void, Never>?
    var count: Int = 0

    init() {
        print("init")
        task = Task {
            for await value in await countService.countStream() {
                count = value
            }
        }
    }

    deinit {
        print("deinit")
        task?.cancel()
    }

    func countUp() async {
        await countService.countUp()
    }
}

actor CountService {
    private let countSubject = CurrentValueSubject<Int, Never>(0)

    func countStream() -> AsyncStream<Int> {
        AsyncStream { continuation in
            let cancellable = countSubject.sink { value in
                continuation.yield(value)
            }
            continuation.onTermination = { _ in
                cancellable.cancel()
            }
        }
    }

    func countUp() {
        countSubject.value += 1
    }
}

extension AnyCancellable: @retroactive @unchecked Sendable {}

この例ではContentViewonDisappearが呼ばれてもdeinitが呼ばれません。これは、for await inのところでObservable classの持ち物を強参照していることでメモリリークが発生してしまっているのが原因です。

init() {
    task = Task {
        // ここで強参照
        for await value in await countService.countStream() {
            count = value
        }
    }
}

対策

対策はいくつかありますが、簡単なものを2つ紹介します。
@matsujiさんに追加で対策を教えていただいたので3つ紹介します。

まずは、Taskのキャンセルをdeinitで行うのをやめてonDisappearで行うようにすることです。Taskが生存している限りObservable classの強参照は解除されないため、Viewが不要になったタイミングでTaskも破棄します。

@MainActor @Observable class ContentModel {
    private let countService = CountService()
     @ObservationIgnored private var task: Task<Void, Never>?
    var count: Int = 0

    init() {
        print("init")
        task = Task {
            for await value in await countService.countStream() {
                count = value
            }
        }
    }

    deinit {
        print("deinit")
    }

    func onDisappear() {
        task?.cancel()
    }

    func countUp() async {
        await countService.countUp()
    }
}

struct ContentView: View {
    @State private var model = ContentModel()

    var body: some View {
        VStack {
            Text(model.count.description)
            Button {
                Task {
                    await model.countUp()
                }
            } label: {
                Text("+")
            }
        }
        .onAppear {
            print("onAppear")
        }
        .onDisappear {
            print("onDisappear")
            model.onDisappear()
        }
    }
}

2つ目は、AsyncStreamの購読をView.taskで行うことです。リファレンスに書いてありますが、このTaskonDisappearの際に自動でキャンセルされます。

@MainActor @Observable class ContentModel {
    let countService = CountService()
    var count: Int = 0

    init() {
        print("🦩 init")
    }

    deinit {
        print("🦩 deinit")
    }

    func countUp() async {
        await countService.countUp()
    }
}

struct ContentView: View {
    @State private var model = ContentModel()

    var body: some View {
        VStack {
            Text(model.count.description)
            Button {
                Task {
                    await model.countUp()
                }
            } label: {
                Text("+")
            }
        }
        .onAppear {
            print("onAppear")
        }
        .task {
            for await value in await model.countService.countStream() {
                model.count = value
            }
        }
        .onDisappear {
            print("onDisappear")
        }
    }
}

3つ目は、Taskのスコープでキャプチャを用いてselfの強参照を回避することです。重要なのはselfを強参照する原因になるものは全てキャプチャする点です。今回の場合はselfだけでなくcountServiceもキャプチャすると綺麗に書けます。

@MainActor @Observable class ContentModel {
    let countService = CountService()
    @ObservationIgnored var task: Task<Void, Never>?
    var count: Int = 0

    init() {
        print("🦩 init")
        task = Task { [weak self, countService] in
            for await value in await countService.countStream() {
                self?.count = value
            }
        }
        // または
        task = Task { [weak self] in
            guard let countService = self?.countService else { return }
            for await value in await countService.countStream() {
                self?.count = value
            }
        }
    }

    deinit {
        print("🦩 deinit")
    }

    func countUp() async {
        await countService.countUp()
    }
}

struct ContentView: View {
    @State private var model = ContentModel()

    var body: some View {
        VStack {
            Text(model.count.description)
            Button {
                Task {
                    await model.countUp()
                }
            } label: {
                Text("+")
            }
        }
        .onAppear {
            print("onAppear")
        }
        .onDisappear {
            print("onDisappear")
        }
    }
}

Xcode 16以降ではViewがデフォルトで@MainActorになりdeinitにもグローバルアクターが反映され流ようになったため、終了時の処理をdeinitでやりやすくなった側面があります。しかし、そこに釣られて何でもdeinitで処理を書いて満足していると、実はdeinitが呼ばれておらず意図しない挙動を生んでしまっているかもしれません。気をつけましょう。

Discussion