Когда WebView готов к снимку ()? - PullRequest
9 голосов
/ 18 января 2020

Документы JavaFX заявляют, что WebView готово, когда Worker.State.SUCCEEDED достигнуто, однако , если только вы не подождете некоторое время (то есть Animation, Transition, PauseTransition и др. c.), отображается пустая страница.

Это говорит о том, что внутри WebView происходит событие, готовящее его к захвату, но что это такое?

Есть сверх 7 000 фрагментов кода на GitHub, которые используют SwingFXUtils.fromFXImage, но большинство из них, по-видимому, либо не связаны с WebView, либо интерактивны (человек маскирует состояние гонки), либо используют произвольные переходы (в любом месте от 100 мс до 2000 мс).

Я пробовал:

  • Прослушивание changed(...) в пределах измерений WebView (свойства высоты и ширины DoubleProperty реализует ObservableValue, который может контролировать эти вещи)

    • otНе жизнеспособно. Иногда кажется, что значение меняется отдельно от процедуры рисования, что приводит к частичному содержанию.
  • Слепо говоря что-либо и все до runLater(...) на Тема приложения FX.

    • thisМногие методы используют это, но мои собственные модульные тесты (а также некоторые отличные отзывы от других разработчиков) объясняют, что события часто уже находятся в нужном потоке, и этот вызов является избыточным. Лучшее, что я могу придумать, - это добавить лишь достаточную задержку в очереди, чтобы она работала для некоторых.
  • Добавление слушателя / триггера DOM или JavaScript слушателя / триггера для WebView

    • othBoth JavaScript и DOM, кажется, загружаются правильно, когда вызывается SUCCEEDED несмотря на пустой захват. Слушатели DOM / JavaScript, похоже, не помогают.
  • Использование Animation или Transition для эффективного «сна» без блокировки основного потока FX.

    • ⚠️ Этот подход работает и, если задержка достаточно велика, может дать до 100% модульных тестов, но времена перехода кажутся некоторым будущим моментом, о котором мы только догадываемся и плохой дизайн. Для высокопроизводительных или критически важных приложений это вынуждает программиста находить компромисс между скоростью и надежностью, что является потенциально плохим опытом для пользователя.

Когда это хорошее время для звонка WebView.snapshot(...)?

Использование:

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

Фрагмент кода:

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

Связанный:

Ответы [ 2 ]

1 голос
/ 19 января 2020

Кажется, это ошибка, возникающая при использовании методов loadContent WebEngine. Это также происходит при использовании load для загрузки локального файла, но в этом случае вызов reload () компенсирует это.

Кроме того, поскольку сцена должна отображаться, когда Вы делаете снимок, вам нужно позвонить show() перед загрузкой содержимого. Поскольку контент загружается асинхронно, вполне возможно, что он будет загружен до завершения оператора, следующего за вызовом load или loadContent.

Временное решение заключается в размещении контента в файле. и вызовите метод reload() WebEngine ровно один раз. Во второй раз, когда содержимое загружается, снимок может быть успешно взят из прослушивателя свойства состояния загрузчика.

Обычно это будет легко:

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

Но поскольку вы используя static для всего, вам нужно будет добавить несколько полей:

private static boolean reloaded;
private static volatile Path htmlFile;

И вы можете использовать их здесь:

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

И тогда вам придется сбросить его каждый раз при загрузке контента:

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

Обратите внимание, что существуют более эффективные способы выполнения многопоточной обработки. Вместо использования классов Atomi c вы можете просто использовать volatile поля:

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

(логические поля по умолчанию имеют значение false, а объектные поля по умолчанию равны нулю. В отличие от C программ, Это жесткая гарантия, предоставляемая Java, не существует такой вещи, как неинициализированная память.)

Вместо опроса в al oop изменений, внесенных в другом потоке, лучше использовать синхронизацию, блокировку или класс более высокого уровня, такой как CountDownLatch , который использует эти вещи внутренне:

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 не объявлен как энергозависимый, поскольку к нему обращаются только в потоке приложения JavaFX.

0 голосов
/ 18 марта 2020

Чтобы приспособиться к изменению размера, а также к базовому поведению снимка, я (мы) разработал следующее рабочее решение. Обратите внимание, что эти тесты выполнялись в 2000x (Windows, macOS и Linux), обеспечивая случайные размеры WebView с 100% успехом.

Сначала я процитирую одного из разработчиков JavaFX. Это цитируется в частном (спонсируемом) отчете об ошибке:

"Я предполагаю, что вы инициируете изменение размера в FX AppThread и это делается после достижения состояния SUCCEEDED. В этом случае оно Мне кажется, что в этот момент ожидание двух импульсов (без блокировки FX AppThread) должно дать реализации webkit достаточно времени для внесения изменений, если только это не приведет к изменению некоторых измерений в JavaFX, что может снова привести к изменению измерений внутри webkit.

Я думаю о том, как вставить эту информацию в обсуждение в JBS, но я почти уверен, что будет ответ, что «вы должны делать снимок только тогда, когда веб-компонент стабилен». Таким образом, чтобы предвидеть этот ответ, было бы неплохо увидеть, работает ли этот подход для вас, или, если окажется, что он вызывает другие проблемы, было бы хорошо подумать об этих проблемах и посмотреть, если / как они могут быть исправлено в самом OpenJFX. "

  1. По умолчанию JavaFX 8 использует значение по умолчанию 600 если высота точно 0. Повторное использование кода WebView должно использовать setMinHeight(1), setPrefHeight(1), чтобы избежать этой проблемы. Этого нет в приведенном ниже коде, но стоит упомянуть любого, кто адаптирует его к своему проекту.
  2. Чтобы обеспечить готовность WebKit, подождите ровно два импульса изнутри таймера анимации.
  3. Чтобы предотвратить ошибку пустого снимка, используйте обратный вызов снимка, который также прослушивает импульс.
// 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();
    }
});
...