Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
Expand Down Expand Up @@ -90,6 +92,18 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
private final JFXListView<ModInfoObject> listView;
private final JFXTextField searchField;

/**
* Creates the file subtitle shown in the mod list.
*/
static String createModSubtitle(LocalModFile modInfo) {
StringJoiner joiner = new StringJoiner(" | ");
if (modInfo.getModLoaderType() != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
joiner.add(modInfo.getId());

joiner.add(FileUtils.getName(modInfo.getFile()));
return joiner.toString();
}

@FXThread
private boolean isSearching = false;

Expand Down Expand Up @@ -568,6 +582,13 @@ final class ModInfoListCell extends MDListCell<ModInfoObject> {
JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
BooleanProperty booleanProperty;
final InvalidationListener activeListener = observable -> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

你这个 activeListener 根本没有地方持有强引用啊,不怕被回收掉吗?

@HowXu HowXu Jul 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这一段在之前被gemini指出内存泄露,我看了一下说的也在理,当时确实没发现,然后按照gemini的要求改了一下

medium

Declaring activeListener and weakActiveListener as final and initializing them inline during instance construction avoids allocating a new lambda and a new WeakInvalidationListener instance every time updateControl is called. This reduces GC pressure and improves performance during list scrolling.

        final InvalidationListener activeListener = observable -> {
            ModInfoObject item = getItem();
            if (item != null) {
                content.setSubtitle(createModSubtitle(item.getModInfo()));
            }
        };
        final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);

activeListener 是 ModInfoListCell 的 final 字段,cell 自身持有它的强引用,WeakInvalidationListener 防止 dataItem.active 反过来强引用整个 cell 导致泄漏(不包 weak ,addListener(strongListener) 会让 listener 链上 cell 的强引用,而 listener 通过 lambda 又反向捕获 cell)。

包了 weak 之后,dataItem.active -> weakActiveListener(weak) -> activeListener(strong via cell),cell 才能被 GC。

这个写法我后来看了一下,是gemini按照已有的模式给出的,OptionsListSkin 的 Cell 里 updateStyleListener 大概也是也是这个写法 final listener套一层weak

private final InvalidationListener updateStyleListener = o -> updateStyle();

    private final class Cell extends ListCell<OptionsList.Element> {
        private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first");
        private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last");

        @SuppressWarnings("FieldCanBeLocal")
        private final InvalidationListener updateStyleListener = o -> updateStyle();

        private StackPane wrapper;

        public Cell() {
            FXUtils.limitCellWidth(listView, this);

            WeakInvalidationListener weakListener = new WeakInvalidationListener(updateStyleListener);
            listView.itemsProperty().addListener((o, oldValue, newValue) -> {
                if (oldValue != null)
                    oldValue.removeListener(weakListener);
                if (newValue != null)
                    newValue.addListener(weakListener);

                weakListener.invalidated(o);
            });
            itemProperty().addListener(weakListener);
            contentPaddings.addListener(weakListener);
        }

我认为 weakActiveListener 这个对象本身被 cell 当 final 字段持有,就不会被回收;它内部的弱引用指向的 activeListener 也被 cell 当 final 持有,也不用担心回收。两个对象生命周期和ListView或者Cell本身是一样长的,切走ListView肯定能GC了,只要Cell的生命周期是正常的,这个东西就没什么问题。

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

哦,是我的错,我错把它看成方法局部变量了。

ModInfoObject item = getItem();
if (item != null) {
content.setSubtitle(createModSubtitle(item.getModInfo()));
}
};
final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);

Tooltip warningTooltip;

Expand Down Expand Up @@ -601,6 +622,12 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) {
warningTooltip = null;
}

if (booleanProperty != null) {
booleanProperty.removeListener(weakActiveListener);
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
booleanProperty = null;
}
Comment thread
HowXu marked this conversation as resolved.

if (empty) return;

List<String> warning = new ArrayList<>();
Expand Down Expand Up @@ -636,13 +663,7 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) {
}
content.setTitle(displayName);

StringJoiner joiner = new StringJoiner(" | ");
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
joiner.add(modInfo.getId());

joiner.add(FileUtils.getName(modInfo.getFile()));

content.setSubtitle(joiner.toString());
content.setSubtitle(createModSubtitle(modInfo));

if (modLoaderType == ModLoaderType.UNKNOWN) {
content.addTagWarning(i18n("mods.unknown"));
Expand All @@ -664,10 +685,9 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) {
content.addTag(modVersion);
}

if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active);
// Re-read the current path after active toggling; failed renames leave it unchanged.
dataItem.active.addListener(weakActiveListener);
Comment thread
HowXu marked this conversation as resolved.
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
restoreButton.setOnAction(e -> {
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
Expand Down
100 changes: 100 additions & 0 deletions HMCL/src/test/java/org/jackhuang/hmcl/ui/versions/ModListPageTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* 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.ui.versions;

import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.mod.ModManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

import static org.junit.jupiter.api.Assertions.assertEquals;

/**
* Tests for instance mods list file name change.
*
* @author dev@howxu.cn
*/
final class ModListPageTest {

@TempDir
Path tempDir;

@Test
void modSubtitleUsesUpdatedFilePathAfterDisabling() throws IOException {
LocalModFile modFile = createModFile("example.jar", "testmod", ModLoaderType.FABRIC);

modFile.setActive(false);

assertEquals("testmod | example.jar.disabled", ModListPageSkin.createModSubtitle(modFile));
}

@Test
void modSubtitleUsesUpdatedFilePathAfterEnabling() throws IOException {
LocalModFile modFile = createModFile("example.jar.disabled", "testmod", ModLoaderType.FABRIC);

modFile.setActive(true);

assertEquals("testmod | example.jar", ModListPageSkin.createModSubtitle(modFile));
}

@Test
void modSubtitleShowsCurrentEnabledFilePath() throws IOException {
LocalModFile modFile = createModFile("example.jar", "testmod", ModLoaderType.FABRIC);

assertEquals("testmod | example.jar", ModListPageSkin.createModSubtitle(modFile));
}

@Test
void modSubtitleOmitsUnknownLoaderId() throws IOException {
LocalModFile modFile = createModFile("unknown.jar", "unknown", ModLoaderType.UNKNOWN);

assertEquals("unknown.jar", ModListPageSkin.createModSubtitle(modFile));
}

@Test
void modSubtitleDoesNotChangeWhenFileRenameFails() throws IOException {
LocalModFile modFile = createModFile("example.jar", "testmod", ModLoaderType.FABRIC);
Path disabledTarget = tempDir.resolve("example.jar.disabled");
Files.createDirectory(disabledTarget);
Files.writeString(disabledTarget.resolve("child"), "occupied");

modFile.setActive(false);

assertEquals("testmod | example.jar", ModListPageSkin.createModSubtitle(modFile));
}

private LocalModFile createModFile(String fileName, String modId, ModLoaderType modLoaderType) throws IOException {
Path file = tempDir.resolve(fileName);
Files.writeString(file, "mod");

ModManager modManager = new ModManager(null, "test");
return new LocalModFile(
modManager,
modManager.getLocalMod(modId, modLoaderType),
file,
"Test Mod",
new LocalAddonFile.Description("Test mod")
);
}
}