工具大全
踩坑实录作者:Coocon2026年8月20日14 次阅读约 8 分钟阅读

蜗牛在你点开的瞬间原地调头:一帧的时钟误差,被取模回绕放大成了最大误差

现象

耳鸣舒缓 App 的声音页顶部有一张常驻场景卡:吉祥物蜗牛 Coco 站在平地上,用户点开雨声,背景换成雨景、Coco 开始极慢地来回爬(一个来回 90 秒);停止播放,背景换回平地。整张卡片包在 TimelineView(.animation(minimumInterval: 1/20)) 里,每秒重算 20 次。

两个毛病,用户的原话是:

  1. 第一次点自然声的时候,蜗牛会原地左右调头一下。第二次、第三次都不会。
  2. 打开有渐入,关闭太生硬,背景是硬切的。

第二个是设计问题,第一个是真 bug——而且它查了两轮才见底。

为什么不能用 SwiftUI 自带的过渡

先交代前提,否则后面的代码看着像在造轮子。

这张卡最初是想用 .transition(.opacity)withAnimation 做背景切换的,实际表现是渐变时有时无,第一次几乎必然没有。原因是 SwiftUI 的过渡依赖「状态变更发生在一个带动画的事务(transaction)里」,而 TimelineView 每 50ms 就要重算一次 body,那次重算会把事务吞掉——过渡还没开始就被下一帧的无动画事务覆盖了。

结论:在 TimelineView 内部,不要依赖 SwiftUI 的隐式/显式动画。时间轴反正每帧都把 t 递给你,那就自己用 t 算进度。

骨架:把动画状态写成时间的纯函数

于是场景卡里的每一样会动的东西,都是一个纯值语义的小状态机,对外只暴露 f(t) -> 值

/// 爬行进度时钟:走-停-走之间保留相位(停在哪就留在哪,恢复时从原地继续)
struct WalkClock: Equatable {
    private(set) var frozenPhase: Double = 0   // 停住时的相位(0..1,三角波一个来回)
    private(set) var origin: Double?           // 在走时的相位原点(nil = 停住)

    func phase(at t: Double, period: Double) -> Double { ... }
    mutating func start(at t: Double, period: Double) {
        guard origin == nil else { return }
        origin = t - frozenPhase * period      // 从冻结的相位续走
    }
}

同一套写法还有两个:AmplitudeRamp(蠕动幅度的 smoothstep 渐起渐停)和 SceneFade(场景交叉淡化的进度)。它们全是 struct、无副作用、不碰 SwiftUI,所以能直接进单元测试——这是意外收获,动画逻辑通常是最难测的那部分。

渲染层只负责把 t 喂进去:

let phase = clock.phase(at: t, period: Self.walkPeriod)
let tri = phase < 0.5 ? phase * 2 : 2 - phase * 2   // 三角波往返 0→1→0
let facingRight = phase < 0.5
...
.scaleEffect(x: facingRight ? 1 : -1, y: 1)         // 朝向靠镜像
.offset(x: 16 + tri * travel, y: -10)

注意 facingRight 这一行:朝向是相位在 0.5 和 0 两处的不连续函数。相位差之毫厘,镜像翻转是全量的。这个性质待会儿是关键。

第一轮误判:暂停的时间轴

第一轮定位到的是 TimelineViewpaused 参数。当时写的是 paused: !animated——没在播放时暂停时间轴省电。问题是 timeline.date 在 paused 期间是冻结的,恢复后从冻结点继续;而 clock.start(at:) 用的是 Date(),真实墙钟。

用户进页面停留了 40 秒再点雨声,两个时间源就差了 40 秒:origin 落在 timeline.date 的未来 40 秒处,相位算出来是负的,回绕成 0.55 左右——蜗牛朝左、还在中间,然后一路走到相位 1 再翻回来。这解释了「先左移、再掉头」。

改法是 paused: reduceMotion,让时间轴在非「减少动态效果」时一直跑,两个时间源对齐。写进了项目的 lessons,收工。

