Etiqueta: Singleton

  • Agnostic Swift Data

    Agnostic Swift Data

    One of the most shocking experiences I encountered as an iOS developer was working with Core Data, now known as Swift Data. While there are many architectural patterns for software development, I have rarely seen an approach that combines view logic with database access code. Separating data access from the rest of the application has several advantages: it centralizes all data operations through a single access point, facilitates better testing, and ensures that changes to the data access API do not impact the rest of the codebase—only the data access layer needs adaptation.

    Additionally, most applications I’ve worked on require some level of data processing before presenting information to users. While Apple excels in many areas, their examples of how to use Core Data or Swift Data do not align well with my daily development needs. This is why I decided to write a post demonstrating how to reorganize some components to better suit these requirements.

    In this post, we will refactor a standard Swift Data implementation by decoupling Swift Data from the View components.

    Custom Swift Data

    The Starting Point sample app is an application that manages a persisted task list using Swift Data.
    import SwiftUI
    import SwiftData
    
    struct TaskListView: View {
        @Environment(\.modelContext) private var modelContext
        @Query var tasks: [TaskDB]
    
        @State private var showAddTaskView = false
    
        var body: some View {
            NavigationView {
                List {
                    ForEach(tasks) { task in
                        HStack {
                            Text(task.title)
                                .strikethrough(task.isCompleted, color: .gray)
                            Spacer()
                            Button(action: {
                                task.isCompleted.toggle()
                                try? modelContext.save()
                            }) {
                                Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                            }
                            .buttonStyle(BorderlessButtonStyle())
                        }
                    }
                    .onDelete(perform: deleteTasks)
                }
                .navigationTitle("Tasks")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button(action: { showAddTaskView = true }) {
                            Image(systemName: "plus")
                        }
                    }
                }
                .sheet(isPresented: $showAddTaskView) {
                    AddTaskView()
                }
            }
        }
    
        private func deleteTasks(at offsets: IndexSet) {
            for index in offsets {
                modelContext.delete(tasks[index])
            }
            try? modelContext.save()
        }
    }

    As observed in the code above, the view code is intertwined with data access logic (Swift Data). While the code is functioning correctly, there are several concerns:

    1. Framework Dependency: If the framework changes, will all the views using this framework need to be updated?
    2. Unit Testing: Are there existing unit tests to validate CRUD operations on the database?
    3. Debugging Complexity: If I need to debug when a record is added, do I need to set breakpoints across all views to identify which one is performing this task?
    4. Code Organization: Is database-related logic spread across multiple project views?

    Due to these reasons, I have decided to refactor the code.

    Refactoring the app

    This is a sample app, so I will not perform a strict refactor. Instead, I will duplicate the views to include both approaches within the same app, allowing cross-CRUD operations between the two views.

    @main
    struct AgnosticSwiftDataApp: App {
        var body: some Scene {
            WindowGroup {
                TabView {
                    TaskListView()
                        .tabItem {
                        Label("SwiftData", systemImage: "list.dash")
                    }
                        .modelContainer(for: [TaskDB.self])
                    AgnosticTaskListView()
                        .tabItem {
                        Label("Agnostic", systemImage: "list.dash")
                    }
                }
            }
        }
    }
    

    First of all we are going to create a component that handles all DB operations:

    import SwiftData
    import Foundation
    
    @MainActor
    protocol DBManagerProtocol {
        func addTask(_ task: Task)
        func updateTask(_ task: Task)
        func removeTask(_ task: Task)
        func fetchTasks() -> [Task]
    }
    
    @MainActor
    class DBManager: NSObject, ObservableObject {
    
        @Published var tasks: [Task] = []
    
    
        static let shared = DBManager()
    
        var modelContainer: ModelContainer? = nil
    
        var modelContext: ModelContext? {
            modelContainer?.mainContext
        }
    
        private init(isStoredInMemoryOnly: Bool = false) {
            let configurations = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly)
            do {
                modelContainer = try ModelContainer(for: TaskDB.self, configurations: configurations)
            } catch {
                fatalError("Failed to initialize ModelContainer: \(error)")
            }
        }
    }
    
    extension DBManager: DBManagerProtocol {
    
        func removeTask(_ task: Task) {
            guard let modelContext,
                let taskDB = fetchTask(by: task.id) else { return }
    
            modelContext.delete(taskDB)
    
            do {
                try modelContext.save()
            } catch {
                print("Error on deleting task: \(error)")
            }
        }
    
        func updateTask(_ task: Task) {
            guard let modelContext,
                let taskDB = fetchTask(by: task.id) else { return }
    
            taskDB.title = task.title
            taskDB.isCompleted = task.isCompleted
    
            do {
                try modelContext.save()
            } catch {
                print("Error on updating task: \(error)")
            }
            return
        }
    
        private func fetchTask(by id: UUID) -> TaskDB? {
            guard let modelContext else { return nil }
    
            let predicate = #Predicate<TaskDB> { task in
                task.id == id
            }
    
            let descriptor = FetchDescriptor<TaskDB>(predicate: predicate)
    
            do {
                let tasks = try modelContext.fetch(descriptor)
                return tasks.first
            } catch {
                print("Error fetching task: \(error)")
                return nil
            }
        }
    
        func addTask(_ task: Task) {
            guard let modelContext else { return }
            let taskDB = task.toTaskDB()
            modelContext.insert(taskDB)
            do {
                try modelContext.save()
                tasks = fetchTasks()
            } catch {
                print("Error addig tasks: \(error.localizedDescription)")
            }
        }
    
        func fetchTasks() -> [Task] {
            guard let modelContext else { return [] }
    
            let fetchRequest = FetchDescriptor<TaskDB>()
    
            do {
                let tasksDB = try modelContext.fetch(fetchRequest)
                tasks = tasksDB.map { .init(taskDB: $0) }
                return tasks 
            } catch {
                print("Error fetching tasks: \(error.localizedDescription)")
                return []
            }
        }
    
        func deleteAllData() {
            guard let modelContext else { return }
            do {
                try modelContext.delete(model: TaskDB.self)
            } catch {
                print("Error on removing all data: \(error)")
            }
            tasks = fetchTasks()
        }
    }
    

    In a single, dedicated file, all database operations are centralized. This approach offers several benefits:

    • If the framework changes, only the function responsible for performing the database operations needs to be updated.
    • Debugging is simplified. To track when a database operation occurs, you only need to set a single breakpoint in the corresponding function.
    • Unit testing is more effective. Each database operation can now be tested in isolation.
    import Foundation
    import Testing
    import SwiftData
    @testable import AgnosticSwiftData
    
    extension DBManager {
        func setMemoryStorage(isStoredInMemoryOnly: Bool) {
            let configurations = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly)
            do {
                modelContainer = try ModelContainer(for: TaskDB.self, configurations: configurations)
            } catch {
                fatalError("Failed to initialize ModelContainer: \(error)")
            }
        }
    }
    
    @Suite("DBManagerTests", .serialized)
    struct DBManagerTests {
        
        func getSUT() async throws -> DBManager {
            let dbManager = await DBManager.shared
            await dbManager.setMemoryStorage(isStoredInMemoryOnly: true)
            await dbManager.deleteAllData()
            return dbManager
        }
        
        @Test("Add Task")
        func testAddTask() async throws {
            let dbManager = try await getSUT()
            let task = Task(id: UUID(), title: "Test Task", isCompleted: false)
            
            await dbManager.addTask(task)
            
            let fetchedTasks = await dbManager.fetchTasks()
            #expect(fetchedTasks.count == 1)
            #expect(fetchedTasks.first?.title == "Test Task")
            
            await #expect(dbManager.tasks.count == 1)
            await #expect(dbManager.tasks[0].title == "Test Task")
            await #expect(dbManager.tasks[0].isCompleted == false)
        }
        
        @Test("Update Task")
        func testUpateTask() async throws {
            let dbManager = try await getSUT()
            let task = Task(id: UUID(), title: "Test Task", isCompleted: false)
            await dbManager.addTask(task)
            
            let newTask = Task(id: task.id, title: "Updated Task", isCompleted: true)
            await dbManager.updateTask(newTask)
            
            let fetchedTasks = await dbManager.fetchTasks()
            #expect(fetchedTasks.count == 1)
            #expect(fetchedTasks.first?.title == "Updated Task")
            #expect(fetchedTasks.first?.isCompleted == true)
            
            await #expect(dbManager.tasks.count == 1)
            await #expect(dbManager.tasks[0].title == "Updated Task")
            await #expect(dbManager.tasks[0].isCompleted == true)
        }
        
        @Test("Delete Task")
        func testDeleteTask() async throws {
            let dbManager = try await getSUT()
            let task = Task(id: UUID(), title: "Test Task", isCompleted: false)
            await dbManager.addTask(task)
            
            await dbManager.removeTask(task)
            
            let fetchedTasks = await dbManager.fetchTasks()
            #expect(fetchedTasks.isEmpty)
            
            await #expect(dbManager.tasks.isEmpty)
        }
        
        
    
        @Test("Fetch Tasks")
        func testFetchTasks() async throws {
            let dbManager = try await getSUT()
            let task1 = Task(id: UUID(), title: "Task 1", isCompleted: false)
            let task2 = Task(id: UUID(), title: "Task 2", isCompleted: true)
            
            await dbManager.addTask(task1)
            await dbManager.addTask(task2)
            
            let fetchedTasks = await dbManager.fetchTasks()
            #expect(fetchedTasks.count == 2)
            #expect(fetchedTasks.contains { $0.title == "Task 1" })
            #expect(fetchedTasks.contains { $0.title == "Task 2" })
            
            await #expect(dbManager.tasks.count == 2)
            await #expect(dbManager.tasks[0].title == "Task 1")
            await #expect(dbManager.tasks[0].isCompleted == false)
            await #expect(dbManager.tasks[1].title == "Task 2")
            await #expect(dbManager.tasks[1].isCompleted == true)
        }
    
        @Test("Delete All Data")
        func testDeleteAllData() async throws {
            let dbManager = try await getSUT()
            let task = Task(id: UUID(), title: "Test Task", isCompleted: false)
            
            await dbManager.addTask(task)
            await dbManager.deleteAllData()
            
            let fetchedTasks = await dbManager.fetchTasks()
            #expect(fetchedTasks.isEmpty)
            
            await #expect(dbManager.tasks.isEmpty)
        }
        
        @Test("Model Context Nil")
        @MainActor
        func testModelContextNil() async throws {
            let dbManager = try await getSUT()
            dbManager.modelContainer = nil
            
            dbManager.addTask(Task(id: UUID(), title: "Test", isCompleted: false))
            #expect(try dbManager.fetchTasks().isEmpty)
            
            #expect(dbManager.tasks.count == 0)
        }
        
    }
    

    And this is the view:

    import SwiftUI
    
    struct AgnosticTaskListView: View {
        @StateObject private var viewModel: AgnosticTaskLlistViewModel = .init()
        
        @State private var showAddTaskView = false
        
        var body: some View {
            NavigationView {
                List {
                    ForEach(viewModel.tasks) { task in
                        HStack {
                            Text(task.title)
                                .strikethrough(task.isCompleted, color: .gray)
                            Spacer()
                            Button(action: {
                                viewModel.toogleTask(task: task)
                            }) {
                                Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                            }
                            .buttonStyle(BorderlessButtonStyle())
                        }
                    }
                    .onDelete(perform: deleteTasks)
                }
                .navigationTitle("Tasks")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button(action: { showAddTaskView = true }) {
                            Image(systemName: "plus")
                        }
                    }
                }
                .sheet(isPresented: $showAddTaskView) {
                    AddTaskViewA()
                        .environmentObject(viewModel)
                }
            }.onAppear {
                viewModel.fetchTasks()
            }
        }
        
        private func deleteTasks(at offsets: IndexSet) {
            viewModel.removeTask(at: offsets)
        }
    }

    SwiftData is not imported, nor is the SwiftData API used in the view. To refactor the view, I adopted the MVVM approach. Here is the ViewModel:

    import Foundation
    
    @MainActor
    protocol AgnosticTaskLlistViewModelProtocol {
        func addTask(title: String)
        func removeTask(at offsets: IndexSet)
        func toogleTask(task: Task)
    }
    
    @MainActor
    final class AgnosticTaskLlistViewModel: ObservableObject {
        @Published var tasks: [Task] = []
        
        let dbManager = appSingletons.dbManager
        
        init() {
            dbManager.$tasks.assign(to: &$tasks)
        }
    }
    
    extension AgnosticTaskLlistViewModel: AgnosticTaskLlistViewModelProtocol {
        func addTask(title: String) {
            let task = Task(title: title)
            dbManager.addTask(task)
        }
        
        func removeTask(at offsets: IndexSet) {
            for index in offsets {
                dbManager.removeTask(tasks[index])
            }
        }
        
        func toogleTask(task: Task) {
            let task = Task(id: task.id, title: task.title, isCompleted: !task.isCompleted)
            dbManager.updateTask(task)
        }
        
        func fetchTasks() {
            _ = dbManager.fetchTasks()
        }
    }

    The ViewModel facilitates database operations to be consumed by the view. It includes a tasks list, a published attribute that is directly linked to the @Published DBManager.tasks attribute.

    Finally the resulting sample project looks like:

    Conclusions

    In this post, I present an alternative approach to handling databases with Swift Data, different from what Apple offers. Let me clarify: Apple creates excellent tools, but this framework does not fully meet my day-to-day requirements for local data persistence in a database.

    You can find source code used for writing this post in following repository.

    References

  • iOS NFC Development: From URLs to Deeplinks

    iOS NFC Development: From URLs to Deeplinks

    Writing a URL or deep link into an NFC tag enables seamless integration between the physical and digital worlds. It offers instant access to online content and enhanced user experiences. Additionally, it creates automation opportunities, simplifying interactions such as opening web pages, accessing app-specific features, or triggering IoT actions. These capabilities make NFC tags valuable for marketing, smart environments, and personalization. This technology finds applications in retail, events, tourism, and healthcare, bringing convenience, innovation, and a modern touch.

    In this post, we will continue evolving the app created in the “Harnessing NFC Technology in Your iOS App” post by adding two more functionalities: one for storing a regular web URL and another for adding a deep link to open the same app. By the end of this guide, you’ll be equipped to expand your app’s NFC capabilities and create an even more seamless user experience.

    Storing web url into NFC tag

    Add a new function to handle the write URL operation:
        func startWritingURL() async {
            nfcOperation = .writeURL
            startSesstion()
        }
        
        private func startSesstion() {
            nfcSession = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false)
            nfcSession?.begin()
        }
    We ran out of Boolean operations, so I created an enum to implement the three current NFC operations: read, write, and write URL. For this process, we set the operation to perform and initiate an NFC session.
    The readerSession delegate function handles connecting to the NFC tag and querying its status.
    func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
            guard let tag = tags.first else { return }
            
            session.connect(to: tag) { error in
                if let error = error {
                    session.invalidate(errorMessage: "Connection error: \(error.localizedDescription)")
                    return
                }
                
                tag.queryNDEFStatus { status, capacity, error in
                    guard error == nil else {
                        session.invalidate(errorMessage: "Error checking NDEF status")
                        return
                    }
                    
                    switch status {
                    case .notSupported:
                        session.invalidate(errorMessage: "Not compatible tat")
                    case  .readOnly:
                        session.invalidate(errorMessage: "Tag is read-only")
                    case .readWrite:
                        switch self.nfcOperation {
                        case .read:
                            self.read(session: session, tag: tag)
                        case .write:
                            self.write(session: session, tag: tag)
                        case .writeURL:
                            self.writeUrl(session: session, tag: tag)
                        }
                        
                    @unknown default:
                        session.invalidate(errorMessage: "Unknown NDEF status")
                    }
                }
            }
        }
    When a writable NFC tag is detected and the operation is set to .writeURL, the method responsible for writing the URL to the tag will be called.
        private func writeUrl(session: NFCNDEFReaderSession, tag: NFCNDEFTag) {
            guard let url = URL(string: "https://javios.eu/portfolio/"),
                let payload = NFCNDEFPayload.wellKnownTypeURIPayload(string: url.absoluteString) else {
                session.invalidate(errorMessage: "No se pudo crear el payload NDEF.")
                return
            }
    
            write(session, tag, payload) { error in
                guard  error == nil else { return }
                print(">>> Write: \(url.absoluteString)")
            }
        }
        
        private func write(_ session: NFCNDEFReaderSession,
                           _ tag: NFCNDEFTag,
                           _ nfcNdefPayload: NFCNDEFPayload, completion: @escaping ((Error?) -> Void)) {
            
            let NDEFMessage = NFCNDEFMessage(records: [nfcNdefPayload])
            tag.writeNDEF(NDEFMessage) { error in
                if let error = error {
                    session.invalidate(errorMessage: "Writing error: \(error.localizedDescription)")
                    completion(error)
                } else {
                    session.alertMessage = "Writing succeeded"
                    session.invalidate()
                    completion(nil)
                }
            }
        }
    
    This Swift code facilitates writing a URL as an NFC NDEF payload onto an NFC tag. The writeUrl function generates an NDEF payload containing a well-known type URI record that points to the URL «https://javios.eu/portfolio/«. If the payload is valid, the function invokes the write method, passing the NFC session, tag, and payload as parameters. The write function then creates an NFC NDEF message containing the payload and writes it to the NFC tag.
    Once the URL is placed within the tag, you can use the tag to open the web link, functioning similarly to scanning a QR code that redirects you to a website.

    Deeplinks

    A deeplink in iOS is a type of link that directs users to a specific location within an app, rather than just opening the app’s home screen. This helps enhance the user experience by providing a direct path to particular content or features within the app.

    In this example, we will create a deeplink that will open our current NFCApp directly:

    When iOS detects the ‘nfcreader://jca.nfcreader.open’ deep link, it will open the currently post development app on iOS.

    @main
    struct NFCAppApp: App {
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .onOpenURL { url in
                    handleDeeplink(url: url)
                }
            }
        }
    
        func handleDeeplink(url: URL) {
            // Maneja el deeplink aquí
            print("Se abrió la app con el URL: \(url)")
        }
    }

    By adding the .onOpenURL modifier, the app will be able to detect when it is launched (or awakened) via a deep link.

    Finally, implement the deep link writing functionality by adapting the previously created writeUrl method:

        private func writeUrl(session: NFCNDEFReaderSession, tag: NFCNDEFTag, urlString: String) {
            guard let url = URL(string: urlString),
                let payload = NFCNDEFPayload.wellKnownTypeURIPayload(string: url.absoluteString) else {
                session.invalidate(errorMessage: "No se pudo crear el payload NDEF.")
                return
            }
    
            write(session, tag, payload) { error in
                guard  error == nil else { return }
                print(">>> Write: \(url.absoluteString)")
            }
        }

    It would be called in the following way for creating deeplink

                    case .readWrite:
                        switch self.nfcOperation {
                        case .read:
                            self.read(session: session, tag: tag)
                        case .write:
                            self.write(session: session, tag: tag)
                        case .writeURL:
                            self.writeUrl(session: session, tag: tag, urlString: "https://javios.eu/portfolio/")
                        case .writeDeeplink:
                            self.writeUrl(session: session, tag: tag, urlString: "nfcreader://jca.nfcreader.open")
                        }
                        

    Deploy project on a real device for validating behaviour

    Once the tag is written, when the deep link is triggered, the app will be closed and then reopened. You will be prompted to open the app again.

    Conclusions

    In this post, I have extended the functionalities we can implement with NFC tags by using URLs. You can find source code used for writing this post in following repository.

    References

  • Safely Gathering Singletons While Avoiding Data Races

    Safely Gathering Singletons While Avoiding Data Races

    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