Metodologie di Programmazione: Lezione 20

Riccardo Silvestri

JavaFX e Task Asincroni

Introduciamo nel nostro mini Web Browser la possibilità di scaricare tutte le immagini della pagina. Questo è un compito che può richiedere un tempo significativo e quindi per non ridurre la reattività della GUI deve essere eseguito in uno o più thread nel background. In questa lezione vedremo come si possono usare alcuni strumenti di JavaFX per gestire task asincroni che devono cooperare con un'applicazione JavaFX.

Download asincrono di immagini

Vogliamo aggiungere al nostro mini Web Browser la capacità di scaricare tutte le immagini della pagina corrente e di visualizzarle in una finestra di primo livello. Ovviamente, non vogliamo che durante il downloading delle immagini la reattività della GUI sia ridotta. Perciò il compito che consuma più tempo, cioè proprio il download delle immagini da un server remoto, lo dobbiamo eseguire in uno o più thread che lavorano nel background. Però il compito di estrarre dal Document della pagina corrente i link relativi alle immagini conviene eseguirlo nel JavaFX Thread. La ragione è che il Document ritornato dal metodo getDocument di una WebEngine può essere usato in modo affidabile solamente nel JavaFX Thread.

Per prima cosa definiamo un metodo statico che prende in input un Document e ritorna l'insieme1 degli URI2 delle immagini contenute nel Document. Un'immagine è contenuta in un Document se c'è un elemento HTML con tag img che ha un attributo src valido, cioè un indirizzo relativo o assoluto ad un'immagine. Se l'indirizzo è relativo deve essere risolto rispetto all'URI della pagina che si ottiene dal Document col metodo String getDocumentURI(), ma sotto forma di stringa.

/** Ritorna l'insieme degli URI delle immagini contenute nella pagina relativa
 * al Document dato. Eventuali URI malformati sono ignorati. Se il Document
 * non ha un URI, ritorna un insieme vuoto.
 * @param doc  il Document di una pagina web
 * @return l'insieme degli URI delle immagini della pagina */
private static Set<URI> imgURIs(Document doc) {
    Set<URI> uris = new HashSet<>();
    try {
        URI base = new URI(doc.getDocumentURI());
        NodeList ii = doc.getElementsByTagName("img");
        for (int i = 0 ; i < ii.getLength() ; i++) {
            org.w3c.dom.Node a = ii.item(i).getAttributes().getNamedItem("src");
            if (a != null)
                try {
                    uris.add(base.resolve(a.getNodeValue()));
                } catch (Exception ex) {}
        }
    } catch (Exception e) {}
    return uris;
}

Il try-catch più esterno serve a catturare l'eventuale eccezione lanciata dalla creazione del URI della pagina, base. Mentre quello più interno cattura le eventuali eccezioni lanciate dal metodo URI resolve(String str) di URI, che abbiamo usato per risolvere gli indirizzi relativi rispetto all'indirizzo della pagina. Per non appesantire troppo il codice non abbiamo aggiunto il controllo che l'indirizzo, almeno formalmente, sia ad un'immagine di un certo formato3.

Adesso occupiamoci della finestra che deve visualizzare le immagini. Conviene definire una classe annidata statica che gestisce tale finestra. In questo modo avremo maggiore libertà nel cambiare le modalità di visualizzazione e nell'aggiungere nuove funzionalità. Le operazioni fondamentali che deve gestire, oltre alla creazione della finestra e del suo scene graph, sono l'aggiunta di una nuova immagine e il rendere visibile la finestra e se è già visibile portarla in primo piano. Un layout molto semplice per contenere le immagini e visualizzarle è il FlowPane, in javafx.scene.layout, che le visualizza in una linea continua che va a capo quando raggiunge il confine. La visualizzazione è analoga a quella di un testo, con le immagini al posto delle parole. Siccome le immagini possono essere moltissime e un FlowPane non ha automaticamente le barre di scorrimento (scroll bar), conviene usare un controllo ScrollPane in javafx.scene.control, che permette di gestire la visualizzazione di un qualsiasi Node tramite un rettangolo di vista (una cosiddetta viewport) che può essere fatta scorrere con le barre di scorrimento.

/** Gestisce una finestra di primo livello che visualizza immagini che possono
 * essere aggiunte dinamicamente. */
private static class DownloadWin {
    /** Crea il gestore della finestra per le immagini. La finestra non è
     * resa visibile. */
    DownloadWin() {
        win = new Stage();             // La finestra di primo livello
        imgPane = new FlowPane();      // Il contenitore per le immagini
        ScrollPane sp = new ScrollPane(imgPane);
        sp.setFitToWidth(true);  // Per far sì che il contenitore delle
        sp.setFitToHeight(true); // immagini occupi tutto lo spazio disponibile
        win.setScene(new Scene(sp, 500, 400));
    }

    /** Aggiunge un'immagine e la visualizza
     * @param img  un'immagine */
    void add(Image img) {
        imgPane.getChildren().add(new ImageView(img));
    }

    /** Rende la finestra visibile e la porta in primo piano */
    void show() {
        win.show();           // Rende visibile la finestra se già non lo era
        win.toFront();        // Porta la finestra in primo piano
    }

    private final Stage win;
    private final FlowPane imgPane;
}

Si noti che prima di aggiungere l'immagine al imgPane dobbiamo creare un ImageView che permette di visualizzarla.

Passiamo ora a definire il task per scaricare le immagini. Siccome il task può interagire con JavaFX, conviene usare gli strumenti per la concorrenza offerti nel package javafx.concurrent. Abbiamo già incontrato l'interfaccia Worker che rappresenta un oggetto che esegue del lavoro in uno o più thread il cui stato è osservabile e disponibile nel JavaFX Thread. Il package javafx.concurrent fornisce anche due implementazioni dell'interfaccia, tramite classi astratte, Task<V> e Service<V>. La classe Task è per un compito singolo che una volta terminato non è più usabile. Mentre Service è un oggetto che può eseguire più task, usa gli oggetti Task, e può gestire anche i thread per l'esecuzione. Un Service è un po' più complicato da usare dei Task. Conviene iniziare dalla classe più semplice. Per gestire il compito di scaricare le immagini definiamo quindi una classe che estende un Task e che chiamiamo ImgTask. L'unico metodo astratto di Task che è necessario implementare è abstract V call() throws Exception. Questo è il metodo invocato per eseguire il compito. Nel nostro caso tale metodo deve scaricare le immagini e vorremmo che non appena una nuova immagine è pronta venga aggiunta alla finestra di visualizzazione. Però bisogna tenere in conto che il metodo call sarà eseguito in un thread qualsiasi mentre l'aggiunta dell'immagine alla finestra deve essere eseguita nel JavaFX Thread. Per garantire che questa operazione sia eseguita nel JavaFX Thread si può usare il metodo statico void runLater(Runnable runnable) della classe Platform in javafx.application. Tale metodo esegue il Runnable nel JavaFX Thread, ovviamente in modo asincrono. Per permettere la massima flessibilità di utilizzo della classe ImgTask, diamo la possibilità di impostare nel costruttore un'operazione generica tramite un Consumer<Image> che sarà eseguita, nel JavaFX Thread, ogni volta che una nuova immagine è scaricata.

/** Un task che può essere eseguito in modo asincrono per scaricare un insieme
 * di immagini */
private static class ImgTask extends Task<Void> {
    /** Crea un task asincrono per scaricare le immagini relative all'insieme
     * di URI dato e per eseguire su ognuna di esse l'azione specificata che
     * è eseguita nel JavaFX Thread.
     * @param uu  insieme di URI di immagini
     * @param act  azione eseguita per ogni immagine scaricata */
    ImgTask(Set<URI> uu, Consumer<Image> act) {
        uris = uu;
        action = act;
    }

    @Override
    protected Void call() throws Exception {
        for (URI u : uris) {
            Image img = new Image(u.toString());      // Scarica l'immagine
                     // Esegue l'azione in modo asincrono nel JavaFX Thread
            Platform.runLater(() -> action.accept(img));
        }
        return null;
    }

    private final Set<URI> uris;
    private final Consumer<Image> action;
}

Questa è la versione iniziale della classe ImgTask, ma fra poco vedremo come la classe Task permetta di monitorare l'esecuzione del compito e di gestirne l'eventuale cancellazione. La classe Task<V> estende FutureTask<V> quindi il completamento del task e l'eventuale valore ritornato (che nel nostro caso non c'è e per questo abbiamo impostato il parametro di tipo a Void) può essere richiesto tramite uno dei metodi get di FutureTask.

Ci manca solo un ultimo passo per poter avere una prima versione funzionante. Dobbiamo aggiungere un controllo al nostro mini Web Browser per dare la possibilità all'utente di iniziare a scaricare le immagini della pagina corrente. Ci bastano due bottoni, uno per innescare il downloading asincrono delle immagini e un altro per rendere visibile la finestra di visualizzazione delle immagini. Per implementarli ci conviene definire un metodo statico che ritorna il Node che contiene i due bottoni e che si occuperà di crearli e inizializzarli. L'inizializzazione comporta anche la creazione di un pool di thread per l'esecuzione degli ImgTask. Possiamo usare un CachedThreadPool però se non facciamo esplicitamente lo shutdown il programma non potrà terminare fino a che non siano passati 60 secondi dall'ultimo utilizzo dei thread del pool. Una soluzione è di usare il metodo ExecutorService newCachedThreadPool(ThreadFactory threadFactory) di Executors, fornendo una ThreadFactory che crea daemon thread4.

