第十五章:动态QML

第十五章:动态QML

动态QML

目前为止,我们只把QML当作构建可以相互跳转的静态场景的工具。依赖各种状态和逻辑规则,一个生动和动态的用户界面被构建出来。通过将QML和JavaScript以更加动态的方式配合使用,加深了其灵活性和可扩展性。组件可以在运行时加载和初始化,元素可以被销毁。动态创建的用户界面可以存储到硬盘,后续可以被恢复。

动态加载组件

动态加载QML的不同部分的最简单方法是使用Loader元素。它做为被加载项目的占位符。通过sourcesourceComponent属性来加载项目。前者通过给定的URL来加载项目,后者初始化组件Component
因为loader作为被加载项目的占位符,所在其尺寸依赖于项目的尺寸。如果Loader元素设有尺寸,则无论设置其widthheight,还是通过锚定,被加载的项目将会跟加载器(loader)的尺寸一致。如果Loader没设尺寸,那么它的尺寸就与要加载的项目一致。
下面的例子演示了如何将两个分离的用户界面通过Loader元素加载到一起。思路是,一个既可以模拟显示也可以数字显示的车速表,如下图所示。表盘上的数字不受当前加载项目的影响。


第一步是在应用中声明一个Loader元素。注意source属性未写。 这是因为source依赖于当在用户在哪个界面。

Loader {
    id: dialLoader

    anchors.fill: parent
}

在父元素dialLoaderstates属性中,一系列的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并非总有设置,而目标元素也可能没有指定的属性propertyBinding元素的when属性限定本绑定设置何时有效。比如,可以限定使用在用户界面指定的模式。
Binding元素也带有一个delayed属性。当这个属性被设置为true时,直到事件队列被清空,绑定才会传给target。在高负载情况下,这可以作为一种优化,因为中间值不会被推送到目标元素。

创建与销毁对象

Loader元素能够动态填充用户界面。然而界面的整体结构仍然是静态的。通过JavaScript,可以再进一步,完全动态地初始化QML元素。
在详细了解创建元素的细节前,需要先了解流程。当从文件或从网络加载一段QML代码时,组件就被创建了。组件封装了解释执行的QML代码,可被用于创建项目。这意味着加载一段QML代码与从中初始化项目是两个过程。首先,QML代码被解析为组件。然后组件初用于初始化实际的项目实例。
除了从来自文件或网络服务器上的QML代码创建元素,也可以从包含QML代码的文本中直接创建QML对象。动态创建的项目与一次性创建(非动态)的项目在实际应用中没啥不同。

动态加载和初始化项目

当加载一段QML时,首先会被转化成为组件。 这一过程包括加载依赖以及验证代码有效性。QML的路径,既可以是本地文件,一种Qt资源,也可以是由URL指定的远程网络。这意味着加载时间有很大的不确定性,比如,在内存资源中且没有未加载的依赖,又比如很耗时的,一段由运行很慢的服务器上提供的代码,且有多个依赖需要加载的情况。
被创建的组件的状态可以通过它的status属性来跟踪。其可用的值有:Component.NullComponent.LoadingComponent.Ready以及Component.Error。状态值通常的流程从NullLoading再到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个参数:qmlparent,和filepathqml参数包含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.createObjectcreateQmlObject等动态机制来创建的元素(也就是说,非动态创建的元素也不能销毁)。
通过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。
这意味着每次addUfoaddRocket函数被调用,当新对象完成创建时,itemAdded函数也将被调用。后者将为objectsModel模型添加对象引用和源URL。
objectsModel有多种用途。在上面仍有疑问的例子中,clearItems函数还依赖于它。这个函数证明了两件事。首先,如何遍历模型并执行一个任务,如,为每个项目调用destroy函数并移除它。其次,它凸显了模型没有随着对象的销毁而更新的事实。那个模型的obj属性被置为了null,而不是删除连接到相关对象的模型项。想修正它,代码必须在移除对象时清空模型项。

function clearItems() {
    while(objectsModel.count > 0) {
        objectsModel.get(0).obj.destroy()
        objectsModel.remove(0)
    }
}

有了一个代表所有动态创建的项目的模型,很容易创建一个序列化项目的函数。在示例代码中,序列化的信息由每个对象的源URL以及它的xy属性组成。这些属性可以由用户修改。这些信息用于构建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会设置新创建的对象的xy属性。然后更新索引,如有还有需要创建的对象,就接着创建下个对象。

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。然后用这个组件ComponentcreateObject方法来创建对象。
因为绑定属性、信号连接以及访问对象实例,都依赖于对象id,这些对于动态创建的对象(没有id)来说,必须有替代方法。创建绑定要用Binding元素。而Connections元素能够实现连接动态创建的对象的信号。
使用动态创建项目的一个挑战是对它们的跟踪。这可以使用一个模型实现。通过一个模型来跟踪动态创建的项目,可以实现序列化和反序列化的函数,进而实现存储和恢复动态创建的场景。

posted @ 2022-04-04 20:00  sammy621  阅读(1337)  评论(1编辑  收藏  举报