扫描时分组卡片一直闪——三层非确定性和一个 epoch 分箱
Flick 复盘:分析边跑边出结果,分组页的卡片却在循环里出现、消失、再出现。凶手是叠了三层的非确定性,加上一个会随时间线漂移的窗口锚点。修法是让「同一批照片」在每次重建里都算出逐位相同的分组。
Flick 的相似照片分组是边分析边出的:Vision 特征一批批算出来,每 2 秒重建一次分组,卡片渐进浮现。体验设计上没问题,直到用户报了一个诡异现象——扫描过程中,分组卡片在循环里出现、消失、又出现,像坏掉的霓虹灯。
这不是一个 bug,是叠在一起的三层非确定性,加一个会漂移的窗口锚点。逐层拆开,主题只有一个:同一批已分析的照片,每次重建都该算出逐位相同的结果。它没做到,SwiftUI 就忠实地把差异渲染成了闪烁。
为什么「结果抖一下」会变成「卡片闪一下」
先讲清楚放大器。SwiftUI 靠身份(identity)决定复用还是重建视图:ForEach 里每个分组的 id 变了,框架就认为「这是个新分组」——把旧卡片整个拆掉、建一个新的,新卡片的封面图要重新异步加载,于是你看到一次「占位图 → 封面」的闪烁。
所以只要分组的 id、或分组的排序、或组内照片的顺序在两次重建间发生任何变化,哪怕背后是同一批照片,SwiftUI 都会把它当成变更去重绘。2 秒一次的重建节奏把这个放大成了持续闪烁。
第一层:分组 id 绑了成员顺序
分组 id 当时是从「簇里第一个成员」派生的(dup-<第一张的 id>)。问题是簇成员的顺序来自 UnionFind 和字典遍历——没有稳定顺序。同一个簇,这次重建「第一个」是 A,下次可能是 B,id 就从 dup-A 变成 dup-B。对 SwiftUI 这就是两个不同的分组,拆了重建,闪。
修法是给 id 一个不依赖遍历顺序的稳定来源——取簇内最小 id。最小值在簇长大(又并进来几张相似照片)时依然是同一个(除非新并入的比它更小,但那是罕见边界),id 因此稳定:
private static func canonicalClusters(_ clusters: [[String]]) -> [[String]] {
clusters
.map { $0.sorted() } // 成员排序 → id 取 .first 即稳定最小值
.sorted { a, b in
if a.count != b.count { return a.count > b.count }
return (a.first ?? "") < (b.first ?? "") // 同大小按最小成员定序
}
}
第二层:同大小的簇之间没有定序
分组列表按「簇大小」降序排,再 prefix(50) / prefix(30) 截断显示。但 sorted { a.count > b.count } 对大小相同的簇不保证顺序——同为 3 张的两个簇,谁前谁后随实现摇摆。麻烦出在截断边界上:正好卡在第 50 名附近的簇,会随这种摇摆一会儿进 prefix 一会儿出,真的在两次重建间整组消失又出现。
修法在上面那段 canonicalClusters 里一并解决了:同大小时按「最小成员 id」二级排序,截断边界因此固定。
第三层:组内照片顺序是遍历顺序
封面是「组内第一张」,缩略图条是组内顺序铺开。如果这个顺序也来自遍历,那每次重建封面和缩略图条都在重排,连 BestShot(选最佳一张)的并列打破都可能翻转。修法同构——给组内照片一个稳定且有意义的顺序:按拍摄时间,id 二级打破。
private static func displayOrder(_ photos: [PhotoAsset]) -> [PhotoAsset] {
photos.sorted {
let l = $0.creationDate ?? .distantPast
let r = $1.creationDate ?? .distantPast
if l != r { return l < r }
return $0.id < $1.id
}
}
真正的元凶:会漂移的窗口锚点
上面三层修完,卡片还在循环闪。这才挖到根:视觉相似的分箱用了「间隔锚定」的时间窗——「距上一个窗口起点超过 5 分钟就开一个新窗口」,而锚点是从已分析的那部分照片推出来的。
问题在于扫描还在跑,「已分析的部分」每 2 秒就变一次。每来一批新特征,锚点就挪一下,同一批照片被重新分进不同的窗口,簇随之裂开或合并,分组在 count >= 3 这条过滤线上上下横跳——卡片就这么循环着生灭。这一层前面三个确定性修复根本够不着,因为它们修的是「给定分箱后怎么稳定输出」,而分箱本身在漂。
修法是把窗口从「相对锚定」换成绝对的 epoch 对齐分箱:按 floor(拍摄时间 / 300) 把每张照片扔进一个固定的 5 分钟格子。格子归属只取决于照片自己的时间戳,和「已分析了多少」无关——于是随着分析落地,成员单调增长,绝不重排:
var bins: [Double: [String]] = [:]
for (id, creation) in timeline {
bins[(creation / windowSeconds).rounded(.down), default: []].append(id)
}
代价在代码注释里写清楚了:一次跨越格子边界的拍摄会被切成两组(间隔锚定本来能把它们收在一起)。但近重复照片通常只差几秒,跨界概率很低——拿这个小概率的切分,换整个分组页的稳定,值。
附带一枪:重建竞态
还有一个隐蔽的闪烁源:rebuildGroups 会被多个源头同时触发——2 秒定时器、启动引导、待分箱刷新。两次重建读的是不同时刻的快照,applyGroups 落地顺序若颠倒,一个旧的、更小的结果会覆盖掉一个新的完整结果,闪一下。修法是合并并发的重建调用;万一某次竞态输了,下一个定时器 tick 几秒内自会修回来。
顺带把「核弹级重扫」拆了
查这条线时翻出一个相关的坏设计:用户忽略一个分组后点「重新扫描」,要等几分钟。原因是那个操作 clearCacheAndRestart 把整个特征缓存 DELETE + VACUUM 清空,然后对每一张照片重算 Vision 特征、模糊度、dHash——可根本没有哪种情况真需要这样。
因为所有「数据过期」的情形,启动路径本来就增量兜住了:新照片不在已分析集合里、编辑过的照片 modTime 对不上、算法升级走 schemaVersion 迁移……于是把它换成一个统一的增量重建——复用启动和外部变更时那条「只重算过期的、其余全复用」的路径,几秒而非几分钟,也不再需要破坏性二次确认。顺手删掉了 clearCacheAndRestart、FeatureStore.clear() 和五种语言里 8 个废弃文案键。
复盘
- 渐进式 UI 的前提是幂等的重建。只要「同一份输入」两次算出的 id / 排序 / 组内顺序有任何差异,SwiftUI 就会把差异渲染成拆建,表现为闪烁——渐进浮现的价值全靠「不动的东西真的不动」;
- 非确定性会分层叠加,逐层修不一定见效。id、排序、组内序修完还在闪,是因为最深的那层(分箱锚点)还在漂;排查这类问题别满足于「修了一个还在」,要一直挖到输入本身稳定为止;
- 锚点要绑绝对量,别绑「目前已知的数据」。间隔锚定依赖已分析子集,而子集在增长——凡是「随着数据到达会变」的锚点,都是抖动之源。epoch 对齐这类绝对分箱,用一点边界切分的代价换单调性;
- 「清空重来」几乎总是过重。能增量识别过期项,就不该核弹级全量重算——慢,且往往顺手引入破坏性操作和一堆专属 UI。
留言