如何在 Python 和 Qt Quick QML 应用程序中实现简化的双向数据绑定

Posted

技术标签:

【中文标题】如何在 Python 和 Qt Quick QML 应用程序中实现简化的双向数据绑定【英文标题】:How to acheive simpliefed two-way data binding in Python and Qt Quick QML application 【发布时间】:2020-06-14 22:00:47 【问题描述】:

我第一次尝试使用 Python 和 Qt 创建桌面应用程序。由于这是我第一次创建基于 gui 的应用程序,我希望有经验的人可以在我遇到的一些问题上指导我正确的方向。

背景:我正在尝试使用 Python 和 Qt 创建桌面应用程序。 Python 逻辑用于与硬件(传感器和电机)接口以控制过程。前端 gui 应该允许用户监控过程以及在过程发生时更改过程参数。以前我使用 LabVIEW 来实现我的目标,但现在我想迁移到 Python。然而,我发现用 Python 开发 gui 并不像 LabVIEW 那样简单。经过一些研究,我发现基于 QML 和 Qt Quick 的 gui 与使用 Qt (pyqt/pyside) 或 wxPython 的典型基于小部件的 gui 相比是最简单的。

问题:我很难理解如何在我的流程变量和前端 gui 之间获得两个绑定。这些过程变量可以为过程中的各种硬件(例如传感器和电机)输入/输出。我读了this tutorial,它使用基于属性的方法来获得双向绑定。 here 还解释了另一种数据绑定方法。但是,似乎必须为过程中的每个变量编写大量代码(信号、槽、setter、getter)。当后端逻辑可能有数十或数百个过程参数需要在前端 gui 中显示和修改时,代码会变得非常复杂。

我编写了一个示例程序,允许用户根据给定的输入长度和宽度计算面积。所有这些输入/输出参数都在 gui 上更新。虽然这个例子可能无法捕捉到控制某些硬件和并行进程的后端逻辑的真正复杂性,但我希望它可以帮助回答我的一些问题。

问题:

我创建了一个名为 twoWayBindedParam 的类,它可以最大限度地减少我为每种类型的过程参数编写的信号、槽、设置器和获取器的代码。这是实现后端逻辑参数和前端gui之间双向绑定的最佳方法吗?有什么可以改进的吗? 在 twoWayBindedParam 类中,我定义了属性qml_prop_*,将提供给前端 gui。但是,我遇到的问题是我必须指定它发出的信号/插槽的类型(例如:int、float、str 等......)。我不能简单地将其指定为“对象”类型。另外我在创建这个属性时不能参考self.typeOfParam。 documentation on the QtCore Property module 是有限的。如何创建一个属性可以与前端 QML 代码接口,我不必准确指定正在创建的信号槽的值类型(例如:int、float、str 等)。 在 main() 函数中,我必须将 QQmlApplicationEngine 对象注入我的 myBackendLogic 对象实例。没有这个我不能做 rootContext().setContextProperty()。有没有一种方法可以在 myBackendLogic 类中获取 QQmlApplicationEngine() 的当前运行实例,而不必在创建 myBackendLogic 对象时将其作为实例化参数提供? 如何让 QML 前端类成为一个不属于某个类的函数?例如,在下面的示例代码中,我想在按下计算按钮时运行函数 doSomething()。但是,我不确定如何调用这个 doSomething() 函数,因为 rootContext().setContextProperty() 需要类对象的名称。 是否有建议或简单的后端逻辑方法,当启动 gui 应用程序时,我可以加载存储在某个文件(例如:json、xml、csv)中的数十/数百个每个配置的参数。 我当前的 gui 按预期工作,但是当我将 gui 窗口拖到第二个监视器时,所有控件都冻结了。当它解冻时,我需要将窗口拉回主监视器。

我希望回答我问题的人注意使用 Python 而不是 C++ 作为示例代码。

ma​​in.py

import sys
import os
import random

from PySide2.QtGui import QGuiApplication
from PySide2.QtQml import QQmlApplicationEngine
from PySide2.QtCore import QObject, Signal, Slot, Property

class twoWayBindedParam(QObject):
   # A class to represent a variable that is two-binded in the Python logic and in QML gui

    def __init__(self, value):
        QObject.__init__(self)
        self.value = value
        self.typeOfParam = type(self.value) # Determine if its a str, int, float, etc...

    @Signal
    def valueChanged(self):
        pass

    @Slot(int)
    def set(self, value):
        self.value = value
        self.valueChanged.emit()

    def get(self):
        return self.value

    # Problem: I must create different properties for each type
    # In the QML gui, I must use the correct type of property
    # The problem is when creating the Property() object,
    # I can NOT refer to the self.typeOfParam
    # Chagning the type to 'object' doesn't work: https://***.com/a/5186587/4988010
    qml_prop_int = Property(int, get, set, notify=valueChanged)
    qml_prop_float = Property(float, get, set, notify=valueChanged)
    qml_prop_bool = Property(bool, get, set, notify=valueChanged)

