SwiftUI: ObservationでTaskを保持する時の注意事項
SwiftUI × Swift Concurrencyでの開発も徐々に浸透してきた頃だと思いますが、慣れてきたなと油断していたら気づかない間に罠にハマっていたので失敗を共有しておこうと思います。
問題
あるViewが表示されたら何かしらのAsyncStream
をfor await in
で購読し、Viewが閉じられたら購読を止めるような実装がしたいとします。その場合、Observable class
のinit
で購読のTask
を開始し、deinit
でTask
をキャンセルするようにしていると意図通りに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 {}
この例ではContentView
のonDisappear
が呼ばれても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で行うことです。リファレンスに書いてありますが、このTask
はonDisappear
の際に自動でキャンセルされます。
@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