Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Viitteiden hallinta ja sisäkkäisten property-olioiden kuunteleminen

Löydät tämän esimerkin koodit kokonaisuudessaan GitHubista.

Usein olion on tarpeen viitata toiseen olioon, jotta se voi käyttää toisen olion tietoja tai toimintoja.

Oletetaan, että meillä on seuraava tietomalli.

Sovelluksessa riippuvuus voisi näyttää esimerkiksi seuraavalta.

Tehdään Tehtava-luokkaan StringProperty otsikko ja ObjectProperty<Kategoria> kategoria -kentät, jotka vastaavat yllä esitettyjä JSON-kenttiä. Vastaavasti Kategoria-luokkaan tehdään StringProperty nimi -kenttä.

Tehtava.java
package fi.jyu.ohj2.esimerkit.viitteidenkorjaaminen;

import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class Tehtava {

    private final StringProperty otsikko = new SimpleStringProperty();
    private final ObjectProperty<Kategoria> kategoria = new SimpleObjectProperty<>();

    public Tehtava() {
    }

    public Tehtava(String otsikko, Kategoria kategoria) {
        this.otsikko.set(otsikko);
        this.kategoria.set(kategoria);
    }

    // Otsikko
    public void setOtsikko(String otsikko) {
        this.otsikko.set(otsikko);
    }

    public String getOtsikko() {
        return otsikko.get();
    }

    public StringProperty otsikkoProperty() {
        return otsikko;
    }

    // Kategoria
    public void setKategoria(Kategoria kategoria) {
        this.kategoria.set(kategoria);
    }

    public Kategoria getKategoria() {
        return kategoria.get();
    }

    public ObjectProperty<Kategoria> kategoriaProperty() {
        return kategoria;
    }
}

Tehtävät näyttävät JSON-tiedostossa suurin piirtein tältä:

[
  {
     "otsikko": "Herää",
     "kategoria": "Tärkeä"
  },
  {
    "otsikko": "Mene kouluun",
    "kategoria": "Tärkeä"
  },
  {
    "otsikko": "Syö",
    "kategoria": "Tärkeä"
  },
  {
    "otsikko": "Pelaa Fortniteä",
    "kategoria": "Ei-tärkeä"
  },
  {
    "otsikko": "Mene nukkumaan",
    "kategoria": "Vähemmän tärkeä"
  }
]

Vastaavasti kategoriat näyttäisivät tältä:

[
  {"nimi": "Tärkeä" },
  {"nimi": "Vähemmän tärkeä" },
  {"nimi": "Ei-tärkeä" }
]

Jos kategorian nimeä muutetaan, olisi loogista, että kaikki tehtävät, jotka viittaavat tähän kategoriaan, saavat automaattisesti päivitetyn kategorian nimen, koska ne viittaavat samaan Kategoria-olioon. Alla on kuvattuna tavoitetila, miten haluaisimme, että sovelluksemme toimii:

JSON-tiedostossa tehtävän sisällä kategoria on kuitenkin vain merkkijono, joka toimii kategorian tunnisteena. Kun tuon merkkijonon perusteella aikanaan kontrolleriessa luodaan Tehtava-olio, luodaan uusi Kategoria-olio, joka sisältää saman kategorian nimen, kuin Kategoria-olio, joka luetaan kategoriat.json-tiedostosta. Nämä ovat kaksi eri oliota. Näin ollen mitä tahansa kategorialle tapahtuukaan, muutos ei näy tehtävissä ilman manuaalista päivitystä. Tämä on ongelmallista, koska se rikkoo olioiden välisen yhteyden ja tekee sovelluksesta vaikeammin ylläpidettävän.

Jotta kategorian nimi saadaan päivittymään tehtävälistauksessa ilman manuaalista päivitystä, on

  1. tehtävä-oliossa viitattava oikeaan kategoria-olioon, ja
  2. TableView-olion kategoria-sarakkeen on kuunneltava kategorian nimen muutoksia setCellValueFactory-metodissa käyttäen flatMap-metodia (ks. JavaDoc). Esimerkki tästä on:
