先问一个扎心的问题你在网上看了那么多炫酷的计算器截图有没有想过自己动手写一个我看过很多人的Java学习路径到了Swing环节基本就是“课后作业做完就忘”然后转头去卷Spring Boot。说实话我也不例外直到有一次需要用一个小工具处理一堆混合表达式才意识到Swing计算器这个项目被严重低估了。它不像CRUD那样机械重复也不会难到你怀疑人生刚好卡在能锻炼布局、事件、算法和异常处理的位置。这篇文章不是教科书式教程是我自己从零搭建一个Java Swing计算器的完整复盘。无论你是刚学Java的萌新还是想给自己简历加一个拿得出手的小作品都能从里面找到能直接用的东西。1. 为什么是Swing先把技术选型这件事想明白1.1 计算器的“小”是Swing最合适的试炼场很多人一听Swing就皱眉头觉得这东西太老、太丑连官方都转向JavaFX了。但如果你只是想做一个像模像样的桌面计算器Swing反而比JavaFX更合适。原因很简单Swing是JDK自带的不用额外装运行时打出来的JAR包几MB就能跑JavaFX从JDK 11开始模块化要先配置module-path对新手来说光是把项目跑起来就可能劝退。再往深一层看计算器这个项目本质上是一个“小而完整”的桌面应用。它不涉及复杂的硬件交互不需要3D渲染也不依赖多媒体框架Swing提供的JFrame、JPanel、JButton、布局管理器这些基础组件完全够用。我在实际写的过程中甚至觉得Swing的“丑”是被夸张了——只要你愿意花一点时间调颜色、字体、边框做出来的效果完全不输给JavaFX默认皮肤。1.2 这个项目能练到的“硬通货”别看计算器小它在我心里是Java基础的最佳综合训练场。你可以从里面同时拿到好几项面试和工作中都硬通货的技能布局管理GridLayout、BorderLayout、GridBagLayout到底怎么配合比看一百遍示例都记得牢。事件处理按钮监听、ActionCommand分发、键盘绑定这些都是桌面应用交互的地基。算法能力把“23*4”这样的字符串解析成正确结果要用到栈和调度场算法这就是面试里常说的“手写计算器”题。防御式编程除零、空表达式、多余括号、非法字符每一样都在逼你写出更健壮的代码。发布打包最后导出一个双击就能跑的JAR甚至用jlink裁剪一个最小JRE成就感比写十个CRUD接口都强。2. 开工前先列需求计算器不是“数字按钮”的拼凑2.1 把功能分成三个阶段我见过很多人上来就照着Windows计算器抄什么按钮都堆上去结果代码写到一半就崩了。正确的做法是先按自己的水平划清边界。我把需求分成三档你可以根据自己的时间取舍优先级功能清单说明必须做四则运算、小数、括号、退格、清空、错误提示这是核心做不出来就不算计算器进阶做键盘输入、连续按等号、历史记录、正负号切换、主题切换让项目有“作品感”也是加分项暂不做三角函数、平方根、阶乘、单位换算、进制转换这些属于科学计算器等核心稳定后再考虑为什么要这样切因为计算器最大的坑就是“看起来按钮多其实逻辑乱”。如果你一上来就想着支持sin、cos、log那你的表达式解析器复杂度会直接翻倍。我强烈建议第一版只做“普通计算器”把加减乘除、括号、小数点、连续表达式算明白就已经能吊打很多应付作业的项目了。2.2 界面区域划分与尺寸界面设计其实有个经典套路上面是显示区下面是按键区。显示区我用JTextField实现设置成不可编辑方便用户直接复制结果按键区用一个JPanel配合GridLayout5行4列间距统一按钮自然等大。我实际用的按键矩阵是这样的String[][] keys { {C, ±, %, ÷}, {7, 8, 9, ×}, {4, 5, 6, −}, {1, 2, 3, }, {0, ., ⌫, } };注意这里的“×”“−”“÷”是显示字符真正计算时我会把它们映射成“*”“-”“/”。为什么不用普通键盘上的符号因为显示在按钮上更清晰美观也方便以后换皮肤。窗口尺寸我建议初始400x580左右或者320x480的小尺寸把resizable设为false能省掉很多布局适配的麻烦。2.3 按钮尺寸与字体适配字体是界面质感的决定因素。显示区我用等宽字体“Monospaced”这样数字右对齐时视觉上很稳按钮字体我用“Microsoft YaHei”或者“PingFang SC”粗细用BOLD字号大概20。要注意的是按钮除了设字体还得设一个合适的Margin否则文字会贴在边框上button.setMargin(new Insets(10, 10, 10, 10)); button.setFocusPainted(false);setFocusPainted(false)这行很多人会漏掉。如果不设按钮被点击后会有一个难看的焦点框非常影响整体观感。3. 用代码拼出界面布局、样式和事件分发3.1 主框架搭建我习惯先搭一个干净的JFrame把所有初始化动作封到initUI()里这样结构清楚后面加功能也好维护public class CalculatorFrame extends JFrame { private JTextField display; public CalculatorFrame() { setTitle(我的Swing计算器); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); initUI(); pack(); setLocationRelativeTo(null); } }整体布局用BorderLayout显示区放NORTH按键面板放CENTER。这样窗口宽度变了之后按键区能自动横向拉伸显示区也跟着变长视觉效果不会太奇怪。3.2 GridLayout还是GridBagLayout我的选择很多人一听到“GridBagLayout”就头大但计算器这个场景其实不需要那么复杂。我对比过三种方案布局管理器优点缺点适合场景BorderLayout简单直接适合区域划分只能放五块无法细控按键做主框架GridLayout等宽等高代码最少无法合并单元格间距需要自己加数字键盘GridBagLayout自由度最高能跨行跨列参数多调试麻烦复杂表单或特殊布局结论是主框架用BorderLayout按键面板用GridLayout间距通过setHgap、setVgap或EmptyBorder控制。计算器按键都是等大的用GridLayout是最省心的选择没必要上来就挑战GridBagLayout。JPanel buttonPanel new JPanel(new GridLayout(5, 4, 8, 8)); buttonPanel.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));这行代码里的8和8分别代表水平和垂直间距12是对外边框留白写出来之后按钮之间和窗口边缘都有呼吸感不会挤成一团。3.3 统一事件监听别给每个按钮单独写ActionListener新手最容易犯的错误是给每个按钮new一个ActionListener写出一堆重复代码。我推荐一个优雅的写法用ActionCommand做分发。每个按钮的actionCommand带前缀比如“num:7”表示数字7“op:”表示加号“cmd:clear”表示清空然后在同一个监听器里解析处理。ActionListener listener new ActionListener() { Override public void actionPerformed(ActionEvent e) { String cmd e.getActionCommand(); if (cmd.startsWith(num:)) { handleNumber(cmd.substring(4)); } else if (cmd.startsWith(op:)) { handleOperator(cmd.substring(3)); } else { handleCommand(cmd.substring(4)); } } };这样做的好处非常明显以后想加键盘快捷键、换按钮文字、或者做主题切换你只需要改一个地方。界面逻辑和业务逻辑真正分离代码的可读性和可维护性都上了一个台阶。3.4 键盘输入用Key Bindings而不是KeyListener如果想让计算器支持键盘输入我踩过一个坑直接用KeyListener挂在JFrame上数字键倒是能按出来但窗口焦点一跑到某个按钮上监听就失效了。后来我换成Swing的Key Bindings机制把按键和Action绑定到面板上稳定得多InputMap im buttonPanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am buttonPanel.getActionMap(); im.put(KeyStroke.getKeyStroke(7), num7); am.put(num7, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { handleNumber(7); } });WHEN_IN_FOCUSED_WINDOW表示只要当前窗口有焦点按对应按键就会触发不用关心具体哪个组件获得了焦点。这是我强烈推荐的做法也是解决“回车没反应”这类问题的关键。4. 核心逻辑把“字符串”变成“结果”4.1 两种计算模式渐进式计算和整串表达式解析计算器最核心的部分不是界面而是怎么处理用户的输入。有两种思路一种像古董计算器按一下数字键就算一步显示的是“实时结果”另一种是先让用户把整个表达式输进去按等号时一次性求值。我采用的是第二种因为只有这样才能正确处理“23*414”而不是“20”。想把整串表达式算出来最经典的算法是“中缀表达式转后缀表达式”也就是调度场算法。它把人类的数学写法转成计算机喜欢的形式然后再用栈求值。4.2 令牌化与负数处理第一步是把字符串拆成令牌比如“12.53”拆成[12.5, , 3]。这部分要特别小心小数点和一元负号。我的做法是扫描字符数字和小数点累加到一个StringBuilder里碰到运算符就先把之前的数字令牌输出再输出当前运算符。一元负号的处理我偷了个懒如果“-”出现在表达式开头或者紧跟在左括号和运算符后面就在它前面补一个0把它变成“0-”。这样“-53”就变成“0-53”完全符合正常的二元减法规则后续算法不用特殊处理。代码大致是这样private ListString tokenize(String s) { ListString tokens new ArrayList(); StringBuilder num new StringBuilder(); for (int i 0; i s.length(); i) { char c s.charAt(i); if (Character.isDigit(c) || c .) { if (c . num.indexOf(.) 0) continue; // 忽略重复小数点 num.append(c); } else { if (num.length() 0) { tokens.add(num.toString()); num.setLength(0); } if (c - (i 0 || tokens.isEmpty() || isOperator(tokens.get(tokens.size() - 1)) || tokens.get(tokens.size() - 1).equals(())) { tokens.add(0); } tokens.add(String.valueOf(c)); } } if (num.length() 0) { tokens.add(num.toString()); } return tokens; }这段代码你第一次可能觉得绕但跑一遍“-35”就明白了它前面插入了一个“0”变成“0-35”之后就是标准三令牌序列求值不会再出负号优先级的问题。4.3 调度场算法与后缀表达式求值中缀转后缀的核心是维护一个运算符栈。数字直接输出左括号压栈右括号弹出直到左括号普通运算符则把栈中优先级不低于当前运算符的全部弹出再把自己压栈。private ListString toPostfix(ListString tokens) { MapString, Integer precedence Map.of(, 1, -, 1, *, 2, /, 2); DequeString ops new ArrayDeque(); ListString output new ArrayList(); for (String token : tokens) { if (isNumber(token)) { output.add(token); } else if (token.equals(()) { ops.push(token); } else if (token.equals())) { while (!ops.isEmpty() !ops.peek().equals(()) { output.add(ops.pop()); } ops.pop(); } else { while (!ops.isEmpty() precedence.getOrDefault(ops.peek(), -1) precedence.get(token)) { output.add(ops.pop()); } ops.push(token); } } while (!ops.isEmpty()) { output.add(ops.pop()); } return output; }得到后缀表达式后再用栈求值遇到数字就压栈遇到运算符就弹出两个数计算再把结果压回去。这部分的代码逻辑很清晰private double evalPostfix(ListString postfix) { DequeDouble stack new ArrayDeque(); for (String token : postfix) { if (isNumber(token)) { stack.push(Double.parseDouble(token)); } else { double b stack.pop(); double a stack.pop(); switch (token) { case - stack.push(a b); case - - stack.push(a - b); case * - stack.push(a * b); case / - { if (b 0) throw new ArithmeticException(除零); stack.push(a / b); } } } } return stack.pop(); }需要提醒的是运算符出栈时顺序千万别搞错。后缀表达式里“5 3 -”先弹出的是3作为右操作数b再弹出5作为左操作数a计算a-b才是正确结果。我一开始写成b-a结果算出一堆负数排错花了半小时。4.4 连续按等号状态记忆的实用策略很多人做完基本功能后发现一个问题输入“23”按等号显示5再按一次等号还是5不更新。这是因为你每次都重新解析原始表达式。要实现“235再按等号538”的效果我采用了一个实用但不算完美的策略等号按下时从刚算完的表达式里提取最后的运算符和右操作数保存成lastOperator和lastRightOperand。下一次再按等号如果当前显示已经是计算结果就把“结果lastOperatorlastRightOperand”作为新表达式重新求值。private void handleEquals() { String current display.getText(); if (!current.isEmpty() current.equals(format(lastResult)) lastOperator ! null) { current format(lastResult) lastOperator lastRightOperand; display.setText(current); } double result evaluate(current); lastResult result; lastOperator findLastOperator(current); lastRightOperand findLastRightOperand(current); display.setText(format(result)); }这种写法只对“单运算符表达式”的重复计算有效比如“34”“82”。如果你输入“234”再按等号重复等号的逻辑不会完美复现整个运算过程。我觉得对计算器来说已经足够自然毕竟普通用户不会天天按压根号级表达式然后连按等号。5. 调试排错实录这几个坑我替你们踩过了5.1 字体和边框导致按钮内容被截断第一次跑起来的时候我所有的数字按钮文字都挤在一个角落看起来非常局促。原因是按钮默认的Margin太小而字体又设得比较大。后来我统一给按钮设置Margin并且用FontMetrics动态测一下文本宽度来辅助调整按钮尺寸btn.setMargin(new Insets(10, 10, 10, 10)); FontMetrics fm btn.getFontMetrics(btn.getFont()); int textWidth fm.stringWidth(btn.getText()); if (btn.getPreferredSize().width textWidth 24) { btn.setPreferredSize(new Dimension(textWidth 24, 60)); }这段代码的意义是让按钮宽度永远比文字宽度多出24像素保证任何字体下都不会出现文字被切掉的情况。5.2 回车键没反应焦点问题我用JTextField当显示区后发现用户输入数字后按回车计算器没任何反应。排查后发现焦点在JTextField上回车事件被文本框吸收了。解决办法有两个一是让显示区不可聚焦setFocusable(false)二是用前面提到过的Key Bindings把这些按键绑定到整个面板上。我推荐后者因为即使窗口内焦点发生了跳转按键映射依然有效。5.3 EDT线程阻塞导致界面假死Swing所有界面操作都在事件分发线程EDT上执行。如果你在ActionListener里写了个Thread.sleep或者做了什么耗时的文件操作整个界面会卡住。计算器本来计算极快但我后来加历史记录和主题切换时不小心在保存文件的地方卡了半秒体验立刻变差。正确处理是把耗时任务放到SwingWorker里SwingWorkerDouble, Void worker new SwingWorker() { Override protected Double doInBackground() throws Exception { return evaluate(expression); } Override protected void done() { display.setText(format(get())); } }; worker.execute();这样一个简单的改动就能保证再复杂的表达式也不会把UI卡死。这是个很好的习惯哪怕计算器用不上以后写Swing工具时也都会用到。5.4 表达式脏数据多点、连续运算符、空括号真实用户不会乖乖输入规范表达式。我最常遇到的异常是“1..23”和“12”。多点的问题我通过tokenize时的重复小数点判断解决了连续运算符我则用了一个简单规则如果输入运算符时显示区最后一个字符已经是运算符那就替换成当前运算符而不是追加。比如“12”会变成“12”。空括号的情况我用try-catch统一兜底所有求值异常都在界面显示“错误”。try { double result evaluate(display.getText()); display.setText(format(result)); } catch (Exception ex) { display.setText(错误); }这段代码看似简单但正是它保证了你的计算器不会因为用户乱按而直接崩掉。6. 从“能用”到“炫酷”外观、记忆和主题扩展6.1 自定义圆角按钮如果只是默认的灰色按钮确实谈不上炫酷。我通过继承JButton做一个简单的圆角按钮再配合暗色背景整个界面立刻有了科技感。核心技术是覆盖paintComponent用Graphics2D的fillRoundRect画圆角矩形并关掉按钮默认的背景填充class RoundButton extends JButton { public RoundButton(String text) { super(text); setFocusPainted(false); setContentAreaFilled(false); setOpaque(false); } Override protected void paintComponent(Graphics g) { Graphics2D g2 (Graphics2D) g.create(); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.setColor(getBackground()); g2.fillRoundRect(0, 0, getWidth(), getHeight(), 20, 20); g2.dispose(); super.paintComponent(g); } }注意两点一是setContentAreaFilled(false)否则按钮会先填一整块默认背景二是在画完圆角背景后还是要调用super.paintComponent这样才能正常绘制按钮文字。6.2 显示区样式与主题切换显示区我用深色背景配合亮色数字高光效果立刻出来了display.setEditable(false); display.setHorizontalAlignment(JTextField.RIGHT); display.setFont(new Font(Monospaced, Font.BOLD, 28)); display.setBackground(new Color(30, 30, 40)); display.setForeground(new Color(240, 240, 245)); display.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));主题切换我做一个JToggleButton点击时遍历所有按钮更新背景色和前景色再更新显示区颜色最后调用repaint()刷新。这里的核心是“全局状态驱动UI刷新”不建议为每个按钮单独写切换逻辑那样加一个新按钮就会漏一个地方。6.3 加历史记录和记忆功能做到“炫酷”之后我开始加实用功能。历史记录用JList或JTextArea放在右侧每次等号后把表达式和结果写入列表点击历史条目可以把历史表达式回填到显示区。记忆功能则用四个按钮M、M-、MR、MC本质上就是一个double变量的事但会让计算器看起来专业很多。private double memory; void handleMemory(String cmd) { switch (cmd) { case M - memory lastResult; case M- - memory - lastResult; case MR - display.setText(format(memory)); case MC - memory 0; } }这部分代码简单到不用解释但它告诉我一个道理很多看起来专业的功能底层逻辑并不复杂难的是把它们和前端的交互流畅地串起来。6.4 打包发布让别人也能双击运行最后一步我用Maven的shade插件把项目打成可执行JAR这样别人双击就能跑。如果连Java环境都不想装还可以用jlink生成一个只包含所需模块的定制运行时配合 jpackage 打出exe安装包。这个流程我建议每个写Swing项目的人都走一遍因为“能跑”和“能交付”是两码事。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.4.1/version executions execution phasepackage/phase goalsgoalshade/goal/goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.example.CalculatorApp/mainClass /transformer /transformers /configuration /execution /executions /plugin配置完成后执行mvn packagetarget目录下就会出现一个带Main-Class的可执行JAR分享给朋友再也不用“你先装个JDK”了。7. 最后分享一点我的亲身体会做完这个项目后我最大的感受是Swing不难难的是把细节当回事。很多人把计算器当成“面试作业”一提交就扔。其实它会逼你处理输入脏数据、状态记忆、布局适配这些真实世界的问题这些恰恰是书本上不会讲的。我自己后来面试时被问到一个Java基础题——怎么把字符串“35*2”算出来我直接用这个项目里写的解析思路回答现场就能写。这就是练手项目的价值它不是用来证明你会写代码而是让你在遇到问题时真的知道从哪儿下手。另外有个小建议做完一个版本后主动改一遍UI布局或增加一个单位换算功能再回头看原来的代码你会发现设计模式的必要性。比如按钮生成用工厂方法事件处理用策略模式扩展起来会舒服很多。这些感悟不亲自动手是得不到的。如果你也打算写一个别急着找现成源码先给自己定一个需求清单哪怕只是“能算加减乘除、支持括号、按键不串”然后一点点把它做出来。祝你别再羡慕别人家的计算器自己动手做一个才最香。