Swift 6.0并发Sendable错误排查及解决方案咨询
问题背景
我开发的应用启动时会读取currency exchange rates,数据存入moneyRate数组,仅部分汇率会被复制到全应用使用的rateArray中。升级到Swift 6.0后出现以下问题:
GetRatesModel中的错误
在GetRatesModel中写入日期、基准货币及汇率到moneyRates时,触发两个错误:
Capture of 'self' with non-sendable type 'GetRatesModel' in a 'Sendable Closure'Implicit capture of 'self' requires that GetRatesModel conforms to 'Sendable'
@main文件中的警告
给ViewModel的GetRatesModel添加@preconcurrency宏后,在@main启动文件中出现警告:Sending 'self.vm' risks causing data races(将主线程隔离的'self.vm'发送至非隔离调用方可能引发数据竞争)。
尝试过的方案
- 将ViewModel放入
actor中:除发布日期显示问题外,警告与错误均消失,但根据Hacking with Swift的内容,这并非推荐方案。 - 尝试用actor作为调用方与被调用方的中介传递本国货币至
checkForUpdates,未成功。
疑问
编译器似乎在抱怨checkForUpdates为非隔离方法,若在ContentView的onAppear(置于task内)中调用汇率ViewModel是否有帮助?恳请提供解决这些Sendable错误的思路。
相关代码
@main文件代码
@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() } } }
解决思路
1. 让相关类型符合Sendable协议
- 自定义
GetRates结构体需要显式声明符合Sendable(其内部属性String、[String:Double]本身都是Sendable类型):struct GetRates: Sendable { var date: String var base: String var rates: [String: Double] } - 让
GetRatesModel显式符合Sendable,@Observable类在Swift 6中默认支持Sendable,但显式声明更清晰:@Observable final class GetRatesModel: Sendable { // 现有代码保持不变 }
2. 替换回调式URLSession为异步API
当前使用的dataTask(with:completionHandler:)是回调式API,容易引发Sendable问题。改用Swift原生异步data(from:)方法,避免闭包捕获self,同时更贴合Swift 6并发模型:
func checkForUpdates(baseCur: String) async -> Bool { guard await monitor.isConnected() else { return false } let date = Date.now let todayDate = date.formatted(.iso8601.year().month().day().dateSeparator(.dash)) let storedDate = UserDefaults.standard.string(forKey: StorageKeys.upd.rawValue) guard storedDate == nil || todayDate != storedDate else { return false } 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 gotNewRates } do { let (data, _) = try await URLSession.shared.data(from: url) let result = try JSONSerialization.jsonObject(with: data) as! [String:Any] var keys = Array(result.keys) guard let dateIndex = keys.firstIndex(of: "date"), let sDate = result[keys[dateIndex]] as? String, keys.count == 2, storedDate != sDate else { print("Data not stored: \(sDate ?? "unknown")") return false } keys.remove(at: dateIndex) guard let base = keys.first, let rates = result[base] as? [String: Double] else { return false } guard rates["eur"] != 0, rates["chf"] != 0, rates["gbp"] != 0 else { return false } moneyRates.date = sDate moneyRates.base = base moneyRates.rates = rates UserDefaults.standard.setValue(sDate, forKey: StorageKeys.upd.rawValue) gotNewRates = true getRates(baseCur: baseCur) return true } catch { print(error.localizedDescription) return false } }
3. 解决@main中的数据竞争警告
在MyApp的task中,通过MainActor.run确保ViewModel的调用在主线程执行,避免跨线程访问风险:
@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: await MainActor.run { await vm.checkForUpdates(baseCur: base.baseCur.baseS) } case .background, .inactive: break @unknown default: break } } } .environment(vm) } }
或者直接给checkForUpdates方法标记@MainActor,确保方法入口在主线程:
@MainActor func checkForUpdates(baseCur: String) async -> Bool { // 异步代码可保留,async方法会自动切换线程,@MainActor确保入口在主线程 }
4. 关于ContentView中调用的疑问
将调用移到ContentView的onAppear的task中是可行的,能让调用时机与视图生命周期绑定,但这只是调整调用时机,无法从根本上解决Sendable错误,需要配合上述方案一起使用。
内容的提问来源于stack exchange,提问作者Galen Smith

