ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt仿360界面实战:无边框窗口、QSS换肤与多线程架构解析

Qt仿360界面实战:无边框窗口、QSS换肤与多线程架构解析 简介这是一套基于Qt框架仿照360安全卫士风格实现的界面源码Demo面向具备一定C与Qt基础的桌面应用开发者重点解决自定义复杂界面时遇到的控件布局、风格统一与事件响应的实际问题可用于快速理解成熟商业软件UI的搭建脉络。压缩包共150个文件约2.25MB其中91个png与22个jpg提供按钮图标和背景素材17个h与16个cpp构成核心业务代码另配pro工程文件、qrc资源文件与ico图标可直接在Qt环境中整体加载编译。代码通过主窗口、设置对话框、换肤控件、字符控件等模块完整展示了从控件创建、布局管理到信号槽事件处理、QSS自定义皮肤、自定义控件重绘的实现路径同时涉及系统托盘与后台线程相关处理能让初学者直观看到如何在UI不卡顿的前提下执行耗时任务具备较强的参考价值。项目已有641人学习下载文件按功能模块划分清晰适合逐步拆解学习也便于在此基础上改造出自己的360风格界面。1. 为什么拿 Qt 重写 360 界面从源码清单看这个 demo 的工程骨架360 安全卫士的界面在 Qt 桌面开发里算是一个值得拆的样本它同时涉及无边框窗口、顶部自绘标题栏、多页导航、皮肤换肤、系统托盘和后台扫描大部分常见问题都能碰到。这个 demo 的代码主要拆在十个左右的 cpp 文件里main_widget.cpp负责主框架组装title_widget.cpp撑标题栏change_skin_widget.cpp和skin_widget.cpp做皮肤展示与切换system_tray.cpp负责托盘驻留content_widget.cpp放功能面板还有clabel.cpp这种自定义控件。对刚解决 Qt 入门、想知道真实项目里工程长什么样的人这个 demo 比零散的控件教程直观得多对已经写过几年桌面端的人来说值得讨论的是换肤后哪些控件不会自动刷新、自绘控件和 QSS 的边界在哪。下面按主线拆开讲。2. 主窗体的组装方式main_widget.cpp 里的布局与模块划分2.1 顶层为什么用 QWidget 容器而不是 QMainWindow我拿到这种仿 360 的项目第一眼会看根窗口到底继承自 QWidget 还是 QMainWindow。很多入门项目习惯直接继承 QMainWindow因为它自带 menuBar、statusBar 和中心控件但 360 这类界面顶部不是系统菜单是自绘的标题栏底部也没有传统状态栏改用 QMainWindow 反而要禁掉多余裸露接口再塞布局不如用一个 QWidget 做根容器外层套一层 QVBoxLayout第一行放 TitleWidget下面一行放左侧导航和右侧内容区。这样一来所有子模块只依赖自己的 parent layout替换和测试都更干净也方便以后把 MainWidget 嵌到别的地方。2.2 布局参数伸缩系数、边距和间距MainWidget::MainWidget(QWidget *parent) : QWidget(parent) { setWindowFlags(Qt::FramelessWindowHint | Qt::WindowMinimizeButtonHint); TitleWidget *titleBar new TitleWidget(this); QWidget *leftNav createLeftNav(); contentStack_ new QStackedWidget(this); QHBoxLayout *bodyLayout new QHBoxLayout; bodyLayout-setContentsMargins(0, 0, 0, 0); bodyLayout-setSpacing(0); bodyLayout-addWidget(leftNav, 0); // 左侧固定宽度 bodyLayout-addWidget(contentStack_, 1); // 右侧撑满剩余空间 QVBoxLayout *rootLayout new QVBoxLayout(this); rootLayout-setContentsMargins(0, 0, 0, 0); rootLayout-setSpacing(0); rootLayout-addWidget(titleBar); // 标题栏高度由 sizeHint 决定 rootLayout-addLayout(bodyLayout, 1); // 内容区占据剩余高度 }这段代码里addWidget(leftNav, 0)第二个参数是 stretch 系数0 表示不参与伸缩左侧导航保持固定宽度addWidget(contentStack_, 1)把多出来的横向空间全部给内容区。QHBoxLayout、QVBoxLayout在同一条轴上的 stretch 会按比例分配如果你想左侧导航宽度可调可以把addWidget改成QSplitter然后分别用setStretchFactor控制初始比例。这个 demo 里页面切换用的是 QStackedWidget它的好处是页面都在同一尺寸下管理切换时不会重新布局导航按钮只需驱动它换到对应页面即可。下面是我常用的对应关系初写这类界面时可以照着选布局容器区域建议容器布局方式注意点顶部标题栏TitleWidget (QWidget)QHBoxLayout左侧放 Logo/页面名右侧放最小化、关闭按钮左侧导航QListWidget 或 QButtonGroupQVBoxLayout需要高亮态按钮可 setCheckable(true)主内容区QStackedWidget每个页面内部自行布局切换时用 setCurrentWidget 而不是 show/hide底部按钮条QDialogButtonBoxQHBoxLayout设置对话框里常见这里我一般会把左侧导航按钮放在QButtonGroup里再以idClicked信号统一处理避免给每个按钮写一个独立 lambda代码会少很多。2.3 导航切换的实现与高亮状态维护在真实代码里你通常不会让每个页面自己处理选中态而是在主窗口统一维护。常见做法是让所有导航按钮可选中checkable同组的按钮放在同一个 QButtonGroup然后连接 group 的 idClicked 到槽函数槽里同时设置contentStack_-setCurrentIndex(id)。这样做的好处是高亮和页面切换永远走同一条路径新增页面时只需要往两个容器里各 add 一次。navGroup_ new QButtonGroup(this); navButtons_ btnHome btnVirus btnClean btnOptimize; for (int i 0; i navButtons_.size(); i) { navGroup_-addButton(navButtons_.at(i), i); navButtons_.at(i)-setCheckable(true); } connect(navGroup_, QOverloadint::of(QButtonGroup::idClicked), this, [this](int id) { if (id 0 || id contentStack_-count()) return; contentStack_-setCurrentIndex(id); });这里用QOverloadint::of(QButtonGroup::idClicked)是为了在 Qt 5.15 里明确选中带 int 参数的重载版本如果你直接写QButtonGroup::idClicked在 Qt6 里编译器有时会建议加QOverload。代码里还判断了id范围防止在导航和数据页数量不一致时越界。这个细节虽然小但实际塞新页面时最容易漏。3. 无边框窗口、标题栏按钮与系统托盘title_widget.cpp 和 system_tray.cpp 的联动3.1 FramelessWindowHint 下的窗口拖动用了Qt::FramelessWindowHint后窗口没有系统标题栏也就没有拖动和缩放能力这两件事要自己在代码里补。标题栏拖动最常见的实现是重写 TitleWidget 的鼠标事件每次按下时记录全局坐标和窗口原点的差值移动时用这个差值重新 move。需要区分的是事件坐标系event-globalPosition()返回的是全局坐标Qt 5 里是globalPos()跨版本写的时候需要用toPoint()转成QPoint。void TitleWidget::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { dragPos_ event-globalPosition().toPoint() - parentWidget()-frameGeometry().topLeft(); event-accept(); } } void TitleWidget::mouseMoveEvent(QMouseEvent *event) { if (!(event-buttons() Qt::LeftButton)) return; parentWidget()-move(event-globalPosition().toPoint() - dragPos_); }dragPos_是QPoint成员变量这样拖动过程中窗口左上角和鼠标的偏移保持不变。要注意的是parentWidget()只有在标题栏被直接塞进主窗口布局时才是主窗体如果标题栏中间又包了一层容器就要用window()来找顶层窗口。不少 demo 在这里直接写死 parent一重构就失灵。3.2 边缘缩放的 nativeEvent 处理如果窗口允许拉伸光靠 mouseMove 拖标题栏是不够的用户还会去拖边缘而 FramelessWindowHint 会让系统默认的 WM_NCHITTEST 失效。Windows 下需要把 nativeEvent 里识别到的鼠标位置转换成系统命中的边框常量再把HTLEFT、HTRIGHT、HTTOP、HTBOTTOM等返回给系统让系统继续做缩放处理。这个逻辑在 demo 里通常不写但产品要能拖拽大小就必须加。bool MainWidget::nativeEvent(const QByteArray eventType, void *message, qintptr *result) { #ifdef Q_OS_WIN MSG *msg static_castMSG *(message); if (msg-message WM_NCHITTEST) { const int border 4; QPoint mousePos QCursor::pos(); QRect winRect this-frameGeometry(); int left mousePos.x() - winRect.left(); int right winRect.right() - mousePos.x(); int top mousePos.y() - winRect.top(); int bottom winRect.bottom() - mousePos.y(); if (left border top border) *result HTTOPLEFT; else if (right border top border) *result HTTOPRIGHT; else if (left border bottom border) *result HTBOTTOMLEFT; else if (right border bottom border) *result HTBOTTOMRIGHT; else if (left border) *result HTLEFT; else if (right border) *result HTRIGHT; else if (top border) *result HTTOP; else if (bottom border) *result HTBOTTOM; else return QWidget::nativeEvent(eventType, message, result); return true; } return QWidget::nativeEvent(eventType, message, result); #endif }这段代码先判断四个角再判断四条边顺序不能乱否则鼠标到角上时会被边判断先命中拉伸手感变得很怪。qintptr *result在 32/64 位下都能装下系统返回的 HT 常量。非 Windows 平台可以直接返回 false 交给 Qt 默认逻辑或另外用 QMouseEvent 做简化版缩放。3.3 标题栏按钮只发信号不直接操作窗口标题栏的最小化和关闭按钮我会把点击事件封装成信号标题栏本身不持有主窗口指针。这样 title_widget.cpp 可以单独复用测试时只需要连接信号到任意槽函数即可。主窗口在组装时负责把信号翻译成窗口动作最小化调用 showMinimized关闭则根据是否开启托盘驻留决定 hide 还是 quit。// TitleWidget 内部 connect(minButton_, QPushButton::clicked, this, TitleWidget::minimizeRequested); connect(closeButton_, QPushButton::clicked, this, TitleWidget::closeRequested); // MainWidget 组装时 connect(titleBar, TitleWidget::minimizeRequested, this, QWidget::showMinimized); connect(titleBar, TitleWidget::closeRequested, this, MainWidget::onCloseClicked);这种信号转发比起直接parentWidget()-close()的好处是按钮不关心窗口是什么类型也不负责判断托盘逻辑。主窗口对关闭按钮的处理应该放在一个槽里而不是收到 closeRequested 就直接 close因为你还要决定是否拦截 closeEvent。3.4 系统托盘与关闭按钮的配合360 这类安全工具关闭主界面时通常不是退出而是缩小到托盘右键菜单里才有真正的退出。这个行为要同时处理两件事托盘图标的创建以及主窗口 closeEvent 的拦截。托盘代码一般在 system_tray.cpp 里我在里面喜欢把所有控件都收在 SystemTray 类内部主窗口只需要调用 init 并连接 showRequested。SystemTray::SystemTray(QObject *parent) : QObject(parent) { tray_ new QSystemTrayIcon(this); tray_-setIcon(QIcon(:/resources/app.png)); tray_-setToolTip(tr(安全卫士运行中)); QMenu *menu new QMenu; QAction *showAction menu-addAction(tr(显示主界面)); QAction *quitAction menu-addAction(tr(退出)); tray_-setContextMenu(menu); connect(showAction, QAction::triggered, this, SystemTray::showRequested); connect(quitAction, QAction::triggered, qApp, QApplication::quit); }注意先QSystemTrayIcon::isSystemTrayAvailable()判断环境是否支持托盘Linux 桌面环境里有的系统托盘实现并不标准直接 show 会什么都不显示。主窗口的 closeEvent 里要判断一个成员标记比如realQuit_如果是托盘模式则event-ignore(); hide();只有真正的退出动作才放行 close。这里有个细节quitAction 连接的是 QApplication::quit它会直接结束事件循环不经过 closeEvent所以不用担心退出时被再拦截一次。4. QSS 换肤引擎、皮肤列表与 CLabel 自绘控件4.1 皮肤数据结构与展示逻辑换肤是这个 demo 里比较值得研究的部分也最容易写乱。如果只是给每个控件单独 setStyleSheet那么切肤时你得找到所有控件逐个重新设置漏一个就是残影。合理的做法是把皮肤抽象成一套 QSS 模板利用 qApp 全局样式统一加载。我在 skin_widget.cpp 里通常维护一个简单的 SkinInfo 结构只存皮肤目录、预览色和对应的 QSS 文件列表切换时读文件、替换模板变量、最后一次性qApp-setStyleSheet。struct SkinInfo { QString name; QString qssFile; // 相对 qrc 的路径例如 :/skins/green/style.qss QString primaryColor; }; QString SkinManager::buildQss(const SkinInfo skin) { QFile f(skin.qssFile); if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { qWarning() open qss failed: skin.qssFile; return QString(); } QString qss QString::fromUtf8(f.readAll()); qss.replace(QStringLiteral(primary), skin.primaryColor); qss.replace(QStringLiteral(radius), QStringLiteral(6px)); return qss; }模板里先用primary这类占位符占住所有主题色位置换肤时直接 replace避免在 QSS 里写死色值。文件路径如果以:/开头说明资源编译进了可执行文件如果皮肤允许用户自定义则把 qssFile 改成外部磁盘路径用 QDir 拼 applicationDirPath这样才能在发布后不重新编译就换皮。4.2 动态属性选择器与样式刷新QSS 支持属性选择器比如QPushButton[statedanger] { color: white; background: #d9534f; }这在做监控状态、危险按钮时很好用。但这里有一个坑用代码setProperty(state, danger)之后Qt 不会自动重算样式需要手动 unpolish 再 polish否则界面上没有任何变化。void WidgetHelper::refreshStyle(QWidget *w) { w-style()-unpolish(w); w-style()-polish(w); w-update(); }我一般会在换肤公共函数里做一个辅助方法接收需要刷新的控件列表逐个调用 refreshStyle确保动态属性和全局 QSS 都生效。对于自绘控件这一步尤其重要因为它的 paintEvent 里经常读取属性来决定画什么属性变了但没触发重绘就会看到旧状态。顺带一提自定义进度条也适合用这个套路在 QSS 里通过QProgressBar::chunk控制滑块颜色再配合动态属性切换进度条状态比直接 new 一个 QProgressBar 子类要省事。4.3 CLabel 重写 paintEvent 做角标与红点CLabel 是这个工程里一个自定义控件典型用途是给导航图标右上角画红点、数字角标或者做扫描状态指示。这种需求不适合直接用 QSS 的 border-image因为你可能要根据业务状态随时显示、隐藏或改数字。更可控的做法是继承 QLabel保留原生 QLabel 的文字和图片绘制再在 paintEvent 尾部叠加一个角标。void CLabel::paintEvent(QPaintEvent *event) { QLabel::paintEvent(event); // 先让父类把文本/图片画完 if (!badgeVisible_ || badgeCount_ 0) return; QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); QFont f painter.font(); f.setPointSizeF(8.0); painter.setFont(f); QRectF badgeRect(rect().right() - 18, 2, 16, 16); painter.setBrush(QColor(0xF0, 0x3E, 0x3E)); painter.setPen(Qt::NoPen); painter.drawEllipse(badgeRect); QRectF textRect badgeRect.adjusted(1, 0, -1, 0); painter.setPen(Qt::white); painter.drawText(textRect, Qt::AlignCenter, badgeCount_ 99 ? 99 : QString::number(badgeCount_)); }这段代码先调用父类 paintEvent保证文字和图片不会被盖掉然后判断badgeVisible_和计数再画圆形背景和白色数字。这里要注意的是badgeCount_ 99的缩写角标只有 16 像素宽三位数会挤压变形所以需要截断。painter 的字体大小如果跟随系统 DPI 变最好用pointSizeF而不是setPixelSize否则在 125% 缩放下数字会显得偏小。这也是换了用户界面缩放比例后自定义控件最常出现的问题之一。4.4 换肤覆盖范围的验证换肤不生效的排查一般先到 QSS 文件本身是否被qApp-setStyleSheet覆盖还是被局部控件的 styleSheet 干扰。子控件的局部样式表优先级高于 qApp 全局样式如果之前为了调某个按钮颜色加了一句btn-setStyleSheet(...)换肤时会发现那个按钮永远不变。这里我建议项目里统一约定普通配色全部走 qrc 里的 QSS只在业务强相关的控件上用局部 setStyleSheet。这个约定虽然简单但比任何高级技巧都能省下最多的调试时间。5. 设置对话框、角色窗口与耗时扫描的线程刷新5.1 QDialog 的分页设置与 exec/show 的选择setting_dialog.cpp 负责打开设置中心通常用 QTabWidget 把常规、扫描设置等页面分成多个 Tab每个分页是一个独立 Widget。这样 hook 简单新增配置项时只需要往对应页面加一行控件然后集中保存。SettingDialog::SettingDialog(QWidget *parent) : QDialog(parent) { setWindowTitle(tr(设置中心)); setFixedSize(720, 480); QTabWidget *tabs new QTabWidget(this); GeneralPage *generalPage new GeneralPage(tabs); ScanPage *scanPage new ScanPage(tabs); tabs-addTab(generalPage, tr(常规)); tabs-addTab(scanPage, tr(扫描)); tabs-addTab(new AboutPage(tabs), tr(关于)); QVBoxLayout *layout new QVBoxLayout(this); layout-addWidget(tabs); QDialogButtonBox *buttons new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); layout-addWidget(buttons); connect(buttons, QDialogButtonBox::accepted, this, QDialog::accept); connect(buttons, QDialogButtonBox::rejected, this, QDialog::reject); }tabs-addTab的第二个参数是页签标题会经过 tr() 包装后面接 Qt 国际化时直接提取。exec()会开启一个模态事件循环适合设置中心这种必须等用户点确定或取消的场景show()则适合可以同时操作主界面的面板。不要把设置项保存在 dialog 的构造函数里应该在其完成初始化后从 QSettings 里读一次点击 Ok 时统一写回去避免用户取消也写入。5.2 CharacterWidget 的透明背景与帧动画character_widget.cpp 对应的是界面里的小助手角色。实现上不复杂但有一个设置坑透明的 QWidget 必须在构造函数里调用setAttribute(Qt::WA_TranslucentBackground)否则即使图片背景是透明的控件最外层也会被默认背景色填死。角色动画如果只是序列帧图片我一般用 QTimer 定时换图而不是 QMovie因为 QMovie 对帧间隔的控制不如 timer 灵活且长 GIF 容易吃掉 CPU。换帧时最好只对变化区域 update()重绘一个矩形避免整个窗口重绘导致其他控件闪烁。5.3 用 Worker 对象把扫描任务挪到后台线程360 界面的病毒扫描、垃圾清理都是耗时操作如果在按钮槽函数里直接循环UI 会卡死。Qt 里最干净的方式是 QThread Worker 对象把耗时逻辑放进 QObject 的子类槽里用 moveToThread 移动到工作线程再用信号把进度传回主线程。class ScanWorker : public QObject { Q_OBJECT public slots: void doScan() { for (int i 1; i 100; i) { // 模拟扫描逻辑真实代码里在这里跑文件遍历或病毒引擎 QThread::msleep(30); emit progressChanged(i); } emit scanFinished(); } signals: void progressChanged(int percent); void scanFinished(); }; // 在 MainWidget 中 QThread *thread new QThread(this); ScanWorker *worker new ScanWorker; worker-moveToThread(thread); connect(thread, QThread::finished, worker, QObject::deleteLater); connect(thread, QThread::finished, thread, QObject::deleteLater); connect(scanButton, QPushButton::clicked, thread, [thread]() { if (!thread-isRunning()) thread-start(); }); connect(thread, QThread::started, worker, ScanWorker::doScan); connect(worker, ScanWorker::progressChanged, progressBar, QProgressBar::setValue); connect(worker, ScanWorker::scanFinished, scanButton, QWidget::setEnabled);这里有一个特别容易出错的地方不能直接把 scanButton 的 clicked 连到 worker-doScan因为 clicked 信号发生在主线程doScan 会在主线程执行那一堆 msleep 照样卡界面。正确做法是把 start() 和 doScan() 都连好让线程启动后自动触发 worker 槽。另一个注意点是线程对象的生命周期把 deleteLater 挂在 finished 信号上线程结束才会回收避免线程还在跑对象已经被释放。progressChanged跨线程时信号槽会按 AutoConnection 转成队列连接槽函数在接收者所在的线程主线程执行所以进度条更新是安全的不会出现两个线程同时操作一个 QProgressBar 的情况。5.4 关闭窗口时不要让后台线程悬空主窗体关闭时如果扫描线程还在运行直接销毁 MainWidget 会导致 worker 对象悬空程序退出时报QThread: Destroyed while thread is still running。解决办法是在 closeEvent 里设置一个stopRequested标志让扫描循环每轮检查它并提前 break然后线程正常跑完 doScan 退出或者调用thread-quit()后再thread-wait()但 wait 不能放 UI 线程太久否则界面像死掉一样。我一般只设置标志因为扫描循环的粒度已经足够细。6. 换肤与发布路径两个容易浪费半小时的验证技巧6.1 换肤后自绘控件不更新的快速定位换肤后最怕是按钮变了、红点没变。如果自绘控件没改先别怀疑 QSS检查它的 paintEvent 里有没有读到最新状态先在构造外部调用clabel-update()再用调试器查看 styleSheet 文件是否真的被 load。一个常见做法是在 qss.replace 完成后qWarning() qss.left(200);看占位符有没有被替换掉。资源 qss 与外部 qss 路径混淆时打印QFileInfo(qssPath).exists()能直接暴露问题。如果这些都没问题就确认局部 setStyleSheet 是否优先覆盖了全局样式。6.2 windeployqt 后 QSS 和翻译文件摆在哪里发布阶段用 windeployqt 工具收集 DLL 时qrc 编译进 exe 的 QSS 不需要复制但如果皮肤目录在外部就要注意工作目录。Windows 上右键桌面的快捷方式起始位置不一定是 exe 所在目录建议启动时把当前目录切到QCoreApplication::applicationDirPath()或者所有外部文件路径都用它拼接否则 QFile 会找不到皮肤。带 Qt 国际化的程序qt_zh_CN.qm会被 windeployqt 放进 translations 目录建议用QTranslator的 load 接口显式指定该目录而不是依赖系统 locale 自动查找。验证时可以在 main 函数里临时打印QLibraryInfo::location(QLibraryInfo::TranslationsPath)看实际搜索路径是否和发布目录一致。最后用dumpbin /dependents Demo.exe检查依赖打入再回到 style.qss 检查对象名是否写对——通常问题就是按钮实际 objectName 是btnStart样式表里却写成了QPushButton#startBtn改完这一处样式立刻生效。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进