SwiftUI macOS应用:阻止「打开最近」菜单因主窗口UI频繁更新而自动关闭
我完全懂你的困扰——做音频播放器嘛,主UI肯定得跟着播放状态每秒更新,但没想到这居然会连累「打开最近」菜单,刚点开就自动关闭,体验太糟了。咱们来一步步解决这个问题,核心思路就是把菜单的更新逻辑和主UI的频繁刷新彻底隔离开。
问题根源拆解
你的主窗口UI之所以会频繁更新,是因为AudioDiscPlayer里的一堆@Published属性在实时推送状态变化。而SwiftUI的App结构体里的body(包括你定义的commands)会跟着所有绑定的ObservableObject一起刷新——也就是说,主UI每更一次,整个命令菜单都会被重建一遍,这就导致刚打开的「打开最近」菜单直接被刷没了。
解决方案:隔离菜单的更新逻辑
咱们要做的就是让「打开最近」菜单只在最近文件列表真的变化时才更新,完全不受主UI刷新的影响。具体分三步:
1. 优化RecentDiscsModel:用系统通知自动更新,摆脱手动调用
原来你是手动调用refresh(),现在咱们监听系统自带的通知,让最近文件列表自动更新,还能避免不必要的刷新:
class RecentDiscsModel: ObservableObject { static let shared = RecentDiscsModel() @Published private(set) var recentDocs: [URL] = [] private init() { // 初始化时先加载一次 refresh() // 监听系统的最近文档变化通知,自动触发更新 NotificationCenter.default.addObserver( self, selector: #selector(refresh), name: NSDocumentController.didChangeRecentDocumentsNotification, object: NSDocumentController.shared ) } @objc func refresh() { let newDocs = NSDocumentController.shared.recentDocumentURLs // 只有当列表真的变化时才更新,避免无意义的刷新 if newDocs != recentDocs { recentDocs = newDocs } } }
2. 重构App的Commands:让菜单只依赖RecentDiscsModel
原来你直接在菜单里用NSDocumentController.shared.recentDocumentURLs,这会导致每次App body刷新都重建菜单。现在改成绑定RecentDiscsModel的属性,只有当最近文件变化时才重建菜单:
@main struct MyApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var recentDiscsModel = RecentDiscsModel.shared var body: some Scene { Window("Player", id: "main-window") { PlayerView() } .commands { CommandGroup(replacing: .newItem) { } CommandGroup(after: .newItem) { Button("Open...") { NotificationCenter.default.post(name: .openDocumentRequested, object: nil) } .keyboardShortcut("O", modifiers: .command) // 只在recentDiscsModel的列表非空时显示菜单 if !recentDiscsModel.recentDocs.isEmpty { Menu("Open Recent") { // 遍历model里的列表,而不是直接用NSDocumentController的属性 ForEach(recentDiscsModel.recentDocs, id: \.self) { url in Button(url.lastPathComponent) { Globals.shared.insertDisc(at: url) } } Divider() Button("Clear Menu") { NSDocumentController.shared.clearRecentDocuments(nil) // 现在不需要手动调用refresh,系统通知会自动处理 } } } } PlayBackMenu() } } }
3. 简化Globals的insertDisc方法:去掉手动刷新
现在有了系统通知的自动更新,就不用再手动调用RecentDiscsModel.shared.refresh()了:
class Globals: ObservableObject { static let shared = Globals() init() {} @Published var errorMessage = "" @Published var errorDetails = "" @Published var showingErrorAlert = false func handleFileImport(result: Result<URL, Error>) { switch result { case .success(let url): guard url.startAccessingSecurityScopedResource() else { errorMessage = "Unable to access file" errorDetails = "Permission denied" showingErrorAlert = true return } defer { url.stopAccessingSecurityScopedResource() } insertDisc(at: url) case .failure(let error): errorMessage = "Failed to import file" errorDetails = error.localizedDescription showingErrorAlert = true } } func insertDisc(at url: URL, startPlayback: Bool = false) { do { try AudioDiscPlayer.shared.insertDisc(at: url, startPlayback: startPlayback) NSDocumentController.shared.noteNewRecentDocumentURL(url) // 移除手动调用refresh的代码 } catch { let nsError = error as NSError errorMessage = nsError.localizedDescription let reason = nsError.localizedFailureReason ?? "" let recovery = nsError.localizedRecoverySuggestion ?? "" errorDetails = "\n\n\(reason)\n\n\(recovery)".trimmingCharacters(in: .whitespacesAndNewlines) showingErrorAlert = true } } }
为什么这样能解决问题?
- 隔离刷新源:现在「打开最近」菜单只绑定
RecentDiscsModel,只有当最近文件列表真的变化时,菜单才会重建,完全不受主UI每秒更新的影响。 - 利用系统通知:借助
NSDocumentController的原生通知,自动处理最近文件的添加/清除,既可靠又不用自己写额外的监听逻辑。 - 减少无意义刷新:
RecentDiscsModel里的refresh()只会在列表真的变化时更新@Published属性,避免了不必要的UI重建。
额外优化建议
如果你的PlayBackMenu也依赖AudioDiscPlayer的实时状态,建议把它的数据源也做类似隔离,比如用@EnvironmentObject在View层级传递,而不是放在App结构体里,进一步减少App body的刷新频率。
内容来源于stack exchange

