Wann ist ein WebView für einen Snapshot bereit ()?

9

Der Zustand docs JavaFX , dass ein WebViewbereit ist , wenn Worker.State.SUCCEEDEDerreicht wird , aber wenn Sie eine Weile warten (dh Animation, Transition, PauseTransition, etc.), wird eine leere Seite gerendert.

Dies deutet darauf hin, dass im WebView ein Ereignis auftritt, das es für eine Erfassung vorbereitet. Was ist das?

Es gibt über 7.000 Code-Schnipsel auf GitHub, die verwendet werden,SwingFXUtils.fromFXImage aber die meisten scheinen entweder nicht in Beziehung zu WebViewstehen, interaktiv zu sein (der Mensch maskiert die Rassenbedingungen) oder willkürliche Übergänge zu verwenden (irgendwo zwischen 100 ms und 2.000 ms).

Ich habe es versucht:

  • Abhören changed(...)innerhalb der WebViewDimensionen des Geräts ( DoublePropertyGeräte für Höhen- und Breiteneigenschaften ObservableValue, die diese Dinge überwachen können)

    • 🚫Nicht lebensfähig. Manchmal scheint sich der Wert getrennt von der Malroutine zu ändern, was zu einem Teilinhalt führt.
  • Blind alles und jeden runLater(...)im FX Application Thread erzählen .

    • 🚫Viele Techniken verwenden dies, aber meine eigenen Komponententests (sowie einige großartige Rückmeldungen von anderen Entwicklern) erklären, dass Ereignisse häufig bereits im richtigen Thread sind und dieser Aufruf überflüssig ist. Das Beste, was ich mir vorstellen kann, ist, dass die Warteschlange gerade so verzögert wird, dass es für einige funktioniert.
  • Hinzufügen eines DOM-Listeners / -Triggers oder JavaScript-Listeners / -Triggers zum WebView

    • 🚫 Sowohl JavaScript als auch das DOM scheinen ordnungsgemäß geladen zu sein, wenn SUCCEEDEDes trotz der leeren Erfassung aufgerufen wird. DOM / JavaScript-Listener scheinen nicht zu helfen.
  • Verwenden eines Animationoder, Transitionum effektiv zu "schlafen", ohne den Haupt-FX-Thread zu blockieren.

    • ⚠️ Dieser Ansatz funktioniert und wenn die Verzögerung lang genug ist, können bis zu 100% der Komponententests durchgeführt werden. Die Übergangszeiten scheinen jedoch ein zukünftiger Moment zu sein, den wir nur erraten und das Design schlecht ist. Für performante oder geschäftskritische Anwendungen zwingt dies den Programmierer, einen Kompromiss zwischen Geschwindigkeit oder Zuverlässigkeit einzugehen, was für den Benutzer eine potenziell schlechte Erfahrung ist.

Wann ist ein guter Zeitpunkt, um anzurufen? WebView.snapshot(...) ?

Verwendungszweck:

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

Code-Auszug:

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

Verbunden:

tresf
quelle
Platform.runLater ist nicht redundant. Möglicherweise stehen Ereignisse aus, die erforderlich sind, damit WebView das Rendern abschließt. Platform.runLater ist das erste, was ich versuchen würde.
VGR
Das Rennen sowie die Unit-Tests legen nahe, dass die Ereignisse nicht anstehen, sondern in einem separaten Thread stattfinden. Platform.runLaterwurde getestet und behebt es nicht. Bitte versuchen Sie es selbst, wenn Sie nicht einverstanden sind. Ich würde mich freuen, falsch zu liegen, es würde das Problem schließen.
Tresf
Darüber hinaus stellen die offiziellen Dokumente fest, dass der SUCCEEDEDStatus (von dem der Listener auf den FX-Thread feuert) die richtige Technik ist. Wenn es eine Möglichkeit gibt, Ereignisse in der Warteschlange anzuzeigen, würde ich mich freuen, es zu versuchen. Ich habe spärliche Vorschläge durch Kommentare in den Oracle-Foren und einige SO-Fragen gefunden, die von WebViewNatur aus in einem eigenen Thread ausgeführt werden müssen. Nach Tagen des Testens konzentriere ich mich dort auf Energie. Wenn diese Annahme falsch ist, großartig. Ich bin offen für vernünftige Vorschläge, die das Problem ohne willkürliche Wartezeiten beheben.
Tresf
Ich habe meinen eigenen sehr kurzen Test geschrieben und konnte erfolgreich einen Snapshot einer WebView im Status-Listener des Load Workers abrufen. Aber Ihr Programm gibt mir eine leere Seite. Ich versuche immer noch, den Unterschied zu verstehen.
VGR
Es scheint, dass dies nur bei Verwendung einer loadContentMethode oder beim Laden einer Datei-URL geschieht .
VGR

Antworten:

1

Es scheint, dass dies ein Fehler ist, der bei der Verwendung der WebEngine- loadContentMethoden auftritt . Es tritt auch auf, wenn loadeine lokale Datei geladen wird. In diesem Fall wird jedoch reload () aufgerufen. ausgeglichen.

