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

Tehtävien lukeminen ja kirjoittaminen tiedostoon

Sovelluksemme alkaa olla lähellä valmis toiminnallisesti. Toteutetaan vielä kaksi viimeistä toiminnallisuutta:

  • Tehtävät tallennetaan tiedostoon, jotta ne säilyvät sovelluksen sulkemisen jälkeen
  • Tehtävät haetaan tiedostosta sovelluksen käynnistyessä

Osassa 6.5 opimme, miten olioita voi tallentaa tiedostoihin käyttäen JSON-tiedostomuotoa. Suunnitellaan hieman tiedostomuotoa. Tällä hetkellä yksittäinen tehtävä voitaisiin mallintaa kahdella attribuutilla: tehtävän teksti merkkijonona sekä tieto, onko tehtävä tehty boolean-arvona. Toisin sanoen, yksittäistä tehtävää voidaan mallintaa JSON-oliona:

{
    "teksti": "Opiskele ohjelmointia",
    "tehty": true
}

Koska tehtäviä on monta, tallennetaan kaikki tehtävät yhteen listaan, jolloin JSON-tiedoston muoto olisi seuraavanlainen:

[
    {
        "teksti": "Opiskele ohjelmointia",
        "tehty": true
    },
    {
        "teksti": "Mene kouluun",
        "tehty": false
    }
]

Tallentamisen valmistelu

Lisää alkuun riippuvuus Jackson-kirjastoon osan 6.5 ohjeen mukaisesti.

Teemme seuraavaksi luokan Tehtava, joka mallintaa yllä olevaa yksittäistä tehtävää. Lisäämme luokkaan attribuutit teksti ja tehty sekä tarvittavat saantimetodit. Teemme lisäksi rakentajan, joka asettaa attribuuttien arvot:

public class Tehtava {
    @SuppressWarnings("FieldMayBeFinal")
    private String teksti;
    @SuppressWarnings("FieldMayBeFinal")
    private boolean tehty;

    @SuppressWarnings("unused")
    public Tehtava() { /* Jätä tyhjäksi tai tee oletustoteutus */ }

    public Tehtava(String teksti, boolean tehty) { /* Lisää toteutus */ }

    public boolean getTehty() { /* Lisää toteutus */ }

    public String getTeksti() { /* Lisää toteutus */ }
}

Lisää järkevä toteutus itse. Jätämme set-asetusmetodit toistaiseksi lisäämättä, koska käytämme luokkaa toistaiseksi vain tehtävien lataamiseen ja tallentamiseen. Emme kuitenkaan merkitse attribuutteja final-määreellä, jotta Jackson-kirjasto osaa asettaa arvoja attribuutteihin. Lisäämme vielä oletusmuodostajan, jota Jackson käyttää olioiden alustamiseen.

Valinnaista lisätietoa: Tehtävä on tietue

Jos luokan attribuutteja ei ole tarkoitettu muokattavaksi (eli kaikki attribuutit ovat final), luokka voidaan kirjoittaa tiiviimmässä muodossa käyttäen Javan tietuesyntaksia:

record Tehtava(String teksti, boolean tehty) {}

Tietuesyntaksia käydään tarkemmin läpi luvussa 6.5.

Materiaalin seuraavassa osassa tehtäväolioita käytetään suoraan käyttöliittymässä, jolloin lisäämme tarvittavat set-asetusmetodit. Tästä syystä toteutamme tehtävät tavallisena luokkana.

Tehtävien tallentaminen

Aloitetaan tehtävien tallentamisella. Aivan aluksi meidän pitäisi saada tuotettua lista tehtävistä Tehtava-olioina. Tällä hetkellä mallinnamme tehtävät suoraan CheckBox-komponentilla. Niinpä meidän täytyy muuntaa VBox-säiliössä olevat valintaruudut listaksi tehtävistä. Listojen muuntaminen onnistuu helposti muun muassa striimeillä (ks. luku 6.2). Lisätään lisaaTehtava-metodin loppuun koodi, joka hakee alkuun kaikki tekemättömät tehtävät:

