ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Qt Widgets与Qt Quick混合编程实战:C++与QML通信架构全解析

Qt Widgets与Qt Quick混合编程实战:C++与QML通信架构全解析

简介:这是一套围绕《QT和QT Quick实战》整理的源码与配套学习资源包,面向桌面应用、嵌入式及跨平台界面开发者,适合正在系统学习Qt框架、希望从传统控件编程过渡到QML声明式开发的读者。包内共535个文件,压缩包约58.53MB,包含大量cpp源代码、h头文件、pro工程文件,以及qml界面文件、qmlproject项目文件、ui设计文件和qrc资源文件,完整保留了工程配置与界面描述;另有大量png图片、少量音频和文档,对应项目所需的视觉素材与辅助说明。内容覆盖窗口程序、信号与槽机制、QML控件、动画状态机、网络数据库等主题。已有2513人学习下载。通过对照源码与工程组织,读者能掌握Qt Creator下的项目构建、界面布局和跨平台适配方法,也能从实际项目中体会多线程、文件处理、UI资源管理等常见问题的处理思路。整体目录按功能模块划分,适合边阅读边运行调试,是一份可直接上手的实战参考。

1. QT 和 QT quick 实战:这份源码包真正值钱的部分,是它把两套 UI 体系缝合在了一个工程里

学 Qt 最容易撞上的卡点,不是某个控件不会用,而是基础语法过完、教程翻了一圈,自己却拼不出一份能编译运行的完整工程。网上很多资料只讲半边:要么纯 Widgets 写 C++,要么纯 QML 画界面,两个体系里的数据到底怎么互相传,反而没人往深处讲。我最近拆了一套 QT 和 QT quick 实战源码,属于某跨平台系统的完整工程包,一口气把 C++ 业务层、QML 界面层、信号槽通信、资源加载全部串了起来,而不是零散页面拼凑。它解决的核心问题是:Qt Widgets 和 Qt Quick 怎么在同一份代码里共存、边界划在哪、数据从哪进 QML。适合刚学完基础准备做完整项目的开发者,也适合想在老工程里逐步引入 QML 的同学,拿来抄结构比闷头试错快得多。

2. Qt 与 Qt Quick 的分工:C++ 写业务、QML 写界面,但边界没你想的那么简单

把源码包里的代码逐段读完之前,我先通读了目录结构和构建文件。这是拆工程最优先的步骤,因为目录怎么分直接决定后期维护时的心智负担。这份源码没有把界面代码全部压在 C++ 里,也没有把所有逻辑全写进 QML,而是按“数据与状态归 C++、表现与交互归 QML”的原则做了拆分。原则听上去简单,落到具体场景还是有不少细节需要拿捏,下面把我看到的边界展开讲。

2.1 什么时候用 Qt Widgets、什么时候上 Qt Quick:四条选型判据

很多人以为 Qt Quick 是 Qt Widgets 的替代品,实际上它们是两套并列的 UI 体系,不是换皮关系。Qt Widgets 控件基于 QWidget,面向桌面类成熟交互,稳定、控件全、跟 C++ 集成最直接;Qt Quick 走 GPU 渲染,动画、触摸交互、异形界面优势明显,逻辑写在 QML 里更接近声明式编程。

挑选判据时我会快速过四条:

  1. 目标平台的触摸占比。如果主要跑桌面且交互是常规表单、表格、菜单,用 Widgets 就够,没必要绕 QML。反过来说,要同时出移动端或带复杂手势,直接上 Qt Quick。
  2. 界面的动态程度。页面结构经常调整、UI 复用碎片多,QML 的声明式写法改起来成本低;而 Widgets 改一次布局往往要重新编译,联调时间翻倍。
  3. 团队的技术栈。成员都熟 C++、对 JavaScript/QML 不熟,短期交付就老老实实 Widgets;新项目且愿意补 QML,长期收益更高。
  4. 渲染性能要求。同一屏要刷新大量自绘图形,QML 的 Scene Graph 有优势,但也要接受调试复杂度上升。

