Java GUI智慧公交管理系统实战:Swing选型与避坑指南
简介:一套基于 Java GUI 与 MySQL 8.0 的智慧公交管理系统完整项目包,面向 Java 学习者、数据库课程设计及毕业设计人群,覆盖车辆、员工、线路、站点、排班方案和密码修改等核心业务,内置管理员、调度员、员工三类角色,登录后即可体验完整的数据联动流程。包体共 2 个文件,项目源码压缩包与数据库脚本各一个,整体约 10.72MB;将 SQL 脚本导入 MySQL 即可还原 busmanage 数据库,方便快速搭建运行环境,并对照学习 JDBC 连接、GUI 界面布局和多表查询设计。当前已有 1137 人学习下载。配套实现百分百可运行,数据库表设计覆盖员工表、登录表、排班表、站点表、线路表、车辆表及线路-站点表,能帮助理解真实公交管理场景中的信息同步与业务流程;项目结构清晰,适合作为课程设计参考,也可作为 Java Swing 与数据库整合开发的实战范本。
1. Java GUI 制作的智慧公交管理系统:它不是过时的课程设计
Java GUI 制作的智慧公交管理系统,听起来像是课程设计的选题,但在调度室里它真的在跑:一块大屏实时显示车辆位置、发车间隔和司机排班,数据存在 MySQL 里,前端要看得见、改得动。很多人第一反应是上 Web 前端,但内部工具用 Swing 反而更顺——只要装了 JDK,双击 jar 包就能启动,团队里会 Java 的人远多于会前端框架的人。这套系统常被当成毕设题目,但按真实调度业务来做,难度并不低:角色要分级、表格要实时刷新、排班要算得准。标题里的“智慧”不在界面多炫,而在数据与调度逻辑能否落地。这篇文章适合两类人:拿它做课程设计或毕设的学生,以及要给车队做内部小工具的一线开发者。
2. 技术选型:为什么 Java GUI 仍选 Swing,以及项目怎么分层
2.1 Swing vs JavaFX:支撑这套系统的三个理由
现在的 Java 桌面开发基本在 Swing 和 JavaFX 之间二选一。JavaFX 有 CSS 样式、FXML 布局和硬件加速,视觉上确实现代,但它的类库在 JDK 8 之后被移出标准 JDK,需要手动引入 openjfx 模块,打包时还要区分模块化与类路径两种方式。公交公司的后台通常跑在内网机器或旧服务器上,JavaFX 的依赖和模块路径问题,往往比界面本身更耗时间。
Swing 从 JDK 1.2 起就一直在标准库里,javax.swing 包在任何装了 JDK 的机器上都能用。智慧公交管理系统的使用场景是调度室里的运营后台,交互就是看数据、改数据、盯状态,不需要动画和换肤。Swing 的事件分发线程(EDT)模型虽然老,但行为可预测:界面卡住时,检查是不是在事件线程里干了重活,基本能定位问题。
第三个理由是团队熟悉度。能写 JDBC 的 Java 工程师,比熟悉 JavaFX 生命周期和 FXML 的人多得多。接手成本低、改起来快。我做这类内部工具的选型原则很简单:没有复杂动画、没有触屏、没有大屏炫技需求,就用 Swing。上 JavaFX 的前提是项目需要对视觉细节做精细控制,否则引入的成本大于收益。两个方向的差异可以看这张表:
| 维度 | Swing | JavaFX |
|---|---|---|
| JDK 内置 | 一直内置 | JDK 8 后需额外引依赖 |
| 界面样式 | 默认朴素,可自绘 | CSS 定制能力强 |
| 打包体积 | jar 即可 | 需附带 jfx 模块 |
| 表格大数据量 | 常规数据够用 | 虚拟化更强 |
| 学习曲线 | 组件模型直白 | FXML、生命周期概念多 |
| 团队门槛 | 低 | 中高 |
2.2 三层结构:不让一个类既画界面又查数据库
新手写 Java GUI 项目,最常见的问题是把所有逻辑塞进一个 JFrame 子类:按钮监听里写 JDBC 查询,查询结果直接往 JTable 里塞,发车间隔算法也堆在里面。这种写法在 Demo 阶段能跑,一旦要加一个“按线路筛选”功能,就可能动到查询方法,而查询方法又和界面刷新耦合在一起,改一处崩一片。
我建议从一开始就按“界面-业务-数据”三个层次拆,包结构通常是:
com.example.bus ├── ui # LoginFrame、MainFrame、RoutePanel、BusPanel ├── service # RouteService、ScheduleService、DispatchService ├── dao # RouteDao、BusDao、DriverDao └── model # Route、Bus、Driver、Schedule界面层只做两件事:把用户动作转成 service 调用,把 service 返回的结果显示到组件上。service 层处理排班、间隔计算、状态判断这些业务规则,不依赖任何 JFrame 类。dao 层只做参数化 SQL 和 ResultSet 到 model 的映射。model 是纯数据容器。
分层带来的一个隐性好处是 service 和 dao 可以在不打开窗口的情况下用 JUnit 测试。比如调度算法直接喂数据验证,不用反复重启界面去手工点按钮。启动失败时也好定位:报错堆栈落在 dao 层就是 SQL 问题,落在 service 层就是业务逻辑问题,落在 ui 层才需要查界面线程或布局。如果所有代码混在一起,异常栈四层深,每一层都像是一个黑匣子。
2.3 主窗口模块划分:JTabbedPane 承载四个业务页签
主窗口不推荐把 JFrame 的 contentPane 堆成巨型面板。我习惯把主界面拆成四个区域:顶部一条状态栏显示登录用户和数据库连接状态;中间主区用 JTabbedPane 放四个页签,分别是线路管理、车辆管理、调度监控、基础报表;每个页签内部再分左表右操作区;底部一条提示栏显示最近一次操作的结果。
这个划分的逻辑是让每个页签对应一条业务线,组件之间不互相引用。线路管理页签只依赖 RouteService 和 RouteDao,车辆管理页签只依赖 BusService。调度监控页签复杂一些,要同时读线路、车辆和实时位置三张表,所以单独抽一个 DispatchPanel 做聚合,不在 MainFrame 里写聚合逻辑。
JTabbedPane 本身不复杂,关键是要给每个页签一个独立的 JPanel 子类,而不是在 MainFrame 里匿名 new 出来堆在一起。独立类的好处是每个面板持有自己的 TableModel 和刷新逻辑,出问题时互不牵连。后面避坑章节里讲到的几个翻车点,很大一部分就出在“所有面板塞在一个类里”这种写法上。如果项目里发现某个文件超过 800 行,优先考虑拆面板,而不是继续往下堆。
3. 从登录到主控台:Swing 界面骨架的完整代码
3.1 登录窗口:JFrame 生命周期与角色判断
登录是入口,但登录窗口的写法有不少细节。多数教程里用一个 JFrame 就完事,我这里给出一个更接近生产环境的做法:登录窗口独立运行,校验通过后关闭自身并打开主控台窗口。
public class LoginFrame extends JFrame { private JTextField userField; private JPasswordField pwdField; private LoginService loginService; public LoginFrame() { loginService = new LoginService(); setTitle("智慧公交管理系统 - 登录"); setSize(360, 220); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); JPanel panel = new JPanel(new GridBagLayout()); GridBagConstraints gbc = new GridBagConstraints(); gbc.insets = new Insets(5, 5, 5, 5); gbc.gridx = 0; gbc.gridy = 0; panel.add(new JLabel("用户名:"), gbc); userField = new JTextField(15); gbc.gridx = 1; gbc.gridy = 0; panel.add(userField, gbc); gbc.gridx = 0; gbc.gridy = 1; panel.add(new JLabel("密码:"), gbc); pwdField = new JPasswordField(15); gbc.gridx = 1; gbc.gridy = 1; panel.add(pwdField, gbc); JButton loginBtn = new JButton("登录"); gbc.gridx = 1; gbc.gridy = 2; panel.add(loginBtn, gbc); loginBtn.addActionListener(e -> doLogin()); setContentPane(panel); } private void doLogin() { String user = userField.getText().trim(); String pwd = new String(pwdField.getPassword()).trim(); User u = loginService.authenticate(user, pwd); if (u != null) { new MainFrame(u).setVisible(true); dispose(); } else { JOptionPane.showMessageDialog(this, "用户名或密码错误", "登录失败", JOptionPane.ERROR_MESSAGE); } } }这段逻辑的关键点有三个:第一,登录窗口的默认关闭操作设置为 EXIT_ON_CLOSE,因为登录是应用的唯一入口,直接退出整个进程是合理的。第二,密码框用 JPasswordField,getPassword() 返回的是 char[] 而不是 String,这样做的目的是避免密码字符串长期留在堆内存里。第三,登录成功后 new MainFrame(u).setVisible(true) 然后 dispose() 关闭登录窗口,主控台拿到当前用户对象,后续按钮的可用性按角色判断。
LoginService 返回 User 而不是 boolean,是为了把角色信息带到主窗口。如果只返回 boolean,MainFrame 还得再查一次用户信息,多一次数据库往返。角色字段可以用 int 或枚举,比如 0 表示调度员、1 表示管理员、2 表示只读用户。界面层拿到角色之后,决定哪些页签可见、哪些按钮可点。
3.2 主控台窗口:JTabbedPane 组装业务面板的骨架
登录成功后进入主控台。主控台的主要职责是承载四个业务面板并维护顶部状态栏,不在里面写具体增删改查逻辑,只负责组装。
public class MainFrame extends JFrame { private JLabel statusLabel; private JTabbedPane tabPane; public MainFrame(User user) { super("智慧公交管理系统 - " + user.getName()); setSize(1024, 700); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); // 顶部状态栏 JPanel topBar = new JPanel(new BorderLayout()); topBar.setPreferredSize(new Dimension(0, 40)); statusLabel = new JLabel("当前用户: " + user.getName() + " 角色: " + user.getRoleName()); topBar.add(statusLabel, BorderLayout.WEST); // 中间页签:每个面板接收 user 用于内部权限控制 tabPane = new JTabbedPane(); tabPane.addTab("线路管理", new RoutePanel(user)); tabPane.addTab("车辆管理", new BusPanel(user)); tabPane.addTab("调度监控", new DispatchPanel(user)); tabPane.addTab("基础报表", new ReportPanel(user)); // 底部提示栏 JLabel tipLabel = new JLabel("就绪"); add(topBar, BorderLayout.NORTH); add(tabPane, BorderLayout.CENTER); add(tipLabel, BorderLayout.SOUTH); } }组装方式看着简单,但有两件事值得注意。第一,每个业务面板都接收 user 参数,权限控制的逻辑可以下沉到面板内部,而不是集中在 MainFrame 里用 if 判断。比如只读用户,RoutePanel 在构造时就把增删改按钮设为不可用,代码职责清晰。第二,底部 tipLabel 只是占位,实际项目里我会封装一个 StatusBar 类,带 setMessage(String msg) 方法供各面板调用,这样底部提示栏不会和某个具体面板耦合。
顶栏 JPanel 需要显式设置 preferredSize,否则 BorderLayout.NORTH 区域的高度可能撑不开,按钮和文字会挤在一起。setPreferredSize(new Dimension(0, 40)) 里的宽度 0 表示不限制宽度,只限定高度。这是一个经常被忽视的小地方,但不设置的话,界面在不同系统上的表现会有差异。
3.3 自定义 TableModel:JTable 与业务数据的桥梁
JTable 不直接接受 List ,需要经过 TableModel。Swing 自带的 DefaultTableModel 只能存 Object 二维数组,泛型和类型安全都缺失,实际开发中更推荐继承 AbstractTableModel 自己实现。
public class RouteTableModel extends AbstractTableModel { private List<Route> data = new ArrayList<>(); private final String[] columns = {"线路编号", "线路名称", "起点", "终点", "首班时间", "末班时间", "状态"}; public void setData(List<Route> newData) { data = newData; fireTableDataChanged(); } @Override public int getRowCount() { return data == null ? 0 : data.size(); } @Override public int getColumnCount() { return columns.length; } @Override public String getColumnName(int column) { return columns[column]; } @Override public Object getValueAt(int rowIndex, int columnIndex) { Route r = data.get(rowIndex); switch (columnIndex) { case 0: return r.getLineNo(); case 1: return r.getName(); case 2: return r.getStartStation(); case 3: return r.getEndStation(); case 4: return r.getFirstTime(); case 5: return r.getLastTime(); case 6: return r.getStatus(); default: return ""; } } }整个模型里最重要的方法是 fireTableDataChanged()。setData() 传入一个新的 List 之后调用它,通知 JTable 重新拉取数据。不调用这个方法,界面上永远显示旧数据,很多人在这一步翻车——数据库里改了数据,界面上毫无变化,还以为是查询失败。
为什么不用 DefaultTableModel?因为它是基于 Object 二维数组和 Vector 的,拿不到 Route 实体对象,需要根据行号取线路 ID 时还得从单元格里拼字符串,容易出错。自定义 RouteTableModel 可以加一个 getRouteAt(int row) 方法,事件监听里直接取当前行对应的实体对象,这才是可靠的做法。渲染器、数据、实体三者的类型安全都握在自己手里,后面做状态颜色渲染时不用再做无谓的类型转换。
4. 数据层与界面联动:JDBC、异步刷新与状态渲染
4.1 JDBC 与 DAO 层:参数化 SQL 是底线
智慧公交系统的数据模型通常包括线路表、车辆表、司机表、班次表和实时位置表。数据落在关系型数据库里,最常见的是 MySQL。DAO 层的写法决定整个项目的数据访问稳定性。
public class RouteDao { private DataSource dataSource; public RouteDao(DataSource dataSource) { this.dataSource = dataSource; } public List<Route> listRoutes(String keyword) throws SQLException { String sql = "SELECT id, line_no, name, start_station, end_station, " + "first_time, last_time, status FROM route " + "WHERE name LIKE ? OR line_no LIKE ? ORDER BY line_no"; List<Route> result = new ArrayList<>(); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + keyword + "%"; ps.setString(1, like); ps.setString(2, like); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Route r = new Route(); r.setId(rs.getInt("id")); r.setLineNo(rs.getString("line_no")); r.setName(rs.getString("name")); r.setStartStation(rs.getString("start_station")); r.setEndStation(rs.getString("end_station")); r.setFirstTime(rs.getString("first_time")); r.setLastTime(rs.getString("last_time")); r.setStatus(rs.getInt("status")); result.add(r); } } } return result; } }使用 PreparedStatement 而不是拼接 SQL,是这条代码的底线。站名、线路名可能包含引号,直接拼接字符串不仅容易出错,还会带来注入风险。所有按条件查询都应该参数化。listRoutes 方法里两个占位符都用了 LIKE 的模式串,而不是直接拼接前后百分号,这样写清晰且不会漏掉转义。
try-with-resources 在这里承担了连接关闭的职责。Connection、PreparedStatement、ResultSet 三者都在 try 块里声明,方法结束后自动关闭,不会出现连接泄漏。连接泄漏是数据库连接池告警里最常见的现象,解法没有捷径,就是这种写法。
数据源从外部传入,而不是在 DAO 内部 new 一个 Connection,是为了后续接连接池。常见做法是在启动类里用 HikariCP 初始化 DataSource,再传给 service 层,换数据库或调连接池参数时不用改 DAO。
提示:HikariCP 的 maximumPoolSize 不用设得很大,内部工具 10 个连接足够,设置过大会增加数据库端的无效连接开销。
4.2 SwingWorker 刷新数据:EDT 阻塞导致的界面假死
Swing 的所有界面更新都必须在事件分发线程(EDT)上完成。如果直接在 ActionListener 里执行一个耗时两秒的数据库查询,界面上所有按钮都会无响应,这就是典型的“界面假死”。
JButton refreshBtn = new JButton("刷新线路"); refreshBtn.addActionListener(e -> { refreshBtn.setEnabled(false); new SwingWorker<List<Route>, Void>() { @Override protected List<Route> doInBackground() throws Exception { return routeDao.listRoutes(keywordField.getText().trim()); } @Override protected void done() { try { List<Route> routes = get(); model.setData(routes); statusBar.setMessage("已加载 " + routes.size() + " 条线路"); } catch (Exception ex) { statusBar.setMessage("刷新失败: " + ex.getMessage()); } finally { refreshBtn.setEnabled(true); } } }.execute(); });doInBackground() 里做的事情在 SwingWorker 的后台线程中执行,不占用 EDT;done() 方法会自动切回 EDT 线程执行,所以可以在里面安全调用 model.setData() 和 statusBar 更新。这个分工是 SwingWorker 的核心特征,也是最容易用错的地方。
两个常见误用需要特别注意。第一,在 doInBackground() 里更新界面组件,这不会自动切回 EDT,仍然可能触发跨线程访问,导致界面不刷新或偶发异常。第二,刷新按钮没有禁用,用户手快点了十下,就会发起十个重复查询,数据库压力瞬间上去,界面响应更慢。示例里先 setEnabled(false),finally 里恢复,这是最简单的防重复提交手段。
4.3 TableCellRenderer 做车辆状态颜色标注
调度监控页签需要一眼看出车辆处于“运营中”“停运”“维修”还是“事故”,JTable 必须能根据行数据给单元格上色。DefaultTableCellRenderer 是这里最常用的工具。
class StatusCellRenderer extends DefaultTableCellRenderer { @Override public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); if (!isSelected) { Object raw = table.getModel().getValueAt(row, 6); int status = (raw instanceof Integer) ? (Integer) raw : -1; switch (status) { case 1: setForeground(new Color(0, 128, 0)); break; // 运营中 case 2: setForeground(new Color(200, 120, 0)); break; // 停运 case 3: setForeground(Color.RED); break; // 维修 default: setForeground(Color.GRAY); break; } } return this; } }使用方式是在对应列上挂载渲染器:table.getColumnModel().getColumn(6).setCellRenderer(new StatusCellRenderer())。渲染器每画一个单元格都会被调用一次,所以里面不应做耗时操作,比如二次查询数据库,这会导致滚动表格时明显卡顿。
取值时要小心类型。这里从 table.getModel().getValueAt(row, 6) 拿到的可能是 Integer,也可能是 String,取决于 TableModel 第 6 列的实现。我在改造别人代码时多次遇到 ClassCastException,解法是统一让 TableModel 的状态列返回 Integer,并在渲染器里做 instanceof 防御。另一个细节是选中状态下不要覆盖前景色,否则选中行高亮背景和自定义前景色叠加,往往看不清文字,所以代码里只在 !isSelected 时调整颜色。
5. 避坑指南:Swing 开发与智慧公交联调中五个翻车点
5.1 刷新后界面不变:TableModel 没通知
现象:数据库里新增了一条线路,点了刷新按钮,JTable 没有任何变化,也没有报错。反复确认查询 SQL 没问题,数据确实查出来了,但表格就是不动。
原因:TableModel 的 setData() 里替换了 List,但没有调用 fireTableDataChanged()。JTable 不知道数据变了,自然不会重绘。另一种隐蔽的子原因是 setData() 里先 clear() 了旧列表,再逐条 add(),但通知写在了 clear() 之后、add() 之前,数据还没加完就通知了界面。
解决:setData() 里赋值新 List 后必须调用 fireTableDataChanged()。排查时在通知方法前后加日志,确认 JTable 的 repaint 有没有被触发。把 add 和更新通知放在一起,保持“先改数据后发通知”的固定顺序。以后不管谁来改这个模型,只要遵守这个约定,就不会出现数据显示与库里不一致的情况。
5.2 双击 jar 包没反应:启动失败的黑匣子排查
现象:在 IDE 里点 Run 程序正常跑起来,打成 jar 后双击没反应,什么弹窗都没有。很多人第一反应是打包工具问题,折腾半天下载各种封装工具,其实问题根本不在打包这一步。
原因:常见原因有三个。第一,本地编译用的 JDK 版本和运行机器的 JRE 版本不匹配,JDK 17 编译的 class 放到 JRE 8 上直接报 UnsupportedClassVersionError。第二,MANIFEST.MF 里没写 Main-Class 属性,java -jar 不知道从哪个类启动。第三,用了第三方依赖如 JDBC 驱动,但 jar 包里没有把它们打进去,运行时报 NoClassDefFoundError。
解决:先到命令行执行 java -jar your-app.jar,黑匣子立刻打开,报错信息会直接指出是版本问题还是缺少主类。然后用 maven-shade-plugin 或类似工具打 fat jar,把依赖打进去。MANIFEST.MF 里的 Main-Class 必须写全限定名,不带 .class 后缀。如果环境变量配置不当导致 java 命令本身启动失败,先到命令行执行 java -version 确认当前默认 JDK 是哪一个,再决定是改 PATH 还是改用绝对路径调用 java。
5.3 中文乱码贯穿全流程:三处编码缺一不可
现象:界面按钮、表格里的中文显示成问号,控制台打印 SQL 也是乱的,导出的报表更没法看。
原因:这不是单点问题,而是三处编码不一致叠加的结果。第一,源代码文件用 UTF-8 保存,但 javac 按平台默认编码(Windows 上是 GBK)编译,字符串字面量在 class 文件里就错了。第二,数据库连接 URL 没有指定 characterEncoding,写入和读取中文的编码不一致。第三,运行环境的 file.encoding 和编译时不匹配,部分字符在运行时被转换坏掉。
解决:统一三处编码。源码保存为 UTF-8,在 Maven 的 pom.xml 里设置 project.build.sourceEncoding 为 UTF-8。数据库连接 URL 显式加上 useUnicode=true 和 characterEncoding=utf8(以 MySQL 8 驱动为例)。运行时若还在 IDE 里乱码,给 JVM 加 -Dfile.encoding=UTF-8 启动参数。三处全部统一之后,乱码问题基本能根除,不需要在代码里到处做字符串编码转换。
5.4 关窗进程不退出:线程和关闭流程的清理顺序
现象:点窗口右上角 X,界面关了,但 Java 进程还活着,端口和数据库连接一直被占着。开发机上不明显,部署到服务器上后,重启应用时经常报端口被占用。
原因:窗口关闭不等于进程退出。JFrame.EXIT_ON_CLOSE 会退出整个进程,但如果项目为了做“关闭前确认”改成了 DO_NOTHING_ON_CLOSE,就要自己处理退出逻辑。除此之外,调度监控里通常有定时刷新用的 Swing Timer,或者轮询数据库的后台线程,这些非守护线程没有停掉,JVM 就不会退出。
解决:在 windowClosing 事件里按顺序清理:先停掉所有 Swing Timer 和 SwingWorker,再关闭数据源(HikariCP 调 dataSource.close()),最后调用 System.exit(0)。如果直接 System.exit(0) 而不关连接池,未提交的事务会被强制中断,可能产生脏数据。退出前还应该检查有没有未保存的编辑记录,弹窗提示用户,这个步骤加上以后,误关窗口的损失能降不少。
5.5 定时轮询错过发车状态:刷新时机与业务事件不同步
现象:调度监控面板每分钟刷新一次车辆状态,但屏幕上显示某辆车还在“停运”,实际上它五分钟前已经发车了,现场广播和界面信息对不上。
原因:固定间隔轮询是采样逻辑,而公交业务有明确的发车时刻、到站时刻。用固定间隔去采样一个随时可能变化的状态,必然会错过两次采样之间的状态切换。轮询改成 30 秒、10 秒,也只是降低漏报概率,不能根除。
解决:把数据驱动方式反过来,让状态变化去通知界面刷新,而不是让界面盲目轮询。具体做法是建一个内存事件队列:车辆状态变更时,写入数据库并在队列里发一条事件;界面在 EDT 上监听队列,收到事件才刷新对应行。如果现阶段不上队列,至少让轮询间隔小于最小业务事件间隔的一半。比如最小发车间隔是 5 分钟,轮询间隔就得小于 2.5 分钟,否则一定出现丢状态。这个原则在排班系统的所有定时任务里都适用。
6. 把“智慧”做实:排班算法、站点自绘与验证方法
6.1 一个够用的发车间隔计算逻辑
排班算法的“智慧”不体现在复杂模型上,而在于边界处理。核心逻辑是根据线路车辆数和单趟往返时长计算发车间隔:
public int calcInterval(int vehicleCount, int cycleMinutes) { if (vehicleCount <= 0) { throw new IllegalArgumentException("车辆数必须大于 0"); } return Math.max(5, (int) Math.ceil(cycleMinutes / (double) vehicleCount)); }两个边界值得注意:车辆数为 0 时直接抛业务异常而不是除零;间隔不得低于 5 分钟的硬下限。调度面板每次修改车辆数或路况等级时重算这个值,把结果显示在界面上。如果要按发车间隔在表格里排序,用 TableRowSorter 配一个 Comparator 即可,不必把排序逻辑写进 SQL。
6.2 用 JPanel 自绘站线图
站点地图如果去接在线地图 SDK,依赖重且需要网络,内部大屏反而不好维护。我建议在调度监控页签里用一个 JPanel 自绘:站点画成圆点,线路连成折线。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); for (int i = 0; i < points.size() - 1; i++) { Point p1 = points.get(i), p2 = points.get(i + 1); g2.drawLine(p1.x, p1.y, p2.x, p2.y); } for (Point p : points) { g2.fillOval(p.x - 4, p.y - 4, 8, 8); } }站点坐标不是经纬度,而是初始化时算好的相对百分比坐标,存到 List 里。收到位置更新事件后调用 repaint() 触发重绘,整个站点图就跟着动起来。反锯齿开关放在这里,画出来的线比默认效果平滑很多。
6.3 验证这套系统的三个技巧
第一,用 Swing Timer 模拟车辆位置数据,每两秒生成一条位置记录喂给调度监控面板,验证状态颜色切换、刷新是否及时,这是最常用的回归手段,不需要真实 GPS 数据也能跑完整套逻辑。第二,盯着 EDT 占用:查询一律在 SwingWorker 的 doInBackground 里做,界面更新在 done 里做,遵守这条,界面假死问题基本绝缘。第三,用连接池日志观察连接使用情况,确认 DAO 的 try-with-resources 没有连接泄漏。做完整个系统后,我的习惯是把数据刷新频率刻意调高,让系统在高频更新下硬跑一段时间,暴露渲染和线程问题。这套系统的质量,其实全靠这种贴近真实压力的验证方式打磨出来,而不是靠写完代码看一眼觉得没问题就算结束。希望帮到你。
本文还有配套的精品资源,点击获取