void lisaaTehtava() {
    //...metodin alku piilotettu
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
    });
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();

    List<Tehtava> tekemattomatList = tekemattomat.getChildren().stream()
            .map(n -> (CheckBox) n)
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();
}

Käytämme tässä striimiä, joka koostuu kahdesta map-muunnoksesta ja toList()-kerääjästä. Huomaa, että VBox-säiliön lapsikomponentit ovat tyyppiä Node – JavaFX:ssä kaikki visuaaliset komponentit, myös CheckBox, periytyvät Node-luokasta. Tässä tapauksessa tiedämme varmasti, että tehtyjen ja tekemättömien tehtävien lista sisältää ainoastaan CheckBox-komponentteja. Tästä syystä ensimmäinen map-muunnos muuntaa kaikki säiliössä olevat oliot CheckBox-tyypiksi; muunnos ei tässä tapauksessa tuota virheitä.

Toinen map-muunnos muuntaa CheckBox-oliot Tehtava-olioiksi. CheckBox-luokan getText()-metodi palauttaa valintaruudussa näkyvän tekstin (ks. JavaDoc) eli tehtävän tekstin ja isSelected() palauttaa valintaruudun tilan (ks. JavaDoc), eli onko tehtävä tehty tai ei. Lopuksi toList() kerää kaikki tehtävät listaan.

Tekemättömien lista perinteisellä silmukalla

Tekemättömien listan voisi toki muodostaa myös perinteisellä silmukkarakenteella. Alla vertailun vuoksi sama koodi for-each-silmukkana.

List<Tehtava> tekemattomatList = new ArrayList<>();
for (Node node : tekemattomat.getChildren()) {
    CheckBox c = (CheckBox) node;
    String tekstiC = c.getText();
    boolean tehtyC = c.isSelected();
    Tehtava tehtava = new Tehtava(tekstiC, tehtyC);
    tekemattomatList.add(tehtava);
}
Bonus: Mitä jos säiliössä on muitakin kuin CheckBox-komponentteja?

Tässä tapauksessa jätimme tyyppitarkistuksen pois, koska tiesimme, että VBox-säiliöt sisältävät vain valintaruutuja. Jos sen sijaan VBox sisältäisi erilaisia komponentteja, ja haluaisimme löytää vain tietyntyyppiset komponentit, voisimme tehdä tyyppitarkistuksen. Tämä tehdään instanceof-operaattorilla, jonka perään voidaan kirjoittaa muuttujan nimi (tässä c), johon tarkistettu objekti sijoitetaan uuden tyypin kera:

List<Tehtava> tekemattomatList = new ArrayList<>();

for (Node node : tekemattomat.getChildren()) {
    // Periaatteessa kaikki lapsikomponentit pitäisi 
    // olla CheckBox-komponentteja, mutta varmuuden vuoksi tarkistetaan 
    // tämä kuitenkin. 
    if (!(node instanceof CheckBox c)) {
        continue;
    }
    // Jos pääsemme tänne, niin node on CheckBox, 
    // ja voimme turvallisesti käyttää c-muuttujaa
    // CheckBox-tyyppisenä.

    String tekstiC = c.getText();
    boolean tehtyC = c.isSelected();
    Tehtava tehtava = new Tehtava(tekstiC, tehtyC);
    tekemattomatList.add(tehtava);
}

Mainittakoon, että vastaavat tyyppitarkistukset on myös mahdollista toteuttaa striimeillä käyttäen mapMulti-metodia (JavaDoc), jonka avulla voi valikoivasti poistaa tai lisätä alkioita striimiin:

tekemattomat.getChildren().stream()
            .<CheckBox>mapMulti((n, consumer) -> {
                // Tarkistetaan, onko komponentti tyyppiä CheckBox
                if (n instanceof CheckBox c) {
                    // Jos on, c-muuttuja sisältää tyyppimuunnetun komponentin
                    // consumer.accept lisää komponentin striimiin
                    consumer.accept(c);
                }
            })
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();

