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:
- Capture of 'self' with non-sendable type 'GetRatesModel' in a 'Sendable Closure'.
- Implicit capture of 'self' requires that GetRatesModel conforms to 'Sendable'.
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 = "/@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()
}
}
}
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:
- Capture of 'self' with non-sendable type 'GetRatesModel' in a 'Sendable Closure'.
- Implicit capture of 'self' requires that GetRatesModel conforms to 'Sendable'.
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/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()
}
}
}
Share
Improve this question
asked Nov 19, 2024 at 23:51
Galen SmithGalen Smith
3876 silver badges18 bronze badges
2
|
2 Answers
Reset to default 2These 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:
- Make the model fully conform to Sendable. However, this requires value types, an actor, or immutate classes. So, in this case it should be:
@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
}
}
- Make the mode isolate with global actors. I would use @MainActor, or you can create a new @globalActor one.
@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
}
}
- Mark the model with @unchecked and provide an internal locking mechanism, maybe with
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)
Swift 6 enforces correct structure you will not be able to use custom view model objects anymore and especially not ones with async funcs that corrupt shared mutable state. The async func should be in a struct not a class and it should return a result you can set on a State. In SwiftUI the view structs are the view models so are the place to hold the view data not objects.
@State
is not for objects, it should hold the data that is the result of calling the async function from.task
. It is like a memory leak to init an object in@State
because a new one is init on the heap and lost every time this View is init. Unnecessary heap allocations should be avoided because they slow down SwiftUI.@StateObject
prevents this problem but.task
replaces the need for it since it gives you the same reference semantics of an object. – malhal Commented Nov 20, 2024 at 12:26