Spis treści

Prosta aplikacja w javafx - statystyki Covid. Tutorial

Zdarzało mi się wiele razy, że kiedy chciałam stworzyć małą, prostą aplikację (albo prototyp jakiegoś rozwiązania), to dość szybko natykałam się na nieprzyjemny etap “bootstrapingu”, czyli przygotowania odpowiednich narzędzi, konfigurowania systemu budowania, zapewnienia sobie wszystkich zasobów potrzebnych do szybkiego rozpoczęcia pracy.

Kroki potrzebne do rozpoczęcia projektu w Javafx są trochę inne niż te, które wykonałabym, gdybym tworzyła projekt w Clojure czy używała Reacta, choć w zasadzie sprowadzają się do kilku najważniejszych aspektów.

W tym wpisie opiszę krok po kroku jak stworzyć prostą aplikację desktopową z wykorzystaniem biblioteki openjfx.

Najważniejsze etapy rozpoczynania projektu

Przestrzeń robocza

W moim systemie plików utrzymuję specjalny katalog dev, do którego wrzucam różne “deweloperskie” projekty, prototypy, testy bibliotek, szybkie skrypty its. dev jest podzielony na podkatalogi nazwane według używanego języka programowania (ten podział nie zawsze ma sens, ale zwykle się sprawdza). Nowy projekt w javafx będzie miał swój “dom” w ~/dev/java:

1
2
3
cd ~/dev/java
mkdir covidstat
cd covidstat

Utworzenie repozytorium Git

Kolejnym krokiem jest utworzenie repozytorium Git-a. Pierwszy komit zawiera jedynie plik README.md, który będzie pełnił rolę podręcznego notatnika na pomysły, odnośniki, spis decyzji projektowych itd.

1
2
3
4
5
touch README.md
git init
echo "Display some covid-related data in javafx" > README.md
git add README.md
git commit -m "Initialize repository with README.md file"

Utworzenie podstawowej struktury projektu javafx

W katalogy covidstat utworzę plik pom.xml zawierający zależności potrzebne do rozpoczęcia pracy z biblioteką javafx. Nie będę tworzyć go ręcznie - wykorzystam archetyp wspomniany na stronie openjfx.io

1
2
3
4
5
6
7
8
mvn archetype:generate \
-DarchetypeGroupId=org.openjfx \
-DarchetypeArtifactId=javafx-archetype-simple \
-DarchetypeVersion=0.0.3 \
-DgroupId=com.kamilachyla \
-DartifactId=covidstat \
-Dversion=1.0.0 \
-Djavafx-version=15.0.1

Ten archetyp wygeneruje następujący pom.xml:

 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
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.kamilachyla</groupId>
    <artifactId>covidstat</artifactId>
    <version>1.0.0</version>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>13</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.0</version>
                <configuration>
                    <release>11</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.3</version>
                <configuration>
                    <mainClass>com.kamilachyla.App</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Dokumentacja - ważne odnośniki

Ważnym zasobem w pracy z projektem będzie strona openjfx, z której jest dostęp do:

Warto mieć tę dokumentację otwartą w przeglądarce.

Test konfiguracji przy użyciu Mavena

Projekt utworzony przy pomocy archetypu można uruchomić poleceniem:

1
mvn javafx:run

Przykładowa aplikacja w archetypie wygląda tak:

javafx-from-archetype.png

Jednak po zmianie pom.xml tak, aby:

  • używał javy 15
  • używał nowszej wersji openjdk (17-ea+6)

po uruchomieniu otrzymam okienko zawierające dane:

javafx-from-newer-archetype.png

Dostęp do danych o COVID

Odnośniki do publicznego API i jego dokumentacji

Publicznie dostępne API znajduje się na stronie covid19api.com. Dokumentacja jest dostępna tutaj. Przykładowa aplikacja będzie pobierać dane na temat Polski.

Jaki jest “slug” dla Polski?

Informacje na temat danego kraju wymagają znajomości jego specjalnej nazwy, tzw. “sluga”, który będzie występował w URI żądań dotyczących tego kraju. Sprawdzę, jaki jest slug Polski - wykorzystam API GET Countries (tutaj dokumentacja).

