0

I'm developing a Swift class as a Manager that needs to fetch initial data from an API during initialization. I want to ensure that the class is fully initialized only after the data has been fetched and is available for use. Here's a simplified version of what I'm trying to achieve:

import Foundation
class DataManager {
static let shared = DataManager()
private let apiService: APIService
private var data: [String] = []

private init(apiService: APIService = APIService()) {
    self.apiService = apiService

    // How can I ensure that the class waits for data fetching to complete before continuing initialization?
    Task {
        do {
            self.data = try await self.fetchInitialData()
            print("Initial data fetched: \(self.data)")
        } catch {
            print("Error fetching initial data: \(error)")
        }
    }
}

func fetchInitialData() async throws -> [String] {
    // Example of fetching data asynchronously from API
    let fetchedData = try await apiService.fetchData()
    return fetchedData
}

var fetchedData: [String] {
    return data
}

}

The reason I'm trying to do this is so that multiple ViewModels can rely on a single API call to fetch the data. However, I'm encountering a problem where sometimes I get an empty data list because the initialization hasn't completed fetching the data before the data is accessed.

3
  • You would have to make the init itself be async. You cannot do this with a Task inside the init. Commented Jul 5, 2024 at 15:47
  • Or you could compel the init to require a completion handler. Actually, looking at this object, I wonder whether you wouldn't be happier making it an actor. Commented Jul 5, 2024 at 16:48
  • seems like actor my fit to my idea, I need to try it. If it waits for that api call result to store all data at once and then can use that data through other view model that will suite nice. I will let you know if it works. thanks for comment. if you have some other ideas for implementation I'm lookin forward to see other ideas Commented Jul 6, 2024 at 10:12

1 Answer 1

0

A few observations:

  1. Thread-safety

    This is not directly related to your question, but this code is not thread-safe because the DataManager has an unsynchronized mutable state. I would suggest changing the “Strict Concurrency Setting” to “Complete” and it will warn you of these issues.

    Making this an actor is the easiest way to eliminate the data race. For more information, see WWDC 2022’s Eliminate data races using Swift Concurrency for a primer on Sendable types.

  2. Allow multiple callers await the same result

    The title of this question uses the phrase “Ensuring Synchronous Initialization”. I would avoid the use of the term “synchronous” because that has a very specific meaning (blocking the current thread), which is antithetical to how Swift concurrency operates and is unnecessary. Whenever possible, we avoid doing anything synchronously.

    The question is “how to not return until the asynchronous work launched by init is complete.” An async initializer is the “go to” solution. Unfortunately, you cannot use an async initializer in conjunction with this shared global/static pattern. You will receive a warning:

    'async' call cannot occur in a global variable initializer

    Given that we cannot use an async initializer, an alternative way to allow multiple callers await the same network request is to save the Task associated with the request. (Personally, I would pull the asynchronous request out of the initializer, too.) Perhaps:

    actor DataManager {
        static let shared = DataManager()
    
        private let apiService: APIService
        private var savedTask: Task<[String], Error>?
    
        private init(apiService: APIService = APIService()) {
            self.apiService = apiService
        }
    
        func values() async throws -> [String] {
            if let savedTask {
                return try await savedTask.value
            }
    
            let task = Task {
                try await apiService.fetchData()
            }
            savedTask = task
    
            return try await task.value
        }
    }
    

    Note, rather than the computed property, fetchedData, I have an async throws method, values that will return the results.

    let result = try await DataManager.shared.values()
    

    This way, multiple calls to values will await a single Task. And because this is an actor, there is no race on savedTask.

    Please note that with unstructured concurrency, we bear responsibility for handling cancelation. Thus, we would generally wrap that try await task.value in a withTaskCancellationHandler. But I have not done so in this case because if you have multiple callers all awaiting the same task, you likely do not want the dismissal of a view to cancel the other requests.

There are tons of variations on the theme, but hopefully this illustrates the basic idea of allowing multiple callers await the same asynchronously fetched result.

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.