资讯动态

Android个人记账本开发实战:从Room建表到数据闭环与导出

发布时间:2026/9/17 3:03:04 来源:尧图企业网站定制
简介基于Android的个人记账本毕业设计项目以支持多插件的Eclipse为开发环境采用SQLite关系型数据库完成数据持久化并使用StarUML绘制用例图与包图整体严格遵循MVC分层模式。资源包含完整的Android工程源码、可直接导入的数据库文件、配套设计报告以及打包好的APK适合计算机相关专业学生、Android入门开发者用作课程设计、毕业设计或项目实战参考。压缩包共399个文件大小约10.45MB内含41个Java源码文件、54个XML布局与配置、118个class编译产物、159张PNG界面图表另有jar依赖库、APK安装包及说明文档目录结构清晰便于按模块逐一查阅目前已有352人下载学习。通过该项目可完整了解个人记账应用的模块划分与典型功能实现包括收支记录、报表统计、提醒等模块并能重点学习Activity与Fragment的协作、SQLite增删改查以及MVC架构在实际开发中的落地方法。在现有代码基础上还能方便地进行功能扩展、界面调整和论文撰写是一份完成度较高的毕业设计参考资料。1. Android 个人记账本的毕设边界不是功能堆砌而是数据闭环答辩现场老师点开你 Android 手机上的个人记账本随手记了一笔 12.5 元的早餐然后问这条数据写进数据库了吗进程被杀之后还在不在第二天按类别统计数字跟总数对不对得上这三个问题才是“基于 Android 的个人记账本”这个毕业设计真正的门槛。很多人把精力花在花哨的图表和动画上结果连最基本的“一笔账从输入到落库到统计”都没走通。下面按数据闭环的顺序讲先定表结构再做录入和列表然后做统计导出最后处理迁移、性能和打包新手能照着做老手也能拿走几组平时容易忽略的参数。2. 用 Android 的 Room 给记账本建模表结构、字段类型与索引怎么定2.1 为什么选 Room 而不是 SQLiteOpenHelper 或 SharedPreferences在 Android 上存账目最直接的选择有三个SQLiteOpenHelper、Room、SharedPreferences。个人记账本的数据有固定结构每一笔账包含金额、分类、时间、备注还要做按月分组、按分类汇总的查询SharedPreferences 存 JSON 数组只能应付几十条数据账目一多就得整表读出来再过滤性能和代码可维护性都跟不上。SQLiteOpenHelper 能工作但需要自己写 Cursor 转换、生命周期管理和 SQL 拼接毕业设计代码里会塞满大量样板答辩时很难把“稳定落库”这件事讲清楚。Room 是 Android 官方在 SQLite 之上封装的 ORM 框架它在编译期生成实现代码并检查 SQL 语法写错表名或字段名时 IDE 直接报错而不是运行期崩溃。配合 Kotlin 协程和 FlowDAO 方法能直接返回FlowListBill数据表一有变化UI 层自动收到新数据。作为毕业设计这一条本身就是论文里“数据持久化方案选型”一节的好素材。常见做法是只依赖它完成本地存储不接后端因为记账数据偏隐私本地存储也足够完整。2.2 用 Entity 定义账目表和分类表2.2.1 账目表 bill 的字段与索引先看核心表结构。金额字段有一个容易被问倒的点不要用Double而是用Long单位是“分”。浮点数在反复相加、比较、显示时会有精度误差而Long存 12.5 元就是 1250 分所有求和都精确展示时再除以 100 并格式化两位小数。Entity( tableName bill, indices [ Index(value [bill_time]), Index(value [category_id]), Index(value [type, bill_time]) ] ) data class Bill( PrimaryKey(autoGenerate true) val id: Long 0L, val type: Int 0, // 0 支出1 收入 val amount: Long 0L, // 金额单位分 val categoryId: Long 0L, // 关联 category 表主键 val accountId: Long 0L, // 账户预留字段用于区分现金/银行卡等 val remark: String? null, val billTime: Long 0L, // 这笔账发生的毫秒时间戳 val createTime: Long 0L // 创建时间列表排序用 )字段存储类型说明amountLong单位分避免 Double 精度丢失billTimeLong毫秒时间戳支持范围查询categoryIdLong关联 category.id聚合统计时经常使用这里给bill_time单独建索引是因为首页列表总是按时间倒序翻页type bill_time复合索引对应统计页按类型和时间段聚合的场景。索引不是越多越好写入时也要维护索引所以只给查询最频繁的两列加。复合索引的列顺序也有讲究type放在前面SQLite 在只按 type 过滤时也能走这个索引的前缀部分。accountId在这个版本可以先设默认 0但预留字段比以后升级改表更省事。2.2.2 分类表 category 和预置数据分类表字段更简洁id、分类名name、收入支出类型type、显示顺序sort。type用来把支出分类和收入分类分开统计时不会把工资和餐饮混在一张饼图里。Entity(tableName category) data class Category( PrimaryKey(autoGenerate true) val id: Long 0L, val name: String , val type: Int 0, val sort: Int 0 )首次安装时数据库是空的需要用RoomDatabase.Callback在onCreate里插入预置分类。注意onCreate只会在数据库文件第一次创建时执行一次迁移版本时不会重复执行所以插入语句不需要判断是否已存在。private val CALLBACK object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) db.execSQL(INSERT INTO category(name, type, sort) VALUES(餐饮, 0, 1)) db.execSQL(INSERT INTO category(name, type, sort) VALUES(交通, 0, 2)) db.execSQL(INSERT INTO category(name, type, sort) VALUES(购物, 0, 3)) db.execSQL(INSERT INTO category(name, type, sort) VALUES(工资, 1, 1)) } }预置分类不要写太多能在答辩时演示“餐饮、交通、购物、工资”四类就足够了。比较耗时的分类图标资源可以后续再补表结构上留一个 icon 字段即可现在先用默认图标。2.3 DAO 里的按月分页和分类汇总 SQL表结构定完最值得写进论文的是 DAO 的两个查询。第一个是首页账单列表用LIMIT OFFSET做分页。OFFSET在数据量过万后性能会变差但个人记账本一年几千条数据完全够用答辩时能说清为什么这么写就行。Dao interface BillDao { Query( SELECT * FROM bill WHERE bill_time :start AND bill_time :end ORDER BY bill_time DESC LIMIT :limit OFFSET :offset ) suspend fun getPagedBills( start: Long, end: Long, limit: Int, offset: Int ): ListBill Query( SELECT category_id AS categoryId, SUM(amount) AS total FROM bill WHERE type :type AND bill_time :start AND bill_time :end GROUP BY category_id ORDER BY total DESC ) suspend fun getCategoryTotal( type: Int, start: Long, end: Long ): ListCategoryTotal }第一个查询的区间写法是 start AND end方便统一按月份边界传参。第二个查询返回一个投影类CategoryTotal它不需要是 EntityRoom 会把category_id和SUM(amount)的别名自动映射到categoryId与total。SUM在 SQLite 中返回的是 Integer用Long接收不会丢精度ORDER BY total DESC能保证饼图和列表都从占比最大的分类开始展示。2.4 初始化 AppDatabase 时的三个关键参数最后是Room.databaseBuilder的骨架。这里有几个容易被忽略的参数数据库文件名固定为account_book.db使用单例防止多次创建实例不要调用allowMainThreadQueries()否则主线程做 IO列表一卡就解释不清。Database( entities [Bill::class, Category::class], version 1, exportSchema true ) abstract class AppDatabase : RoomDatabase() { abstract fun billDao(): BillDao companion object { Volatile private var instance: AppDatabase? null fun get(context: Context): AppDatabase instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, account_book.db ) .addCallback(CALLBACK) .build() .also { instance it } } } }exportSchema true会在 Gradle 里要求配置room.schemaLocation也就是数据库 schema 的 JSON 输出目录不配置会编译报错。毕业论文如果提到“数据库迁移到版本 2”这个 JSON 文件就是迁移测试的依据所以不要为了省事关掉它。3. 在 Android Studio 里实现记账本录入闭环校验、存储与列表刷新3.1 先把 Android 工程拆成 data / ui / util 三层开发这个项目时我一般不会一上来就写页面而是先给包结构定边界。个人记账本功能不复杂但至少有两个页面要共享同一份账单数据记账页写入首页读取。如果把 DAO 实例散落在各个 Activity 里后面改表结构时就要改三处。常见做法是让所有数据操作集中在AccountRepository Activity 只跟 Repository 打交道。app/src/main/java/com/example/accountbook/ ├── data/ │ ├── db/ │ │ ├── AppDatabase.kt │ │ ├── Bill.kt │ │ ├── Category.kt │ │ └── BillDao.kt │ └── repository/ │ └── AccountRepository.kt ├── ui/ │ ├── add/AddBillActivity.kt │ ├── home/HomeActivity.kt │ └── stats/StatsActivity.kt └── util/Formatter.ktRepository 里封装插入和查询ViewModel 再依赖 Repository。这样写的好处是单元测试时可以用inMemoryDatabaseBuilder替换真实数据库论文里测试章节可以多写一段。Android Studio 的新项目模板已经默认集成了 ViewModel 和 Compose但记账本这类表单密集的应用用传统 View 体系写起来更直白下面的代码也以 View 体系为主。如果真机是小米手机连接电脑后总识别不到先检查两件事手机打开“开发者选项”打开“USB 调试”连接时选择“文件传输”模式Android Studio 右侧 Device Explorer 能看到设备树就说明连上了。USB 驱动和授权弹窗问题占了这种排查的大半。3.2 记一笔页面的输入校验与金额过滤正则记账页最核心的是一个金额输入框、一个支出/收入切换、一个分类选择和一个保存按钮。这里最容易翻车的是金额处理用户可能输入 12.5、0.01也可能输成 12.345 或空字符串。不要在每个按键事件里手工判断而是用InputFilter在输入层拦住非法字符。val amountInputFilter InputFilter { source, start, end, dest, dstart, dend - val newText dest.replace(dstart, dend, source, start, end) if (newText.matches(Regex(^\\d{0,7}(\\.\\d{0,2})?$))) { source } else { } }dest.replace(dstart, dend, source, start, end)构造的是“假设输入完成后的完整文本”再用正则决定是否放行这个正则允许最多 7 位整数和 2 位小数。source是本次输入的字符返回空字符串表示拒绝该字符。需要配合EditText的inputTypenumberDecimal一起用软键盘才会弹数字键盘。保存时再把元转成“分”统一走 ViewModel。下面的示例用StateFlow保存页面状态旋转屏幕后金额和选项不丢。校验失败时把错误信息放进UiState由界面通过 snackbar 展示而不是用require抛异常否则进程直接退出答辩时太难看。data class AddBillUiState( val amountText: String , val type: Int 0, val selectedCategoryId: Long 0L, val remark: String , val error: String? null, val isSaving: Boolean false ) class AddBillViewModel( private val repository: AccountRepository ) : ViewModel() { private val _uiState MutableStateFlow(AddBillUiState()) val uiState: StateFlowAddBillUiState _uiState.asStateFlow() fun save() { val current _uiState.value if (!current.amountText.matches(AMOUNT_REGEX)) { _uiState.update { it.copy(error 金额格式不正确) } return } if (current.selectedCategoryId 0L) { _uiState.update { it.copy(error 请选择分类) } return } val amountFen current.amountText .toBigDecimal() .multiply(BigDecimal(100)) .longValueExact() viewModelScope.launch { repository.insertBill( Bill( type current.type, amount amountFen, categoryId current.selectedCategoryId, remark current.remark, billTime System.currentTimeMillis(), createTime System.currentTimeMillis() ) ) } } private companion object { val AMOUNT_REGEX Regex(^\\d{1,7}(\\.\\d{1,2})?$) } }AMOUNT_REGEX的整数部分允许 1 到 7 位小数部分 1 到 2 位与输入过滤正则保持一致。toBigDecimal().multiply(BigDecimal(100))比Double乘 100 再转 Long 更可控longValueExact()在溢出时抛异常但因为正则限定了位数这里不会真的溢出。控件建议实现关键参数金额 EditText常规 EditTextinputTypenumberDecimalmaxLength10InputFilter 过滤类型切换RadioGroup默认选中支出切换时清空已选分类分类列表RecyclerView 单选状态记录选中 categoryId保存前校验非 0备注 EditText常规 EditTextmaxLength50可空保存按钮ButtonisSaving 为 true 时禁用避免重复提交3.3 保存后列表自动刷新的 Flow Room 写法账记得多了用户会频繁“记一笔”再回首页。这时候最忌讳的是在onResume里重新加载一次列表、手动adapter.notifyDataSetChanged()。Room 的 DAO 方法如果返回FlowListBill就不用关心“什么时候刷新”这件事。Query( SELECT * FROM bill WHERE bill_time :start AND bill_time :end ORDER BY bill_time DESC ) fun observeBills(start: Long, end: Long): FlowListBill在 ViewModel 里暴露这个 FlowUI 层通过repeatOnLifecycle(Lifecycle.State.STARTED)收集。每次insertBill提交事务后Room 会自动通知这个 Flow 重新查询首页列表下一帧就更新。Flow返回值只适合读操作插入、删除、更新仍然用挂起函数。这里有一个生命周期坑直接写lifecycleScope.launch { viewModel.bills.collect { ... } }在 Activity 进入后台后依然会跑应该改成lifecycleScope.launch { repeatOnLifecycle(STARTED) { viewModel.bills.collect { adapter.submitList(it) } } }。这样列表会随界面状态自动启停也不会在页面销毁后收到数据更新。3.4 RecyclerView 展示账单DiffUtil 只更新变化的行账单列表每行显示分类图标、备注、金额和时间。如果用submitList就必须配一个DiffUtil.ItemCallback否则列表更新时会闪一下或者整体重绘。下面是按业务字段比较的写法class BillDiffCallback : DiffUtil.ItemCallbackBill() { override fun areItemsTheSame(oldItem: Bill, newItem: Bill): Boolean oldItem.id newItem.id override fun areContentsTheSame(oldItem: Bill, newItem: Bill): Boolean oldItem.type newItem.type oldItem.amount newItem.amount oldItem.categoryId newItem.categoryId oldItem.remark newItem.remark oldItem.billTime newItem.billTime }areItemsTheSame判断“同一行”用主键areContentsTheSame判断“同一行内容是否变了”业务字段只要有一个不同就返回 false。这样插入一笔新 bill只有新行会执行绑定旧行不会闪。如果列表项里有局部状态比如点击展开备注DiffUtil 的职责限制决定了它不会保留这些局部状态需要自己在 ViewHolder 里做好保存和恢复。再补充一个适配点小米、OPPO 等系统允许全局修改字体大小列表金额用sp会被系统缩放容易换行建议金额文本用dp或在TextView上限制maxLines1同时给长备注加ellipsizeend。4. 给 Android 记账本加统计图表与 CSV 导出答辩时更有话说4.1 Android 统计图表的选型MPAndroidChart 配置参数个人记账本的统计页通常需要饼图、折线图或柱状图。Android 原生没有现成图表控件自己要画饼图虽然能体现绘图功底但异形屏适配和动画交互会耗费大量时间。毕业设计里我一般用 MPAndroidChart理由是它覆盖了常见图表的配置项文档资料多答辩时提到“缩放、高亮、动画”都能找到对应 API。在 Android Studio 里接入它先要在 settings.gradle 或根 build.gradle 添加 JitPack 仓库然后在 app 模块的build.gradle添加一行依赖。首次 Sync 会联网拉取跑完 Gradle 任务再写代码。dependencies { implementation com.github.PhilJay:MPAndroidChart:v3.1.0 }给饼图配置参数时最常调的是sliceSpace、holeRadius、animateY和setDrawEntryLabels。下面是一组比较耐看的参数可以直接套用参数作用建议值sliceSpace每个扇区之间的间隔dp 单位2fholeRadius环形图内孔半径占整体百分比50fanimateY入场动画时间600mssetDrawEntryLabels是否绘制每个扇区的文字标签falsevalueTextSize扇区上数值文字大小12fsetDrawEntryLabels设为 false 后用户要看分类名就得依赖图例或者放一个居中的TextView展示“本月总支出”这样界面干净不少。4.2 把 DAO 的 SUM 结果绑定到饼图统计页要画图第一步是拿到“当月支出按分类汇总”的数据这一步第 2 章的聚合 SQL 已经定义好现在把它映射成饼图的PieEntry。注意PieEntry的第一个参数类型是Float而total是Long转换时单位依然是“分”比例不受影响。private fun bindPieChart(chart: PieChart, totals: ListCategoryTotal) { val total totals.sumOf { it.total } if (total 0L) { chart.clear() return } val entries totals.map { PieEntry(it.total.toFloat(), getCategoryName(it.categoryId)) } val dataSet PieDataSet(entries, 支出构成) dataSet.sliceSpace 2f dataSet.valueTextSize 12f dataSet.setDrawEntryLabels(false) val data PieData(dataSet) data.setValueFormatter(PercentFormatter(chart)) chart.data data chart.holeRadius 50f chart.description.isEnabled false chart.animateY(600) }PieEntry的第二个 String 参数是扇区标签如果不设图例这个标签会挡住小扇区PercentFormatter(chart)会把数值显示成占比而不是原始金额。如果当月没有任何支出totals为空直接chart.clear()否则会残留上一次月份的旧图观感很差。4.3 月度区间计算的时区坑统计页必然要处理月份切换。很多人会直接取“1 号 00:00:00”到“下个月 1 号 00:00:00”但如果没把Calendar的毫秒位清零上个月最后一笔发生在 23:59:59.999 的账就会掉进下个月的区间。统一用半开区间[start, end)最安全。fun monthRange(anchor: Long): PairLong, Long { val cal Calendar.getInstance().apply { timeInMillis anchor set(Calendar.DAY_OF_MONTH, 1) set(Calendar.HOUR_OF_DAY, 0) set(Calendar.MINUTE, 0) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) } val start cal.timeInMillis cal.add(Calendar.MONTH, 1) val end cal.timeInMillis return start to end }monthRange返回闭区间起点和开区间终点传给 DAO 的查询条件是bill_time start AND bill_time end。因为end已经是下个月 1 号的零点不需要再减 1 毫秒。统计页和首页默认展示本月时都调用同一个方法两处时间口径才会统一。4.4 导出 CSV 到手机并用 FileProvider 分享“导出账单”是个人记账本的加分项它让论文里“数据可迁移”的陈述落地。CSV 是最通用的格式Excel 和 WPS 都能打开。导出文件放在context.getExternalFilesDir(null)也就是应用专属外部目录不需要申请存储权限也符合 Android 的分区存储规则。fun exportCsv(context: Context, bills: ListBill): Uri { val file File(context.getExternalFilesDir(null), bills.csv) file.bufferedWriter().use { writer - writer.write(billTime,type,amount,remark\n) bills.forEach { bill - writer.write( ${bill.billTime},${bill.type},${bill.amount}, ${escapeCsv(bill.remark ?: )}\n ) } } return FileProvider.getUriForFile( context, ${context.packageName}.fileprovider, file ) } fun escapeCsv(input: String): String \ input.replace(\, \\) \CSV 字段如果包含逗号或换行会被拆列所以对remark统一用双引号包裹内部的引号再翻倍转义。分享给微信或邮件时用Intent.ACTION_SEND并带上FLAG_GRANT_READ_URI_PERMISSION否则接收方没有读取content://URI 的权限。val sendIntent Intent(Intent.ACTION_SEND).apply { type text/csv putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(Intent.createChooser(sendIntent, 导出账单))账单量比较大时可以在导出前显示一个ProgressBar在协程里完成文件写入getExternalFilesDir路径下如果每次都生成新文件只保留最新一次即可避免越积越多。导出按钮的点击逻辑和文件写入放在同一个 ViewModel 的viewModelScope中旋转屏幕时任务不会中断。5. Android 记账本数据库升级、性能体检与混淆打包的三件套5.1 数据库 schema 变更用 Migration别 fallbackToDestructiveMigration到了验收阶段你可能会往bill表加一个“账户”字段。如果直接改Database版本号而不加迁移Room 会直接崩掉。最简单但不可取的办法是fallbackToDestructiveMigration()它会把整个数据库删掉重建用户记了半年的账瞬间清空。正确做法是写一个Migrationval MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE bill ADD COLUMN account_id INTEGER NOT NULL DEFAULT 0) } }构建数据库时用.addMigrations(MIGRATION_1_2)。注意ALTER TABLE只适合加字段字段要加NOT NULL时默认值必须写上否则老数据会因空值冲突迁移失败。改字段类型、改表关系时常见做法是建临时表、拷贝数据、删旧表、改名那就不能一行 SQL 带过了。5.2 性能体检StrictMode 打开主线程检测页面卡顿最常见的来源是主线程做文件 IO 或数据库查询。StrictMode 只影响 DEBUG 包不影响 release 包适合答辩前全面跑一遍核心流程。在 Application 的onCreate里加上if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder() .detectAll() .penaltyLog() .build() ) }然后在真机上把“记一笔”到“首页列表”反复操作几遍看 Logcat 里是否有StrictMode policy violation。有的话把对应操作挪到协程的 IO 线程或改走 DAO 的挂起函数。5.3 混淆打包后 Room 保留规则与 manifest 导出配置release 包勾选 minifyEnabled 后R8 会裁剪代码Room 的实体类字段如果被混淆或移除运行时会报“Cannot find setter for field”一类错误。项目里通常给data包统一加保留规则-keepattributes *Annotation* -keep class com.example.accountbook.data.** { *; }最后再检查一遍android:exported属性targetSdk 31 之后AndroidManifest 里带intent-filter的 Activity 必须声明android:exportedtrue没有 intent-filter 的声明为false否则安装到 Android 12 及以上的设备会直接解析失败。改完之后在 Android Studio 的 Terminal 执行./gradlew clean assembleRelease生成的 APK 再装到真机走一遍记账、统计、导出这三件事做完个人记账本就可以提交验收了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价