然后用户回来说:还是调头。

真正的根因:取模把 ε 放大成最大误差

上一轮把误差从 40 秒压到了一帧,但没压到零,而这个 bug 不需要 40 秒——它只需要误差是负的。

origin = Date() 取的是用户点击那一刻的墙钟。状态一变,SwiftUI 立刻重算 body,而这次重算拿到的 timeline.date上一帧的时间戳,必然早于 Date()。所以第一帧上 t - origin 是个小负数,几十毫秒。

然后它撞上了这一行:

let raw = ((t - origin) / period).truncatingRemainder(dividingBy: 1)
return raw < 0 ? raw + 1 : raw    // ← 这里

+1 是为了把负相位规范化到 0..1,本身没错。但它把 -0.0005 变成了 0.9995——一个数值上距离 0 最远的相位。取模回绕的性质就是这样:在环上,0 的负邻域就是 1 的正邻域,ε 的误差被翻译成 1-ε 的坐标。

对位置而言无所谓:tri = 2 - 2 × 0.9995 ≈ 0.001,横向差了 0.3 个像素,看不出来。但对朝向而言是灾难:facingRight = phase < 0.5 判成 falsescaleEffect(x: -1) —— 整只蜗牛镜像翻转。下一帧 t 追上 origin,相位回到 0.0001,又翻回来。

一次 50ms 的全身镜像,眼睛看得清清楚楚,就是「原地调头一下」。

为什么只有第一次?因为 origin = t - frozenPhase * period。冷启动时 frozenPhase == 0origin 恰好等于「现在」,是它能取到的最大值,一帧的负误差刚好把相位推过 0 这个边界。第二次点击时 frozenPhase 已经是 0.02、0.3……,origin 被推到过去几秒甚至几十秒,一帧的偏差穿不过去。

这个 bug 的存活条件是「起点恰好在环的接缝上」,而冷启动必然把起点放在接缝上。

实测:让 App 自己把两个时钟打出来

上面这套推断是自洽的,但「timeline.date 一定滞后于 Date()」这句话读文档读不出来——SwiftUI 从没承诺过时间轴给的是哪个时刻,它完全可以给你下一帧的呈现时间。真是那样的话 elapsed 就永远为正,本文的根因当场作废。所以别推断,直接量。

TimelineView 的 body 里同时取两个时钟,并把新旧两套公式各算一遍:

enum FlipProbe {
    static func tick(t: Double, walkOrigin: Double?, period: Double) {
        guard let o = walkOrigin else { return }
        let now = Date().timeIntervalSinceReferenceDate
        let elapsed = t - o
        let old = WalkClock.legacyPhase(elapsed: elapsed, period: period)  // raw < 0 ? raw + 1 : raw
        let new = max(0, elapsed) / period
        print(String(format: "skew=%+.5f elapsed=%+.5f | old=%.5f %@ | new=%.5f %@",
                     t - now, elapsed,
                     old, old < 0.5 ? "→R" : "←L",
                     new, new < 0.5 ? "→R" : "←L"))
    }
}

模拟器里跑一次,第 4 秒由代码自动开一次雨声(避免手点带来的时序偶然):

base #01..#15  skew(t-now) = -0.00001 … -0.0271715 帧全为负
armed  walkOrigin=808935265.34566
#01  skew=-0.01543  elapsed=-0.01517 | old=0.99983 ←L | new=0.00000R
#02  skew=-0.02723  elapsed=+0.03483 | old=0.00039R | new=0.00039R
#03  skew=-0.01057  elapsed=+0.08483 | old=0.00094R | new=0.00094R