这四条不是理论,是实际项目里吵过架的结论。这套源码里明显是按第三条原则走的:主体逻辑在 C++,UI 层几乎全部 QML,C++ 只暴露必要接口,不直接操作控件。界面侧的同学改布局时,完全不碰 C++ 代码,编译时间大幅缩短,回归风险也少了一半。

2.2 源码里界面层和业务层的目录结构:一份可以直接复制的分工剧本

看工程最先注意的不是某个类的写法,而是目录结构。我把这份源码的顶层结构整理如下:

某跨平台系统/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── core/ │ │ ├── DataBridge.h │ │ ├── DataBridge.cpp │ │ ├── TaskListModel.h │ │ └── TaskListModel.cpp │ └── services/ │ ├── NetWorker.h │ └── NetWorker.cpp ├── qml/ │ ├── main.qml │ ├── pages/ │ │ ├── DashboardPage.qml │ │ └── TaskPage.qml │ ├── controls/ │ │ ├── StatCard.qml │ │ └── NavBar.qml ├── assets/ │ ├── icons/ │ └── fonts/ ├── qml.qrc └── deploy/ ├── app.icns └── app.rc

从结构就能猜出设计意图:C++ 只放两个目录,core 放跨页面共享的业务对象,services 放网络和文件这类外部 IO;qml 目录下页面和自定义控件分开,assets 管静态资源,deploy 放各平台打包用的文件。main.cpp 只做三件事:建引擎、注册上下文属性、加载入口 QML。我把这种组织方式简称为“薄主程序加厚模块”,它最大的好处是各层依赖单向:QML 只能通过上下文属性调用 C++ 暴露的接口,不能直接 new 一个 C++ 类,避免界面代码里到处散布业务逻辑。

另外值得抄的一个细节是,这份源码把 C++ 和 QML 的接口命名统一了。C++ 侧暴露给 QML 的对象叫 bridge,QML 里只认 bridge 这一个全局名字。如果接口超过五个,就按领域拆出多个 context property,不要用一个胖对象把所有方法都挂上去。有些项目为了省事,把所有业务函数全塞进一个单例暴露给 QML,初期很爽,后期改一个字段都牵扯全局,这是典型的“早期爽、后期还债”写法。

看一段最核心的桥接代码,它负责把 C++ 的业务结果转成 QML 能直接消费的格式:

// DataBridge.cpp void DataBridge::notifyTasksChanged() { QVariantList payload; for (const Task &t : m_tasks) { QVariantMap item; item.insert("code", t.code); item.insert("title", t.title); item.insert("progress", t.progress); payload.append(item); } emit tasksUpdated(payload); }

这里的思路是:C++ 侧永远不直接告诉 QML“你去刷新 ListView”,而是只发射一个携带数据的信号,界面层收到信号后自己决定怎么刷新。QVariantList 里每一项是 QVariantMap,对应 QML 里的对象数组。参数说明:progress 用浮点数,QML 侧可以直接绑到 ProgressBar 的 value 上,不需要二次转换;code 是字符串,在 QML 里做点击回调的参数时不会丢失类型。

3. 从源码到跑起来:构建配置、资源路径和 QML 引擎初始化的三个关键点

源码拿在手上,第一件正经事就是把它编译运行。很多人卡在第一步不是代码问题,而是构建方式选错了。下面按源码实际用的方案一步步拆。

3.1 构建系统选 .pro 还是 CMake:决定后续打包方式的第一个岔路口

这套源码用的是 CMake。原因很现实:Qt 从 6 开始官方重心明显向 CMake 偏移,qmake 的新特性基本冻结。如果你还在用 Qt 5.15 或者项目里全是老代码,.pro 没有毛病;但要是新工程,我会建议直接用 CMake,哪怕要花半天熟悉语法。理由有三:跨平台 CI 好写、第三方库集成方便、Qt Quick 的资源注册在 CMake 里有专门的辅助函数,省掉一堆手工 qrc 操作。

源码里的 CMakeLists.txt 核心片段如下:

cmake_minimum_required(VERSION 3.16) project(QtQuickDemo VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 REQUIRED COMPONENTS Quick Widgets Qml) qt_standard_project_setup() qt_add_executable(appQtQuickDemo src/main.cpp src/core/DataBridge.cpp src/core/TaskListModel.cpp ) qt_add_qml_module(appQtQuickDemo URI QtQuickDemo VERSION 1.0 QML_FILES qml/main.qml qml/pages/DashboardPage.qml qml/pages/TaskPage.qml qml/controls/StatCard.qml qml/controls/NavBar.qml RESOURCES qml.qrc )

解释一下关键点:qt_add_qml_module是 Qt 6 里专门给 QML 工程用的函数,它同时做了三件事——生成 qmldir 模块描述文件、把列出的 QML 文件注册进资源系统、自动处理 import 路径。QML_FILES 里的路径必须是相对 CMakeLists 的相对路径,别用绝对路径,否则换一台机器就崩。URI 不能随便取,它要和 QML 文件里import QtQuickDemo的模块名对上。这里的RESOURCES qml.qrc是额外资源,放图片和字体。

构建命令没什么玄学,常规两步:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build

-S指定源码目录,-B指定构建目录,第一次配置时如果报找不到 Qt6,多半是 CMake 没找到 Qt 安装路径,加一行-DCMAKE_PREFIX_PATH=/你的Qt安装目录就行。构建完成后,可执行文件在 build 目录下,直接用 Qt Creator 的 Run 按钮也可以,但要注意 Qt Creator 里选择的构建套件必须和 CMake 配置一致,否则会出现“构建成功但运行报错找不到库”的怪问题。

3.2 main.cpp 里引擎和上下文属性的初始化顺序:顺序错了就是玄学报错

这是新手最容易翻车的地方。QML 引擎的初始化和上下文属性注入是有严格顺序的:必须先创建引擎,然后在引擎 load 任何 QML 文件之前把属性注册进 rootContext,最后才 load。顺序错了,QML 里拿到的就是 undefined。源码 main.cpp 的结构如下:

#include <QGuiApplication> #include <QQmlApplicationEngine> #include <QQmlContext> #include "core/DataBridge.h" #include "core/TaskListModel.h" int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); QCoreApplication::setOrganizationName("ExampleCorp"); QQmlApplicationEngine engine; DataBridge bridge; TaskListModel model; engine.rootContext()->setContextProperty("bridge", &bridge); engine.rootContext()->setContextProperty("taskModel", &model); engine.load(QUrl(QStringLiteral("qrc:/qt/qml/QtQuickDemo/qml/main.qml"))); if (engine.rootObjects().isEmpty()) return -1; return app.exec(); }

这段代码有两个细节值得多说一句。第一,DataBridge bridge和TaskListModel model定义在 main 函数栈上,它们的析构发生在 main 结束之后,而app.exec()阻塞期间一直有效,所以生命周期没问题;但如果你把 engine 和业务对象都定义成全局对象,初始化顺序就不可控了,强烈不建议。第二,engine.load返回后立刻检查rootObjects().isEmpty(),如果 QML 加载失败,这里能第一时间发现,而不是等到程序跑起来才发现白屏。QML 加载失败时 qDebug 会有输出,但有些错误被吞掉了,这个检查是后悔药。

3.3 qrc 资源前缀与 QML import 路径:对不上就白屏的隐藏规则

源码里 QML 文件的加载路径是qrc:/qt/qml/QtQuickDemo/qml/main.qml,这个路径不是随便写的,它由qt_add_qml_module的 URI 推导出来。在 Qt 6 中,QML 文件不再像 Qt 5 那样直接挂根路径,而是按模块路径组织:URI 的每个点对应一级目录,qt 和 qml 是固定前缀。

对应到 qrc 文件里,可能会看到这样的结构:

<qresource prefix="/qt/qml/QtQuickDemo"> <file>qml/main.qml</file> <file>qml/pages/DashboardPage.qml</file> <file>qml/pages/TaskPage.qml</file> </qresource>

