iosswiftasync-awaitgrand-central-dispatchdispatch-queue

Proper iOS DispatchQueue usage?


I'm trying to extend the Landmark SwiftUI Tutorials to persist data. The code I added getDataFromFileURL draw inspiration from Persisting data tutorial.

I expect for the first time, the json files won't exist in the user document directory, and the program will get data from bundle via DispatchQueue.main.async print("1. ...")

However, what actually appears to happen is that getDataFromFileURL seems to return immediately and data is nil and the program fill data in print("5. ...") first and print("4.a ...") came later.

As I got this in the log output:

5. after getDataFromFileURL is still nil, loading landmarkData.json from bundle
5. after getDataFromFileURL is still nil, loading hikeData.json from bundle
1. loaded landmarkData.json from bundle
4.a success loading data from getDataFromFileURL
1. loaded hikeData.json from bundle
4.a success loading data from getDataFromFileURL

I suspect this behavior is due to DispatchQueue.global().async is an async call and will return immediately. Since my load function is depending on the results from getDataFromFileURL, what will be the best way to handle it?

Code:

import Foundation
import Combine

final class ModelData: ObservableObject {
    @Published var landmarks: [Landmark] = load("landmarkData.json")
    var hikes: [Hike] = load("hikeData.json")
    
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarks, by: {$0.category.rawValue})
    }
    
    var features: [Landmark] {
        landmarks.filter{ $0.isFeatured }
    }
    @Published var profile = Profile.default
}

private func fileURL(filename: String) throws -> URL {
    try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
        .appending(path: filename)
}

private func getDataFromFileURL(filename: String, completion: @escaping (Result<Data?, Error>)->Void) {
    DispatchQueue.global(qos: .background).async {
        do {
            let fileURL = try fileURL(filename: filename)
            guard let file = try? FileHandle(forReadingFrom: fileURL) else {
                DispatchQueue.main.async {
                    print("1. loaded \(filename) from bundle")
                    completion(.success(getDataFromBundle(filename)))
                }
                return
            }
            DispatchQueue.main.async {
                print("2. loaded \(filename) from fileURL")
                completion(.success(file.availableData))
            }
        } catch {
            DispatchQueue.main.async {
                print("3. failed to load \(filename)")
                completion(.failure(error))
            }
        }
    }
}

private func getDataFromBundle(_ filename: String) -> Data? {
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }
    do {
        return try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }
}

func load<T: Decodable>(_ filename: String) -> T {
    var data: Data?
    
    getDataFromFileURL(filename: filename) { result in
        switch result {
        case .success(let _data):
            print("4.a success loading data from getDataFromFileURL")
            data = _data
        case .failure(_):
            print("4.b failed to load data from getDataFromFileURL")
            data = getDataFromBundle(filename)
        }
    }
    
    if data == nil {
        print("5. after getDataFromFileURL is still nil, loading \(filename) from bundle")
        data = getDataFromBundle(filename)
    }
    
    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data!)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

