news 2026/9/8 10:51:11

Java实战:从零开发一个命令行背单词软件,覆盖面向对象与集合框架

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java实战:从零开发一个命令行背单词软件,覆盖面向对象与集合框架

去年这个时候,我还在为 Java 的语法细节焦虑——lambda 表达式到底怎么用、HashMap 和 Hashtable 有什么区别、异常处理为什么要分 checked 和 unchecked。这些东西单个拿出来都能写几百字笔记,可真要独立做一个完整项目,脑子里反而是空的。这种状态我太熟悉了:教程看了无数遍,demo 跟着敲了一堆,一到自己动手就不知道从哪下笔。

所以我给自己定了个阶段目标:别再看教程了,直接做一个真正能用的工具。工具选来选去,最后定了背单词软件。原因很简单:我自己备考需要背单词,市面上的软件要么收费要么广告多,而且背着背着就想改功能,比如我想统计每个单词的出错次数,想只复习错题本,这些需求在别人的软件里根本没法定制。那就自己写一个。

这一篇是系列的第 22 篇,前面 21 篇把 Java 基础、面向对象、集合框架、异常处理这些零散知识点都过了一遍,现在到了综合实战的阶段。我会从零开始,把一个背单词软件拆解成需求、设计、编码、踩坑的全过程,尽量把每一步选择的理由也讲清楚。这篇是第一部分,先做出一个能跑起来的命令行版本,覆盖单词导入、学习、测试、错题统计这些核心功能。图形界面、记忆曲线、数据持久化这些,后面几篇再逐个迭代。

如果你也处于"基础学完但不会做项目"的阶段,或者想看看一个 Java 小程序从需求到落地是怎么一步步走过来的,这篇应该对你有用。

1. 为什么选背单词软件作为综合实战项目

1.1 业务模型天然契合 Java 的核心特性

很多人学完 Java 基础之后做的第一个项目是"学生管理系统"或者"图书管理系统",不是说不行,但这类项目有个问题:业务逻辑太流程化,写完 CRUD 之后其实没多少可思考的空间。背单词软件不一样,它看起来简单,仔细一拆会发现每个环节都有值得设计的点。

最核心的一点,单词本身就是典型的面向对象模型。一个单词有拼写、有音标、有释义、有出错次数、有掌握状态,这些属性天然就可以映射成一个Word类。单词集合放在内存里就是典型的集合框架应用场景——用List保存全部词库,用Set记录哪些单词被抽过,用Map做词性和释义的映射。学习流程涉及状态流转(生词→学习中→已掌握→又忘了),这又逼着我去思考状态怎么记录、怎么更新。可以说,Java 面向对象三大特性里,封装和多态在这个项目里体现得特别明显。

1.2 复杂度可控,又能渐进式扩展

这类项目最怕的就是一开始设计得太重,结果写着写着就烂尾了。背单词软件的好处在于,它可以分阶段演进,每个阶段都有完整可用的成果:

  • 第一阶段(本篇):命令行交互版,词库从文本文件加载,支持学习、测试、错题统计,程序能真正跑起来每天用。
  • 第二阶段:加上 Swing 或 JavaFX 图形界面,把控制台输出换成面板和按钮。
  • 第三阶段:引入文件持久化,把学习进度、错题记录保存到本地,下次启动接着来。
  • 第四阶段:可以继续加数据库、加记忆曲线算法、加多用户支持。

每个阶段都不需要推翻前一个阶段的代码,只需要在分层设计上预留好接口就行。这种"能跑、能用、能扩展"的节奏,对建立学习信心特别重要。我自己之前烂尾过好几个项目,全是栽在"想一步到位"上,这次学乖了,先跑起来再说。

1.3 做一个自己每天都会用的工具,动力完全不同

还有一个很实际的原因:项目得是自己有需求的。你做一个人事管理系统,做完可能就丢一边了;但背单词软件做完,我是真的每天会用,用完发现哪里不顺,立刻就想改,改了就能立刻体验效果。这种"反馈闭环"是教程项目给不了的。我强烈建议,如果你也想拿项目练手,先想想自己日常有什么高频的小需求,哪怕很普通,也比为了做项目而做项目要有价值得多。

