Android记账本App开发实战:从数据库存储到图表统计完整实现
2026/9/12 11:49:51 网站建设 项目流程

简介:这是一份基于Android Studio实现的记账本App项目源码,面向Android初学者、期末大作业或课程设计需要完整项目的学生。项目包含账单添加、分类统计等常见功能,代码结构清晰,可直接导入运行。压缩包共56个文件,其中12个Java文件对应核心业务逻辑,20个XML文件覆盖界面布局与配置,另有Gradle构建脚本、ProGuard混淆配置及图标资源等,整体仅166KB,轻量易用。已有1878人学习下载,属于高分结课项目。资源内附完整工程目录,含app模块、gradle配置和项目级构建文件,下载后无需修改即可编译运行,适合作为参考模板快速理解Android开发流程,或在此基础上扩展功能完成课程设计。

1. 为什么“记账本 App”是安卓期末大作业最稳妥的题目

期末大作业最怕两件事:一是题目太偏,做完没人看得懂;二是项目太模板化,答辩时老师一问实现细节就卡壳。记账本 App 恰好卡在中间:功能直观、涉及数据持久化、列表展示、界面跳转、统计图表这些安卓课必考的知识点,每一样都能在答辩时展开讲。更关键的是,它能用“我加了一个功能”来拉开与同学的差距,而不是靠改个颜色交差。

这份基于 Android Studio 的记账本项目源码,做的不是那种只存进内存、重启就清空的 Demo,而是用数据库把每一笔收支真正落盘。无论你拿它当自己的期末大作业底子,还是想拆开看一个标准的“界面 + 数据库 + 逻辑”三层结构怎么写,都有可抄的作业。下面我按自己做项目时会走的路径来讲:先把环境收拾利索,再把一笔账从界面写进数据库,然后补统计和导出,最后聊打包和答辩自测。

2. 开发前的准备:Android Studio 环境与数据库选型

2.1 把 Android Studio 环境调到能直接跑项目的状态

拿到别人的源码,第一步不是打开就编译,而是确认三件事:SDK 版本、JDK 版本、Gradle 版本。这三样只要有一个对不上,同步的时候就报一堆莫名其妙的红叉。

常见做法是先用 Android Studio 自带 SDK Manager 装好目标平台的 SDK。热词里有人问“android studio sdk 无法勾选怎么解决”,多数情况是网络问题,SDK 组件列表加载不出来。解决办法是配置国内镜像源,在SDK Manager -> SDK Update Sites里把下载源改成镜像地址。如果你用的是新版 Android Studio,直接在设置里搜 “SDK” 就能看到。

Gradle 同步慢是最容易让人中途放弃的坎。新建或导入项目时,建议在用户目录下的~/.gradle/init.gradle里写入阿里云镜像:

