Quand une WebView est-elle prête pour un instantané ()?

9

L'état du JavaFX que WebViewest prêt quand Worker.State.SUCCEEDEDest atteint cependant, à moins que vous attendez un certain temps (c. -à Animation, Transition, PauseTransition, etc.), une page vierge est rendue.

Cela suggère qu'il y a un événement qui se produit à l'intérieur du WebView le préparant pour une capture, mais qu'est-ce que c'est?

Il y a plus de 7000 extraits de code sur GitHub qui utilisentSwingFXUtils.fromFXImage mais la plupart d'entre eux semblent être sans rapport avec WebView, sont interactifs (l'homme masque la condition de concurrence) ou utilisent des transitions arbitraires (de 100 à 2000 ms).

J'ai essayé:

  • Écoute à changed(...)partir WebViewdes dimensions du ( DoublePropertyimplémentation des propriétés de hauteur et de largeur ObservableValue, qui peuvent surveiller ces choses)

    • 🚫Non viable. Parfois, la valeur semble changer indépendamment de la routine de peinture, conduisant à un contenu partiel.
  • Dire aveuglément tout et n'importe quoi runLater(...)sur le fil d'application FX.

    • Any De nombreuses techniques utilisent cela, mais mes propres tests unitaires (ainsi que d'excellents commentaires d'autres développeurs) expliquent que les événements sont souvent déjà sur le bon thread, et cet appel est redondant. Le mieux que je puisse penser est d'ajouter juste assez de retard dans la mise en file d'attente pour que cela fonctionne pour certains.
  • Ajouter un écouteur / déclencheur DOM ou un écouteur / déclencheur JavaScript au WebView

    • JavaScript Les deux JavaScript et le DOM semblent être chargés correctement lorsqu'ils SUCCEEDEDsont appelés malgré la capture vierge. Les écouteurs DOM / JavaScript ne semblent pas aider.
  • Utiliser un Animationou Transitionpour "dormir" efficacement sans bloquer le thread FX principal.

    • ⚠️ Cette approche fonctionne et si le retard est suffisamment long, peut produire jusqu'à 100% des tests unitaires, mais les temps de transition semblent être un moment futur que nous devinons juste et une mauvaise conception. Pour les applications performantes ou critiques, cela oblige le programmeur à faire un compromis entre la vitesse ou la fiabilité, une expérience potentiellement mauvaise pour l'utilisateur.

Quel est le bon moment pour appeler WebView.snapshot(...)?

Usage:

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.
 */

Extrait de code:

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

En relation:

tresf
la source
Platform.runLater n'est pas redondant. Il peut y avoir des événements en attente qui sont nécessaires pour que WebView termine son rendu. Platform.runLater est la première chose que j'essaierais.
VGR
La course ainsi que les tests unitaires suggèrent que les événements ne sont pas en attente, mais se produisent plutôt dans un thread séparé. Platform.runLatera été testé et ne le résout pas. Veuillez l'essayer par vous-même si vous n'êtes pas d'accord. Je serais heureux de me tromper, cela résoudrait le problème.
tresf
De plus, les documents officiels mettent en scène l' SUCCEEDEDétat (dont l'auditeur se déclenche sur le thread FX) est la bonne technique. S'il y a un moyen de montrer les événements en file d'attente, je serais ravi d'essayer. J'ai trouvé des suggestions clairsemées à travers des commentaires sur les forums Oracle et quelques questions SO qui WebViewdoivent fonctionner dans son propre fil de conception, donc après des jours de test, je concentre l'énergie là-bas. Si cette hypothèse est fausse, tant mieux. Je suis ouvert à toutes suggestions raisonnables pour résoudre le problème sans temps d'attente arbitraires.
tresf
J'ai écrit mon propre test très court et j'ai réussi à obtenir un instantané d'une WebView dans l'écouteur d'état du travailleur de charge. Mais votre programme me donne une page blanche. J'essaie toujours de comprendre la différence.
VGR
Il semble que cela se produit uniquement lors de l'utilisation d'une loadContentméthode ou lors du chargement d'une URL de fichier.
VGR

Réponses:

1

Il semble que ce soit un bug qui se produit lors de l'utilisation des loadContentméthodes de WebEngine . Cela se produit également lors de l'utilisation loadpour charger un fichier local, mais dans ce cas, l'appel à reload () le compensera.

