[Enhancement] 延后加载并缓存资源包图标 (#6730)

This commit is contained in:
ToobLac authored and GitHub committed 2026-09-09 21:00:08 +08:00
1 parent 450504ceda
commit 886a25a935
8 files changed
+219 -136

No files matched your search

@@ -27,7 +27,6 @@ import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.ListChangeListener;
import javafx.collections.transformation.FilteredList;
@@ -46,7 +45,6 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.datapack.DataPack;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -55,15 +53,13 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.IntStream;
@@ -263,11 +259,13 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
private final BooleanProperty activeProperty;
private final DataPack.Pack packInfo;
private SoftReference<CompletableFuture<Image>> iconCache;
private final ItemPropertyAsyncCache<Image, DataPackInfoObject> iconCache;
DataPackInfoObject(DataPack.Pack packInfo) {
this.packInfo = packInfo;
this.activeProperty = packInfo.activeProperty();
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
String getTitle() {
@@ -282,7 +280,11 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
return packInfo;
}
Image loadIcon() {
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image image = null;
Path imagePath;
if (this.getPackInfo().isDirectory()) {
@@ -291,7 +293,6 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
} else {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(getPackInfo().getPath())) {
@@ -301,7 +302,6 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
}
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
}
@@ -309,36 +309,9 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
} else {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
return getDefaultIcon();
}
}
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<DataPackInfoObject>> current) {
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
CompletableFuture<Image> imageFuture;
if (iconCache != null && (imageFuture = iconCache.get()) != null) {
Image image = imageFuture.getNow(null);
if (image != null) {
imageContainer.setImage(image);
return;
}
} else {
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
this.iconCache = new SoftReference<>(imageFuture);
}
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
imageFuture.thenAcceptAsync(image -> {
if (current != null) {
ObjectProperty<DataPackInfoObject> infoObjectProperty = current.get();
if (infoObjectProperty == null || infoObjectProperty.get() != this) {
// The current ListCell has already switched to another object
return;
}
}
imageContainer.setImage(image);
}, Schedulers.javafx());
}
}
private final class DataPackInfoListCell extends MDListCell<DataPackInfoObject> {
@@ -377,7 +350,7 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.activeProperty);
dataItem.loadIcon(imageContainer, new WeakReference<>(this.itemProperty()));
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
}
}
@@ -23,7 +23,6 @@ import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
@@ -64,13 +63,13 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
@@ -599,13 +598,15 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
private final LocalModFile localModFile;
private final @Nullable ModTranslations.Mod modTranslations;
private SoftReference<CompletableFuture<Image>> iconCache;
private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;
ModInfoObject(LocalModFile localModFile) {
this.localModFile = localModFile;
this.active = localModFile.activeProperty();
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public LocalModFile getModInfo() {
@@ -616,7 +617,10 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
return modTranslations;
}
@FXThread
private Image getDefaultIcon() {
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
}
private Image loadIcon() {
List<String> iconPaths = new ArrayList<>();
@@ -639,34 +643,7 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
LOG.warning("Failed to load mod icons", e);
}
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
}
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<ModInfoObject>> current) {
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
CompletableFuture<Image> imageFuture;
if (iconCache != null && (imageFuture = iconCache.get()) != null) {
Image image = imageFuture.getNow(null);
if (image != null) {
imageContainer.setImage(image);
return;
}
} else {
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
this.iconCache = new SoftReference<>(imageFuture);
}
imageContainer.setImage(GameInstanceIconType.getIconType(localModFile.getModLoaderType()).getIcon());
imageFuture.thenAcceptAsync(image -> {
if (current != null) {
ObjectProperty<ModInfoObject> infoObjectProperty = current.get();
if (infoObjectProperty == null || infoObjectProperty.get() != this) {
// The current ListCell has already switched to another object
return;
}
}
imageContainer.setImage(image);
}, Schedulers.javafx());
return getDefaultIcon();
}
}
@@ -683,7 +660,7 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
var imageContainer = new ImageContainer(40);
titleContainer.setAlignment(Pos.CENTER_LEFT);
modInfo.loadIcon(imageContainer, null);
modInfo.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.getTitleLabel().setWrapText(true);
@@ -845,7 +822,7 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
ModLoaderType modLoaderType = modInfo.getModLoaderType();
dataItem.loadIcon(imageContainer, new WeakReference<>(this.itemProperty()));
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
String displayName = modInfo.getName();
if (modTranslations != null && I18n.isUseChinese()) {
@@ -57,14 +57,14 @@ import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Path;
import java.util.*;
import java.util.Objects;
@@ -494,13 +494,18 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
}
public static class ResourcePackInfoObject {
public static final class ResourcePackInfoObject {
private final ResourcePackFile file;
private final BooleanProperty enabled;
private final ItemPropertyAsyncCache<Image, ResourcePackInfoObject> iconCache;
public ResourcePackInfoObject(Pair<ResourcePackFile, Boolean> pair) {
this.file = pair.key();
this.enabled = new SimpleBooleanProperty(this, "enabled", pair.value());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public ResourcePackFile getFile() {
@@ -511,13 +516,16 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
return enabled;
}
Image getIcon() {
Image image = file.getIcon();
if (image == null || image.isError() || image.getWidth() <= 0 || image.getHeight() <= 0 ||
(Math.abs(image.getWidth() - image.getHeight()) >= 1)) {
image = FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image icon = file.loadIcon();
if (icon != null && !icon.isError() && icon.getWidth() > 0 && icon.getHeight() > 0 && Math.abs(icon.getWidth() - icon.getHeight()) < 1) {
return icon;
}
return image;
return getDefaultIcon();
}
}
@@ -583,7 +591,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
if (empty || item == null) return;
ResourcePackFile file = item.getFile();
imageContainer.setImage(item.getIcon());
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(itemProperty()));
content.getTags().clear();
content.setTitle(file.getFileName());
@@ -623,7 +631,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
ImageContainer imageContainer = new ImageContainer(40);
imageContainer.setImage(packInfoObject.getIcon());
packInfoObject.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(pack.getFileName());
@@ -114,16 +114,16 @@ public sealed abstract class ResourcePackFile extends LocalAddonFile implements
@Nullable
@Contract(pure = true)
public abstract PackMcMeta getMeta();
public abstract PackMcMeta.PackInfo getPackInfo();
@Nullable
public LocalAddonFile.Description getDescription() {
if (getMeta() == null || getMeta().pack() == null) return null;
return getMeta().pack().description();
if (getPackInfo() == null) return null;
return getPackInfo().description();
}
// 64*64
public abstract @Nullable Image getIcon();
public abstract @Nullable Image loadIcon();
@Override
public int compareTo(@NotNull ResourcePackFile other) {
@@ -30,8 +30,7 @@ import java.nio.file.Path;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
final class ResourcePackFolder extends ResourcePackFile {
private final PackMcMeta meta;
private final @Nullable Image icon;
private final PackMcMeta.PackInfo info;
public ResourcePackFolder(ResourcePackManager manager, Path path) {
super(manager, path);
@@ -42,33 +41,31 @@ final class ResourcePackFolder extends ResourcePackFile {
} catch (Exception e) {
LOG.warning("Failed to parse resource pack meta", e);
}
this.meta = meta;
this.info = meta != null ? meta.pack() : null;
}
@Override
public PackMcMeta.PackInfo getPackInfo() {
return info;
}
@Override
public @Nullable Image loadIcon() {
byte[] iconData = null;
Image iconTemp = null;
try {
iconData = Files.readAllBytes(path.resolve("pack.png"));
} catch (IOException e) {
LOG.warning("Failed to read resource pack icon", e);
iconData = Files.readAllBytes(getFile().resolve("pack.png"));
} catch (Exception e) {
LOG.warning("Failed to load resource pack icon", e);
}
if (iconData != null) {
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(iconData)) {
iconTemp = new Image(inputStream, 64, 64, true, true);
return new Image(inputStream, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load resource pack icon", e);
}
}
this.icon = iconTemp;
}
@Override
public PackMcMeta getMeta() {
return meta;
}
@Override
public @Nullable Image getIcon() {
return icon;
return null;
}
@Override
@@ -522,18 +522,18 @@ public final class ResourcePackManager extends LocalAddonManager<ResourcePackFil
}
public ResourcePackFile.Compatibility getCompatibility(@NotNull ResourcePackFile resourcePack) {
if (resourcePack.getMeta() == null || resourcePack.getMeta().pack() == null)
if (resourcePack.getPackInfo() == null)
return ResourcePackFile.Compatibility.MISSING_PACK_META;
if (this.getRequiredVersion().isUnspecified())
return ResourcePackFile.Compatibility.MISSING_GAME_META;
int requiredMajor = getRequiredVersion().majorVersion();
VersionRange<PackMcMeta.PackVersion> versionRange;
if (requiredMajor > 64)
versionRange = getResourcePackVersionRangeNew(resourcePack.getMeta().pack());
versionRange = getResourcePackVersionRangeNew(resourcePack.getPackInfo());
else if (requiredMajor > 15)
versionRange = getResourcePackVersionRangeOld(resourcePack.getMeta().pack());
versionRange = getResourcePackVersionRangeOld(resourcePack.getPackInfo());
else
versionRange = getResourcePackVersionRangeOldest(resourcePack.getMeta().pack());
versionRange = getResourcePackVersionRangeOldest(resourcePack.getPackInfo());
if (versionRange.isEmpty())
return ResourcePackFile.Compatibility.INVALID;
if (versionRange.getMaximum().compareTo(this.getRequiredVersion()) < 0)
@@ -36,52 +36,47 @@ import java.util.Optional;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
final class ResourcePackZipFile extends ResourcePackFile {
private final PackMcMeta meta;
private final @Nullable Image icon;
private final PackMcMeta.PackInfo info;
public ResourcePackZipFile(ResourcePackManager manager, Path path) throws IOException {
super(manager, path);
PackMcMeta metaTemp = null;
byte[] iconData = null;
PackMcMeta meta = null;
try (var zipFileTree = CompressingUtils.openZipTree(path)) {
try {
metaTemp = PackMcMeta.fromNonNullJson(zipFileTree.readTextEntry("/pack.mcmeta"));
meta = PackMcMeta.fromNonNullJson(zipFileTree.readTextEntry("/pack.mcmeta"));
} catch (Exception e) {
LOG.warning("Failed to parse resource pack meta", e);
}
}
this.info = meta != null ? meta.pack() : null;
}
@Override
public PackMcMeta.PackInfo getPackInfo() {
return info;
}
@Override
public @Nullable Image loadIcon() {
byte[] iconData = null;
try (var zipFileTree = CompressingUtils.openZipTree(getFile())) {
var iconEntry = zipFileTree.getEntry("/pack.png");
if (iconEntry != null) {
try {
iconData = zipFileTree.readBinaryEntry(iconEntry);
} catch (Exception e) {
LOG.warning("Failed to load resource pack icon", e);
}
iconData = zipFileTree.readBinaryEntry(iconEntry);
}
} catch (Exception e) {
LOG.warning("Failed to load resource pack icon", e);
}
this.meta = metaTemp;
Image iconTemp = null;
if (iconData != null) {
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(iconData)) {
iconTemp = new Image(inputStream, 64, 64, true, true);
return new Image(inputStream, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load resource pack icon", e);
}
}
this.icon = iconTemp;
}
@Override
public PackMcMeta getMeta() {
return meta;
}
@Override
public @Nullable Image getIcon() {
return icon;
return null;
}
@Override
@@ -0,0 +1,133 @@
/*
* 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.beans.property.ObjectProperty;
import org.jackhuang.hmcl.task.Schedulers;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/// Cache for a property of an item of [javafx.scene.control.ListCell].
///
/// @param <T> type of the property value
/// @param <B> type of the bean, aka. the item
public abstract class ItemPropertyAsyncCache<T, B> {
private final B bean;
public ItemPropertyAsyncCache(B bean) {
this.bean = Objects.requireNonNull(bean);
}
protected abstract @Nullable T getValue();
protected abstract @Nullable T getDefault();
/// @return the completable future that loads the value, or null if not set or already garbage-collected
@Nullable
protected abstract CompletableFuture<T> getFuture();
protected abstract void setFuture(@NotNull CompletableFuture<T> imageFuture);
public final void attachValue(ObjectProperty<T> property, @Nullable WeakReference<ObjectProperty<B>> current) {
CompletableFuture<T> future = getFuture();
if (future != null) {
T value = future.getNow(null);
if (value != null) {
property.set(value);
return;
}
} else {
future = CompletableFuture.supplyAsync(this::getValue, Schedulers.io());
setFuture(future);
}
property.set(getDefault());
future.thenAcceptAsync(image -> {
if (current != null) {
ObjectProperty<B> thisProperty = current.get();
if (thisProperty == null || thisProperty.get() != bean) {
// The current ListCell has already switched to another object
return;
}
}
property.set(image);
}, Schedulers.javafx());
}
///
/// @param <T> {@inheritDoc}
/// @param <B> {@inheritDoc}
private static abstract class Base<T, B> extends ItemPropertyAsyncCache<T, B> {
private final Supplier<T> valueSupplier, defaultSupplier;
///
/// @param bean the item of list cell
/// @param valueSupplier supplier of the value
/// @param defaultSupplier supplier of the placeholder value
public Base(B bean, Supplier<T> valueSupplier, @Nullable Supplier<T> defaultSupplier) {
super(bean);
this.valueSupplier = Objects.requireNonNull(valueSupplier);
this.defaultSupplier = Objects.requireNonNullElse(defaultSupplier, () -> null);
}
@Override
protected @Nullable T getValue() {
return valueSupplier.get();
}
@Override
protected @Nullable T getDefault() {
return defaultSupplier.get();
}
}
/// Implementation of [ItemPropertyAsyncCache], using a [SoftReference] to balance the cost of time and memory.
///
/// @param <T> {@inheritDoc}
/// @param <B> {@inheritDoc}
public static final class Soft<T, B> extends Base<T, B> {
private SoftReference<@Nullable CompletableFuture<T>> cache = null;
///
/// @param bean the item of list cell
/// @param valueSupplier supplier of the value
/// @param defaultSupplier supplier of the placeholder value
public Soft(B bean, Supplier<T> valueSupplier, @Nullable Supplier<T> defaultSupplier) {
super(bean, valueSupplier, defaultSupplier);
}
@Override
protected @Nullable CompletableFuture<T> getFuture() {
return cache != null ? cache.get() : null;
}
@Override
protected void setFuture(@NotNull CompletableFuture<T> future) {
this.cache = new SoftReference<>(future);
}
}
}