Übungsaufgaben zu Properties, Bindings und MVVM: ein ViewModel für die Mitfahrgesuche

https://bildung.social/@oerinformatik/

https://oer-informatik.de/javafx-properties-binding-mvvm-uebungsaufgaben

JavaFX, Property, Binding, ObservableList, MVVM, ViewModel, Komponententest, JUnit, Speicherleck, Fehlersuche, Übungsaufgaben, Prüfungsvorbereitung

tl/dr; (Bearbeitungszeit insgesamt 70 min, mehrere Aufgaben): Übungen zu Properties, Bindings und MVVM in JavaFX: Du sagst das Verhalten von uni- und bidirektionalen Bindings und Listenern voraus, schreibst ein ViewModel für die Mitfahrgesuche mit JUnit-Tests ohne Fenster, bindest die Gesuche-Maske daran und findest ein Speicherleck, eine Endlosschleife und zwei weitere Binding-Fehler anhand ihrer Symptome [AI-Assisted]. (Zuletzt geändert am 11.11.2026)

Dieser Artikel ist Teil einer Artikelreihe über eine Desktop-App mit JavaFX: Übersicht / Kanban-Projektstart / Projekt anlegen / FXML, Layouts und CSS / Properties, Bindings und MVVM / Backend anbinden / TableView für Buchungen / Formulare und Dialoge / Anmeldung mit Keycloak / Verwaltung und Diagramme / GUI-Tests mit TestFX / Abnahmetest und Kanban-Metriken / Paketieren mit jpackage

Diese Übungsaufgaben gehören zum Artikel Properties, Bindings und MVVM: Die Angebotsliste mit ViewModel testen. Sie spielen im Projekt Mitfahrlabor und führen den Strang der Mitfahrgesuche weiter: Für Ticket JFX-13 „Offene Gesuche an meinem Haltepunkt anzeigen“ hat das Gesuche-Fenster seit den Übungen zu FXML, Layouts und CSS eine Maske mit Haltepunkt, Richtung und Datum. Jetzt bekommt es Daten und ein ViewModel, das die passenden Gesuche filtert, sobald sich etwas an der Maske ändert.

Schema MVVM der Angebotsliste: View mit FXML, CSS und Controller bindet sich per bind und bindBidirectional an das AngebotslisteViewModel mit Properties und Bindings, das über die AngebotQuelle das Model lädt (mit KI erstellt)

Hinweise zur Bearbeitung: Bis zur Teil-1-Prüfung der IT-Berufe solltest Du die Basiskompetenz beherrschen, also Aufgabe 1.1. Als FIA, FIDP oder FIDV musst Du zur Teil-2-Prüfung Aufgaben auf Kernkompetenz-Ebene lösen können, das sind Aufgabe 2.1 und Aufgabe 2.2. Von FIA wird zusätzlich erwartet, dass ihnen Aufgaben auf Profilkompetenz-Ebene (Aufgabe 3.1) keine Probleme bereiten. Als Startcode dient der Code-Stand src/04-properties-binding-mvvm/mitfahrlabor-desktop aus dem Artikel, ergänzt um Deine Dateien gesuche.fxml, gesuche.css, GesucheController und MitfahrgesucheApp aus den letzten Übungen. Eine vollständige Beispiellösung der Aufgaben 2.1 und 2.2 mit Tests liegt unter src/04-properties-binding-mvvm/uebung-mitfahrgesuche. Code-Aufgaben löst Du am besten am Rechner und prüfst sie mit gradlew test und gradlew run (bzw. mvnw test und mvnw javafx:run). Schreib Deine Lösung zuerst vollständig auf, bevor Du die Beispiellösung aufklappst.

Du möchtest eigene Aufgaben zum Üben von einem KI-Sprachmodell generieren lassen?

Kopiere den Text in ein KI-Sprachmodell Deiner Wahl und passe Kompetenzstufe, Anzahl, Bearbeitungszeit und Szenario bei Bedarf an. Wie die Aufgaben aufgebaut sind, beschreibt der Artikel Wie sind die Aufgaben aufgebaut?. Prüfe die Ergebnisse mit dem Lehrartikel: KI-Sprachmodelle machen Fehler.

Erstelle neue Übungsaufgaben zum Thema „Properties, Bindings und MVVM:
ein ViewModel für die Mitfahrgesuche“.

