TableView 滚动和排序导致通过 RowFactory 样式不正确的行

TableView scrolling and sorting results in incorrectly styled rows through RowFactory

我有一个 TableView,它使用 RowFactory 根据行项目的特定 属性 设置行样式。 RowFactory 使用工作线程来检查此特定 属性 对数据库调用的有效性。问题是正确的行有时会被标记为不正确的(通过 PseudoClass 显示为红色)并且不正确的行不会被标记。我在下面创建了一个最小可复制示例。此示例应仅标记偶数行...但它也标记其他行。

测试实体

public class TestEntity
{

    public TestEntity(String firstName, String lastName, int c)
    {
        setFirstName(firstName);
        setLastName(lastName);
        setC(c);
    }

    private StringProperty firstName = new SimpleStringProperty();
    private StringProperty lastName = new SimpleStringProperty();
    private IntegerProperty c = new SimpleIntegerProperty();

    public int getC()
    {
        return c.get();
    }

    public IntegerProperty cProperty()
    {
        return c;
    }

    public void setC(int c)
    {
        this.c.set(c);
    }

    public String getFirstName()
    {
        return firstName.get();
    }

    public StringProperty firstNameProperty()
    {
        return firstName;
    }

    public void setFirstName(String firstName)
    {
        this.firstName.set(firstName);
    }

    public String getLastName()
    {
        return lastName.get();
    }

    public StringProperty lastNameProperty()
    {
        return lastName;
    }

    public void setLastName(String lastName)
    {
        this.lastName.set(lastName);
    }
}

主要

public class TableViewProblemMain extends Application
{
    public static void main(String[] args)
    {
        launch(args);
        AppThreadPool.shutdown();
    }

    @Override
    public void start(Stage stage)
    {
        TableView<TestEntity> tableView = new TableView();

        TableColumn<TestEntity, String> column1 = new TableColumn<>("First Name");
        column1.setCellValueFactory(new PropertyValueFactory<>("firstName"));

        TableColumn<TestEntity, String> column2 = new TableColumn<>("Last Name");
        column2.setCellValueFactory(new PropertyValueFactory<>("lastName"));

        TableColumn<TestEntity, String> column3 = new TableColumn<>("C");
        column3.setCellValueFactory(new PropertyValueFactory<>("c"));

        tableView.getColumns().addAll(column1, column2, column3);

        tableView.setRowFactory(new TestRowFactory());

        for (int i = 0; i < 300; i++)
        {
            tableView.getItems().add(new TestEntity("Fname" + i, "Lname" + i, i));
        }


        VBox vbox = new VBox(tableView);

        Scene scene = new Scene(vbox);
        scene.getStylesheets().add(this.getClass().getResource("/style.css").toExternalForm());
        // Css has only these lines:
        /*
        .table-row-cell:invalid {
            -fx-background-color: rgba(240, 116, 116, 0.18);
        }
        * */
        stage.setScene(scene);
        stage.show();
    }
}

行工厂

public class TestRowFactory implements Callback<TableView<TestEntity>, TableRow<TestEntity>>
{
    private final PseudoClass INVALID_PCLASS = PseudoClass.getPseudoClass("invalid");

    @Override
    public TableRow<TestEntity> call(TableView param)
    {

        TableRow<TestEntity> row = new TableRow();

        Thread validationThread = new Thread(() ->
        {
            try
            {
                if(row.getItem() != null)
                {
                    Thread.sleep(500); // perform validation and stuff...
                    if(row.getItem().getC() % 2 == 0)
                    {
                        Tooltip t = new Tooltip("I am a new tooltip that should be shown only on red rows");
                        row.setTooltip(t);

                        row.pseudoClassStateChanged(INVALID_PCLASS, true);
                    }
                }



            } catch (InterruptedException e)
            {
                e.printStackTrace();
            }
        });


        ChangeListener changeListener = (obs, old, current) ->
        {
            row.setTooltip(null);
            AppThreadPool.perform(validationThread);
        };


        row.itemProperty().addListener((observable, oldValue, newValue) ->
        {
            row.setTooltip(null);

            if (oldValue != null)
            {
                oldValue.firstNameProperty().removeListener(changeListener);
            }

            if (newValue != null)
            {
                newValue.firstNameProperty().removeListener(changeListener);
                AppThreadPool.perform(validationThread);
            }
            else
            {
                row.pseudoClassStateChanged(INVALID_PCLASS, false);
            }

        });

        row.focusedProperty().addListener(changeListener);

        return row;
    }

}

AppThreadPool

public class AppThreadPool
{

    private static final int threadCount = Runtime.getRuntime().availableProcessors();
    private static final ThreadPoolExecutor executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(threadCount * 2 + 1);

    public static <R extends Runnable> void perform(R runnable)
    {
        executorService.submit(runnable);
    }

    public static void shutdown()
    {

        executorService.shutdown();
    }
}

截图

您的代码中存在一些误解。第一个是关于单元格和重用(TableRowCell)。单元格可以任意重复使用,并且可能经常(尤其是在用户滚动期间)停止显示一项并显示另一项。

