第十八章: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中,我們聲明了依賴關系,這里是QtQuick
和QtQuick.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!"
}
}
當導入QtQuick
和QtQuick.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。這里需要一個read
和write
函數。我們也看到write
函數接收path
和text
參數,而read
函數接收path
參數並返回文本。如你所見,path
和text
是常用參數,或許可以將其提取出來作為屬性,來簡化聲明式上下文環境下的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.h
和fileio.cpp
,它們聲明和實現了FileIO
類型,還有一個允許 QML 引擎發現擴展的實際插件類的fileio_plugin.cpp
。
插件類是從QQmlEngineExtensionPlugin
類繼承的,並包含Q_OBJECT
和Q_PLUGIN_METADATA
宏。整個文件如下:
#include <QQmlEngineExtensionPlugin>
class FileioPlugin : public QQmlEngineExtensionPlugin
{
Q_OBJECT
Q_PLUGIN_METADATA(IID QQmlEngineExtensionInterface_iid)
};
#include "fileio_plugin.moc"
擴展會自動發現並注冊所有以QML_ELEMENT
和QML_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
函數的null
和 4
參數將對結果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 運行時的導入路徑。