The text is clear and conveys the intended message effectively. However, it can be slightly refined for improved readability and flow. Here's a polished version: In our previous post, we discussed migrating an app that uses a Singleton to Swift 6.0. In this post, we’ll focus on consolidating multiple multipurpose Singletons into a single access point. This approach simplifies unit testing by enabling the injection of mocked Singletons.

Base project

We begin where we left off in the iOS Location Manager: A Thread-Safe Approach post. In that post, we explained how to migrate a Location Manager. Now, we’ll create a new blank project, ensuring that the Swift testing target is included.

The base code is the source code provided in the commented section of the post. At the end of the post, you will find a link to the GitHub repository. By reviewing its history, you can trace back to this point.

At this stage, we will create a second singleton whose purpose is to manage a long-running background task.

@globalActor
actor GlobalManager {
    static var shared = GlobalManager()
}

protocol LongTaskManagerProtocol {
    @MainActor var isTaskDone: Bool { get }
    func doLongTask() async
}

@GlobalManager
class LongTaskManager: ObservableObject, LongTaskManagerProtocol {

    @MainActor
    static let shared = LongTaskManager()

    @MainActor
    @Published var isTaskDone: Bool = false
    
    private var isTaskDoneInternal: Bool = false {
        didSet {
            Task {
                await MainActor.run { [isTaskDoneInternal] in
                    isTaskDone = isTaskDoneInternal
                }
            }
        }
    }

    #if DEBUG
    @MainActor
    /*private*/ init() {
    }
    #else
    @MainActor
    private init() {
    }
    #endif
    
    // MARK :- LongTaskManagerProtocol
    func doLongTask() async {
        isTaskDoneInternal = false
        print("Function started...")
        // Task.sleep takes nanoseconds, so 10 seconds = 10_000_000_000 nanoseconds
        try? await Task.sleep(nanoseconds: 10_000_000_000)
        print("Function finished!")
        isTaskDoneInternal = true
    }
}

Key Concepts at Work

  1. Actor Isolation:

    • Ensures thread safety and serializes access to shared state (isTaskDoneInternal) through GlobalManager.
    • @MainActor guarantees main-thread access for UI-related properties and tasks.
  2. SwiftUI Integration:

    • @Published with ObservableObject enables reactive UI updates.
  3. Encapsulation:

    • Internal state (isTaskDoneInternal) is decoupled from the externally visible property (isTaskDone).
  4. Concurrency-Safe Singleton:

    • The combination of @MainActor, @GlobalManager, and private init creates a thread-safe singleton usable across the application.
 
We will now make minimal changes to ContentView to integrate and provide visibility for this new Singleton.
struct ContentView: View {
    @StateObject private var locationManager = LocationManager.shared
    @StateObject private var longTaskManager = LongTaskManager.shared
    
    var body: some View {
        VStack(spacing: 20) {
            Text("LongTask is \(longTaskManager.isTaskDone ? "done" : "running...")")
           ...
        .onAppear {
            locationManager.checkAuthorization()
            
            
            Task {
              await longTaskManager.doLongTask()
            }
        }
        .padding()
    }
}

Key Concepts at Work

  1. Singleton Reference:
    Use a singleton reference to the LongTaskManager.
    The @StateObject property wrapper ensures that any changes in LongTaskManager.isTaskDone automatically update the ContentView.

  2. LongTaskManager Execution Status:
    The longTaskManager.isTaskDone property determines the message displayed based on the execution status.

  3. Start Long Task:
    The .onAppear modifier is the appropriate place to invoke longTaskManager.doLongTask().

  4. Testing on a Real Device:
    Build and deploy the app on a real device (iPhone or iPad) to observe the long task execution. You’ll notice that it takes a while for the task to complete.

All the Singletons came together at one location

During app development, there may come a point where the number of Singletons in your project starts to grow uncontrollably, potentially leading to maintenance challenges and reduced code manageability. While Singletons offer advantages—such as providing centralized access to key functionality (e.g., Database, CoreLocation, AVFoundation)—they also have notable drawbacks:

  1. Global State Dependency: Code relying on a Singleton is often dependent on global state, which can lead to unexpected behaviors when the state is altered elsewhere in the application.
  2. Challenges in Unit Testing: Singletons retain their state across tests, making unit testing difficult and prone to side effects.
  3. Mocking Limitations: Replacing or resetting a Singleton for testing purposes can be cumbersome, requiring additional mechanisms to inject mock instances or reset state.

To address these challenges, the following Swift code defines a struct named AppSingletons. This struct serves as a container for managing singletons, simplifying dependency injection and promoting better application architecture.

import Foundation

struct AppSingletons {
    var locationManager: LocationManager
    var longTaskManager: LongTaskManager
    
    init(locationManager: LocationManager = LocationManager.shared,
         longTaskManager: LongTaskManager = LongTaskManager.shared) {
        self.locationManager = locationManager
        self.longTaskManager = longTaskManager
    }
}
 var appSingletons = AppSingletons()

Ensure that singleton references are obtained from appSinglegons.

struct ContentView: View {
    @StateObject private var locationManager = appSingletons.locationManager
    @StateObject private var longTaskManager = appSingletons.longTaskManager
    
    var body: some View {

After performing a sanity check to ensure everything is working, let’s move on to the test target and add the following unit test:

import Testing
@testable import GatherMultipleSingletons

struct GatherMultipleSingletonsTests {

    @Test @MainActor func example() async throws {
        let longTaskManagerMock = LongTaskManagerMock()
        appSingletons = AppSingletons(longTaskManager: longTaskManagerMock)
        #expect(appSingletons.longTaskManager.isTaskDone == false)
        await appSingletons.longTaskManager.doLongTask()
        #expect(appSingletons.longTaskManager.isTaskDone == true)
    }

}

final class LongTaskManagerMock: LongTaskManager {
    
    override func doLongTask() async {
        await MainActor.run {
            isTaskDone = true
        }
    }
}
The test verifies the behavior of a mock implementation of a singleton when performing a long task. It is likely part of verifying the integration between AppSingleton and LongTaskManager, ensuring that the singleton’s behavior matches expectations under controlled test conditions. By using the mock, the test becomes predictable and faster, avoiding the need for actual long-running logic.

...Thread safe touch

Now is time to turn  this code into a thread safe. Set Swift Concurrency Checking to Complete:

… and Swift language version to Swift 6.

The first issue we identified is that, from a non-isolated domain, the struct is attempting to access an isolated one (@MainActor). Additionally, appSingletons is not concurrency-safe because, as mentioned, it resides in a non-isolated domain.

ContentView (@MainActor) is currently accessing this structure directly. The best approach would be to move the structure to an @MainActor-isolated domain.

import Foundation

@MainActor
struct AppSingletons {
    var locationManager: LocationManager
    var longTaskManager: LongTaskManager
    
    init(locationManager: LocationManager = LocationManager.shared,
         longTaskManager: LongTaskManager = LongTaskManager.shared) {
        self.locationManager = locationManager
        self.longTaskManager = longTaskManager
    }
}

@MainActor var appSingletons = AppSingletons()

This means that the LongTaskManager is executed only within the @MainActor. However, this isn’t entirely true. The part responsible for accessing shared attributes and updating the @Published property is executed under the @MainActor, but the part performing the heavy lifting runs in a @globalActor isolated domain.

Conclusions

In this post I have showed a way avoid Singleton discontrol, by gathering them in a global structure. You can find the source code used in this post in the following repository.

References

Copyright © 2024-2025 JaviOS. All rights reserved