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)")
}
}
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:
get rid of the local variable, data
, defined outside of the getDataFromFileURL
completion handler … you only want to access the results of getDataFromFileURL
from within its completion handler;
move the logic, currently after the completion handler closure, that attempts to decodes data
, to inside the completion handler;
you will end up putting methods with completion handlers inside completion handlers of other methods, which gets messy quickly;
because load
is the result of an asynchronous, completion-handler-based process, you cannot just return
a result … you will want to give load
its own completion handler to you know when it is done … unless you use Swift concurrency, you cannot return
a value that was retrieved asynchronously from a completion handler … you have to repeat the completion handler pattern at every level.
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:
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.
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.
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).
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.
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.