【译】将 C++ 属性( Attributes )暴露到 QML ( Qt 5.11 )

2,149 阅读10分钟

原文链接:doc.qt.io/archives/qt…

QML 引擎和 Qt 元对象系统(Qt meta-object system)是紧密集成在一起的,任何 QObject 派生类的成员变量、函数在经过很少量的处理后(或无需处理)都能够再 QML 中直接访问——因此,我们可以非常简单地实现使用 C++ 代码来对 QML 进行扩展。

QML 引擎能够通过元对象系统实现对 QObject 实例的 introspect 。这意味着任何 QML 代码都能够获取到 QObject 派生类的下列成员:

  • 属性( Property )
  • 方法( 需要是 public slot 方法或使用 Q_INVOKABLE 宏标记过的方法)
  • 信号

没有想到如何翻译 introspect 。introspect 和反射类似,都能够在运行时获取对象的信息,只是相较于反射少了修改对象的能力。

此外,使用 Q_ENUMS 宏声明的枚举类型也是可以的。更多信息请读 Data Type Conversion Between QML and C++

通常来说,无论 QObject 的派生类是否使用 QML 类型系统注册(registered with the QML type system)过,其实都是可以从 QML 访问到的。只有在用这个类的时候需要 QML 引擎获取到额外的类型信息时——例如类本身要被做作为其他函数的参数或属性,又或者类的枚举类型要这么用时,这个类才需要进行注册。

本文中涉及到的一些重要概念都在 使用 C++ 编写 QML 扩展 教程中讲到过,有需要可以看看

数据类型管理和所有权问题

从 C++ 传到 QML 的所有数据——无论是一个属性值、一个方法参数、一个返回值,还是一个信号参数值,都必须是 QML 引擎所支持的类型。

默认情况下,QML 引擎支持将一些 Qt C++ 类型自动转换为对应的 QML 类型。此外,使用 QML 类型系统注册过的 C++ 类和枚举也能够作为数据类型使用。相关信息详见 Data Type Conversion Between QML and C++

在数据从 C++ 传输到 QML 时,数据所有权也是需要考虑的一个问题。当数据从 C++ 传到 QML 时,数据的所有权总是会留在 C++ 部分的。但当 C++ 显式函数调用的返回值是 QObject 时是个例外:这种情况下,除非 C++ 代码中显式地通过指定 QQmlEngine::CppOwnership 调用 QQmlEngine::setObjectOwnership(),否则其他情况下 QML 引擎将拥有被返回对象的所有权。

暴露属性

属性( Property )是一个类中提供了其自身的读(必有)写(可选)函数的数据成员。我们可以使用 Q_PROPERTY() 宏来将任意的 QObject 派生类指定为属性( Property )。

听起来有些拗口,其实【属性】指的就是一个成员,这个成员在类中存在为其准备的读写函数。看看下面的代码例子也许会容易理解一些

所有 QObject 派生类的属性都是可以在 QML 中访问的。

下面是一个带有 author 属性的 Message 类。author 成员变量可以通过成员函数 author() 读取,可以通过 setAuthor() 函数写入新值。author 变量及其读、写函数通过 Q_PROPERTY 宏调用相关联:

class Message: public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString author READ author WRITE setAuthor NOTIFY authorChanged)
public:
    void setAuthor(const QString &a) {
        if(a!=m_author) {
            m_author = a;
            emit authorChanged();
        }
    }
    QString author() const {
        return m_author;
    }
singals:
    void authorChanged();
private:
    QString m_author;
}

若该类的实例在 C++ 加载 MyItem.qml 时,被像下面这样设置为了一个上下文属性

int main(int argc, char *argv[]) {
    QGuiApplication app(argc, argv);
    
    QQuickView view;
    Message msg;
    view.engine()->rootContext()->setContextProperty("msg", &msg);
    view.setSource(QUrl::fromLocalFile("MyItem.qml"));
    view.show();
    
    return app.exec();
}

那么, author 属性就能够在 MyItem.qml 中读取到:

// MyItem.qml
import QtQuick 2.0

Text {
	width: 100; height: 100
	text: msg.author		// 调用 Message::author() 获取到 author 值
	
	component.onCompleted: {
		msg.author = "Jonah"	// 调用 Message::setAuthor()
	}
}

若想最大程度加强 C++ 和 QML 的互操作性,则可以为所有可写属性都匹配一个在属性值发生变化时触发的 NOTIFY 信号。关联上这样的信号后,该属性就能和属性绑定一起使用了——属性绑定是 QML 的一个基本特性,该特性让属性的值随着其依赖的其他属性值自动变化,以强制保持属性和属性之间的关系。

在上面的例子中,Q_PROPERTY() 宏调用指定了 authorChanged 是与 author 属性关联的 NOTIFY 信号。这意味着每当 Message::setAuthor() 的调用触发信号时,该信号都会通知 QML 引擎:所有与 author 属性相关的其他绑定值都必须被更新一次,反过来,QML 引擎也会再使用 Message::author() 来再更新一次 text 属性的值。

