Linux Embedded

Le blog des technologies libres et embarquées

Qt QML et JSON texte

QML et les sources de données

Voici un article qui concerne le framework Qt, et en particulier les composants Qt Quick et Qt QML qui forment un ensemble composé du langage QML et de son infrastructure de runtime (moteur de rendu, sous-ensemble javascript et mécanismes de liaison avec un éventuel backend C++ ou Python) et sa collection d'éléments prédéfinis (graphiques ou non).

https://doc.qt.io/qt-6/qtquick-index.html

https://doc.qt.io/qt-6/qtqml-index.html

La problématique mise en lumière est la liaison entre l'IHM elle-même d'une part (décrite en QML) et les données qui vont nourrir l'IHM d'autre part ; par exemple la donnée "vitesse du véhicule" qui va servir à animer un contrôle QML de tableau de bord d'automobile. On va s'intéresser tout particulièrement à la communication dans le sens du backend vers le frontend, et pour reprendre le même exemple, à la remontée de la donnée de "vitesse" jusqu'au programme QML qui l'affiche sous forme de cadran.

 

Modèles de données 100% QML

C'est la façon la plus basique de fournir des données structurées à un programme QML chargé de les afficher, directement par l'API du QML lui-même, sans nécessiter des composants annexes éventuellement écrits dans d'autres langages. C'est bien sûr aussi la plus limitée.

En QML cela donne :

import QtQuick
import QtQuick.Window


Window
{
    width: 300 ; height: 150 ; color: "grey"
    visible: true
    title: qsTr("Example")

    ListModel
    {
        id: data
        ListElement { prop_name: "a" ; prop_value: "50"     ; desc: "none" }
        ListElement { prop_name: "b" ; prop_value: "5"      ; desc: "none" }
        ListElement { prop_name: "c" ; prop_value: "2.2512" ; desc: "none" }
        ListElement { prop_name: "d" ; prop_value: "x"      ; desc: "none" }
    }

    Component
    {
        id: render_data

        Row
        {
            Text
            {
                width: 100 ; height: 30 ; color: "blue"
                text: model.prop_name
            }

            Text
            {
                width: 200 ; height: 30 ; color: "lightblue"
                text: model.prop_value
            }
        }
    }

    ListView
    {
        spacing: 10
        anchors.fill: parent

        delegate: render_data
        model: data
    }
}

Le modèle de données QML est décrit avec les balises ListModel et ListElement. Le modèle pourrait être plus complexe, car ListElement peut contenir à son tour d'autre ListElement.

Ici, on décrit un modèle "à plat", qui contient 4 données, chacune comprenant un nom et une valeur.

Ce qui donne à l'écran :

qml
  • Avantages

    • Permet de découpler localement les données ("le modèle") de leur affichage, rapidement : autrement on aurait dupliqué les champs "QML Text" pour autant de fois que d'éléments à afficher (8 fois, ici), un avantage clair en termes d'architecture et de maintenance

    • Adapté à de très faibles quantités de données

    • Mise en œuvre très simple, réutilisable pour des formes plus abouties, d'autant que les propriétés contenues dans le modèle sont certes accessibles, mais aussi modifiables par le programme QML, ce qui en fait un excellent moyen de stockage temporaire
       

  • Inconvénients

    • Syntaxe un peu lourde

    • Limités à des constantes, ne supporte pas le binding ou les expression "scriptées"

    • Ne permet pas à l'IHM d'aller s'alimenter en communiquant avec un backend (à moins de charger du QML dynamiquement, ce qui ne va pas dans le sens de la simplicité)

 

Modèles de données Qt / C++

C'est clairement la méthode préconisée, mais certainement aussi la plus complexe. Même dans le cas le plus basique que l'on explore ici on voit rapidement la quantité de choses à mettre en oeuvre.

 

Même le main() est à retoucher, pour fournir une ContextProperty :

QGuiApplication app(argc, argv);

QQmlApplicationEngine engine;

const QUrl url(QStringLiteral("qrc:/main.qml"));

Backend backend;
engine.rootContext()->setContextProperty ("backend", &backend);

QObject::connect(&engine,
             &QQmlApplicationEngine::objectCreated,
             &app,
             [url](QObject * obj, const QUrl & objUrl)
             {
                if (!obj && url == objUrl) QCoreApplication::exit(-1);
             },
             Qt::QueuedConnection
);

engine.load(url);

return app.exec();

 

Le QML devient :

import QtQuick
import QtQuick.Window


