返回博客

Tauri 自定义标题栏拖动后 IPC 卡死几秒——用 AppKit 原生拖拽绕过

给桌面应用做了一个自定义标题栏,用 data-tauri-drag-region 实现无边框窗口拖拽。体验初看没问题:拖窗口时丝滑,松手后点点按钮、命令行继续输出,一切正常。但大约用了几分钟就发现了一个怪现象:松开鼠标之后再去抓标题栏,有时要等 2~4 秒才能拖动。等会儿又好了,再拖一次又卡——循环往复,像是某种定时炸弹。

更诡异的是,同一段时间内点别的按钮(触发 Tauri invoke())也会卡同样的时长。CPU、GPU 占用都是 idle。

现象

  • 按住拖动时完全流畅,鼠标跟手;
  • 松开后 2~4 秒内,再次按住标题栏没有反应,窗口纹丝不动;
  • 同一时间段内所有 invoke() 调用也明显变慢甚至超时;
  • 等几秒自动恢复,拖一次又复发——循环周期性
  • 关掉所有后台任务、清空流式输出,问题依然复现。

根因

data-tauri-drag-region 的实现方式决定了这个问题必然存在。

Tauri 给带这个属性的 DOM 元素注入了一个 mousedown 监听器,触发时调用 getCurrentWindow().startDragging()。而 startDragging() 是一个 IPC 调用——要从 WebView 的 JS 上下文穿越到 Rust 运行时,再由 Rust 调用 AppKit API 开始拖拽。

问题就在这里:Tauri 上游存在一个已知 bug(tauri-apps/tauri#12597),每次拖拽结束后,IPC/command 通道会被降级数秒。这段时间内任何 invoke() 都会排队等待,包括下一次拖拽的 startDragging()——于是就出现了"拖完之后再抓标题栏没反应"的周期性症状。

窗口一旦开始拖动(OS 接管),就不再需要 IPC,所以持续拖动是流畅的。只有"重新发起"这个动作依赖 IPC,才会被卡住。这个"按住 = 流畅,重抓 = 卡"的特征是与 GPU 渲染卡顿的决定性区分点。

坑:data-tauri-drag-region 是 JS+IPC,拖完后 IPC 通道降级数秒

每次完成一次拖动,Tauri IPC 通道会被降级约 2~5 秒(#12597),导致后续所有 invoke() 变慢,包括下一次拖拽触发的 startDragging()。症状是"松手后 N 秒内再拖无效 + 按钮迟钝",循环出现。按住时流畅不代表没问题——那段时间根本不走 IPC。

正解

Electron 没有这个问题,因为它用的是 -webkit-app-region: drag——由 Chromium 在原生层处理,完全不走 JS/IPC。Tauri 用的是 WKWebView,不支持这个 CSS 属性,所以只能自己实现等价的原生方案。

方案 A(最简单):整窗口背景可拖

objc2setup 钩子里对 NSWindow 设置 isMovableByWindowBackground = true。AppKit 从此在任何空白背景区域捕获 mousedown 就开始拖窗口,全程不走 IPC。

# Cargo.toml(仅 macOS 编译)
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = "0.3"
#[cfg(target_os = "macos")]
fn enable_native_window_drag(window: &tauri::WebviewWindow) {
    use objc2_app_kit::NSWindow;
    let Ok(ptr) = window.ns_window() else { return };
    if ptr.is_null() { return; }
    let ns_window: &NSWindow = unsafe { &*ptr.cast::<NSWindow>() };
    ns_window.setMovableByWindowBackground(true);
}
 
// in .setup(|app| { ... })
#[cfg(target_os = "macos")]
if let Some(win) = app.get_webview_window("main") {
    enable_native_window_drag(&win);
}

注意:启用后,所有空白背景区域都会拖窗口,包括侧边栏空白、终端面板等。按钮、输入框这类消费了 mousedown 的控件不受影响,但带拖拽手势的自定义控件(如分隔条)会和窗口拖拽冲突——因为 WKWebView 不支持 -webkit-app-region: no-drag,DOM 层无法局部排除。

方案 B(推荐):仅标题栏中间条原生拖拽

用一个透明 NSView 子类覆盖在标题栏中间区域,mouseDown: 里调用 performWindowDragWithEvent:。两个技巧让实现变得简单:

  1. 不覆盖按钮区域,左右各留出偏移量(交通灯 + 自定义按钮),就不需要重写 hitTest: 打洞;
  2. autoresizingMask = ViewWidthSizable | ViewMinYMargin,宽度跟随窗口,不需要监听 resize 事件手动更新 frame。
#[cfg(target_os = "macos")]
define_class!(
    #[unsafe(super(objc2_app_kit::NSView))]
    #[thread_kind = MainThreadOnly]
    #[name = "TitlebarDragView"]
    struct TitlebarDragView;
    impl TitlebarDragView {
        #[unsafe(method(mouseDown:))]
        fn mouse_down(&self, event: &objc2_app_kit::NSEvent) {
            if let Some(win) = self.window() {
                win.performWindowDragWithEvent(event);
            }
        }
    }
);

重要细节:不要用 mouseDownCanMoveWindow → YES——在 titleBarStyle: "Overlay" / fullSizeContentView 窗口下这个属性实测无效(整个标题栏变成不可拖动)。performWindowDragWithEvent: 是 AppKit 10.11+ 的官方 API,无论窗口样式都可靠。

最后,把所有 data-tauri-drag-region 从 DOM 里删掉——如果新旧两条路径并存,标题栏区域会同时触发原生拖拽和 IPC startDragging(),IPC 降级问题会通过旧路径卷土重来。

正解:用 performWindowDragWithEvent: 原生拖拽,完全绕开 IPC

透明 NSView 覆盖标题栏中间条,mouseDown: 里调 performWindowDragWithEvent:;删除所有 data-tauri-drag-region。拖拽全程不经过 Tauri IPC,#12597 无从触发,周期性卡顿彻底消失。

一句话外卖

Tauri 的 data-tauri-drag-region 本质是 IPC,只要 upstream #12597 未修复,拖完窗口后 IPC 通道必然降级——自定义标题栏的正确姿势是用 AppKit 原生 API,拖拽从不经过 Rust。