Search code examples
javajavafxconcurrencychangelistener

Make multiple change firings lead to far fewer actions


I have a TextArea in which the user of my app can write things. A ChangeListener is also listening to the StringProperty "text" of this TextArea. Whenever the text content changes, ChangeListener.changed(), among other things, sets a "dirty" BooleanProperty to true on a central app object. Where "dirty" has the sense of "document needs saving".

But I've just implemented a thing in my app whereby any time that the "dirty" Property gets set to true triggers a save-file-to-disk action, automatically, so the user doesn't have to worry about manually saving things. NB the act of saving also sets dirty back to false of course.

One problem with this is, though, that it slows typing in the TextArea (particulary as this saving takes place on the FX thread). Each new character added or deleted therefore fires a save action.

I want to find a solution where every single change-of-text action is always followed by a save at most within, say, 1 second, but where saving never happens more than once per this 1 second ... obviously in a non-FX thread.

This isn't the first time I've encountered this situation and in the past I've tinkered with various queues of timers, and so on. But it's difficult to find a solution where both criteria are met and I'm just wondering if there's a well-known technique to deal with this... even maybe something from some library?

Here's an MRE/MCVE:

import javafx.application.Application;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.TextArea;
import javafx.stage.Stage;

public class Main extends Application {
    public static void main(String[] args) {
        launch(args);
    }

    BooleanProperty dirty;
    void setDirty(Boolean value) {
        dirtyProperty().set(value);
        if( value ){
            System.out.println( "serialise to file again...");
            // successful saving also means we become clean again:
            dirtyProperty().set( false );
        }
    }
    Boolean getDirty() { return dirtyProperty().get(); }
    BooleanProperty dirtyProperty() {
        if ( dirty == null) dirty = new SimpleBooleanProperty(this, "dirty");
        return dirty;
    }

    @Override
    public void start(Stage stage) throws Exception {
        Scene scene = new Scene(new Group());
        Group sceneRoot = (Group)scene.getRoot();
        TextArea textArea = new TextArea();

        textArea.textProperty().addListener(new ChangeListener<String>() {
            @Override
            public void changed(ObservableValue<? extends String> observableValue, String s, String t1) {
                setDirty( true );
            }
        });

        sceneRoot.getChildren().add( textArea );
        stage.setMinWidth( 600 );
        stage.setMinHeight( 400 );
        stage.setScene(scene);
        stage.show();
    }
}

Each keystroke causes a save...


Solution

  • Instead of using background threads, use a pause transition:

    PauseTransition pause = new PauseTransition(Duration.seconds(1));
    pause.setOnFinished(e -> {
        // save action here
    });
    
    dirtyProperty().addListener((obs, wasDirty, isNowDirty) -> {
        if (isNowDirty && pause.getStatus() != Animation.Status.RUNNING) {
            pause.playFromStart();
        }
    });
    

    This will start the one-second pause when the dirtyProperty() changes to true, and at the end of the pause, will save the data. However, by checking the status of the pause, it will not schedule more than one save per second.

    If you do want to use background threads, you can do something along the following lines:

    BlockingQueue<String> textToSave = new ArrayBlockingQueue<>(1);
    ScheduledExecutorService exec = Executors.newScheduledThreadPool(1);
    exec.scheduleWithFixedDelay(() -> {
        try {
            String text = textToSave.take();
            // save text
        } catch (InterruptedException interrupt) {
            Thread.currentThread().interrupt();
        }
    }, 0, 1, TimeUnit.SECONDS);
    

    and then

    dirtyProperty().addListener((obs, wasDirty, isNowDirty) -> {
        textToSave.clear();
        textToSave.offer(myTextArea.getText());
    });
    

    This assumes no threads other than the FX Application Thread is pushing data to the textToSave "queue" (not sure if something of size <= 1 is properly called a "queue"), which seems easy enough to ensure.

    The advantage of this approach is that the IO happens on a background thread, which means there's no opportunity whatsoever to block the FX Application Thread by writing the file.