Da die Bühne angezeigt werden muss, wenn Sie einen Schnappschuss machen, müssen Sie show()vor dem Laden des Inhalts anrufen . Da Inhalte asynchron geladen werden, ist es durchaus möglich, dass sie vor der Anweisung nach dem Aufruf von loadoder geladen werdenloadContent beendet wird.

Die Problemumgehung besteht also darin, den Inhalt in eine Datei zu platzieren und die WebEngine aufzurufen reload() Methode genau einmal . Beim zweiten Laden des Inhalts kann ein Snapshot erfolgreich von einem Listener der State-Eigenschaft des Load Workers erstellt werden.

Normalerweise wäre das einfach:

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

Da Sie jedoch staticfür alles verwenden, müssen Sie einige Felder hinzufügen:

private static boolean reloaded;
private static volatile Path htmlFile;

Und Sie können sie hier verwenden:

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

Und dann müssen Sie es jedes Mal zurücksetzen, wenn Sie Inhalte laden:

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

Beachten Sie, dass es bessere Möglichkeiten gibt, eine Multithread-Verarbeitung durchzuführen. Anstatt Atomklassen zu verwenden, können Sie einfach volatileFelder verwenden:

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

(Boolesche Felder sind standardmäßig falsch und Objektfelder sind standardmäßig null. Anders als in C-Programmen ist dies eine harte Garantie von Java; es gibt keinen nicht initialisierten Speicher.)

Anstatt in einer Schleife nach Änderungen zu suchen, die in einem anderen Thread vorgenommen wurden, ist es besser, die Synchronisation, eine Sperre oder eine übergeordnete Klasse wie CountDownLatch zu verwenden, die diese Dinge intern verwendet:

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 wird nicht als flüchtig deklariert, da nur im JavaFX-Anwendungsthread darauf zugegriffen wird.

VGR
quelle
1
Dies ist eine sehr schöne Beschreibung, insbesondere die Codeverbesserungen rund um das Threading und die volatileVariablen. Leider funktioniert das Anrufen WebEngine.reload()und Warten auf eine Folge SUCCEEDEDnicht. Wenn ich einen Zähler in den HTML-Inhalt setze, erhalte ich Folgendes: 0, 0, 1, 3, 3, 5anstatt darauf 0, 1, 2, 3, 4, 5hinzuweisen, dass die zugrunde liegende Race-Bedingung dadurch nicht behoben wird.
Tresf
Zitat: "besser zu verwenden [...] CountDownLatch". Upvoting, da diese Informationen nicht leicht zu finden waren und die Geschwindigkeit und Einfachheit des Codes beim ersten FX-Start erhöht.
Tresf
0

Um die Größenänderung sowie das zugrunde liegende Snapshot-Verhalten zu berücksichtigen, habe ich (wir) die folgende funktionierende Lösung entwickelt. Beachten Sie, dass diese Tests 2.000-mal ausgeführt wurden (Windows, MacOS und Linux) und zufällige WebView-Größen mit 100% Erfolg lieferten.

Zuerst zitiere ich einen der JavaFX-Entwickler. Dies wird aus einem privaten (gesponserten) Fehlerbericht zitiert:

"Ich gehe davon aus, dass Sie die Größenänderung des FX AppThread initiieren und dass dies nach Erreichen des SUCCEEDED-Status erfolgt. In diesem Fall scheint es mir, dass in diesem Moment das Warten von 2 Impulsen (ohne das FX AppThread zu blockieren) das geben sollte Die Implementierung des Webkits hat genügend Zeit, um Änderungen vorzunehmen, es sei denn, dies führt dazu, dass einige Dimensionen in JavaFX geändert werden, was wiederum dazu führen kann, dass die Dimensionen im Webkit geändert werden.

Ich denke darüber nach, wie ich diese Informationen in die Diskussion in JBS einspeisen kann, aber ich bin mir ziemlich sicher, dass die Antwort lautet: "Sie sollten nur dann einen Schnappschuss machen, wenn die Webkomponente stabil ist." Um diese Antwort vorwegzunehmen, wäre es gut zu sehen, ob dieser Ansatz für Sie funktioniert. Wenn sich herausstellt, dass andere Probleme auftreten, sollten Sie über diese Probleme nachdenken und prüfen, ob und wie sie in OpenJFX selbst behoben werden können. "

  1. Standardmäßig verwendet JavaFX 8 die Standardeinstellung, 600wenn die Höhe genau ist 0. Die Wiederverwendung von Code WebViewsollte verwendet werden setMinHeight(1), setPrefHeight(1)um dieses Problem zu vermeiden. Dies ist nicht im folgenden Code enthalten, aber für jeden, der es an sein Projekt anpasst, erwähnenswert.
  2. Warten Sie in einem Animations-Timer auf genau zwei Impulse, um der Bereitschaft von WebKit gerecht zu werden.
  3. Nutzen Sie den Snapshot-Rückruf, der auch auf einen Impuls wartet, um den Fehler beim Löschen des Snapshots zu vermeiden.
// 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
quelle