6
votes

I have an app where i have a TreeView which will have TreeItems holding a large number of leaf TreeItems. Having a huge number of TreeItems in the treeview hurts the performance of the app noticeably, to avoid that, what i will do, is i will allow only one non-leaf TreeItem to be expanded at a time, and once a TreeItem is folded, i will clear it's children, and load them asynchronously once needed (When the user expands the TreeItem).

The weird issue is, in this test below, when i first click the expand arrow on the treeitem, the children load fine, and if i fold it (which will clear children) and unfold it again, sometimes it works and others the program hogs and starts consuming 30% of the cpu for a couple of minutes then gets back running. What's weirder is that if i double click on the TreeItem to expand it (Not using the arrow) the hog starts right away, even at first program launch.

What could i be possibly doing wrong here?

PS:

  • Some of the code in the LazyTreeItem class is inspired by James_D's answer Here

  • I tried running the loadItems task on the fx thread(Not using the ItemLoader), but it didn't make any difference.

  • Same issue occurs using both JAVA 8 and JAVA 9

App.java

public class App extends Application {

    private TreeView<Item> treeView = new TreeView<>();

    @Override
    public void start(Stage primaryStage) throws Exception {
        primaryStage.setTitle("TreeView Lazy Load");
        primaryStage.setScene(new Scene(new StackPane(treeView), 300, 275));
        initTreeView();
        primaryStage.show();
    }

    private void initTreeView() {
        treeView.setShowRoot(false);
        treeView.setRoot(new TreeItem<>(null));

        List<SingleItem> items = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            items.add(new SingleItem(String.valueOf(i)));
        }
        TreeItem<Item> parentItem = new TreeItem<>(new Item());
        parentItem.getChildren().add(new LazyTreeItem(new MultipleItem(items)));

        treeView.getRoot().getChildren().add(parentItem);
    }

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

LazyTreeItem.java

public class LazyTreeItem extends TreeItem<Item> {
    private boolean childrenLoaded = false;
    private boolean isLoadingItems = false;

    public LazyTreeItem(Item value) {
        super(value);
        // Unload data on folding to reduce memory
        expandedProperty().addListener((observable, oldValue, newValue) -> {
            if (!newValue) {
                flush();
            }
        });
    }

    @Override
    public ObservableList<TreeItem<Item>> getChildren() {
        if (childrenLoaded || !isExpanded()) {
            return super.getChildren();
        }
        if (super.getChildren().size() == 0) {
            // Filler node (will translate into loading icon in the
            // TreeCell factory)
            super.getChildren().add(new TreeItem<>(null));
        }
        if (getValue() instanceof MultipleItem) {
            if (!isLoadingItems) {
                loadItems();
            }
        }
        return super.getChildren();
    }

    public void loadItems() {
        Task<List<TreeItem<Item>>> task = new Task<List<TreeItem<Item>>>() {
            @Override
            protected List<TreeItem<Item>> call() {
                isLoadingItems = true;
                List<SingleItem> downloadSet = ((MultipleItem) LazyTreeItem.this.getValue()).getEntries();
                List<TreeItem<Item>> treeNodes = new ArrayList<>();
                for (SingleItem download : downloadSet) {
                    treeNodes.add(new TreeItem<>(download));
                }
                return treeNodes;
            }
        };
        task.setOnSucceeded(e -> {
            Platform.runLater(() -> {
                super.getChildren().clear();
                super.getChildren().addAll(task.getValue());
                childrenLoaded = true;
                isLoadingItems = false;
            });
        });
        ItemLoader.getSingleton().load(task);
    }

    private void flush() {
        childrenLoaded = false;
        super.getChildren().clear();
    }

    @Override
    public boolean isLeaf() {
        if (childrenLoaded) {
            return getChildren().isEmpty();
        }
        return false;
    }
}

ItemLoader.java

public class ItemLoader implements Runnable {
    private static ItemLoader instance;
    private List<Task> queue = new ArrayList<>();
    private Task prevTask = null;