Bauplan: Halte Dich an Aufbau, Kompetenzstufen, Operatoren,
Anforderungsbereiche, Punkte und Zeitschätzung, die im Artikel
https://oer-informatik.de/aufgaben-aufbau beschrieben sind.

Fachliche Grundlage:
- https://oer-informatik.de/javafx-properties-binding-mvvm
- https://oer-informatik.de/szenario-mitfahrlabor
- https://oer-informatik.de/javafx-kanban-projektstart
- Stilbeispiel: https://oer-informatik.de/javafx-properties-binding-mvvm-uebungsaufgaben

Die Aufgaben sollen Inhalte zu diesen Schlagwörtern abdecken:
- JavaFX
- Property
- Binding
- ObservableList
- MVVM
- ViewModel
- Komponententest
- JUnit
- Speicherleck
- Fehlersuche

Kompetenzstufe: Kernkompetenz (alternativ: Basiskompetenz, Profilkompetenz, Expertise)
Anzahl: 1 Aufgabe mit Teilaufgaben
Bearbeitungszeit in Minuten ca.: <5 bis 30 min>
Szenario: eines der Szenarien aus dem Bauplan

Vorgehen:
1. Lies zuerst die verlinkten Artikel. Kannst Du keine Webseiten
   abrufen, sag mir das, statt zu raten.
2. Steht bei einer Angabe noch ein Platzhalter in <…>, frag mich
   zuerst danach. Richte Punkte und Zahl der Teilaufgaben nach der
   Bearbeitungszeit (ca. 0,9 min je Punkt).
3. Gib je Aufgabe Titel mit Kompetenzstufe, Ausgangssituation und
   Teilaufgaben mit Operator und Punktzahl aus, dazu die geschätzte
   Bearbeitungszeit.
4. Gib die Beispiellösungen mit Punkteverteilung erst aus, wenn ich
   danach frage.

Aufgabe 1.1: Binding-Verhalten vorhersagen (Basiskompetenz)

(ca. 14 min Bearbeitungszeit)

Lukas Hofmann aus dem Projektteam von Mitfahrlabor probiert Properties in der JShell aus, bevor er sie im Code verwendet. Alle Properties stammen aus javafx.beans.property.

  1. Gib die Ausgaben A1 bis A4 an. (4 Punkte)
IntegerProperty freiePlaetze = new SimpleIntegerProperty(3);
IntegerProperty anzeige = new SimpleIntegerProperty(0);
anzeige.bind(freiePlaetze);
System.out.println(anzeige.get());        // A1
freiePlaetze.set(2);
System.out.println(anzeige.get());        // A2
anzeige.unbind();
freiePlaetze.set(1);
System.out.println(anzeige.get());        // A3
anzeige.set(4);
System.out.println(freiePlaetze.get());   // A4
  1. Gib die Ausgaben B1 bis B3 an. (3 Punkte)
StringProperty feld = new SimpleStringProperty("Herford");
StringProperty filter = new SimpleStringProperty("Bielefeld");
feld.bindBidirectional(filter);
System.out.println(feld.get());     // B1
feld.set("Werther");
System.out.println(filter.get());   // B2
filter.set("Halle");
System.out.println(feld.get());     // B3
  1. Gib alle Ausgaben dieses Programmstücks in der richtigen Reihenfolge an und beschreibe, was in der letzten Zeile passiert. (4 Punkte)
StringProperty richtung = new SimpleStringProperty("zur-schule");
richtung.addListener((o, alt, neu) -> System.out.println(alt + " -> " + neu));
richtung.set("nach-hause");
richtung.set("nach-hause");
richtung.set("zur-schule");
StringProperty kopie = new SimpleStringProperty();
kopie.bind(richtung);
System.out.println(kopie.get());
kopie.set("nach-hause");
  1. Ordne die Bestandteile der Desktop-App den Rollen Model, View und ViewModel zu. (4 Punkte)
