第十八章:QML扩展

第十八章:QML扩展

用C++ 扩展QML

仅用QML来创建应用在某些场景下会受到限制。QML的 运行时(环境)是使用C++ 来开发的,而运行时 是可以扩展的,以使其可以自由和充份地利用相关系统环境的性能。

理解QML运行时

当运行QML应用时,QML是在运行时环境中被执行的。运行时是在C++ 的QtQml模块中实现的。引擎-负责执行QML,上下文-为每个组件提供全局级的属性访问,以及组件-可以从QML中实例化的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文件作为应用程序的起点。这时,QML文件一般是包括窗体和文本类型的main.qml

注意
QmlApplicationEngine如果加载仅以Item作为根元素的main.qml,那么显示器上将不会有任何内容,因为它需要一个窗体来管理渲染层。
引擎是可以加载不包括任何用户界面(如,平面对象)的QML代码的。因此,它并不会为你创建默认可视窗体。qml运行时首先会尝试检查main QML的根元素中是否存在一个窗体,如果没有,则创建一个窗体,并将原来的根元素作为新创建窗体的子元素。因此,你的main.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中,我们声明了依赖关系,这里是QtQuickQtQuick.Window。这些声明将会触发从导入路径对这些模块的查找,如果查找成功,则会由引擎加载所需要插件。然后,新加载的类型将通过代表报告的 qmldir 文件中的声明提供给 QML 环境。点此了解qmldir
也可以通过在main.cpp中直接向引擎添加类型的方式来简化插件创建。这里我们假定有一个从QObject基类继承的子类CurrentTime

QQmlApplicationEngine engine();

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

engine.load(source);

现在就可以在QML文件中使用CurrentTime了。

import org.example 1.0

CurrentTime {
    // access properties, functions, signals
}

如果不需要在QML里新建实例,也可以使用引擎的上下文属性来将C++ 对象暴露给QML,如:

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

QQmlApplicationEngine engine();

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

engine.load(source);

注意
不要混淆了setContextProperty()setProperty()。第一个为qml的上下文设置了上下文属性,而setProperty() 就为QObject设置一个动态属性,这不是用在当下的这个场景里的。

现在就可以在应用中的任何地方使用current属性了。由于上下文继承,它在 QML 代码中随处可用。current对象注册在最外层的根上下文中,该上下文随处继承。

import QtQuick
import QtQuick.Window

Window {
    visible: true
    width: 512
    height: 300

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

以下是常用的几种扩展QML的方法:

  • Context 属性 - setContextProperty()
  • 通过引擎注册类型 - 在main.cpp来调用qmlRegisterType
  • QML 扩展插件 - 灵活性最大,后面会讨论
    Context 属性 对于较小的程序来说比较容易使用。你只需要将全局对象暴露给系统API,而不需要其它操作。确保没有名称冲突很重要(如,为对象使用特殊字符$-本例中是$.currentTime)。$是有效的JS变量。
    注册QML类型允许用户从QML来控制C++ 对象的生命周期。这是contxt属性做不到的。而且,它也不污染全局命名空间。所有类型仍然需要先注册,因此,应用程序启动时要链接所有库,对多数程序来说这不是什么问题。
    QML扩展插件是最为灵活的。它允许QML文件在首次调用导入模块时加载插件并注册类型。通过使用QML单例,也不会污染全局命名空间了。可以在不同的工程之间重用插件,这在使用Qt开发多个项目时,很方便。
    回到最简单的main.qml文件:
import QtQuick 2.5
import QtQuick.Window 2.2

Window {
    visible: true
    width: 512
    height: 300

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

当导入QtQuickQtQuick.Window时,是告诉QML运行时来找相应的扩展插件并加载它们。这些是QML引擎在QML导入路径里查找模块来实现的。这些新加载的类型将在QML环境中可用。
本章的剩余部分将关注 QML 扩展插件。因为它提供了最大的灵活性和可重用性。

插件内容

插件是有着确定接口,可根据需要进行加载的库。它不同于单纯的库文件,因为库文件是应用程序启动时加载的。在QML里,接口被称为QQmlExtensionPlugin。有两个我们感兴趣的方法initializeEngine()registerTypes()。当插件首次被加载时会调用initializeEngine(),这会使引擎将插件对象暴露给顶层上下文context。多数时候,只会用到registerTypes()方法。这允许在提供的 URL 上向引擎注册自定义 QML 类型。
通过创建一个小的FileIO工具类来了解一下。它允许从QML中读取文本文件。在模拟的 QML 实现中,第一次迭代版本代码可能看起来像这样:

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

这是一个纯QML实现,它可能基于C++ 的QML API接口。我们用它来实现API。这里需要一个readwrite函数。我们也看到write函数接收pathtext参数,而read函数接收path参数并返回文本。如你所见,pathtext是常用参数,或许可以将其提取出来作为属性,来简化声明式上下文环境下的API的易用性。

// 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,我们应该创建一个象这样的Qt 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();
    ...
}

FileIO类型需要使用QML 引擎来注册。我们想要在“org.example.io”模块中使用,qmlRegisterType<FileIO>("org.example.io", 1, 0, "FileIO")

import org.example.io 1.0

FileIO {
}

一个插件可以以同样的模块名暴露多个类型。但不能从一个插件暴露多个模块。所以模块与插件之间有一对一的关系。这个关系通过模块标识符来表达。

插件的创建

Qt Creator包括一个向导来创建 QtQuick 2 QML Extension Plugin ,可以在新建工程向导的 Library 下找到。我们用它来创建一个名为 fileio 的插件,插件从org.example.io模块启动一个FileIO对象。

注意
向导生成一个基于QMake的项目。请从本章的例子开始,将其更改为基于CMake的工程。

工程应该包括fileio.hfileio.cpp,它们声明和实现了FileIO类型,还有一个允许 QML 引擎发现扩展的实际插件类的fileio_plugin.cpp
插件类是从QQmlEngineExtensionPlugin类继承的,并包含Q_OBJECTQ_PLUGIN_METADATA宏。整个文件如下:

#include <QQmlEngineExtensionPlugin>

class FileioPlugin : public QQmlEngineExtensionPlugin
{
    Q_OBJECT
    Q_PLUGIN_METADATA(IID QQmlEngineExtensionInterface_iid)
};

#include "fileio_plugin.moc"

扩展会自动发现并注册所有以QML_ELEMENTQML_NAMED_ELEMENT标记的类型。我们将在FileIO的实现部分看到这是如何做到的。
为了能让模块顺利导入,用户需要指定一个URI。比如 import org.example.io。有趣的是,我们在任何地方都看不到模块 URI。这是使用 qmldir 文件从外部设置的,或者在项目的 CMakeLists.txt 文件中设置。
qmldir文件指定了QML插件内容,让插件在QML端更好地使用。为插件手写的qmldir文件看起来应该象这样:

module org.example.io
plugin fileio

这就是用户要导入模块的URI,以及之后指定的要加载的URI中插件的名字。插件行必须与插件文件名相同(mac系统里,文件系统中的名字应该是libfileio_debug.dylib,而在qmldir中应该是fileio;对于Linux系统, 对应的文件系统中的文件名应该是libfileio.so)。这些文件是由Qt Creator根据给定的信息来创建的。
创建正确的qmldir最简章的方式是在项目的CMakeLists.txt里,在qt_add_qml_module宏里。这里的URI参数用于指定插件的URI,如,org.example.io。这种方式下,qmldir文件就会在项目构建时生成。

如何安装模块?

当要导入名为‘org.example.io’的模块,QML引擎会查找某个导入路径,并根据qmldir尝试锁定 “org/example/io” 路径。qmldir接着告诉引擎加载哪个库作为模块URI指定的QML扩展插件。有相同URI名称的两个模块将会相互覆盖。

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

我们先省略属性,因为它们是简单的 setter 和 getter。
read方法以reading模式打开文件并使用文本流读取数据。

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

当文本变更了,要使用emit textChanged(m_text)来发出变化信息。否则,属性绑定将无效。
write方法做了同样的事情,但以write模式打开文件,并使用流来将text属性的内容写入文件。

void FileIO::write()
{
    if(m_source.isEmpty()) {
        return;
    }
    QFile file(m_source.toLocalFile());
    if(!file.exists()) {
        qWarning() << "Does not exist: " << m_source.toLocalFile();
        return;
    }
    if(file.open(QIODevice::WriteOnly)) {
        QTextStream stream(&file);
        stream << m_text;
    }
}

为了能让类型对QML可见,我们在Q_PROPERTY那几行下面添加了QML_ELEMENT宏。这告诉Qt这个类对QML是可见的。如果你想提供与C++ 类不同的名字,可以使用QML_NAMED_ELEMENT宏。
别忘了最后要调用make install。否则,插件文件不会被拷贝到qml文件夹,而qml引擎也无法锁定模块。

注意
因为读和写是阻塞型的函数调用,你应该在小型文本文件中使用FileIO,否则将可能阻塞Qt的UI线程。小心使用!

使用FileIO

现在就可以使用我们最近创建的文件来访问数据了。本例中,我们将会用到JSON格式的城市数据并将其显示在表格中。我们为些创建两个工程:一个是扩展插件(工程名为fileio),可以提供从文件中读取文本的方法;另一个是在表格中展示数据,(工程名:CityUI)。CityUI使用fileio扩展来读写文件。

JSON数据其实是可以方便地转化为JS对象/数组的格式化的文本,它也能方便地转化为普通文本。我们使用FileIO来读取JSON格式数据并使用内置的Javascript函数JSON.parse()将其转化为JS对象。数据后续被用于表格视图的模型。这是在读文档和写文档函数中实现的,如下。

FileIO {
    id: io
}

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

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

本例中用到的JSON数据是cities.json文件。它包含了城市数据条目列表,每个条目包含了关于城市的相关数据,如下:

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

应用程序窗体

使用Qt Creator的QtQuick Application向导来创建基于Qt Quick Controls 2的应用 。虽然使用 ui.qml 文件的新的窗体方式比以前的版本更具可用性,但我们将不会使用新的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
}

