Quando un WebView è pronto per un'istantanea ()?


9

Il JavaFX docs stato che un WebViewè pronto quando Worker.State.SUCCEEDEDviene raggiunto tuttavia, a meno che non si aspetta un po '(vale a dire Animation, Transition, PauseTransition, ecc), una pagina vuota viene eseguito il rendering.

Ciò suggerisce che si verifica un evento all'interno del WebView che lo sta preparando per un'acquisizione, ma che cos'è?

Ci sono oltre 7.000 frammenti di codice su GitHub che usano,SwingFXUtils.fromFXImage ma la maggior parte di essi sembra non avere alcuna relazione WebView, sono interattivi (maschere umane la condizione della razza) o usano Transizioni arbitrarie (da 100ms a 2.000ms).

Ho provato:

  • Ascoltare changed(...)dall'interno delle WebViewdimensioni ( DoublePropertyimplementa le proprietà di altezza e larghezza ObservableValue, che possono monitorare queste cose)

    • 🚫Non praticabile. A volte, il valore sembra cambiare separatamente dalla routine di pittura, portando a un contenuto parziale.
  • Dire ciecamente tutto e tutto runLater(...)sul thread dell'applicazione FX.

    • 🚫Molte tecniche usano questo, ma i miei test unitari (oltre ad alcuni ottimi feedback da parte di altri sviluppatori) spiegano che gli eventi sono spesso già sul thread giusto e questa chiamata è ridondante. Il meglio che mi viene in mente è di aggiungere un ritardo sufficiente per fare la fila che funziona per alcuni.
  • Aggiunta di un listener / trigger DOM o listener / trigger JavaScript a WebView

    • Oth Sia JavaScript che il DOM sembrano caricati correttamente quando SUCCEEDED viene chiamato nonostante l'acquisizione del bianco. I listener DOM / JavaScript non sembrano aiutare.
  • Usando un Animation o Transitionper "dormire" efficacemente senza bloccare il thread FX principale.

    • ⚠️ Questo approccio funziona e se il ritardo è abbastanza lungo, può produrre fino al 100% dei test unitari, ma i tempi di transizione sembrano essere un momento futuro che stiamo solo supponendo e progettando male. Per applicazioni performanti o mission-critical, questo costringe il programmatore a fare un compromesso tra velocità o affidabilità, entrambe potenzialmente potenzialmente dannose per l'utente.

Quando è un buon momento per chiamare WebView.snapshot(...) ?

Uso:

SnapshotRaceCondition.initialize();
BufferedImage bufferedImage = SnapshotRaceCondition.capture("<html style='background-color: red;'><h1>TEST</h1></html>");
/**
 * Notes:
 * - The color is to observe the otherwise non-obvious cropping that occurs
 *   with some techniques, such as `setPrefWidth`, `autosize`, etc.
 * - Call this function in a loop and then display/write `BufferedImage` to
 *   to see strange behavior on subsequent calls.
 * - Recommended, modify `<h1>TEST</h1` with a counter to see content from
 *   previous captures render much later.
 */

Snippet di codice:

import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Worker;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.WritableImage;
import javafx.scene.web.WebView;
import javafx.stage.Stage;

import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;

public class SnapshotRaceCondition extends Application  {
    private static final Logger log = Logger.getLogger(SnapshotRaceCondition.class.getName());

    // self reference
    private static SnapshotRaceCondition instance = null;

    // concurrent-safe containers for flags/exceptions/image data
    private static AtomicBoolean started  = new AtomicBoolean(false);
    private static AtomicBoolean finished  = new AtomicBoolean(true);
    private static AtomicReference<Throwable> thrown = new AtomicReference<>(null);
    private static AtomicReference<BufferedImage> capture = new AtomicReference<>(null);

    // main javafx objects
    private static WebView webView = null;
    private static Stage stage = null;

    // frequency for checking fx is started
    private static final int STARTUP_TIMEOUT= 10; // seconds
    private static final int STARTUP_SLEEP_INTERVAL = 250; // millis

    // frequency for checking capture has occured 
    private static final int CAPTURE_SLEEP_INTERVAL = 10; // millis

    /** Called by JavaFX thread */
    public SnapshotRaceCondition() {
        instance = this;
    }

    /** Starts JavaFX thread if not already running */
    public static synchronized void initialize() throws IOException {
        if (instance == null) {
            new Thread(() -> Application.launch(SnapshotRaceCondition.class)).start();
        }

        for(int i = 0; i < (STARTUP_TIMEOUT * 1000); i += STARTUP_SLEEP_INTERVAL) {
            if (started.get()) { break; }

            log.fine("Waiting for JavaFX...");
            try { Thread.sleep(STARTUP_SLEEP_INTERVAL); } catch(Exception ignore) {}
        }

        if (!started.get()) {
            throw new IOException("JavaFX did not start");
        }
    }


