「bug 修好了,可它还在」——从外面看就是这样。所有者的抱怨听起来很简单:「进入应用时数据不更新——必须手动下拉,天气和其他所有东西才会刷新」。一个症状,一句话,期待一处修改。
MeteoHealth(项目页面)是一款已上架 App Store 的应用,这样的抱怨通常确实只值一行放错地方的代码。我找到了原因,修复了它,测试变绿——抱怨消失了。持续了十二天。
8 月 22 日它几乎一字不差地回来了,而这一次,「不刷新」这个症状背后挖出了四个相互独立的原因,外加第五个——由第一次修复本身引入的。下面讲的不是一个 bug 的故事,而是当修复站不住脚时该怎么办的故事。
原因 1:.task 活得比看上去长#
第一个发现事后看来几乎是显而易见的。「今天」页面在 .task 里加载数据,而 TabView 内部的 .task 在进程生命周期里只执行一次:各个标签页保持挂载状态,切换标签和从后台返回都不会重启它。
页面层面根本没人监听 scenePhase——在应用层面它只忙于处理通知和深链接。代码里的注释直接记录了这一点:
// 起因:「进入应用时数据不更新——必须手动下拉」。原因在于加载挂在
// `.task` 上,而 `TabView` 内部的 `.task` 在进程生命周期里只执行一次:
// 各个标签页保持挂载状态,切换标签和从后台返回都不会重启它。8 月 10 日的修复(952975c)给四个标签页——「今天」「预报」「日记」「分析」——加上了 .refreshOnForeground 修饰符,绑定在 scenePhase 切换到 .active 上。这关闭了「进入后台再返回」这条路径。但没有关闭「切到别的标签再切回来、全程没进过后台」——同一个 .task 继续沉默。
原因的这一半直到 8 月 22 日才浮出水面,作为同一症状下的独立条目:「切回标签页什么都不刷新:门闸只在进过后台之后才触发」。一个机制,两个触发器,分两轮才关闭。
原因 2:没有后门的缓存#
第二个原因就在同一次首轮修复里:WeatherService 持有一个硬性的十分钟缓存,没有任何绕过手段。即便页面老老实实地重启了加载,updateWeatherData() 也会悄悄返回缓存里的同一批值——手动触发的 pull-to-refresh 什么都改变不了。页面重绘了,加载圈转了,数据还是昨天的——对用户来说,这和「完全不工作」没有区别。
/// - Parameter force: 绕过十分钟缓存。在由人发起更新的地方
/// (pull-to-refresh)或应用从后台返回时置位:否则在缓存窗口内的
/// 「手动下拉」会悄悄把同样的值重新赋一遍,数据看起来是新的,
/// 实际上并不是。
func updateWeatherData(force: Bool = false) async {
...
let lifetime = force ? Self.forcedCacheLifetime : Self.cacheLifetime
if let entry = cache.object(forKey: cacheKey as NSString),
Date().timeIntervalSince(entry.value.timestamp) < lifetime {force 参数并不完全取消缓存——它把有效期从十分钟缩短到一分钟,以免向 API 密钥的共享限额打出一连串请求。通过 inFlightUpdate 对并行调用做的 coalescing 保持原样——事实证明这很关键:正是它引发了原因 3。
原因 3:淹没在人群里的 force#
到 8 月 22 日,上面两个原因都已关闭,抱怨却回来了。应用启动时,有两处同时发起更新:MainTabView.task 和 TodayViewModel.loadData——都不带 force。如果人恰好在这个窗口里手动下拉页面,他的强制请求只会通过 inFlightUpdate 汇入已经在跑的普通请求,拿到同样的缓存数据。pull-to-refresh 手势物理上触发了——却什么都没做。
/// 当一个更新已在进行时,如何处理新到的更新请求。
///
/// 启动时 `MainTabView.task` 和 `TodayViewModel.loadData` 同时发起更新,
/// 且都不带 `force`;如果人在这个窗口里下拉页面,他的强制请求会汇入
/// 别人的普通请求,后者从十分钟缓存里返回数据,手势于是什么都没做。
enum WeatherUpdateCoalescer {
enum Decision: Equatable {
case start
case join
case joinThenForce
}
static func decide(incomingForce: Bool, inFlightForce: Bool?) -> Decision {
guard let inFlightForce else { return .start }
return incomingForce && !inFlightForce ? .joinThenForce : .join
}
}coalescing 本身是正确的想法——没有它,并行调用会催生重复的网络请求。错误在于它不区分请求的强度:弱的一次和强的一次坍缩成了一个弱的结果。
原因 4:天气在等一场搬家#
第四个原因独立于前三个,住在地理位置解析里。GPS 定位慢的时候,resolveLocation() 撞上八秒超时、空手而归——而且没有重试,因为 didUpdateLocations 只在移动超过 5 公里时才重新请求天气。如果人哪儿也没搬(而他通常确实没搬——只是早上在同一个地方打开了应用),更新就悄无声息地永远熄灭了。页面停留在昨天的快照上,直到手动下拉——而这个手势本身又被原因 3 吃掉了。
/// 定位慢时的冷启动撞上八秒超时、空手而归;尝试本身不再重复——
/// `didUpdateLocations` 只在移动超过 5 公里时才重新请求天气。
/// 两次尝试是上限:如果之后仍然没有定位,问题就不在时间上。
private func scheduleLocationRetryIfNeeded(force: Bool) {
guard authorizationStatus != .denied, authorizationStatus != .restricted else { return }
guard locationRetryCount < Self.maxLocationRetries else { return }
locationRetryCount += 1
Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Self.locationRetryDelay * 1_000_000_000))
await self?.updateWeatherData(force: force)
}
}外加对系统位置缓存的 fallback(location ?? currentLocation ?? locationManager.location)——如果没有新鲜的定位,最后已知的位置对天气来说通常就够用了。
四个原因——全部关闭。还剩第五层,这四个都没把它算在内,因为把它引进来的正是第一次修复本身。
原因 5:抢在自己前面的修复#
第一次修复(952975c)自己引入了一个独立的 bug,第二天在模拟器上实地检查时就被发现了。门闸 RefreshOnForegroundGate 按「上次更新的时间」计算节流——于是冷启动后立刻变为 .active 的场景,会紧跟着第一次加载再安排第二次页面加载。日志里是这样的:01:15:23 和 01:15:26——相隔三秒的连续两次加载,尽管应用才刚刚打开。
规则被改写成抱怨本身的措辞:不是「上次什么时候更新的」,而是「应用在后台待了多久」:
mutating func shouldRefresh(on phase: ScenePhase, now: Date = Date()) -> Bool {
switch phase {
case .background:
backgroundedAt = now
return false
case .active:
guard let wentAway = backgroundedAt else { return false }
backgroundedAt = nil
return now.timeIntervalSince(wentAway) >= minimumBackgroundTime
default:
return false
}
}改写带来了一个令人愉快的副作用:两个误触发顺带一起消失了——冷启动(backgroundedAt 就是 nil,没什么可刷新的)和覆盖在应用之上的系统对话框(它给出的是 .inactive 而不是 .background)。在同样的日志里得到验证:01:19:50 冷启动——一次加载;后台待了 32 秒;01:20:26 返回——第二次。正好两次,没有重复。
作为纯类型的门闸#
这次改动让我喜欢的不是修复这件事本身,而是「现在刷不刷新」这个决策从一开始就被从 View 挪进了独立类型 RefreshOnForegroundGate,逻辑内部没有任何对 SwiftUI 的依赖。正是这一点让节流、.inactive vs .background 以及重复加载都能用单元测试验证,而不是在模拟器上用眼睛看。
区分 .inactive 和 .background 不是 API 的偶然细节,而是抱怨的直接推论:系统权限对话框或控制中心覆盖在应用上时给出的是 .inactive,那种场景下数据客观上来不及过期。为每个这样的对话框都刷新一次,等于无缘无故地打网络。
30 秒的阈值住在两个地方——门闸本身,以及复制了一份,放在一个在模拟器上跑真实「最小化——等待——返回」循环的 UI 测试里(XCUIDevice.shared.press(.home) → 长于阈值的 Thread.sleep → app.activate())。这份复制记录在注释里:
/// 来自 `RefreshOnForegroundGate` 的阈值。UI 测试无法对应用做
/// `@testable import`(它作为独立进程启动),所以数值复制了一份。
/// 一旦偏离,测试会开始过早返回并变红,而不是撒谎。
private enum RefreshOnForegroundThreshold {
static let seconds: TimeInterval = 30
}对一个复制在两个进程里的常量来说,最糟糕的情形是无声的偏离。在这里,偏离会弄坏测试,而不是悄悄溜过去。
围绕这一切的过程诚实度也值得一说。提交 952975c 里直接写着:「未完全验证:未能在模拟器上跑通『最小化——返回』的交互循环」。而关于 pull-to-refresh 之后滚动回归的 22b8068 里写着:「在模拟器上未能复现原始回归——即使在回滚后的代码上测试也是绿的——所以修复只能在真机上确认」。记录已验证的边界,与修复本身属于同一种纪律:一个无法证明 bug 已关闭的测试,被诚实地称为防范未来损坏的哨兵,而不是证明。
这个故事的结局#
当人们说一个 bug「修好了,可它还在」——这几乎从不意味着「修复没起作用」。它意味着原因不止一个,而且它们之间没有逻辑关联,只有共同的症状。这里是四个独立的层——SwiftUI 生命周期(TabView 里的 .task)、没有绕过手段的服务缓存、并行请求的 coalescing、按距离触发的地理触发器——而每一层都掩盖着其他层:修好 .task,pull-to-refresh 仍然因为缓存而沉默;修好缓存,启动时它又被 coalescing 吃掉;修好 coalescing,没有定位修复,页面在 GPS 出结果之前照样不刷新。此外还有单独一个——由第一次修复本身引入的 bug,只有当终于腾出手在模拟器上实地检查、而不是只看构建日志时才被发现。
实践结论比「多写测试」更具体:把逻辑从 View 挪进纯类型的决定收回了成本——正是它让节流无需截图就可验证。还有第二条:如果症状被用户用一句话描述出来,这不代表原因只有一个——在关闭工单之前,每一层都值得单独验证。