实际操作中如果自己维护 qrc,记住一条规则:qrc 里的路径前缀必须和 CMake 里 qt_add_qml_module 的 URI 一致。不一致的表现是运行时 QML 报 module 未找到,但编译期毫无提示。还有一种常见做法是把 QML 文件直接扔在 qrc 根路径下,用qrc:/main.qml加载,这也能跑,但一旦页面之间互相 import,模块路径就会乱,工程复杂度上来之后很难理清。所以建议从第一个工程就按模块路径组织,后面加页面只是往对应目录里丢文件,CMake 重新扫描一下就行。

4. 信号槽与属性绑定:源码里三种 C++ 与 QML 通信方式的实战拆解

一套完整项目里,C++ 和 QML 的通信方式通常不止一种。这套源码里有三套并存的通信模式,分别是传统 C++ 信号槽、注入 QML 的上下文对象调用、以及 Model/View 数据驱动。把它们逐个拆开讲,能覆盖绝大多数实际场景。

4.1 传统 C++ 信号槽:线程边界和参数类型是主要陷阱

先看 C++ 内部对象之间的通信。NetWorker 请求网络数据,完成后通过信号把结果交回给 DataBridge,这种场景在 Qt 里最常用的是connect加 Lambda:

connect(netWorker, &NetWorker::dataReceived, this, [this](const QByteArray &raw) { QJsonDocument doc = QJsonDocument::fromJson(raw); m_tasks = parseTasks(doc.array()); notifyTasksChanged(); });

这里的连接方式是 Qt 5 之后推荐的新语法,好处是编译期检查信号和槽的参数类型,不用等到运行时报错。需要注意的是dataReceived信号如果来自另一个线程,Lambda 体里的执行线程就是发送者所在线程。如果 NetWorker 跑在子线程,这个 Lambda 也在子线程执行,此时直接改 m_tasks 就和主线程产生数据竞争。解决办法是连接时显式指定连接类型:

connect(netWorker, &NetWorker::dataReceived, this, [this](const QByteArray &raw) { // 在接收者所在线程(主线程)执行 }, Qt::QueuedConnection);

Qt::QueuedConnection会把槽的执行放到接收者线程的事件循环里,数据竞争问题就没了。源码里对这类跨线程场景的处理,基本都加了 QueuedConnection,这个习惯值得直接搬走。

4.2 C++ 对象注入 QML 上下文:双向调用的完整链路

第二套是上下文对象,也就是 main.cpp 里那个bridge。QML 里可以直接调它的槽函数,C++ 也能主动往 QML 发信号。先看 DataBridge 的类声明:

class DataBridge : public QObject { Q_OBJECT public: explicit DataBridge(QObject *parent = nullptr); public slots: void applyFilter(const QString &keyword); signals: void filterApplied(const QString &keyword); };

QML 侧绑定方式:

TextField { id: searchInput onTextEdited: bridge.applyFilter(searchInput.text) } ListView { Connections { target: bridge onFilterApplied: { console.log("filter applied:", keyword) } } }

这里的关键点是:public slots声明的函数在 QML 里可以直接调用,不需要额外注册;signals声明后,QML 里要用Connections对象或者直接在属性绑定里引用bridge.filterApplied(...)。字段类型要注意:QML 的信号参数没有重载概念,C++ 信号里参数类型是 QString,QML 侧收到的就是一个 string,类型敏感场景建议统一用 QString 而不是 const char*。

另一个容易被忽略的细节是invokeMethod的用法。有时候不是用户点击触发的调用,而是 C++ 主动通知 QML 执行某个函数:

QMetaObject::invokeMethod(rootObject, "scrollToTop", Q_ARG(QVariant, QVariant::fromValue(index)));

QMetaObject::invokeMethod要求被调用的 QML 函数必须声明为普通 function,且参数类型要跟 Q_ARG 里一致。这个写法在源码里用于列表跳转和页面切换,比额外设一个属性再靠属性变化触发要直接。

4.3 QAbstractListModel 与 ListView 的对接:数据驱动界面的标准姿势

第三套是最重要也最容易写错的部分:给 ListView 喂数据。源码里定义了一个 TaskListModel 继承 QAbstractListModel,核心代码:

class TaskListModel : public QAbstractListModel { Q_OBJECT public: enum TaskRoles { CodeRole = Qt::UserRole + 1, TitleRole, ProgressRole }; int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role) const override; QHash<int, QByteArray> roleNames() const override; };

对应实现:

QHash<int, QByteArray> TaskListModel::roleNames() const { return { { CodeRole, "code" }, { TitleRole, "title" }, { ProgressRole, "progress" } }; } QVariant TaskListModel::data(const QModelIndex &index, int role) const { if (!index.isValid()) return {}; const Task &t = m_tasks.at(index.row()); switch (role) { case CodeRole: return t.code; case TitleRole: return t.title; case ProgressRole: return t.progress; default: return {}; } }

roleNames()是整个 Model/View 对接里最重要的环节,它决定了 QML 里用model.code还是model.title来取值。role 的 int 值必须从Qt::UserRole + 1开始递增,这个枚举值不在 roleNames 里返回,QML 就拿不到数据。ListView 侧绑定时,通常还要配合一个 Delegate:

ListView { model: taskModel delegate: Rectangle { height: 56 Text { text: model.title } ProgressBar { value: model.progress } } }

如果数据源变化了,必须通知模型刷新。QAbstractListModel 要求你在增删改之前调用beginInsertRows和endInsertRows,不是直接emit dataChanged就行。很多人写 Model 时跳过 begin/end 调用,结果就是 QML 里列表永远不更新,界面就像死了一样。源码里任何一次m_tasks.clear()前后都成对出现了 beginResetModel/endResetModel,这是要抄成习惯的。

5. 避坑与排查:混合工程里五个高频翻车现场

把源码里踩过的坑和我在其它 Qt Quick 项目里见过的同类问题合并,挑五个最有代表性的,按现象、原因、解决三段写出。

5.1 现象一:主窗口白屏,QML 加载失败但控制台没有任何错误

看起来程序启动了,窗口存在,但里面什么都没有,控制台干净得像没焊过线。排查第一步是用qDebug()打印engine.rootObjects().isEmpty(),如果是 true,说明 QML 根本没加载成功。原因通常是两个:qrc 资源里漏了文件,或者 engine.load 的 URL 跟模块路径对不上。Qt 6 里模块路径拼写错误是静默失败的,QML 文件里引用其它组件时如果路径错,报错信息也可能被吞掉。解决:先确认 CMake 里 QML_FILES 列了所有文件,再用绝对 qrc URL 试一次,比如qrc:/qt/qml/QtQuickDemo/qml/main.qml。如果绝对 URL 能加载,说明相对路径问题;如果还是空白,就在 QML 文件第一行加console.log("qml loaded"),终端没输出就一定是资源没进包。

5.2 现象二:按钮点了没反应,槽函数根本没进断点

按钮点击后界面上没有变化,在 C++ 槽函数里打断点也进不去。这种问题十有八九是连接方式写错了。QML 里调用 C++ 槽函数有一个隐含约束:槽函数必须声明在public slots:下面,C++ 类还必须用Q_OBJECT宏。如果这两个条件不满足,QML 里那个属性的值会是 undefined,点击时静默失败。另外还有一种情况是信号参数类型对不上,比如 QML 侧发出的字符串是 QVariant,C++ 槽函数接收参数是 int,类型转换失败后槽函数不会调用。处理办法:先看 QML 控制台有没有“TypeError”输出,再检查槽函数签名是否完全一致。如果槽函数确实声明对了还是没反应,可以先用 QML 里一个空函数试调用,确认链路是否通到 QML。

5.3 现象三:Model 数据改了,但 ListView 界面纹丝不动

这个坑我见到的次数最多。数据源确实变了,但在 QML 里列表就是不刷新。原因几乎都出在 Model 没有正确通知视图。QAbstractListModel 的机制是:视图靠模型发来的结构变化信号重建部分界面,你在 C++ 里偷偷把 QList 改了却没发信号,视图当然不知道。具体看代码,少了这三行中的任意一行都会出问题:

beginInsertRows(QModelIndex(), row, row + count - 1); // 往 m_tasks 里插入数据 endInsertRows();

beginInsertRows的参数是父索引和插入的行范围,范围给错了视图会乱。如果是一次性清空重建,用beginResetModel()和endResetModel()更省事。UI 没刷新时先查这两个调用是不是成对出现,八成能找到问题。

5.4 现象四:中文注释或字符串导致编译报错,界面中文全部乱码

Windows 上这个坑特别经典。源文件保存成 GBK 编码,Qt 6 默认把所有源码当成 UTF-8 解析,于是注释里的中文字符被读成乱码,甚至可能把后面的代码挤到注释里,编译直接报错。解决办法不是去调编译器编码,而是让源码文件统一使用 UTF-8。Qt Creator 里可以在“编辑器→默认编码”里设成 UTF-8。代码里写中文字符串时,建议用QStringLiteral(u"中文")显式标注 UTF-16,或者用QString::fromUtf8("中文"),这样即使文件编码乱了,字符串内容也不会变成乱码。QML 文件里汉字的处理要简单一些,只要 QML 文件本身是 UTF-8,直接写就行,不需要额外转换。

5.5 现象五:图片和字体资源加载不出来,日志提示 URL 找不到

资源路径的问题在 Qt Quick 项目里几乎人人都会碰到。QML 里写source: "assets/icons/logo.png",运行时报“file not found”。原因大概率是 qrc 的实际路径和你写的不一致。qrc 的根路径是qrc:/,如果 qrc 里文件挂在前缀/assets下,QML 里的引用就要写qrc:/assets/icons/logo.png。另一个坑是 qrc 文件修改后没有重新构建,资源没进包里。这种情况在 Qt Creator 里表现为:QML 编辑界面能看到图片,运行时却不行,因为编辑预览走的是磁盘路径,运行时资源只认 qrc。解决:检查 qrc 里的路径前缀,并对 qrc 做一次强制重新编译。字体资源同理,加载时要用FontLoader指定source: "qrc:/assets/fonts/xxx.ttf",且保证 qrc 前缀一致。

6. 让源码 Demo 变成能交付的版本:调试工具、发布参数和一个固定验证习惯

工程能跑通只是起点,真正要交到别人手里,还要过调试和发布这两关。

6.1 调试 QML 侧逻辑:不要只靠 console.log

QML 里的报错信息往往只告诉你有问题,不说明具体在哪一行。我最推荐的工具是 Qt Creator 自带的 QML Profiler 和调试器。打开调试模式后,可以在 QML 文件里打断点,查看 JavaScript 变量值,这比console.log插桩高效得多。另一个容易被忽略的参数是QML_IMPORT_TRACE=1,在运行环境变量里加上它,终端会打印所有模块导入的路径搜索过程,模块没找到的问题一眼就能定位。还有一个常用技巧:在 main.cpp 里把 qDebug 输出重定向到日志文件,线上版本排查问题时不至于抓瞎。

6.2 发布打包时的三个参数

Qt Quick 应用发布比 Widgets 程序多几个坑。打包目录里必须包含qml目录下的全部 QML 文件和插件,用 windeployqt 时注意加--qmldir参数,指定 QML 源码目录,它才能把用到的 QML 模块和插件带上。另一个是平台插件目录必须完整,否则启动时直接报 “could not find or load the Qt platform plugin”。最后一个参数是高清屏适配,Windows 上要在 main 函数开头加QGuiApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough),否则高分屏下界面容易发虚或缩放异常。

6.3 一个固定验证习惯:改完 QML 强制走一遍全链路

拆完这套源码之后,我给自己定了一个规矩:每次修改完 QML 或信号槽逻辑,强制走一遍“冷启动 → 数据加载 → 界面交互 → 切页 → 再冷启动”的流程。这个习惯是从项目里一次严重翻车学来的。当时改了一个页面的属性绑定,单页测试一切正常,合入后从另一个页面跳转进来时,List 数据全部丢失,因为跳转会重新创建页面组件,而上下文属性里的 model 没有重新关联。从那以后我每次改完界面代码都会强制走一遍完整流程,检查的不只是当前页面有没有问题,而是页面跳转后的状态是否还在。希望这个习惯也能给你省几次发布后救火的经历,这套源码值得你下载下来,照着目录结构重新组织一遍自己的工程,踩过一遍的坑比看十遍教程都记得牢。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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