How to cancel a long term Task in ViewModel?

635 views Asked by At

This is simple test but not simple. HomeView show TestView as a sheet, TestView will hide almost actions (no .task, onReceive...) into TestViewModel, TestViewModel will detect device orientation and show it on TestView. when dismiss TestView, stop the detection.

Almost works fine but when dismiss TestView, print("TestViewModel deinit.") not happened, and the detection is still working.

I think Task have a reference of TestViewModel, that causes TestViewModel cannot release. But how to fix?

OK, next question is how to cancel the Task in TestViewModel(not in TestView) when dismiss TestView?

Any suggestion?

struct HomeView: View {
    @State var showTestView = false
    
    var body: some View {
        Button("Show Test View") {
            showTestView = true
        }.sheet(isPresented: $showTestView) {
            TestView()
        }
    }
}

struct TestView: View {
    @StateObject private var vm = TestViewModel()
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        VStack {
            Text("isPortrait = \(vm.isPortrait.description)")
            Button("Dismiss") {
                dismiss()
            }
        }
        .onDisappear {
            print("TestView onDisappear.")
        }
    }
}

@MainActor
class TestViewModel: ObservableObject {
    @Published var isPortrait = false
    
    init() {
        print("TestViewModel init.")
        setup()
    }
    
    deinit {
        print("TestViewModel deinit.")
    }
    
    func setup() {
        Task {
            await observeNotification()
        }
    }
    
    private func observeNotification() async {
        let sequence = NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification)
            .map { _ in await UIDevice.current.orientation }
        for await value in sequence {
            print("orientationDidChangeNotification changed, orientation = \(value).")
            isPortrait = value == .portrait
        }
    }
}
1

There are 1 answers

5
Louis Lac On BEST ANSWER

You need to manage the cancellation of the task yourself: keep a reference to the background task in TaskViewModel and cancel the task when the object is destroyed.

@MainActor
final class TestViewModel: ObservableObject {
    @Published var isPortrait = false
    private var task: Task<Void, Never>? = nil
    
    init() {
        print("TestViewModel init.")
        setup()
    }
    
    deinit {
        print("TestViewModel deinit.")
        task?.cancel()
        task = nil
    }
    
    func setup() {
        guard task == nil else { return }
        
        task = Task { [weak self] in
            let sequence = NotificationCenter.default.notifications(
                named: UIDevice.orientationDidChangeNotification
            )
            .map { _ in
                await UIDevice.current.orientation
            }
            
            for await value in sequence {
                if Task.isCancelled {
                    print("Task cancelled")
                    return
                }
                
                print("orientationDidChangeNotification changed, orientation = \(value).")
                self?.isPortrait = value == .portrait
            }
            
            print("Observation ended")
        }
    }
}

As you guessed, you must treat self as a weak reference inside the fire up background task, otherwise a reference cycle will keep both objects alive in memory.

A behavior I did not find well documented is that the async iterator returned by the .notification()method seems to handle cancellation, as such, the iterator returns nil when the task is cancelled and the for loop is ended.

That being said, the .task() view modifier is the most convenient and a safer way to achieve what you are trying to do. It is designed specifically for this purpose.