等待加载完成时无法设法更新树 cell/item 图形

Cannot manage to update tree cell/item graphics while waiting for a load to complete

初始情况是这样的:

现在,这棵树可能很大(例如,我有一棵有 2200 万个节点的树)。这里发生的是我使用 jooq/h2 后端来存储所有节点并执行查询来查找子节点。

这意味着,在上图中,节点被标记为可展开,但其子节点尚未填充。它是按需完成的。展开后我得到这个:

我的问题是,扩展当然需要时间;我想做的是在 TreeItem 的图形中添加一个视觉线索,以表明它正在加载...

我做不到。


OK,首先是架构的总体概览:

使用 JavaFX:

*Viewclass其实是一个接口;这使我能够制作该程序的 webapp 版本(计划中)。


现在,这段代码的上下文...

*Presenter*View*Display 都与上图中可见的 "Parse tree" 选项卡相关。

鉴于上述架构,问题在于 *View class 和 *Display class.

的实施

*Display class 有一个 init() 方法,如果需要,它会初始化所有相关的 JavaFX 组件。在这种情况下,称为 parseTreeTreeView 被初始化为:

@Override
public void init()
{
    parseTree.setCellFactory(param -> new ParseTreeNodeCell(this));
}

ParseTreeNodeCell 定义如下:

public final class ParseTreeNodeCell
    extends TreeCell<ParseTreeNode>
{
    // FAILED attempt at showing a progress indicator...
    private final ProgressIndicator indicator = new ProgressIndicator();
    private final Text text = new Text();
    private final HBox hBox = new HBox(text, indicator);

    public ParseTreeNodeCell(final TreeTabDisplay display)
    {
        // FIXME: not sure about the following line...
        indicator.setMaxHeight(heightProperty().doubleValue());
        // ... But this I want: by default, not visible
        indicator.setVisible(false);

        // The whole tree is readonly
        setEditable(false);

        // Some non relevant code snipped away
    }

    public void showIndicator()
    {
        indicator.setVisible(true);
    }

    public void hideIndicator()
    {
        indicator.setVisible(false);
    }

    // What to do when a TreeItem is actually attached...
    @Override
    protected void updateItem(final ParseTreeNode item, final boolean empty)
    {
        super.updateItem(item, empty);
        if (empty) {
            setGraphic(null);
            return;
        }
        final String msg = String.format("%s (%s)",
            item.getRuleInfo().getName(),
            item.isSuccess() ? "SUCCESS" : "FAILURE");
        text.setText(msg);
        setGraphic(hBox);
        // HACK. PUKE. UGLY.
        ((ParseTreeItem) getTreeItem()).setCell(this);
    }
}

ParseTreeItem是这样的:

public final class ParseTreeItem
    extends TreeItem<ParseTreeNode>
{
    private final boolean leaf;

    private ParseTreeNodeCell cell;

    public ParseTreeItem(final TreeTabDisplay display,
        final ParseTreeNode value)
    {
        super(value);
        leaf = !value.hasChildren();

        // If the item is expanded, we load children.
        // If it is collapsed, we unload them.
        expandedProperty().addListener(new ChangeListener<Boolean>()
        {
            @Override
            public void changed(
                final ObservableValue<? extends Boolean> observable,
                final Boolean oldValue, final Boolean newValue)
            {
                if (oldValue == newValue)
                    return;
                if (!newValue) {
                    getChildren().clear();
                    return;
                }
                display.needChildren(ParseTreeItem.this);
            }
        });
    }

    @Override
    public boolean isLeaf()
    {
        return leaf;
    }

    public void setCell(final ParseTreeNodeCell cell)
    {
        this.cell = cell;
    }

    public void showIndicator()
    {
        cell.showIndicator();
    }

    public void hideIndicator()
    {
        cell.hideIndicator();
    }
}

现在,总是在*Displayclass中,needChildren()方法是这样定义的:

ParseTreeItem currentItem;

// ...

