JavaFX ListView自定义单元格异步加载图片显示错误问题
解决JavaFX ListView异步加载图片时的单元格复用问题
这个问题我太熟悉了——这是ListView单元格复用机制和异步任务生命周期不匹配导致的经典坑!快速滚动时,单元格被重新分配给新的列表项,但之前启动的图片加载线程还在后台跑,等线程完成后,它依然会把图片塞给那个已经“换主人”的ImageView,就出现了你看到的张冠李戴的情况。
核心原因分析
当你调用updateItem时,如果单元格被复用,原来的ImageView已经属于新的Comment对象,但之前为旧Comment启动的异步线程还没结束。线程执行完Platform.runLater时,它不知道这个ImageView已经绑定了新数据,依然会把旧图片设置上去。
具体解决方案
咱们可以从两个层面修复这个问题:
- 跟踪并取消未完成的异步任务:确保单元格复用后,旧的加载任务不会再干扰新的列表项。
- 设置图片前校验当前数据匹配性:即使任务没被取消,也要确认当前单元格的item和任务对应的item一致,再设置图片。
另外,你的images HashMap不是线程安全的,多线程操作可能出问题,建议换成ConcurrentHashMap。
修改后的代码示例
我把你的代码关键部分修改一下,用JavaFX的Task来代替普通Thread,这样可以方便地取消任务:
package ir.sadeghpro.instagram.cell; import com.ibm.icu.util.PersianCalendar; import ir.sadeghpro.insta.client.Comment; import javafx.application.Platform; import javafx.collections.ObservableList; import javafx.concurrent.Task; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.Node; import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.Region; import javafx.scene.paint.Color; import javafx.scene.shape.Circle; import javafx.scene.text.Text; import javafx.scene.text.TextAlignment; import javafx.scene.text.TextFlow; import java.awt.*; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.util.Calendar; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class DischargeComment extends ListCell<Comment> { @FXML private AnchorPane pane; @FXML private TextFlow lblComment; @FXML private Label lblDate; @FXML private Label lblTime; @FXML private Hyperlink lblUsername; @FXML private ImageView img; public static String search = ""; private FXMLLoader mLLoader; // 换成线程安全的ConcurrentHashMap private static Map<String, Image> images = new ConcurrentHashMap<>(); // 跟踪当前单元格的加载任务 private Task<Image> currentImageTask; @Override protected void updateItem(Comment item, boolean empty) { super.updateItem(item, empty); // 先取消当前可能正在运行的任务 if (currentImageTask != null && !currentImageTask.isDone()) { currentImageTask.cancel(true); currentImageTask = null; } if (empty || item == null) { setText(null); setGraphic(null); // 清空ImageView,避免残留旧图片 if (img != null) { img.setImage(null); } } else { if (mLLoader == null) { mLLoader = new FXMLLoader(getClass().getClassLoader().getResource("cell/discharge_comment.fxml")); mLLoader.setController(this); try { mLLoader.load(); } catch (IOException e) { e.printStackTrace(); } } // 评论文本处理逻辑保留 ObservableList<Node> children = lblComment.getChildren(); lblComment.setTextAlignment(TextAlignment.JUSTIFY); children.clear(); if (!search.isEmpty() && item.getText().contains(search)) { int lastIndex = 0; for (int index = item.getText().indexOf(search); index >= 0; index = item.getText().indexOf(search, index + 1)) { Text text = new Text(item.getText().substring(lastIndex, index)); text.setTextAlignment(TextAlignment.LEFT); children.add(text); text = new Text(item.getText().substring(index, index + search.length())); text.setTextAlignment(TextAlignment.LEFT); text.setFill(Color.RED); children.add(text); lastIndex = index + search.length(); } if (lastIndex < item.getText().length()) { Text text = new Text(item.getText().substring(lastIndex)); text.setTextAlignment(TextAlignment.LEFT); children.add(text); } } else { children.add(new Text(item.getText())); } // 日期时间处理逻辑保留 PersianCalendar persianCalendar = new PersianCalendar(); persianCalendar.setTimeInMillis(item.getTimestamp() * 1000L); lblDate.setText(persianCalendar.get(Calendar.YEAR) + "/" + (persianCalendar.get(Calendar.MONTH) + 1) + "/" + persianCalendar.get(Calendar.DAY_OF_MONTH)); lblTime.setText(persianCalendar.get(Calendar.HOUR) + ":" + persianCalendar.get(Calendar.MINUTE)); lblUsername.setText(item.getOwnerUsername()); // 图片加载逻辑修改 Image cachedImage = images.get(item.getOwnerId()); if (cachedImage != null) { img.setImage(cachedImage); } else { // 先清空ImageView,显示默认状态(比如占位图) img.setImage(null); // 创建新的加载Task currentImageTask = new Task<>() { @Override protected Image call() throws Exception { // 如果任务被取消,直接返回null if (isCancelled()) { return null; } return new Image(item.getOwnerProfilePicUrl()); } }; // 任务成功完成后的处理 currentImageTask.setOnSucceeded(event -> { // 首先检查任务是否被取消,以及当前单元格的item是否还是原来的那个 if (!currentImageTask.isCancelled() && getItem() != null && getItem().getOwnerId().equals(item.getOwnerId())) { Image loadedImage = currentImageTask.getValue(); if (loadedImage != null) { images.put(item.getOwnerId(), loadedImage); img.setImage(loadedImage); } } // 任务完成后清空引用 currentImageTask = null; }); // 任务失败或取消的处理 currentImageTask.setOnFailed(event -> { currentImageTask.getException().printStackTrace(); currentImageTask = null; }); // 启动任务 new Thread(currentImageTask).start(); } // 圆形裁剪逻辑保留 Circle clip = new Circle(25, 25, 25); img.setClip(clip); // 用户名点击逻辑保留 lblUsername.setOnMouseClicked(e->{ try { Desktop.getDesktop().browse(new URI("https://www.instagram.com/" + item.getOwnerUsername())); } catch (IOException | URISyntaxException exception) { exception.printStackTrace(); } }); setText(null); setGraphic(pane); setHeight(Region.USE_COMPUTED_SIZE); } } }
关键修改点说明
- 用
Task替代Thread:Task支持取消操作,当单元格复用后,我们可以取消旧的加载任务,避免它后续干扰新数据。 - 任务完成前校验数据匹配:在
onSucceeded里,我们检查当前单元格的item是否和任务对应的Comment是同一个(通过ownerId判断),只有匹配时才设置图片。 - 替换为
ConcurrentHashMap:保证多线程下缓存的安全性,避免并发修改异常。 - 清空旧状态:当单元格变为空或绑定新数据时,先清空
ImageView的内容,避免残留旧图片。
这样修改后,快速滚动列表时,旧的加载任务会被取消,即使有漏网的任务,也会因为数据不匹配而不会错误设置图片,完美解决你的问题!
内容的提问来源于stack exchange,提问作者sadeghpro
相关产品推荐
相关产品推荐