Bestandteile und Rollen
Nr. Bestandteil Rolle
1 Record Angebot mit Datum, Abfahrt, Richtung und freien Plätzen
2 Klasse mit BooleanProperty nurZurSchule und StringBinding status, ohne Import aus javafx.scene
3 Datei hauptfenster.fxml
4 Klasse, die in initialize() Labels an Bindings hängt und über fx:controller geladen wird
5 Interface AngebotQuelle mit der Methode angeboteLaden()
6 Methode aktualisieren(), die Filter zurücksetzt und neu lädt, ohne einen Node zu kennen
7 Stylesheet mitfahrlabor.css
8 SortedList<Angebot>, die eine ListView als Items bekommt
  1. Je Ausgabe 1 Punkt:
  • A1: 3, denn beim Binden übernimmt anzeige sofort den Wert der Quelle.
  • A2: 2, denn anzeige folgt der Quelle.
  • A3: 2, denn nach unbind() behält anzeige den letzten Wert und folgt nicht mehr.
  • A4: 1, denn das unidirektionale Binding wirkte nie von anzeige zurück auf freiePlaetze, und nach unbind() ist anzeige ohnehin unabhängig.
  1. Je Ausgabe 1 Punkt:
  • B1: Bielefeld, denn bei a.bindBidirectional(b) übernimmt a beim Binden den Wert von b.
  • B2: Werther, denn die Änderung an feld wird auf filter übertragen.
  • B3: Halle, denn die Änderung an filter wird auf feld übertragen.
  1. Ausgaben (je Zeile 0,5 Punkte, insgesamt 1,5 Punkte):
zur-schule -> nach-hause
nach-hause -> zur-schule
zur-schule

Das zweite set("nach-hause") ändert nichts, weil der Wert schon gleich ist; ein ChangeListener wird dann nicht aufgerufen (1 Punkt). kopie.get() liefert den Wert der Quelle zur-schule. Die letzte Zeile wirft eine RuntimeException mit der Meldung „A bound value cannot be set.“, weil kopie unidirektional gebunden ist (1,5 Punkte).

  1. Je zwei richtige Zeilen 1 Punkt:
Lösung Rollen
Nr. Rolle
1 Model
2 ViewModel
3 View
4 View (der FXML-Controller gehört zur View)
5 Model
6 ViewModel
7 View
8 ViewModel

Aufgabe 2.1: Ein ViewModel für die Mitfahrgesuche (Kernkompetenz)

(ca. 18 min Bearbeitungszeit)

Für Ticket JFX-13 soll das Gesuche-Fenster echte Gesuche zeigen. Oksana Melnyk hat das Model schon angelegt (Paket de.dbh.mitfahrlabor.desktop.model):

public record Gesuch(long id, String haltepunkt, String richtung, LocalDate datum,
                     LocalTime fruehestens, LocalTime spaetestens, String notiz) {

    @Override
    public String toString() {
        return fruehestens + "–" + spaetestens + (notiz.isBlank() ? "" : "  " + notiz);
    }
}

@FunctionalInterface
public interface GesuchQuelle {
    List<Gesuch> gesucheLaden();
}

Die Klasse Beispielgesuche liefert mit offeneGesuche() fünf Gesuche (Datei im Ordner der Beispiellösung, Konstanten GUETERSLOH, BRACKWEDE, HALLE für die Namen der Haltepunkte):

Beispielgesuche
id Haltepunkt Richtung Datum Zeitfenster Notiz
1 Hauptbahnhof, ZOB Steig 3 (Gütersloh) zur-schule 16.11.2026 07:00–08:00 komme mit dem Rad zum Bahnhof
2 Bahnhof Brackwede (Bielefeld) nach-hause 16.11.2026 15:30–16:30
3 Hauptbahnhof, ZOB Steig 3 (Gütersloh) zur-schule 16.11.2026 06:45–07:15
4 Hauptbahnhof, ZOB Steig 3 (Gütersloh) nach-hause 16.11.2026 14:00–15:00 mit Sporttasche
5 Marktplatz (Halle (Westf.)) zur-schule 17.11.2026 07:00–07:30