allprojects { repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://jitpack.io' } } }

这段配置把 Google 仓库和 Maven Central 都指到了国内镜像。第一次同步项目时,Gradle 会自动读取init.gradle,你就不用在每个项目的build.gradle里逐个改仓库地址了。同步完成后记得检查File -> Project Structure -> SDK Location,确认 JDK 路径用的是 Android Studio 自带的 JBR,而不是你机器上装的别的版本。

提示:如果你导入项目后看到tag number over 30 is not supported,说明项目里的 build-tools 版本太新而当前 SDK 没装全,去 SDK Manager 勾选对应版本重新同步即可。

2.2 新建项目的包名规划和目录结构

我一般不建议拿别人的项目直接覆盖成自己的作业,因为包名里常常带着原作者的名字或者学号,答辩时老师一眼就看出问题。正确姿势是新建一个空项目,然后把源码里的java目录和res目录按包路径复制进去,最后统一改applicationId

build.gradle里找到 applicationId,改成你自己的域名倒序,比如com.example.myaccount。注意AndroidManifest.xml里所有android:name=".activity.MainActivity"这类简写引用,它们会自动拼上 applicationId,所以只要包名一致,不会报错。

记账本项目至少分这几个包:

包名职责
bean/entity存放数据实体类,比如 TransactionBean
db数据库辅助类或 ORM 数据库类
adapterRecyclerView 或 ListView 的适配器
activity各个界面
utils日期格式化、金额校验等工具类

2.3 SQLite 与 Room:为什么期末作业推荐用 Room

记账本一定需要本地数据库。安卓上有三种选择:直接用SQLiteOpenHelper写原生 SQL、用Room封装、或者用SharedPreferences存 JSON。SharedPreferences 只适合存设置项,不适合存几十上百条账单记录,因为每次读取都要全量反序列化。

原生 SQLite 不是不能做,但对期末作业来说,写onCreate建表、ContentValues插入、Cursor遍历这一套太繁琐,而且很容易在更新数据库版本时漏掉迁移逻辑。Room 是 Google 官方的 ORM 框架,它在 SQLite 之上加了编译期检查——如果你的 SQL 写错了表名或字段名,编译直接报错,而不是运行时才崩溃。

这是我的建议:凡是“从列表页跳到详情页、又能增删改查”的项目,直接用 Room。它帮你把模板代码省掉,让你把精力留到功能差异上。

注意:Room 需要添加注解处理器依赖。如果你的项目因为引入 Room 后编译报错,优先检查是否忘了加kaptksp插件。

3. 记一笔账:从界面输入到数据库落盘的核心链路

3.1 定义实体类与数据表结构

先建一个数据类,对应数据库里的tb_transaction表。这张表记录每一笔收支的关键字段。我一般会加一个type字段区分收入还是支出,再用一个account字段区分现金、微信、支付宝等支付方式,这样后面做统计时不至于没东西可分组。

@Entity(tableName = "tb_transaction") data class Transaction( @PrimaryKey(autoGenerate = true) val id: Long = 0, // 数据库生成的自增主键 val type: String, // 类型:0 支出,1 收入 val category: String, // 分类:餐饮、交通、工资等 val amount: Double, // 金额,保留两位小数 val account: String, // 支付方式:现金、微信、支付宝 val remark: String, // 备注,可选 @ColumnInfo(name = "record_time") // 存储毫秒时间戳,方便按时间排序和筛选 val time: Long )

3.2 用 DAO 封装增删改查方法

DAO 是 Room 里负责访问数据库的接口,用注解声明 SQL 操作。最常用的四个方法:插入、删除、更新、按条件查询。

@Dao interface TransactionDao { @Insert suspend fun insert(transaction: Transaction): Long @Delete suspend fun delete(transaction: Transaction) @Update suspend fun update(transaction: Transaction) @Query("SELECT * FROM tb_transaction ORDER BY time DESC") fun getAllTransactions(): LiveData<List<Transaction>> @Query("SELECT * FROM tb_transaction WHERE id = :id") suspend fun getTransactionById(id: Long): Transaction? }

@Insert方法标注了suspend,这是 Room 配合协程的推荐写法。getAllTransactions返回LiveData,好处是当数据库里的数据变化时,界面层观察的LiveData会自动收到新列表,不需要你手动刷新 Adapter。这个特性在期末答辩时很加分,因为“数据驱动 UI 更新”是面试和答辩都喜欢问的点。

最后写数据库类:

@Database(entities = [Transaction::class], version = 1, exportSchema = false) abstract class AppDatabase : RoomDatabase() { abstract fun transactionDao(): TransactionDao 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, "account.db" ).build().also { INSTANCE = it } } } } }

3.3 界面输入与数据写入

记账页面一般有两个输入框或一个选择器:一个填金额,一个选分类。点击保存按钮后,先做“金额是否为空”这类基础校验,再组装 Transaction 对象,最后通过 ViewModel 调用 DAO 的方法。

推荐用 ViewModel 而不是直接在 Activity 里拿数据库实例,因为旋转屏幕时 Activity 重建,ViewModel 不会丢,避免数据重复提交的问题。

// 在 Activity 中获取 ViewModel val viewModel: TransactionViewModel by viewModels { TransactionViewModel.Factory( (application as MyApplication).database.transactionDao() ) } // 保存按钮点击事件 binding.btnSave.setOnClickListener { val amountStr = binding.etAmount.text.toString() val amount = amountStr.toDoubleOrNull() ?: 0.0 if (amount <= 0) { Toast.makeText(this, "请输入正确的金额", Toast.LENGTH_SHORT).show() return@setOnClickListener } val transaction = Transaction( type = if (binding.rbIncome.isChecked) "收入" else "支出", category = binding.spCategory.selectedItem.toString(), amount = amount, account = binding.spAccount.selectedItem.toString(), remark = binding.etRemark.text.toString(), time = System.currentTimeMillis() ) lifecycleScope.launch { viewModel.insertTransaction(transaction) Toast.makeText(this@AddActivity, "保存成功", Toast.LENGTH_SHORT).show() finish() } }

校验逻辑放在toDoubleOrNull()这里。如果用户输入非法字符,得到 null,金额会被设成 0.0,配合后面的if (amount <= 0)直接拦截。注意 Toast 后面return@setOnClickListener表示只退出这个监听器的回调,不影响 Activity 生命周期。