Kokeillaan nyt tallentaa ainakin tekemättömiä tehtäviä tiedostoon käyttäen Jackson-kirjastoa. Lisäämme lisaaTehtava()-metodin loppuun Jackson-kirjaston ObjectMapper-olion ja käytämme sen writeValue()-metodia:

private void lisaaTehtava() {
    //...metodin alku piilotettu
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
    });
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();

    List<Tehtava> tekemattomatList = tekemattomat.getChildren().stream()
            .map(n -> (CheckBox) n)
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();
    // HIGHLIGHT_GREEN_BEGIN
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(Path.of("tehtavat.json"), tekemattomatList);
    // HIGHLIGHT_GREEN_END
}

Kokeile nyt ajaa sovellus ja lisätä pari uutta tehtävää. Joka kerta, kun lisäät uuden tehtävän, tehtävät tallentuvat tehtavat.json-tiedostoon. Näet tiedoston IDEAssa sen jälkeen, kun suljet sovelluksen.

huomautus

Tässä välissä on hyvä lisätä .gitignore-tiedostoon rivi tehtavat.json, koska tuota tiedostoa ei haluta versionhallintaan. Tallennuksen jälkeen lisää .gitignore-komento Gitiin ja tee uusi commit:

git add .gitignore
git commit -m "Lisätty tehtavat.json .gitignoreen"

Jos lisäsit jo tehtavat.json-tiedoston versionhallintaan, poista se ensin komennolla git rm --cached tehtavat.json, tee commit, ja muuta vasta sen jälkeen .gitignore-tiedostoa.

Luonnollisesti myös tehdyt tehtävät tulee tallentaa. Jotta koodia ei tarvitse toistaa, tehdään yllä olevasta koodista uusi metodi, haeTehtavat(VBox vbox), joka palauttaa VBox-parametrin lapsikomponenttien perusteella listan Tehtava-olioita. Sen jälkeen kerätään sekä tehdyt että tekemättömät tehtävät samaan listaan:

private List<Tehtava> haeTehtavat(VBox sailio) {
    return sailio.getChildren().stream()
            .map(n -> (CheckBox) n)
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();
}

private void lisaaTehtava() {
    //...metodin alku piilotettu
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
    });
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();

    // HIGHLIGHT_RED_BEGIN
    List<Tehtava> tekemattomatList = tekemattomat.getChildren().stream()
            .map(n -> (CheckBox) n)
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();
    // HIGHLIGHT_RED_END
    // HIGHLIGHT_GREEN_BEGIN
    List<Tehtava> tehtavat = new ArrayList<>();
    tehtavat.addAll(haeTehtavat(tekemattomat));
    tehtavat.addAll(haeTehtavat(tehdyt));
    // HIGHLIGHT_GREEN_END
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(Path.of("tehtavat.json"), tehtavat);
}

Kokeile ajaa sovellus ja tutki, miten tehtavat.json-tiedosto muuttuu. Nyt sekä tekemättömät että tehdyt tehtävät tallentuvat aina, kun lisäät uuden tehtävän.

Huomaamme kuitenkin, että tehtävien tila ei päivity tiedostoon, kun tehtävä merkitään tehdyksi tai tekemättömäksi. Tämä kyllä onnistuu, jos kirjoitetaan sama tallennuskoodi uudestaan CheckBox-komponentin setOnAction-tapahtumankäsittelijään, mutta koodin toistaminen ei ole hyvä ratkaisu.

Mietitäänpä siis hetki. Tallentaminen on selkeästi oma kokonaisuutensa, joka ei liity suoraan siihen, miten tehtävät luodaan, näytetään tai siirretään. Refaktoroidaan tallennus siis omaksi metodiksi:

