如何避免 SwiftUI 窗口点击关闭按钮导致程序崩溃的 AI 编程难题?

摘要:问题背景 最近在开发 MacOS APP 时,遇到点击窗口(Search Window)的关闭按钮(×)会导致应用崩溃问题。我提供给 AI 实现搜索功能的提示词如下: 为应用程序新增搜索功能,具体实现要求如下: 1
问题背景 最近在开发 MacOS APP 时,遇到点击窗口(Search Window)的关闭按钮(×)会导致应用崩溃问题。我提供给 AI 实现搜索功能的提示词如下: 为应用程序新增搜索功能,具体实现要求如下: 1. 界面元素添加: - 在应用程序界面的合适位置(建议为导航栏或工具栏)添加一个视觉清晰的搜索按钮 - 设计并实现搜索按钮的交互效果,包括悬停状态和点击反馈 2. 搜索窗口实现: - 点击搜索按钮时,打开一个搜索窗口 - 搜索窗口采用垂直布局,包含以下核心元素: - 顶部搜索输入框(支持键盘回车触发搜索) - 中部搜索结果显示区域 - 可选的搜索状态提示和清空/取消按钮 3. 搜索结果区域布局: - 采用双列布局设计搜索结果窗口: - 左侧列:显示匹配的对话标题列表,包含对话标题和相关元数据 - 右侧列:显示当前选中对话中的消息列表,每项显示包含搜索关键字的消息上下文 - 实现左侧列列表项的选中状态视觉反馈,确保用户清晰了解当前选择项 4. 关键字高亮与内容展示: - 对搜索结果中的所有关键字进行醒目高亮处理(建议使用不同颜色背景或文本颜色) - 历史对话标题列表中需突出显示包含关键字的标题 - 消息列表中需展示关键字所在的上下文内容片段,确保上下文完整且关键字突出 5. 交互功能实现: - 实现点击消息列表项的交互功能: - 点击后将主界面窗口置于顶层显示 - 保持搜索窗口处于打开状态(非关闭) - 自动定位并滚动主界面至对应的聊天位置 - 高亮显示主界面中定位到的具体消息 6. 性能与用户体验要求: - 实现搜索功能的即时响应,搜索延迟不超过300ms - 添加搜索过程中的加载状态提示 - 处理无搜索结果的空状态,提供友好提示 - 确保搜索功能在不同屏幕尺寸下的响应式显示效果 7. 辅助功能: - 支持键盘导航(箭头键选择、Enter确认) - 实现搜索窗口的关闭机制(右上角关闭按钮、ESC键) - 添加搜索历史记录功能(可选) 该问题应该是 AI 在实现 5. 交互功能实现 功能时引入的。问题表现为: 错误信息: EXC_BAD_ACCESS (code=1, address=0x20) 影响范围: 每次都能稳定复现,完全阻断搜索功能的使用 表现: 崩溃,出现转圈(鼠标自旋)无响应 最终和 AI 结对编程解决了这个问题,过程也是十分坎坷 🥲,有时候 AI 也不是也容易就能发现问题、解决问题,甚至制造自身无法解决的问题。 问题分析过程 第一阶段:初期诊断 添加了全面的日志记录来追踪窗口生命周期: SearchWindowController 的窗口创建/显示日志 SearchView 的生命周期事件(onAppear/onDisappear) SearchReducer 的状态变化日志 发现: 日志显示在 windowWillClose 委托回调时,系统正在进行不安全的状态操作。 第二阶段:错误的尝试 添加了全面的日志记录来追踪窗口生命周期: SearchWindowController 的窗口创建/显示日志 SearchView 的生命周期事件(onAppear/onDisappear) SearchReducer 的状态变化日志 发现: 日志显示在 windowWillClose 委托回调时,系统正在进行不安全的状态操作。 第二阶段:错误的尝试 尝试了多个失败的方案: 添加 NSWindowController 包装 - 引入强引用,导致更复杂的引用计数问题 在 windowWillClose 中进行异步清理 - 导致界面冻结(转圈) 添加 windowClosingInProgress 标志 - 仍然导致死锁 从 SearchView 发送 reducer action - NSWindowDelegate 回调与 SwiftUI 状态更新产生竞争条件 根本发现: 问题不是单一的逻辑错误,而是架构性冲突 - 多个层级都在尝试管理窗口的生命周期。 第三阶段:架构简化 逐步移除自定义逻辑: 移除 NSWindowDelegate 委托 - 不再监听 windowWillClose 事件 移除 searchWindowPresentedChanged 状态管理 - 简化 reducer 移除 Escape 键窗口关闭逻辑 - 不再程序化关闭窗口 改用临时 searchStore - 每个窗口实例都有独立的 store 关键洞察: 让用户直接使用系统标准的关闭按钮,不要通过代码关闭窗口。 第四阶段:发现隐藏的冲突 发现 WindowSizeManager 在为搜索窗口设置自定义委托: WindowSizeManager 在监听所有新窗口 给搜索窗口设置了 WindowMinimumSizeDelegate 多个委托对象导致事件处理冲突 解决: 在 shouldConfigureWindow 中添加检查排除搜索窗口。 第五阶段:最终架构重构 完全改造 SearchWindowController 的核心设计。 根本原因分析 最终确定的根本原因是多层次的窗口管理冲突: ┌─────────────────────────────────────────────────┐ │ SearchWindowController (NSObject) │ │ - 手动创建 NSWindow │ │ - 尝试跟踪窗口生命周期 │ └─────────────────────┬───────────────────────────┘ │ ┌─────▼──────────────┐ │ NSWindow │ │ ├─ windowWillClose │◄─── NSWindowDelegate (多个!) │ └─ ...events │ └────────────────────┘ │ ┌───────────┼───────────┐ │ │ │ ┌──────▼──┐ ┌──────▼──┐ ┌────▼──────────┐ │SearchView│ │Reducer │ │WindowSize │ │ │ │Handler │ │ManagerDelegate│ └──────────┘ └─────────┘ └─────────────┘ 核心问题: NSWindowDelegate 的 windowWillClose 在系统线程中执行 多个委托对象都试图处理相同的事件 SearchView 和 SearchReducer 试图在 delegate 回调时更新 SwiftUI 状态 WindowSizeManager 的委托与搜索窗口委托产生冲突 NSWindow 被 weak reference 持有,可能导致提前释放或引用失效 解决方案 方案核心:遵循 Cocoa 标准窗口管理模式 之前的错误设计: // ❌ 错误:手动管理 NSWindow,引入复杂的生命周期处理 @MainActor final class SearchWindowController: NSObject { private weak var window: NSWindow? func showSearchWindow<Content: View>(content: Content) { let hostingView = NSHostingView(rootView: content) let newWindow = NSWindow(...) self.window = newWindow // weak reference newWindow.makeKeyAndOrderFront(nil) } } 正确的设计: // ✅ 正确:继承 NSWindowController,让系统管理窗口生命周期 @MainActor final class SearchWindowController: NSWindowController { init<Content: View>(content: Content) { let hostingView = NSHostingView(rootView: content) let window = NSWindow(...) // 在调用 super.init 之前创建窗口 super.init(window: window) // 窗口由 NSWindowController 管理,自动处理释放 window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } 关键改进: 使用 NSWindowController 而不是 NSObject - Cocoa 的标准实践 在初始化时设置窗口 - 而不是在方法中 强引用窗口 - 让 NSWindowController 自动管理生命周期 移除委托处理 - NSWindowController 自动处理标准事件 简化 ContentView - 只需创建 controller,不需要管理窗口 配套修改 ContentView.swift // ❌ 之前 @State private var searchWindowController = SearchWindowController() private func showSearchWindow() { searchWindowController.showSearchWindow(content: searchView) } // ✅ 之后 @State private var searchWindowController: SearchWindowController? private func showSearchWindow() { let tempSearchStore = StoreOf<SearchReducer>(...) let searchView = SearchView(store: tempSearchStore) // 创建 controller,自动显示窗口 searchWindowController = SearchWindowController(content: searchView) } WindowSizeManager.swift // ✅ 添加搜索窗口的排除检查 static func shouldConfigureWindow(_ window: NSWindow) -> Bool { let title = window.title if title.contains("搜索对话") || title.lowercased().contains("search") { return false } // ... 其他检查 } SearchView.swift // ❌ 移除的代码 case .escape: NSApplication.shared.keyWindow?.close() // 不安全的窗口操作 return true // ✅ 结果 // 移除了 escape 键处理,只保留标准的关闭按钮 总结关键改进点 方面 改进前 改进后 窗口管理 NSObject + 手动 NSWindow NSWindowController 生命周期 多个委托处理,容易冲突 系统自动管理 窗口引用 weak reference(风险) strong reference(安全) 关闭方式 程序化调用 close()(不安全) 用户点击系统按钮(标准) 状态冲突 SearchView + Reducer + WindowManager 仅 SearchView(简洁) 初始化 showSearchWindow() 方法 init(content:) 初始化器 实现细节 NSWindowController 的核心优势 // NSWindowController 提供的自动处理: // - 窗口的保存/恢复(Frame Autosave) // - 窗口的 showWindow(_:) 方法 // - 标准的关闭流程(windowShouldClose) // - 内存管理和释放 完全移除的有问题代码 NSWindowDelegate 的 windowWillClose 回调 searchWindowPresentedChanged action KeyNavigation.escape case NSApplication.shared.keyWindow?.close() 调用 搜索窗口的 weak reference 跟踪 WindowSizeManager 对搜索窗口的干扰 新的流程 用户点击关闭按钮 ↓ NSWindowController 处理标准关闭事件 ↓ SearchWindowController 实例释放 ↓ SearchView 销毁 ↓ tempSearchStore 销毁(由于超出作用域) ↓ 干净的内存清理,无副作用 测试验证 编译成功后,应验证以下场景: ✅ 点击搜索窗口的关闭按钮能正常关闭 ✅ 多次打开/关闭搜索窗口无崩溃 ✅ Escape 键无特殊作用(已移除) ✅ 其他窗口的 minimum size 限制仍然生效 ✅ 内存正确释放(无泄漏) 根本性学习 Cocoa 开发的核心原则: 不要与框架对抗,要融入框架。NSWindowController 已经为你解决了窗口管理的所有问题,直接使用它的标准 API,避免自己实现委托和生命周期管理。 关键教训 尽量使用高级 API - NSWindowController > NSWindow 直接操作 避免多层委托冲突 - 一个窗口应该只有一个主要责任人 让系统处理生命周期 - 不要程序化 close(),让用户点击标准按钮 完全遵循模式 - 不要半途混合新旧 API 总结 通过将 SearchWindowController 从 NSObject 改造为 NSWindowController,并完全移除所有自定义的窗口生命周期处理,我们解决了 EXC_BAD_ACCESS 崩溃问题。这次修复示范了在 Cocoa 开发中,遵循框架设计模式比自己实现更可靠和安全。最终代码更简洁、更安全,所有的复杂性都交由系统处理。 我认为这个问题并不复杂,如果比较了解 swift ui、appkit, 应该可以轻松解决这个问题,但是 AI 容易不断兜圈子。 为了解决这个问题,我换了多个模型尝试,始终无法定位问题。从上面的解决过程可以看出,我的兜底的解决方案是告诉 AI,这些功能我都不要了,我只要窗口能正常关闭,并让 AI 移除所有相关代码,这是最笨的办法,也是一定能找到问题的办法。定位到问题后,可以避开这个问题再把原本想实现的功能重新实现。 如果你有任何想法,欢迎在评论区交流。