2. 需求拆解与总体设计

2.1 第一版功能清单

开始写代码之前,我先在纸上把需求列了一下。既然是自己用的工具,功能不用贪多,但核心链路必须完整。第一版我定了四个模块:

功能模块具体说明优先级
词库导入从本地文本文件读取单词,解析词条并校验格式必须
学习模式逐个展示单词和音标,让用户自评是否认识,记录状态必须
测试模式看单词选释义(四选一),计算正确率,错词自动进错题本必须
错题统计查看所有出错的单词及其错误次数,支持按错误次数排序必须

学习模式的自评逻辑我考虑过要不要做成"输入释义",后来放弃了。原因一是输入匹配的处理很麻烦,比如大小写、空格、释义里带括号的情况,判断标准不好定;二是背单词的实际场景里,遮住释义想一下再看答案,这种"自评式"的方法更符合记忆规律。所以第一版就用"认不认识"的二选一,后续再考虑更严格的考核方式。

2.2 分层设计:UI、业务、数据分开

这个项目虽然小,但我不打算把所有代码塞进一个类里写。哪怕只有几百行,分包分层也能让代码结构清晰很多,更重要的是,后续加图形界面时可以只替换 UI 层,业务逻辑完全不用动。我的包结构是这样设计的:

src/ ├── Main.java // 程序入口,负责组装各层 ├── entity/ │ └── Word.java // 单词实体类 ├── dao/ │ └── WordRepository.java // 数据访问层,负责读文件、解析词条 ├── service/ │ └── WordService.java // 业务逻辑层,抽词、判题、错题记录 └── ui/ └── ConsoleUI.java // 控制台交互层,菜单、输入输出

这个分层思路在真实后端项目里更明显——Controller 接收请求、Service 处理业务、DAO 操作数据库,各司其职。我现在做这个小工具就按这个模式来,等于提前养成工程化习惯。每层之间通过方法调用传递数据,不跨层访问,后面想换掉任何一层都相对容易。

2.3 为什么第一版不碰数据库

你可能想问,为什么不直接用 SQLite 或者 MySQL 存单词?我的考虑是,第一版的核心目标是跑通业务逻辑,如果用数据库,就得先解决引入依赖、写 SQL、处理连接这些事,容易喧宾夺主。用文本文件当词库,读的时候用 Java 文件 IO 处理,这本身就是前面学的 IO 流知识的实战演练。等第二版需要持久化学习进度的时候,再引入数据库不迟。项目的每个阶段只解决这个阶段最核心的问题,这个原则帮我避免了很多内耗。

3. 核心数据结构:Word 实体与词库加载

3.1 Word 类的字段设计

整个项目中,Word类是最核心的数据结构,几乎所有逻辑都在围绕它的字段转。我的第一版设计如下:

package entity; public class Word { private final String word; // 单词拼写 private final String phonetic; // 音标 private final String meaning; // 释义(含词性) private int errorCount = 0; // 累计错误次数 private boolean mastered = false; // 是否已掌握 public Word(String word, String phonetic, String meaning) { this.word = word; this.phonetic = phonetic; this.meaning = meaning; } public String getWord() { return word; } public String getPhonetic() { return phonetic; } public String getMeaning() { return meaning; } public int getErrorCount() { return errorCount; } public void setErrorCount(int errorCount) { this.errorCount = errorCount; } public void increaseErrorCount() { this.errorCount++; } public boolean isMastered() { return mastered; } public void setMastered(boolean mastered) { this.mastered = mastered; } @Override public String toString() { return word + " " + phonetic + " " + meaning; } }

有几个设计细节我想单独说一下。

为什么不把 errorCount 设成 boolean 类型的 isWrong?因为只记"对错"信息量太少了。一个单词错 1 次和错 8 次,复习优先级应该完全不同。用 int 记录次数,后面可以根据次数决定复习策略,比如错误次数超过 3 次的优先出现在测试里,这就是最朴素的"记忆曲线"雏形。这是我做第一版时就留的后手。

为什么字段用 final?word、phonetic、meaning 这三个字段在单词创建后不应该被修改,所以用 final 修饰,顺便在构造方法里强制初始化。这算是好的编码习惯:能不让别人改的字段,就不要开放修改权限。而 errorCount 和 mastered 是会变化的,所以保留 setter。

3.2 词库文件格式选择

词库文件用什么格式,我纠结了一小会儿。选项有三个:

  • properties 文件:Java 原生支持,new Properties().load(reader)就能读,但它是键值对结构,一个 key 只能对应一个 value,单词和释义的关系倒是一对一,可如果后续想加例句、加词根词缀,properties 就不够用了,而且释义里如果包含=:还得转义,很麻烦。
  • JSON 文件:结构清晰,扩展性好,但要引入第三方库(Jackson 或 Gson),不符合"第一版零依赖"的原则。
  • 自定义格式文本文件:每行一个词条,用制表符或空格分隔字段,读取时用split拆开。简单直接,格式完全由自己控制。

最后我选了第三种。词库文件每行一个单词,格式如下:

abandon /əˈbændən/ v. 放弃;抛弃 ability /əˈbɪləti/ n. 能力;才能 absent /ˈæbsənt/ adj. 缺席的;不在场的

这里有个坑要提醒:分隔符我用的是制表符\t,而不是空格。原因很简单——释义(尤其是英文注释)里经常有空格,如果用空格分隔,split(" ")会把释义拆成多段,还得自己拼回去。用\t|这种词条里几乎不会出现的字符做分隔,解析要省心得多。我用的split("\\t", 3),第三个参数3表示最多拆成 3 段,这样即使释义里有制表符(其实几乎不可能),后面的部分也不会被拆散。

3.3 文件读取与解析实现

数据访问层我做了一个WordRepository,只有两个职责:加载词库、对外提供单词列表。代码不长,但几个细节值得注意:

package dao; import entity.Word; import java.io.BufferedReader; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; public class WordRepository { private final List<Word> words = new ArrayList<>(); /** * 从文本文件加载词库,返回成功加载的单词数量 */ public int loadFromFile(String filePath) { Path path = Paths.get(filePath); if (!Files.exists(path)) { throw new IllegalArgumentException("词库文件不存在: " + filePath); } if (!Files.isReadable(path)) { throw new IllegalArgumentException("词库文件不可读: " + filePath); } int lineNumber = 0; try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; while ((line = reader.readLine()) != null) { lineNumber++; String trimmed = line.trim(); // 跳过空行和注释行,方便维护词库时写说明 if (trimmed.isEmpty() || trimmed.startsWith("#")) { continue; } Word word = parseLine(trimmed, lineNumber, filePath); if (word != null) { words.add(word); } } } catch (IOException e) { System.err.println("读取词库文件失败: " + e.getMessage()); } return words.size(); } private Word parseLine(String line, int lineNumber, String filePath) { String[] parts = line.split("\\t", 3); if (parts.length < 3) { System.err.println("【警告】第 " + lineNumber + " 行格式不完整,已跳过: " + line); return null; } String wordText = parts[0].trim(); String phonetic = parts[1].trim(); String meaning = parts[2].trim(); if (wordText.isEmpty() || meaning.isEmpty()) { System.err.println("【警告】第 " + lineNumber + " 行缺少单词或释义,已跳过: " + line); return null; } return new Word(wordText, phonetic, meaning); } public List<Word> getAllWords() { return words; } public int size() { return words.size(); } }

几个我实际用的时候踩过或特意处理的点:

1. 编码必须显式指定。这里用的是StandardCharsets.UTF_8,但如果你在 Windows 命令行直接运行,控制台默认编码可能是 GBK,会出现中文乱码。这不是代码逻辑问题,是环境编码不一致的问题。我用Files.newBufferedReader(path, StandardCharsets.UTF_8)显式指定了文件读取编码,代码层面固定 UTF-8,但控制台输出时 Windows 下可能还是乱码。解决办法后面"踩坑记录"部分细说。

2. try-with-resources 别忘了。BufferedReader用完之后必须关闭,忘了关在短小程序里可能没什么感知,但长时间运行会泄漏文件句柄。Java 7 之后的 try-with-resources 语法会在块结束时自动关闭资源,我推荐一律这么写,比手写finally { reader.close(); }干净得多。

3. 脏数据要容错。词库是手工维护的,难免有一行多了个空格、少了个字段。parseLine里做了格式检查,不完整的行直接跳过并打印警告。这样做的好处是,程序不会因为一个坏词条就整体崩掉,这对用户自己维护数据文件的场景太重要了。你可以试试故意在 words.txt 里加一行没有释义的文字,看看程序是不是还能正常启动。

4. 学习与测试模式的核心逻辑

4.1 随机抽词算法:既要随机,又要不重复

第一版一个重点功能是"随机抽词"。我开始想得很简单,random.nextInt(list.size())取一个下标不就行了?但很快发现一个问题:如果纯随机,极端情况下可能连续抽到同一个单词,而另一些单词一直没被抽到。为了让一轮学习尽可能覆盖更多单词,我引入了一个"已抽取索引集合":

package service; import entity.Word; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Random; import java.util.Set; public class WordService { private final List<Word> words; private final Set<Integer> usedIndexes = new HashSet<>(); private final List<Word> wrongWords = new ArrayList<>(); private final Random random = new Random(); public WordService(List<Word> words) { this.words = words; } /** * 返回下一个待学习的单词,保证一轮之内不重复 */ public Word nextWord() { if (words.isEmpty()) { throw new IllegalStateException("词库为空,无法学习"); } // 一轮用完之后,重置集合,开始下一轮 if (usedIndexes.size() >= words.size()) { usedIndexes.clear(); } int index; do { index = random.nextInt(words.size()); } while (usedIndexes.contains(index)); usedIndexes.add(index); return words.get(index); } /** * 记录用户对某个单词的回答结果 */ public void recordAnswer(Word word, boolean correct) { if (correct) { word.setMastered(true); } else { word.setMastered(false); word.increaseErrorCount(); if (!wrongWords.contains(word)) { wrongWords.add(word); } } } public List<Word> getWrongWords() { return wrongWords; } public long getCorrectCount() { return words.stream().filter(Word::isMastered).count(); } }

这里有个细节值得展开:为什么用Set<Integer>存下标,而不是直接Set<Word>Set<Word>也可以,但用下标更稳——万一以后Word类重写了equalshashCode,用对象去重时的行为可能会变,而下标是纯粹的位置信息,不会受对象内容影响。再者,我需要从List里取单词,按下标取最直接。

还有一个边界情况:当usedIndexes.size() >= words.size()时,说明这一轮已经把所有单词都过了一遍,此时把所有下标清空,重新开始下一轮。这样保证每轮学习都能覆盖全部词库,不会出现某个单词永远学不到的情况。这个逻辑在词库数量大时尤其重要。

4.2 测试模式:干扰项生成与判题流程

测试模式我设计成"看单词选释义"的四选一。核心难点是干扰项不能重复,还得像回事。如果随机选三个错误释义,结果把正确释义也混进去了,那就尴尬了。看代码:

/** * 为指定单词生成 4 个选项,其中一个是正确释义,其余为干扰项 */ public List<String> generateOptions(Word target, int optionCount) { List<String> options = new ArrayList<>(); options.add(target.getMeaning()); // 收集所有其他单词的释义作为候选干扰项 List<String> allMeanings = new ArrayList<>(); for (Word w : words) { if (!w.equals(target)) { allMeanings.add(w.getMeaning()); } } Set<String> used = new HashSet<>(options); while (options.size() < optionCount && !allMeanings.isEmpty()) { String candidate = allMeanings.get(random.nextInt(allMeanings.size())); if (used.add(candidate)) { options.add(candidate); } } // 打乱选项顺序,避免正确项永远在第一个 Collections.shuffle(options, random); return options; }

这个实现的思路很直白:先把正确释义放进集合,然后从候选池里随机挑,挑到不重复的就加进去,直到凑满 4 个。used这个Set既用来去重,又作为"是否已被选中"的标记,一举两得。最后用Collections.shuffle把正确选项的位置打乱,不然正确答案永远在第一位,测试就失去意义了。

不过这里有个性能隐患:allMeanings每次测试都要遍历整个词库重建,词库几百个单词时无所谓,但如果是几万个单词,每次出题都要 O(N) 的复杂度,可以优化成初始化时就把所有释义缓存在一个列表里,每次出题只需从中取样。第一版我没做这个优化,属于"知道有问题、但当前规模下不值得解决"的取舍。

4.3 判题与错题本的更新时机

判题逻辑我放在了recordAnswer方法里。注意一个细节:答对时,我只把mastered置为 true,并不清空errorCount。这是我特意保留的——错误次数是历史累加数据,它对后续复习排序还有参考价值。如果你更希望"掌握之后错误次数归零",也可以在recordAnswer里加一行word.setErrorCount(0),全看你怎么理解"掌握"这个词。

错题本用的是List<Word>,但添加时做了去重判断if (!wrongWords.contains(word))。有人会问,为什么不直接用Set<Word>?因为Set依赖equals/hashCode,而我的Word类没有重写这两个方法,默认是引用相等——同一个对象自然是同一个对象,所以用Set也没问题。但用List有个好处:能保留错题出现的先后顺序,后面如果想按时间查看错题记录,List更合适。等以后需要去重+排序+快速查找时,再换成LinkedHashSet也不迟。

5. 第一版控制台程序的完整实现

5.1 组装入口:Main 类

程序入口的作用是把各层组装起来。我把它单独拎出来,不放在ConsoleUI里,是为了让 UI 类只关心交互、不关心依赖怎么构建:

import dao.WordRepository; import entity.Word; import service.WordService; import ui.ConsoleUI; public class Main { public static void main(String[] args) { WordRepository repository = new WordRepository(); int wordCount = repository.loadFromFile("words.txt"); if (wordCount == 0) { System.out.println("词库为空,程序退出。请检查 words.txt 文件。"); return; } WordService service = new WordService(repository.getAllWords()); ConsoleUI ui = new ConsoleUI(service, wordCount); ui.run(); } }

如果你用过 Spring 这类框架,会发现这个Main类的角色很像框架里的"装配根"——把各个 Bean 创建好、注入依赖、启动。手写小程序时,这种装配工作放在入口类里最合适,不要散落在各个业务类里。

5.2 交互层:菜单循环与输入处理

ConsoleUI是用户直接面对的部分。第一版功能不复杂,我设计了一个主菜单和三个子流程:

package ui; import entity.Word; import service.WordService; import java.util.List; import java.util.Scanner; public class ConsoleUI { private final WordService service; private final int totalCount; private final Scanner scanner = new Scanner(System.in); public ConsoleUI(WordService service, int totalCount) { this.service = service; this.totalCount = totalCount; } public void run() { System.out.println("===== 背单词软件 v0.1 ====="); System.out.println("词库加载完成,共 " + totalCount + " 个单词。"); System.out.println("=================================="); while (true) { showMenu(); String input = scanner.nextLine().trim(); int choice; try { choice = Integer.parseInt(input); } catch (NumberFormatException e) { System.out.println("输入无效,请输入 0-4 之间的数字。"); continue; } switch (choice) { case 1 -> startStudySession(); case 2 -> startTestSession(); case 3 -> showWrongWords(); case 4 -> showStatistics(); case 0 -> { System.out.println("再见,坚持就是胜利!"); return; } default -> System.out.println("没有这个选项,请输入 0-4 之间的数字。"); } } } private void showMenu() { System.out.println(); System.out.println("-------- 主菜单 --------"); System.out.println("1. 学习新单词"); System.out.println("2. 单词测试(四选一)"); System.out.println("3. 查看错题本"); System.out.println("4. 学习统计"); System.out.println("0. 退出"); System.out.print("请输入你的选择: "); } // ... 子流程方法 }

这里有一个很关键的输入处理细节:我把菜单选择用scanner.nextLine()读字符串,再用Integer.parseInt转数字,而不是直接用scanner.nextInt()。新手很容易踩nextInt()nextLine()混用的坑——nextInt()不会消费掉行尾的换行符,后面跟着的nextLine()会直接读到空字符串。为了避免这种灵异现象,我统一用nextLine()+ 解析的方式处理输入,能省很多排查的功夫。这个坑我后面细讲。

子流程的方法分别是startStudySessionstartTestSession,它们只负责交互,业务逻辑全部委托给service,这样职责才清晰。比如学习模式:

private void startStudySession() { System.out.println("进入学习模式,本轮会覆盖全部 " + totalCount + " 个单词。"); int reviewed = 0; while (reviewed < totalCount) { Word word = service.nextWord(); reviewed++; System.out.println("-------------------------------------------"); System.out.println("单词 " + reviewed + "/" + totalCount + ": " + word.getWord()); System.out.println("音标: " + word.getPhonetic()); System.out.print("认识这个单词吗?(输入 1 认识 / 0 不认识): "); String input = scanner.nextLine().trim(); boolean known = input.equals("1"); service.recordAnswer(word, known); if (!known) { System.out.println("释义: " + word.getMeaning()); } } System.out.println("本轮学习结束。"); }

5.3 测试模式的完整流程

测试模式是我这一版里交互最复杂的部分。每轮测试我固定出 10 道题,全部答完后显示正确率,并把错题更新进错题本:

private void startTestSession() { int questionCount = Math.min(10, totalCount); int correct = 0; System.out.println("测试开始,共 " + questionCount + " 题。看单词选释义,准备好了吗?"); System.out.println("-------------------------------------------"); for (int i = 0; i < questionCount; i++) { Word target = service.nextWord(); List<String> options = service.generateOptions(target, 4); int answerIndex = options.indexOf(target.getMeaning()) + 1; System.out.println("第 " + (i + 1) + " 题: " + target.getWord() + " " + target.getPhonetic()); for (int j = 0; j < options.size(); j++) { System.out.println(" " + (j + 1) + ". " + options.get(j)); } System.out.print("请选择正确释义的序号: "); String input = scanner.nextLine().trim(); int userChoice; try { userChoice = Integer.parseInt(input); } catch (NumberFormatException e) { System.out.println("输入无效,本题判错。"); userChoice = -1; } boolean isCorrect = (userChoice == answerIndex); service.recordAnswer(target, isCorrect); if (isCorrect) { correct++; } else { System.out.println("回答错误。正确释义是: " + target.getMeaning()); } System.out.println(); } double rate = (double) correct / questionCount * 100; System.out.printf("测试结束,正确率: %.1f%% (%d/%d)%n", rate, correct, questionCount); }

注意answerIndex的计算:options.indexOf(target.getMeaning()) + 1。因为在generateOptions里选项已经被shuffle打乱,所以正确选项的位置每次都是随机的,正好可以检验indexOf用得熟不熟。如果indexOf返回 -1,说明打乱前正确释义被重复项覆盖了——这是我在generateOptions里特意用used.add()去重来防止的边界情况。

还有一个细节:测试也复用了service.nextWord()的抽词逻辑,这意味着测试和学习共用同一轮"不重复下标"的记录,如果先学习再测试,测试可能抽不到全部前面学过的词。这是第一版的已知行为,第二版我会把学习和测试的抽词记录拆开,让它们各自独立。

5.4 编译运行与效果验证

代码写完之后,我用命令行编译运行了一把。在项目根目录下执行:

javac -encoding UTF-8 src/**/*.java -d out java -cp out Main

这里有个小坑:javac如果不加-encoding UTF-8,在中文 Windows 上会按 GBK 解码源文件,而我的源码是 UTF-8 保存的,带中文的字符串全都会编译报错"编码 GBK 的不可映射字符"。反过来说,在 Linux/macOS 上源码按 UTF-8 存、编译时用 UTF-8 编码,基本就没这个问题。所以建议在 IDE 里统一设置项目编码为 UTF-8,命令行编译时永远带上-encoding UTF-8,一劳永逸。

运行效果大致是这样:

===== 背单词软件 v0.1 ===== 词库加载完成,共 3 个单词。 ================================== -------- 主菜单 -------- 1. 学习新单词 2. 单词测试(四选一) 3. 查看错题本 4. 学习统计 0. 退出 请输入你的选择: 1 ...

整个流程跑下来,确实能完成"学单词→测单词→记错题→看统计"的闭环,第一版的目标算是达成了。

6. 踩坑记录与常见问题排查

做这个小项目的过程中,我踩了几个很典型的坑,有的是必踩的 Java 新手坑,有的是编码环境问题。整理成速查表,能帮后来人省不少时间。

问题现象根本原因解决方案
源码编译时报"编码 GBK 的不可映射字符"javac 默认按平台编码(Windows 为 GBK)解码源文件,源码却是 UTF-8编译时加-encoding UTF-8,或在 IDE 中统一设置 UTF-8
运行时控制台输出中文乱码文件读取编码与输出编码不一致,或控制台代码页不是 UTF-8文件读取用StandardCharsets.UTF_8;Windows 控制台执行chcp 65001或改用 IDE 运行
连续用nextLine()却读不到输入nextInt()后残留的换行符被下一个nextLine()消费统一用nextLine()+Integer.parseInt(),不用nextInt()
遍历 List 时删除元素抛ConcurrentModificationException集合迭代过程中结构被修改Iteratorremove(),或先收集再统一删除
词库某一行格式不对导致整个程序崩溃解析时没做容错,异常直接抛出逐行解析、逐行校验,非法行打印警告后跳过

6.1 解码世界:文件编码与控制台编码之争

这一节值得单独说,因为我第一次跑出乱码时一度以为是代码写错了,排查了半天才发现是编码问题。Java 源码文件本身是字节序列,javac在编译时需要把这些字节解码成字符;运行时程序里的字符串要输出到控制台,又是一个编码转换过程。任何一环的编码不一致,中文就会变成乱码。

我最终确定的组合是:源码和词库文件都用 UTF-8 保存,读取时用StandardCharsets.UTF_8显式指定,IDE 里运行程序。这样在 IDEA/Eclipse 里完全不会乱码。如果你一定要在 Windows 命令行跑,先执行chcp 65001把代码页切到 UTF-8,再运行java -cp out Main,一般也能解决。至于某些版本 IDEA 控制台自己要用 GBK 的情况,那就去设置里把控制台编码也调成 UTF-8,保持全局一致。

6.2 nextInt() 与 nextLine() 混用的经典坑

这个坑几乎每个用Scanner的人都踩过。现象是这样的:先用nextInt()读了一个数字,接着调nextLine()想读一行字符串,结果nextLine()返回的是空字符串。原因很简单:你输入1然后按回车,nextInt()只消费了字符1,回车产生的换行符还留在缓冲区里,nextLine()一读就把这个换行符读出来了,自然就是空的。

我现在的习惯是:纯用nextLine()读输入,再用Integer.parseIntDouble.parseDouble做类型转换。如果转换失败就提示用户重新输入,这样就不会有缓冲区残留的问题,代码也更统一。

6.3 词库文件维护的实践建议

词库是我自己找的高频词汇,手工整理成文本格式。整理过程中有几个经验值得分享:

  • 一行一个词条,用制表符分隔,这样 Excel 也能打开编辑,编辑完另存为 UTF-8 编码的 txt 就能直接用。
  • 词库顶部加几行以#开头的注释,记录来源、更新日期、格式说明。这属于文件自描述,程序里已经兼容跳过了。
  • 定期备份。因为词库是手工维护的资产,万一改坏了,至少能从备份恢复。我自己就吃过亏,一次批量替换操作把几百行词条弄乱了,还好有备份。
  • 规模控制在 100~200 词左右做测试。如果你的词库有上万词,第一版程序加载和测试倒还能撑住,但先拿小词库跑通流程,定位问题是会容易得多。

6.4 关于代码 Bad Smell 的一点自我反思

第一版代码跑通之后,我回头审视,发现有两处可以明显改进:

一是WordService里的wrongWords.contains(word)List的线性查找,词库小还好,错题多了性能会下降,而且每个单词只保留一个引用,无法记录同一单词多次出错的独立时间线。后续版本可以改成Map<String, Integer>来统计错题次数,或者用数据库表结构存word_iderror_count

二是generateOptions里每次都遍历全词库收集释义,O(N) 的出题成本在词库大时不可接受。应该把词库加载完成后就缓存一份释义列表,出题时直接从这里取样。

这些不是 bug,但能感受到"能跑"和"设计合理"之间还有距离。这也是做系列项目有意思的地方——每个阶段的复盘都能发现下一阶段要解决的新问题。

最后再分享一点个人体会

这个背单词软件的第一版,从需求梳理到跑通全部功能,我大概花了一个周末。最大的收获不是写了几百行代码,而是完整走了一遍"需求→设计→编码→测试→复盘"的循环。它把我之前学的零散知识点真正串了起来:面向对象建模、集合框架选型、IO 读取文件、异常处理、代码分层——每个知识点在这个项目里都有了具体的使用场景,而不是孤立的概念。

如果你也卡在"学过但不会用"的阶段,我建议你也挑一个自己真正会使用的小工具下手,先别追求完美,第一版烂一点没关系,能跑起来就是胜利。背单词软件这个项目后续我还会继续迭代,下一篇打算加图形界面,把控制台里的这些功能用 Swing 重新呈现出来,同时解决数据持久化的问题——这样每次背完单词,进度不会丢。有兴趣的话,可以下一章见。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 10:50:56

从零评估FrontStep v0.5.2:前端开源工具部署与工作流接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 10:48:29

课程资料问答助手:RAG落地全流程解析与避坑指南

课程资料问答助手&#xff0c;本质上是把讲义、教材、PPT、课后习题这些零散资料整理成一个能直接对话的知识库。学生问“第三章的重点是什么”&#xff0c;它不靠搜索引擎给一堆链接&#xff0c;而是从你上传的课程资料里找到对应段落&#xff0c;再组织成自然语言回答。这个案…

作者头像 李华
网站建设 2026/9/8 10:47:05

基于MATLAB/Simulink的三端VSC-HVDC仿真模型搭建与调试

1. 三端VSC-HVDC模型&#xff1a;为什么我要用MATLAB搭这套东西搞电力电子和电网仿真的朋友应该都有同感&#xff0c;VSC-HVDC&#xff08;柔性直流输电&#xff09;这名字听了无数遍&#xff0c;书本里背了一堆理论&#xff0c;什么MMC子模块均压、环流抑制、功率解耦控制&…

作者头像 李华
网站建设 2026/9/8 10:46:42

基于YOLOv8的高跟鞋检测数据集实战:从标注规范到模型训练全流程

简介&#xff1a;女士高跟鞋检测数据集面向YOLO目标检测学习者与算法验证场景&#xff0c;基于ultralytics框架设计&#xff0c;可用于训练和评估高跟鞋识别模型。资源共552个文件&#xff0c;含276张JPG图像与276个XML标注文件&#xff0c;一一对应&#xff0c;压缩包仅10.55M…

作者头像 李华
网站建设 2026/9/8 10:46:24

AI时代算力紧张:开发者应对策略与优化实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 10:44:53

PHP在线PS图片处理源码:从部署到二次开发实战指南

简介&#xff1a;PHP在线照片处理网站源码是一套基于PHP构建的网页版图像编辑工具&#xff0c;面向需要快速部署在线编辑功能的开发者、个人站长或课程设计者&#xff0c;可省去本地安装Photoshop的流程&#xff0c;随时随地进行图片尺寸调整、裁剪、旋转、添加滤镜与文字图形等…

作者头像 李华