生命周期问题要注意:启动协程用的是lifecycleScope.launch,在 Activity 被销毁时协程会自动取消,避免出现界面关闭但数据库写入还在进行的悬空操作。

3.4 列表展示与删除、编辑

列表页用 RecyclerView,Adapter 接收List<Transaction>。因为getAllTransactions()返回 LiveData,只需要在 Activity 里观察它:

viewModel.allTransactions.observe(this) { list -> adapter.submitList(list) }

删除操作要在列表项上做一个长按监听,弹一个 AlertDialog 确认,确认后调用viewModel.delete(transaction)。编辑的常规做法是:点击列表项跳转到编辑页,通过 Intent 携带交易 id;编辑页拿到 id 后先查询原数据,再把原数据显示到输入框中。

这里有一个设计细节值得提:删除前应该保存被删对象的引用。Room 的 @Delete 是根据主键定位的,如果你在别处重新构造了一个只有 id 的 Transaction 对象,其他字段为 null,@Delete 依然能按主键删掉。但如果传入的对象的 id 是 0,那 Room 会尝试删除主键为 0 的行,不会报错但也不会删掉任何数据。

3.5 不要在数据库访问上踩的线程坑

Room 默认不允许在主线程操作数据库,除非你调用allowMainThreadQueries()。我建议绝对不要图省事加这个方法,因为当数据量到几百条时,主线程查询会引发界面掉帧,甚至触发 ANR。

正确做法是:把数据库操作全放进协程里。上面的insertdeleteupdate都声明为suspend,Room 会在后台线程自动执行。查询则用LiveData,它内部已经处理了线程切换。

提示:Room 的suspend函数会在 Room 自己的事务执行器上跑,你在代码里不需要再包一层withContext(Dispatchers.IO),Room 已经帮你做了。

4. 给记账本加分:统计图表、多账本与数据导出

4.1 用 MPAndroidChart 展示消费趋势

期末大作业一单加就加图表。Android 自带的控件不支持饼图和折线图,所以最常用的方案是引入 MPAndroidChart。在build.gradle里添加依赖:

implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'

它需要通过 JitPack 仓库下载,所以确保前面init.gradle或项目仓库配置里有maven { url 'https://jitpack.io' }。你可能会看到别人用implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'报错找不到仓库的问题,八成就是少了 jitpack。

绘制一张按日期聚合的支出折线图,核心逻辑是把一段时间内的交易数据按“天”分组,和值作为 Y 轴数据:

// 获取当前月的所有支出记录 val monthStart = getMonthStartMillis() val monthEnd = getMonthEndMillis() val transactions = viewModel.getTransactionsByTimeRange(monthStart, monthEnd) // 按日期聚合金额 val map = HashMap<String, Float>() for (t in transactions) { if (t.type != "支出") continue val day = SimpleDateFormat("MM-dd", Locale.getDefault()).format(Date(t.time)) map[day] = (map[day] ?: 0f) + t.amount.toFloat() } val entries = map.map { Entry(it.value, it.key) } val dataSet = LineDataSet(entries, "每日支出").apply { color = Color.parseColor("#FF7043") setDrawFilled(true) fillColor = Color.parseColor("#FFCCBC") }

图表相关的清单:

参数推荐值作用
setDrawFilled(true)true折线下方填充颜色,观感提升
description.isEnabledfalse去掉右下角默认的 Description
axisRight.isEnabledfalse隐藏右侧 Y 轴刻度,版面更干净
xAxis.positionXAxis.XAxisPosition.BOTTOMX 轴标签默认在底部更习惯

4.2 多账本:一个数据表的设计方案

有些人会加一个“账本”维度,比如分成“日常账本”和“旅行账本”。实现时不需要改表结构,只需要在 Transaction 表里新增一列book_id,默认值是 0 代表默认账本。查询时加一个条件:

@Query("SELECT * FROM tb_transaction WHERE book_id = :bookId ORDER BY time DESC") fun getTransactionsByBook(bookId: Long): LiveData<List<Transaction>>

新建账本页用一个单独的book表,保存账本名称、创建时间和图标。这样做的好处是:添加新账本时不需要迁移原表,只插入一条新记录。注意数据库版本要从 1 升到 2,并在@Database注解的version字段上改,同时提供一个Migration对象,否则旧用户升级时会崩溃。

val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE tb_transaction ADD COLUMN book_id INTEGER NOT NULL DEFAULT 0") } }

