简介面向需要在Qt项目中实现无边框窗口和精美标题栏的开发者这份自定义UI资源包解决了系统自带标题栏样式固定、定制成本高的问题。工程真正支持标题栏双击、鼠标拖动及边缘拉伸调整大小采用纯QSS绘制按钮样式配合fontawesome字体图标完成最小化、最大化、关闭等交互样式简洁、易于扩展。压缩包共39个文件、约2.21MB包含C源文件与头文件、界面文件、工程文件、图标字体及绘图库同时附有可直接运行的演示程序。作者刻意将主窗体界面与功能界面分离注释两行代码即可切换适合想快速集成或仿照自定义窗体的Qt开发者。另含16种曲线绘制示例点击按钮即可切换可顺带熟悉QCustomPlot的常见用法已有2965人学习下载代码可免费用于任何项目。1. 系统标题栏不够用自定义 UI 的起点在哪先说结论QT 里的自定义标题栏工作量大头从来不是画界面而是把系统窗口管理器原来默默做完的拖动、缩放、状态切换全部接管回来。所谓「QT实现自定义UI-精美标题栏支持最小化最大化关闭鼠标拖动改变大小」翻译成具体需求就是去掉系统边框自己画一条标题栏自己响应三个窗口按钮自己处理鼠标拖动移动窗口自己处理边缘拖拽调整大小。适合做这件事的开发者是那种界面原型已经定稿、整体视觉风格和系统控件明显不一致、又不想引入整套皮肤库的桌面端项目。这件事入门不难但涉及窗口标志、事件坐标体系、窗口状态同步三条线每一条都有大量细节会在运行期反咬一口。我把从零到可复用的完整路径整理在下面每一段代码都对应一个明确边界方便你直接抄作业。2. 搭建最小可运行的窗口框架无边框窗口、阴影与标题栏骨架2.1 去掉系统边框但不丢视觉层级窗口标志与透明背景的组合在 QT 中去掉系统窗口装饰的标准做法是setWindowFlags传入Qt::FramelessWindowHint这一点几乎所有资料都会告诉你。但把这一行加进构造函数之后你会立刻看到三个连锁反应。第一窗口四角的系统阴影没了窗口边缘像贴纸一样贴在桌面上视觉层级明显变弱。第二窗口边缘不再有可拖拽的命中区域用户拖边调整大小的习惯失效。第三系统双击标题栏最大化、右键标题栏弹出系统菜单这些行为全部消失。换句话说去掉系统装饰不是删除一个问题而是把一整套问题转移到了你身上。所以我一般不会单独使用FramelessWindowHint而是同时打开WA_TranslucentBackground// 放在主窗口构造函数的最前面控制窗口级行为 setWindowFlags(Qt::FramelessWindowHint); // 去掉系统标题栏和窗口边框 setAttribute(Qt::WA_TranslucentBackground); // 背景透明为阴影和圆角留出绘制空间这两行代码必须在show()之前执行不要在showEvent里临时追加窗口标志否则窗口管理器可能来不及处理变更轻则窗口隐形重则 resize 失灵。我最早在某个跨平台系统开发里踩过一次在showEvent里追加Qt::WindowStaysOnTopHint窗口直接消失排查了很久才发现是show()之后改 flags 导致窗口被系统按新参数重建。凡是涉及 window flags 的操作都放到构造函数早期这是条铁律。透明背景是有代价的窗口会变成 composited 分层窗口。普通窗口只有内容损坏时才重绘分层窗口每次移动和缩放都要重新合成CPU 和 GPU 开销明显更大。这里有个取舍如果你对 resize 流畅度敏感不接受整个窗口透明合成的开销那就退一步保留FramelessWindowHint但去掉透明背景阴影改用QGraphicsDropShadowEffect挂在标题栏和内容区的父级控件上模拟。QGraphicsDropShadowEffect的阴影是对 widget 内容做离屏渲染性能也不算好但它比整个窗口透明合成更可控阴影失效时容易定位。两种方案我都用过结论是只有整个窗口要做圆角、异形或沉浸式背景时才值得开透明背景只是想要一点阴影用QGraphicsDropShadowEffect更省心。注意setAttribute(Qt::WA_TranslucentBackground)打开后窗口背景的绘制全部交给 QPainter。如果你不小心用不透明背景色填充了整个窗口阴影会被背景色盖住透明背景等于白开。调试阴影时先画一个带 alpha 的纯色看效果再上正式 UI。2.2 把标题栏拆成独立的 QWidget成员设计、布局与信号接口标题栏不应该直接写成主窗口里的几个按钮堆在顶部而应该拆成一个独立的TitleBar类继承QWidget。原因有两个一是主窗口的resizeEvent、eventFilter已经够复杂再混入标题栏布局代码后面没法维护二是自定义标题栏的视觉和交互大概率还会调整独立成类之后改标题栏不会碰到主窗口其他逻辑。类的核心成员如下// TitleBar.h 头文件核心部分 class TitleBar : public QWidget { Q_OBJECT public: explicit TitleBar(QWidget *parent nullptr); void setMaximizedState(bool maximized); // 由主窗口在状态变化时调用 void setTitleText(const QString text); // 设置标题文字 signals: void minimizeRequested(); // 请求最小化 void maximizeRequested(); // 请求最大化或还原 void closeRequested(); // 请求关闭 protected: void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; void mouseDoubleClickEvent(QMouseEvent *event) override; private: QLabel *m_iconLabel; QLabel *m_titleLabel; QPushButton *m_minBtn; QPushButton *m_maxBtn; QPushButton *m_closeBtn; QPoint m_dragStartPos; QPoint m_windowStartPos; bool m_dragging; bool m_isMaximized; };这里把三个按钮都设为私有成员不是仅仅为了美观而是后面所有交互逻辑都要读写这几个按钮的状态。信号只暴露三个「请求」而不是直接操作窗口是我一贯的接口习惯。标题栏组件不应该知道外层窗口是谁它只负责发出意图主窗口决定意图怎么落地。这样当你的应用从单体主窗口演进到多文档界面、每个子窗口都要带自己的标题栏时TitleBar的代码一行不用改。构造函数里的布局写起来比较直白但有两个细节需要立规矩。第一个是按钮尺寸固定不随布局自动拉伸否则三个按钮在窗口拉宽时会变形。第二个是标题栏高度固定内容区高度由剩余空间自动填充这样标题栏在任何窗口尺寸下都保持视觉一致性TitleBar::TitleBar(QWidget *parent) : QWidget(parent) { m_minBtn new QPushButton(this); m_maxBtn new QPushButton(this); m_closeBtn new QPushButton(this); m_minBtn-setFixedSize(32, 32); m_maxBtn-setFixedSize(32, 32); m_closeBtn-setFixedSize(32, 32); m_iconLabel new QLabel(this); m_titleLabel new QLabel(this); m_layout new QHBoxLayout(this); m_layout-setContentsMargins(10, 0, 6, 0); m_layout-setSpacing(4); m_layout-addWidget(m_iconLabel); m_layout-addWidget(m_titleLabel); m_layout-addStretch(); m_layout-addWidget(m_minBtn); m_layout-addWidget(m_maxBtn); m_layout-addWidget(m_closeBtn); setFixedHeight(36); }这里把m_iconLabel和m_titleLabel放在最左中间用addStretch()把按钮组推到最右侧。如果你希望标题栏中间放搜索框或工具栏把 stretch 的位置调整到按钮组左边或右边即可布局结构不用变。按钮图标我一般用 SVG 资源而不是 PNG因为高分屏下 PNG 放大发虚SVG 由 Qt 按devicePixelRatio自动渲染清晰度稳定。SVG 图标加载直接用QIcon(:/images/minimize.svg)前提是.qrc资源文件已经把 svg 文件登记进去这个坑在 Qt Creator 里很常见——资源路径写错图标空白窗口看起来像三个隐形按钮。主窗口接入TitleBar的代码很固定不再用setMenuWidget或setWindowTitle去影响标题栏而是把TitleBar作为第一个子控件放在垂直布局顶部QVBoxLayout *mainLayout new QVBoxLayout(this); mainLayout-setContentsMargins(0, 0, 0, 0); mainLayout-setSpacing(0); mainLayout-addWidget(titleBar); // 标题栏固定高度 mainLayout-addWidget(contentWidget); // 内容区撑满剩余空间到这里一个最小可运行的框架已经成型窗口无边框、有透明背景、有标题栏布局但没有任何窗口行为。下一章把三个按钮的事件线补齐再处理拖动和缩放。3. 实现最小化/最大化/关闭按钮事件与状态同步3.1 三段式信号转发把按钮点击和窗口行为解耦按钮行为第一版很多人会直接写成这样connect(m_minBtn, QPushButton::clicked, window(), QWidget::showMinimized);这个写法在单窗口程序里能跑通但有一个隐患window()返回的是按钮所在控件的最顶层窗口。如果TitleBar被嵌入到自定义容器、嵌套布局或某个 Dock 里window()返回的对象不一定是业务主窗口最小化行为就会落在错误的窗口上。另外这个写法把标题栏和具体窗口行为焊死了后续想加最小化动画、想记录用户行为日志都得回来改组件。我习惯的写法是信号转发标题栏只发意图// TitleBar 内部按钮点击 - 自定义信号 connect(m_minBtn, QPushButton::clicked, this, TitleBar::minimizeRequested); connect(m_maxBtn, QPushButton::clicked, this, TitleBar::maximizeRequested); connect(m_closeBtn, QPushButton::clicked, this, TitleBar::closeRequested);外层主窗口再接收信号去落地具体行为// MainWindow 构造中连接 TitleBar 信号 connect(titleBar, TitleBar::minimizeRequested, this, MainWindow::onMinimize); connect(titleBar, TitleBar::maximizeRequested, this, MainWindow::onToggleMaximize); connect(titleBar, TitleBar::closeRequested, this, MainWindow::close);void MainWindow::onMinimize() { showMinimized(); } void MainWindow::onToggleMaximize() { if (isMaximized()) { showNormal(); } else { showMaximized(); } }参数说明onToggleMaximize里用isMaximized()判断当前状态再决定走showNormal()还是showMaximized()这个判断是必须的因为它直接影响changeEvent里接收到的状态变化进而影响图标刷新。如果你在按钮 click 信号里直接写showMaximized()不判断当前状态还原操作会失效。这里还要补一个视觉细节三个按钮不能只有图标没有反馈。鼠标悬停时背景色变化、按下时按钮略微下陷这些交互提示看起来是小事情但没有它们用户分不清按钮到底有没有被点到。用 QSS 样式表时注意QPushButton的border要显式写成none否则默认边框会出现一条系统风格的黑边QPushButton#minBtn, QPushButton#maxBtn, QPushButton#closeBtn { border: none; border-radius: 4px; background: transparent; } QPushButton#closeBtn:hover { background: #e81123; /* 关闭按钮悬停用警示色 */ }3.2 双击标题栏最大化事件目标判定与阈值双击标题栏空白区域最大化或还原是系统行为的基本盘不能丢。实现里最容易翻车的是事件目标判定双击发生在按钮上时按钮自己会消费双击事件但双击发生在图标、标题文字或空白区域时事件会自然冒泡到TitleBar。所以要在mouseDoubleClickEvent里筛一遍命中的子控件void TitleBar::mouseDoubleClickEvent(QMouseEvent *event) { // 只有落在标题栏自身的空白区域才处理 QWidget *child childAt(event-pos()); if (child this || child m_iconLabel || child m_titleLabel) { emit maximizeRequested(); // 请求切换最大化/还原 } else { QWidget::mouseDoubleClickEvent(event); } }这里的childAt()是坐标命中检测的关键。按钮区域在上层鼠标按在按钮上时childAt返回的是按钮而不是TitleBar所以按钮区域天然被排除。child this这一句用来兜住标题栏空白区因为childAt命中空白区域时返回的是父控件本身。双击事件和拖动事件存在时序冲突。双击的物理过程是两次 press/release如果你在mousePressEvent里已经开始记录拖动起点第一次 press 就会把窗口移动逻辑触发一遍用户会看到「双击时窗口先抖了一下」。解决办法在第四章的拖动代码里核心是给移动加一个位移阈值位移不超过 3 像素就不移动窗口。这个 3 像素不是拍脑袋它同时规避了手抖误拖和双击抖动。3.3 窗口状态变化与图标刷新changeEvent 的正确用法最大化按钮的图标要在「最大化」和「还原」两个状态之间切换这个切换不能只在按钮点击时做。因为用户还可能通过键盘快捷键、任务栏、甚至外部脚本改变窗口状态按钮图标如果只跟着点击走必然出现图标和实际状态不一致。正确做法是监听QEvent::WindowStateChange让状态驱动 UIvoid MainWindow::changeEvent(QEvent *event) { if (event-type() QEvent::WindowStateChange) { const bool maximized windowState().testFlag(Qt::WindowMaximized); titleBar-setMaximizedState(maximized); } QMainWindow::changeEvent(event); }// TitleBar 内根据状态刷新图标与提示 void TitleBar::setMaximizedState(bool maximized) { m_isMaximized maximized; if (maximized) { m_maxBtn-setIcon(QIcon(:/icons/restore.svg)); m_maxBtn-setToolTip(tr(还原)); } else { m_maxBtn-setIcon(QIcon(:/icons/maximize.svg)); m_maxBtn-setToolTip(tr(最大化)); } }这里有个血泪经验不要在按钮clicked信号里直接取window()-isMaximized()去判断因为点击信号触发时窗口状态可能还没更新拿到的是旧状态图标就会晚一步甚至错一步。正确顺序是先执行showMaximized()或showNormal()窗口状态变更后changeEvent被调用图标跟着刷新。把图标刷新统一交给窗口状态事件而不是按钮点击是消除不一致最稳的手段。注意某些 Linux 窗口管理器下程序化最大化之后WindowStateChange事件不会立刻到达可能有数百毫秒延迟。遇到这种平台在onToggleMaximize里调用showMaximized()之后手动补一次titleBar-setMaximizedState(true)用主动查询和兜底刷新补上事件延迟的缺口。4. 鼠标拖动与边缘调整大小事件坐标与几何计算的细节4.1 拖动标题栏移动窗口全局坐标与窗口起点记录窗口拖动是自定义标题栏里最容易写错的地方。很多初版代码会写成在mousePressEvent里记录event-pos()在mouseMoveEvent里用当前pos()和起点pos()的差值去move()窗口。这个方案在简单窗口上能跑但一旦窗口带透明背景、圆角遮罩或子控件嵌套层级event-pos()返回的是相对于标题栏控件的局部坐标和窗口坐标系之间存在偏移拖动距离会被错误地累计出现「越拖越歪」的现象。我惯用的可靠写法是全程使用全局坐标void TitleBar::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { m_dragStartPos event-globalPos(); // 鼠标按下时的全局位置 m_windowStartPos window()-frameGeometry().topLeft(); // 窗口当前全局位置 m_dragging true; event-accept(); return; } QWidget::mousePressEvent(event); } void TitleBar::mouseMoveEvent(QMouseEvent *event) { if (m_dragging (event-buttons() Qt::LeftButton)) { const QPoint delta event-globalPos() - m_dragStartPos; if (delta.manhattanLength() 3) { // 小于阈值视为点击不移动窗口 window()-move(m_windowStartPos delta); } } } void TitleBar::mouseReleaseEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { m_dragging false; } QWidget::mouseReleaseEvent(event); }这段代码的关键参数有两个。第一m_dragStartPos用event-globalPos()而不是event-pos()全局坐标不受控件嵌套层级影响无论事件从标题栏哪个子控件冒泡上来坐标基准都一致。第二m_windowStartPos取frameGeometry().topLeft()而不是pos()frameGeometry()返回窗口管理器眼中的窗口几何包括边框pos()返回的是客户区位置在部分平台上两者有偏差用错会导致窗口在按下瞬间跳一下。manhattanLength()返回的是水平距离加垂直距离的绝对值之和用这个做阈值判断比计算欧氏距离快效果完全够。3 像素阈值正好卡在双击的 press 和 release 之间能有效挡住「双击时窗口抖动」。4.2 边缘命中与光标切换九宫格区域检测算法窗口调整大小的关键在于边缘和角落的命中检测。系统标题栏时代这套逻辑由窗口管理器负责去掉边框后必须自己写。常见做法是给主窗口安装eventFilter在鼠标移动时根据全局坐标判断当前是否处于窗口边缘区域并切换光标形状。九宫格划分成四个边缘和四个角加上中间区域共九个判定分支bool MainWindow::eventFilter(QObject *obj, QEvent *event) { if (event-type() QEvent::MouseMove) { QMouseEvent *mouseEvent static_castQMouseEvent *(event); const QPoint globalPos mouseEvent-globalPos(); const QRect windowRect frameGeometry(); // 先判断是否最大化最大化状态下不能缩放 if (isMaximized()) { unsetCursor(); return QWidget::eventFilter(obj, event); } const int margin 6; // 边缘命中宽度建议按 DPI 缩放 const bool hitLeft qAbs(globalPos.x() - windowRect.left()) margin; const bool hitRight qAbs(globalPos.x() - windowRect.right()) margin; const bool hitTop qAbs(globalPos.y() - windowRect.top()) margin; const bool hitBottom qAbs(globalPos.y() - windowRect.bottom()) margin; if (hitLeft hitTop) setCursor(Qt::SizeFDiagCursor); else if (hitRight hitBottom) setCursor(Qt::SizeFDiagCursor); else if (hitLeft hitBottom) setCursor(Qt::SizeBDiagCursor); else if (hitRight hitTop) setCursor(Qt::SizeBDiagCursor); else if (hitLeft || hitRight) setCursor(Qt::SizeHorCursor); else if (hitTop || hitBottom) setCursor(Qt::SizeVerCursor); else unsetCursor(); } return QWidget::eventFilter(obj, event); }这段代码只处理了光标反馈是纯 UI 层的命中提示。真正改变窗口大小需要在mousePressEvent里记录命中边缘在mouseMoveEvent里按拖拽方向计算新几何执行setGeometry。边缘命中的记录可以用四个布尔位或枚举这里用枚举更清晰enum class ResizeDirection { None, Left, Right, Top, Bottom, TopLeft, TopRight, BottomLeft, BottomRight };margin的值有一个很实在的坑在 1080p 屏幕下 6 像素足够但在 2K、4K 高分辨率或者 Windows 150% 缩放下6 像素几乎无法用鼠标精准命中。我建议按devicePixelRatio换算基准 6 像素乘以缩放系数基线给出 6 到 12 的区间具体数值依屏幕尺寸和用户习惯微调。4.3 resize 时防闪烁透明背景的代价与应对打开WA_TranslucentBackground之后resize 过程的残影和闪烁几乎是必然的。常见表现是拖动窗口右边缘时内容区出现一列矩形残像或者阴影区域忽宽忽窄视觉上像没刷新干净。原因有两层。第一层透明背景让窗口变成分层合成模式每次缩放窗口管理器都要重新合成整个窗口远比普通窗口的重绘流程慢。第二层内容区普通控件在缩放时不会自动触发全量重绘除非你在resizeEvent里主动刷新。我遇到一次印象比较深的翻车窗口缩放时内容区某个自绘控件的 QPainter 绘制明显滞后能看见一块一块的矩形残影。排查后定位到根因是自绘控件没有设置Qt::WA_OpaquePaintEvent系统以为它需要保留背景缩放时把旧帧残影一起合成进来。修复分两步// 自绘内容控件设置不透明绘制事件 contentWidget-setAttribute(Qt::WA_OpaquePaintEvent);void MainWindow::resizeEvent(QResizeEvent *event) { QMainWindow::resizeEvent(event); // 缩放期间内容区强制刷新避免透明合成导致残影 contentWidget-update(); }这里有个取舍要说透透明背景和圆角阴影确实好看但代价是每次缩放和移动都会有合成开销。如果你的应用是高频绘制图形的场景比如实时曲线、视频渲染我建议放弃整个窗口的WA_TranslucentBackground改用不透明背景加一层模拟阴影边框缩放的流畅度会明显提升。视觉和性能之间的平衡需要按实际场景决策没有绝对正确的配置。5. 自定义标题栏避坑五条高频率踩坑记录5.1 拖动偏移越拖越远起始点记录错误导致累积偏差现象第一次按下标题栏拖动窗口正常松开后再次按下拖动窗口会先跳一大截之后越拖偏移量越大甚至拖到屏幕外面。原因m_windowStartPos在每次mousePressEvent时更新但mouseMoveEvent里使用差值移动时如果某次 press 的起点没有正确记录或者frameGeometry()在最大化状态下返回的是屏幕几何增量就会把错误值累计进去。解决每次mousePressEvent里务必同时更新m_dragStartPos和m_windowStartPos不要用成员变量的旧值兜底另外确认frameGeometry().topLeft()取的是当前窗口的实际位置而不是缓存的上一次值。5.2 双击未最大化反而错乱按钮状态事件目标与状态驱动不对齐现象双击标题栏空白区域窗口没有最大化但最大化按钮的图标或isChecked状态变了再次单击按钮行为又错乱。原因mouseDoubleClickEvent里childAt()命中了按钮区域按钮把双击事件也当成自己的clicked处理而状态刷新逻辑写在按钮clicked里导致 UI 状态先翻转窗口行为后执行或没执行。解决双击判定只处理child this和标题相关控件把按钮区域排除同时把按钮图标的状态刷新统一交给changeEvent驱动不在点击路径里做状态翻转。两处修复缺一不可只修一处还会在其他交互入口遇到状态不一致。5.3 最大化状态拖动标题栏窗口“粘”在屏幕顶部不下来现象窗口最大化后按住标题栏拖动窗口没有还原为普通状态而是保持最大化尺寸跟着鼠标跑顶部紧贴屏幕边缘无法重置位置。原因最大化状态下frameGeometry()返回的是屏幕工作区几何直接拿差值做move()等价于把最大化窗口当普通窗口移动几何语义完全错了。解决在mousePressEvent里判断window()-isMaximized()如果是最大化状态先调用showNormal()还原再用还原后的frameGeometry().topLeft()重新记录起点最后才执行移动。还原瞬间窗口位置会有轻微跳动这是窗口管理器重排几何带来的无法彻底消除但能控制在半个标题栏高度以内。5.4 无边框后无法调整大小边缘命中条件太窄与子控件吃事件现象去掉系统边框后鼠标移到窗口边缘光标没有变成双向箭头拖拽也没有任何反应。原因有两个可能同时存在。第一个边缘命中 margin 设成了 2 到 3 像素在高分屏上物理宽度太小鼠标实际命中不了。第二个窗口内容区里有QWebEngineView、QOpenGLWidget或播放视频画面的控件这类控件会独立捕获鼠标事件MainWindow的eventFilter根本收不到MouseMove。解决margin 按 DPI 缩放基准 6 像素2 倍缩放取 12 像素对吞事件的子控件在eventFilter里忽略它们或者用QTimer以 30ms 间隔轮询QCursor::pos()做命中判断虽然不算优雅但能覆盖所有控件类型。5.5 图标不随窗口状态刷新事件迟到时的双保险兜底现象用系统快捷键或任务栏把窗口最大化后标题栏上的最大化按钮还是旧图标点一下还原按钮图标才更新。原因窗口状态由外部程序改变时WindowStateChange事件在个别平台被延迟或者被某些全局事件过滤器拦截吞掉changeEvent里的刷新逻辑没有被执行。解决把状态同步做成双保险——事件驱动加上主动查询。在changeEvent里刷新图标同时在showEvent里补一次titleBar-setMaximizedState(window()-isMaximized())。主动查询不需要很频繁只在窗口被显示或切换焦点时做一次成本低但能覆盖所有事件丢失的边界。场景主驱动兜底点击标题栏按钮切换状态changeEvent收到状态变更onToggleMaximize手动刷新快捷键 / 任务栏切换状态changeEvent收到状态变更showEvent主动查询刷新外部脚本改窗口状态事件可能丢失主动查询模式兜底6. 封装可复用 TitleBar 的进阶技巧接口收敛、DPI 适配与自我验证走到这一步窗口拖动、边缘 resize、按钮逻辑都通了但真正让这个方案值钱的是把它封装成一个别人拿到就能用的组件。我最后给出三个建议。第一个建议是接口收敛。TitleBar对外的接口尽量控制在三个 signal 和两个 setter 里minimizeRequested、maximizeRequested、closeRequested以及setTitleText、setMaximizedState。不要把window()直接暴露出去也不要在TitleBar内部使用QApplication::activeWindow()之类全局函数去猜外层窗口。我之前在一个多窗口项目里试过让标题栏自己找父窗口结果窗口中嵌套窗口时行为完全失控。接口越窄复用越稳。第二个建议是 DPI 适配。标题栏高度和按钮尺寸写成 36、32 这类固定值在 150% 缩放屏幕上会显得按钮特别小在 125% 缩放下又偏大。我常用的写法是以 96 DPI 的整数倍做基准在构造函数里用devicePixelRatioF()换算实际像素值const qreal dpr this-devicePixelRatioF(); const int titleHeight qRound(36 * qMax(1.0, dpr)); const int btnSize qRound(32 * qMax(1.0, dpr)); setFixedHeight(titleHeight); m_minBtn-setFixedSize(btnSize, btnSize); m_maxBtn-setFixedSize(btnSize, btnSize); m_closeBtn-setFixedSize(btnSize, btnSize);注意这个换算只做一次不要放在resizeEvent里重复计算否则拖动窗口时按钮尺寸会跳动。第三个建议是我个人的验证习惯每次改完标题栏组件我会暂时注释掉setWindowFlags(Qt::FramelessWindowHint)把窗口切回系统边框把两种模式各操作一遍——拖动、双击、最小化、最大化、拖边缩放。这个对比操作能暴露自定义标题栏遗漏的系统行为比如最大化时的焦点处理、拖拽边缘时按 Esc 取消、双击标题栏后重新聚焦等。系统标题栏是自定义 UI 最好的测试基准。工具条可以越做越漂亮但这些基础行为缺一样用户都会觉得这个窗口「不太对吧」。回头看我最早写的一版自定义标题栏那会儿只图好看按钮用 PNG、阴影全透明、拖动用局部坐标最后在项目里改了一个星期才把边界情况补完。现在我把这套组件的接口和验证流程固定下来新项目接入只需要半天。希望帮到你。本文还有配套的精品资源点击获取