简介:这款基于Android Studio开发的安卓记账本源码,适合安卓初学者或需要快速搭建记账类应用的开发者,用于课程设计、毕业设计或个人项目参考都很合适。源码使用SQLite数据库,完整实现登录注册、记账金额与类型的增删改查、数据统计和饼状图展示,覆盖从基础界面到数据可视化的关键知识点。资源共72个文件,压缩包约7.62MB,包含25个xml布局与配置文件、14个java核心源码、3个gradle构建文件,以及png图片、docx说明文档、mp4演示视频和可直接安装的apk,文件类型分工明确,便于按需查阅。作者额外提供了环境安装说明、运行情况视频和手机安装包,方便对照源码理解Android项目结构、SQLite数据操作及图表框架集成方式。目前已有2543人浏览学习,适合系统掌握安卓记账应用的完整开发流程。
1. 记账本App不是小项目,是安卓开发的完整训练场
很多人觉得记账本是个“期末大作业”级别的练手项目,功能无非是记一笔、删一笔、再画个饼图。但真把需求列全,你会发现它几乎覆盖了安卓开发的所有主战场:SQLite 与 Room 的数据持久化、RecyclerView 的列表复用与差量更新、ViewModel + LiveData/Flow 的生命周期感知、Kotlin 协程的异步任务调度、自定义 View 的图表绘制、Material Design 的交互规范,甚至多用户数据备份与导出。任何一个模块单独拎出来,都能对应到一线开发中的真实面试题。这也是“基于安卓开发的记账本源码”能成为搜索长尾的原因——它不是一个玩具,而是一套可以反复重构、逐步加码的 mini 业务系统。
本文不虚构某份具体源码包,而是基于这个标题,把一套可落地、可复现、能跑到真机上的记账本 App 方案完整讲清楚。无论你是准备安卓开发期末大作业、在找毕设方向,还是想系统补一遍 Jetpack 全家桶的工程实践,按下面的章节一步步做,最终能拿到一个结构清晰、能导出报表、经得起推敲的完整项目。我们直接从技术选型和数据模型开始。
2. 数据层设计:用 Room + Flow 把每一笔账存稳、查快
2.1 为什么选 Room 而不是 SQLiteOpenHelper 或 GreenDAO
记账本的核心资产是数据。第一版用SQLiteOpenHelper手写 SQL 确实能跑,但表结构一升级、字段一多,onUpgrade里的迁移逻辑就会变得难以维护。GreenDAO 虽然性能好,但注解处理器生成的代码侵入性强,对 Kotlin 协程的支持也不如 Room 自然。当前安卓开发的主流方案是 Room,原因有三:编译期校验 SQL 语句、与 LiveData/Flow 的响应式绑定开箱即用、迁移机制Migration让表结构演进有章可循。
在依赖层面,build.gradle.kts里需要引入 Room 运行时、KSP 编译器插件和协程支持。注意 2024 年后 KSP 已经是 Room 注解处理的事实标准,kotlin-kapt 在新项目里不再推荐。依赖版本建议直接写 2.6.1 以上,KSP 插件版本要和 Kotlin 版本匹配,否则会报Unknown error这种几乎无法定位的编译问题。
// app/build.gradle.kts 核心依赖片段 plugins { id("com.google.devtools.ksp") version "2.0.21-1.0.28" } android { // 启用 ViewBinding,减少 findViewById 样板代码 buildFeatures { viewBinding = true } } dependencies { val roomVersion = "2.6.1" implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-ktx:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion") 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") }room-ktx这一行不能省,它提供了协程作用域内的 suspend DAO 支持;lifecycle-viewmodel-ktx则让我们能在 ViewModel 里直接用viewModelScope.launch切协程。版本号建议锁定,不要用+动态依赖,Gradle 的依赖锁在某些离线构建场景下会直接翻车。
2.2 账目表的字段设计:type、category、amount 的取舍
记账本的核心表结构通常围绕三个维度展开:这笔钱花在哪(category)、花了多少(amount)、什么时候花的(timestamp)。但设计表的时候不能只存这三个字段,type字段必须独立出来区分收入与支出,否则月末统计时会在 SQL 里写一堆CASE WHEN,查询效率和数据可读性都很差。
@Entity(tableName = "transactions") data class Transaction( @PrimaryKey(autoGenerate = true) val id: Long = 0, val type: Int, // 0 = 支出, 1 = 收入 val categoryId: Long, // 关联 category 表 val amount: Long, // 单位:分,避免浮点误差 val note: String? = null, // 备注,允许为空 val timestamp: Long, // 毫秒时间戳 val synced: Boolean = false // 是否需要同步到云/导出 )金额字段用 Long 而不是 Double,这是记账类应用最常见的坑。0.1 + 0.2在 Double 里是0.30000000000000004,虽然日常显示看不出来,但在月度汇总和图表聚合时会累积出莫名其妙的几分钱差异。统一以“分”为单位存储,展示层再除以 100,这是所有金融类 App 的基本纪律。
分类表单独建一张categories,预置「餐饮、交通、购物、工资、理财」等固定分类,允许用户自定义。每笔交易用categoryId关联,这样后续做分类统计只需要一条GROUP BY查询,不需要在交易表里冗余分类名——冗余看似省事,但用户一旦重命名分类,历史数据的统计口径就全乱了。
2.3 DAO 层用 Flow 做流式查询:统计页自动刷新的秘密
DAO 层的关键设计是返回值类型的选择。如果只做一次性查询,用suspend fun返回List<Transaction>就够了;但记账本的主页列表和统计页需要“数据一变,界面马上更新”的效果,此时应该让 DAO 返回Flow<List<Transaction>>。Room 会把每一次表数据变更都重新发射到收集端,配合repeatOnLifecycle可以做到页面在前台时自动刷新、退到后台时停止收集,避免电量浪费。
@Dao interface TransactionDao { @Insert suspend fun insert(transaction: Transaction): Long @Update suspend fun update(transaction: Transaction) @Delete suspend fun delete(transaction: Transaction) @Query("SELECT * FROM transactions WHERE type = :type ORDER BY timestamp DESC") fun getTransactionsByType(type: Int): Flow<List<Transaction>> @Query(""" SELECT categoryId, SUM(amount) AS total FROM transactions WHERE type = 0 AND timestamp BETWEEN :start AND :end GROUP BY categoryId ORDER BY total DESC """) fun getExpenseSummary(start: Long, end: Long): Flow<List<CategorySummary>> }getExpenseSummary返回的是一个自定义 POJO,不需要非得是 Entity 类。Room 允许查询结果映射到任何带有匹配字段名的类上,这就是轻量级投影。分类汇总用SUM(amount)聚合后按金额倒序,饼图和列表就能直接消费这份数据。注意BETWEEN的边界是闭区间,如果end是当天 23:59:59 的毫秒值,那么start = 当天 00:00:00时会漏掉 0 点整的账单,建议在传入前统一归一化时间戳。
2.4 数据库升级:从 v1 到 v2 的 Migration 模板
开发阶段改表结构是大忌中的大忌。很多新手图省事,直接卸载 App 重装,这等于把用户数据当垃圾丢掉。正确做法是用Migration类描述表结构的增量变化:
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE transactions ADD COLUMN synced INTEGER NOT NULL DEFAULT 0") } } val database = Room.databaseBuilder(context, AppDatabase::class.java, "ledger.db") .addMigrations(MIGRATION_1_2) .build()SQLite 没有ALTER TABLE ADD IF NOT EXISTS语法,所以每个Migration必须精细控制。第一次发布时就把synced、deleted这类预留字段设计好,可以少写很多迁移代码。如果只是调试阶段本地玩,可以用.fallbackToDestructiveMigration()兜底;但发布版务必去掉这行,否则用户升级后数据全消失,投诉率会直接拉满。
3. RecyclerView 列表与编辑页:从增删改查到差量刷新的完整闭环
3.1 账目列表用 ListAdapter + DiffUtil:滑动不卡顿的关键
记账首页的核心是流水列表。直接notifyDataSetChanged()在数据量大时会导致整个列表重绘,滑动掉帧。ListAdapter内置AsyncListDiffer,会在后台线程对比新旧列表差异,只更新变化的条目,配合 RecyclerView 的setHasFixedSize(true)可以做到非常顺滑。
class TransactionAdapter( private val onClick: (Transaction) -> Unit ) : ListAdapter<Transaction, TransactionAdapter.TransactionViewHolder>( DIFF_CALLBACK ) { companion object { val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Transaction>() { override fun areItemsTheSame(old: Transaction, new: Transaction) = old.id == new.id override fun areContentsTheSame(old: Transaction, new: Transaction) = old == new } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransactionViewHolder { val binding = ItemTransactionBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return TransactionViewHolder(binding, onClick) } override fun onBindViewHolder(holder: TransactionViewHolder, position: Int) { holder.bind(getItem(position)) } }areItemsTheSame判断是否为同一条数据,用主键 id 比较;areContentsTheSame判断同一条数据的内容是否变化,用 data class 的equals比较。这两个回调缺一不可:只实现前者会导致内容改了但列表不刷新,只实现后者会丢失实体的唯一性判断。
3.2 日历范围选择与流式过滤:统计页只刷一次
记账列表不能只有全部流水,需要按日、按周、按月过滤。过滤条件如果每次变化都重新查数据库,会显得很笨重;正确做法是把“过滤条件”本身作为一个 StateFlow,在 ViewModel 里用flatMapLatest切换数据源:
class LedgerViewModel( private val dao: TransactionDao ) : ViewModel() { private val typeFilter = MutableStateFlow(0) // 0支出, 1收入 private val dateRange = MutableStateFlow(TodayRange.get()) val transactions: Flow<List<Transaction>> = combine(typeFilter, dateRange) { type, range -> type to range }.flatMapLatest { (type, range) -> dao.getTransactionsInRange(type, range.start, range.end) } }combine会把两个 StateFlow 的最新值打包,任一个变化都会触发下游重组;flatMapLatest保证只有最新的查询生效,前一个 Flow 被自动取消。这样做的好处是:用户在日期选择器里滑动选范围,只有松开确认那一刻才触发一次数据库查询,不会每滑一格就打一次 SQL。
3.3 编辑页的防误触与数据回填:用 ViewBinding 减少样板代码
新增和编辑可以复用同一个 Activity/Fragment,通过传入transactionId区分模式。回填数据时要注意金额的显示转换:数据库里存的是分,UI 上要除以 100;用户输入时也要校验,不能出现负数或超过两位小数的金额。编辑页代码不需要炫技,关键是输入框的输入法和类型限制。
binding.etAmount.apply { inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL // 保留两位小数的过滤器 filters = arrayOf(DecimalDigitsInputFilter(2)) } binding.btnSave.setOnClickListener { val amountText = binding.etAmount.text.toString() val amountCent = (amountText.toDoubleOrNull() ?: 0.0) * 100 if (amountCent <= 0) { binding.etAmount.error = "金额必须大于0" return@setOnClickListener } viewModel.saveTransaction( type = if (binding.rbExpense.isChecked) 0 else 1, amount = amountCent.toLong(), categoryId = selectedCategoryId, note = binding.etNote.text.toString(), timestamp = selectedTimestamp ) finish() }DecimalDigitsInputFilter是自定义的InputFilter,用于拦截非法字符输入,避免用户在键盘上敲出1.2.3这种无法解析的金额。error方法只是设置输入框的错误提示文字,不会自动取消光标,真正的拦截必须靠filters在输入阶段完成。
3.4 左滑删除与撤销:Snackbar + 二次确认的取舍
列表项左滑删除是记账类应用的常见交互,但直接删除容易误触。主流的折中方案是:左滑后弹出Snackbar,提供“撤销”按钮,同时延时 3 秒真正执行数据库删除。这样既保留了交互效率,又给了用户反悔的机会。
val callback = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean = false override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val position = viewHolder.bindingAdapterPosition val item = adapter.getItem(position) viewModel.deleteWithUndo(item) } } ItemTouchHelper(callback).attachToRecyclerView(binding.recyclerView)deleteWithUndo内部实现很直接:先删除数据库记录,再把Transaction对象放进一个用于撤销的队列,3 秒后队列自动清空。如果用户点了 Snackbar 的撤销,就把这条记录重新插入。注意被删除记录的主键是自增的,重新插入会拿到新 id,如果用 id 做图片文件名等外链,撤销后可能对不上。
4. 图表统计与自定义 View:用 Canvas 画一个不依赖第三方库的饼图
4.1 饼图背后的数学:角度、比例、颜色的映射关系
记账本的统计页通常需要饼图展示消费分布。很多人第一反应是引入 MPAndroidChart 或 HiCharts,但如果是学习项目,手写一个饼图能对自定义 View 有更深的理解,而且代码量其实不大。饼图的本质是画圆弧:每一条数据对应一个角度区间,用Canvas.drawArc绘制扇形,再用Path画分隔线或绘制标签。
class PieChartView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val data = mutableListOf<PieEntry>() private val colors = listOf( Color.parseColor("#FF7043"), Color.parseColor("#FFCA28"), Color.parseColor("#26A69A"), Color.parseColor("#7E57C2"), Color.parseColor("#5C9EFF") ) fun setData(entries: List<PieEntry>) { data.clear() data.addAll(entries) invalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val total = data.sumOf { it.value } if (total <= 0f) return val rectF = RectF( width * 0.1f, height * 0.1f, width * 0.9f, height * 0.9f ) var startAngle = -90f // 从12点方向开始 data.forEachIndexed { index, entry -> val sweepAngle = entry.value / total * 360f paint.style = Paint.Style.FILL paint.color = colors[index % colors.size] canvas.drawArc(rectF, startAngle, sweepAngle, true, paint) // 绘制标签位置:扇形中心方向上的半径中点 val midAngle = Math.toRadians((startAngle + sweepAngle / 2).toDouble()) val labelRadius = rectF.width() / 2f * 0.6f val labelX = (width / 2f + Math.cos(midAngle).toFloat() * labelRadius).toFloat() val labelY = (height / 2f + Math.sin(midAngle).toFloat() * labelRadius).toFloat() paint.style = Paint.Style.FILL paint.color = Color.BLACK paint.textSize = 28f canvas.drawText( "${entry.label}\n${(entry.value / total * 100).toInt()}%", labelX, labelY, paint ) startAngle += sweepAngle } } }drawArc的第三个参数是useCenter,传 true 表示画扇形而不是弧形。标签位置不会落在小扇区里,因为文字挤在一起;真正要处理小扇区,需要单独做引线逻辑,这里先不展开。
4.2 统计页的联动:点击图例切换列表
图表和列表的联动是统计页体验的关键。用户点某个分类的扇形或图例,下方流水列表筛选出该分类的明细,返回时恢复全部分类。实现上可以用一个共享的categoryFilter: MutableStateFlow<Long?>:
fun setCategoryFilter(categoryId: Long?) { categoryFilter.value = categoryId } val filteredTransactions: Flow<List<Transaction>> = combine(categoryFilter, dateRange) { categoryId, range -> if (categoryId == null) { dao.getTransactionsInRange(0, range.start, range.end) } else { dao.getTransactionsByCategory(0, categoryId, range.start, range.end) } }.flatMapLatest { it }点击饼图扇区时调用setCategoryFilter(categoryId),DAO 切换为单分类查询;再次点击同一个扇区时取消过滤。这里要注意combine的两个 Flow 的首次发射顺序:StateFlow 一创建就会发射初值,所以第一次进入页面时filteredTransactions也会正常收集到数据,不需要额外手动触发。
4.3 折线图的优化:滑动平均值让趋势更平滑
除了饼图,统计页还可以加一个月度收支趋势折线图。直接绘制每天的收支明细会出现剧烈抖动,一个双十一就能把整条曲线拉飞到看不见。常见处理是引入 7 日滑动平均值,数据点更平滑、趋势更明确。
fun calculateMovingAverage(raw: List<Pair<Long, Double>>, window: Int = 7): List<Pair<Long, Double>> { if (raw.size <= window) return raw val result = mutableListOf<Pair<Long, Double>>() for (i in raw.indices) { val start = (i - window + 1).coerceAtLeast(0) val sub = raw.subList(start, i + 1) val avg = sub.map { it.second }.average() result.add(raw[i].first to avg) } return result }窗口大小一般取 7 或 30,分别对应周趋势和月趋势。代码里的小技巧是coerceAtLeast(0)处理头部数据窗口不满的情况,避免subList越界。把计算放到Dispatchers.Default协程里,避免在主线程做大量集合操作。
5. 数据备份与导出:CSV 文件的生成与分享
5.1 备份到哪里:MediaStore 与 Storage Access Framework 的选择
记账 App 最害怕的是用户换手机后数据全失。帮用户导出 CSV 到本地存储,是最低成本的可迁移方案。Android 10 之后不能再直接写公共目录,需要分两种情况处理:导出到 App 专属外部目录不需要权限,但用户无法直接通过文件管理器访问;导出到公共 Download 目录则必须使用 MediaStore API 或 SAF。
这里推荐走ActivityResultContracts.CreateDocument,用户主动选择保存位置,既不需要申请存储权限,又能生成一个系统认可的合法文件 Uri:
private val exportLauncher = registerForActivityResult( ActivityResultContracts.CreateDocument("text/csv") ) { uri -> if (uri != null) { viewModel.exportToCsv(uri) } } binding.btnExport.setOnClickListener { exportLauncher.launch("ledger_${System.currentTimeMillis()}.csv") }5.2 CSV 内容与中文乱码问题:BOM 头必须加
CSV 的核心是逗号分隔,但中文内容在 Excel 里打开常见乱码,本质原因是 UTF-8 无 BOM 编码被 Excel 按本地编码解析。解决方式是在文件头部写入'\uFEFF'这个零宽度非换行空格(BOM)。代码里不能只处理字符串,还要注意导出到contentResolver.openOutputStream(uri)时用OutputStreamWriter指定字符集:
fun exportToCsv(transactions: List<Transaction>, uri: Uri) { val contentResolver = context.contentResolver contentResolver.openOutputStream(uri)?.use { stream -> val writer = OutputStreamWriter(stream, Charsets.UTF_8) writer.write("\uFEFF") // BOM,防止Excel中文乱码 writer.write("类型,分类,金额(元),备注,时间\n") transactions.forEach { t -> val typeStr = if (t.type == 0) "支出" else "收入" val amountYuan = "%.2f".format(t.amount / 100.0) writer.write("$typeStr,$categoryName,${amountYuan},${escapeCsv(t.note)},${formatTime(t.timestamp)}\n") } writer.flush() } }escapeCsv函数必须处理备注里的逗号、引号和换行符。最简单的实现是把备注中的双引号替换为两个双引号,再用双引号包住整个字段。不然用户的备注里一旦出现逗号,整个 CSV 这一行的列数就对不上了。
5.3 启动页检查导出结果:Toast 与日志双通道
导出完成后的反馈不能只做一个Toast就完事。文件可能写失败(磁盘满、Uri 失效、权限被回收),需要让用户知道失败原因。在 ViewModel 里用LiveData<Event<String>>发射一次性事件,Activity 收集后展示 Toast 或 Snackbar。日志方面用Log.i(TAG, "export success: $uri")留痕,崩溃平台上传时具备可排查性。
6. 导入与容错:CSV 反向解析的 3 个边界处理
完整的记账本必须有导入能力,否则备份就是单向逃生通道。导入 CSV 的常见坑比导出多得多:表头可能变化、金额格式可能是1,234.56、时间可能是多种格式(2025-01-01、2025/1/1 10:30、纯时间戳)。不能假设用户手里的 CSV 一定是自己 App 导出的。
6.1 解析函数要容忍脏数据跳过而非崩溃
导入解析建议用BufferedReader.readLine()逐行读取,而不是一次性把整个文件读进内存。每一行先分割字段,再逐个解析字段类型,解析失败的行计入错误统计,不中断整个导入过程。核心逻辑不需要复杂框架,手动split(",")前要把首行表头识别出来。
fun parseCsvLine(line: String): CsvRecord? { val fields = line.split(",") if (fields.size < 4) return null return try { val type = when (fields[0].trim()) { "支出" -> 0 "收入" -> 1 else -> return null } val amountCent = (fields[2].trim().toDouble() * 100).toLong() CsvRecord( type = type, categoryName = fields[1].trim(), amountCent = amountCent, note = fields[3].trim(), timestamp = parseTime(fields[4].trim()) ) } catch (e: NumberFormatException) { null // 记录解析失败,跳过,但不中断整个文件 } }split(",")对引号包裹的字段是不安全的,如果备注里有逗号,这一行的字段数会变多。用别人导出的 CSV 做兼容时,内部数据最好使用统一的分隔符(比如制表符\t),避免解析歧义;但对外导出要按 Excel 常见的逗号分隔。
6.2 时间格式的归一化与重映射
parseTime内部处理多种格式,核心思路是先收集所有可能的DateTimeFormatter依次尝试,全部失败再尝试SimpleDateFormat,再不行就尝试解析 Long 型纯时间戳。解析完成后统一转为毫秒值,写入数据库时减掉本地时区偏移,保证按天分组查询时结果正确。
fun parseTime(raw: String): Long { val s = raw.trim() if (s.isEmpty()) return System.currentTimeMillis() val formatters = listOf( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm"), DateTimeFormatter.ofPattern("yyyy-MM-dd") ) for (fmt in formatters) { try { return LocalDateTime.parse(s, fmt) .atZone(ZoneId.systemDefault()) .toInstant() .toEpochMilli() } catch (_: DateTimeParseException) { // 继续尝试下一个 } } return s.toLongOrNull() ?: System.currentTimeMillis() }时区是导入最容易忽略的坑。LocalDateTime.parse解析出的是本地时钟的时间,如果在东八区运行,atZone(ZoneId.systemDefault())能正确转成带时区的时刻;如果用户从另一个 App 导出的时间戳是 UTC,这个转换就会错 8 小时。稳妥做法是导入前让用户确认时区,或者在 UI 上显示解析后的时间预览让用户人工核对。
6.3 导入完成后的统计刷新:Database 变更通知是异步的
导入完成后 UI 不会自动刷新,因为 Room 对数据库的变更通知是异步的,且 Flow 收集端的生命周期状态可能不在 RESUMED。正确方式是导入完成后调用一个refreshTrigger,触发 ViewModel 里的数据重新拉取。
private val refreshTrigger = MutableStateFlow(0) val transactions: Flow<List<Transaction>> = combine(typeFilter, dateRange, refreshTrigger) { type, range, _ -> type to range }.flatMapLatest { (type, range) -> dao.getTransactionsInRange(type, range.start, range.end) } fun refresh() { refreshTrigger.value++ }导入结束、批量删除等批量操作后调用一次refresh()即可。要注意refreshTrigger.value++是原子操作,连续快速点击多次只会触发一次中间值变化,基本不会造成重复查询。如果想保留撤销栈,导入前的数据可以备份到一个临时表,不过这一层复杂度对普通记账应用已经超出必要范围——用户大概率只会在换机时导入一次。
本文还有配套的精品资源,点击获取