做桌面 UI 的人迟早会碰到这个需求:去掉系统标题栏,自己画窗口。圆角、投影、自定义标题栏按钮、拖拽移动——这些在设计稿上很常见,实现起来却有一堆系统层面的坑。这篇文章以我在 7AA7工具箱里做界面的实践为背景,把 Qt 侧无边框窗口的实现要点和踩过的坑梳理一遍。
一、Qt 的底子:信号槽与元对象系统
先说两句 Qt 的编程模型,它决定了后面所有 UI 代码的写法。Qt 的核心是信号槽机制加元对象系统:
connect(button, &QPushButton::clicked,
this, &MainWindow::onAddClicked);
信号槽是类型安全的观察者模式,解耦了事件的发出方和处理方。配合 Qt 的元对象系统(Q_OBJECT 宏 + moc 生成的代码),还能获得运行时反射、属性系统、动态属性绑定这些能力。对 UI 开发来说,这意味着”状态变化自动更新界面”可以写得很声明式。
代价是构建流程里多了 moc/uic/rcc 的预处理步骤,初学者的编译错误有一半来自宏展开后的代码。CMake 里把 AUTOMON 打开能解决大部分问题,但要理解这套机制才能调试深入。
对无边框窗口这个需求,信号槽层面没什么特殊,真正的战场在窗口标志和事件处理。
二、去掉系统标题栏:三件事跟着丢掉
去掉标题栏只需一行:
setWindowFlags(Qt::FramelessWindowHint | Qt::Window);
但系统标题栏不是白给的,它附带了三样东西:窗口拖拽移动、边缘缩放、系统阴影/圆角。去掉了标题栏,这三样都要自己补回来。
拖拽移动是最基础的。思路是在 mousePressEvent 记录按下点与窗口左上角的偏移,在 mouseMoveEvent 里用 QCursor::pos() - 偏移 移动窗口:
void MainWindow::mousePressEvent(QMouseEvent *e) {
if (e->button() == Qt::LeftButton)
dragOffset = e->globalPosition().toPoint() - frameGeometry().topLeft();
}
void MainWindow::mouseMoveEvent(QMouseEvent *e) {
if (e->buttons() & Qt::LeftButton)
move(e->globalPosition().toPoint() - dragOffset);
}
坑在细节:全屏最大化时要禁用拖拽(否则会拖出屏幕外)、要处理”点在按钮上不该拖拽”的区域过滤、以及 Windows 上 DWM 对移动的处理在多显示器不同 DPI 下有坐标换算问题。
边缘缩放更麻烦。要在窗口边缘设置命中区域(通常 4~8 像素宽),按位置判断缩放方向,然后边移动 move() 边改 resize()。鼠标形状要跟着方向换(Qt::SizeFDiagCursor 等)。这段逻辑没有捷径,写一个 NativeResizeHandler 类封装是值得的。
窗口动画与系统菜单也要自己补:双击标题栏最大化、右键系统菜单、Win+方向键贴边、Aero Snap 这些系统行为都会消失。按需实现,通常双击最大化和基本拖拽是必须的,其余看产品要求。
三、圆角与抗锯齿:QSS 还是 paintEvent
圆角的实现有两条路,选哪条看需求的复杂度。
QSS 路线(样式表,类似 CSS):
MainWindow {
border-radius: 12px;
background: #ffffff;
}
优点是写起来快、和 Qt 的样式系统统一、支持状态切换(hover/pressed)。缺点是 QSS 的圆角渲染质量一般,尤其在高 DPI 下边缘容易有锯齿,而且 QSS 一旦写复杂,性能(样式重算)和可维护性都会下滑。
paintEvent 路线(自绘):
void MainWindow::paintEvent(QPaintEvent*) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
QPainterPath path;
path.addRoundedRect(rect().adjusted(1,1,-1,-1), 12, 12);
p.fillPath(path, QColor("#ffffff"));
p.setPen(QColor("#e0e0e0"));
p.drawPath(path);
}
自绘的好处是抗锯齿可控(Antialiasing hint)、像素级精度、可以画渐变和复杂路径。坏处是所有视觉细节都要自己维护,改个圆角大小要动代码。
我的实践是分层做:窗口的外壳(圆角、边框、投影)用自绘,保证清晰度;内部控件的样式用 QSS,保证开发效率。另外有个容易忽略的点——无边框窗口的圆角区域如果不做透明处理,四角会露出背景色的直角。需要 setAttribute(Qt::WA_TranslucentBackground) 让窗口背景透明,然后自绘圆角底色。这一步开了之后,窗口的合成方式会变,某些机器上可能引发性能或兼容问题,要真机测试。
投影的常见做法是在自绘里用 QPainterPath 画多层半透明圆角矩形模拟高斯模糊(简单但成本可控),或者用 QGraphicsDropShadowEffect(效果好但有性能开销)。做工具类软件我倾向前者——投影是装饰,不值得为它付太多 GPU。
四、DPI 适配:不只是乘个缩放比
高 DPI 是 Windows 桌面开发的长期战场。Qt 5.14 之后 QT_ENABLE_HIGHDPI_SCALING 相关的自动缩放已经比较成熟,但无边框窗口的场景有几个特殊问题:
自绘坐标要按设备像素比换算。 QPainter 的逻辑坐标是设备无关像素,但自己算边缘命中区域、阴影偏移时,别忘了 devicePixelRatioF() 的换算,否则 4K 屏上边缘缩放区域会细到点不中。
边框宽度是物理像素敏感的。 1 像素的描边在 150% 缩放下会变成 1.5 像素,Qt 会帮你做子像素渲染但可能发虚。习惯用 qRound 或者干脆按缩放比设宽度。
多显示器混合 DPI。 窗口从 100% 的显示器拖到 150% 的显示器,Qt 的 QWindow::screenChanged 信号要处理,自绘内容和尺寸要做相应调整。Windows 上还有 Per-Monitor DPI Awareness 的版本差异(v1/v2),manifest 里声明 v2 能拿到更正确的系统行为。
实践上的建议:一开始就把高 DPI 测试机放进开发流程,后补适配的成本远高于边做边测。
五、打包发布:windeployqt 的思路
开发完的最后一公里是打包。Qt 程序不能只发 exe——它依赖一堆 DLL(Qt 库、编译器运行时、插件),缺一个就白屏或启动报错。
windeployqt 是官方的部署扫描工具,它会分析 exe 的依赖,把需要的 DLL、platforms/qwindows.dll 这类插件拷贝到发布目录:
windeployqt --release --no-translations MyTool.exe
几个实践要点:
- 插件目录结构要保持。
platforms/、styles/、imageformats/这些子目录的位置错了,Qt 加载不到插件,症状是”启动无窗口”。 - MSVC 运行时要分发。 windeployqt 不管 Visual C++ Redistributable,要么让用户装,要么用静态运行时编译(
/MT)或把运行时 DLL 一起打包。 - 自绘/无边框窗口没有特殊的部署要求,但如果你用了 Qt Quick 或额外模块,记得把对应 DLL 也扫进去。
- 签名和哈希。 发布前对 exe 做代码签名(有证书的话),至少提供文件哈希。分发独立性是这类工具的信任基础。
我做 7AA7工具箱的安装包时,是把 windeployqt 的输出再配合自写的打包脚本做压缩分发——工具类软件讲究解压即用,依赖自包含是底线。
六、写在后面
无边框圆角窗口是个”入门容易、做完很难”的需求:Demo 二十行代码,打磨到能发布质量要处理拖拽、缩放、阴影、DPI、动画、可访问性一整串。Qt 的价值在于它把这些系统差异抹平了大半,剩下的部分需要对平台行为有具体的理解。
做桌面 UI 的乐趣也在这里——像素级的掌控感,和对操作系统行为的深度理解,是 Web 开发不常有的体验。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.