You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift Watch健身App中@Published跨类使用遇后台线程发布报错

问题描述

我是Swift新手,正在开发一款Apple Watch健身App,包含两个页面:

  • 首页:展示最近一次活动数据,提供「开始训练」按钮
  • 训练页面:实时展示当前训练状态

我创建了WorkoutSession类,它遵循NSObject、HKLiveWorkoutBuilderDelegate、HKWorkoutSessionDelegate、ObservableObject协议,通过@Published属性管理训练数据。点击「开始训练」时会调用startWorkoutSession方法启动数据收集,但运行时出现以下错误:

2023-05-09 18:12:14.741560-0300 ApodWatch Watch App[67831:629899] [SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

我尝试过替换为@State、@StateObject但都无效,希望能通过正确使用@Published解决问题。

代码实现

ContentView(主视图)

import SwiftUI
import HealthKit

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(alignment: .leading) {
                VStack(alignment: .leading) {
                    Text("Última atividade")
                        .padding(.bottom, 12)
                    
                    HStack {
                        
                        CaloriesRingView(
                            calories: 168
                        )
                        .padding(.trailing, 24)
                        
                        DurationRingView(
                            duration: 5400
                        )
                    }
                    
                    
                }.padding(.top, 12)
                
                NavigationLink(destination: WorkoutView()) {
                    Text("Começar treino")
                }
                .background(.green)
                .cornerRadius(20)
                .padding(.top, 24)
            }
            .padding(.horizontal, 12)
        }
        .navigationBarBackButtonHidden(true)
    }
}

WorkoutView(训练视图)

import SwiftUI

struct WorkoutView: View {
    @ObservedObject var workoutSession = WorkoutSession()
    
    init() {
        workoutSession.setUpSession()
        workoutSession.startWorkoutSession()
    }
    
    var body: some View {
        NavigationView {
            if workoutSession.status == .inProgress {
                if workoutSession.bpm != nil {
                    Text(heartBeatFormatter())
                }
                
                if workoutSession.energyBurned != nil {
                    Text("\(workoutSession.energyBurned!) kcal")
                }
                
                if workoutSession.elapsedTime != nil {
                    Text("\(workoutSession.elapsedTime!)")
                }
            }
        }
        .navigationBarBackButtonHidden(true)

    }
    
    func heartBeatFormatter() -> String {
        let formatter = NumberFormatter()
        formatter.maximumSignificantDigits = 0
        return formatter.string(from: workoutSession.bpm! as NSNumber) ?? ""
    }
}

WorkoutSession类

import Foundation
import HealthKit
import SwiftUI

enum WorkoutSessionStatus {
    case inProgress, complete, cancelled, notStarted, paused
}

class WorkoutSession: NSObject, HKLiveWorkoutBuilderDelegate, HKWorkoutSessionDelegate, ObservableObject {
    @Published var energyBurnedStatistics: HKStatistics?;
    @Published var heartRateStatistics: HKStatistics?;
    @Published var elapsedTimeStatistics: HKStatistics?;
    
    @Published var energyBurned: Double?;
    @Published var bpm: Double?;
    @Published var elapsedTime: TimeInterval?;
    @Published var status = WorkoutSessionStatus.notStarted;
    @Published var workoutData: HKWorkout?
    
    @Published var session: HKWorkoutSession?;
    @Published var builder: HKLiveWorkoutBuilder?;
    
    var healthStore = HKHealthStore();
    
    func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
        self.heartRateStatistics = workoutBuilder.statistics(for: .init(.heartRate))
        
        self.energyBurned = workoutBuilder.statistics(for: .init(.activeEnergyBurned))?.sumQuantity()?.doubleValue(for: .kilocalorie())
        self.energyBurnedStatistics = workoutBuilder.statistics(for: .init(.activeEnergyBurned))
        
        self.elapsedTime = workoutBuilder.elapsedTime
        