Wyjście cURL-a przepuszczę przez ripgrep (opcja -C5 pokazuje 5 wierszy kontekstu) i poszukam sluga dla Polski:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
karma@tpd~/dev/java/covidstat  (master %) λ  curl  --location --request GET 'https://api.covid19api.com/countries' | rg -C5 Poland
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0        "Country": "New Zealand",
        "Slug": "new-zealand",
        "ISO2": "NZ"
    },
    {
        "Country": "Poland",
        "Slug": "poland",
        "ISO2": "PL"
    },
    {
        "Country": "Marshall Islands",
100 24258  100 24258    0     0  62200      0 --:--:-- --:--:-- --:--:-- 62040

Całkowita liczba przypadków: confirmed, active, deaths, recovered

API GET By Country Total pozwala zdobyć dane o wszystkich przypadkach w następującym formacie (poniżej ściągam dane dla Polski, zapisuję w pliku i wyświetlam jego ostatnie 15 wierszy, po uprzednim sformatowaniu):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
curl https://api.covid19api.com/total/country/poland > all-by-country.json
python3 -m json.tool all-by-country.json | tail -n 15

    {
        "Country": "Poland",
        "CountryCode": "",
        "Province": "",
        "City": "",
        "CityCode": "",
        "Lat": "0",
        "Lon": "0",
        "Confirmed": 2471617,
        "Deaths": 55703,
        "Recovered": 2054697,
        "Active": 361217,
        "Date": "2021-04-07T00:00:00Z"
    }
]

Aplikacja

Co ma robić przykładowa aplikacja? Oto jej zakres:

  • ściąga dane o dostępnych krajach
  • umożliwia wybranie jednego kraju
  • ściąga dane na temat tego kraju
  • wyświetla informacje na temat kraju (np. ostatnie dane, wykres podsumowanie itp.)

Serwis do pobierania danych

Pobieraniem danych zajmie się serwis (stworzę dwie implementacje - jedna będzie dostarczać dane z dysku, druga będzie rzeczywiście korzystać z sieci).

API serwisu

Serwis będzie posiadał dwie metody:

  • Stream<Country> getCountries(); - do pobierania listy krajów
  • Stream<Case> getCases(Country data); - do pobierania danych o przypadkach dla danego kraju
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.kamilachyla.service;

import java.util.stream.Stream;
import java.time.LocalDate;

public interface CovidService {

  Stream<Country> getCountries();
  Stream<Case> getCases(Country data);
}

record Country(String name, String slug, String iso2){}

record Case(LocalDate date, int confirmed, int deaths, int recovered, int active){}

Użycie rekordów oznacza, że w pom.xml należy dodać --enable-preview:

  • jako opcję kompilatora:
1
2
3
4
5
6
7
8
9
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.8.0</version>
    <configuration>
        <release>15</release>
        <compilerArgs><arg>--enable-preview</arg></compilerArgs>
    </configuration>
</plugin>
  • oraz w pluginie javafx:
1
2
3
4
5
6
7
8
9
  <plugin>
      <groupId>org.openjfx</groupId>
      <artifactId>javafx-maven-plugin</artifactId>
      <version>0.0.5</version>
      <configuration>
          <mainClass>com.kamilachyla.App</mainClass>
          <commandlineArgs>--enable-preview</commandlineArgs>
      </configuration>
  </plugin>

Prosta implementacja serwisu

W prostej implementacji serwis zwraca statyczne dane. Ten serwis posłuży do zaprototypowania GUI.

Oto jego implementacja:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public final class HandmadeService implements CovidService {

  public Stream<Country> getCountries(){
    return Stream.of(
        new Country("Poland", "poland", "PL"),
        new Country("Germany", "germany", "DE")
        );
  }

  public Stream<Case> getCases(Country data) {
    if (data.name().equals("Poland")) {
      return Stream.of(
              new Case(LocalDate.parse("2021-04-07"), 1, 2, 3, 4),
              new Case(LocalDate.parse("2021-04-08"), 10, 20, 30, 40)
              );
    } else {
      return Stream.of(
              new Case(LocalDate.parse("2021-04-07"), 100, 200, 300, 400),
              new Case(LocalDate.parse("2021-04-08"), 101, 201, 301, 401)
              );
    }
  }
}

Sposób działania

Czas odpaić Intellij. Będzie łatwiej :) Plan jest taki:

  • po uruchomieniu aplikacji startujemy w tle task, który pobierze dane o nazwach krajów (getCountries()) z serwisu
  • ładujemy dane o krajach do listy
  • po wyborze kraju z listy startujemy w tle inny task, który pobierze dane o danym kraju
  • task aktualizuje informacje wyświetlaną w GUI - pokazują się statystyki z ostatniego dnia

Architektura

Użyjemy standardowej (i polecanej dla aplikacji GUI w javafx) architektury MVVM - Model-View-ViewModel, dzięki której uzyskamy separację różnych fragmentów programu:

Model