使用Actions

为更好的使用/重用命令,这里使用QML Action类型。这将使我们在可以在后续使用可能的工具栏动作。打开、保存和退出动作是基本的。打开和保存动作目前还不包含任何逻辑,后面会有。菜单栏是使用一个文件菜单和三个动作条目来创建的。而且我们已经准备了一个文件对话框,用于后面打开城市文件。对话框在声明时还不可见,需要使用open()方法来显示。

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

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

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: {
        root.readDocument()
    }
}

格式化表格

各城市的数据将会显示在表格中。为此我们使用了表格视图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"
    },
    ...
]

我们要做的就是,让用户选择文件、读取、转化并将数据展示在表格视图。

数据读取

我们让打开动作来打开一个对话框。当用户选择了一个文件后,文件对话框的onAccepted函数会被调用。应该在那里调用readDocument()函数。readDocument()函数从文件对话框获取URL并将其赋予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
}

数据写入

对于保存文档,我们将‘save’动作与saveDocument()函数绑定。保存文档函数从视图接收模型,该模型是JS对象,需要使用JSON.stringify()函数来将其转化为文本。结果文本被赋予FileIO对象的text属性,并调用write()将数据保存到硬盘。stringify函数的null4参数将对结果JSON数据进行4个空格缩进的格式化。这仅是为使保存的文档有更好地可读性。

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,您只需要一种读取和写入文本文件或发送接收文本缓冲区的方法。

画龙点睛

程序目前还不完备。我们将为其添加国旗列,并允许用户从模型中移除城市条目以修改文档。
本例中,旗图标文件存放在与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/'前辍,并显示为Image元素。
对于移除数据功能,我们用类似的技术来显示一个移除按钮。

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数组并不象类似QAbstractItemModel的Qt模型那样聪明,它(JS数组)的行或数据变化并不通知视图。目前视图将不会变化因为它未被通知有变化。仅当将数据设置回视图时,视图才会识别有新数据并刷新视图内容。使用view.model = data的方式重新为模型赋值,是使得视图知道有数据变化的一种方式。

总结

本章创建的插件是非常简单的插件。但它是可以在其它不同类型的应用中被重用和扩展的。使用插件可以创建非常灵活的方案。比如,你可以只使用qml来启动UI。打开 CityUI 项目所在的文件夹,使用 qml main.qml 启动 UI。QML 引擎可以从任何项目中轻松使用该扩展,并且可以在任何地方导入该扩展。
我们鼓励您以某种使用qml的方式编写应用程序。这大大节省了开发人员的时间,而且在应用中保持逻辑和界面呈现的清晰分离也是一种好习惯。
使用插件的唯一坏处是它让部署变得更复杂,越简单的程序越明显(因为创建和部署插件的工作量不变)。现在你需要部署你的应用和插件。如果这对你来说比较困难,你仍然可以使用FileIO类并在main.cpp文件里qmlRegisterType来直接注册。QML代码不变。
在大型项目里, 你不会这样来使用应用。您有一个简单的qml运行时,类似于Qt提供的qml命令,并且需要所有本机功能作为插件提供。您的项目是使用这些 qml 扩展插件的简单纯 qml 项目。这提供了更大的灵活性并省去了 UI 更改后的编译步骤。在编辑UI文件后需要运行UI。这使得用户界面编写人员能够保持灵活性和敏捷性,以便进行所有这些像素级的小修改。
插件在 C++ 后端开发和 QML 前端开发之间提供了良好而清晰的分离。在开发 QML 插件时,始终牢记 QML 方面,并毫不犹豫地首先使用仅 QML 的模型来验证您的 API,然后再用 C++ 实现它。如果 API 是用 C++ 编写的,人们通常会犹豫是否要更改它或害怕重写。在 QML 中模拟 API 提供了更大的灵活性和更少的初始工作量。使用插件时,模拟 API 和真实 API 之间的切换只是更改 qml 运行时的导入路径。

posted @ 2022-04-15 13:55  sammy621  阅读(880)  评论(4编辑  收藏  举报