    private ItemLoader() {
        Thread runner = new Thread(this);
        runner.setName("ItemLoader thread");
        runner.setDaemon(true);
        runner.start();
    }

    public static ItemLoader getSingleton() {
        if (instance == null) {
            instance = new ItemLoader();
        }
        return instance;
    }

    public <T> void load(Task task) {
        if (queue.size() < 1) {
            queue.add(task);
        }
    }

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            if (!queue.isEmpty()) {
                Task task = queue.get(0);
                if (task != prevTask) {
                    prevTask = task;
                    task.run();
                    queue.remove(task);
                }
            }
        }
    }
}

Model (Item.java, SingleItem.java, MultipleItem.java)

public class Item {

}
/****************************************************************
 **********                  SingleItem              ************
 ****************************************************************/
public class SingleItem extends Item {
    private String id;

    public SingleItem(String id) {
        this.id = id;
    }

    public void setId(String id) {
        this.id = id;
    }
}
/****************************************************************
 **********                  MultipleItem            ************
 ****************************************************************/
public class MultipleItem extends Item {

    private List<SingleItem> entries = new ArrayList<>();

    public MultipleItem(List<SingleItem> entries) {
        this.entries = entries;
    }

    public List<SingleItem> getEntries() {
        return entries;
    }

    public void setEntries(List<SingleItem> entries) {
        this.entries = entries;
    }
}
1
I can reproduce the issue in fx 10 (when double clicking on the item) - not when expanding on the triangle. No idea what happens, but: the leaf/leafProperty is not synched (due to the api bug, as James noted), so some internals - which are dirty anyway - might get into further trouble. In your shoes I would go into a profiling round: find the bottleneck with a profiler and then go from there.kleopatra
ookay .. seems to be related to selection: if the expanding item is not selected, all is fine - if it is selected it hangs ...kleopatra
it's hanging in TreeViewFocusModel which is buggy in checking the row of each of the added items (vs. querying the row of the last added in a block as TreeViewSelectionModel does) - no idea how to hack around, implementing a custom FocusModel might be a (nasty .. all is very hidden and very hard to extend) the only way outkleopatra
definitely a bug (will file it soon), and definitely (modulo missing tests ;) can be hacked by a custom FocusModel that queries the newly added items only if focusedIndex > sourceRow (c&p the default - which is diiirty by tweaking internal state of both treeItem and treeView ... which in turn requires dirty reflective access) and then do better than default impl, no reason to query each added item for its row, doing so for the last of the added bunch should be just finekleopatra
@kleopatra If/when you file the bug you should include the TreeTableViewFocusModel because it does pretty much the same thing.Slaw

1 Answers

5
votes

The issue, as pointed out by @kleopatra, is caused by adding a large amount of children when there are one or more items selected. One way to fix this is to try and implement your own FocusModel, as the default FocusModel seems to be the source of the problem. Another, and in my opinion easier, way to create a workaround is to clear the selection before adding the large group of children; afterwards, you can re-select the items that were previously selected.

The way I went about doing this is by firing TreeModificationEvents with custom EventTypes. Also, I decided not to override isLeaf() inside my lazy TreeItem. I find it easier to use a placeholder TreeItem for when the parent TreeItem is a lazy branch. Since there is a placeholder the parent will register as a branch automatically.

Here's an example that browses the default FileSystem. To test if the solution worked I created a 100,000 file directory and opened it; there was no hang for me. Hopefully that means this can be adapted to your code.

Note: This example does remove the children when the branch is collapsed, just like you are doing in your code.


App.java

import java.nio.file.FileSystems;
import java.nio.file.Path;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.stage.Stage;

public class App extends Application {

  private static String pathToString(Path p) {
    if (p == null) {
      return "null";
    } else if (p.getFileName() == null) {
      return p.toString();
    }
    return p.getFileName().toString();
  }

