什么时候可以对WebView进行快照()操作?

10
JavaFX文档指出,当达到Worker.State.SUCCEEDED状态时,WebView已准备就绪,但是,除非您等待一段时间(即AnimationTransitionPauseTransition等),否则会呈现空白页面。
这表明在WebView中有一个事件,使其准备好进行捕捉,但是它是什么?
GitHub上有超过7,000个代码片段使用了SwingFXUtils.fromFXImage,但它们中的大多数似乎与WebView无关,是交互式的(人为地遮盖了竞态条件)或使用任意转换(从100毫秒到2,000毫秒不等)。
我已经尝试过:
  • WebView的尺寸(高度和宽度属性DoubleProperty实现了ObservableValue,可以监控这些内容)内侦听changed(...)

    • 不可行。有时,该值似乎与绘制例程分开更改,导致部分内容。
  • 盲目地告诉任何事物都要在FX应用程序线程上runLater(...)

    • 许多技术使用此方法,但我的单元测试(以及其他开发人员的反馈)表明事件通常已经在正确的线程上,这个调用是冗余的。我能想到的最好的方法是通过排队添加足够的延迟时间,这对一些情况有效。
  • WebView添加DOM侦听器/触发器或JavaScript侦听器/触发器

    • 尽管捕获为空,但在调用SUCCEEDED时,JavaScript和DOM似乎都已加载。DOM / JavaScript侦听器似乎没有帮助。
  • 使用AnimationTransition来有效地“休眠”,而不会阻塞主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();
    }
}

相关:


Platform.runLater 不是多余的。可能存在某些待处理事件,这些事件对于 WebView 完成其渲染是必要的。我会首先尝试使用 Platform.runLater。 - VGR
竞赛以及单元测试表明事件不是挂起的,而是在另一个线程中发生。已经测试了 Platform.runLater 并且没有解决问题。如果您不同意,请自行尝试。如果我错了,我会很高兴关闭这个问题。 - tresf
此外,官方文档阶段SUCCEEDED状态(其中侦听器在FX线程上触发)是正确的技术。如果有一种方法可以显示排队的事件,我会很高兴尝试。我在Oracle论坛的评论和一些SO问题中找到了零散的建议,即WebView必须按设计在其自己的线程中运行,因此经过数天的测试,我正在集中精力解决这个问题。如果这个假设是错误的,那太好了。我愿意接受任何合理的建议,以在没有任意等待时间的情况下解决问题。 - tresf
我编写了自己的非常简短的测试,并成功地在加载工作器状态侦听器中获取了 WebView 的快照。但是你的程序给了我一个空白页面。我仍在努力理解其中的区别。 - VGR
似乎只有在使用loadContent方法或加载文件URL时才会发生这种情况。 - VGR
显示剩余2条评论
2个回答

1
为了适应调整大小以及基础快照行为,我(我们)想出了以下解决方案。请注意,这些测试在Windows、macOS和Linux上随机提供WebView大小并运行2,000次,成功率达到100%。
首先,我将引用JavaFX的一位开发人员的话。这是从私人(赞助)错误报告中引用的:
“我假设您在FX AppThread上启动了调整大小,并且在达到SUCCEEDED状态后完成了调整。在这种情况下,我认为此时等待2个脉冲(而不阻塞FX AppThread)应该给webkit实现足够的时间来进行其更改,除非这会导致某些尺寸在JavaFX中被更改,这可能会再次导致在webkit内部更改尺寸。
我正在考虑如何将这些信息注入JBS讨论中,但我相当肯定会有答案:“您应该仅在webcomponent稳定时拍摄快照”。因此,为了预见这个答案,看看这种方法是否适用于您是很好的选择。或者,如果它导致其他问题,那么考虑这些问题,并看看如何在OpenJFX本身中修复它们会很好。”
默认情况下,JavaFX 8 如果高度为 0,则使用默认值 600。重用 WebView 的代码应该使用 setMinHeight(1)setPrefHeight(1) 来避免这个问题。虽然下面的代码没有包含这些内容,但对于任何需要将其适配到自己项目中的人来说都是值得一提的。 为了适应WebKit的准备就绪,等待动画计时器内部恰好两个脉冲。 为了防止快照空白错误,利用快照回调,它也会监听脉冲。
// 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();
    }
});

1
似乎这是使用WebEngine的loadContent方法时出现的错误。当加载本地文件时,也会出现此问题,但在这种情况下,调用reload()方法可以弥补它。
此外,由于需要在显示Stage时拍摄快照,因此需要在加载内容之前调用show()方法。由于内容是异步加载的,所以完全有可能在调用load或loadContent后的语句完成之前加载内容。
因此,解决方法是将内容放入文件中,并仅调用一次WebEngine的reload()方法。第二次加载内容时,可以从加载工作器状态属性的侦听器成功地拍摄快照。
通常,这很容易:
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,对象字段默认为空。与C程序不同,这是Java所做的硬性保证;不存在未初始化的内存。)

与其在循环中轮询另一个线程所做的更改,不如使用同步、锁或像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 应用程序线程中被访问。


1
这是一篇非常好的文章,特别是关于线程和volatile变量的代码改进。不幸的是,调用WebEngine.reload()并等待后续的SUCCEEDED并不能解决问题。如果我在HTML内容中放置一个计数器,我会收到:0, 0, 1, 3, 3, 5而不是0, 1, 2, 3, 4, 5,这表明它实际上并没有解决潜在的竞态条件。 - tresf
建议使用 CountDownLatch,这样可以加快和简化代码的初始 FX 启动。感谢您的点赞,因为这些信息并不容易找到。 - tresf

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接