JavaFX und MVVM

MVVM ist aus meiner Sicht für Desktop Anwendungen mit die beste Lösung, da die UI deklarativ erstellt wird und damit gut vom Model getrennt ist. Hier war in der Vergangenheit die Library mvmFX eine sehr gute Unterstützung, jedoch hat diese zuletzt vor einigen Jahren ein Update bekommen, so dass eine produktive Nutzung eher problematisch ist.

Daher macht es Sinn, einfach einmal zu schauen, wie eine MVVM Anwendung prinzipiell aufgebaut ist und wie man hier ggf. mit ein paar wenigen Klassen eine deutliche Vereinfachung herbeiführen kann.

Aufbau MVVM

Model

Wir haben eine Datenklasse mit unseren Daten. Dazu bauen wir uns einfach einmal eine kleine Klasse User mit username und email:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Model
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@AllArgsConstructor
public class User {
    private String username;
    private String email;
}

View (FXML)

Wenn wir nun eine Instanz dieser Klasse User anzeigen lassen wollen, brauchen wir eine Oberfläche, die wir per fxml deklarativ darstellen können. In der Oberfläche möchten wir dann username und email sehen und ändern können und wir haben eine einfache Aktion, die wir hier einfach einmal als Speichern aufgeführt haben.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// View (FXML)
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>

<GridPane xmlns:fx="http://javafx.com/fxml" fx:controller="de.example.UserViewController">
    <Label text="Benutzername:" GridPane.rowIndex="0" GridPane.columnIndex="0"/>
    <TextField fx:id="usernameField" GridPane.rowIndex="0" GridPane.columnIndex="1"/>

    <Label text="E-Mail:" GridPane.rowIndex="1" GridPane.columnIndex="0"/>
    <TextField fx:id="emailField" GridPane.rowIndex="1" GridPane.columnIndex="1"/>

    <Button text="Speichern" onAction="#saveUser" GridPane.rowIndex="2" GridPane.columnIndex="1"/>
</GridPane>

ViewModel

Zur Verbindung benötigen wir nun noch ein ViewModel. Dieses kennt das Model und bietet die Daten der View über Properties an. Weiterhin kennt es mögliche Aktionen, bei uns das Speichern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ViewModel
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class UserViewModel {
private final StringProperty username = new SimpleStringProperty();
private final StringProperty email = new SimpleStringProperty();
private final User user;

    public UserViewModel(User user) {
        this.user = user;
        username.set(user.getUsername());
        email.set(user.getEmail());
    }

    public StringProperty usernameProperty() {
        return username;
    }

    public StringProperty emailProperty() {
        return email;
    }

    public void save() {
        user.setUsername(username.get());
        user.setEmail(email.get());
    }
}

Controller (als Teil der View)

Nun müssen die View und das ViewModel verbunden werden. Dazu ist in der View ein Controller angegeben. Dieser kenn das ViewModel und die View und kann daher die Elemente verbinden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import javafx.fxml.FXML;
import javafx.scene.control.TextField;

public class UserViewController {
    @FXML private TextField usernameField;
    @FXML private TextField emailField;

    private UserViewModel viewModel;
    
    public void setViewModel(PersonViewModel viewModel) {
        this.viewModel = viewModel;
        usernameField.textProperty().bindBidirectional(viewModel.usernameProperty());
        emailField.textProperty().bindBidirectional(viewModel.emailProperty());
    }
    
    @FXML
    public void saveUser() {
        if (viewModel != null) {
            viewModel.save();
        }
    }
}

Dies zeigt den einfachen Aufbau von MVVM sowie die grosse Masse an Code, die hier geschrieben werden muss, nur um dies umzusetzen:

  • Eine zweite Version des Models, das alle Attribute enthält und das darüber hinaus noch weiteren Code (wie z.B. die save Methode) enthält.
  • Ein Controller nur für das Binding
  • Code, den wir nicht gezeigt haben: Nach dem Laden des FXML muss im Anschluss noch im Controller das ViewModel gesetzt werden.

Das ist nicht nur zusätzliche Arbeit sondern zugleich auch umständlich zu warten und anzupassen. Was wir also benötigen, ist eine Vereinfachung.

Ideen zur Vereinfachung

Model

Das ist unsere Datenklasse. Die wollen wir natürlich prinzipiell unverändert lassen. Eine Option, die wir hier ins Auge fassen können: Erweiterung des Models durch Annotations

ViewModel

Das ist eine Klasse, wie wir automatisiert aus dem Model erzeugen können:

  • Alle Datenfelder werden zu entsprechenden Properties.
  • Mögliche Angabe von Commands. Damit können wir Aktionen binden wie z.B. ein Knopfdruck.

Controller

Der Controller hat in erster Linie eine Aufgabe: Das Binding zwischen View und ViewModel. Dieses Binding können wir auch in unser FXML aufnehmen und hier das Binding direkt angeben.

View

Diese erstellen wir erst einmal weiter und ergänzen diese mit den Bindings. Für Formulare und ähnlichen Standard Anwendungen können wir aber auch überlegen, ob man dies nicht auch generieren kann um dann die notwendigen Angaben in das Model fliessen zu lassen (als ein weiterer Ausbau).

Automatische Erstellung des ViewModels

Ein erstes, einfaches, automatisches ViewModel könnte so aussehen:

  • Es ist eine generische Klasse für ein Model vom Typ T
  • Es speichert das Model in sich
  • Es hat eine Map von Properties: Der Name der Property hin zu der Property
  • Im Konstruktor wird dann das Model übergeben. Dabei wird dieses ausgewertet per Reflection und alle notwendigen Properties erstellt und in einer Map gespeichert.

Das führt dann zu einer ersten Klasse wie:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
public class AutoViewModel<T> {

    private final T model;
    private final Map<String, Property<?>> properties = new HashMap<>();

    public AutoViewModel(T model) {
        this.model = model;
        initProperties();
    }

    private void initProperties() {
        for (Method getter : model.getClass().getMethods()) {
            if (isGetter(getter)) {
                String fieldName = getFieldName(getter);
                Object value = invokeGetter(getter);

                Property<?> prop = toProperty(value);
                properties.put(fieldName, prop);

                // Bind ViewModel → Model
                prop.addListener((obs, oldVal, newVal) -> {
                    Method setter = findSetterFor(model.getClass(), fieldName, newVal != null ? newVal.getClass() : null);
                    if (setter != null) {
                        try {
                            setter.invoke(model, newVal);
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    }
                });
            }
        }
    }

    public Property<?> getProperty(String name) {
        return properties.get(name);
    }

    public T getModel() {
        return model;
    }

    // ========== Hilfsmethoden ==========

    private boolean isGetter(Method method) {
        return Modifier.isPublic(method.getModifiers())
                && method.getParameterCount() == 0
                && !method.getReturnType().equals(void.class)
                && (method.getName().startsWith("get") || method.getName().startsWith("is"));
    }

    private String getFieldName(Method method) {
        String name = method.getName();
        if (name.startsWith("get")) {
            return decapitalize(name.substring(3));
        } else if (name.startsWith("is")) {
            return decapitalize(name.substring(2));
        }
        return name;
    }

    private String decapitalize(String str) {
        if (str == null || str.isEmpty()) return str;
        return str.substring(0, 1).toLowerCase() + str.substring(1);
    }

    private Object invokeGetter(Method method) {
        try {
            return method.invoke(model);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    private Method findSetterFor(Class<?> clazz, String fieldName, Class<?> valueType) {
        String setterName = "set" + capitalize(fieldName);
        for (Method m : clazz.getMethods()) {
            if (m.getName().equals(setterName) && m.getParameterCount() == 1) {
                if (valueType == null || m.getParameterTypes()[0].isAssignableFrom(valueType)) {
                    return m;
                }
            }
        }
        return null;
    }

    private String capitalize(String str) {
        if (str == null || str.isEmpty()) return str;
        return str.substring(0, 1).toUpperCase() + str.substring(1);
    }

    private Property<?> toProperty(Object value) {
        if (value instanceof String s) return new SimpleStringProperty(s);
        if (value instanceof Integer i) return new SimpleIntegerProperty(i);
        if (value instanceof Boolean b) return new SimpleBooleanProperty(b);
        if (value instanceof Double d) return new SimpleDoubleProperty(d);
        if (value instanceof Float f) return new SimpleFloatProperty(f);
        if (value instanceof Long l) return new SimpleLongProperty(l);
        return new SimpleObjectProperty<>(value);
    }
}

Aber das Problem wird schnell offensichtlich: Wir haben jetzt zwar unsere Properties, aber damit können wir nicht direkt etwas anfangen. In unserem Beispiel klappt es, da wir Strings haben und auc die View mit StringProperties arbeitet. Aber nehmen wir nur ein weiteres Attribut, wie alter (int) oder ein Zeitstempel (Instant). Diese könnten wir auch in einem Textfeld darstellen wollen nur das hast eine StringProperty und wir haben IntegerProperty oder ObjectProperty. Das lässt sich so nicht direkt binden. Statt dessen brauchen wir zusätzlich Converter, die dann z.B. einen Instant entsprechend darstellen oder eben eine Eingabe in einen Instant umwandelt. Diese Problematik würde entweder direkt in dem ViewModel oder im Controller beim Binding gelöst.

Da es vom verwendeten Control abhängen kann, was benötigt wird und die View angepasst werden können soll, ohne das ViewModel zu ändern, werden wir diese Thematik im Binding angehen.

Anpassung des Bindings

Um Bindings vornehmen zu können, können wir nun ein neues Control Bindings erstellen. Dieses bekommt dann alle Informationen, die notwendig sind, um das Binding durchzuführen:

  • Source: Was soll genau gebunden werden?
  • Target: Was ist das Ziel des Bindings?
  • Richtung: Ist es nur in eine Richtung oder geht das Binding in beide Richtungen
  • Converters: Was für Converter sind für ein Binding notwendig?