kategoriaColumn.setCellValueFactory(cellData -> 
                 cellData
                .getValue()
                .kategoriaProperty()
                .flatMap(kategoria -> kategoria.nimiProperty()));

Katsotaan tätä tarkemmin vaihe vaiheelta.

Korjataan aluksi Tehtava-oliossa olevat Kategoria-olioiden viitteet oikeaksi sovelluksen käynnistämisen jälkeen.

Oletetaan, että meillä on MainController-luokassa attribuutit tehtäville ja kategorioille ja niitä vastaavat GUI-oliot.

public class MainController implements Initializable {
    // ...
    @FXML
    private TableView<Tehtava> tehtavatTable;

    @FXML
    private TableView<Kategoria> kategoriatTable;

    private ObservableList<Tehtava> tehtavat = FXCollections.observableArrayList();
    private ObservableList<Kategoria> kategoriat = FXCollections.observableArrayList();
    // ...
}

Luetaan JSON-tiedostosta kategoriat ja tehtävät.

public void initialize(URL url, ResourceBundle resourceBundle) {
    Path tehtavatPolku = Path.of("tehtavat.json");
    Path kategoriatPolku = Path.of("kategoriat.json");
    ObjectMapper mapper = new ObjectMapper();
    try {
        Kategoria[] k = mapper.readValue(kategoriatPolku.toFile(), Kategoria[].class);
        Tehtava[] t = mapper.readValue(tehtavatPolku.toFile(), Tehtava[].class);
        kategoriat.setAll(k);
        tehtavat.setAll(t);
    } catch (JacksonException e) {
        e.printStackTrace();
    }
}

Lisätään vielä tehtävät ja kategoriat UI-olioihin.

public void initialize(URL url, ResourceBundle resourceBundle) {
    // ...
    TableColumn<Tehtava, String> otsikkoColumn = new TableColumn<>("Otsikko");
    otsikkoColumn.setCellValueFactory(cellData -> cellData.getValue().otsikkoProperty());
    TableColumn<Tehtava, String> kategoriaColumn = new TableColumn<>("Kategoria");
    kategoriaColumn.setCellValueFactory(cellData -> cellData.getValue().kategoriaProperty().asString());
    tehtavatTable.getColumns().addAll(otsikkoColumn, kategoriaColumn);

    TableColumn<Kategoria, String> nimiColumn = new TableColumn<>("Nimi");
    nimiColumn.setCellValueFactory(cellData -> cellData.getValue().nimiProperty());
    kategoriatTable.getColumns().add(nimiColumn);
}

Nyt jos muokkaamme kategorian nimeä, se ei päivity tehtävään.

Korjataan viitteet oikeiksi tiedostojen lukemisen jälkeen.

ObjectMapper mapper = new ObjectMapper();
try {
    Kategoria[] k = mapper.readValue(kategoriatPolku.toFile(), Kategoria[].class);
    Tehtava[] t = mapper.readValue(tehtavatPolku.toFile(), Tehtava[].class);

    kategoriat.setAll(k);
    // HIGHLIGHT_GREEN_BEGIN
    for (Tehtava tehtava : t) {
        Kategoria jsonistaLuettuKategoria = tehtava.getKategoria();
        tehtava.setKategoria(asetaKategoriaViite(jsonistaLuettuKategoria.getNimi()));
    }
    // HIGHLIGHT_GREEN_END
    tehtavat.setAll(t);

} catch (JacksonException e) {
    e.printStackTrace();
}

// Jos tehtävällä ei ole kategoriaa, palautetaan uusi kategoria, 
// joka on vain tyhjä merkkijono.
// HIGHLIGHT_GREEN_BEGIN
private static final Kategoria TYHJA_KATEGORIA = new Kategoria("");

