C++扩展QML(Extending QML with C++)

QML执行在受限的空间中,QML作为一种语言提供的功能有时是被限制的。通过C++写的本地函数可以扩展QML运行时的功能。应用程序可以充分利用基础平台的性能和自由度。

理解QML运行环境(Understanding the QML Run-time)

当运行QML时,它在一个运行时环境下执行。这个运行时环境是由QtQml模块下的C++代码实现的。它由一个负责执行QML的引擎,持有访问每个组件属性的上下文和实例化的QML元素组件构成。

#include <QtGui>
#include <QtQml>

int main(int argc, char **argv)
{
    QGuiApplication app(argc, argv);
    QUrl source(QStringLiteral("qrc:/main.qml"));
    QQmlApplicationEngine engine;
    engine.load(source);
    return app.exec();
}

在这个例子中,QGuiApplication封装了所有与应用程序引用相关的属性(例如应用程序名称,命令行参数,和事件循环管理)。QQmlApplicationEngine分层管理上下文和组件的顺序。它需要加载一个典型的qml文件作为应用程序的开始点。在这个例子中,main.qml包含了以一个窗口和一个文本。

注意

通过QmlApplicationEngine加载一个使用简单项作为根类型的main.qml不会在你的屏幕上显示任何东西,它需要一个窗口来管理一个平面的渲染。引擎可以加载不包含任何用户界面的qml代码(例如一个纯粹的对象)。由于它不会默认为你创建一个窗口。qmlcene或者新的qml运行环境将会在内部首先检查main.qml文件是否包含一个窗口作为根项,如果没有包含将会为你创建一个并且设置根项作为新创建窗口的子项。

import QtQuick 2.4
import QtQuick.Window 2.0

Window {
    visible: true
    width: 512
    height: 300

    Text {
        anchors.centerIn: parent
        text: "Hello World!"
    }
}

在QML文件中我们定义我们的依赖是QtQuickQtQuick.Window。这些定义将会触发在导入的路径中查找这些模块,并在加载成功后由引擎加载需要的插件。新加载的类型将会倍qmldir控制在qml文件中可用。

当然也可以使用快速创建插件直接向引擎添加我们的自定义类型。这里我们假设我们有一个基于QObjectCurrentTime类。

QQmlApplicationEngine engine;

qmlRegisterType<CurrentTime>("org.example", 1, 0, "CurrentTime");

engine.load(source);

现在我们也可可以在我们的qml文件中使用CurrentTime类型。

import org.example 1.0

CurrentTime {
    // access properties, functions, signals
}

一种更偷懒的方式是通过上下文属性直接设置。

QScopedPointer<CurrentTime> current(new CurrentTime());

QQmlApplicationEngine engine;

engine.rootContext().setContextProperty("current", current.value())

engine.load(source);

注意

不要混淆setContextProperty()和setProperty()。setContextProperty()是设置一个qml上下文的属性,setProperty()是设置一个QObject的动态属性值,这对你没什么帮助。

现在你可以在你的应用程序的任何地方使用这个属性了。感谢上下文继承这一特性。

import QtQuick 2.4
import QtQuick.Window 2.0

