JavaFX: update UI in separate, non-controller clas

2019-04-17 00:57发布

问题:

I have a JavaFX program that launches a task performed by a separate class. I would like to output the status of that task's progress to a Label element in the UI. I cannot get this to work. Here is my code:

Main.java:

public class Main extends Application {
    public void start(Stage primaryStage) throws Exception {
        Parent root = FXMLLoader.load(getClass().getResource("ui.fxml"));
        primaryStage.setTitle("Data collector");
        primaryStage.setScene(new Scene(root, 400, 400));
        primaryStage.show();
    }

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

Controller.java:

The label I want to update is created by delcaring globally @FXML public Label label = new Label();. Creates a new thread via new Thread(new Task<Void>() { ... }).start(); to run the collect_data method from TaskRun. The TaskRun class is stated below:

TaskRun.java:

class TaskRun {
    private Controller ui;

    TaskRun() {
        FXMLLoader loader = new FXMLLoader(getClass().getResource("ui.fxml"));
        ui = loader.getController();
    }

    void collect_data() {
        for (int i = 0; i < 100; i++) {
            // do stuff...
            send_progress_to_ui(((float) i / (float)) * 100);
        }
    }

    void send_progress_to_ui(float percent) {
        new Thread(new Task<Void>() {
            @Override
            protected Void call() throws Exception {
                Platform.runLater(() -> ui.label.setText(Float.toString(percent_complete) + "%"));

                return null;
            }
        }).start();
    }

}

I get a NullPointerException on the line with the Platform.runLater(...).

So obviously, this method is not going to work. How do I update the UI through this non-controller class TaskRun?

回答1:

Your design is not really very helpful for what you are trying to do here. Instead of a class that hides the task entirely and creates a thread to run it (which is really inflexible: what if you want to submit the task to an existing Executor?), your class should implement the task. Then you can just update the task's progress, which is an observable property:

class TaskRun extends Task<Void> {

    @Override
    protected Void call() {
        for (int i = 0; i < 100; i++) {
            // do stuff...
            updateProgress(i, 100);
        }
        return null ;
    }

}

Now in your controller you can just do:

TaskRun task = new TaskRun();
task.progressProperty().addListener((obs, oldProgress, newProgress) -> 
    label.setText(String.format("%.2f percent complete", newProgress.doubleValue()*100)));
new Thread(task).start();

Alternatively, use a binding instead of the listener:

label.textProperty().bind(task.progressProperty().asString("%.2f percent complete"));

This gets rid of all the horrendous coupling you have, because now TaskRun doesn't need to know anything about the controller class.



回答2:

Passing the instance of the Controller that updates the UI solves the issue (thanks go to @azro). It turns out that instantiating a controller with fxmlLoader.getController() doesn't return the current instance of the controller that is updating the UI.

All that needs to be modified is the TaskRun constructor:

TaskRun(Controller c) {
    ui = c;
}

Where ui was the global Controller object for TaskRun class.

The controller class instantiates a TaskRun object within a Threadas follows: TaskRun task = new TaskRun(Controller.this);

After making these changes, the UI updates as expected.