☰
Java Scanner用户交互实战:从加法练习到高频坑排查
2026/9/26 7:06:40 网站建设 项目流程

直接从一行代码开始说起吧:

Scanner scanner = new Scanner(System.in);

这大概是 Java 世界里被引用次数最多、也最容易被误解的一行代码。“Scanner”这个词本身就有三重身份:办公一体机里的硬件扫描模块、局域网管理工具 Advanced IP Scanner、以及我们代码里天天用的 java.util.Scanner。当它们同时出现在搜索结果里,新手很容易搞混。

但我想提醒你的是,真正决定一个程序“好不好用”的,从来不是这行代码本身,而是你拿它去构建的那套用户交互闭环。这篇文章我会围绕“用户交互 Scanner”这个主题,讲讲我在实际项目中怎么用它、为什么有人坚持不用它,以及那种“随机生成两个一位数加法练习题”的场景下,带 Scanner 和不带 Scanner 的写法到底差在哪。希望你看完以后,能把 Scanner 用出“交互设计”的味道,而不是停留在背 API 的阶段。

1. 先把“用户交互Scanner”这个词掰开揉碎

1.1 同名同姓的三种 Scanner

先说清楚,免得后面聊跑偏。

第一种是物理硬件,比如设备管理器里常见的 “Generic 18BW-7EN Scanner”。这是打印机一体机驱动暴露出来的扫描仪接口,负责把纸质内容变成图像。它解决的是“光学采集”问题。

第二种是网络工具,典型代表就是 Advanced IP Scanner。运维人员拿它扫一遍局域网,看看哪些设备在线、开放了哪些端口,本质上是“网络探测”工具。

第三种是 Java 标准库里的java.util.Scanner,它拿来做的事情是扫描数据源中的文本。数据源可以是键盘输入流System.in、一个文件、一段字符串或者一个管道流。它把一个连续的字符流切成一段一段有意义的 token,并自动转成 int、double、String 等类型。

今天这篇只聊第三种,因为“用户交互”这个前缀天然指向它。但你也看到了,搜索引擎不会帮你分流,所以很多人在搜 Scanner 教程时,会被硬件驱动和网络工具的页面干扰。我在开头把这三种身份列出来,就是想让后面的内容干净一点。

1.2 完整交互闭环里,Scanner 只负责入口

很多教程只教你“ Scanner 能读什么类型”,却忽略了一个更重要的问题:真实的用户交互是什么样?

拿一个最简单的命令行答题程序举例。用户看到题目后输入答案,程序判断对错、给出反馈、继续下一题。这整个流程里,Scanner 只负责“读取用户输入”这一小步。但这一小步的质量,会直接决定后面几步的实现难度。

我把完整闭环拆成五步:

  1. 向用户展示提示,告诉对方要输入什么格式的数据
  2. 通过 Scanner 读取输入
  3. 验证输入是否合法、是否属于预期类型
  4. 执行业务逻辑(算分、判断、记录)
  5. 给出反馈,循环回到第 1 步

Scanner 提供的方法里,hasNextInt()、hasNextLine()就是为第 3 步服务的,nextInt()、nextLine()则负责第 2 步。为什么有的人写出来的交互代码又脆又容易崩?大多数情况是只用了next系列方法,完全没搭理hasNext系列,导致非法输入直接抛异常。所以我一直觉得,判断一个人会不会写交互程序,看他用 Scanner 的方式就够了。

2. new Scanner(System.in) 到底拆成了什么

2.1 System.in 是一个“只会输出字节”的流

System.in是一个InputStream,代表标准输入,也就是默认的键盘输入。但你要是直接在它上面操作,会非常痛苦:它一次只能读一个字节,你得自己处理换行符、编码、缓冲区,还得把读进来的字节拼成字符串再转成整数。

举个例子,你想读一个整数,用System.in的原始方式大概是:

int input = System.in.read() - '0';

这还只能处理单个数字,多位数、负数、带空格的输入全部得自己写逻辑。这就是为什么我们需要 Scanner:它是包在InputStream外面的一层“翻译器”,把笨重的字节流变成友好的类型解析接口。

2.2 Scanner 的三个内部动作:缓冲、切分、转换

我习惯用三个词来理解 Scanner 的内部工作:缓冲、切分、转换。

先说缓冲。Scanner 内部维护了一个缓冲区,默认会成批读取输入流里的数据,而不是用户敲一个键读一个字节。所以它不至于慢到让人没法用。

再说切分。Scanner 通过正则表达式来定位 token 的边界。默认分隔符是\p{javaWhitespace}+,也就是所有空白字符,包括空格、换行、制表符。这意味着你输入12 34然后连续调用两次nextInt(),第一次读到 12,第二次读到 34,中间不需要任何额外处理。这个设计极大简化了“多个数据在同一行输入”的场景。

最后是转换。当nextInt()找到一个 token 后,会在内部调用类似Integer.parseInt(token)的操作,把它从字符串变成整数。如果转换失败,就会抛出InputMismatchException。nextDouble()、nextBoolean()也是同一个套路。

理解这三点以后,很多经典问题其实不用背答案也能推理出来。比如为什么nextInt()之后再调nextLine()会读到一个空串?因为nextInt()只把数字 token 消费掉了,数字后面的换行符还留在缓冲区里,nextLine()一读,读到的是那个残留的换行符,于是返回空字符串。

2.3 初始化时的三个工程细节

第一,是否要close()。独立小工具里,main方法跑完 JVM 就退出了,你不关 Scanner 问题不大。但如果你在写一个会被多次调用的模块,Scanner 包装的是System.in,最好不要随便关。Scanner.close()会把底层的输入流也关掉,System.in一旦关闭,在当前 JVM 进程里基本等于废了。

第二,不要重复创建。有人喜欢在循环里new Scanner(System.in),这不会立即报错,但容易造成资源浪费,而且一旦关闭其中一个,后面全乱套。全局维护一个实例就够了。

第三,从文件读取时务必指定字符集。new Scanner(Paths.get("data.txt"), StandardCharsets.UTF_8)这种写法才能避免中文乱码。如果你图省事只写文件路径,会走平台默认编码,在 Windows 上经常读出一堆问号。

3. 实战:写一个随机加法练习交互程序(Scanner 版)

3.1 需求拆解:想要的交互效果是什么

有个热词是“java 随机生成两个一位数加法练习题 不用 scanner”。这个需求很有意思,它有两种理解:一种是只要生成练习题,不需要用户输入,纯输出文件或打印;另一种是想要一个交互式答题器,但不想用 Scanner 读输入。

我们先做交互版本,因为这才是标题“用户交互 Scanner”最对口的场景。需求定成这样:

  • 随机出 5 道题,每道题是两个一位数相加
  • 用户在控制台输入答案
  • 程序判断对错并反馈
  • 结束后统计正确率和用时

这个需求很小,但覆盖了交互闭环里最关键的三步:提示、读取验证、反馈。

3.2 完整代码:5题计分版加法练习器

import java.util.Random; import java.util.Scanner; public class AdditionQuiz { public static void main(String[] args) { Scanner sc = new Scanner(System.in); Random random = new Random(); int total = 5; int correct = 0; long start = System.currentTimeMillis(); System.out.println("随机两位数加法练习,一共" + total + "题,每题请输入一个整数。"); for (int i = 1; i <= total; i++) { int a = random.nextInt(9) + 1; // 生成 1~9 int b = random.nextInt(9) + 1; System.out.printf("第%d题:%d + %d = ", i, a, b); if (sc.hasNextInt()) { int answer = sc.nextInt(); if (answer == a + b) { correct++; System.out.println(" 回答正确!"); } else { System.out.println(" 回答错误,正确答案是 " + (a + b)); } } else { System.out.println(" 输入不是整数,本题记为错误。"); sc.next(); } } sc.close(); long cost = System.currentTimeMillis() - start; System.out.printf("答题结束:共答对 %d/%d 题,正确率 %.0f%%,用时 %.1f 秒。%n", correct, total, correct * 100.0 / total, cost / 1000.0); } }

这里有几个关键点。random.nextInt(9) + 1生成的是 1 到 9 的整数,正好对应“一位数”的约束。hasNextInt()是校验闸门,用户输入abc时不会抛异常,而是走 else 分支提示输入无效。最后用sc.next()把非法 token 消费掉,避免死循环。

3.3 运行效果:真实控制台对话什么样

上面这段代码跑起来,控制台会是这样:

随机两位数加法练习,一共5题,每题请输入一个整数。 第1题:5 + 3 = 8 回答正确! 第2题:9 + 1 = 11 回答错误,正确答案是 10 第3题:abc 输入不是整数,本题记为错误。 第4题:2 + 4 = 6 回答正确! 第5题:7 + 7 = 14 回答正确! 答题结束:共答对 3/5 题,正确率 60%,用时 12.6 秒。

看到没,非法输入也被当成一次有效的流程分支处理,这就是hasNextInt()的功劳。如果你去掉这个判断,直接sc.nextInt(),程序会在第 3 题直接炸掉。这个区别,就是“能用”和“好用”的分水岭。

3.4 扩展思路:菜单循环、非法输入重试

真实项目里的交互不会这么简单,通常是一个主循环里嵌套多个分支。我经常用这种菜单模式:

while (true) { System.out.println("请选择功能:1=新一组练习,2=查看成绩,0=退出"); if (sc.hasNextInt()) { int cmd = sc.nextInt(); if (cmd == 0) break; switch (cmd) { case 1 -> runQuiz(sc); case 2 -> showScore(); default -> System.out.println("未知指令"); } } else { System.out.println("请输入数字。"); sc.next(); // 清掉非法输入 } }

这个模式里,hasNextInt()配合sc.next()清理非法输入是核心配合。很多初学者喜欢用try-catch包住nextInt()来处理异常,其实没必要,hasNext系列方法就是为了让你避免异常而设计的,用异常当流程控制既不优雅也影响性能。

4. 不用 Scanner 怎么写?三个替代方案一次说清

4.1 什么场景适合放弃 Scanner

前面聊了这么多 Scanner 的好处,但确实有一些场景,用 Scanner 反而是负担。

第一种是纯批处理。比如你要生成一张包含 50 道加法题的练习卷,输出到一个文本文件。整个过程没有任何人参与输入,Scanner 根本派不上用场,真正的核心是Random生成题目和BufferedWriter写文件。

第二种是性能敏感的程序。Scanner 做类型转换很方便,但它内部的正则切分和自动装箱有开销。如果是从超大文件里逐行读取几十万条数据,直接上BufferedReader会更快,内存也更好控。

第三种是你希望自己掌控每一步解析逻辑。Scanner 的默认分隔符是空白字符,但有些格式要求按逗号、按固定长度切分,这时候你可能会更愿意用split()加手动转换,而不是调整 Scanner 的正则表达式。

4.2 方案一:BufferedReader 手动转换

BufferedReader是 Scanner 最常见的替代者,典型写法:

BufferedReader reader = new BufferedReader( new InputStreamReader(System.in, StandardCharsets.UTF_8) ); String line = reader.readLine(); int answer = Integer.parseInt(line.trim());

这种方式的优点是把“读一行”和“解析内容”拆开,逻辑更直白。但你要自己处理各种NumberFormatException,不像hasNextInt()那么体贴。如果只读一个整数,Scanner 明显更省事;如果是读几千行处理日志,BufferedReader 更合适。

维度ScannerBufferedReader
易用性高,自动类型转换低,需要手动解析
性能中等较高
内存自带缓冲、内部正则状态更轻量
适合场景交互对话、小文件解析大文本逐行读取
非法输入处理有 hasNext 系列方法只能 try-catch

4.3 方案二:System.console() 的取舍

System.console()也是一个交互读取入口,用法:

Console console = System.console(); if (console != null) { String name = console.readLine("请输入姓名:"); char[] password = console.readPassword("请输入密码:"); }

readPassword()能在终端里隐藏输入回显,这是 Scanner 做不到的。但问题是,System.console()在绝大多数 IDE 的 Run 控制台中返回null,因为那些环境里没有被分配真正的控制台。也就是说,你隔离了 IDE 跑就崩溃。所以它更适合成熟的命令行应用,不适合学习和快速原型。

4.4 方案三:纯批处理模式,不读用户输入

回到热词“随机生成两个一位数加法练习题 不用 Scanner”。如果需求只是生成题单,真正的无交互版本可以这样:

import java.io.BufferedWriter; import java.io.FileWriter; import java.nio.charset.StandardCharsets; import java.util.Random; public class AdditionWorksheetGenerator { public static void main(String[] args) throws Exception { Random random = new Random(); try (BufferedWriter writer = new BufferedWriter( new FileWriter("addition_quiz.txt", StandardCharsets.UTF_8))) { for (int i = 0; i < 50; i++) { int a = random.nextInt(9) + 1; int b = random.nextInt(9) + 1; writer.write(String.format("第%02d题:%d + %d = ____", i + 1, a, b)); writer.newLine(); } } } }

没有 Scanner,因为这里压根没有读取用户输入的需要。数据流是单向的:程序产生内容,直接输出到文件。这个例子刚好说明一个常被忽略的事实——Scanner 是输入工具,不是输出工具。搞混输入和输出方向,是初学者最容易犯的逻辑错误。

5. Scanner 高频坑与排查实录

5.1 nextInt() 后面跟 nextLine() 为什么会读到空串

这是我在各种技术群里见过最多的问题。现象是:

int num = sc.nextInt(); String line = sc.nextLine();

结果line永远是空字符串。原理前面说过:nextInt()只消费数字 token,后面的换行符留在缓冲区;nextLine()读到这个换行符,直接结束。

解决办法有三种:

  1. 在nextInt()后面补一个sc.nextLine()把换行符吃掉
  2. 全部用nextLine()读,再自己Integer.parseInt()转换
  3. 如果需要混合读取,就统一用第 2 种方案,避免心智负担

我个人的经验是:交互程序里尽量全部用nextLine(),然后手动转换。虽然代码多写两行,但至少不会出现“读着读着突然吃掉一行”这种诡异问题。

5.2 close() 之后 System.in 也“凉了”

很多人习惯在函数末尾写scanner.close(),在简单 demo 里没问题,但在真实系统里可能埋雷。

Scanner.close()如果传入的是System.in,会把标准输入流也关闭。同一个 JVM 里一旦关闭System.in,后续任何想从控制台读取的代码都会拿到“流已关闭”的异常,而且没法恢复。所以我的建议是:

  • 在 main 方法这种“一次性脚本”里,想关就关
  • 在一个可被复用的组件里,尽量别关
  • 如果实在担心资源,可以看实际场景用try-with-resources,但你要清楚它同样会关闭底层流

5.3 hasNext() 不退出,卡死循环

有同学写了个循环想“读完全部输入”,写成这样:

while (sc.hasNext()) { String word = sc.next(); System.out.println(word); }

从文件读取没问题,因为文件有结尾;但从System.in读,hasNext()会一直等待新输入,永远不会返回 false。想在交互模式下结束?你得输入 Ctrl+D(Unix/Linux/macOS)或 Ctrl+Z(Windows 老的控制台)来发送 EOF。

正确做法是在交互循环里给个退出条件:

while (sc.hasNextLine()) { String line = sc.nextLine(); if ("quit".equalsIgnoreCase(line)) { break; } System.out.println("收到:" + line); }

永远不要在交互程序里依赖 EOF 来结束循环,用户根本不知道要按 Ctrl+D。

5.4 中文乱码:别让 Scanner 背锅

Scanner 在读取控制台输入时,编码跟随系统默认字符集。你在 Windows 上跑中文程序,如果系统区域设置是 GBK,而你的代码文件是 UTF-8,很容易出现中文乱码。

一个比较稳妥的写法是:

Scanner sc = new Scanner(System.in, StandardCharsets.UTF_8);

但要注意,控制台本身的编码也必须匹配。从 JDK 18 开始,UTF-8 已经成为默认字符集,乱码问题在较新版本里会少很多。如果你还在维护老版本 JDK,记得检查file.encoding相关配置。

5.5 避免用 try-catch 当流程控制

最后一个坑,也是我特别想强调的:不要把InputMismatchException当作正常的流程分支来处理。

那种写法长这样:

try { int num = sc.nextInt(); } catch (InputMismatchException e) { System.out.println("输入非法"); }

看起来也没问题,但异常处理的代价远高于普通的条件判断,而且一旦出现异常,Scanner 内部的状态也需要小心处理,你可能还得手动清掉非法 token。正确思路是:用hasNextInt()、hasNextLine()做前置校验,把非法输入挡在门外;异常处理只留给那些真正预料之外的错误。

我写交互程序的经验就一句话:Scanner 不是洪水猛兽,只要你把它当成“带缓冲、带正则、带类型转换的输入翻译器”来用,而不是当成一个黑盒 API 来背,绝大多数问题都能用底层原理推出来。从最简单的加法练习器,到复杂的命令行菜单系统,核心永远是用hasNext做校验、用next做读取、用循环做交互,这三件事想清楚,你的用户交互代码就能比大多数人都稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询