Moderne GUIs mit Model View Adapter in C++ entwickeln

Autor / Redakteur: Tobias Boldte * / Sebastian Gerstl |

Grafischer Benutzeroberflächen basieren oft noch auf dem „Model View Controller“-,also MVC-Pattern. Für viele moderne Bibliotheken ist dieser Ansatz inzwischen veraltet. Besser geeignet ist der Model View Adapter, kurz MVA: Er lässt sich gut als Grundlage für das Entwickeln moderner, komplizierterer GUIs mit C++ und Qt verwenden und gewährleistet Wartbarkeit und Erweiterbarkeit.

Anbieter zum Thema

Bild 1: Im ursprüngliche Model View Controller Pattern werden Benutzereingaben vom Controller verwaltet: Der Benutzer sieht den Programmzustand auf dem Monitor in der View und gibt über das keyboard Änderungen an den Controller ein. Dieser verändert daraufhin die Daten im Model und es entsteht ein neuer Programmzustand, der wiederum von der View am Monitor angezeigt wird.
Bild 1: Im ursprüngliche Model View Controller Pattern werden Benutzereingaben vom Controller verwaltet: Der Benutzer sieht den Programmzustand auf dem Monitor in der View und gibt über das keyboard Änderungen an den Controller ein. Dieser verändert daraufhin die Daten im Model und es entsteht ein neuer Programmzustand, der wiederum von der View am Monitor angezeigt wird.
(Bild: MixedMode)

Der Einsatz eines Model View-Adapters (MVA) bietet eine universell einsetzbare Lösungsmöglichkeit für die in der Praxis sehr häufig vorkommende Aufgabe, User Interfaces für komplizierte Programmsteuerungen mit vielen Parametern umzusetzen. Die Zahl der Einstellungsoptionen kann dabei zum Beispiel für Maschinensteuerungen (CNC/SPS) oder für Chip-Konfiguratoren sehr umfangreich werden. So benötigen solche Applikationen oftmals Parametersätze mit mehreren tausend Einzeldaten mit unterschiedlichen Datenformaten.

Der durch diese Parametersätze gespeicherte Maschinenzustand soll ohne großen Aufwand in den Ausgangszustand zurückversetzt werden können (Reset). Außerdem soll dieser Zustand durch ein oder mehrere Settings Files verwaltet werden können, jeweils mit einem automatischen Update der GUI-Anzeige für die gewählten Einstellungen. Oft kommt noch die Anforderung hinzu, für unterschiedliche Benutzergruppen unterschiedliche Ansichten desselben Programm- bzw. Maschinenzustands darzustellen.

Bildergalerie

Der ursprüngliche Ansatz zur Realisierung von grafischen Benutzeroberflächen, das Model View Controller Pattern (MVC), ist inzwischen veraltet und passt in seiner ursprünglichen Definition nicht mehr zu modernen Grafikbibliotheken wie zum Beispiel Qt. In der damaligen Vorstellung hatte es der User noch mit zwei Interfaces zu tun: Zum einen mit der Programmanzeige auf dem Monitor (= View) und zum anderen mit dem Controller, also einer Kontrollinstanz, die getrieben durch den User Input (Keyboard) die Manipulationen an den Daten (= Model(l)) steuert (Bild 1).

Model-View-Adapter: Ein moderner Ansatz zur Schaffung komplexer GUIs

Heute gibt es diese Trennung zwischen der View und einer Kontrollinstanz für den User Input so nicht mehr. Aufgrund des objektorientierten Ansatzes werden in modernen Grafikbibliotheken die User Events wie Maus- und Keyboardevents schon lange den Windowobjekten mit Fokus zugeordnet. Spätestens mit dem Touch-Screen (Smartphone) verschmelzen daher in modernen Architekturen die ursprüngliche View und Teile des ursprünglichen Controllers zu einer gemeinsamen Benutzerschnittstelle, die im Weiteren als View bezeichnet werden soll (Bild 2).

Das hier vorgestellte MVA-Pattern reagiert auf diese globale architektonische Veränderung. In diesem Pattern wird ein Adapter (auch als mediating Controller bezeichnet) eingeführt, der als Vermittler zwischen der Datenschicht (= Model(l)) und der View dient. Die View setzt als Benutzerschnittstelle sowohl die Anzeige als auch die Entgegennahme des User Inputs um.

Dabei kommt es zu einer völligen Trennung von Modell und View; die beiden Objekte kennen einander gar nicht mehr. Idealerweise sollten Modell und View auch den Adapter nicht kennen, um spätere Verflechtungen in der Software von vornherein zu vermeiden. Signale wie “user action” und “notify” können hierzu über callback-Funktionen oder Signal slots umgesetzt werden.

