Прокрутка и сортировка TableView приводят к неправильному стилю строк через 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();
    }
}

Снимок экрана

Прокрутка и сортировка TableView приводят к неправильному стилю строк через RowFactory

хорошо привести пример, но повторюсь (комментарий к вашему предыдущему вопросу: stackoverflow.com/questions/71494286/…): вы не должен измените свойства узла в графике активной сцены вне потока приложения fx (как вы делаете, устанавливая всплывающую подсказку и обновляя псевдосостояние изнутри фоновая ветка)

kleopatra 16.03.2022 17:16

кстати: кажется неправильным позволять строке решать о достоверности (или запускать любую маршрутизацию проверки) - это должно быть выполнено в самой модели/данных, а затем иметь список данных с экстрактором для этого свойства (и, возможно, другие, которые могут потребовать строка для обновления) - при этом не должно быть необходимости в слушателях для элемента

kleopatra 16.03.2022 17:20

пожалуйста, предоставьте полный класс сущности - вы хотите максимально упростить для потенциальных помощников запуск примера и оказание помощи, не так ли :)

kleopatra 16.03.2022 17:22

Существует много серьезных недопониманий о том, как все это работает. 1. Как указано в другом комментарии, вы не должен изменяете или получаете доступ к состоянию графа сцены из фонового потока. 2. Ячейки (строки) повторно используются, возможно, очень часто, (преднамеренно) неопределенным образом. Если вы когда-либо устанавливали свойство ячейки на основе каких-то данных, вы всегда должны устанавливать это свойство при обновлении ячейки. 3. Представление (строка таблицы) — совершенно неподходящее место для манипулирования данными. «Действительное» состояние вашей сущности должно где-то управляться в модели.

James_D 16.03.2022 17:34

Итак, это хороший минимальный воспроизводимый пример, но для его исправления требуются некоторые знания о том, что вы на самом деле хотите делать в своем реальном приложении. Мои догадки таковы: 1. У вас есть объект, который может находиться в действительном состоянии, а может и не находиться. 2. Вы хотите указать пользователю любые отображаемые объекты, которые являются недействительными. 3. Установление достоверности — длительный процесс. 4. Действительное состояние данного объекта зависит от некоторого свойства, которое является изменчивым и может изменяться во время работы приложения (я предполагаю, что это основано на вашем слушателе на firstNameProperty). Если все верно, отредактируйте вопрос, чтобы включить его.

James_D 16.03.2022 17:39

@kleopatra Я старался ничего не менять в своем решении, чтобы каждую ошибку можно было упомянуть в ответе и никогда не забывать :). Использование модели звучит как очень хорошая идея, и я прямо сейчас рассмотрю ответ James_D. Класс сущности обновлен.

Renis1235 17.03.2022 08:52
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
2
6
56
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

В вашем коде есть несколько недоразумений. Первый касается ячеек и повторного использования (TableRow есть Cell). Ячейки можно повторно использовать произвольно и потенциально часто (особенно во время прокрутки пользователем), чтобы остановить отображение одного элемента и отобразить другой.

В вашем коде, если строка используется для отображения недопустимой сущности, слушатель в строке itemProperty вызовет исполняемый объект в фоновом потоке, который в какой-то момент установит состояние псевдокласса в true.

Однако, если ячейка впоследствии повторно используется для отображения допустимого элемента, следующий исполняемый объект, который выполняется, не меняет состояние псевдокласса. Таким образом, это состояние остается истинным, а цвет строки остается красным.

Следовательно, строка становится красной, если в какой-то момент она когда-либо отображала недопустимый элемент. (Нет, если в настоящее время он отображает недопустимый элемент.) Если вы достаточно прокрутите, в конечном итоге все ячейки станут красными.

Во-вторых, вы не должен обновляете любой пользовательский интерфейс, являющийся частью графа сцены, из любого потока, кроме потока приложения FX. Кроме того, некоторые другие операции, такие как создание экземпляров Window (Tooltip является подклассом Window), должны выполняться в потоке приложения FX. Обратите внимание, что это включает в себя изменение свойств модели, привязанных к пользовательскому интерфейсу, включая свойства, используемые в столбцах таблицы. Вы нарушаете это в своем validationThread, где вы создаете Tooltip, устанавливаете его в строке и изменяете состояние псевдокласса, и все это в фоновом потоке.

