
简介一份面向高校毕业设计场景的Android财务管理系统项目源码基于Android技术栈实现涵盖登录验证、公告管理、账单增删改查、收支统计、个人密码修改与用户权限管理等完整模块适合计算机相关专业学生作为毕业设计参考或课程项目二次开发。资源共1226个文件以Java源码、XML布局、PNG图片资源和GIF演示动画为主另含JSON配置、JSP页面与Gradle构建文件等压缩包大小约10.86MB目录结构完整便于导入Android Studio后直接查看工程与运行调试。已有167人学习浏览说明该方案在同类毕业设计资源中具有一定参考价值。通过该资源可快速了解财务类App的模块拆分、数据库表设计思路、管理员与普通用户权限差异以及Android项目构建打包时常见的资源文件组织方式对完成开题、系统设计或答辩准备均有实际帮助。1. 毕业设计里的 Android 财务系统真正的考题是账目闭环答辩现场最常被问到的一句话是“你做的 App 和 Excel 表格有什么区别”问的人未必想要标准答案但这个问题会逼你把整个账目链路讲清楚记一笔支出、落到数据库、聚合出报表、导出给别人打开数据从头到尾自洽。基于 Android 的财务系统最务实的做法是本地优先Room 存账目Repository 对接 DAOViewModel 暴露状态图表交给 MPAndroidChart。它不是写几个 Activity 就能交差的事。下面按数据层、记账链路、聚合报表、导出验证这个顺序展开适合正在准备毕设答辩的学生也适合想把个人记账工具做扎实的 Android 开发者。先明确一件事财务类 App 的复杂度不在界面而在数据模型。界面丑一点可以答辩时解释表结构少一个外键、金额用了浮点追问两轮就会露馅。所以第二章节先把地基打牢。2. Android 财务系统的数据层Room 选型、三张表和金额精度2.1 Room 为什么比裸 SQLite 更值得写进立项文档财务系统的数据需求很明确增删改查、按时间聚合、跨月汇总。裸 SQLite 不是不能用但它把 SQL 正确性检查压到了运行期改一行字段就要手动处理所有 SQLiteOpenHelper 的版本升级而毕业设计周期里最怕的就是改表。Room 在编译期用注解处理器生成实现表结构写错、SQL 语法错误会在 build 阶段直接抛出来这一点对没有专门测试团队的独立开发者价值最大。我一般会直接选 Room KSP因为 KSP 比 kapt 在处理 Kotlin 注解上更快Room 2.6 以上的版本默认支持 KSP 接入。还有同学上来就把 Android Studio 界面设置成中文来减少心智负担这属于个人偏好但依赖版本和 Gradle 配置才是真正耗时间的部分建议一开始就把 room-runtime、room-ktx、room-compiler 的版本锁在同一个版本号上避免 KSP 插件和 Room 编译器版本不一致导致看不到具体报错。维度裸 SQLiteRoomGreenDAOSQL 校验运行期报错编译期检查编译期生成响应式查询自己封装LiveData/Flow 内建依赖 RxJava迁移机制手写版本判断Migration 可测试配置较重学习成本低中中偏高表格对应的结论很直接GreenDAO 在 Kotlin 项目里的存在感越来越弱裸 SQLite 适合做几十行数据的工具不适合当财务系统的存储层。Room 的 Flow 返回值能直接和 ViewModel 的状态流拼接省掉一层手动刷新。2.2 三张表账户、分类、交易流水账目闭环的最小建模是三张表账户表管钱从哪来分类表管钱花到哪去流水表管每一笔变动。账户表最简单的字段是 id、账户名、初始余额、创建时间分类表需要区分收入和支出否则聚合统计时无法分开口径流水表是所有报表的输入字段设计直接决定后续 SQL 好写还是难写。Entity( tableName account, indices [Index(value [accountName], unique true)] ) data class AccountEntity( PrimaryKey(autoGenerate true) val id: Long 0, val accountName: String, val initBalance: Long 0, // 单位分 val createdAt: Long System.currentTimeMillis() ) Entity( tableName category, indices [Index(value [categoryName, type], unique true)] ) data class CategoryEntity( PrimaryKey(autoGenerate true) val id: Long 0, val categoryName: String, val type: Int, // 0支出 1收入 val isDeleted: Int 0 ) Entity( tableName transaction, foreignKeys [ ForeignKey( entity AccountEntity::class, parentColumns [id], childColumns [accountId], onDelete ForeignKey.RESTRICT ), ForeignKey( entity CategoryEntity::class, parentColumns [id], childColumns [categoryId], onDelete ForeignKey.SET_NULL ) ], indices [ Index(accountId), Index(categoryId), Index(value [accountId, recordTime]), Index(value [categoryId, recordTime]) ] ) data class TransactionEntity( PrimaryKey(autoGenerate true) val id: Long 0, val accountId: Long, val categoryId: Long?, val amount: Long, // 支出存负数收入存正数单位分 val note: String? null, val recordTime: Long, val isDeleted: Int 0 )三个 Entity 里最容易被追问的是两处。第一金额一律用 Long 存“分”不用 Double浮点金额在累加时会出现 0.1 0.2 不等于 0.3 的经典问题财务场景不允许这种误差。第二流水表的 amount 用正负号区分收支而不是单独存一个 direction 字段这样月度汇总时直接对 amount 做 SUM再配合 CASE WHEN 分列比维护收支两列的写法更省事。class 对应的表结构里categoryId 设为可空 Long? 是有意的分类被软删除后历史账单仍然保留但外键不能继续指向已删除的类别所以外键策略用 SET_NULL。账户则用 RESTRICT账户余额和流水强关联不允许删掉还有流水的账户。2.3 索引、外键与 Migration 的落地写法账本数据量上千条时全表扫描还能接受量级上万且要按账户、分类、时间做报表就必须有索引。上面 transaction 表里加了 accountId、categoryId 两个单列索引再加 accountIdrecordTime、categoryIdrecordTime 两个复合索引覆盖“查某账户某段时间流水”和“查某分类历史统计”两类高频查询。索引不是越多越好更新时索引本身也要写盘财务 App 的写入频率远低于查询所以这里可以保守多建。数据库迁移是答辩时比较容易讲出亮点的部分。比如第一版没预算表第二版要加正确做法不是卸载重装而是定义 Migrationval MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( CREATE TABLE IF NOT EXISTS budget ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, categoryId INTEGER NOT NULL, monthlyLimit INTEGER NOT NULL, updatedAt INTEGER NOT NULL ) .trimIndent() ) } }这个 Migration 在 RoomDatabase.Builder 注册后升级版本号到 2 就会自动执行。注意两点Migration 里只能用 SupportSQLiteDatabase 执行原生命令不能用 DAO如果用户从 1 直接升到 3Room 会要求同时提供 1 到 2 和 2 到 3 两条路径所以不要只写最终版本的建表语句。写的时候顺手把 budget 表加到 Database 的 entities 列表否则 Room 运行期会报告 schema 校验不一致。提示财务表结构里所有删除都建议用 isDeleted 软删除。硬删除一行账单后月度报表数据会整体变化答辩时很容易被“数据怎么对不上”追问。3. Android 财务系统的记账主流程ViewModel、仓库和预算检查3.1 分层Activity 只负责导航账目逻辑全部收进 ViewModel记账入口通常是底部弹窗或独立页面但不管哪种业务逻辑都要和 UI 解耦。常见做法是三层Repository 封装 DAO 并提供挂起函数ViewModel 持有 StateFlow/UiStateActivity/Fragment 只做事件绑定。这样做的价值在于预算检查、金额校验这类财务规则可以脱离界面单独测而不是把 SQL 写进 Fragment 里靠手工点击验证。class FinanceRepository( private val transactionDao: TransactionDao, private val budgetDao: BudgetDao ) { suspend fun insertTransaction(t: TransactionEntity) transactionDao.insert(t) suspend fun checkBudget(categoryId: Long?, amountFen: Long): Boolean { if (categoryId null) return true val budget budgetDao.getByCategory(categoryId) ?: return true val start LocalDate.now() .withDayOfMonth(1) .atStartOfDay(ZoneId.systemDefault()) .toInstant() .toEpochMilli() val used transactionDao.sumExpenseSince(categoryId, start) ?: 0L return used amountFen budget.monthlyLimit } }Repository 里 checkBudget 的逻辑是没有预算配置就不拦有预算就统计当月初到现在的该分类支出。注意 sumExpenseSince 返回的是负数因为支出在存储层就是负数这里 used amountFen 用的是负号相加不要写成 used - amountFen否则预算口径会反。对应 DAO 的统计 SQLQuery(SELECT COALESCE(SUM(amount), 0) FROM transaction WHERE categoryId :categoryId AND recordTime :startTime AND isDeleted 0) suspend fun sumExpenseSince(categoryId: Long, startTime: Long): Long?COALESCE 把空集补成 0挂起函数返回单条聚合结果避免 Flow 空转。Repository 层不需要再包一层缓存Room 单库操作的本地延迟足够低。3.2 保存一笔账金额解析、事务插入与状态回调保存流程里最容易出 bug 的是金额转换。界面拿到的是“12.34”这种字符串先校验再转分。用 BigDecimal 而不是 Doubleprivate fun parseAmountToFen(text: String): Long? { val value text.trim() if (value.isEmpty()) return null return try { BigDecimal(value).movePointRight(2).longValueExact() } catch (e: ArithmeticException) { null } catch (e: NumberFormatException) { null } }movePointRight(2) 把元转到分longValueExact 在出现除不尽或超范围时抛 ArithmeticException直接返回 null 交调用方提示。不要用(value.toDouble() * 100).toLong()1.005 这类值会变成 100.49999 然后截断成 100账面差一分钱。这属于财务系统的精度红线写进注释也不为过。save 函数把解析结果包装成 UiStatefun save(accountId: Long, categoryId: Long?, amountText: String, note: String?, recordTime: Long) { if (accountId 0L) { _saveState.value SaveState.Error(请选择账户); return } val amount parseAmountToFen(amountText) if (amount null || amount 0L) { _saveState.value SaveState.Error(金额不合法); return } viewModelScope.launch { _saveState.value SaveState.Saving val allowed repo.checkBudget(categoryId, amount) if (!allowed) { _saveState.value SaveState.Error(该分类本月预算已超限) returnlaunch } repo.insertTransaction( TransactionEntity( accountId accountId, categoryId categoryId, amount amount, note note?.takeIf { it.isNotBlank() }, recordTime recordTime ) ) _saveState.value SaveState.Success } }状态触发点UI 响应Idle默认不显示任何结果Saving开始写库按钮禁用 进度圈Successinsert 返回关页面并刷新列表Error解析/预算失败Toast 展示错误原因Saving 状态一定要有否则用户连点两次按钮会插入两条重复账单。用 StateFlow 收集时saveState 只消费一次在 Activity 里用 repeatOnLifecycle 订阅避免页面停在后台时还弹提示。3.3 输入过滤器、软键盘与联动布局金额输入框要限制长度和小数位数。InputFilter 里不能用 Double.parseDouble 做校验因为过滤器和文本变更回调会互相触发。一个正则实现class MoneyInputFilter : InputFilter { override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { val newText dest.toString().substring(0, dstart) source.toString() dest.toString().substring(dend) if (newText.isEmpty()) return null return if (newText.matches(Regex(\\d{0,7}(\\.\\d{0,2})?))) null else } }这段正则把整数部分限在 7 位、小数最多 2 位返回值 null 表示放行返回空串表示拦截。注意过滤器的 source 是可变 CharSequence不要直接拿它和 dest 拼 String正确做法是每次重新拼完整文本再匹配。软键盘弹出的场景里财务记账页通常有金额输入、分类选择、备注栏最容易犯的错误是忘设 windowSoftInputMode。在 Manifest 里给记账 Activity 配android:windowSoftInputModeadjustResize再用 CoordinatorLayout 承载表单键盘弹出时才不会把提交按钮顶出屏幕。分类选择器可以用 BottomSheetDialog但记得 BottomSheet 里的键盘处理是另一套逻辑用 NestedScrollView 包住输入区域比直接依赖外层布局更稳。4. 聚合与图表用 SQL 让 Android 财务系统的报表自己说话4.1 统计口径SQLite 时间函数和时区问题按月聚合一上来就会踩时区坑。如果直接写WHERE recordTime 当月1号零点这个零点取的是本地时区还是 UTC直接决定每月第一天那几笔账算到哪个月。SQLite 的 strftime 在做时间分组时把毫秒先转成秒再用 unixepoch 和 localtime 做本地化换算SELECT strftime(%Y-%m, recordTime / 1000, unixepoch, localtime) AS month, SUM(CASE WHEN amount 0 THEN -amount ELSE 0 END) AS expense, SUM(CASE WHEN amount 0 THEN amount ELSE 0 END) AS income FROM transaction WHERE isDeleted 0 GROUP BY month ORDER BY month DESC这个 SQL 里recordTime / 1000把毫秒转秒unixepoch 告诉 SQLite 这个值是 Unix 时间戳localtime 让分组按设备时区算。如果漏掉 localtime在中国时区每月 1 号 00:00 到 08:00 的流水会被归到上个月这类问题在答辩演示时很难一眼发现但数据一核对就会露馅。DAO 里对应的 Flow 方法返回不可变列表聚合结果变化时自动重查Query( SELECT strftime(%Y-%m, recordTime / 1000, unixepoch, localtime) AS month, SUM(CASE WHEN amount 0 THEN -amount ELSE 0 END) AS expense, SUM(CASE WHEN amount 0 THEN amount ELSE 0 END) AS income FROM transaction WHERE isDeleted 0 GROUP BY month ORDER BY month DESC ) fun observeMonthlySummary(): FlowListMonthlySummary统计维度过滤条件聚合字段常见错误月度趋势isDeleted0按 month 分组时区未用 localtime分类占比支出且时间区间按分类分组混入收入账户余额全部流水初始余额SUM(amount)软删除未过滤分类占比的 SQL 里注意 LEFT JOIN如果分类被软删除后 categoryId 变成 nullINNER JOIN 会把这部分账单从统计中丢掉查出来比实际支出少LEFT JOIN 配合 COALESCE(categoryName, 未分类) 才能保证不缺项。4.2 MPAndroidChart 画月度趋势与分类饼图图表库用 MPAndroidChart 就够了不需要引入重量级方案。它依赖低、API 直白毕业设计里出图效果可以很好。先看折线图绑定月度数据fun bindMonthlyChart(chart: LineChart, data: ListMonthlySummary, months: ListString) { val entries data.mapIndexed { index, item - Entry(index.toFloat(), (item.expense / 100f).toFloat()) } val dataSet LineDataSet(entries, 月度支出).apply { color Color.parseColor(#4A90D9) setDrawCircles(true) setDrawValues(true) mode LineDataSet.Mode.CUBIC_BEZIER lineWidth 2f valueTextSize 10f } chart.data LineData(dataSet) chart.xAxis.valueFormatter object : ValueFormatter() { override fun getFormattedValue(value: Float): String { val index value.toInt() return if (index in months.indices) months[index] else } } chart.invalidate() }expense 是 Long 单位分除以 100f 转成元给图表展示不要除以 100 后再转 DoubleMPAndroidChart 的 Entry 和 ValueFormatter 都基于 Float转回 Double 反而会有显示精度问题。xAxis 的 valueFormatter 控制刻度文案这里直接用 months 列表的索引对应第几个月刻度粒度设置为 1避免出现“0.5 月”这种标签。饼图配置里有一个细节容易被忽略图例标签如果默认显示在饼图右侧横屏或多分类时会挤掉扇形区域。设置 legend.verticalAlignment 和 horizontalAlignment 让图例居中底部再用 setDrawEntryLabels(false) 关掉扇区外的分类文字和百分比冲突。切分类筛选时饼图数据的刷新逻辑和折线图一致先清 data再 setData最后 invalidate。4.3 空态、分页与大数据量下的折线退化没有数据时 MPAndroidChart 默认画一条 0 的线视觉上会让人误以为当前月份支出为 0实际上是没有记录。绑定前判空空数据显示一张写有“暂无记录”的布局不要让图表参与界面布局。另一种折中是把 LineData 设成 null 但保留坐标轴适合强调“这个维度有指标”的场景毕业设计用前者更直观。数据超过几千条以后直接把一年的流水全塞给图表缩放和拖动会有明显卡顿。常见做法是按需加载图表先展示最近三个月明细顶部提供时间跨度切换多查一个月时用增量 SQL 限定 recordTime 范围而不是重新读全表。折线图要控制 X 轴显示的 Entry 数量超过两个月后把 X 轴标签按周隔显示否则文案重叠到没法看。提示角标数字用“元”展示时记得从分转换。图表层显示 1234 元数据层查出来是 123400 分漏了除法在答辩台上会非常尴尬。5. Android 财务系统的导出与验证答辩前必须盯住的三个细节5.1 CSV 导出走 FileProvider 而不是 file://财务系统的导出功能是最能体现工程意识的点。先在应用私有目录生成 CSV再用 FileProvider 转成授权 URI 交给系统分享或存储不能直接拼file:///storage/emulated/0/...这种外部路径。网上常见的形如content://.../external_root/android/data/...报错多半就是把外部路径写死导致的权限问题。fun exportToCsv(context: Context, transactions: ListExportRow): Uri { val file File(context.cacheDir, finance_${System.currentTimeMillis()}.csv) FileWriter(file).use { writer - writer.append(时间,账户,分类,金额(元),备注\n) transactions.forEach { row - writer.append(${row.time},${row.account},${row.category},) .append(String.format(%.2f, row.amountFen / 100.0)).append(,) .append(\${row.note.orEmpty().replace(\, \\)}\\n) } } return FileProvider.getUriForFile(context, ${context.packageName}.fileProvider, file) }CSV 里备注字段包含逗号时要用双引号包裹包含双引号时双引号本身要写成两个引号转义否则用办公软件打开会错列。导出按钮的点击事件要加防抖避免双击写出两个文件并弹出两次分享框。5.2 答辩前用三组数据做回归验证准备三组固定测试数据每组验证一个规则。第一组是跨月边界12 月 31 日 23:59 和 1 月 1 日 00:01 各记一笔报表月份必须分列并分别计入各自月份。第二组是金额精度逐笔输入 0.01、1.01、99.99看合计是否精确等于 101.01 元如果用了浮点存储这里大概率能看到微妙误差。第三组是软删除删掉一条 3 月的记录后月度报表当月合计应与删除前差这一笔分类删除后再查看历史流水分类名应回退成“未分类”而不是直接消失。验证时打开数据库自查是更快的办法开发版里加一段“导出调试数据”按钮能把 transaction 表前 100 行直接打到 Logcat核对 recordTime 与 UI 显示的时间是否一致能省去大量翻页面时间。5.3 构建与布局的三个反例新版 Android Studio 里 BuildConfig 字段默认不生成BuildConfig.VERSION_CODE找不到大概率不是依赖问题而是没开启buildFeatures { buildConfig true }。如果要用 debug/release 区分图表数据源或弹窗文案用 buildConfigField 显式声明字段即可。自定义控件里如果用 setTag(int, key) 堆临时状态单类 Tag 字符串超过 30 个时旧版 AGP 会直接报 “tag number over 30 is not supported”。正确的收敛方式是把这些临时状态收进 ViewModel 或数据类View 只负责渲染。图标资源用 Vector Drawable 而不是多套 PNG既能缩包体又便于适配暗色主题这也是 Android 官方推荐图标方案里成本最低的一条。答辩演示时记得把开发者选项里的过度动画关掉图表切换会更顺滑且不影响任何功能逻辑。本文还有配套的精品资源点击获取