JavaFX bad design: Row identity in observable lists behind the TableView?

↘锁芯ラ 提交于 2019-12-24 07:16:42

问题


Suppose I am displaying very long table with TableView. Manual says, TableView

is designed to visualize an unlimited number of rows of data

So, since million of rows won't fit the RAM, I would introduce some caching. This means, that ObservableList#get() is allowed to return different instances for the same row index.

Is this true?

What about opposite? May I return the same instance for all row indices filled with different data?

I noticed, that this implies some problem with row editing. At which moment should I pass data to the store? Looks like TableView never calls ObservableList#set() but just mutates obtained instance.

Where to intercept?

UPDATE

Also imagine this very big table was updated at server side. Suppose, one million of records were added.

The only way to report about it -- is by firing observable list addition event, while an addition event also contains reference to all added rows. Which is nonsense -- why send data, which is not event displayed?


回答1:


I think the intention of the statement in the Javadocs that you quote

is designed to visualize an unlimited number of rows of data

is meant to imply that the TableView imposes no (additional) constraints on the size of the table data: in other words that the view is essentially scalable at a constant memory consumption. This is achieved by the "virtualization": the table view creates cells only for the visible data, and reuses them for different items in the backing list as, e.g., the user scrolls. Since in a typical application the cells (which are graphical) consume far more memory than the data, this represents a big performance saving and allows for as many rows in the table as could feasibly be handled by the user.

