I have an app that reads in currency exchange rates
at startup. The data is read into the array moneyRate, and only select currency exchange rates are copied to rateArray, which is used throughout the app.
With the update to Swift 6.0, I am seeing some errors and warnings. I have two errors in GetRatesModel where I am writing the date, base currency, and exchange rates into moneyRates:
When the @preconcurrency macro is added to the top of the viewModel GetRatesModel I get a warning in the startup file @main Sending 'self.vm' risks causing data races
(Sending main actor-isolated 'self.vm' to non-isolated callee risks data races).
I have tried putting the viewModel into an actor
(the warnings and errors went away except for a problem displaying the release date), but I noticed on Hacking with Swift that this isn't a recommended solution. I also looked into using an actor as an intermediary between the caller and callee to pass the home currency into checkForUpdates without success.
In @main it looks like the compiler is complaining about checkForUpdates being non-isolated. Would it help if the exchange rate viewModel was called from onAppear (within a task) in ContentView()?
Any ideas for resolving these sendable errors are much appreciated.
Below is the code for MyApp and GetRatesModel
I'm working on an update to an asynchronous URLsession API.
@main
struct MyApp: App {
@State private var vm = GetRatesModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.task(id: scenePhase) {
switch scenePhase {
case .active:
// get today's exchange rates
await vm.checkForUpdates(baseCur: base.baseCur.baseS) <- error here
case .background:
case .inactive:
@unknown default: break
}
}
}
.environment(vm)
}
}
// ViewModel
@Observable final class GetRatesModel {
@ObservationIgnored var currencyCode: String = ""
@ObservationIgnored var rate: Double = 0.0
@ObservationIgnored var storedDate: String?
var lastRateUpdate: Date = Date()
private let monitor = NWPathMonitor()
var rateArray: [Double] = Array(repeating: 0.0, count: 220)
var moneyRates = GetRates(date: "2020-07-04", base: "usd", rates: [:])
init() {
// read in rateArray if available
}
/*-----------------------------------------------------
Get exchange rates from network
Exchange rates are read into moneyRates. Valid country
exchange rate are then copied to rateArray
-----------------------------------------------------*/
func checkForUpdates(baseCur: String) async -> (Bool) {
// don't keep trying to retrieve data when there is no wifi signal
if await monitor.isConnected() {
// format today's date
let date = Date.now
let todayDate = date.formatted(.iso8601.year().month().day().dateSeparator(.dash))
// read rate change date
let storedDate = UserDefaults.standard.string(forKey: StorageKeys.upd.rawValue)
// do currency rate update if storedDate is nil or today's date != storedDate
if storedDate?.count == nil || todayDate != storedDate {
let rand = Int.random(in: 1000..<10000)
let sRand = String(rand)
let requestType = ".json?rand=" + sRand
let baseUrl = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/"
guard let url = URL(string: baseUrl + baseCur + requestType) else {
print("Invalid URL")
return self.gotNewRates
}
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { [self] data, response, error in
if let data = data {
do {
// result contains date, base, and rates
let result = try JSONSerialization.jsonObject(with: data) as! [String:Any]
// store downloaded exchange rates: date, base currency, and rates in moneyRate
var keys = Array(result.keys)
//get exchange rate date
if let dateIndex = keys.firstIndex(of: "date"),
let sDate = result[keys[dateIndex]] as? String, keys.count == 2 {
// was there a change in date?
if storedDate != sDate {
// get exchange rate base
keys.remove(at: dateIndex)
let base = keys.first!
// is rateArray date different from the new data we just received?
if sDate != storedDate {
moneyRates.date = sDate <- error here
moneyRates.base = base
moneyRates.rates = result[base] as! [String : Double]
// don't update if new data stream is zero
if moneyRates.rates["eur"] != 0.0 && moneyRates.rates["chf"] != 0.0 && moneyRates.rates["gbp"] != 0.0 { <- error here
// set last update date to today
UserDefaults.standard.setValue(sDate, forKey: StorageKeys.upd.rawValue) // this is storedDate
self.gotNewRates = true
getRates(baseCur: baseCur)
}
} else {
print("Data not stored: \(sDate)")
}
}
}
} catch {
print(error.localizedDescription)
}
}
}.resume()
}
}
}
These errors are quite clear, if you look into dataTask
completion, you could see it's marked with @Sendable
:
open func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask
However, the GetRatesModel
is non-sendable. That's why it's throwing an error. You can make the model conform to Sendable
to resolve the error by:
@Observable final class GetRatesModel: Sendable {
...
}
Now the error within the completion has been addressed. But another error occurs for these stored properties, which looks like this:
Stored property 'currencyCode' of 'Sendable'-conforming class 'GetRatesModel' is mutable
Because Sendable
indicates the types that are safe to share concurrently, but these properties did not have any synchronization. Thus, you can try one of these approaches:
@Observable final class GetRatesModel: Sendable {
@ObservationIgnored private let currencyCode: String
@ObservationIgnored private let rate: Double
@ObservationIgnored private let storedDate: String?
nonisolated init(currencyCode: String?, rate: Double?, storedDate: String?) {
self.currencyCode = currencyCode
self.rate = rate
self.storedDate = storedDate
}
}
@MainActor @Observable final class GetRatesModel {
@ObservationIgnored var currencyCode: String?
...
}
And you can keep these stored properties as var
as it's. Because the entire GetRatesModel
is now isolated with MainActor. Whenver checkForUpdates
gets called, it will execute on the main thread.
func checkForUpdates(baseCur: String) async -> (Bool) {
//<- Main
URLSession.shared.dataTask(with: request) { //<- Background
//<- Main
}
}
DispatchQueue
@Observable final class GetRatesModel: @unchecked Sendable {
@ObservationIgnored var currencyCode: String?
...
private let serialQueue = DispatchQueue(label: "internalGetRatesModelQueue")
func updateCurrencyCode(_ code: String) {
serialQueue.sync {
self.currencyCode = code
}
}
}
Site note: there is a variant of URLSession.shared.dataTask
to support async await. I would refactor it to:
let (data, response) = try await URLSession.shared.data(for: request)