Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Proper cancellation of Tasks in View Model

When using MVVM architecture in Swift for iOS (or for KMM iOS part), is it needed to cancel the Tasks inside of View Model manually or will the iOS do that automatically?

If I need to do it manually, what is the right place to cancel the task (eligibilityCheckJob below)

  • deinit() method of my AppInitViewModel? or
  • onDisappear() method of my AppInitScreen view = which calls onCleared() in AppInitViewModel?

Here is my code snippet for reference:

struct AppInitScreen: View {

    private let s: Services

    @StateObject
    var viewModel: AppInitViewModel
    
    init(s: Services) {
        self.s = s
        _viewModel = StateObject(wrappedValue: AppInitViewModel(networkService: s.networkService))
    }
    
    var body: some View {
        AppInitScreenContents(
            uiState: viewModel.uiState,
            uiListener: viewModel
        )
        .onDisappear{
            print("AppInitScreen: onDisappear")
            viewModel.onCleared()
        }
    }
    
}

.....

extension AppInitScreen { 
    
    class AppInitViewModel: ObservableObject, AppInitScreenStateIListener { 
        
        private var networkService: NetworkService
        
        @Published 
        private(set) var uiState =  AppInitScreenState(showWelcomeText: true, showProgressBar: false)
        
        private var eligibilityCheckJob: Task<(), Error>? = nil
        
        init(networkService: NetworkService) {
            self.networkService = networkService
        }
        
        @MainActor //all code inside will be executed on main UI thread
        func onNextButtonClick() { //method from AppInitScreenStateIListener
            uiState = uiState.doCopy(
                showWelcomeText: false,
                showProgressBar: uiState.showProgressBar
            )
            processEligibilityCheck()
        }
        
        @MainActor
        func onBackButtonClick() { //method from AppInitScreenStateIListener
            eligibilityCheckJob?.cancel()
            //reset to initial state
            uiState = AppInitScreenState(
                showWelcomeText: true,
                showProgressBar: false
            )
        }
        
        @MainActor
        private func processEligibilityCheck() {
            eligibilityCheckJob = Task {
                
                //show progress indicator
                uiState = uiState.doCopy(showWelcomeText: uiState.showWelcomeText, showProgressBar: true)
                
                let isInternetAvailable: KotlinBoolean
                do {
                    isInternetAvailable = try await networkService.isInternetAvailable()
                } catch {
                    isInternetAvailable = false
                }
                if(isInternetAvailable.boolValue) { 
                    //...more code
                }
                
                //hide progress indicator
                uiState = uiState.doCopy(showWelcomeText: uiState.showWelcomeText, showProgressBar: false)

            }
            
        }
        
        func onCleared() { //called from onDisappear of AppInitScreen
            print("AppInitViewModel: onCleared")
            eligibilityCheckJob?.cancel() //IS THIS NEEDED HERE?

        }
        
        deinit {
            print("AppInitViewModel: deinit")
            eligibilityCheckJob?.cancel() //OR IS THIS NEEDED HERE?
        }
        
    }
    
}

I have tried both ways (with task cancellation and without) on emulator but have not encountered any memory leaks.

UPDATE 1

@Rob have suggested to use .task modifier of the view itself. I wanted to keep the code in my View Model however so I created extension function below. Is it ok to use it like this? I would not like to break the MVVM pattern.

extension AppInitScreen {
    ///Extension function to launch task via .task modifier of the screen View. The task will follow View lifecycle (it does not need to be canceled manually). Use for fire-and-forget tasks.
    func launchTask(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View { //needs import SwiftUI
        return self.task(priority: priority, action)
    }
    
    class AppInitScreen: ObservableObject, AppInitScreenStateIListener {
        //...
        @MainActor
        private func processEligibilityCheck() {
            screenView.launchTask {
                //...
                isInternetAvailable = try await networkService.isInternetAvailable()
            }
        }
    }
    
}

UPDATE 2

Code with launchTask from UPDATE 1 requires to pass View to the View Model, and that breaks the MVVM pattern. The task is being fired when button is pressed not when View appears so I have not used .task modifier. I ended up with:

