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)
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:
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.
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With