Async iniatilization of the SwiftUI app

Viacheslav Tkachenko
3 min readApr 11, 2024

--

Writing an app with SwiftUI is a lot of fun, but it can be a bit tricky at times. One such challenge is initializing your app with an async function.

Initial conditions

Let’s discuss a simple application that consists of just two view flows. For instance, the app should show the login screen, and once the user is logged in, it should display the main screen. Let’s also assume that our application stores its state in some kind of thread-safe AppState class, which is guarded by its own AppStateActor actor. This means we can only read it from async functions.

// AppState.swift
import Foundation

@globalActor actor AppStateActor {
static let shared = AppStateActor()
}

@AppStateActor class AppState {
var isLoggedIn: Bool = true
}

Question

The pressing question then becomes: how do we initialize the app with an async function to check if the user is logged in?

Solution 1

The first idea that springs to mind is placing our initial setup within the onAppear method of the root view, as illustrated in the first code snippet:

import SwiftUI

@MainActor
class SwiftUIAsyncAppInitAppViewModel: ObservableObject {
var isLoggedIn: Bool = false
func detectState() async {
isLoggedIn = await AppState().isLoggedIn
}
}


@main
struct SwiftUIAsyncAppInitApp: App {
@StateObject var viewModel = SwiftUIAsyncAppInitAppViewModel()

var body: some Scene {
WindowGroup {
ZStack {
if viewModel.isLoggedIn {
MainView()
} else {
LoginView()
}
}.onAppear() {
Task { await viewModel.detectState() }
}
}
}
}

In this scenario, the user will experience the sequence: AppSplash -> LoginView -> LoginView or MainView. If the user was previously logged in, they will momentarily see the LoginView before it transitions to the MainView. This does not constitute a good user experience.

Solution 2

The documentation from Apple states:

“Adds an asynchronous task to perform before this view appears,”

leading you to believe it will execute before the view is visible. However, this isn’t entirely accurate. The task does start before the view appears, but the view may be displayed before the task completes. Let’s delve into this with the second code example:

@main
struct SwiftUIAsyncAppInitApp: App {
@StateObject var viewModel = SwiftUIAsyncAppInitAppViewModel()

var body: some Scene {
WindowGroup {
ZStack {
if viewModel.isLoggedIn {
MainView()
} else {
LoginView()
}
}.task {
await viewModel.detectState()
}
}
}
}

Unfortunately, the user will again experience the sequence: AppSplash -> LoginView -> LoginView or MainView, seeing the LoginView briefly if they were previously logged in, even if our initialization is rapid.

Solution 3: The Working Fix

The third approach is a little hacky, but it works. We can write a synchronous Task (you can find many other implementations on StackOverflow) in which we will check the user’s state. The problem is that we can’t set the state of the app in that Synchronous Task (because we should perform changes on the Main Thread, which is already waiting for us to finish — this will lead to a deadlock). But we can use unsafe global variables to transfer the state to the main thread and finish the initialization of the root viewModel. Of course, global variables are not good practice, but in this case, it is the only way to transfer the state from the async function to the main thread. Let’s test it:

import SwiftUI

fileprivate var isLoggedInUnsafe: Bool = false

@MainActor
class SwiftUIAsyncAppInitAppViewModel: ObservableObject {
var isLoggedIn: Bool = false
init() {
Task.synchronous {
isLoggedInUnsafe = await AppState().isLoggedIn
}
isLoggedIn = isLoggedInUnsafe
}
}


@main
struct SwiftUIAsyncAppInitApp: App {
@StateObject var viewModel = SwiftUIAsyncAppInitAppViewModel()

var body: some Scene {
WindowGroup {
ZStack {
if viewModel.isLoggedIn {
MainView()
} else {
LoginView()
}
}
}
}
}

And yes, finally, the application will display the correct screen sequence: AppSplash -> LoginView or MainView, without any intermediate screens and flickering.

Conclusion

The approach described above enables us to initialize the app with an async function, but it should be used cautiously. The primary issue is that switching to the main thread during initialization can lead to a deadlock. Moreover, the initialization time should be minimized as much as possible because the application will be frozen until the initialization completes.

Do you have your own opinion or an alternative solution? Feel free to share it in the comments.

Thanks for reading! The full example of all three solutions is available here: SwiftUI-Async-App-Init

--

--

Viacheslav Tkachenko
0 Followers

Wanderer at the crossroads of technology and creativity.