¿Cuándo está listo un WebView para una instantánea ()?

9

El JavaFX documentos de estado que una WebViewestá listo cuando Worker.State.SUCCEEDEDse alcanza sin embargo, a menos que esperar un tiempo (es decir Animation, Transition, PauseTransition, etc.), una página en blanco se procesa.

Esto sugiere que hay un evento que ocurre dentro de WebView y lo prepara para una captura, pero ¿qué es?

Hay más de 7,000 fragmentos de código en GitHub que usan,SwingFXUtils.fromFXImage pero la mayoría de ellos parecen no estar relacionados WebView, son interactivos (las máscaras humanas son la condición de la carrera) o usan Transiciones arbitrarias (de 100ms a 2,000ms).

He intentado:

  • Escuchando changed(...)desde dentro de las WebViewdimensiones ( DoublePropertyimplementos de propiedades de alto y ancho ObservableValue, que pueden monitorear estas cosas)

    • ViableNo viable. A veces, el valor parece cambiar por separado de la rutina de pintura, lo que lleva a un contenido parcial.
  • Contar ciegamente cualquier cosa y todo runLater(...)en el hilo de la aplicación FX.

    • 🚫 Muchas técnicas usan esto, pero mis propias pruebas unitarias (así como algunos excelentes comentarios de otros desarrolladores) explican que los eventos a menudo ya están en el hilo correcto, y esta llamada es redundante. Lo mejor que se me ocurre es que agrega un retraso suficiente a través de la cola para que funcione para algunos.
  • Agregar un escucha / disparador DOM o un escucha / disparador JavaScript al WebView

    • JavaScript Tanto JavaScript como DOM parecen estar cargados correctamente cuando SUCCEEDEDse llama a pesar de la captura en blanco. Los oyentes DOM / JavaScript no parecen ayudar.
  • Usando Animationo Transitionpara "dormir" efectivamente sin bloquear el hilo principal de FX.

    • ⚠️ Este enfoque funciona y si el retraso es lo suficientemente largo, puede producir hasta el 100% de las pruebas unitarias, pero los tiempos de transición parecen ser un momento futuro que solo estamos adivinando y con un mal diseño. Para aplicaciones de rendimiento o de misión crítica, esto obliga al programador a hacer una compensación entre velocidad o confiabilidad, una experiencia potencialmente mala para el usuario.

Cuando es un buen momento para llamar 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.
 */

Fragmento de código:

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();
    }
}

Relacionado:

tresf
fuente
Platform.runLater no es redundante. Puede haber eventos pendientes que son necesarios para que WebView complete su representación. Platform.runLater es lo primero que probaría.
VGR
Tanto la carrera como las pruebas unitarias sugieren que los eventos no están pendientes, sino que ocurren en un hilo separado. Platform.runLaterfue probado y no lo soluciona. Por favor, pruébelo usted mismo si no está de acuerdo. Me alegraría estar equivocado, cerraría el problema.
Tresf
Además, los documentos oficiales representan el SUCCEEDEDestado (del cual el oyente dispara en el hilo FX) es la técnica adecuada. Si hay una manera de mostrar los eventos en cola, me encantaría intentarlo. He encontrado sugerencias dispersas a través de comentarios en los foros de Oracle y algunas preguntas SO que WebViewdeben ejecutarse en su propio hilo por diseño, por lo que después de días de pruebas, estoy enfocando la energía allí. Si esa suposición es incorrecta, genial. Estoy abierto a cualquier sugerencia razonable que solucione el problema sin tiempos de espera arbitrarios.
Tresf
Escribí mi propia prueba muy corta y pude obtener con éxito una instantánea de un WebView en el escucha de estado del trabajador de carga. Pero su programa me da una página en blanco. Todavía estoy tratando de entender la diferencia.
VGR
Parece que esto solo ocurre cuando se utiliza un loadContentmétodo o al cargar una URL de archivo.
VGR

Respuestas:

1

Parece que este es un error que ocurre cuando se usan los loadContentmétodos de WebEngine . También ocurre cuando se usa loadpara cargar un archivo local, pero en ese caso, llamar a reload () lo compensará.

