简介:基于JavaFX开发的流程图设计器源码工程,面向学习Java与JavaFX课程的高校学生,尤其适合作为期末大作业参考或图形界面编程实战案例。压缩包内共21个文件,包含13个Java源文件、6个XML配置文件、1个说明文档及Git忽略文件,整包仅26KB,属于轻量级源码项目。Java源文件覆盖了流程图元素的绘制、交互编辑与布局算法,XML配置则包含Maven的pom.xml及开发环境信息,便于快速掌握项目结构与依赖管理。目前已有65人学习下载,资源保留了完整的src/main源码目录和IDE配置,可直接导入主流Java开发环境运行调试。通过该项目,读者能深入理解JavaFX中Canvas绘图、Stage/Scene界面搭建、事件监听与图形节点操作等关键机制,同时掌握流程图设计器从创建、编辑到保存导出的完整实现思路,对完成同类期末设计或提升桌面应用开发能力都很有帮助。
1. JavaFX流程图设计器:为什么说它是期末大作业里性价比最高的一类选题
「JavaFX流程图设计器」是一个放在期末大作业清单里容易被低估的选题。它比管理系统多出看得见的交互——节点能拖、能连线、能选中删除;又比游戏简单,所有图形都用 Rectangle、Circle、Text 这些 JavaFX 基础组件拼出来,不碰物理引擎和动画循环。用一句话概括核心玩法:在画布上摆节点,在节点之间拉连线,最后把整张图保存成工程文件再打开。
这个选题能一次性覆盖面向对象设计、集合管理、事件驱动和文件持久化四块课程重点,演示效果好,答辩也好讲。适合不愿意卷算法、也不想碰 Android 方向的 Java 课学生,也适合想拿 JavaFX 做内部小工具的工程师练手。下面按从零搭这类工具的顺序展开:最小工程、可拖拽节点、连线交互,再到避坑和答辩加分写法。
2. 先立骨架:Maven + JavaFX 最小工程与启动参数配置
2.1 为什么我不用 IDE 自带的 JavaFX 模板,而用 Maven
多数课程的上机环境是 Eclipse 或 IDEA,点 New Project 里确实有 JavaFX 选项,但那个模板依赖 IDE 帮你配置的本地 SDK 路径,换一台机器、换一个 IDE 版本,路径就失效,重新导包能折腾一下午。我一般拿到别人给的 JavaFX 工程,第一件事也是看有没有 pom.xml,没有就先按 Maven 结构重建。
Maven 的好处是把依赖从中央仓库拉下来,pom.xml 写清楚坐标和版本,Eclipse、IDEA、命令行都能跑,交作业时把 src 和 pom.xml 一起压缩就行,不需要附带几十 MB 的 SDK。前提是 JDK 17 以上且 JAVA_HOME 配置正确——这一步对环境变量不熟的人最容易卡住,后面第 5 章会专门说启动失败怎么排查。
有人会问:JavaFX 不是 JDK 里自带吗?那是 JDK 8 及之前的旧事。从 JDK 9 开始,JavaFX 被从 JDK 中拆出去独立维护,JDK 11 之后的发行版里根本没有 javafx 包。这就是为什么很多同学把代码从 JDK 8 项目复制到 JDK 17,一编译满屏红叉。
2.2 pom.xml 里的 javafx-controls 依赖与模块化两种写法
先看一份能直接跑通的最小 pom.xml,只引入 javafx-controls 一个模块,它会把图形、事件、场景相关的类全部带进来。
<project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>com.flowdesigner</groupId> <artifactId>flow-designer</artifactId> <version>1.0</version> <packaging>jar</packaging> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <javafx.version>21.0.2</javafx.version> </properties> <dependencies> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> <version>${javafx.version}</version> </dependency> </dependencies> <build> <plugins> <!-- javafx-maven-plugin 负责把模块路径传给 JVM,解决“JavaFX runtime components are missing” --> <plugin> <groupId>org.openjfx</groupId> <artifactId>javafx-maven-plugin</artifactId> <version>0.0.8</version> <configuration> <mainClass>com.flowdesigner.MainApp</mainClass> </configuration> </plugin> </plugins> </build> </project>逻辑说明:javafx-controls是 JavaFX 最核心的桌面 UI 模块,javafx-maven-plugin的职责是帮你算出--module-path并启动应用。如果你只需要弹窗、画布、按钮、文本,这一个依赖足够;等做到导出图片那一步,可以再补javafx-swing或直接用javafx.graphics里的Snapshot功能。
参数说明里有三处要留意。第一,maven.compiler.source/target要和本机 JDK 版本匹配,写成 17 是最稳妥的,JavaFX 21 要求 JDK 17 以上,低了会直接报错。第二,mainClass如果用了module-info.java,要写成模块名/全类名的格式,否则只写全类名。第三,JavaFX 版本 17 和 21 都是最常见的 LTS,21.0.2 能同时兼容 JDK 17 和 JDK 21,省得来回换。
是否写module-info.java是期末作业里最容易纠结的点。不写模块描述文件也能跑,此时 javafx-maven-plugin 用 classpath 模式启动,适合绝大多数课程演示;写了模块描述文件,代码更规范,但要把requires javafx.controls;写对,稍有不慎就变成启动失败。我的习惯是不写,把精力留在功能上,答辩时反而能解释清楚两种模式的区别。
2.3 最小启动类:从 Application.launch 到首屏窗口
JavaFX 的入口跟普通 main 方法不一样,需要继承Application类并实现start方法。下面是最小可运行版本,先把窗口和一块空画布立起来。
package com.flowdesigner; import javafx.application.Application; import javafx.scene.Scene; import javafx.scene.layout.AnchorPane; import javafx.stage.Stage; public class MainApp extends Application { @Override public void start(Stage stage) { // 用 AnchorPane 当画布容器,子节点靠 setLayoutX/setLayoutY 自由定位 AnchorPane canvas = new AnchorPane(); canvas.setPrefSize(1280, 800); canvas.setStyle("-fx-background-color: #f7f8fa;"); Scene scene = new Scene(canvas); stage.setTitle("流程图设计器 - JavaFX 期末大作业"); stage.setScene(scene); stage.show(); } public static void main(String[] args) { launch(args); } }逻辑说明:launch(args)是 JavaFX 框架的真正入口,它会初始化图形环境,然后在 JavaFX 应用线程上回调start方法。AnchorPane是最适合做自由画布的容器,因为子节点不参与布局管理,坐标完全由自己控制;换成BorderPane或VBox反而要处理布局约束,徒增麻烦。
参数说明里有一个容易被忽略的事实:Scene不设宽高时,默认跟随根节点的PrefSize,所以canvas.setPrefSize(1280, 800)实际决定了首屏大小。做期末作业时建议把画布再做大一点,比如 1600×1000,因为流程图一旦连了十几条线,800 的高度会显得很挤。编译运行命令如下:
mvn clean javafx:run如果 IDE 里直接跑main方法报「JavaFX runtime components are missing」,不要硬刚,回到命令行用mvn javafx:run,这是这个插件存在的意义。
2.4 fxml、图片、字体放进 resources 后的加载路径坑
不少作业会配合 FXML 做界面,FXML 文件本身没毛病,但资源加载路径有讲究。用getClass().getResource("/view/main.fxml")时,路径最前面必须带斜杠,表示从 classpath 根目录开始找;写成getResource("view/main.fxml")就会去当前类的包目录里找,十有八九返回 null,然后抛NullPointerException。
哪怕你暂时不用 FXML,CSS 样式表也会踩同一个坑。scene.getStylesheets().add(getClass().getResource("/css/style.css").toExternalForm())才是正解。打包成 jar 之后,文件系统的相对路径会全部失效,只有 classpath 路径还能用,所以从一开始就养成带前导斜杠的习惯,能省掉答辩前夜一次莫名其妙的修 bug。
3. 把节点做活:拖拽、选中与画布坐标换算
3.1 用 Group 把 Rectangle 和 Text 合成流程图节点
流程图里的每个节点,本质上是一块圆角矩形、一行文字和两个端口圆点。用Group把这些组件包起来,拖拽时只需要移动 Group 本身,内部的图形自动跟随,这是最省事的设计。
public class DraggableNode extends Group { private final String nodeId; private final Rectangle body; private final Text label; public DraggableNode(String nodeId, String text, double x, double y) { this.nodeId = nodeId; body = new Rectangle(140, 56); body.setArcWidth(12); body.setArcHeight(12); body.setFill(Color.web("#ffffff")); body.setStroke(Color.web("#8a93a6")); body.setStrokeWidth(1.5); label = new Text(text); label.setX(12); label.setY(30); label.setFont(Font.font("System", 14)); getChildren().addAll(body, label); setLayoutX(x); setLayoutY(y); } public String getNodeId() { return nodeId; } }逻辑说明:Group不同于普通容器,它不强制子节点参与布局,所有子节点坐标都是相对 Group 内部的,移动 Group 本身最直接。body是节点的可视底板,label是节点文字。节点 ID 单独存一份,后面做连线和 JSON 存取时靠它索引,不能依赖对象引用。
参数说明里值得关注的是setLayoutX/setLayoutY与setTranslateX/setTranslateY的差别。前者是节点在父容器中的布局位置,后者是叠加的偏移量。做拖拽时优先改layoutX/layoutY,因为保存工程文件时读这两个值最直观;用translate的话,保存前还得做一次加法,容易漏。
3.2 三个鼠标事件让节点“跟手”:按下记偏移、拖动改坐标、松开落定位
拖拽最忌讳的做法是拖动时不断用getLayoutX() + 位移,那样会把上一次的漂移累积进去。正确做法是在按下时记录一次基准值,拖动时用「基准值 + 鼠标总位移」重新计算。
private double pressSceneX; private double pressSceneY; private double layoutStartX; private double layoutStartY; setOnMousePressed(e -> { // 按下瞬间记录鼠标场景坐标和节点当前布局坐标 pressSceneX = e.getSceneX(); pressSceneY = e.getSceneY(); layoutStartX = getLayoutX(); layoutStartY = getLayoutY(); toFront(); }); setOnMouseDragged(e -> { // 布局坐标 = 按下时的布局坐标 + 鼠标移动量 setLayoutX(layoutStartX + e.getSceneX() - pressSceneX); setLayoutY(layoutStartY + e.getSceneY() - pressSceneY); }); setOnMouseReleased(e -> { // 松开后不需要做额外处理,坐标已经写进 layoutX/layoutY });逻辑说明:e.getSceneX()是鼠标在整个窗口中的坐标,getSceneX() - pressSceneX是本次拖动的总位移。把位移加到按下时的布局坐标上,无论之前怎么拖,都不会产生累积误差。toFront()把被拖的节点抬到最上层,避免节点间遮挡导致拖不动。
这里有个关键细节:不要在MouseDragged里读e.getX()或e.getY(),那是相对于当前节点的坐标,节点一动它就变,用来定位会越算越乱。这属于典型的「看着能跑、跑起来飞」的写法。
3.3 场景坐标转画布坐标:为什么不能直接 setLayoutX(e.getSceneX())
很多同学写拖拽时直接setLayoutX(e.getSceneX()),结果是越拖越偏,最后节点跑到窗口外面去。原因在于场景坐标和父容器坐标不是同一个坐标系。
Scene坐标的原点在窗口客户区左上角,而AnchorPane的坐标原点是画布左上角。当画布直接作为 Scene 的根节点并铺满窗口时,两者原点基本重合;一旦画布外面套了菜单栏、工具栏或标题栏,Y 方向就会差几十像素。正确做法是用父节点的坐标系转换:
Parent parent = getParent(); if (parent != null) { Point2D local = parent.sceneToLocal(e.getSceneX(), e.getSceneY()); setLayoutX(local.getX()); setLayoutY(local.getY()); }逻辑说明:sceneToLocal把场景坐标换算成当前父容器坐标系下的坐标,自动抵消标题栏、菜单栏带来的偏移。如果你的画布就是窗口里唯一的根节点,直接getSceneX()碰巧能跑,但拿掉窗口标题栏或加一个顶栏就会翻车。我一律用sceneToLocal,省得后面排查玄学漂移。
3.4 选中描边与拖动阈值:防止点击和拖拽打架
流程图设计器需要反馈「你选中了哪个节点」。最轻量的做法是改描边颜色和粗细,选中为蓝色粗边,取消选中恢复灰色。
setOnMouseClicked(e -> { // 只有没有明显位移的点击才算选中,避免拖完自动变选中 if (Math.abs(e.getSceneX() - pressSceneX) < 3 && Math.abs(e.getSceneY() - pressSceneY) < 3) { body.setStroke(Color.web("#2b7df7")); body.setStrokeWidth(2.0); toFront(); } });逻辑说明:如果不对位移做判断,拖拽结束时也会触发MouseClicked,导致每个节点拖完都变成选中状态。加一个 3 像素的拖动阈值,小于它就当作点击,这是一个纯鼠标交互的默认容差。
选中之后还要维护「上一次选中节点」的取消逻辑。我一般让画布持有一个DraggableNode selectedNode字段,新节点选中时把旧节点的描边和描边宽度还原。这一步不做,多个节点同时高亮,流程图看起来就像装修现场。
4. 把连线做通:锚点、折线与删除交互
4.1 给节点加输入/输出端口:两个 Circle 锚点怎么放
只有节点没有端口,连线就没有落点。流程图设计器里最常见的端口约定是:输入端口在顶部中点,输出端口在底部中点。这样读图顺序天然是从上到下、从左到右。
端口用Circle实现,直径 12 像素,加一圈边线让它在白色节点底板上看得清。
private final Circle inputPort; private final Circle outputPort; // 在 DraggableNode 构造器里追加 inputPort = new Circle(70, 0, 6); // 顶部中点 inputPort.setFill(Color.web("#ffffff")); inputPort.setStroke(Color.web("#2b7df7")); inputPort.setStrokeWidth(1.5); outputPort = new Circle(70, 56, 6); // 底部中点 outputPort.setFill(Color.web("#ffffff")); outputPort.setStroke(Color.web("#2b7df7")); outputPort.setStrokeWidth(1.5); getChildren().addAll(inputPort, outputPort);逻辑说明:Circle(70, 0, 6)的前两个参数是圆心坐标,第三个是半径。节点宽 140,所以中点在 x=70;高 56,顶部中点在 y=0,底部中点在 y=56。端口放在 Group 内部,随节点一起移动,连线时不需要重新计算端口位置,直接用localToScene换算一次即可。
端口半径影响连线的视觉精度。6 像素的半径意味着鼠标点到端口附近 6 像素内都算命中,手感比较友好。如果做得太细,比如 3 像素,用户连四五条线就会烦。
4.2 四段式折线连线:从源端口到目标端口的 Path 怎么画
两个节点之间的连线不能是直线,直线会穿过中间的节点,而且视觉上像蜘蛛网。工业流程设计器普遍用正交折线,我习惯用一个四段式Path:先水平出去,再垂直拐弯,再水平进来。
public class LinkLine extends Path { public LinkLine(double sx, double sy, double tx, double ty) { double bend = Math.max(20, Math.abs(sx - tx) / 4); getElements().add(new MoveTo(sx, sy)); getElements().add(new HLineTo(sx + bend)); getElements().add(new VLineTo(ty)); getElements().add(new HLineTo(tx)); getStyleClass().add("link-line"); } public void update(double sx, double sy, double tx, double ty) { double bend = Math.max(20, Math.abs(sx - tx) / 4); getElements().clear(); getElements().add(new MoveTo(sx, sy)); getElements().add(new HLineTo(sx + bend)); getElements().add(new VLineTo(ty)); getElements().add(new HLineTo(tx)); } }逻辑说明:路径元素从左到右依次执行。MoveTo把画笔移到源端口坐标;HLineTo(sx + bend)先向右画一段水平线,bend是拐角余量;VLineTo(ty)垂直到达目标点的 Y 坐标;最后HLineTo(tx)水平进入目标端口。整个路径是一条「先右、再下、再左」的正交折线。
参数说明里最容易误解的是bend的计算。固定写死 30 像素会出问题:当起点和终点水平距离很近,甚至终点在源端口左侧时,固定 bend 会导致路径往回折形成 Z 字形,很丑。把它设成Math.abs(sx - tx) / 4并且限制最小值 20,能保证拐弯长度始终小于连线总长的一半,路径不会出现回头弯。
4.3 连线跟随节点移动:监听 layoutXProperty 而不是手动刷新
拖节点时,已连好的线必须跟着动。最笨的办法是每次MouseDragged里遍历所有连线重新算坐标,但那样要处理大量查找逻辑。更简洁的方案是让连线监听节点的layoutXProperty和layoutYProperty。
// 创建 line 之后,给源节点和目标节点各挂一个监听器 source.layoutXProperty().addListener((obs, oldVal, newVal) -> line.update( sourceNodeCenterX(), sourceNodeOutputY(), targetNodeCenterX(), targetNodeInputY() )); source.layoutYProperty().addListener((obs, oldVal, newVal) -> line.update(...)); target.layoutXProperty().addListener((obs, oldVal, newVal) -> line.update(...)); target.layoutYProperty().addListener((obs, oldVal, newVal) -> line.update(...));逻辑说明:layoutXProperty是 JavaFX 的属性系统,当setLayoutX改变坐标时,监听器自动触发。节点 Group 内部有端口,源端口在节点底部,目标端口在节点顶部,所以更新时要把端口相对节点中心的偏移量加进去。监听器里只改Path的元素,不会反向修改节点的坐标,因此不存在循环触发的问题。
有个实际坑:如果 Line 存的是「建立时的坐标快照」而不是「对节点的引用」,监听器根本没有数据来源。所以连线模型里必须持有sourceNode和targetNode的引用,而不是只存sourceX/sourceY。这一点在后续做 JSON 存取时要特别区分:界面运行用对象引用,保存文件用节点 ID。
4.4 删线与右键菜单:别让悬空线留在画布上
流程图连错线是常态,删除操作必须顺手。常见做法有两个:选中连线后按 Delete 键,或者在连线上点右键弹出删除菜单。对期末作业来说,Delete 键最直观。
scene.setOnKeyPressed(e -> { if (e.getCode() == KeyCode.DELETE && selectedLine != null) { canvas.getChildren().remove(selectedLine.getPath()); edgeList.remove(selectedLine); selectedLine = null; } });逻辑说明:selectedLine是画布维护的当前选中连线对象。删除时要做两件事:把Path从画布的children里移除,把LinkLine从数据列表里移除。只删界面不删数据,保存工程文件时会发现幽灵线;只删数据不删界面,画布上会残留一条永远点不中的线。
右键菜单可以用ContextMenu,给Path挂setOnContextMenuRequested,菜单里放一个「删除连线」项。这比 Delete 键更符合鼠标操作习惯,但实现时要注意:Path本身比较细,右键不容易点中,我一般把描边宽度调成 3 并在setPickOnBounds(true),让鼠标在连线附近 3 像素内都能命中,手感会好很多。
setPickOnBounds(true)是命中判定里最有用的参数:默认Path只响应路径本体的像素范围,把整个包围盒变成可点击区域后,用户不需要精确到线上,这对小目标交互是质的提升。
5. 避坑实录:JavaFX流程图设计器最常见的 5 个翻车现场
以下五个问题是我自己和学生项目里反复出现的,按「现象 → 原因 → 解决」的顺序写,你遇到时可以直接对号入座。
5.1 启动失败:找不到 JavaFX 运行组件
现象:用mvn clean javafx:run启动后,控制台报JavaFX runtime components are missing,或者 IDE 里跑main方法直接抛NoClassDefFoundError: javafx/application/Application。
原因:JDK 9 之后不再内置 JavaFX,也没有把 JavaFX 的 jar 放进 classpath。IDE 直接运行main方法时,没有--module-path参数,JVM 就找不到javafx.application.Application这个类。常见于从 JDK 8 项目迁移过来、或者手贱删掉了 pom 里的插件。
解决:pom.xml 里必须同时有javafx-controls依赖和javafx-maven-plugin,然后用mvn clean javafx:run启动,不要用 IDE 的 Run 按钮。如果你非要 IDE 运行,需要手动加 VM 参数:
--module-path /path/to/javafx-sdk/lib --add-modules javafx.controls注意:javafx-maven-plugin的mainClass配置如果写错包名,会报「找不到主类」,排查时先确认全类名和实际代码一致,这是最蠢也最容易犯的错。
5.2 拖拽漂移:节点“飞掉”或越拖越偏
现象:鼠标按住节点拖动,节点要么一下子跳到左上角,要么每拖一次就偏离鼠标一段距离,拖到后面节点和鼠标之间隔了大半个屏幕。
原因:用了setLayoutX(e.getSceneX())直接把场景坐标当布局坐标,或者拖动时不断累加getLayoutX() + e.getX()。前者忽略了窗口标题栏和菜单栏导致的坐标系偏移,后者把每次拖动的微小误差反复叠加,最终漂移成非线性。
解决:按下时记录基准值,拖动时计算位移量。代码参考第三章 3.2 小节,核心是setLayoutX(layoutStartX + e.getSceneX() - pressSceneX)。实在要处理容器偏移,就调用parent.sceneToLocal(e.getSceneX(), e.getSceneY()),这是最稳的换算,没有之一。
5.3 连线不规整:斜线、穿节点、回头折
现象:两个节点之间的连线是一条歪斜的对角线,甚至从节点正中间穿过;水平距离很近时,连线出现一个明显的 Z 字形回折。
原因:直接用Line从端口圆心连到端口圆心,没有考虑正交折线;或者折线的拐角偏移量用固定值,没做边界处理。对角线在流程图上看起来非常业余,访客级别的人一眼就能看出来是赶工产物。
解决:改用第四章的Path四段式折线,并且bend = Math.max(20, Math.abs(sx - tx) / 4)。这个公式保证拐弯长度不超过连线水平总长的一半,两端距离再近也不会回折。节点边框要有圆角,要确保端点坐标不在矩形内部——从端口圆心而不是矩形角出发就行了。
5.4 导出 PNG 全白(全黑、只截到一块儿)
现象:把画布截图保存成图片,打开一看整张全白或只有左上角一小块内容,节点和线全部消失。
原因:用node.snapshot时传了不存在的节点,或者对Scene的根节点做快照时画布还没完成布局。JavaFX 的snapshot执行的是「当前画面渲染」,如果节点尚未出现在窗口可见区域,或者使用了ScrollPane而内容超出视口,快照就会丢东西。这个问题的外在表现很像玄学,实际就是快照目标选错了。
解决:对scene.getRoot()做快照,导出前先调用root.applyCss()和root.layout()强制完成布局,再用WritableImage接收:
WritableImage image = new WritableImage((int) root.getBoundsInParent().getWidth(), (int) root.getBoundsInParent().getHeight()); root.snapshot(new SnapshotParameters(), image); ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file);逻辑说明:applyCss让样式表先计算完毕,layout让所有节点的坐标落定,快照才能拿到完整像素。这里需要javafx-swing模块配合把 FX 图像转成BufferedImage,记得在 pom 里补依赖:org.openjfx:javafx-swing:21.0.2。
5.5 关窗口进程不退:界面没了,Java 进程还挂着
现象:点掉窗口右上角,界面消失了,但 IDE 控制台的小红方块还在,进程没退出,任务管理器里能看到一个 java 进程占着内存。
原因:JavaFX 窗口关闭后,应用线程自动结束的前提是没有非守护线程存活。常见的元凶是AnimationTimer、ScheduledExecutorService、或者你手写的new Thread(() -> { while(true) ... })。期末作业里最常见的就是为了做闪烁提示或动画轮询,起了线程忘了停。
解决:在stop()方法里做清理。stop是Application生命周期里窗口关闭后、进程退出前会回调的方法,把定时器和动画线程在里面显式关闭:
@Override public void stop() throws Exception { if (timer != null) timer.stop(); if (executor != null) executor.shutdownNow(); super.stop(); }逻辑说明:AnimationTimer.stop()只停止动画帧回调,不会结束 JavaFX 线程;ScheduledExecutorService必须shutdownNow,否则线程池里的线程一直活着,进程自然退不掉。养成在 stop 里做清理的习惯,这个坑基本能 100% 避开。
6. 答辩加分项:撤销重做 + JSON 工程存取的两个落地写法
6.1 撤销/重做:把画布快照压栈,比逐条记录操作省事
实现撤销最省心的做法不是记录具体命令,而是给画布做「快照」。每次操作前把当前状态深拷贝压进撤销栈,操作完成后清空重做栈。撤销时弹栈恢复,同时把当前状态压进重做栈。代码骨架如下:
Deque<GraphSnapshot> undoStack = new ArrayDeque<>(); Deque<GraphSnapshot> redoStack = new ArrayDeque<>(); private void pushUndo() { undoStack.push(captureSnapshot()); if (undoStack.size() > 50) { undoStack.removeLast(); // 防止快照太多把内存吃垮 } redoStack.clear(); } private void undo() { if (undoStack.isEmpty()) return; redoStack.push(captureSnapshot()); restoreSnapshot(undoStack.pop()); }逻辑说明:captureSnapshot遍历画布上的节点和连线,把节点 ID、坐标、文本、连线关系收集到一个普通 POJO 里;restoreSnapshot清空画布再按快照数据重建。快照栈上限 50 是经验值,流程图节点多时,一次快照可能要保存上百条数据,无限压栈内存会涨得很难看。
答辩时演示撤销的正确姿势是:连续拖三个节点、连两条线,然后「Ctrl+Z 三连」,每一步画面都回退一次,视觉效果比任何口头讲解都有说服力。这一步不做,被评委问到「支持撤销吗」就只能尬住。
6.2 JSON 存取:用 ObjectMapper 把工程存成 .flow.json
工程文件的保存不要用 Java 序列化,也不要直接拿 JavaFX 组件去存。正确做法是定义两个纯数据类:GraphNode存节点 ID、坐标、文本,GraphEdge存源节点 ID 和目标节点 ID,整个工程用GraphDocument包起来,然后用 Jackson 的ObjectMapper读写。
public class GraphDocument { public String name; public List<GraphNode> nodes; public List<GraphEdge> edges; } ObjectMapper mapper = new ObjectMapper(); GraphDocument doc = mapper.readValue(file, GraphDocument.class); // 加载时根据 doc 重建节点对象,再根据 edges 重建连线逻辑说明:GraphNode是普通 POJO,只有id、x、y、text四个字段,不持有 JavaFX 组件引用。保存时从界面节点取getLayoutX()写入x,加载时用new DraggableNode(id, text, x, y)重建。JSON 文件天然可读、可 diff、可提交到 git,答辩现场用文本编辑器打开工程文件展示数据结构,会显得你对持久化理解很到位。
这个方向值得做。快照、撤销、JSON 存取这三件事共用同一套数据结构,写一次等于做了三件事。我最后一次做类似项目时,把撤销栈的深度限制忽略了,演示时撤销超过 20 步,内存肉眼可见地涨,当场被评委追问,后来所有画布类工具我都先写 UndoManager 再写界面。希望帮到你。
本文还有配套的精品资源,点击获取