Rust 桌面宠物拖拽踩坑实录:重影、不跟手、置顶失效

平台:Windows 11 / Rust / winit 0.30 / softbuffer 0.4 / windows 0.61 关键词:无边框窗口、透明窗口、窗口拖拽、SetWindowPosHWND_TOPMOST、DPI 坐标


一、背景:我想做一个桌面宠物

需求很简单:

  • 桌面上悬浮一个 96×96 的小窗口 ,无边框、背景透明、始终置顶
  • 窗口内容就是一张 icon.png
  • 鼠标左键能拖动
  • 系统托盘带右键菜单(显示/隐藏、退出)

技术选型上,我选了 winit 建窗口 + softbuffer 软件渲染(不引入 GPU/浏览器内核,保持极轻量),托盘用 tray-icon

功能很快就跑通了------除了拖拽


二、初版实现

最容易想到的拖拽写法:按下时记录"窗口位置"和"光标位置",移动时用位移增量去更新窗口位置。

rust 复制代码
// 左键按下:记录起点
MouseButton::Left => {
    self.dragging = true;
    if let Ok(pos) = win.outer_position() {
        self.drag_window_start = Some((pos.x, pos.y));   // 窗口屏幕坐标
    }
    self.drag_cursor_start = self.last_cursor;            // 光标客户区坐标
}

// 鼠标移动:按位移增量移动
WindowEvent::CursorMoved { position, .. } => {
    if self.dragging {
        let nx = wx as f64 + (position.x - cx);
        let ny = wy as f64 + (position.y - cy);
        let _ = win.set_outer_position(PhysicalPosition::new(nx as i32, ny as i32));
    }
    self.last_cursor = Some(position);
}

能拖,但体验很糟,而且拖完还附带一个隐藏 bug。


三、三个症状

# 症状 影响
1 拖动时有重影,像拖着一条尾巴 观感很差
2 拖动不跟手,有轻微滞后/跳动 手感黏滞
3 拖过一次后,窗口不再置顶了,会被其他窗口盖住 功能失效(这个最隐蔽)

四、排查过程

4.1 怀疑一:DPI 缩放导致坐标错乱?------ 排除

症状 2「不跟手」最像的经典原因,是逻辑坐标和物理坐标混用

在 Windows 上,如果系统缩放是 125% / 150%,那么:

  • 逻辑坐标(DPI 无关):100 个逻辑像素
  • 物理坐标(真实像素):125 个物理像素

如果我把「逻辑坐标的位移」加到「物理坐标的窗口位置」上,拖拽速度就会和鼠标对不上,表现为拖得"慢半拍"。

于是我去翻了 winit 0.30.13 的源码:

rust 复制代码
// winit-0.30.13/src/event.rs:242
CursorMoved {
    device_id: DeviceId,
    /// (x,y) coords in pixels relative to the top-left corner of the window.
    position: PhysicalPosition<f64>,   // ← 注意:是 PhysicalPosition
},

outer_position() 返回的也是物理坐标:

rust 复制代码
// winit-0.30.13/src/window.rs:701
pub fn outer_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError>

两者都是物理坐标,单位一致,DPI 这个嫌疑排除。

💡 这里有个容易踩的认知误区:很多人印象里 winit 的鼠标事件是逻辑坐标。 但 winit 0.30 的 CursorMoved 已经是 PhysicalPosition<f64>。 版本不同行为不同,遇到坐标问题时,查源码比查记忆靠谱。

顺带确认了另一件事:我用的是无边框窗口 ,客户区原点 == 窗口原点,所以"用位移增量计算"这个思路本身是没问题的

4.2 怀疑二:softbuffer 反复 present 造成残影 ------ 确认

softbuffer软件渲染 :每次绘制都要把整块像素缓冲 present() 给系统。

拖拽时发生的事是:

scss 复制代码
鼠标移动 → 移动窗口 → 触发 RedrawRequested → draw() → softbuffer present()
         ↑                                                      │
         └──────────────────────────────────────────────────────┘
                    (每次移动都伴随一次全窗口重绘)

"移动窗口"和"重绘窗口"高频交替,而软件渲染的 present 与 DWM(桌面窗口管理器)的合成时机不同步,于是就出现了上一帧残留------也就是重影。

同时,每次 nx as i32 的取整截断会让窗口位置有 1px 级别的跳动,进一步加重了不跟手的感觉。

4.3 一次失败的尝试:WM_SYSCOMMAND / SC_MOVE

查资料时看到一个"经典解法":模拟拖动标题栏。

rust 复制代码
SendMessageW(hwnd, WM_SYSCOMMAND, SC_MOVE | 0x0002, 0);

