简介:这套基于安卓开发工具构建的记账本系统,面向初学安卓开发的学生或需要日常记账的个人用户,完整覆盖登录注册、账单增删改查、分类筛选与个人信息管理等功能流程。压缩包共70个文件,整体约10.68MB,其中16份Java源代码承载业务逻辑,22份布局与配置文件搭建界面,18张图片资源提供视觉素材,另有构建脚本、安装包、演示视频及运行文档,类型配置齐全,便于从源码到安装运行全流程理解与实操。目前已有493人学习下载。开发者可借助开放的源代码研究数据存储、用户验证与界面交互等实现方式,结合演示视频和说明文档快速完成导入调试;普通用户可直接安装使用,通过分类记账和账单筛选掌握收支情况。资源内另附开发环境配置说明与运行文档,适合作为课程设计、毕业设计或安卓入门实践项目的参考模板,也可帮助开发者快速上手安卓应用开发的常见模块。
1. 安卓记账本系统:比想象中更考验工程功底的练手项目
很多人以为记账本是安卓入门项目,真做完才发现它卡人的地方根本不在「记一笔」——而是在数据怎么存才不丢、列表刷新怎么不卡、统计图怎么画得准、打包出来在别人手机上能不能装得上。基于 Android Studio 开发的安卓记账本系统,本质上是一套「本地数据库 + 界面交互 + 图表统计 + 打包交付」的完整闭环,正好覆盖了一个安卓应用从开发到上架前的大部分环节。适合刚学完四大组件、想交付完整应用的新手,也适合拿它做课设、毕设或内部工具,再往深走还能加备份恢复、桌面小组件和多账户。这篇按我自己的做法,从建工程讲到打包避坑,照着走能少踩一半的坑。
2. 先把工程搭对:技术选型与 Android Studio 项目骨架
2.1 为什么记账本不需要后端,本地数据库反而更合适
记账数据是强隐私数据,绝大多数个人记账场景根本不需要云同步。本地数据库的好处是离线可用、响应快、没有服务器成本,坏处是一旦手机丢了数据就没了,所以后面必须做导出备份。常见做法是 Room + SQLite,Room 在 SQLite 之上做了一层编译期校验和 LiveData/Flow 的响应式封装,比裸写 SQLiteOpenHelper 适合现代安卓开发。
如果只是课设级别,SQLiteOpenHelper 也能跑,但你要自己处理游标关闭、线程切换、类型转换,代码量翻倍还容易漏。Room 的学习成本大约半天,换来的是 DAO 接口编译期检查 SQL 语法错误,这个收益在后期改表结构时特别明显。我一般会直接选 Room,哪怕是最简版本。
2.2 新建工程与 Gradle 配置:依赖下载慢和设置中文的落地处理
Android Studio 新建 Empty Views Activity 项目后,第一件事不是写代码,而是确认 Gradle 能顺利拉依赖。国内网络环境下,google() 和 mavenCentral() 不一定稳,常见做法是在项目级 build.gradle 里加阿里云镜像,注意顺序要放在 google() 前面才生效。
// 项目级 build.gradle 的 repositories 部分 buildscript { repositories { maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } google() mavenCentral() } }这段配置把 Google Maven 和中央仓库的请求优先转发到国内镜像,能明显缩短首次 Sync 时间。如果之前已经卡在下载阶段,改完后执行 File > Sync Project with Gradle Files。还有个小坑:个别镜像同步有延迟,拉不到最新版本库时,把 dependencies 里的版本号降到稳定版,别追最新。
新手还会找 Android Studio 怎么设置中文,界面语言在 Settings > Appearance & Behavior > System Language 里切,跟工程本身无关,不影响编译。真正要花时间的是搞清楚 Gradle JDK 版本和 compileSdk 的匹配关系,默认模板一般没问题,但如果你装了多个 JDK,在 Settings > Build Tools > Gradle 里指定 JDK 17,不然老项目会报 Unsupported Java version。
2.3 按业务拆包:一个能撑到毕设结束的目录结构
很多记账本项目最后的结局是:Activity 里塞了两千行代码,数据库操作写在 onResume 里,改一个查询要翻半天。我一般会按数据层、界面层、工具类三层拆包,哪怕项目很小也这么拆,后面加功能不用重构。
com.example.ledger/ ├── data/ │ ├── db/ # RoomDatabase、Entity、DAO、Migration │ ├── repository/ # BillRepository,统一对外暴露数据接口 ├── ui/ │ ├── activity/ # MainActivity、统计页面 │ ├── adapter/ # RecyclerView 的 Adapter │ ├── dialog/ # 新增/编辑账单的对话框 ├── utils/ │ ├── DateUtils.kt # 时间格式化 │ ├── MoneyUtils.kt # 分转元、金额校验 ├── App.kt # Application,初始化数据库拆包不是形式主义。记账本的核心数据流只有一条「界面 → ViewModel/Repository → DAO → Room」,按层拆开后,每一层都能单独测试。比如你觉得统计算错了,直接查 Repository 里的聚合查询,不用从界面往下追。Activity 里只做两件事:绑视图、观察数据,其它一律不碰。
3. 数据层是记账本的命根子:Room 建表、DAO 与仓储设计
3.1 Room 还是 SQLiteOpenHelper:核心差异在哪
Room 对比 SQLiteOpenHelper 最大的优势不是性能,而是编译期检查。你写一个 SQL 查询,字段名拼错,SQLiteOpenHelper 要跑到那行代码才崩溃,Room 在编译期直接报错。另一个优势是响应式——DAO 返回 Flow,数据一变界面自动更新,不用手动 notifyDataSetChanged。这对记账本非常实用:新增一笔、删除一笔,列表自动刷新,不会出现「记了账界面没反应」的玄学问题。
Room 也有黑匣子的一面:它生成的实现代码在 build/generated 目录里,出问题时要会看编译日志。常见做法是先让它跑起来,再逐步加复杂查询。首次接入需要这三样:Entity 实体类、DAO 接口、RoomDatabase 子类,一个都不能少。
3.2 定义 Entity 与 DAO:金额用分存,时间用 Long 存
记账本最容易翻车的不是界面,是金额精度和时间格式。Java/Kotlin 的 double 在连续加减时会出 0.1 + 0.2 != 0.3 这种问题,所以金额统一用 Long 存「分」,界面显示时再转成元。时间统一用 Long 存毫秒时间戳,排序和范围查询都靠它,界面展示时才格式化成 yyyy-MM-dd。
@Entity(tableName = "bill") data class Bill( @PrimaryKey(autoGenerate = true) val id: Long = 0, val amountFen: Long, // 金额,单位分,避免浮点精度问题 val category: String, // 消费分类:餐饮、交通、购物等 val note: String, // 备注,可为空字符串 val timestamp: Long, // Unix 毫秒时间戳,排序、统计都靠它 val type: Int // 0 = 支出,1 = 收入,统计时按此区分 )amountFen 加注释说明单位,不然三个月后你自己都会忘。type 用 Int 而不是布尔值,是因为后面可能加「退款」「转账」等类型,Int 扩展性更好。timestamp 存的是 System.currentTimeMillis(),查询某月账单时用 start 和 end 两个时间戳做 BETWEEN 条件,比存字符串再用 LIKE 匹配快一个数量级。
DAO 接口是数据层的脸面,所有 SQL 都集中在这里:
@Dao interface BillDao { @Insert suspend fun insert(bill: Bill): Long @Update suspend fun update(bill: Bill) @Delete suspend fun delete(bill: Bill) @Query("SELECT * FROM bill ORDER BY timestamp DESC") fun getAllBills(): Flow<List<Bill>> @Query("SELECT COALESCE(SUM(amountFen), 0) FROM bill " + "WHERE type = 0 AND timestamp BETWEEN :start AND :end") suspend fun getExpenseSum(start: Long, end: Long): Long @Query("SELECT COALESCE(SUM(amountFen), 0) FROM bill " + "WHERE type = 1 AND timestamp BETWEEN :start AND :end") suspend fun getIncomeSum(start: Long, end: Long): Long }getAllBills 返回 Flow,Room 会在表数据变化时自动重新发射,列表界面订阅后自动刷新。getExpenseSum 里的 COALESCE 很关键:某个月没有支出时 SUM 返回 null,不包一层默认值,后面转 Long 直接 NPE。这是新手最常踩的坑,SQLite 的聚合函数对空表返回 null,不是 0。
3.3 Repository 包一层:Activity 里不写 SQL 也不管线程
有了 DAO 之后,很多人直接在 Activity 里调 dao.insert(),短期没问题,一旦业务变复杂就失控。我习惯加一层 Repository,把「数据从哪来」的细节全部收进去。Activity 只知道 repository.addBill(bill),至于 Room 还是以后换网络接口,Activity 完全不用改。
class BillRepository(private val billDao: BillDao) { fun getAllBills(): Flow<List<Bill>> = billDao.getAllBills() suspend fun addBill(amountFen: Long, category: String, note: String, type: Int) { billDao.insert(Bill( amountFen = amountFen, category = category, note = note, timestamp = System.currentTimeMillis(), type = type )) } suspend fun deleteBill(bill: Bill) = billDao.delete(bill) suspend fun getMonthExpense(monthStart: Long, monthEnd: Long): Long { return billDao.getExpenseSum(monthStart, monthEnd) } }注意 addBill 是 suspend 函数,说明它要在协程里调用。Room 的 suspend DAO 方法会自动切到后台线程执行,你在主线程调也安全,但还是要养成「所有数据操作都走 suspend + ViewModel」的习惯。如果项目不引入 ViewModel,直接在 Activity 里用 lifecycleScope.launch 包一层也行,只是 Activity 会慢慢变胖。Android Studio 自带抽取方法快捷键(Ctrl+Alt+M 或 Cmd+Alt+M),写 Activity 时看到超 5 行的逻辑块就抽出去,界面代码能一直保持清爽。
提示:Room 必须在 Application 里初始化,不能每次 new 一个实例,否则会创建多个数据库连接,偶发磁盘占用异常。初始化代码放在 App.kt 里,用 companion object 暴露单例。
4. 界面层怎么组织:RecyclerView 列表、统计图表与输入防呆
4.1 账单列表:RecyclerView 适配器与点击删除
记账本的主界面是典型的「列表 + 悬浮按钮」结构,列表用 RecyclerView 而不是 ListView,性能差异在数据量超过几百条时就能感觉到。适配器要接收 List 数据,用 ListAdapter 加 DiffUtil 是最稳的做法,数据更新时列表自动做最小刷新,不会闪烁。
class BillAdapter(private val onItemClick: (Bill) -> Unit) : ListAdapter<Bill, BillAdapter.BillViewHolder>(DiffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BillViewHolder { val binding = ItemBillBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return BillViewHolder(binding) } override fun onBindViewHolder(holder: BillViewHolder, position: Int) { val bill = getItem(position) holder.binding.tvAmount.text = MoneyUtils.fenToYuan(bill.amountFen) holder.binding.tvCategory.text = bill.category holder.binding.tvNote.text = bill.note holder.binding.root.setOnClickListener { onItemClick(bill) } } companion object DiffCallback : DiffUtil.ItemCallback<Bill>() { override fun areItemsTheSame(oldItem: Bill, newItem: Bill) = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Bill, newItem: Bill) = oldItem == newItem } }适配器只负责绑定数据,点击事件通过回调抛出去,由 Activity 决定是弹窗改还是删。注意 DiffCallback 里 areItemsTheSame 用的是 id,areContentsTheSame 用的是整个对象比较,两者语义不同:前者判断是不是同一条数据,后者判断内容变没变。如果内容比较字段太多影响性能,可以只比 amountFen 和 timestamp。
4.2 月度趋势:MPAndroidChart 画收支曲线
统计页面是记账本区别于「流水账」的核心功能。常见做法是用 MPAndroidChart 画一张月度收支趋势图,x 轴是日期,y 轴是金额。这个库已经快十年没大更新,但够稳定,配合 build.gradle 里的 implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0' 就能用。
val lineChart = binding.lineChart val entries = monthBills.groupBy { it.dayOfMonth } .map { (day, bills) -> Entry(day.toFloat(), bills.sumOf { it.amountFen } / 100f) } val dataSet = LineDataSet(entries, "每日支出").apply { color = Color.parseColor("#FF6B6B") lineWidth = 2f circleRadius = 3f setDrawValues(false) } lineChart.data = LineData(dataSet) lineChart.xAxis.valueFormatter = object : IndexAxisValueFormatter() { override fun getFormattedValue(value: Float): String { return "${value.toInt()}日" } }注意汇总逻辑在 Kotlin 侧做而不是 SQL 侧做,因为每天一个点的数据量很小,Kotlin 的 groupBy 足够快,而且 SQL 按天分组还要再处理日期格式化,代码可读性更差。x 轴用 IndexAxisValueFormatter 把数字转成「3日」「15日」,不然默认显示 3.0、15.0 很丑。图表的坑在空数据:一天都没记录时 entries 为空,LineDataSet 会崩,先判断 size == 0 就显示空视图。
4.3 输入防呆:金额、日期、分类三个容易翻车的地方
记账本每天打开,输入体验决定用户会不会持续用。金额输入框用 EditText 的 inputType="numberDecimal",键盘只弹数字和小数点;但用户还是能输入「1.2.3」这种,必须在提交时做一次校验。日期默认给今天,提供 DatePickerDialog 选择;分类用预置列表加「其他」兜底,不要做自由输入,否则统计图里会出现十几个相似分类。
fun parseAmount(input: String): Long? { val trimmed = input.trim() if (trimmed.isEmpty()) return null return try { val yuan = trimmed.toDouble() if (yuan < 0) null else Math.round(yuan * 100) } catch (e: NumberFormatException) { null } }这段把用户输入的「12.34」元转成 1234 分。注意用 toDouble 再四舍五入,而不是直接字符串处理小数位,因为用户可能输入「12.345」,这时精度丢就丢了,记本金句:界面允许的输入精度决定了数据精度,你不可能通过后端补偿界面丢掉的精度。校验失败时 Toast 提示具体原因,别只弹「输入有误」,用户不知道自己错哪了。
5. 打包与真机调试避坑:签名、资源错误、版本兼容与数据库迁移
5.1 模拟器连不上 adb、模拟器不联网的排查
开发阶段最常见的翻车是模拟器起不来、adb 连不上、或者跑起来没网。现象千奇百怪:adb devices 列表空、Android Studio 一直显示 waiting for target device to come online、模拟器里浏览器打不开网页。原因基本集中在三处:ADB 版本和模拟器不匹配、模拟器 DNS 配置错误、或者 AVD 冷启动没完成就急着装应用。
先确认 adb 能识别设备,再查网络。常见做法是命令行执行 adb kill-server 后重启 adb,再 adb devices。模拟器不联网时,先看系统时间对不对,不对的话 DNS 解析必然失败;再在模拟器设置里把 Wi-Fi 的 DNS 改成 223.5.5.5 这类公共 DNS,重启模拟器。如果你用的是 Android Studio 自带的 AVD,冷启动慢是常态,别在启动动画没结束时就操作,等桌面图标出现再装 APK。
5.2 签名打包:jks 密钥、Gradle 签名配置与安装失败
发布给别人安装的 APK 必须签名。Android Studio 的 Build > Generate Signed Bundle / APK 向导能帮你一步步生成 jks 密钥库,但有个坑:很多人生成完密钥就丢,等下次要更新版本时找不到 jks,只能换包名重新上架,之前的用户全部要手动卸载。所以 jks 文件要备份,密码要放在自己能找到的地方。
签名配置写进 build.gradle 后,以后打 release 包就不用手动点向导了:
android { signingConfigs { release { storeFile file("keystore/ledger.jks") storePassword "your_password_here" keyAlias "ledger" keyPassword "your_password_here" v1SigningEnabled true v2SigningEnabled true } } buildTypes { release { signingConfig signingConfigs.release minifyEnabled false shrinkResources false } } }v1 和 v2 签名必须都打开,v2 是 Android 7.0 以上校验方式,v1 兼容老设备,只开 v2 会导致部分 6.0 以下机型无法安装。还有一个经典报错:Installation failed with message INSTALL_FAILED_UPDATE_INCOMPATIBLE,因为手机里已经装了同一个包名的旧版本,但签名不一样。解决就两条:卸载旧版,或者把应用包名改掉。测试阶段我一般用 debug 包,发布才切 release 签名。
打出来的 release APK 传到手机安装时又会遇到「解析包出现问题」或「该文件包含恶意代码」的提示。前者多半是 APK 没下载完整,后者是部分国产 ROM 对未知来源应用的风控,让你在设置里允许「安装未知应用」。这一步跟代码没有关系,但第一次做的人会以为包坏了,白折腾半天。
5.3 数据库升级:从 1.0 到 2.0 的迁移脚本怎么写
记账本上线后最容易出事故的就是改表结构。用户手机里已经有数据,你升级版本时如果 Room 发现 schema 不匹配,默认行为是崩溃并提示需要迁移或重建。新手常见的处理方式是卸载重装,但记账数据全没了,用户直接流失。
正确做法是每次改表结构都升版本号并写 Migration:
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE bill ADD COLUMN is_deleted INTEGER NOT NULL DEFAULT 0") } } Room.databaseBuilder(context, AppDatabase::class.java, "ledger.db") .addMigrations(MIGRATION_1_2) .build()这里从版本 1 升到 2,加了一个 is_deleted 字段用于软删除。ALTER TABLE 加列是轻量操作,不需要重建表;但如果要改列名、改约束,SQLite 的 ALTER 能力有限,常见做法是先建新表、拷贝数据、删旧表、重命名。写迁移脚本前先备份原数据库文件(在 data/data/包名/databases/ 下),迁移跑完再看数据是否完整,这是唯一靠谱的后悔药。
还有一类「release 正常、debug 正常、一上正式版就崩」的疑难杂症,现象是 Room 报 Schema export directory 相关错误,原因是 Room 编译期需要导出 schema 文件,缺省配置下 release 构建找不到目录。解决方法是 build.gradle 里给 kapt/ksp 配 schemaLocation,指向项目里的 schemas 目录,并且把生成的 json 文件提交到 git。这些文件能让你在升级数据库时用命令行工具对比新旧 schema,排查「为什么 Room 认为表结构不匹配」这类黑匣子问题。
5.4 资源重复错误:android resource linking failed 的排查
改项目过程中会遇到编译报错提示 resource linking failed 或 Duplicate resources,常见于你往 res 目录里放了非标准文件(比如把 xlsx、zip 直接丢进 drawable 或 mipmap),或者复制别处代码时把 layout 文件一起带进来,同名字段冲突。定位方法是看 Build 输出里具体报哪个文件、哪个资源名,然后删掉重复资源。还有一个高频误操作:在 values 里重复定义了同名的 string 或 color,Android Studio 编辑器有时候不报错,但 assemble 时翻车。养成改完 resource 就执行一次 gradlew clean 的习惯,能省不少排查时间。
6. 让记账本从「能跑」变成「能用」:备份恢复与性能自查
记账本能记录只是及格线,真正的分水岭是数据安全和操作流畅度。导出备份用 CSV 是最通用的方案,一份文件可以用 Excel 打开,也能用于数据迁移。我把导出功能放在主界面的菜单里,生成文件写到应用专属外部目录,同时用 Intent 调起分享,让用户直接发给自己或存网盘。
fun exportBillsToCsv(context: Context, bills: List<Bill>): File { val file = File( context.getExternalFilesDir(null), "ledger_backup_${System.currentTimeMillis()}.csv" ) file.bufferedWriter().use { writer -> writer.write("id,amountFen,category,note,timestamp,type\n") bills.forEach { bill -> writer.write("${bill.id},${bill.amountFen},${bill.category}," + "${bill.note},${bill.timestamp},${bill.type}\n") } } return file }导出的字段和数据库列一一对应,恢复时按 CSV 逐行解析,重新 insert 进 Room。注意 note 里可能有逗号和换行,写 CSV 时要用引号包一下,否则恢复解析会错位。这个坑我踩过,恢复出来几十条账单分类全串了,从那以后凡是写 CSV 我都加引号包裹文本字段。
性能自查用 Android Studio 的 Profiler 就能做。打开 CPU Profiler 操作一遍「新增账单 → 切统计页 → 切回列表」,看主线程是否有超过 16ms 的任务;再打开 Memory Profiler 看有没有内存抖动。记账本数据量在几万条以内,性能问题几乎都出在列表 Item 里做了耗时操作(比如在 bind 里加载图片、格式化日期用了 SimpleDateFormat 的实例化),把日期格式化抽成工具类复用即可。
我自己的习惯是每个月导一次 CSV 存到本地,防止哪天调试把数据库玩坏。这个项目做完最大的收获不是学会了 Room 或 RecyclerView,而是意识到:安卓应用交付不是「编译过」就完事,而是要在用户手里能装、能跑、数据不丢。希望你做完记账本后,也会像我一样先备份再升级,这个习惯能救你很多次。
本文还有配套的精品资源,点击获取