Здесь хорошим подходом является использование API параллелизма JavaFX. Используйте Task, которые, насколько это возможно, используют только неизменяемые данные и возвращают неизменяемое значение. Если вам нужно обновить свойства, которые отображаются в пользовательском интерфейсе, используйте Platform.runLater(...), чтобы запланировать эти обновления в потоке приложения FX.

С точки зрения дизайна MVC, хорошей практикой для вашего класса (ов) модели является хранение всех данных, необходимых представлению. Ваш дизайн сталкивается с проблемой, потому что нет реального места, где хранится статус проверки. Более того, статус проверки на самом деле больше, чем просто «действительный» или «недействительный»; существует фаза, когда поток выполняется, но не завершен, и статус проверки неизвестен.

Вот мое решение, которое решает эти проблемы. Я предполагаю:

  1. У вашей сущности есть понятие достоверности.
  2. Установление действительности объекта — длительный процесс.
  3. Срок действия зависит от одного или нескольких свойств, которые могут измениться во время отображения пользовательского интерфейса.
  4. Действительность должна устанавливаться «лениво» по мере необходимости.
  5. Пользовательский интерфейс предпочитает не отображать «неизвестную» достоверность, и если отображается сущность, достоверность которой неизвестна, ее следует установить и отобразить повторно.

Я создал перечисление для 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. Это просто имитирует вызов базы данных, засыпая в течение случайного промежутка времени, а затем возвращая чередующиеся результаты.

Когда задача меняет состояние (т. е. по мере прохождения своего жизненного цикла), свойство проверки объекта обновляется: UNKNOWN, если задача не завершается нормально, PENDING, если задача находится в незавершенном состоянии, и либо VALID, либо INVALID, в зависимости от по результату задания, если задание выполнено успешно.

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, в службу отправляется запрос на проверку текущего элемента. Существует два состояния псевдокласса: недействительный (красный) и неизвестный (оранжевый), которые обновляются каждый раз при изменении элемента или его статуса проверки. Всплывающая подсказка устанавливается, если элемент недействителен, в противном случае устанавливается значение null.

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);
    }
}

А вот и класс приложения. Я добавил возможность редактировать первое имя, что позволяет вам увидеть, как элемент возвращается к неизвестному, а затем восстанавливает свою действительность (вам нужно быстро изменить выбор после фиксации редактирования).

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);
}

Это был один из самых сложных ответов, которые я когда-либо видел на SO. Очень хорошо объяснил, и я понял каждую из своих ошибок / ошибок. Я ценю время, которое вы потратили, чтобы написать все это, и мне жаль, что я не могу дать больше, чем один голос.

Renis1235 17.03.2022 09:52

хм ... вы знаете, что мне неудобно с таким количеством логики в строке: это просто не его работа, ИМО ;) Наличие слушателей свойств ячейки является хрупким (слишком много может и часто идет не так). Вместо этого я предпочитаю выполнять всю проверку вне представления и позволять ячейке обновлять себя при изменении состояния проверки. Что, по общему признанию, немного затруднено для строк из-за проблем.

kleopatra 17.03.2022 14:00

@kleopatra Конечно: это компромисс. И вы можете переписать прослушиватель на itemProperty, переопределив updateItem(), хотя доступ к «старому элементу» немного менее прозрачен и больше зависит от базовой реализации ячейки. Суть в том, что разные шаблоны MV* лучше работают в разных сценариях. Предполагая, что требование состоит в том, чтобы выполнять дорогостоящую проверку только для отображаемых элементов, это взывает к MVVM, где в виртуальной машине есть свойство displayed, которое вы можете использовать. Но строки таблицы (и вообще ячейки) просто так не реализованы.

James_D 17.03.2022 14:23

в основном согласен, пробег варьируется - и, возможно, преувеличивает мои личные предпочтения :) Хотя, по моему опыту, проверка должна (почти?) всегда выполняться независимо от показа. Но тогда это может быть всего лишь одно предположение слишком много, и вопрос на самом деле не слишком ясен в контексте.

kleopatra 17.03.2022 14:36

Другие вопросы по теме