这样能让 Windows 系统接管整个拖拽过程,理论上最丝滑。

我满怀信心地改了,结果------完全拖不动了

原因:这条消息要求当前线程正处于"鼠标按下"的消息循环上下文 中才有效。而 winit 的事件回调(WindowEvent::MouseInput)是异步派发出来的,等我收到这个事件时,早就脱离了那个上下文,系统直接忽略了这条消息。

⚠️ 如果你在 winit / 类似事件循环框架里看到"发 SC_MOVE 就能原生拖拽"的说法,要小心------ 它只在原生 Win32 消息处理函数(WndProc)里直接处理 WM_NCHITTEST/WM_LBUTTONDOWN 时才成立。

另外提一句:这个方案里我还试过 winit 的 start_drag_move(),但 winit 0.30 根本没有这个方法(那是 Tauri/其他框架提供的),编译直接报错。

4.4 真凶:HWND_TOP 悄悄干掉了"始终置顶"

既然不能用 SC_MOVE,那就回到自己移动窗口,但把 set_outer_position 换成更底层的 SetWindowPos。改完能拖了,重影也减轻了------但置顶失效了

我去翻 winit 是怎么实现 AlwaysOnTop 的:

rust 复制代码
// winit-0.30.13/src/platform_impl/windows/window_state.rs:341
SetWindowPos(
    window,
    match (new.contains(WindowFlags::ALWAYS_ON_TOP), ...) {
        (true, false) => HWND_TOPMOST,     // ← winit 用的是 TOPMOST
        (false, false) => HWND_NOTOPMOST,
        ...
    },
    ...
);

而我自己的移动代码里写的是:

rust 复制代码
SetWindowPos(hwnd, Some(HWND_TOP), x, y, 0, 0, ...);
//                     ^^^^^^^^^ 问题在这

这就是根因。HWND_TOPHWND_TOPMOST 看着只差几个字母,语义完全不同:

常量 含义
HWND_TOP 放到普通窗口层级的顶部 。如果窗口原本是 topmost,会被降级为普通顶层窗口
HWND_TOPMOST 放到置顶层级的顶部,窗口保持"始终置顶"

也就是说,我每拖一次窗口,就把它从"置顶层"踢回"普通层" 。窗口创建时设的 AlwaysOnTop 就这样被我自己覆盖了。

💡 这是 Windows 窗口编程的经典坑:任何一次 SetWindowPos 的调用, 只要你传了 Z 序参数(且没加 SWP_NOZORDER),都会重新定义这个窗口的层级 。 想保持层级不变,要么传正确的常量,要么加 SWP_NOZORDER 标志。


五、最终方案

四个改动,逐个击破。

5.1 移动窗口:SetWindowPos + HWND_TOPMOST

rust 复制代码
unsafe {
    let _ = SetWindowPos(
        hwnd,
        Some(HWND_TOPMOST),              // ✅ 保持置顶,而非 HWND_TOP
        nx,
        ny,
        0,
        0,
        SWP_NOSIZE | SWP_NOACTIVATE,     // 不改尺寸、不抢焦点
    );
}

两个细节:

  • 去掉了 SWP_SHOWWINDOW------它会强制显示窗口,和"显示/隐藏"功能冲突
  • 加了 SWP_NOACTIVATE------拖动时不让窗口抢焦点

winit 的 Window 要拿到 HWND,通过 raw-window-handle

rust 复制代码
use raw_window_handle::{HasWindowHandle, RawWindowHandle};

let hwnd = match window.window_handle() {
    Ok(h) => match h.as_raw() {
        RawWindowHandle::Win32(w) => HWND(w.hwnd.get() as *mut _),
        _ => return,
    },
    Err(_) => return,
};

5.2 消除重影:尺寸比对式重绘

最直觉的做法是"拖拽时完全不重绘":

rust 复制代码
fn draw(&mut self) {
    if self.dragging { return; }   // ❌ 有隐患
    ...
}

这确实消掉了重影,但引入了新问题:如果拖拽过程中系统触发了重绘 (拖到另一块不同 DPI 的屏幕、窗口被遮挡后又露出),跳过绘制会导致 softbuffer 内容失效,窗口直接变成全透明------图标就"消失"了。

改进为尺寸比对:只有当尺寸真的变了才必须重绘。

rust 复制代码
let size = window.inner_size();
let (width, height) = (size.width, size.height);

// 拖拽中且尺寸没变 → 跳过重绘(避免 present 造成重影)
// 尺寸一旦变化(例如拖到不同 DPI 的屏幕)→ 照常重绘,防止内容丢失
if self.dragging && self.last_size == Some((width, height)) {
    return;
}
self.last_size = Some((width, height));