Schreibe die Klasse GesucheViewModel im Paket de.dbh.mitfahrlabor.desktop.viewmodel. Sie bekommt im Konstruktor eine GesuchQuelle und lädt daraus sofort alle Gesuche.

  1. Lege die Zustände der Maske als Properties an: haltepunkt (Name des Haltepunkts, anfangs leer), zurSchule (anfangs true) und datum (anfangs leer), jeweils mit Methode nach der Namenskonvention. Biete außerdem die Liste der sechs Haltepunkte als ObservableList<String> an und schreibe eine Methode zuruecksetzen(), die alle drei Properties auf den Anfangszustand setzt. (6 Punkte)

  2. Ergänze die abgeleiteten Werte: (7 Punkte)

  • getPassendeGesuche() liefert die Gesuche, die zu Haltepunkt, Richtung und Datum passen, sortiert nach fruehestens. Solange Haltepunkt oder Datum fehlen, ist die Liste leer. Die Liste soll sich bei jeder Änderung einer Property selbst aktualisieren.
  • filterVollstaendigProperty() ist true, wenn Haltepunkt und Datum gewählt sind.
  • hinweisProperty() liefert „Bitte Haltepunkt und Datum wählen.“, solange der Filter unvollständig ist, „Keine offenen Gesuche für diese Auswahl.“ bei leerer Trefferliste und sonst „1 offenes Gesuch“ bzw. „N offene Gesuche“.
  1. Schreibe mindestens vier JUnit-Tests für das ViewModel, die ohne JavaFX-Plattform laufen. Decke damit den unvollständigen Filter, die Filterung und Sortierung, einen Filter ohne Treffer und zuruecksetzen() ab. (7 Punkte)
  1. und b) Bewertung: drei Properties mit Anfangswerten und Methoden …Property() (3 Punkte), Liste der Haltepunkte (1 Punkt), zuruecksetzen() (2 Punkte); FilteredList mit gebundenem Prädikat und allen drei Abhängigkeiten (3 Punkte), SortedList (1 Punkt), filterVollstaendig mit der Fluent-API (1 Punkt), hinweis mit vollständigen Abhängigkeiten (2 Punkte).
public class GesucheViewModel {

    private final ObservableList<String> haltepunkte = FXCollections.observableArrayList(
            "Hauptbahnhof, ZOB Steig 3 (Gütersloh)",
            "Park & Ride am Bahnhof (Herford)",
            "Marktplatz (Halle (Westf.))",
            "Busbahnhof (Bad Salzuflen)",
            "Kirchplatz (Werther (Westf.))",
            "Bahnhof Brackwede (Bielefeld)");

    private final ObservableList<Gesuch> gesuche = FXCollections.observableArrayList();
    private final FilteredList<Gesuch> gefiltert = new FilteredList<>(gesuche, g -> false);
    private final SortedList<Gesuch> passend =
            new SortedList<>(gefiltert, Comparator.comparing(Gesuch::fruehestens));

    private final ObjectProperty<String> haltepunkt = new SimpleObjectProperty<>(this, "haltepunkt");
    private final BooleanProperty zurSchule = new SimpleBooleanProperty(this, "zurSchule", true);
    private final ObjectProperty<LocalDate> datum = new SimpleObjectProperty<>(this, "datum");

    private final BooleanBinding filterVollstaendig = haltepunkt.isNotNull().and(datum.isNotNull());
    private final StringBinding hinweis;

    public GesucheViewModel(GesuchQuelle quelle) {
        gesuche.setAll(quelle.gesucheLaden());
        gefiltert.predicateProperty().bind(Bindings.createObjectBinding(
                this::filter, haltepunkt, zurSchule, datum));
        hinweis = Bindings.createStringBinding(() -> {
            if (!filterVollstaendig.get()) {
                return "Bitte Haltepunkt und Datum wählen.";
            }
            return passend.isEmpty() ? "Keine offenen Gesuche für diese Auswahl."
                    : passend.size() == 1 ? "1 offenes Gesuch"
                    : passend.size() + " offene Gesuche";
        }, filterVollstaendig, passend);
    }

    private Predicate<Gesuch> filter() {
        if (haltepunkt.get() == null || datum.get() == null) {
            return g -> false;
        }
        String richtung = zurSchule.get() ? "zur-schule" : "nach-hause";
        return g -> g.haltepunkt().equals(haltepunkt.get())
                && g.richtung().equals(richtung)
                && g.datum().equals(datum.get());
    }

    public void zuruecksetzen() {
        haltepunkt.set(null);
        datum.set(null);
        zurSchule.set(true);
    }

    public ObservableList<String> getHaltepunkte() { return haltepunkte; }
    public ObservableList<Gesuch> getPassendeGesuche() { return passend; }
    public ObjectProperty<String> haltepunktProperty() { return haltepunkt; }
    public BooleanProperty zurSchuleProperty() { return zurSchule; }
    public ObjectProperty<LocalDate> datumProperty() { return datum; }
    public BooleanBinding filterVollstaendigProperty() { return filterVollstaendig; }
    public StringBinding hinweisProperty() { return hinweis; }
}