  @Override
  public void start(Stage primaryStage) {
    TreeView<Path> tree = new TreeView<>(new TreeItem<>());
    tree.setShowRoot(false);
    tree.setCellFactory(LazyTreeCell.forTreeView("Loading...", App::pathToString));
    TreeViewUtils.installSelectionBugWorkaround(tree);

    for (Path fsRoot : FileSystems.getDefault().getRootDirectories()) {
      tree.getRoot().getChildren().add(new LoadingTreeItem<>(fsRoot, new DirectoryLoader(fsRoot)));
    }

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

}

DirectoryLoader.java

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
import javafx.scene.control.TreeItem;

public class DirectoryLoader implements Callable<List<? extends TreeItem<Path>>> {

  private static final Comparator<Path> COMPARATOR = (left, right) -> {
    boolean leftIsDir = Files.isDirectory(left);
    if (leftIsDir ^ Files.isDirectory(right)) {
      return leftIsDir ? -1 : 1;
    }
    return left.compareTo(right);
  };

  private final Path directory;

  public DirectoryLoader(Path directory) {
    this.directory = directory;
  }

  @Override
  public List<? extends TreeItem<Path>> call() throws Exception {
    try (Stream<Path> stream = Files.list(directory)) {
      return stream.sorted(COMPARATOR)
          .map(this::toTreeItem)
          .collect(Collectors.toList());
    }
  }

  private TreeItem<Path> toTreeItem(Path path) {
    return Files.isDirectory(path)
           ? new LoadingTreeItem<>(path, new DirectoryLoader(path))
           : new TreeItem<>(path);
  }

}

LoadingTreeItem.java

import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Supplier;
import javafx.application.Platform;
import javafx.collections.ObservableList;
import javafx.event.Event;
import javafx.event.EventType;
import javafx.scene.control.TreeItem;

public class LoadingTreeItem<T> extends TreeItem<T> {

  private static final EventType<?> PRE_ADD_LOADED_CHILDREN
      = new EventType<>(treeNotificationEvent(), "PRE_ADD_LOADED_CHILDREN");
  private static final EventType<?> POST_ADD_LOADED_CHILDREN
      = new EventType<>(treeNotificationEvent(), "POST_ADD_LOADED_CHILDREN");

  @SuppressWarnings("unchecked")
  static <T> EventType<TreeModificationEvent<T>> preAddLoadedChildrenEvent() {
    return (EventType<TreeModificationEvent<T>>) PRE_ADD_LOADED_CHILDREN;
  }

  @SuppressWarnings("unchecked")
  static <T> EventType<TreeModificationEvent<T>> postAddLoadedChildrenEvent() {
    return (EventType<TreeModificationEvent<T>>) POST_ADD_LOADED_CHILDREN;
  }

  private final Callable<List<? extends TreeItem<T>>> callable;
  private boolean needToLoadData = true;

  private CompletableFuture<?> future;

  public LoadingTreeItem(T value, Callable<List<? extends TreeItem<T>>> callable) {
    super(value);
    this.callable = callable;
    super.getChildren().add(new TreeItem<>());
    addExpandedListener();
  }

  @SuppressWarnings("unchecked")
  private void addExpandedListener() {
    expandedProperty().addListener((observable, oldValue, newValue) -> {
      if (!newValue) {
        needToLoadData = true;
        if (future != null) {
          future.cancel(true);
        }
        super.getChildren().setAll(new TreeItem<>());
      }
    });
  }

  @Override
  public ObservableList<TreeItem<T>> getChildren() {
    if (needToLoadData) {
      needToLoadData = false;
      future = CompletableFuture.supplyAsync(new CallableToSupplierAdapter<>(callable))
          .whenCompleteAsync(this::handleAsyncLoadComplete, Platform::runLater);
    }
    return super.getChildren();
  }