author 属性是可写的,却没有设置与之关联的 NOTIFY 信号,那么 text 值仍能够被 Message::author() 在初始化时返回的初始值改变,但之后 author 值变化时就无法再更新 text 属性的值了。此外,任何试图从 QML 绑定该值的操作都会引起 QML 引擎的运行时警告。

注意:推荐将 NOTIFY 信号命名为 <property>Changed 。由 QML 引擎生成的属性改变信号命名全都是 on<property>Changed 这种形式的,所以将自定义的信号按 <property>Changed 规则命名能够避免造成不必要的困惑。

使用通知信号的注意事项

为了避免循环求值和过度求值,开发者需要确保属性改变信号只会在属性值真的改变时才会被触发。同样的,如果一个属性或一组属性不会被频繁使用,那么实际上也可以让这些属性共享同一个 NOTIFY 信号。在确定这么做之前,需要小心确认不会引起严重的性能问题。

需要注意的是,使用 NOTIFY 信号的确会带来一些很小的性能开销。在一些情况下,属性值在对象创建时确定后就不会再改变了,例如:当一个类型使用组属性时,成组的属性对象可能只被创建一次,然后保持不变,直到对象被删除时其本身才会被释放。声明这样的属性时,可以考虑使用 CONSTANT 来代替 NOTIFY 信号。

CONSTANT 属性( attribute )应该只用于那些在构造函数中确定值的属性( properties ),所有其他需要绑定的属性还是应该设置一个 NOTIFY 信号。

组属性:

在一些情况下,一个属性可能会包含多个子属性,这些子属性可以用【点符号(dot)】或【组符号(group)】进行赋值。以下面这个例子来说明:一个 Text 类型有一个 font 组属性,其中的第一个 Text 对象使用点符号来初始化 font,第二个用组符号来初始化:

Text {
	// 点符号
	font.pixelSize: 12
	font.b: true
}

Text {
	// 组符号
	font { pixelSize: 12; b: true }
}

成组属性类型是【包含了多个子属性】的基本类型。某些这样的基本类型由 QML 语言提供,其他的可能由被 import 的 Qt Quick 模块提供。

有对象类型的属性

在 QML 类型系统中注册过的对象类型的属性时可以在 QML 中直接访问的。

如下面这个例子:Message 类型包含一个子属性 MessageBody*

class Message: public QObject
{
    Q_OBJECT
    Q_PROPERTY(MessageBody* body READ body WRITE setBody NOTIFY bodyChanged)
public:
    MessageBody* body() const;
    void setBody(MessageBody* body);
};

class MessageBody: public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString text READ text WRITE text NOTIFY textChanged)
    // ......
}

假设 Message 类型已经在 QML 类型系统中注册过了,其已经可以在 QML 代码中作为一个对象类型来使用,如下:

Message {
	// ...
}

如果 MessageBody 类型也在类型系统中注册过了,那么我们就可以直接在 QML 代码中把 MessageBody 赋值给 Messagebody 属性:

Message {
	body: MessageBody {
		text: "Hello, world!"
	}
}

有对象列表类型的属性

包含 【QObject 派生类型的列表类型】的属性也是可以暴露给 QML 的。若想实现这样的应用,需要使用 QQmlListProperty 作为属性的类型(只能用 QQmlListProperty,不能是 QList<T> ):这是因为 QList 并不是一个 QObject 的派生类型,所以无法通过 Qt 元对象系统提供足够 QML 使用的属性信息,以让其完成应该具备的功能(比如, list 不是 QObject 派生来的,当然也就加不上需要有的通知信号)。

QQmlListProperty 是一个能够使用 QList 值进行构造的模板类。

例如:下面的 MessageBoard 类有一个 messages 属性,这个属性是 QQmlListProperty 类型的,是一个用于存储多个 Message 实例的列表:

class MessageBoard: public QObject
{
    Q_OBJECT
    Q_PROPERTY(QQmlListProperty<Message> messages READ messages)
public:
    QQmlListProperty<Message> messages();

private:
    static void append_message(QQmlListProperty<Message> *list, Message *msg);
    
    QList<Message *> m_messages;
}

MessageBoard::messages() 函数的功能是用 QList<Message *> m_messages 成员创建一个 QQmlListProperty 并返回

QQmlListProperty<Message> MessageBoard::messages()
{
	return QQmlListProperty<Message>(this, 0, &MessageBoard::append_message);
}

void MessageBoard::append_message(QQmlListProperty(Message) *list, Message *msg)
{
    MessageBoard *msgBoard = qobject_cast<MessageBoard *>(list->object);
    if(msg)
        msgBoard->m_messages.append(msg);
}

需要注意的是,QQmlListProperty 的模板类类型(即上面的 Message )必须在 QML 类型系统中注册过。