/** Ritorna un componente grafico (un Node) che contiene due bottoni per
 * gestire il dowload asincrono e la visualizzazione delle immagini
 * contenute nelle pagine scaricate dalla web engine data.
 * @param wEng  una web engine
 * @return un componente grafico (un Node) con due bottoni */
private static Node downloadImages(WebEngine wEng) {
    ExecutorService exec = Executors.newCachedThreadPool(r -> {  // Factory
        Thread t = new Thread(r);   // dei thread usati dall'esecutore per
        t.setDaemon(true);          // far sì che siano daemon thread, cioè
        return t;                   // non bloccano la chiusura del programma
    });
    DownloadWin win = new DownloadWin();      // Per visualizzare le immagini
    Button loadBtn = new Button(null, new ImageView(Browser.class.
            getResource("load16.png").toString()));
    loadBtn.disableProperty().bind(Bindings.createBooleanBinding(() ->
           wEng.getDocument() == null, wEng.documentProperty()));
    loadBtn.setOnAction(e ->
            exec.submit(new ImgTask(imgURIs(wEng.getDocument()), win::add)));
    Button winBtn = new Button(null, new ImageView(Browser.class.
            getResource("win16.png").toString()));
    winBtn.setOnAction(e -> win.show());
    return new HBox(loadBtn, winBtn);
}

Il bottone loadBtn che inizia il download delle immagini non può essere abilitato se non c'è una pagina corrente, quindi abbiamo definito un binding della proprietà disable in modo tale che il bottone è disabilitato se il metodo getDocument() ritorna null. L'azione del bottone crea un ImgTask per l'insieme degli URI delle immagini della pagina corrente della web engine specificata. E come azione da compiere per ogni immagine scaricata aggiunge l'immagine alla finestra win. Le icone dei due bottoni load16.png e win16.png. Rimane solamente da invocare il metodo downloadImages nel metodo createUI, passandogli il riferimento alla web engine, e aggiungere il Node ritornato al HBox dei controlli,

Node dlImg = downloadImages(we);
HBox hb = new HBox(back, forth, url, dlImg);

Provandolo con qualche pagina ricca d'immagini

Si può notare che durante il downloading delle immagini la GUI rimane reattiva proprio perché tale compito è eseguito in thread differenti da quelli che gestiscono la GUI (principalmente il JavaFX Thread). Inoltre si possono osservare le immagini che vengono aggiunte dinamicamente alla finestra di visualizzazione.

Progresso di un task

Ora vogliamo poter osservare il progresso del task durante il downloading delle immagini. Per questo le istanze di Task hanno una proprietà osservabile progress, ottenibile col metodo ReadOnlyDoubleProperty progressProperty(), il cui valore rappresenta il progresso del task in una scala da 0.0 a 1.0. Tale proprietà è aggiornabile all'interno del metodo call del task tramite il metodo void updateProgress(long workDone, long max) che imposta il valore del progresso a workDone/max. Quindi modifichiamo il metodo call di ImgTask aggiungendo l'aggiornamento del progresso,

protected Void call() throws Exception {
    int count = 0;         // Per il conteggio delle immagini scaricate
    for (URI u : uris) {
        Image img = new Image(u.toString());      // Scarica l'immagine
                    // Esegue l'azione in modo asincrono nel JavaFX Thread
        Platform.runLater(() -> action.accept(img));
        count++;
        updateProgress(count, uris.size());
    }
    return null;
}

Per visualizzare il progresso di un task, abbiamo la scelta tra due controlli ProgressBar che gestisce la visualizzazione di una tipica barra (orizzontale) di progresso e ProgressIndicator che visualizza il progresso tramite un disco che si riempie. Siccome potremmo avere più ImgTask in esecuzione contemporaneamente e vogliamo mantenere l'elenco delle pagine di cui abbiamo fatto il download delle immagini, conviene mostrare queste informazioni sempre nella finestra delle immagini in un panello separato. Aggiungiamo a DownloadWin un contenitore per le pagine e i loro progressi durante il downloading. Usiamo un VBox per contenere le informazioni relative ad ogni pagina, una per riga. Aggiungiamo quindi un campo pageList per tale VBox a DownloadWin, modifichiamo il costruttore e introduciamo un metodo add per aggiungere una nuova pagina e il relativo Task,

private final VBox pageList;