Model będzie przechowywał dane domenowe i nie będzie w żaden sposób zależał od javafx. Do danych modelu należą instancje klas Country i Case. Funkcję modelu będzie spełniac prosta klasa CasesPerCountryModel, która będzie działała jako cache dla obiektów domenowych. *

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class CasesPerCountryModel {
    private final Map<Country, List<Case>> countryCasesMap = new HashMap<>();
    private final List<Country> countries = new ArrayList<>();

    public List<Case> getCases(Country country) {
        return countryCasesMap.getOrDefault(country, Collections.emptyList());
    }

    public void addCases(Country country, List<Case> cases) {
        countryCasesMap.put(country, cases);
    }

    public void setCountries(List<Country> countries) {
        this.countries.clear();
        this.countries.addAll(countries);
    }

    public List<Country> getCountries() {
        return List.copyOf(countries);
    }

Model oferuje oddzielnie operacje dostępu do istniejących danych (READ) oraz operacje modyfikujące (ustawiające) dane (WRITE). W przypadku braku danych zwraca puste kolekcje.

ViewModel

ViewModel to tzw. model widoku, czyli klasa zawierająca dane potrzebne do wyświetlenia w GUI stanu programu. Udostępnia zestaw obiektów Property (do których podłączone zostaną kontrolki widoku) ualtualnianych w wyniku akcji użytkownika (bądź w rezultacie wywołań zwrotnych po zakończeniu operacji serwisów - patrz niżej).

Do jego zadań należy również implementacja wywołań zwrotnych (callbacków) a więc wykonywanie całej logiki programu (być może oddelegowanej do odrębnych wątków, aby nie blokować głównego wątku gui) i aktualizacja swojego stanu. Co warto oddelegować do innego wątku? Wszystkie operacje związane ze ściąganiem danych z internetu. Dlatego też wszystkie (dwie!) operacje, które wykonuje przekazana do ViewModel impelemntacja CovidService będą się odbywać w osobnym wątku: zaimplementuję dwa Workery (podklasy Service):

  • CountriesProviderService ściągnie dane o nazwach krajów (ten serwis jest uruchamiany tylko raz, w konstruktorze modelu widoku)
  • CovidUpdateService będzie ściągał - na żądanie - dane o konkretnum kraju (uruchamiany jest wówczas, gdy został wybrany inny kraj)

Jeśli uruchomione przez te workery Taski zakończą się powodzeniem, zaktualizuję model widoku w callbacku task.setOnSucceeded(), który wykonywany jest już w JavaFX ApplicationThread.

ViewModel jest reprezentowany przez klasę CovidViewModel.

  • w jej konstruktorze rejestrowane są handlery zmian propertiesów, handlery serwisów oraz odpalany jest serwis CountriesProviderService.
  • metoda update(country) wywoływana po zmianie wyboru elementu na liście krajów ma dzięki temu bardzo prostą implementację:
1
2
3
    public void update(Country nv) {
        selectedCountry.set(nv);
    }

View

View to kontrolki javafx:

  • lista (ListView) krajów, która po wybraniu kraju deleguje odświeżenie danych do ViewModel (ChangeListener modelu selekcji wywołuje viewModel.update(country))
  • labelki (Label) wyświetlające statystyki dla wybranego kraju

Po utworzeniu są one “bindowane” z właściwościami ViewModel, a więc automatycznie pokażą zmiany wartości obiektów Property z ViewModel. Ponieważ cała logika programu wykonuje się po ustawieniu wybranego kraju na liście, to w konstruktorze widoku, na samym końcu, “wybieram” przy pomocy API ListView pierwszy element. Muszę to zrobić w wątki JavaFX Application Thread, więc wykorzystuję javafx-owy odpowiednik SwingUtilities.invokeLater():

1
2
3
4
5
6
7
8
public CovidView(CovidViewModel viewModel) {
    this.viewModel = viewModel;
    createView();
    bindViewModel();
    Platform.runLater(() -> {
        listView.getSelectionModel().selectFirst();
    });
}

Aplikacja główna

W aplikacji głównej tworzę model widoku, przekazując do niego pożądaną implementaję CovidService (obecnie tworzę HandmadeService który zwraca stałe wartości). Model widoku jest argumentem konstruktora widoku.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class App extends Application {

    @Override
    public void start(Stage stage) {
        var viewModel = new CovidViewModel(new HandmadeService());
        var scene = new Scene(new CovidView(viewModel), 640, 480);
        stage.setScene(scene);
        stage.setTitle("Covid statistics per country");
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }

}

Screenshot (statystyki dla ostatniego dnia)

/covid-app-window.png

Wykres statystyk

Dodanie wykresu nie będzie trudne. Javafx posiada już gotowe klasy do tworzenia różnego rodzaju wykresów (patrz pakiet javafx.scene.chart).

W naszym przypadku potrzebujemy wykresu łączącego linią punkty danych - użyjemy XYChart.

Stworzę prosty widok (klasa ChartView), który w parametrze konstruktora otrzyma property SimpleListProperty<Case>.

Property to tworzone jest już w modelu widoku. Muszę tylko udostępnić je klasie widoku. Przechowuje ono ocase-y wybranego kraju, więc śledząc zmany tego property (tj. rejestrując ChartView jako słuchacza zmian) mogę łatwo aktualizować serie danych. Odrobina magii sprawi, że zmiana danych spowoduje automatyczne zaktualizowanie wykresów.

Serie danych

Obiekt Case przechowuje kilka różnych statystyk, a lista obiektów Case - która zmienia się po wybraniu innego kraju - pozwala na zaktualizowanie serii danych na wykresie.

Utworzę wykres od razu z czterema seriami danych - będą one początkowo puste. Serie będę przechowywać w mapie, której kluczami będą wartości typu enumeracyjmego CASETYPE.

Wartości CASETYPE dodatkowo potrafią “wyjąć” wartość odpowiedniego pola z obiektu Case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
enum CASETYPE {
    ACTIVE, DEATHS, CONFIRMED, RECOVERED;

    public Integer extract(Case c) {
        return switch (this) {
            case ACTIVE -> c.active();
            case DEATHS -> c.deaths();
            case CONFIRMED -> c.confirmed();
            case RECOVERED -> c.recovered();
        };
    }
}

Pojedyncza “dana”

Wykres XYChart składa się z punktów danych typu XYChart.Data<K, V>. W naszym przypadku:

  • typ K - dziedzina - to String (na osi X będą daty jako napisy)
  • typ V - przeciwdziedzina - to Integer.

Serie dla poszczególnych CASETYPE przechowuję w mapie oList, której wartości będące ObservableList będę aktualizować przy zmianie kraju.

1
private Map<CASETYPE, ObservableList<XYChart.Data<String, Integer>>> oLists = new HashMap<>();

Referencje do tych serii dodaję do obserwowanej listy observableSeries która jest ustawiana jako dane wykresu; dzięki temu wykres będzie się aktualizował automatycznie podczas aktualizacji serii danych w mapie oList.

Klasa ChartView

Oto cała klasa ChartView:

 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

public class ChartView extends Parent {
    private final CategoryAxis xa = new CategoryAxis();
    private final NumberAxis ya = new NumberAxis();
    private Map<CASETYPE, ObservableList<XYChart.Data<String, Integer>>> oLists = new HashMap<>();
    private final LineChart lineChart = new LineChart(xa, ya);


    public ChartView(SimpleListProperty<Case> cases) {
        super();
        xa.setLabel("Dates");
        ya.setLabel("Cases");
        lineChart.setTitle("Cases");
        ObservableList<XYChart.Series<String, Integer>> observableSeries = FXCollections.observableArrayList();
        for (CASETYPE name : CASETYPE.values()){
            ObservableList<XYChart.Data<String, Integer>> seriesList = FXCollections.observableArrayList();
            oLists.put(name, seriesList);
            XYChart.Series<String, Integer> series = new XYChart.Series<>(seriesList);
            series.setName(name.toString());
            observableSeries.add(series);
        }
        lineChart.setData(observableSeries);
        lineChart.setAnimated(false);

        cases.addListener((o, ov, nv) -> {
            setSeries(nv);
        });

        this.getChildren().add(lineChart);
    }

    public void setSeries(List<Case> cases) {
        updateSeriesWith(cases);
    }

    private void updateSeriesWith(List<Case> cases) {
        Arrays.asList(CASETYPE.values()).forEach(ct ->{
            oLists.get(ct).setAll(cases.stream()
                    .map(c -> new XYChart.Data<>(c.date().toString(), ct.extract(c)))
                    .collect(Collectors.toList()));
        });
    }


}

Dodanie ChartView do CovidView

ChartView dodaję do CovidView w metodzie createView(), przekazując odpowiedni Property z modelu widoku:

1
chartView = new ChartView(viewModel.selectedCountryCasesProperty());

Screenshot (dodany wykres)

Oto okienko aplikacji z wykresem (do HandmadeService.java dodałam kilka punktów danych, aby wykres przypominał wykres):

/covid-app-window-chart.png

Kolejne kroki

Lista todo jest całkiem spora. Co należy teraz zrobić?

  • zaimplementować CovidService tak, aby czytał dane z pliku (do zaimplementowania będzie parsowanie JSON-a); kolejny krok to użycie klasy HttpClient i łączenie z serwisem wystawiającym API covidowe
  • umożliwić przeglądanie danych z kolejnych dni (przyciski: “next”, “prev” lub jakiś DatePicker)
  • umożliwienie wyboru krajów i rysowanie nałożonych na siebie wykresów

Możliwości są nieskończone :) Tylko, jak zawsze, czasu mało… Ściąganie rzeczywistych danych zostawiam jako ćwiczenie dla czytelnika :D

Repozytorium

Kod dostępny jest na GitHubie: covidstat