Zentrale Merkmale der Umsetzung

Die Grundidee der Umsetzung besteht darin, die Daten eines Modells über den Adapter mit den Daten einer oder mehrerer Views zu verbinden. Die Erfahrung hat gezeigt, dass dabei optimalerweise eine Zerlegung in maximal kleine Komponenten vorgenommen wird. Diese Komponenten sollen im Folgenden als Datenelemente bezeichnet werden. Um eine maximale Flexibilität zu erreichen, kann in dieser Umsetzung sowohl eine View als auch ein Modell 1 bis n solcher Datenelemente enthalten, umgesetzt als key-value pairs. Indem die Datenelemente durch ein abstraktes Interface über eine templatebasierte Instanziierung realisiert werden, muss die ganze Programmlogik (Adapter, Model- und View-Interface) nur einmal für alle Typen implementiert werden. Eine alternative Möglichkeit der Umsetzung könnte in der Verwendung einer Variant liegen, wobei hier klar geregelt werden müsste, dass diese an gewissen Stellen keine Conversions zulässt.

Die Datenelemente und ihre Keys der Modelle und Views muss man sich dabei wie Steckverbindungen vorstellen, die durch den Adapter miteinander verbunden werden können. Dabei wird beim connect einmalig eine Typüberprüfung durchgeführt, damit nur Modell- und View-Datenelemente gleichen Typs miteinander verknüpft werden können. Ein Datenelement eines Modells kann mit mehreren View- Datenelementen verknüpft werden; ein Datenelement einer View hingegen muss genau einem Modell- Datenelement zugewiesen werden. Um ein besseres Gefühl für das Zusammenspiel der Komponenten vermitteln zu können, wird an dieser Stelle eine einfache Beispielapplikation eingeführt (Bilder 3 und 4). Sie soll es dem Benutzer ermöglichen, in einer graficsScene ein Objekt per Mausdrag hin- und herzuschieben. Zusätzlich soll der Benutzer durch die Veränderung von Settings in SpinBoxes die Position und die Farbe des Objektes manipulieren können.

Bildergalerie

Realisierung mit dem MVA-Ansatz

Dieses Beispiel lässt sich mit dem MVA-Ansatz (View und Modell mit jeweils 1 bis n Datenelementen) mit einem Modell und 10 Views realisieren:

Das Modell hat dabei 3 <float>-Datenelemente (range 0 bis 1, mit wrap=true) mit den keys moveX, moveY und rotate sowie 6 <unsigned char>-Datenelemente mit den keys color1Red bis color2Blue. Auf View-Seite gibt es zum einen eine Spinbox mit einem Datenelement mit key spinboxValue, umgesetzt einmal für <float> und einmal für <unsigned char>. Zum anderen gibt es die Display View, deren Interface über 9 Datenelemente verfügt:

  • 3 <float>-Datenelemente mit den keys posMoveX, posMoveY und posRotate

sowie

  • 6 <unsigned char>-Datenelemente mit den keys color1Red bis color2Blue.

Die Datenelemente mit den keys posMoveX und posMoveY werden nach einem dragging mit der Maus sowohl zum Empfang von Updates von Modell-Seite als auch zum Senden von View-Updates an das Modell verwendet. Alle anderen keys der Display View dienen nur dem Empfang von Updates. Die Model Keys werden jeweils mit dem View Key spinboxValue mit einer der 9 SpinBoxes verbunden. Außerdem werden die Model Keys nochmals mit den entsprechenden View Keys der Display View verbunden.

An diesem Beispiel sollte klar werden, warum eine Zerlegung der Dateninterfaces in kleine Komponenten von Vorteil ist: Wäre die Koordinatenposition der Display View als ein Datenelement <coord(x, y)> spezifiziert worden, könnte dieses nicht direkt an die gleichen Model-Datenelemente angeschlossen werden wie die Datenelemente der beiden Spinboxes. Das gleiche gilt für die Farben.

Eine Alternative dazu ist die Einführung von main- und satellite-Daten (bzw. master und slave) im Modell. So könnte für dieses Beispiel ein main-Datenelement vom Typ Color <unsigned char [3]> erzeugt werden, dem 3 satellite-Datenelemente vom Typ <unsigned char> zugewiesen werden. Die Idee ist dabei, dass das main-Datenelement die eigentliche Dateninstanz ist. Die satellite-Datenelemente halten keine eigenen Daten und dienen nur als Anschlussmöglichkeiten für Views mit anderen Datenformaten. Zur Übersetzung des main-Datenelements in die satellite-Datenelemente und umgekehrt dient eine converter-Funktion, die beim Anlegen der satellite-Datenelemente spezifiziert wird.

