Qt 学习之路 2(93):使用 C++ 扩展 QML

QML 只能运行在一个受限环境中,这是由于 QML 语言本身有一些限制。为了解决这一问题,我们可以使用 C++ 编写一些功能,供 QML 运行时调用。

为了能够利用 C++ 扩展 QML,首先我们需要理解 QML 的运行机制。

与 C++ 不同,QML 运行在自己的运行时环境中。这个运行时在 QtQml 模块,由 C++ 实现,包含一个负责执行 QML 的引擎,为每个组件保存可访问属性的上下文,以及实例化的 QML 元素组件。

在 Qt Creator 中,我们创建 Qt Quick Application 项目,打开 Qt Creator 自动帮我们生成的 main.cpp,可以看到类似下面的代码(由于版本问题,这段代码可能会有所不同):

#include <QGuiApplication>
#include <QQmlApplicationEngine>

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    QQmlApplicationEngine engine;
    engine.load(QUrl(QStringLiteral("qrc:/main.qml")));

    return app.exec();
}

在这段代码中,QGuiApplication封装了有关应用程序实例的相关信息(比如程序名字、命令行参数等)。QQmlApplicationEngine管理带有层次结构的上下文和组件。QQmlApplicationEngine需要一个 QML 文件,将其加载作为应用程序的入口点。在这个例子中,这个文件就是 main.qml。Qt Creator 帮我们生成的 QML 文件被作为资源文件,因此需要使用“qrc”前缀访问到。这个 QML 文件内容如下:

import QtQuick 2.3
import QtQuick.Window 2.2

Window {
    visible: true

    MouseArea {
        anchors.fill: parent
        onClicked: {
            Qt.quit();
        }
    }

    Text {
        text: qsTr("Hello World")
        anchors.centerIn: parent
    }
}

这个 QML 文件根元素是Window项目,包含了一个MouseAreaText。对于根元素是Item的 QML 文件,使用QmlApplicationEngine加载并不会显示任何内容,甚至连窗口都不会显示。这是由于,单独一个Item不能作为独立的窗口,因而没有渲染的容器。QmlApplicationEngine可以加载不带有任何界面的 QML 文件(比如纯逻辑代码),正因为如此,它才不会为你建立一个默认的窗口(这一点与 Qt/C++ 有些不同,Qt/C++ 会为一个独立的QLabel创建默认窗口,不过这一点也有些争议,因此QLabel其实也是一个界面元素)。 另一方面,如果我们直接使用 IDE 运行根元素是Item的 QML 文件,会开启一个 qmlscene 或者新的 QML 运行时,这个新的进程会首先检查主 QML 文件是不是包含窗口作为其根元素,如果没有,则会为其创建一个默认窗口,如果已经有了则会直接设置根元素。这是使用QmlApplicationEngine代码运行和利用 IDE 的 qmlscene 运行二者的区别。

在这个 QML 文件中,我们引入了两个声明:QtQuickQtQuick.Window。这些声明会触发一个在引入路径上面的查找模块的动作,如果成功,则将所需插件加载到 QML 引擎。这些类型的加载实际是由配置文件 qmldir 管理的。除了这种常规方式,我们也可以使用 C++ 代码直接将插件交给 QML 引擎加载。例如,如果我们有一个父类为QObjectCurrentTime类,就可以使用 C++ 这样加载:

QQmlApplicationEngine engine;
qmlRegisterType<CurrentTime>("org.example", 1, 0, "CurrentTime");
engine.load(source);

现在,我们就可以直接在 QML 文件中使用CurrentTime类型了:

import org.example 1.0

CurrentTime {
    //
}

如果你还想更懒一些,那么也可以直接使用上下文属性加载类型:

QScopedPointer<CurrentTime> current(new CurrentTime());
QQmlApplicationEngine engine;
engine.rootContext().setContextProperty("current", current.value())
engine.load(source);

注意,不要将setContextProperty()setProperty()两个函数混淆:前者用于 QML 上下文设置上下文属性,后者则是通常意义上的给QObject添加动态属性。但是这里是不适用动态属性的。

多亏了上下文的层次结构,我们添加到根上下文中的属性可以在整个应用中使用:

import QtQuick 2.4
import QtQuick.Window 2.0

Window {
    visible: true
    width: 512
    height: 300

    Component.onCompleted: {
        console.log('current: ' + current)
    }
}

看!现在你连import语句和对象的声明都省掉了!

扩展 QML 一般有三种方式:

  • 上下文属性:setContextProperty()
  • 向引擎注册类型:在 main.cpp 调用qmlRegisterType
  • QML 扩展插件

对于小型应用,上下文属性即可满足需要。上下文属性不会造成很大的影响,你只是将自己的系统 API 暴露成全局对象。因此,需要注意的就是,确保系统中不会有名字冲突(例如,像 jQuery 一样,使用特殊符号$指代this,就像$.currentTime)。$的确是一个合法的 JS 变量。

注册 QML 类型允许用户使用 QML 控制 C++ 对象的生命周期。使用上下文属性是不能达到这一目的的。另外,这种实现也不会污染全局命名空间。但是,所有类型在使用前都必须注册;因此,所有的库都必须在应用程序启动时链接。不过,在大多数情况下,这都不是个问题。

QML 扩展插件是最灵活的方式。当 QML 文件第一次调用import语句时,插件才会被加载,而类型注册则发生在插件中。另外,通过使用 QML 单例,也无需污染全局命名空间。插件允许跨项目复用模块,这对于使用 QML 开发的多个项目极其有用。