Window
{
    width: 300 ; height: 150 ; color: "grey"
    visible: true
    title: qsTr("Example")

    property var d: backend != null ? [{ prop_name: "a" , prop_value: backend.a },
                                       { prop_name: "b" , prop_value: backend.b },
                                       { prop_name: "c" , prop_value: backend.c },
                                       { prop_name: "d" , prop_value: backend.d }] : null

    Component
    {
        id: render_data

        Row
        {
            Text
            {
                width: 100 ; height: 30 ; color: "blue"
                text: modelData.prop_name
            }

            Text
            {
                width: 200 ; height: 30 ; color: "lightblue"
                text: modelData.prop_value.toString()
            }
        }
    }

    Column
    {
        spacing: 10 ; padding: 10
        anchors.fill: parent

        Repeater
        {
            model: d
            delegate: render_data
        }
    }
}

 

Et bien sûr on introduit une classe C+, qui va contenir les données et fournir toute l'infrastructure nécessaire aux mécanismes de liaison avec le QML (ici Q_PROPERTY, get/set et signaux) :

#ifndef BACKEND_HPP
#define BACKEND_HPP

#include <QObject>
#include <QTimer>

class Backend : public QObject
{
    Q_OBJECT
    Q_PROPERTY (int a     READ get_a WRITE set_a NOTIFY a_changed);
    Q_PROPERTY (double b  READ get_b WRITE set_b NOTIFY b_changed);
    Q_PROPERTY (QString c READ get_c WRITE set_c NOTIFY c_changed);
    Q_PROPERTY (int d     READ get_d WRITE set_d NOTIFY d_changed);

public:
    explicit Backend(QObject * parent = nullptr);
    virtual ~Backend();

    int get_a();
    void set_a(int p_a);

    double get_b();
    void set_b(double p_b);

    QString get_c();
    void set_c(QString p_c);

    bool get_d();
    void set_d(bool p_d);

signals:
    void a_changed();
    void b_changed();
    void c_changed();
    void d_changed();

private:
    int     m_a{12};
    double  m_b{25.999};
    QString m_c{"..."};
    bool    m_d{true};
    QTimer  m_refresh_timer;

};

#endif // BACKEND_HPP
#include "backend.hpp"
#include <QDebug>

Backend::Backend(QObject * parent) : QObject{parent}
{
    m_refresh_timer.setInterval (500);
    QObject::connect(&m_refresh_timer, &QTimer::timeout, [this](){
        set_a(rand () % 10000);
        set_b(rand () / 123456.789);
        set_c((rand () < (RAND_MAX / 2)) ? QStringLiteral ("YES") : QStringLiteral("NO"));
        set_d(rand () < (RAND_MAX / 2));
    });
    m_refresh_timer.start ();
}

Backend::~Backend()
{
    m_refresh_timer.stop ();
}

int Backend::get_a()
{
    return m_a;
}

void Backend::set_a(int p_a)
{
    if (p_a != m_a)
    {
        m_a = p_a;
        emit a_changed();
    }
}

double Backend::get_b()
{
    return m_b;
}

void Backend::set_b(double p_b)
{
    if (p_b != m_b)
    {
        m_b = p_b;
        emit b_changed();
    }
}

QString Backend::get_c()
{
    return m_c;
}

void Backend::set_c(QString p_c)
{
    if (p_c != m_c)
    {
        m_c = p_c;
        emit c_changed();
    }
}

bool Backend::get_d()
{
    return m_d;
}

void Backend::set_d(bool p_d)
{
    if (p_d != m_d)
    {
        m_d = p_d;
        emit d_changed();
    }
}

 

A ce prix là, on s'offre au moins des valeurs changeantes à l'écran :-)

cpp
  • Avantages

    • La solution la plus aboutie autant pour les performances, la richesse fonctionnelle (la communication bidirectionnelle entre le QML et le C++ est à portée de main), et bien sûr tout ce qu'on peut imaginer implémenter dans un backend C++ (base de données, réseau, algorithmes, ...)

    • Permet d'éviter vraiment de placer de la logique "métier" dans l'IHM, grâce au découpage net entre le QML et le C++

    • Adapté à de grandes quantités de données, des mécanismes disponibles pour ne précharger qu'une partie des éléments, pour filtrer, ... Une API C++ dédiée : QAbstractItemModel (un peu plus complexe encore)
       

  • Inconvénients

    • Nécessite de maîtriser les deux faces d'une IHM QML complexe : QML et C++, et les API correspondantes (Q_PROPERTY, QAbstractItemModel, QQmlListProperty, qmlRegisterType(), ...)

    • Développement plus coûteux, acceptable pour industrialiser un produit, mais quelquefois plus difficile à justifier pour des POC ou des outils de mise au point

    • On a vite besoin d'outils annexes pour se débrouiller, comme l'excellent GammaRay :
      https://github.com/KDAB/GammaRay

 

