Use SwiftUI with AppKit and UIKit
SwiftUI & UI Frameworks 进阶 14m

将 SwiftUI 与 AppKit 和 UIKit 搭配使用

Use SwiftUI with AppKit and UIKit

2026年6月10日

在 Apple 官方观看视频

一句话判断

这场 Session 最值得关注的一件事是:AppKit 和 UIKit 的原生视图(NSView/UIView)终于能直接“白嫖” @Observable 的自动刷新机制了,老项目混编最恶心的手动状态同步彻底成为历史。

这场 Session 讲了什么

以前在 AppKit 或 UIKit 老项目里掺 SwiftUI,最痛苦的莫过于状态管理割裂。你在 SwiftUI 里用 @Observable 爽得飞起,切回 NSView 还得苦哈哈地写 needsDisplay = true 或者手动 KVO。Apple 这次直接把 Observation 框架的自动追踪能力下放给了原生 UI 框架。只要你在 drawlayoutupdateConstraints 里读了 @Observable 模型的属性,底层就会自动帮你注册依赖,属性一变,视图自动重绘。

除了状态同步,Apple 还把“反向桥接”的口子开到了最大。以前我们习惯用 NSViewRepresentable 把老视图塞进 SwiftUI,现在你可以用 NSGestureRecognizerRepresentable 把老手势直接扔给 SwiftUI,用 NSHostingMenu 把 SwiftUI 写的菜单塞进 AppKit 主菜单,甚至用 NSHostingSceneRepresentation 在现有的 NSApplicationDelegate 里动态挂载 MenuBarExtra 这种纯 SwiftUI Scene。

这对那些背着几十万行 AppKit/UIKit 历史包袱、又想逐步现代化的团队来说是绝对的利好。你不需要重写整个 ViewController,只需把 Model 换成 @Observable,然后像搭积木一样把新特性用 SwiftUI 写,老代码和新代码之间的缝合线被 Apple 用官方 API 熨平了。

值得深挖的点

Observation Tracking 下沉到 NSView/UIView

Apple 这么做的原因很直白:消除混编项目中的“状态精神分裂”。以前 @Observable 是 SwiftUI 的专属魔法,AppKit 只能用老掉牙的 KVO 或手动 invalidate。现在,Apple 在 NSView.draw(_:)updateConstraints()layout() 甚至 NSViewController 的对应方法里内置了 Observation 追踪。

这个设计的 trade-off 在于隐式依赖。开发者在 draw 里随手读了一个 model 的属性,视图就自动绑定了重绘,这很爽,但也容易在复杂的绘制逻辑中意外触发不必要的重绘。好在这套机制向后兼容到了 macOS 15 和 iOS 18,只需在 Info.plist 里加个 NSObservationTrackingEnabled 开关,2026 年的新系统则默认开启。对于老项目,建议先在一个独立的自定义 NSView 里试水,观察 Instruments 里的重绘频率,再决定要不要全局铺开。

补齐“反向桥接”:让老资产原生融入 SwiftUI

过去几年,Apple 提供了各种 Representable 让我们把 AppKit/UIKit 塞进 SwiftUI。但现实是,很多老项目积累了大量极其复杂的自定义 NSGestureRecognizer 或完整的 AppKit 生命周期管理。这次推出的 NSGestureRecognizerRepresentable 是个神来之笔。你不需要把几千行的手势识别逻辑用 SwiftUI 的 Gesture 协议重写,只需包一层,就能直接用 .gesture() 挂载。

同样,NSHostingSceneRepresentation 解决了“老 App 想加个 MenuBarExtra 难道要重构整个 AppDelegate 吗”的痛点。它允许你在 applicationWillFinishLaunching 里动态注入 SwiftUI Scene,甚至能通过 environment.openSettings() 从老代码里唤起 SwiftUI 的设置窗口。这种“不改动整体架构就能加新 Scene”的设计,比强迫你迁移到 @mainApp 协议务实得多。

代码片段

1. @Observable 自动刷新 NSView

场景:自定义 NSSliderCell,读取 model 属性自动重绘,告别 needsDisplay = true

import Observation
import AppKit

@Observable @MainActor
final class ColorModel {
    var hue: Double = 0.6
    var brightness: Double = 1.0
}

class CustomSliderCell: NSSliderCell {
    var model: ColorModel?
    
    // 在 draw 方法中读取 model 属性
    // AppKit 会自动追踪依赖,属性改变时自动触发重绘
    override func drawKnob(_ knobRect: NSRect) {
        guard let model = model else { return }
        let color = NSColor(hue: model.hue, saturation: 1.0, brightness: model.brightness, alpha: 1.0)
        color.setFill()
        NSBezierPath(ovalIn: knobRect).fill()
        // 以前这里必须手动监听 model 变化并调用 needsDisplay = true,现在不需要了
    }
}