Solution

  • The problem is that getDataFromFileURL is using completion handler pattern, whereas load is not. Also, as you noted, the getDataFromFileURL runs asynchronously (i.e., returns immediately), so code after it will run before the code in its completion handler does (i.e., before you set data, which was defined outside of the completion handler).

    To fix this you will would want to:

    This all gets messy pretty quickly. As lorem ipsum implied with his link to WWDC 2021 video Meet async/await in Swift, you might consider using the Swift concurrency system, instead, which makes these sorts of dependencies between asynchronous tasks far more intuitive.

    In this case, it might just be:

    func load<T: Decodable>(filename: String) async throws -> T {
        do {
            let fileURL = try persistentStorageURL(filename: filename)
            return try await loadAndDecode(from: fileURL)
        } catch {
            let fileURL = try bundleURL(filename: filename)
            return try await loadAndDecode(from: fileURL)
        }
    }
    

    Here is a more complete implementation:

    @MainActor
    final class ViewModel: ObservableObject {
        @Published var landmarks: [Landmark] = []
        @Published var hikes: [Hike] = []
        @Published var error: Error?
        @Published var profile = Profile.default
    
        func fetch() async throws {
            async let landmarks: [Landmark] = StorageService.shared.load(filename: "landmarksData.json")
            async let hikes: [Hike] = StorageService.shared.load(filename: "hikeData.json")
                
            self.landmarks = try await landmarks
            self.hikes = try await hikes
        }
    
        func categories() -> [String: [Landmark]] {
            Dictionary(grouping: landmarks, by: \.category.rawValue)
        }
    
        func features() -> [Landmark] {
            landmarks.filter(\.isFeatured)
        }
    }
    
    // MARK: - StorageService
    
    class StorageService {
        static let shared = StorageService()
        
        private let encoder = JSONEncoder()
        private let decoder = JSONDecoder()
    
        /// Load and decode data
        ///
        /// Try loading from persistent storage, and if that fails, from the bundle.
        /// This will load the resource and decode the JSON therein.
        ///
        /// - Parameters:
        ///    - filename: The filename to be used when retrieving the asset.
        
        func load<T: Decodable>(filename: String) async throws -> T {
            do {
                let fileURL = try persistentStorageURL(filename: filename)
                return try await loadAndDecode(from: fileURL)
            } catch {
                let fileURL = try bundleURL(filename: filename)
                return try await loadAndDecode(from: fileURL)
            }
        }
        
        /// Encode and save data
        ///
        /// - Parameters:
        ///    - value: The object to be saved.
        ///    - filename: The filename to be used when retrieving the asset.
            
        func save<T: Encodable>(_ value: T, filename: String) async throws {
            let fileURL = try persistentStorageURL(filename: filename)
            try await encodeAndSave(value, to: fileURL)
        }
    }
    
    // MARK: - Private utility methods
    
    private extension StorageService {
        func loadAndDecode<T: Decodable>(from fileURL: URL) async throws -> T {
            try await Task.detached {
                let data = try Data(contentsOf: fileURL)
                return try self.decoder.decode(T.self, from: data)
            }.value
        }
        
        func encodeAndSave<T: Encodable>(_ value: T, to fileURL: URL) async throws {
            try await Task.detached {
                let data = try self.encoder.encode(value)
                try data.write(to: fileURL, options: .atomic)
            }.value
        }
        
        func persistentStorageURL(filename: String) throws -> URL {
            try FileManager.default
                .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) // I’d suggest application support directory rather than documents; remember to specific `create` of `true`, though
                .appending(path: filename)
        }
        
        func bundleURL(filename: String) throws -> URL {
            guard let fileURL = Bundle.main.url(forResource: filename, withExtension: nil) else {
                throw StorageServiceError.notFound
            }
    
            return fileURL
        }
    }
    
    // MARK: - Error
    
    extension StorageService {
        enum StorageServiceError: Error {
            case notFound
        }
    }
    

    A couple of minor observations:

    1. The computed properties, categories and features, are hiding some computational complexity. Depending upon how you use them, you could end up repeated calling filter and the Dictionary grouping initializer. I would suggest making them methods (in which an application developer might reasonably infer something complicated might be going on). Or make them stored properties that are updated when landmarks is. But I would be wary of hiding this complexity behind a computed property.

    2. I am not personally a fan of object initializers automatically starting some asynchronous processes. So I have moved this into a fetch method, which I would call when I want to retrieve data. (Perhaps when the view is loaded or the refresh button is tapped.) If you feel compelled to do so, you theoretically could start this from init, but I generally would not.

    3. Elsewhere, it was advised to use URLSession. This is, again, a matter of personal preference, but for me, given that the documentation advises that it “coordinates a group of related, network data transfer tasks” (emphasis added), I personally stick with Data(contentsOf:) for local resources (but definitely not network resources). And I made sure to use a detached task in case that file reading/decoding task took any substantial amount of time (which, admittedly, is unlikely to be a problem unless there are many, many landmarks).

    4. I have decorated this ObservableObject with the @MainActor qualifier on the assumption that its primary purpose was to publish values observed by the UI. Given that the UI always must happen on the main thread, often marking this ObservableObject with @MainActor is the logical approach. But if you are not using this for updating the UI, feel free to remove that or make this an actor of its own. It’s up to you.

    5. It was not entirely clear from your code snippet where these file reading/decoding routines were. So I put them in their own StorageService, as they seem to be best left loosely couple with the model-specific data.

    But, I would advise that you do not get too lost in the weeds of my implementation above. I would encourage you to delve into Swift concurrency, perhaps watch that video and others linked on that page. Swift concurrency might feel a bit foreign at first, but it is ideally suited for these sorts of scenarios with dependencies between asynchronous tasks.