SwiftUI倒计时计时器逐步加速问题求助
健身App计时器加速问题排查与修复
问题背景
我开发了一款健身App,包含5个对应不同训练内容的页面,每个页面都关联同一个TimerView控制计时器。App配备开始/重置、暂停/恢复按钮,点击“下一个训练”会重置计时器并通过navigationPath跳转页面。但切换页面后出现异常:比如从训练1到训练2运行正常,返回训练1时,计时器每秒减少2而非1,页面层级越深,倒计时速度越快。
所有训练页面共享单个TimerStruct和TimerView,切换页面时会重置计时器。
相关代码示例
训练页面示例(ExerciseScreen4View)
import SwiftUI import AVKit struct ExerciseScreen4View: View { @Binding var streaks: Streaks @Binding var timerStruct: TimerStruct var countdownTimer = 300 @State var player = AVPlayer() var exercisePlan: ExercisePlan @Binding var navigationPath: NavigationPath func reset() { timerStruct.countdownTimer = 300 timerStruct.timerRunning = false } var body: some View { VStack { Form { Section(header: Text("4th Exercise (Scroll down for exercise steps)")) { Text(exercisePlan.exercise4.title) .font(.system(size: 35, weight: .medium)) } Section(header: Text("Video example")) { VideoPlayer(player: exercisePlan.exercise4.video) .scaledToFit() .frame(alignment: .center) .cornerRadius(10) } Section(header: Text("Steps:")){ Text(exercisePlan.exercise4.steps) .font(.system(size: 20, weight: .regular)) .padding() .frame(alignment: .center) } } TimerView(streaks: $streaks, timerStruct: $timerStruct) .padding(bottom: 15) Button { navigationPath.append("ExerciseScreen5View") reset() } label: { Text("Next exercise") .padding() .background(Color(red: 184/255, green: 243/255, blue: 255/255)) .foregroundColor(.black) .cornerRadius(10) } .padding(EdgeInsets(top: 0, leading: 10, bottom: 10, trailing: 10)) .navigationBarBackButtonHidden() } } }
TimerStruct
import Foundation import SwiftUI struct TimerStruct { var exerciseTime = 0.0 var isAlertpresented = false var countdownTimer = 300 var timerRunning = false var isPaused = false var isActive = false var isCompleted = false }
TimerView
import SwiftUI struct TimerView: View { @Binding var streaks: Streaks @Binding var timerStruct: TimerStruct let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() func format(seconds: Int) -> String { String(format:"%d:%02d", seconds / 60, seconds % 60) } func reset() { timerStruct.countdownTimer = 300 timerStruct.timerRunning = false } var body: some View { VStack { Text("Time: \(format(seconds: timerStruct.countdownTimer))") .padding() .onReceive(timer) { _ in if timerStruct.countdownTimer > 0 && timerStruct.timerRunning == true { timerStruct.countdownTimer -= 1 timerStruct.exerciseTime += 1.0 } else { timerStruct.timerRunning = false if timerStruct.countdownTimer <= 0, timerStruct.timerRunning == false { timerStruct.isAlertpresented = true reset() } } } .font(.system(size: 30)) .alert("Timer done! Press the button below to go to the next exercise", isPresented: $timerStruct.isAlertpresented) {} HStack(spacing:30) { Button(timerStruct.timerRunning ? "Reset" : "Start") { if timerStruct.timerRunning == true { reset() } else { timerStruct.timerRunning = true } } .padding() .background(.green) .foregroundColor(.black) .cornerRadius(10) .font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16))) Button(timerStruct.timerRunning ? "Pause" : "Resume") { if timerStruct.timerRunning == true { timerStruct.timerRunning = false } else { timerStruct.timerRunning = true } } .padding() .foregroundColor(.black) .background(.red) .cornerRadius(10) .font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16))) } } } }
问题原因与修复方案
问题根源
TimerView内部声明的let timer = Timer.publish(...).autoconnect()是视图级别的定时器,每当训练页面被重新创建(比如返回上一页时),新的TimerView实例会生成新的定时器,而旧的定时器并未被取消。多个定时器同时触发onReceive回调,导致每秒多次执行倒计时减1操作,视觉上表现为倒计时速度加快。
修复步骤
1. 重构为ViewModel管理定时器
创建一个TimerViewModel,用ObservableObject统一管理定时器的生命周期和状态,确保同一时间只有一个定时器运行:
import Foundation import Combine class TimerViewModel: ObservableObject { @Published var exerciseTime = 0.0 @Published var isAlertPresented = false @Published var countdownTimer = 300 @Published var timerRunning = false private var timerCancellable: AnyCancellable? func startTimer() { guard !timerRunning else { return } timerRunning = true timerCancellable = Timer.publish(every: 1, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in guard let self = self else { return } if self.countdownTimer > 0 && self.timerRunning { self.countdownTimer -= 1 self.exerciseTime += 1.0 } else { self.stopTimer() if self.countdownTimer <= 0 { self.isAlertPresented = true self.reset() } } } } func stopTimer() { timerRunning = false timerCancellable?.cancel() timerCancellable = nil } func reset() { stopTimer() countdownTimer = 300 exerciseTime = 0.0 } }
2. 更新TimerView使用ViewModel
移除视图内部的定时器,改为依赖ViewModel:
struct TimerView: View { @Binding var streaks: Streaks @ObservedObject var timerVM: TimerViewModel func format(seconds: Int) -> String { String(format:"%d:%02d", seconds / 60, seconds % 60) } var body: some View { VStack { Text("Time: \(format(seconds: timerVM.countdownTimer))") .padding() .font(.system(size: 30)) .alert("Timer done! Press the button below to go to the next exercise", isPresented: $timerVM.isAlertPresented) {} HStack(spacing:30) { Button(timerVM.timerRunning ? "Reset" : "Start") { timerVM.timerRunning ? timerVM.reset() : timerVM.startTimer() } .padding() .background(.green) .foregroundColor(.black) .cornerRadius(10) .font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16))) Button(timerVM.timerRunning ? "Pause" : "Resume") { timerVM.timerRunning ? timerVM.stopTimer() : timerVM.startTimer() } .padding() .foregroundColor(.black) .background(.red) .cornerRadius(10) .font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16))) } } } }
3. 更新训练页面传递ViewModel
在父视图中创建唯一的TimerViewModel实例,传递给所有训练页面:
// 父视图示例 struct WorkoutRootView: View { @StateObject private var timerVM = TimerViewModel() @State private var navigationPath = NavigationPath() @State private var streaks = Streaks() var exercisePlan: ExercisePlan var body: some View { NavigationStack(path: $navigationPath) { ExerciseScreen1View( streaks: $streaks, timerVM: timerVM, exercisePlan: exercisePlan, navigationPath: $navigationPath ) .navigationDestination(for: String.self) { screenName in switch screenName { case "ExerciseScreen2View": ExerciseScreen2View(streaks: $streaks, timerVM: timerVM, exercisePlan: exercisePlan, navigationPath: $navigationPath) case "ExerciseScreen3View": ExerciseScreen3View(streaks: $streaks, timerVM: timerVM, exercisePlan: exercisePlan, navigationPath: $navigationPath) case "ExerciseScreen4View": ExerciseScreen4View(streaks: $streaks, timerVM: timerVM, exercisePlan: exercisePlan, navigationPath: $navigationPath) case "ExerciseScreen5View": ExerciseScreen5View(streaks: $streaks, timerVM: timerVM, exercisePlan: exercisePlan, navigationPath: $navigationPath) default: EmptyView() } } } } } // 更新ExerciseScreen4View定义 struct ExerciseScreen4View: View { @Binding var streaks: Streaks @ObservedObject var timerVM: TimerViewModel var exercisePlan: ExercisePlan @Binding var navigationPath: NavigationPath var body: some View { VStack { // 原有Form内容保持不变 Form { Section(header: Text("4th Exercise (Scroll down for exercise steps)")) { Text(exercisePlan.exercise4.title) .font(.system(size: 35, weight: .medium)) } Section(header: Text("Video example")) { VideoPlayer(player: exercisePlan.exercise4.video) .scaledToFit() .frame(alignment: .center) .cornerRadius(10) } Section(header: Text("Steps:")){ Text(exercisePlan.exercise4.steps) .font(.system(size: 20, weight: .regular)) .padding() .frame(alignment: .center) } } TimerView(streaks: $streaks, timerVM: timerVM) .padding(bottom: 15) Button { navigationPath.append("ExerciseScreen5View") timerVM.reset() } label: { Text("Next exercise") .padding() .background(Color(red: 184/255, green: 243/255, blue: 255/255)) .foregroundColor(.black) .cornerRadius(10) } .padding(EdgeInsets(top: 0, leading: 10, bottom: 10, trailing: 10)) .navigationBarBackButtonHidden() } } }
额外优化
- 移除原有的
TimerStruct,用TimerViewModel统一管理所有计时器状态。 - 切换页面时调用
timerVM.reset(),确保计时器彻底重置并停止,避免残留定时器。
内容的提问来源于stack exchange,提问作者Zudoturiku
相关产品推荐
相关产品推荐

