应用从后台返回后,圆形进度环会短暂地向后旋转一次

移动开发 2026-07-09

我有一个SwiftUI iOS应用,屏幕名为“工作时间”。

在该屏幕上,一个带有60个刻度的圆环在跟踪运行时持续旋转。应用本身不再崩溃,但仍然存在一个视觉问题:

  • 应用进入后台一段时间后再回到前台,圆环会短暂地朝相反方向移动一个小步一次,然后继续顺时针正常旋转。
  • 未显示运行时错误,这只是一个视觉问题。

预期结果:在前台恢复后,圆环应平滑地以相同方向继续旋转,不应有反向的步骤。

实际结果:在前台恢复后,会先出现一个短暂的“反向刻度”,随后再恢复正常旋转。

我尝试了几种方法,仍然会出现反向步进:

  1. withAnimation(.linear(...).repeatForever) +在前台重新启动。
  2. 使用 TimelineView 的时间线驱动旋转,并搭配 .animation.periodic 两种调度。
  3. 旋转角度带模与不带模包装。
  4. 基于墙钟时间和基于单调运行时的旋转。
  5. 渲染调整(几何中的Canvas旋转vs rotationEffect,有/无预热延迟)。
  6. 不同的圆环视觉效果(统一刻度vs长短的类似小时刻度)。

问题仍然可复现。

import SwiftUI

struct ReproClockView: View {
    @Environment(\.scenePhase) private var scenePhase

    private let tickCount = 60
    private let rotationDuration: Double = 120

    @State private var rotationDegrees: Double = 0
    @State private var hasAppeared = false

    var body: some View {
        ZStack {
            Canvas { context, size in
                let center = CGPoint(x: size.width / 2, y: size.height / 2)
                let radius = min(size.width, size.height) / 2 - 8

                for index in 0..<tickCount {
                    let angle = Double(index) * (360.0 / Double(tickCount)) - 90
                    let radians = angle * .pi / 180
                    let isLongTick = index % 5 == 0
                    let length: CGFloat = isLongTick ? 22 : 14

                    let outerRadius = radius
                    let innerRadius = radius - length

                    let startPoint = CGPoint(
                        x: center.x + cos(radians) * outerRadius,
                        y: center.y + sin(radians) * outerRadius
                    )
                    let endPoint = CGPoint(
                        x: center.x + cos(radians) * innerRadius,
                        y: center.y + sin(radians) * innerRadius
                    )

                    var path = Path()
                    path.move(to: startPoint)
                    path.addLine(to: endPoint)

                    context.stroke(
                        path,
                        with: .color(.red),
                        style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
                    )
                }
            }
            .rotationEffect(.degrees(rotationDegrees))
            .drawingGroup()
        }
        .frame(width: 340, height: 340)
        .onAppear {
            guard !hasAppeared else { return }
            hasAppeared = true
            startRotation()
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if oldPhase == .background && newPhase == .active {
                withAnimation(.linear(duration: 0)) {
                    rotationDegrees = 0
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                    startRotation()
                }
            }
        }
    }

    private func startRotation() {
        rotationDegrees = 0
        withAnimation(.linear(duration: rotationDuration).repeatForever(autoreverses: false)) {
            rotationDegrees = 360
        }
    }
}

要实现一个在后台/前台切换时仍保持方向稳定的连续旋转圆环(没有一个帧的反向步骤),应该采用什么样的正确SwiftUI做法?

如果可能,请给出一个在当前iOS版本上都稳健、在场景阶段变化时避免视觉伪影的模式。

解决方案

这个问题其实关乎iOS在将应用移到后台时拍摄快照的确切时刻。

我在模拟器上通过Device > App Switcher切换到另一个应用(Settings)再切回来时,能够重现这个问题。几点说明:

  • 首次调出App Switcher时,场景阶段从 active 变化到 inactive。在这个状态下,动画仍在运行。
  • 选择其他应用时,场景阶段从 inactive 变化到 background。在这个状态下,动画不会运行。
  • 截图是在阶段变为 background 之后拍摄的。
  • 由于你看到时钟会往回跳一点,我猜在阶段仍然是 inactive、紧接着变为 background 之前就记录了与动画相关的状态。然后动画从这个记录的状态恢复。也可能动画是从重置位置开始。无论如何,动画重新开始时的位置都不是截图时的位置。

为了解决这个问题,可以考虑在场景阶段变为 background 时重置并冻结动画。这样,快照始终处于重置位置,应用再次活跃时动画总是从该位置开始。

下面给出一个按此方式工作的代码更新版本。并包含了一些其他简化:

  • 我认为如果在 .onAppear 中无条件启动动画就能正常工作。因此,hasAppeared 这个标志不再需要。
  • 当场景阶段为 background 时显示冻结的时钟;否则显示动画时钟。不需要再通过 .onChange 修饰符来跟踪阶段变化。
  • 在我的测试中,我尝试了用 Capsule 形状绘制时钟的实现。我认为这个版本实际上比你原来使用的 Canvas 实现更省CPU。当在模拟器上运行时,胶囊版本的CPU占用在一段时间后降至3-5%,而你原始版本则始终高于10%。因此我保留了这个时钟实现,并且代码也更少。
struct ReproClockView: View {
    @Environment(\.scenePhase) private var scenePhase

    private let tickCount = 60
    private let rotationDuration: Double = 120
    private let clockSize: CGFloat = 340
    @State private var rotationDegrees: Double = 0

    private var clock: some View {
        ZStack {
            ForEach(0..<tickCount, id: \.self) { index in
                let angleDegrees = Double(index) * (360.0 / Double(tickCount))
                let isLongTick = index % 5 == 0
                let length: CGFloat = isLongTick ? 24 : 16
                Capsule()
                    .frame(width: 2.5, height: length)
                    .offset(y: (length / 2) - (clockSize / 2) + 7)
                    .rotationEffect(.degrees(angleDegrees))
            }
        }
        .frame(width: clockSize, height: clockSize)
    }

    var body: some View {
        ZStack {
            if scenePhase == .background {
                clock
            } else {
                clock
                    .drawingGroup()
                    .rotationEffect(.degrees(rotationDegrees))
                    .onAppear {
                        withAnimation(
                            .linear(duration: rotationDuration)
                            .repeatForever(autoreverses: false)
                        ) {
                            rotationDegrees = 360
                        }
                    }
                    .onDisappear { rotationDegrees = 0 }
            }
        }
        .foregroundStyle(.red)
    }
}

Animation

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章