直接从一行代码开始说起吧:
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 只负责“读取用户输入”这一小步。但这一小步的质量,会直接决定后面几步的实现难度。
我把完整闭环拆成五步:
- 向用户展示提示,告诉对方要输入什么格式的数据
- 通过 Scanner 读取输入
- 验证输入是否合法、是否属于预期类型
- 执行业务逻辑(算分、判断、记录)
- 给出反馈,循环回到第 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 更合适。
| 维度 | Scanner | BufferedReader |
|---|---|---|
| 易用性 | 高,自动类型转换 | 低,需要手动解析 |
| 性能 | 中等 | 较高 |
| 内存 | 自带缓冲、内部正则状态 | 更轻量 |
| 适合场景 | 交互对话、小文件解析 | 大文本逐行读取 |
| 非法输入处理 | 有 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()读到这个换行符,直接结束。
解决办法有三种:
- 在
nextInt()后面补一个sc.nextLine()把换行符吃掉 - 全部用
nextLine()读,再自己Integer.parseInt()转换 - 如果需要混合读取,就统一用第 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做读取、用循环做交互,这三件事想清楚,你的用户交互代码就能比大多数人都稳。