删除已经提交,为什么还在转圈:Flick 的刷新边界
一次删除不该等整座相册重新分组。Flick 把系统提交、实际记账、快照刷新和后台重建拆开,同时挡住旧结果复活、分页误删与无关缓存失效。
Flick 在 10 月的一次修复里,改掉了一条过长的等待链:照片已经交给 PhotoKit 删除,待删除页却还要等全库分组重建完成,才结束「正在删除」。
问题藏在调用顺序里。删除流程最后接了一句 await coordinator.rebuildGroups()。这句会等待正在进行的重建,以及为当前请求安排的后续一轮。用户刚删掉几张照片,却可能被相册里其他照片的分析和分组拖住。
本文记录 f069da8(2026-10-12)及其后续实现里的处理方式。这是一篇源码开发记录;代码进入仓库与对应版本是否已经上架,是两件需要分别核实的事。
先把「删除完成」拆清楚
一次删除同时碰到几种状态,它们不能共用一个模糊的「完成」:
| 阶段 | 必须成立的事实 |
|---|---|
| 系统提交 | PHPhotoLibrary.performChanges 成功返回 |
| 本地记账 | 按实际提交的资源更新额度、待删除集合和进度 |
| 快照刷新 | 受影响的照片或视频查询结果已换新,旧分组结果不能覆盖回来 |
| 分组重建 | 基于新快照重新计算全库派生结果 |
前三步是这次操作收尾的一部分。最后一步可以排队进行,界面先从已有分组里移除已确认删除的资源。
这里也有一个显示口径的边界:PhotoKit 确认删除,不等于磁盘空间当场释放。系统的「最近删除」和 iCloud 同步仍有自己的生命周期,媒体字节数不能直接当成立即腾出的空间承诺。
返回实际提交的 ID,不能只返回请求数量
待删除页保留的是用户先前选择的一份快照。等到提交时,其中的资源可能已被别的设备删除,也可能已经不可见。直接拿这份旧快照扣额度、加进度,会把「请求删除」算成「成功删除」。
PhotoStore.delete 先重新解析资源、校验额度;进入 PhotoKit 的 changes block 后,再按 ID 获取仍存在且非隐藏的资源。CleanupDeletionReceipt 记录这一子集,只有系统事务成功,才把对应摘要和 committedIDs 交给 didCommit。
这个顺序让后面的几件事共享同一份事实:
- 免费额度按提交回执记账;
- 待删除集合只移除本次实际提交的 ID;
- 进度统计只增加实际提交的内容字节;
- 分组裁剪使用同一组 ID。
删除许可也不是拿到 PhotoKit 结果就释放。所有窗口共享 PhotoStore,许可覆盖记账阶段,避免两个窗口同时看见同一份剩余额度并各花一次。
去掉长等待,不能顺手放回旧结果
把 await rebuildGroups() 改成随手起一个后台任务,还不够。旧的重建可能早已读走删除前的快照,正在别的执行上下文里计算。即使界面先把照片裁掉,它晚一点把结果写回来,已删除资源仍会重新出现在卡片里。
现有实现先刷新受影响的查询快照,再裁剪界面、排队重建。分组落地时,还要检查照片和视频两个版本号:
guard !Task.isCancelled,
store.revision == revision,
store.videoRevision == videoRevision else { return }
这是 applyGroups 中守卫条件的简化写法。重建开头读到的版本,与落地时的版本对不上,就放弃这轮结果。旧任务可以继续算,旧结果没有继续生效的权利。
提交后的快照刷新使用独立任务并等待其完成。这里独立于调用者的取消状态,是因为系统删除一旦成功,就不能随着页面任务被取消而放弃必要的状态同步。它没有让用户继续等待整座相册的重建;等的是让后续结果能够正确判旧的快照更新。
裁剪分组时,已加载页面不等于全部成员
界面先更新,意味着要自己把分组状态裁剪正确。PhotoGroup 同时持有 memberIDs 和已加载的 photos:前者是完整成员,后者可能只是一页。
假设一组有 A、B、C,当前只加载了 A。删除 A 后,photos 为空,分组却仍有 B、C。如果用 photos.isEmpty 判断是否删掉整组,就会让一个仍然有效的分组消失。
removingAssets(withIDs:) 因此按完整成员数决定分组是否保留:重复、相似、连拍组至少要剩两项;截图、模糊、视频和全部资源组可以只剩一项。已加载页面、标题、建议删除集合同步裁剪,分组 ID 保持稳定,内容版本随变化更新。
「最佳保留」也不能机械补位。如果用户删掉了原先选出的 keeper,代码清掉 keeper、置信度、理由和原有删除建议,等待重新评估。否则余下资源可能全都还带着「建议删除」,而那套建议的前提——已经留了一张——已经不存在了。
合并任务,需要区分「等结果」与「安排工作」
CoalescingWork 原来提供 run:调用者等到请求之后开始的一轮工作完成。这个语义适合需要确认重建完成的入口,但不适合删除后的后台收尾。
新增的 schedule 只安排工作。两种入口共享一个串行 worker:
- 当前一轮继续运行,不与下一轮重叠;
- 运行中收到多个请求,只保留最新的待运行闭包;
- 被合并掉的闭包,其
run等待者仍然保留; - 后续一轮真正完成后,才唤醒对应等待者。
最后两点容易漏。合并工作不等于丢弃等待者,也不能因为别人调用了 schedule,就提前告诉正在 run 的调用者「你等的结果好了」。
于是删除页改为调用 refreshAfterDeletion(removing:):在 MainActor 上同步裁剪 groups 与由它派生的 sections,再用 schedule 安排完整重建,不再把重建耗时算进删除按钮的等待时间。
顺着失效范围,留下没变的视频缓存
同一轮修复还发现,删除照片会走默认的全量 reload,顺带刷新视频快照。视频分组缓存按 videoRevision 失效;视频明明没变,先前测过的大小却要重新计算。
现在按提交回执选择刷新范围:只删照片,就刷新照片;只删视频,就刷新视频;两者都有,才都刷新。独立视频刷新与完整 reload 使用同一个门闩串行化,防止较早开始的启动加载,晚于删除流程写回旧查询结果。
这个优化没有减少正确性检查。它只是把「整个相册有变化」细化为「哪一种资源的快照真的变了」。
把回归条件写成可检查的事实
仓库里的回归用例覆盖了这里最容易退化的几处边界:已加载页删空但完整分组仍存活、keeper 被删除后清空建议、裁剪保持分组身份,以及 schedule 在现有任务阻塞时能够返回、后续任务不重叠、所有 run 等待者在正确时机恢复。
日志也按阶段拆开:获取许可、准备资源、PhotoKit 提交、记账、快照刷新分别记耗时。记录内容限于计数、耗时和失败的错误域/错误码,不记录照片内容或资源 ID。这样再遇到「删除很慢」,至少能先判断慢在系统事务,还是应用自己的后处理,而不用把两者混在一起猜。
这次记录没有重新跑真机 PhotoKit 删除,也没有新增一组耗时对比。可以从源码和测试确认的是:全库重建已经移出删除等待链,实际提交子集贯穿记账与裁剪,旧快照的结果被版本守卫拦住。真实照片库、系统确认弹窗和 iCloud 行为,仍需要设备验证。
源码线索:PhotoStore.swift、CleanupDeletionPlan.swift、AnalysisCoordinator.swift、CoalescingWork.swift、PhotoGroup.swift,以及 test-async-lifecycle.swift、test-video-group.swift。这次修复最有用的地方,是把每一处等待重新对应到它真正需要保证的事实。
留言