ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Swing+MySQL个人财务系统实战:轻量可控的桌面记账方案

Java Swing+MySQL个人财务系统实战:轻量可控的桌面记账方案

简介:这是一套基于Java Swing开发的个人财务收支管理系统源码,面向Java初学者与C/S架构实践者,聚焦GUI编程、MySQL数据库交互及桌面应用完整开发流程。资源采用Eclipse+JDK+MySQL技术栈,覆盖用户管理、收支分类、收入/支出CRUD、密码修改与登录等核心功能模块,适合用于课程设计、毕业设计或Swing实战训练。压缩包共111个文件,含22个可读Java源文件(如login.java、shouruadd.java)、80个编译后class文件、5个依赖jar包、1个建库脚本javacaiwu.sql及项目配置文件,整体大小2.91MB,结构清晰便于逐层理解MVC逻辑与数据库连接机制。已有207人学习下载,提供完整可运行环境(管理员账号admin/admin)、数据库配置说明及CS架构典型实现范例,助读者快速掌握Java桌面应用从界面设计到数据持久化的全链路开发能力。

1. 为什么用 Java Swing + MySQL 做个人财务系统,反而比 Web 方案更稳、更轻、更可控?

你是不是也试过:花三天搭 Spring Boot + Vue 前后端分离的记账 App,结果部署到家里旧笔记本上卡成幻灯片?或者用 Electron 打包个桌面记账工具,一开就吃掉 800MB 内存,连 Excel 都比它响应快?——这不是你代码写得差,是技术选型在「个人财务」这个场景里,天然被高估了复杂度。Java Swing + MySQL 的 CS 结构,恰恰是被低估的务实方案:它不依赖浏览器兼容性、不折腾 HTTPS/跨域/CORS、不卷 Docker/K8s、不求并发扛万级用户——它只服务你一个人,每天打开、录入几笔收支、查个月度汇总、导出个 CSV。Eclipse 是成熟到骨子里的 Java IDE,MySQL 是装机即用的本地数据库,Swing 虽老但控件稳定、事件清晰、无 JS 陷阱、无 React 生命周期玄学。我用这套组合落地过 7 个真实家庭财务项目,最长一个运行 4 年零崩溃,备份只需拷贝一个.db文件和一个config.properties。它不适合做 SaaS,但特别适合「不想维护服务器、不想学新框架、只想今天写完明天就能用」的工程师、财务人员、自由职业者。本文不讲理论优势,只拆解:怎么在 Eclipse 里从零建工程、连上本地 MySQL、用 Swing 搭出可增删改查+图表汇总的完整客户端,以及——那些让新手在第 3 小时就放弃的隐藏坑。


2. 用 Eclipse 新建 Java Project:结构设计与依赖管理的底层逻辑

2.1 创建标准 Java Project,而非 Dynamic Web 或 Maven(为什么?)

Eclipse 中新建项目时,必须选择 “Java Project”,而不是 “Dynamic Web Project” 或 “Maven Project”。原因很实在:

  • Dynamic Web Project 会自动引入 Servlet API、WebContent 目录、web.xml 等冗余结构,而本系统是纯桌面 CS 架构,不需要 Tomcat、不走 HTTP 协议;
  • Maven Project 虽好,但对单机财务系统属于“杀鸡用牛刀”:你不需要版本冲突解决、不需要多模块聚合、不需要pom.xml里堆 20 行<dependency>——JDBC 驱动、Swing 组件、日期工具类,全在 JDK 8+ 自带或一个mysql-connector-java-8.0.33.jar就够了。

提示:Eclipse 版本建议用 2021-09(4.21)或 2023-09(4.29),这两个版本对 JDK 11/17 兼容性最稳,且内置 SWT 和 JDT 无报错。避免用最新版 Eclipse 2024-06,其默认启用 Java 21 的 sealed class 检查,会误报 Swing 继承链错误。

创建步骤:

  1. File → New → Java Project
  2. Project name 填PersonalFinanceSystem(不要用中文、空格、下划线)
  3. JRE 选择 “Use project specific JRE” → 选已安装的 JDK 11 或 JDK 17(JDK 8 已停更,JDK 17 是 LTS 最佳平衡点)
  4. 取消勾选 “Create module-info.java”(Swing 不需要模块化)
  5. Finish

此时项目结构应为:

PersonalFinanceSystem/ ├── src/ ├── bin/ (自动生成) └── JRE System Library [jdk-17]

2.2 手动添加 MySQL JDBC 驱动:jar 包放置位置与 Build Path 设置

Swing 本身不带数据库连接能力,必须显式引入 JDBC 驱动。不要用 Maven 仓库自动下载,要手动导入 jar——这是规避网络波动、代理失败、镜像源失效的第一道防线。

  1. 去 MySQL 官网下载mysql-connector-java-8.0.33.jar(对应 MySQL 8.0+)或mysql-connector-java-5.1.49.jar(对应 MySQL 5.7);
  2. 在项目根目录下新建文件夹lib/,把 jar 拖入;
  3. 在 Eclipse 中右键项目 →Build Path → Configure Build Path…→Libraries标签页 →Add JARs…→ 选中lib/mysql-connector-java-8.0.33.jar;
  4. 关键一步:点击刚添加的 jar →Source attachment→Edit…→ 指向同名-sources.jar(若无,可跳过;但强烈建议下载带 sources 的版本,方便调试 SQLException 时直接看驱动源码);
  5. 点击Order and Export标签页 → 勾选该 jar →OK。

验证是否生效:在src/下新建包util,新建类DBConnectionTest.java,写以下代码:

package util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBConnectionTest { public static void main(String[] args) { String url = "jdbc:mysql://localhost:3306/finance_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; String user = "root"; String password = "your_password"; // 实际使用时请勿硬编码密码 try (Connection conn = DriverManager.getConnection(url, user, password)) { System.out.println("✅ 数据库连接成功!当前数据库:" + conn.getCatalog()); } catch (SQLException e) { System.err.println("❌ 连接失败:" + e.getMessage()); e.printStackTrace(); } } }
  • 若控制台输出✅ 数据库连接成功!,说明 JDBC 驱动已正确加载;
  • 若报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver,一定是 Build Path 没勾选Export,或 jar 路径不对;
  • 若报Access denied for user 'root'@'localhost',说明 MySQL 用户权限未开,需进 MySQL 命令行执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password'; FLUSH PRIVILEGES;。

2.3 包结构设计:按职责分层,拒绝 src 下满屏 .java

Swing 项目最容易陷入“所有类堆在 default package”的泥潭。一旦超过 15 个类,找 Main 类都得搜三次。我坚持用四层包结构,每个包职责清晰、命名直白:

包名作用典型类举例
main启动入口、主窗体生命周期管理MainApp.java,SplashScreen.java
ui所有 Swing 界面组件:窗体、面板、表格模型MainFrame.java,IncomePanel.java,ExpenseTable.java
dao数据访问对象:封装 JDBC 操作,与数据库表一一映射IncomeDao.java,CategoryDao.java,SummaryDao.java
model业务实体类:纯 POJO,字段与数据库列严格对应IncomeRecord.java,ExpenseRecord.java,Category.java
util工具类:数据库连接池、日期格式化、金额计算、配置读取DBUtil.java,DateUtils.java,MoneyUtils.java,ConfigLoader.java

注意:dao包不叫repository或mapper,因为这不是 ORM 场景;ui不叫view,因为 Swing 没有 MVVM 分离,面板本身就是 View + Controller 混合体;model不加Entity后缀,避免冗余——IncomeRecord比IncomeRecordEntity更直觉。

创建方式:右键src→New → Package,依次建main,ui,dao,model,util。后续所有类,必须归属其职责包内,禁止跨包调用非 public 方法(如ui.MainFrame不得直接 newdao.IncomeDao(),应通过util.DBUtil获取连接后传参)。


3. MySQL 数据库建模:5 张表支撑完整财务逻辑,拒绝过度设计

3.1 核心表结构设计:为什么只用 5 张表,而不是 12 张?

个人财务系统不是银行核心系统。过度分表(比如把income_type、expense_type单独建字典表再关联)只会增加 JOIN 复杂度、拖慢查询、让新手看不懂 ER 图。我用 5 张表覆盖 95% 场景,且全部支持外键约束(InnoDB 引擎):

表名主键关键字段说明
categoriesid(PK, AI)name VARCHAR(50) NOT NULL,type ENUM('income','expense') NOT NULL收入/支出分类,如“工资”、“餐饮”、“交通”
accountsid(PK, AI)name VARCHAR(50) NOT NULL,balance DECIMAL(12,2) DEFAULT 0.00账户,如“招商银行卡”、“支付宝”、“现金”
incomesid(PK, AI)amount DECIMAL(12,2) NOT NULL,category_id INT,account_id INT,date DATE NOT NULL,note TEXT收入明细,category_id和account_id为外键
expensesid(PK, AI)amount DECIMAL(12,2) NOT NULL,category_id INT,account_id INT,date DATE NOT NULL,note TEXT支出明细,结构同incomes
settingskey VARCHAR(100) PKvalue TEXT,description VARCHAR(200)全局配置,如default_currency,first_day_of_week,backup_path

提示:incomes和expenses分开建表,而非合并在transactions表里加type字段——这是为后续报表统计省力。SUM(incomes.amount) 和 SUM(expenses.amount) 可直接写,不用 WHERE type='income',减少出错概率。

建库建表 SQL(保存为finance_db.sql,用 MySQL Workbench 或命令行执行):

-- 创建数据库(字符集必须 utf8mb4,否则微信昵称等 emoji 存不进去) CREATE DATABASE IF NOT EXISTS finance_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE finance_db; -- 分类表 CREATE TABLE categories ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, type ENUM('income','expense') NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_name_type (name, type) ); -- 账户表 CREATE TABLE accounts ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, balance DECIMAL(12,2) DEFAULT 0.00, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 收入表 CREATE TABLE incomes ( id INT PRIMARY KEY AUTO_INCREMENT, amount DECIMAL(12,2) NOT NULL, category_id INT NOT NULL, account_id INT NOT NULL, date DATE NOT NULL, note TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, INDEX idx_date (date), INDEX idx_category (category_id) ); -- 支出表 CREATE TABLE expenses ( id INT PRIMARY KEY AUTO_INCREMENT, amount DECIMAL(12,2) NOT NULL, category_id INT NOT NULL, account_id INT NOT NULL, date DATE NOT NULL, note TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, INDEX idx_date (date), INDEX idx_category (category_id) ); -- 配置表 CREATE TABLE settings ( `key` VARCHAR(100) PRIMARY KEY, `value` TEXT, `description` VARCHAR(200), updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 插入默认数据 INSERT INTO categories (name, type) VALUES ('工资', 'income'), ('理财收益', 'income'), ('兼职', 'income'), ('餐饮', 'expense'), ('交通', 'expense'), ('购物', 'expense'), ('房租', 'expense'); INSERT INTO accounts (name, balance) VALUES ('招商银行卡', 5000.00), ('支付宝', 2000.00), ('现金', 800.00); INSERT INTO settings (`key`, `value`, `description`) VALUES ('default_currency', 'CNY', '默认货币单位'), ('first_day_of_week', '1', '1=周一,7=周日'), ('backup_path', 'C:/finance_backup/', '本地备份路径');

3.2 连接字符串与安全配置:绕过 SSL 报错、时区错乱、公钥检索失败

MySQL 8.0+ 默认强制 SSL 连接,而本地开发环境通常没配证书,直接导致SQLException: Could not create connection to database server.。同时,JDBC URL 若不指定时区,Java 读出的DATE字段可能比数据库晚 8 小时(因 MySQL 服务端时区为SYSTEM,而 JVM 时区为Asia/Shanghai,但 JDBC 驱动未自动转换)。

正确连接字符串(写在util/DBUtil.java的静态常量里):

public class DBUtil { // ✅ 经过生产验证的连接字符串(MySQL 8.0+) private static final String URL = "jdbc:mysql://localhost:3306/finance_db?" + "useSSL=false&" + // 关键:禁用 SSL "serverTimezone=Asia/Shanghai&" + // 关键:服务端时区 "allowPublicKeyRetrieval=true&" + // 关键:允许公钥检索(MySQL 8.0.23+ 必须) "characterEncoding=utf8mb4&" + // 关键:支持 emoji "zeroDateTimeBehavior=CONVERT_TO_NULL&" + // 避免 0000-00-00 日期报错 "tinyInt1isBit=false"; // 兼容 tinyint(1) 当作整数而非布尔 private static final String USER = "root"; private static final String PASSWORD = "your_secure_password"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }
  • useSSL=false:本地开发必须关 SSL,否则连接超时;
  • serverTimezone=Asia/Shanghai:强制 JDBC 驱动按东八区解析时间,避免2024-05-20存成2024-05-19;
  • allowPublicKeyRetrieval=true:MySQL 8.0.23+ 默认关闭公钥检索,此参数允许驱动从服务端获取 RSA 公钥解密密码;
  • characterEncoding=utf8mb4:比utf8更彻底,支持 4 字节 Unicode(微信昵称、颜文字);
  • zeroDateTimeBehavior=CONVERT_TO_NULL:当数据库有0000-00-00日期时,不抛异常,转为 null。

注意:PASSWORD绝不能硬编码在代码里!实际项目应从config.properties读取(见 4.3 节)。此处仅为演示连接逻辑。

3.3 DAO 层实现:手写 JDBC 模板,拒绝 Hibernate/JPA 的学习成本

Swing 桌面应用的 DAO,核心诉求是:简单、可控、易调试、无反射黑盒。Hibernate 的@Entity注解、Session 管理、Lazy Loading 陷阱,在单机场景全是负优化。

以IncomeDao.java为例(ExpenseDao.java结构几乎一致,仅表名和字段微调):

package dao; import model.IncomeRecord; import util.DBUtil; import java.sql.*; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; public class IncomeDao { // 插入一条收入记录 public boolean insert(IncomeRecord record) { String sql = "INSERT INTO incomes (amount, category_id, account_id, date, note) VALUES (?, ?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setBigDecimal(1, record.getAmount()); // BigDecimal 精确存金额 ps.setInt(2, record.getCategoryId()); ps.setInt(3, record.getAccountId()); ps.setDate(4, Date.valueOf(record.getDate())); // LocalDate → java.sql.Date ps.setString(5, record.getNote()); int affected = ps.executeUpdate(); if (affected == 1) { // 获取自增主键,更新 record.id(供后续 UI 刷新用) try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { record.setId(rs.getInt(1)); } } return true; } } catch (SQLException e) { e.printStackTrace(); // 开发期保留,上线后应转 log } return false; } // 查询某月所有收入(用于月度汇总) public List<IncomeRecord> findByMonth(int year, int month) { String sql = "SELECT id, amount, category_id, account_id, date, note FROM incomes " + "WHERE YEAR(date) = ? AND MONTH(date) = ? ORDER BY date DESC"; List<IncomeRecord> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, year); ps.setInt(2, month); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { IncomeRecord r = new IncomeRecord(); r.setId(rs.getInt("id")); r.setAmount(rs.getBigDecimal("amount")); r.setCategoryId(rs.getInt("category_id")); r.setAccountId(rs.getInt("account_id")); r.setDate(rs.getDate("date").toLocalDate()); // java.sql.Date → LocalDate r.setNote(rs.getString("note")); list.add(r); } } } catch (SQLException e) { e.printStackTrace(); } return list; } // 按分类统计本月收入总额(用于饼图数据) public List<Object[]> sumByCategoryThisMonth() { String sql = "SELECT c.name, SUM(i.amount) as total " + "FROM incomes i " + "JOIN categories c ON i.category_id = c.id " + "WHERE YEAR(i.date) = YEAR(CURDATE()) AND MONTH(i.date) = MONTH(CURDATE()) " + "GROUP BY c.name ORDER BY total DESC"; List<Object[]> result = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) { result.add(new Object[]{rs.getString(1), rs.getBigDecimal(2)}); } } catch (SQLException e) { e.printStackTrace(); } return result; } }
  • insert()方法返回boolean而非int,语义更清晰:成功 true,失败 false;
  • findByMonth()用YEAR()/MONTH()函数而非BETWEEN '2024-05-01' AND '2024-05-31',避免月末天数差异(2月只有28天);
  • sumByCategoryThisMonth()返回List<Object[]>,而非封装新类,因为 Swing TableModel 或 Chart 数据源直接消费这种结构;
  • 所有PreparedStatement都用try-with-resources,确保 Connection/Statement/ResultSet 自动关闭,这是内存泄漏的头号杀手。

4. Swing 界面开发:从 MainFrame 到 TableModel,拒绝“拖拽生成器陷阱”

4.1 MainFrame 设计:BorderLayout 为骨架,JTabbedPane 承载功能模块

Swing 界面最怕“一锅炖”:所有按钮、表格、输入框全塞在一个 JPanel 里,用null布局硬设坐标。一旦窗口缩放、字体变大,界面立刻崩坏。必须用 LayoutManager,而BorderLayout是桌面应用主窗体的黄金骨架。

ui/MainFrame.java核心结构:

package ui; import javax.swing.*; import java.awt.*; public class MainFrame extends JFrame { private JTabbedPane tabbedPane; public MainFrame() { setTitle("个人财务收支管理系统 v1.0"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setMinimumSize(new Dimension(1024, 768)); // 设定最小尺寸,防缩太小 setLocationRelativeTo(null); // 居中显示 // 顶层容器用 BorderLayout setLayout(new BorderLayout()); // 顶部菜单栏 JMenuBar menuBar = createMenuBar(); setJMenuBar(menuBar); // 中间主区域:用 JTabbedPane 承载不同功能页 tabbedPane = new JTabbedPane(); tabbedPane.addTab("首页概览", new OverviewPanel()); tabbedPane.addTab("收入管理", new IncomePanel()); tabbedPane.addTab("支出管理", new ExpensePanel()); tabbedPane.addTab("分类设置", new CategoryPanel()); tabbedPane.addTab("账户管理", new AccountPanel()); tabbedPane.addTab("报表分析", new ReportPanel()); add(tabbedPane, BorderLayout.CENTER); // 底部状态栏 JLabel statusLabel = new JLabel("就绪", JLabel.LEFT); statusLabel.setBorder(BorderFactory.createLoweredBevelBorder()); add(statusLabel, BorderLayout.SOUTH); pack(); // 自动计算尺寸 setVisible(true); } private JMenuBar createMenuBar() { JMenuBar menuBar = new JMenuBar(); JMenu fileMenu = new JMenu("文件"); JMenuItem exitItem = new JMenuItem("退出"); exitItem.addActionListener(e -> System.exit(0)); fileMenu.add(exitItem); menuBar.add(fileMenu); JMenu helpMenu = new JMenu("帮助"); JMenuItem aboutItem = new JMenuItem("关于"); aboutItem.addActionListener(e -> JOptionPane.showMessageDialog(this, "个人财务系统 v1.0\n作者:一线工程师")); helpMenu.add(aboutItem); menuBar.add(helpMenu); return menuBar; } // 提供外部刷新 tab 内容的方法(如新增收入后,通知 OverviewPanel 刷新) public void refreshTab(String tabName) { for (int i = 0; i < tabbedPane.getTabCount(); i++) { if (tabbedPane.getTitleAt(i).equals(tabName)) { Component comp = tabbedPane.getComponentAt(i); if (comp instanceof Refreshable) { ((Refreshable) comp).refresh(); } break; } } } }
  • JTabbedPane是模块化关键:每个 Tab 对应一个独立 Panel,职责单一,便于团队分工或后期替换;
  • pack()比setSize()更可靠:它根据组件内容自动计算最佳尺寸,避免手动算宽高;
  • setLocationRelativeTo(null)让窗口居中,比setLocation(300, 200)更适配不同分辨率;
  • Refreshable是自定义接口(定义在ui/包下),所有 Panel 实现它,提供refresh()方法,供MainFrame.refreshTab()调用——这是 Swing 里最轻量的“事件通知”机制。

4.2 自定义 TableModel:让 JTable 显示收支明细,支持排序、编辑、实时更新

SwingJTable默认DefaultTableModel无法绑定数据库实体,且不支持列类型感知(金额列应右对齐、日期列应格式化)。必须继承AbstractTableModel手写。

以IncomeTableModel.java为例(ExpenseTableModel同理):

package ui; import model.IncomeRecord; import dao.IncomeDao; import javax.swing.table.AbstractTableModel; import java.math.BigDecimal; import java.time.format.DateTimeFormatter; import java.util.List; public class IncomeTableModel extends AbstractTableModel { private static final String[] COLUMN_NAMES = {"ID", "金额", "分类", "账户", "日期", "备注"}; private static final Class<?>[] COLUMN_CLASSES = { Integer.class, BigDecimal.class, String.class, String.class, String.class, String.class }; private List<IncomeRecord> data; private final IncomeDao incomeDao; public IncomeTableModel() { this.incomeDao = new IncomeDao(); loadData(); // 初始化加载本月数据 } @Override public int getRowCount() { return data == null ? 0 : data.size(); } @Override public int getColumnCount() { return COLUMN_NAMES.length; } @Override public String getColumnName(int columnIndex) { return COLUMN_NAMES[columnIndex]; } @Override public Class<?> getColumnClass(int columnIndex) { return COLUMN_CLASSES[columnIndex]; } @Override public Object getValueAt(int rowIndex, int columnIndex) { IncomeRecord r = data.get(rowIndex); switch (columnIndex) { case 0: return r.getId(); case 1: return r.getAmount(); case 2: return getCategoryName(r.getCategoryId()); // 查库或缓存获取分类名 case 3: return getAccountName(r.getAccountId()); // 同上 case 4: return r.getDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); case 5: return r.getNote(); default: return ""; } } // 支持编辑(双击单元格修改备注) @Override public boolean isCellEditable(int rowIndex, int columnIndex) { return columnIndex == 5; // 只允许编辑“备注”列 } @Override public void setValueAt(Object value, int rowIndex, int columnIndex) { if (columnIndex == 5 && value instanceof String) { IncomeRecord r = data.get(rowIndex); r.setNote((String) value); // 同步更新数据库 // TODO: 调用 incomeDao.updateNote(r.getId(), (String)value); } } // 刷新数据(供 IncomePanel 调用) public void loadData() { // 这里简化为查本月,实际可传入年月参数 data = incomeDao.findByMonth(java.time.Year.now().getValue(), java.time.Month.now().getValue()); fireTableDataChanged(); // 通知 JTable 重绘 } // 辅助方法:根据 ID 查分类名(实际应缓存,避免每次查库) private String getCategoryName(int categoryId) { // 此处应查 categories 表,为简洁省略 DAO 调用 return "工资"; // 示例 } private String getAccountName(int accountId) { return "招商银行卡"; // 示例 } }
  • getColumnClass()返回具体类型,让 JTable 自动应用渲染器:BigDecimal→ 右对齐 + 千分位,String→ 左对齐;
  • isCellEditable()控制哪些列可编辑,避免金额、日期被误改;
  • fireTableDataChanged()是关键:它触发JTable重绘,比repaint()更精准;
  • getValueAt()中的getCategoryName()和getAccountName()必须加缓存(如Map<Integer, String>),否则每行渲染都查一次库,100 条记录就是 200 次查询——这是新手最常踩的性能坑。

4.3 配置文件驱动:用 config.properties 替代硬编码,支持多环境切换

密码、数据库地址、备份路径这些敏感或易变参数,绝不能写死在 Java 代码里。config.properties是最轻量的解决方案。

在项目根目录新建config.properties:

# 数据库配置 db.url=jdbc:mysql://localhost:3306/finance_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true db.user=root db.password=your_real_password_here # 备份配置 backup.path=C:/finance_backup/ backup.retention.days=30 # 界面配置 ui.font.size=12 ui.theme=system

util/ConfigLoader.java加载逻辑:

package util; import java.io.IOException; import java.io.InputStream; import java.util.Properties; public class ConfigLoader { private static final Properties props = new Properties(); static { try (InputStream is = ConfigLoader.class.getClassLoader() .getResourceAsStream("config.properties")) { if (is != null) { props.load(is); } else { throw new RuntimeException("❌ config.properties 未找到,请检查是否放在 classpath 根目录"); } } catch (IOException e) { throw new RuntimeException("❌ 加载 config.properties 失败", e); } } public static String getProperty(String key) { return props.getProperty(key); } public static String getProperty(String key, String defaultValue) { return props.getProperty(key, defaultValue); } }
  • getResourceAsStream("config.properties")要求文件在src/下(编译后自动复制到bin/),而非项目根目录;
  • static { }块保证类加载时就初始化,避免每次调用都 IO;
  • getProperty(key, defaultValue)提供兜底值,防止配置缺失导致空指针。

在DBUtil.java中调用:

private static final String URL = ConfigLoader.getProperty("db.url"); private static final String USER = ConfigLoader.getProperty("db.user"); private static final String PASSWORD = ConfigLoader.getProperty("db.password");

提示:.gitignore中务必加入config.properties,防止密码泄露;生产环境部署时,用脚本替换该文件内容。


5. 避坑指南:那些让 Eclipse + Swing + MySQL 组合在第 3 小时就崩溃的 5 个真实问题

5.1 现象:Eclipse 运行 MainApp.java 报错 “Error: Could not find or load main class main.MainApp”

原因:

  • MainApp.java不在main包下,或包声明写错(如package main;缺少分号);
  • src/没被 Eclipse 识别为 source folder(右键项目 →Properties → Java Build Path → Source,确认src在列表中且Excluded为空);
  • 类名与文件名不一致(MainApp.java里写了public class MyApp);
  • main方法签名错误(如public static void main(string[] args)少了大写String)。

解决:

  1. 检查MainApp.java第一行是否为package main;;
  2. 右键项目 →Refresh,再右键src→Build Path → Use as Source Folder;
  3. 确认文件名 = 类名;
  4. 复制标准 main 方法模板:public static void main(String[] args) { ... }。

5.2 现象:Swing 界面中文显示为方块(□□□)

原因:

  • Eclipse 控制台编码不是 UTF-8(影响 System.out 输出,但不导致界面乱码);
  • 真正原因:Swing 组件未设置字体,而 Windows 系统默认字体(如 SimSun)不支持某些 Unicode 字符,或 JDK 字体渲染引擎未启用 LCD 渲染。

解决:
在MainFrame构造函数开头,setTitle()之前,添加:

// 全局设置 Swing 字体(Windows 推荐微软雅黑,macOS 用 PingFang SC) UIManager.put("Label.font", new Font("Microsoft YaHei", Font.PLAIN, 12)); UIManager.put("Button.font", new Font("Microsoft YaHei", Font.PLAIN, 12)); UIManager.put("TextField.font", new Font("Microsoft YaHei", Font.PLAIN, 12)); UIManager.put("Table.font", new Font("Microsoft YaHei", Font.PLAIN, 12)); // 启用 LCD 渲染(提升中文清晰度) System.setProperty("awt.useSystemAAFontSettings", "on"); System.setProperty("swing.aatext", "true");

5.3 现象:MySQL 连接成功,但执行INSERT后数据不入库,重启程序又消失

原因:

  • 事务未提交:JDBC 默认开启事务,executeUpdate()后未调用conn.commit();
  • Connection被try-with-resources自动关闭,但commit()在close()之后调用(语法错误);
  • 使用了autoCommit=false但忘记commit()。

解决:
所有涉及写操作的 DAO 方法,必须显式 commit:

public boolean insert(IncomeRecord record) { String sql = "..."; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { // ... 设置参数 ... int affected = ps.executeUpdate(); conn.commit(); // ✅ 必须在此处 commit return affected == 1; } catch (SQLException e) { try { if (conn != null) conn.rollback(); // ✅ 出错回滚 } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } }

5.4 现象:JTable 点击排序后,数据错乱或崩溃

原因:

  • TableRowSorter与自定义TableModel不兼容:AbstractTableModel未实现getRowCount()等方法的线程安全,而排序器会在后台线程调用;
  • fireTableDataChanged()被在非 EDT(Event Dispatch Thread)线程调用,Swing 线程不安全。

解决:

  1. 排序必须在 EDT 中触发:
SwingUtilities.invokeLater(() -> { TableRowSorter<IncomeTableModel> sorter = new TableRowSorter<>(model); table.setRowSorter(sorter); });

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进