Kapan WebView siap untuk snapshot ()?

9

Negara JavaFX docs bahwa WebViewsiap ketika Worker.State.SUCCEEDEDtercapai Namun, kecuali jika Anda menunggu beberapa saat (yaitu Animation, Transition, PauseTransition, dll), halaman kosong yang diberikan.

Ini menunjukkan bahwa ada peristiwa yang terjadi di dalam WebView yang menyiapkannya untuk ditangkap, tetapi apakah itu?

Ada lebih dari 7.000 cuplikan kode di GitHub yang menggunakanSwingFXUtils.fromFXImage tetapi sebagian besar dari mereka tampaknya tidak berhubungan dengan WebView, bersifat interaktif (topeng manusia kondisi ras) atau menggunakan Transisi sewenang-wenang (di mana saja dari 100 ms ke 2.000 ms).

Saya sudah mencoba:

  • Mendengarkan changed(...)dari dalam WebViewdimensi ( DoublePropertyimplementasi properti tinggi dan lebar ObservableValue, yang dapat memonitor hal-hal ini)

    • 🚫Tidak layak. Terkadang, nilainya tampaknya berubah secara terpisah dari rutinitas cat, mengarah ke konten parsial.
  • Secara membabi buta mengatakan apa saja runLater(...)tentang FX Application Thread.

    • TechniquesBanyak teknik menggunakan ini, tetapi unit test saya sendiri (serta beberapa umpan balik yang bagus dari pengembang lain) menjelaskan bahwa acara sering kali berada di utas yang benar, dan panggilan ini berlebihan. Yang terbaik yang bisa saya pikirkan adalah menambahkan cukup banyak penundaan melalui antrian yang berfungsi untuk beberapa orang.
  • Menambahkan pendengar / pemicu DOM atau pendengar / pemicu JavaScript ke WebView

    • OthKedua JavaScript dan DOM tampaknya dimuat dengan benar saat SUCCEEDEDdipanggil meskipun tangkapan kosong. Pendengar DOM / JavaScript sepertinya tidak membantu.
  • Menggunakan Animationatau Transitionuntuk "tidur" secara efektif tanpa memblokir utas FX utama.

    • Approach Pendekatan ini berhasil dan jika penundaannya cukup lama, dapat menghasilkan hingga 100% dari pengujian unit, tetapi waktu Transisi tampaknya menjadi beberapa saat di masa depan yang kami duga dan desainnya buruk. Untuk aplikasi berkinerja atau misi-kritis, ini memaksa programmer untuk membuat tradeoff antara kecepatan atau keandalan, baik pengalaman yang berpotensi buruk bagi pengguna.

Kapan waktu yang tepat untuk menelepon WebView.snapshot(...)?

Pemakaian:

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

Cuplikan Kode:

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

Terkait:

tresf
sumber
Platform.runLater tidak mubazir. Mungkin ada acara yang tertunda yang diperlukan untuk WebView untuk menyelesaikan rendernya. Platform.runLater adalah hal pertama yang akan saya coba.
VGR
Perlombaan serta tes unit menunjukkan bahwa acara tidak tertunda, tetapi terjadi di utas terpisah. Platform.runLatertelah diuji dan tidak memperbaikinya. Silakan coba sendiri jika Anda tidak setuju. Saya akan senang salah, itu akan menutup masalah.
tresf
Selain itu, dokumen resmi SUCCEEDEDmenyatakan status (yang pendengarnya ditembakan melalui thread FX) adalah teknik yang tepat. Jika ada cara untuk menampilkan acara yang antri, saya akan gembira untuk mencoba. Saya telah menemukan saran yang jarang melalui komentar di forum Oracle dan beberapa pertanyaan SO yang WebViewharus dijalankan dengan desain sendiri, jadi setelah beberapa hari pengujian saya memfokuskan energi di sana. Jika anggapan itu salah, bagus. Saya terbuka untuk saran yang masuk akal yang memperbaiki masalah tanpa menunggu waktu sewenang-wenang.
tresf
Saya menulis tes saya sendiri yang sangat singkat, dan berhasil mendapatkan snapshot dari WebView di pendengar keadaan pekerja beban. Tetapi program Anda tidak memberi saya halaman kosong. Saya masih mencoba memahami perbedaannya.
VGR
Tampaknya ini hanya terjadi ketika menggunakan loadContentmetode atau saat memuat URL file.
VGR

Jawaban:

1

Tampaknya ini adalah bug yang terjadi saat menggunakan loadContentmetode WebEngine . Itu juga terjadi ketika menggunakan loaduntuk memuat file lokal, tetapi dalam kasus itu, memanggil reload () akan mengkompensasinya.

Juga, karena Panggung perlu ditampilkan ketika Anda mengambil snapshot, Anda perlu menelepon show()sebelum memuat konten. Karena konten dimuat secara tidak sinkron, sangat mungkin konten akan dimuat sebelum pernyataan setelah panggilan loadatau loadContentselesai.

Solusinya, kemudian, adalah untuk menempatkan konten dalam file, dan memanggil metode WebEngine reload()tepat sekali. Kali kedua konten dimuat, snapshot dapat diambil dengan sukses dari pendengar properti negara pekerja muat itu.

Biasanya, ini mudah:

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

Tetapi karena Anda menggunakan staticsemuanya, Anda harus menambahkan beberapa bidang:

private static boolean reloaded;
private static volatile Path htmlFile;

Dan Anda dapat menggunakannya di sini:

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

Dan Anda harus mengatur ulang setiap kali Anda memuat konten:

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

Perhatikan bahwa ada cara yang lebih baik untuk melakukan pemrosesan multithread. Alih-alih menggunakan kelas atom, Anda cukup menggunakan volatilebidang:

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

(bidang boolean salah secara default, dan bidang objek adalah null secara default. Tidak seperti dalam program C, ini adalah jaminan sulit yang dibuat oleh Java; tidak ada yang namanya memori tidak diinisialisasi.)

Alih-alih polling dalam loop untuk perubahan yang dibuat di utas lain, lebih baik menggunakan sinkronisasi, Lock, atau kelas tingkat yang lebih tinggi seperti CountDownLatch yang menggunakan hal-hal itu secara internal:

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 tidak dinyatakan volatile karena hanya diakses di utas aplikasi JavaFX.

VGR
sumber
1
Ini adalah artikel yang sangat bagus, terutama perbaikan kode yang mengelilingi threading dan volatilevariabel. Sayangnya, menelepon WebEngine.reload()dan menunggu yang berikutnya SUCCEEDEDtidak berhasil. Jika saya menempatkan penghitung di konten HTML, saya menerima: 0, 0, 1, 3, 3, 5alih-alih 0, 1, 2, 3, 4, 5, menyarankan bahwa itu tidak benar-benar memperbaiki kondisi balapan yang mendasarinya.
tresf
Kutipan: "lebih baik menggunakan [...] CountDownLatch". Diperbaharui karena informasi ini tidak mudah ditemukan dan membantu kecepatan dan kesederhanaan kode dengan permulaan FX awal.
tresf
0

Untuk mengakomodasi perubahan ukuran serta perilaku snapshot yang mendasarinya, saya (kami) datang dengan solusi kerja berikut. Catatan, tes ini dijalankan 2.000x (Windows, macOS dan Linux) memberikan ukuran WebView acak dengan keberhasilan 100%.

Pertama, saya akan mengutip salah satu pengembang JavaFX. Ini dikutip dari laporan bug pribadi (disponsori):

"Saya menganggap Anda memulai pengubahan ukuran pada FX AppThread, dan itu dilakukan setelah keadaan SUCCEEDED tercapai. Dalam hal ini, menurut saya pada saat itu, menunggu 2 pulsa (tanpa memblokir FX AppThread) harus memberikan implementasi webkit cukup waktu untuk melakukan perubahan, kecuali jika ini menghasilkan beberapa dimensi yang diubah di JavaFX, yang dapat menghasilkan lagi dimensi yang diubah di dalam webkit.

Saya sedang berpikir tentang cara memasukkan info ini ke dalam diskusi di JBS, tetapi saya cukup yakin akan ada jawaban bahwa "Anda harus mengambil snapshot hanya ketika komponen web stabil". Jadi untuk mengantisipasi jawaban ini, akan baik untuk melihat apakah pendekatan ini cocok untuk Anda. Atau, jika ternyata menyebabkan masalah lain, akan baik untuk memikirkan masalah ini, dan melihat apakah / bagaimana mereka dapat diperbaiki di OpenJFX sendiri. "

  1. Secara default, JavaFX 8 menggunakan default 600jika tingginya tepat 0. WebViewPenggunaan kembali kode harus digunakan setMinHeight(1), setPrefHeight(1)untuk menghindari masalah ini. Ini tidak ada dalam kode di bawah ini, tetapi perlu disebutkan untuk siapa pun yang mengadaptasinya ke proyek mereka.
  2. Untuk mengakomodasi kesiapan WebKit, tunggu tepat dua pulsa dari dalam timer animasi.
  3. Untuk mencegah bug kosong snapshot, gunakan snapback callback, yang juga mendengarkan denyut nadi.
// 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
sumber