class myBackendLogic(QObject):

    def __init__(self, app_engine):
        QObject.__init__(self)

        # The app_engine object is needed to use the function rootContext().setContextProperty()
        # Is there a way to get the current instance of the app_engine that is created in the main
        # without it having to be passed as a paramter to the myBackendLogic() object?
        self.eng = app_engine

        self.init_default_params()

    def init_default_params(self):

        random.seed(23)
        length = random.randint(0,100)
        width = random.randint(0,100)
        area = self.whatIsArea(length,width)

        self.length_param = twoWayBindedParam(length)
        self.eng.rootContext().setContextProperty("length_param", self.length_param)

        self.width_param = twoWayBindedParam(width)
        self.eng.rootContext().setContextProperty("width_param", self.width_param)

        self.area_param = twoWayBindedParam(area)
        self.eng.rootContext().setContextProperty("area_param", self.area_param)

        self.continuous_calc_param = twoWayBindedParam(False)
        self.eng.rootContext().setContextProperty("continuous_calc_param", self.continuous_calc_param)

    def whatIsArea(self, l,w):
        result = float(l*w) + random.random() # Add some noise
        return result

    @Slot()
    def calculate_area_param(self):
        area = self.whatIsArea(self.length_param.get(),self.width_param.get())
        self.area_param.set(area)

def doSomething():
    print('Do something')

def main():
    app = QGuiApplication(sys.argv)
    engine = QQmlApplicationEngine()

    mylogic = myBackendLogic(engine)
    engine.rootContext().setContextProperty("mylogic", mylogic)

    engine.load(os.path.join(os.path.dirname(__file__), "main.qml"))

    if not engine.rootObjects():
        sys.exit(-1)
    sys.exit(app.exec_())

if __name__ == "__main__":

    main()

ma​​in.qml

import QtQuick 2.13
import QtQuick.Window 2.13
import QtQuick.Controls 2.2

Window 
    title: qsTr("Hello World")
    width: 640
    height: 480
    visible: true

    Column 
        id: column
        x: 131
        y: 63
        width: 72
        height: 263

        TextInput 
            id: textInput_length
            width: 80
            height: 20
            text: length_param.qml_prop_int
            font.pixelSize: 12
        

        Slider 
            id: slider_length
            to: 100
            orientation: Qt.Vertical
            value: length_param.qml_prop_int
            onValueChanged: 
                length_param.set(value)
                if (continuous_switch.checked)  mylogic.calculate_area_param() 
            
        


    

    Column 
        id: column1
        x: 249
        y: 63
        width: 72
        height: 263

        TextInput 
            id: textInput_width
            width: 80
            height: 20
            text: width_param.qml_prop_int
            font.pixelSize: 12
        

        Slider 
            id: slider_width
            to: 100
            value: width_param.qml_prop_int
            orientation: Qt.Vertical
            onValueChanged: 
                width_param.set(value)
                if (continuous_switch.checked)  mylogic.calculate_area_param() 
            
        
    

    Row 
        id: row
        x: 110
        y: 332
        width: 274
        height: 53


        Slider 
            id: slider_area
            to: 10000
            value: area_param.qml_prop_float
        

        Label 
            id: label_area
            text: area_param.qml_prop_float
        
    


    Switch 
        id: continuous_switch
        x: 343
        y: 149
        text: qsTr("Continuous calculate")
        checked: continuous_calc_param.qml_prop_bool
    

    Button 
        id: button
        x: 383
        y: 205
        text: qsTr("Calculate")
        onClicked: 
            mylogic.calculate_area_param()
        
    

    Label 
        id: label
        x: 131
        y: 23
        text: qsTr("Length")
        font.pointSize: 12
    

    Label 
        id: label1
        x: 249
        y: 23
        text: qsTr("Width")
        font.pointSize: 12
    

    Label 
        id: label3
        x: 196
        y: 377
        text: qsTr("Area")
        font.pointSize: 12
    



【问题讨论】:

请避免询问 SW 推荐,因为它们明显偏离主题 【参考方案1】:

