优化下载页面图标缓存功能 (#5211)

This commit is contained in:
Glavo
2026-01-13 20:30:14 +08:00
committed by GitHub
parent 62f0d0a1c0
commit b1402d3821
2 changed files with 125 additions and 69 deletions

View File

@@ -56,29 +56,20 @@ import org.jackhuang.hmcl.ui.construct.FloatListCell;
import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.AggregatedObservableList; import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.lang.ref.WeakReference;
import java.net.URI; import java.net.URI;
import java.util.*; import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent; import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadListPage extends Control implements DecoratorPage, VersionPage.VersionLoadable { public class DownloadListPage extends Control implements DecoratorPage, VersionPage.VersionLoadable {
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(); protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
@@ -247,6 +238,12 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> { private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
private final JFXListView<RemoteMod> listView = new JFXListView<>(); private final JFXListView<RemoteMod> listView = new JFXListView<>();
private final RemoteImageLoader iconLoader = new RemoteImageLoader() {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull URI uri) {
return FXUtils.getRemoteImageTask(uri, 80, 80, true, true);
}
};
protected ModDownloadListPageSkin(DownloadListPage control) { protected ModDownloadListPageSkin(DownloadListPage control) {
super(control); super(control);
@@ -377,6 +374,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0); IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0);
IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1); IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1);
EventHandler<ActionEvent> searchAction = e -> { EventHandler<ActionEvent> searchAction = e -> {
iconLoader.clearInvalidCache();
if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) { if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) {
control.pageOffset.set(0); control.pageOffset.set(0);
} }
@@ -536,7 +534,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here // ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
var iconCache = new WeakHashMap<String, WeakReference<CompletableFuture<Image>>>();
listView.setCellFactory(x -> new FloatListCell<>(listView) { listView.setCellFactory(x -> new FloatListCell<>(listView) {
private final TwoLineListItem content = new TwoLineListItem(); private final TwoLineListItem content = new TwoLineListItem();
private final ImageView imageView = new ImageView(); private final ImageView imageView = new ImageView();
@@ -564,66 +562,9 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
if (getSkinnable().shouldDisplayCategory(category)) if (getSkinnable().shouldDisplayCategory(category))
content.addTag(getSkinnable().getLocalizedCategory(category)); content.addTag(getSkinnable().getLocalizedCategory(category));
} }
loadIcon(dataItem); iconLoader.load(imageView.imageProperty(), dataItem.getIconUrl());
} }
private void loadIcon(RemoteMod mod) {
if (StringUtils.isBlank(mod.getIconUrl())) {
imageView.setImage(null);
return;
}
WeakReference<CompletableFuture<Image>> cacheRef = iconCache.get(mod.getIconUrl());
CompletableFuture<Image> cache;
if (cacheRef != null && (cache = cacheRef.get()) != null) {
loadIcon(cache, mod.getIconUrl());
return;
}
URI iconUrl = NetworkUtils.toURIOrNull(mod.getIconUrl());
if (iconUrl == null) {
imageView.setImage(null);
return;
}
CompletableFuture<Image> future = new CompletableFuture<>();
WeakReference<CompletableFuture<Image>> futureRef = new WeakReference<>(future);
iconCache.put(mod.getIconUrl(), futureRef);
FXUtils.getRemoteImageTask(iconUrl, 80, 80, true, true)
.whenComplete(Schedulers.defaultScheduler(), (result, exception) -> {
if (exception == null) {
future.complete(result);
} else {
LOG.warning("Failed to load image from " + iconUrl, exception);
future.completeExceptionally(exception);
}
}).start();
loadIcon(future, mod.getIconUrl());
}
private void loadIcon(@NotNull CompletableFuture<Image> future,
@NotNull String iconUrl) {
Image image;
try {
image = future.getNow(null);
} catch (CancellationException | CompletionException ignored) {
imageView.setImage(null);
return;
}
if (image != null) {
imageView.setImage(image);
} else {
imageView.setImage(null);
future.thenAcceptAsync(result -> {
RemoteMod item = getItem();
if (item != null && iconUrl.equals(item.getIconUrl())) {
this.imageView.setImage(result);
}
}, Schedulers.javafx());
}
}
}); });
} }

View File

@@ -0,0 +1,115 @@
/*
* 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;
import javafx.beans.value.WritableValue;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public abstract class RemoteImageLoader {
private final Map<URI, WeakReference<Image>> cache = new HashMap<>();
private final Map<URI, List<WeakReference<WritableValue<Image>>>> pendingRequests = new HashMap<>();
private final WeakHashMap<WritableValue<Image>, URI> reverseLookup = new WeakHashMap<>();
public RemoteImageLoader() {
}
protected @Nullable Image getPlaceholder() {
return null;
}
protected abstract @NotNull Task<Image> createLoadTask(@NotNull URI uri);
@FXThread
public void load(@NotNull WritableValue<Image> writableValue, String url) {
URI uri = NetworkUtils.toURIOrNull(url);
if (uri == null) {
reverseLookup.remove(writableValue);
writableValue.setValue(getPlaceholder());
return;
}
WeakReference<Image> reference = cache.get(uri);
if (reference != null) {
Image image = reference.get();
if (image != null) {
reverseLookup.remove(writableValue);
writableValue.setValue(image);
return;
}
cache.remove(uri);
}
{
List<WeakReference<WritableValue<Image>>> list = pendingRequests.get(uri);
if (list != null) {
list.add(new WeakReference<>(writableValue));
reverseLookup.put(writableValue, uri);
return;
} else {
list = new ArrayList<>(1);
list.add(new WeakReference<>(writableValue));
pendingRequests.put(uri, list);
reverseLookup.put(writableValue, uri);
}
}
createLoadTask(uri).whenComplete(Schedulers.javafx(), (result, exception) -> {
Image image;
if (exception == null) {
image = result;
} else {
LOG.warning("Failed to load image from " + uri, exception);
image = getPlaceholder();
}
cache.put(uri, new WeakReference<>(image));
List<WeakReference<WritableValue<Image>>> list = pendingRequests.remove(uri);
if (list != null) {
for (WeakReference<WritableValue<Image>> ref : list) {
WritableValue<Image> target = ref.get();
if (target != null && uri.equals(reverseLookup.get(target))) {
reverseLookup.remove(target);
target.setValue(image);
}
}
}
}).start();
}
@FXThread
public void unload(@NotNull WritableValue<Image> writableValue) {
reverseLookup.remove(writableValue);
}
@FXThread
public void clearInvalidCache() {
cache.entrySet().removeIf(entry -> entry.getValue().get() == null);
}
}