简介:一套面向班级财务透明管理场景的安卓班费管理系统源码包,基于Java语言打造,适合Android开发者、计算机专业学生或需要搭建班费管理平台的人群学习与复用。压缩包共200个文件、约7.17MB,核心由24个Java源文件构成业务逻辑,125个XML文件承担界面布局与配置,并辅以WebP/PNG图片资源、Gradle构建脚本、JAR依赖及Git忽略配置等,整体工程结构完整,可直接导入IDE查看运行。功能上涵盖班费记录、开支查询、一周内质疑、班委回复及全员可见的应答记录,且半数以上成员确认且质疑解决后可标记有效,清晰展现了多人参与、流程透明的设计思路。目前已有330人学习下载,适合作为Java/Android课程设计、毕业设计或班级管理信息化项目的参考,能够帮助读者理解Android UI搭建、数据持久化与业务状态流转的完整实现。
1. 基于Java的安卓班费管理系统:为什么要做、解决什么问题
班里收班费、记流水、月底对账,几乎是每个班级都要经历的麻烦事。负责管钱的班委用微信聊天记录记账,年底一问余额多少,对不上账的情况经常发生。班费管理系统这个项目,核心就是把「谁交了多少钱、什么时候花了多少钱、花在哪儿、还剩多少」这套流程从聊天记录和纸质本子搬到手机 App 里,做到每一笔收支有记录、可追溯、能导出。基于 Java 的安卓原生实现,意味着不用依赖第三方框架,Android Studio 里直接能编译运行,适合作为课程设计、毕业设计,也适合真正给班级日常使用。
这个项目最直接的受众是两类人:一类是计算机相关专业的学生,需要一套能跑通、代码结构清晰的安卓项目作为课设或毕设;另一类是正在做班级管理的班委,希望有个简单工具替代手工记账。从技术角度看,它的难点不在界面多华丽,而在收支数据的准确性和账目可追溯性,所以我会把重点放在数据表设计、金额校验、统计查询这几块,界面部分只给最必要的实现。
2. 技术选型与项目结构:为什么用 Java 原生、数据存哪、代码怎么组织
2.1 Java 还是 Kotlin:标题定死 Java,但要知道取舍
这个项目标题明确写了「基于 Java」,所以主语言直接用 Java 没得选。不过在做选型时还是要说清楚:现在新开的安卓项目大部分已经转向 Kotlin,Java 版本的优势在于学习资料多、课设答辩时老师看起来熟悉、不需要额外学协程等新概念。缺点就是代码会相对啰嗦,比如 findViewById、OnClickListener 这些样板代码比较多。
我一般会保留 Java 的同时,把项目结构按分层拆干净:Activity 只做界面交互,数据操作放到独立的工具类或 DAO 里,这样即使以后想迁移 Kotlin,也不需要改动业务逻辑部分。实际操作时,我建议用 Android Studio 自带的项目模板创建 Empty Activity 项目,语言选 Java,最低 SDK 设到 21 左右,既能覆盖绝大多数设备,又不用写太多兼容代码。
2.2 数据存储选型:SQLite 原生还是 Room
班费管理的数据量不大,一个班级通常几十人,一年下来流水几百条,这种量级用 SQLite 原生 SQL 就足够了。但有一点要注意:如果直接用SQLiteOpenHelper写底层 SQL,所有建表、升级、查询都得自己维护,代码一多就容易出错。
从可维护性出发,我建议在新项目里直接引入 Room 框架,它是官方封装好的 ORM,编译期会校验 SQL 正确性,还能自动处理主键生成、字段映射这些事。不过如果课设要求里明确写了「使用 SQLite 数据库」而不是「使用 Room」,那还是老老实实用SQLiteOpenHelper,因为答辩时老师可能会对着源码问你 SQLite 的底层 API。
这里给一个折衷做法:用原生SQLiteOpenHelper管数据库,但把 CRUD 操作封装进独立的FinanceDatabaseHelper类,Activity 不直接碰 SQL 语句,这样既满足了课程要求,代码也不至于乱成一团。
2.3 架构分层:简单但不简陋
班费管理系统的规模不大,用不上完整的 MVP 或 MVVM,强行套架构反而增加理解成本。我一般会拆成四层:
Activity/Fragment:界面层,负责展示和接收用户输入;DatabaseHelper:数据访问层,封装所有 SQL 操作;Model:实体类,对应数据库表结构;Utils:工具类,放金额格式化、日期解析、CSV 导出等通用逻辑。
目录结构大致是这样:
app/src/main/java/com/example/banfei/ ├── activity/ │ ├── MainActivity.java // 主界面:首页余额与最近流水 │ ├── IncomeActivity.java // 记一笔收入 │ ├── ExpenseActivity.java // 记一笔支出 │ ├── MemberActivity.java // 成员管理 │ └── StatisticActivity.java // 统计图表 ├── database/ │ ├── DatabaseHelper.java // 建库建表 + 升级 │ ├── MemberDao.java // 成员增删改查 │ └── FinanceDao.java // 收支流水增删改查 ├── model/ │ ├── Member.java │ └── FinanceRecord.java └── utils/ ├── MoneyUtils.java // 金额工具 └── CsvExporter.java // 导出 CSVActivity 只调用 DAO 的方法拿到数据,再刷新界面。比如MainActivity启动时调用FinanceDao.getBalance()获取余额,FinanceDao.getLatestRecords(10)获取最近十条流水,具体 SQL 对界面完全不可见。
3. 数据表设计与核心模块拆分:先定表结构,再写功能代码
3.1 三张核心表:成员表、收支流水表、班级设置表
班费管理系统的核心就三张表,我现在把建表语句直接列出来:
-- 成员表 CREATE TABLE member ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, student_no TEXT UNIQUE, -- 学号,允许为空 join_date TEXT NOT NULL, -- 入班日期,格式 yyyy-MM-dd status INTEGER DEFAULT 1 -- 1=在读,0=已转出 ); -- 收支流水表 CREATE TABLE finance_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, member_id INTEGER, -- 关联成员,支出可空 type INTEGER NOT NULL, -- 1=收入,2=支出 amount_cents INTEGER NOT NULL, -- 金额,单位:分 category TEXT NOT NULL, -- 分类,如"班费收缴""打印资料" note TEXT, -- 备注 record_date TEXT NOT NULL, -- 日期 yyyy-MM-dd HH:mm operator TEXT, -- 经办人 FOREIGN KEY (member_id) REFERENCES member(id) ); -- 班级设置表 CREATE TABLE class_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), -- 只允许一行 class_name TEXT, total_members INTEGER DEFAULT 0 );这里有两个设计上的关键点,新手容易踩坑,值得展开说一下。
第一,金额字段必须用amount_cents存储以「分」为单位的整数,而不是用 REAL 存元。浮点数在 Java 里计算 0.1 + 0.2 会得到 0.30000000000000004,如果直接存元做累加,余额迟早对不上。用整数存分可以避开这个问题,展示时再除以 100 转成元。
第二,finance_record表里存的是「每次缴费的记录」,不是「成员当前应缴状态」。比如某同学第一学期交了 50 元,第二学期又交了 50 元,那就记两条收入记录,而不是更新成员表里的某个字段。这样后续统计「每个人总共交了多少」「哪些人还没交」都只需要查流水表,不需要额外维护冗余字段。
3.2 功能模块划分:记账、成员管理、统计、导出
按照上面三张表,功能模块可以非常自然地拆出来。成员管理负责往member表里增删成员,班费收支负责向finance_record表里插记录,统计模块从流水表里做聚合查询。我一般会把一个功能做成一个独立的 Activity,而不是全部堆在 MainActivity 里,这样每个界面的代码量控制在两百行左右,阅读和维护都轻松。
「记录一笔收入」这个操作需要用户填三个必填项:交费成员、金额、分类(默认「班费收缴」)。支出需要填金额、分类(如「教辅资料」「班级活动」「打印费」)、备注。这些字段直接映射到FinanceRecord实体类上。
3.3 数据库升级策略:不要用onCreate里改表结构
很多人在开发阶段发现表结构要改,就直接卸载重装 App,这在开发期没问题,但如果项目要交给别人演示或真正使用,数据一卸载就没了。我习惯在一开始就写好版本升级的规范路径:DatabaseHelper构造时传入版本号,onUpgrade里先判断旧版本,再执行ALTER TABLE或新建临时表迁移数据。
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE member ADD COLUMN avatar TEXT"); } if (oldVersion < 3) { db.execSQL("ALTER TABLE finance_record ADD COLUMN operator TEXT"); } }这里注意,每个if块对应一个版本增量,不要写else if,因为用户可能从某个旧版本直接跳到最新版,中间每个增量都要执行。开发期如果改动频繁,可以调大版本号或直接删库,但要记住,一旦代码交给别人,数据迁移就必须按这个方式来。
4. 核心功能代码实现:记账、查余额、统计的完整逻辑与参数说明
4.1 记一笔收支:事务保证数据不丢
班费收支操作的核心要求是:插入流水后,余额计算必须基于真实流水。当前余额是通过查询所有收入减去所有支出算出来的,不是存一个 balance 字段,这样每一笔钱都有据可查。插入操作的代码长这样:
public long insertRecord(FinanceRecord record) { SQLiteDatabase db = this.getWritableDatabase(); long insertId = -1; db.beginTransaction(); try { ContentValues values = new ContentValues(); values.put("member_id", record.getMemberId()); values.put("type", record.getType()); values.put("amount_cents", record.getAmountCents()); values.put("category", record.getCategory()); values.put("note", record.getNote()); values.put("record_date", record.getRecordDate()); values.put("operator", record.getOperator()); insertId = db.insert("finance_record", null, values); db.setTransactionSuccessful(); } catch (Exception e) { Log.e("FinanceDao", "insertRecord failed", e); } finally { db.endTransaction(); } return insertId; }逻辑说明:beginTransaction()和setTransactionSuccessful()配合,保证要么插入成功,要么什么都不写入。insert返回 -1 表示失败,返回正数表示新记录的 id。这里我加了record.getAmountCents()而不是直接传一个「元」的浮点数,就是逼着调用方在入口处就把元转成分为单位的整数,避免边界计算误差。
参数的注意点:member_id在支出记录里可以为空,但收入记录必须关联到某个成员,所以IncomeActivity里在做插入前会做一层校验。record_date统一格式化成yyyy-MM-dd HH:mm字符串存储,不要存时间戳,因为时间戳在导出 CSV 时人可读性差,班级用户看不懂 1699000000000 这种数字。
4.2 查余额与最近流水:两个聚合查询
余额计算只涉及两张表的聚合查询,代码不复杂,但 SQL 要写对:
public long getBalance() { SQLiteDatabase db = this.getReadableDatabase(); long income = 0, expense = 0; Cursor cursor = db.rawQuery( "SELECT COALESCE(SUM(amount_cents), 0) FROM finance_record WHERE type = 1", null); if (cursor.moveToFirst()) { income = cursor.getLong(0); } cursor.close(); cursor = db.rawQuery( "SELECT COALESCE(SUM(amount_cents), 0) FROM finance_record WHERE type = 2", null); if (cursor.moveToFirst()) { expense = cursor.getLong(0); } cursor.close(); return income - expense; }为什么要用两个SELECT SUM而不是一条SELECT SUM(CASE WHEN type=1 ...)?因为前者代码可读性更好,后面如果要加「按月份查余额」的版本,只需要在 SQL 里加WHERE record_date LIKE '2025-03%'前缀,改造起来更直观。性能上,几百条数据两个 sum 的开销完全可忽略,不需要优化成一条。
拿到最近流水列表时,我建议用一个 LEFT JOIN 把成员姓名带出来,方便界面直接显示「张三交了 50 元」而不是只有一串数字:
public List<FinanceRecord> getLatestRecords(int limit) { List<FinanceRecord> list = new ArrayList<>(); SQLiteDatabase db = this.getReadableDatabase(); String sql = "SELECT f.id, f.member_id, f.type, f.amount_cents, f.category, " + "f.note, f.record_date, f.operator, m.name AS member_name " + "FROM finance_record f LEFT JOIN member m ON f.member_id = m.id " + "ORDER BY f.record_date DESC, f.id DESC LIMIT ?"; Cursor cursor = db.rawQuery(sql, new String[]{String.valueOf(limit)}); while (cursor.moveToNext()) { FinanceRecord r = new FinanceRecord(); r.setId(cursor.getInt(cursor.getColumnIndexOrThrow("id"))); r.setMemberName(cursor.getString(cursor.getColumnIndexOrThrow("member_name"))); r.setAmountCents(cursor.getLong(cursor.getColumnIndexOrThrow("amount_cents"))); // ... 省略其余字段赋值 list.add(r); } cursor.close(); return list; }这里的关键是LEFT JOIN用了可空关联,因为可能有些收入记录不关联成员(比如班级义卖收入)。ORDER BY record_date DESC, id DESC双重排序是为了防止同秒内插入多条记录时顺序不稳定,id DESC作为第二排序能保证「后插入的显示在上面」,这一点在真实使用中很管用。
4.3 月度统计:按分类和月份两个维度聚合
班费管理最常用的统计是「这个月花了多少、花在哪些地方」,我用一个按分类分组的查询就可以实现:
public Map<String, Long> getExpenseByCategory(int year, int month) { Map<String, Long> result = new LinkedHashMap<>(); SQLiteDatabase db = this.getReadableDatabase(); String monthPrefix = String.format("%04d-%02d", year, month); String sql = "SELECT category, SUM(amount_cents) FROM finance_record " + "WHERE type = 2 AND record_date LIKE ? " + "GROUP BY category ORDER BY SUM(amount_cents) DESC"; Cursor cursor = db.rawQuery(sql, new String[]{monthPrefix + "%"}); while (cursor.moveToNext()) { result.put(cursor.getString(0), cursor.getLong(1)); } cursor.close(); return result; }逻辑说明:record_date存储格式是yyyy-MM-dd HH:mm,所以用LIKE '2025-03%'就能精确匹配三月份所有记录,字符串比较效率足够。返回的LinkedHashMap保证顺序和 SQL 的ORDER BY SUM(amount_cents) DESC一致,这样统计界面直接迭代 Map 就可以渲染柱状图,不需要再排序一次。
这块的边界要注意:用户可能在StatisticActivity里切换月份,月份参数要从界面传入而不是在 DAO 里写死,否则每次改月份都要改代码。接口设计上把year和month作为参数传进去,界面端负责计算。
4.4 成员缴费状态查询:判断「谁还没交钱」
「谁没交班费」是班委最关心的查询。实现方式是:查成员表所有在读成员,再查流水表里某个时间段内每个成员的缴费总额:
public List<MemberPaymentStatus> getPaymentStatus(int year, int month) { List<MemberPaymentStatus> result = new ArrayList<>(); SQLiteDatabase db = this.getReadableDatabase(); String monthPrefix = String.format("%04d-%02d", year, month); String sql = "SELECT m.id, m.name, COALESCE(SUM(f.amount_cents), 0) AS paid " + "FROM member m LEFT JOIN finance_record f " + "ON f.member_id = m.id AND f.type = 1 AND f.record_date LIKE ? " + "WHERE m.status = 1 GROUP BY m.id ORDER BY m.id"; Cursor cursor = db.rawQuery(sql, new String[]{monthPrefix + "%"}); while (cursor.moveToNext()) { MemberPaymentStatus s = new MemberPaymentStatus(); s.setMemberId(cursor.getInt(0)); s.setMemberName(cursor.getString(1)); s.setPaidCents(cursor.getLong(2)); result.add(s); } cursor.close(); return result; }这段 SQL 的细节在于LEFT JOIN的条件里带上了f.type = 1 AND f.record_date LIKE ?,而不是只写ON f.member_id = m.id,这样没缴费的同学也会出现在结果里,COALESCE把 NULL 转成 0,界面显示「未缴」。如果把过滤条件误放到WHERE里,就会把没有记录的同学整行过滤掉,这是新手最容易写错的地方。
数据显示部分,我建议在界面上对paidCents == 0的行标红或显示「未缴」标签,这属于纯 UI 逻辑,不放进 DAO。
5. 安卓班费管理系统踩坑记录:五个最常翻车的点与排查路径
5.1 建表时把amount设计成 REAL,结果余额越加越不对
现象:记了几十笔账后,余额显示 99.999999999,或者某两笔相同金额的收入加起来少了 0.01 元。查单条记录金额没问题,但 SUM 的结果就是不对。
原因:SQLite 的 REAL 是 8 字节浮点数,0.1 无法精确表示。多笔累加后误差被放大,Java 层用double做乘法也会有同样问题。这不是数据库的 bug,是浮点数的物理特性。
解决:表结构改用INTEGER存金额,单位是分。界面输入时用户输入的是元,比如 50.5 元,通过Math.round(Double.parseDouble(input) * 100)转成 5050 分再入库。展示时用String.format("%.2f", cents / 100.0)恢复成元。所有 DAO 方法的参数类型都定义为long而不是double,从入口杜绝浮点金额进入业务层。
5.2onUpgrade不执行,改了表结构却一直报「no such column」
现象:在onCreate里加了新字段,把 App 覆盖安装上去,运行时报android.database.sqlite.SQLiteException: no such column: operator。
原因:onCreate只在数据库第一次创建时执行。覆盖安装时数据库文件还在,系统发现版本号没变,就不会调用onUpgrade,表结构不会自动增加新列。很多人开发期习惯卸载重装,于是从来没触发过onUpgrade路径。
解决:每次改表结构必须同步递增DatabaseHelper的版本号,同时把改动写在onUpgrade里。如果数据库版本已经混乱,最简单的处理是卸载重装,但演示数据会丢失;更好的方案是写一个小工具函数,遍历PRAGMA table_info(finance_record)检查列是否存在,不存在再ALTER TABLE。
5.3 日期比较直接用字符串拼>=和<=,月初和月末数据对不上
现象:统计 3 月数据时,把 3 月 1 日和 3 月 31 日的记录都包含进去了,但 3 月 31 日 23:59 的记录偶尔查不到,或者查出了一部分 4 月 1 日的数据。
原因:record_date存储格式是yyyy-MM-dd HH:mm,如果按record_date >= '2025-03-01' AND record_date <= '2025-03-31'来查,字符串比较会漏掉2025-03-31 23:30这种带时间的值,因为'2025-03-31'比'2025-03-31 23:30'字典序更小。
解决:用LIKE '2025-03%'前缀匹配,或者把结束日期写成下个月的第一天再排除等号。我最常用的是LIKE方案,因为它既简单又不会踩月末边界。如果团队要求 SQL 标准写法,就用record_date >= '2025-03-01' AND record_date < '2025-04-01',注意结束条件是开区间。
5.4 旋转屏幕后输入的数据丢失,用户记一笔账却要重新填
现象:在「记支出」页面填好金额和备注,手机转一下横屏,界面重启,填的内容全没了。
原因:默认情况下 Activity 在配置变更时会销毁重建,没有onSaveInstanceState保存临时输入框的内容。很多课设项目都忽略这一点,演示时一旋转屏幕就翻车。
解决:最简单的方法是在AndroidManifest.xml里给记账相关 Activity 加上android:configChanges="orientation|screenSize",让旋转时不重建 Activity。但这不是最优解,因为一旦这么做,界面不会自适应横屏布局。更规范的方案是重写onSaveInstanceState,把输入框内容放进 Bundle,在onCreate里恢复。对课设项目来说,我建议用第一种,省事且演示稳定。
5.5 导出 CSV 文件中文乱码,Excel 打开一片问号
现象:用FileWriter写 CSV,手机里用文本编辑器看没问题,发到电脑上用 Excel 打开,中文全是乱码。
原因:CSV 文件没有 BOM 头,Excel 默认按本地编码(简体中文系统上通常是 GBK)解析。如果文件是用 UTF-8 写入但没加 BOM,Excel 就会猜成 GBK,导致乱码。
解决:写入 CSV 时先写入\ufeff(UTF-8 BOM)作为文件开头,再写表头和内容。\ufeff在 Java 字符串里就是一个字符,直接追加到字符串最前面即可。代码实现见下一章,这个方法在安卓和桌面端通用,我所有涉及导出的项目都这么做。
6. 进阶:CSV 导出、数据备份与整机验证
CSV 导出和整机验证这两个方向,能让班费管理系统从「能记账」提升到「能交付使用」。先说导出,很多班级用户根本不会去翻 App 里的统计界面,他们只认 Excel 表格。我提供了一个一键导出的工具方法:
public static File exportToCsv(Context context, List<FinanceRecord> records) throws IOException { String fileName = "banfei_" + System.currentTimeMillis() + ".csv"; File file = new File(context.getExternalFilesDir(null), fileName); FileOutputStream fos = new FileOutputStream(file); OutputStreamWriter writer = new OutputStreamWriter(fos, "UTF-8"); writer.write('\ufeff'); // BOM,防止 Excel 打开乱码 writer.write("日期,类型,成员,分类,金额(元),备注\n"); for (FinanceRecord r : records) { String typeStr = r.getType() == 1 ? "收入" : "支出"; String amountYuan = String.format("%.2f", r.getAmountCents() / 100.0); writer.write(String.format("%s,%s,%s,%s,%s,%s\n", r.getRecordDate(), typeStr, r.getMemberName() == null ? "" : r.getMemberName(), r.getCategory(), amountYuan, r.getNote() == null ? "" : r.getNote())); } writer.flush(); writer.close(); return file; }参数说明:\ufeff就是 UTF-8 BOM,不加它 Excel 打开会乱码;getExternalFilesDir是应用私有目录,不需要申请存储权限,这个细节能省掉一大段权限请求代码。文件名带时间戳保证每次导出不覆盖。实际使用中,文件导出后用户通过系统分享菜单发送到电脑即可。
整机验证是我现在做的最后一步:装好 App 后,模拟一个真实班级的完整账期,录入三十个成员、给每个成员记一笔收入,再记二十笔不同类型支出。然后核对三件事:余额计算与手算一致,月度统计分类加总一致,导出 CSV 与 App 内流水一致。这个过程走下来踩了三次小数点精度问题,最后才意识到「分」这个设计约束在一开始就该定死。
最后说一个习惯:我每次导完 CSV 或做完备份后,都会在文件管理里找到那个文件,用第三方文本编辑器打开看一眼编码是否正确。安卓的文档文件从应用私有目录复制到电脑时,很容易被系统级的备份恢复到另一台设备上导致路径失效。所以我的习惯是导出后立刻发一份到班级群做验证,而不是等事后需要统计数据时才发现文件是坏的。希望这篇文章里踩过的坑,能帮你少走几趟弯路。
本文还有配套的精品资源,点击获取