Mabot开发经验:Swift中警惕.onDismiss的使用

状态
Tags
Tech_Tag
Created
Jun 29, 2025 11:27 PM

产品目标

我们的产品是一个“流程陪伴助手”,核心目标是让用户能够摆脱拖延,专注完成任务。为了实现这个目标,一个最基础的功能是:用户必须能够创建、编辑并可靠地保存他们自己的任务流程(包括名称、时长和脚本)。 这是所有后续功能(如 AI 生成)的基石。

第一幕:初遇难题 —— “新任务为何凭空消失?”

1. 问题发现

在实现了创建新任务的界面后,我们发现了一个最基础的问题:点击“+”号,进入新任务页面,填写内容,点击保存后,回到主列表,新任务并没有出现。 它就像从未存在过一样,用户的努力全部白费。

2. 探索与尝试 (The Binding Trap)

  • 初步诊断:AI 助手 (Cursor) 首先怀疑是数据传递出了问题。在 SwiftUI 中,当一个视图(TaskSelectionView)弹出一个子视图(TaskEditorView)进行编辑时,通常使用 @Binding 来传递数据。
  • 初次修复:助手的第一个方案是调整 @Binding 的创建方式,确保 TaskEditorView 能拿到最新的数据。同时,在 TaskSelectionView.onDismiss(当弹窗关闭时)事件中,增加 taskStore.add() 的逻辑,试图将新任务加入到数据仓库中。
  • 结果:问题依旧。这说明问题比想象的更深,不仅仅是数据绑定,还涉及到**持久化(Persistence)**的环节。

3. 根本原因与解决方案

  • 问题根源:核心问题在于,新创建的任务对象仅仅存在于内存中,并且没有一个明确的机制告诉 App:“嘿,这是一个需要被永久保存到手机磁盘上的新东西!”。同时,保存逻辑中存在一些不严谨的判断,比如“任务名为空就不保存”,这导致一个空的新任务永远无法被创建。
  • 最终解决方案 (第一幕)
      1. 完善 TaskStore:确保 TaskStore 具备 save()load() 方法,并且能正确地将自定义任务通过 JSONEncoder 编码后写入本地文件。
      1. 强化保存逻辑:在 TaskSelectionViewsaveChanges() 函数中,明确区分“更新已有任务”和“添加新任务”的逻辑,并确保无论任务名是否为空,只要是新任务,就调用 taskStore.add()
      1. 赋予初始值:在创建新任务时,就给它一个默认的 TaskVersion,让它从诞生之初就是一个“完整”的任务,可以直接被播放器使用。

第二幕:诡异的“闪现” —— “为何只有改标题才能保存?”

1. 问题发现

在解决了基础的保存问题后,一个更诡异、更令人困惑的 bug 出现了:
  • 现象 A: 如果创建一个新任务,只修改“任务名称”,点击保存,一切正常。
  • 现象 B: 如果创建新任务后,修改了“任务时长”或“脚本内容”,点击保存,任务就无法被创建。
  • 关键线索: 在现象 B 发生时,点击保存后,屏幕会**“闪现”**一下“编辑任务”的弹窗,然后才彻底关闭。

2. 探索与尝试 (The State & Timing Trap)

  • 初次诊断:AI 助手认为这可能是一个状态判断问题,即 App 无法正确区分“新任务”和“编辑任务”。它尝试引入一个 @State var isNewTask: Bool 状态变量来手动标记。
  • 你的反驳(非常关键!):你敏锐地指出,用一个临时状态变量不是一个健壮的方案,应该从产品和技术架构的长期角度出发,依赖“唯一事实来源 (Single Source of Truth)”。
  • 二次尝试:助手接受了你的建议,重构了代码。让 TaskEditorView 依赖注入的 @EnvironmentObject var taskStore,通过检查当前任务的 id 是否存在于 taskStore 中,来动态判断它是否为“新任务”。这是一个巨大的进步。
  • 结果:问题依然存在!“闪现”和“只有改标题才能保存”的现象没有消失。这证明问题比状态判断更深,进入了 SwiftUI 的“深水区”。

3. 根本原因:竞态条件 (Race Condition) 与不可靠的信使 (.onDismiss)

