首页 / 资讯中心 / 文章详情

从零实现Java Swing画图工具:GUI开发全流程实战

从零实现Java Swing画图工具:GUI开发全流程实战 ★ FEATURED ARTICLE
Java Swing 画图工具是我这几年带新手做项目时最喜欢推荐的一个练手项目。别小看这个“简易画图工具”它几乎把 GUI 开发的整个闭环都串起来了窗口构建、事件分发、自定义绘制、状态管理、文件输出。这篇文章会从零开始带你完整走一遍开发全流程内容包括环境搭建、画布实现、鼠标交互、工具栏、撤销重做、保存图片最后再聊聊我好几次才绕过去的坑。适合有一定 Java 基础、想通过实战项目提升的读者哪怕你只是刚学完集合和事件也可以跟着一步步敲出来。整个项目不依赖任何第三方库JDK 自带 Swing 就能跑做完你会有一种“原来桌面程序是这样攒出来的”的实感。1. 项目拆解简易画图工具到底在画什么1.1 核心需求与功能边界很多同学一上来就想做个“画图软件”结果被图层、PS 效果、导出多种格式这些词吓住最后啥也没做成。我一开始也是这样后来我把需求砍到最小发现反而能做出一个完整、能用的东西。所谓简易画图工具最基本的能力就是用鼠标在画布上绘制几种基础图形比如直线、矩形、椭圆还有自由手绘的曲线能选择颜色能调画笔粗细能撤销、重做能清空画布最后能把画布内容保存成图片文件。这个边界很关键。我们不做图层不做滤镜不做图形的“选中后拖动修改”也不做复杂文字编辑。因为每一项目标都会引入大量额外代码会让你迷失在细节里。先把闭环跑通后续再扩展。哪怕只是这样里面已经包含了一个桌面绘图系统最核心的骨架画布组件、绘图状态、鼠标交互、图形数据管理和重绘机制。1.2 技术选型为什么是 Swing 而不是 JavaFX有时候学员问我是不是应该直接用 JavaFXSwing 太老了我理解这种焦虑但你的目标如果是理解 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.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target /properties build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.3.0/version configuration archive manifest mainClasscom.example.paint.Main/mainClass /manifest /archive /configuration /plugin /plugins /build2.3 画出第一个 Swing 窗口从 JFrame 开始任何桌面程序都先有一个顶层面板在 Swing 里就是 JFrame。先别管画布和工具栏写一个能显示空窗口的启动类跑通了再说。这是整个项目的入口也是最有成就感的一步。我习惯这样组织 Main.javapackage 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 ListDrawItem 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.drawLineRectItem 保存起点和当前点绘制时用g2.drawRectOvalItem 绘制椭圆用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);自由曲线稍微复杂一点。最简单的实现是保存一个ListPoint每次鼠标拖动时把最新点加进去绘制时依次连接成折线。因为鼠标移动时事件频率很高存下来的点足够密看起来就是一条曲线。如果点太少画出的线会是一折一折的第四章我会讲怎么用贝塞尔曲线让它变平滑。颜色和粗细怎么设置不要在 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直接保存整个列表的“快照”最简单但每次撤销都复制整个列表数据量大时会有内存压力。简易画图工具完全够用因为每个图形就是几个坐标和颜色再大的画布也画不了上万个图形。更工程化的做法是只保存新增/删除的操作记录但复杂度会上升一个级别。我建议学习阶段先做快照式撤销跑通后再考虑优化。代码大致如下DequeListDrawItem undoStack new ArrayDeque(); DequeListDrawItem 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 清空。这样用户按 CtrlZ 还能把清空的内容找回来体验好很多。保存图片则是把画布内容渲染到 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 保存已经完成绘制的图形的ListDrawItem同时保存一个 currentItem 作为“正在绘制对象”。HistoryManager 持有对 DrawPanel 的引用通过快照方式实现撤销重做。ImageSaver 接收 DrawPanel读取它的尺寸和图形列表输出文件。当用户操作时流程是工具栏按钮修改 ToolContext 的当前工具、颜色、粗细鼠标按下时根据 ToolContext 创建对应 DrawItem拖动时修改 DrawItem 的坐标释放时把 DrawItem 加入列表并通知 HistoryManager 保存快照。所有坐标都保存在图形对象内部所以重绘画布时只依赖 DrawPanel 自己的 items 列表不访问任何外部状态。这就是我在开头强调“数据流干净”的含义。6.2 可运行示例的完整关键代码为了让你能快速抄起来我把几个最关键的部分拼成一个可运行版本。下面代码省略了每个类的完整文件但绘制核心都在这里面。DrawItem 接口、LineItem、RectItem、OvalItem、FreeLineItem 都是相似的每个类只存坐标、颜色、粗细和必要的点列表。例如 LineItempublic 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 里面保存ListPointdraw 方法用上一节提到的 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 的半成品有价值得多。我在实际开发中还有一个体会画图工具的代码写完后最好给自己留一天时间做“压力测试”——连续画几百条线反复撤销重做切换各种颜色粗细看有没有内存异常、坐标错乱、卡顿问题。往往这些小问题在正常操作时发现不了但给别人演示时就暴露了。把这些坑都填完你会对整个事件驱动模型有比看书深得多的理解。
阅读完成 · 觉得有帮助?
咨询建站