简介:基于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 协程和 Flow,DAO 方法能直接返回Flow<List<Bill>>,数据表一有变化,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 // 创建时间,列表排序用 )| 字段 | 存储类型 | 说明 |
|---|---|---|
| amount | Long | 单位分,避免 Double 精度丢失 |
| billTime | Long | 毫秒时间戳,支持范围查询 |
| categoryId | Long | 关联 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 ): List<Bill> @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 ): List<CategoryTotal> }第一个查询的区间写法是>= start AND < end,方便统一按月份边界传参。第二个查询返回一个投影类CategoryTotal,它不需要是 Entity,Room 会把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的inputType="numberDecimal"一起用,软键盘才会弹数字键盘。
保存时再把元转成“分”,统一走 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: StateFlow<AddBillUiState> = _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 | 常规 EditText | inputType="numberDecimal",maxLength=10,InputFilter 过滤 |
| 类型切换 | RadioGroup | 默认选中支出,切换时清空已选分类 |
| 分类列表 | RecyclerView + 单选状态 | 记录选中 categoryId,保存前校验非 0 |
| 备注 EditText | 常规 EditText | maxLength=50,可空 |
| 保存按钮 | Button | isSaving 为 true 时禁用,避免重复提交 |
3.3 保存后列表自动刷新的 Flow + Room 写法
账记得多了,用户会频繁“记一笔”再回首页。这时候最忌讳的是在onResume里重新加载一次列表、手动adapter.notifyDataSetChanged()。Room 的 DAO 方法如果返回Flow<List<Bill>>,就不用关心“什么时候刷新”这件事。
@Query(""" SELECT * FROM bill WHERE bill_time >= :start AND bill_time < :end ORDER BY bill_time DESC """) fun observeBills(start: Long, end: Long): Flow<List<Bill>>在 ViewModel 里暴露这个 Flow,UI 层通过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.ItemCallback<Bill>() { 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上限制maxLines=1,同时给长备注加ellipsize="end"。
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 单位 | 2f |
| holeRadius | 环形图内孔半径,占整体百分比 | 50f |
| animateY | 入场动画时间 | 600ms |
| setDrawEntryLabels | 是否绘制每个扇区的文字标签 | false |
| valueTextSize | 扇区上数值文字大小 | 12f |
setDrawEntryLabels设为 false 后,用户要看分类名就得依赖图例,或者放一个居中的TextView展示“本月总支出”,这样界面干净不少。
4.2 把 DAO 的 SUM 结果绑定到饼图
统计页要画图,第一步是拿到“当月支出按分类汇总”的数据,这一步第 2 章的聚合 SQL 已经定义好,现在把它映射成饼图的PieEntry。注意PieEntry的第一个参数类型是Float,而total是Long,转换时单位依然是“分”,比例不受影响。
private fun bindPieChart(chart: PieChart, totals: List<CategoryTotal>) { 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): Pair<Long, 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: List<Bill>): 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(),它会把整个数据库删掉重建,用户记了半年的账瞬间清空。正确做法是写一个Migration:
val 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:exported="true",没有 intent-filter 的声明为false,否则安装到 Android 12 及以上的设备会直接解析失败。改完之后,在 Android Studio 的 Terminal 执行./gradlew clean assembleRelease,生成的 APK 再装到真机走一遍记账、统计、导出,这三件事做完,个人记账本就可以提交验收了。
本文还有配套的精品资源,点击获取