这样既避免了无意义的 present,又不会因为漏绘而丢内容。

另外在鼠标松开时补一次重绘兜底:

rust 复制代码
WindowEvent::MouseInput {
    state: ElementState::Released,
    button: MouseButton::Left,
    ..
} => {
    self.dragging = false;
    if let Some(win) = &self.window {
        win.request_redraw();   // 拖拽期间跳过的绘制,松手时补上
    }
}

5.3 拖拽起点:用 GetCursorPos 代替"上一次鼠标位置"

初版用 self.last_cursor(上一次 CursorMoved 事件记录的位置)作为起点,有个隐蔽缺陷:

如果按下鼠标时,还没发生过任何 CursorMoved 事件 (比如窗口恰好显示在光标正下方,或者刚切换显示),last_cursorNone,拖拽就完全不生效------表现为"第一次点下去拖不动"。

改成按下时主动查询 光标位置,而且直接取屏幕坐标 ,和 outer_position() 完全同一坐标系:

rust 复制代码
#[cfg(windows)]
fn cursor_screen_pos() -> Option<(i32, i32)> {
    use windows::Win32::Foundation::POINT;
    use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;

    let mut pt = POINT::default();
    if unsafe { GetCursorPos(&mut pt) }.is_ok() {
        Some((pt.x, pt.y))
    } else {
        None
    }
}

移动时也用同一个函数,逻辑变得非常干净:

rust 复制代码
WindowEvent::CursorMoved { .. } => {
    if self.dragging {
        if let (Some(win), Some((wx, wy)), Some((cx, cy))) =
            (&self.window, self.drag_window_start, self.drag_cursor_start)
        {
            if let Some((mx, my)) = cursor_screen_pos() {
                move_window(win, wx + (mx - cx), wy + (my - cy));
            }
        }
    }
}

全是屏幕坐标,全部 i32不再有任何坐标系转换和取整问题

5.4 边界限制:别让宠物跑出屏幕

顺手加的体验优化------用 MonitorFromWindow + GetMonitorInfoW 拿到当前显示器的工作区 ,把窗口 clamp 在范围内:

rust 复制代码
let hmonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
let mut mi = MONITORINFO {
    cbSize: std::mem::size_of::<MONITORINFO>() as u32,
    ..Default::default()
};
if GetMonitorInfoW(hmonitor, &mut mi).as_bool() {
    let max_x = mi.rcWork.right - w;
    let max_y = mi.rcWork.bottom - h;
    // 工作区比窗口还小的极端情况,避免 max < min 导致 clamp 崩溃
    if max_x > min_x { nx = nx.clamp(min_x, max_x); }
    if max_y > min_y { ny = ny.clamp(min_y, max_y); }
}

两个注意点:

  • MONITORINFO.cbSize 必须手动填 ,否则 GetMonitorInfoW 会失败
  • rcWork(工作区,不含任务栏)而不是 rcMonitor(整个屏幕),否则宠物能藏到任务栏下面
  • clamp(min, max)min > max 时会 panic,所以要先判断

六、完整代码

move_window 的最终版本:

rust 复制代码
// Windows:取当前光标的「屏幕坐标」(与 outer_position 同一坐标系)
#[cfg(windows)]
fn cursor_screen_pos() -> Option<(i32, i32)> {
    use windows::Win32::Foundation::POINT;
    use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;

    let mut pt = POINT::default();
    if unsafe { GetCursorPos(&mut pt) }.is_ok() {
        Some((pt.x, pt.y))
    } else {
        None
    }
}

