· engineering· 约 21 分钟精读

十七个字段,一次下滑:现场表单在提交之前住在哪里

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

SlingGuard 的检验表单有 17 个 @State,TagWorks 的检测表单有 16 个。它们由 sheet 呈现,取消、保存与系统下滑是三条退出路径;只有保存会写盘,下滑不经过任何自定义代码。本文按源码核对表单状态的持有者、被预填的字段与一份并不存在的草稿。

十七个字段,一次下滑:现场表单在提交之前住在哪里

港口的工具间里,检验员把最后一条备注敲完,拇指顺着屏幕下缘往下一带。表单向下一沉,回到资产详情页。没有提示,没有撤销,也没有一条写着「草稿」的记录。刚才那 17 个字段——检验类型、姓名、资质编号、五个勾选、结论、备注、方案引用、缺陷描述、处置动作、期限开关、期限日期、检验日期、确认位——在视图消失的一瞬间一起没了。

这不是某个按钮写错。InspectionEntryView.swift 里的 Cancel 只做一件事:调用环境里的 dismiss()。Save 只做一件事:在 canSave 为真时把 prospectiveRecord 交给 store.addInspectionRecord,然后同样 dismiss()。第三条路径更短:系统自带的下滑手势,不经过应用里的任何一行代码。

三条路径通向三个结果。本文只谈提交之前的这一段:表单的字段住在哪里,什么时候被释放,以及为什么「保存之后」那套原子写、防抖、备份与恢复的机制,一点也帮不到「保存之前」。

一次录入,三条退出路径表单的 17 个字段住在视图身份里;只有保存会写进登记册SlingGuard 检验表单17 个 @State预填:姓名、资质不预填:读数、清单、备注视图销毁 → 存储释放取消dismiss()无确认,文本不处理保存canSave闸门通过后写入登记册下滑swipe-to-dismiss不经过任何自定义代码视图释放@State存储随身份结束登记册register.json提交之后才存在照片文件仅 TagWorks无记录引用草稿文件:0 个进程终止与下滑,结果相同提交之前的可靠性,不由提交之后的原子写负责。

左右滑动查看完整图表

一、三种退出,只有一种写盘

把两个工程的录入表单摊开,退出路径可以列成一张很短的账。

退出路径经过的代码检查、清理与写盘
取消dismiss()不检查未保存;TagWorks 逐个删除照片;不写盘
保存canSave 通过后追加记录,再防抖写盘检查记录完整性;照片不适用;写盘
下滑系统手势不检查、不清理、不写盘
进程终止无应用侧回调不检查、不清理、不写盘

两个工程里,interactiveDismissDisabled 的出现次数都是 0,onDisappear 是 0,SceneStorage 也是 0。三个 0 放在一起,说明下滑这条路没有任何应用侧的插口:它既不询问,也不保存,也不清理。取消按钮也一样:两端都不看 canSave,也不看当前有没有输入;只要视图还在,它就是可点的。

Apple 对 interactiveDismissDisabled(_:) 的说明很直接:这个修饰符用来有条件地阻止 sheet、popover 一类的交互式关闭,但它对程序化关闭无效——调用 dismiss() 或改动控制呈现的绑定,仍然会关掉。[2] 换句话说,即使加上它,取消按钮仍然需要自己的确认;它挡的是手势,不是所有出口。界面指南对模态的要求是同一个方向:模态要有明确的退出方式,并且只在确实需要打断当前任务时使用。[6] 不过,「提供退出方式」与「退出时保留输入」是两件事;前者已经有了,后者还没有。

保存路径的检查条件在两端并不相同。TagWorks 的 canSave 要求技术员姓名非空、负载电流若填写则必须是非负有限值;检测与复测还要目视结果与最终结论都不为空,手动 PASS 不能越过未通过或未测量的读数,记录缺陷时必须有备注。SlingGuard 的 canSave 只有两个条件:confirmsEntry 为真,且 prospectiveRecord 的证据完整性判定为真。两套条件都在回答「这条记录能不能成为证据」,没有一套在回答「这个人是不是要离开」。

二、17 个字段住在哪里

两个应用的主要录入界面,字段都放在视图自己的 @State 里;下表括号内是该视图的 @State 数量。

表单字段与闸门
SlingGuard
检验(17)
检验类型、姓名、资质、五个勾选、结论、备注、方案引用、缺陷、处置、期限、确认位、日期;confirmsEntry 且证据完整
SlingGuard
批量检验(17)
同一套共享字段,投影到 N 件资产;canApply
TagWorks
检测(16)
三个读数、技术员、备注、仪器四项、目视结果、照片、结论覆盖;canSave
TagWorks
新增资产(8)
名称、标识、场所、类别、环境、风险;名称非空
SlingGuard
新增资产(12)
编号、名称、类型、额定载荷、长度、地点、制造商、周期等;isValid

@State 不是全局变量,也不是文件。Apple 在 WWDC21 的「Demystify SwiftUI」里把它讲得很清楚:State 与 StateObject 是绑定在视图身份上的持久存储;身份开始时分配,身份结束或视图被移除时释放。[1] State 文档也把这份存储描述为视图生命周期内的持久存储,视图被移除时随之释放。[7] 视图结构体本身是短命的,每次 body 求值都会创建新值又丢弃;真正跨更新存活的是那份按身份索引的存储。

这条定义放到 sheet 上,结论就很硬:sheet 被关闭,内容视图从树上移除,身份结束,17 份存储一起释放。它不是内存泄漏,也不是异常路径,而是框架对「视图状态」的默认语义。@State 的寿命等于视图在界面上的寿命,不等于应用进程的寿命,更不等于文件的寿命。

也正因为如此,@State 适合承载「正在编辑的这一份输入」,不适合承载「必须留下的那一份证据」。前者本来就应该随视图消失;后者需要另一个容器。把两者放在同一个属性包装器上,等于把「临时」和「必须」交给了同一条生命周期。

三、会回来的与不会回来的

两个应用都在 onAppear 里做了一次预填。TagWorks 从 UserDefaults 取五个键:inspectorName、testerModel、testerSerial、procedureReference,以及存在时的 calibrationDate。SlingGuard 从 store.profile 取两个字段:inspectorName 与 certificationNumber。

会回来(预填)不会回来(本次输入)
TagWorks:技术员、仪器型号、仪器序列号、程序或证书引用、校准日期接地、绝缘、负载三个读数,目视结果,备注,照片,结论覆盖
SlingGuard:检验员姓名、资质编号检验类型,五个勾选,结论,备注,方案引用,缺陷描述,处置动作,期限,确认位

预填解决的是「每次都一样」的部分:谁在做、用什么仪器、按哪份程序。它解决不了「这一次才有」的部分:读数、观察、缺陷、处置。后者恰好是现场最花时间、最难重建的部分,也恰好是下滑时丢得最干净的部分。

TagWorks 的预填甚至会在保存时反向写回:save() 把仪器型号、序列号、程序引用写回 UserDefaults,把校准日期在开关打开时写回。也就是说,仪器信息越用越全,而一条被下滑取消的检测记录不会留下任何读数。这个不对称不是设计者的疏忽,而是两类数据被放在了两个不同的存储层:一类是偏好,一类是记录;前者有全局容器,后者只有视图。

四、保存之后才存在

把保存路径摊开,会看到一套相当完整的写盘纪律。SlingGuard 的 addInspectionRecord 先把记录插到 records 的最前面,更新资产的最近检验日与隔离或报废标记,然后调用 scheduleSave();scheduleSave() 取消上一个任务,等 300 毫秒,再调用 save()。save() 用 JSONEncoder 编码整个 Snapshot,再以 .atomic 写进 rigging_register.json。TagWorks 的节奏是 400 毫秒,写的是 register.json,并且在失败时把 persistenceIssue 交给应用顶层的告警条。

这些机制保护的是「已经进入 records 的那条记录」。在按下 Save 之前,records 里没有它,assets 里的 lastInspectionDate 也没有变,磁盘上更没有任何字节。防抖窗口、原子写、备份档案、恢复闸门,全部在这条线之后。它们很重要,但它们不覆盖这条线之前。

TagWorks 在 scenePhase 变为 .inactive 或 .background 时会调用 store.flush(),把当前快照立刻写盘。[4] 这看起来像一道保险,但它保的仍然是 assets 与 records,不是表单里的 17 个 @State。SlingGuard 连 scenePhase 都没有。两个应用在进入后台时都不会问一句「表单里还有没有没提交的内容」。

进程终止是同一件事的更硬版本。iOS 可以把挂起的应用清出内存;用户也可以从应用切换器里向上划掉它。@State 不跨进程,SceneStorage 又没有被使用;即使使用了,Apple 也明确说明系统不保证持久化的时机与频率,而且场景被显式销毁时数据同样消失,不适合承载敏感或不可恢复的内容。[3] 在这条路径上,17 个字段不是「丢失」,而是从来没有被交给任何持久层。

五、唯一离开视图的东西

TagWorks 的照片是一个例外,也是一面镜子。importPhotos 在表单还开着的时候就把图片解码、按 0.8 重编码、写进 Documents/Photos,文件名是 UUID;savedPhotoNames 只是把这些名字记在 @State 里。也就是说,照片在按下 Save 之前已经落盘了。

取消按钮知道这件事:它遍历 savedPhotoNames,逐个调用 PhotoStore.delete,再 dismiss()。下滑不知道:它不经过 Cancel 的闭包,也不经过任何清理代码。于是同一次误操作会留下两种结果:17 个字段归零,一张或几张没有记录引用的照片留在目录里。

这个对照很能说明问题。应用并不是没有能力在编辑期间写盘——照片就是证据;它只是没有把同样的写盘路径给字段。照片大、字段小;照片是附件、字段是内容。结果恰好相反:大的留下了,小的丢了。

六、批量表单把问题放大

BatchInspectionView 有同样的 17 个 @State,但它服务的是 selectedAssets 里的 N 件资产。选择集住在 RegisterView 的 selectedAssetIds 里,属于父视图;共享的检验类型、五个勾选、结论、备注、缺陷、处置、期限与确认位住在批量表单自己身上。下滑关闭批量表单,选择集还在,输入全没了。

批量的语义让损失更贵:一次输入本来要被投影到 N 件资产上,prospectiveRecord(for:) 是纯函数,逐件生成记录,applyBatchInspection() 在 canApply 为真时循环写入。输入丢失后,检验员面对的是「N 件都要重填」;而在港口或高空作业面,重新组织一次批量检验的成本远高于一次单件录入。

这也是 canApply 与 canSave 的另一个共同点:它们都是提交闸门,不是退出闸门。闸门只决定「能不能写」,不决定「离开时要不要提醒」。一个禁用的 Save 按钮加上一个可用的下滑手势,构成了最容易丢数据的那种界面。

七、为什么不能「在 onDisappear 里存进登记册」

最直觉的补法是:在 onDisappear 里把当前字段拼成一条记录,直接写进 records。这条路看起来省事,实际会破坏三件事。

第一,记录会变得不完整。canSave 之所以存在,是因为一条检验记录要进入状态计算、报告分母与下次到期日;一条缺确认位、缺结论、缺缺陷描述的记录一旦进了 records,就会以「已记录」的身份参与这些计算。它不再是草稿,而是一条有副作用的半成品。

第二,取消会变成谎言。Cancel 的语义是「不保存」,如果 onDisappear 无条件保存,取消和保存就没有区别。用户点取消,系统却留下一条记录;这比丢失更难解释。

第三,onDisappear 不是可靠的落盘时机。它在视图消失时调用,但进程被终止时不一定有机会调用;即使调用,它也无法在用户离开之前完成一次完整的编码与写盘。把一个必须成功的动作放在一个不保证执行的钩子里,只是把丢失窗口换了个位置。

正确的对象不是「半条记录」,而是一份「草稿」:它有自己的文件、自己的版本、自己的清理规则;它不进 records,不进状态计算,不进报告,不进 CSV;它在保存成功或明确丢弃时被删除。记录与草稿的边界,应该由存储位置划开,而不是由字段完整度划开。

