首页 / 资讯中心 / 文章详情

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

Java扫雷英雄榜:从Swing界面到文件IO与排序的完整项目实战 ★ FEATURED ARTICLE
简介这是一份基于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[][]当前格子是否为雷falserevealed[][]当前格子是否已翻开falseflagged[][]当前格子是否已被玩家插旗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() { ListInteger 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 ComparableScoreEntry { 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(ListScoreEntry 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.namevalue 是该玩家的姓名。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(ListScoreEntry 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 ListT topN(ListT source, Comparator? super T comparator, int limit) { return source.stream() .sorted(comparator) .limit(limit) .collect(Collectors.toCollection(ArrayList::new)); }调用的时候ScoreEntry 已经实现了 Comparable直接传自然顺序就行ListScoreEntry 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 逻辑很多人就是栽在这里。我最早做类似项目时总想着“能跑就行”结果每次都在持久化和线程这两个环节翻车后来养成了一个习惯凡是涉及文件读写的功能启动前先删数据文件、跑一遍、再看重启后的状态。从那以后我每次做课程设计都强制走一遍这个流程踩坑成本低收效却非常明显。希望这篇拆解能帮你省下那些我没省下的调试时间希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站