ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Android记账本开发实战:Room数据库与MVVM架构完整方案

Android记账本开发实战:Room数据库与MVVM架构完整方案 简介面向Android开发学习者和毕业设计人员这份个人记账本项目以Eclipse为开发工具基于SQLite数据库与MVC模式实现配合StarUML用例图、包图及完整报告覆盖记账、分类、报表等功能模块可作为课程设计或毕设的完整参考。资源包共399个文件、10.45MB以png截图、class编译文件、xml布局配置、java源码和jar依赖库为主另含doc报告、apk安装包等目录结构清晰。已有352人学习下载。内含可运行项目代码、数据库和配套报告并保留日志、缓存等开发痕迹便于理解记账流程、数据库操作及界面跳转逻辑适合二次开发或答辩前查漏补缺。1. 毕设做 Android 记账本别把课题做成 CRUD 展示每年毕业设计的 Android 题目里“个人记账本”都是出现频率最高的那批。这个题目看起来简单无非是记一笔收入、记一笔支出再列个账单列表但实际上手之后你会发现它把所有 Android 开发的核心知识点都串起来了本地数据库设计、生命周期管理、异步任务、列表渲染、图表绘制、数据导入导出。更现实的问题是同一届可能有七八个人都在做记账本如果你只是把“增删改查”做出来答辩老师问一句“数据库为什么用 Room 不用 SQLite”“月度统计的图表数据怎么来的”场面很容易尴尬。这篇文章不打算给你一份“标准答案”似的工程源码而是用一线开发会用的技术方案把基于 Android 的个人记账本从选型、建表、业务实现到统计图表这整条链路推演一遍。你可以把这里写的代码直接抄进自己的项目里也完全可以理解机制后按自己的偏好替换组件。篇幅会比较长因为中途涉及的关键配置和参数都会展开说明。适合正在做毕设、或者想用记账本项目练手的中级 Android 开发者阅读有两年以上经验的人可以直接跳到 Room 设计和统计查询部分看思路。2. 记账本的技术栈选型与工程目录划分个人记账本这类应用最核心的特点有两个数据完全私有、不需要服务端交互路径短、页面数量少。这意味着技术选型的首要原则不是“功能多”而是“本地数据处理顺手”。常见做法是 Kotlin 作为开发语言Jetpack 组件里的 Room、ViewModel、LiveData/Flow 作为架构基座UI 层用 RecyclerView 加载账单列表。这套组合的出发点是Kotlin 的 null 安全和协程让数据库操作和异步任务更简洁Room 在编译期检查 SQL 语句、能省掉大量手写 SQLiteOpenHelper 的模板代码ViewModel 自动处理旋转屏幕时的数据保留——这三样正好精准命中记账本的需求。2.1 架构分层MVVM 下的一句话职责做毕设不需要把架构搞得过于隆重但完全不过脑地把 SQL 写在 Activity 里也是给自己挖坑。这里用的结构是标准的 MVVM 分层app/ ├── data/ │ ├── db/ # Room 数据库、实体、DAO │ ├── repository/ # 数据仓库向 ViewModel 提供数据接口 ├── ui/ │ ├── add/ # 记账页面编辑页 │ ├── list/ # 账单列表页 │ ├── stats/ # 统计图表页 │ └── settings/ # 设置、数据导出 ├── viewmodel/ # 各页面对应的 ViewModel └── utils/ # 日期格式化、金额转换等工具类Activity/Fragment 只负责拿到 ViewModel 中的数据并渲染到界面不做任何 SQL 拼接ViewModel 通过 Repository 向 Room 发起订阅Repository 层是整个架构里唯一与数据库直接接触的地方。这样分的好处是后期想换数据库实现或者加内存缓存只需要替换 Repository 内部代码上层 UI 完全不用动。对毕设论文来说“架构分层”这一节也好写——你确实在工程里体现了关注点分离。2.2 关键依赖配置清单在build.gradle.kts模块级别中需要添加以下依赖注意 Room 的 KSP 插件一定要同步配置否则运行时会直接崩plugins { id(com.android.application) id(org.jetbrains.kotlin.android) id(com.google.devtools.ksp) version 2.0.21-1.0.28 } android { compileSdk 35 defaultConfig { applicationId com.example.bookkeeping minSdk 24 targetSdk 35 versionCode 1 versionName 1.0 } buildFeatures { viewBinding true } } dependencies { implementation(androidx.core:core-ktx:1.15.0) implementation(androidx.appcompat:appcompat:1.7.0) implementation(com.google.android.material:material:1.12.0) implementation(androidx.constraintlayout:constraintlayout:2.2.0) // Room implementation(androidx.room:room-runtime:2.7.0) implementation(androidx.room:room-ktx:2.7.0) ksp(androidx.room:room-compiler:2.7.0) // 生命周期与协程 implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7) implementation(androidx.lifecycle:lifecycle-livedata-ktx:2.8.7) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0) // 图表 implementation(com.github.PhilJay:MPAndroidChart:v3.1.0) }这段配置里有几个值得说明的点。第一room-ktx提供协程扩展函数能直接返回Flow类型的查询结果这是记账本列表实时刷新的基础。第二viewBinding true是为了替代findViewById减少模板代码也避免引入 DataBinding 那套注解处理带来的编译问题。第三MPAndroidChart 是本地图表库里最省事的方案它会把统计数据渲染成柱状图或饼图不需要去碰自定义 View 绘制。minSdk 取 24 覆盖了 Android 7.0 及以上设备做兼容测试的压力会小很多。3. Room 数据库设计从建表到直接能用的 Repository记账本的数据模型要比想象中稍微多一点。最基础的表是账单记录表但仅仅一张表撑不起完整的记账体验——你总得给账单分分类比如餐饮、交通、购物、工资、理财收益等在记账页下拉选择分类本身就是一种刚需交互。所以这里建议建两张表account_book账单记录表和category分类表两表通过分类 ID 关联。如果你只建一张表、把分类做成字符串直接存进去后面做统计时会遇到一个很难受的问题分类名如果写错了统计数据就会对不上。3.1 两张表三个文件实体定义与 DAO先看两个实体类。第一个是账单记录实体Entity( tableName account_book, indices [Index(value [categoryId]), Index(value [date])] ) data class AccountRecord( PrimaryKey(autoGenerate true) val id: Long 0, val amount: Double, // 金额单位是元保留两位小数 val type: Int, // 0支出1收入 val categoryId: Long, // 关联 category 表 val date: Long, // 时间戳毫秒记录的是当天零点 val note: String , // 备注允许为空 val updatedAt: Long System.currentTimeMillis() )amount这里用了Double是因为记账本涉及除法运算比如 AA 制分摊Int存分会带来大量换算。实际生产环境一般用Int存分避免浮点误差但毕设项目用Double配合保留两位小数的格式化方法已经足够答辩时把理由说清楚即可。date字段存的是当天零点的时间戳比如 2025 年 1 月 5 日的记录date 就是 2025-01-05 00:00:00 对应的毫秒值。这样设计后按天和按月分组只需要对时间戳做范围判断索引能生效聚合查询速度会比较理想。第二个是分类实体Entity(tableName category) data class Category( PrimaryKey(autoGenerate true) val id: Long 0, val name: String, // 分类名称餐饮、交通... val iconRes: Int, // 图标资源 ID val type: Int // 0支出分类1收入分类 )接着是 DAO 接口。这里不需要手写 SQLite 的onCreate建表逻辑Room 会在首次创建数据库时自动执行。我习惯把记账本最常用的四类操作写在一个接口里插入、更新、删除、按条件查询。Dao interface AccountDao { Insert suspend fun insert(record: AccountRecord): Long Update suspend fun update(record: AccountRecord) Delete suspend fun delete(record: AccountRecord) // 按时间倒序查全部记录配合 Flow 实现自动刷新 Query(SELECT * FROM account_book ORDER BY date DESC, id DESC) fun observeAll(): FlowListAccountRecord // 根据日期范围查记录入参是当天的起始和结束时间戳 Query(SELECT * FROM account_book WHERE date BETWEEN :start AND :end ORDER BY date DESC) fun observeByDate(start: Long, end: Long): FlowListAccountRecord // 按月聚合收入与支出返回一个数据对 Query(SELECT SUM(CASE WHEN type 1 THEN amount ELSE 0 END) AS totalIncome, SUM(CASE WHEN type 0 THEN amount ELSE 0 END) AS totalExpense FROM account_book WHERE date BETWEEN :start AND :end) fun observeMonthlySummary(start: Long, end: Long): FlowMonthlySummary // 按月分组统计支出金额用于图表展示 Query(SELECT strftime(%Y-%m, date / 1000, unixepoch, localtime) AS month, SUM(CASE WHEN type 0 THEN amount ELSE 0 END) AS expenseTotal FROM account_book GROUP BY month ORDER BY month) fun observeExpenseByMonth(): FlowListMonthlyExpense }这里Query的内容值得停下来细看。date是毫秒时间戳SQLite 内置的strftime函数要用date / 1000转成秒再加unixepoch修饰符最后用localtime保证时区转换正确。GROUP BY month对分组结果的排序依赖第一个 SELECT 字段month所以 ORDER BY 要写在这个别名上。SUM(CASE WHEN ...)这种写法比在 Java/Kotlin 层做双重循环统计要高效得多也是后面图表页的数据来源。这两个返回类型不是 Entity需要再定义两个 POJO 类Room 会自动按字段名映射data class MonthlySummary( val totalIncome: Double, val totalExpense: Double ) data class MonthlyExpense( val month: String, val expenseTotal: Double )3.2 Database 定义与稳妥的预填充分类方案数据库实例的标准定义方式如下Database( entities [AccountRecord::class, Category::class], version 1, exportSchema false ) abstract class AppDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao abstract fun categoryDao(): CategoryDao companion object { Volatile private var INSTANCE: AppDatabase? null fun getInstance(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, bookkeeping.db ).build().also { INSTANCE it } } } } }exportSchema false在毕设里可以这样设置因为不需要导出 schema 文件做迁移测试但如果你在论文里写“数据库版本升级与迁移”最好改成true并配置room.schemaLocation。另一个需要注意的问题是预置分类数据——新用户装完应用后打开记账页如果分类下拉框是空的使用体验会非常差。常见做法是在 App 启动时检查分类表是否有数据为空则插入预置分类class CategoryInitializer( private val dao: CategoryDao ) { suspend fun ensureDefaultCategories() { if (dao.count() 0) return val defaults listOf( Category(name 餐饮, iconRes R.drawable.ic_food, type 0), Category(name 交通, iconRes R.drawable.ic_transport, type 0), Category(name 购物, iconRes R.drawable.ic_shopping, type 0), Category(name 居住, iconRes R.drawable.ic_home, type 0), Category(name 娱乐, iconRes R.drawable.ic_entertainment, type 0), Category(name 工资, iconRes R.drawable.ic_salary, type 1), Category(name 理财, iconRes R.drawable.ic_finance, type 1) ) dao.insertAll(defaults) } }这段预置逻辑要在应用启动后的第一个协程里执行通常放在启动页或Application类的onCreate的CoroutineScope中。ensureDefaultCategories的幂等性由count() 0这个条件保证重复启动不会重复插入。这里需要注意首次冷启动时插入操作和用户进入记账页的查询操作是并发的Room 的事务机制能保证数据最终一致但如果用户手速过快、在预置完成前就打开分类下拉框可能会看到空列表。稳妥一点的做法是在数据库构建时使用RoomDatabase.Callback的onCreate在数据库文件落地的同一时刻执行预置插入.addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) // 此处需要获取协程作用域因为 onCreate 是同步回调 CoroutineScope(Dispatchers.IO).launch { getInstance(context).categoryDao().insertAll(defaults) } } })两种方案选一种即可。个人经验是onCreate回调更干净避免在业务代码里产生“检查-插入”的时序问题。3.3 Repository 统一出口不让 ViewModel 直接碰 DAOViewModel 直接持有 DAO 会带来一个问题当 DAO 接口签名变化时所有关联的 ViewModel 都得改。中间加一层 Repository 后上层只依赖 Repository 暴露的挂起函数和 Flow数据库细节完全隔离。class AccountRepository( private val accountDao: AccountDao, private val categoryDao: CategoryDao ) { fun observeAccounts() accountDao.observeAll() fun observeByDate(start: Long, end: Long) accountDao.observeByDate(start, end) fun observeMonthlySummary(start: Long, end: Long) accountDao.observeMonthlySummary(start, end) fun observeExpenseByMonth() accountDao.observeExpenseByMonth() suspend fun addRecord(record: AccountRecord) { accountDao.insert(record) } suspend fun updateRecord(record: AccountRecord) { accountDao.update(record) } suspend fun deleteRecord(record: AccountRecord) { accountDao.delete(record) } fun observeCategories(type: Int): FlowListCategory { return categoryDao.observeByType(type) } }Repository 层的每个函数体只有一行看起来有点“透传”的味道但这层封装的价值在于将来你需要在插入记录时同步更新一张统计表、或者在删除记录时清除对应的缩略图缓存直接在 Repository 里加逻辑就行ViewModel 完全无感知。对答辩时“你的项目亮点是什么”这个问题Repository 模式本身就是一个可以讲的点。4. 记账页与列表页从表单校验到 DiffUtil 聚合并联动数据库层铺好之后真正的业务代码围绕两个页面展开一个是新增/编辑账单页一个是账单列表页。这部分是整个 App 交互密度最高的地方也是写代码时最容易出 bug 的地方。要关注的问题包括表单校验的时机、分类选择的联动、列表项的选中交互、以及编辑状态下回显数据。4.1 新增/编辑表单ViewModel 承担状态管理记账页通常包含金额输入框、支出/收入切换、分类选择器、日期选择器、备注输入框。这里给出一个精简的 ViewModel 实现核心是从 UI 层接收用户输入、做校验、最后组装成AccountRecord插入数据库class AddRecordViewModel( private val repository: AccountRepository ) : ViewModel() { private val _saveState MutableLiveDataSaveState() val saveState: LiveDataSaveState _saveState var currentType: Int 0 private set fun switchType(type: Int) { currentType type } fun save( amountText: String, categoryId: Long, date: Long, note: String ) { val amount amountText.toDoubleOrNull() // 金额为空、为负数、为 0 都视为非法输入 if (amount null || amount 0) { _saveState.value SaveState.Error(请输入合法的金额) return } if (categoryId 0) { _saveState.value SaveState.Error(请选择分类) return } val record AccountRecord( amount Math.round(amount * 100.0) / 100.0, // 保留两位小数 type currentType, categoryId categoryId, date date, note note.trim() ) viewModelScope.launch { repository.addRecord(record) _saveState.value SaveState.Success } } sealed class SaveState { object Success : SaveState() data class Error(val msg: String) : SaveState() } }这个 ViewModel 的关键点有两个。第一所有 UI 操作都在主线程调用save()但真正的数据库插入被viewModelScope.launch切到后台线程执行主线程不会卡顿。第二SaveState这个密封类让 UI 层可以通过when(状态)干净地分发“保存中/成功/失败”三种界面反馈避免用布尔标记加字符串拼接这种脆弱的写法。Math.round(amount * 100.0) / 100.0这一步是为了处理0.1 0.2这类浮点计算带来的尾差确保落到数据库里的金额不会出现0.30000000000000004。4.2 列表页的 RecyclerView 最佳搭档ListAdapter DiffUtil账单列表页的核心诉求是数据变动时列表能以最小代价刷新。如果每次数据更新都notifyDataSetChanged()几千条数据时会有明显卡顿。这里用ListAdapter配合DiffUtil实现精细化刷新class AccountListAdapter( private val onItemClick: (AccountRecord) - Unit, private val onItemLongClick: (AccountRecord) - Unit ) : ListAdapterAccountRecord, AccountListAdapter.ViewHolder(DiffCallback) { object DiffCallback : DiffUtil.ItemCallbackAccountRecord() { override fun areItemsTheSame(oldItem: AccountRecord, newItem: AccountRecord): Boolean { return oldItem.id newItem.id } override fun areContentsTheSame(oldItem: AccountRecord, newItem: AccountRecord): Boolean { return oldItem newItem } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding ItemAccountBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val record getItem(position) holder.binding.tvAmount.text if (record.type 0) { -${record.amount} } else { ${record.amount} } holder.binding.tvNote.text record.note.ifEmpty { 无备注 } holder.itemView.setOnClickListener { onItemClick(record) } holder.itemView.setOnLongClickListener { onItemLongClick(record) true } } class ViewHolder(val binding: ItemAccountBinding) : RecyclerView.ViewHolder(binding.root) }DiffCallback的两个方法的语义区别要弄清楚areItemsTheSame判断是否同一个数据项通常用主键 idareContentsTheSame判断同一数据项的内容是否变化通常用 equals。ListAdapter内部在收到新列表后会先在后台线程执行DiffUtil.calculateDiff再把增量结果抛到主线程刷新这种机制天然避免了在数据量大时做全量重绘。列表页在 Activity 中加载数据的写法是标准的订阅 Flowclass AccountListActivity : AppCompatActivity() { private val viewModel: AccountListViewModel by viewModels { val db AppDatabase.getInstance(applicationContext) val repo AccountRepository(db.accountDao(), db.categoryDao()) AccountListViewModelFactory(repo) } private lateinit var adapter: AccountListAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ... 初始化 Binding、RecyclerView adapter AccountListAdapter( onItemClick { record - openEditPage(record) }, onItemLongClick { record - confirmDelete(record) } ) recyclerView.adapter adapter lifecycleScope.launch { viewModel.accounts.collect { list - adapter.submitList(list) } } } }这里用lifecycleScope.launch来收集Flow可以自动感知生命周期页面不可见时暂停收集避免后台执行列表刷新带来的资源浪费。注意AccountListViewModel里的val accounts repository.observeAll()要声明为val不要在每个函数里重新查询否则 Flow 的冷流特性会导致每次订阅都重新查询数据库。4.3 编辑与删除复用同一条流程编辑功能的实现路径是列表点击某项后把这条AccountRecord通过 Intent 传给编辑页编辑页加载数据回显保存时调用repository.updateRecord(record)。删除则比较简单直接弹确认对话框后调用 DAO 的 delete 方法。这里想强调一点Room 的Update会按主键匹配所以从列表页拿到的record经过copy(id record.id)再修改金额、分类等字段直接传给更新方法即可不需要额外写条件更新 SQL。5. 统计图表页聚合 SQL 与 MPAndroidChart 的搭配记账本如果没有统计功能就像计算器没有等于号。月度支出柱状图、收入支出饼图这两样几乎是毕设答辩必看的页面。上一章 DAO 里的聚合查询已经拿到了按月份分组的支出数据这一章把它从数据库经过 ViewModel 转成图表库的数据结构完成展示。5.1 用 Flow 串起“数据库 → ViewModel → UI”的数据管道统计页的 ViewModel 代码如下class StatsViewModel( private val repository: AccountRepository ) : ViewModel() { // 最近 6 个月支出趋势 val expenseTrend: FlowListMonthlyExpense repository.observeExpenseByMonth() .map { list - // 只保留最近 6 条数据 list.takeLast(6) } }这里Flow的变换链在数据发生变化时自动执行不需要手动刷新。UI 层收集这个 Flow 后把数据转成 MPAndroidChart 需要的BarEntry列表。BarEntry的两个参数分别是 x 轴索引和 y 轴数值由于observeExpenseByMonth已经按月份排好序takeLast(6)能保证取到的是最近六个月。5.2 柱状图的 Chart 配置参数详解下面是配置柱状图的核心代码fun setupBarChart(barChart: BarChart, data: ListMonthlyExpense) { if (data.isEmpty()) { barChart.setNoDataText(暂无支出数据) return } val entries data.mapIndexed { index, item - BarEntry(index.toFloat(), item.expenseTotal.toFloat()) } val barDataSet BarDataSet(entries, 月度支出).apply { color ContextCompat.getColor(context, R.color.chart_blue) // 柱状图柱体圆角宽度 barWidth 0.6f valueTextSize 11f } val labels data.map { it.month } // [2025-01, 2025-02, ...] barChart.apply { // 关闭图例记账本单数据系列不需要 legend.isEnabled false // 关闭描述标签 description.isEnabled false data BarData(barDataSet) xAxis.apply { valueFormatter object : ValueFormatter() { override fun getFormattedValue(value: Float): String { // value 是索引值0 对应 labels[0] return labels.getOrNull(value.toInt())?.replace(-, 年) ?: } } // 避免 x 轴标签密集重叠 granularity 1f setLabelCount(labels.size, false) } axisLeft.apply { // 左侧 Y 轴只保留整数刻度金额单位是元不需要小数 granularity 10f axisMinimum 0f } axisRight.isEnabled false // 触摸交互长按显示十字线 setOnChartValueSelectedListener(object : OnChartValueSelectedListener { override fun onValueSelected(e: Entry?, h: Highlight?) { e?.let { // 这里可以弹 Toast 显示该月总支出 } } override fun onNothingSelected() {} }) // 关键交互开关 isDoubleTapToZoomEnabled false setScaleEnabled(false) isDragEnabled true animateY(600) invalidate() } }这段代码里几个参数的坑是真实踩过的。第一granularity 1f必须设置否则当数据条数较少时x 轴会自动插值产生小数刻度valueFormatter传进去的值变成 0.5、1.5labels.getOrNull()会直接越界返回空的。第二axisMinimum 0f强制 Y 轴从 0 开始否则当某个月支出很小、另一个月支出很大时图表会从非零起点截断视觉上放大差距答辩时被老师指出数据呈现有误导性就尴尬了。第三setScaleEnabled(false)关闭了双指缩放因为统计页的数据量就那么几条缩放只会带来坐标轴错乱的问题不如禁用掉。饼图展示支出分类占比时核心逻辑是按分类分组做聚合查询DAO 的 SQL 写法为SELECT c.name AS categoryName, SUM(a.amount) AS total FROM account_book a LEFT JOIN category c ON a.categoryId c.id WHERE a.type 0 AND a.date BETWEEN :start AND :end GROUP BY a.categoryIdLEFT JOIN在这里很关键如果某条账单的categoryId在分类表里不存在比如预置分类被清理了左连接仍然能返回记录虽然categoryName是 null但金额不会丢。拿到结果后转成PieEntry的过程就不需要展开了格式都是类似的。6. 数据导出、备份恢复与答辩演示的细节技巧统计页面做完一个完整的记账本已经具备主体功能但如果想让作品在答辩时显得成熟一些数据导出与备份恢复值得做。存储到本地数据库的数据一旦用户卸载应用或清空缓存就会全部丢失提供一个导出 CSV 文件和通过 SAFStorage Access Framework选择备份路径的能力能实打实体现出增量价值。6.1 CSV 导出到 Download 目录导出 CSV 的方案最早是直接写Environment.getExternalStoragePublicDirectory在 Android 10 之后此接口已被废弃写入公共目录必须用 MediaStore。以下代码已经按 targetSdk 适配suspend fun exportToCsv(context: Context, records: ListAccountRecord) { val contentValues ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, bookkeeping_${System.currentTimeMillis()}.csv) put(MediaStore.Downloads.MIME_TYPE, text/csv) } val resolver context.contentResolver val uri resolver.insert( MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues ) ?: throw IOException(创建文件失败) resolver.openOutputStream(uri)?.use { output - val writer OutputStreamWriter(output, Charsets.UTF_8) writer.append(日期,类型,分类,金额,备注\n) records.forEach { record - writer.append( ${formatDate(record.date)}, ${if (record.type 0) 支出 else 收入}, ${record.categoryId},${record.amount},${record.note}\n ) } writer.flush() } Toast.makeText(context, 导出成功, Toast.LENGTH_SHORT).show() }需要注意的点文件名加了时间戳避免重复导出时被系统默认规则改名写入过程中一旦openOutputStream返回 null直接抛异常让上层感知不静默失败分类字段我导出的是categoryId如果想让 CSV 更可读可以 JOIN 查询后再导出分类名称这里看你的取舍。6.2 备份与恢复的代码骨架备份的思路是把数据库文件复制到用户选择的位置恢复则反向复制回来。注意数据库文件名要与Room.databaseBuilder中定义的一致最简单的实现方式如下fun backupDatabase(context: Context, targetUri: Uri): Boolean { val dbFile context.getDatabasePath(bookkeeping.db) if (!dbFile.exists()) return false return runCatching { context.contentResolver.openOutputStream(targetUri)?.use { output - dbFile.inputStream().use { input - input.copyTo(output) } } }.isSuccess }恢复操作前必须先把正在运行的数据库连接关掉否则 Room 持有文件句柄会导致复制不完整。常见做法是提供一个“退出并恢复”的确认对话框调用RoomDatabase.close()后再执行文件复制完成后重新拉起应用。6.3 答辩演示时的三个隐藏加分项第一个加分项是崩溃恢复测试完整跑一遍记账-统计-导出流程后直接强制杀掉应用再打开检查数据是否还在、列表是否正常加载。Room 的事务机制能保证 SQL 执行到一半崩溃时不会写入半个数据但你自己要亲手验证一次避免演示现场出意外。第二个加分项是空状态设计——刚安装完应用时列表页和统计页都要有合适的空数据提示图而不是一块空白。这个细节很多毕设会漏掉但给人的“完成度”感觉完全不一样。第三个加分项是边角交互点击某个月份的柱子时弹出一个PopupWindow显示该月支出前五的分类明细这个只需在onValueSelected回调里加一个再查询但它直接说明了你把长按事件和跨表查询串起来了这种细节是最能回答“你做了哪些别人没做的功能”这个问题的。本文还有配套的精品资源点击获取
返回列表