WebView何时准备好使用snapshot()?


9

JavaFX的文档状态,一个WebView当准备Worker.State.SUCCEEDED达到但是,除非你稍等片刻(即AnimationTransitionPauseTransition等等),一个空白页面的呈现方式。

这表明在WebView内部发生了一个事件,准备将其捕获,但这是什么?

GitHub上SwingFXUtils.fromFXImage7,000多个使用的代码片段,但其中大多数似乎与无关WebView,是交互式的(人为掩盖种族条件)或使用任意的Transitions(从100ms到2,000ms的任何地方)。

我试过了:

  • 在的尺寸changed(...)范围内进行监听WebView(height和width属性DoubleProperty实现ObservableValue,可以监视这些东西)

    • 🚫不可行。有时,该值似乎与绘制例程分开更改,从而导致部分内容。
  • 盲目地告诉runLater(...)FX应用程序线程上的所有内容。

    • techniques许多技术都使用此方法,但是我自己的单元测试(以及其他开发人员的一些反馈)解释说,事件通常已经在正确的线程上,并且此调用是多余的。我能想到的最好的办法就是通过排队,对某些人起作用而增加了足够的延迟。
  • 将DOM侦听器/触发器或JavaScript侦听器/触发器添加到 WebView

    • despite SUCCEEDED尽管捕获了空白,但在调用JavaScript和DOM时似乎都正确加载了。DOM / JavaScript侦听器似乎无济于事。
  • 使用AnimationTransition来有效地“休眠”而不会阻塞主FX线程。

    • This️这种方法行之有效,如果延迟足够长,则可以产生高达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();
    }
}

有关:


Platform.runLater不是多余的。WebView可能需要完成某些事件才能完成其呈现。Platform.runLater是我要尝试的第一件事。
VGR

竞赛以及单元测试表明事件不是未决的,而是发生在单独的线程中。 Platform.runLater经过测试,无法解决。如果您不同意,请自己尝试。我很乐意犯错,这将解决问题。
tresf

此外,正式文档登台SUCCEEDED状态(侦听器在FX线程上激发状态)是适当的技术。如果有一种方法可以显示排队的事件,我很乐意尝试。通过在Oracle论坛上的评论以及一些WebView必须按设计在其自己的线程中运行的SO问题,我发现了稀疏的建议,因此经过几天的测试,我将精力集中在那里。如果这个假设是错误的,那就太好了。我愿意接受任何合理的建议来解决问题,而无需等待时间。
tresf

我编写了自己的简短测试,并且能够在装入工作程序的状态侦听器中成功获取WebView的快照。但是您的程序确实给了我空白页。我仍在尝试了解差异。
VGR

看来这仅在使用loadContent方法或加载文件URL时发生。
VGR

Answers:


1

看来这是使用WebEngine的loadContent方法时发生的错误。在load用于加载本地文件时也会发生这种情况,但是在这种情况下,调用reload()会对此进行补偿。

另外,由于拍摄快照时需要显示舞台,因此需要show()在加载内容之前先进行调用。由于内容是异步加载的,因此完全有可能在调用loadloadContent完成之后的语句之前加载内容。

然后,解决方法是将内容放置在文件中,然后reload()仅一次调用WebEngine的方法。第二次加载内容时,可以从加载程序的state属性的侦听器成功获取快照。

通常,这很容易:

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

请注意,有更好的方法来执行多线程处理。除了使用原子类,您还可以使用volatile字段:

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

(布尔字段默认情况下为false,对象字段默认情况下为null。与C程序不同,这是Java做出的硬保证;不存在未初始化的内存。)

与其在一个循环中轮询另一个线程中所做的更改,不如使用同步,一个Lock或一个更高级的类(例如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 不会声明为volatile,因为只能在JavaFX应用程序线程中访问它。


1
这是一个非常不错的文章,尤其是围绕线程和volatile变量的代码改进。不幸的是,打电话WebEngine.reload()和等待随后的电话SUCCEEDED是行不通的。如果我在HTML内容中放置一个计数器,则会收到:0, 0, 1, 3, 3, 5而不是0, 1, 2, 3, 4, 5,表明它实际上并未解决潜在的竞争条件。
tresf

Quote:“更好地使用[...] CountDownLatch”。提议是因为不容易找到此信息,并且可以在最初的FX启动中帮助提高代码的速度和简便性。
tresf

0

为了适应调整大小以及基本快照行为,我(我们)提出了以下可行的解决方案。请注意,这些测试运行了2,000倍(Windows,macOS和Linux),并提供了100%成功的随机WebView尺寸。

首先,我将引用其中一个JavaFX开发人员。这是从一个私人(赞助)的错误报告中引用的:

“我假设您在FX AppThread上启动了调整大小,并在达到SUCCEEDED状态后完成。在这种情况下,在我看来,此时等待2个脉冲(不阻塞FX AppThread)应该给webkit实现有足够的时间进行更改,除非这会导致JavaFX中的某些尺寸发生更改,这可能又导致webkit内部的尺寸发生更改。

我正在考虑如何将此信息反馈到JBS的讨论中,但是我很肯定会有答案,“只有在Web组件稳定时才应该拍摄快照”。因此,为了期待这个答案,最好看看这种方法是否对您有用。或者,如果导致其他问题,那么考虑这些问题,看看是否可以/如何在OpenJFX本身中解决这些问题将是一件好事。”

  1. 默认情况下,JavaFX 8使用默认值600if height is 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();
    }
});
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.