Window {
    visible: true
    width: 512
    height: 300

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

通常有以下几种不同的方式扩展QML:

  • 上下文属性 - setContextProperty()

  • 引擎注册类型 - 在main.cpp中调用qmlRegisterType

  • QML扩展插件 - 后面会讨论

上下文属性使用对于小型的应用程序使用非常方便。它们不需要你做太多的事情就可以将系统编程接口暴露为友好的全局对象。它有助于确保不会出现命名冲突(例如使用($)这种特殊符号,例如$.currentTime)。在JS变量中$是一个有效的字符。

注册QML类型允许用户从QML中控制一个c++对象的生命周期。上下文属性无法完成这间事情。它也不会破坏全局命名空间。所有的类型都需要先注册,并且在程序启动时会链接所有库,这在大多数情况下都不是一个问题。

QML扩展插件提供了最灵活的扩展系统。它允许你在插件中注册类型,在第一个QML文件调用导入鉴定时会加载这个插件。由于使用了一个QML单例这也不会再破坏全局命名空间。插件允许你跨项目重用模块,这在你使用Qt包含多个项目时非常方便。

这章的其余部分将会集中在qml扩展插件上讨论。它们提供了最好的灵活性和可重用性。

插件内容(Plugin Content)

插件是一个已定义接口的库,它只在需要时才被加载。这与一个库在程序启动时被链接和加载不同。在QML场景下,这个接口叫做QQmlExtensionPlugin。我们关心其中的两个方法initializeEngine()registerTypes()。当插件被加载时,首先会调用initializeEngine(),它允许我们访问引擎将插件对象暴露给根上下文。大多数时候你只会使用到registerTypes()方法。它允许你注册你自定义的QML类型到引擎提供的地址上。

我们稍微退一步考虑一个潜在的文件IO类型,它允许我们在QML中读取/写入一个小型文本文件。第一次的迭代可能看起来像在嘲笑QML的实现。

// FileIO.qml (good)
QtObject {
    function write(path, text) {};
    function read(path) { return "TEXT"}
}

这是一个纯粹的qml可能的实现,C++基于QML编程接口来探索一些编程接口。我们看到我们有一个读取和写入函数。写入函数需要一个路径和一个文本,读取函数需要一个路径,返回一个文本。路径和文本看起来是公共参数,或许我们可以将它们提取作为属性。

// FileIO.qml (better)
QtObject {
    property url source
    property string text
    function write() { // open file and write text };
    function read() { // read file and assign to text };
}

当然这看起来更像一个QML编程接口。我们使用属性让我们的环境能够绑定我们的属性并且响应变化。

在C++中创建这个编程接口我们需要创建类似的一个接口。

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();
    ...
}

QML引擎需要注册FileIO类型。我们想要在org.example.io模块中使用它。

import org.example.io 1.0

FileIO {
}

一个插件可以在相同的模块中暴露若干个类型。但是不能从一个插件中暴露若干个模块。所以模块与插件之间的关系是一对一的。这个关系由模块标识符表示。

创建插件(Creating the plugin)

Qt Creator包含了一个创建QtQuick 2 QML Extension Plugin向导,我们使用它来创建一个叫做fileio
的插件,这个插件包含了一个从org.example.io中启动的FileIO对象。

插件类源于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");
}

有趣的是我们不能在这里看到模块统一资源标识符(例如org.example.io)。这似乎是从外面设置的。

看你查找你的项目文件夹是,你会发现一个qmldir文件。这个文件指定了你的qml插件内容或者最好是你插件中关于QML的部分。它看起来应该像这样。

module org.example.io
plugin fileio

模块是统一资源标识符,在统一标识符下插件能够被其它插件获取,并且插件行必须与插件文件名完全相同(在mac下,它将是libfileio_debug.dylib存在于文件系统上,fileioqmldir中)。这些文件由Qt Creator基于给定的信息创建。模块的标识符在.pro文件中同样可用。用来构建安装文件夹。

当你在构建文件夹中调用make install时,将会拷贝库文件到Qtqml
文件夹中(在Qt5.4之后mac上这将在~/Qt/5.4/clang_64/qml文件夹中。这个路径依赖Qt按住那个位置,并且使用系统上的编译器)。你将会在org/example/io文件夹中发现库文件。目前包含两个文件。

libfileio_debug.dylib
qmldir

当导入一个叫做org.example.io的模块时,qml引擎将会在导入路径下查找一个可用模块并且尝试使用qmldir文件定位org/example/io路径。qmldir会告诉引擎使用哪个模块标识符加在哪个库作为qml扩展插件。两个模块使用相同的统一标识符将会相互覆盖。

FileIO实现(FileIO Implementation)

FileIO实现很简单。记住编程接口我们想要创建的像这样。

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();
    ...
}

我们将保留属性,因为它们是简单的设置者和获取者。

读取方法在读取模式下打开一个文件并且使用一个文本流读取数据。

void FileIO::read()
{
    if(m_source.isEmpty()) {
        return;
    }
    QFile file(m_source.toLocalFile());
    if(!file.exists()) {
        qWarning() << "Does not exits: " << m_source.toLocalFile();
        return;
    }
    if(file.open(QIODevice::ReadOnly)) {
        QTextStream stream(&file);
        m_text = stream.readAll();
        emit textChanged(m_text);
    }
}

当文本变化时,使用emit textChanged(m_text)需要通知其它对象这个变化。否则属性绑定无法工作。

写入方法相同,但是在写入模式下打开文件,使用文本流写入内容。

void FileIO::write()
{
    if(m_source.isEmpty()) {
        return;
    }
    QFile file(m_source.toLocalFile());
    if(file.open(QIODevice::WriteOnly)) {
        QTextStream stream(&file);
        stream << m_text;
    }
}

最后不要忘记调用make install。否则你的插件文件不会拷贝到qml文件夹,qml引擎无法定位模块。

由于读取和写入会阻塞程序运行,你只能使用FileIO处理小型文本,否则会阻塞Qt的UI线程运行。这里一定要注意!

