Qt/QML expone las clases de C++ a QML y por qué setContextProperty no es la mejor idea

 C Programming >> Programación C >  >> Tags >> Qt
Qt/QML expone las clases de C++ a QML y por qué setContextProperty no es la mejor idea

En este artículo, voy a discutir las diferentes formas de exponer una clase de C++ a QML. QML es un lenguaje de marcado (parte del marco QT) como HTML/CSS, con JavaScript en línea que puede interactuar con el código C++ de su aplicación (QT). Hay varias formas de exponer una clase de C++ a QML, cada una con sus propios beneficios y peculiaridades. Esta guía cubrirá tres métodos de integración, qmlRegisterSingletonType<> , rootContext->setContextProperty() y qmlRegisterType<> . Terminaremos con un punto de referencia simple que muestra la diferencia en los tiempos de inicio entre los dos primeros.

El resumen ejecutivo es que setContextProperty está en desuso, tiene un impacto en el rendimiento (y debe usar qmlRegisterSingletonType<> . En mybenchmarks el qmlRegisterSingletonType uno es más rápido que setContextProperty . Si necesita más de una instancia de su clase, use qmlRegisterType<> e instancia tus objetos en QML directamente.qmlRegisterType también es más rápido que una propiedad de contexto en mis puntos de referencia.

El método singleton es, en mi humilde opinión, el mejor método si necesita una instancia específica (como un modelo o un modelo de vista) y el método registerType es el mejor método si necesita instanciar muchas cosas en QML. Establecer una propiedad de contexto raíz tiene múltiples problemas, el rendimiento es uno de ellos, así como posibles conflictos de nombres, no hay análisis estático y está disponible para cualquier persona en cualquier lugar de QML. Según un informe de error de Qt (QTBUG-73064), se eliminará de QML en el futuro.

Introducción

En mi opinión, es preferible tener límites claros en su aplicación en lugar de un desorden entrelazado donde todo está estrechamente relacionado con todo lo demás. Con un singleton o un tipo, esa separación es posible, con una propiedad de contexto raíz que no es posible. Para proyectos pequeños, el setContextProperty El método está bien, pero el método singleton no requiere más esfuerzo, por lo que incluso en ese caso preferiría usar singletons.

La documentación de Qt/QML es completa, pero una falla que encuentro es que el marco no tiene una forma (recomendada) de hacer las cosas. Puedes encontrar todos los parámetros del método y las opciones posibles, pero si quieres saber cómo cambiar el color del texto en un Button{} , buena suerte buscando en StackOverflow. Lo mismo ocurre con la integración de C++ con QML. La documentación de Qt proporciona una descripción general de los diferentes métodos de integración, pero no le dice cuál es el mejor. Simplemente le dice lo que es posible y deja que usted decida. Hay diagramas de flujo para ayudarlo con el método que debe usar, pero casi todas las guías y ejemplos en línea solo usan rootContext->setContextProperty() . Incluso mi propio artículo sobre señales y tragamonedas usa eso, debido a la simplicidad para proyectos pequeños.

QML no debe tener ningún conocimiento del dominio, es solo un lenguaje de marcado de interfaz de usuario, por lo que cualquier trabajo o lógica real debe realizarse en el lado de C++, no a través de QML/JavaScript. El uso de JavaScript se complica muy rápido y no se puede probar a través de pruebas unitarias, por lo tanto, usarlo es un gran no, no para mí. Al igual que con WPF y XAML en el lado de Microsoft, su interfaz de usuario debe tener solo algunos enlaces al viewModel y sin código o lógica propia. He visto máquinas de estado completo y métodos JavaScript complejos en QML que eran tan complejos que todavía tengo pesadillas con ellos. Todas esas funciones podrían realizarse simplemente en C++, donde serían comprobables mediante pruebas unitarias. Te apuesto a que también serían más rápidos.

La razón para escribir este artículo es que me estaba sumergiendo en las diferentes opciones de integración de C++ en QML. En el trabajo, recientemente refactorizamos una gran cantidad de código QML por razones de rendimiento, y descartar una propiedad de contexto global fue de gran ayuda. También asigné espacios de nombres a gran parte de nuestro código y activos y me encontré con más de un problema con la documentación de Qt faltante o incorrecta. Nuestro código está compilado como una aplicación estática y como staticlib en el caso de bibliotecas, incluyendo todos los activos en un qrc expediente. Esa compilación estática y las rutas del sistema de archivos que casi coincidían con mi qmldir Los nombres (desajuste de letras mayúsculas) combinados con documentación incorrecta dieron muchos dolores de cabeza, pero al final lo arreglé todo, mostrando un aumento notable en los tiempos de respuesta de cara al usuario.

El código fuente de ejemplo para este proyecto se puede encontrar en mi github aquí.

Ejemplo de QML de semáforo

He construido un ejemplo QML simple con un semáforo y algunos botones para controlar dicho semáforo. El TrafficLightQml objeto es un rectángulo con 3 círculos en él, cada uno de un color diferente. Se exponen tres propiedades para encender o apagar las diferentes lámparas. Este es un opacity controlado por un bool , para simplificar las cosas. No es el mejor ejemplo, una máquina de estado sería ideal para esto, pero para simplificar este artículo, decidí que esto estaba bien.

El TrafficLightQmlControlButtons alberga dos botones y expone una propiedad y una señal. En realidad, dos señales, ya que las propiedades tienen un onXXXChanged generado implícitamente señal. Un botón enciende o apaga la luz y un botón alterna las diferentes luces en el patrón que usan los semáforos holandeses:

Red (stop) -> Green (go) -> Orange (caution, almost Red)

¿Por qué exponer propiedades y señales en lugar de llamar a las funciones relevantes dentro del propio TrafficLight QML? Eso acoplaría estrechamente el QMLcontrol con la contraparte de C++ y el método de exposición. Al hacer que el control QML sea lo suficientemente genérico, puedo cambiar la implementación cuando lo desee. La interfaz de usuario solo necesita saber cómo se ve y qué hacer, no cómo o cuándo hacerlo. Esto hace que la prueba unitaria del comportamiento sea mucho más fácil, porque no hay inteligencia en el control QML, no tienes que probar eso. Deberíamos poder confiar en que el marco funciona al pasar señales y métodos. La lógica central, como qué patrón de lámpara o cuándo encender o apagar, debe probarse unitariamente, lo cual es fácil de hacer, por ejemplo, con Qt Test o GoogleTest. Probar una función de control / javascript QML es mucho más difícil.

El main.qml El archivo tiene 4 instancias de esos dos controles, pero con cada uno, las propiedades y las señales están vinculadas a diferentes objetos de C++. De esa manera, puede ver claramente cómo usar cada uno, incluida la forma en que se crean y se transmiten en main.cpp .

Los nombres de archivo y clase son muy detallados para mostrarle qué se usa, cuándo y dónde. Si todo (qml, c++, id's) se llamara trafficlight , esa visibilidad y perspicacia se pierde. Ahora está muy claro qué línea se relaciona con qué componente, tanto en QML como en C++.

establecerPropiedadContexto

Empecemos con el ejemplo más popular, casi todos los tutoriales que encuentras lo usan. Incluso en la documentación oficial de Qt sobre mejores prácticas, sección Pushing References to QML , usan un setContextProperty .

Al usar setContextProperty , la propiedad está disponible para todos los componentes cargados por el motor QML. Las propiedades de contexto son útiles para los objetos que deben estar disponibles tan pronto como se carga el QML y no se pueden instanciar en QML.

En mi ejemplo de semáforo se ve así en main.cpp

TrafficLightClass trafficLightContext;
qmlRegisterUncreatableType<TrafficLightClass>("org.raymii.RoadObjectUncreatableType", 1, 0, "TrafficLightUncreatableType", "Only for enum access");
engine.rootContext()->setContextProperty("trafficLightContextProperty", &trafficLightContext);

En (cada) QML puedo usarlo así:

Component.onCompleted: { trafficLightContextProperty.nextLamp(); // call a method } 
redActive: trafficLightContextProperty.lamp === TrafficLightUncreatableType.Red // use a property

No se requiere declaración de importación. Hay un párrafo sobre las enumeraciones más adelante en el artículo, que explica el UncreatebleType ves arriba. Puede omitir esa parte si no planea usar enumeraciones de su clase en el lado QML.

No hay nada intrínsecamente malo por ahora con el uso de este enfoque para obtener una clase C++ en QML. Para proyectos pequeños o proyectos donde el rendimiento no es un problema, la propiedad de contexto está bien. En el esquema general de las cosas, estamos hablando de las capacidades, como la capacidad de mantenimiento, pero para un proyecto pequeño eso probablemente no importe tanto como en un proyecto con una base de código más grande o varios equipos trabajando en él.

¿Por qué entonces una propiedad de contexto es mala?

Hay algunas desventajas en comparación con el enfoque singleton o registerType. Hay un error Qt que rastrea la eliminación futura de las propiedades de contexto, una publicación de StackOverflow y una Guía de codificación QML brindan un excelente resumen. La documentación QML también señala estos puntos, pero en un forma menos obvia, por lo que el resumen es agradable.

Citando el error de Qt (QTBUG-73064):

El problema con las propiedades de contexto es que "mágicamente" inyectan estado en su programa QML. Sus documentos QML no declaran que necesitan este estado, pero por lo general no funcionarán sin él. Una vez que las propiedades de contexto están presentes, puede usarlas, pero ninguna herramienta puede realizar un seguimiento adecuado de dónde se agregan y dónde se eliminan (o deberían eliminarse). Las propiedades de contexto son invisibles para las herramientas QML y los documentos que las utilizan son imposibles de validar estáticamente.

Citando la guía de codificación QML:

Las propiedades de contexto siempre toman un QVariant o QObject , lo que significa que cada vez que accede a la propiedad, se vuelve a evaluar porque entre cada acceso, la propiedad puede cambiarse como setContextProperty() se puede usar en cualquier momento.

Las propiedades de contexto son costosas de acceder y difíciles de razonar. Cuando esté escribiendo código QML, debe esforzarse por reducir el uso de variables contextuales (una variable que no existe en el ámbito inmediato, sino en el que está por encima) y el estado global. Cada documento QML debe poder ejecutarse con QMLscene siempre que se establezcan las propiedades requeridas.

Citando esta respuesta de StackOverflow sobre problemas con setContextProperty :

setContextProperty establece el objeto como valor de una propiedad en el mismo nodo raíz de su árbol QML, por lo que básicamente se ve así:

property var myContextProperty: MySetContextObject {}
ApplicationWindow { ... }

Esto tiene varias implicaciones:

  • Necesita tener posibles referencias entre archivos a archivos que no son "locales" entre sí (main.cpp y donde sea que intentes usarlo)
  • Los nombres se sombrean fácilmente. Si el nombre de la propiedad de contexto se usa en otro lugar, no podrá resolverlo.
  • Para la resolución de nombres, se rastrea a través de un posible árbol de objetos profundo, siempre buscando la propiedad con su nombre, hasta que finalmente encuentra la propiedad de contexto en la raíz misma. Esto puede ser un poco ineficiente, pero probablemente no sea una gran diferencia.

qmlRegisterSingletonType por otro lado, le permite importar los datos en la ubicación donde los necesite. Por lo tanto, puede beneficiarse de una resolución de nombres más rápida, el sombreado de los nombres es básicamente imposible y no tiene referencias transparentes entre archivos.

Ahora que ha visto un montón de razones por las que casi nunca debería usar una propiedad de contexto, continuemos con cómo debería exponer una sola instancia de una clase a QML.

qmlRegisterSingletonType<>

Un tipo singleton permite exponer propiedades, señales y métodos en un espacio de nombres sin necesidad de que el cliente cree una instancia de objeto manualmente. QObject Los tipos singleton son una forma eficiente y conveniente de proporcionar funcionalidad o valores de propiedades globales. Una vez registrado, un QObject el tipo singleton debe importarse y usarse como cualquier otro QObject instancia expuesta a QML.

Entonces, básicamente lo mismo que la propiedad de contexto, excepto que tienes que importarlo en QML. Esa, para mí, es la razón más importante para usar propiedades de contexto singletonsover. En los párrafos anteriores ya mencioné las diferencias y desventajas de las propiedades del contexto, así que no me repetiré aquí.

En el código de semáforo de ejemplo, este es el código relevante en main.cpp :

TrafficLightClass trafficLightSingleton;
qmlRegisterSingletonType<TrafficLightClass>("org.raymii.RoadObjects", 1, 0, "TrafficLightSingleton",
                                     [&](QQmlEngine *, QJSEngine *) -> QObject * {
    return &trafficLightSingleton;
    // the QML engine takes ownership of the singleton so you can also do:
    // return new trafficLightClass;
});

En el lado de QML, debe importar el módulo antes de poder usarlo:

import org.raymii.RoadObjects 1.0

Ejemplo de uso:

Component.onCompleted: { TrafficLightSingleton.nextLamp() // call a method }
redActive: TrafficLightSingleton.lamp === TrafficLightSingleton.Red; // use a property

Sin enum rarezas con UncreatableTypes en este caso.

qmlTipoRegistro

Todos los párrafos anteriores han expuesto un único objeto C++ existente a QML. Eso está bien la mayor parte del tiempo, en el trabajo exponemos nuestro models y viewmodels de esta manera a QML. Pero, ¿qué pasa si necesita crear y usar más de una instancia de un objeto C++ en QML? En ese caso, puede exponer toda la clase a QML a través de qmlRegisterType<> , en nuestro ejemplo en main.cpp :

qmlRegisterType<TrafficLight>("org.raymii.RoadObjectType", 1, 0, "TrafficLightType");

En el lado QML, nuevamente debe importarlo:

import org.raymii.RoadObjectType 1.0

El uso es como los otros ejemplos, con la adición de crear una instancia de su objeto:

TrafficLightType {
    id: trafficLightTypeInstance1
}

TrafficLightType {
    id: trafficLightTypeInstance2
}

En el ejemplo anterior, hice 2 instancias de ese tipo C++, en QML, sin crear una manualmente y exponer esa instancia en main.cpp . El uso es casi el mismo que thesingleton:

redActive: trafficLightTypeInstance1.lamp === TrafficLightType.Red; // use a property
Component.onCompleted: { trafficLightTypeInstance1.nextLamp() // call a method }

Y para nuestra segunda instancia:

redActive: trafficLightTypeInstance2.lamp === TrafficLightType.Red; // use a property
Component.onCompleted: { trafficLightTypeInstance2.nextLamp() // call a method }

La única diferencia es el ID, trafficLightTypeInstance1 contra trafficLightTypeInstance2 .

Si vas a tener muchas cosas, exponer toda la clase a través de qmlRegisterType es mucho más conveniente que crear manualmente todas esas cosas en C++, luego exponerlas como singletons y finalmente importarlas en QML.

Rarezas con setContextProperty y enumeraciones

En la clase de semáforo de ejemplo tenemos un enum class para el LampState . La lámpara puede ser Off o cualquiera de los tres colores. Al registrar el tipo como singleton, funciona la siguiente asignación de propiedad QML a través de una evaluación booleana:

redActive: TrafficLightSingleton.lamp === TrafficLightSingleton.Red

lamp es un Q_PROPERTY expuesto con una señal adjunta en el cambio. Red es parte del enum class .

Sin embargo, cuando se usa la misma declaración de propiedad con la instancia registrada a través de setContextProperty , lo siguiente no funciona:

redActive: trafficLightContextProperty.lamp === trafficLightContextProperty.Red

Da como resultado un error vago como qrc:/main.qml:92: TypeError: Cannot read property 'lamp' of null y la propiedad nunca se establece en verdadero. He probado muchas soluciones diferentes, como llamar a la función captadora la señal QML utilizada (.getLamp() ) y depuración en Component.onCompleted() . AQ_INVOKABLE el método de depuración en la clase funciona bien, pero el valor de enumeración regresa undefined . Otras llamadas a tragamonedas, como .nextLamp() funciona bien, solo los valores de enumeración no son accesibles.

Esto se incluye en el diagrama de flujo y en los documentos, pero apuesto a que se siente frustrado antes de descubrirlo.

Qt Creator conoce los valores, incluso intenta autocompletarlos, y los mensajes de error no son útiles en absoluto. No intente autocompletarlos si puedo usarlos o dar un mensaje de error útil, sería mi sugerencia para quienquiera que desarrolle Qt Creator.

La solución para esto es, como se indica en los documentos, registrar la clase completa como UncreatableType :

Sometimes a QObject-derived class may need to be registered with the QML
type system but not as an instantiable type. For example, this is the
case if a C++ class:

    is an interface type that should not be instantiable
    is a base class type that does not need to be exposed to QML
    **declares some enum that should be accessible from QML, but otherwise should not be instantiable**
    is a type that should be provided to QML through a singleton instance, and should not be instantiable from QML

El registro de un tipo que no se puede crear le permite usar los valores de enumeración, pero no puede instanciar un TrafficLightType {} Objeto QML. Eso también le permite proporcionar una razón por la cual la clase no se puede crear, muy útil para futuras referencias:

qmlRegisterUncreatableType<TrafficLight("org.raymii.RoadObjectType", 1, 0, "TrafficLightType", "Only for enum access");

En su archivo QML ahora tiene que importar el tipo:

import org.raymii.RoadObjectType 1.0

Después de lo cual puede usar los valores de enumeración en una comparación:

redActive: trafficLightContextProperty.lamp === TrafficLightType.Red

Si está poniendo todo ese trabajo extra para registrar el tipo, ¿por qué no simplemente usar la implementación singleton? Si no estás usando enums puedes escaparte con setContextProperty() , pero aún. Me parece mucho mejor importar algo solo cuando lo necesitas en lugar de tenerlo disponible en todas partes en cualquier momento.

¿Por qué no QML_ELEMENT? / QML_UNCREATABLE / QML_INTERFACE / QML_SINGLETON ?

En Qt 5.15 se pusieron a disposición algunos métodos nuevos para integrar C++ con QML. Estos funcionan con una macro en su archivo de encabezado y una definición adicional en su .pro expediente.

QML_ELEMENT / QML_UNCREATABLE / QML_INTERFACE / QML_SINGLETON / QML_ANONYMOUS

En la última instantánea del documento 5.15 y en la publicación del blog, se explican estos métodos, deberían resolver un problema que podría surgir, a saber, que debe mantener su código C++ sincronizado con sus registros QML. Citando la entrada del blog:

Luego entran en algunos detalles técnicos más (válidos).

La razón por la que no los incluyo en esta comparación es porque son nuevos, solo están disponibles en Qt 5.15 y versiones posteriores y porque dependen de .pro archivos y por lo tanto en qmake . El soporte de cmake no está disponible, ni siquiera en Qt 6.0.

Si su base de código es lo suficientemente nueva como para ejecutarse en esta última versión de Qt 5.15, o está ejecutando 6+, entonces estos nuevos métodos son mejores que los enumerados anteriormente, consulte la parte técnica de la publicación de blog por qué. Si puede, por lo tanto, si su versión de Qt y su sistema de compilación (qmake ) lo permite, es mejor usar QML_SINGLETON y amigos.

He escrito un pequeño ejemplo para lograr lo mismo que qmlRegisterType<> abajo para referencia. En tu .pro archivo agregas un CONFIG+= adicional parámetro(qmptypes ) y otros dos nuevos parámetros:

CONFIG += qmltypes
QML_IMPORT_NAME = org.raymii.RoadObjects
QML_IMPORT_MAJOR_VERSION = 1    

En tu .cpp clase, en nuestro caso, TrafficLightClass.h , agregas lo siguiente:

#include <QtQml>
[...]
// below Q_OBJECT
QML_ELEMENT

Si quieres el mismo efecto que un qmlRegisterSingleton , agrega QML_SINGLETON debajo del QML_ELEMENT línea. Crea un singleton construido por defecto.

En su archivo QML, importe el tipo registrado:

import org.raymii.RoadObjects 1.0

Luego puede usarlos en QML, por su nombre de clase (no un nombre separado como hicimos arriba):

TrafficLightClass {
    [...]
}

Tiempo de inicio de evaluación comparativa

Para estar seguro de si lo que estamos haciendo realmente hace alguna diferencia, he creado un punto de referencia simple. La única forma de asegurarse de que algo es más rápido es crear un perfil. El Qt Profiler está en una liga completa por sí mismo, así que voy a usar una prueba más simple.

Incluso si la variante singleton resulta ser más lenta, la preferiría a la propiedad global por las mismas razones que se mencionaron anteriormente. (Si se lo pregunta, he escrito esta sección antes de hacer los puntos de referencia).

La primera línea en main.cpp imprime la época actual en milisegundos y en el lado QML en la ventana raíz he agregado un Component.onCompleted manejador que también imprime la época actual en milisegundos, luego llama a Qt.Quit para salir de la aplicación. Restar esas dos marcas de tiempo de época me da tiempo de ejecución de inicio, hazlo varias veces y toma el promedio, para la versión con solo un qmlRegisterSingleton y la versión con solo un rootContext->setProperty() .

La compilación tiene habilitado el compilador Qt Quick y es una compilación de lanzamiento. No se cargó ningún otro componente QML, ningún botón de salida, ningún texto de ayuda, solo una ventana con un TrafficLightQML y los botones. El QML del semáforo tiene un encendido Completado que enciende la luz C++.

Tenga en cuenta que este punto de referencia es solo una indicación. Si tiene problemas con el rendimiento de la aplicación, le recomiendo que use Qt Profiler para averiguar qué está pasando. Qt tiene un artículo sobre rendimiento que también puede ayudarte.

Imprimiendo la marca de tiempo de la época en main.cpp :

#include <iostream>
#include <QDateTime>
[...]
std::cout << QDateTime::currentMSecsSinceEpoch() << std::endl;

Imprimiéndolo en main.qml :

Window {
    [...]
    Component.onCompleted: {
        console.log(Date.now())
    }
}

Usando grep y una expresión regular para obtener solo la marca de tiempo, luego invirtiéndola con tac (invertir cat ), luego usando awk para restar los dos números. Repite eso cinco veces y usa awk nuevamente para obtener el tiempo promedio en milisegundos:

for i in $(seq 1 5); do 
    /home/remy/tmp/build-exposeExample-Desktop-Release/exposeExample 2>&1 | \
    grep -oE "[0-9]{13}" | \
    tac | \
    awk 'NR==1 { s = $1; next } { s -= $1 } END { print s }'; 
done | \
awk '{ total += $1; count++ } END { print total/count }'
  • El promedio para el qmlRegisterSingleton<> ejemplo:420ms

  • El promedio para el qmlRegisterType<> ejemplo:492,6 ms

  • El promedio para el rootContext->setContextProperty ejemplo:582,8 ms

Repetir el punto de referencia anterior 5 veces y promediar esos promedios da como resultado 439,88 ms para el singleton, 471,68 ms para el tipo de registro y 572,28 ms para la propiedad rootContext.

Este ejemplo simple ya muestra una diferencia de 130 a 160 ms para una variable singleton. Incluso registrar un tipo e instanciarlo en QML es más rápido que una propiedad de contexto. (En realidad, no esperaba tal diferencia)

Este punto de referencia se realizó en una Raspberry Pi 4, Qt 5.15 y, mientras se ejecutaba, no se estaban ejecutando otras aplicaciones, excepto IceWM (administrador de ventanas) y xterm (emulador de terminal).

Repetí este proceso con nuestra aplicación de trabajo, que tiene un objeto bastante grande y complejo con alrededor de un millón de enlaces de propiedad (número real, los conté yo mismo al refactorizar) y ahí la diferencia fue de más de 2 segundos.

Sin embargo, realice algunas pruebas comparativas usted mismo en su propia máquina con su propio código antes de tomar las medidas anteriores como fuente absoluta de veracidad.

Y si conoce una manera fácil de medir el tiempo de inicio con Qt Profiler varias veces y promediarlo, más fácil que buscar manualmente en toda la lista, envíeme un correo electrónico.