7

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?

roman
  • 151
  • 1
  • 9
  • 2
    Bindings are implemented using listeners. When a property is invalidated/changed the registered listeners are invoked on the same thread which made the invalidation/change. In the context of the question, this leads to the `TextField` being updated by a background thread. The UI must never be updated by a background thread; all UI updates must happen on the _JavaFX Application Thread_. You can use `Platform#runLater(Runnable)` to schedule an action on the FX thread from a background thread. That said, in your case, since you're creating timers/stopwatches, consider using an `AnimationTimer`. – Slaw Dec 28 '19 at 18:54
  • I see, thank you. I understood that the UI must never be updated by a background thread, but I assumed that when a property bound to a gui element is updated on the background thread, the listener would actually use the FX thread to perform the update. I'll take a look at AnimationTimer. – roman Dec 28 '19 at 18:57
  • 1
    That's unfortunately not the case with the implementations in the core JavaFX library. It's always possible to create such a binding implementation, however. – Slaw Dec 28 '19 at 18:59
  • 1
    Although the question appears to be slightly different, it's not really and the answer is pretty much the same as: [Can a node's tranforms be safely manipulated from a non-UI thread?](https://stackoverflow.com/questions/59446936/javafx-can-a-nodes-tranforms-be-safely-manipulated-from-a-non-ui-thread/59447225#59447225) – jewelsea Dec 28 '19 at 20:34
  • 1
    A corollary question which could be posed is "How to create a threadsafe binding which is usable from a non-UI thread?". It [may be possible to do this](https://stackoverflow.com/a/45199363/1155209), but tricky, won't with the default bindings used throughout JavaFX libraries, and could cause additional, difficult to recognize issues, such as flooding the run later queue. So I don't think I would really recommend trying such an approach. – jewelsea Dec 28 '19 at 20:44
  • Thanks for all the info, I've read through the links and learned a lot. As Slaw suggested, `AnimationTimer` is sufficient for my needs, so I posted it as the answer to my question, but knowing the other options and details is very helpful. – roman Dec 29 '19 at 00:14

1 Answers1

5

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
}
roman
  • 151
  • 1
  • 9
  • 1
    Nice work roman on putting the info together and coming up with a concise, correct and clear answer yourself. – jewelsea Dec 29 '19 at 08:49