在下面的文章中,我们主要关注 QML 扩展插件,因为它们提供了最大的灵活性和可复用性。

插件是一种满足预定义接口的库,在需要时由系统进行加载。这是插件与普通库的区别之一:普通库在应用启动时就会被链接、加载。在 QML 中,插件必须满足的接口是QQmlExtensionPlugin。我们主要关心接口中的两个函数:initializeEngine()registerTypes()。当插件被系统加载时,首先会调用initializeEngine();该函数允许我们访问 QML 引擎,以便将插件对象暴露给根上下文。大多数情况我们只需要使用registerTypes()函数;该函数允许我们提供一个 URL,以便向 QML 引擎注册自定义 QML 类型。

在前面的章节,我们提到,QML 缺少直接读取本地文件的功能。下面,我们就利用插件实现一个简单的读写文本文件的 QML 插件。首先,我们先定义一个 QML 插件的占位符:

// FileIO.qml (看起来不错)
QtObject {
    function write(path, text) {};
    function read(path) { return "TEXT"}
}

这是一个基于 C++ QML API 的纯 QML 实现,可以对外暴露其 API。可以看到,我们需要两个接口的实现:readwritewrite函数接受两个参数:写入路径path和写入内容textread函数接受一个参数:读取路径path,返回读取到的文本。由于pathtext都是通用参数,那么,我们就可以将其提升为属性:

// FileIO.qml (更好一些)
QtObject {
    property url source
    property string text
    function write() { // 打开文件,写入text };
    function read() { // 读取文件,赋值给text };
}

没错,这下更像 QML API 了。使用属性,我们就可以允许数据绑定和监听属性值的改变。

下一步,我们需要使用 C++ 创建这个 API:

class FileIO : public QObject {
    ...
    Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged)
    Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged)
    ...
public:
    Q_INVOKABLE void read();
    Q_INVOKABLE void write();
    ...
}

FileIO类需要注册到 QML 引擎。我们想将其注册到“org.example.io”模块,也就是可以这么使用:

import org.example.io 1.0

FileIO {
}

一个插件可以在一个模块暴露多种类型,但是不允许在一个插件暴露多个模块。因此,模块与插件是一种一对一的关系,而这种关系就是由模块标识符表示的。

最新版的 Qt Creator 提供了一个创建 QtQuick 2 QML Extension Plugin 的向导。利用它,我们可以创建一个名为 fileio 的插件。这个插件包含一个叫作FileIO的对象,该对象位于模块“org.example.io”。

插件类继承QQmlExtensionPlugin,实现了registerTypes()函数。Q_PLUGIN_METADATA一行强制将该插件识别为一个 QML 扩展插件。除此以外,这段代码并没有什么特别之处。

#ifndef FILEIO_PLUGIN_H
#define FILEIO_PLUGIN_H

#include <QQmlExtensionPlugin>

class FileioPlugin : public QQmlExtensionPlugin
{
    Q_OBJECT
    Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QQmlExtensionInterface")

public:
    void registerTypes(const char *uri);
};

#endif // FILEIO_PLUGIN_H

registerTypes()函数实现很简单,我们使用qmlRegisterType()函数注册了FileIO类。

#include "fileio_plugin.h"
#include "fileio.h"

#include <qqml.h>

void FileioPlugin::registerTypes(const char *uri)
{
    // @uri org.example.io
    qmlRegisterType<FileIO>(uri, 1, 0, "FileIO");
}

到目前为止,我们还没有见到模块 URI,也就是前面提到的“org.example.io”标识符。事实上,这个标识符是在外部文件定义的。我们检查项目目录,可以找到一个 qmldir 文件。这个文件指定了 QML 插件的内容以及插件的 QML 方面的描述。该文件内容大致如下:

module org.example.io
plugin fileio

第一行指定了别人在使用你的插件时,需要使用哪一个 URI;第二行则必须与你的插件的文件名一致(Mac 系统中,这个插件的文件名可能是 libfileio_debug.dylib,而在 qmldir 文件中,我们需要填写 fileio)。事实上,这些文件都是由 Qt Creator 基于我们给出的信息自动生成的。模块的 URI 也可以在 .pro 文件中使用,用于构建安装目录。

当在构建文件夹中执行make install命令时,生成的插件文件将会被复制到 Qt 的 qml 文件夹(Mac 系统中路径可能是 ~/Qt5.5.1/5.5/clang_64/qml,Windows 中可能是 C:\Qt5.5.1\5.5\msvc2013\qml),这个路径实际取决于 Qt 的安装位置和构建时使用的编译器。安装完毕之后,该文件夹下会有“org/example/io”文件夹,其中有两个文件(以 Mac 系统为例):

  • libfileio_debug.dylib
  • qmldir

当需要导入名为“org.example.io”的模块时,QML 引擎会在预定义的导入路径中进行查找,直到找到一个包含有 qmldir 文件的“org/example/io”文件夹。这个 qmldir 文件告诉 QML 引擎,使用哪个 URI 加载哪个模块作为 QML 扩展插件。如果两个模块有相同的 URI,它们就会相互覆盖。

6 Comments

  1. 小空 2016年3月4日
    • 豆子 2016年3月7日
      • 小空 2016年3月10日
        • 豆子 2016年3月10日
    • zh 2016年10月25日
  2. 小丹 2016年3月11日

Leave a Reply