前面写过不少工具类小项目,但最近做的这个 Qt 悬浮工具箱,算是实用性最强的一个。它解决的是我日常写代码时的一个老大难问题:在编辑器和各种小工具之间来回切换,光 Alt+Tab 就按到怀疑人生。把常用功能做成一块悬浮在屏幕上方的无边框小面板,随时呼出、随手操作,效率确实高了不少。这篇把实现思路、核心源码、以及踩过的一些坑完整分享出来,代码是基于 C++ 和 Qt Widgets 写的,结构上适配 Qt 5.15 和 Qt 6,适合有一定 Qt 基础、想自己做桌面效率工具的开发者参考。
1. 一个悬浮工具到底解决了什么问题
1.1 使用场景:多个工具反复切换的低效
我自己的日常开发状态是这样的:写代码时经常需要启动某个命令行工具、临时看一眼屏幕某个位置的颜色值、确认系统 CPU 占用是不是又飙了、复制好几段代码块留作对比。每一步都意味着从当前窗口切走,在任务栏里找到目标程序,操作完再切回来。一次两次还好,一天重复几十次,整个人的注意力就全被打散了。
悬浮工具箱的核心价值,就是把这些高频操作全部收进一块“永远在顶层、随时能点、用完不挡事”的小面板里。它平时贴在屏幕右侧边缘,鼠标移过去会自动滑出来,移开又自动缩回去,既不干扰主工作区,又随叫随到。这个交互模型是我觉得最舒服的,比全局快捷键呼出那种方式更直观,也比纯固定在桌面上更不惹眼。
1.2 功能范围:先做真正高频的几件事
做工具类项目最容易犯的毛病就是功能堆砌。我第一版也幻想过把计算器、笔记、待办、天气全塞进去,后来冷静了一下,悬浮面板的空间就那么大,塞满按钮反而失去了效率工具的意义。
最终这一版只保留了四组功能:
| 模块 | 具体功能 | 实现难度 |
|---|---|---|
| 快速启动 | 点击按钮拉起常用的外部程序 | 低 |
| 屏幕取色 | 鼠标所在位置像素的 RGB/HEX 值 | 中 |
| 系统监控 | CPU 占用率、可用内存实时显示 | 中 |
| 剪贴板历史 | 记录最近复制的文本,点击回填 | 中 |
选这四个模块的逻辑很简单:它们都是“用完即走”的操作,不需要额外窗口,非常适合放在悬浮面板里。像笔记、待办这种需要长时间输入的功能,就不该出现在这里,否则面板会越做越复杂。
1.3 UI 交互设计:圆角卡片而不是传统窗口
传统带标题栏的窗口直接拿来用,效果会很生硬,一条灰白的系统标题栏放在深色面板上非常突兀。我的设计目标是让它看起来像一块“浮在桌面上方的半透明毛玻璃卡片”,没有标题栏、没有边框、四周带一点柔和阴影,背景半透明。
这就涉及 Qt 无边框窗口和半透明背景两个基础能力:通过Qt::FramelessWindowHint去掉系统窗口边框,通过Qt::WA_TranslucentBackground让窗口背景支持 alpha 通道,然后自己在paintEvent里绘制圆角矩形背景。这两个属性是整个悬浮窗技术栈的基石,后面所有功能都建立在它们之上。
2. 悬浮窗的技术底座:窗口标志位与透明背景
2.1 为什么普通窗口不能直接做悬浮工具
很多第一次接触悬浮窗的人会问:QWidget拖出来就是一个小窗口,直接 setStyleSheet 改个背景不就行了?其实不行。普通窗口默认带着系统标题栏,你还得处理系统按钮、拖拽行为、焦点策略,视觉上也做不到真正的“悬浮卡片”效果。
更关键的是点击穿透和置顶这两个行为。普通窗口最小化、关闭行为都很常规,但悬浮工具箱需要的是:永远不被遮挡、鼠标点到透明区域时事件能直接穿透到下层窗口。这些都属于窗口系统层面的属性控制,不是单纯样式表能搞定的。
2.2 核心属性:FramelessWindowHint、WindowStaysOnTopHint、WA_TranslucentBackground
窗口创建的核心配置就几行:
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool); setAttribute(Qt::WA_TranslucentBackground); setAttribute(Qt::WA_ShowWithoutActivating);逐条解释一下。
Qt::FramelessWindowHint去掉系统边框和标题栏。Qt::WindowStaysOnTopHint让窗口始终保持在 Z 序最上层。Qt::Tool则是一个容易被忽略但很重要的标志:它让窗口不占据任务栏位置,也不会在 Alt+Tab 列表里出现——悬浮工具就应该是这样的配角身份。此外Qt::WA_ShowWithoutActivating让窗口出现时不去抢焦点,点它按键时不会打断当前输入窗口的状态,这个对“随时取用、用完即走”的体验很关键。
透明背景需要注意一个匹配关系:只设WA_TranslucentBackground还不够,如果窗口没有去除边框,透明效果会被系统边框挡住。因此三个属性必须组合使用。
2.3 自绘圆角背景与阴影
无边框之后,整个窗口的视觉都得自己画。我的做法是在paintEvent里用 QPainter 绘制圆角矩形,再叠加一层阴影效果:
void FloatPanel::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); // 阴影区域:先画一圈半透明的黑色,模拟投影 QRectF shadowRect = rect().marginsRemoved(QMarginsF(kShadowMargin, kShadowMargin, kShadowMargin, kShadowMargin)); painter.setPen(Qt::NoPen); painter.setBrush(QColor(0, 0, 0, 80)); painter.drawRoundedRect(shadowRect.translated(0, 4), kRadius, kRadius); // 主体背景 painter.setBrush(QColor(36, 36, 42, 220)); painter.drawRoundedRect(shadowRect, kRadius, kRadius); }这里kShadowMargin为阴影留出左右上下空间,kRadius控制圆角弧度。如果窗口实际内容铺满整个 rect,圆角外反而会露出四个白色直角,所以我统一让所有子控件布局在shadowRect内部。背景用了半透明黑色,能让后面的桌面透过来一点,视觉上比实心舒服得多。
3. 核心源码:搭出一个能拖动、能贴边的悬浮窗
3.1 工程结构与继承关系
项目采用最简洁的单一主窗口类加几个工具类的方式。没有必要一开始就上复杂的插件架构,等模块多了再拆不迟。
FloatPanel // 主悬浮窗,继承 QWidget,负责整体布局和窗口行为 ├── DragController // 拖拽处理:按下、移动、贴边判断 ├── QuickLauncher // 快速启动模块 ├── ColorPicker // 取色模块 ├── SysMonitor // 系统监控模块 └── ClipboardHist // 剪贴板历史模块主窗口持有这些模块的实例,并用QHBoxLayout或QGridLayout排列按钮区域。模块之间互不依赖,通过主窗口的信号槽总线交互。
3.2 可拖动的自定义标题区
无边框窗口需要自己实现“按住面板空白处拖动窗口”的逻辑。这一步有个特别容易出错的细节:计算偏移量时到底用globalPos()还是frameGeometry().topLeft()。
第一版我用了错误的写法:
void FloatPanel::mousePressEvent(QMouseEvent *event) { m_dragPos = event->globalPos() - pos(); // 错误示范 }pos()返回的是窗口相对父对象的坐标,在无边框场景下基本等于 geometry 的左上角,但一旦以后改成带阴影的外边距或者加了动画,坐标系统就会乱。稳妥的做法是基于frameGeometry().topLeft()计算,它始终表示窗口真实边框的左上角:
void FloatPanel::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_dragOffset = event->globalPos() - frameGeometry().topLeft(); m_dragging = true; } } void FloatPanel::mouseMoveEvent(QMouseEvent *event) { if (m_dragging && (event->buttons() & Qt::LeftButton)) { move(event->globalPos() - m_dragOffset); } } void FloatPanel::mouseReleaseEvent(QMouseEvent *event) { m_dragging = false; handleEdgeSnap(); }鼠标偏移量在按下时计算,移动时用全局坐标减去偏移,得到窗口新位置。这样无论按在面板的哪个位置,窗口都不会发生“跳变”。
只在窗口空白区域拖动当然更合理。想限定可拖动范围,可以做一个子区域 QFrame 作为“拖动条”,把三个鼠标事件放到拖动条类里处理,避免按钮点击也被误解为拖拽起点。我实际用的是后者,体验更稳。
3.3 贴边自动隐藏与鼠标感应弹出
贴边隐藏是这类悬浮工具的灵魂交互。窗口靠近屏幕边缘时自动隐藏大部分区域,只露几个像素的“耳朵”用于感应鼠标进入。
判断逻辑放在handleEdgeSnap()里:
void FloatPanel::handleEdgeSnap() { QScreen *screen = QGuiApplication::screenAt(this->pos()); if (!screen) return; QRect avail = screen->availableGeometry(); int threshold = 12; // 距离边缘多近算“贴边” bool atLeft = (frameGeometry().left() - avail.left()) <= threshold; bool atRight = (avail.right() - frameGeometry().right()) <= threshold; if (atLeft) { m_edge = Edge::Left; m_hiddenPos = QPoint(avail.left() - width() + kVisiblePeek, pos().y()); } else if (atRight) { m_edge = Edge::Right; m_hiddenPos = QPoint(avail.right() - width() + width() - kVisiblePeek, pos().y()); } // hide }贴边的本质是把窗口位移到屏幕外,但保留kVisiblePeek像素露在外面供鼠标悬停触发。再加上动画,会让整个过程自然很多。这里我用了QPropertyAnimation对窗口的pos属性做平滑移动,避免生硬跳变。
鼠标感应弹出也不复杂,重写enterEvent:
void FloatPanel::enterEvent(QEnterEvent *event) { if (m_edge != Edge::None) { slideBack(); } QWidget::enterEvent(event); }鼠标进入露出的那个小边缘时,窗口自动滑回完整位置;鼠标离开并经过leaveEvent延迟判断后,再次贴边隐藏。判断里要加一个 300 毫秒左右的 QTimer 延迟,防止鼠标划过面板时频繁弹出缩回造成抖动。
3.4 位置记忆:使用 QSettings 持久化
如果一个悬浮工具每次启动都出现在固定位置,次数多了会让人烦。我用 QSettings 保存窗口位置和贴边状态,启动时恢复:
void FloatPanel::saveState() { QSettings settings("YourApp", "FloatPanel"); settings.setValue("pos", frameGeometry().topLeft()); settings.setValue("edge", static_cast<int>(m_edge)); } void FloatPanel::restoreState() { QSettings settings("YourApp", "FloatPanel"); QPoint pos = settings.value("pos", QPoint(0, 0)).toPoint(); m_edge = static_cast<Edge>(settings.value("edge", 0).toInt()); move(pos); }注意一个细节:QSettings的 organization 和 application 名称写死在你自己的工程里,不要用默认空值,否则配置会写进系统默认的杂乱分组里,不便于管理和清理。
4. 高频工具模块的代码实现细节
4.1 快速启动器:用 QProcess 拉起外部程序
这个模块最简单但最常用。点击面板上的按钮,直接启动配置好的程序路径:
void QuickLauncher::launch(const QString &program, const QStringList &args) { bool ok = QProcess::startDetached(program, args); if (!ok) { QMessageBox::warning(this, QStringLiteral("启动失败"), QStringLiteral("无法启动程序: %1").arg(program)); } }startDetached非常关键。它启动的子进程独立运行,不随主程序退出而终止,适合“点击启动一个应用后各干各的”这种场景。如果你用QProcess::start(),子进程会绑定在 parent 上,主进程退出它也就被带走,完全不符合快速启动器的预期。
实际使用中,程序路径可能会变。所以我没有写死路径,而是做了一份 JSON 配置文件,面板上的每个按钮对应一个 item,双击面板空白区域可以弹出编辑窗口改名称和路径。悬浮面板工具不是给自己一个人用的,这份可配置性后面帮了很大忙。
4.2 屏幕取色器:抓取鼠标所在像素颜色
取色原理不复杂:获取鼠标全局坐标,对当前屏幕对应坐标的 1x1 区域截屏,读取该像素的 RGB 值。
QColor ScreenUtil::pickColor(const QPoint &globalPos) { QScreen *screen = QGuiApplication::screenAt(globalPos); if (!screen) { return QColor(); } QPixmap pixmap = screen->grabWindow(0, globalPos.x(), globalPos.y(), 1, 1); QImage image = pixmap.toImage(); return image.pixelColor(0, 0); }screenAt是 Qt 5.10 之后才提供的 API,如果你还在用老版本,需要遍历QGuiApplication::screens()手动判断坐标落在哪个屏幕内。截屏坐标用的是全局坐标,这个在多屏环境下没问题。
取色模块交互上做了两层:平时按住取色按钮,按钮下方沿鼠标位置显示放大 200% 的局部预览;松开鼠标时把颜色复制到剪贴板,并显示#RRGGBB结果。放大预览的实现是在 QTimer 里以 30 帧的频率重绘一个局部缩放的 QPixmap,顺带画出十字准星辅助精准定位。
4.3 系统监控:CPU 与内存实时数值
CPU 占用的跨平台读取是这类模块最麻烦的地方。Windows 下可以用系统 API 拿到整体占用,Linux 下通常要解析/proc/stat。为了示例逻辑清晰,这里用 Qt 的方式通过 QTimer 周期读取,并将文本直接更新到面板标签上:
void SysMonitor::refresh() { // 简化版:调用平台相关函数、或读取 /proc、或使用兜底的 QTimer uint64_t cpuTime = readCpuTime(); uint64_t idleTime = readIdleTime(); // 两次采样的差值相除得到占用率 double usedPercent = 100.0 * (1.0 - (idleDelta / cpuDelta)); m_cpuLabel->setText(QStringLiteral("CPU %1%").arg(usedPercent, 4, 'f', 1, QLatin1Char('0'))); // 内存同理 qint64 totalMem = readTotalMemory(); qint64 usedMem = readUsedMemory(); m_memLabel->setText(QStringLiteral("MEM %1%") .arg(100.0 * usedMem / totalMem, 4, 'f', 1, QLatin1Char('0'))); }CPU 使用率不能靠单次采样,必须把两次采样之间的差值和空闲时间变化量算出比例,才能得到“最近一段时间”的平均占用。实际项目里我用一个 1000ms 的 QTimer,连续采样两次/proc/stat,差值除以总时间就是这段时间的 CPU 平均占用。开源方案里比较省事的是直接接一个系统信息库,能同时拿到 CPU、内存、网络、磁盘,代码更短,但依赖会重一些。
4.4 剪贴板历史:记录最近复制的内容
剪贴板历史记录在实现上比想得简单,核心就是QClipboard::dataChanged信号:
void ClipboardHist::init() { QClipboard *clipboard = QGuiApplication::clipboard(); connect(clipboard, &QClipboard::dataChanged, this, [this]() { QString text = QGuiApplication::clipboard()->text(); if (text.isEmpty()) return; if (m_history.contains(text)) { m_history.removeOne(text); // 移到最前 } m_history.prepend(text); while (m_history.size() > 20) { m_history.removeLast(); // 只保留最近 20 条 } }); }复制图片或复杂格式时text()可能返回空,这里就直接忽略。对纯文本工具的日常使用场景,这个过滤足够可靠。
交互上,点击剪贴板按钮会展开一个小型列表,点击任意条目就把对应内容回填到当前剪贴板。这个模块看起来不起眼,但它其实是我使用频率最高的一个功能,写代码时对比两段文本、在多个输入框间搬运内容,都靠它省了不少事。
5. 踩坑实录:几个让我调试半天的细节
5.1 坐标系统混乱:globalPos 与 frameGeometry 不一致
这是最典型的一个坑。我的第一版拖拽代码用的是event->globalPos() - pos(),看起来对,但实际运行中经常出现“窗口跑偏”现象——按下面板左上角拖拽时,窗口突然跳开一段距离。
原因是pos()返回的是窗口内容区的坐标位置,而无边框窗口搭配WA_TranslucentBackground后,系统可能额外产生一层透明边距,尤其是当你手动设置了windowMask或者窗口本身带阴影外框时,geometry和frameGeometry会有偏差。解决办法就是我前面写的:统一用frameGeometry().topLeft(),并且把鼠标偏移量在按下时就算清楚。
排查这类问题最快的方法是在mousePressEvent里临时打印所有位置信息:全局坐标、pos()、frameGeometry().topLeft()、geometry().topLeft()。打一遍你立刻就能看出差异来源,比瞎猜高效得多。
5.2 透明区域的鼠标事件穿透问题
实现截屏取色和系统监控后,我发现鼠标放在面板圆角外那一小块透明区域时,点击事件会丢——明明窗口在那里,按钮却点不着。后来查清是WA_TranslucentBackground导致的:透明像素区域默认不接收鼠标事件,事件直接穿到下层窗口去了。
这个其实不影响正常使用,因为透明区域本来也没放控件。真正的问题是,如果我想做“面板空白处可拖动”,而空白处的背景矩形带有透明区域时,点击可能点不中这块区域。
解决的套路有两个,具体看需求:
// 方式一:强制窗口整块区域都收鼠标事件 setAttribute(Qt::WA_TransparentForMouseEvents, false); // 方式二:平移 + 手动命中区域判断第一个方式简单,但会让圆角外的透明块也参与命中,鼠标悬停在圆角外一点点也会被面板“吸住”。第二个方式更精细:把所有子控件布局在有效矩形内,然后用childAt(event->pos())判断命中的控件,空则直接忽略或执行拖拽逻辑。推荐做工具面板时用第二种,体验更自然。
5.3 多屏环境下坐标与屏幕判断的异常
多屏用户大概率会遇到这个问题:把悬浮窗拖到第二块屏幕,重启后它却跑到了主屏幕上。原因在于保存位置时用的是普通坐标恢复,而第二屏可能在主屏左侧,坐标值直接是负数,QSettings存负整数没问题,但有的符号判断或错误的availableGeometry查询会让窗口被“矫正”回主屏。
另一个隐蔽问题是screenAt(pos())可能因为旧版 Qt 的 bug 返回 null,导致贴边逻辑失效。我的处理是每次计算屏幕区域前先主动请求一次屏幕列表:
QScreen *screen = QGuiApplication::screenAt(this->pos()); if (!screen) { screen = QGuiApplication::primaryScreen(); }同时,保存状态时不要再启动时恢复move(),改为恢复setGeometry(),并额外做一次边界钳制:如果窗口的可见部分没有落在任何一块已知屏幕的有效区域内,就把窗口重新摆到主屏幕中央。这个逻辑能避免屏幕热插拔后窗口丢失在虚拟空间里的诡异情况。
5.4 全局热键的注册与冲突
我最初计划用快捷键呼出悬浮窗,但全局热键依赖平台相关 API,Qt 本身在高版本提供了部分跨平台封装,但低版本还是需要调用原生接口。测试中发现两个问题:一是热键注册失败时没有明确提示,二是与系统的组合键冲突时静默失效。
后来处理方案是做一个双保险:
- 第一次注册失败就立刻给出提示条,并且在下一次启动时自动重试;
- 允许用户在设置界面自定义组合键,避免和系统常用快捷键强行抢。
另外,热键回调内不要做重活,只发信号让 Qt 事件循环去调度 UI 操作,否则响应会感觉卡顿。
6. 让这个项目真正好用的几件收尾工作
6.1 源码目录组织与可维护性
模块多了以后,把所有实现都堆在FloatPanel类里会很痛苦。我的做法是把每个工具模块做成独立的 QObject 子类或 QWidget 子类,主窗口只负责创建和布局。这样新增一个“TODO 便签”模块时,只需要写一个类、加到面板布局里,十几分钟就能接完。
具体目录结构我放在这里供参考:
FloatPanel/ ├── main.cpp ├── floatpanel.h/cpp // 主悬浮窗 ├── dragcontroller.h/cpp // 拖拽与贴边逻辑 ├── modules/ │ ├── quicklauncher.h/cpp │ ├── colorpicker.h/cpp │ ├── sysmonitor.h/cpp │ └── cliphist.h/cpp └── resources/ ├── styles.qss // 统一样式表 └── icons/ // svg 图标6.2 把按钮配置做成外部 JSON 文件
按钮和模块配置不应该写死在代码里。我在项目根目录放了一个tools.json,大致结构如下:
{ "launcher": [ { "name": "编辑器", "path": "/usr/bin/editor" }, { "name": "终端", "path": "/usr/bin/terminal" } ], "hotkey": "Ctrl+Alt+F", "autoStart": true }程序启动时读取配置动态生成按钮。这样即便不懂 Qt 的人也能通过改 JSON 来增删工具,悬浮面板的实用性会明显提升。
6.3 打包、体积与启动速度优化
Qt 程序打包后体积往往偏大,但悬浮工具这类小工具不太适合动不动几十 MB。我在发布版里关掉了不需要的模块,并用strip减小二进制体积。静态链接 Qt 库可以再压一部分体积,不过要注意许可证合规问题,采用动态链接加对应运行库拷贝的方式在多数场景下更稳妥。
启动速度方面也做了一层优化:悬浮窗默认启动时不立即加载所有模块的图标资源,而是等面板真正滑出时再异步懒加载,体验上会让首次展开稍慢一点点,但程序冷启动的速度会快很多。
6.4 这套代码还能往哪些方向扩展
如果后续想继续升级这个悬浮工具箱,我建议优先加三个方向:
- 插件系统:把每个模块做成独立动态库,主程序只留一个加载器,新增模块无需重新编译主程序。
- 主题系统:用 QSS 变量统一管理颜色和圆角,让用户切主题跟切肤色一样简单。
- 云同步:把 JSON 配置同步到本地网盘目录,多台机器共用一份配置。
我个人的实际感受是,这个项目花时间最多的不是功能代码,而是窗口行为细节的打磨。无边框、半透明、置顶、贴边、穿透,这些 QT 里看似各自独立的小特性,组合到一起之后会产生很多交互层面的连锁反应。代码量其实不大,主线也就千行左右,但把每个细节调顺的过程,才是桌面端工具开发真正有意思的地方。源码里各种控件的坐标、动画时长、边缘距离阈值都是反复试出来的,如果你照着实现过程中遇到手感不丝滑的地方,大胆调参数就好,这套框架本身足够灵活。