public void needChildren(final ParseTreeItem parseTreeItem)
{
    // Keep a reference to the current item so that the *View can act on it
    currentItem = parseTreeItem;
    presenter.needChildren(currentItem.getValue());
}

演示者这样做:

public void needChildren(final ParseTreeNode value)
{
    taskRunner.computeOrFail(
        view::waitForChildren, () -> {
            // FOR TESTING
            TimeUnit.SECONDS.sleep(1L);
            return getNodeChildren(value.getId());
        },
        view::setTreeChildren,
        throwable -> mainView.showError("Tree expand error",
            "Unable to extend parse tree node", throwable)
    );
}

(参见 here;对于 taskRunner

上面 view 成员中的相应方法(JavaFX 实现)执行此操作:

@Override
public void waitForChildren()
{
    // Supposedly shows the indicator in the TreeItemGraphic...
    // Except that it does not.
    display.currentItem.showIndicator();
}

@Override
public void setTreeChildren(final List<ParseTreeNode> children)
{
    final List<ParseTreeItem> items = children.stream()
        .map(node -> new ParseTreeItem(display, node))
        .collect(Collectors.toList());
    // This works fine
    display.currentItem.getChildren().setAll(items);
    // But this does not...
    display.currentItem.hideIndicator();
}

即使我在 TreeItem 上定义方法来显示进度指示器,它也根本不显示 :/

其实我的问题是双重的,都和ParseTreeItem有关:

不仅如此,出于某种原因我需要检查(在 ParseTreeNodeCell 中)我是否确实有一个值,否则我会得到一个 NPE。而且我找不到从树项中获取匹配单元格的方法...

所以,总而言之,我做的很多事情都很糟糕,但 none 正确。

在那种情况下,只要加载仍在进行中,我如何设法获得 TreeItem 变化的图形?


编辑

找到解决方案,受@James_D编写的代码启发;查看我自己的答案,了解我是如何做到的。

首先,我承认我没有仔细检查您的所有代码。

我认为这里的方法是使用一个 TreeItem 子类,该子类公开一个可观察的 属性 描述 children 的 "loaded status"。然后让树细胞观察当前树项的加载状态,并相应显示进度条。

这是一个 SSCCE:

(已更新:显然,如果我只观察到 treeItem 而不是 item,则树无法从空单元格中删除披露图形。 .. 通过使用 itemProperty 管理文本修复。)

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import javafx.application.Application;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;



public class LazyTreeCellLoadingExample extends Application {

    // Executor for background tasks:        
    private static final ExecutorService exec = Executors.newCachedThreadPool(r -> {
        Thread t = new Thread(r);
        t.setDaemon(true);
        return t ;
    });

    @Override
    public void start(Stage primaryStage) {
        TreeView<Long> tree = new TreeView<>();
        tree.setRoot(new LazyTreeItem(1L));

        // cell factory that displays progress bar when item is loading children:
        tree.setCellFactory(tv ->  {

            // the cell:
            TreeCell<Long> cell = new TreeCell<>();

            // progress bar to display when needed:
            ProgressBar progressBar = new ProgressBar();

            // listener to observe *current* tree item's child loading status:
            ChangeListener<LazyTreeItem.ChildrenLoadedStatus> listener = (obs, oldStatus, newStatus) -> {
                if (newStatus == LazyTreeItem.ChildrenLoadedStatus.LOADING) {
                    cell.setGraphic(progressBar);
                } else {
                    cell.setGraphic(null);
                }
            };

            // listener for tree item property
            // ensures that listener above is attached to current tree item:
            cell.treeItemProperty().addListener((obs, oldItem, newItem) -> {

                // if we were displaying an item, (and no longer are...),
                // stop observing its child loading status:
                if (oldItem != null) {
                    ((LazyTreeItem) oldItem).childrenLoadedStatusProperty().removeListener(listener);
                }

                // if there is a new item the cell is displaying:
                if (newItem != null) {

                    // update graphic to display progress bar if needed:
                    LazyTreeItem lazyTreeItem = (LazyTreeItem) newItem;
                    if (lazyTreeItem.getChildrenLoadedStatus() == LazyTreeItem.ChildrenLoadedStatus.LOADING) {
                        cell.setGraphic(progressBar);
                    } else {
                        cell.setGraphic(null);
                    }

                    // observe loaded status of current item in case it changes 
                    // while we are still displaying this item:
                    lazyTreeItem.childrenLoadedStatusProperty().addListener(listener);
                } 
            });

            // change text if item changes:
            cell.itemProperty().addListener((obs, oldItem, newItem) -> {
                if (newItem == null) {
                    cell.setText(null);
                    cell.setGraphic(null);
                } else {
                    cell.setText(newItem.toString());
                }
            });

            return cell ;
        });

        Button debugButton = new Button("Debug");
        debugButton.setOnAction(evt -> {
            dumpData(tree.getRoot(), 0);
        });

        primaryStage.setScene(new Scene(new BorderPane(tree, null, null, debugButton, null), 400, 250));
        primaryStage.show();
    }

    private void dumpData(TreeItem<Long> node, int depth) {
        for (int i=0; i<depth; i++) System.out.print(" ");
        System.out.println(node.getValue());
        for (TreeItem<Long> child : node.getChildren()) dumpData(child, depth+1);
    }

    // TreeItem subclass that lazily loads children in background
    // Exposes an observable property specifying current load status of children
    public static class LazyTreeItem extends TreeItem<Long> {

        // possible load statuses:
        enum ChildrenLoadedStatus { NOT_LOADED, LOADING, LOADED }

        // observable property for current load status:
        private final ObjectProperty<ChildrenLoadedStatus> childrenLoadedStatus = new SimpleObjectProperty<>(ChildrenLoadedStatus.NOT_LOADED);

        public LazyTreeItem(Long value) {
            super(value);
        }

        // getChildren() method loads children lazily:
        @Override
        public ObservableList<TreeItem<Long>> getChildren() {
            if (getChildrenLoadedStatus() == ChildrenLoadedStatus.NOT_LOADED) {
                lazilyLoadChildren();
            }
            return super.getChildren() ;
        }

        // load child nodes in background, updating status accordingly:
        private void lazilyLoadChildren() {

            // change current status to "loading":
            setChildrenLoadedStatus(ChildrenLoadedStatus.LOADING);
            long value = getValue();

            // background task to load children:
            Task<List<LazyTreeItem>> loadTask = new Task<List<LazyTreeItem>>() {

                @Override
                protected List<LazyTreeItem> call() throws Exception {
                    List<LazyTreeItem> children = new ArrayList<>();
                    for (int i=0; i<10; i++) {
                        children.add(new LazyTreeItem(10*value + i));
                    }

                    // for testing (loading is so lazy it falls asleep)
                    Thread.sleep(3000);
                    return children;
                }

            };

            // when loading is complete:
            // 1. set actual child nodes to loaded nodes
            // 2. update status to "loaded"
            loadTask.setOnSucceeded(event -> {
                super.getChildren().setAll(loadTask.getValue());
                setChildrenLoadedStatus(ChildrenLoadedStatus.LOADED);
            });

            // execute task in backgroun
            exec.submit(loadTask);
        }

        // is leaf is true only if we *know* there are no children
        // i.e. we've done the loading and still found nothing
        @Override
        public boolean isLeaf() {
            return getChildrenLoadedStatus() == ChildrenLoadedStatus.LOADED && super.getChildren().size()==0 ;
        }

        // normal property accessor methods:

        public final ObjectProperty<ChildrenLoadedStatus> childrenLoadedStatusProperty() {
            return this.childrenLoadedStatus;
        }

        public final LazyTreeCellLoadingExample.LazyTreeItem.ChildrenLoadedStatus getChildrenLoadedStatus() {
            return this.childrenLoadedStatusProperty().get();
        }

        public final void setChildrenLoadedStatus(
                final LazyTreeCellLoadingExample.LazyTreeItem.ChildrenLoadedStatus childrenLoadedStatus) {
            this.childrenLoadedStatusProperty().set(childrenLoadedStatus);
        }


    }

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

更新

经过一番讨论,我想出了第二个解决方案。这在管理进度条的方式上与之前的解决方案基本相似:有一个 TreeItem 子类公开了一个 BooleanProperty 当且仅当该项目当前正在加载其 child仁。 TreeCell 在当前显示的 TreeItem 上观察到此 属性 - 注意向 treeItemProperty 注册一个监听器,以便 loadingProperty 的监听​​器是始终注册当前项目。

区别在于 children 的加载方式,以及 - 在本解决方案中 - 卸载方式。在之前的解决方案中,child个节点是在第一次请求时加载,然后保留。在此解决方案中, child 个节点在节点展开时加载,然后在节点折叠时移除。这是通过 expandedProperty 上的简单侦听器处理的。

从用户的角度来看,第一个解决方案的行为稍微更符合预期,因为如果您折叠作为子树头部的节点,然后再次展开它,子树的展开状态将保留。在第二种解决方案中,折叠节点具有折叠所有后代节点的效果(因为它们实际上已被删除)。

第二种解决方案对内存使用更稳健。这实际上不太可能成为某些极端用例之外的问题。 TreeItem objects 是纯粹的模型 - 即它们只存储数据,没有 UI。因此,它们每个可能使用不超过几百字节的内存。为了消耗大量内存,用户将不得不浏览数十万个节点,这可能需要数天时间。 (也就是说,我将其输入 Google Chrome,我认为我已经 运行 一个多月了,每天至少有 8-10 小时的活跃使用,所以这样的用例当然不是不可能的。)

这是第二种解决方案。一个注意事项:我没有做任何努力来处理节点的快速展开和折叠(在数据仍在加载时折叠)。 TreeItem 子类应该真正跟踪任何当前 Task(或使用 Service)并调用 cancel() 如果任务是 运行 并且用户折叠节点。我不想过多地混淆逻辑来证明基本思想。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

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.concurrent.Task;
import javafx.scene.Scene;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;

public class LazyTreeCellLoadingExample2 extends Application {

    private static final ExecutorService EXEC = Executors.newCachedThreadPool((Runnable r) -> {
        Thread t = new Thread(r);
        t.setDaemon(true);
        return t ;
    });

    @Override
    public void start(Stage primaryStage) {
        TreeView<Integer> tree = new TreeView<>();
        tree.setRoot(new LazyTreeItem(1));

        tree.setCellFactory(tv -> createTreeCell()) ;

        primaryStage.setScene(new Scene(new BorderPane(tree), 450, 600));
        primaryStage.show();
    }

    private TreeCell<Integer> createTreeCell() {

        ProgressBar progressBar = new ProgressBar();
        TreeCell<Integer> cell = new TreeCell<>();

        ChangeListener<Boolean> loadingChangeListener = 
                (ObservableValue<? extends Boolean> obs, Boolean wasLoading, Boolean isNowLoading) -> {
                   if (isNowLoading) {
                       cell.setGraphic(progressBar);
                   } else {
                       cell.setGraphic(null);
                   }
                };

        cell.treeItemProperty().addListener( 
                (ObservableValue<? extends TreeItem<Integer>> obs, 
                        TreeItem<Integer> oldItem, 
                        TreeItem<Integer> newItem) -> {

                if (oldItem != null) {
                    LazyTreeItem oldLazyTreeItem = (LazyTreeItem) oldItem ;
                    oldLazyTreeItem.loadingProperty().removeListener(loadingChangeListener);
                }

                if (newItem != null) {
                    LazyTreeItem newLazyTreeItem = (LazyTreeItem) newItem ;
                    newLazyTreeItem.loadingProperty().addListener(loadingChangeListener);

                    if (newLazyTreeItem.isLoading()) {
                        cell.setGraphic(progressBar);
                    } else {
                        cell.setGraphic(null);
                    }
                }
        });

        cell.itemProperty().addListener(
                (ObservableValue<? extends Integer> obs, Integer oldItem, Integer newItem) -> {
                   if (newItem == null) {
                       cell.setText(null);
                       cell.setGraphic(null);
                   } else {
                       cell.setText(newItem.toString());
                   }
                });

        return cell ;
    }

    public static class LazyTreeItem extends TreeItem<Integer> {

        private final BooleanProperty loading = new SimpleBooleanProperty(false);

        private boolean leaf = false ;

        public final BooleanProperty loadingProperty() {
            return this.loading;
        }

        public final boolean isLoading() {
            return this.loadingProperty().get();
        }

        public final void setLoading(final boolean loading) {
            this.loadingProperty().set(loading);
        }


        public LazyTreeItem(Integer value) {
            super(value);

            expandedProperty().addListener((ObservableValue<? extends Boolean>obs,  Boolean wasExpanded,  Boolean isNowExpanded) -> {
                if (isNowExpanded) {
                    loadChildrenLazily();
                } else {
                    clearChildren();
                }
            });
        }

        @Override
        public boolean isLeaf() {
            return leaf ;
        }

        private void loadChildrenLazily() {

            setLoading(true);

            int value = getValue();
            Task<List<TreeItem<Integer>>> loadTask = new Task<List<TreeItem<Integer>>>() {

                @Override
                protected List<TreeItem<Integer>> call() throws Exception {
                    List<TreeItem<Integer>> children = new ArrayList<>();
                    for (int i=0; i<10; i++) {
                        children.add(new LazyTreeItem(value * 10 + i));
                    }
                    Thread.sleep(3000);
                    return children ;
                }

            };

            loadTask.setOnSucceeded(event -> {
                List<TreeItem<Integer>> children = loadTask.getValue();
                leaf = children.size() == 0 ;
                getChildren().setAll(children);
                setLoading(false);
            });

            EXEC.submit(loadTask);
        }

        private void clearChildren() {
            getChildren().clear();
        }
    }

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

单元格中的"double listener"是因为我们确实需要观察一个"property of a property"。具体来说,单元格有一个 treeItem 属性,树项有一个 loadingProperty。我们真正感兴趣的是属于当前树项的 loading 属性。当然,这可以通过两种方式改变:树项在单元格中发生变化,或者 loading 属性 发生变化在树项中。 EasyBind 框架包括 API 专门用于观察 "properties of properties"。如果你使用EasyBind,你可以替换(30行左右)代码

ChangeListener<Boolean> loadingChangeListener = ... ;

cell.treeItemProperty().addListener(...);

    ObservableValue<Boolean> loading = EasyBind.select(cell.treeItemProperty())
    // ugly cast still here:
            .selectObject(treeItem -> ((LazyTreeItem)treeItem).loadingProperty());

    loading.addListener((obs, wasLoading, isNowLoading) -> {
        if (isNowLoading != null && isNowLoading.booleanValue()) {
            cell.setGraphic(progressBar);
        } else {
            cell.setGraphic(null);
        }
    });

问题已解决!

我是这样解决的...

我从这个问题中学到的第一件事是 TreeCell 实际上是 "moving object":它从 TreeItem 移动到 TreeItem;在这里,TreeItem 是一个 ParseTreeItem,它有一个专用的 属性、loadingProperty(),在单元格的 treeItemProperty() 中,我使用了这个 属性更新图形。

正如@James_D 在他的代码中建议的那样,我使用 EasyBind;代码基本上是他的抄袭,除了我不使用单元格的文本而只使用图形,这是 HorizontalBox.

此外,我还必须监听单元格的 selectedProperty(),因为当它被选中时,我需要更新信息框和文本区域:

public final class ParseTreeNodeCell
    extends TreeCell<ParseTreeNode>
{
    private final Text text = new Text();
    private final ProgressBar progressBar = new ProgressBar();
    private final HBox hBox = new HBox(text);

    public ParseTreeNodeCell(final TreeTabDisplay display)
    {
        setEditable(false);

        selectedProperty().addListener(new ChangeListener<Boolean>()
        {
            @SuppressWarnings("AutoUnboxing")
            @Override
            public void changed(
                final ObservableValue<? extends Boolean> observable,
                final Boolean oldValue, final Boolean newValue)
            {
                if (!newValue)
                    return;
                final ParseTreeNode node = getItem();
                if (node != null)
                    display.parseTreeNodeShowEvent(node);
            }
        });

        final ObservableValue<Boolean> loading
            = EasyBind.select(treeItemProperty())
            .selectObject(item -> ((ParseTreeItem) item).loadingProperty());

        loading.addListener(new ChangeListener<Boolean>()
        {
            @Override
            public void changed(
                final ObservableValue<? extends Boolean> observable,
                final Boolean oldValue, final Boolean newValue)
            {
                final ObservableList<Node> children = hBox.getChildren();
                if (newValue == null || !newValue.booleanValue()) {
                    children.remove(progressBar);
                    return;
                }

                if (!children.contains(progressBar))
                    children.add(progressBar);
            }
        });

        itemProperty().addListener(new ChangeListener<ParseTreeNode>()
        {
            @Override
            public void changed(
                final ObservableValue<? extends ParseTreeNode> observable,
                final ParseTreeNode oldValue, final ParseTreeNode newValue)
            {
                if (newValue == null) {
                    setGraphic(null);
                    return;
                }
                text.setText(String.format("%s (%s)",
                    newValue.getRuleInfo().getName(),
                    newValue.isSuccess() ? "SUCCESS" : "FAILURE"));
                setGraphic(hBox);
            }
        });
    }
}

现在,扩展 TreeItem<ParseTreeNode>ParseTreeItem 代码如下:

public final class ParseTreeItem
    extends TreeItem<ParseTreeNode>
{
    private final BooleanProperty loadingProperty
        = new SimpleBooleanProperty(false);

    private final boolean leaf;

    public ParseTreeItem(final TreeTabDisplay display,
        final ParseTreeNode value)
    {
        super(value);
        leaf = !value.hasChildren();
        expandedProperty().addListener(new ChangeListener<Boolean>()
        {
            @Override
            public void changed(
                final ObservableValue<? extends Boolean> observable,
                final Boolean oldValue, final Boolean newValue)
            {
                if (!newValue) {
                    getChildren().clear();
                    return;
                }
                display.needChildren(ParseTreeItem.this);
            }
        });
    }

    public BooleanProperty loadingProperty()
    {
        return loadingProperty;
    }

    @Override
    public boolean isLeaf()
    {
        return leaf;
    }
}

再说一次,这几乎是抄袭,除了我不希望该项目保持以下逻辑之外:

  • 获取 children,
  • 正在更新自己的加载 属性。

"secret"在display.needChildren(ParseTreeItem.this);这就是它的作用:

public void needChildren(final ParseTreeItem parseTreeItem)
{
    currentItem = parseTreeItem;
    presenter.needChildren(currentItem.getValue());
}

反过来,presenter 中的代码会:

public void needChildren(final ParseTreeNode value)
{
    taskRunner.computeOrFail(
        view::waitForChildren,
        () -> getNodeChildren(value.getId()),
        view::setTreeChildren,
        throwable -> mainView.showError("Tree expand error",
            "Unable to extend parse tree node", throwable)
    );
}

view 的用武之地;它是更新 currentItemview,因为它可以直接访问显示及其字段:

@Override
public void waitForChildren()
{
    display.currentItem.loadingProperty().setValue(true);
}

@Override
public void setTreeChildren(final List<ParseTreeNode> children)
{
    final List<ParseTreeItem> items = children.stream()
        .map(node -> new ParseTreeItem(display, node))
        .collect(Collectors.toList());
    display.currentItem.getChildren().setAll(items);
    display.currentItem.loadingProperty().setValue(false);
}

如您所见,当 waitingForChildren()setTreeChildren() 更新 loadingProperty();然后 ParseTreeNodeCell 将相应地更新图形。