        self.bpm = calculateBPM()
    }
    
    func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
        
    }
    
    func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
    
    }
    
    func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
        
    }
    
    func setUpSession() {
        let typesToShare: Set<HKWorkoutType> = [HKQuantityType.workoutType()]
        
        let typesToRead: Set<HKQuantityType> = [
            HKQuantityType.quantityType(forIdentifier: .heartRate)!,
            HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
        ]
        
        healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { (success, error) in
            if !success {
//                TODO: Levar para uma página dizendo que só pode usar o app após autorizar
            }
        }
    }
    
    func startWorkoutSession() {
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = .functionalStrengthTraining
        configuration.locationType = .indoor
        
        do {
            session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
            builder = session!.associatedWorkoutBuilder()
            
            builder!.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
            session!.delegate = self
            builder!.delegate = self
            session!.startActivity(with: Date())
            builder!.beginCollection(withStart: Date()) { success, error in
                if !success {
                    print("Unable to start collection, the error: \(String(describing: error))")
                    return
                }
                
                self.status = .inProgress
            }
        } catch {
            print("Unable to create workout session, the error: \(String(describing: error))")
        }
    }
    
    func endWorkoutSession() {
        guard let session = session else {
            print("Session is nil. Unable to end workout.")
            return
        }
        
        guard let builder = builder else {
            print("Builder is nil. Unable to end workout.")
            return
        }
        
        session.end()
        
        builder.endCollection(withEnd: Date()) { success, error in
            if !success {
                print("Unable to end collection")
                return
            }
            
            builder.finishWorkout { workout, error in
                if workout == nil {
                    print("Unable to read workout")
                    return
                }
                
                self.status = .complete
                self.workoutData = workout
            }
        }
    }
    
    func resumeWorkout() {
        guard let session = session else {
            print("Session is nil. Unable to end workout.")
            return
        }
        
        session.resume()
        self.status = .inProgress
    }
    
    func pauseWorkout() {
        guard let session = session else {
            print("Session is nil. Unable to end workout.")
            return
        }
        
        session.pause()
        self.status = .paused
    }
}

extension WorkoutSession {
    private func calculateBPM() -> Double? {
        let countUnit: HKUnit = .count()
        let minuteUnit: HKUnit = .minute()
        let beatsPerMinute: HKUnit = countUnit.unitDivided(by: minuteUnit)
        
        return self.heartRateStatistics?.mostRecentQuantity()?.doubleValue(for: beatsPerMinute)
    }
}
解决方案

错误核心原因:HealthKit的回调方法(如workoutBuilder(_:didCollectDataOf:)、授权回调、训练结束闭包)均在后台线程执行,而SwiftUI要求所有@Published属性的更新必须在主线程完成,否则会触发警告。

只需将所有更新@Published属性的代码包裹到DispatchQueue.main.async中,具体修改如下:

1. 修改workoutBuilder(_:didCollectDataOf:)方法

func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
    DispatchQueue.main.async {
        self.heartRateStatistics = workoutBuilder.statistics(for: .init(.heartRate))
        
        self.energyBurned = workoutBuilder.statistics(for: .init(.activeEnergyBurned))?.sumQuantity()?.doubleValue(for: .kilocalorie())
        self.energyBurnedStatistics = workoutBuilder.statistics(for: .init(.activeEnergyBurned))
        
        self.elapsedTime = workoutBuilder.elapsedTime
        
        self.bpm = self.calculateBPM()
    }
}

2. 修改startWorkoutSession中的闭包

builder!.beginCollection(withStart: Date()) { success, error in
    if !success {
        print("Unable to start collection, the error: \(String(describing: error))")
        return
    }
    
    DispatchQueue.main.async {
        self.status = .inProgress
    }
}

3. 修改endWorkoutSession中的闭包

builder.finishWorkout { workout, error in
    if workout == nil {
        print("Unable to read workout")
        return
    }
    
    DispatchQueue.main.async {
        self.status = .complete
        self.workoutData = workout
    }
}

4. 优化视图中对象的生命周期管理

在WorkoutView中,应使用@StateObject而非@ObservedObject持有WorkoutSession实例,避免视图重建时重复创建对象:

struct WorkoutView: View {
    @StateObject var workoutSession = WorkoutSession()
    
    // 其余代码不变
}

修改完成后,所有@Published属性的更新都会在主线程执行,警告将消失,数据也能正常同步到SwiftUI视图。

内容的提问来源于stack exchange,提问作者Lucas Buchalla Sesti

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 12:02:36