Das Prädikat liest alle drei Properties; deshalb stehen alle drei in createObjectBinding. Der Hinweis liest filterVollstaendig und die Trefferliste passend. Eine Alternative für filterVollstaendig ist Bindings.createBooleanBinding(() -> haltepunkt.get() != null && datum.get() != null, haltepunkt, datum).

  1. Bewertung: Testdouble oder Beispieldaten als Quelle ohne Platform.startup (1 Punkt), je sinnvoller Test mit passender Zusicherung 1,5 Punkte (höchstens 6).
class GesucheViewModelTest {

    private static final LocalDate MONTAG = LocalDate.of(2026, 11, 16);

    private GesucheViewModel viewModel;

    @BeforeEach
    void vorbereiten() {
        viewModel = new GesucheViewModel(Beispielgesuche::offeneGesuche);
    }

    @Test
    void ohneVollstaendigenFilterKeineGesucheUndHinweis() {
        viewModel.haltepunktProperty().set(Beispielgesuche.GUETERSLOH);

        assertFalse(viewModel.filterVollstaendigProperty().get());
        assertTrue(viewModel.getPassendeGesuche().isEmpty());
        assertEquals("Bitte Haltepunkt und Datum wählen.", viewModel.hinweisProperty().get());
    }

    @Test
    void filtertNachHaltepunktRichtungUndDatumSortiertNachFruehestens() {
        viewModel.haltepunktProperty().set(Beispielgesuche.GUETERSLOH);
        viewModel.datumProperty().set(MONTAG);

        List<Long> ids = viewModel.getPassendeGesuche().stream().map(Gesuch::id).toList();
        assertEquals(List.of(3L, 1L), ids);
        assertEquals("2 offene Gesuche", viewModel.hinweisProperty().get());
    }

    @Test
    void richtungNachHauseZeigtAndereGesuche() {
        viewModel.haltepunktProperty().set(Beispielgesuche.GUETERSLOH);
        viewModel.datumProperty().set(MONTAG);

        viewModel.zurSchuleProperty().set(false);

        assertEquals(1, viewModel.getPassendeGesuche().size());
        assertEquals(4L, viewModel.getPassendeGesuche().get(0).id());
    }

    @Test
    void keineTrefferErgibtEigenenHinweis() {
        viewModel.haltepunktProperty().set(Beispielgesuche.HALLE);
        viewModel.datumProperty().set(MONTAG);

        assertTrue(viewModel.getPassendeGesuche().isEmpty());
        assertEquals("Keine offenen Gesuche für diese Auswahl.", viewModel.hinweisProperty().get());
    }

    @Test
    void zuruecksetzenLeertDieAuswahl() {
        viewModel.haltepunktProperty().set(Beispielgesuche.GUETERSLOH);
        viewModel.datumProperty().set(MONTAG);
        viewModel.zurSchuleProperty().set(false);

        viewModel.zuruecksetzen();

        assertNull(viewModel.haltepunktProperty().get());
        assertTrue(viewModel.zurSchuleProperty().get());
        assertFalse(viewModel.filterVollstaendigProperty().get());
    }
}

Gesuch 3 (ab 06:45) steht vor Gesuch 1 (ab 07:00), obwohl es in den Daten später kommt; der Test prüft damit die Sortierung. Wer mit einem eigenen Lambda als Quelle arbeitet (() -> List.of(…)), ist unabhängig von späteren Änderungen an den Beispieldaten.

Aufgabe 2.2: Die Gesuche-Maske an das ViewModel binden (Kernkompetenz)

(ca. 13 min Bearbeitungszeit)

Das GesucheViewModel aus Aufgabe 2.1 steht. Jetzt soll das Gesuche-Fenster es nutzen. Die Liste filtert sofort bei jeder Änderung der Maske; der Button „Filtern“ wird deshalb zum Button „Zurücksetzen“ (Kennung zuruecksetzen, Tastenkürzel Alt+Z).

  1. Gib für jedes Element an, ob es mit bind() (und in welcher Richtung), mit bindBidirectional() oder mit setItems(…) an das ViewModel angeschlossen wird, und begründe kurz: ComboBox #haltepunktAuswahl (Wert und Einträge), RadioButton #zurSchule, DatePicker #datumAuswahl, ListView #gesucheListe, Label #gesuchHinweis. (5 Punkte)

  2. Schreibe die Klasse GesucheController neu: Sie erzeugt das ViewModel mit Beispielgesuche::offeneGesuche, schließt alle Elemente aus a) an, setzt am Hinweis die Style-Klasse fehler, solange der Filter unvollständig ist, und ruft beim Klick auf „Zurücksetzen“ das ViewModel auf. Gib auch die geänderte Zeile der FXML-Datei an. (7 Punkte)

  3. Erläutere, warum beim Zurücksetzen auch der RadioButton „nach Hause“ seine Markierung verliert, obwohl nur #zurSchule gebunden ist. (2 Punkte)

  1. Je Element 1 Punkt:
  • ComboBox: Einträge mit setItems(viewModel.getHaltepunkte()); Wert mit valueProperty().bindBidirectional(…), weil die Person wählt und das ViewModel beim Zurücksetzen leert.
  • RadioButton #zurSchule: selectedProperty().bindBidirectional(…), aus demselben Grund.
  • DatePicker: valueProperty().bindBidirectional(…), ebenso.
  • ListView: setItems(viewModel.getPassendeGesuche()); die SortedList hält sich selbst aktuell, ein Binding ist nicht nötig.
  • Label: textProperty().bind(viewModel.hinweisProperty()), Richtung ViewModel → Label; der Text wird nie von der Oberfläche geändert.
  1. Bewertung: ViewModel-Feld (1 Punkt), setItems für ComboBox und ListView (1 Punkt), drei bidirektionale Bindings (2 Punkte), Hinweis gebunden (1 Punkt), Style-Klasse fehler abhängig von filterVollstaendig inklusive Startzustand (1 Punkt), zuruecksetzen() mit FXML-Zeile (1 Punkt).
public class GesucheController {

    private final GesucheViewModel viewModel = new GesucheViewModel(Beispielgesuche::offeneGesuche);

    @FXML private ComboBox<String> haltepunktAuswahl;
    @FXML private RadioButton zurSchule;
    @FXML private DatePicker datumAuswahl;
    @FXML private ListView<Gesuch> gesucheListe;
    @FXML private Label gesuchHinweis;

    @FXML
    private void initialize() {
        haltepunktAuswahl.setItems(viewModel.getHaltepunkte());
        gesucheListe.setItems(viewModel.getPassendeGesuche());

        haltepunktAuswahl.valueProperty().bindBidirectional(viewModel.haltepunktProperty());
        zurSchule.selectedProperty().bindBidirectional(viewModel.zurSchuleProperty());
        datumAuswahl.valueProperty().bindBidirectional(viewModel.datumProperty());

        gesuchHinweis.textProperty().bind(viewModel.hinweisProperty());
        markiereFehler(!viewModel.filterVollstaendigProperty().get());
        viewModel.filterVollstaendigProperty()
                .addListener((beobachtet, alt, vollstaendig) -> markiereFehler(!vollstaendig));
    }

    @FXML
    private void zuruecksetzen() {
        viewModel.zuruecksetzen();
    }

    private void markiereFehler(boolean fehler) {
        if (fehler && !gesuchHinweis.getStyleClass().contains("fehler")) {
            gesuchHinweis.getStyleClass().add("fehler");
        } else if (!fehler) {
            gesuchHinweis.getStyleClass().remove("fehler");
        }
    }
}
<Button fx:id="zuruecksetzen" text="_Zurücksetzen" mnemonicParsing="true"
        onAction="#zuruecksetzen" GridPane.columnIndex="1" GridPane.rowIndex="3"/>

Die Style-Klasse ist eine Liste, keine Property mit einem Wert; deshalb setzt hier ein Listener sie. Er ist unbedenklich, weil ViewModel und Controller gleich lange leben. Den festen Text „Noch keine Mitfahrgesuche geladen.“ am Label entfernst Du, denn ein gebundenes Label bekommt seinen Text vom ViewModel. Eine elegantere Alternative ist eine Pseudo-Klasse (PseudoClass.getPseudoClass("fehler") und pseudoClassStateChanged(…), im Stylesheet .statuszeile:fehler).

  1. zuruecksetzen() setzt zurSchule auf true; über das bidirektionale Binding wird der RadioButton #zurSchule markiert (1 Punkt). Beide RadioButtons gehören zur ToggleGroup richtung, die immer nur eine Markierung zulässt; sie nimmt deshalb #nachHause die Markierung weg (1 Punkt).

Aufgabe 3.1: Speicherleck und Endlosschleife anhand der Symptome finden (Profilkompetenz)

(ca. 25 min Bearbeitungszeit)

