十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于安卓开发的记账本App:Kotlin+Room+协程从零到一实战

基于安卓开发的记账本App:Kotlin+Room+协程从零到一实战 简介这款基于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返回ListTransaction就够了但记账本的主页列表和统计页需要“数据一变界面马上更新”的效果此时应该让 DAO 返回FlowListTransaction。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): FlowListTransaction 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): FlowListCategorySummary }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 ) : ListAdapterTransaction, TransactionAdapter.TransactionViewHolder( DIFF_CALLBACK ) { companion object { val DIFF_CALLBACK object : DiffUtil.ItemCallbackTransaction() { 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: FlowListTransaction 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 returnsetOnClickListener } 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 mutableListOfPieEntry() private val colors listOf( Color.parseColor(#FF7043), Color.parseColor(#FFCA28), Color.parseColor(#26A69A), Color.parseColor(#7E57C2), Color.parseColor(#5C9EFF) ) fun setData(entries: ListPieEntry) { 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: MutableStateFlowLong?fun setCategoryFilter(categoryId: Long?) { categoryFilter.value categoryId } val filteredTransactions: FlowListTransaction 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: ListPairLong, Double, window: Int 7): ListPairLong, Double { if (raw.size window) return raw val result mutableListOfPairLong, 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用户主动选择保存位置既不需要申请存储权限又能生成一个系统认可的合法文件 Uriprivate 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: ListTransaction, 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 里用LiveDataEventString发射一次性事件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: FlowListTransaction 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是原子操作连续快速点击多次只会触发一次中间值变化基本不会造成重复查询。如果想保留撤销栈导入前的数据可以备份到一个临时表不过这一层复杂度对普通记账应用已经超出必要范围——用户大概率只会在换机时导入一次。本文还有配套的精品资源点击获取
返回列表