坑:如果在 draw 里读取了不参与 UI 展示的辅助计算属性,也会导致无意义的重绘,记得把纯逻辑计算移出绘制生命周期。

2. 复用老 AppKit 手势到 SwiftUI

场景:把现有的 Force Click 手势识别器无缝接入 SwiftUI 视图。

import SwiftUI
import AppKit

struct ForceClickReset: NSGestureRecognizerRepresentable {
    var model: ColorModel

    // 返回你现有的 NSGestureRecognizer 子类
    func makeNSGestureRecognizer(context: Context) -> ForceClickGestureRecognizer {
        ForceClickGestureRecognizer()
    }

    // 手势触发时的回调,直接操作 model
    func handleNSGestureRecognizerAction(_ recognizer: ForceClickGestureRecognizer, context: Context) {
        withAnimation {
            model.saturation = 1.0
            model.brightness = 1.0
        }
    }
}

// 在 SwiftUI 中像原生手势一样使用
struct ColorPickerView: View {
    var model: ColorModel
    var body: some View {
        Canvas { context, size in /* 绘制逻辑 */ }
            .gesture(DragGesture()) // 现有的拖拽手势
            .gesture(ForceClickReset(model: model)) // 复用老手势
    }
}

坑:handleNSGestureRecognizerAction 里如果涉及 UI 更新,记得用 withAnimation 包裹,否则 SwiftUI 不会自动补间动画。

3. 在老 AppDelegate 中动态注入 SwiftUI Scene

场景:不重构 @main,在现有 AppKit 应用里加一个 MenuBarExtra。

import AppKit
import SwiftUI

@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
    let model = AppModel()
    var openSettingsAction: (() -> Void)?

    func applicationWillFinishLaunching(_ notification: Notification) {
        // 用 NSHostingSceneRepresentation 包裹 SwiftUI Scene
        let scenes = NSHostingSceneRepresentation {
            LightMenuBarExtra(appModel: model)
            LightSettings(appModel: model)
        }
        
        // 动态添加到现有的 NSApplication 中
        NSApplication.shared.addSceneRepresentation(scenes)
        
        // 暴露打开设置的闭包给老代码使用
        openSettingsAction = {
            scenes.environment.openSettings()
        }
    }
}

坑:NSHostingSceneRepresentation 必须在 applicationWillFinishLaunching 这种极早期的生命周期里添加,放到 applicationDidFinishLaunching 可能会导致首帧渲染延迟或菜单闪烁。

最佳实践

  • 已有项目的迁移策略:别想着“一把梭”重写。最稳妥的起手式是把核心数据模型加上 @Observable 宏,让现有的 NSView/UIView 享受自动刷新;遇到需要大改 UI 或新增复杂交互的独立模块(比如新的设置面板、复杂的图表),直接用 SwiftUI 写,通过 NSHostingView 嵌进去;边缘 Scene(如菜单栏扩展)则可以通过 NSHostingSceneRepresentation 无缝替换。
  • 新项目的采用建议:直接上纯 SwiftUI。除非你要做极度依赖底层事件循环的复杂自定义控件(比如 DAW 软件的钢琴卷帘),否则不要主动引入 AppKit/UIKit 混编,徒增心智负担。
  • 实战中容易踩的坑:Info.plist 里的 NSObservationTrackingEnabled 开关。如果你要支持 macOS 15 / iOS 18,必须手动加这个 key 才能开启自动追踪,而在 macOS 26 / iOS 26 上它是默认开启的。漏了这个 key 会导致老系统上视图死活不刷新,排查起来极其痛苦。

还有什么值得关注

  • NSHostingMenu:可以直接用 SwiftUI 的 View(包含 Button、Picker 等)构建菜单,然后包装成 NSMenu 塞进 AppKit 的主菜单栏,连键盘快捷键都能完美映射。
  • Canvas 与 CoreGraphics 互通:在 SwiftUI 的 Canvas 里可以通过 withCGContext 直接拿到 CGContext,以前写在 drawRect 里的老 CoreGraphics 绘制代码可以一行不改地搬到 SwiftUI 里。
  • Liquid Glass 的底层实现:Apple 透露 AppKit 里的 NSSliderNSSwitch 等基础控件,底层已经大量使用 SwiftUI 来渲染新的 Liquid Glass 效果,这意味着你即使不写 SwiftUI,也在享受它的渲染红利。
SwiftUI AppKit UIKit Observation NSHostingView