Kiedy WebView jest gotowy na migawkę ()?

9

JavaFX Docs państwo, że WebViewjest gotowy, kiedy Worker.State.SUCCEEDEDzostanie osiągnięta jednak chyba trochę poczekać (czyli Animation, Transition, PauseTransition, itd.), Puste strony są renderowane.

Sugeruje to, że wewnątrz WebView występuje zdarzenie, które przygotowuje je do przechwytywania, ale co to jest?

Na GitHubSwingFXUtils.fromFXImage znajduje się ponad 7000 fragmentów kodu, które wykorzystują, ale większość z nich wydaje się być niezwiązana z nimi WebView, są interaktywne (człowiek maskuje warunki wyścigu) lub używają dowolnych przejść (od 100 ms do 2000 ms).

Próbowałem:

  • Słuchanie w changed(...)obrębie WebViewwymiarów ( DoublePropertyimplementowane są właściwości wysokości i szerokości ObservableValue, które mogą monitorować te rzeczy)

    • OtNie opłacalne. Czasami wartość wydaje się zmieniać niezależnie od procedury malowania, co prowadzi do częściowej zawartości.
  • Ślepe mówienie wszystkiego i wszystkiego runLater(...)w wątku aplikacji FX.

    • Use Wykorzystuje to wiele technik, ale moje własne testy jednostkowe (a także świetne opinie od innych programistów) wyjaśniają, że zdarzenia często są już na właściwym wątku i to wywołanie jest zbędne. Najlepsze, co mogę wymyślić, to wystarczające opóźnienie w kolejce, które działa dla niektórych.
  • Dodanie detektora / wyzwalacza DOM lub detektora / wyzwalacza JavaScript do WebView

    • Oth Oba skrypty JavaScript i DOM wydają się być ładowane poprawnie, gdy SUCCEEDEDsą wywoływane pomimo pustego przechwytywania. Słuchacze DOM / JavaScript nie wydają się pomagać.
  • Używanie Animationlub Transitiondo efektywnego „uśpienia” bez blokowania głównego wątku FX.

    • Approach Takie podejście działa i jeśli opóźnienie jest wystarczająco długie, może dać do 100% testów jednostkowych, ale czasy przejścia wydają się być przyszłym momentem, który tylko zgadujemy i źle zaprojektowaliśmy. W przypadku wydajnych lub krytycznych aplikacji zmusza to programistę do kompromisu między szybkością a niezawodnością, co jest potencjalnie złym doświadczeniem dla użytkownika.

Kiedy jest dobry moment na telefon WebView.snapshot(...)?

Stosowanie:

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

Fragment kodu:

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

Związane z:

tresf
źródło
Platform.runLater nie jest redundantny. Mogą istnieć zdarzenia oczekujące, które są niezbędne do ukończenia renderowania przez WebView. Platform.runLater to pierwsza rzecz, której spróbuję.
VGR
Wyścig oraz testy jednostkowe sugerują, że wydarzenia nie są w toku, a raczej mają miejsce w osobnym wątku. Platform.runLaterzostał przetestowany i nie naprawia go. Spróbuj tego sam, jeśli się nie zgadzasz. Byłbym szczęśliwy, że się mylę, to zamknę problem.
tresf
Co więcej, oficjalna dokumentacja pokazuje SUCCEEDEDstan (którego słuchacz strzela w wątku FX) jest właściwą techniką. Jeśli istnieje sposób na pokazanie wydarzeń w kolejce, byłbym podekscytowany, aby spróbować. Znalazłem rzadkie sugestie poprzez komentarze na forach Oracle i niektóre pytania SO, które WebViewmuszą być projektowane w swoim własnym wątku, więc po dniach testowania skupiam tam energię. Jeśli to założenie jest błędne, świetnie. Jestem otwarty na wszelkie rozsądne sugestie, które naprawią problem bez arbitralnych czasów oczekiwania.
tresf
Napisałem własny bardzo krótki test i udało mi się uzyskać migawkę WebView w detektorze stanów programu ładującego. Ale twój program daje mi pustą stronę. Wciąż próbuję zrozumieć różnicę.
VGR
Wydaje się, że dzieje się tak tylko podczas korzystania z loadContentmetody lub ładowania pliku URL.
VGR

Odpowiedzi:

1

Wygląda na to, że jest to błąd występujący podczas korzystania z loadContentmetod WebEngine . Występuje również przy loadładowaniu pliku lokalnego, ale w takim przypadku wywołanie reload () zrekompensuje to.