De plus, comme la scène doit s'afficher lorsque vous prenez un instantané, vous devez appeler show()avant de charger le contenu. Étant donné que le contenu est chargé de manière asynchrone, il est tout à fait possible qu'il soit chargé avant l'instruction suivant l'appel à loadou se loadContenttermine.

La solution de contournement consiste alors à placer le contenu dans un fichier et à appeler la reload()méthode WebEngine exactement une fois. La deuxième fois que le contenu est chargé, un instantané peut être pris avec succès à partir d'un écouteur de la propriété d'état du travailleur de chargement.

Normalement, ce serait 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());

Mais parce que vous utilisez staticpour tout, vous devrez ajouter quelques champs:

private static boolean reloaded;
private static volatile Path htmlFile;

Et vous pouvez les utiliser ici:

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

Et puis vous devrez le réinitialiser à chaque fois que vous chargez du contenu:

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

Notez qu'il existe de meilleures façons d'effectuer un traitement multithread. Au lieu d'utiliser des classes atomiques, vous pouvez simplement utiliser des volatilechamps:

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

(les champs booléens sont faux par défaut, et les champs objet sont nuls par défaut. Contrairement aux programmes C, c'est une garantie solide faite par Java; il n'y a pas de mémoire non initialisée.)

Au lieu d'interroger dans une boucle les modifications apportées dans un autre thread, il est préférable d'utiliser la synchronisation, un verrou ou une classe de niveau supérieur comme CountDownLatch qui utilise ces choses en interne:

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 n'est pas déclaré volatile car il n'est accessible que dans le thread d'application JavaFX.

VGR
la source
1
Ceci est une très bonne écriture, en particulier les améliorations de code entourant le thread et les volatilevariables. Malheureusement, appeler WebEngine.reload()et attendre une suite SUCCEEDEDne fonctionne pas. Si je place un compteur dans le contenu HTML, je reçois: 0, 0, 1, 3, 3, 5au lieu de 0, 1, 2, 3, 4, 5, suggérant qu'il ne résout pas réellement la condition de concurrence sous-jacente.
tresf
Citation: "mieux utiliser [...] CountDownLatch". Vote ascendant car ces informations n'étaient pas faciles à trouver et aident à accélérer et à simplifier le code lors du démarrage initial de FX.
tresf
0

Pour tenir compte du redimensionnement ainsi que du comportement de l'instantané sous-jacent, j'ai (nous avons) proposé la solution de travail suivante. Notez que ces tests ont été exécutés 2000x (Windows, macOS et Linux) fournissant des tailles WebView aléatoires avec un succès de 100%.

Tout d'abord, je vais citer l'un des développeurs JavaFX. Ceci est extrait d'un rapport de bogue privé (sponsorisé):

"Je suppose que vous lancez le redimensionnement sur le FX AppThread, et que cela se fait après que l'état SUCCEEDED soit atteint. Dans ce cas, il me semble qu'à ce moment, attendre 2 impulsions (sans bloquer le FX AppThread) devrait donner la L'implémentation du webkit a suffisamment de temps pour effectuer ses modifications, à moins que cela n'entraîne la modification de certaines dimensions dans JavaFX, ce qui peut entraîner une nouvelle modification des dimensions à l'intérieur du webkit.

Je réfléchis à la façon de nourrir ces informations dans la discussion dans JBS, mais je suis presque sûr qu'il y aura la réponse que "vous ne devriez prendre un instantané que lorsque le composant Web est stable". Donc, afin d'anticiper cette réponse, il serait bon de voir si cette approche fonctionne pour vous. Ou, s'il s'avère que cela cause d'autres problèmes, il serait bon de penser à ces problèmes et de voir si / comment ils peuvent être corrigés dans OpenJFX lui-même. "

  1. Par défaut, JavaFX 8 utilise une valeur par défaut de 600si la hauteur est exactement 0. Le recyclage du code WebViewdevrait utiliser setMinHeight(1), setPrefHeight(1)pour éviter ce problème. Ce n'est pas dans le code ci-dessous, mais mérite d'être mentionné pour quiconque l'adapte à son projet.
  2. Pour s'adapter à l'état de préparation de WebKit, attendez exactement deux impulsions à l'intérieur d'un minuteur d'animation.
  3. Pour éviter le bogue vide de l'instantané, tirez parti du rappel de l'instantané, qui écoute également une impulsion.
// 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
la source