Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why does binding a TextField to a property which is being updated on another thread end up with the application throwing errors?

I have a javafx application where I have multiple tabs (timer, stopwatch, clock) each with a separate Controller, and the user is able to add and independently start multiple timers using a start/stop button.

I tried binding a TextField to a property of another class MyTimer which keeps track of the elapsed time, but it eventually (after running for a couple of seconds) starts throwing an error. (If you check the code below, note that it only happens if the "Thread.sleep" is set to 10ms - when i increase the delay to 100ms, it kept running for about a minute and did not crash - I did not test further, since I would like to solve the root cause instead of increasing the delay).

Just so you have a quick idea: app image

MyTimer class:

    public class MyTimer implements Startable {

...

    private long startNanoTime, storedElapsedTime, totalTime;
    private TimerStates state;
    private StringProperty timerStringProperty = new SimpleStringProperty(DEFAULT_TIMER_STRING_VALUE); 

    public MyTimer() {
        //constructor
    }

    public long getRemainingTime() {
        //returns remaining time
    }

    public StringProperty timerStringPropertyProperty() {
        return timerStringProperty;
    }

    @Override
        public boolean start() {
            if (this.state.isRunning() ) {
                System.out.println("Already running.");
                return false;
            }

            this.startNanoTime = System.nanoTime();
            this.state = TimerStates.RUNNING;

            Runnable startTimerRunnable = new Runnable() {
                @Override
                public void run() {
                    while(state.isRunning()) {
                        timerStringProperty.set(MyFormatter.longMillisecondsTimeToTimeString(getRemainingTime())); //The parameter passed is simply the remaining time formatted to a String
                        try {
                            Thread.sleep(10);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            };
            Thread daemonTimer = new Thread(startTimerRunnable);
            daemonTimer.setDaemon(true);
            daemonTimer.start();

            return true;
        }
    }

While trying to implement the binding, I tried to bind the one default TextProperty which exists without any user interaction at application startup to the property representing the remaining time from the MyTimer class to the value in the Controller:

public class TimerTabController {
    ...

    @FXML
    private Tab timerTab;
    @FXML
    private HBox defaultTimerHBox;
    @FXML
    private TextField defaultTimerTextField;

    private Map<HBox, MyTimer> timers = new HashMap<>();

    @FXML
    protected void initialize() {
        MyTimer defaultTimer = new MyTimer();
        timers.put(defaultTimerHBox, defaultTimer);
        defaultTimerTextField.textProperty().bind(defaultTimer.timerStringPropertyProperty());
    }
}

The Main method which starts it all up is fairly standard, but I'll include it anyway:

public class Main extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception{
        Parent root = FXMLLoader.load(getClass().getResource("fxml/mainWindow.fxml"));
        primaryStage.setTitle("Mortimer");
        primaryStage.setScene(new Scene(root, 800, 700));
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

And finally, the stack trace: First, there's this:

Exception in thread "Thread-3" java.lang.NullPointerException
    at javafx.graphics/com.sun.javafx.text.PrismTextLayout.createLine(PrismTextLayout.java:893)
    at javafx.graphics/com.sun.javafx.text.PrismTextLayout.layout(PrismTextLayout.java:1193)
    at javafx.graphics/com.sun.javafx.text.PrismTextLayout.ensureLayout(PrismTextLayout.java:222)
    at javafx.graphics/com.sun.javafx.text.PrismTextLayout.getBounds(PrismTextLayout.java:245)
    at javafx.graphics/javafx.scene.text.Text.getLogicalBounds(Text.java:430)
    at javafx.graphics/javafx.scene.text.Text.getYRendering(Text.java:1085)
    at javafx.graphics/javafx.scene.text.Text.access$4400(Text.java:127)
    at javafx.graphics/javafx.scene.text.Text$TextAttribute$11.computeValue(Text.java:1764)
    at javafx.graphics/javafx.scene.text.Text$TextAttribute$11.computeValue(Text.java:1756)
    at javafx.base/javafx.beans.binding.ObjectBinding.get(ObjectBinding.java:151)
    at javafx.base/javafx.beans.binding.ObjectExpression.getValue(ObjectExpression.java:49)
    at javafx.base/javafx.beans.property.ObjectPropertyBase.get(ObjectPropertyBase.java:133)
    at javafx.controls/javafx.scene.control.skin.TextFieldSkin.lambda$new$4(TextFieldSkin.java:252)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
    at javafx.base/javafx.beans.property.ObjectPropertyBase.fireValueChangedEvent(ObjectPropertyBase.java:106)
    at javafx.base/javafx.beans.property.ObjectPropertyBase.markInvalid(ObjectPropertyBase.java:113)
    at javafx.base/javafx.beans.property.ObjectPropertyBase.access$000(ObjectPropertyBase.java:52)
    at javafx.base/javafx.beans.property.ObjectPropertyBase$Listener.invalidated(ObjectPropertyBase.java:234)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
    at javafx.base/javafx.beans.binding.ObjectBinding.invalidate(ObjectBinding.java:170)
    at javafx.graphics/javafx.scene.text.Text.doGeomChanged(Text.java:842)
    at javafx.graphics/javafx.scene.text.Text.access$500(Text.java:127)
    at javafx.graphics/javafx.scene.text.Text$1.doGeomChanged(Text.java:158)
    at javafx.graphics/com.sun.javafx.scene.shape.TextHelper.geomChangedImpl(TextHelper.java:106)
    at javafx.graphics/com.sun.javafx.scene.NodeHelper.geomChanged(NodeHelper.java:137)
    at javafx.graphics/javafx.scene.text.Text.needsTextLayout(Text.java:266)
    at javafx.graphics/javafx.scene.text.Text.needsFullTextLayout(Text.java:261)
    at javafx.graphics/javafx.scene.text.Text.access$900(Text.java:127)
    at javafx.graphics/javafx.scene.text.Text$3.invalidated(Text.java:461)
    at javafx.base/javafx.beans.property.StringPropertyBase.markInvalid(StringPropertyBase.java:110)
    at javafx.base/javafx.beans.property.StringPropertyBase.access$000(StringPropertyBase.java:50)
    at javafx.base/javafx.beans.property.StringPropertyBase$Listener.invalidated(StringPropertyBase.java:231)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
    at javafx.base/javafx.beans.binding.StringBinding.invalidate(StringBinding.java:169)
    at javafx.base/com.sun.javafx.binding.BindingHelperObserver.invalidated(BindingHelperObserver.java:52)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper$Generic.fireValueChangedEvent(ExpressionHelper.java:348)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
    at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.fireValueChangedEvent(TextInputControl.java:1430)
    at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.markInvalid(TextInputControl.java:1434)
    at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.controlContentHasChanged(TextInputControl.java:1373)
    at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.access$1600(TextInputControl.java:1341)
    at javafx.controls/javafx.scene.control.TextInputControl.lambda$new$0(TextInputControl.java:144)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
    at javafx.controls/javafx.scene.control.TextField$TextFieldContent.insert(TextField.java:87)
    at javafx.controls/javafx.scene.control.TextInputControl.replaceText(TextInputControl.java:1244)
    at javafx.controls/javafx.scene.control.TextInputControl.filterAndSet(TextInputControl.java:1211)
    at javafx.controls/javafx.scene.control.TextInputControl.access$900(TextInputControl.java:80)
    at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.doSet(TextInputControl.java:1451)
    at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.access$1200(TextInputControl.java:1341)
    at javafx.controls/javafx.scene.control.TextInputControl$TextProperty$Listener.invalidated(TextInputControl.java:1474)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
    at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
    at javafx.base/javafx.beans.property.StringPropertyBase.fireValueChangedEvent(StringPropertyBase.java:104)
    at javafx.base/javafx.beans.property.StringPropertyBase.markInvalid(StringPropertyBase.java:111)
    at javafx.base/javafx.beans.property.StringPropertyBase.set(StringPropertyBase.java:145)
    at javafx.base/javafx.beans.property.StringPropertyBase.set(StringPropertyBase.java:50)
    at MorTimer/sample.MyTimer$1.run(MyTimer.java:106)
    at java.base/java.lang.Thread.run(Thread.java:834)

And then the following errors keep repeating:

Exception in thread "JavaFX Application Thread" java.lang.NullPointerException
    at javafx.graphics/com.sun.javafx.text.PrismTextLayout.getRuns(PrismTextLayout.java:235)
    at javafx.graphics/javafx.scene.text.Text.getRuns(Text.java:389)
    at javafx.graphics/javafx.scene.text.Text.updatePGText(Text.java:1460)
    at javafx.graphics/javafx.scene.text.Text.doUpdatePeer(Text.java:1490)
    at javafx.graphics/javafx.scene.text.Text.access$100(Text.java:127)
    at javafx.graphics/javafx.scene.text.Text$1.doUpdatePeer(Text.java:137)
    at javafx.graphics/com.sun.javafx.scene.shape.TextHelper.updatePeerImpl(TextHelper.java:75)
    at javafx.graphics/com.sun.javafx.scene.NodeHelper.updatePeer(NodeHelper.java:102)
    at javafx.graphics/javafx.scene.Node.syncPeer(Node.java:710)
    at javafx.graphics/javafx.scene.Scene$ScenePulseListener.synchronizeSceneNodes(Scene.java:2366)
    at javafx.graphics/javafx.scene.Scene$ScenePulseListener.pulse(Scene.java:2512)
    at javafx.graphics/com.sun.javafx.tk.Toolkit.lambda$runPulse$2(Toolkit.java:412)
    at java.base/java.security.AccessController.doPrivileged(Native Method)
    at javafx.graphics/com.sun.javafx.tk.Toolkit.runPulse(Toolkit.java:411)
    at javafx.graphics/com.sun.javafx.tk.Toolkit.firePulse(Toolkit.java:438)
    at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:519)
    at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:499)
    at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulseFromQueue(QuantumToolkit.java:492)
    at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.lambda$runToolkit$11(QuantumToolkit.java:320)
    at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96)
    at javafx.graphics/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
    at javafx.graphics/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:174)
    at java.base/java.lang.Thread.run(Thread.java:834)
Exception in thread "JavaFX Application Thread" java.lang.NullPointerException
    at javafx.graphics/javafx.scene.Scene$ScenePulseListener.synchronizeSceneNodes(Scene.java:2365)
    at javafx.graphics/javafx.scene.Scene$ScenePulseListener.pulse(Scene.java:2512)
    at javafx.graphics/com.sun.javafx.tk.Toolkit.lambda$runPulse$2(Toolkit.java:412)
    at java.base/java.security.AccessController.doPrivileged(Native Method)
    at javafx.graphics/com.sun.javafx.tk.Toolkit.runPulse(Toolkit.java:411)
    at javafx.graphics/com.sun.javafx.tk.Toolkit.firePulse(Toolkit.java:438)
    at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:519)
    at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:499)
    at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulseFromQueue(QuantumToolkit.java:492)
    at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.lambda$runToolkit$11(QuantumToolkit.java:320)
    at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96)
    at javafx.graphics/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
    at javafx.graphics/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:174)
    at java.base/java.lang.Thread.run(Thread.java:834)

When I used Platform.runLater() in the MyTimer.start() function, it did work, like this:

    Runnable startTimerRunnable = new Runnable() {
        @Override
        public void run() {
            while(state.isRunning()) {
                Platform.runLater(new Runnable() {
                    @Override
                    public void run() {
                        timerStringProperty.set(MyFormatter.longMillisecondsTimeToTimeString(getRemainingTime()));
                    }
                });
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    };

    Thread daemonTimer = new Thread(startTimerRunnable);
    daemonTimer.setDaemon(true);
    daemonTimer.start();

But it seems wrong to use Platform.runLater like this, or is it fine? I understand that the GUI should not be updated outside of the FX thread, but I thought that binding properties takes care of this - that updating the property bound to a GUI element does not need to take place in the FX thread, and the GUI update would indeed be handled properly on the FX thread as part of the binding...

Ultimately, my solution which seems to work fairly well is to not use binding, but instead update the fields periodically in the Controller itself (the following method is called upon "onSelectionChanged" of the Tabs themselves) - but I was wondering how to make the binding work, as it seems that binding is the better practice. Anyway, here is the code which also does work:

public void startTimeFieldUpdates() {
    Runnable timeTracker = new Runnable() {
        @Override
        public void run() {
            while(timerTab.isSelected()) {
                Platform.runLater(new Runnable() {
                    @Override
                    public void run() {
                        for (HBox hBox : timers.keySet()) {
                            if (hBox.getChildren().get(1) instanceof TextField) {
                                TextField currentField = (TextField) hBox.getChildren().get(1);
                                currentField.setText(MyFormatter.longMillisecondsTimeToTimeString(
                                        timers.get(hBox).getRemainingTime())
                                                    );
                            }
                        }
                    }
                });

                try {
                    Thread.sleep(10);
                } catch (InterruptedException ex) {
                    ex.printStackTrace();
                }

            }
        }
    };
    Thread daemonStopwatch = new Thread(timeTracker);

    daemonStopwatch.setDaemon(true);
    daemonStopwatch.start();
}

My question then is, what is the correct approach to this problem, please?

like image 945
roman Avatar asked Dec 28 '19 18:12

roman


1 Answers

The answer, as posted in the comments to my question, is indeed that property binding uses listeners which ultimately run on the same thread where the property itself is updated - so, to avoid problems, the bound properties need to be updated on the Java FX Application Thread (or another solution should be sought, as was my case).

The solution that worked for me - as mentioned in the comments to my question, I looked at what AnimationTimer is, and it seems to be exactly what I was looking for and works perfectly.

In case it helps someone, here is my implementation:

import ...

public class TimerTabController {
    public static final int TIMER_HBOX_TEXTFIELD_INDEX = 1;
    public static final int TIMER_HBOX_STARTSTOP_BUTTON_INDEX = 2;

    @FXML
    private Tab timerTab;
    @FXML
    private HBox defaultTimerHBox;
    @FXML
    private TextField defaultTimerTextField;

    private Map<HBox, MyTimer> timers = new HashMap<>();

    private AnimationTimer timerTabAnimationTimer = new AnimationTimer() {
        @Override
        public void handle(long l) {
        //the GUI updates go here
            for (HBox hBox : timers.keySet()) {
                if (hBox.getChildren().get(TIMER_HBOX_TEXTFIELD_INDEX) instanceof TextField) {
                    TextField currentField = (TextField) hBox.getChildren().get(TIMER_HBOX_TEXTFIELD_INDEX);
                    currentField.setText(MyFormatter.longMillisecondsTimeToTimeString(
                            timers.get(hBox).getRemainingTime())
                                        );
                }
            }
        }
    };

    @FXML
    protected void initialize() {
        timers.put(defaultTimerHBox, new MyTimer());
    }

    @FXML
    void handleSelectionChanged() { //triggered by changing tab selection
        if (timerTab.isSelected()) {
            timerTabAnimationTimer.start();
        } else {
            timerTabAnimationTimer.stop();
        }
    }

    //some more code
}
like image 79
roman Avatar answered Nov 07 '22 21:11

roman