您的问题很有趣,但对于帖子而言,它可能很广泛,因此对于下一个问题,建议您为每个问题创建一个帖子。

也许 Qt 所需的逻辑对您来说似乎很复杂,并且它也被转移到 python 绑定(如 PyQt5 和 PySide2),但如果您在 Qt 世界中花费更多时间,您会意识到它不是。

与具有表示仪器(机械、电气等)的专用元素的 LabView 不同。 Qt 是一个通用库,因此显然您将不得不投入更多时间来实现您想要的,如果您的目标是特定领域,那么如果您相信 Qt 之上的库会很好。


在本节中,我将尝试回答每个问题,因此我将列出它们:

    对于许多人来说,必须为琐碎的功能实现大量代码似乎很广泛且没有必要,如以下问题所示:How to create PyQt Properties dynamically,因此您可以将答案作为基础。

    李>

    PySide2/PyQt5 是 C++ 对象包装器,因此设置了强类型语言限制,因此您无法设置“对象”类型。这有一个优势和理由:Qt 希望使用尽可能少的内存,因为它的许多应用程序都是用于嵌入式应用程序的,并且它还减少了每个任务的延迟。另一方面,PySide2 文档仍在工作,因此您可以将 Qt 文档作为基础,例如,属性等同于 Q_PROPERTY 或 documentation of its PyQt5brother

    要从 QML 使用在 python 中实现的 QObject,不需要使用 setContextProperty()。有两种方法可以将 QObjects 暴露给 QML:

    通过 setContextProperty() 公开一个 QObject,它的行为就像一个具有全局范围的单例。

    通过 qmlRegisterType 公开一个从 QObject 继承的类(参见 here),然后您可以像 TextInput、Slider 等其他项一样创建该 QML 类的对象。

    因此,无需访问引擎即可将类公开给 QML。

    不可能向 QML 公开函数,只能公开 QObjects 对象、一些基本类型,如 int、string 等,以及基于 QObject 的类。一种解决方案是创建一个具有该方法的类并调用它。

    根据Qt中的数据结构,推荐使用模型(QAbstractItem, Table, ListModel, QStandardItemModel, QSqlQuery, TableModel等)并使用QListView创建小部件,QGridView,中继器。这些模型可以从 json、xml、csv 等来源获取信息,例如已经有一个名为 XmlListModel 的 Item,基于 json 或 csv 构建模型通常并不复杂。

    我没有第二台显示器,所以我无法测试你的指示,但如果是这样,那么这是一个 Qt 错误,所以我建议报告它。

customproperty.py

from PySide2 import QtCore, QtGui, QtQml


class PropertyMeta(type(QtCore.QObject)):
    def __new__(cls, name, bases, attrs):
        for key in list(attrs.keys()):
            attr = attrs[key]
            if not isinstance(attr, Property):
                continue
            initial_value = attr.initial_value
            type_ = type(initial_value)
            notifier = QtCore.Signal(type_)
            attrs[key] = PropertyImpl(
                initial_value, name=key, type_=type_, notify=notifier
            )
            attrs[signal_attribute_name(key)] = notifier
        return super().__new__(cls, name, bases, attrs)


class Property:
    """ Property definition.

    This property will be patched by the PropertyMeta metaclass into a PropertyImpl type.
    """

    def __init__(self, initial_value, name=""):
        self.initial_value = initial_value
        self.name = name


class PropertyImpl(QtCore.Property):
    """ Actual property implementation using a signal to notify any change. """

    def __init__(self, initial_value, name="", type_=None, notify=None):
        super().__init__(type_, self.getter, self.setter, notify=notify)
        self.initial_value = initial_value
        self.name = name

    def getter(self, inst):
        return getattr(inst, value_attribute_name(self.name), self.initial_value)

    def setter(self, inst, value):
        last_value = getattr(inst, self.name)
        if last_value != value:
            setattr(inst, value_attribute_name(self.name), value)
            notifier_signal = getattr(inst, signal_attribute_name(self.name))
            notifier_signal.emit(value)


def signal_attribute_name(property_name):
    """ Return a magic key for the attribute storing the signal name. """
    return f"_property_name_prop_signal_"


def value_attribute_name(property_name):
    """ Return a magic key for the attribute storing the property value. """
    return f"_property_name_prop_value_"

ma​​in.py

import os
import random

from PySide2 import QtCore, QtGui, QtQml

from customproperty import Property, PropertyMeta


def whatIsArea(l, w):
    result = float(l * w) + random.random()
    return result


class Utils(QtCore.QObject):
    @QtCore.Slot()
    def doSomething(self):
        print("Do something")