成组的属性( Grouped Properties )

任何只读的对象类型属性都可以作为一个【成组属性(Grouped property)】从 QML 中进行访问。这可以被用于暴露一个类型( type )的一组相互关联属性。

例如:假设 Message::author 属性是一个 MessageAuthor ,其包含 nameemail 子属性,而不是一个简单的字符串,定义如下:

class MessageAuthor: public QObject
{
    Q_PROPERTY(QString name READ name WRITE setName)
    Q_PROPERTY(QString email READ email WRITE setEmail)
public:
    ...
};

class Message: public QObject
{
    Q_OBJECT
    Q_PROPERTY(MessageAuthor* author READ author)
public:
    Message(QObject *parent)
        : QObject(parent), m_author(new MessageAuthor(this))
    {
    }
    MessageAuthor *author() const {
        return m_author;
    }
private:
    MessageAuthor *m_author;
}

author 属性可以使用 QML 的组属性语法来赋值,如下:

Message {
	author.name: "Alexandra"
	author.email: "alexandra@mail.com"
}

作为组属性暴露的类型,和【对象类型属性】的不同之处在于:

  • 前者是只读的,而且只能在构造阶段由父对象进行其值的初始化;
  • 组属性的子属性也可以在 QML 中进行修改,但组属性对象本身是不会随着这样的修改而改变的,与之相对的,对象类型属性却可以在 QML 中随时进行修改;
  • 组属性对象由其的 C++ 父对象( parent )实现严格控制,而对象类型属性却可以在 QML 中随时创建和销毁。

暴露方法(包括 Qt 槽)

任何满足下列条件的 QObject 派生类型函数,都能够被从 QML 代码中访问:

  • 使用 Q_INVOKABLE() 宏作为标志声明的 public 函数;
  • 公共 Qt 槽函数

例如:下面的 MessageBoard 类有一个 postMessage() 函数,该函数带有一个 Q_INVOKABLE 宏,还带有一个公共槽函数 refresh()

class MessageBoard: public QObject
{
    Q_OBJECT
public:
    Q_INVOKABLE bool postMessage(const QString &msg) {
        qDebug() << "Called the C++ method with" << msg;
        return true;
    }
    
public slots:
    void refresh() {
        qDebug() << "Called the C++ slot";
    }
}

如果一个 MessageBoard 实例被设置为了 MyItem.qml 的上下文数据,那么就可以在 MyItem.qml 中像下面这样调用前面的两个函数:

C++:

int main(int argc, char *argv[]) {
    QGuiApplication app(argc, argv);
    
    MessageBoard msgBoard;
    QQuickView view;
    view.engine()->rootContext()->setContextProperty("msgBoard", &msgBoard);
    view.setSource(QUrl::fromLocalFile("MyItem.qml"));
    view.show();
    
    return app.exec();
}

QML:

// MyItem.qml
import QtQuick 2.0

Item {
	width: 100; height: 100
	
	MouseArea {
		anchors.fill: parent
		onClicked: {
			var result = msgBoard.postMessage("Hello from QML")
			console.log("Result of postMessage():", result)
			msgBoard.refresh()
		}
	}

}

如果一个 C++ 函数有 QObject* 类型的参数,那么参数值也是可以在 QML 中用对象 id 或引用那个对象的 JavaScript var 变量来作为参数传给函数的。

QML 同样支持调用重载的 C++ 函数。QML 能够根据参数的数量和类型,调用正确的 C++ 函数。

C++ 函数执行完后的返回值会被转化为 JavaScript 类型的值,这样就可以在 QML 中直接取值了。

暴露信号

任何 QObject 派生类型的公共信号都是可以从 QML 中访问的。

QML 引擎会为符合要求的信号自动创建一个信号处理器( signal handler )。信号处理器的命名规则是 on<Signal> ,其中的 <Signal> 是首字母大写的信号名称。所有传给信号的参数都可以在信号处理器中直接通过参数名使用。

例如,假设 MessageBoard 类有一个 newMessagePosted() 信号,该信号带一个 subject 参数,定义如下:

class MessageBoard: public QObject
{
    Q_OBJECT
public:
    // ...
signals:
    void newMessagePosted(const QString &subject);
};

MessageBoard 已经于 QML 类型系统中注册过的情况下,MessageBoard 在 QML 中声明的一个对象就可以通过 onNewMessagePosted 信号处理器接收 newMessagePosted() 信号并从参数中取值了,用法如下:

MessageBoard {
	onNewMessagePosted: console.log("New message received:", subject)
}

和属性值及函数参数一样,信号的参数也必须是 QML 引擎支持的类型,详见QML 和 C++ 之间的数据类型转换 。直接用未注册过的类型不会报错,但无法取到值。

对于一个类中有多个同名信号的情况需要特别注意:QML 是无法区分同名不同参数的多个信号的,这些信号中只有最后一个能够被 QML 获取到。