AI 结队编程:解决 SwiftUI 窗口点击关闭按钮崩溃问题

问题背景

最近在开发 MacOS APP 时,遇到点击窗口(Search Window)的关闭按钮(×)会导致应用崩溃问题。我提供给 AI 实现搜索功能的提示词如下:

为应用程序新增搜索功能,具体实现要求如下:

1. 界面元素添加:
   - 在应用程序界面的合适位置(建议为导航栏或工具栏)添加一个视觉清晰的搜索按钮
   - 设计并实现搜索按钮的交互效果,包括悬停状态和点击反馈

2. 搜索窗口实现:
   - 点击搜索按钮时,打开一个搜索窗口
   - 搜索窗口采用垂直布局,包含以下核心元素:
     - 顶部搜索输入框(支持键盘回车触发搜索)
     - 中部搜索结果显示区域
     - 可选的搜索状态提示和清空/取消按钮

3. 搜索结果区域布局:
   - 采用双列布局设计搜索结果窗口:
     - 左侧列:显示匹配的对话标题列表,包含对话标题和相关元数据
     - 右侧列:显示当前选中对话中的消息列表,每项显示包含搜索关键字的消息上下文
   - 实现左侧列列表项的选中状态视觉反馈,确保用户清晰了解当前选择项

4. 关键字高亮与内容展示:
   - 对搜索结果中的所有关键字进行醒目高亮处理(建议使用不同颜色背景或文本颜色)
   - 历史对话标题列表中需突出显示包含关键字的标题
   - 消息列表中需展示关键字所在的上下文内容片段,确保上下文完整且关键字突出

5. 交互功能实现:
   - 实现点击消息列表项的交互功能:
     - 点击后将主界面窗口置于顶层显示
     - 保持搜索窗口处于打开状态(非关闭)
     - 自动定位并滚动主界面至对应的聊天位置
     - 高亮显示主界面中定位到的具体消息

6. 性能与用户体验要求:
   - 实现搜索功能的即时响应,搜索延迟不超过300ms
   - 添加搜索过程中的加载状态提示
   - 处理无搜索结果的空状态,提供友好提示
   - 确保搜索功能在不同屏幕尺寸下的响应式显示效果

7. 辅助功能:
   - 支持键盘导航(箭头键选择、Enter确认)
   - 实现搜索窗口的关闭机制(右上角关闭按钮、ESC键)
   - 添加搜索历史记录功能(可选)

该问题应该是 AI 在实现 5. 交互功能实现 功能时引入的。问题表现为:

  1. 错误信息: EXC_BAD_ACCESS (code=1, address=0x20)
  2. 影响范围: 每次都能稳定复现,完全阻断搜索功能的使用
  3. 表现: 崩溃,出现转圈(鼠标自旋)无响应

aegeajhgo

最终和 AI 结对编程解决了这个问题,过程也是十分坎坷 🥲,有时候 AI 也不是也容易就能发现问题、解决问题,甚至制造自身无法解决的问题。

问题分析过程

第一阶段:初期诊断

添加了全面的日志记录来追踪窗口生命周期:

  • SearchWindowController 的窗口创建/显示日志
  • SearchView 的生命周期事件(onAppear/onDisappear)
  • SearchReducer 的状态变化日志

发现: 日志显示在 windowWillClose 委托回调时,系统正在进行不安全的状态操作。

第二阶段:错误的尝试

添加了全面的日志记录来追踪窗口生命周期:

  • SearchWindowController 的窗口创建/显示日志
  • SearchView 的生命周期事件(onAppear/onDisappear)
  • SearchReducer 的状态变化日志

发现: 日志显示在 windowWillClose 委托回调时,系统正在进行不安全的状态操作。

第二阶段:错误的尝试

尝试了多个失败的方案:

  1. 添加 NSWindowController 包装 - 引入强引用,导致更复杂的引用计数问题
  2. 在 windowWillClose 中进行异步清理 - 导致界面冻结(转圈)
  3. 添加 windowClosingInProgress 标志 - 仍然导致死锁
  4. 从 SearchView 发送 reducer action - NSWindowDelegate 回调与 SwiftUI 状态更新产生竞争条件

根本发现: 问题不是单一的逻辑错误,而是架构性冲突 - 多个层级都在尝试管理窗口的生命周期。

第三阶段:架构简化

逐步移除自定义逻辑:

  1. 移除 NSWindowDelegate 委托 - 不再监听 windowWillClose 事件
  2. 移除 searchWindowPresentedChanged 状态管理 - 简化 reducer
  3. 移除 Escape 键窗口关闭逻辑 - 不再程序化关闭窗口
  4. 改用临时 searchStore - 每个窗口实例都有独立的 store

关键洞察: 让用户直接使用系统标准的关闭按钮,不要通过代码关闭窗口。

第四阶段:发现隐藏的冲突

