Java Swing 画图工具是我这几年带新手做项目时最喜欢推荐的一个练手项目。别小看这个“简易画图工具”,它几乎把 GUI 开发的整个闭环都串起来了:窗口构建、事件分发、自定义绘制、状态管理、文件输出。这篇文章会从零开始,带你完整走一遍开发全流程,内容包括环境搭建、画布实现、鼠标交互、工具栏、撤销重做、保存图片,最后再聊聊我好几次才绕过去的坑。适合有一定 Java 基础、想通过实战项目提升的读者,哪怕你只是刚学完集合和事件,也可以跟着一步步敲出来。整个项目不依赖任何第三方库,JDK 自带 Swing 就能跑,做完你会有一种“原来桌面程序是这样攒出来的”的实感。
1. 项目拆解:简易画图工具到底在画什么
1.1 核心需求与功能边界
很多同学一上来就想做个“画图软件”,结果被图层、PS 效果、导出多种格式这些词吓住,最后啥也没做成。我一开始也是这样,后来我把需求砍到最小,发现反而能做出一个完整、能用的东西。所谓简易画图工具,最基本的能力就是:用鼠标在画布上绘制几种基础图形,比如直线、矩形、椭圆,还有自由手绘的曲线;能选择颜色;能调画笔粗细;能撤销、重做;能清空画布;最后能把画布内容保存成图片文件。
这个边界很关键。我们不做图层,不做滤镜,不做图形的“选中后拖动修改”,也不做复杂文字编辑。因为每一项目标都会引入大量额外代码,会让你迷失在细节里。先把闭环跑通,后续再扩展。哪怕只是这样,里面已经包含了一个桌面绘图系统最核心的骨架:画布组件、绘图状态、鼠标交互、图形数据管理和重绘机制。
1.2 技术选型:为什么是 Swing 而不是 JavaFX
有时候学员问我,是不是应该直接用 JavaFX,Swing 太老了?我理解这种焦虑,但你的目标如果是理解 GUI 底层机制,Swing 反而是最合适的材料。JavaFX 本身很优秀,节点系统、CSS 样式、动画管线都更现代,但它在学习阶段会引入“舞台、场景、属性绑定”这些额外概念。Swing 则简单直接:一个 JFrame 放组件,JPanel 上重写 paintComponent 画东西,鼠标监听器处理事件,没有任何框架层的魔法。
另外,Swing 是 Java 标准库的一部分,不需要额外配置模块,在 JDK 8 到 21 上都能正常运行,对于国内很多还在用 JDK 8 的办公环境来说非常友好。你用 Swing 做出来的程序,双击 jar 包就能跑,丢给同样装了 JRE 的机器也没问题。更关键的是,Swing 的绘图模型是基于轻量组件的 Graphics2D,你能直接接触到像素、抗锯齿、重绘机制这些底层内容。把 Swing 啃透,后面看 JavaFX、Android 自定义 View,甚至前端 Canvas,都会觉得很多概念是相通的。
1.3 整体架构规划:一个最小化的 MVC 骨架
画图工具虽小,但我强烈建议一开始就按“数据、视图、控制”三块拆分,不要把所有代码堆在 JPanel 里。数据部分负责保存“已经绘制完成的图形对象”,每个图形对象知道自己怎么被画出来;视图部分就是画布 JPanel,负责把所有的图形对象渲染出来;控制部分则是鼠标监听器和工具栏按钮,负责把用户的操作转成“新增图形”“撤销”“清空”等动作。
这个结构不复杂,但好处是后续加撤销重做、加保存图片都变得非常轻松。如果你不加,等到把代码写完后想加撤销,就会发现旧图形和当前临时图形混在一起,根本无法区分。我在项目里用的数据模型其实就是一个 List,里面存的是一个个 DrawItem 对象。DrawItem 可以是一个接口,定义 draw(Graphics2D g) 方法。直线、矩形、椭圆、自由曲线都实现这个接口,画布在重绘时只要遍历 List,逐个调用 draw 方法即可。这种多态用法,正好能把 Java 面向对象的特性串起来。
2. 环境准备与项目搭建
2.1 JDK 版本选择与开发工具
虽然这个项目用什么版本的 JDK 都能跑,但我建议你选用 LTS 版本,比如 JDK 8、11、17 或者 21。如果你电脑上装了多个版本,在 IDE 里针对项目设置好 Java SDK 就行。我当时用的是 JDK 8,因为公司老项目就是那个环境,后来在 JDK 17 上跑了一遍也没任何问题。Swing API 这些年几乎没有变化,不需要担心版本导致的坑。
开发工具方面,IntelliJ IDEA 社区版、Eclipse、VS Code 都可以。我平时用 IDEA,创建工程时选“Java”项目,不勾选框架依赖。这样编译时不会引入一堆无关库,也方便你用命令行 javac/java 直接编译运行。如果你喜欢折腾,用记事本加终端也可以,只是每次编译javac -encoding UTF-8 src/com/example/paint/Main.java这种命令稍微麻烦一点。不管用哪个,务必保证源码文件编码是 UTF-8,否则中文字符串会变成乱码。
2.2 用 Maven/Gradle 还是直接 javac
这个项目只有一个类,十几个类文件,没有第三方依赖,直接 javac 完全没问题。但如果你已经在用 Maven 管理其他项目,也可以用 Maven 建一个空项目,什么都不加,只放 Java 源码。两种方式我都试过。直接 javac 的好处是构建过程透明,你能看到每个 class 从哪里来;坏处是如果有人添加了图片资源、配置文件,命令行打包会麻烦一些。我给你的建议是:学习阶段用 IDE 的运行按钮即可,理解原理后用 Maven 打一个可执行 jar,这样分发给别人也好用。
如果你用 Maven,需要在 pom 里配置maven-jar-plugin,指定主类。我只列出核心配置:
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <archive> <manifest> <mainClass>com.example.paint.Main</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build>2.3 画出第一个 Swing 窗口:从 JFrame 开始
任何桌面程序都先有一个顶层面板,在 Swing 里就是 JFrame。先别管画布和工具栏,写一个能显示空窗口的启动类,跑通了再说。这是整个项目的入口,也是最有成就感的一步。我习惯这样组织 Main.java:
package com.example.paint; import javax.swing.SwingUtilities; public class Main { public static void main(String[] args) { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { MainFrame frame = new MainFrame(); frame.setVisible(true); } }); } }为什么一定要用SwingUtilities.invokeLater?因为 Swing 的组件只能在事件分发线程(Event Dispatch Thread,简称 EDT)里创建和操作。如果直接在 main 线程里创建 JFrame,程序小的时候看不出问题,但一旦涉及多线程,界面刷新、鼠标事件都会出现奇怪的问题。所以从第一行代码开始就养成这个习惯。
然后创建 MainFrame 类,继承 JFrame。构造函数里先设置标题、大小、默认关闭行为,再把内容区布局设置为 BorderLayout,方便接下来把工具栏放北边、画布放中间。窗口居中可以用setLocationRelativeTo(null)。这一步做完,你已经有了一个可以移动、缩放、关闭的空白窗体。
3. 核心绘图实现:画布、画笔与鼠标交互
3.1 自定义 JPanel 画布与双缓冲
画布本质是一个JPanel,但我们要继承它,重写paintComponent方法。为什么不是paint?因为paint会负责边框、子组件等更复杂的工作,而paintComponent就是专门绘制面板自身内容的,更轻量、更安全。Swing 默认开启了双缓冲,我们不需要手动创建后台缓冲区,但底层原理值得知道:系统先把所有绘制步骤画到一个内存中的图片,再一次性贴到屏幕上,避免闪烁。
自定义画布类大致长这样:
package com.example.paint; import javax.swing.JPanel; import java.awt.*; import java.util.ArrayList; import java.util.List; public class DrawPanel extends JPanel { private final List<DrawItem> items = new ArrayList<>(); private DrawItem currentItem = null; public DrawPanel() { setBackground(Color.WHITE); setFocusable(true); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); for (DrawItem item : items) { item.draw(g2); } if (currentItem != null) { currentItem.draw(g2); } } public void addItem(DrawItem item) { items.add(item); repaint(); } public void clearAll() { items.clear(); currentItem = null; repaint(); } // 其他方法:设置工具、颜色、粗细,获得items等 }抗锯齿一定要开,否则画出来的斜线会有明显的锯齿。这里把当前正在拖动的图形 currentItem 单独画,是因为在鼠标释放前,它还没有“正式完成”,不应当进入撤销列表。很多新手把所有点都塞进 items,导致释放前每次 repaint 重绘所有点,性能差还很难实现撤销。
3.2 鼠标事件监听:按下、拖动、释放的配合
光有画布,鼠标不响应就是一块白板。我们需要让画布能监听鼠标事件。JPanel 默认可以接收鼠标事件,我们只需要注册两个监听器:MouseListener和MouseMotionListener。也可以用MouseAdapter,它是两个接口的空实现,省去重写一堆用不到的方法。
鼠标按下的动作:记录起点坐标,根据当前选择的工具类型创建对应的临时图形对象。比如选的是直线,就创建一条只有起点、没有终点的 LineItem;选的是铅笔,就创建一个空的 FreeLineItem,再把起点加入它的点集合。拖动过程:更新临时对象的终点坐标,或者继续向自由曲线的点列表里加入新点,然后调用repaint()。释放动作:把临时对象“提交”到 items 列表,并把 currentItem 置空,再次 repaint。
一个很容易踩的坑是:鼠标事件里的坐标默认是相对于“组件左上角”的,也就是当前画布组件。因为画布放在窗口中间,四周可能有留白,千万不要用e.getLocationOnScreen()去算,否则在高分屏或者画布偏移时会差十万八千里。直接用e.getPoint()就得到相对于 DrawPanel 的坐标。
3.3 用 Graphics2D 绘制直线、矩形、椭圆与自由曲线
为了统一处理不同类型,我定义了一个接口DrawItem,这是整个绘图数据的核心抽象。
package com.example.paint; import java.awt.Graphics2D; public interface DrawItem { void draw(Graphics2D g2); }然后定义几个实现类。比如 LineItem 保存起点和终点,绘制时用g2.drawLine;RectItem 保存起点和当前点,绘制时用g2.drawRect;OvalItem 绘制椭圆用g2.drawOval。这里有个数学细节:矩形和椭圆需要根据起终点计算左上角坐标、宽度、高度。因为用户可能从右下往左上拖,所以不能直接用起点的 x 减去终点的 x,否则宽度为负数时图形会画不出来。合适的方法是:
int x = Math.min(startX, currentX); int y = Math.min(startY, currentY); int width = Math.abs(currentX - startX); int height = Math.abs(currentY - startY);自由曲线稍微复杂一点。最简单的实现是保存一个List<Point>,每次鼠标拖动时把最新点加进去,绘制时依次连接成折线。因为鼠标移动时事件频率很高,存下来的点足够密,看起来就是一条曲线。如果点太少,画出的线会是一折一折的,第四章我会讲怎么用贝塞尔曲线让它变平滑。
颜色和粗细怎么设置?不要在 DrawItem 内部直接依赖全局某个变量的颜色,那样会让每个图形的颜色被后面改的颜色影响到,撤销时颜色就乱了。正确做法是:每个 DrawItem 在创建时,把自己当时使用的颜色和线条粗细存下来,draw 方法里用自己存的值画。这个细节非常重要。否则你画一条红线,又改成蓝色画一条线,撤销掉蓝色那条之后,红线绘制时可能又变成蓝色,因为你读的是全局颜色。
3.4 细节:坐标修正与防误触
在实现过程中,我发现几个影响手感的小问题。第一个是鼠标拖动时坐标偶尔会跑到画布外面,比如用户拖得太快,鼠标到了边框外面,这时候点坐标可能落在负值或者大于画布宽度的区域,画出的图形会有一截消失在画布边缘之外。解决方法是限制坐标在画布范围内:x = Math.max(0, Math.min(getWidth() - 1, x))。
第二个问题是用户可能只点击了一下就松开,没有拖动。这种情况对于矩形和椭圆来说,会生成一个宽高为 0 的不可见图形,白白占一条撤销记录。我一般会在鼠标释放时检查一下宽高,如果都小于 3 个像素,就认为这是误触,不把它加入 items。自由曲线则要判断点数量是否大于 1。
第三个小细节:鼠标事件特别密集,每次repaint()都会触发整个画布重绘,如果 items 已经很多,性能会下降。对于简易画图工具,只要图形数量在几千以内,现代机器完全扛得住。万一你需要批量绘制,可以想一些局部重绘技巧,但那些留到 5.1 节再说。
4. 工具栏与功能扩展
4.1 工具按钮的布局与事件分发
图形画出来了,但用户还无法切换工具。这一步就是给窗体的北部放一个工具栏。最简单的方式是用JPanel,里面放几个JToggleButton。JToggleButton 有选中和未选中两种状态,很适合表示当前选中的工具。我用一个 ButtonGroup 把这些按钮归组,这样点直线时,矩形按钮会自动取消选中。
工具栏上的每个按钮都要有一个唯一标识,方便在同一个事件处理器里判断用户点了什么。我会给按钮设置 actionCommand,比如 "LINE"、"RECT"、"OVAL"、"PEN",然后统一加一个 ActionListener。监听器里根据 actionCommand 调用画布的setToolType方法。这样工具栏和画布的耦合度很低,以后加一个“圆角矩形”按钮,只要增加对应枚举值和分支就行。
布局方面,我在 JFrame 的 NORTH 放这个工具栏面板,CENTER 放 DrawPanel。工具栏内部我用 FlowLayout,左对齐排列按钮。如果按钮图标不好找,直接用中文文本“直线”“矩形”“椭圆”“铅笔”,简单直接,还能避免图片资源路径问题。
4.2 颜色选择器与画笔粗细调整
颜色选择用 Swing 自带的JColorChooser.showDialog,这个对话框外观虽然朴素,但功能完备,支持 RGB/HSB 等模式,对工具来说足够了。我加了一个“颜色”按钮,点击后弹出颜色选择器,用户选完颜色后,把返回的 Color 对象保存到当前画笔颜色变量里。在画布新建 DrawItem 时把这个颜色传进去,作为图形的属性。
粗细调整我用了JSlider,范围从 1 到 10,默认 2。为了让用户知道当前粗细微调,下面加了一个 JLabel 显示数字。这个 Silder 的值也要在创建 DrawItem 时传给图形对象。注意 Swing 的 JSlider 最小值和最大值都有边界值,你可以在 change 事件里获取新值,但不需要每次变化都重绘画布——只要更新“当前画笔粗细”变量即可,等下一个图形创建时才使用它。
这里有个组件事件的知识点:JSlider 监听ChangeListener时,stateChanged会被触发很多次,如果你在里面每调一次就 repaint,会白白消耗性能。实际上当前绘图状态变化不需要立刻反映到画布上,只要等用户下次拖动鼠标时在 paintComponent 中使用新颜色、新粗细即可。所以我在面板里只维护一个ToolContext对象,集中存放当前工具类型、颜色、粗细,鼠标事件发生时从里面取值。
4.3 撤销与重做:简单栈实现
画图工具最重要的功能之一就是撤销。撤销的本质是“回到上一步的图形列表状态”。实现思路并不复杂:当你把一个图形提交到 items 列表时,先把旧状态推入一个 undo 栈,然后执行添加操作。撤销时,从 undo 栈弹出旧状态,把当前状态保存到 redo 栈,然后把 items 恢复成旧状态,最后 repaint。
由于图形对象列表是 ArrayList,直接保存整个列表的“快照”最简单,但每次撤销都复制整个列表,数据量大时会有内存压力。简易画图工具完全够用,因为每个图形就是几个坐标和颜色,再大的画布也画不了上万个图形。更工程化的做法是只保存新增/删除的操作记录,但复杂度会上升一个级别。我建议学习阶段先做快照式撤销,跑通后再考虑优化。代码大致如下:
Deque<List<DrawItem>> undoStack = new ArrayDeque<>(); Deque<List<DrawItem>> redoStack = new ArrayDeque<>(); public void snapshotBeforeNewItem() { undoStack.push(new ArrayList<>(items)); redoStack.clear(); } public void undo() { if (undoStack.isEmpty()) return; redoStack.push(new ArrayList<>(items)); items.clear(); items.addAll(undoStack.pop()); repaint(); } public void redo() { if (redoStack.isEmpty()) return; undoStack.push(new ArrayList<>(items)); items.clear(); items.addAll(redoStack.pop()); repaint(); }注意我用的是push到栈顶,ArrayDeque当栈用。如果你用普通 ArrayList 模拟栈,记得总是在末尾添加、末尾删除,否则性能会退化。撤销和重做按钮放在工具栏上,同时我建议给它们配置快捷键。快捷键不是必须的,但能明显提升工具的使用体验。具体做法是使用InputMap和ActionMap,而非在 KeyListener 里判断,因为 ActionMap 能在按钮获得焦点时仍然有效。后面我会给出简化的按键配置。
4.4 清空画布与保存为图片
清空操作最简单:调用items.clear(),然后 repaint。但清空也应该支持撤销吗?大多数画图工具里的清空是可以撤销的,所以我在点击“清空”按钮时,同样把当前的 items 快照压入 undo 栈,再把 items 清空。这样用户按 Ctrl+Z 还能把清空的内容找回来,体验好很多。
保存图片则是把画布内容渲染到 BufferedImage,再用 ImageIO 写文件。很多新手直接用component.getGraphics()去截图,这种方法拿到的 Graphics 可能包含组件边框等垃圾,而且需要组件显示在屏幕上。正确做法是创建一个 BufferedImage,获取它的 Graphics2D,先填充白色背景,再遍历 items 把所有图形画上去。这样即使画布没显示也能保存。保存格式我用 PNG,因为 PNG 是无损压缩,能保留透明背景(只要我们设置白色背景,就不会出现透明区域变黑的问题)。
具体代码:
public void saveToFile(File file) throws IOException { BufferedImage image = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_RGB); Graphics2D g2 = image.createGraphics(); g2.setColor(Color.WHITE); g2.fillRect(0, 0, getWidth(), getHeight()); for (DrawItem item : items) { item.draw(g2); } g2.dispose(); ImageIO.write(image, "png", file); }如果你的画布很小,保存出来的图片也很小。用户画的时候感觉大小“差不多”,但图片尺寸是像素数,跟屏幕缩放有关。更稳妥的做法是让保存图片的尺寸等于画布实际像素,或者弹一个对话框询问保存尺寸。简易工具可以直接用画布当前尺寸,我倾向于再加一个“保存为高清大图”的选项,把尺寸放大 2 倍,用 Graphics2D 的缩放变换g2.scale(2.0, 2.0),把所有图形放大两倍保存。这样分享到手机或电脑上看,细节更清楚。
5. 进阶打磨与常见问题排查
5.1 解决画面闪烁问题(双缓冲原理)
Swing 组件的 paintComponent 默认就被 JComponent 的双缓冲机制包着,理论上不会闪烁。但为什么网上还有人问闪烁?我遇到过的几种情况:一是你在paint方法里做了太多重活,比如每次绘制都加载图片;二是你在绘制过程中调用了像Thread.sleep这样的阻塞操作,占用了 EDT;三是你自己创建了 JPanel 的子类,却调用了super.paint(g)而不是super.paintComponent(g),导致背景刷了两遍。想彻底消除视觉闪动,最可靠的办法是不碰paint方法,所有绘制都放在paintComponent里,并且不要在绘制期间做耗时操作。
如果图形数量很多,重绘整个画布也会带来“卡顿感”。一种局部重绘的优化思路是:在鼠标拖动时只更新包含当前图形的那一块矩形区域,用repaint(x, y, width, height)代替repaint()。不过这样会引入很多数学问题,尤其是自由曲线,每帧的包围盒都在变化。对我这样的简易工具来说,先保证不闪烁,再保证流畅,性能不够时再优化,不要一开始就上复杂方案。
5.2 自由曲线为什么不圆滑(插值与平滑)
自由曲线如果直接用鼠标采样点连成折线,写出来的线会感觉“毛刺多”,尤其是在快速移动鼠标时。这是因为两点之间的线段方向变化剧烈。解决办法是用贝塞尔曲线平滑路径。一个很实用的做法是让鼠标经过的每个点作为曲线终点,但控制点取两点之间的中点。具体来说,对于点列表P0, P1, P2 ...,绘制时从P0开始,对于每个中间点,控制点取当前点和下一个点的中点,这样生成的曲线在每个节点都是平滑连接的。
在 Swing 里可以用Path2D.Float来构建路径:
Path2D.Float path = new Path2D.Float(); path.moveTo(points.get(0).x, points.get(0).y); for (int i = 1; i < points.size() - 1; i++) { Point p1 = points.get(i); Point p2 = points.get(i + 1); int midX = (p1.x + p2.x) / 2; int midY = (p1.y + p2.y) / 2; path.quadTo(p1.x, p1.y, midX, midY); } // 最后连接最后一个点注意最后的点段要单独处理,否则最后一段会用折线连过去,看起来会不协调。这样画出的铅笔线立刻就有“丝滑”感了。你也可以存一个二次贝塞尔曲线序列,但实现会复杂一点。对我个人来说,Path2D 的方案足够好用,还能直接交给 Graphics2D 绘制。
5.3 多显示器缩放下的坐标错乱
现在的电脑经常外接高分屏,Windows 会把不同屏幕的缩放比例设置得不一样。这给 Swing 带来了让人头疼的坐标换算问题。如果你使用MouseEvent.getPoint(),Swing 已经做了大部分换算,正常情况下没问题。但如果你自作聪明地在鼠标事件里调用getLocationOnScreen()然后减去画布在屏幕上的位置,计算出来的坐标会受 DPI 缩放影响,导致画的图形跟鼠标光标偏移。
我的经验是:不要拿屏幕坐标来算画布坐标,而是直接用组件上的相对坐标。为了让组件和图形在高分屏下也清晰,可以在 paintComponent 里通过Graphics2D.getTransform()查看当前的缩放变换。Swing 在 HiDPI 下默认的坐标系统是逻辑像素,不是物理像素,所以画出来的线条宽度也会对应缩放,效果通常是不错的。如果你的程序要同时支持非常规缩放,可以考虑用Toolkit.getDefaultToolkit().getScreenResolution()做手动适配,但简易工具不建议碰,容易踩坑。
5.4 头号坑:Swing 事件线程的规矩
Swing 是单线程模型,所有 UI 操作都必须在事件分发线程(EDT)上执行。如果你在按钮点击的 ActionListener 里写了一行Thread.sleep(5000),整个窗口会卡死 5 秒,鼠标和键盘都没反应。这是几乎所有初学 Swing 的人都会踩的坑,我也没少踩。正确做法是:如果要做耗时操作,比如保存大图片、导出文件,就用SwingWorker在后台线程处理,处理完再回到 EDT 更新界面。
我的保存图片操作虽然不算慢,但调ImageIO.write大图片时还是可能在硬盘慢的情况下让界面“卡一下”。后续我改成了使用 SwingWorker,点击保存后立即弹出“正在保存…”提示,完成后再跳回界面。这样即便卡住也不影响界面刷新。这一节虽然跟绘图逻辑不直接相关,但对桌面程序的使用体验影响巨大,建议你一定要记住。
还有一个小规则:不要在监听器之外的地方直接操作组件,尤其是定时器线程里 new 一个 JLabel 或者弹窗。所有涉及组件状态的变更,最好通过SwingUtilities.invokeLater放到 EDT 中执行。你会少掉很多莫名其妙的异常。
5.5 鼠标事件丢失与手势偏差
鼠标拖得快的时候,系统可能不会挨个触发每一像素的 mouseDragged 事件,导致自由曲线的点之间距离过大。如果你的曲线平滑算法依赖点的连续性,可能会画出奇怪的线。解决办法是在曲线绘制时,如果两点间距离超过一定阈值就做插值。还有一个小场景:用户按下鼠标后在画布外移动,然后再移动回画布内,某些系统可能不会继续触发拖动事件。这时可以监听 mouseEntered/mouseExited,配合全局坐标判断,但简易工具里我选择忽略,只要不是用来做精密绘图,问题不大。
如果你的程序想支持触控笔或平板,还要考虑笔压信息,Swing 的标准鼠标事件没有直接暴露压感,只有 AWT 的MouseEvent里有可选的参数,但支持程度因设备而异。这个坑别碰,留到将来用 JavaFX 再说。
6. 代码合辑与运行效果
6.1 核心类结构一览
现在把整个工程的类组织一下。我习惯按功能分包,包里总共只有五个类加一个接口,非常清爽:
src/com/example/paint/ ├── Main.java // 程序入口 ├── MainFrame.java // 主窗口,组合工具栏和画布 ├── DrawPanel.java // 自定义画布 ├── ToolContext.java // 全局画笔状态 ├── DrawItem.java // 图形接口 ├── LineItem.java // 直线 ├── RectItem.java // 矩形 ├── OvalItem.java // 椭圆 ├── FreeLineItem.java // 自由曲线 ├── HistoryManager.java // 撤销/重做栈管理 └── ImageSaver.java // 保存图片工具类这个结构在数据流的传递上非常顺畅。MainFrame 创建 ToolContext、DrawPanel、HistoryManager,并把工具栏按钮和它们关联起来。DrawPanel 保存已经完成绘制的图形的List<DrawItem>,同时保存一个 currentItem 作为“正在绘制对象”。HistoryManager 持有对 DrawPanel 的引用,通过快照方式实现撤销重做。ImageSaver 接收 DrawPanel,读取它的尺寸和图形列表,输出文件。
当用户操作时,流程是:工具栏按钮修改 ToolContext 的当前工具、颜色、粗细;鼠标按下时根据 ToolContext 创建对应 DrawItem;拖动时修改 DrawItem 的坐标;释放时把 DrawItem 加入列表并通知 HistoryManager 保存快照。所有坐标都保存在图形对象内部,所以重绘画布时只依赖 DrawPanel 自己的 items 列表,不访问任何外部状态。这就是我在开头强调“数据流干净”的含义。
6.2 可运行示例的完整关键代码
为了让你能快速抄起来,我把几个最关键的部分拼成一个可运行版本。下面代码省略了每个类的完整文件,但绘制核心都在这里面。DrawItem 接口、LineItem、RectItem、OvalItem、FreeLineItem 都是相似的,每个类只存坐标、颜色、粗细和必要的点列表。例如 LineItem:
public class LineItem implements DrawItem { private final int startX, startY, endX, endY; private final Color color; private final float strokeWidth; public LineItem(int startX, int startY, int endX, int endY, Color color, float strokeWidth) { this.startX = startX; this.startY = startY; this.endX = endX; this.endY = endY; this.color = color; this.strokeWidth = strokeWidth; } @Override public void draw(Graphics2D g2) { g2.setColor(color); g2.setStroke(new BasicStroke(strokeWidth)); g2.drawLine(startX, startY, endX, endY); } }矩形、椭圆类只需把 draw 方法换成drawRect、drawOval,并且自己计算左上角和宽高。FreeLineItem 里面保存List<Point>,draw 方法用上一节提到的 Path2D 平滑画出来。ToolContext 是一个简单的 POJO,包含 currentTool、color、strokeWidth,提供 getter/setter。在 DrawPanel 的鼠标适配器里这样写:
mousePressed -> { Point p = convertPoint(e); currentItem = createItemByContext(p); } mouseDragged -> { if (currentItem == null) return; currentItem.updateEndPoint(convertPoint(e)); repaint(); } mouseReleased -> { if (currentItem == null) return; if (currentItem.isValid()) { historyManager.snapshotBeforeNewItem(); addItem(currentItem); } currentItem = null; repaint(); }注意updateEndPoint和isValid是 DrawItem 接口加的两个默认方法。为了保持简单,我可以在接口里用default方法,也可以在每个类里实现。这里的“提交前快照”很关键,因为撤销栈必须保存“添加这条线之前”的状态,而不是之后的状态。
6.3 还可以怎么扩展:图层、印章、导出 SVG
如果你做完基础版还觉得不过瘾,我建议按以下优先级扩展。首先是“橡皮擦”:它可以简单地实现为用背景色绘制一条粗线,但更科学的方式是把绘制内容切成小块,只删除与擦除路径相交的图形。第二个扩展是“图形选中与移动”:给每个 DrawItem 加一个contains(Point p)判断是否点击到它,命中后记录偏移量,在拖动时更新坐标。这需要把 DrawItem 变成可变状态,同时要考虑撤销栈记录哪种快照。第三个是“文字输入”:点击画布时弹出输入框,然后生成一个 TextItem,在 draw 方法里调用g2.drawString,同时要解决字体、换行等问题。
如果你对导出格式有需求,可以直接用 Graphics2D 输出 PDF 或用 Apache Batik 导出 SVG。Swing 的 Graphics2D 本身可以配合PDFGraphics2D之类的第三方库工作,但那是后话了。我的建议是先把基础功能和撤销重做做稳定,再考虑这些炫技的部分。一个能稳定运行、功能闭环的小工具,比一个功能众多但处处是 bug 的半成品有价值得多。
我在实际开发中还有一个体会:画图工具的代码写完后,最好给自己留一天时间做“压力测试”——连续画几百条线,反复撤销重做,切换各种颜色粗细,看有没有内存异常、坐标错乱、卡顿问题。往往这些小问题在正常操作时发现不了,但给别人演示时就暴露了。把这些坑都填完,你会对整个事件驱动模型有比看书深得多的理解。