JavaFX 糟糕的设计:TableView 后面的可观察列表中的行标识?

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

假设我显示很长 table 和 TableView。手册说,TableView

is designed to visualize an unlimited number of rows of data

因此,由于 RAM 放不下数百万行,我将引入一些缓存。这意味着,ObservableList#get() 允许 return 同一行索引的不同实例。

这是真的吗?

对面呢?我可以 return 为所有填充不同数据的行索引使用相同的实例吗?

我注意到,这意味着行编辑存在一些问题。我应该在什么时候将数据传递给商店?看起来 TableView 从来没有调用过 ObservableList#set() 而只是改变了获得的实例。

在哪里拦截?

更新

想象一下这个非常大的 table 是在服务器端更新的。假设,添加了一百万条记录。

报告它的唯一方法是触发可观察列表添加事件,而添加事件还包含对所有添加行的引用。废话--为什么要发送数据,这不是事件显示?

我认为您引用的 Javadocs 中声明的意图

is designed to visualize an unlimited number of rows of data

意味着 TableView 对 table 数据的大小没有施加(额外的)限制:换句话说,该视图在内存消耗不变的情况下基本上是可扩展的。这是通过“虚拟化”实现的:table 视图仅为可见数据创建单元格,并将它们重新用于支持列表中的不同项目,例如,用户滚动。由于在典型的应用程序中,单元格(图形化的)消耗的内存比数据多得多,这代表了很大的性能节省,并允许 table 中的行数尽可能多地由用户处理。

当然,table 数据大小还有其他约束,这些约束不是由 table 视图强加的。模型(即可观察列表)需要存储数据,因此内存约束将(在默认实现中)对 table 中的行数施加约束。如果需要,您可以实施缓存列表(见下文)以减少内存占用。正如@fabian 在问题下方的评论中指出的那样,用户体验很可能会在您达到该点之前很久就施加限制(我建议使用分页或某种过滤)。

您关于从列表中检索到的元素的身份的问题与缓存实现相关:它基本上归结为列表实现是否有义务保证 list.get(i) == list.get(i),或者仅仅保证 list.get(i).equals(list.get(i))。据我所知,TableView 只需要后者,因此 ObservableList 的实现可以缓存相对较少的元素并根据需要重新创建它们。

为了概念验证,这里是不可修改的缓存可观察列表的实现:

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 ;
    }
  
}

这是一个使用它的简单示例,在 table 中有 100,000,000 行。显然,从用户体验的角度来看,这是不可用的,但它似乎工作得很好(即使您将缓存大小更改为小于显示的单元格数)。

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);
    }
}

如果要实现列表以使其可修改,显然还有很多工作要做;首先考虑如果 index 不在缓存中,set(index, element) 需要什么行为...然后子类 ModifiableObservableListBase.

待编辑:

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.

我可以看到你有三个选项:

如果您的域对象使用 JavaFX 属性,则默认行为是在提交编辑时更新 属性。您可以使用这些属性注册侦听器,并在它们发生变化时更新后备存储。

或者,您可以使用 TableColumn; 注册一个 onEditCommit 处理程序;当在 table 中提交编辑时,这将收到通知,因此您可以从中更新商店。请注意,这将替换默认的编辑提交行为,因此您还需要更新 属性。如果由于某种原因对商店的更新失败,这使您有机会否决对缓存的更新 属性,这可能是您想要的选项。

第三,如果您自己实现编辑单元格,而不是使用 TextFieldTableCell 等默认实现,您可以直接从单元格中的控件调用模型上的方法。这可能是不可取的,因为它违反了标准设计模式并避免了 table 视图中内置的常用编辑通知,但在某些情况下它可能是一个有用的选项。

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.

据我所知,这不是真的。 ListChangeListener.Change 有一个 getAddedSublist() 方法,但是 API 文档说明了它 returns

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

所以它应该只是 return getItems().sublist(change.getFrom(), change.getTo())。当然,这只是 return 缓存列表实现的子列表视图,因此不会创建对象,除非您明确请求它们。 (请注意,getRemoved() 可能会导致更多问题,但也应该有一些方法可以解决这个问题。)

最后,为了实现这个完整的循环,可观察列表实现正在这里缓存元素并使模型在它可以支持的行数方面“不受限制”(最多 Integer.MAX_VALUE) ,如果 table 视图没有实现“虚拟化”,就不可能在 table 视图中使用它。 table 视图的非虚拟化实现将为列表中的每个项目创建单元格(即它将为 0 <= i < items.size() 调用 get(i),为每个项目创建一个单元格),将单元格放在滚动窗格的实现,即使在列表中进行缓存,内存消耗也会激增。所以 Javadocs 中的“无限制”确实意味着任何限制都推迟到模型的实现。