Ponadto, ponieważ stół montażowy musi się wyświetlać podczas robienia migawki, musisz wywołać show()przed załadowaniem zawartości. Ponieważ treść jest ładowana asynchronicznie, jest całkowicie możliwe, że zostanie załadowana przed instrukcją po wywołaniu loadlub loadContentzakończeniu.

Obejściem tego problemu jest umieszczenie zawartości w pliku i wywołanie metody WebEngine reload()dokładnie raz. Podczas drugiego ładowania treści można pomyślnie wykonać migawkę z obiektu nasłuchującego właściwości stanu pracownika ładującego.

Zwykle byłoby to łatwe:

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

Ale ponieważ używasz staticdo wszystkiego, musisz dodać kilka pól:

private static boolean reloaded;
private static volatile Path htmlFile;

Możesz ich użyć tutaj:

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

A potem będziesz musiał go zresetować za każdym razem, gdy ładujesz zawartość:

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

Należy pamiętać, że istnieją lepsze sposoby wykonywania przetwarzania wielowątkowego. Zamiast używać klas atomowych, możesz po prostu użyć volatilepól:

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

(pola boolowskie są domyślnie fałszywe, a pola obiektowe są domyślnie zerowe. W przeciwieństwie do programów w C, jest to twarda gwarancja złożona przez Javę; nie ma czegoś takiego jak niezainicjowana pamięć).

Zamiast odpytywania w pętli o zmiany dokonane w innym wątku, lepiej jest użyć synchronizacji, blokady lub klasy wyższego poziomu, takiej jak CountDownLatch, która używa tych rzeczy wewnętrznie:

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 nie jest deklarowany jako niestabilny, ponieważ jest dostępny tylko w wątku aplikacji JavaFX.

VGR
źródło
1
Jest to bardzo miły opis, szczególnie ulepszenia kodu dotyczące wątków i volatilezmiennych. Niestety dzwonienie WebEngine.reload()i oczekiwanie na kolejne SUCCEEDEDnie działa. Jeśli umieszczę licznik w treści HTML, otrzymuję: 0, 0, 1, 3, 3, 5zamiast 0, 1, 2, 3, 4, 5, sugerując, że tak naprawdę nie naprawia podstawowych warunków wyścigu.
tresf
Cytat: „lepiej używać [...] CountDownLatch”. Uaktualnienie, ponieważ ta informacja nie była łatwa do znalezienia i pomaga w szybkości i prostocie kodu przy początkowym uruchomieniu FX.
tresf
0

Aby dostosować rozmiar, jak również podstawowe zachowanie migawki, ja (my) opracowałem następujące działające rozwiązanie. Uwaga: testy te przeprowadzono 2000 razy (Windows, macOS i Linux), zapewniając losowe rozmiary WebView ze 100% powodzeniem.

Najpierw zacytuję jednego z deweloperów JavaFX. Jest to cytowane z prywatnego (sponsorowanego) raportu o błędzie:

„Zakładam, że inicjujesz zmianę rozmiaru w FX AppThread i że odbywa się to po osiągnięciu stanu SUCCEEDED. W takim przypadku wydaje mi się, że w tym momencie odczekanie 2 impulsów (bez blokowania FX AppThread) powinno dać implementacja pakietu Webkit wystarczająca ilość czasu na wprowadzenie zmian, chyba że spowoduje to zmianę niektórych wymiarów w JavFX, co może spowodować zmianę wymiarów wewnątrz pakietu.

Zastanawiam się, jak wprowadzić te informacje do dyskusji w JBS, ale jestem prawie pewien, że pojawi się odpowiedź, że „powinieneś zrobić migawkę tylko wtedy, gdy webcomponent jest stabilny”. Aby więc przewidzieć tę odpowiedź, dobrze byłoby sprawdzić, czy to podejście działa dla Ciebie. Lub, jeśli okaże się, że powoduje to inne problemy, dobrze byłoby pomyśleć o tych problemach i sprawdzić, czy / jak można je naprawić w samym OpenJFX. ”

  1. Domyślnie JavaFX 8 używa domyślnej wartości, 600jeśli wysokość jest dokładnie 0. Kod Ponowne WebViewnależy używać setMinHeight(1), setPrefHeight(1)aby uniknąć tego problemu. Nie ma tego w poniższym kodzie, ale warto o tym wspomnieć dla każdego, kto dostosowuje go do swojego projektu.
  2. Aby uwzględnić gotowość WebKit, poczekaj dokładnie dwa impulsy z wnętrza timera animacji.
  3. Aby zapobiec pustemu błędowi migawki, skorzystaj z wywołania zwrotnego migawki, które również nasłuchuje impulsu.
// 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
źródło