Modèles JSON texte

Discrètement utilisée, dans le second exemple, vous avez peut-être remarqué la notation JSON :

property var d: backend != null ? [{ prop_name: "a" , prop_value: backend.a },
                                   { prop_name: "b" , prop_value: backend.b },
                                   { prop_name: "c" , prop_value: backend.c },
                                   { prop_name: "d" , prop_value: backend.d }] : null

Cela est peu utilisé quand on manipule des données C++, puisque la structure des données est généralement exprimée dans sa totalité dans les objets C++, sans avoir besoin de conteneurs javascript, pour ne présenter au QML que des instances "racines", souvent exposées sous forme de ContextProperty. L'IHM peut puiser dans ces structures, à condition d'avoir été correctement enregistrées auprès du moteur QML.

Pourtant, c'est oublier un peu vite que le langage déclaratif QML est accompagné d'un runtime javascript bien nécessaire, notamment pour les expressions placées dans les bindings, ou les emplacements de code impératif.

Dans Qt 6.4, il est annoncé que le moteur du langage QML supporte ECMAScript 7.

Javascript qui est habituellement le langage compagnon de HTML5 / CSS peut tout à fait être utilisé dans une IHM Qt / QML avec une stratégie qui ressemble à son usage "traditionnel" pour le web :

  • sans utiliser les données d'objets C++ dans le QML, pour se libérer de la mise en œuvre coûteuse

  • en puisant dans des structures de données natives javascript : Array, String, Set, ...

  • en profitant de l'encodage/décodage standard JSON qui fait partie des éléments disponibles dans QML (parse/stringify)

  • et en le transportant par le moyen de votre choix : WebSocket, API REST, ... ou appel natif C++

 

Voici l'exemple, transposé avec cette stratégie, en utilisant l'appel natif C++ (on conserve un rafraîchissement des données, piloté cette fois-ci coté QML) :

import QtQuick
import QtQuick.Window

Window
{
    width: 300 ; height: 150 ; color: "grey"
    visible: true
    title: qsTr("Example")

    property var d: Array()

    Component
    {
        id: render_data

        Row
        {
            Text
            {
                width: 100 ; height: 30 ; color: "blue"
                text: modelData.prop_name
            }

            Text
            {
                width: 200 ; height: 30 ; color: "lightblue"
                text: modelData.prop_value.toString()
            }
        }
    }

    Column
    {
        spacing: 10 ; padding: 10
        anchors.fill: parent

        Repeater
        {
            model: d
            delegate: render_data
        }
    }

    // Mise à jour du modèle de données de la page
    function update_data()
    {
        if (backend != null)
        {
            d = JSON.parse(backend.get_json());
            // ^^^^^^^^^^^ Vient sous forme de JSON texte depuis le backend C++
        }
    }

    // Mise à jour périodique
    Timer
    {
        id: refresh
        repeat: true
        triggeredOnStart: true
        interval: 500
        onTriggered: update_data();
    }

    // Le rafraichissement débute à la construction de la page
    Component.onCompleted:
    {
        refresh.start();
    }
}

On conserve donc une classe C++ pour acheminer le texte JSON au QML :

#ifndef BACKEND_HPP
#define BACKEND_HPP

#include <QObject>
#include <QTimer>

class Backend : public QObject
{
    Q_OBJECT

public:
    explicit Backend(QObject * parent = nullptr);
    virtual ~Backend();

    Q_INVOKABLE QString get_json();

private:
    int     m_a{12};
    double  m_b{25.999};
    QString m_c{"..."};
    bool    m_d{true};
    QTimer  m_refresh_timer;

};

#endif // BACKEND_HPP
#include "backend.hpp"

#include <QDebug>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonDocument>

Backend::Backend(QObject * parent) : QObject{parent}
{
    m_refresh_timer.setInterval (500);
    QObject::connect(&m_refresh_timer, &QTimer::timeout, [this](){
        m_a = rand () % 10000;
        m_b = rand () / 123456.789;
        m_c = rand () < (RAND_MAX / 2) ? QStringLiteral ("YES") : QStringLiteral("NO");
        m_d = rand () < (RAND_MAX / 2);
    });
    m_refresh_timer.start ();
}

