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 次的优先出现在测试里这就是最朴素的记忆曲线雏形。这是我做第一版时就留的后手。为什么字段用 finalword、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 ListWord 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 ListWord 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 ListWord words; private final SetInteger usedIndexes new HashSet(); private final ListWord wrongWords new ArrayList(); private final Random random new Random(); public WordService(ListWord 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 ListWord getWrongWords() { return wrongWords; } public long getCorrectCount() { return words.stream().filter(Word::isMastered).count(); } }这里有个细节值得展开为什么用SetInteger存下标而不是直接SetWord用SetWord也可以但用下标更稳——万一以后Word类重写了equals和hashCode用对象去重时的行为可能会变而下标是纯粹的位置信息不会受对象内容影响。再者我需要从List里取单词按下标取最直接。还有一个边界情况当usedIndexes.size() words.size()时说明这一轮已经把所有单词都过了一遍此时把所有下标清空重新开始下一轮。这样保证每轮学习都能覆盖全部词库不会出现某个单词永远学不到的情况。这个逻辑在词库数量大时尤其重要。4.2 测试模式干扰项生成与判题流程测试模式我设计成看单词选释义的四选一。核心难点是干扰项不能重复还得像回事。如果随机选三个错误释义结果把正确释义也混进去了那就尴尬了。看代码/** * 为指定单词生成 4 个选项其中一个是正确释义其余为干扰项 */ public ListString generateOptions(Word target, int optionCount) { ListString options new ArrayList(); options.add(target.getMeaning()); // 收集所有其他单词的释义作为候选干扰项 ListString allMeanings new ArrayList(); for (Word w : words) { if (!w.equals(target)) { allMeanings.add(w.getMeaning()); } } SetString 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)全看你怎么理解掌握这个词。错题本用的是ListWord但添加时做了去重判断if (!wrongWords.contains(word))。有人会问为什么不直接用SetWord因为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() 解析的方式处理输入能省很多排查的功夫。这个坑我后面细讲。子流程的方法分别是startStudySession和startTestSession它们只负责交互业务逻辑全部委托给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(); ListString 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_8Windows 控制台执行chcp 65001或改用 IDE 运行连续用nextLine()却读不到输入nextInt()后残留的换行符被下一个nextLine()消费统一用nextLine()Integer.parseInt()不用nextInt()遍历 List 时删除元素抛ConcurrentModificationException集合迭代过程中结构被修改用Iterator的remove()或先收集再统一删除词库某一行格式不对导致整个程序崩溃解析时没做容错异常直接抛出逐行解析、逐行校验非法行打印警告后跳过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.parseInt或Double.parseDouble做类型转换。如果转换失败就提示用户重新输入这样就不会有缓冲区残留的问题代码也更统一。6.3 词库文件维护的实践建议词库是我自己找的高频词汇手工整理成文本格式。整理过程中有几个经验值得分享一行一个词条用制表符分隔这样 Excel 也能打开编辑编辑完另存为 UTF-8 编码的 txt 就能直接用。词库顶部加几行以#开头的注释记录来源、更新日期、格式说明。这属于文件自描述程序里已经兼容跳过了。定期备份。因为词库是手工维护的资产万一改坏了至少能从备份恢复。我自己就吃过亏一次批量替换操作把几百行词条弄乱了还好有备份。规模控制在 100~200 词左右做测试。如果你的词库有上万词第一版程序加载和测试倒还能撑住但先拿小词库跑通流程定位问题是会容易得多。6.4 关于代码 Bad Smell 的一点自我反思第一版代码跑通之后我回头审视,发现有两处可以明显改进一是WordService里的wrongWords.contains(word)是List的线性查找词库小还好错题多了性能会下降而且每个单词只保留一个引用无法记录同一单词多次出错的独立时间线。后续版本可以改成MapString, Integer来统计错题次数或者用数据库表结构存word_id和error_count。二是generateOptions里每次都遍历全词库收集释义O(N) 的出题成本在词库大时不可接受。应该把词库加载完成后就缓存一份释义列表出题时直接从这里取样。这些不是 bug但能感受到能跑和设计合理之间还有距离。这也是做系列项目有意思的地方——每个阶段的复盘都能发现下一阶段要解决的新问题。最后再分享一点个人体会这个背单词软件的第一版从需求梳理到跑通全部功能我大概花了一个周末。最大的收获不是写了几百行代码而是完整走了一遍需求→设计→编码→测试→复盘的循环。它把我之前学的零散知识点真正串了起来面向对象建模、集合框架选型、IO 读取文件、异常处理、代码分层——每个知识点在这个项目里都有了具体的使用场景而不是孤立的概念。如果你也卡在学过但不会用的阶段我建议你也挑一个自己真正会使用的小工具下手先别追求完美第一版烂一点没关系能跑起来就是胜利。背单词软件这个项目后续我还会继续迭代下一篇打算加图形界面把控制台里的这些功能用 Swing 重新呈现出来同时解决数据持久化的问题——这样每次背完单词进度不会丢。有兴趣的话可以下一章见。