使用FileIO(Using FileIO)

现在我们可以使用新创建的文件访问一些简单的数据。这个例子中我们想要读取一个JSON格式下的城市数据并且在表格中显示。我们将使用两个项目,一个是扩展插件项目(叫做fileio),提供读取和写入文件的方法。另外一个项目通过fileio读取/写入文件将数据显示在表格中(CityUI)。这个例子中使用的数据在cities.json文件中。

JSON只是文本,它被格式化为可以转换为一个有效的JS对象/数组并返回一个文本。我们使用FileIO读取格式化的JSON数据并使用JSON.parse()将它转换为一个JS对象。数据在后面被用作一个表格视图的数据模型。我们粗略的阅读函数文档就可以获取这些内容。为了保存数据我们将转换回文本格式并使用写入函数保存。

城市的JSON数据是一个格式化文本文件,包含了一组城市数据条目,每个条目包含了关于城市数据。

[
    {
        "area": "1928",
        "city": "Shanghai",
        "country": "China",
        "flag": "22px-Flag_of_the_People's_Republic_of_China.svg.png",
        "population": "13831900"
    },
    ...
]

应用程序窗口(The Application Window)

使用Qt Creator的QtQuick Application向导创建一个基于QtQuick controls的应用程序。我们将不再使用新的QML格式,这在一本书里面将很难解释,即使新格式使用ui.qml文件将比之前更加容易达到目的。所以你可以移除/删除格式文件。

一个应用程序窗口基础配置包含了一个工具栏,菜单栏和状态栏。我们只使用菜单栏创建一些典型的菜单条目来打开和保存文档。基础配置的窗口只会显示一个空的窗口。

import QtQuick 2.4
import QtQuick.Controls 1.3
import QtQuick.Window 2.2
import QtQuick.Dialogs 1.2

ApplicationWindow {
    id: root
    title: qsTr("City UI")
    width: 640
    height: 480
    visible: true
}

使用动作(Using Actions)

为了更好的使用/复用我们的命令,我们使用QMLAction类型。这将允许我们在后面可以使用相同的动作,也可以用于潜在的工具栏。打开,保存和退出动作是标准动作。打开和保存动作不会包含任何逻辑,我们后面再来添加。菜单栏由一个文件菜单和这三个动作条目组成。此外我们已经准备了一个文件对话框,它可以让我们选择我们的城市文档。对话框在定义时是不可见的,需要使用open()方法来显示它。

...
Action {
    id: save
    text: qsTr("&Save")
    shortcut: StandardKey.Save
    onTriggered: { }
}

Action {
    id: open
    text: qsTr("&Open")
    shortcut: StandardKey.Open
    onTriggered: {}
}

Action {
    id: exit
    text: qsTr("E&xit")
    onTriggered: Qt.quit();
}

menuBar: MenuBar {
    Menu {
        title: qsTr("&File")
        MenuItem { action: open }
        MenuItem { action: save }
        MenuSeparator { }
        MenuItem { action: exit }
    }
}

...

FileDialog {
    id: openDialog
    onAccepted: { }
}

格式化表格(Formatting the Table)

城市数据的内容应该被现实在一个表格中。我们使用TableView
控制并定义4列:城市,国家,面积,人口。每一列都是典型的TableViewColumn。然后我们添加列的标识并移除要求自定义列代理的操作。

TableView {
    id: view
    anchors.fill: parent
    TableViewColumn {
        role: 'city'
        title: "City"
        width: 120
    }
    TableViewColumn {
        role: 'country'
        title: "Country"
        width: 120
    }
    TableViewColumn {
        role: 'area'
        title: "Area"
        width: 80
    }
    TableViewColumn {
        role: 'population'
        title: "Population"
        width: 80
    }
}

现在应用程序能够显示一个包含文件菜单的菜单栏和一个包含4个表头的空表格。下一步是我们的FileIO扩展将有用的数据填充到表格中。

文档cities.json是一组城市条目。这里是一个例子。

[
    {
        "area": "1928",
        "city": "Shanghai",
        "country": "China",
        "flag": "22px-Flag_of_the_People's_Republic_of_China.svg.png",
        "population": "13831900"
    },
    ...
]

我们任务是允许用户选择文件,读取它,转换它,并将它设置到表格视图中。

读取数据(Reading Data)

我们让打开动作打开一个文件对话框。当用户已选择一个文件后,在文件对话框上的onAccepted方法被调用。这里我们调用readDocument()函数。readDocument函数将来自文件对话框的地址设置到我们的FileIO对象,并调用read()方法。从FileIO中加载的文本使用JSON.parse()方法解析,并将结果对象作为数据模型直接设置到表格视图上。这样非常方便。

