Modernize your AppKit app
SwiftUI & UI Frameworks 进阶 18m

升级改造你的 AppKit App

Modernize your AppKit app

2026年6月10日

在 Apple 官方观看视频

一句话判断

✅ AppKit 彻底抛弃 mouseDown: 转向手势识别器(Gesture Recognizers),并用全新的“边角同心性(Concentricity)”API 终结了 macOS 嵌套圆角对不齐的强迫症。

这场 Session 讲了什么

这场 Session 最值得关注的一件事是:AppKit 终于把 UIKit 早就用熟的输入模型和 UI 自动化能力全面补齐了。AppKit 作为 macOS 的元老级框架,历史包袱太重,很多老代码还在用 mouseDown: 和追踪循环(Tracking loops)硬写鼠标交互。这不仅和 SwiftUI、UIKit 的输入模型割裂,还经常导致事件吞没和状态混乱。这次 Apple 下了狠手,把手势识别器和控制事件(Control Events)作为处理输入的“唯一正解”强推给开发者。

除了输入模型,macOS 的 UI 细节也迎来了自动化。以前为了对齐父子视图的圆角,开发者得手动算 padding 或者套 mask,现在 macOS 27 直接给了 NSViewCornerConfiguration API,让子视图自动贴合父视图的圆角。加上液态玻璃(Liquid Glass)材质的自动演进,AppKit 在视觉和交互上终于向现代声明式框架看齐了。

这对还在维护大型纯 AppKit 项目,或者搞 AppKit + SwiftUI 混合架构的 macOS 开发者影响最大。如果你还在手写 tracking loop,或者手动计算圆角半径,是时候重构了。

值得深挖的点

输入模型大换血:为什么必须干掉 mouseDown:

Apple 强推手势识别器和控制事件替代 mouseDown:,主要原因是跨框架的统一。现在 Mac 上跑着 AppKit、SwiftUI 和 Mac Catalyst (UIKit),如果 AppKit 还在用古老的追踪循环,当 SwiftUI 视图嵌入 AppKit 窗口时,手势和点击事件就会打架。

控制事件的引入非常实用。以前想知道一个按钮是“正在被按下”还是“按下后鼠标移出了按钮区域”,你得子类化 NSButton 然后重写一堆 mouseDown:mouseExited:。现在直接给按钮加 .trackingEndedOutside 控制事件就行,连子类都不用写。

不过坑也很明显:手势识别器是挂在视图层级上的,如果你有一个覆盖在上面的透明兄弟视图,它会把鼠标事件悄悄吃掉。Apple 给的解法是重写覆盖层的 hitTest(_:) 返回 nil,让事件穿透。老项目迁移时,一定要全局排查这种“隐形遮挡”问题,否则你会发现很多按钮突然点不动了。

UI 细节自动化:边角同心性(Concentricity)是个神仙 API

macOS 的设计语言越来越依赖大圆角和复杂材质。在实际开发中,如果一个带圆角的父容器里放了一个带背景色的子视图,子视图的圆角必须比父视图小,且圆心必须一致(同心),否则边缘就会出现难看的白边或锯齿。

以前解决这个问题,要么手动计算 cornerRadius = parentRadius - padding,要么用 CAShapeLayer 做 mask,既繁琐又影响渲染性能。这次新增的 NSViewCornerConfiguration 提供了 .containerConcentric 选项,直接让系统接管计算。这是非常典型的“把复杂留给系统,把简单留给开发者”的设计。配合 Liquid Glass 在 macOS 27 的自动硬边效果和半粗体侧边栏选中态,AppKit 开发者终于不用在像素级对齐上浪费生命了。

代码片段

1. 用控制事件替代鼠标追踪

场景:监听按钮的复杂交互状态,无需子类化 NSButton

let button = NSButton()
// 监听鼠标在按钮外部抬起的事件,替代繁琐的 mouseExited 重写
button.addTarget(
    self,
    action: #selector(trackingEndedOutsideHandler),
    for: .trackingEndedOutside
)

坑:控制事件依赖标准的控件实现,如果你自定义的控件没有正确调用 sendAction,这些事件就不会触发。

2. 解决手势识别器的事件遮挡

场景:当视图层级中有覆盖层(Overlay)导致下方按钮无法点击时。

class OverlayView: NSView {
    // 重写 hitTest 返回 nil,让鼠标事件穿透到下方的视图
    override func hitTest(_ point: NSPoint) -> NSView? {
        return nil
    }
}

坑:返回 nil 意味着这个覆盖层自己也无法接收任何鼠标事件,确保它真的只是一个纯视觉层。

3. 状态栏自定义 UI 的焦点管理

场景:点击状态栏图标弹出自定义窗口时,让系统正确接管键盘焦点。

extension AppDelegate: NSStatusItemExpandedInterfaceDelegate {
    // 系统准备展示扩展界面时调用
    func statusItem(_ statusItem: NSStatusItem, didBegin session: NSStatusItemExpandedInterfaceSession) {
        customWindow.makeKeyAndOrderFront(nil) // 显示自定义窗口
    }
    
    // 用户执行了操作,主动取消会话以关闭窗口
    func selectedAction() {
        statusItem.expandedInterfaceSession?.cancel()
    }
}

坑:如果你不实现这个 Delegate,弹出的自定义窗口在键盘导航(Tab 键)时会导致焦点乱飞,甚至卡死在状态栏。

最佳实践

老项目迁移不要想着“一键替换”。先全局搜索 mouseDown:mouseDragged:,把那些只为了处理“点击”和“拖拽”的代码换成 NSClickGestureRecognizerNSPanGestureRecognizer。对于列表选择、右键菜单和拖放,直接用 AppKit 现成的基于视图的 API(如 menuForEvent: 和现代的 pasteboardWriterForRow),别再自己算坐标了。

新项目必须把 window.autorecalculatesKeyViewLoop = truewindow.preventsApplicationTerminationWhenModal = false 作为 Window Controller 的标配。前者让 Tab 键导航自动适应视图增删,后者确保你的非关键 Sheet 不会在系统夜间重启更新时阻挡整个 Mac 关机。

状态恢复(State Restoration)千万不要把整个文档数据塞进 encodeRestorableState。只存 UI 状态,比如侧边栏选中的 productIdentifier。数据本身应该从数据库或文件系统里读,否则你的 App 恢复时会慢得让人想砸键盘。

还有什么值得关注

  • NSTextSelectionManager:让非 NSTextView 的自定义视图也能白嫖原生的双向文本选择、拖拽和切换行为。
  • Liquid Glass 自动演进:macOS 27 会自动给侧边栏选中项加半粗体,给工具栏加玻璃材质,老 App 重新编译就能获得视觉升级。
  • NSTableView 现代拖拽:用 pasteboardWriterForRow 替代老旧的 writeRowsWithIndexes,直接返回 NSPasteboardItem,跨进程拖拽更稳。
AppKit Gesture Recognizers macOS 27 State Restoration Liquid Glass