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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 19:35:16