Strikte Trennung der Datenhaltung von der GUI

Das Hauptziel bei der Umsetzung des MVA ist die strikte Trennung der Datenhaltung von der GUI. Durch diese klare Trennung wird zum einen die Wartbarkeit und Erweiterbarkeit des Quellcodes verbessert. Zum anderen ergibt sich die Option, die GUI später auszutauschen zu können. So könnte z.B. eine Qt Widgets-Implementierung später durch eine QML-Implementierung mit Anschluss an das View-Konzept über das C-Interface ersetzt werden. Die Implementierung des Basis-Frameworks (View Interface, Adapter und Model Interface) wurde von uns in nativem C++ nur unter Verwendung der standard libraries realisiert. Das bietet den Vorteil, dass später grundsätzlich jede Grafikbibliothek zur Umsetzung der grafischen Oberfläche benutzt werden kann. Allgemein muss diese Grafikbibliothek nur die C-Schnittstellen der View Interfaces darunter bedienen können.

Die Architektur des MVA ist so beschaffen, dass jedes Feature des Datenmanagements (initialize-Modell, Model Reset, File IO, Undo, Tracking und Test) nur einmal zentral im Controller oder in der Basisklasse des Modells implementiert wird und somit keine Zeile Zusatzaufwand für diese Features pro hinzugefügtem Datenelement ensteht. Auch bei der Verwendung eines neuen Datentyps für ein Datenelement entsteht nahezu kein Zusatzaufwand, da das gesamte Datenmanagement template-basiert umgesetzt ist. Bei der Verwendung eines neuen Datentyps müssen bei Verwendung der File IO-Funktionalität nur der entsprechende Streaming-Operator sowie bei Verwendung von automatischen range checks die Vergleichs- Operatoren implementiert werden.

Ein Adapter als Vermittlungsinstanz

Bei der Umsetzung des MVA-Patterns hat es sich gezeigt, dass es Vorteile bringt, die Programmlogik komplett aus dem Adapter herauszunehmen und dem Modell zuzuordnen. Der Adapter wird damit zu einer reinen Vermittlungsinstanz zwischen den Modellen und Views, die die Verbindungen zwischen den Daten dieser Objekte hält und für die gegenseitigen Updates sorgt. Das Heraushalten applikationsspezifischer Programmlogik aus dem Adapter ermöglicht dabei eine Implementierung, die zu 100 % wiederverwendet werden kann.

Das kann dadurch erreicht werden, dass der Adapter bei all seinen Operationen nur auf abstrakten Basisklassentypen arbeitet und die tatsächlich übertragenen Datentypen, die sich in jeder Applikation ändern können, nicht kennt. Das Modell ist bei dieser Art der Umsetzung als Hauptinstanz der Applikation zu verstehen, die die Gültigkeit der Daten überprüft und an die Programmlogik angeschlossen ist. Der Adapter dient nur noch als Kommunikationsschnittstelle und hat dabei die Funktion eines Connectors, der dafür sorgt, dass alle Views immer auf dem aktuellen Modell-Stand gehalten werden.

Durch den Einsatz eines zentralen Adapters läuft die gesamte GUI-Interaktion der Applikation über einen Strang, was ein sehr gutes Erweiterungspotential bietet: An dieser Stelle kann zum Beispiel ganz einfach ein Undo-Framework oder ein Testframework eingefügt werden. Dadurch, dass alle Benutzerinteraktionen über den Adapter laufen, kann an der Stelle, an der nach dem notify einer View die aktuellen Daten der View geholt worden sind, ein Interaction Tracing eingebaut werden. Mittels eines solchen Tracings kann eine Testsequenz aufgezeichnet werden, die von einem Benutzer auf der GUI ausgeführt wird mit entsprechendem Tracking der erwarteten Resultate im Modell.

Derartig angelegte Testsequenzen können dann später im Rahmen eines Systemtests dazu benutzt werden, die aufgezeichnete Testsequenz in der gleichen zeitlichen Abfolge auszuführen und nach jedem Schritt die Zwischenresultate im Modell mit den erwarteten Resultaten zu vergleichen. Zu einem kompletten GUI-Test fehlt dann nur noch der Weg innerhalb der einzelnen View-Elemente von der Benutzerinteraktion bis zum notify und pull des Adapters: Tests also, die durch kleine Einzeltests der verwendeten View-Elemente abgebildet werden können.

Dieser Artikel stammt von unserem PartnerportalEmbedded-Software-Engineering.de.

* Tobias Boldte ist Softwareentwickler mit Fokus auf C++, Qt und OpenGL bei der Mixed Mode GmbH.

(ID:45513266)