Quando um WebView está pronto para uma captura instantânea ()?

9

O JavaFX docs estado que um WebViewestá pronto quando Worker.State.SUCCEEDEDé atingido no entanto, a menos que você esperar um pouco (ou seja Animation, Transition, PauseTransition, etc.), uma página em branco é processado.

Isso sugere que há um evento que ocorre dentro do WebView, preparando-o para uma captura, mas o que é?

Existem mais de 7.000 trechos de código no GitHub que usam,SwingFXUtils.fromFXImage mas a maioria deles parece não estar relacionada WebView, são interativos (humanos mascara a condição de corrida) ou usam transições arbitrárias (de 100 a 2.000ms).

Eu tentei:

  • Ouvindo changed(...)de dentro das WebViewdimensões do ( DoublePropertyimplementações de propriedades de altura e largura ObservableValue, que podem monitorar essas coisas)

    • Não é viável. Às vezes, o valor parece mudar separadamente da rotina de pintura, levando ao conteúdo parcial.
  • Dizendo cegamente tudo e qualquer coisa runLater(...)no Thread do Aplicativo FX.

    • TechniquesMuitas técnicas usam isso, mas meus próprios testes de unidade (bem como ótimos comentários de outros desenvolvedores) explicam que os eventos já estão no thread certo e essa chamada é redundante. O melhor que consigo pensar é adicionar um atraso suficiente nas filas para que funcione para alguns.
  • Incluindo um ouvinte / gatilho DOM ou um ouvinte / gatilho JavaScript no WebView

    • JavaScript Ambos JavaScript e DOM parecem estar carregados corretamente quando SUCCEEDEDsão chamados, apesar da captura em branco. Ouvintes DOM / JavaScript não parecem ajudar.
  • Usando um Animationou Transitionpara "dormir" efetivamente sem bloquear o thread FX principal.

    • ⚠️ Essa abordagem funciona e, se o atraso for longo o suficiente, pode render até 100% dos testes de unidade, mas os tempos de transição parecem ser um momento futuro que estamos apenas adivinhando e com um design ruim. Para aplicativos de desempenho ou de missão crítica, isso força o programador a fazer uma troca entre velocidade ou confiabilidade, uma experiência potencialmente ruim para o usuário.

Quando é um bom momento para ligar WebView.snapshot(...)?

Uso:

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

Fragmento de código:

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

Relacionado:

tresf
fonte
Platform.runLater não é redundante. Pode haver eventos pendentes necessários para o WebView concluir sua renderização. Platform.runLater é a primeira coisa que eu tentaria.
VGR 18/01
A corrida e os testes de unidade sugerem que os eventos não estão pendentes, mas ocorrem em um segmento separado. Platform.runLaterfoi testado e não o corrige. Por favor, tente você mesmo se você não concorda. Eu ficaria feliz em estar errado, isso resolveria o problema.
tresf 19/01
Além disso, os documentos oficiais encenam o SUCCEEDEDestado (do qual o ouvinte dispara no encadeamento FX) é a técnica adequada. Se houver uma maneira de mostrar eventos na fila, eu ficaria feliz em tentar. Encontrei sugestões esparsas através de comentários nos fóruns da Oracle e algumas perguntas de SO que WebViewdevem ser executadas em seu próprio encadeamento, por isso, depois de dias de teste, concentro a energia lá. Se essa suposição estiver errada, ótimo. Estou aberto a sugestões razoáveis ​​que resolvam o problema sem tempos de espera arbitrários.
tresf 19/01
Eu escrevi meu próprio teste muito curto e consegui obter com êxito uma captura instantânea de um WebView no ouvinte de estado do trabalhador de carregamento. Mas seu programa me dá uma página em branco. Eu ainda estou tentando entender a diferença.
VGR 19/01
Parece que isso acontece apenas ao usar um loadContentmétodo ou ao carregar um URL de arquivo.
VGR 19/01

Respostas:

1

Parece que este é um erro que ocorre ao usar os loadContentmétodos do WebEngine . Também ocorre ao usar loadpara carregar um arquivo local, mas, nesse caso, chamar reload () compensará isso.

Além disso, como o Palco precisa ser exibido quando você tira uma captura instantânea, é necessário ligar show()antes de carregar o conteúdo. Como o conteúdo é carregado de forma assíncrona, é perfeitamente possível que ele seja carregado antes da instrução após a chamada loadou loadContentterminar.

A solução alternativa, então, é colocar o conteúdo em um arquivo e chamar o reload()método do WebEngine exatamente uma vez. Na segunda vez que o conteúdo é carregado, uma captura instantânea pode ser obtida com êxito de um ouvinte da propriedade de estado do trabalhador de carregamento.

Normalmente, isso seria fácil:

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

Mas, como você está usando statictudo, precisará adicionar alguns campos:

private static boolean reloaded;
private static volatile Path htmlFile;

E você pode usá-los aqui:

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

E você precisará redefini-lo sempre que carregar o conteúdo:

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

Observe que existem maneiras melhores de executar o processamento multithread. Em vez de usar classes atômicas, você pode simplesmente usar os volatilecampos:

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

(os campos booleanos são falsos por padrão e os campos de objetos são nulos por padrão. Ao contrário dos programas C, essa é uma garantia garantida pelo Java; não existe memória não inicializada.)

Em vez de pesquisar em um loop as alterações feitas em outro encadeamento, é melhor usar a sincronização, um bloqueio ou uma classe de nível superior como CountDownLatch, que usa essas coisas internamente:

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 não é declarado volátil porque é acessado apenas no encadeamento de aplicativo JavaFX.

VGR
fonte
11
Essa é uma descrição muito interessante, especialmente as melhorias de código em torno do encadeamento e das volatilevariáveis. Infelizmente, ligar WebEngine.reload()e aguardar um subsequente SUCCEEDEDnão funciona. Se eu colocar um contador no conteúdo HTML, recebo: em 0, 0, 1, 3, 3, 5vez de 0, 1, 2, 3, 4, 5sugerir que ele realmente não corrige a condição de corrida subjacente.
tresf 20/01
Citação: "melhor usar [...] CountDownLatch". Voto positivo porque essa informação não foi fácil de encontrar e ajuda a acelerar e a simplicidade do código com a inicialização inicial do FX.
tresf 31/01
0

Para acomodar o redimensionamento, bem como o comportamento subjacente do instantâneo, eu (nós) apresentamos a seguinte solução de trabalho. Observe que esses testes foram executados 2.000x (Windows, macOS e Linux), fornecendo tamanhos aleatórios do WebView com 100% de sucesso.

Primeiro, citarei um dos desenvolvedores JavaFX. Isso é citado em um relatório de bug privado (patrocinado):

"Suponho que você inicie o redimensionamento no FX AppThread, e que seja feito após o estado SUCCEEDED. Nesse caso, parece-me que naquele momento, aguardar 2 pulsos (sem bloquear o FX AppThread) deve dar o implementação do webkit tempo suficiente para fazer suas alterações, a menos que isso resulte na alteração de algumas dimensões no JavaFX, o que pode resultar novamente na alteração de dimensões dentro do webkit.

Estou pensando em como alimentar essas informações na discussão da JBS, mas tenho certeza de que haverá a resposta de que "você deve tirar uma foto instantânea apenas quando o componente da web estiver estável". Portanto, para antecipar esta resposta, seria bom verificar se essa abordagem funciona para você. Ou, se isso causar outros problemas, seria bom pensar sobre esses problemas e ver se / como eles podem ser corrigidos no OpenJFX. "

  1. Por padrão, o JavaFX 8 usa um padrão 600se height for exatamente 0. Código reutilização WebViewdeve usar setMinHeight(1), setPrefHeight(1)para evitar este problema. Isso não está no código abaixo, mas vale a pena mencionar para qualquer pessoa que o adaptar ao seu projeto.
  2. Para acomodar a disponibilidade do WebKit, aguarde exatamente dois pulsos de dentro de um cronômetro de animação.
  3. Para evitar o bug em branco da captura instantânea, aproveite o retorno de chamada da captura instantânea, que também escuta um pulso.
// 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
fonte