In mobile native apps (iOS/Android), it is quite common to execute a series of tasks before the app is ready for the user. These tasks might include checking if the app requires an update, fetching remote app configurations, presenting the "What's New" information for the latest release, and requesting user login if the user is not already logged in. All of this needs to be done as quickly as possible, often with animations playing to keep the user engaged during the wait.

This post introduces what I call the sequencer pattern. By leveraging NSOperation, we can encapsulate each task into a self-contained unit and define dependencies among them. This approach establishes the initial execution order of the tasks. An added advantage is that when two or more tasks have no dependencies, iOS can execute them in parallel, further reducing startup times.

Adding splash screen

The first task we will add is responsible for presenting the splash screen. First, we will modify the ContentView.

struct ContentView: View {
    @StateObject var sequencer = appSingletons.sequencer
    var body: some View {
        if sequencer.isDone {
            HomeView()
        } else {
            sequencer.currentView
        }
    }    
}

Sequencer at the end is another singleton, but gathered in a global structure. I explain the benefits of this aproach in the post Safely gathering singletons while avoiding data races. And then basically while sqeuencer has not finished (!sequencer.isDone) is the responsible for providing view depending on task executed. When is done then is delegated whole view hierarchy to HomeView.

Let’s see what is on Sequencer:

final class Sequencer: ObservableObject {
    @MainActor
    @Published var isDone: Bool = false

    @MainActor
    @Published var currentView: AnyView = AnyView(Text("Initial View"))

    @MainActor
    func updateView(to newView: AnyView) {
        currentView = newView
    }

    @MainActor
    static let shared = Sequencer()

    fileprivate let operationQueue = OperationQueue()

    private init() { }

    @MainActor
    func start() {
        Task {
            await self.regularInitialSequence()
        }
    }

    @GlobalManager
    func regularInitialSequence() {
        let presentSplashOperation = PresentSplashOperation()
        let operations = [presentSplashOperation]
        
        // Add operation dependencies

        operationQueue.addOperations(operations, waitUntilFinished: false)
    }

    func cancel() {
        operationQueue.cancelAllOperations()
    }
}

The Sequencer is an ObservableObject that publishes the current view associated with any task, as well as its readiness status. The start method creates tasks and initiates their execution. Currently, only the splash view task is being executed.

The PresentSplashOperation performs the following functions:

    override func main() {
        os_log("Start: PresentSplashOperation", log: log, type: .debug)
        Task { @MainActor in
            Sequencer.shared.updateView(to: AnyView(SequencerView()))
        }
        sleep(5)
        os_log("End: PresentSplashOperation", log: log, type: .debug)
        self.state = .Finished
        Task { @MainActor in
            Sequencer.shared.isDone = true
        }
    }

Provides the view to be displayed while the PresentingSplashOperation is being executed. Afterward, there is a delay of 5 seconds before marking the task as finished. Once completed:

  1. isDone is set to true, allowing view control to transition to ContentView and present HomeView.
  2. self.state is set to .Finish, enabling the NSOperations engine to execute the next task, if another operation depends on this one to start.

To initiate the process, simply call the start method from the sequencer to begin the sequence.

@main
struct SequencerPatternApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                appSingletons.sequencer.start()
            }
        }
    }
}

Build and run on a simulator or real device, and the result should be:

What's new screen

It is quite common that whenever there is an app software update introducing new user features, a page viewer is displayed once to showcase what’s new in the app. First of all, let’s set the app version in a centralized location, as explained in Force update iOS Apps when… post:

Then we are going to implement a task that carries on this task:

@GlobalManager
final class WhatsNewOperation: ConcurrentOperation, @unchecked Sendable {
    
    
    override init() {
        super.init()
    }

