ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java扫雷英雄榜:从Swing界面到文件IO与排序的完整项目实战

Java扫雷英雄榜:从Swing界面到文件IO与排序的完整项目实战

简介:这是一份基于Java开发的扫雷小游戏完整项目源码,适合Java初学者或对桌面小游戏开发感兴趣的读者,重点演示了界面绘制、鼠标事件响应、雷区算法与英雄榜排名等功能。资源内置等级机制,玩家可体验不同难度下的布雷与计时逻辑,并通过英雄榜记录成绩,直观理解数据存储与展示流程。压缩包共19个文件,以Java源码与编译后的class文件为主,另含运行界面GIF截图、英雄榜文本以及Eclipse工程配置文件,既能查看源码学习,也能直接运行查看效果,整体仅23KB,轻量易用。目前已有672人学习下载,对于想快速上手Swing游戏编程、完善项目结构或参考排行榜实现方案的读者,这份资源提供了清晰的思路与可运行示例。

1. Java 扫雷英雄榜:一个能跑通的小项目,把 Swing、文件 IO 和排序串成一条线

看到“java 扫雷 英雄榜”这个资源标题,我的第一反应是:这八成是个课程设计或毕业设计级别的 Swing 项目。扫雷本身就是 java 基础阶段最经典的练手项目,矩形雷盘、随机布雷、翻开扩散、计时判定,每一个知识点都能对上;但加上“英雄榜”三个字,项目难度立刻上一个台阶。它意味着程序要把每局成绩存下来,跨多次运行都在,还要按用时排序展示。这等于把 java 集合排序、Comparable 接口、文件 IO、窗口关闭事件这几块串成了完整闭环。适合两类人:一类是学过语法但没写过一个完整窗口程序的初学者,另一类是准备 java 面试题、手头需要一个拿得出手的 Swing 项目的开发者。下面按我拆这个资源时的顺序,把雷盘数据层、英雄榜持久化、Swing 界面以及最容易翻车的点逐个说透。

2. 雷盘数据层:二维数组、Fisher-Yates 布雷和 flood fill 开格

2.1 GameBoard 类:三个布尔数组把雷盘状态一次收口

很多新手做扫雷,喜欢直接把格子状态存在 JButton 上,什么“这个按钮是雷”“那个按钮已经翻开”,全靠按钮的文本和背景色去猜。这种做法在初级难度还能凑合,到了高级难度 480 个格子,界面和逻辑混在一起,改一个功能要动十几处代码。所以这种带英雄榜的项目,第一步一定是先把数据层从界面里拆出来。

这个资源里 GameBoard 的设计是典型的三布尔数组方案:用行和列两个下标定位格子,再用三个二维数组分别记录“是不是雷”“是否已翻开”“是否已插旗”,表意非常清楚。

数组含义默认值
mine[][]当前格子是否为雷false
revealed[][]当前格子是否已翻开false
flagged[][]当前格子是否已被玩家插旗false

这样处理的直接好处是:雷盘运算只跟数组打交道,不关心外层是 JButton、JPanel 还是控制台。想把它改成 JavaFX 界面,或者做一套文字版的扫雷,数据层可以原样搬走。这也是 java 面向对象编程 java 里最典型的“数据和展示分离”例子,聊到 java 基础的时候可以直接拿它当素材。

构造函数按难度初始化数组,三个难度的参数我用常量表维护,不散落在界面层:

public class GameBoard { // 难度表:行数、列数、雷数 private static final int[][] DIFFICULTY = { {9, 9, 10}, // 初级 {16, 16, 40}, // 中级 {16, 30, 99} // 高级 }; private final int rows; private final int cols; private final int mineCount; private final boolean[][] mine; private final boolean[][] revealed; private final boolean[][] flagged; public GameBoard(int difficulty) { int[] d = DIFFICULTY[difficulty]; this.rows = d[0]; this.cols = d[1]; this.mineCount = d[2]; this.mine = new boolean[rows][cols]; this.revealed = new boolean[rows][cols]; this.flagged = new boolean[rows][cols]; layMines(); } }

代码逻辑不复杂:通过难度下标取出行列和雷数,然后 new 出三个二维数组。这里有个 java 数据类型的细节——boolean 数组的默认值是 false,也就是说三个状态天然都是“否”,不需要再写循环去填默认值。后面凡是设计到 java 数据结构、二维数组初始化的地方,这个类都可以当标准答案。

参数说明:DIFFICULTY 每行固定三个值,依次是行数、列数、雷数;雷数必须小于总格数,否则布雷会越界。游戏过程中不要再改这三个值,难度切换重新 new 一个 GameBoard 就好。

2.2 布雷:用 Fisher-Yates 洗牌而不是连续 random

布雷这块,网上相当多代码是这么写的:while 循环里不断生成随机行和列,如果该位置还没雷就放一颗,直到雷数够了。初级 10 颗雷时没感觉,高级 99 颗雷时,重复坐标导致的空转就会明显变多,运气差一点可能要多跑几百次循环。

这个资源里用的是洗牌法,先把所有格子编号,然后打乱,取前 N 个编号作为雷的位置:

private void layMines() { List<Integer> cells = new ArrayList<>(rows * cols); for (int i = 0; i < rows * cols; i++) { cells.add(i); // 每个格子一个编号 } // Collections.shuffle 底层就是 Fisher-Yates Collections.shuffle(cells, new Random()); for (int i = 0; i < mineCount; i++) { int idx = cells.get(i); // 编号还原成行列坐标 mine[idx / cols][idx % cols] = true; } }

这段代码的执行逻辑是:先把 0 到 rows × cols - 1 的整数按顺序放进 List,再调用 Collections.shuffle,让每个编号等概率出现在任意位置,最后取前 mineCount 个编号换算坐标。时间复杂度稳定在 O(总格数),不存在重复随机碰撞的问题。

参数方面:idx / cols 得到行号,idx % cols 得到列号。这里要注意 Java 泛型不支持基本类型,所以 List 里存的是 Integer 而不是 int,自动装箱在这个数据量下开销可以忽略。如果你有性能洁癖,可以自己写一个针对 int[] 的 Knuth 洗牌,效果一样,但对于扫雷项目,Collections.shuffle 已经够用,而且读起来更清楚。

洗牌法也顺带解释了为什么直接用 List 集合而不是数组:shuffle 方法是专门为集合设计的,数组要自己写循环交换。说句实在话,这个点在 java 面试题里出现过很多次,标准答法就是“Fisher-Yates 洗牌 + 前 N 个元素”,比回答 while 循环随机快得多。

2.3 翻开格子:递归 flood fill 和相邻雷数计算

布雷结束,扫雷的核心就只剩“翻开格子”了。规则大家都玩过:翻到雷直接失败;翻到数字显示周围八格里的雷数;翻到空白格,自动把相邻的空白和数字连续展开,直到碰到数字边界为止。这个自动展开的动作,在算法里叫 flood fill,翻译过来就是泛洪填充。

public void reveal(int row, int col) { if (row < 0 || row >= rows || col < 0 || col >= cols) { return; // 越界直接忽略 } if (revealed[row][col] || flagged[row][col]) { return; // 已翻开或已插旗 } revealed[row][col] = true; if (mine[row][col]) { return; // 踩雷,结果交给上层处理 } // 当前格子是空白格,向八个方向扩散 if (countAdjacentMines(row, col) == 0) { for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { reveal(row + dr, col + dc); } } } } public int countAdjacentMines(int row, int col) { int count = 0; for (int r = row - 1; r <= row + 1; r++) { for (int c = col - 1; c <= col + 1; c++) { if (r >= 0 && r < rows && c >= 0 && c < cols && mine[r][c]) { count++; } } } return count; }

这段代码在几乎每个扫雷实现里都能看到,属于“看着简单,细节不少”的那种。参数上需要注意三点:边界判断必须先做,因为 row-1、col-1 可能产生 -1 下标,越界异常会让游戏直接中断;已经翻开的格子不再递归,避免死循环;已经插旗的格子不能被左键翻开,否则玩家插旗保护雷的操作就白做了。

flood fill 这次用的是递归,最大递归深度不会超过总格数 480,不会爆栈。如果你把同样的逻辑拿去处理几千乘几千的迷宫,就要改成用队列的 BFS,否则递归栈可能撑不住。这个资源里用递归是最直观、最容易检查 bug 的写法,适合教学。

到这里,雷盘本身能点了。下一章把英雄榜的数据类接上,这部分才是资源里最像工程代码的环节。

3. 英雄榜的数据设计:Comparable、排序规则和文件持久化

3.1 ScoreEntry:把“用时最少排第一”写进 compareTo

英雄榜要显示的内容并不复杂:玩家名、通关用时、难度、完成时间。怎么组织这条数据,直接决定你后面排序能写多顺手。这个资源里给的是实现 Comparable 接口的 JavaBean 类,核心逻辑如下:

public class ScoreEntry implements Comparable<ScoreEntry> { private final String playerName; private final int seconds; private final int difficulty; private final String finishedAt; public ScoreEntry(String playerName, int seconds, int difficulty, String finishedAt) { this.playerName = playerName; this.seconds = seconds; this.difficulty = difficulty; this.finishedAt = finishedAt; } @Override public int compareTo(ScoreEntry o) { // 用时越短,排名越靠前 int result = Integer.compare(this.seconds, o.seconds); if (result != 0) { return result; } // 用时相同,按玩家名字典序排 return this.playerName.compareTo(o.playerName); } }

代码逻辑分两级:先比较秒数,秒数小的人排在前面;秒数相同就按名字字典序排。这样 Collections.sort 直接就能把英雄榜从快到慢排出来。

这里可以讲透三个细节。第一,实现 Comparable 而不是在调用处写 Comparator,等于把“哪条成绩更靠前”的规则定义在数据类内部。以后如果有“按通关次数排行”的新需求,再写一个 Comparator 传进去就行,原规则不受影响,这就是面向对象编程 java 里行为和属性打包在一起的意思。第二,比较秒数用 Integer.compare 而不是 this.seconds - o.seconds,后者在数值溢出时会得到错误结果,虽然秒数不会溢出,但这个写法一旦被别人 review,一定会被指出来。第三,同秒数比较用名字,会导致同名同秒数的两条记录分不出先后,严格一点应该在 finishedAt 里保存毫秒时间戳,用时间戳做第三级排序。

如果这道题出现在 java 面试题里,标准答法是:Comparable 是对象自身的排序规则,Comparator 是外部传入的排序策略;一个类只能有一个 Comparable,但可以有多个 Comparator。拿扫雷英雄榜当例子,比背概念好记得多。

3.2 持久化:用 Properties 存成绩,不用纯文本拼格式

英雄榜要跨局保存,最基本的方案就是写本地文件。这个资源选择的是 Properties 文件而不是数据库,理由很充分:单机游戏,只有自己一个人写,不存在并发写入,用 MySQL 完全是杀鸡用牛刀;Properties 自带 store 和 load,中文和特殊符号的转义都处理好了,不用自己拼字符串。

public class ScoreStore { private static final String SCORE_FILE = "scores.properties"; private static final int MAX_RECORDS = 10; public void save(List<ScoreEntry> entries) { Properties props = new Properties(); for (int i = 0; i < entries.size(); i++) { ScoreEntry e = entries.get(i); // key 用“难度 + 序号”做前缀,三档难度互不覆盖 props.setProperty("rank." + e.getDifficulty() + "." + i + ".name", e.getPlayerName()); props.setProperty("rank." + e.getDifficulty() + "." + i + ".seconds", String.valueOf(e.getSeconds())); props.setProperty("rank." + e.getDifficulty() + "." + i + ".date", e.getFinishedAt()); } try (FileOutputStream out = new FileOutputStream(SCORE_FILE)) { props.store(out, "Minesweeper leaderboard"); } catch (IOException e) { e.printStackTrace(); } } }

保存文件里的 key 规则是这样的:rank.难度.序号.字段名。以初级难度第 4 名为例,对应的 key 是 rank.0.3.name,value 是该玩家的姓名。

key 示例含义
rank.0.3.name初级难度第 4 名玩家名
rank.0.3.seconds初级难度第 4 名用时,单位秒
rank.0.3.date初级难度第 4 名完成时间

为什么要用固定编号做 key,而不是直接用玩家名做 key?因为 Properties 文件的 key 是全局唯一的,两个玩家重名时,后写入的数据会把前面那条直接覆盖。用编号做 key,每个位置语义固定,更新榜单时把前十条原样写进去,旧数据自然被顶掉,不用单独删。

要注意的是 try-with-resources。FileOutputStream 放在 try 的括号里,用完自动关闭,不用在 finally 里手动 close。很多旧教程还在用老式写法,这里直接用新版,习惯好。load 的时候,第一次运行文件还不存在,FileInputStream 会抛 IOException,这时候捕获后返回一个空列表就行,不要让程序崩掉。

3.3 插入新成绩:排序后截断到前 10 名

玩家赢了一局,新成绩怎么进榜?最直接的想法是“先查一下当前第 10 名用时多少,如果新成绩更快就插进去”。但更省事的做法是:把新成绩加进列表,排序,然后直接截断,让 Collections.sort 和 subList 替你干活。

public void addScore(List<ScoreEntry> entries, ScoreEntry newOne) { entries.add(newOne); Collections.sort(entries); // 按 compareTo 规则升序排列 if (entries.size() > MAX_RECORDS) { // 删掉从第 10 名往后的所有慢成绩 entries.subList(MAX_RECORDS, entries.size()).clear(); } }

这段代码有两个容易忽略的地方。subList 返回的是原列表的视图,不是新列表,对它调用 clear,相当于从原列表里删掉这一段,这是 JDK 里一个非常好用但不常被注意的方法。如果传入的列表是 List.of 生成的不可变列表,subList().clear() 会抛 UnsupportedOperationException,所以调用方必须传入普通 ArrayList。

排序加截断这套组合,其实就是“Top-N”问题最朴素的解。它不需要先查最大值、不需要维护两个列表,代码行数最少。缺点是多排了整条列表,但对一个一局只产生一条记录、总记录不超过几十条的单机小游戏来说,这个开销约等于零。java 排序这里用到的 Collections.sort,底层是 TimSort,对部分有序的数据效率很高,英雄榜这种每次只加一条的场景正好适合。

到这里,数据和存储都就位了,下一步把它们接到窗口上。

4. Swing 界面与控制流:计时、插旗和弹窗怎么做

4.1 JButton 网格还是自绘:我选择前者

Swing 扫雷有两种画法:一种是用 JPanel 重写 paintComponent 自绘,另一种是用 JButton 摆网格。自绘的优点是能精确控制每个像素,但代码量很大,要处理鼠标悬停、按下、掀开、插旗全套状态。对这个资源来说,JButton 网格明显更划算:每个格子是一个按钮,天然带边框和点击事件,坐标位置就藏在按钮的 client property 里,界面层的代码可以少写一半。

JButton 网格的最大问题是默认样式太“胖”,按钮四边带大片内边距,雷盘看起来像计算器不像扫雷。所以每个按钮都要设置:

button.setMargin(new Insets(0, 0, 0, 0)); // 去掉按钮内边距 button.setFocusable(false); // 去掉点击后的高亮边框

setMargin把按钮内容区缩到贴着文字,setFocusable(false)避免空格键按下后按钮留下一圈虚线焦点框。外层容器用GridLayout(rows, cols, 0, 0),间距设成 0,按钮之间没有缝隙,视觉上才连续。

这个资源里还有个细节,就是按钮坐标不靠 getX 和 getY 去推算,而是塞进属性里:

button.putClientProperty("row", row); button.putClientProperty("col", col);

putClientProperty 是 JComponent 自带的附加数据区,适合挂这种轻量标记。鼠标事件里取出来就行,比维护一个二维 JButton 数组再倒推坐标省心。

4.2 计时器:第一次点击才开始,别在开局抢跑

扫雷的计时规则是“第一次点击才触发”,不是窗口打开就开始。玩家打开窗口后可能先去调大小、看英雄榜,这些准备时间不该算进成绩。这个资源用的是 javax.swing.Timer,而不是普通线程 Timer,因为 Swing Timer 的事件回调会在 EDT 上执行,不会出现跨线程更新 UI 的问题。

Timer timer = new Timer(1000, e -> timeLabel.setText(++elapsedSeconds + " 秒")); timer.setInitialDelay(1000); // 在第一次真正翻开格子时调用 timer.start()

Timer 构造函数的第一个参数是触发间隔,单位毫秒,1000 表示每秒触发一次;第二个参数是每次触发时执行的动作。这里有一个特别容易被忽略的参数:setInitialDelay。如果不设置,第一次触发会在 0 毫秒后发生,等于计时器立刻跳了一秒,所有成绩都比真实用时多 1 秒。在英雄榜这种以秒为单位的排名里,1 秒可能就差好几个名次,这个坑必须写进 initialDelay 里。

计时停止的时机也要管住。游戏胜利或者踩雷时,第一件事是 timer.stop(),否则窗口开着不操作,后台还在继续刷秒数。后面接英雄榜的时候就会发现成绩莫名其妙多出一大截,那就是这秒还在跑。

4.3 左键翻开、右键插旗:一个 MouseListener 分开处理

JButton 的点击默认只响应左键,右键不会触发 ActionListener,所以插旗必须走 MouseListener。这里常见做法是给按钮挂一个 MouseAdapter,在 mouseClicked 里用 SwingUtilities 判断按钮类型:

button.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { int row = (int) button.getClientProperty("row"); int col = (int) button.getClientProperty("col"); if (SwingUtilities.isLeftMouseButton(e)) { gameBoard.reveal(row, col); // 左键翻开 } else if (SwingUtilities.isRightMouseButton(e)) { gameBoard.toggleFlag(row, col); // 右键插旗/拔旗 } refreshBoard(); } });

左键负责翻开,右键负责插旗和拔旗,两个操作通过 SwingUtilities 的静态方法区分,互不干扰。插旗的判断不要写在 UI 层,直接在 GameBoard.toggleFlag 里处理:如果格子已经翻开,就忽略这次操作;如果格子已经插旗,再次右键要拔旗而不是拒绝,这样玩家可以改主意。

refreshBoard 会遍历整个雷盘刷新按钮的显示状态。性能上不用太担心,480 个按钮全量刷新也是毫秒级,扫雷这种低频交互完全撑得住。

4.4 英雄榜弹窗:JDialog 里放 JTable,而不是 JTextArea

英雄榜界面最容易做丑的方式是用 JTextArea 把成绩拼成字符串,靠空格对齐,字体一换全部错位。这个资源里用的是 JDialog 加 JTable,代码量反而更少:

String[] columns = {"名次", "玩家", "用时", "完成时间"}; Object[][] data = new Object[records.size()][4]; for (int i = 0; i < records.size(); i++) { ScoreEntry entry = records.get(i); data[i][0] = i + 1; data[i][1] = entry.getPlayerName(); data[i][2] = entry.getSeconds() + " 秒"; data[i][3] = entry.getFinishedAt(); } JTable table = new JTable(data, columns); JDialog dialog = new JDialog(frame, "英雄榜 - " + difficultyName, true); dialog.add(new JScrollPane(table)); dialog.setSize(400, 320); dialog.setLocationRelativeTo(frame); dialog.setVisible(true);

JTable 构造时直接传数据数组和列名数组,出来的就是一张不可编辑的静态表格。外面的 JScrollPane 负责处理滚动条,当记录不足一屏时,表头也能正常显示。最后一个参数 true 表示模态对话框,弹出来之后必须关掉才能回到游戏,避免了玩家边看榜边点雷盘导致的界面混乱。setLocationRelativeTo(frame) 是把弹窗居中到主窗口,不写的话每次弹出来都停在屏幕左上角,观感很差。

界面、数据、榜单三层都齐了。接下来是真正花时间的部分——编译能过不代表能跑,这些坑我几乎每个都踩过一遍。

5. 避坑与排查:第一次点击踩雷、EDT 阻塞和 Properties 写入丢失

5.1 翻开大片空白时界面卡死,CPU 直接占满

现象:游戏开局正常,点开空白区域的一瞬间窗口变成白板,鼠标转圈,CPU 飙到 100%,等好几秒才恢复。

原因:reveal 在 Swing 的 EDT 线程里执行。虽然递归深度有限,但如果每个按钮被翻开时都触发一次 setText、setBackground 和 repaint,一次空白展开可能要同时刷新几十上百个按钮,重绘风暴把 UI 线程堵死了。

解决:数据层一次展开完,再统一刷新界面。把 gameBoard.reveal 和 refreshBoard 分开,前者只改二维数组状态,后者只做一次全量刷新,不要在递归的每一层里刷新按钮。这样即使一次点开 100 个格子,也只需要刷新一遍。

5.2 第一次点击就踩雷,玩家体验接近摔鼠标

现象:玩家第一下点的明明是安全格,结果直接翻出炸弹,游戏秒败。

原因:布雷在 GameBoard 构造函数里执行,雷位固定了,玩家还没操作就已经有一颗雷可能压在首点上。扫雷的通行规则是首点必安全,很多实现没做这一层。

解决:把布雷改成延迟到第一次 reveal 时执行,顺便做一个安全区:以首点为中心的 3×3 区域全部跳过布雷,让玩家第一下必定是空白或者数字。具体改法是加一个 boolean laidMines 标记,reveal 方法开头判断一次,如果还没布雷就调用 layMines(row, col) 并传入首点坐标。这个改动很小,但游戏体验区别很大。

5.3 排行榜写进去了,重启之后却全部消失

现象:本局结束后英雄榜弹窗能看到成绩,关掉游戏再打开,排行榜是空的。

原因:save 方法虽然被调用过,但程序是通过窗口右上角 X 直接关闭的。在 Swing 里,点了 X 之后可能直接退出 JVM,也可能只是隐藏窗口,具体行为由 setDefaultCloseOperation 决定。如果保存动作写在某个按钮的监听器里,而关闭窗口走的不是这条路,文件根本没来得及写盘。

解决:统一在窗口关闭事件里保存。给 JFrame 加一个 WindowAdapter,在 windowClosing 里调用 scoreStore.save,并确保 save 内部的 try-with-resources 把 FileOutputStream 关闭。更保险的做法是把当前成绩列表常驻内存,每次新增成绩时先更新内存,再立即写文件,避免丢数据的窗口期。

5.4 高级难度窗口超出屏幕,按钮被挤变形

现象:初级中级都正常,一点高级,窗口高度和宽度都超过屏幕,按钮被压缩成细条,布局错乱。

原因:高级难度是 16×30,共 480 格。如果按初级难度固定按钮大小,16×30 的尺寸会超出常见 1080P 屏幕;如果让 GridLayout 强行拉伸,按钮长宽比就会失衡。

解决:动态计算按钮大小。进入游戏时用 Toolkit.getDefaultToolkit().getScreenSize() 拿到屏幕宽高,减去窗口标题栏和菜单栏的占位,用剩余尺寸除以行列数,得到一个格子能放的最大尺寸,再设置 JButton 的 preferredSize。或者更简单,把雷盘放进 JScrollPane,窗口固定为可滚动区域,格子大小保持不变,超出部分滚动查看。

5.5 IDEA 里运行乱码,英雄榜变“英 雄 榜”

现象:代码里的中文全变成乱码,或者显示成一串问号,但英文和数字正常。

原因:源码文件是 UTF-8 编码,而 javac 编译或 IDE 控制台用了系统默认编码 GBK,两边不匹配,字符串在生成字节时就坏了。Properties 文件里的中文则没问题,因为 Properties 的默认编码就是 UTF-8 加转义。

解决:统一编码。IDEA 里把 File Encoding 的 Global Encoding、Project Encoding 和 Properties Files 编码全部设成 UTF-8,并在编辑器的文件编码设置里把当前源码文件转成 UTF-8。如果是命令行编译,在 javac 参数里加 -encoding UTF-8。运行时再给 JFrame 内容设置统一的 Font,比如 new Font("Dialog", Font.PLAIN, 12),让 Swing 使用 Java 自带的字体渲染中文,而不是依赖系统字体。

6. 让英雄榜能复用:把 Top-N 排序抽成通用组件,顺手做一次自测

6.1 把“取前 N 名”变成一条流式操作

英雄榜现在的排序逻辑写在 addScore 里,只服务 ScoreEntry 这一种类型。以后你想加一个“最快点击速度榜”或者“通关次数榜”,还得复制一遍排序代码。更干净的做法是把 Top-N 这个动作抽成泛型方法:

public static <T> List<T> topN(List<T> source, Comparator<? super T> comparator, int limit) { return source.stream() .sorted(comparator) .limit(limit) .collect(Collectors.toCollection(ArrayList::new)); }

调用的时候,ScoreEntry 已经实现了 Comparable,直接传自然顺序就行:

List<ScoreEntry> best = topN(entries, Comparator.naturalOrder(), 10);

这段流式操作的逻辑是:sorted 按传入比较器排序,limit 截取前 N 条,collect 收集成新的 ArrayList。注意一定要用 toCollection(ArrayList::new),而不是 Collectors.toList(),后者返回的 List 不保证可变,后续如果想继续 add 或者 subList 就会报错。

这套抽象用上了泛型、Comparator 和 Stream,已经比课程设计常见水平高了一档。下次再遇到类似的排名需求,直接调用这一个方法就够了。

6.2 三个验证点:把最容易翻车的地方主动踩一遍

代码写到这里,我建议验收时别只点几个格子就收工。以下三个验证点是我每次做完这类项目都会强制走一遍的:

第一,重名测试。用同一个玩家名连续通关两次,再开游戏看英雄榜,两条成绩应该都在,且按时间先后排列。如果第二条覆盖了第一条,说明持久化 key 和 subList 截断的位置有问题。

第二,首次运行测试。删掉 scores.properties 文件,直接启动游戏,应该能正常进入雷盘而不是抛 FileNotFoundException。load 方法要把 missing 文件当成空列表处理,而不是让异常中断初始化。

第三,窗口关闭测试。推进一局,弹英雄榜,然后用窗口右上角 X 关程序,重新打开,成绩还在并能正常排序。这一步专门验证窗口关闭监听器和 flush 逻辑,很多人就是栽在这里。

我最早做类似项目时,总想着“能跑就行”,结果每次都在持久化和线程这两个环节翻车,后来养成了一个习惯:凡是涉及文件读写的功能,启动前先删数据文件、跑一遍、再看重启后的状态。从那以后我每次做课程设计都强制走一遍这个流程,踩坑成本低,收效却非常明显。希望这篇拆解能帮你省下那些我没省下的调试时间,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进