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 運行時的導入路徑。
本章完。