Oksana Melnyk hat das Gesuche-Fenster für Mitfahrlabor erweitert: Ein Doppelklick auf ein Gesuch öffnet ein kleines Detailfenster mit der Notiz, und zwei neue Felder grenzen das Zeitfenster ein. Ein GesucheViewModel gibt es für die ganze App genau einmal; jedes Detailfenster bekommt dieselbe Instanz. Die folgenden Ausschnitte sind absichtlich fehlerhaft.

// GesucheViewModel (Ausschnitt mit Oksanas Erweiterungen)
private final ObjectProperty<Gesuch> ausgewaehlt = new SimpleObjectProperty<>(this, "ausgewaehlt");
private final ObjectProperty<LocalTime> abFruehestens =
        new SimpleObjectProperty<>(this, "abFruehestens", LocalTime.of(7, 0));
private final ObjectProperty<LocalTime> bisSpaetestens =
        new SimpleObjectProperty<>(this, "bisSpaetestens", LocalTime.of(8, 0));

public GesucheViewModel(GesuchQuelle quelle) {
    // ... Liste und Filter wie bisher
    hinweis = Bindings.createStringBinding(() -> {
        if (haltepunkt.get() == null || datum.get() == null) {
            return "Bitte Haltepunkt und Datum wählen.";
        }
        return passend.isEmpty() ? "Keine offenen Gesuche für diese Auswahl."
                : passend.size() == 1 ? "1 offenes Gesuch"
                : passend.size() + " offene Gesuche";
    }, haltepunkt);
    // Zeitfenster: spätestens soll immer eine Stunde nach frühestens liegen
    abFruehestens.addListener((o, alt, neu) -> bisSpaetestens.set(neu.plusMinutes(60)));
    bisSpaetestens.addListener((o, alt, neu) -> abFruehestens.set(neu.minusMinutes(30)));
}

public void zuruecksetzen() {
    haltepunkt.set(null);
    datum.set(null);
    zurSchule.set(true);
}
// GesucheController.initialize() (Ausschnitt)
haltepunktAuswahl.valueProperty().bindBidirectional(viewModel.haltepunktProperty());
zurSchule.selectedProperty().bindBidirectional(viewModel.zurSchuleProperty());
viewModel.datumProperty().bind(datumAuswahl.valueProperty());
gesuchHinweis.textProperty().bind(viewModel.hinweisProperty());
// GesuchDetailController: jedes Detailfenster hat eine eigene Instanz
public class GesuchDetailController {

    @FXML private Label detailNotiz;

    /** Ruft das Gesuche-Fenster bei jedem Öffnen eines Detailfensters auf. */
    public void verbinden(GesucheViewModel viewModel) {
        viewModel.ausgewaehltProperty().addListener((o, alt, neu) -> {
            System.out.println("Detailfenster aktualisiert");
            detailNotiz.setText(neu == null ? "" : neu.notiz());
        });
    }
}

Im Kanban-Meeting berichten Ben Richter und Lukas Hofmann von vier Beobachtungen:

Beobachtungen im Kanban-Meeting
Nr. Beobachtung
M1 Nachdem Ben dreimal ein Detailfenster geöffnet und wieder geschlossen hat, erscheint bei jedem Wechsel der Auswahl dreimal „Detailfenster aktualisiert“ in der Konsole. Nach einem langen Testtag zeigt ein Speicherprofil Hunderte Objekte GesuchDetailController und Label.
M2 Lukas stellt „frühestens“ auf 06:30. Die App hängt kurz, in der Konsole steht ein StackOverflowError.
M3 Ben klickt auf „Zurücksetzen“. Der Haltepunkt ist danach leer, Datum und Richtung bleiben aber stehen. In der Konsole steht RuntimeException: A bound value cannot be set.
M4 Ben wählt zuerst den Haltepunkt, dann das Datum. Die Liste zeigt zwei Gesuche, der Hinweis bleibt aber bei „Bitte Haltepunkt und Datum wählen.“. Erst als er einen anderen Haltepunkt wählt, stimmt der Hinweis.
  1. Gib für jede Beobachtung M1 bis M4 die Ursache im Code an und korrigiere sie. (8 Punkte)

  2. Erläutere, warum Deine Korrektur zu M1 das Detailfenster für den Garbage Collector freigibt, und warum es nicht reicht, den Lambda-Ausdruck direkt in new WeakChangeListener<>(…) zu verpacken. (3 Punkte)

  3. Schreibe einen JUnit-Test ohne JavaFX-Plattform, der den Fehler M4 aufdeckt. (4 Punkte)

  4. Die Pipeline hat die Tests GesucheViewModelTest aus der Beispiellösung von Aufgabe 2.1 ausgeführt, und alle waren grün, obwohl M4 schon im Code stand. Beurteile, ob das sein kann, und leite daraus eine Regel für das Team ab. (3 Punkte)

  1. Je Beobachtung 1 Punkt für die Ursache, 1 Punkt für die Korrektur:
Ursachen und Korrekturen
Nr. Ursache Korrektur
M1 Jedes Detailfenster hängt einen Listener an ausgewaehlt des langlebigen ViewModels und entfernt ihn nie. Das Lambda hält detailNotiz und damit den Controller fest. Binding statt Listener: detailNotiz.textProperty().bind(Bindings.createStringBinding(() -> viewModel.ausgewaehltProperty().get() == null ? "" : viewModel.ausgewaehltProperty().get().notiz(), viewModel.ausgewaehltProperty())); oder den Listener in einem Feld merken und beim Schließen (stage.setOnHidden(…)) mit removeListener entfernen.
M2 Die Listener setzen sich gegenseitig mit unvereinbaren Regeln (+60 und −30 Minuten). Jede Runde ändert den Wert erneut, es entsteht eine endlose Rekursion. Regeln vereinbar machen (beide 60 Minuten, dann endet das Ping-Pong nach einer Runde) oder besser nur eine Richtung: bisSpaetestens als abgeleiteter Wert von abFruehestens mit Binding und ohne eigenen Listener.
M3 datum ist unidirektional an den DatePicker gebunden; zuruecksetzen() scheitert an datum.set(null). haltepunkt wurde vorher schon geleert, zurSchule danach nicht mehr erreicht. datumAuswahl.valueProperty().bindBidirectional(viewModel.datumProperty());
M4 Im createStringBinding des Hinweises fehlen datum und passend in den Abhängigkeiten. Eine Änderung des Datums macht den Hinweis nicht ungültig. alle gelesenen Werte angeben: …, haltepunkt, datum, passend);
  1. Das Binding meldet sich an ausgewaehlt nur mit einem schwachen Listener an, der das Binding und damit Label und Controller nicht festhält. Hält sonst niemand das Detailfenster fest, kann der Garbage Collector es einsammeln (1,5 Punkte). Ein WeakChangeListener hält den eigentlichen Listener nur schwach. Ist das Lambda sonst nirgends gespeichert, sammelt der Garbage Collector es sofort ein, und das Detailfenster bekommt schon nach kurzer Zeit keine Änderungen mehr. Der Controller muss den Listener deshalb zusätzlich in einem eigenen Feld halten (1,5 Punkte).

  2. Bewertung: Test ohne Platform.startup (1 Punkt), Reihenfolge erst Haltepunkt, dann Datum (2 Punkte), Zusicherung auf den Hinweis (1 Punkt).

@Test
void hinweisFolgtAuchDemDatum() {
    GesucheViewModel viewModel = new GesucheViewModel(Beispielgesuche::offeneGesuche);
    viewModel.haltepunktProperty().set(Beispielgesuche.GUETERSLOH);
    assertEquals("Bitte Haltepunkt und Datum wählen.", viewModel.hinweisProperty().get());

    viewModel.datumProperty().set(LocalDate.of(2026, 11, 16));

    assertEquals("2 offene Gesuche", viewModel.hinweisProperty().get());
}

Der Test fragt den Hinweis vor dem Setzen des Datums einmal ab. Dadurch ist das Binding gültig berechnet, und der fehlerhafte Code liefert danach weiter den alten Text.

  1. Das kann sein (1 Punkt): Im Test filtertNachHaltepunktRichtungUndDatumSortiertNachFruehestens wird der Hinweis erst nach beiden set-Aufrufen zum ersten Mal abgefragt. Ein Binding, das noch nie berechnet wurde, rechnet beim ersten get() mit den aktuellen Werten und liefert deshalb „2 offene Gesuche“, auch wenn eine Abhängigkeit fehlt (1 Punkt). Regel, z. B. für die Spalte „In Arbeit – erledigt“: Für jede Abhängigkeit eines Bindings gibt es einen Test, der den Wert vorher abfragt, dann genau diese Abhängigkeit ändert und den neuen Wert prüft (1 Punkt).

Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Übungsaufgaben zu Properties, Bindings und MVVM: ein ViewModel für die Mitfahrgesuche” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/javafx-properties-binding-mvvm-uebungsaufgaben veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/javafx/javafx-einstieg. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: