· engineering· 约 18 分钟精读

Evidence Photo Reference Graph Orphan Files — Technical Analysis | KeyRotate Technology

核心要点速览 · TL;DR 概要

一条检验记录最多附四张照片。照片从相册取出、解码、按 0.8 重编码、原子写盘,四步之内没有一次降采样;此后它只以文件名的形式活在一个字符串数组里。删除设备时两行级联清空了设备与记录,照片留在目录中;界面从不渲染它,报告只打印数量。本文按源码核对这条链上的每一个落点。

Evidence Photo Reference Graph Orphan Files — Technical Analysis | KeyRotate Technology

工具间里,检验员给一台角磨机拍了三张照片:铭牌、电源线护套、接地端子。他按下保存,记录进了台账。两个月后设备复检,他打开同一台设备的历史,屏幕上只有一行日期、一个结论、一个名字。照片还在设备里,但不在任何一屏上。

这不是界面偷懒。TagWorks 的证据照片有一条完整的写入链,也有完整的出口链——备份、报告、CSV——但没有任何一条链回到屏幕。本文按源码把这条链拆开:四张的上限写在哪个表达式里、四个写入动作各自做了什么、文件名如何成为唯一的纽带、删除设备时那处空缺是怎么留下的,以及为什么最终只有数字被带走。

一、四张是一个表达式,不是一个常量

上限出现在选择器上:

maxSelectionCount: max(0, 4 - savedPhotoNames.count)

已存四张时表达式归零,选择器被禁用;已存三张时最多再选一张。导入函数里还有一道同源的判断:remainingCapacity 取出剩余格数,items.prefix(remainingCapacity) 只处理前几张。超出的部分不会静默丢弃,而是拼进一条提示,明确告诉用户一条检验记录只能附四张。

四属于记录,不属于设备,也不属于整本台账。台账的照片总量因此没有上限,它是记录条数的四倍:一台设备一年检六次,就有二十四个位置;一百台设备一年就是两千四百个位置。代码里没有任何地方统计这个乘积,也没有任何地方为它设预算。

二、四个动作里没有一步降采样

从相册到磁盘,照片经过四个动作。

第一步,item.loadTransferable(type: Data.self)。这一步跨进程,把相册里的原始文件读成字节。拿到的是完整分辨率的数据,不是缩略图。

第二步,UIImage(data:)。字节被解码成位图。一张一千二百万像素的照片,解码后是 4032×3024×4 字节,约 48.8 MB;四千八百万像素则是 8064×6048×4,约 195.1 MB。这块内存在解码期间必须真实存在。

第三步,image.jpegData(compressionQuality: 0.8)。位图重新编码为 JPEG。质量参数 0.8 影响的是量化表,不是像素尺寸:四千八百万像素进去,四千八百万像素出来。

第四步,data.write(to: url, options: .atomic)。写入 Documents/Photos。原子写先写临时文件再改名,保证单个文件不出现半截;它不保证跨文件一致,也不保证介质已经落稳。

四个动作里没有任何一步做降采样。这是整条链上最值得记录的工程事实:应用在采集端保留了原始像素,却在展示端一次也没有使用它们。写入端的成本按原图支付,读取端的收益为零。

一张证据照片的写入链与六个引用点四步落盘 · 一个文件名当纽带 · 六处引用没有一处读像素写入链引用点与它实际拿到的东西相册取回原始字节解码成像素位图按 0.8 重编码 JPEG原子写入沙盒目录四步之内没有降采样,原始像素一路到磁盘落盘之后没人认领记录删了,字节还在目录里没有枚举入口,也没有引用计数Documents/Photos/<UUID>.jpg检验记录唯一结构引用records.photoFiles备份归档缺一张即拒绝整包Archive.photosPDF 报告只有数量,没有画面Photos: NCSV 台账只有数量,回导不还原PhotoCount删除设备记录清空,文件留下delete(asset)界面渲染定义存在,调用点为零PhotoStore.load无主文件不是异常分支,而是这张引用图的必然产物。

三、文件名是唯一的纽带

写盘时文件名由 UUID().uuidString + ".jpg" 生成。名字里没有资产标识、没有记录标识、没有日期、没有序号。

写盘前有一道校验,isSafeFileName 要求三件事同时成立:非空;与 URL(fileURLWithPath:).lastPathComponent 相等;不含 ..。这三条挡住的是路径穿越,不是语义错误。

真正把文件与台账连起来的是记录里的一个字符串数组:photoFiles: [String]。这个数组是唯一的结构引用。资产模型上还有一个 photoFile: String?,注释写着”filename inside the photo store”,但在整个工程里它从未被赋过值。备份的引用集合仍然把它并进去:

referenced.formUnion(...),其中一项正是 assets.compactMap(\.photoFile)。

一个永远为空的槽位,被当作可能存在的引用对待。这在当下没有副作用,但它说明引用集合的设计意图比实现走得更远。

于是引用图非常薄:文件名对数组元素。没有校验和,没有字节数,没有像素尺寸,没有拍摄时间。文件被重命名、被外部工具搬走、被系统清理,台账都不会知道——它只知道字符串还在。

四、先写文件,后写记录

照片不是保存记录时才落盘的。用户在选择器里选完,importPhotos 立刻逐张保存,把返回的文件名追加进 savedPhotoNames;这个数组是视图的局部状态,此时记录还不存在。

这段时序制造了两个中间态。

第一个是取消。工具栏的 Cancel 会遍历 savedPhotoNames,逐个调用 PhotoStore.delete,把本次导入的文件删掉,然后关闭表单。这条路径是干净的。

但表单是一个 sheet,系统默认允许交互式下滑关闭。工程里没有任何一处 interactiveDismissDisabled,也没有 onDisappear 兜底。下滑关闭走的是 SwiftUI 的 dismiss,不经过 Cancel 按钮的闭包,savedPhotoNames 随视图一起销毁,文件留在磁盘上。这是第一类无主文件。

第二个是进程终止。照片已经落盘,记录还在内存里;records 的 didSet 触发 400 毫秒防抖保存,应用退到 .inactive 与 .background 时还会主动 flush()。正常退到后台是安全的。但如果进程在这之前被系统回收、被用户强行结束、或者崩溃,记录没有写进去,照片已经在盘上。这是第二类无主文件。

两类无主文件的共同点是:它们的文件名只存在于一个已经消失的数组里。

五、删除设备只清两行数组

登记册的删除只有一处调用点,列表的 onDelete。它调用 store.delete(asset),函数体是两行:

assets.removeAll { $0.id == asset.id }

records.removeAll { $0.assetID == asset.id }

设备与它的全部检验记录一起消失。照片不在其中。PhotoStore.delete 在整个工程里只有一个调用点——表单的 Cancel。删除设备不会碰到它。

结果是第三类无主文件,也是唯一一类永久无主:记录没了,引用没了,文件名再也没有任何地方可以查到,但 JPEG 还在 Documents/Photos 里,占着与真实证据相同的空间,享受相同的备份策略,却永远不会被任何界面、任何报告、任何导出再提到一次。

整册恢复也会制造同样的残留。restore 先用 installPhotos 把归档里的照片写入磁盘,再用 replaceSnapshot 整体替换内存中的设备与记录。归档之外的照片不会被删除——恢复是覆盖引用,不是重建目录。恢复一次旧备份,当前册里那些不在旧备份中的照片,就变成了无主文件。

代码里没有任何回收路径:没有启动扫描,没有引用差集,没有按年龄清理,没有容量阈值。PhotoStore 面向文件只有四个动作——save、write、load、delete,其中 load 在整个工程里零调用。

六、三种出口与零次渲染

照片离开设备的形态只有三类——备份、报告、CSV;其中备份还分导出与恢复两个方向。界面渲染的次数是零。

出口实际携带可复核性
备份归档photos: [String: Data],在 JSON 内以 base64 存放逐张校验可读,缺一张即拒绝整包
整册恢复installPhotos 逐张写盘只写不删,不清理归档之外的文件
PDF 报告Photos: N 一行文字只有数量,没有画面
CSV 台账PhotoCount 一列只有数量,没有文件名

备份的强一致值得单独说。archiveData 先求引用集合,再逐张 Data(contentsOf:);任何一张读不到,就抛 incompleteArchive,整个备份不生成。这是 fail-closed 的正确方向:宁可不出包,也不出一个引用悬空的包。代价是可用性——一张丢失的照片会阻塞整本台账的导出,而应用没有提供”跳过缺失项继续导出”的分支。

base64 的代价是体积。JSONEncoder 把 Data 编码为 base64 字符串,字节数按 4/3 膨胀。一份含六百张、每张 2 MB 照片的台账,照片部分裸字节约 1.2 GB,进入归档约 1.6 GB;而同一份台账的 register.json 只有几十到几百 KB。

界面上,照片出现在两处:选择器的 “Add photos”,以及已附加时的 “N photo(s) attached”。这两处之后,照片从屏幕上消失。历史行只有日期、类型、检验员、结论;报告只打印数量;CSV 只打印数量。PhotoStore.load 定义了却从未被调用——这不是一段被注释掉的实验代码,而是一个从未接上的读取端。

七、失败面与另一端

照片链上的失败都被命名了。编码失败是 imageEncodingFailed,目录不可用是 directoryUnavailable,写盘失败是 writeFailed,文件名非法是 invalidFileName。导入时逐条收集失败原因,最后合成一条提示,明确告诉用户可以不带这些照片保存,或者重试。

写盘失败不会回滚已经写成功的张数:savedPhotoNames 里已有的文件仍然是有效的,取消时会被删除,保存时会进入记录。部分成功优于全部丢弃,这个取舍是合理的。

作为对照,SlingGuard 的记录里没有任何照片字段。它的证据是五个布尔勾选、三个自由文本、一个结论枚举。同一家公司的两款现场工具,一款把照片当作可选证据,另一款在数据模型里没有这个概念。这个差异不是疏忽,而是两类作业的取证习惯不同;但它也意味着,“照片证据”这条链的能力边界只存在于 TagWorks 一侧。

八、字节账

设每条记录平均附 p 张照片、单张平均 b MB、记录数为 R,则照片目录的裸字节约为:

S≈p⋅R⋅bS \approx p \cdot R \cdot b

按 p 等于 4、b 等于 2 MB 计:

设备数每台记录数记录总数照片上限裸字节归档后约
2561506001.2 GB1.6 GB
100660024004.8 GB6.4 GB
10012120048009.6 GB12.8 GB

表里的每个数字都是上限,不是典型值;实际张数取决于现场习惯。它要说明的是一件更朴素的事:照片是这本台账里唯一一个没有预算、没有上限、也没有回收机制的字节来源。设备条数有免费层限制,记录条数没有,照片张数只有”每条四张”。

峰值内存是另一笔账。导入逐张进行,位图在每次循环结束时释放,所以峰值约等于一张原图的解码位图加一份 JPEG 字节。一千二百万像素约 48.8 MB,四千八百万像素约 195.1 MB。这个峰值与台账规模无关,只与用户选中的那张照片有关。

九、五条工程线

  1. 采集即降采样。在 PhotoStore.save 之前把长边限制到 2048 像素,再以 0.8 质量编码。证据的可读性由内容决定,不由像素数决定;铭牌上的序列号在 2048 长边下仍然清晰,而字节数会下降一个量级。

  2. 暂存与转正分离。导入时写入临时目录,记录保存成功后再移动到 Documents/Photos。移动是同一卷内的改名,成本远低于复制;失败时整个暂存目录可以直接丢弃。这一条同时解决下滑关闭与进程终止两类无主文件。

  3. 引用差集回收。启动时列举 Photos 目录,减去 records.flatMap(\.photoFiles) 与资产引用的并集,剩下的就是无主文件。删除前给一份清单,列出文件名、字节数与生成时间;不要静默删除,也不要在没有清单的情况下永久保留。

  4. 删除设备时给出后果。当前的两行级联没有确认,也没有告诉用户有多少条记录、多少张照片会一起消失。至少应在删除前显示将删除的记录数与照片数,并在删除后保留一份可导出的墓碑记录。

  5. 报告接上缩略图。报告现在只打印 Photos: N,读者无法判断照片内容是否与结论一致。把每张照片缩到 320 长边、按 72 dpi 嵌入报告页,一页 A4 可以放下四张,体积增加有限,但证据链从”有 N 张”变成”这 N 张是什么”。

十、能力边界

  • 四张是记录级上限,不是册级预算。
  • 照片写入先于记录提交,中间态没有事务保护。
  • 文件名是唯一纽带,重命名即失联。
  • 删除设备不删除照片,应用内也没有回收入口。
  • 备份 fail-closed,一张缺失即阻塞整册导出。
  • 恢复只覆盖引用,不重建目录,归档之外的照片会成为无主文件。
  • 报告与 CSV 只携带数量,不携带内容或校验和。
  • 全部行为在本机完成,没有服务端核对,也没有跨设备的照片账本。

十一、结语

一条照片链是否可靠,不取决于它能不能拍、能不能存,而取决于三件事:写进去的字节有没有人认领,认领的关系有没有人维护,维护的结果有没有人看得见。TagWorks 的这条链在前两件事上做了明确的取舍,在第三件事上是空的。

接上读取端,不需要联网,也不需要新的存储格式。需要的只是承认:一张证据照片的价值不在它被保存的那一刻,而在三个月后有人愿意打开它、并且能打开它的那一刻。

参考资料

  • [1] Apple. PhotoKit 相册选择框架. photokit
  • [2] Apple. UIImage 图像解码与 JPEG 编码. uiimage
  • [3] Apple. Data 的原子写入选项. data
  • [4] Apple. FileManager 沙盒目录操作. filemanager
  • [5] IETF. RFC 4648 Base16/Base32/Base64 编码. rfc4648
  • [6] Standards Australia / Standards New Zealand. AS/NZS 3760:2022 在用电气设备检验与测试. as-nzs-3760
Share:
Back to Blog