QML voor een bedienscherm: de afspraken die we volgen

Dit zijn de afspraken die we volgen als we een bedienscherm bouwen in Qt 6 en QML. De eerste hoofdstukken gaan over code die een collega vlot kan lezen en aanpassen. Verderop komen de dingen die je op een laptop niet merkt en op het toestel wel: geheugen, threads en wat de GPU moet doen.
Niet alles is meteen nodig. Voor een eerste scherm volstaan de eerste hoofdstukken. Een scherm dat jaren meegaat en door meerdere mensen wordt aangepast, heeft de rest ook nodig. De lijst is gegroeid uit wat we op machines tegenkwamen, aangevuld met wat Qt zelf documenteert over bindings, modules, modellen en het scene graph.
Een bestand dat je kunt lezen
We zetten één type per bestand, en het bestand krijgt de naam van dat type in PascalCase: AlarmRow.qml bevat AlarmRow. Een view, een herbruikbare rij en een hulpfunctie horen in aparte bestanden, ook als ze samen nog klein zijn.
Het wortelitem krijgt altijd id: root, en bindings in het bestand verwijzen naar die id in plaats van naar parent. Wikkelt iemand de component later in een extra item, dan verandert parent, maar root blijft kloppen. Een id is ook alleen zichtbaar in het eigen bestand. Wil een ander bestand label.text zetten, dan is dat meestal een teken dat het type een property mist.
Properties en functies schrijven we in camelCase, met een naam die zegt wat ze bevatten of doen, zoals alarmCount en acknowledge(). Namen als x1 of rect2 dwingen de volgende lezer om het hele bestand door te nemen voor hij weet wat hij aanpast.
De volgorde in een bestand ligt vast, zodat je bij een review niet hoeft te zoeken: eerst id, dan properties, signalen, functies, de kinderen, en onderaan states en transitions. Bovenaan staat één regel commentaar over wat het type doet. Verder voegen we alleen commentaar toe bij een binding die je zonder uitleg moeilijk volgt.
Een container die zelf niets tekent, is een Item. Een doorzichtige Rectangle als houder voor kinderen voegt een extra node toe die niets bijdraagt.
import QtQuickItem { id: root property string title: "" property int alarmCount: 0 implicitWidth: label.implicitWidth implicitHeight: label.implicitHeight Text { id: label text: qsTr("%1 actief").arg(root.alarmCount) }}Een standaardwaarde gebruik je voor iets dat de aanroeper mag weglaten. Vergeet iemand alarmCount, dan blijft die hier ongemerkt op nul staan. Moet de aanroeper de waarde altijd meegeven, dan maak je er een required property van. Daar komen we op terug bij het hergebruiken van componenten.
Bindings
Een binding is een uitdrukking die opnieuw berekend wordt telkens als iets waar ze van afhangt verandert:
text: root.titleZo blijft de tekst gekoppeld aan de data. Een toekenning in een handler doorbreekt die koppeling en zet er één vaste waarde in de plaats:
onClicked: label.text = qsTr("Bezig")Na die klik volgt label.text de status niet meer, en latere statuswijzigingen verschijnen niet op het scherm. In een demo klikt niemand twee keer op dezelfde knop, op de machine gebeurt dat voortdurend.
Handlers gebruik je voor neveneffecten: een geluid afspelen, een regel loggen of root.acknowledge() aanroepen. Hetzelfde geldt voor onTitleChanged. Daar mag je iets doen omdat de titel veranderde, maar niet de nieuwe titel naar een tweede property kopiëren. Zo’n kopie is een binding die je met de hand nabouwt, en die loopt vroeg of laat achter.
Moet een binding tijdelijk wijken en daarna terugkomen, dan gebruik je het Binding-element met een when-voorwaarde.
Houd bindings ook goedkoop. Ze worden bij elke wijziging opnieuw berekend, en animaties en layouts zorgen voor meer wijzigingen dan je verwacht. Een lus over een lijst, een datum die je opmaakt door telkens objecten aan te maken of een aanroep naar C++ die echt werk doet, reken je vooraf uit, zodat de binding alleen het resultaat toont. Een pure functie uit een .js-bestand is geen probleem, zolang ze alleen met haar argumenten werkt:
import "format.js" as Formattext: Format.duration(root.seconds)Let op met property var. Doe je een push op een array of pas je een veld van een JavaScript-object aan, dan krijgt de binding daar geen melding van en blijft ze de oude waarde tonen. Getallen, strings, booleans en echte modellen melden hun wijzigingen wel.
Tekst, layout en touch
Elke tekst die de operator te zien krijgt, staat in qsTr(). Plak je woorden aan elkaar met +, dan leg je de Nederlandse woordvolgorde vast. Met %1 en .arg() kan de vertaler de volgorde aanpassen, en meervoud doe je met %n. Het tweede argument van qsTr() is de context, voor woorden die in het Nederlands één betekenis hebben en in een andere taal twee.
Wil je zien welke teksten nog niet vertaald zijn, draai dan lupdate en test met een taal die je bewust onvolledig laat. Teksten in de broncode tijdelijk in hoofdletters zetten om ze te markeren, is riskant: wat niet in het .ts-bestand terechtkomt, belandt zo in het product.
Een Duits label is vaak langer dan het Engelse woord uit het ontwerp. RowLayout en ColumnLayout vangen dat op. Anchors gebruik je voor vaste geometrie, zoals een pagina die het hele scherm vult of een lijn die tegen een rand moet. anchors.fill: parent op een pagina die fullscreen staat, is daar een goed voorbeeld van. Combineer anchors niet met een vaste width op hetzelfde item, want dan bepalen twee dingen tegelijk de breedte.
Knoppen moeten groot genoeg zijn. Een knop van 28 pixels hoog raak je met een handschoen of in een bewegende cabine niet betrouwbaar. De layout moet die hoogte aankunnen, ook als de tekst langer uitvalt dan in het ontwerp.
Op een paneel met een vaste resolutie gebruiken we font.pixelSize. pointSize hangt af van de DPI die het systeem doorgeeft, en die klopt op een embedded paneel vaak niet met wat het ontwerp aannam. Houd het bij één lettertypefamilie en een beperkt aantal groottes. Tekst tekenen kost meer dan een rechthoek, zeker de eerste keer dat een bepaalde lettergrootte gerasterd moet worden.
Componenten die je hergebruikt
Zodra een tweede scherm onderdelen van het eerste wil gebruiken, wordt de structuur belangrijker.
Het scherm hoort in een QML-module, met een URI en een versie, aangemaakt via qt_add_qml_module. In Qt 6 importeer je import QtQuick zonder versienummer. Wat andere modules mogen gebruiken, is de publieke kant van de module. De rest blijft intern.
Binnen de module houden we een vaste mapindeling aan: views/ voor schermen, components/ voor herbruikbare onderdelen en models/ voor modellen in QML. De mappen dwingen zelf niets af, dat doet de module, maar ze helpen de lezer om de weg te vinden.
Sinds Qt 6.5 zetten we pragma ComponentBehavior: Bound bovenaan. Zonder die pragma zoekt QML een onbekende naam steeds verder naar buiten, tot in main.qml. Staat daar een Item met id: theme, dan werkt theme.font overal. Dat gaat goed tot iemand een eigen property theme toevoegt of het hoofdscherm opsplitst, en de helft van de teksten plots het verkeerde lettertype heeft. Met de pragma verwijs je expliciet naar root. of naar een singleton.
Met required property maak je duidelijk wat een component nodig heeft. Vergeet de aanroeper alarmCount, dan faalt het laden meteen, in plaats van dat de waarde ongemerkt op nul blijft staan. In AlarmRow.qml ziet dat er zo uit:
pragma ComponentBehavior: Boundimport QtQuickItem { id: root required property string title required property int alarmCount enum Severity { Info, Warning, Fault } property int severity: AlarmRow.Info signal acknowledge() QtObject { id: d property bool pressed: false }}Een enum geeft een toestand een naam. Met severity: 2 moet de lezer elders gaan opzoeken wat 2 betekent. Je spreekt de waarde aan als AlarmRow.Info, omdat de bestandsnaam ook de naam van het type is.
Interne toestand die buiten dit bestand niemand aangaat, zetten we in een QtObject met een eigen id. d.pressed is zo alleen binnen dit bestand zichtbaar.
Wees voorzichtig met een property alias naar een knop of kleur binnenin de component. Daarmee maak je een implementatiedetail deel van de API. Beter geef je het type een eigen property en bind je die naar binnen. Een alias is wel op zijn plaats als het binnenste object bewust de API is, bijvoorbeeld een TextField dat je als geheel naar buiten brengt.
Kleuren, lettertypen en marges bewaren we in een singleton. QML kent geen stylesheets, en een item in main.qml dat als thema dient, werkt alleen dankzij de lookup die we net hebben uitgezet. Het bestand krijgt pragma Singleton, en in CMake zet je QT_QML_SINGLETON_TYPE TRUE op dat bestand. Anders registreert de module het niet als singleton.
pragma Singletonimport QtQuickQtObject { readonly property color alarm: "#c0392b" readonly property int margin: 16}Een kleur die maar op één knop voorkomt, mag gerust op die knop blijven staan tot een tweede scherm ze ook nodig heeft. Hetzelfde geldt voor componenten: een patroon dat je voor de tweede keer kopieert, maak je een eigen component. Bij de eerste keer is dat meestal nog te vroeg.
Lijsten en modellen
Een Repeater maakt alle rijen meteen aan. Voor een log van vijftig regels is dat prima. Een alarmhistoriek van een paar duizend regels hoort in een ListView, die alleen de zichtbare rijen en de cacheBuffer aanmaakt. Zijn de rijen zwaar, zet dan reuseItems: true, zodat de view bestaande rijen hergebruikt in plaats van nieuwe te maken.
Een delegate declareert de rollen die ze nodig heeft, inclusief index, als required property. Zonder die declaraties leunt de rij op de context properties die de view er automatisch omheen zet, zoals model. Dat werkt alleen binnen die ene view, en met de pragma ComponentBehavior: Bound zijn zulke ongekwalificeerde namen niet meer toegestaan.
ListView { id: root model: alarmModel reuseItems: true delegate: AlarmDelegate { required property string message required property int severity required property int index width: ListView.view.width }}ListView.view verwijst naar de view waarin de rij staat. Zo werkt de breedte ook als je de delegate later in een apart bestand zet.
De rij bindt op haar required properties. Een property var die je in Component.onCompleted invult, toont alleen de waarde van het moment waarop de rij werd aangemaakt, en mist wat het model daarna verandert.
ListModel en ListElement gebruiken we voor vaste voorbeelddata tijdens het ontwerpen. In een ListElement kun je geen qsTr() aanroepen. Markeer de tekst daar met QT_TR_NOOP() en vertaal ze in de delegate met qsTr(). Komt de data van de machine, dan hoort ze in een QAbstractListModel in C++, met benoemde rollen, dataChanged voor een enkele rij en een reset wanneer de hele lijst vervangen wordt.
Gebruik je een JavaScript-array als model, vervang die dan door een nieuwe array. Een push op de bestaande array ververst het scherm niet betrouwbaar, om dezelfde reden als bij property var.
Pas het model ook niet aan vanuit een delegate terwijl de view aan het tekenen is. Dat geeft flikkeringen en verspringende rijen. Een bevestiging gaat naar C++, dat het model aanpast, en de view volgt vanzelf.
Staat, signalen en animatie
Ziet een scherm er anders uit als de machine draait, dan geef je het een state. Met when: root.running koppel je die state aan de data. PropertyChanges past bijvoorbeeld color aan zolang de state actief is, en zet de waarde daarna terug. Een onRunningChanged die zelf de kleur toekent, is weer de losse toekenning uit het hoofdstuk over bindings.
onClicked zet je op de knop zelf. Connections gebruik je voor signalen van een ander object, of van een object dat kan wisselen. Een verbinding die je in JavaScript maakt met someObject.clicked.connect(...), blijft bestaan tot je ze zelf losmaakt, ook als het scherm dat ze aanmaakte al weg is.
Voor animaties op x, opacity of scale gebruiken we Behavior en Transition. Die lopen gelijk met het tekenen van het scherm. Een Timer die elke 16 ms een coördinaat aanpast, doet dat niet en hapert zodra de GUI-thread ander werk heeft. Wil je dat een animatie ook vloeiend blijft als de GUI-thread even bezet is, gebruik dan de Animator-types zoals OpacityAnimator en XAnimator. Die draaien op de renderthread. Tikt de operator tijdens een transition, dan wordt die onderbroken en eindigt het scherm in de toestand die op dat moment geldt.
Een animatie die niets verduidelijkt, laten we weg. Op een bedienscherm houdt ze vooral iemand op die snel een alarm wil bevestigen.
Businesslogica laten we niet in het scherm groeien. Een recept berekenen, een as opvolgen of een alarmlijst bijhouden gebeurt in C++, en het scherm bindt op het resultaat. Een kleine hulpfunctie die seconden omzet naar een kloktijd, mag in een .js-bestand naast de component. Toestand die het scherm moet volgen, hoort daar niet in een variabele in een closure.
De grens met C++
De toestand van een as, een recept of een alarmlijst leeft in een QObject op de GUI-thread. Het scherm toont die toestand en stuurt commando’s terug.
Elke waarde die QML moet volgen, is een Q_PROPERTY met een NOTIFY-signaal. Zonder dat signaal wordt de binding één keer berekend bij het laden en daarna nooit meer. Een property die niet verandert, markeer je als CONSTANT.
Een commando is een slot of een Q_INVOKABLE, bijvoorbeeld acknowledge(int id). C++ past daarna de property aan, en de binding werkt het scherm bij.
Alles wat de grens oversteekt, heeft een duidelijk type. Een id is een int of een string, een status is een Q_ENUM met dezelfde namen aan beide kanten, en een lijst die groeit is een model. QVariant, QVariantMap en var bewaren we voor de randen waar het type echt onbekend is. In een gewone API nemen ze alles aan, en daarna weet niemand meer wat erin zit.
Een object dat C++ aan QML doorgeeft, heeft een duidelijke eigenaar nodig. Blijft het van de machine, dan houdt C++ het in handen. Geeft een Q_INVOKABLE een object zonder parent terug, dan krijgt JavaScript het eigendom, en kan de garbage collector het opruimen terwijl C++ er nog een pointer naar heeft. Leg het eigendom daarom expliciet vast met QQmlEngine::setObjectOwnership, of geef het object een parent.
Een QObject dat de QML-engine gebruikt, spreek je alleen aan vanaf de GUI-thread. Een worker-thread die rechtstreeks een property zet of een signaal uitstuurt, veroorzaakt crashes die je maar af en toe ziet. Laat het werk in de worker gebeuren en stuur het resultaat terug via een queued verbinding of QMetaObject::invokeMethod. Het object op de GUI-thread zet dan de property.
Component.onCompleted wordt aangeroepen zodra het object in de boom hangt. Haal je daar één keer toestand op en kijk je er daarna niet meer naar, dan krijg je hetzelfde probleem als met een toekenning in een handler: de eerste waarde klopt, de volgende niet.
Wat in het geheugen blijft
QML ruimt een object op als zijn parent verdwijnt of als je destroy() aanroept. Zolang een object een parent heeft, blijft het bestaan.
visible: false haalt een object uit beeld, maar de bindings blijven lopen en het geheugen blijft bezet. Een SwipeView of StackLayout met drie pagina’s als kinderen maakt die pagina’s allemaal aan bij het opstarten. Een pagina die nog niet open is, zet je in een Loader met active: false. Die maakt de pagina pas aan als de operator ze opent, en kan ze daarna ook weer afbreken.
Loader { active: root.page === "alarms" sourceComponent: AlarmPage { model: root.alarmModel }}Een StackView houdt bij wat er op de stack staat. Bij een pop wordt het bovenste item vernietigd als de StackView het zelf heeft aangemaakt, uit een component of een URL. Wat je gepusht hebt en nog niet hebt gepopt, bestaat nog, samen met de modellen en timers van dat scherm. Geef een timer daarom running: root.visible, of een parent die mee verdwijnt. Anders blijft hij afgaan op een scherm dat de operator al lang verlaten heeft.
createObject(root, { ... }) hangt het nieuwe object in de boom. Zonder parent ruimt niemand het op, tenzij je zelf destroy() aanroept. Popups zijn de klassieke plek waar zo’n lek opduikt, omdat ze pas problemen geven als het scherm dagen open blijft staan.
Ook een JavaScript-closure die een heel scherm vasthoudt, houdt dat scherm in leven nadat de view het heeft losgelaten. In de objectboom zie je het niet. In een geheugenmeting wel, als een pagina die je gepopt hebt en die toch blijft groeien.
Een Item dat enkel als omhulsel dient en niets toevoegt, halen we weg. Elk visueel item is een node in het scene graph. De diepte van de nesting is zelden het echte probleem, maar het loont om ze beperkt te houden.
Wat de GPU doet
Het scene graph voegt nodes samen tot batches, zodat de GPU niet elke Rectangle apart hoeft te tekenen. Alles wat een batch onderbreekt, kost een extra tekenstap. Op een i.MX of STM32MP tikt dat sneller aan dan op de laptop waarop het scherm ontworpen werd.
clip: true op één viewport is normaal. Zet je het op elke cel van een lijst, dan onderbreekt elke rij de batching.
opacity onder 1 op een item met kinderen wordt op elk kind afzonderlijk toegepast. Overlappende kinderen schijnen dan door elkaar heen. Wil je een groep als één geheel laten vervagen, dan heb je layer.enabled nodig, en dat rendert de groep eerst naar een aparte texture. Een rij die alleen wat lichter moet, geef je beter een kleur met alpha op de Rectangle zelf.
layer.enabled, schaduwen, blur en maskers vragen allemaal zo’n aparte texture. Eén schaduw achter een dialoog merk je niet. Dezelfde schaduw op elke alarmrij wel.
Een afbeelding wordt gedecodeerd op de grootte van het bestand. Een PNG van 2000 pixels die je op 48 pixels toont, neemt het geheugen van 2000 pixels in. Met sourceSize laat je ze decoderen op de grootte waarop je ze tekent.
Image { width: 48 height: 48 source: "qrc:/icons/valve.png" sourceSize.width: width sourceSize.height: height}Kleine iconen kunnen samen in een atlas. Een foto als achtergrond van een knop blijft wel een volledige foto in het geheugen.
Anchors zijn trouwens niet vanzelf sneller dan layouts, ook al lees je dat vaak. Meet het liever. De QML-profiler in Qt Creator en de rendercounters van het scene graph tonen of een schokkerige lijst aan de delegate ligt, aan een binding of aan een schaduw. Wij optimaliseren pas wat de meting aanwijst.
In de build
qmlformat zorgt voor een consistente opmaak, zodat inspringing geen onderwerp meer is in een review. qmllint vindt ongebruikte imports, onbekende properties, binding loops die het statisch kan zien, en ongekwalificeerde namen als de pragma aanstaat. Bij ons draaien beide mee in de build, en een fout van qmllint laat de build falen. Waarschuwingen die niets tegenhouden, worden anders snel genegeerd.
console.log gebruiken we alleen op de ontwikkelmachine. Op een image komt het in het journal terecht, of als tekst die een operator nooit had mogen zien. Lees de waarschuwingen van Qt zelf, zoals binding loops, verouderde properties of “creating a lot of items”, één keer grondig door op een release-build. Daarna hoort het scherm stil op te starten.
Componenten testen we met Qt Quick Test. Een TestCase zet een property en wacht met tryCompare op het resultaat. Een vaste wait is op de laptop toevallig lang genoeg, en op het toestel niet. Met SignalSpy controleer je dat een commando vertrekt als de operator op bevestigen tikt, zonder de hele applicatie te starten. Squish of een vergelijkbare tool gebruiken we voor de flow tussen schermen, zoals een operator die door de applicatie loopt. Dat komt bovenop de componenttests en vervangt ze niet.
Veel van deze afspraken houd je vast in een review, zoals de opbouw van een bestand en het gebruik van bindings. De rest, van modules en modellen tot een build die stopt op qmllint, zorgt ervoor dat het ook zo blijft als de codebase groeit.