    @MainActor
    func WhatsNewView() -> some View {
        VStack {
            HStack {
                Spacer()
                Button {
                    Sequencer.shared.isDone = true
                    self.state = .Finished
                } label: {
                    Image(systemName: "xmark")
                        .font(.system(size: 20, weight: .bold))
                        .foregroundColor(.white)
                        .frame(width: 40, height: 40)
                        .background(Color.red)
                        .clipShape(Circle())
                        .shadow(radius: 5)
                }
            }
           // Spacer()
            TabView{
                VStack {
                    Text("What's new feature A")
                }
                VStack {
                    Text("What's new feature B")
                }
                VStack {
                    Text("What's new feature C")
                }
            }
            .font(.system(size: 20, weight: .bold))
            .tabViewStyle(.page)
            .indexViewStyle(.page(backgroundDisplayMode: .always))
        }
        .padding()
    }
    
    override func main() {
        @AppStorage("appVersion") var appVersion = "0.0.0"
        
        os_log("Start: WhatsNewOperation", log: log, type: .debug)
        let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
        let isLatest = appVersion == marketingVersion
        if !isLatest {
            appVersion = marketingVersion
            Task { @MainActor in
                Sequencer.shared.updateView(to: AnyView(WhatsNewView()))
            }
        } else {
            self.state = .Finished
            Task { @MainActor in
                Sequencer.shared.isDone = true
            }
        }
        os_log("End: WhatsNewOperation", log: log, type: .debug)
    }
}
 

We fetch the appVersion from the App Store (via UserDefaults, as implemented previously). This is compared against the current app version stored in the project configuration (MARKETING_VERSION). If the versions differ, a “What’s New” view is presented. After this, the new MARKETING_VERSION value is stored in AppStorage.

An important note: the last task in the sequencer is now WhatsNewOperation. As a result, this operation is responsible for setting Sequencer.shared.isDone to true. PresentSplashOperation is no longer responsible for setting this flag. Be sure to remove any code in PresentSplashOperation that sets this flag; otherwise, HomeView will be presented as soon as PresentSplashOperation finishes.

    override func main() {
        os_log("Start: PresentSplashOperation", log: log, type: .debug)
        Task { @MainActor in
            Sequencer.shared.updateView(to: AnyView(SequencerView()))
        }
        sleep(5)
        os_log("End: PresentSplashOperation", log: log, type: .debug)
        self.state = .Finished
//        Task { @MainActor in
//            Sequencer.shared.isDone = true
//        }
    }

Look out! The self.state = .Finished remains untouched. Now, this will allow the NSOperation engine to process the next operation (PresentSplashOperation). It is now time to create a new operation, set its dependencies, and update the regularInitialSequence() method.

    @GlobalManager
    func regularInitialSequence() {
        let presentSplashOperation = PresentSplashOperation()
        let whatsNewOperation = WhatsNewOperation()
        
        // DO NOT FORGET ADD OPERATION IN operations array. XDDDDD
        let operations = [presentSplashOperation,
                          whatsNewOperation]
        
        // Add operation dependencies
        whatsNewOperation.addDependency(presentSplashOperation)
        
        operationQueue.addOperations(operations, waitUntilFinished: false)
    }

Add a new WhatsNewOperation() to the operations array. It’s important to set the whatsNewOperation to depend on the completion of the presentSplashOperation.

Build and run. The expected result should be:

The ‘What’s New’ section is displayed only once upon the app’s first startup and does not appear on subsequent launches.

Force update

We are now going to insert a force update operation between the previous steps. Specifically, the sequence will be: PresentSplash, followed by ForceUpdateOperation, and then What’s New.

The implementation of the force update operation is as follows:

@GlobalManager
final class ForceUpdateOperation: ConcurrentOperation, @unchecked Sendable {

    override init() {
        super.init()
    }

    @MainActor
    func ForceUpdateRequiredView() -> some View {
        VStack {
          //  ProgressView()
            Text("Software Update Required!!!")
                .font(.largeTitle)
                .fontWeight(.bold)
            Button("Download it from Apple Store...") {}
                .buttonStyle(.borderedProminent)
        }
            .padding()
    }

    override func main() {
        let required = true
        os_log("Start: ForceUpdateOperation", log: log, type: .debug)
        sleep(5)
        if required {
            Task { @MainActor in
                Sequencer.shared.updateView(to: AnyView(ForceUpdateRequiredView()))
            }
        } else {
            self.state = .Finished
        }
        os_log("End: ForceUpdateOperation", log: log, type: .debug)
    }
}