class Backend(QtCore.QObject, metaclass=PropertyMeta):
    length = Property(0)
    width = Property(0)
    area = Property(0)
    is_continuous = Property(False)

    @QtCore.Slot()
    def calculate_area(self):
        self.area = whatIsArea(self.length, self.width)


CURRENT_DIR = os.path.dirname(__file__)

QtQml.qmlRegisterType(Backend, "MyLibrary", 1, 0, "Backend")


def main():
    import sys

    app = QtGui.QGuiApplication(sys.argv)

    engine = QtQml.QQmlApplicationEngine()

    utils = Utils()
    engine.rootContext().setContextProperty("Utils", utils)

    engine.load(os.path.join(CURRENT_DIR, "main.qml"))

    if not engine.rootObjects():
        sys.exit(-1)
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()
import QtQuick 2.13
import QtQuick.Window 2.13
import QtQuick.Controls 2.2

import MyLibrary 1.0

Window 
    title: qsTr("Hello World")
    width: 640
    height: 480
    visible: true

    Backend
        id: backend
        length: 55
        width: 87
        Component.onCompleted: calculate_area()
    

    Column 
        id: column
        x: 131
        y: 63
        width: 72
        height: 263

        TextInput 
            id: textInput_length
            width: 80
            height: 20
            text: backend.length
            font.pixelSize: 12
        

        Slider 
            id: slider_length
            to: 100
            orientation: Qt.Vertical
            value: backend.length
            onValueChanged: 
                backend.length = value
                if (backend.is_continuous)  backend.calculate_area() 
            
        
    

    Column 
        id: column1
        x: 249
        y: 63
        width: 72
        height: 263

        TextInput 
            id: textInput_width
            width: 80
            height: 20
            text: backend.width
            font.pixelSize: 12
        

        Slider 
            id: slider_width
            to: 100
            value: backend.width
            orientation: Qt.Vertical
            onValueChanged: 
                backend.width = value
                if (backend.is_continuous)  backend.calculate_area() 
            
        
    

    Row 
        id: row
        x: 110
        y: 332
        width: 274
        height: 53


        Slider 
            id: slider_area
            to: 10000
            value: backend.area
        

        Label 
            id: label_area
            text: backend.area
        
    


    Switch 
        id: continuous_switch
        x: 343
        y: 149
        text: qsTr("Continuous calculate")
        checked: backend.is_continuous
        onCheckedChanged: backend.is_continuous = checked
    

    Button 
        id: button
        x: 383
        y: 205
        text: qsTr("Calculate")
        onClicked: 
            backend.calculate_area()
        
    

    Label 
        id: label
        x: 131
        y: 23
        text: qsTr("Length")
        font.pointSize: 12
    

    Label 
        id: label1
        x: 249
        y: 23
        text: qsTr("Width")
        font.pointSize: 12
    

    Label 
        id: label3
        x: 196
        y: 377
        text: qsTr("Area")
        font.pointSize: 12
    

    Component.onCompleted: Utils.doSomething()


【讨论】:

感谢您的详细解答。我最初的后续问题是关于如何在示例代码中的类 Backend 中创建双向绑定变量(Property 对象)。看来我只能将这些 Property 对象创建为类属性而不是实例属性。因此,我不能在 Backend 类的__init__() 方法中执行self.length = Property(0)。我确实有一些额外的后续问题,但我仍在尝试理解和消化您使用元类的示例代码。 @Zythyr 1) 以这种方式声明属性,2) Qt 只向 QML 公开一些属性,请记住 Qt 是用 C++ 编写的,因此存在范围概念:私有、公共和受保护这也可以推断为它们的绑定,这也有助于我们轻松地分离逻辑。 3) 元类用于向类添加功能。 4)如果您有后续问题,请在另一篇文章中进行,并尽量不要像我在回答开头指出的那样过于宽泛。此外,如果您有 n 个问题,那么您必须创建一个帖子,因为您更有可能有人回答 y

以上是关于如何在 Python 和 Qt Quick QML 应用程序中实现简化的双向数据绑定的主要内容,如果未能解决你的问题,请参考以下文章

如何通过按下和拖动在 Qt Quick/QML 画布中绘制一个矩形

Qt Quick - 如何仅通过 c++ 代码与 qml 属性交互

如何拦截 Qt Quick qml 事件?

Qt5 和 QML:如何使用 WebEngine Quick Nano Browser 自动输入用户名和密码

Qt和Qt Quick QML,

如何使 Qt Quick (QML) ListView 项目无法选择?