Action {
    id: open
    ...
    onTriggered: {
        openDialog.open()
    }
}

...

FileDialog {
    id: openDialog
    onAccepted: {
        root.readDocument()
    }
}

function readDocument() {
    io.source = openDialog.fileUrl
    io.read()
    view.model = JSON.parse(io.text)
}


FileIO {
    id: io
}

写入数据(Writing Data)

我们连接保存动作到saveDocument()函数来保存文档。保存文档函数从视图中取出模型,模型是一个JS对象,并使用JSON.stringify()函数将它转换为一个字符串。将结果字符串设置到FileIO对象的文本属性中,并调用write()来保存数据到磁盘中。在stringify函数上参数null4将会使用4个空格缩进格式化JSON数据结果。这只是为了保存文档更好阅读。

Action {
    id: save
    ...
    onTriggered: {
        saveDocument()
    }
}

function saveDocument() {
    var data = view.model
    io.text = JSON.stringify(data, null, 4)
    io.write()
}

FileIO {
    id: io
}

从根本上说,这个应用程序就是读取,写入和现实一个JSON文档。考虑下如果使用XML格式读取和写入,会花多少时间。使用JSON格式你只需要读取/写入一个文本文件或者发送/接收一个文本缓存。

收尾工作(Finishing Touch)

这个应用程序还没有真正的完成。我们想要显示旗帜,并允许用户通过从数据模型中移除城市来修改文档。

这些旗帜被存放在main.qml文件夹下的flags文件夹中。为了在表格列中显示它们,我们需要定义一个渲染旗帜图片的代理。

TableViewColumn {
    delegate: Item {
        Image {
            anchors.centerIn: parent
            source: 'flags/' + styleData.value
        }
    }
    role: 'flag'
    title: "Flag"
    width: 40
}

它将JS数据模型中暴露的flag属性作为styleData.value交给代理。代理调整图片路径,并在路径前面加上'flags/'并显示它。

对于移除,我们使用相似的技巧来显示一个移除按钮。

TableViewColumn {
    delegate: Button {
        iconSource: "remove.png"
        onClicked: {
            var data = view.model
            data.splice(styleData.row, 1)
            view.model = data
        }
    }
    width: 40
}

数据移除操作,我们坚持从视图模型上获取数据,然后使用JS的splice函数移除一个条目。这个方法提供给我们的模型来自一个JS数组。splice方法通过移除已有元素,添加新的元素来改变数组内容。

一个JS数组不如一个Qt模型智能,例如QAbstractItemModel,它无法通知视图行更新或者数据更新。由于视图无法接收到任何更新的通知,它无法更新数据显示。只有在我们将数据重新设置回视图时,视图才会知道有新的数据需要刷新视图内容。使用view.model = data再次设置数据模型可以让视图知道有数据更新。

总结(Summary)

插件的创建非常简单,但是它可以复用,并且为不同的应用程序扩展类型。使用创建的插件是非常灵活的解决方案。例如你可以只使用qmlscene开始创建UI。打开CityUI项目文件夹,从qmlscenemain.qml开始。我真的鼓励大家使用与qmlscene一起工作的方式写应用程序。对于UI开发者,这将是一个巨大的改变,也是一个好的习惯来保证清晰的分离。

使用插件有一个缺点,对于简单的应用程序开发增加了难度。你需要为你的应用程序开发插件。如果这是一个问题,你也可以使用与FileIO对象相同的机制使用qmlRegisterType直接注册到你的main.cpp中。QML代码保持一样就可以了。

通常在大型项目中,你不会像这样使用应用程序。你有一个与qmlscene类似的简单的qml运行环境,并且需要所有本地的功能插件。你的项目使用这些qml扩展插件,也是简单纯粹的qml项目。这为UI的变换提供了最大的灵活性并移除了编译步骤。在编辑完成一个QML文件后,你只需要运行UI。这允许用户界面开发者保持灵活性并迅速的使所有的小修改立刻得到响应。

插件提供了健壮和清晰的C++后台开发与QML前端开发的分离。当开发QML插件时,通常在QML端有一个想法,并在使用C+=实现前,可以使用QML的样本模型进行API验证。如果API是C++人员写的,通常会犹豫去改变它或者重写它。复制一个QML提供的API通常更加灵活并且初始投资更少。当使用插件切换一个样本模型API和一个真是API时,仅仅只需要改变qml运行环境的导入路径。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 2291184112@qq.com

×

喜欢就点赞,疼爱就打赏