Backend::~Backend()
{
    m_refresh_timer.stop ();
}

QString Backend::get_json()
{
    QJsonArray a;
    a += QJsonObject{{"prop_name", "a"}, {"prop_value", m_a}};
    a += QJsonObject{{"prop_name", "b"}, {"prop_value", m_b}};
    a += QJsonObject{{"prop_name", "c"}, {"prop_value", m_c}};
    a += QJsonObject{{"prop_name", "d"}, {"prop_value", m_d}};
    return QJsonDocument(a).toJson ();
}

Notez le gain en matière de simplicité !

Visuellement, aucune différence :

json

 

  • Avantages

    • Simplicité de mise en œuvre et de refactoring

    • La représentation JSON est un "contrat" lisible entre le C++ et le QML : pratique pour les tests, le découpage des développements

    • Le QML peut l'obtenir depuis n'importe quel composant logiciel distant, y compris non-Qt, peu importe le langage ou le runtime source
       

  • Inconvénients

    • Des performances évidemment en retrait par rapport à un modèle de données implémenté et exporté en C++ au moteur QML

    • Nécessite de bien coordonner les changements appliqués au fournisseur du texte JSON et le QML qui le consomme

 

Modèles XML chargés depuis une URL

Il existe un autre type de modèles supportés par QML, qui privilégient plutôt XML à JSON.

Ainsi, en obtenant un contenu XML de la forme suivante :

<data>
 <list>
  <item prop_name="a" prop_value="540"/>
  <item prop_name="b" prop_value="12477.1"/>
  <item prop_name="c" prop_value="YES"/>
  <item prop_name="d" prop_value="False"/>
 </list>
</data>

 

On peut construire dans QML un comportement similaire à l'exemple précédent, grâce au type XmlListModel :

XmlListModel
{
    id: d
    source: "<URL>"
    query: "/data/list/item"

    XmlListModelRole { name: "prop_name"  ; elementName: "prop_name"  }
    XmlListModelRole { name: "prop_value" ; elementName: "prop_value" }
}

 

Remarques

Malheureusement, le chargement des ressources XML n'est supporté dans Qt 6.4 qu'à travers des URL (fichiers ou services distants), mais pas directement depuis un objet javascript (ce qui était vraisemblablement pourtant le cas dans Qt 5), ce qui rend l'adaptation de notre exemple impossible tel quel, dans un objectif de simplification.

On peut d'ailleurs remarquer, que coté C++, la préparation du XML est déjà sensiblement moins agréable que son équivalent JSON :

QString Backend::get_xml()
{
    qDebug() << __PRETTY_FUNCTION__ ;

    QDomDocument doc;
    QDomElement data = doc.createElement("data");
    doc.appendChild(data);

    QDomElement list = doc.createElement("list");
    data.appendChild(list);

    QDomElement a = doc.createElement("item");
    list.appendChild(a);
    a.setAttribute ("prop_name", "a");
    a.setAttribute ("prop_value", QString::number (m_a));

    QDomElement b = doc.createElement("item");
    list.appendChild(b);
    b.setAttribute ("prop_name", "b");
    b.setAttribute ("prop_value", QString::number (m_b));

    QDomElement c = doc.createElement("item");
    list.appendChild(c);
    c.setAttribute ("prop_name", "c");
    c.setAttribute ("prop_value", m_c);

    QDomElement d = doc.createElement("item");
    list.appendChild(d);
    d.setAttribute ("prop_name", "d");
    d.setAttribute ("prop_value", m_d ? "True" : "False");

    return (doc.toString());
}

Cette variante ne sera donc pas développée dans cet article.

 

Conclusion

Le langage QML et les fonctions et outils associés apportent une vraie plus-value au framework Qt et introduisent la création d'IHM, notamment dans les systèmes embarqués, à un niveau de richesse rarement vu avant. Il n'est pas surprenant que cette solution technique ait été massivement choisie dans le domaine de l'automobile pourtant très exigeante à la fois en terme de fiabilité et de contraintes matérielles diverses.

Pour autant, si le langage QML et son compagnon le javascript ont un abord plutôt aisé, on voit que les choses se compliquent dès qu'il est question de nourrir l'IHM avec des données dynamiques.

Le choix de la stratégie de communication et des éléments nécessaires à son implémentation vont largement conditionner la robustesse, les performances mais aussi la capacité à être maintenue de façon raisonnable.

Enfin, le framework Qt étant en constante évolution, il sera judicieux de s'assurer de la pérennité de la solution choisie.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.