iOSFlick并发稳定性

删除已经提交,为什么还在转圈: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。这次修复最有用的地方,是把每一处等待重新对应到它真正需要保证的事实。

✦

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。