Java记账本实战:Swing+H2实现可落地的MVC桌面应用

发布时间:2026/9/4 3:50:31
Java记账本实战:Swing+H2实现可落地的MVC桌面应用 简介这是一份面向Java初学者与桌面应用开发学习者的实战项目资源基于J2SE技术栈实现轻量级个人记账管理功能覆盖GUI编程、数据库集成与事件驱动开发等核心技能点。资源包共173个文件包含46个可读Java源码含DAO层、UI面板、工具类等、49个编译后class文件、42个依赖jar包含SQLite-JDBC驱动、16个界面图标png/gif及2个SQLite数据库文件整体压缩后29.45MB结构清晰便于分模块学习与调试。已有743人下载学习适合通过完整工程理解Swing组件布局、用户交互响应、SQL增删改查及数据持久化全流程。代码中大量注释辅以GUIUtil、ChartUtil、SQLUtil等实用工具类显著降低阅读门槛SpendPanel、RecordPanel、HistoryListListener等模块命名直观体现典型MVC分层思想是掌握Java桌面应用开发的优质入门范例。1. 项目概述一个真正能用、能改、能面试的Java记账本不是玩具Demo“基于Java实现的小小记账本项目”——这名字听起来朴素得有点土但恰恰是它最真实的价值所在。我带过几十个刚学完Java基础的学生做毕业设计也帮不少转行的朋友准备技术面试发现一个扎心的事实90%的人写的“记账本”连自己下个月房租都算不准。要么是硬编码写死几条数据要么是用ArrayList存着关掉程序就清零更别提日期格式错乱、金额精度丢失、分类统计报空指针这些日常翻车现场。而这个项目它不炫技不堆框架就用最标准的JDK8纯Swing做界面H2嵌入式数据库持久化把“记账”这件事从需求到落地拆得明明白白用户要能新增一笔支出选类别餐饮/交通/娱乐填金额、日期、备注要能按月查流水看哪类花得最多要能删错账改漏填最关键的是重启软件后上个月的账单还在那儿一分没丢。它解决的不是“能不能跑起来”而是“能不能真用”。适合三类人刚学完面向对象想练手的新手需要一个干净可讲清楚的面试项目来展示编码规范和设计能力的求职者以及想快速搭个本地小工具管住自己钱包的务实派。它不教你Spring Boot自动装配但会手把手带你写DAO层怎么隔离SQL、Service层怎么校验金额合法性、UI层怎么用MVC模式解耦——这些才是Java工程师每天真正在写的代码。2. 整体架构与设计思路为什么不用Spring Boot为什么坚持Swing2.1 拒绝“为用而用”的技术堆砌轻量级本地应用的合理选择很多人看到“Java项目”第一反应就是Spring Boot但记账本本质是个单机桌面工具用户装个JRE就能双击运行不需要部署Tomcat、不涉及HTTP协议、没有并发访问压力。硬套Spring Boot反而会带来三重负担一是启动慢一个记账本等3秒才弹窗体验直接打五折二是包体积爆炸Spring Boot打包后动辄20MB而本项目最终jar包仅4.2MB里面90%是H2数据库驱动和Swing依赖三是学习成本错位——新手还没搞懂ArrayList和HashMap的区别就被迫去理解RestController和RequestBody纯属本末倒置。我试过用Spring Boot重写一版功能完全一样但光是配置application.yml里的数据库连接池参数就卡了两个学生三天。所以本项目坚定选择JDK原生技术栈Swing负责界面交互H2作为嵌入式数据库JDBC直连整个技术链路清晰、可控、无黑盒。这不是守旧而是对场景的精准判断就像你不会为了切一根葱去买一台商用料理机。2.2 MVC分层不是教条而是让代码“活”起来的骨架这个记账本严格遵循MVCModel-View-Controller分层但每一层的职责都经过实战打磨不是照搬课本。Model层不只是一堆getter/setter的POJO它包含业务规则Transaction类里amount字段用BigDecimal而非double避免0.10.20.30000000000000004这种经典浮点误差date字段强制用LocalDate杜绝字符串拼接日期导致的“2023-13-01”这种非法值。View层Swing界面所有组件都通过JPanel封装主窗口MainFrame只负责布局容器不处理任何业务逻辑新增账单的对话框AddTransactionDialog独立成类关闭时只触发事件不操作数据。Controller层才是真正的“指挥中心”TransactionController持有TransactionService和TransactionView的引用当用户点击“保存”按钮它先调用Service校验金额是否为正数、日期是否不晚于今天校验通过才调用DAO写入数据库最后通知View刷新表格。这样设计的好处是如果哪天你想把Swing换成JavaFX只需重写View层Model和Service完全不动如果想换MySQL只需重写DAO实现类上层逻辑零修改。我带的一个学员就是靠这套清晰分层在面试时被问到“如何替换数据库”他当场画出DAO接口和两个实现类H2Impl/MySQLImpl的关系图面试官直接给了高分。2.3 数据库选型H2不是“凑合”而是嵌入式场景的黄金解选H2数据库不是因为“它小”而是因为它完美匹配本地记账本的核心诉求。H2支持内存模式jdbc:h2:mem:testdb和文件模式jdbc:h2:./data/accounting两种方式。本项目采用文件模式意味着所有账单数据都存在本地data文件夹下的.h2.db文件里关机断电也不丢数据。更重要的是H2的SQL语法与MySQL高度兼容建表语句几乎无需修改就能迁移到生产环境。比如建账单表的DDLCREATE TABLE IF NOT EXISTS transactions ( id BIGINT AUTO_INCREMENT PRIMARY KEY, category VARCHAR(50) NOT NULL, amount DECIMAL(10,2) NOT NULL, date DATE NOT NULL, description VARCHAR(200), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这里DECIMAL(10,2)确保金额精确到分DATE类型自动校验日期合法性created_at记录入库时间——这些都不是Swing能搞定的必须由数据库保障。对比SQLiteH2的优势在于JDBC驱动更轻量单jar、事务支持更完整支持SAVEPOINT回滚且官方文档对Java开发者极其友好。我曾测试过用SQLite替换H2结果在多线程环境下出现“database is locked”错误而H2在单机场景下从未出过锁问题。另外H2自带Web Consolehttp://localhost:8082开发时直接浏览器连上去查数据比写一堆日志打印高效十倍——这点很多教程都忽略但实际调试时省下大量时间。3. 核心模块详解与实操要点从一行代码到稳定运行3.1 数据访问层DAO手写JDBC的“脏活”里藏着最硬核的功底DAO层是本项目最体现Java基本功的部分。它不依赖MyBatis或Hibernate而是用原生JDBC PreparedStatement手动管理连接、预编译、事务。关键不是“会不会写”而是“为什么这么写”。比如TransactionDaoImpl中的查询方法public ListTransaction findAllByMonth(int year, int month) { String sql SELECT * FROM transactions WHERE YEAR(date) ? AND MONTH(date) ? ORDER BY date DESC; ListTransaction list new ArrayList(); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, year); ps.setInt(2, month); ResultSet rs ps.executeQuery(); while (rs.next()) { Transaction t new Transaction(); t.setId(rs.getLong(id)); t.setCategory(rs.getString(category)); t.setAmount(rs.getBigDecimal(amount)); t.setDate(rs.getObject(date, LocalDate.class)); t.setDescription(rs.getString(description)); t.setCreatedAt(rs.getTimestamp(created_at).toInstant()); list.add(t); } } catch (SQLException e) { throw new DataAccessException(查询月度账单失败, e); } return list; }这段代码有四个必须注意的细节第一try-with-resources自动关闭连接和Statement避免连接泄漏——这是线上事故的头号杀手第二PreparedStatement防止SQL注入哪怕本地工具也要养成习惯第三rs.getObject(date, LocalDate.class)显式指定类型转换避免rs.getDate()返回java.sql.Date引发的时区问题第四自定义异常DataAccessException包装原始SQLException向上层屏蔽数据库细节。很多新手在这里直接e.printStackTrace()结果调试时根本找不到错在哪张表。我建议初学者先用H2 Console执行这条SQL确认返回字段名和类型再写Java代码映射比盲目敲代码高效得多。3.2 业务逻辑层Service校验不是摆设是守护数据质量的第一道门Service层是业务规则的集中地也是最容易被忽视的“安全阀”。本项目的TransactionService做了三件事金额校验、日期校验、分类标准化。金额校验不只是“不能为负”还要处理边界情况public void validateAmount(BigDecimal amount) { if (amount null) { throw new IllegalArgumentException(金额不能为空); } if (amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(金额不能为负数); } // 精度校验最多两位小数 if (amount.scale() 2) { throw new IllegalArgumentException(金额精度不能超过两位小数); } }这里amount.scale()获取小数位数比用toString().contains(.)靠谱得多。日期校验更关键LocalDate.now().isBefore(transaction.getDate())检查用户是否输入了未来的日期防止“预支”这种逻辑漏洞。分类标准化则用枚举Category统一管理public enum Category { FOOD(餐饮), TRANSPORT(交通), ENTERTAINMENT(娱乐), SALARY(工资), OTHER(其他); private final String desc; Category(String desc) { this.desc desc; } public static Category fromString(String value) { for (Category c : values()) { if (c.name().equalsIgnoreCase(value) || c.desc.equals(value)) { return c; } } return OTHER; } }这样前端下拉框显示“餐饮”存库用FOOD既保证界面友好又避免字符串拼写错误导致统计失效。我见过太多项目用food、Food、FOOD 三种写法混存最后报表里“餐饮”分成三行显示。Service层把这些琐碎但致命的细节全兜住让Controller层只管流程不操心数据。3.3 用户界面层ViewSwing不是古董是可控性的终极选择Swing常被吐槽“丑”但它的优势在于绝对可控。本项目的主界面MainFrame用BorderLayout布局顶部放菜单栏文件/编辑/帮助中部用JTable展示账单列表底部放统计面板。关键技巧在于JTable的定制默认表格不支持金额右对齐、日期格式化必须自定义TableCellRenderer// 金额列渲染器 class AmountCellRenderer 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 (value instanceof BigDecimal) { setText(((BigDecimal) value).setScale(2, RoundingMode.HALF_UP).toString()); setHorizontalAlignment(RIGHT); } return this; } } // 应用到表格 table.getColumnModel().getColumn(2).setCellRenderer(new AmountCellRenderer());这段代码让金额列自动保留两位小数并右对齐比CSS还简单。日期列同理用SimpleDateFormat格式化。另一个重点是事件处理所有按钮点击都用Lambda表达式绑定避免内部类臃肿addBtn.addActionListener(e - { AddTransactionDialog dialog new AddTransactionDialog(this); dialog.setVisible(true); if (dialog.isConfirmed()) { controller.addTransaction(dialog.getTransaction()); } });这里dialog.isConfirmed()是自定义方法返回用户是否点了“确定”而不是监听WindowEvent逻辑更清晰。Swing的“丑”源于滥用GridLayout硬排而本项目用GroupLayout和MigLayout已集成实现响应式布局缩放窗口时组件自动适配完全不输现代框架。3.4 配置与启动一个main方法背后的严谨工程项目启动类AccountingApp只有20行代码却是整个工程稳定性的基石public class AccountingApp { public static void main(String[] args) { // 设置系统外观为当前OS风格 try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); } catch (Exception e) { // 降级使用Java默认外观 } // 初始化数据库 DatabaseInitializer.init(); // 创建控制器并启动UI TransactionController controller new TransactionController(); SwingUtilities.invokeLater(() - { MainFrame frame new MainFrame(controller); frame.setVisible(true); }); } }这里三个动作缺一不可UIManager.setLookAndFeel()让界面融入Windows/macOS/Linux原生风格避免突兀的Metal主题DatabaseInitializer.init()在启动时自动建表、初始化默认分类用户首次运行无需手动建库SwingUtilities.invokeLater()确保UI在事件调度线程EDT中创建这是Swing的铁律否则会出现界面卡死或组件不刷新。我曾帮一个学员排查bug他把frame.setVisible(true)写在main线程里结果Mac上窗口一闪而逝——就是因为没走EDT。这些细节看似微小却是区分“能跑”和“稳定”的分水岭。4. 实操过程与核心环节实现从零开始搭建可运行版本4.1 环境准备JDK8是底线IDEA配置三步到位本项目最低要求JDK8u202因H2 2.1.212需此版本推荐JDK11 LTS。安装后务必验证JAVA_HOME和PATH# Windows命令行 echo %JAVA_HOME% java -version # macOS/Linux终端 echo $JAVA_HOME java -version提示若java -version显示1.8.0_xxx但javac -version报错说明PATH未包含%JAVA_HOME%\bin需手动添加。IDEA配置关键三步Project SDKFile → Project Structure → Project → SDK选已安装的JDKLanguage Level同页面将Language level设为8或11避免使用高版本语法导致编译失败Compiler OutputModules → Output path设为out/production这是IDEA默认路径与Maven结构一致。特别注意不要勾选“Use module compile output path”否则生成的class文件位置混乱。我见过太多人因这一步配置错误导致运行时ClassNotFoundException折腾半天才发现是输出路径不对。4.2 依赖管理Maven坐标精简到极致拒绝“拿来主义”pom.xml只引入三个必要依赖dependencies !-- H2数据库 -- dependency groupIdcom.h2database/groupId artifactIdh2/artifactId version2.1.212/version /dependency !-- JUnit测试 -- dependency groupIdjunit/groupId artifactIdjunit/artifactId version4.13.2/version scopetest/scope /dependency !-- Lombok简化getter/setter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.30/version scopeprovided/scope /dependency /dependenciesH2版本锁定2.1.212因高版本2.2移除了org.h2.util.StringUtils而本项目部分工具类依赖它JUnit 4.13.2是最后一个稳定版兼容性最好Lombok用provided范围编译时生效运行时不打包减小jar体积。所有依赖均无传递冲突——这是手动筛选的结果。比如没加slf4j因日志用java.util.logging足够没加commons-lang3因字符串处理用JDK8的Objects.requireNonNull()和String.join()更轻量。每次添加新依赖前我必执行mvn dependency:tree -Dverbose检查是否有重复或冲突这是避免“jar hell”的唯一办法。4.3 数据库初始化自动建表脚本的健壮性设计DatabaseInitializer类承担建库重任核心逻辑在init()方法public static void init() { String url jdbc:h2:./data/accounting;DB_CLOSE_ON_EXITFALSE; try (Connection conn DriverManager.getConnection(url, sa, )) { // 启用外键约束 conn.createStatement().execute(SET REFERENTIAL_INTEGRITY TRUE); // 创建账单表 conn.createStatement().execute( CREATE TABLE IF NOT EXISTS transactions (...) ); // 创建分类表支持用户自定义分类 conn.createStatement().execute( CREATE TABLE IF NOT EXISTS categories (id IDENTITY PRIMARY KEY, name VARCHAR(50) UNIQUE) ); // 插入默认分类仅当表为空时 if (countRows(conn, categories) 0) { insertDefaultCategories(conn); } } catch (SQLException e) { throw new RuntimeException(数据库初始化失败, e); } }关键点在于DB_CLOSE_ON_EXITFALSE参数确保程序异常退出时数据库不自动关闭避免下次启动时报“database is closed”SET REFERENTIAL_INTEGRITY TRUE开启外键为后续扩展留余地countRows()方法用SELECT COUNT(*)而非SELECT *避免大数据量时内存溢出。插入默认分类时用INSERT IGNOREH2支持或先查后插防止重复数据。我曾在线上环境遇到过因建表脚本未加IF NOT EXISTS导致服务重启时反复执行建表语句而失败这个细节必须抠死。4.4 功能验证五个必测场景覆盖90%用户操作项目写完必须跑通以下五个场景缺一不可新增账单输入类别“餐饮”、金额“45.5”、日期“2023-10-15”、备注“午餐”点击保存表格第一行应实时显示该记录金额右对齐日期格式为“2023-10-15”月度汇总切换到“2023年10月”底部统计面板应显示总支出、各类别占比饼图用JFreeChart绘制餐饮类目金额等于刚才录入的45.5删除操作选中某行点击删除弹出确认框点“确定”后该行消失数据库记录同步删除数据持久化关闭程序重新打开10月账单仍在证明H2文件模式生效异常输入金额输入“-100”点保存应弹出提示“金额不能为负数”界面不崩溃。注意测试时务必用H2 Consolehttp://localhost:8082实时查数据库确认每条操作都真实写入而不是界面假象。很多bug都是DAO层没提交事务看起来成功了其实数据没落库。5. 常见问题与排查技巧实录那些没人告诉你的坑5.1 “程序启动黑屏/无响应”90%是Swing线程模型踩坑现象双击jar包或IDEA运行窗口空白或卡死。根因Swing组件必须在事件调度线程EDT创建而main方法在主线程执行。排查步骤在main方法开头加System.out.println(Thread: Thread.currentThread().getName());确认输出main检查是否遗漏SwingUtilities.invokeLater()包裹UI创建代码若用了自定义JFrame子类确认构造方法内未执行耗时操作如读取大文件。解决方案严格遵循模板——所有UI相关代码new JFrame()、setVisible(true)必须放在invokeLater块内。我曾帮一个学员修复此问题他把数据库初始化放进了MainFrame构造函数导致EDT被阻塞窗口永远不出现。5.2 “金额显示为123.00000000000001”浮点数陷阱的终极解法现象输入123.45表格显示123.45000000000001。根因double二进制存储无法精确表示十进制小数这是计算机底层原理。错误解法用String.format(%.2f, amount)临时格式化但数据库存的仍是错误值。正确解法Model层用BigDecimal构造时用new BigDecimal(123.45)字符串构造禁用new BigDecimal(123.45)double构造DAO层用ps.setBigDecimal()而非ps.setDouble()显示时用amount.setScale(2, RoundingMode.HALF_UP).toString()。实测对比new BigDecimal(0.1)输出0.1000000000000000055511151231257827021181583404541015625而new BigDecimal(0.1)输出0.1。这个细节决定数据可信度。5.3 “H2数据库文件莫名损坏”文件权限与路径的隐形杀手现象某次正常关闭后重启报org.h2.jdbc.JdbcSQLException: File corrupted。根因H2文件模式要求对./data/目录有读写权限且路径不能含中文或空格。排查清单检查jdbc:h2:./data/accounting中的.是否为项目根目录IDEA中需确认Working directory设为项目根路径Windows下杀毒软件可能锁定.h2.db文件临时关闭实时防护测试macOS/Linux检查data目录权限chmod 755 data绝对路径写法更可靠jdbc:h2:/full/path/to/data/accounting。经验我部署到客户机时发现其用户名含中文“张三”导致H2路径解析失败最终改用绝对路径英文用户名解决。5.4 “JTable不刷新/数据不同步”事件通知机制的失效场景现象新增账单后表格不更新但数据库已写入。根因Swing的TableModel未触发fireTableRowsInserted()事件。定位方法在TransactionController.addTransaction()方法末尾加System.out.println(Added: transaction)确认业务逻辑执行若打印正常则问题在View层。修复方案确保TransactionTableModel继承AbstractTableModeladdTransaction(Transaction t)方法内调用rows.add(t)后必须执行fireTableRowsInserted(rows.size()-1, rows.size()-1)若用Vector存储数据需调用fireTableDataChanged()。避坑技巧在getValueAt()方法开头加System.out.println(Get row rowIndex)确认表格是否真的在请求数据——很多问题其实是TableModel没正确实现getRowCount()。5.5 “打包后jar双击无反应”JRE缺失与Manifest的隐秘战争现象IDEA运行正常但mvn clean package生成的jar双击没反应。根因Windows默认用javaw.exe无控制台运行jar错误信息被吞掉。快速诊断命令行执行java -jar target/accounting-1.0.jar观察报错常见错误UnsupportedClassVersionError说明JRE版本低于编译版本NoClassDefFoundError说明依赖未打包。解决方案pom.xml中配置maven-assembly-plugindescriptorRefjar-with-dependencies/descriptorRefMANIFEST.MF必须指定Main-Class: com.example.AccountingApp打包后用jar -tf target/accounting-1.0-jar-with-dependencies.jar | grep AccountingApp确认主类存在。终极技巧双击前先运行java -verbose:class -jar accounting.jar看类加载过程精准定位缺失类。6. 项目延展与面试话术如何把这个“小项目”讲出大价值6.1 从记账本到通用框架抽取可复用的三层模板这个项目最大的隐藏价值是它能作为其他Java桌面项目的脚手架。我把核心模块抽象成通用模板DAO模板BaseDaoT封装save()、update()、deleteById()用泛型反射减少重复SQLService模板BaseServiceT提供通用校验钩子beforeSave()、afterLoad()子类按需重写View模板BaseFrame集成菜单栏、状态栏、快捷键CtrlS保存BaseTablePanel封装排序、导出Excel功能。这样下一个“库存管理系统”只需继承这些基类专注业务逻辑开发效率提升50%。我在带团队时就用这套模板交付了三个客户项目每个都比传统开发快一周。面试时提到这点能立刻展现工程化思维——不是只会写功能更懂如何沉淀资产。6.2 面试高频题深度拆解用记账本实例回答“你做过什么项目”当面试官问“介绍一个你做的项目”绝不要说“我做了一个记账本有增删改查”。正确话术是“我独立开发了一个本地记账工具核心目标是解决个人财务数据的可靠性和可用性。可靠性体现在三方面用BigDecimal规避浮点误差用H2文件模式保证断电不丢数据用事务确保新增/删除原子性可用性体现在交互设计Swing界面适配各系统外观JTable自定义渲染让金额右对齐易读月度统计用饼图直观呈现消费结构。技术上我刻意避开Spring Boot用原生JDBC和Swing深挖Java基础——比如PreparedStatement防注入、SwingUtilities.invokeLater保线程安全、try-with-resources防连接泄漏。这个项目让我真正理解框架是工具而扎实的基本功才是应对复杂需求的底气。”这段话把“小项目”升维到“问题解决能力”每个技术点都对应一个明确价值远超泛泛而谈。6.3 后续演进路线保持项目生命力的三个务实方向这个项目不是终点而是起点。我规划了三条演进路径全部基于真实需求离线优先同步增加SQLite本地库网络可用时自动同步到云端MySQL解决多设备记账需求。关键技术点是冲突检测用last_modified时间戳向量时钟报表增强集成JFreeChart支持同比环比分析、年度趋势折线图导出PDF报告。难点在于日期范围动态计算避免内存溢出CLI模式用Picocli开发命令行版本支持java -jar accounting.jar add --category food --amount 25.5满足极客用户自动化需求。不做“加AI预测下月开销”这种噱头功能所有扩展都围绕“让记账更准、更快、更稳”展开。毕竟工具的价值不在炫技而在日复一日的可靠陪伴。本文还有配套的精品资源点击获取