在您的代码中,如果该行用于显示无效的实体,则该行 itemProperty 上的侦听器将触发后台线程上的可运行对象,这将在某个时候设置伪class 状态为 true.

但是,如果该单元格随后被重新用于显示有效项目,则执行的下一个可运行对象不会更改伪class 状态。因此状态保持为真,行颜色保持为红色。

因此,如果某一行在某个时候显示了无效项目,则该行为红色。 (如果它当前显示的是无效项目则不会。)如果滚动足够多,最终所有单元格都会变成红色。

其次,您不得从 FX 应用程序线程以外的任何线程更新属于场景图一部分的任何 UI。此外,一些其他操作,例如创建 Window 实例(TooltipWindow 的子 class)必须在 FX 应用程序线程上执行。请注意,这包括修改绑定到 UI 的模型属性,包括 table 列中使用的属性。您在 validationThread 中违反了这一点,您在其中创建了一个 Tooltip,将其设置在行中,并更改伪 class 状态,所有这些都在后台线程中进行。

此处的一个好方法是使用 JavaFX concurrency API。使用 Tasks,尽可能只使用 immutable 数据和 return 一个 immutable 值。如果确实需要更新 UI 中显示的属性,请使用 Platform.runLater(...) 在 FX 应用程序线程上安排这些更新。

就 MVC 设计而言,模型 class(es) 存储视图所需的所有数据是一种很好的做法。您的设计遇到了麻烦,因为没有真正存储验证状态的地方。而且,验证状态真的不仅仅是“有效”或“无效”;有一个阶段,线程处于 运行 但未完成,验证状态未知。

这是我的解决方案,它解决了这些问题。我假设:

  1. 您的实体具有有效性概念。
  2. 建立实体的有效性是一个long-running过程
  3. 有效性取决于一项或多项属性,这些属性可能会在显示 UI 时发生变化
  4. 有效性应该在 as-need 的基础上“延迟”建立。
  5. UI不希望显示“未知”有效性,如果显示有效性未知的实体,应建立并重新显示。

我为 ValidationStatus 创建了一个枚举,它有四个值:

public enum ValidationStatus {
    VALID, INVALID, UNKNOWN, PENDING ;
}

UNKNOWN 表示有效性未知,未请求验证; PENDING 表示已请求验证但尚未完成。

然后我为您的实体提供了一个包装器,它将验证状态添加为可观察的 属性。如果基础实体中验证所依赖的 属性 发生更改,则验证将重置为 UNKNOWN.

import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;

public class ValidatingTestEntity {

    private final TestEntity entity ;
    private final ObjectProperty<ValidationStatus> validationStatus = new SimpleObjectProperty<>(ValidationStatus.UNKNOWN);


    public ValidatingTestEntity(TestEntity entity) {
        this.entity = entity;

        entity.firstNameProperty().addListener((obs, oldName, newName) -> setValidationStatus(ValidationStatus.UNKNOWN));
    }


    public TestEntity getEntity() {
        return entity;
    }

    public ValidationStatus getValidationStatus() {
        return validationStatus.get();
    }

    public ObjectProperty<ValidationStatus> validationStatusProperty() {
        return validationStatus;
    }

    public void setValidationStatus(ValidationStatus validationStatus) {
        this.validationStatus.set(validationStatus);
    }
}

ValidationService 提供在后台线程上验证实体的服务,用结果更新适当的属性。这是通过线程池和 JavaFX Tasks 管理的。这只是通过休眠一段随机时间然后 return 交替结果来模拟数据库调用。

当任务改变状态时(即随着它在其生命周期中的进展),实体的验证 属性 会更新:UNKNOWN 如果任务未能正常完成,PENDING 如果任务处于未完成状态,并且 VALIDINVALID,取决于任务的结果,如果任务成功。

import javafx.application.Platform;
import javafx.concurrent.Task;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;

public class ValidationService {

    private final Executor exec = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2 + 1,
            r -> {
                Thread thread = new Thread(r);
                thread.setDaemon(true);
                return thread;
            }
    );

    public Task<Boolean> validateEntity(ValidatingTestEntity entity) {

        // task runs on a background thread and should not access mutable data,
        // so make final copies of anything needed here:
        final String firstName = entity.getEntity().getFirstName();
        final int code =entity.getEntity().getC();

        Task<Boolean> task = new Task<Boolean>() {
            @Override
            protected Boolean call() throws Exception {
    
                try {
                    Thread.sleep(500 + ThreadLocalRandom.current().nextInt(500));
                } catch (InterruptedException exc) {
                    // if interrupted other than being cancelled, reset thread's interrupt status:
                    if (! isCancelled()) {
                        Thread.currentThread().interrupt();
                    }
                }

                boolean result = code % 2 == 0;
                return result;
            }
        };

        task.stateProperty().addListener((obs, oldState, newState) ->
            entity.setValidationStatus(
                    switch(newState) {
                        case CANCELLED, FAILED -> ValidationStatus.UNKNOWN;
                        case READY, RUNNING, SCHEDULED -> ValidationStatus.PENDING ;
                        case SUCCEEDED ->
                                task.getValue() ? ValidationStatus.VALID : ValidationStatus.INVALID ;
                    }
            )
        );


        exec.execute(task);

        return task ;
    }
}