There are still, of course, other constraints on the table data size that are not imposed by the table view. The model (i.e. observable list) needs to store the data and consequently memory constraints will (in the default implementation) impose a constraint on the number of rows in the table. You could implement a caching list (see below) to reduce the memory footprint, if needed. And as @fabian points out in the comments below the question, user experience is likely to impose constraints long before you reach that point (I'd recommend using pagination or some kind of filtering).

Your question about identity of elements retrieved from the list is pertinent in a caching implementation: it basically boils down to whether a list implementation is obliged to guarantee list.get(i) == list.get(i), or whether it is enough merely to guarantee list.get(i).equals(list.get(i)). To the best of my knowledge, TableView only expects the latter, so an implementation of ObservableList that caches a relatively small number of elements and recreates them as needed should work.

For proof of concept, here is an implementation of an unmodifiable caching observable list:

import java.util.LinkedList;
import java.util.function.IntFunction;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import javafx.collections.ObservableListBase;

public class CachedObservableList<T> extends ObservableListBase<T> {

    private final int maxCacheSize ;
    private int cacheStartIndex ;
    private int actualSize ;
    private final IntFunction<T> generator ;

    private final LinkedList<T> cache ;

    public CachedObservableList(int maxCacheSize, int size, IntFunction<T> generator) {
        this.maxCacheSize = maxCacheSize ;
        this.generator = generator ;

        this.cache = new LinkedList<T>();

        this.actualSize = size ;
    }

    @Override
    public T get(int index) {

        int debugCacheStart = cacheStartIndex ;
        int debugCacheSize = cache.size(); 

        if (index < cacheStartIndex) {
            // evict from end of cache:
            int numToEvict = cacheStartIndex + cache.size() - (index + maxCacheSize);
            if (numToEvict < 0) {
                numToEvict = 0 ;
            }
            if (numToEvict > cache.size()) {
                numToEvict = cache.size();
            }
            cache.subList(cache.size() - numToEvict, cache.size()).clear();

            // create new elements:
            int numElementsToCreate = cacheStartIndex - index ;
            if (numElementsToCreate > maxCacheSize) {
                numElementsToCreate = maxCacheSize ;
            }
            cache.addAll(0, 
                    IntStream.range(index, index + numElementsToCreate)
                    .mapToObj(generator)
                    .collect(Collectors.toList()));

            cacheStartIndex = index ;

        } else if (index >= cacheStartIndex + cache.size()) {
            // evict from beginning of cache:
            int numToEvict = index - cacheStartIndex - maxCacheSize + 1 ;
            if (numToEvict < 0) {
                numToEvict = 0 ;
            }
            if (numToEvict >= cache.size()) {
                numToEvict = cache.size();
            }

            cache.subList(0, numToEvict).clear();

            // create new elements:

            int numElementsToCreate = index - cacheStartIndex - numToEvict - cache.size() + 1; 
            if (numElementsToCreate > maxCacheSize) {
                numElementsToCreate = maxCacheSize ;
            }

            cache.addAll(
                    IntStream.range(index - numElementsToCreate + 1, index + 1)
                    .mapToObj(generator)
                    .collect(Collectors.toList()));

            cacheStartIndex = index - cache.size() + 1 ;
        }

        try {
            T t = cache.get(index - cacheStartIndex);
            assert(generator.apply(index).equals(t));
            return t ;
        } catch (Throwable exc) {
            System.err.println("Exception retrieving index "+index+": cache start was "+debugCacheStart+", cache size was "+debugCacheSize);
            throw exc ;
        }

    }

    @Override
    public int size() {
        return actualSize ;
    }

}

And here's a quick example using it, that has 100,000,000 rows in the table. Obviously this is unusable from a user experience perspective, but it seems to work perfectly well (even if you change the cache size to be smaller than the number of displayed cells).

import java.util.Objects;

import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.stage.Stage;

public class CachedTableView extends Application {

    @Override
    public void start(Stage primaryStage) {
        CachedObservableList<Item> data = new CachedObservableList<>(100, 100_000_000, i -> new Item(String.format("Item %,d",i)));

        TableView<Item> table = new TableView<>();
        table.setItems(data);

        TableColumn<Item, String> itemCol = new TableColumn<>("Item");
        itemCol.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
        itemCol.setMinWidth(300);
        table.getColumns().add(itemCol);

        Scene scene = new Scene(table, 600, 600);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static class Item {
        private final StringProperty name = new SimpleStringProperty();


        public Item(String name) {
            setName(name) ;
        }

        public final StringProperty nameProperty() {
            return this.name;
        }


        public final String getName() {
            return this.nameProperty().get();
        }


        public final void setName(final String name) {
            this.nameProperty().set(name);
        }

        @Override
        public boolean equals(Object o) {
            if (o.getClass() != Item.class) {
                return false ;
            }
            return Objects.equals(getName(), ((Item)o).getName());
        }
    }


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

There's obviously quite a lot more to do if you want to implement the list so that it is modifiable; start by thinking about exactly what behavior you would need for set(index, element) if index is not in the cache... and then subclass ModifiableObservableListBase.

For editing:

I noticed, that this implies some problem with row editing. At which moment should I pass data to the store? Looks like TableView never calls ObservableList#set() but just mutates obtained instance.

You have three options that I can see:

If your domain objects use JavaFX properties, then the default behavior is to update the property when editing is committed. You can register listeners with the properties and update the backing store if they change.

Alternatively, you can register an onEditCommit handler with the TableColumn; this will get notified when an edit is committed in the table, and so you could update the store from this. Note that this will replace the default edit commit behavior, so you will also need to update the property. This gives you the opportunity to veto the update to the cached property if the update to the store fails for some reason, and is probably the option you want.

Thirdly, if you implement the editing cells yourself, instead of using default implementations such as TextFieldTableCell, you could invoke methods on the model directly from the controls in the cell. This is probably not desirable, as it violates the standard design patterns and avoids the usual editing notifications built into the table view, but it may be a useful option in some cases.

Also imagine this very big table was updated at server side. Suppose, one million of records were added.

The only way to report about it -- is by firing observable list addition event, while an addition event also contains reference to all added rows.

That's not true, as far as I can tell. ListChangeListener.Change has a getAddedSublist() method, but the API docs for this state it returns

a subList view of the list that contains only the elements added

so it should simply return getItems().sublist(change.getFrom(), change.getTo()). Of course, this simply returns a sublist view of the cached list implementation, so doesn't create the objects unless you explicitly request them. (Note that getRemoved() might potentially cause more problems, but there should be some way to work around that too.)

Finally, to bring this full circle, while the observable list implementation is doing work here of caching the elements and making the model "unlimited" in the number of rows it can support (up to Integer.MAX_VALUE), it wouldn't be possible to use this in the table view if the table view didn't implement "virtualization". A non-virtualized implementation of table view would create cells for each item in the list (i.e. it would call get(i) for 0 <= i < items.size(), creating a cell for each), place the cells in a scroll pane implementation, and the memory consumption would blow up even with the caching in the list. So "unlimited" in the Javadocs really does mean that any limit is deferred to implementation of the model.



来源:https://stackoverflow.com/questions/36937118/javafx-bad-design-row-identity-in-observable-lists-behind-the-tableview

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!