DownloadWin() {
    win = new Stage();             // La finestra di primo livello
    imgPane = new FlowPane();      // Il contenitore per le immagini
    ScrollPane sp = new ScrollPane(imgPane);
    sp.setFitToWidth(true);  // Per far sì che il contenitore delle
    sp.setFitToHeight(true); // immagini occupi tutto lo spazio disponibile
    pageList = new VBox();
    ScrollPane sp2 = new ScrollPane(pageList);
    SplitPane split = new SplitPane(sp, sp2);
    win.setScene(new Scene(split, 500, 400));
}

/** Aggiunge una nuova pagina di cui è iniziato il downloading delle 
 * immagini tramite lo specificato task.
 * @param task  il task che esegue il dowloading delle immagini
 * @param page  l'URI della pagina */
void add(Task task, String page) {
    ProgressIndicator pi = new ProgressIndicator();
    pi.progressProperty().bind(task.progressProperty());
    pageList.getChildren().add(new Label(page, pi));
}

La pageList è inserita in uno ScrollPane che a sua volta è in uno SplitPane che gestisce anche imgPane. Si noti che la proprietà progress del ProgressIndicator è aggiornata direttamente tramite un bind all'analoga proprietà del task.

Rimane solamente da modificare l'azione eseguita dal bottone loadBtn nel metodo downloadImages in modo tale che quando è cliccato oltre a sottomettere il task per l'esecuzione aggiunge anche lo URI della pagina e il task stesso alla finestra di visualizzazione.

loadBtn.setOnAction(e -> {
    Document d = wEng.getDocument();
    Task t = new ImgTask(imgURIs(d), win::add);
    exec.submit(t);
    win.add(t, d.getDocumentURI());
});

Provando la nuova versione,

Cancellare un task

Vorremmo anche avere la possibilità di cancellare il task che sta effettuando il downloading delle immagini di una pagina. Gli oggetti di tipo Worker come Task hanno il metodo boolean cancel() che può essere invocato per cancellare un task. Però tale metodo esattamente come quello di Future semplicemente imposta un flag interno e non cancella il task se questo non controlla il valore del flag tramite il metodo boolean isCancelled(). Quindi la cancellazione di un task in esecuzione è sempre un'operazione di tipo cooperativo e non coercitivo. Nel metodo call di ImgTask, aggiungiamo il controllo subito dopo l'aggiornamento del progresso

         . . .
         updateProgress(count, uris.size());
         if (isCancelled())
             break;
         . . .

Poi modifichiamo il metodo add aggiungendo un bottone stop che permette di cancellare il task.

void add(Task task, String page) {
    ProgressIndicator pi = new ProgressIndicator();
    pi.progressProperty().bind(task.progressProperty());
    Button stop = new Button("X");
    stop.setOnAction(e -> task.cancel());
    stop.disableProperty().bind(task.runningProperty().not());
    HBox hb = new HBox(pi, stop);
    hb.setAlignment(Pos.BOTTOM_LEFT);
    pageList.getChildren().add(new Label(page, hb));
}

Inoltre, non appena il task termina il bottone stop è disabilitato grazie a un semplice binding. Abbiamo introdotto un HBox per contenere il ProgressIndicator il bottone stop e poi una Label per contenere anche l'indirizzo della pagina.

Esercizi

[NumeroImmagini]    Nella finestra di visualizzazione delle immagini aggiungere per ogni pagina il numero di immagini scaricate.

[Pagine]    Tenere traccia delle pagine per le quali sono state scaricate le immagini e quando la pagina corrente è una di queste, disabilitare il bottone che inizia il downloading delle immagini.

[Immagini]    Tenere traccia di tutti gli URI delle immagini scaricate ed evitare che un task possa scaricare un'immagine il cui URI è già stato scaricato.

[MultiThread]    Implementare ImgTask in modo che possa usare più thread per scaricare le immagini della stessa pagina.

12 Mag 2016


  1. Per raccogliere gli URI estratti usiamo un Set così evitiamo di avere duplicati.

  2. Usiamo gli URI invece degli URL, che potrebbero sembrare più appropriati, per due ragioni. La prima è che è problematico usare gli URL in una Collection come Set perché il metodo boolean equals(Object obj) di URL è bloccante in quanto può cercare di accedere al DNS (Domain Name Server) per risolvere l'host del URL, lo stesso dicasi per il metodo int hashCode(). La seconda è che gli URI, a differenza degli URL, permettono di manipolare indirizzi relativi e di risolverli rispetto a un indirizzo (URI) assoluto.

  3. Infatti, la classe Image di JavaFX supporta solamente i formati: BMP, GIF, JPEG e PNG.

  4. Un daemon thread a differenza di un normale thread non impedisce che la JVM termini se è vivo. In altre parole la JVM termina se i thread ancora vivi sono solamente daemon thread. Questo significa che un daemon thread non dovrebbe eseguire compiti critici o usare risorse che richiedono particolari operazioni di finalizzazione, perché appunto potrebbe essere ucciso inaspettatamente.