ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

安卓班费管理系统Java源码解析:SQLite事务与账目一致性

安卓班费管理系统Java源码解析:SQLite事务与账目一致性 简介这是一份基于Java开发的安卓班费管理系统设计源码面向有Android基础的学习者、课程设计或毕业设计学生以及需要提高班费透明度的班委群体既可作为移动端练手项目也能帮助理解从界面到数据流转的完整开发流程。资源共含200个文件核心由24个Java源文件与125个XML文件组成Java文件实现记账、查询、质疑、回复等业务逻辑XML文件负责界面布局与资源定义另含Gradle构建脚本、Properties配置及WebP/PNG图片压缩包仅7.17MB结构便于直接导入Android工程学习。目前已有330人学习下载。系统完整覆盖班费记录、流水查询、开支质疑与班委公开回应流程所有质疑与应答对全员可见当超过半数成员确认且质疑经发起人确认后该笔记录可标记有效形成公开、可追溯的班费管理闭环是理解Android实际业务系统或进行二次开发的实用参考。1. 基于Java的安卓班费管理系统源码难的不是界面而是账目一致基于Java的安卓班费管理系统源码第一眼看上去和个人记账本差别不大但真做起来会发现班费场景对账目一致性的要求远高于普通待办应用。钱是大家凑的哪一笔收了谁、哪一笔花在哪里、中途有没有人垫付后报销都要能翻出旧账。很多入门者拿到这个题目先画登录页和漂亮卡片等做到“退一笔记错的费”时才发现删除记录会直接把余额搞乱金额用 double 存又算出一堆 0.00000004。班费管理系统的核心不是 UI而是流水表的状态位、金额单位、写入事务和可追溯的撤销机制。这篇文章要解决的是什么、怎么做、参数怎么设、坑在哪几类问题。适合想做工具型 App 交付的开发也适合用 Java SQLite 做课程设计或开源项目的人。下面按数据模型、DAO 事务、界面刷新和验证技巧四条线拆开所有代码可以直接照抄到自己的安卓工程里。2. 班费管理系统的数据模型Java 实体与 SQLite 表结构设计班费管理系统源码里数据模型是整个工程的地基。一次班会凑了多少钱、买水花了多少钱、哪位同学垫付了班费这些信息如果只用一个amount字段存后面对账会非常痛苦。常见做法是把成员和流水拆成两张表流水表保留完整审计信息成员表单独维护缴费人。2.1 存储引擎选型Java 直接用 SQLiteOpenHelper 还是 ORM安卓本地存储绕不开 SQLite。班费系统数据量不大一个班通常几十人、几百条流水SQLite 完全够用。问题是 Java 工程里用哪种方式访问它原生SQLiteOpenHelper还是 Room、GreenDAO 这类 ORM 框架。从设计源码的角度看我更偏向用SQLiteOpenHelper包一层 DAO因为班费管理逻辑本身不多ORM 带来的编译期校验优势有限反而要额外学习注解和编译插件。把数据库访问全部收口在 DAO 类里以后想换 Room 或第三方库也不影响业务层。对比维度SQLiteOpenHelperRoom / ORMSQL 可控性完全自己写容易调优SQL 由框架生成复杂查询要自己写编译期校验无SQL 错误运行时才报有表结构对不上编译期报错样板代码多需要手写 Cursor 映射少自动完成对象映射迁移机制手动改onUpgrade提供 Migration 机制仍需手写学习成本对 Java 基础要求低需要理解注解和生命周期适合场景表少、逻辑清晰的工具型 App表多、团队协作的中大型项目班费管理系统表不超过 5 张用原生方式保留 SQL 的可读性更划算。源码结构上定一个FeeDbHelper负责建表再定FeeRecordDao、MemberDao负责读写Activity 不直接接触SQLiteDatabase。2.2 Java 实体类、枚举与金额存储单位金额处理是整个系统最容易被喷的地方。用float或double存班费时间一长会出现类似12.34显示成12.3399999的精度问题Java 的BigDecimal只适合在计算层使用数据库存储用整数“分”才是标准做法。一个long类型的amountCents字段足以覆盖千万级班费金额不会溢出也不存在浮点误差。public class FeeRecord { private long id; private int type; // 1收入 2支出 private long amountCents; // 金额单位分 private Long memberId; // 缴费人允许为 null private String category; // 分类班费、班服、活动等 private String note; // 备注 private int status; // 1正常 0作废 private long createTime; // 创建时间戳毫秒 public FeeRecord() { } // getter/setter 省略实际工程用 getXxx() 与 setXxx() }实体类里type和status如果直接填 1、0代码里会散落大量魔法数字。给type定义一个枚举既方便判断收入支出也能防止别人误传成 3、4。public enum TxType { INCOME(1), EXPENSE(2); private final int code; TxType(int code) { this.code code; } public int getCode() { return code; } public static TxType fromCode(int code) { for (TxType type : values()) { if (type.code code) { return type; } } throw new IllegalArgumentException(非法的账目类型: code); } }fromCode的作用是把数据库查出来的 int 转成枚举一旦拿到未知值直接抛异常比后面业务层静默处理更安全。2.3 流水表与成员表建表 SQL 及字段约束建表 SQL 里要把约束写清楚尤其是金额必须大于 0、类型必须在收入和支出之间、时间必须非空。班费管理不同于日志系统账目数据脏了很难自动修复前置校验比事后补偿便宜得多。CREATE TABLE member ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT 匿名, joined_at INTEGER NOT NULL ); CREATE TABLE fee_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER NOT NULL CHECK (type IN (1, 2)), amount_cents INTEGER NOT NULL CHECK (amount_cents 0), category TEXT NOT NULL DEFAULT 其他, member_id INTEGER, note TEXT, status INTEGER NOT NULL DEFAULT 1, create_time INTEGER NOT NULL, void_time INTEGER, void_reason TEXT, FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE SET NULL ); CREATE INDEX idx_fee_record_type_status ON fee_record(type, status); CREATE INDEX idx_fee_record_create_time ON fee_record(create_time);CHECK (amount_cents 0)保证数据库层面拒绝 0 元或负数流水。status默认 1作废时改为 0而不是删除记录这是和普通记账本最大的区别。两个索引分别服务“按类型统计”和“按时间排序”的查询流水量不超过几万行时足够用。3. 班费收支源码的核心Java DAO 与可靠的事务写入班费管理系统和普通招募应用的差别之一是写操作必须在一个事务里完成。比如新增收入流水时如果只插入fee_record而不更新余额下次查询又会重算所以这类系统更倾向于直接重算余额而不是维护冗余的余额字段。3.1 使用 SQLiteOpenHelper 编写 Java DAO 层的插入与查询先用SQLiteOpenHelper写好数据库访问入口onCreate里执行第 2 章建表 SQLonConfigure里打开外键约束。缺少外键约束member_id指向被删除成员时查不到归属对账时会出现“这笔钱是谁交的”的悬念。public class FeeDbHelper extends SQLiteOpenHelper { private static final String DB_NAME class_fee.db; private static final int DB_VERSION 1; public FeeDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onConfigure(SQLiteDatabase db) { super.onConfigure(db); db.setForeignKeyConstraintsEnabled(true); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE member ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT 匿名, joined_at INTEGER NOT NULL)); db.execSQL(CREATE TABLE fee_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER NOT NULL CHECK (type IN (1, 2)), amount_cents INTEGER NOT NULL CHECK (amount_cents 0), category TEXT NOT NULL DEFAULT 其他, member_id INTEGER, note TEXT, status INTEGER NOT NULL DEFAULT 1, create_time INTEGER NOT NULL, void_time INTEGER, void_reason TEXT, FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE SET NULL)); db.execSQL(CREATE INDEX idx_fee_record_type_status ON fee_record(type, status)); db.execSQL(CREATE INDEX idx_fee_record_create_time ON fee_record(create_time)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 班费系统同样要保留历史表这里先做迁移占位 } }插入流水的 DAO 操作必须放到事务里。insertOrThrow在写入失败时抛出异常让外层事务回滚避免只插入一半导致的数据残缺。public class FeeRecordDao { private final FeeDbHelper dbHelper; public FeeRecordDao(FeeDbHelper dbHelper) { this.dbHelper dbHelper; } public long insert(FeeRecord record) { SQLiteDatabase db dbHelper.getWritableDatabase(); long rowId -1; db.beginTransaction(); try { ContentValues values new ContentValues(); values.put(type, record.getType()); values.put(amount_cents, record.getAmountCents()); values.put(category, record.getCategory()); if (record.getMemberId() ! null) { values.put(member_id, record.getMemberId()); } values.put(note, record.getNote()); values.put(status, 1); values.put(create_time, System.currentTimeMillis()); rowId db.insertOrThrow(fee_record, null, values); db.setTransactionSuccessful(); } finally { db.endTransaction(); } return rowId; } public ListFeeRecord listByTime(int limit, int offset) { SQLiteDatabase db dbHelper.getReadableDatabase(); ListFeeRecord list new ArrayList(); Cursor cursor db.rawQuery( SELECT id, type, amount_cents, member_id, category, note, status, create_time FROM fee_record ORDER BY create_time DESC, id DESC LIMIT ? OFFSET ?, new String[]{String.valueOf(limit), String.valueOf(offset)}); try { while (cursor.moveToNext()) { FeeRecord record new FeeRecord(); record.setId(cursor.getLong(cursor.getColumnIndexOrThrow(id))); record.setType(cursor.getInt(cursor.getColumnIndexOrThrow(type))); record.setAmountCents(cursor.getLong(cursor.getColumnIndexOrThrow(amount_cents))); if (!cursor.isNull(cursor.getColumnIndexOrThrow(member_id))) { record.setMemberId(cursor.getLong(cursor.getColumnIndexOrThrow(member_id))); } record.setCategory(cursor.getString(cursor.getColumnIndexOrThrow(category))); record.setNote(cursor.getString(cursor.getColumnIndexOrThrow(note))); record.setStatus(cursor.getInt(cursor.getColumnIndexOrThrow(status))); record.setCreateTime(cursor.getLong(cursor.getColumnIndexOrThrow(create_time))); list.add(record); } } finally { cursor.close(); } return list; } }LIMIT ? OFFSET ?两个参数必须用字符串数组传入不能拼进 SQL 字符串否则会有注入风险。finally里关闭 Cursor事务的endTransaction也必须放在 finally保证异常时一定结束事务。3.2 撤销与冲账为什么不要直接 delete录入一笔错误支出后用户第一反应是删掉。在班费管理里直接delete会破坏审计链条你说删掉其他同学怎么知道当初记过这笔账因此系统设计要引入“作废”而不是“删除”用status0标记同时记录作废时间和作废原因。public void voidRecord(long recordId, String reason) { SQLiteDatabase db dbHelper.getWritableDatabase(); db.beginTransaction(); try { ContentValues values new ContentValues(); values.put(status, 0); values.put(void_time, System.currentTimeMillis()); values.put(void_reason, reason null ? : reason); int rows db.update(fee_record, values, id ? AND status 1, new String[]{String.valueOf(recordId)}); if (rows 0) { // 已经作废或不存在直接抛异常让调用方感知 throw new IllegalStateException(流水不存在或已作废); } db.setTransactionSuccessful(); } finally { db.endTransaction(); } }UPDATE条件里加AND status 1防止二次作废覆盖原来的作废原因。作废操作适用于支出记错、收入重复提交等场景不适用于更正金额。更正金额应该新增一条“撤销原流水 新增正确流水”的复合操作而不是直接改原记录。操作是否保留原数据能否知道为何变化对账影响直接删除否否余额变化无法溯源作废原记录是是统计时过滤 status0余额正确且可查3.3 余额汇总 SQL 与 Java 侧二次校验余额的计算逻辑要同时处理两类状态type 区分收入支出status 区分正常和作废。最容易写错的是只过滤 type 而不过滤 status把作废流水重新算进余额。SELECT COALESCE(SUM(CASE WHEN type 1 AND status 1 THEN amount_cents ELSE 0 END), 0) AS income_cents, COALESCE(SUM(CASE WHEN type 2 AND status 1 THEN amount_cents ELSE 0 END), 0) AS expense_cents, COALESCE(SUM(CASE WHEN type 1 AND status 1 THEN amount_cents ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN type 2 AND status 1 THEN amount_cents ELSE 0 END), 0) AS balance_cents FROM fee_record;SUM在没有匹配行时返回 NULL外面包一层COALESCE才能让 Java 侧拿到 0 而不是null。Java 侧读取时也做一次防御避免 Navite 层错误导致崩溃。public long queryBalanceCents(SQLiteDatabase db) { String sql SELECT COALESCE(SUM(CASE WHEN type 1 AND status 1 THEN amount_cents ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN type 2 AND status 1 THEN amount_cents ELSE 0 END), 0) FROM fee_record; Cursor cursor db.rawQuery(sql, null); long balance 0; if (cursor.moveToFirst()) { balance cursor.getLong(0); } cursor.close(); return balance; }用long做余额计算因为所有金额都以分为单位Java 侧的加减不会带来浮点误差。如果最终展示需要“元”单位再在界面层转成字符串存库始终是分。4. 安卓界面接数据RecyclerView、刷新与输入解析数据库层写好后剩下的是把班费数据展示到安卓界面上。ListView 早就该退役RecyclerView 是现在的主选。Java 工程里写 RecyclerView 没有太复杂关键是注意线程、适配器更新策略和输入解析。4.1 用 Java 写 RecyclerView 适配器承接班费流水适配器的任务是把ListFeeRecord映射成一行一行的卡片。班费流水列表通常需要显示类型图标、分类、金额、时间、作废状态ViewHolder 只负责持有控件绑定数据写在onBindViewHolder里。public class FeeAdapter extends RecyclerView.AdapterFeeAdapter.FeeViewHolder { private final ListFeeRecord records new ArrayList(); public void submitData(ListFeeRecord newRecords) { records.clear(); if (newRecords ! null) { records.addAll(newRecords); } notifyDataSetChanged(); } NonNull Override public FeeViewHolder onCreateViewHolder(NonNull ViewGroup parent, int viewType) { View itemView LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_fee_record, parent, false); return new FeeViewHolder(itemView); } Override public void onBindViewHolder(NonNull FeeViewHolder holder, int position) { FeeRecord record records.get(position); String amountText; if (record.getType() TxType.INCOME.getCode()) { amountText formatCents(record.getAmountCents()); } else { amountText - formatCents(record.getAmountCents()); } holder.amountView.setText(amountText); holder.categoryView.setText(record.getCategory()); holder.timeView.setText(formatTime(record.getCreateTime())); if (record.getStatus() 0) { holder.amountView.setPaintFlags(holder.amountView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); } else { holder.amountView.setPaintFlags(holder.amountView.getPaintFlags() (~Paint.STRIKE_THRU_TEXT_FLAG)); } } Override public int getItemCount() { return records.size(); } public static class FeeViewHolder extends RecyclerView.ViewHolder { TextView categoryView; TextView amountView; TextView timeView; public FeeViewHolder(NonNull View itemView) { super(itemView); categoryView itemView.findViewById(R.id.tv_category); amountView itemView.findViewById(R.id.tv_amount); timeView itemView.findViewById(R.id.tv_time); } } }getItemCount决定列表长度onCreateViewHolder创建行容器onBindViewHolder负责按 position 填充数据。作废流水用删除线标出来比直接隐藏更符合审计意识。formatCents把“分”转成“元”要保证金额为 0 时显示“0.00”而不是空。4.2 首页余额卡片的后台查询与主线程刷新安卓抛出的“不能在主线程做网络操作”并不限制 SQLite但消费流水查询如果数据量大放在主线程仍然会卡界面导致 ANR。班费系统虽然数据量小规则仍要养成数据库查询放到子线程结果回到主线程刷新 UI。private final ExecutorService executor Executors.newSingleThreadExecutor(); private void refreshBalance() { executor.execute(() - { long balanceCents dao.queryBalanceCents(dbHelper.getReadableDatabase()); runOnUiThread(() - balanceText.setText(formatCents(balanceCents))); }); }newSingleThreadExecutor保证多条数据库操作按顺序执行避免并发写导致锁冲突。runOnUiThread把结果切回主线程。Activity 销毁时记得调用executor.shutdown()否则后台任务持有 Activity 引用会内存泄漏。刷新时机推荐做法说明新增或作废流水后refreshBalance()手动刷新不用额外状态回调从详情页返回列表onResume里刷新防止别的页面改了数据每次弹软键盘不需刷新和账目无关重复刷新浪费电4.3 金额输入的格式化解析与非法值拦截用户输入“12.3”或“12.345”数据库存储单位是分解析规则必须统一。直接用Integer.parseInt会忽略小数Double.parseDouble又引入精度风险。用BigDecimal把输入字符串转成“分”是最稳的它能捕获12.345这种无法精确转换的小数。public static long parseCents(String input) { if (input null) { throw new IllegalArgumentException(金额不能为空); } String trimInput input.trim(); if (trimInput.isEmpty()) { throw new IllegalArgumentException(金额不能为空); } try { BigDecimal amount new BigDecimal(trimInput); if (amount.signum() 0) { throw new IllegalArgumentException(金额必须大于0); } return amount.multiply(BigDecimal.valueOf(100)).longValueExact(); } catch (ArithmeticException e) { throw new IllegalArgumentException(金额最多两位小数, e); } catch (NumberFormatException e) { throw new IllegalArgumentException(金额格式错误, e); } }longValueExact在 BigDecimal 超出 long 范围或存在小数位时抛ArithmeticException这一步拦住了“20.999”这种输入。signum() 0拦截 0 和负数保证数据库 CHECK 约束不会被触发也避免用户以为交了 0 元也能入账。5. 班费管理源码的进阶验证技巧对账、导出与单测班费管理系统的源码能不能拿得出手看的是验证手段。功能做出来只是第一步还要能证明“余额是对的”常见做法是流水重算、CSV 导出和 JUnit 单元测试。5.1 用流水重算和余额快照对账不要只显示一个从fee_record实时算出来的余额否则哪天 SQL 条件改错错误会隐藏在日常显示里。更稳的做法是定期生成余额快照对账时用流水重算和快照做对比。public void checkBalance(long snapshotCents) { long recalculatedCents dao.queryBalanceCents(dbHelper.getReadableDatabase()); if (snapshotCents ! recalculatedCents) { throw new IllegalStateException(对账不平快照 snapshotCents 重算 recalculatedCents); } }这个逻辑可以每月末执行一次也可以每次打开 App 时在后台静默跑。对账失败提示三位字段快照余额、重算余额、最早一条未匹配流水方便直接定位。5.2 导出 CSV 并处理特殊字符导出流水到 CSV 文件是班费管理非常务实的功能方便生活委员在 Excel 里做二次核对。导出文件要加 UTF-8 BOM否则 Excel 打开中文会乱码。备注字段可能包含逗号、引号、换行必须做转义。private static String escapeCsv(String value) { if (value null) { return ; } if (value.contains(,) || value.contains(\) || value.contains(\n) || value.contains(\r)) { return \ value.replace(\, \\) \; } return value; } public void exportCsv(File file, ListFeeRecord records) throws IOException { try (FileOutputStream fos new FileOutputStream(file); OutputStreamWriter writer new OutputStreamWriter(fos, StandardCharsets.UTF_8)) { writer.write(\uFEFF); writer.write(类型,分类,金额(分),备注,状态,时间\n); for (FeeRecord record : records) { String type record.getType() TxType.INCOME.getCode() ? 收入 : 支出; writer.write(type , escapeCsv(record.getCategory()) , record.getAmountCents() , escapeCsv(record.getNote()) , (record.getStatus() 1 ? 正常 : 作废) , record.getCreateTime() \n); } } }5.3 JUnit 验证账目计算器的三个关键用例班费计算器parseCents是最容易被后续改动破坏的逻辑给它写单元测试比写 SQL 测试更划算。纯 Java 的junit测试不用连数据库直接验证输入到分的转换规则。Test public void parseCents_shouldConvertYuanToFen() { assertEquals(1234L, FeeCalculator.parseCents(12.34)); assertEquals(1000L, FeeCalculator.parseCents(10)); assertEquals(1L, FeeCalculator.parseCents(0.01)); } Test public void parseCents_shouldRejectMoreThanTwoDecimals() { assertThrows(IllegalArgumentException.class, () - FeeCalculator.parseCents(12.345)); } Test public void parseCents_shouldRejectZeroAndNegative() { assertThrows(IllegalArgumentException.class, () - FeeCalculator.parseCents(0)); assertThrows(IllegalArgumentException.class, () - FeeCalculator.parseCents(-5)); }三个用例覆盖正常整数、两位小数、超精度、零值和负值跑通后金额输入写死的逻辑就有了回归保护。之后无论界面层怎么改底层计算规则不会偷偷变掉。本文还有配套的精品资源点击获取
返回列表