资讯动态

Java Swing智慧公交管理系统实战:从SQLite到BFS换乘算法

发布时间:2026/10/14 15:45:26 来源:尧图企业网站定制
简介一份面向 Java 初学者的智慧公交管理系统完整源码包基于 Java GUI 与 MySQL 8.0 实现涵盖车辆、员工、线路、站点、排班等核心业务模块并提供管理员、调度员、员工三类角色的独立登录与权限管理。系统附带 busmanage.sql 数据库脚本建好了员工表、登录表、排班表、站点表等共 7 张数据表配合 mysql-connector-java-8.0.29.jar 驱动在 IDEA 2021.3.3 与 JDK 1.8 环境下配置后可直接运行。资源共 2 个文件包含 1 个项目压缩包和 1 个数据库脚本总大小约 10.72MB适合用于课程设计、数据库大作业或 Java GUI 开发实战参考。目前已有 1137 人学习压缩包目录结构清晰可帮助理解登录鉴权、多表关联查询与界面事件绑定等关键实现是快速上手完整桌面管理系统的实用素材。1. 智慧公交管理系统为什么Java GUI仍是课程设计最稳的选型每年到了课程设计和毕业设计季总有人纠结同样的选题。智慧公交管理系统被拿出来讨论的次数最多理由也最直白这是典型的窗口应用需求看得见摸得着能查线路、管车辆、做换乘查询业务逻辑不深不浅Java GUI 正好落在能力圈内。我的观点可能和很多人反过来——这个项目的难点从来不在 Swing 拖几个控件而在数据结构和线程模型。你把这层想清楚界面只是时间问题。选择 Java Swing 不是在开倒车。对于这类管理系统Swing 的成熟度、资料密度和部署方便程度真实项目里反而比炫技方案更省心。适合谁做适合还没接触过框架、想用一门语言完整走一遍“设计表结构—写业务逻辑—做界面”全过程的人。本文不按教程腔从头讲到尾只讲我按这个标题做下来认为最值得抄的路径以及那些会让你通宵查问题的坑。2. 搭骨架Swing SQLite 的分层架构与数据持久化2.1 Swing 还是 JavaFX选型依据一张表说清楚每次有人问界面用 Swing 还是 JavaFX我都先问一句你的运行环境是不是要带着 JDK 到处跑如果答案是“是”Swing 基本没有对手。做个管理系统最关键的不是控件多炫而是别人拿过去能跑起来。对比项SwingJavaFXJDK 自带程度从 JDK 1.2 起就在开箱即用JDK 8 内嵌JDK 11 起需单独引入模块学习资料密度课程设计、毕业设计案例最多偏企业级中文案例略少表格能力JTable 成熟稳定够用TableView 功能更强但有学习曲线部署复杂度java -jar 直接运行需要处理模块化参数我一般直接选 Swing不为别的JTable 配合 TableModel 做数据台账省下来的时间去写换乘算法性价比高得多。JavaFX 的优势在动画和样式但公交管理系统的消耗大头是数据表格和表单Swing 的表现足够稳定。2.2 包结构与三层架构从 model 到 dao 到 view架构上不需要过度设计但这套分层必须在一开始就立住否则后期改需求会非常痛苦。我的习惯是把工程分成五个包分别放实体、数据访问、业务逻辑、窗口界面和工具类。常见做法是com.xxx.bus ├── MainApp.java # 入口只负责启动 ├── model/ # 实体类BusLine, Station, Vehicle ├── dao/ # JDBC 数据访问每个类对应一张表 ├── service/ # 业务层换乘算法、排班判断 ├── view/ # Swing 窗口主窗体、各种面板 └── util/ # 数据库连接、常量配置入口类看起来简单但有一个细节容易忽略Swing 界面必须在事件分发线程上创建直接写在 main 里跑会埋下偶发问题。规范写法是这样public class MainApp { public static void main(String[] args) { // Swing 的单线程模型要求界面创建放在 EDT 上 SwingUtilities.invokeLater(() - { MainFrame frame new MainFrame(); frame.setVisible(true); }); } }这段代码里 invokeLater 的作用是把界面初始化任务丢给事件分发线程串行执行。这样做的原因是 Swing 的组件绝大多数不是线程安全的如果在主线程直接 new 窗口遇到后面加定时器刷新时界面重绘和状态更新就可能打架。刚开始不写这行也能跑但学会写它是做所有 Swing 项目的第一课。2.3 SQLite 还是 MySQL连接代码与配置要点数据库选型上我倾向 SQLite。它把整个库封装在一个文件里交作业时直接连同 .db 文件一起给不需要评审现场装数据库服务省掉大量环境问题。MySQL 适合多人协作或数据量大的场景但单机版智慧公交管理系统用不到那个量级。SQLite 的 JDBC 驱动配置特别简单把 sqlite-jdbc 的 jar 放进 lib 目录后连接代码只有一行Connection conn DriverManager.getConnection(jdbc:sqlite:db/bus_system.db);连接串里的 db/bus_system.db 是数据库文件路径。第一次运行如果文件不存在驱动会自动创建空的库文件不需要手动建库。这点对新手很友好但也带来一个坑如果程序跑起来发现表不存在不是连接失败而是没人执行建表脚本。我把建表语句放在初始化类里程序启动时自动执行CREATE TABLE IF NOT EXISTS bus_line ( id INTEGER PRIMARY KEY AUTOINCREMENT, line_no TEXT NOT NULL, start_station TEXT NOT NULL, end_station TEXT NOT NULL, interval_min INTEGER DEFAULT 10 );CREATE TABLE IF NOT EXISTS 这种写法可以反复执行不会报错。注意 SQLite 的整数主键用 INTEGER PRIMARY KEY 就能自增不需要额外写 AUTOINCREMENT除非你有特殊需求。interval_min 表示发车间隔这个字段后面做排班和准点率统计都要用一开始就要建上。2.4 数据访问层DAO 的标准写法与参数说明做数据访问层时最常见的翻车是每查一次就打开一个连接用完不关最后 SQLite 文件锁死。我的做法是整个程序启动时创建一个连接通过工具类集中管理数据库操作都在这个连接上执行。为了演示完整写法这里给出线路表的数据访问代码public class LineDao { private final Connection conn; // 构造时传入连接避免每次查询都重新建立连接 public LineDao(Connection conn) { this.conn conn; } public ListBusLine findAll() throws SQLException { ListBusLine list new ArrayList(); String sql SELECT id, line_no, start_station, end_station, interval_min FROM bus_line; try (PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { BusLine line new BusLine(); line.setId(rs.getInt(id)); line.setLineNo(rs.getString(line_no)); line.setStartStation(rs.getString(start_station)); line.setEndStation(rs.getString(end_station)); line.setIntervalMin(rs.getInt(interval_min)); list.add(line); } } return list; } }这里有两个关键点需要说明。第一PreparedStatement 必须放在 try 括号里这是 JDK 7 的 try-with-resources 语法它保证无论查询是否抛异常PreparedStatement 和 ResultSet 都会被自动关闭不会占住数据库句柄。第二传入的 Connection 是外部共享的所以 DAO 内部不能关闭它只关自己的 Statement 和 ResultSet这个边界要分清楚。同理新增线路和修改线路的写法也是同一个套路只是 SQL 换成 INSERT 和 UPDATE参数通过 ps.setString 和 ps.setInt 绑定。参数绑定不要用字符串拼接哪怕现在数据全是你自己录入的也要养成绑定的习惯后面接外部数据时能少一类注入问题。3. 把界面做出来主窗体布局、表格定制与查询联动3.1 主窗体布局与功能导航一屏装下所有子模块智慧公交管理系统的功能一般有线路管理、站点管理、车辆信息、换乘查询、数据统计这几块。界面结构我用的是左侧导航加右侧内容区的经典布局右侧用 CardLayout 切换不同功能页这样切页时不会重建组件数据状态能保留体验也稳定。布局代码核心部分如下public class MainFrame extends JFrame { private CardLayout cardLayout new CardLayout(); private JPanel contentPanel new JPanel(cardLayout); private LinePanel linePanel; private VehiclePanel vehiclePanel; public MainFrame() { setTitle(智慧公交管理系统); setSize(1000, 650); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); linePanel new LinePanel(); vehiclePanel new VehiclePanel(); contentPanel.add(linePanel, line); contentPanel.add(vehiclePanel, vehicle); // 左侧按钮触发切页 JButton lineBtn new JButton(线路管理); lineBtn.addActionListener(e - cardLayout.show(contentPanel, line)); // vehicleBtn 同理 } }每个功能面板都是一个独立的 JPanel 子类比如 LinePanel 内部自己完成表格、表单按钮和事件处理代码隔离清晰。CardLayout 的好处是切换只是显示和隐藏面板里的表格数据不会因为切页而丢失回到页面不需要重新查询数据库。3.2 用 JTable TableModel 做可增删改的线路台账表格是这套系统的核心交互区。JTable 的使用有一个重要认知不要直接把二维数组丢给 JTable 完事要自定义 AbstractTableModel。这样做的直接收益是后续刷新数据只需更新 Model 再触发一个事件表格自动重绘而不是整表销毁重建。public class LineTableModel extends AbstractTableModel { private final String[] columns {线路编号, 起点站, 终点站, 发车间隔(分钟)}; private final ListBusLine lines new ArrayList(); public void setLines(ListBusLine newLines) { lines.clear(); lines.addAll(newLines); // 通知表格整个数据范围都变了触发重绘 fireTableDataChanged(); } public void addLine(BusLine line) { lines.add(line); // 只通知插入了新行避免整个表格闪烁 fireTableRowsInserted(lines.size() - 1, lines.size() - 1); } Override public int getRowCount() { return lines.size(); } Override public int getColumnCount() { return columns.length; } Override public Object getValueAt(int rowIndex, int columnIndex) { BusLine line lines.get(rowIndex); switch (columnIndex) { case 0: return line.getLineNo(); case 1: return line.getStartStation(); case 2: return line.getEndStation(); case 3: return line.getIntervalMin(); default: return null; } } }setLines 方法用 fireTableDataChanged 做整体刷新适合初始化或条件搜索后重新灌数据addLine 用 fireTableRowsInserted 只通知新增一行适合表单提交后局部刷新。这两者的区别在数据量大时会直接体现为界面卡顿和闪烁属于必须掌握的细节。JTable 本身的配置也有讲究。至少要关掉列拖拽否则用户随手一拖就把表头顺序打乱了table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.getTableHeader().setReorderingAllowed(false); table.getColumnModel().getColumn(0).setPreferredWidth(80); table.getColumnModel().getColumn(1).setPreferredWidth(200);selectionMode 设为单选避免用户按住 Ctrl 选中多行导致删除操作目标不确定。列宽设置里线路编号给窄值站点给宽值这属于纯视觉调节但做不做对评委的第一观感影响很大。3.3 搜索筛选与表格刷新的联动处理搜索功能如果实现得糙最常见的毛病是每次按键都去数据库查一遍导致输入框打字都卡。我一般把搜索做成两种模式一是查询按钮触发精确搜索二是按回车或防抖延迟触发实时过滤。课程设计阶段不用做太复杂做一个“线路编号模糊搜索”加一个“起点站精确匹配”就够覆盖演示需求。做法是在查询按钮的事件里重新走一遍 DAO 查询然后把结果灌进 TableModelsearchBtn.addActionListener(e - { String keyword searchField.getText().trim(); // 注意参数传递使用 PreparedStatement 绑定不做字符串拼接 ListBusLine result lineService.searchLines(keyword); tableModel.setLines(result); });searchLines 在 Service 层内部根据传入关键字拼 SQL 的 WHERE 条件用 LIKE 做模糊匹配。参数说一下LIKE 的匹配模式用 keyword %代表前缀匹配也可以用 % keyword % 做包含匹配但包含匹配在数据量大时无法走索引这个表规模无所谓养成前缀匹配的习惯没有坏处。刷新时机要注意一点如果用户在表格里删了一行然后立即点搜索界面上的数据要重新同步。常见做法是删除成功后统一调用 refreshTable 方法内部逻辑就是重新查一次当前列表并 setLines。不要试图去手动删表格行那会让数据源和界面状态分叉后续操作各种对不上。4. “智慧”在哪里线路建模、换乘算法与车辆实时模拟4.1 数据模型线路-站点-车辆的三张核心表关系管理系统做得像不像样首先要看数据库表设计。公交领域的核心关系是一条线路经过多个站点一个站点能有多条线路经过一辆车归属于某条线路。这中间必须用关联表把多对多关系拆开否则换乘查询写不出来。我设计的核心表有这几张外加一张线路站点关联表表名作用关键字段bus_line线路基本信息和发车参数line_no, start_station, end_station, interval_minbus_station站点信息station_name, lng, latbus_line_station线路与站点的关联line_id, station_id, seqbus_vehicle车辆信息vehicle_no, line_id, statusbus_line_station 里的 seq 字段是精髓它表示站点在线路上的顺序从 1 开始递增。有了这个字段两个站点是否在同一线路上、中间隔几站直接查这张表排序即可不需要在代码里做复杂推导。车辆表的 status 字段我用整数表示0 表示停运、1 表示运营中、2 表示维修用整数而不是字符串是为了后续做统计时能直接 sum 和 count。4.2 换乘推荐无权图求最少换乘的 BFS 实现换乘查询是“智慧”二字的验收点。这里有一个容易想复杂的点如果只要求“换乘次数最少”问题等价于在无权图上求最少边数用 BFS 就能解决不需要上 Dijkstra。只有在计算“最短距离”时才需要带权重的算法。我的建图思路是把每个站点当作节点把同一条线路上的相邻站点用边连起来。这样一次换乘的路径在图上就是经过两个节点之间的若干条边。用 BFS 从起点站扩散一旦到达终点站路径就是换乘次数最少的方案。public ListTransferPath findMinTransfer(String startStation, String endStation) { // BFS 队列节点为站点ID QueueInteger queue new LinkedList(); // prevStation 记录每个站点是从哪个站点过来的 MapInteger, Integer prevStation new HashMap(); // prevLine 记录每条转移用的线路用于后续描述换乘方案 MapInteger, Integer prevLine new HashMap(); SetInteger visited new HashSet(); int startId stationDao.findIdByName(startStation); int endId stationDao.findIdByName(endStation); queue.add(startId); visited.add(startId); while (!queue.isEmpty()) { int current queue.poll(); if (current endId) break; ListLink links routeDao.findLinksByStation(current); for (Link link : links) { if (!visited.contains(link.getTargetStationId())) { visited.add(link.getTargetStationId()); prevStation.put(link.getTargetStationId(), current); prevLine.put(link.getTargetStationId(), link.getLineId()); queue.add(link.getTargetStationId()); } } } // 回溯 prevStation 得到完整路径 return buildPath(startId, endId, prevStation, prevLine); }findLinksByStation 返回当前站点能通过哪条线路到达的下一个站点。visited 集合是 BFS 的关键它保证每个站点只在第一次被发现时被处理避免回头路和死循环。由于公交站点的图可能成环不加 visited 程序会直接进入无限循环这是很多同学第一次写 BFS 时踩的坑。buildPath 从终点站一路按 prevStation 回溯到起点再把这段路径按换乘点切开得到“几号线→在某站换乘→几号线”的文本描述。这个回溯过程是 BFS 的固定套路本质上是用空间换时间存下每个节点的前驱最后倒着找回完整路径。这里提醒一个和直觉有关的问题BFS 保证换乘次数最少但不保证总站数最少。用户可能觉得“明明有更近的路线”因为近似路线换了三次但站数少。真实系统中一般把换乘次数作为第一优先级总站数作为第二排序条件在结果展示里标注“换乘 1 次共 12 站”逻辑就站得住。4.3 车辆位置模拟定时器驱动的“伪实时”数据流“实时监控车辆位置”听起来像是要接 GPS 和大屏可视化但在单机课程设计里完全可以用定时器驱动模拟数据流来实现。这个方案的原理很简单车辆表里存当前所在站点序号定时器每隔几秒把在线车辆的位置字段向后推进一个站点界面同时刷新表格里的“当前站点”列视觉上就有了车辆在线路上移动的效果。Timer timer new Timer(3000, e - { // 从 service 层取得所有运营中的车辆 ListVehicle vehicles vehicleService.getRunningVehicles(); for (Vehicle v : vehicles) { int currentSeq v.getCurrentSeq(); int nextSeq currentSeq 1; if (nextSeq v.getTotalSeq()) { nextSeq 1; // 到达终点后从起点重新开始模拟循环线路 } vehicleService.updateCurrentSeq(v.getId(), nextSeq); } // 刷新界面表格只发射数据变更事件 refreshVehicleTable(); }); timer.start();javax.swing.Timer 的回调是放在事件分发线程上的所以在这个回调里直接操作表格组件是安全的不需要再做 SwingUtilities.invokeLater 包装。触发间隔 3000 毫秒是个可以调的参数写 2000 会感觉车跑得快写 5000 更适合演示时逐站讲清楚。注意 updateCurrentSeq 不要每辆车单独提交事务可以攒一批再统一更新减少数据库写入压力。这个模拟方案的真实价值在于它把表格从“一次性查询结果”变成了“持续变化的数据源”这才配得上“智慧”两个字。数据量大了之后可以考虑把 Timer 的刷新目标和表格的 ToolTip 结合让鼠标悬停到车辆编号时显示“下一站预计到达时间”实现成本极低但演示效果提升明显。5. Java GUI 高频踩坑与排查从假死到乱码的现场记录5.1 界面假死为什么 UI 线程不能跑数据库查询现象点击“加载全部线路”按钮后窗口立刻变成无响应状态标题栏显示“正在响应”过几秒才恢复数据量大时直接像死机。原因按钮的事件回调运行在事件分发线程上也就是 EDT。EDT 做的事越少界面越流畅。如果你在回调里查询数据库要么是查询慢要么是 SQLite 在写数据时被占锁EDT 被卡住就停止绘制和响应鼠标事件看起来就是假死。解决把耗时操作挪出 EDT。SwingWorker 是最标准的做法doInBackground 里执行查询done 里操作界面组件更新表格。界面操作的代码永远不要出现在 doInBackground 和任何后台线程里这是 Swing 并发的第一铁律。5.2 中文乱码编译期到运行期一整条链路排查现象控制台输出正常但窗口界面上所有中文都变成“锟斤拷”或问号数据库里读出来的中文也显示乱码。原因编码问题是一条链路任何一环断了都会乱码。先查源文件编码再查编译编码然后查 JDBC 连接串是否声明 UTF-8最后查数据库表字符集。SQLite 不存在字符集问题所以这个项目里乱码几乎都出在前两环。解决IDE 里把工程文件编码统一为 UTF-8如果用命令行编译javac 加上 -encoding UTF-8。Main 方法里可以强制设置 JVM 默认字符集但这属于兜底手段根子上还是要统一源文件编码。我建议在代码里尽量避免用 new String(bytes, GBK) 这种硬编码转换让数据从入库到展示一直保持 UTF-8。5.3 表格刷新闪烁整表重建与增量更新的差别现象搜索或删除操作后表格区域明显发白闪一下操作频繁时视觉上像屏幕在跳动观感很差。原因代码里用了 table.setModel(new DefaultTableModel(...)) 或者 setRowCount(0) 后重新 addRow这等于把整个表格销毁重建重绘工作量变大闪烁就出现了。默认表格模型在每次 addRow 时都会触发结构事件多次连续 addRow 会产生大量重绘请求。解决改用自定义 AbstractTableModel并且用带精确位置的更新方法。整体数据变化时用 fireTableDataChanged单独加一行用 fireTableRowsInserted。只改某一行的某个单元格时用 fireTableCellUpdated尽量避免无差别的整表通知。真需要整表刷新的场景先把界面重绘挂起数据更新完再恢复。5.4 SQLite 数据库被锁连接关闭的“后悔药”与预防现象程序运行一段时间后执行某个新增或更新操作时抛异常提示 database is locked重启程序又恢复正常过一阵再次出现。原因SQLite 是文件型数据库并发写同一文件时会对文件加锁。应用里如果创建了多个连接一个连接打开了写事务没提交另一个连接去写就被阻塞。最常见的情况是 DAO 里每查一次构建新连接查完只关 ResultSet 没关 Connection连接对象被垃圾回收时事务状态不明确锁就释放不掉。解决单机应用只保留一个全局连接所有 DAO 共享写操作做成短事务执行完立刻 commit。Swing 的定时刷新如果用后台线程写数据要在写入方法上加锁或者统一回到 EDT 上执行写库。这条经验算是血泪教训排查时最隐蔽因为报错信息不是每次必现只在时间凑巧时冒出来。5.5 换乘结果与直觉反着BFS 返回了但用户不认可现象换乘查询能跑通但结果是一条很绕的路径比如明明在站台对面就能换乘算法给的方案却要多走一站。原因换乘次数最少不等于步行距离最少或总站数最少。BFS 以“经过的相邻站点边数”为代价两个站点若在相同的两条线上BFS 可能给出上车后多坐几站再换乘的结果而人眼直觉会选“这站下车原地等另一条线”。算法数学上正确但不符合直觉。解决在图建模阶段把“同站换乘”定义为代价 0 的边也就是把同一个站点在不同线路上的节点视为连通这样 BFS 在代价相同情况下就能返回换乘点更合理的路径。如果还有余力在 BFS 的结果集中做二次排序把总站点数最少的结果排在最前面展示时附上换乘点和预计站数让用户有确认依据。6. 升级技巧用 SwingWorker 把静态 CRUD 变成可控的实时监控台这一章不写新功能讲一个把现有系统从“能交差”提升到“能演示”的技巧把界面启动时的数据加载和定时刷新统一放进 SwingWorker让工程真正具备后台任务的正确处理范式。先看核心用法。程序启动进入主界面时往往要加载线路、车辆、站点三类数据串行查询要一两秒。把这个过程挪到后台线程界面先显示“数据加载中”加载完成自动切入内容页面用户感知会好很多SwingWorkerVoid, Void loader new SwingWorker() { Override protected Void doInBackground() { // 后台线程执行不能触碰任何界面组件 allLines lineDao.findAll(); allStations stationDao.findAll(); allVehicles vehicleDao.findAll(); return null; } Override protected void done() { // 回到 EDT可以安全更新组件 lineTableModel.setLines(allLines); stationTableModel.setStations(allStations); vehicleTableModel.setVehicles(allVehicles); cardLayout.show(contentPanel, line); } }; loader.execute();doInBackground 里的任务是全局唯一的连接所以不存在并发问题。done 里代码都在 EDT 上执行不需要再包 invokeLater。注意不要在 doInBackground 里调用任何 setText、setModel、fireTableXxx 之类操作那会触发跨线程访问组件的异常表现为控制台刷一堆异常但界面有时正常有时错乱。接下来是第二个习惯定时器也要用 SwingWorker 包一层吗分情况。如果定时任务只是内存里计算再刷新表格直接在 Timer 回调里做即可因为 Timer 回调本身就在 EDT。如果任务要查数据库哪怕 SQLite 很快也应先由后台线程查出结果再交给 EDT 更新表格。最稳妥的模式是混合使用SwingWorker 管加载Timer 管周期触发两者都遵循“后台取数据EDT 更新视图”的规则。最后一个扎实的习惯是统一封装刷新方法。我一般会在每个功能面板内部写一个私有方法 refreshAll它负责从 service 层拿最新数据、更新 TableModel 并处理异常。界面所有入口都调这个统一方法包括搜索按钮、删除成功后的回调、Timer 的 tick这样逻辑只有一份排查问题时只要看一个方法就够了。我之前就是因为加载逻辑散落在按钮事件和构造器里导致启动时表格空白、点搜索才出现数据调了半小时才意识到是初始化时序问题。统一后这个问题再没复现过。这套做法你不一定原样照搬但“耗时操作不进 EDT、数据刷新只走一个方法”这两条能守住项目稳定性和演示效果都不会差。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