等待加载完成时无法设法更新树 cell/item 图形
Cannot manage to update tree cell/item graphics while waiting for a load to complete
初始情况是这样的:
现在,这棵树可能很大(例如,我有一棵有 2200 万个节点的树)。这里发生的是我使用 jooq/h2 后端来存储所有节点并执行查询来查找子节点。
这意味着,在上图中,节点被标记为可展开,但其子节点尚未填充。它是按需完成的。展开后我得到这个:
我的问题是,扩展当然需要时间;我想做的是在 TreeItem
的图形中添加一个视觉线索,以表明它正在加载...
我做不到。
OK,首先是架构的总体概览:
- 简而言之,"MVP with passive views";
- JavaFX 称之为 "controller",在我的代码中由
*Display
classes 实现的是被动视图,其唯一作用是捕获 UI事件并将它们转发到 *Presenter
;这样的 class 是 "GUI implementation specific";
*Presenter
通过命令 *View
class 更新 *Display
; 来响应这些事件
当任务需要一定的时间才能完成时,GUI 无法保持交互,*Presenter
使用 BackgroundTaskRunner
至:
- 指示
*View
修改UI以确认任务(在GUI线程上);
- 执行任务(在后台线程上);
- 指示
*View
在任务完成时修改UI(在GUI线程上);
- 如果任务失败,指示
*View
相应地修改UI(在GUI线程上)。
使用 JavaFX:
- UI是一个
*Display
class;它由 FXML 文件定义并从中加载;
-
*View
class 的(实现)对 *Display
class.[=132= 中定义的所有 GUI 元素具有可见性]
*View
class其实是一个接口;这使我能够制作该程序的 webapp 版本(计划中)。
现在,这段代码的上下文...
*Presenter
、*View
和 *Display
都与上图中可见的 "Parse tree" 选项卡相关。
鉴于上述架构,问题在于 *View
class 和 *Display
class.
的实施
*Display
class 有一个 init()
方法,如果需要,它会初始化所有相关的 JavaFX 组件。在这种情况下,称为 parseTree
的 TreeView
被初始化为:
@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();
}
}
现在,总是在*Display
class中,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
中,我需要转换为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
的用武之地;它是更新 currentItem
的 view
,因为它可以直接访问显示及其字段:
@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
将相应地更新图形。
初始情况是这样的:
现在,这棵树可能很大(例如,我有一棵有 2200 万个节点的树)。这里发生的是我使用 jooq/h2 后端来存储所有节点并执行查询来查找子节点。
这意味着,在上图中,节点被标记为可展开,但其子节点尚未填充。它是按需完成的。展开后我得到这个:
我的问题是,扩展当然需要时间;我想做的是在 TreeItem
的图形中添加一个视觉线索,以表明它正在加载...
我做不到。
OK,首先是架构的总体概览:
- 简而言之,"MVP with passive views";
- JavaFX 称之为 "controller",在我的代码中由
*Display
classes 实现的是被动视图,其唯一作用是捕获 UI事件并将它们转发到*Presenter
;这样的 class 是 "GUI implementation specific"; *Presenter
通过命令*View
class 更新*Display
; 来响应这些事件
当任务需要一定的时间才能完成时,GUI 无法保持交互,
*Presenter
使用BackgroundTaskRunner
至:- 指示
*View
修改UI以确认任务(在GUI线程上); - 执行任务(在后台线程上);
- 指示
*View
在任务完成时修改UI(在GUI线程上); - 如果任务失败,指示
*View
相应地修改UI(在GUI线程上)。
- 指示
使用 JavaFX:
- UI是一个
*Display
class;它由 FXML 文件定义并从中加载; -
*View
class 的(实现)对*Display
class.[=132= 中定义的所有 GUI 元素具有可见性]
*View
class其实是一个接口;这使我能够制作该程序的 webapp 版本(计划中)。
现在,这段代码的上下文...
*Presenter
、*View
和 *Display
都与上图中可见的 "Parse tree" 选项卡相关。
鉴于上述架构,问题在于 *View
class 和 *Display
class.
*Display
class 有一个 init()
方法,如果需要,它会初始化所有相关的 JavaFX 组件。在这种情况下,称为 parseTree
的 TreeView
被初始化为:
@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();
}
}
现在,总是在*Display
class中,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
中,我需要转换为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
的用武之地;它是更新 currentItem
的 view
,因为它可以直接访问显示及其字段:
@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
将相应地更新图形。