三件事一次问清楚:

  • timeline.date 恒滞后于墙钟,常态 0.5ms,偶尔 15–27ms,十五帧无一超前。它给的是刚渲染完那帧的时间,不是未来的呈现时间。
  • 状态变更触发的那次 body 求值,elapsed 是负的(-0.015s),和推断一致。
  • 旧公式在那一帧算出 0.99983,判定朝左;新公式算出 0,朝右。下一次求值(#02)就恢复了。

#01#02 之间墙钟走了 62ms(0.03483 + 0.02723),意味着镜像状态在屏幕上活了约四个 60Hz 刷新周期——不是理论上的一瞬,是真的看得见。把旧公式装回去真正驱动画面,录屏逐帧翻出来:

连续八帧 60fps 录屏:中间四帧蜗牛壳在右、触角在左,整只镜像;前后各两帧正常

中间四帧就是 66ms,和日志算出的 62ms 对得上。

顺带一个坑,差点让我把结论搞反:xcrun simctl io recordVideo 产出的 mov 时间戳不单调,ffmpeg 的 trim=start= 按 pts 选帧会选到完全不同的位置。第一次找这几帧时我按时间裁了一段、发现全是正常帧,差点写成「渲染了但没上屏」。先 -fps_mode cfr -r 60 转成恒定帧率再裁,才对得上。用录屏当证据前,先确认你裁到的真是你以为的那一秒。

修复:一行夹紧

func phase(at t: Double, period: Double) -> Double {
    guard let origin else { return frozenPhase }
    // t 可能略早于 origin:origin 取自事件发生那一刻的墙钟,而状态一变,
    // SwiftUI 会先用上一帧(更早)的时间轴时间重算一次 body。不夹住的话
    // 负相位会回绕到 ≈1.0,朝向瞬间反向——就是刚点开时蜗牛原地调头那一下。
    let elapsed = max(0, t - origin)
    return (elapsed / period).truncatingRemainder(dividingBy: 1)
}

时钟只会向前,负的 elapsed 一定是时间源抖动,不是真实语义,夹到 0 即可。

值得一提的是,同一个文件里另外两个状态机(AmplitudeRamp.valueSceneFade.progress早就有 p <= 0 的保护,因为它们的进度是单调 0→1、天然要处理「还没开始」的情况。只有 WalkClock 漏了,恰恰因为它是周期性的——写循环相位的时候,脑子里想的是「回绕」,不是「还没开始」。

另一半:不要淡出,只要淡入

第二个毛病顺带说清楚,因为它的解法也是反直觉的。

交叉淡化的自然写法是「旧的淡出 + 新的淡入」,但两层同时处于半透明时,中间会短暂露出底色,观感是一次洗白。正确的层序是:

ZStack {
    // 旧场景整幅垫底,不透明,只管继续动
    if let outgoing = fade.outgoing, !fade.isSettled(at: t) {
        sceneLayer(outgoing, t: t, width: geo.size.width)
    }
    // 新场景按时间算出的 p 叠加淡入,盖住为止
    sceneLayer(fade.current, t: t, width: geo.size.width).opacity(p)
}

只有淡入,没有淡出。 旧层始终 100% 不透明地垫在底下,直到被完全盖住才移出视图树。任意时刻画面的总不透明度都是 1,不可能露底。

有了这个层序,「关闭太生硬」就不是加一个淡出动画,而是把关闭也走同一条淡入路径:停止播放时,「平地」也是一个正常场景,它淡入盖住雨景就行了。原来的代码为了「停止要干脆」特意写了 groundFade = 0 硬切,删掉这个特例,开和关立刻对称了。

一个细节:淡出层在过渡期间必须继续动。雨还在下、海浪还在涌,只是逐渐被盖住——如果这时候把旧层的时间冻成 0,观感是「雨先停住、再消失」,比硬切还别扭。所以每一层按自己的场景独立决定要不要动:

let time = (!reduceMotion && (layerScene != .ground || isWalking)) ? t : 0

最终效果:

修复后的场景卡:开与关都是新场景整层淡入盖住旧场景,旧层在底下继续下雨直到被盖住;蜗牛全程保持朝向,不再翻转(iOS 模拟器实录,原速未加速)

适用边界

  • 只在 TimelineView / CADisplayLink 这类自带时钟的场景里这么写。 普通视图该用 withAnimation 就用,别为了「纯函数」把简单动画复杂化。
  • 代价是所有动画状态要自己实现缓动。这里用的是 smoothstep(p*p*(3-2*p)),够用;要 spring 就得自己写弹簧解,不如回去用 SwiftUI 的动画系统。
  • paused 省的那点电,未必值得引入第二个时间源。 真要暂停,就把所有 Date() 换成从 timeline.date 传下去的 t,别混用。

带走的教训

在环形坐标系(相位、角度、时刻、ring buffer 下标)里,取模会把一个微小的负误差翻译成一个最大的正误差。 线性坐标里 -0.00050 差 0.0005;环上它俩差 0.9995。所以凡是「量 + 取模」的组合,都要先问:这个量有没有可能因为时间源抖动、浮点误差、时钟回拨而略微为负?如果有,是夹紧还是回绕,必须是明确的决定,不能是顺手写的那句 x < 0 ? x + 1 : x

推论是:别让离散的判定直接骑在连续量的不连续点上facingRight = phase < 0.5 这种写法,把一个全量的视觉翻转绑在了一个数值边界上,边界附近任何抖动都会被放大成 100% 的视觉变化。要么在数值层面保证不抖(本文的做法),要么给判定加滞回,别裸着。

最后一条更通用:同一个动画里出现两个时间源,就是等着出错。第一轮我们把两个时间源的差从 40 秒压到 40 毫秒,感觉「基本对齐了」——但这个 bug 的触发条件是符号,不是量级。压小误差不等于修好,只有把误差的符号变得不可能,才算修好。

相关文章

AI 视频的字幕越到后面偏得越多:别再修对齐算法了,问题出在「先生成整段音频」这一步

用 TTS + 自动字幕做科普视频,字幕前半段完美、越往后漂移越大,尾部时间戳甚至退化成 00:00:00。第一反应是修对齐算法——归一化数字、调相似度阈值——都是打补丁。这篇复盘给出根因:「整段 TTS + whisper 转写 + 事后匹配」这类方案在结构上必然漂移,因为它把本该由生成过程直接产出的时序信息,交给了一个会出错的事后重建环节。解法是逐句 TTS + 采样级拼接:字幕时间戳等于每句真实 wav 采样数的累加,句边界天然重置误差,物理上不存在漂移。附完整切句规则、缓存设计与适用边界。

ttsbug-复盘+3
pitfalls2026年8月20日4 min
99

Fable 5 明明是 1M 上下文,状态栏却显示 200k:别急着换工具,先抓一份现场数据

Claude Fable 5 官方确认 1M token 上下文窗口,但 Claude Code 底部状态栏的分母一直是 200k。第一反应是「换个更好的 statusline」——错了。本文复盘完整排障过程:用一行 tee 抓下 statusline 的 stdin 现场,实锤官方字段对新模型误报 200000,最后用一张模型表修正。附赠一个通用教训:换工具修不了数据源的错。

llmclaude-code+3
pitfalls2026年8月14日3 min
283

Google 说我的页面'被 robots.txt 屏蔽'?——别急着改代码,先用 URL Inspection API 查官方判定

一条'页面未收录、被 robots.txt 屏蔽'的反馈,差点让我们推翻整个多语言 301 架构。用 GSC URL Inspection API 一查:robots.txt 状态 ALLOWED,真实状态是'网页会自动重定向'——内容早已在目标 URL 正常收录。本文完整复盘排查链路、多语言站的 SEO 架构取舍,以及一套可复用的'查 Google 官方判定'方法论。

seobug-复盘+2
pitfalls2026年8月8日6 min
176

时间计算三连坑:错误的校准锚点、被误读的经典公式、消失的一小时

给五行排盘工具修一个用户反馈的 bug,结果连挖出三个时间计算深坑:日柱基准用错误用例校准、万年历节气公式的时区语义被误读导致边界偏差 6.5 小时、1986-1991 年中国夏令时吃掉一小时。本文完整复盘现象、根因、解法与验证方法。

时间处理历法计算+2
pitfalls2026年8月8日7 min
179