MIGRATION_1_2传进Room.databaseBuilder().addMigrations(MIGRATION_1_2)。这个细节很多人忽略,答辩时老师爱问“如果升级数据库版本怎么办”,提前讲出Migration就能显得你考虑过真实场景。

4.3 导出 CSV:用 FileProvider 解决文件分享问题

统计报表功能做完后,可以加一个“导出账单为 CSV 文件”的功能。共享文件时需要一个content://的 Uri,否则在不同应用间传递文件路径会失败。在AndroidManifest.xml里给 FileProvider 注册:

<provider android:name="androidx.core.content.FileProvider" android:authorities="com.example.myaccount.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>

res/xml/file_paths.xml中声明对外暴露的目录:

<paths> <external-files-path name="export" path="export/" /> </paths>

导出代码的核心部分是拼接 CSV 字符串并用 Intent 发送出去。关键点是FileProvider.getUriForFile帮你把 File 转成合法的content://Uri,对方的收件应用拿到这个 Uri 就能读取流并保存,界面显示诸如content://开头的一长串路径是正常现象,不必理会。

4.4 进度条加载:变“卡住”为“在处理”

期末作业的数据量小,基本不会出现长时间加载。但如果你加了“从导入 CSV 导入”这种 IO 操作,那必须给个进度反馈。常见做法是用一个 ProgressDialog 或ProgressBar指示耗时任务进度。

val dialog = ProgressDialog(this).apply { setMessage("正在导入...") setCancelable(false) setProgressStyle(ProgressDialog.STYLE_SPINNER) } dialog.show() // 导入逻辑放在后台线程 lifecycleScope.launch(Dispatchers.IO) { importFromCsv(uri) withContext(Dispatchers.Main) { dialog.dismiss() Toast.makeText(this@MainActivity, "导入完成", Toast.LENGTH_SHORT).show() } }

这里注意一个浅坑:导入一定在 IO 线程执行,但 UI 的撤回要切到主线程,所以我用withContext(Dispatchers.Main)而不是继续留在 IO 线程里直接 dismiss,否则会抛CalledFromWrongThreadException

5. 打包签名与答辩前的自测技巧

答辩演示怕的不是功能少,而是演示中途 App 崩溃。这里说几个我在交出期末作业前一定会做的自检项,顺手能提升印象分。

打开Build -> Generate Signed App Bundle or APK,选择 APK,新建一个.jks签名文件。这一步做两个事:生成可安装到别人手机的正式 APK;同时把build.gradlesigningConfig配上,方便下次直接 Build APK。如果老师要求演示时直接连手机跑 Android Studio,那这一步可以跳过;但如果你要把作业提交为安装包,签名是必需的。

答辩前模拟一次“从零安装”场景:把打包好的 APK 通过微信或 QQ 发到手机上,然后安装。连真机时优先使用adb install命令来安装来避免传输工具损坏安装包:

adb install -r app-release.apk

-r表示覆盖安装,如果手机上已经装了旧版,保留数据并覆盖安装可以避免卸载重装导致的数据清空。

功能自测建议按这条清单走一遍:

检查项预期表现
新增一笔 0 元或负数的支出弹“金额不正确”,不写入数据库
旋转屏幕后列表数据仍在不报错,不丢失数据
删除某条记录后退出重进数据不复活
统计页切换月份后图表刷新图表 Y 轴自动更新
导出 CSV 后用 WPS 打开列名正确、中文不乱码

其中“中文不乱码”是常见坑。导出 CSV 时默认用系统编码,在国内手机上通常没问题,但如果用 Excel 打开显示乱码,应在写文件时加上 UTF-8 BOM 头。方法是在写内容之前先写\uFEFF这一个字符。

还有一个演示细节:如果演示时手机是锁屏状态,务必先手动点亮屏幕解锁再开始操作。很多人在“演示”场景遇过这种尴尬:屏幕黑着,点击无反应,主考官以为 App 卡死,实际上是有的手机会在演示投屏时对锁屏状态做省电限制,界面不自动点亮。

如果到了答辩现场才发现某条记录不小心删了,别慌张,演示完主动补一句“删除功能刚才已演示,数据库中的记录确实被移除了”。这不影响评分,反而证明功能可用。

正式答辩前可以把手机里的普通账本数据清空,只留 2 ~ 3 条特定分类的账目。空数据演示图表不美观,数据太多又会让列表滚动显得啰嗦。留三条:一条餐饮支出、一条交通支出、一条工资收入,正好覆盖收入和支出的两个统计口径。这样老师看折线图时一眼能看懂规律变化,比留几十条假数据更保险。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询