第十五章:動態QML
動態QML
目前為止,我們只把QML當作構建可以相互跳轉的靜態場景的工具。依賴各種狀態和邏輯規則,一個生動和動態的用戶界面被構建出來。通過將QML和JavaScript以更加動態的方式配合使用,加深了其靈活性和可擴展性。組件可以在運行時加載和初始化,元素可以被銷毀。動態創建的用戶界面可以存儲到硬盤,后續可以被恢復。
動態加載組件
動態加載QML的不同部分的最簡單方法是使用Loader
元素。它做為被加載項目的占位符。通過source
或sourceComponent
屬性來加載項目。前者通過給定的URL來加載項目,后者初始化組件Component
。
因為loader作為被加載項目的占位符,所在其尺寸依賴於項目的尺寸。如果Loader
元素設有尺寸,則無論設置其width
,height
,還是通過錨定,被加載的項目將會跟加載器(loader)的尺寸一致。如果Loader
沒設尺寸,那么它的尺寸就與要加載的項目一致。
下面的例子演示了如何將兩個分離的用戶界面通過Loader
元素加載到一起。思路是,一個既可以模擬顯示也可以數字顯示的車速表,如下圖所示。表盤上的數字不受當前加載項目的影響。
第一步是在應用中聲明一個Loader
元素。注意source
屬性未寫。 這是因為source
依賴於當在用戶在哪個界面。
Loader {
id: dialLoader
anchors.fill: parent
}
在父元素dialLoader
的states
屬性中,一系列的PropertyChanges
元素驅動着加載不同state
下的不同QML文件。本例中source
屬性恰好是相對文件路徑,但它也可以是完整的URL,從而通過網絡來獲取顯示項目。
states: [
State {
name: "analog"
PropertyChanges { target: analogButton; color: "green"; }
PropertyChanges { target: dialLoader; source: "Analog.qml"; }
},
State {
name: "digital"
PropertyChanges { target: digitalButton; color: "green"; }
PropertyChanges { target: dialLoader; source: "Digital.qml"; }
}
]
為了能讓加載的項目生動,其speed
屬性必須綁定到根元素的speed
屬性上。但這無法直接綁定,因為項目並非一直處於加載狀態且過段時間才會變化。相應的,必須使用Binding
元素。每當Loader
觸發onLoaded
信號時,綁定的target
屬性都會改變。
Loader {
id: dialLoader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: analogButton.top
onLoaded: {
binder.target = dialLoader.item;
}
}
Binding {
id: binder
property: "speed"
value: root.speed
}
當項目被加載后,信號onLoaded
讓加載的QML生效。以類似的方式,QML的加載可以依賴於Component.onCompleted
信號。實際上在所有組件上都可以用這個信號,無論他們是怎么加載的。比如,整個應用程序的根組件可以在加載整個用戶界面時使用它來啟動自身。
間接連接
當動態創建QML時,不能用onSignalName
這種靜態設置的方法來連接信號。而必須使用Connections
元素。它可以連接到target
元素的任意數量的信號。
設置了Connections
元素的target
屬性,就可以達到象平常(onSignalName
)那樣連接信號了的效果了。而且,改動target
屬性,可以在不同的時間監視到不同的元素。
上面顯示的例子中,用戶界面由兩個呈現給用戶的可點擊區域組成。任何一個區域被點擊,都會閃現一段動畫。左邊區域在以下代碼片段顯示。在鼠標區域MouseArea
里點擊觸發leftClickedAnimation
,會導致本區域閃爍。
Rectangle {
id: leftRectangle
width: 290
height: 200
color: "green"
MouseArea {
id: leftMouseArea
anchors.fill: parent
onClicked: leftClickedAnimation.start()
}
Text {
anchors.centerIn: parent
font.pixelSize: 30
color: "white"
text: "Click me!"
}
}
除了兩個可點擊區域外,還用到了Connections
元素。點擊當前元素,即(本例中,是由states
切換事件決定的Connections的
)target
元素,會觸發了第三個動畫。
Connections {
id: connections
//target是leftMouseArea時,點擊leftMouseArea才會觸發第三個動畫activeClickedAnimation,此時點擊rightMouseArea並不會觸發第三個動畫;當target是rightMouseArea時,也是同樣的道理
function onClicked() { activeClickedAnimation.start() }
}
為判定哪個區域(左或右)MouseArea
作為目標區域,定義了兩個狀態。注意不能使用PropertyChanges
元素設置target
屬性,因為已經包括了一個target
屬性。而應使用StateChangeScript
。
states: [
State {
name: "left"
StateChangeScript {
script: connections.target = leftMouseArea
}
},
State {
name: "right"
StateChangeScript {
script: connections.target = rightMouseArea
}
}
]
當試着運行本例時,要注意當使用多個信號處理函數時,它們都會被觸發。而執行的順序未設定。
當創建一個未設置target
屬性的Connections
元素時,(target
)屬性被默認設置為parent
。這意味着target
需要被顯式地設置為null
以避免在target
被設置前從parent
獲得信號。這種行為確實使得基於 Connections
元素創建自定義信號處理程序組件成為可能。如此一來,信號的響應代碼可以被包裝和重用。
下面的例子里,Flasher
組件可以放在任一MouseArea
。當被點擊后,會觸發動畫,使得父組件閃爍。在同樣的鼠標區域MouseArea
,也可以觸發執行實際的任務。這區分了實際的動作與標准的用戶反饋,如閃爍。
import QtQuick
Connections {
function onClicked() {
// Automatically targets the parent
}
}
要使用Flasher
,在每個鼠標區域內實例化一個Flasher,就可以了。
import QtQuick
Item {
// A background flasher that flashes the background of any parent MouseArea
}
當使用Connections
元素來監聽多個類型的target
元素的信號時,有時你會發現在目標間的有效信號差別較大(因為目標元素可能不是同類型的,有的元素有信號,有的則沒有)。因為有信號丟失,這會導致Connections
元素輸出運行時錯誤。為避免此種情況,可將ignoreUnknownSignal
屬性設置為true
。這會忽略這樣的錯誤。
注意
阻止錯誤信息通常是非常不好的做法。如果非要這么要,確保在注釋中寫明這么做的原因。
間接綁定
正如不能直接連接到動態創建的元素的信號,若不通過橋接元素,也不能綁定動態創建元素的屬性。綁定任何一個元素的屬性,包括動態創建的元素的屬性,可以使用Binding
元素。
Binding
元素要求指定一個target
元素,一個待綁定property
屬性,一個綁定值value
。通過使用Binding
元素,可以綁定動態加載的元素的屬性。通過本章之前介紹里的例子演示了其用法。
Loader {
id: dialLoader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: analogButton.top
onLoaded: {
binder.target = dialLoader.item;
}
}
Binding {
id: binder
property: "speed"
value: root.speed
}
因為元素Binding
的目標屬性target
並非總有設置,而目標元素也可能沒有指定的屬性property
,Binding
元素的when
屬性限定本綁定設置何時有效。比如,可以限定使用在用戶界面指定的模式。
Binding
元素也帶有一個delayed
屬性。當這個屬性被設置為true
時,直到事件隊列被清空,綁定才會傳給target
。在高負載情況下,這可以作為一種優化,因為中間值不會被推送到目標元素。
創建與銷毀對象
Loader
元素能夠動態填充用戶界面。然而界面的整體結構仍然是靜態的。通過JavaScript,可以再進一步,完全動態地初始化QML元素。
在詳細了解創建元素的細節前,需要先了解流程。當從文件或從網絡加載一段QML代碼時,組件就被創建了。組件封裝了解釋執行的QML代碼,可被用於創建項目。這意味着加載一段QML代碼與從中初始化項目是兩個過程。首先,QML代碼被解析為組件。然后組件初用於初始化實際的項目實例。
除了從來自文件或網絡服務器上的QML代碼創建元素,也可以從包含QML代碼的文本中直接創建QML對象。動態創建的項目與一次性創建(非動態)的項目在實際應用中沒啥不同。
動態加載和初始化項目
當加載一段QML時,首先會被轉化成為組件。 這一過程包括加載依賴以及驗證代碼有效性。QML的路徑,既可以是本地文件,一種Qt資源,也可以是由URL指定的遠程網絡。這意味着加載時間有很大的不確定性,比如,在內存資源中且沒有未加載的依賴,又比如很耗時的,一段由運行很慢的服務器上提供的代碼,且有多個依賴需要加載的情況。
被創建的組件的狀態可以通過它的status
屬性來跟蹤。其可用的值有:Component.Null
,Component.Loading
,Component.Ready
以及Component.Error
。狀態值通常的流程從Null
到Loading
再到Ready
。在每個階段,狀態值status
都可以變成Error
。這種情況下,組件無法被用於創建新的實例對象。函數Component.errorString()
可被用於檢索用戶易懂的錯誤描述。
當通過低速鏈接來加載組件時,可以使用progress
屬性來獲得進度。其范圍從0.0
,表示還未加載,到1.0
,表示已經加載完成。當組件狀態status
變成Ready
,組件就可以被用於初始化實例對象了。下面的代碼演示了如何實現,包括組件准備就緒和直接創建失敗的事件,以及組件略晚就緒的場景。
var component;
function createImageObject() {
component = Qt.createComponent("dynamic-image.qml");
if (component.status === Component.Ready || component.status === Component.Error) {
finishCreation();
} else {
component.statusChanged.connect(finishCreation);
}
}
function finishCreation() {
if (component.status === Component.Ready) {
var image = component.createObject(root, {"x": 100, "y": 100});
if (image === null) {
console.log("Error creating image");
}
} else if (component.status === Component.Error) {
console.log("Error loading component:", component.errorString());
}
}
上面的代碼是單獨的JavaScript源文件,它在QML主文件中被引用。
import QtQuick
import "create-component.js" as ImageCreator
Item {
id: root
width: 1024
height: 600
Component.onCompleted: ImageCreator.createImageObject();
}
組件的createObject
函數用於創建對象實例,如上所示。這不僅適用於動態加載的組件,對於QML中的Component
也適用。生在的對象可以象其它對象一樣用於QML的場景。唯一不同的是,它沒有id。
createObject
函數有兩個參數。第一個是類型Item
的父parent
對象。第二個是屬性和值的列表,形如{"name": value, "name": value}
。下例做了演示。注意,屬性參數可選。
var image = component.createObject(root, {"x": 100, "y": 100});
注意
動態創建的組件實例與QML內聯的組件Component
元素並無不同。內聯的組件Component
元素也有函數來動態初始化實例
孵化組件
當組件的創建是使用createObject
函數時,目標組件的生成是阻塞的。這意味着復雜元素的初始化會阻塞主線程,導致界面模糊卡頓。相應的,復雜組件可以拆分並分階段使用Loader
加載元素。
為了解決這個問題,可以使用孵化對象incubateObject
方法來實例化一個組件。這和createObject
有一樣的效果,可以立即返回一個實例,或在組件完成ready時做回調。這對解決初始化相關的界面動畫延遲問題有可能有用也可能沒用,取決於具體設置。
使用孵化器,就象createComponent
那樣簡單。然而,返回對象是一個孵化器而不是實例對象本身。當孵化器狀態為Component.Ready
,就可以通過孵化器的object
屬性來訪問對象了。所以這些都在下面代碼中有演示。
function finishCreate() {
if (component.status === Component.Ready) {
var incubator = component.incubateObject(root, {"x": 100, "y": 100});
if (incubator.status === Component.Ready) {
var image = incubator.object; // Created at once
} else {
incubator.onStatusChanged = function(status) {
if (status === Component.Ready) {
var image = incubator.object; // Created async
}
};
}
}
}
從文本動態初始化項目
有時,比較方便多QML中的文本中實例化一個對象。正常情況下,這比將代碼放在單獨的文件中要快。這種情況要用到Qt.createQmlObject
函數。
這個函數使用3個參數:qml
,parent
,和filepath
。qml
參數包含qml代碼中要被實例化的QML代碼文本。parent
參數提供了相對於新創建對象的父對象。參數filepath
用於保存對象創建的任何錯誤。函數要么返回一個新的對象,要么返回null
。
警告
函數createQmlObject
總是立即返回。要使函數能執行成功,必須加載調用所有的依賴項。這意味着如果傳給函數qml代碼指向一個未加載的組件,則調用會失敗並返回null
。為更好的處理這種情況,必須使用createComponent
/createObject
方法。
使用Qt.createQmlObject
函數動態創建的對象與其它形式動態創建的對象類似。也就是說跟任何其它QML對象一樣,除了沒有id
。下面的例子里,當root
元素被創建后,從內聯QML代碼中實例化一個新的Rectangle
元素。
import QtQuick
Item {
id: root
width: 1024
height: 600
function createItem() {
Qt.createQmlObject("import QtQuick 2.5; Rectangle { x: 100; y: 100; width: 100; height: 100; color: \"blue\" }", root, "dynamicItem")
}
Component.onCompleted: root.createItem()
}
管理動態創建的元素
動態創建的對象可以與 QML 場景中的任何其他對象一視同仁。然面這里面有一些值得注意的地方。最重要的一個概念是***創建上下文***。
動態創建的對象的創建上下文就是正在創建的對象的上下文(有點繞,仔細體會)。這不一定與父級所在的上下文相同。當創建上下文被銷毀時,與對象相關的綁定也會被銷毀。這意味着在代碼中的某個位置實現動態對象的創建非常重要,該位置將在對象的整個生命周期內被實例化。(此處未吃透,原文:This means that it is important to implement the creation of dynamic objects in a place in the code which will be instantiated during the entire lifetime of the objects.)
動態創建的對象也可以動態銷毀。當要這么做時,有一條慣用規則:千萬別銷毀還未創建的對象。這也包括那些已創建,但不是使用Component.createObject
或createQmlObject
等動態機制來創建的元素(也就是說,非動態創建的元素也不能銷毀)。
通過destroy
函數來銷毀對象。函數接收一個可選的整型參數,它指定了在被銷毀前,對象可以存在多少毫秒的時間。這也適用於讓對象完成最后的事務的場景。
item = Qt.createQmlObject(...)
...
item.destroy()
注意
可以從內部銷毀對象,比如,可以創建自毀彈出窗口。
跟蹤動態對象
使用動態對象,通常需要跟蹤創建的對象。另一個特性是要能夠存儲和恢復動態對象的狀態。所有這些事情可以使用動態填充的XmlListModel
輕松處理。
下面的例子有兩處類型的元素,火箭和不明飛行物可以由用戶創建和移動。為了能夠操縱動態創建的元素的整個場景,我們使用模型來跟蹤這些項目。
當項目們被創建好后,模型,一個XmlListModel
也被填充好了。對象引用與實例化時使用的源URL一起被跟蹤。后者並不嚴格用於跟蹤目標,但稍后會派上用場。
import QtQuick
import "create-object.js" as CreateObject
Item {
id: root
ListModel {
id: objectsModel
}
function addUfo() {
CreateObject.create("ufo.qml", root, itemAdded)
}
function addRocket() {
CreateObject.create("rocket.qml", root, itemAdded)
}
function itemAdded(obj, source) {
objectsModel.append({"obj": obj, "source": source})
}
}
正如上例中所看到的那樣,create-object.js
是前面介紹的JavaScript的更通用的形式。create
方法使用了三個參數:源URL、根元素、以及函數完成后的一個回調函數。回調函數要傳入兩個參數:對新創建對象的引用、和使用的源URL。
這意味着每次addUfo
和addRocket
函數被調用,當新對象完成創建時,itemAdded
函數也將被調用。后者將為objectsModel
模型添加對象引用和源URL。
objectsModel
有多種用途。在上面仍有疑問的例子中,clearItems
函數還依賴於它。這個函數證明了兩件事。首先,如何遍歷模型並執行一個任務,如,為每個項目調用destroy
函數並移除它。其次,它凸顯了模型沒有隨着對象的銷毀而更新的事實。那個模型的obj
屬性被置為了null
,而不是刪除連接到相關對象的模型項。想修正它,代碼必須在移除對象時清空模型項。
function clearItems() {
while(objectsModel.count > 0) {
objectsModel.get(0).obj.destroy()
objectsModel.remove(0)
}
}
有了一個代表所有動態創建的項目的模型,很容易創建一個序列化項目的函數。在示例代碼中,序列化的信息由每個對象的源URL以及它的x
和y
屬性組成。這些屬性可以由用戶修改。這些信息用於構建XML文檔字符串。
function serialize() {
var res = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<scene>\n"
for(var ii=0; ii < objectsModel.count; ++ii) {
var i = objectsModel.get(ii)
res += " <item>\n <source>" + i.source + "</source>\n <x>" + i.obj.x + "</x>\n <y>" + i.obj.y + "</y>\n </item>\n"
}
res += "</scene>"
return res
}
注意
目前,Qt 6的XmlListModel
缺少執行序列化和反序列化的xml
屬性和get()
函數。
通過設置模型的xml
屬性,可以將字XML文檔字符串與XmlListModel
一起使用。下面的代碼中,能看到模型與deserialize
函數。deserialize
函數是通過設置dsIndex
來指向模型的第一個項目,然后再觸發那個項目的創建,來啟動反序列化的。接下來回調函數dsItemAdded
會設置新創建的對象的x
和y
屬性。然后更新索引,如有還有需要創建的對象,就接着創建下個對象。
XmlListModel {
id: xmlModel
query: "/scene/item"
XmlListModelRole { name: "source"; elementName: "source" }
XmlListModelRole { name: "x"; elementName: "x" }
XmlListModelRole { name: "y"; elementName: "y" }
}
function deserialize() {
dsIndex = 0
CreateObject.create(xmlModel.get(dsIndex).source, root, dsItemAdded)
}
function dsItemAdded(obj, source) {
itemAdded(obj, source)
obj.x = xmlModel.get(dsIndex).x
obj.y = xmlModel.get(dsIndex).y
dsIndex++
if (dsIndex < xmlModel.count) {
CreateObject.create(xmlModel.get(dsIndex).source, root, dsItemAdded)
}
}
property int dsIndex
這個例子演示了模型如何用於追蹤已創建的項目,以及序列化和反序列化有多簡單。這可用於存儲動態填充的場景,例如一組小部件。在這個例子中,模型被用於跟蹤每個項目。
替代方案是使用場景中的根元素的children
屬性來跟蹤項目。但是,這需要項目本身知道用於重新創建它們的源 URL。這也要求我們得有一種方法來區分動態創建的項目和場景原生項目,這樣我們就能夠避免嘗試序列化和反序列化任何原生的項目了。
總結
本章我們學習了如何動態創建組件。這能讓我們自由創建QML場景,為用戶可配置和基於插件的架構敞開了大門。
最簡單的動態加載QML組件的方法是使用Loader
元素,由它作為即將加載內容的占位符。
更加動態的方式,可以使用Qt.createQmlObject
函數通過QML的字符串來實例化QML對象。但這種方式也存在不足。較為成熟的作法是使用Qt.createComponent
函數來創建一個組件Component
。然后用這個組件Component
的createObject
方法來創建對象。
因為綁定屬性、信號連接以及訪問對象實例,都依賴於對象id
,這些對於動態創建的對象(沒有id
)來說,必須有替代方法。創建綁定要用Binding
元素。而Connections
元素能夠實現連接動態創建的對象的信號。
使用動態創建項目的一個挑戰是對它們的跟蹤。這可以使用一個模型實現。通過一個模型來跟蹤動態創建的項目,可以實現序列化和反序列化的函數,進而實現存儲和恢復動態創建的場景。