这是 TableRow 实现。它有一个侦听器,用于观察当前项目的验证状态(如果有的话)。如果项目发生变化,则监听器将从旧项目中移除(如果有的话),并附加到新项目(如果有的话)。如果项目发生变化,或者当前项目的验证状态发生变化,则更新该行。如果新验证状态为 UNKNOWN,则会向服务发送请求以验证当前项目。有两种伪 class 状态:无效(红色)和未知(橙色),它们会在项目或其验证状态更改时更新。如果项目无效,则设置工具提示,否则设置为空。

import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.scene.control.TableRow;
import javafx.scene.control.Tooltip;

public class ValidatingTableRow extends TableRow<ValidatingTestEntity> {

    private final ValidationService validationService ;

    private final PseudoClass pending = PseudoClass.getPseudoClass("pending");
    private final PseudoClass invalid = PseudoClass.getPseudoClass("invalid");

    private final Tooltip tooltip = new Tooltip();

    private final ChangeListener<ValidationStatus> listener = (obs, oldStatus, newStatus) -> {
         updateValidationStatus();
    };

    public ValidatingTableRow(ValidationService validationService){
        this.validationService = validationService ;
        itemProperty().addListener((obs, oldItem, newItem) -> {
            setTooltip(null);
            if (oldItem != null) {
                oldItem.validationStatusProperty().removeListener(listener);
            }
            if (newItem != null) {
                newItem.validationStatusProperty().addListener(listener);
            }
            updateValidationStatus();
        });
    }

    private void updateValidationStatus() {

        if (getItem() == null) {
            pseudoClassStateChanged(pending, false);
            pseudoClassStateChanged(invalid, false);
            setTooltip(null);
            return ;
        }
        ValidationStatus validationStatus = getItem().getValidationStatus();
        if( validationStatus == ValidationStatus.UNKNOWN) {
            validationService.validateEntity(getItem());
        }
        if (validationStatus == ValidationStatus.INVALID) {
            tooltip.setText("Invalid entity: "+getItem().getEntity().getFirstName() + " " +getItem().getEntity().getC());
            setTooltip(tooltip);
        } else {
            setTooltip(null);
        }
        pseudoClassStateChanged(pending, validationStatus == ValidationStatus.PENDING);
        pseudoClassStateChanged(invalid, validationStatus == ValidationStatus.INVALID);
    }
}

这里是Entity,和问题中的一样:

import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class TestEntity
{

    public TestEntity(String firstName, String lastName, int c)
    {
        setFirstName(firstName);
        setLastName(lastName);
        setC(c);
    }

    private StringProperty firstName = new SimpleStringProperty();
    private StringProperty lastName = new SimpleStringProperty();
    private IntegerProperty c = new SimpleIntegerProperty();

    public String getFirstName() {
        return firstName.get();
    }

    public StringProperty firstNameProperty() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName.set(firstName);
    }

    public String getLastName() {
        return lastName.get();
    }

    public StringProperty lastNameProperty() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName.set(lastName);
    }

    public int getC() {
        return c.get();
    }

    public IntegerProperty cProperty() {
        return c;
    }

    public void setC(int c) {
        this.c.set(c);
    }
}

这是应用程序 class。我添加了编辑名字的功能,这让您可以看到一个项目恢复为未知,然后 re-establishing 它的有效性(您需要在提交编辑后快速更改选择)。

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

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

    @Override
    public void start(Stage stage)
    {
        TableView<ValidatingTestEntity> tableView = new TableView();
        tableView.setEditable(true);

        TableColumn<ValidatingTestEntity, String> column1 = new TableColumn<>("First Name");
        column1.setCellValueFactory(cellData -> cellData.getValue().getEntity().firstNameProperty());
        column1.setEditable(true);
        column1.setCellFactory(TextFieldTableCell.forTableColumn());

        TableColumn<ValidatingTestEntity, String> column2 = new TableColumn<>("Last Name");
        column2.setCellValueFactory(cellData -> cellData.getValue().getEntity().lastNameProperty());

        TableColumn<ValidatingTestEntity, Number> column3 = new TableColumn<>("C");
        column3.setCellValueFactory(cellData -> cellData.getValue().getEntity().cProperty());

        tableView.getColumns().addAll(column1, column2, column3);

        ValidationService service = new ValidationService();

        tableView.setRowFactory(tv -> new ValidatingTableRow(service));


        for (int i = 0; i < 300; i++)
        {
            tableView.getItems().add(new ValidatingTestEntity(
                    new TestEntity("Fname" + i, "Lname" + i, i)));
        }


        VBox vbox = new VBox(tableView);

        Scene scene = new Scene(vbox);
        scene.getStylesheets().add(this.getClass().getResource("/style.css").toExternalForm());
      
        stage.setScene(scene);
        stage.show();
    }
}

最后,为了完整性,样式表:

.table-row-cell:invalid {
    -fx-background-color: rgba(240, 116, 116, 0.18);
}
.table-row-cell:pending {
    -fx-background-color: rgba(240, 120, 0, 0.18);
}