// Windows 原生移动窗口:SetWindowPos + HWND_TOPMOST,保持置顶且限制在屏幕内
#[cfg(windows)]
fn move_window(window: &Window, x: i32, y: i32) {
    use windows::Win32::Foundation::{HWND, RECT};
    use windows::Win32::Graphics::Gdi::{
        GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
    };
    use windows::Win32::UI::WindowsAndMessaging::{
        GetWindowRect, SetWindowPos, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOSIZE,
    };

    let handle = match window.window_handle() {
        Ok(h) => h,
        Err(_) => return,
    };
    let hwnd = match handle.as_raw() {
        RawWindowHandle::Win32(w) => HWND(w.hwnd.get() as *mut _),
        _ => return,
    };

    unsafe {
        // 1. 取窗口尺寸,用于计算边界
        let mut rect = RECT::default();
        let (w, h) = if GetWindowRect(hwnd, &mut rect).is_ok() {
            (rect.right - rect.left, rect.bottom - rect.top)
        } else {
            (0, 0)
        };

        // 2. 限制在显示器工作区内
        let (mut nx, mut ny) = (x, y);
        let hmonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
        let mut mi = MONITORINFO {
            cbSize: std::mem::size_of::<MONITORINFO>() as u32,
            ..Default::default()
        };
        if GetMonitorInfoW(hmonitor, &mut mi).as_bool() {
            let min_x = mi.rcWork.left;
            let min_y = mi.rcWork.top;
            let max_x = mi.rcWork.right - w;
            let max_y = mi.rcWork.bottom - h;
            if max_x > min_x { nx = nx.clamp(min_x, max_x); }
            if max_y > min_y { ny = ny.clamp(min_y, max_y); }
        }

        // 3. HWND_TOPMOST 保持「始终置顶」
        let _ = SetWindowPos(
            hwnd,
            Some(HWND_TOPMOST),
            nx, ny, 0, 0,
            SWP_NOSIZE | SWP_NOACTIVATE,
        );
    }
}

别忘了 Cargo.toml 要开对应的 feature:

toml 复制代码
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
    "Win32_UI_WindowsAndMessaging",
    "Win32_Foundation",
    "Win32_Graphics_Gdi",      # MonitorFromWindow / GetMonitorInfoW
] }
raw-window-handle = "0.6"

七、知识点小结

这次踩坑提炼出的 4 个可复用经验:

  1. HWND_TOPHWND_TOPMOST 任何一次带 Z 序参数的 SetWindowPos 都会重定义窗口层级。想保持层级,用 HWND_TOPMOST 或加 SWP_NOZORDER框架帮你设置的窗口属性,很可能被你自己的原生 API 调用悄悄覆盖掉。

  2. winit 0.30 的 CursorMovedPhysicalPosition 不是逻辑坐标。涉及坐标计算时,先确认两端的单位是否一致,别凭印象。

  3. WM_SYSCOMMAND / SC_MOVE 不能跨事件循环使用 它依赖"当前线程正处于鼠标按下的消息上下文"。在 winit / Tauri 这类异步派发事件的框架里直接发送是无效的。

  4. 软件渲染下,"移动"和"重绘"要解耦 softbuffer 这类软渲染,每次 present 都是整块 blit。窗口移动本身不需要重绘内容,要避免在移动路径上触发无意义的重绘------但也别一刀切禁止,否则遇到尺寸变化会丢内容。


八、结果

改动后:

  • ✅ 拖拽无重影
  • ✅ 拖拽跟手,无滞后
  • ✅ 拖完仍然置顶
  • ✅ 拖不出屏幕边界
  • ✅ 内容不会丢失变透明

整个程序依然保持极轻量------没有引入 WebView,没有 GPU 渲染,纯 Rust 软绘,适合这种"就贴一张图"的桌面宠物场景。


写在最后

回过头看,这个 bug 最有意思的地方在于:三个症状里,最不起眼的那个(置顶失效)反而是唯一真正的"错误" 。重影和不跟手只是体验问题,而 HWND_TOP 是实打实地覆盖了框架设置------而且它非常隐蔽,因为拖拽功能"看起来是正常的",只有当你打开另一个窗口时才会发现宠物被盖住了。

所以排查这类问题时,除了优化性能,也要留意"你的代码是否在无意中覆盖了框架的默认行为"

如果你也在做类似的桌面小工具,希望这篇能帮你少踩几个坑。欢迎交流。

相关推荐
再吃一根胡萝卜18 分钟前
Rust 桌面宠物进化记:从静态图标到会动的"QQ 宠物"
后端
2601_9620662337 分钟前
SpringBoot3 快速启动框架
java·spring boot·后端
卷无止境3 小时前
Python能做嵌入式开发吗?一份写给动手派的生态与硬件全景图
后端·python
Sherotree9 小时前
Google Antigravity——Agent 被提到主界面
前端·ide·后端·edge浏览器
2601_962056239 小时前
Spring Boot 入门 与 无法解析符号 springframework 的解决
java·spring boot·后端
你驴我10 小时前
WhatsApp 多账号场景下的会话归档与历史消息检索优化实践
后端·python
学长毕业设计10 小时前
基于SpringBoot的瑜伽馆网站的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
Rain的Java大神之路11 小时前
PC版网站被狂刷怎么处理
java·数据库·redis·后端·mysql·web安全·运维开发
学长毕业设计13 小时前
基于springboot的云南中草药知识普及管理网站的设计与开发(源码+文档+讲解视频)
java·spring boot·后端