    @Override
    public void start(Stage primaryStage) {
        started.set(true);
        log.fine("Started JavaFX, creating WebView...");
        stage = primaryStage;
        primaryStage.setScene(new Scene(webView = new WebView()));

        // Add listener for SUCCEEDED
        Worker<Void> worker = webView.getEngine().getLoadWorker();
        worker.stateProperty().addListener(stateListener);

        // Prevents JavaFX from shutting down when hiding window, useful for calling capture(...) in succession
        Platform.setImplicitExit(false);
    }

    /** Listens for a SUCCEEDED state to activate image capture **/
    private static ChangeListener<Worker.State> stateListener = (ov, oldState, newState) -> {
        if (newState == Worker.State.SUCCEEDED) {
            WritableImage snapshot = webView.snapshot(new SnapshotParameters(), null);

            capture.set(SwingFXUtils.fromFXImage(snapshot, null));
            finished.set(true);
            stage.hide();
        }
    };

    /** Listen for failures **/
    private static ChangeListener<Throwable> exceptListener = new ChangeListener<Throwable>() {
        @Override
        public void changed(ObservableValue<? extends Throwable> obs, Throwable oldExc, Throwable newExc) {
            if (newExc != null) { thrown.set(newExc); }
        }
    };

    /** Loads the specified HTML, triggering stateListener above **/
    public static synchronized BufferedImage capture(final String html) throws Throwable {
        capture.set(null);
        thrown.set(null);
        finished.set(false);

        // run these actions on the JavaFX thread
        Platform.runLater(new Thread(() -> {
            try {
                webView.getEngine().loadContent(html, "text/html");
                stage.show(); // JDK-8087569: will not capture without showing stage
                stage.toBack();
            }
            catch(Throwable t) {
                thrown.set(t);
            }
        }));

        // wait for capture to complete by monitoring our own finished flag
        while(!finished.get() && thrown.get() == null) {
            log.fine("Waiting on capture...");
            try {
                Thread.sleep(CAPTURE_SLEEP_INTERVAL);
            }
            catch(InterruptedException e) {
                log.warning(e.getLocalizedMessage());
            }
        }

        if (thrown.get() != null) {
            throw thrown.get();
        }

        return capture.get();
    }
}

Relazionato:


Platform.runLater non è ridondante. Potrebbero esserci eventi in sospeso necessari per il completamento del rendering di WebView. Platform.runLater è la prima cosa che vorrei provare.
VGR,

La gara e i test unitari suggeriscono che gli eventi non sono in sospeso, ma si verificano piuttosto in un thread separato. Platform.runLaterè stato testato e non lo risolve. Per favore, provalo tu stesso se non sei d'accordo. Sarei felice di sbagliarmi, chiuderebbe il problema.
Tresf