private Kategoria asetaKategoriaViite(String nimi) {
    for (Kategoria ehdokas : kategoriat) {
        if (ehdokas.getNimi().equals(nimi)) {
            return ehdokas;
        }
    }
    return TYHJA_KATEGORIA;
// HIGHLIGHT_GREEN_END
    /* Tai stream-tyyliin:
     * return kategoriat.stream()
     * .filter(ehdokas -> ehdokas.getNimi().equals(kategoria.getNimi()))
     * .findFirst()
     * .orElse(TYHJA_KATEGORIA); 
     */
// HIGHLIGHT_GREEN_BEGIN
}
// HIGHLIGHT_GREEN_END

Nyt kategoriatieto kyllä päivittyy tehtäviin, mutta muutos ei näy heti TableView-oliossa, koska kategoria-sarake kuuntelee vain kategorian viitteen muutoksia.

Ensimmäinen ajatus voisi olla, että lisätään tehtavat-listan ekstraktoriin nimiProperty()-kuuntelija (tehtava.kategoriaProperty().get().nimiProperty()). Valitettavasti tämä ei toimi, koska kategoriaProperty()-kuuntelee kategorian viitteen muutoksia, eikä nimen muuttaminen tuota uutta kategoria-oliota.

Ratkaisu on käyttää flatMap-metodia, joka mahdollistaa sisäkkäisten property-olioiden kuuntelemisen. Lisää oheinen rivi initialize()-metodiin.

TableColumn<Tehtava, String> otsikkoColumn = new TableColumn<>("Otsikko");
otsikkoColumn.setCellValueFactory(cellData -> cellData.getValue().otsikkoProperty());
TableColumn<Tehtava, String> kategoriaColumn = new TableColumn<>("Kategoria");
// HIGHLIGHT_GREEN_BEGIN
kategoriaColumn.setCellValueFactory(
            cellData -> cellData.getValue().kategoriaProperty().flatMap(kategoria -> kategoria.nimiProperty()));
// HIGHLIGHT_GREEN_END
// HIGHLIGHT_RED_BEGIN
kategoriaColumn.setCellValueFactory(cellData -> cellData.getValue().kategoriaProperty().asString());
// HIGHLIGHT_RED_END
tehtavatTableView.getColumns().addAll(otsikkoColumn, kategoriaColumn);

Ilman flatMap-metodia meillä on kaksi erillistä property-tasoa sisäkkäin:

kategoriaProperty()  →  ObjectProperty<Kategoria>
                              ↓
                        nimiProperty()  →  StringProperty

Kumpaakin näistä tasoista halutaan kuunnella (kategorian muutos, kategorian nimen muutos), koska haluamme näyttää koko ajan TableView-sarakkeessa ajantasaisen kategorian nimen.

flatMap yhdistää nämä kaksi tasoa yhdeksi ObservableValue<String>-olioksi, joka reagoi molemmilla tasoilla tapahtuviin muutoksiin. Tätä yhdistämistä kutsutaan "litistämiseksi" (flatten), koska kaksi sisäkkäistä kerrosta muuttuu yhdeksi tasaiseksi kerrokseksi.

Nimi flatMap koostuu kahdesta osasta:

  • Map — muuntaa ulomman propertyn arvon (Kategoria) sisemmäksi propertyksi annetulla funktiolla (kategoria -> kategoria.nimiProperty()).
  • Flat — litistää tuloksen niin, ettei synny "property propertyn sisällä" -rakennetta, vaan yksi tasainen ObservableValue.

Sama nimeämiskäytäntö esiintyy myös esimerkiksi Stream.flatMap- ja Optional.flatMap-metodeissa.

Käytännössä flatMap toimii tässä seuraavasti:

  1. Se kuuntelee ulompaa propertyä (kategoriaProperty()).
  2. Kun ulomman propertyn arvo on olemassa, se kutsuu annettua funktiota (kategoria -> kategoria.nimiProperty()) ja alkaa kuunnella palautettua sisempää propertyä.
  3. Jos ulompi arvo vaihtuu, flatMap lopettaa vanhan sisemmän propertyn kuuntelun ja alkaa kuunnella uuden arvon sisempää propertyä.
  4. Tuloksena on yksi ObservableValue<String>, joka päivittyy aina kun kategorian nimi muuttuu tai kun koko kategoriaviite vaihtuu.