0
votes

I have a more complicated application in which I'm rendering text, in single characters to the screen, as if it were being written. In my real app, things are more complicated, so I've simplified for this test.

My issue is: I have a model containing a SimpleStringProperty. I'm binding to this a Text element. Then by changing the SimpleStringProperty in the background thread, I'm expecting my Text element to change.

It works fine at lower speeds. But at higher speeds I get a prism error (pasted at end of post). I can fix the problem by moving updates to my SimpleStringProperty into a Platform.RunLater, but this seems to go against any kind of MVC architecture. My SimpleStringProperty is part of my model, and not my view class which lives inside the JavaFX thread.

By adding Platform.Runlater, things get out of sync and some characters are missed, as I'm probably creating too many RunLaters. I've tried adding a semaphore and a changelistener to release the semaphore, so that I'm not updating the until the RunLater has finished. No joy.

Any help appreciated, thank you.

import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.scene.Scene;
import javafx.scene.layout.GridPane;
import javafx.scene.text.Text;
import javafx.stage.Stage;

public class TestCase extends Application {

String s = "The Hobbit is a tale of high adventure, undertaken by a company of dwarves, in search of dragon- \n" +
        "guarded gold. A reluctant partner in this perilous quest is Bilbo Baggins, a comfort-loving, \n" +
        "unambitious hobbit, who surprises even himself by his resourcefulness and his skill as a burglar. \n" +
        "\n" +
        "Encounters with trolls, goblins, dwarves, elves and giant spiders, conversations with the dragon, \n" +
        "Smaug the Magnificent, and a rather unwilling presence at the Battle of the Five Armies are some of \n" +
        "the adventures that befall Bilbo. But there are lighter moments as well: good fellowship, welcome \n" +
        "meals, laughter and song.";

SimpleStringProperty stringProperty = new SimpleStringProperty("");

private void run() {
    new Thread(() -> {
        try {
            for (int i = 0; i < s.length(); i++) {
                Thread.sleep(10); // works at 15ms
                stringProperty.setValue(stringProperty.getValue().concat(s.substring(i, i + 1))); //this line into Platform.runLater -- but out of sync
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

@Override
public void start(Stage primaryStage) throws Exception {
    Text text = new Text();
    text.setWrappingWidth(500);
    text.textProperty().bind(stringProperty);
    GridPane root = new GridPane();
    root.setPrefSize(500,400);
    Scene theScene = new Scene(root);
    primaryStage.setScene(theScene);
    root.getChildren().add(text);
    primaryStage.show();
    run();
}
}

Current error:

Exception in thread "JavaFX Application Thread" java.lang.NullPointerException
at com.sun.javafx.text.PrismTextLayout.layout(PrismTextLayout.java:1267)
at com.sun.javafx.text.PrismTextLayout.ensureLayout(PrismTextLayout.java:223)
at com.sun.javafx.text.PrismTextLayout.getBounds(PrismTextLayout.java:246)
at javafx.scene.text.Text.getLogicalBounds(Text.java:358)
at javafx.scene.text.Text.impl_computeLayoutBounds(Text.java:1115)
at javafx.scene.Node$12.computeBounds(Node.java:3225)
at javafx.scene.Node$LazyBoundsProperty.get(Node.java:9308)
at javafx.scene.Node$LazyBoundsProperty.get(Node.java:9278)
at javafx.scene.Node.getLayoutBounds(Node.java:3240)
at javafx.scene.Node.prefHeight(Node.java:2770)
at javafx.scene.Node.minHeight(Node.java:2712)
at javafx.scene.layout.Region.computeChildPrefAreaHeight(Region.java:1762)
at javafx.scene.layout.GridPane.computePrefHeights(GridPane.java:1424)
at javafx.scene.layout.GridPane.layoutChildren(GridPane.java:1690)
at javafx.scene.Parent.layout(Parent.java:1087)
at javafx.scene.Scene.doLayoutPass(Scene.java:552)
at javafx.scene.Scene$ScenePulseListener.pulse(Scene.java:2397)
at com.sun.javafx.tk.Toolkit.lambda$runPulse$30(Toolkit.java:355)
at java.security.AccessController.doPrivileged(Native Method)
at com.sun.javafx.tk.Toolkit.runPulse(Toolkit.java:354)
at com.sun.javafx.tk.Toolkit.firePulse(Toolkit.java:381)
at com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:510)
at com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:490)
at com.sun.javafx.tk.quantum.QuantumToolkit.lambda$runToolkit$404(QuantumToolkit.java:319)
at com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:95)
at com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
at com.sun.glass.ui.win.WinApplication.lambda$null$148(WinApplication.java:191)
at java.lang.Thread.run(Thread.java:748)

Here is a semaphored version with RunLater:

public class TestCase extends Application {

private String s = "The Hobbit is a tale of high adventure, undertaken by a company of dwarves, in search of dragon- \n" +
        "guarded gold. A reluctant partner in this perilous quest is Bilbo Baggins, a comfort-loving, \n" +
        "unambitious hobbit, who surprises even himself by his resourcefulness and his skill as a burglar. \n" +
        "\n" +
        "Encounters with trolls, goblins, dwarves, elves and giant spiders, conversations with the dragon, \n" +
        "Smaug the Magnificent, and a rather unwilling presence at the Battle of the Five Armies are some of \n" +
        "the adventures that befall Bilbo. But there are lighter moments as well: good fellowship, welcome \n" +
        "meals, laughter and song.";

private SimpleStringProperty stringProperty = new SimpleStringProperty("");
private int i;
private Semaphore semaphore= new Semaphore(1);

private void run() {
    new Thread(() -> {
        try {
            for (i = 0; i < s.length(); i++) {
                Thread.sleep(15); // works at 15ms
                Platform.runLater(() -> {
                    try {
                        semaphore.tryAcquire(100, TimeUnit.MILLISECONDS);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    stringProperty.setValue(stringProperty.getValue().concat(s.substring(i, i + 1))); //this line into Platform.runLater -- but out of sync
                });
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

@Override
public void start(Stage primaryStage) throws Exception {
    stringProperty.addListener((observable, oldValue, newValue) -> {
        semaphore.release();
    });
    Text text = new Text();
    text.setWrappingWidth(500);
    text.textProperty().bind(stringProperty);
    GridPane root = new GridPane();
    root.setPrefSize(500,400);
    Scene theScene = new Scene(root);
    primaryStage.setScene(theScene);
    root.getChildren().add(text);
    primaryStage.show();
    run();
}
}
1
You must change the string property on the FX Application thread, since changing it causes a change to the UI. I don't understand your comment about using Platform.runLater(...); that works just fine for me. If you create too many runnables on the FX Application Thread queue, you will degrade performance, but they are all guaranteed to run and guaranteed to run in the order they are submitted. You should post your attempt to use Platform.runLater(). - James_D
Thanks, James. I've added something using runLater at the end of the post: I get a string index out of bounds.And at low sleeps (try 5) the characters are not in order. - wax_lyrical
Oh; well you have implemented that incorrectly. You are changing i in one thread and accessing it in another. Of course you will not get things the correct order. - James_D

1 Answers

1
votes

Since the text is bound to your StringProperty, you must only change the string property on the FX Application Thread. Doing otherwise violates the threading rules of JavaFX, and is why you get the null pointer exception (due to some race condition failing somewhere in the internal API).

Your attempt to use Platform.runLater() is incorrectly implemented. By moving the index variable i to an instance variable, you are accessing it in one thread (the FX Application Thread, inside the Platform.runLater()) but modifying it in the for loop in the background thread. It's pretty easy to see that the index variable could get incremented more than once between the invocation of two consecutive runnables submitted to the FX Application Thread. You should only use final variables inside your Platform.runLater(...) calls, or variables that are only ever accessed from the FX Application Thread. The following works just fine:

private void run() {
    new Thread(() -> {
        try {
            for (int i = 0; i < s.length(); i++) {
                Thread.sleep(10); // works at 15ms
                final String append = s.substring(i, i+1);
                Platform.runLater(() -> 
                    stringProperty.setValue(stringProperty.getValue().concat(append))); 
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

Any time you are modifying the UI periodically like this, you should really consider using the animation API instead of using a background thread to implement the "pause" between "frames". There are many advantages here: mostly you save the creation of a background thread at all (even the implementation of the animation under the hood does not use additional threads), so you save resources and avoid any possibility of the race conditions you see in your attempted implementation of Platform.runLater(). Once you are familiar with the animation API, I think the code becomes easier to read too.

Here is a reimplementation of your example, using a Timeline instead of a thread:

import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.scene.Scene;
import javafx.scene.layout.GridPane;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Duration;

public class TestCase extends Application {

    String s = "The Hobbit is a tale of high adventure, undertaken by a company of dwarves, in search of dragon- \n"
            + "guarded gold. A reluctant partner in this perilous quest is Bilbo Baggins, a comfort-loving, \n"
            + "unambitious hobbit, who surprises even himself by his resourcefulness and his skill as a burglar. \n"
            + "\n"
            + "Encounters with trolls, goblins, dwarves, elves and giant spiders, conversations with the dragon, \n"
            + "Smaug the Magnificent, and a rather unwilling presence at the Battle of the Five Armies are some of \n"
            + "the adventures that befall Bilbo. But there are lighter moments as well: good fellowship, welcome \n"
            + "meals, laughter and song.";

    SimpleStringProperty stringProperty = new SimpleStringProperty("");



    @Override
    public void start(Stage primaryStage) throws Exception {
        Text text = new Text();
        text.setWrappingWidth(500);
        GridPane root = new GridPane();
        root.setPrefSize(500, 400);
        Scene theScene = new Scene(root);
        primaryStage.setScene(theScene);
        root.getChildren().add(text);
        primaryStage.show();

        // Number of characters displayed in text:
        IntegerProperty textLength = new SimpleIntegerProperty(0);

        // "Animate" number of characters from 0 to total length of text,
        // over a total of 10 seconds:
        Timeline animation = new Timeline(
            new KeyFrame(Duration.seconds(10), 
                new KeyValue(textLength, s.length())));

        // ensure text displays the appropriate substring of s:
        text.textProperty().bind(Bindings.createStringBinding(
            () -> s.substring(0, textLength.get()), 
            textLength));

        // start the animation:
        animation.play();
    }
    public static void main(String[] args ) {
        launch(args);
    }
}

Also see the example in the Javadocs for Transition, which is pretty similar.