8

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;
    }
}
7
  • 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. Commented Sep 17, 2018 at 9:25
  • 1
    ookay .. seems to be related to selection: if the expanding item is not selected, all is fine - if it is selected it hangs ... Commented Sep 17, 2018 at 11:40
  • 2
    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 out Commented Sep 17, 2018 at 12:43
  • 1
    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 fine Commented Sep 18, 2018 at 10:31
  • 1
    @kleopatra If/when you file the bug you should include the TreeTableViewFocusModel because it does pretty much the same thing. Commented Sep 18, 2018 at 11:04

1 Answer 1

8

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.

Sign up to request clarification or add additional context in comments.

16 Comments

Thanks for taking the time to write this answer slaw. Sorry, i forgot to mention that the hang only happens when there's a huge amount of treeitems to be loaded, in my case 20,000 and above. I removed the isLeaf method as a whole but it didn't solve it. Does your solution work when a directory has 100,000 files?
I will try and do the test now.
I added this inside the else statement in directory loader int count = 0; while (++count < 100000) { list.add(TreeData.createLeaf(fo)); }, and expanded a drive with only one leaf file and the same hang occurred, @Kleopatra in the replies above found out it had to do with the item being selected/focused, in your example app the first treecell is always focused as indicated by the blue border, and therefore produces the hang immediately once treeitem is expanded.
@DarkMental No real advantage. Using a Task could be considered the "more JavaFX way". The reasons I used CompletableFuture are (1) it seemed simpler for a small example and (2) I'm not showing visual progress. And no, calling cancel doesn't stop the execution immediately. In fact, CompletableFuture.cancel behaves differently than a "normal" Future; it doesn't interrupt the Thread. Even if it did, my code doesn't check for interruption so it wouldn't stop execution anyway.
@Thorsten I don't know the exact bug ID, assuming kleopatra (or someone else) ever actually filed a bug report. But the problem, at least back in 2018, was that adding a large number of tree items to the tree view while an item was selected. Basically, in that case, it took forever for the items to be added due to bad code in the focus model implementation. The workaround caches what's selected, clears the selection, adds the items, and then reselects the appropriate items. It's possible this problem has been fixed since this answer was posted, and the workaround is no longer needed.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.