To emulate behavior, we initially included a 5-second sleep. Using a hardcoded flag, we determined whether a force update was required.

Now that we have a long-running operation, we can remove the sleep delay in the PresentSplash operation.

    override func main() {
        os_log("Start: PresentSplashOperation", log: log, type: .debug)
        Task { @MainActor in
            Sequencer.shared.updateView(to: AnyView(SequencerView()))
        }
//        sleep(5)
        os_log("End: PresentSplashOperation", log: log, type: .debug)
        self.state = .Finished
//        Task { @MainActor in
//            Sequencer.shared.isDone = true
//        }
    }
}

It’s time to reorganize operations. Open the Sequencer and update the regularInitialSequence method.

    @GlobalManager
    func regularInitialSequence() {
        let presentSplashOperation = PresentSplashOperation()
        let forceUpdateOperation = ForceUpdateOperation()
        let whatsNewOperation = WhatsNewOperation()
        
        // DO NOT FORGET ADD OPERATION IN operations array. XDDDDD
        let operations = [presentSplashOperation,
                          forceUpdateOperation,
                          whatsNewOperation]
        
        // Add operation dependencies
        forceUpdateOperation.addDependency(presentSplashOperation)
        whatsNewOperation.addDependency(forceUpdateOperation)
        
        operationQueue.addOperations(operations, waitUntilFinished: false)
    }

Simply add a new ForceUpdateOperation to the operations array and reorganize the operation dependencies. The WhatsNewOperation should depend on the ForceUpdateOperation, and the ForceUpdateOperation should depend on the PresentSplashOperation.

After making these changes, build and run the application.

I will now set the required flag in the ForceUpdateOperation to false, so it doesn’t block the app startup sequence. We will then review the logs to assess the execution sequence of the operations.

Fetch configuration

Up until now, we have been sequencing operations, but sometimes it is possible to parallelize operations to reduce startup sequence time. In this case, we have created a simulated FetchConfiguration operation:

@GlobalManager
final class FetchConfigurationOperation: ConcurrentOperation, @unchecked Sendable {

    override init() {
        super.init()
    }

    override func main() {
        let required = true
        os_log("Start: FetchConfigurationOperation", log: log, type: .debug)
        sleep(8)
        self.state = .Finished
        os_log("End: FetchConfigurationOperation", log: log, type: .debug)
    }
}

To emulate the behavior, we initially included an 8-second sleep. This operation will be executed in parallel with the Force Update operation. Let’s create the operation and add its dependencies.

    @GlobalManager
    func regularInitialSequence() {
        let presentSplashOperation = PresentSplashOperation()
        let forceUpdateOperation = ForceUpdateOperation()
        let fetchConfigurationOperation = FetchConfigurationOperation()
        let whatsNewOperation = WhatsNewOperation()
        let operations = [presentSplashOperation,
                          forceUpdateOperation,
                          fetchConfigurationOperation,
                          whatsNewOperation]
        
        // Add operation dependencies
        forceUpdateOperation.addDependency(presentSplashOperation)
        
        fetchConfigurationOperation.addDependency(presentSplashOperation)
        
        whatsNewOperation.addDependency(forceUpdateOperation)
        whatsNewOperation.addDependency(fetchConfigurationOperation)

        operationQueue.addOperations(operations, waitUntilFinished: false)
    }

Create a FetchConfigurationOperation and add it to the operations array. Ensure that it has the same dependencies as the ForceUpdateConfiguration operation. Additionally, the WhatsNewOperation must also depend on FetchConfiguration.

Build the project, run the operation, and review the logs.

ForceUpdateOperation and FetchConfiguration start simultaneously, and it is only when FetchConfiguration finishes that the WhatsNewOperation is executed.

Conclusions

I have used this pattern in two real production iOS projects with reasonable success, and I encourage you to try it out in your personal projects. You can find the codebase used to write this post in this repository.

Copyright © 2024-2025 JaviOS. All rights reserved