重写 MappedObservableList (#5400)

This commit is contained in:
Glavo
2026-02-06 00:22:21 +08:00
committed by GitHub
parent ab9c45549b
commit 5839382c64
2 changed files with 363 additions and 100 deletions

View File

@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,124 +17,121 @@
*/
package org.jackhuang.hmcl.util.javafx;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.WeakListChangeListener;
import org.jackhuang.hmcl.util.Holder;
import javafx.collections.transformation.TransformationList;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import static java.util.stream.Collectors.toCollection;
import static javafx.collections.FXCollections.unmodifiableObservableList;
/// @author Glavo
public final class MappedObservableList<E, F> extends TransformationList<E, F> {
/**
* @author yushijinhun
*/
public final class MappedObservableList {
private MappedObservableList() {
/// This method creates a mapping of `source`, using `mapper` as the converter.
///
/// If an item is added to `source`, `mapper` will be invoked to create a corresponding item, which will also be added to the returned `ObservableList`.
/// If an item is removed from `source`, the corresponding item in the returned `ObservableList` will also be removed.
/// If `source` is permutated, the returned `ObservableList` will also be permutated in the same way.
///
/// The returned `ObservableList` is unmodifiable.
public static <T, U> ObservableList<U> create(ObservableList<T> source, Function<T, U> mapper) {
return new MappedObservableList<>(source, mapper);
}
private static final class MappedObservableListUpdater<T, U> implements ListChangeListener<T> {
private final ObservableList<T> origin;
private final ObservableList<U> target;
private final Function<T, U> mapper;
private final Function<? super F, ? extends E> mapper;
private final List<E> elements;
// If we directly synchronize changes to target, each operation on target will cause a event to be fired.
// So we first write changes to buffer. After all the changes are processed, we use target.setAll to synchronize the changes.
private final List<U> buffer;
MappedObservableListUpdater(ObservableList<T> origin, ObservableList<U> target, Function<T, U> mapper) {
this.origin = origin;
this.target = target;
public MappedObservableList(@NotNull ObservableList<? extends F> source, @NotNull Function<? super F, ? extends E> mapper) {
super(source);
this.mapper = mapper;
this.buffer = new ArrayList<>(target);
this.elements = new ArrayList<>(source.size());
for (F f : source) {
elements.add(mapper.apply(f));
}
}
@Override
public void onChanged(Change<? extends T> change) {
// cache removed elements to reduce calls to mapper
Map<T, LinkedList<U>> cache = new IdentityHashMap<>();
@SuppressWarnings("unchecked")
protected void sourceChanged(ListChangeListener.Change<? extends F> change) {
beginChange();
while (change.next()) {
int from = change.getFrom();
int to = change.getTo();
if (change.wasPermutated()) {
@SuppressWarnings("unchecked")
U[] temp = (U[]) new Object[to - from];
Object[] temp = new Object[to - from];
int[] permutations = new int[to - from];
for (int i = 0; i < temp.length; i++) {
temp[i] = buffer.get(from + i);
temp[i] = elements.get(from + i);
}
for (int idx = from; idx < to; idx++) {
buffer.set(change.getPermutation(idx), temp[idx - from]);
for (int i = from; i < to; i++) {
int n = i - from;
int permutation = change.getPermutation(i);
permutations[n] = permutation;
elements.set(permutation, (E) temp[n]);
}
nextPermutation(from, to, permutations);
} else if (change.wasUpdated()) {
for (int i = from; i < to; i++) {
elements.set(i, mapper.apply(getSource().get(i)));
nextUpdate(i);
}
} else {
List<E> removed = List.of();
if (change.wasRemoved()) {
List<? extends T> originRemoved = change.getRemoved();
List<U> targetRemoved = buffer.subList(from, from + originRemoved.size());
for (int i = 0; i < targetRemoved.size(); i++) {
pushCache(cache, originRemoved.get(i), targetRemoved.get(i));
}
targetRemoved.clear();
List<E> subList = elements.subList(from, from + change.getRemovedSize());
removed = new ArrayList<>(subList);
subList.clear();
}
if (change.wasAdded()) {
@SuppressWarnings("unchecked")
U[] toAdd = (U[]) new Object[to - from];
for (int i = 0; i < toAdd.length; i++) {
toAdd[i] = map(cache, origin.get(from + i));
Object[] temp = new Object[to - from];
List<? extends F> addedSubList = change.getAddedSubList();
for (int i = 0; i < addedSubList.size(); i++) {
temp[i] = mapper.apply(addedSubList.get(i));
}
buffer.addAll(from, Arrays.asList(toAdd));
}
}
}
target.setAll(buffer);
elements.addAll(from, (List<E>) Arrays.asList(temp));
}
private void pushCache(Map<T, LinkedList<U>> cache, T key, U value) {
cache.computeIfAbsent(key, any -> new LinkedList<>())
.push(value);
}
private U map(Map<T, LinkedList<U>> cache, T key) {
LinkedList<U> stack = cache.get(key);
if (stack != null && !stack.isEmpty()) {
return stack.pop();
}
return mapper.apply(key);
if (change.wasRemoved() && change.wasAdded()) {
nextReplace(from, to, removed);
} else if (change.wasRemoved()) {
nextRemove(from, removed);
} else if (change.wasAdded()) {
nextAdd(from, to);
}
}
/**
* This methods creates a mapping of {@code origin}, using {@code mapper} as the converter.
*
* If an item is added to {@code origin}, {@code mapper} will be invoked to create a corresponding item, which will also be added to the returned {@code ObservableList}.
* If an item is removed from {@code origin}, the corresponding item in the returned {@code ObservableList} will also be removed.
* If {@code origin} is permutated, the returned {@code ObservableList} will also be permutated in the same way.
*
* The returned {@code ObservableList} is unmodifiable.
*/
public static <T, U> ObservableList<U> create(ObservableList<T> origin, Function<T, U> mapper) {
// create a already-synchronized target ObservableList<U>
ObservableList<U> target = origin.stream()
.map(mapper)
.collect(toCollection(FXCollections::observableArrayList));
// then synchronize further changes to target
ListChangeListener<T> listener = new MappedObservableListUpdater<>(origin, target, mapper);
// let target hold a reference to listener to prevent listener being garbage-collected before target is garbage-collected
target.addListener(new Holder<>(listener));
// let origin hold a weak reference to listener, so that target can be garbage-collected when it's no longer used
origin.addListener(new WeakListChangeListener<>(listener));
// ref graph:
// target ------> listener <-weak- origin
// <------ ------>
return unmodifiableObservableList(target);
}
endChange();
}
@Override
public E get(int index) {
return elements.get(index);
}
@Override
public int size() {
return elements.size();
}
@Override
public int getSourceIndex(int index) {
Objects.checkIndex(index, this.size());
return index;
}
@Override
public int getViewIndex(int index) {
Objects.checkIndex(index, this.size());
return index;
}
}

View File

@@ -0,0 +1,266 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.javafx;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableListBase;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.*;
/// @author Glavo
public class MappedObservableListTest {
@Test
public void testInitialMapping() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3, 4, 5);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
assertEquals(5, mapped.size());
assertEquals("Item-1", mapped.get(0));
assertEquals("Item-2", mapped.get(1));
assertEquals("Item-3", mapped.get(2));
assertEquals("Item-4", mapped.get(3));
assertEquals("Item-5", mapped.get(4));
}
@Test
public void testAdd() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
source.add(4);
assertEquals(4, mapped.size());
assertEquals("Item-4", mapped.get(3));
source.add(1, 10);
assertEquals(5, mapped.size());
assertEquals("Item-10", mapped.get(1));
assertEquals("Item-2", mapped.get(2));
}
@Test
public void testRemove() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3, 4, 5);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
source.remove(2);
assertEquals(4, mapped.size());
assertEquals("Item-1", mapped.get(0));
assertEquals("Item-2", mapped.get(1));
assertEquals("Item-4", mapped.get(2));
assertEquals("Item-5", mapped.get(3));
source.remove(Integer.valueOf(1));
assertEquals(3, mapped.size());
assertEquals("Item-2", mapped.get(0));
assertEquals("Item-4", mapped.get(1));
assertEquals("Item-5", mapped.get(2));
source.remove(1, 3);
assertEquals(1, mapped.size());
assertEquals("Item-2", mapped.get(0));
}
@Test
public void testSet() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3, 4, 5);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
source.set(2, 10);
assertEquals(5, mapped.size());
// Verify the actual values
assertEquals("Item-1", mapped.get(0));
assertEquals("Item-2", mapped.get(1));
assertEquals("Item-10", mapped.get(2));
assertEquals("Item-4", mapped.get(3));
assertEquals("Item-5", mapped.get(4));
}
@Test
public void testSort() {
ObservableList<Integer> source = FXCollections.observableArrayList(5, 3, 1, 4, 2);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
FXCollections.sort(source);
assertEquals(5, mapped.size());
assertEquals("Item-1", mapped.get(0));
assertEquals("Item-2", mapped.get(1));
assertEquals("Item-3", mapped.get(2));
assertEquals("Item-4", mapped.get(3));
assertEquals("Item-5", mapped.get(4));
}
@Test
public void testClear() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3, 4, 5);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
source.clear();
assertEquals(0, mapped.size());
assertTrue(mapped.isEmpty());
assertThrows(IndexOutOfBoundsException.class, () -> mapped.get(0));
}
@Test
public void testAddAll() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
source.addAll(3, 4, 5);
assertEquals(5, mapped.size());
assertEquals("Item-3", mapped.get(2));
assertEquals("Item-4", mapped.get(3));
assertEquals("Item-5", mapped.get(4));
}
@Test
public void testRemoveAll() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3, 4, 5);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
source.removeAll(2, 4);
assertEquals(3, mapped.size());
assertEquals("Item-1", mapped.get(0));
assertEquals("Item-3", mapped.get(1));
assertEquals("Item-5", mapped.get(2));
}
@Test
public void testSetAll() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
source.setAll(10, 20, 30, 40);
assertEquals(4, mapped.size());
assertEquals("Item-10", mapped.get(0));
assertEquals("Item-20", mapped.get(1));
assertEquals("Item-30", mapped.get(2));
assertEquals("Item-40", mapped.get(3));
}
@Test
public void testGetSourceIndex() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3, 4, 5);
MappedObservableList<String, Integer> mapped = new MappedObservableList<>(source, i -> "Item-" + i);
assertEquals(0, mapped.getSourceIndex(0));
assertEquals(2, mapped.getSourceIndex(2));
assertEquals(4, mapped.getSourceIndex(4));
assertThrows(IndexOutOfBoundsException.class, () -> mapped.getSourceIndex(-1));
assertThrows(IndexOutOfBoundsException.class, () -> mapped.getSourceIndex(5));
}
@Test
public void testGetViewIndex() {
ObservableList<Integer> source = FXCollections.observableArrayList(1, 2, 3, 4, 5);
MappedObservableList<String, Integer> mapped = new MappedObservableList<>(source, i -> "Item-" + i);
assertEquals(0, mapped.getViewIndex(0));
assertEquals(2, mapped.getViewIndex(2));
assertEquals(4, mapped.getViewIndex(4));
assertThrows(IndexOutOfBoundsException.class, () -> mapped.getViewIndex(-1));
assertThrows(IndexOutOfBoundsException.class, () -> mapped.getViewIndex(5));
}
@Test
public void testComplexOperations() {
ObservableList<Integer> source = FXCollections.observableArrayList();
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
// Start with empty
assertEquals(0, mapped.size());
// Add some elements
source.addAll(1, 2, 3, 4, 5);
assertEquals(5, mapped.size());
// Remove middle element
source.remove(2);
assertEquals(4, mapped.size());
assertEquals("Item-4", mapped.get(2));
// Sort
FXCollections.sort(source, Collections.reverseOrder());
assertEquals("Item-5", mapped.get(0));
assertEquals("Item-4", mapped.get(1));
assertEquals("Item-2", mapped.get(2));
assertEquals("Item-1", mapped.get(3));
// Add at specific position
source.add(2, 3);
assertEquals(5, mapped.size());
assertEquals("Item-3", mapped.get(2));
}
/// Test for [javafx.collections.ListChangeListener.Change#wasUpdated()].
@Test
public void testUpdate() {
class TestUpdateList<T> extends ObservableListBase<T> {
private final List<T> backingList;
@SafeVarargs
public TestUpdateList(T... items) {
this.backingList = Arrays.asList(items);
}
@Override
public int size() {
return backingList.size();
}
@Override
public T get(int index) {
return backingList.get(index);
}
public void updateItem(int beginIndex, int endIndex, Function<T, T> mapper) {
Objects.checkFromToIndex(beginIndex, endIndex, size());
beginChange();
for (int i = beginIndex; i < endIndex; i++) {
backingList.set(i, mapper.apply(backingList.get(i)));
nextUpdate(i);
}
endChange();
}
}
TestUpdateList<Integer> source = new TestUpdateList<>(1, 2, 3, 4, 5);
ObservableList<String> mapped = MappedObservableList.create(source, i -> "Item-" + i);
source.updateItem(2, 4, i -> i * 10);
assertEquals(5, mapped.size());
assertEquals("Item-1", mapped.get(0));
assertEquals("Item-2", mapped.get(1));
assertEquals("Item-30", mapped.get(2));
assertEquals("Item-40", mapped.get(3));
assertEquals("Item-5", mapped.get(4));
}
}