  private void handleAsyncLoadComplete(List<? extends TreeItem<T>> result, Throwable th) {
    if (th != null) {
      Thread.currentThread().getUncaughtExceptionHandler()
          .uncaughtException(Thread.currentThread(), th);
    } else {
      Event.fireEvent(this, new TreeModificationEvent<>(preAddLoadedChildrenEvent(), this));
      super.getChildren().setAll(result);
      Event.fireEvent(this, new TreeModificationEvent<>(postAddLoadedChildrenEvent(), this));
    }
    future = null;
  }

  private static class CallableToSupplierAdapter<T> implements Supplier<T> {

    private final Callable<T> callable;

    private CallableToSupplierAdapter(Callable<T> callable) {
      this.callable = callable;
    }

    @Override
    public T get() {
      try {
        return callable.call();
      } catch (Exception ex) {
        throw new CompletionException(ex);
      }
    }

  }

}

LazyTreeCell.java

import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeView;
import javafx.util.Callback;

public class LazyTreeCell<T> extends TreeCell<T> {

  public static <T> Callback<TreeView<T>, TreeCell<T>> forTreeView(String placeholderText,
                                                                   Callback<? super T, String> toStringCallback) {
    return tree -> new LazyTreeCell<>(placeholderText, toStringCallback);
  }

  private final String placeholderText;
  private final Callback<? super T, String> toStringCallback;

  public LazyTreeCell(String placeholderText, Callback<? super T, String> toStringCallback) {
    this.placeholderText = placeholderText;
    this.toStringCallback = toStringCallback;
  }

  /*
   * Assumes that if "item" is null **and** the parent TreeItem is an instance of
   * LoadingTreeItem that this is a "placeholder" cell.
   */
  @Override
  protected void updateItem(T item, boolean empty) {
    super.updateItem(item, empty);
    if (empty) {
      setText(null);
      setGraphic(null);
    } else if (item == null && getTreeItem().getParent() instanceof LoadingTreeItem) {
      setText(placeholderText);
    } else {
      setText(toStringCallback.call(item));
    }
  }

}

TreeViewUtils.java

import java.util.ArrayList;
import java.util.List;
import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeItem.TreeModificationEvent;
import javafx.scene.control.TreeView;

public class TreeViewUtils {

  public static <T> void installSelectionBugWorkaround(TreeView<T> tree) {
    List<TreeItem<T>> selected = new ArrayList<>(0);
    EventHandler<TreeModificationEvent<T>> preAdd = event -> {
      event.consume();
      selected.addAll(tree.getSelectionModel().getSelectedItems());
      tree.getSelectionModel().clearSelection();
    };
    EventHandler<TreeModificationEvent<T>> postAdd = event -> {
      event.consume();
      selected.forEach(tree.getSelectionModel()::select);
      selected.clear();
    };
    ChangeListener<TreeItem<T>> rootListener = (observable, oldValue, newValue) -> {
      if (oldValue != null) {
        oldValue.removeEventHandler(LoadingTreeItem.preAddLoadedChildrenEvent(), preAdd);
        oldValue.removeEventHandler(LoadingTreeItem.postAddLoadedChildrenEvent(), postAdd);
      }
      if (newValue != null) {
        newValue.addEventHandler(LoadingTreeItem.preAddLoadedChildrenEvent(), preAdd);
        newValue.addEventHandler(LoadingTreeItem.postAddLoadedChildrenEvent(), postAdd);
      }
    };
    rootListener.changed(tree.rootProperty(), null, tree.getRoot());
    tree.rootProperty().addListener(rootListener);
  }

  private TreeViewUtils() {}
}

As implemented, the utility method that installs the workaround is tied to you using LoadingTreeItems in the TreeView. I couldn't think of a good way to make the solution general enough to apply to any arbitrary TreeView; for that, I believe creating a custom FocusModel would be necessary.

There is probably a better way to implement the LazyTreeCell by using a class to wrap the real data—like what you're doing with Item. Then you could have an actual placehoder Item instance that tells the TreeCell that it is a placeholder rather than relying on what type the parent TreeItem is. As it is, my implementation is likely brittle.