private void tallenna() {
    List<Tehtava> kaikkiTehtavat = new ArrayList<>();
    kaikkiTehtavat.addAll(haeTehtavat(tekemattomat));
    kaikkiTehtavat.addAll(haeTehtavat(tehdyt));
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(Path.of("tehtavat.json"), kaikkiTehtavat);
}

Nyt voimme korvata lisaaTehtava()-metodin lopussa olevan koodin pelkällä tallenna()-metodin kutsulla. Lisäksi voimme kutsua tallenna()-metodia valintaruudun omassa onAction-tapahtumakäsittelijässä, jolloin tallennus tehdään myös aina, kun jonkin valintaruudun tila muuttuu:

private void lisaaTehtava() {
    // metodin alku piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        // tapahtumankäsittelijän alku piilotettu...
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        // HIGHLIGHT_GREEN_BEGIN
        tallenna();
        // HIGHLIGHT_GREEN_END
    });
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
    // HIGHLIGHT_RED_BEGIN
    List<Tehtava> tehtavat = new ArrayList<>();
    tehtavat.addAll(haeTehtavat(tekemattomat));
    tehtavat.addAll(haeTehtavat(tehdyt));
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(Path.of("tehtavat.json"), tehtavat);
    // HIGHLIGHT_RED_END
    // HIGHLIGHT_GREEN_BEGIN
    tallenna();
    // HIGHLIGHT_GREEN_END
}

Kokeile sovellusta uudestaan. Nyt tehtävien lisääminen sekä tehtävän tilan muuttaminen tallentaa tehtävät tehtavat.json-tiedostoon.

Huomaamme tässä vaiheessa, että lisaaTehtava()-metodi on myös paisunut hieman liian isoksi. Uuden CheckBox-valintaruudun luominen on selkeästi oma kokonaisuutensa, joten se on järkevää erottaa omaksi metodiksi. Tehdään uusi luoCheckBox()-metodi, joka ottaa parametrina valintaruutuun lisättävä teksti ja joka palauttaa luodun valintaruutuolion. Siirretään metodiin nykyinen CheckBox-oliota luova koodi sinne:

private CheckBox luoCheckBox(String teksti) {
    CheckBox tehtava = new CheckBox(teksti);
    // metodin runko piilotettu...
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        tallenna();
    });
    return tehtava;
}

private void lisaaTehtava() {
    // metodin alku piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    // HIGHLIGHT_RED_BEGIN
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        tallenna();
    });
    // HIGHLIGHT_RED_END
    // HIGHLIGHT_YELLOW_BEGIN
    tekemattomat.getChildren().add(luoCheckBox(teksti));
    // HIGHLIGHT_YELLOW_END
    // metodin loppu piilotettu...
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
    tallenna();
}

Tehtävien lataaminen

Nyt olemme saaneet tehtävät tallennettua, mutta ne pitäisi myös lukea ohjelman käynnistyessä. Tehdään sitä varten saman tien metodi lataa(). Käytetään tiedoston lukemiseen tapaa, jonka opimme luvussa 6.5, eli käytetään ObjectMapper-luokan readValue()-metodia. Kääritään koko komeus try-catch-lohkon sisään, jotta mahdolliset poikkeukset saadaan kiinni:

private void lataa() {
    Path path = Path.of("tehtavat.json");
    if (Files.notExists(path)) {
        return;
    }
    try {
        ObjectMapper mapper = new ObjectMapper();
        List<Tehtava> kaikkiTehtavat = mapper.readValue(path.toFile(), new TypeReference<>() {});
        kaikkiTehtavat.forEach(tehtava -> {
            CheckBox checkbox = luoCheckBox(tehtava.getTeksti());
            if (tehtava.getTehty()) {
                tehdyt.getChildren().add(checkbox);
            } else {
                tekemattomat.getChildren().add(checkbox);
            }
        });
    } catch (JacksonException je) {
        IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
    }
}