ISO 15489-1 把记录放在业务活动与证据的语境里,强调真实性、可靠性、完整性与可用性。[5] 一份草稿不具备这四项:它没有提交时刻,没有确认人,也没有稳定的内容。它不该被当成记录保存;但也不该因为「还不是记录」就被当成不存在。

八、如果要补上这条缝

最小的一套改动可以拆成五步,全部落在端侧,不需要网络,也不需要服务端。

第一,把「有没有未保存输入」变成一个可计算的值。不要在每个 onChange 里手写一个 hasUnsavedInput = true;用一个与表单初值同型的 Draft 结构,遵循 Equatable,把当前字段与初值比较。这样「打开后没动过」不会被误判为有草稿,「改回原值」也能正确归零。

第二,给 sheet 加两层退出守卫。interactiveDismissDisabled(hasUnsavedInput) 挡住下滑;Cancel 在 hasUnsavedInput 为真时弹 confirmationDialog,提供「继续编辑」「丢弃并关闭」两个动作。前者挡手势,后者挡按钮,两者缺一不可。[2]

第三,把草稿写到登记册之外。路径可以是 Documents/Drafts/form-id.json,内容包含表单类型、资产 id、字段快照、创建与更新时间、应用版本;写入用 .atomic,频率用 500 毫秒防抖。草稿文件与 register.json 分开,任何报告、CSV、状态计算都不读它。

第四,重新进入时提供恢复,而不是自动套用。打开表单时先查同一资产、同一表单类型的草稿;有则显示「发现未提交的草稿(时间)」,让操作者选择恢复或丢弃。自动套用会把上一班的输入悄悄带进这一班,尤其是在共享设备上。

第五,保存成功或明确丢弃时删除草稿,并加一条按时间的清理规则。草稿是临时产物,不应该无限累积;保留期可以按天计,过期删除时不需要通知,因为它的定义就是「可丢弃」。

这五步不改变记录模型,也不改变 canSave 的语义。它们只是在「视图状态」与「登记册」之间加了一层「草稿」,让三者各自承担自己的寿命。

九、能力边界

  • 草稿不是记录。恢复一份草稿,不等于确认它的内容;提交仍然要过 canSave,仍然要有人按下 Save。
  • @State 的释放不是数据损坏。register.json 的原子写、防抖与恢复机制没有受影响;本文说的是提交之前的另一条时间线。
  • 防抖写入本身有窗口。最后一次按键到草稿落盘之间仍有一个几百毫秒的间隙;进程恰好在这个间隙被杀,损失的是最后几个字符,而不是整张表单。
  • 共享设备上的草稿可能包含上一个操作者的备注。草稿的读取应该与操作者归属一起考虑,不能默认「同一台设备就是同一个人」。
  • 本文列出的五步是设计建议,不是当前构建已经具备的功能。当前构建里,interactiveDismissDisabled、onDisappear 与 SceneStorage 的出现次数都是 0。

十、结语

离线工具的可靠性常被写成一句话:数据不出设备。这句话覆盖的是数据已经存在之后的部分:它怎么落盘、怎么备份、怎么恢复、怎么在断网时仍然可读。提交之前,数据还不存在;它住在视图身份里,与一次下滑共享同一条生命周期。

把草稿当成一种独立的、可丢弃的、永不进入登记册的产物,表单才算有了两层:一层给人改,一层给证据留。前一层可以失败,可以重来;后一层必须完整,必须可追溯。两层分开,现场的那一次下滑才只是一次下滑,而不是一次没有记录的检验。

参考资料

  • [1] Apple. Demystify SwiftUI(WWDC21). 视频
  • [2] Apple. interactiveDismissDisabled(_:). 文档
  • [3] Apple. SceneStorage. 文档
  • [4] Apple. Managing your app’s life cycle. 文档
  • [5] ISO. ISO 15489-1:2016 Records management — Concepts and principles. 标准
  • [6] Apple. Human Interface Guidelines — Modality. 指南
  • [7] Apple. State. 文档
Share:
Back to Blog