前言
用 Qt 写界面时,一个典型的合作模式是:C++ 负责数据模型和业务逻辑,QML 负责界面。问题随之而来——QML 怎么才能看到 C++ 里的那个Person类,并且拿到它的name、age?
初学者最常见的误解是"只要把 C++ 类写出来,QML 自然就能用"。事实完全不是这样。QML 引擎不认识 C++ 的类型系统,它只认识 Qt 的元对象系统(meta-object system):一个由moc(Meta-Object Compiler)在编译期生成的、运行期可查询的属性/方法/信号清单。一个类如果没进这套系统,哪怕它是public的、哪怕你#include了它的头文件,QML 里也完全看不见它。
第二个误解是"属性写上了就完事了"。属性确实会出现在 QML 里,但如果没有配套的NOTIFY 信号,QML 的绑定(binding)只会取一次初始值,之后 C++ 改了数据,界面上还是老样子——这是新手最常遇到的"数据变了界面不变"。
本文以Qt 6为主(同时在对照处标出 Qt 5 的写法),从元对象系统讲起,依次说清属性、可调用方法、枚举的暴露方式,再讲类型注册、对象所有权和线程约束。文中所有 API 名称均取自 Qt 官方文档,写作环境没有 Qt 工具链,示例未经过编译验证,请以你实际安装的 Qt 版本头文件为准。
一、QML 看见的是元对象,不是 C++ 类型
要让一个类被 QML 使用,它必须满足两个条件:
- 继承自
QObject(或其派生类),并且在类体第一行写上Q_OBJECT宏; - 经过
moc处理。用 qmake 时HEADERS里的头文件会自动送去 moc;用 CMake 时,包含Q_OBJECT的头文件也必须列进qt_add_executable/qt_add_qml_module的源文件列表里,否则 moc 不会跑,链接阶段会缺一堆staticMetaObject、qt_metacall之类的符号。
Q_OBJECT宏展开后会往类里插入元对象相关的声明。它不能用在模板类上——moc 不处理模板。需要"模板化的 QObject"时,只能写成普通的 QObject 派生类,或者用Q_GADGET配合值类型。
moc会把类里的这些东西收集成元数据:
| 声明 | 被 moc 收集成 | QML 侧怎么用 |
|---|---|---|
Q_PROPERTY(...) | 属性表 | obj.name、obj.name = "x" |
signals:区的信号 | 信号表 | onNameChanged: { ... }处理器 |
Q_INVOKABLE标记的成员函数 | 可调用方法表 | obj.greeting() |
public slots:区的成员函数 | 槽表(同样可调用) | obj.doSomething() |
Q_ENUM(...)标记的枚举 | 枚举表 | Person.Male |
Q_CLASSINFO(...) | 附加类信息 | 通过className等访问 |
反过来说:不加任何标记的 public 成员函数,QML 是调不到的。这是"方法明明存在却报Property 'xxx' of object is not a function"的根本原因。
二、暴露属性:Q_PROPERTY 与 NOTIFY
一个完整的可暴露类型长这样:
// person.h —— Qt 6 #ifndef PERSON_H #define PERSON_H #include <QObject> #include <QString> #include <QtQml/qqmlregistration.h> // QML_ELEMENT 需要这个头 class Person : public QObject { Q_OBJECT QML_ELEMENT // Qt 6:让 QML 里可以直接写 Person { } Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged) Q_PROPERTY(int age READ age WRITE setAge NOTIFY ageChanged) public: explicit Person(QObject *parent = nullptr) : QObject(parent) {} QString name() const { return m_name; } void setName(const QString &value) { if (m_name == value) // 值没变就不发信号,避免无谓的绑定重算 return; m_name = value; emit nameChanged(); // 通知 QML:属性变了,请重算绑定 } int age() const { return m_age; } void setAge(int value) { if (m_age == value) return; m_age = value; emit ageChanged(); } Q_INVOKABLE QString greeting() const { return QStringLiteral("你好,") + m_name; } signals: void nameChanged(); void ageChanged(); private: QString m_name; int m_age = 0; }; #endif // PERSON_H关于Q_PROPERTY的语法,几个要点:
- 基本形式是
Q_PROPERTY(类型 名字 READ 读函数 WRITE 写函数 NOTIFY 通知信号)。 NOTIFY后面跟的信号必须无参数,并且声明在signals:区。写成nameChanged(const QString &)会被 moc 拒绝。WRITE可以省略,这时属性在 QML 里就是只读的。- 也可以用
MEMBER关键字直接绑定一个成员变量,让 moc 自动生成读写函数:Q_PROPERTY(int age MEMBER m_age NOTIFY ageChanged)。代价是你没法在写入路径上插入校验或副作用,moc 生成的就是一次直来直去的赋值。 - 属性类型必须是元对象系统认识的类型:
int、bool、double、QString、QVariant、QObject派生类指针、用Q_ENUM注册过的枚举,以及用Q_DECLARE_METATYPE注册过的自定义类型。std::string不是——用了它,moc 会在生成阶段直接报错。 - 加了
NOTIFY的属性才具备"可绑定"的语义。没有NOTIFY的属性,QML 只在初始化时取一次值。
QML_ELEMENT是 Qt 6 引入的注册宏,配合 CMake 的qt_add_qml_module使用,不需要再手写qmlRegisterType。Qt 5 没有这个宏,必须用下面第三节的注册函数。
三、暴露方法与枚举
Q_INVOKABLE加在成员函数声明的最前面,把函数放进元对象的方法表:
Q_INVOKABLE int nextAge() { setAge(m_age + 1); return m_age; } Q_INVOKABLE bool save(const QString &path) const;返回值和参数类型同样必须被元对象系统认识。想让一个函数返回自定义结构体,得先让那个结构体成为可被QVariant承载的类型。
枚举用Q_ENUM注册(写在枚举定义之后,同一个类里):
class Person : public QObject { Q_OBJECT QML_ELEMENT public: enum Gender { Male, Female, Other }; Q_ENUM(Gender) // 注册进元对象系统 // ... };QML 里就能这样用:
Person { id: p; gender: Person.Female }注意枚举必须定义在带Q_OBJECT的类里,Q_ENUM才有意义;定义在命名空间里的枚举要用Q_ENUM_NS,且该命名空间必须带Q_NAMESPACE。
四、注册类型、所有权与线程约束
Qt 6:QML_ELEMENT + qt_add_qml_module
CMake 里大致是这样(以官方文档为准,不同 Qt 6 小版本参数略有增减):
cmake_minimum_required(VERSION 3.21) project(people LANGUAGES CXX) find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2) qt_standard_project_setup() qt_add_executable(apppeople main.cpp) qt_add_qml_module(apppeople URI People VERSION 1.0 SOURCES person.h person.cpp QML_FILES Main.qml ) target_link_libraries(apppeople PRIVATE Qt6::Quick Qt6::QuickControls2)URI People就是 QML 侧import People时用的模块名。因为类上写了QML_ELEMENT,Person会自动注册到这个模块下,QML 里import People之后直接写Person { }即可。注意person.h必须出现在SOURCES里,否则 moc 不会处理它。
Qt 5:qmlRegisterType
Qt 5 里在main()中手动注册,必须在引擎加载 QML 之前调用:
#include <QGuiApplication> #include <QQmlApplicationEngine> #include <QUrl> #include "person.h" int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); // Qt 5 的注册方式;签名是 qmlRegisterType<T>(uri, major, minor, qmlName) qmlRegisterType<Person>("People", 1, 0, "Person"); QQmlApplicationEngine engine; engine.load(QUrl(QStringLiteral("qrc:/Main.qml"))); if (engine.rootObjects().isEmpty()) return -1; return app.exec(); }写成qmlRegisterType<Person>("People", 1, 0, "Person")之后,QML 里import People 1.0就能看到Person。Qt 6 仍然保留了这些函数,所以上面这段在 Qt 6 里也能编(只是官方更推荐QML_ELEMENT)。
QML 侧
import QtQuick import People Window { width: 320; height: 160; visible: true Person { id: person name: "张三" age: 30 } Text { anchors.centerIn: parent // 绑定:一旦 name 或 age 变化,NOTIFY 信号会触发这里重算 text: person.greeting() + " / " + person.age } }Qt 6 推荐用不带版本号的import QtQuick(版本无关导入)。Qt 5 需要写版本号,例如import QtQuick 2.15。
所有权:谁负责 delete
QML 引擎对QObject有一套所有权规则:
- QML 里创建的对象(
Person { })默认是QQmlEngine::JavaScriptOwnership,由 QML 的垃圾回收负责销毁;它的parent通常是它的 QML 父项。 - C++ 里
new出来、没有 parent、又交给 QML 引用的对象,默认是QQmlEngine::CppOwnership,QML 不会删它,得你自己管。
可以用QQmlEngine::setObjectOwnership(obj, QQmlEngine::CppOwnership)显式指定。经验法则是:谁new的谁负责,不要让 C++ 和 QML 都以为自己该删。
线程:改属性必须在对象所属线程
QObject有线程亲和性(thread affinity):一个对象属于创建它的那个线程,只能从那个线程调用它的方法、改它的属性。而 QML 引擎运行在主(GUI)线程上,QML 里创建的对象也就绑在主线程。
所以一个后台线程里直接person->setAge(18)是数据竞争,属于未定义行为,并且大概率让 QML 场景图崩溃。正确做法是发一个跨线程信号,让槽函数在主线程里执行(Qt 会根据接收者的线程亲和性自动选用排队连接):
// 工作线程里 emit ageReady(18); // 信号 // 主线程里 Person 的槽 void Person::onAgeReady(int v) { setAge(v); } // 连接类型用 Qt::AutoConnection 即可顺带说一句:volatile在这里帮不上任何忙——它既不提供原子性,也不建立 happens-before 关系,不能用来做线程同步。
常见坑点
1. 忘了Q_OBJECT
class Person : public QObject { Q_PROPERTY(QString name READ name) // ❌ 没有 Q_OBJECT,moc 不生成元数据 public: QString name() const; }; class Person : public QObject { Q_OBJECT // ✅ 必须是类体第一条 Q_PROPERTY(QString name READ name NOTIFY nameChanged) };症状:编译能过,运行时 QML 报Cannot assign to non-existent property "name"。
2. 属性没有NOTIFY,界面不刷新
Q_PROPERTY(int age READ age WRITE setAge) // ❌ 绑定只算一次 Q_PROPERTY(int age READ age WRITE setAge NOTIFY ageChanged) // ✅ 数据变化会推给 QML3. setter 里不加"值没变就返回"
void setName(const QString &v) { m_name = v; emit nameChanged(); } // ❌ 每次都发信号 // 如果 QML 里有 name: object.name 这类双向绑定,会来回震荡 void setName(const QString &v) { // ✅ 先比再改 if (m_name == v) return; m_name = v; emit nameChanged(); }4. 用 QML 不认识的自定义 C++ 类型做属性
Q_PROPERTY(std::string name READ name WRITE setName) // ❌ std::string 不在元对象系统里 Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged) // ✅ 用 QString5.qmlRegisterType调用得太晚
QQmlApplicationEngine engine; engine.load(QUrl(QStringLiteral("qrc:/Main.qml"))); // ❌ 已经加载了 qmlRegisterType<Person>("People", 1, 0, "Person"); // 再注册也来不及 qmlRegisterType<Person>("People", 1, 0, "Person"); // ✅ 注册必须在 load 之前 QQmlApplicationEngine engine; engine.load(QUrl(QStringLiteral("qrc:/Main.qml")));症状:QML 报module "People" is not installed。
6. 在带Q_OBJECT的类上套模板
template <typename T> class Holder : public QObject { Q_OBJECT }; // ❌ moc 不支持模板类 class PersonHolder : public QObject { Q_OBJECT }; // ✅ 老老实实写具体类7. 把 C++ 侧new出来的无父对象交给 QML 后不管
// ❌ C++ new、无 parent、又 setContextProperty 给 QML: // QML 不会删,程序退出时泄漏;对象销毁后 QML 里的引用又成了空壳 QQmlContext *ctx = engine.rootContext(); ctx->setContextProperty("person", new Person()); // ✅ 明确所有权,并且让 C++ 持有它 Person *p = new Person(&app); // 交给 app 做父对象,生命周期跟随 app ctx->setContextProperty("person", p);另外,QQmlContext::setContextProperty会把对象放进全局上下文,Qt 6 里已不推荐在大型项目中使用,更推荐注册类型或单例。
8. 从工作线程修改属性
// ❌ 工作线程 std::thread([p]{ p->setAge(18); }).detach(); // 数据竞争,UB,通常直接崩 // ✅ 发信号回主线程,由排队连接在对象所属线程执行 emit ageReady(18);总结
| 想暴露什么 | 加什么 | 关键约束 |
|---|---|---|
| 数据字段 | Q_PROPERTY(类型 名字 READ … WRITE … NOTIFY …) | 类型必须被元对象系统认识 |
| 数据变化的通知 | signals:里的无参信号 | 与NOTIFY一一对应 |
| 成员函数 | Q_INVOKABLE或放进public slots: | 参数和返回值类型同样受限 |
| 枚举 | Q_ENUM(枚举名) | 枚举必须定义在带Q_OBJECT的类里 |
| 类本身 | Qt 6 用QML_ELEMENT;Qt 5 用qmlRegisterType | 必须在 QML 加载前完成注册 |
| 所有权 | QQmlEngine::setObjectOwnership | 默认 CppOwnership 时由 C++ 负责释放 |
| 跨线程更新 | 信号 + 排队连接 | QObject 只能被它所属线程访问 |
一句话记住:QML 看到的是moc生成的元数据,不是你写的 C++ 类。属性要能被绑定就必须有NOTIFY,类型要在 QML 里可用就必须注册,跨线程改数据就必须回到对象所属的线程——这三条覆盖了绝大多数"明明写了却不起作用"的情况。