16.用 C++ 扩展 QML(Extending QML with C++)
本章的作者:jryannel
注意:
最新的构建时间:2016/03/21
这章的源代码能够在assetts folder找到。
在 QML 作为语言提供的有限空间内执行 QML 有时可能会受到限制。通过使用 C++ 编写的本机功能扩展 QML 运行时环境,应用程序可以利用基础平台的全面性能和自由度。
16.1 了解QML运行时环境
当运行 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 代码。因此,默认情况下不会为我们创建一个窗口。qmlscene 或新的 qml 运行时环境将在内部首先检查主 qml 文件是否包含一个窗口作为根项目,如果没有为我们创建一个,并将根项目设置为新创建的窗口的子项。
import QtQuick 2.5
import QtQuick.Window 2.2
Window {
visible: true
width: 512
height: 300
Text {
anchors.centerIn: parent
text: "Hello World!"
}
}
在 qml 文件中,我们声明我们的依赖关系是 QtQuick 和 QtQuick.Window。这些声明将触发在导入路径中对这些模块进行查找,而成功则将引擎引导所需的插件。然后将新加载的类型提供给由 qmldir 控制的 qml 文件。
也可以通过将引擎类型直接添加到引擎来快捷插入创建。这里我们假设我们有一个基于 CurrentTime QObject 的类。
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()。第一个在 qml 上下文中设置上下文属性,setProperty() 在 QObject 上设置动态属性值,后者不会在使用 C++ 扩展 QML 时帮到我们。
现在,我们可以在应用程序中随处可见的当前属性。感谢上下文继承。
import QtQuick 2.5
import QtQuick.Window 2.0
Window {
visible: true
width: 512
height: 300
Component.onCompleted: {
console.log('current: ' + current)
}
}
以下是扩展 QML 的不同方式:
- Context properties —— setContextProperty()
- Register type with engine —— 在main.cpp中调用qmlRegisterType
- QML extension plugins —— 接下来会讨论
Context properties 易于用于小型应用程序。他们不需要太多的努力,只需要暴露我们的系统 API 与各种全局对象。确保不会有任何命名冲突(例如通过使用此特殊字符 $ 例如 $.currentTime)是有帮助的。$ 是 JS 变量的有效字符。
Registering QML types 允许用户从 QML 控制 C++ 对象的生命周期。这不可能与上下文属性。它也不会污染全局命名空间。仍然需要首先注册所有类型,所有这些库需要在应用程序启动时进行链接,这在大多数情况下并不是一个问题。
最灵活的系统由QML extension plugins提供。它们允许我们在第一个 QML 文件调用导入标识符时加载的插件中注册类型。同样通过使用 QML 单例,不需要再污染全局命名空间。插件允许我们跨项目重用模块,当我们使用 Qt 执行多个项目时,该模块非常方便。
本章的其余部分将重点介绍 qml 扩展插件。因为它们提供了灵活性和重用性。
16.2 插件内容
插件是一个具有定义接口的库,它是按需加载的。这与库的不同之处在于库在启动应用程序时被链接和加载。在 QML 案例中,该界面称为 QQmlExtensionPlugin。 有两种方法对我们 initializeEngine() 和 registerTypes() 有兴趣。当首先加载插件时,将调用 initializeEngine(),这允许我们访问引擎以将插件对象暴露给根上下文。在大多数情况下,我们将只使用 registerTypes() 方法。这允许我们使用提供的网址上的引擎注册自定义 QML 类型。
我们稍微退一步考虑一个潜在的文件 IO 类型,它允许我们在 QML 中读取/写入一个小型文本文件。第一次的迭代可能看起来像在嘲笑 QML 的实现。
// FileIO.qml (good)
QtObject {
function write(path, text) {};
function read(path) { return "TEXT"}
}
这是一个用于探索 API 的可能的基于 C++ 的 QML API 的纯 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 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 {
}
一个插件可以使用相同的模块暴露几种类型。但是它不能从一个插件暴露几个模块。 因此,模块和插件之间存在一对一的关系。该关系由模块标识符表示。
16.3 创建插件
Qt Creator 包含一个创建 QtQuick 2 QML 扩展插件的向导,我们使用它来创建一个名为 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上的 Qt 5.4,这将是 “~/Qt/5.4/clang_64/qml”,确切的路径取决于我们的 Qt 安装位置和我们系统上使用的编译器)。在那里我们将在 “org/example/io” 文件夹中找到一个库。内容是目前这两个文件:
libfileio_debug.dylib
qmldir
当导入称为 “org.example.io” 的模块时,qml 引擎将查找其中一个导入路径,并尝试使用 qmldir 查找 “org/example/io” 路径。然后,qmldir 会告诉引擎哪个库加载为 qml 扩展插件,使用哪个模块 URI。具有相同 URI 的两个模块将相互覆盖。
16.4 FileIO 实现
FileIO 的实现很简单。记住我们要创建的 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();
...
}
我们将保留属性,因为它们是简单的设置者和获取者。
读取方法以读取模式打开文件,并使用文本流读取数据。
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 线程。请一定要注意!
16.5 使用 FileIO
现在我们可以使用我们新创建的文件来访问一些不错的数据。在这个例子中,我们想以 JSON 格式读取一些城市数据,并将其显示在表格中。我们将使用两个项目,一个是扩展插件(称为fileio),它为我们提供了一种从文件中读取和写入文本的方法,另一个是通过使用文件 io 来读取和显示表中的数据(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"
},
...
]
16.5.1 应用程序窗口
我们使用 Qt Creator QtQuick 应用程序向导创建基于 Qt Quick Controls 的应用程序。我们不会使用新的 QML 表单,因为这在一本书中很难解释,尽管使用 ui.qml 文件的新表单方法比以前更有用。所以你现在可以移除/删除表单文件。
基本设置是一个 ApplicationWindow,它可以包含工具栏,菜单栏和状态栏。我们将只使用菜单栏来创建一些标准的菜单条目来打开和保存文档。基本设置只会显示一个空的窗口。
import QtQuick 2.5
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
}
16.5.2 使用 Actions
为了更好地使用/重用我们的命令,我们使用 QML Action 类型。这将允许我们以后对潜在的工具栏使用相同的操作。开放和保存退出操作是退出标准。打开和保存动作不包含任何逻辑,我们稍后再来。使用文件菜单和这三个操作条目创建菜单。另外我们准备了一个文件对话框,这样我们可以稍后选择我们的城市文件。声明时,对话框不可见,我们需要使用 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: { }
}
16.5.3 格式化表格
城市数据的内容应显示在表格中。为此,我们使用 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"
},
...
]
我们的工作是允许用户选择文件,读取它,转换并将其设置到表视图上。
16.5.4 读取数据
为此,我们让打开的操作打开文件对话框。当用户选择文件时,在文件对话框中调用 onAccepted 方法。在那里我们称之为 readDocument() 函数。readDocument() 函数将文件对话框中的 url 设置为 FileIO 对象,并调用 read() 方法。然后使用 JSON.parse() 方法解析来自 FileIO 的加载文本,并将生成的对象作为模型直接设置到表视图上。这样非常方便。
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
}
16.5.5 写入数据
为了保存文档,我们将保存操作挂接到 saveDocument() 函数。保存文档函数从视图中获取模型,该视图是一个 JS 对象,并使用 JSON.stringify() 函数将其转换为字符串。生成的字符串设置为 FileIO 对象的 text 属性,我们调用 write() 将数据保存到磁盘。 stringify 函数中的 “null” 和 “4” 参数将使用 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,我们需要的是读取和写入文本文件或发送接收文本缓冲区的方式。
16.5.6 画龙点睛
该应用程序尚未完成准备。我们仍然希望显示标志,并允许用户通过从模型中移除城市来修改文档。
相对于 flags 文件夹中的 main.qml 文档,存储此示例的标志。为了能够显示它们,表列需要定义一个用于渲染标志图像的自定义代理。
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 拼接函数删除一个条目。这种方法对于我们来说是可用的,因为模型来自 JS 类型的数组。拼接方法通过删除现有元素和/或添加新元素来更改数组的内容。
不幸的是,JS 数组不如像 QAbstractItemModel 这样的 Qt 模型那么聪明,它将通知有关行更改或数据更改的视图。该视图现在不会显示任何更新的数据,因为它从未被通知任何更改。只有当我们将数据设置回视图时,视图才能识别出新数据并刷新视图内容。使用 view.model = data 再次设置模型是让视图知道有一个数据更改的一种方式。
16.6 本章总结
插件的创建非常简单,但是它可以复用,并且为不同的应用程序扩展类型。使用创建的插件是非常灵活的解决方案。例如你可以只使用 qmlscene 开始创建 UI。打开 CityUI 项目文件夹,从 qmlscene 的 main.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 运行时的导入路径。
本章完。