第十八章: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 運行時的導入路徑。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM