Plugin-Architektur: FormDataModel, View und Controller

Das demo2a_crud-Plugin verteilt seine Aufgaben auf drei QML-Dateien. Öffnen Sie jede davon in Ihrem Editor — am besten nebeneinander — während Sie diese Seite durcharbeiten.

1. FormDataModel — d2a_data_model.qml

Das FormDataModel beantwortet eine einzige Frage: Welche Felder hat das Formular? Es ist ein pragma Singleton, d.h. es gibt genau eine Instanz, die im gesamten Plugin geteilt wird. Es enthält keine Logik und keinen Laufzeitzustand — nur ein JS-Array mit Feldbeschreibungs-Objekten.

pragma Singleton
import QtQuick

QtObject {
    property var fields: [
        { "name": "name", "label": "Eintragsname" },
        { "name": "s1",   "label": "Gedanken"     },
        { "name": "s2",   "label": "Gefühle"      }
    ]
}

Jedes Objekt im Array hat zwei Schlüssel:

Um ein neues Feld zum Formular hinzuzufügen, muss hier nur ein Eintrag ergänzt werden. Der Rest des Plugins passt sich automatisch an — das ist der Sinn des Musters.

2. View — d2a_plugin_component.qml

Der View beantwortet: Wie sieht das Formular aus, und wie werden Benutzeraktionen verdrahtet? Er besitzt das Layout und erstellt eine FormController-Instanz. Er ruft die QField-API niemals direkt auf.

Die Controller-Instanz erstellen

Der View instanziiert den Controller als untergeordnetes Objekt und stellt ihn über einen Alias bereit, damit Unterkomponenten ihn leicht erreichen können:

FormController {
    id: formController
    plotId: pluginFrame.plotId
    onSelect: function(f_uid) { /* ComboBox-Auswahl synchronisieren */ }
}
property var controller: formController

Die Feldliste aus FormDataModel lesen

Der View liest FormDataModel.fields einmalig in eine lokale Eigenschaft. Dies ist dasselbe Array, über das der Repeater iteriert:

property var fieldModel: FormDataModel.fields

Die ComboBox — gebunden an controller.entryListModel

Die Eintrags-Auswahlliste ist an controller.entryListModel gebunden — ein ListModel, das der Controller aus der Datenbank befüllt. Wenn der Benutzer eine Auswahl trifft, ruft der View controller.loadEntry(f_uid) auf:

ComboBox {
    id: entrySelector
    model: controller.entryListModel
    textRole: "name"
    onCurrentIndexChanged: {
        if (currentIndex >= 0) {
            var f_uid = model.get(currentIndex).f_uid
            controller.loadEntry(f_uid)
        }
    }
}

Der Repeater und das FormField-Delegate

Anstatt für jedes Feld manuell ein TextField zu schreiben, verwendet der View einen Repeater über fieldModel. Die component FormField-Deklaration ist das Delegate — die Vorlage, die der Repeater für jeden Eintrag im Model-Array instanziiert.

Jedes TextField innerhalb von FormField:

component FormField: Rectangle {
    property var fieldData

    RowLayout {
        Text {
            text: fieldData.label          // aus FormDataModel
        }
        TextField {
            // LESEN: Controller ist die einzige Datenquelle
            text: controller.fieldValues[fieldData.name] !== undefined
                  ? controller.fieldValues[fieldData.name] : ""

            // SCHREIBEN: jeder Tastendruck aktualisiert den Controller
            onTextChanged: {
                controller.updateField(fieldData.name, text)
            }
        }
    }
}

Repeater {
    model: fieldModel          // FormDataModel.fields
    delegate: FormField {
        fieldData: modelData   // ein Feldbeschreibungs-Objekt pro Iteration
    }
}

Schaltflächen

Die drei Aktionsschaltflächen rufen lediglich Controller-Funktionen auf. Der View weiß nicht, wie diese Operationen intern funktionieren:

// Schaltfläche „Neuer Eintrag"
onClicked: { controller.createEntry() }

// Schaltfläche „Eintrag speichern"
onClicked: { controller.saveEntry() }

3. Controller — d2a_form_controller.qml

Der Controller beantwortet: Welche Werte sind gerade in den Feldern gespeichert, und wie werden sie persistiert? Er ist die einzige Komponente, die die QField-API aufruft (LayerUtils, FeatureUtils, layer.startEditing() usw.).

Der Bearbeitungspuffer: fieldValues

fieldValues ist eine einfache JavaScript-Map. Sie ist die Laufzeit-Datenquelle für jedes Feld im Formular. Wenn ein Feature aus der Datenbank gelesen wird, werden seine Werte in diese Map kopiert. Beim Speichern werden die Werte aus der Map zurückgelesen und in den Layer geschrieben.

property var fieldValues: ({})   // z.B. { "name": "Plot A", "s1": "...", "s2": "..." }

function updateField(fieldName, value) {
    fieldValues[fieldName] = value
    fieldValuesChanged()    // QML-Bindungen im View benachrichtigen
}

Der Aufruf von fieldValuesChanged() ist wichtig: Da fieldValues ein JS-Objekt ist (kein nativer QML-Property-Typ), kann die QML-Binding-Engine Änderungen an seinen Inhalten nicht automatisch erkennen. Das manuelle Auslösen des Change-Signals zwingt die text-Bindungen des View zur erneuten Auswertung.

Die Auswahlliste: entryListModel

entryListModel ist ein ListModel. Es wird durch eine Abfrage des entries-Layers befüllt und für jedes Ergebnis eine Zeile angehängt. Die ComboBox im View ist an dieses Model gebunden.

property ListModel entryListModel: ListModel {}

function loadEntries(plotId) {
    entryListModel.clear()
    var expression = "plot_id = '" + plotId + "'"
    let it = LayerUtils.createFeatureIteratorFromExpression(layer, expression)
    while (it.hasNext()) {
        let feature = it.next()
        entryListModel.append({
            "name":  feature.attribute("name"),
            "f_uid": feature.attribute("f_uid")
        })
    }
    it.close()   // Iterator IMMER schließen
}

Einen Eintrag in das Formular laden

Wenn der Benutzer einen Eintrag auswählt, holt loadEntry() das Feature und kopiert seine Attribute in fieldValues. Dabei wird über FormDataModel.fields iteriert, sodass nur die bekannten Felder geladen werden — unabhängig davon, welche weiteren Spalten die Tabelle enthalten mag:

function loadEntry(f_uid) {
    let it = LayerUtils.createFeatureIteratorFromExpression(
                 layer, "f_uid = '" + f_uid + "'")
    let feature = it.next()
    it.close()

    var map = {}
    for (var i = 0; i < FormDataModel.fields.length; i++) {
        var name = FormDataModel.fields[i].name
        map[name] = feature.attribute(name) || ""
    }
    fieldValues = map
    fieldValuesChanged()
    currentFuid = f_uid
}

Einen Eintrag speichern

saveEntry() liest alle Werte aus fieldValues und schreibt sie mit changeAttributeValue in den Layer. Beachten Sie, wie fields.indexOf(key) verwendet wird, um den Attributindex anhand des Namens nachzuschlagen — niemals als hart kodierte Zahl:

function saveEntry() {
    let it = LayerUtils.createFeatureIteratorFromExpression(
                 layer, "f_uid = '" + currentFuid + "'")
    let feature = it.next()
    it.close()

    layer.startEditing()
    var fid    = feature.id
    var fields = feature.fields

    for (var key in fieldValues) {
        var fieldIndex = fields.indexOf(key)
        if (fieldIndex >= 0) {
            layer.changeAttributeValue(fid, fieldIndex, fieldValues[key])
        }
    }
    layer.commitChanges()
}

Datenfluss: Zusammenfassung