  • manually canceling long running tasks in onDisappeared() (onCleared()).
  • not caring about short running tasks, which will just cancel when finished.
like image 484
Petr Apeltauer Avatar asked Sep 02 '25 14:09

Petr Apeltauer


2 Answers

tl;dr

The deinit approach may not work for reasons outlined below. Manually canceling that unstructured concurrency from onDisappear is often what we have to do. The exception is .task view modifier which will cancel its tasks automatically (when combined either with structured concurrency or unstructured concurrency wrapped in a withTaskCancellationHandler).


While deinit has an intuitive appeal, it frequently cannot be relied upon in this sort of scenario. Consider our looping through the NotificationCenter asynchronous sequence, notifications:

@MainActor
class DetailViewModel: ObservableObject {
    private var didEnterBackgroundNotificationTask: Task<Void, Never>?

    deinit {
        didEnterBackgroundNotificationTask?.cancel()       // we’ll never get here!!! 
    }

    func startMonitoringNotificationCenter() {
        didEnterBackgroundNotificationTask = Task {
            for await _ in NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification) {
                didEnterBackground()
            }
        }
    }

    private func didEnterBackground() { … }
}

If you startMonitoringNotificationCenter, you have a strong reference cycle, and thus deinit cannot be called. (This problem is exacerbated by the fact that unlike standard closures, there is no reference to self here to bring your attention to the potential strong reference cycle.)

So you really need to give your view model (or whatever) a function to clean up after itself, and call it from the view’s didDisappear:

@MainActor
class DetailViewModel: ObservableObject {
    private var didEnterBackgroundNotificationTask: Task<Void, Never>?

    func cleanUp() {
        didEnterBackgroundNotificationTask?.cancel()
    }

    func startMonitoringNotificationCenter() {
        didEnterBackgroundNotificationTask = Task {
            for await _ in NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification) {
                didEnterBackground()
            }
        }
    }

    private func didEnterBackground() { … }
}

Now I used an AsyncSequence as my example in order to manifest a persistent strong reference cycle that will never be resolved unless we explicitly cancel it. (I did this to bring the problem into stark relief.) But the same is for any Task instances that (implicitly or explicitly) keeps a strong reference to the view model object: Until these sorts of tasks all finish (or we manually cancel them), the object in question may not be able to be deallocated.

So, in short, manually cancel these in onDisappear.


FWIW, there is a .task modifier that will automatically cancel any structured concurrency (or unstructured concurrency wrapped in a withTaskCancellationHandler) when you dismiss the view in question. E.g.

struct DetailView: View {
    @StateObject var viewModel = DetailViewModel()

    var body: some View {
        Text("Hello, World!")
            .task {
                await viewModel.startMonitoringNotificationCenter()
            }
    }
}

@MainActor
class DetailViewModel: ObservableObject {
    func startMonitoringNotificationCenter() async {
        for await _ in NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification) {
            didEnterBackground()
        }
    }

    private func didEnterBackground() { … }
}

No manual cancelation is required. No deinit. No onDisappear. The .task modifier will start an asynchronous task when the view appears and then automatically cancel it when it disappears.

This .task view modifier obviously can only propagate cancelation within structured concurrency, but in those scenarios, the .task view modifier is an elegant solution.

like image 152
Rob Avatar answered Sep 05 '25 15:09

Rob


To make my answer clearer, you should cancel the task manually , ideally before the view closes or the object may drop out of scope / deinit. Especially if the background task you are running has scope to carry on running a lot of work past the lifetime of the Object and View. In my opinion it is best to call this explicitly in your onCleared() function rather than rely on deinit (which personally I have found variable on when it is called / run (ie. something might still be holding a reference)).

The fact a task runs until it is cancelled explicitly is made clear in Apple documentation, regardless of whether anything has a strong reference to the Task.

https://developer.apple.com/documentation/swift/task

  • "A task runs regardless of whether you keep a reference to it. However, if you discard the reference to a task, you give up the ability to wait for that task’s result or cancel the task."
like image 42
Hongtron Avatar answered Sep 05 '25 15:09

Hongtron