What's new in AppKit
Developer Tools 进阶 26m

AppKit 的新特性

What's new in AppKit

2021年6月11日

在 Apple 官方观看视频

一句话判断

AppKit 在 macOS 12 终于拿到了 Swift Concurrency 的入场券——@MainActor 标注的 NSViewController、async 版本的 panel 展示、以及改进的 SwiftUI 互操作能力,老牌 Mac app 的现代化路径越来越清晰了。

这场 Session 讲了什么

AppKit 作为 macOS 的原生 UI 框架,在 macOS 12 (Monterey) 获得了一系列现代化更新。Session 覆盖了 Swift Concurrency 集成、UI 组件改进和 SwiftUI 互操作三个方向。

Swift Concurrency 方面,NSViewControllerNSWindow 的关键方法现在被标记为 @MainActor。这意味着在 async 上下文中访问这些对象时,编译器会自动确保线程安全。NSAlertNSOpenPanel 新增了 async/await 版本的展示方法,不再需要 completion handler。

UI 组件方面,NSToolbar 的自定义能力得到增强,支持 NSToolbarItemisBorderedisNavigational 属性。NSMenu 支持了新的 NSMenuSelectionRepresentation,可以在菜单中展示自定义的选中状态。

SwiftUI 互操作方面,NSHostingViewNSHostingController 的性能得到了优化。新增了 NSHostingSizedAnchor 用于在 Auto Layout 中精确控制 SwiftUI 视图的尺寸。

值得深挖的点

NSOpenPanel 的 async/await 封装

文件选择对话框之前需要用 beginSheetModal + completion handler 的方式展示。macOS 12 新增了 beginSheetModal(for:completionHandler:) 的 async 版本,可以直接 let url = try await panel.runModal()。这让文件操作的代码流程变得线性化。

SwiftUI 与 AppKit 的混合布局策略

对于既有 AppKit app,Apple 推荐的迁移策略是「外层 AppKit,内层 SwiftUI」。窗口管理、工具栏和菜单仍然用 AppKit,而新的内容区域用 SwiftUI 实现。通过 NSHostingView 把 SwiftUI 视图嵌入到 AppKit 的 NSView 层级中。

代码片段

// 使用 async/await 展示文件选择对话框
import AppKit

class DocumentController {
    @MainActor
    func openDocument(parentWindow: NSWindow) async {
        let panel = NSOpenPanel()
        panel.allowedContentTypes = [.json, .plainText]
        panel.allowsMultipleSelection = false
        panel.canChooseDirectories = false
        
        // 异步展示对话框
        let response = await panel.beginSheetModal(for: parentWindow)
        
        if response == .OK, let url = panel.url {
            do {
                let content = try String(contentsOf: url)
                print("文件内容: \(content.prefix(100))")
            } catch {
                print("读取失败: \(error)")
            }
        }
    }
}
// NSAlert 的 async 版本
@MainActor
func confirmDelete(file: String) async -> Bool {
    let alert = NSAlert()
    alert.messageText = "确认删除"
    alert.informativeText = "确定要删除 \"\(file)\" 吗?此操作不可撤销。"
    alert.addButton(withTitle: "删除")
    alert.addButton(withTitle: "取消")
    alert.alertStyle = .warning
    
    let response = await alert.beginSheetModal()
    return response == .alertFirstButtonReturn
}
// 在 AppKit 中嵌入 SwiftUI 视图
import SwiftUI

class ContentViewController: NSViewController {
    var hostingView: NSHostingView<ContentView>!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let swiftUIView = ContentView()
            .environmentObject(AppData.shared)
        
        hostingView = NSHostingView(rootView: swiftUIView)
        hostingView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(hostingView)
        
        NSLayoutConstraint.activate([
            hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            hostingView.topAnchor.constraint(equalTo: view.topAnchor),
            hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }
}

最佳实践

如果你的 AppKit app 已经在使用 Combine,可以逐步把 Combine 的数据流替换为 AsyncStream。两者可以共存——Combine 的 Publisher 可以通过 .values 属性转换为 AsyncSequence

对于大型 AppKit app,不建议一次性迁移到 SwiftUI。从独立的新功能模块开始,用 SwiftUI 实现,通过 NSHostingController 集成到现有的窗口和导航体系中。验证效果后再扩大 SwiftUI 的使用范围。

还有什么值得关注

  • NSWindow 新增了 setToolbar 方法,可以更方便地管理工具栏。
  • NSTableView 的行拖拽现在支持 NSItemProvider,可以和其他 app 交换数据。
  • macOS 12 的 NSApplication 支持 focusedWindow 的 KVO 监听,方便做窗口级别的状态管理。
WWDC 2021