Además, dado que el escenario debe mostrarse cuando toma una instantánea, debe llamar show()antes de cargar el contenido. Dado que el contenido se carga de forma asíncrona, es completamente posible que se cargue antes de que finalice la declaración que sigue a la llamada loado loadContentfinaliza.

La solución, entonces, es colocar el contenido en un archivo y llamar al reload()método de WebEngine exactamente una vez. La segunda vez que se carga el contenido, se puede tomar una instantánea con éxito de un oyente de la propiedad de estado del trabajador de carga.

Normalmente, esto sería fácil:

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());

Pero como lo está utilizando staticpara todo, deberá agregar algunos campos:

private static boolean reloaded;
private static volatile Path htmlFile;

Y puedes usarlos aquí:

/** 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();
        }
    }
};

Y luego deberá restablecerlo cada vez que cargue contenido:

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);
    }
}));

Tenga en cuenta que hay mejores formas de realizar un procesamiento multiproceso. En lugar de usar clases atómicas, simplemente puede usar volatilecampos:

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

(los campos booleanos son falsos de forma predeterminada, y los campos de objeto son nulos de forma predeterminada. A diferencia de los programas en C, esta es una garantía sólida hecha por Java; no existe una memoria no inicializada).

En lugar de sondear en un bucle los cambios realizados en otro subproceso, es mejor usar sincronización, un bloqueo o una clase de nivel superior como CountDownLatch que usa esas cosas 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 no se declara volátil porque solo se accede a él en el hilo de la aplicación JavaFX.

VGR
fuente
1
Esta es una muy buena reseña, especialmente las mejoras de código que rodean el subproceso y las volatilevariables. Desafortunadamente, llamar WebEngine.reload()y esperar un posterior SUCCEEDEDno funciona. Si coloco un contador en el contenido HTML, recibo: en 0, 0, 1, 3, 3, 5lugar de 0, 1, 2, 3, 4, 5, sugiriendo que en realidad no corrige la condición de carrera subyacente.
Tresf
Cita: "mejor usar [...] CountDownLatch". Votación positiva porque esta información no fue fácil de encontrar y ayuda a acelerar y simplificar el código con el inicio inicial de FX.
Tresf
0

Para acomodar el cambio de tamaño y el comportamiento subyacente de la instantánea, yo (hemos) ideado la siguiente solución de trabajo. Tenga en cuenta que estas pruebas se ejecutaron 2,000x (Windows, macOS y Linux) proporcionando tamaños aleatorios de WebView con un 100% de éxito.

Primero, citaré a uno de los desarrolladores de JavaFX. Esto se cita de un informe de error privado (patrocinado):

"Supongo que inicia el cambio de tamaño en FX AppThread, y que se realiza después de alcanzar el estado EXITOSO. En ese caso, me parece que en ese momento, esperar 2 pulsos (sin bloquear el FX AppThread) debería dar el implementación de webkit el tiempo suficiente para realizar sus cambios, a menos que esto resulte en que algunas dimensiones se cambien en JavaFX, lo que puede resultar nuevamente en que las dimensiones se cambien dentro de webkit.

Estoy pensando en cómo alimentar esta información en la discusión en JBS, pero estoy bastante seguro de que habrá la respuesta de que "debe tomar una instantánea solo cuando el componente web sea estable". Entonces, para anticipar esta respuesta, sería bueno ver si este enfoque funciona para usted. O, si resulta que causa otros problemas, sería bueno pensar en estos problemas y ver si / cómo se pueden solucionar en OpenJFX ".

  1. Por defecto, JavaFX 8 usa un valor predeterminado de 600si la altura es exactamente 0. Código reutilización WebViewdebe utilizar setMinHeight(1), setPrefHeight(1)para evitar este problema. Esto no está en el código a continuación, pero vale la pena mencionarlo para cualquiera que lo adapte a su proyecto.
  2. Para acomodar la preparación de WebKit, espere exactamente dos pulsos desde el interior de un temporizador de animación.
  3. Para evitar el error en blanco de la instantánea, aproveche la devolución de llamada de la instantánea, que también escucha un pulso.
// 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();
    }
});
tresf
fuente