Inoltre, i documenti ufficiali mettono in scena lo SUCCEEDEDstato (di cui l'ascoltatore spara sul thread FX) è la tecnica corretta. Se c'è un modo per mostrare eventi in coda, sarei euforico di provare. Ho trovato suggerimenti sparsi attraverso i commenti sui forum Oracle e alcune domande SO che WebViewdevono essere eseguite nel suo thread in base alla progettazione, quindi dopo giorni di test sto concentrando l'energia lì. Se tale presupposto è sbagliato, ottimo. Sono aperto a qualsiasi suggerimento ragionevole che risolva il problema senza tempi di attesa arbitrari.
Tresf

Ho scritto il mio test molto breve e sono stato in grado di ottenere correttamente un'istantanea di un WebView nel listener di stato del lavoratore di caricamento. Ma il tuo programma mi dà una pagina vuota. Sto ancora cercando di capire la differenza.
VGR

Sembra che ciò avvenga solo quando si utilizza un loadContentmetodo o quando si carica un URL di file.
VGR

Risposte:


1

Sembra che si tratti di un bug che si verifica quando si utilizzano i loadContentmetodi di WebEngine . Si verifica anche quando si utilizza loadper caricare un file locale, ma in tal caso, chiamando reload () lo compenserà.

Inoltre, poiché lo stage deve essere mostrato quando si scatta un'istantanea, è necessario chiamare show()prima di caricare il contenuto. Poiché il contenuto viene caricato in modo asincrono, è del tutto possibile che venga caricato prima dell'istruzione che segue la chiamata a loadoloadContent termina.

La soluzione alternativa, quindi, è posizionare il contenuto in un file e chiamare WebEngine reload() metodo esattamente una volta. La seconda volta che viene caricato il contenuto, è possibile acquisire correttamente un'istantanea da un listener della proprietà state del lavoratore di caricamento.

Normalmente, questo sarebbe facile:

Path htmlFile = Files.createTempFile("snapshot-", ".html");
Files.writeString(htmlFile, html);

WebEngine engine = myWebView.getEngine();
engine.getLoadWorker().stateProperty().addListener(
    new ChangeListener<Worker.State>() {
        private boolean reloaded;

        @Override
        public void changed(ObservableValue<? extends Worker.State> obs,
                            Worker.State oldState,
                            Worker.State newState) {
            if (reloaded) {
                Image image = myWebView.snapshot(null, null);
                doStuffWithImage(image);

                try {
                    Files.delete(htmlFile);
                } catch (IOException e) {
                    log.log(Level.WARN, "Couldn't delete " + htmlFile, e);
                }
            } else {
                reloaded = true;
                engine.reload();
            }
        }
    });


engine.load(htmlFile.toUri().toString());

Ma poiché stai usando staticper tutto, dovrai aggiungere alcuni campi:

private static boolean reloaded;
private static volatile Path htmlFile;

E puoi usarli qui:

/** Listens for a SUCCEEDED state to activate image capture **/
private static ChangeListener<Worker.State> stateListener = (ov, oldState, newState) -> {
    if (newState == Worker.State.SUCCEEDED) {
        if (reloaded) {
            WritableImage snapshot = webView.snapshot(new SnapshotParameters(), null);

            capture.set(SwingFXUtils.fromFXImage(snapshot, null));
            finished.set(true);
            stage.hide();

            try {
                Files.delete(htmlFile);
            } catch (IOException e) {
                log.log(Level.WARN, "Couldn't delete " + htmlFile, e);
            }
        } else {
            reloaded = true;
            webView.getEngine().reload();
        }
    }
};

E poi dovrai resettarlo ogni volta che carichi contenuti:

Path htmlFile = Files.createTempFile("snapshot-", ".html");
Files.writeString(htmlFile, html);

Platform.runLater(new Thread(() -> {
    try {
        reloaded = false;
        stage.show(); // JDK-8087569: will not capture without showing stage
        stage.toBack();
        webView.getEngine().load(htmlFile);
    }
    catch(Throwable t) {
        thrown.set(t);
    }
}));

Si noti che esistono modi migliori per eseguire l'elaborazione multithread. Invece di usare le classi atomiche, puoi semplicemente usare i volatilecampi:

private static volatile boolean started;
private static volatile boolean finished = true;
private static volatile Throwable thrown;
private static volatile BufferedImage capture;

(i campi booleani sono falsi per impostazione predefinita e i campi oggetto sono null per impostazione predefinita. A differenza dei programmi C, questa è una garanzia concreta fatta da Java; non esiste una memoria non inizializzata.)

Invece di eseguire il polling in un ciclo per le modifiche apportate in un altro thread, è meglio usare la sincronizzazione, un blocco o una classe di livello superiore come CountDownLatch che utilizza queste cose internamente:

private static final CountDownLatch initialized = new CountDownLatch(1);
private static volatile CountDownLatch finished;
private static volatile BufferedImage capture;
private static volatile Throwable thrown;
private static boolean reloaded;

private static volatile Path htmlFile;

// main javafx objects
private static WebView webView = null;
private static Stage stage = null;

private static ChangeListener<Worker.State> stateListener = (ov, oldState, newState) -> {
    if (newState == Worker.State.SUCCEEDED) {
        if (reloaded) {
            WritableImage snapshot = webView.snapshot(null, null);
            capture = SwingFXUtils.fromFXImage(snapshot, null);
            finished.countDown();
            stage.hide();

            try {
                Files.delete(htmlFile);
            } catch (IOException e) {
                log.log(Level.WARNING, "Could not delete " + htmlFile, e);
            }
        } else {
            reloaded = true;
            webView.getEngine().reload();
        }
    }
};

@Override
public void start(Stage primaryStage) {
    log.fine("Started JavaFX, creating WebView...");
    stage = primaryStage;
    primaryStage.setScene(new Scene(webView = new WebView()));

    Worker<Void> worker = webView.getEngine().getLoadWorker();
    worker.stateProperty().addListener(stateListener);

    webView.getEngine().setOnError(e -> {
        thrown = e.getException();
    });

    // Prevents JavaFX from shutting down when hiding window, useful for calling capture(...) in succession
    Platform.setImplicitExit(false);

    initialized.countDown();
}

public static BufferedImage capture(String html)
throws InterruptedException,
       IOException {

    htmlFile = Files.createTempFile("snapshot-", ".html");
    Files.writeString(htmlFile, html);

    if (initialized.getCount() > 0) {
        new Thread(() -> Application.launch(SnapshotRaceCondition2.class)).start();
        initialized.await();
    }

    finished = new CountDownLatch(1);
    thrown = null;

    Platform.runLater(() -> {
        reloaded = false;
        stage.show(); // JDK-8087569: will not capture without showing stage
        stage.toBack();
        webView.getEngine().load(htmlFile.toUri().toString());
    });

    finished.await();

    if (thrown != null) {
        throw new IOException(thrown);
    }

    return capture;
}

reloaded non è dichiarato volatile perché è accessibile solo nel thread dell'applicazione JavaFX.


1
Questo è un bel commento, specialmente i miglioramenti del codice che circondano il threading e le volatilevariabili. Purtroppo, chiamare WebEngine.reload()e attendere un successivo SUCCEEDEDnon funziona. Se inserisco un contatore nel contenuto HTML, ricevo: 0, 0, 1, 3, 3, 5invece di 0, 1, 2, 3, 4, 5, suggerendo che non risolve effettivamente la condizione della razza sottostante.
tresf

Citazione: "meglio usare [...] CountDownLatch". Effettuare l'upgrade perché queste informazioni non erano facili da trovare e aiutano la velocità e la semplicità del codice con l'avvio FX iniziale.
tresf

0

Per soddisfare il ridimensionamento e il comportamento dell'istantanea sottostante, io (abbiamo) escogitare la seguente soluzione di lavoro. Si noti che questi test sono stati eseguiti 2.000x (Windows, macOS e Linux) fornendo dimensioni WebView casuali con successo al 100%.

Innanzitutto citerò uno degli sviluppatori JavaFX. Questo è citato da una segnalazione di bug privata (sponsorizzata):

"Presumo che tu abbia avviato il ridimensionamento su FX AppThread e che sia fatto dopo che è stato raggiunto lo stato SUCCEEDED. In tal caso, mi sembra che in quel momento, attendere 2 impulsi (senza bloccare FX AppThread) dovrebbe dare L'implementazione di webkit ha abbastanza tempo per apportare le sue modifiche, a meno che ciò non comporti la modifica di alcune dimensioni in JavaFX, il che potrebbe comportare nuovamente la modifica delle dimensioni all'interno di webkit.

Sto pensando a come inserire queste informazioni nella discussione in JBS, ma sono abbastanza sicuro che ci sarà la risposta che "dovresti fare un'istantanea solo quando il componente web è stabile". Quindi, al fine di anticipare questa risposta, sarebbe bello vedere se questo approccio funziona per te. Oppure, se si scopre che causa altri problemi, sarebbe bene pensare a questi problemi e vedere se / come possono essere risolti in OpenJFX stesso. "

  1. Per impostazione predefinita, JavaFX 8 utilizza un valore predefinito 600se altezza è esattamente 0. Codice riutilizzo WebViewdovrebbe usare setMinHeight(1), setPrefHeight(1)per evitare questo problema. Questo non è nel codice qui sotto, ma vale la pena menzionarlo per chiunque lo adatti al proprio progetto.
  2. Per soddisfare la prontezza di WebKit, attendere esattamente due impulsi dall'interno di un timer di animazione.
  3. Per evitare il bug vuoto dell'istantanea, sfruttare il callback dell'istantanea, che ascolta anche un impulso.
// without this runlater, the first capture is missed and all following captures are offset
Platform.runLater(new Runnable() {
    public void run() {
        // start a new animation timer which waits for exactly two pulses
        new AnimationTimer() {
            int frames = 0;

            @Override
            public void handle(long l) {
                // capture at exactly two frames
                if (++frames == 2) {
                    System.out.println("Attempting image capture");
                    webView.snapshot(new Callback<SnapshotResult,Void>() {
                        @Override
                        public Void call(SnapshotResult snapshotResult) {
                            capture.set(SwingFXUtils.fromFXImage(snapshotResult.getImage(), null));
                            unlatch();
                            return null;
                        }
                    }, null, null);

                    //stop timer after snapshot
                    stop();
                }
            }
        }.start();
    }
});
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.