这是整个调试过程中最核心、最深刻的一课。
  • 问题的根源:我们依赖了 .onDismiss 这个方法来保存数据。.onDismiss 就像一个“迟到的信使”,它只有在弹窗完全关闭动画结束之后才会被调用。而点击“保存”按钮同时触发了两个操作:1. 更新 Task 对象内部的数据。2. 关闭弹窗。这两个操作引发了一场混乱的“竞赛”:
      1. Task 对象内部的数据在变化(比如 versions 数组被修改)。
      1. SwiftUI 视图因为数据变化,准备刷新界面。
      1. 与此同时,视图关闭的指令也已发出。
      1. 当关闭动画结束后,迟到的 .onDismiss 信使才去执行 saveChanges(),但此时它拿到的任务数据,很可能是在上述混乱过程中一个尚未更新的“旧版本”。
      1. “闪现”的原因是:当 saveChanges() 最终被执行并成功把任务加入 taskStore 时,这个状态变化又通知了主列表,主列表刷新,而此时弹窗逻辑还没完全清理干净,导致 SwiftUI 错误地以为应该再弹出一个编辑窗口,于是就“闪现”了。
  • 为何只改标题能成功?TextFieldString 的双向绑定走了一条更简单、直接的状态更新路径,碰巧避开了这个复杂的时序陷阱。

4. 解决方案 (第二幕):抛弃信使,掌握主动权

  • 核心思想:不能再依赖 .onDismiss 这种被动的、时机不确定的方法。必须让“保存”按钮自己掌握保存和关闭的绝对控制权,并按严格的顺序执行。
  • 重构步骤
      1. 移除 .onDismiss:彻底删除 TaskSelectionView 中的 .onDismiss 修饰符。
      1. 传递“指令” (Closure)TaskSelectionView 在弹出 TaskEditorView 时,传递一个 saveAction 闭包过去。这个闭包里封装了“如何保存任务”的完整逻辑。
      1. “保存”按钮全权负责TaskEditorView 的保存按钮现在按严格顺序执行: a. 更新自身绑定的 task 对象。 b. 调用父视图传来的 saveAction,命令父视图“立刻保存!”。 c. 确认指令发出后,再调用 presentationMode.wrappedValue.dismiss() 关闭自己。

第三幕:最终的真相 —— “看不见”的数据更新

1. 问题发现

令人沮丧的是,在实施了最健壮的“主动权”方案后,问题依然存在!这几乎推翻了所有假设,证明还有一个更底层的“幽灵”在作祟。

2. 根本原因:数据模型的“沉默” (ObservableObject@Published)

这是最后一块拼图,也是 SwiftUI 数据流的核心。
  • 问题的根源:我们的 TaskVersion 是一个 struct(值类型)。当你把它传递给 TaskEditorView@State 变量 editingVersion 时,你实际上是在操作一个副本。虽然我们后来改成了 class,但还有一个更关键的问题:
  • ObservableObject 的契约:一个 class 遵循了 ObservableObject 协议,并不意味着它的所有属性变化都会被自动广播。你必须用 @Published 这个属性包装器来标记那些你希望“被观察”的属性。@Published 才是那个在数据变化时大声喊出“嘿!我变了!大家快看!”的“信使”。
  • 致命漏洞:我们的 TaskTaskVersion 类中的 name, description, duration, scripts 等属性,都没有被 @Published 标记。
  • 真相大白:当你修改时长或脚本时,TaskVersion 对象内部的数据确实变了,但因为它保持“沉默”(没有 @Published),SwiftUI 对此一无所知。因此,当“保存”按钮触发 saveAction 时,它传递出去的 task 对象,其 versions 属性依然是旧的、未被修改过的版本!

3. 最终的、真正的解决方案

  • 直击要害:在 Models.swift 文件中,为 TaskTaskVersion 类中所有需要被视图观察和绑定的属性,全部加上 @Published 标记。
  • 升级模型:将 TaskVersionstruct 改为 class,并使其遵循 ObservableObject 协议。这确保了在 TaskEditorView 中编辑 editingVersion 时,修改的是与 task.versions[0] 指向的同一个对象实例,而不是一个副本。

总结:这次调试教给我们的核心经验

  1. 警惕 .onDismiss:不要在 .onDismiss 中执行关键的、依赖于子视图状态的保存逻辑。它时机太晚,容易引发竞态条件。
  1. 掌握主动权:对于“保存并关闭”这类操作,最佳实践是让按钮掌握主动权,通过闭包回调(action)来命令父视图执行操作,并确保严格的执行顺序。
  1. 理解 ObservableObject 的本质ObservableObject + @Published 是一个组合。前者是身份,后者是“发声器”。没有 @Published,你的对象就是个“沉默的观察对象”,其内部变化无法驱动UI更新。
  1. struct vs. class in SwiftUI:在需要跨视图共享和修改同一个数据实例的场景下(比如我们的 TaskTaskVersion),使用 class (并遵循 ObservableObject) 通常比 struct(值类型/副本)更合适。
  1. 相信“诡异的线索”:“只有改标题才能保存”这种看似不合逻辑的现象,恰恰是定位深层次问题的最宝贵线索。它指引我们排除了表面原因,最终找到了数据流和时序这个根本问题。