发现 WindowSizeManager 在为搜索窗口设置自定义委托:

  • WindowSizeManager 在监听所有新窗口
  • 给搜索窗口设置了 WindowMinimumSizeDelegate
  • 多个委托对象导致事件处理冲突

解决: 在 shouldConfigureWindow 中添加检查排除搜索窗口。

第五阶段:最终架构重构

完全改造 SearchWindowController 的核心设计。

根本原因分析
最终确定的根本原因是多层次的窗口管理冲突:

┌─────────────────────────────────────────────────┐
│  SearchWindowController (NSObject)              │
│  - 手动创建 NSWindow                            │
│  - 尝试跟踪窗口生命周期                          │
└─────────────────────┬───────────────────────────┘
                      │
                ┌─────▼──────────────┐
                │ NSWindow           │
                │ ├─ windowWillClose │◄─── NSWindowDelegate (多个!)
                │ └─ ...events       │
                └────────────────────┘
                      │
          ┌───────────┼───────────┐
          │           │           │
   ┌──────▼──┐  ┌──────▼──┐  ┌────▼──────────┐
   │SearchView│ │Reducer  │  │WindowSize    │
   │          │ │Handler  │  │ManagerDelegate│
   └──────────┘ └─────────┘  └─────────────┘

核心问题:

  1. NSWindowDelegate 的 windowWillClose 在系统线程中执行
  2. 多个委托对象都试图处理相同的事件
  3. SearchView 和 SearchReducer 试图在 delegate 回调时更新 SwiftUI 状态
  4. WindowSizeManager 的委托与搜索窗口委托产生冲突
  5. 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")
    }
}

关键改进:

  1. 使用 NSWindowController 而不是 NSObject - Cocoa 的标准实践
  2. 在初始化时设置窗口 - 而不是在方法中
  3. 强引用窗口 - 让 NSWindowController 自动管理生命周期
  4. 移除委托处理 - NSWindowController 自动处理标准事件
  5. 简化 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:) 初始化器

实现细节

  1. NSWindowController 的核心优势

    // NSWindowController 提供的自动处理:
    // - 窗口的保存/恢复(Frame Autosave)
    // - 窗口的 showWindow(_:) 方法
    // - 标准的关闭流程(windowShouldClose)
    // - 内存管理和释放
    
  2. 完全移除的有问题代码

    • NSWindowDelegate 的 windowWillClose 回调
    • searchWindowPresentedChanged action
    • KeyNavigation.escape case
    • NSApplication.shared.keyWindow?.close() 调用
    • 搜索窗口的 weak reference 跟踪
    • WindowSizeManager 对搜索窗口的干扰
  3. 新的流程

    用户点击关闭按钮
    	↓
    NSWindowController 处理标准关闭事件
    	↓
    SearchWindowController 实例释放
    	↓
    SearchView 销毁
    	↓
    tempSearchStore 销毁(由于超出作用域)
    	↓
    干净的内存清理,无副作用
    

测试验证

编译成功后,应验证以下场景:

  1. ✅ 点击搜索窗口的关闭按钮能正常关闭
  2. ✅ 多次打开/关闭搜索窗口无崩溃
  3. ✅ Escape 键无特殊作用(已移除)
  4. ✅ 其他窗口的 minimum size 限制仍然生效
  5. ✅ 内存正确释放(无泄漏)

根本性学习
Cocoa 开发的核心原则:

不要与框架对抗,要融入框架。NSWindowController 已经为你解决了窗口管理的所有问题,直接使用它的标准 API,避免自己实现委托和生命周期管理。

关键教训

  1. 尽量使用高级 API - NSWindowController > NSWindow 直接操作
  2. 避免多层委托冲突 - 一个窗口应该只有一个主要责任人
  3. 让系统处理生命周期 - 不要程序化 close(),让用户点击标准按钮
  4. 完全遵循模式 - 不要半途混合新旧 API

总结

整体下来,我认为这个问题并不复杂,如果比较了解 swift ui、appkit, 应该可以轻松解决这个问题,但是 AI 容易不断兜圈子。

为了解决这个问题,我换了多个模型尝试,始终无法定位问题。从上面的解决过程可以看出,我的兜底的解决方案是告诉 AI,这些功能我都不要了,我只要窗口能正常关闭,并让 AI 移除所有相关代码,这是最笨的办法,也是一定能找到问题的办法。定位到问题后,可以避开这个问题再把原本想实现的功能重新实现。

通过将 SearchWindowController 从 NSObject 改造为 NSWindowController,并完全移除所有自定义的窗口生命周期处理,我们解决了 EXC_BAD_ACCESS 崩溃问题。这次修复示范了在 Cocoa 开发中,遵循框架设计模式比自己实现更可靠和安全。最终代码更简洁、更安全,所有的复杂性都交由系统处理。

如果你有任何想法,欢迎在评论区交流。

posted @ 2025-12-18 16:37  guangzan  阅读(60)  评论(2)    收藏  举报