Toistaiseksi virheen sattuessa tulostamme vain virheen konsoliin. Käsittelemme myöhemmässä osassa, miten virhetilanne voitaisiin ilmoittaa käyttäjälle tarkemmin. Lisätään metodin kutsu initialize()-metodin alkuun:

public void initialize(URL url, ResourceBundle resourceBundle) {
    lataa();
    // metodin loppu piilotettu...
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

Kokeile ajaa sovellus. Nyt tehtävät pysyvät tallessa, vaikka ohjelma suljettaisiin ja käynnistettäisiin uudelleen:

Huomaamme, että lukemisessa, tai oikeastaan valintaruutujen luomisessa, on pieni ongelma. Kun ohjelma käynnistetään uudelleen, tehtyjen tehtävien listassa valintaruudut eivät ole enää valittuna. Tämä johtuu siitä, että luoCheckBox()-metodi ei ota huomioon sitä, onko tehtävä tehty vai ei. Korjataan tämä lisäämällä metodille parametri, joka kertoo, onko valintaruutu luomisen yhteydessä valittu tai ei:

private CheckBox luoCheckBox(String teksti, boolean valittu) {
    CheckBox tehtava = new CheckBox(teksti);
    // HIGHLIGHT_GREEN_BEGIN
    tehtava.setSelected(valittu);
    // HIGHLIGHT_GREEN_END
    // metodin loppuosa piilotettu...
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        tallenna();
    });
    return tehtava;
}

Nyt voimme asettaa valintaruudun oikean "tehty/ei-tehty" -tilan latauksen yhteydessä:

private void lataa() {
    // metodin alkuosa piilotettu...
    Path path = Path.of("tehtavat.json");
    if (Files.notExists(path)) {
        return;
    }
    try {
        ObjectMapper mapper = new ObjectMapper();
        List<Tehtava> kaikkiTehtavat = mapper.readValue(path.toFile(), new TypeReference<>() {});
        kaikkiTehtavat.forEach(tehtava -> {
            // HIGHLIGHT_YELLOW_BEGIN
            CheckBox checkbox = luoCheckBox(tehtava.getTeksti(), tehtava.getTehty());
            // HIGHLIGHT_YELLOW_END
    // metodin loppuosa piilotettu...
            if (tehtava.getTehty()) {
                tehdyt.getChildren().add(checkbox);
            } else {
                tekemattomat.getChildren().add(checkbox);
            }
        });
    } catch (JacksonException je) {
        IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
    }
}

Samalla korjaamme lisaaTehtava()-metodissa oleva luoCheckBox()-kutsu. Koska uusi tehtävä lisätään aina tekemättömäksi, annetaan parametrin arvoksi false:

private void lisaaTehtava() {
    // metodin alkuosa piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    // HIGHLIGHT_YELLOW_BEGIN
    tekemattomat.getChildren().add(luoCheckBox(teksti, false));
    // HIGHLIGHT_YELLOW_END
    // metodin loppuosa piilotettu...
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
    tallenna();
}

Kokeile tallentaa ja ajaa sovellus uudelleen. Nyt sovelluksen käynnistyessä tehtyjen tehtävien valintaruudut merkataan "tehty"-tilaan oikein:

Tehtävä 7.5: Todo-sovellus, vaihe 5. 1 p.

Palauta tässä osan 7.5 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Tallenna tehtävät JSON-tiedostoon aina, kun käyttäjä lisää tehtävän tai muuttaa tehtävän tilaa.

  • Lue tehtävät JSON-tiedostosta ohjelman käynnistyessä (jos tiedosto on olemassa). JSON-tiedoston tulisi näyttää suunnilleen seuraavalta (pois lukien luettavuutta varten lisätyt rivinvaihdot ja sisennykset):

    [
      {
        "tehtava": "Osta maitoa",
        "tehty": false
      },
      {
        "tehtava": "Vie roskat",
        "tehty": true
      }
    ]
    

Kun vaihe on valmis, muista tehdä git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä