1. 先搞清楚这个项目到底能做什么,以及为什么选这个技术栈
如果你正在找一个能自己动手、快速做出一个带图形界面的本地记账软件的项目,那么“Java Swing + SQLite”这个组合,几乎是为这个场景量身定做的。它解决的核心问题是:如何在不依赖网络、不安装复杂数据库服务的情况下,用最熟悉的Java技术,构建一个数据持久化、有完整增删改查功能的桌面应用。
为什么是Swing和SQLite?这不是什么新技术,但恰恰是它的优势所在。
- Java Swing:是Java标准库的一部分,意味着你不需要引入任何第三方GUI框架,环境搭建最简单。虽然界面风格比较“经典”,但对于记账本这种工具型应用,功能完整性和开发效率是第一位的。
- SQLite:它是一个进程内的、无服务器的、零配置的、事务性的SQL数据库引擎。翻译成人话就是:一个.db文件就是整个数据库,不需要像MySQL那样启动一个服务。这对于“本地记账本”来说再合适不过了——数据文件可以随意拷贝、备份,应用启动即用。
所以,这个项目的价值不在于炫技,而在于提供一个完整的、可落地的、从界面到数据库的闭环开发实践。你会完整经历:设计数据库表、用JDBC连接SQLite、在Swing界面中实现数据的增删改查、处理事件响应、以及打包成可独立运行的JAR包。这比单纯写算法题或做Web项目,更能让你理解一个完整桌面应用的骨架。
我建议,无论你是Java初学者想巩固综合能力,还是需要一个小工具来管理个人账目,都可以从这个项目开始。下面,我会按照从零搭建的顺序,把环境、设计、编码、调试和打包的完整链路拆解清楚。
2. 动手前的环境与依赖准备:别在起点卡住
在开始写代码之前,确保你的环境是通的。很多问题(尤其是ClassNotFound、驱动加载失败)都源于这一步没做好。
2.1 Java开发环境确认
首先,你需要一个Java开发环境。我一般用JDK 8或JDK 11,Swing兼容性很好,这两个版本都足够稳定。 打开终端(或CMD),运行:
java -version javac -version确保两个命令都能正确输出版本信息。如果不行,你需要去Oracle官网或Adoptium等网站下载并安装JDK,并正确配置JAVA_HOME和PATH环境变量。这是老生常谈,但确是第一步。
2.2 引入SQLite的JDBC驱动
这是核心依赖。SQLite本身是C库,我们需要一个Java能调用的“桥梁”,这就是JDBC驱动。
- 获取驱动JAR包:推荐从Maven中央仓库(如 https://mvnrepository.com/ )搜索
sqlite-jdbc,选择一个较新的版本(例如3.40以上)下载。 - 在项目中引用:
- 如果你使用IDE(如IntelliJ IDEA或Eclipse),在项目里创建一个
lib文件夹,把下载的sqlite-jdbc-xxx.jar放进去,然后将其添加为项目的库(Library)。 - 如果你使用Maven/Gradle,直接在
pom.xml或build.gradle中添加对应依赖更省事。这是更推荐的方式,便于依赖管理。 Maven示例:<dependency> <groupId>org.xerial</groupId> <artifactId>sqlite-jdbc</artifactId> <version>3.45.1.0</version> </dependency>
- 如果你使用IDE(如IntelliJ IDEA或Eclipse),在项目里创建一个
2.3 准备一个SQLite可视化工具(非必须,但强烈推荐)
虽然我们可以用纯代码操作数据库,但有一个可视化工具能极大提升效率,方便我们查看、调试数据。DB Browser for SQLite (DB4S)是免费开源的首选。
- 作用:创建数据库文件、执行SQL语句、设计表结构、浏览和编辑数据。
- 为什么需要:当你的程序插入数据后,你可以立刻用DB4S打开.db文件,确认数据是否真的写进去了,格式对不对。这能帮你快速区分是“SQL逻辑问题”还是“程序代码问题”。
准备好这三样(JDK、SQLite JDBC驱动、DB4S),你的开发环境就齐活了。
3. 从数据库设计开始:想清楚再写代码
不要一上来就埋头写Swing界面。数据是核心,先设计好数据库表结构,后面的编码逻辑才会清晰。
对于一个最简单的记账本,我们至少需要一张表来记录账单。我建议包含以下核心字段:
| 字段名 | 数据类型 | 说明 | 约束 |
|---|---|---|---|
id | INTEGER | 主键,唯一标识一条记录 | PRIMARY KEY AUTOINCREMENT |
type | TEXT | 类型,如“收入”或“支出” | NOT NULL |
amount | REAL | 金额 | NOT NULL |
category | TEXT | 类别,如“餐饮”、“交通”、“工资” | NOT NULL |
date | TEXT | 日期,格式建议为‘YYYY-MM-DD’ | NOT NULL |
remark | TEXT | 备注 | (可为空) |
为什么这么设计?
id自增主键:保证每条记录唯一,也是我们后续做更新、删除操作时的依据。type和category分开:这样更灵活。你可以固定type为“收入/支出”,而category可以自由扩展。date用TEXT存:SQLite没有专门的Date类型,用TEXT存标准格式的字符串,既便于显示,也便于用SQL进行日期范围的查询(如WHERE date BETWEEN '2024-01-01' AND '2024-01-31')。amount用REAL:因为金额可能有小数。
创建表的SQL语句(可以在DB4S中执行,也可以写在程序初始化代码里):
CREATE TABLE IF NOT EXISTS account ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, amount REAL NOT NULL, category TEXT NOT NULL, date TEXT NOT NULL, remark TEXT );把这张表建好,你的数据“仓库”就有了。接下来就是搭建一个“前台”(Swing界面)来操作这个仓库。
4. 搭建Swing界面骨架与数据库连接
现在进入编码阶段。我们先搭建一个最基础的界面,并建立可靠的数据库连接。
4.1 建立数据库连接工具类
创建一个DBUtil.java类,专门负责获取数据库连接。这样做的好处是连接参数集中管理,避免代码重复。
import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:sqlite:account.db"; // 数据库文件名为account.db static { try { Class.forName("org.sqlite.JDBC"); // 加载驱动 } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL); } // 测试连接 public static void main(String[] args) { try (Connection conn = getConnection()) { System.out.println("数据库连接成功!"); } catch (SQLException e) { e.printStackTrace(); } } }关键点:
jdbc:sqlite:account.db:如果account.db文件不存在,SQLite驱动会自动在当前程序运行目录下创建它。Class.forName(...):老式但可靠的驱动加载方式。确保你的驱动JAR包在类路径中。- 运行
main方法,如果输出“连接成功”,并且当前目录下生成了account.db文件,说明数据库层通了。立刻用DB4S打开这个文件,验证一下。
4.2 设计主界面布局
Swing布局方式很多,对于记账本这种表单+列表的应用,我推荐使用BorderLayout结合JPanel进行嵌套,这样结构清晰。 创建一个AccountBook.java作为主窗口类。
import javax.swing.*; import java.awt.*; public class AccountBook extends JFrame { // 定义组件 private JTextField amountField, categoryField, dateField, remarkField; private JComboBox<String> typeComboBox; private JButton addButton, deleteButton, updateButton, queryButton; private JTable recordTable; private JScrollPane tableScrollPane; public AccountBook() { setTitle("本地记账本"); setSize(800, 600); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); // 窗口居中 initUI(); // 初始化界面 // initData(); // 初始化表格数据(稍后实现) // bindEvents(); // 绑定事件(稍后实现) } private void initUI() { // 1. 顶部输入面板 (North) JPanel inputPanel = new JPanel(new GridLayout(2, 5, 10, 10)); // 2行5列,有间距 inputPanel.setBorder(BorderFactory.createTitledBorder("新增/编辑记录")); inputPanel.add(new JLabel("类型:")); typeComboBox = new JComboBox<>(new String[]{"支出", "收入"}); inputPanel.add(typeComboBox); inputPanel.add(new JLabel("金额:")); amountField = new JTextField(); inputPanel.add(amountField); inputPanel.add(new JLabel("类别:")); categoryField = new JTextField(); inputPanel.add(categoryField); inputPanel.add(new JLabel("日期 (YYYY-MM-DD):")); dateField = new JTextField(); inputPanel.add(dateField); inputPanel.add(new JLabel("备注:")); remarkField = new JTextField(); inputPanel.add(remarkField); // 占位 inputPanel.add(new JLabel()); inputPanel.add(new JLabel()); // 2. 中间表格面板 (Center) recordTable = new JTable(); tableScrollPane = new JScrollPane(recordTable); tableScrollPane.setBorder(BorderFactory.createTitledBorder("账单记录")); // 3. 底部按钮面板 (South) JPanel buttonPanel = new JPanel(new FlowLayout()); addButton = new JButton("新增"); updateButton = new JButton("更新"); deleteButton = new JButton("删除"); queryButton = new JButton("查询"); buttonPanel.add(addButton); buttonPanel.add(updateButton); buttonPanel.add(deleteButton); buttonPanel.add(queryButton); // 将面板添加到主窗口 setLayout(new BorderLayout(10, 10)); add(inputPanel, BorderLayout.NORTH); add(tableScrollPane, BorderLayout.CENTER); add(buttonPanel, BorderLayout.SOUTH); } public static void main(String[] args) { // 在事件分发线程中启动GUI SwingUtilities.invokeLater(() -> { new AccountBook().setVisible(true); }); } }运行这个main方法,你应该能看到一个带有输入框、下拉框、表格和按钮的窗口框架。虽然还不能操作数据,但界面骨架已经出来了。先让界面跑起来,再填充逻辑,这是稳妥的做法。
5. 实现核心功能:增删改查与表格展示
界面有了,数据库连接也有了,现在要把它们粘合起来。核心是四件事:把数据从界面插入数据库、从数据库查出来显示在表格、修改一条记录、删除一条记录。
5.1 定义数据模型(Java Bean)
首先,创建一个AccountRecord.java类,对应数据库表中的一行记录。这能让数据传递更清晰。
public class AccountRecord { private Integer id; private String type; private Double amount; private String category; private String date; private String remark; // 构造方法、Getter和Setter省略... // 务必用IDE生成这些方法,后续操作依赖它们。 }5.2 创建数据访问对象(DAO)
DAO层负责所有数据库操作,将业务逻辑和数据持久化分离。创建AccountDAO.java。
import java.sql.*; import java.util.ArrayList; import java.util.List; public class AccountDAO { // 新增记录 public boolean add(AccountRecord record) { String sql = "INSERT INTO account(type, amount, category, date, remark) VALUES(?,?,?,?,?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, record.getType()); pstmt.setDouble(2, record.getAmount()); pstmt.setString(3, record.getCategory()); pstmt.setString(4, record.getDate()); pstmt.setString(5, record.getRemark()); pstmt.executeUpdate(); return true; } catch (SQLException e) { e.printStackTrace(); return false; } } // 查询所有记录 public List<AccountRecord> queryAll() { List<AccountRecord> list = new ArrayList<>(); String sql = "SELECT * FROM account ORDER BY date DESC, id DESC"; // 按日期和ID倒序,新的在前 try (Connection conn = DBUtil.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) { AccountRecord record = new AccountRecord(); record.setId(rs.getInt("id")); record.setType(rs.getString("type")); record.setAmount(rs.getDouble("amount")); record.setCategory(rs.getString("category")); record.setDate(rs.getString("date")); record.setRemark(rs.getString("remark")); list.add(record); } } catch (SQLException e) { e.printStackTrace(); } return list; } // 更新记录 public boolean update(AccountRecord record) { String sql = "UPDATE account SET type=?, amount=?, category=?, date=?, remark=? WHERE id=?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, record.getType()); pstmt.setDouble(2, record.getAmount()); pstmt.setString(3, record.getCategory()); pstmt.setString(4, record.getDate()); pstmt.setString(5, record.getRemark()); pstmt.setInt(6, record.getId()); int rows = pstmt.executeUpdate(); return rows > 0; } catch (SQLException e) { e.printStackTrace(); return false; } } // 删除记录 public boolean delete(int id) { String sql = "DELETE FROM account WHERE id=?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setInt(1, id); int rows = pstmt.executeUpdate(); return rows > 0; } catch (SQLException e) { e.printStackTrace(); return false; } } // 按条件查询(例如按月份) public List<AccountRecord> queryByMonth(String yearMonth) { // 格式如 "2024-01" List<AccountRecord> list = new ArrayList<>(); String sql = "SELECT * FROM account WHERE date LIKE ? ORDER BY date DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, yearMonth + "%"); ResultSet rs = pstmt.executeQuery(); while (rs.next()) { // ... 组装对象,同queryAll AccountRecord record = new AccountRecord(); // 省略setter代码 list.add(record); } } catch (SQLException e) { e.printStackTrace(); } return list; } }关键点:
- 使用PreparedStatement:这是防止SQL注入攻击的标准做法,务必养成习惯。
- 使用Try-with-Resources:确保
Connection、Statement、ResultSet这些资源在使用后能被自动关闭,避免内存泄漏。 - 查询排序:
ORDER BY date DESC, id DESC让最新的记录显示在最上面,符合使用习惯。
5.3 为表格绑定数据模型(TableModel)
Swing的JTable需要用一个TableModel来提供数据。我们自定义一个。
import javax.swing.table.AbstractTableModel; import java.util.List; public class AccountTableModel extends AbstractTableModel { private final String[] columnNames = {"ID", "类型", "金额", "类别", "日期", "备注"}; private List<AccountRecord> data; public AccountTableModel(List<AccountRecord> data) { this.data = data; } public void setData(List<AccountRecord> data) { this.data = data; fireTableDataChanged(); // 通知表格数据已更新,重绘 } @Override public int getRowCount() { return data.size(); } @Override public int getColumnCount() { return columnNames.length; } @Override public String getColumnName(int column) { return columnNames[column]; } @Override public Object getValueAt(int rowIndex, int columnIndex) { AccountRecord record = data.get(rowIndex); switch (columnIndex) { case 0: return record.getId(); case 1: return record.getType(); case 2: return record.getAmount(); case 3: return record.getCategory(); case 4: return record.getDate(); case 5: return record.getRemark(); default: return null; } } }5.4 在主界面中整合功能
现在回到AccountBook主类,我们需要做三件事:初始化表格数据、为按钮绑定事件、实现刷新表格的方法。
public class AccountBook extends JFrame { // ... 之前定义的组件变量 private AccountTableModel tableModel; private AccountDAO dao = new AccountDAO(); private List<AccountRecord> currentData; // 修改构造函数,调用初始化方法 public AccountBook() { // ... 原有代码 initUI(); initData(); // 初始化数据 bindEvents(); // 绑定事件 } private void initData() { currentData = dao.queryAll(); tableModel = new AccountTableModel(currentData); recordTable.setModel(tableModel); } private void bindEvents() { // 新增按钮事件 addButton.addActionListener(e -> addRecord()); // 更新按钮事件 updateButton.addActionListener(e -> updateRecord()); // 删除按钮事件 deleteButton.addActionListener(e -> deleteRecord()); // 查询按钮事件(这里简化为刷新全部,可扩展) queryButton.addActionListener(e -> refreshTable()); // 表格点击事件:选中一行,将数据填充到上方表单以便编辑 recordTable.getSelectionModel().addListSelectionListener(e -> { if (!e.getValueIsAdjusting()) { int selectedRow = recordTable.getSelectedRow(); if (selectedRow >= 0) { fillFormFromTable(selectedRow); } } }); } private void addRecord() { AccountRecord record = getRecordFromForm(); if (record != null) { if (dao.add(record)) { JOptionPane.showMessageDialog(this, "添加成功!"); clearForm(); refreshTable(); } else { JOptionPane.showMessageDialog(this, "添加失败!"); } } } private void updateRecord() { int selectedRow = recordTable.getSelectedRow(); if (selectedRow < 0) { JOptionPane.showMessageDialog(this, "请先选择要更新的记录!"); return; } int recordId = (int) tableModel.getValueAt(selectedRow, 0); // 获取ID AccountRecord record = getRecordFromForm(); if (record != null) { record.setId(recordId); if (dao.update(record)) { JOptionPane.showMessageDialog(this, "更新成功!"); refreshTable(); } else { JOptionPane.showMessageDialog(this, "更新失败!"); } } } private void deleteRecord() { int selectedRow = recordTable.getSelectedRow(); if (selectedRow < 0) { JOptionPane.showMessageDialog(this, "请先选择要删除的记录!"); return; } int recordId = (int) tableModel.getValueAt(selectedRow, 0); int confirm = JOptionPane.showConfirmDialog(this, "确定要删除这条记录吗?", "确认删除", JOptionPane.YES_NO_OPTION); if (confirm == JOptionPane.YES_OPTION) { if (dao.delete(recordId)) { JOptionPane.showMessageDialog(this, "删除成功!"); refreshTable(); clearForm(); } else { JOptionPane.showMessageDialog(this, "删除失败!"); } } } private void refreshTable() { currentData = dao.queryAll(); tableModel.setData(currentData); } private AccountRecord getRecordFromForm() { try { AccountRecord record = new AccountRecord(); record.setType((String) typeComboBox.getSelectedItem()); record.setAmount(Double.parseDouble(amountField.getText().trim())); record.setCategory(categoryField.getText().trim()); record.setDate(dateField.getText().trim()); record.setRemark(remarkField.getText().trim()); // 这里可以添加简单的数据验证,例如日期格式、金额正负 return record; } catch (NumberFormatException e) { JOptionPane.showMessageDialog(this, "金额格式错误!"); return null; } catch (Exception e) { JOptionPane.showMessageDialog(this, "表单数据不完整或格式错误!"); return null; } } private void fillFormFromTable(int row) { typeComboBox.setSelectedItem(tableModel.getValueAt(row, 1)); amountField.setText(tableModel.getValueAt(row, 2).toString()); categoryField.setText((String) tableModel.getValueAt(row, 3)); dateField.setText((String) tableModel.getValueAt(row, 4)); remarkField.setText((String) tableModel.getValueAt(row, 5)); } private void clearForm() { typeComboBox.setSelectedIndex(0); amountField.setText(""); categoryField.setText(""); dateField.setText(""); remarkField.setText(""); } // ... main方法 }至此,一个具备完整增删改查功能的记账本核心就完成了。运行程序,尝试添加几条记录,看看表格是否刷新,同时用DB Browser for SQLite打开account.db文件,确认数据是否已持久化。
6. 功能增强与实战避坑指南
基础功能跑通后,我们可以让它更实用、更健壮。这里有几个关键的增强点和避坑经验。
6.1 数据验证与用户体验
- 日期输入:让用户手动输入
YYYY-MM-DD容易出错。可以引入JFormattedTextField或第三方日期选择器(如JCalendar)。一个简单的替代方案是使用JSpinner。 - 金额验证:确保输入的是数字,并且支出可以为负(或固定类型为“支出”时金额为正)。在
getRecordFromForm()方法里加强校验。 - 空值处理:类别、日期不能为空。在插入数据库前检查。
- 操作反馈:使用
JOptionPane显示成功/失败提示,但不要滥用。对于批量操作,可以考虑在状态栏显示信息。
6.2 查询与统计功能
基础的列表展示不够,我们通常需要按时间、类型、类别进行筛选和统计。
- 在界面增加查询条件:在顶部输入面板旁,增加一个“查询”区域,包含月份下拉框、类别下拉框和“统计”按钮。
- 实现条件查询:在
AccountDAO中补充对应的方法,如queryByTypeAndMonth(String type, String yearMonth)。使用PreparedStatement动态拼接SQL的WHERE条件(注意处理参数为空的情况)。 - 实现简单统计:在
AccountDAO中增加统计方法,例如计算某个月份的总收入、总支出。public Map<String, Double> getSummaryByMonth(String yearMonth) { Map<String, Double> summary = new HashMap<>(); String sqlIncome = "SELECT SUM(amount) FROM account WHERE type='收入' AND date LIKE ?"; String sqlExpense = "SELECT SUM(amount) FROM account WHERE type='支出' AND date LIKE ?"; // ... 执行查询,将结果放入summary return summary; // 如 {"收入": 5000.0, "支出": 3000.0} } - 展示统计结果:可以在界面底部或单独弹窗显示统计结果。
6.3 打包成可独立运行的JAR
项目写完了,总不能每次都开IDE运行。我们需要打包。
- 确保代码无误:这是前提。
- 处理依赖:如果你用的是Maven/Gradle,它们有现成的打包插件(如
maven-assembly-plugin或shadowJar)可以打出一个包含所有依赖的“胖JAR”(Fat JAR)。 - 手动打包(无构建工具):
- 将编译好的所有
.class文件和依赖的sqlite-jdbc-xxx.jar放在一个目录下。 - 创建
MANIFEST.MF文件,指定主类和Class-Path。Manifest-Version: 1.0 Main-Class: com.yourpackage.AccountBook Class-Path: lib/sqlite-jdbc-3.45.1.0.jar - 使用
jar命令打包:jar cvfm AccountBook.jar MANIFEST.MF com/yourpackage/*.class - 将依赖JAR包放在与打包JAR同级的
lib目录下。
- 将编译好的所有
- 测试JAR包:在命令行运行
java -jar AccountBook.jar,看是否能正常启动并操作数据库。特别注意:此时数据库文件account.db的路径是相对于JAR运行位置的。如果找不到,可能会在运行目录新建一个空的,导致数据“丢失”。可以考虑将数据库文件放在用户目录(如System.getProperty("user.home"))下的固定位置。
6.4 常见问题与排查
- “No suitable driver found”:驱动未加载或不在类路径。检查
Class.forName是否执行,或检查打包时依赖JAR是否包含/路径是否正确。 - 数据库文件被锁定或找不到:检查程序是否正常关闭了Connection。确保没有多个进程同时读写同一个.db文件。使用Try-with-Resources能有效避免连接未关闭。
- 界面卡顿(特别是数据多时):
queryAll()可能返回大量数据。考虑分页查询(LIMIT, OFFSET),或默认只加载最近N条记录。 - 中文字符乱码:确保数据库连接URL中指定了编码(如
jdbc:sqlite:account.db?charset=utf8),并且Java源代码文件本身是UTF-8编码。 - 表格不刷新:调用
tableModel.setData()后,务必调用fireTableDataChanged()。在Swing中,对UI的更新必须在事件分发线程(EDT)中进行,可以使用SwingUtilities.invokeLater()。
7. 项目扩展思路与总结
一个基础版本完成后,你可以根据自己的需求进行扩展,这会让这个项目更有价值:
- 数据导出:增加导出到Excel(使用Apache POI或JExcel库)或CSV文件的功能。
- 图表分析:集成JFreeChart等图表库,将月度支出收入做成饼图或折线图,实现真正的“可视化分析”。
- 多账户/多账簿管理:通过切换不同的.db文件,或在同一数据库中设计
book表来管理多个账本。 - 数据备份与恢复:提供菜单项,将当前.db文件复制到指定备份目录,或从备份恢复。
- 界面美化:使用
LookAndFeel更换Swing默认皮肤,或者使用更现代的JavaFX重写界面层(但需重学一套)。
回过头看,这个“Java Swing + SQLite 记账本”项目,其核心价值在于串联了Java SE阶段多个核心知识点:面向对象、集合、IO、JDBC、Swing事件模型、异常处理、打包部署。它不是一个玩具,而是一个麻雀虽小,五脏俱全的完整应用。
我个人的建议是,在实现基本功能后,不要急于堆砌新功能,而是先把你写的代码重构一遍:看看哪些地方可以抽取成方法,哪些常量可以配置化,异常处理是否完备,用户体验有哪些可以优化的点。把这个过程走通、走扎实,比你单纯完成一个功能列表,收获要大得多。最后,记得用好DB Browser for SQLite这个工具,它在你调试数据库操作时,能提供最直观的反馈。