产品目标
我们的产品是一个“流程陪伴助手”,核心目标是让用户能够摆脱拖延,专注完成任务。为了实现这个目标,一个最基础的功能是:用户必须能够创建、编辑并可靠地保存他们自己的任务流程(包括名称、时长和脚本)。 这是所有后续功能(如 AI 生成)的基石。
第一幕:初遇难题 —— “新任务为何凭空消失?”
1. 问题发现
在实现了创建新任务的界面后,我们发现了一个最基础的问题:点击“+”号,进入新任务页面,填写内容,点击保存后,回到主列表,新任务并没有出现。 它就像从未存在过一样,用户的努力全部白费。
2. 探索与尝试 (The Binding Trap)
- 初步诊断:AI 助手 (Cursor) 首先怀疑是数据传递出了问题。在 SwiftUI 中,当一个视图(
TaskSelectionView
)弹出一个子视图(TaskEditorView
)进行编辑时,通常使用@Binding
来传递数据。
- 初次修复:助手的第一个方案是调整
@Binding
的创建方式,确保TaskEditorView
能拿到最新的数据。同时,在TaskSelectionView
的.onDismiss
(当弹窗关闭时)事件中,增加taskStore.add()
的逻辑,试图将新任务加入到数据仓库中。
- 结果:问题依旧。这说明问题比想象的更深,不仅仅是数据绑定,还涉及到**持久化(Persistence)**的环节。
3. 根本原因与解决方案
- 问题根源:核心问题在于,新创建的任务对象仅仅存在于内存中,并且没有一个明确的机制告诉 App:“嘿,这是一个需要被永久保存到手机磁盘上的新东西!”。同时,保存逻辑中存在一些不严谨的判断,比如“任务名为空就不保存”,这导致一个空的新任务永远无法被创建。
- 最终解决方案 (第一幕):
- 完善
TaskStore
:确保TaskStore
具备save()
和load()
方法,并且能正确地将自定义任务通过JSONEncoder
编码后写入本地文件。 - 强化保存逻辑:在
TaskSelectionView
的saveChanges()
函数中,明确区分“更新已有任务”和“添加新任务”的逻辑,并确保无论任务名是否为空,只要是新任务,就调用taskStore.add()
。 - 赋予初始值:在创建新任务时,就给它一个默认的
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. 关闭弹窗。这两个操作引发了一场混乱的“竞赛”: Task
对象内部的数据在变化(比如versions
数组被修改)。- SwiftUI 视图因为数据变化,准备刷新界面。
- 与此同时,视图关闭的指令也已发出。
- 当关闭动画结束后,迟到的
.onDismiss
信使才去执行saveChanges()
,但此时它拿到的任务数据,很可能是在上述混乱过程中一个尚未更新的“旧版本”。 - “闪现”的原因是:当
saveChanges()
最终被执行并成功把任务加入taskStore
时,这个状态变化又通知了主列表,主列表刷新,而此时弹窗逻辑还没完全清理干净,导致 SwiftUI 错误地以为应该再弹出一个编辑窗口,于是就“闪现”了。
- 为何只改标题能成功?:
TextField
对String
的双向绑定走了一条更简单、直接的状态更新路径,碰巧避开了这个复杂的时序陷阱。
4. 解决方案 (第二幕):抛弃信使,掌握主动权
- 核心思想:不能再依赖
.onDismiss
这种被动的、时机不确定的方法。必须让“保存”按钮自己掌握保存和关闭的绝对控制权,并按严格的顺序执行。
- 重构步骤:
- 移除
.onDismiss
:彻底删除TaskSelectionView
中的.onDismiss
修饰符。 - 传递“指令” (Closure):
TaskSelectionView
在弹出TaskEditorView
时,传递一个saveAction
闭包过去。这个闭包里封装了“如何保存任务”的完整逻辑。 - “保存”按钮全权负责:
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
才是那个在数据变化时大声喊出“嘿!我变了!大家快看!”的“信使”。
- 致命漏洞:我们的
Task
和TaskVersion
类中的name
,description
,duration
,scripts
等属性,都没有被@Published
标记。
- 真相大白:当你修改时长或脚本时,
TaskVersion
对象内部的数据确实变了,但因为它保持“沉默”(没有@Published
),SwiftUI 对此一无所知。因此,当“保存”按钮触发saveAction
时,它传递出去的task
对象,其versions
属性依然是旧的、未被修改过的版本!
3. 最终的、真正的解决方案
- 直击要害:在
Models.swift
文件中,为Task
和TaskVersion
类中所有需要被视图观察和绑定的属性,全部加上@Published
标记。
- 升级模型:将
TaskVersion
从struct
改为class
,并使其遵循ObservableObject
协议。这确保了在TaskEditorView
中编辑editingVersion
时,修改的是与task.versions[0]
指向的同一个对象实例,而不是一个副本。
总结:这次调试教给我们的核心经验
- 警惕
.onDismiss
:不要在.onDismiss
中执行关键的、依赖于子视图状态的保存逻辑。它时机太晚,容易引发竞态条件。
- 掌握主动权:对于“保存并关闭”这类操作,最佳实践是让按钮掌握主动权,通过闭包回调(
action
)来命令父视图执行操作,并确保严格的执行顺序。
- 理解
ObservableObject
的本质:ObservableObject
+@Published
是一个组合。前者是身份,后者是“发声器”。没有@Published
,你的对象就是个“沉默的观察对象”,其内部变化无法驱动UI更新。
struct
vs.class
in SwiftUI:在需要跨视图共享和修改同一个数据实例的场景下(比如我们的Task
和TaskVersion
),使用class
(并遵循ObservableObject
) 通常比struct
(值类型/副本)更合适。
- 相信“诡异的线索”:“只有改标题才能保存”这种看似不合逻辑的现象,恰恰是定位深层次问题的最宝贵线索。它指引我们排除了表面原因,最终找到了数据流和时序这个根本问题。