简介:一套基于Android Studio开发的个人记账工具APP源码,面向Android入门及进阶学习者,覆盖日常收支记录、当日/当月总金额统计、历史账单查看、月度收支图表与百分比分析、按关键字搜索收支记录等功能,能够满足个人记账的常见需求。界面简约流畅,项目技术栈集中体现了Android基础知识点:包括XML布局与常用控件属性、Activity页面跳转与传值、Fragment碎片加载与滑动视图切换、自定义对话框与自定义软键盘、ListView/GridView适配器使用、Android自带SQLite数据库的建表及增删改查、drawable样式定义,以及MPAndroidChart第三方框架绘制柱状图。资源总计164个文件,压缩包约346KB,文件类型以77个png图片、38个xml布局/配置、35个java源码及gradle构建脚本等为主,结构清晰,便于对照学习。已有2779人学习下载,适合希望系统梳理Android开发基础并快速上手完整项目源码的开发者。
1. 为什么个人记账 APP 适合当成 Android Studio 练手源码来写
身边不止一个朋友问过我:想学 Android 开发,该做什么项目?我的答案一直是同一句——别做待办清单,做个记账 APP。原因很简单:记账 APP 的 CRUD 完整、状态多、输入格式敏感、数据要持久化,还要考虑统计和图表。这些恰好把一个 Android 应用从界面到存储到异常处理的链路全部覆盖了一遍。基于 Android Studio 开发的个人记账工具 APP 源码,不是什么高深项目,但它是一个能把“会写代码”升级成“会做产品”的完整样本。
这类源码最适合三类人:刚学完四大组件、想在简历上放一个完整项目的新手;被业务代码困住、想看看一个独立 APP 从零到一怎么组织的老手;还有单纯想给自己写个趁手记账工具、不想被云端同步绑定的普通用户。它解决的是“钱花到哪去了”这个高频问题,同时暴露的坑——金额精度、键盘遮挡、数据库版本升级——每一个都会在你真正上架时再遇到一次。这里不聊空概念,直接讲这套源码背后的设计决策和可复现代码。
2. 记账 APP 的核心需求拆解:别急着写代码,先画数据流
2.1 最小可用功能集:为什么只留五个页面
我见过太多人一开始就想做“全能记账”,预算管理、多人共享账本、语音记账、票据拍照识别,全往里塞。最后源码写了一万行,能跑的功能没几个。个人记账 APP 的最小可用功能集就五个页面:账单列表页、记账编辑页、统计页、分类管理页、设置页。其中设置页甚至可以简化成一个偏好设置界面。
这五个页面对应的是完整的数据闭环:账单列表页展示流水,记账编辑页写入新消费,统计页按时间或分类聚合数据,分类管理页维护支出和收入的标签体系。拿掉任何一个,记账工具都不算“顺手”。我一般建议源码里保留一个隐藏功能——搜索,因为当你记了三个月账之后,一定会想搜“上次买咖啡是什么时候”。RecyclerView 列表加一个 SearchView 就够了,成本很低。
2.2 数据模型设计的两个关键决策:金额用分存储,时间用毫秒时间戳
记账 APP 最容易被新手写崩的数据模型就是金额字段。很多初版源码会用float或double存金额,Java 里跑一下0.1 + 0.2就知道结果不是0.3,而是0.30000000000000004。把这种误差写进账本里,月底一汇总,差出几毛钱都算小事,最让人崩溃的是明细加起来和总额对不上,你根本不知道去哪排查。个人记账 APP 源码里金额字段应该用Long,以“分”为单位存整数,展示时再转成元。
第二个关键决策是时间字段。别用字符串,别用Date对象直接存数据库,统一用Long毫秒时间戳。这关系到后面做统计时的区间查询——BETWEEN startTime AND endTime,毫秒时间戳做区间过滤是不用写任何转换逻辑的。界面展示时再用SimpleDateFormat格式化,必要时用TimeZone.getDefault()处理时区。日期选择器单独存一个calendarDate字符串也不行,因为按周统计时要拿到“这周的第一天”的时间戳,存字符串你就得解析,徒增出错概率。
以下是实体类和建表语句的核心片段,可以直接抄进源码里。
@Entity(tableName = "bill") data class Bill( @PrimaryKey(autoGenerate = true) val id: Long = 0, val amountInCents: Long, // 金额,单位:分 val categoryId: Int, // 分类 ID val note: String, // 备注 val timestamp: Long, // 记账时间,毫秒时间戳 val type: Int // 0=支出,1=收入 )建表语句由 Room 根据实体自动生成,不需要手写 SQL,但要理解这张表的索引策略。timestamp和categoryId会被频繁用于WHERE和GROUP BY,我一般会手动加一个联合索引,因为默认 SQLite 不会替你建立任何非主键索引。
CREATE INDEX IF NOT EXISTS index_bill_time_type ON bill(timestamp, type);这段逻辑说明一下:amountInCents是 Long 不是 Int,因为如果用户记了一笔 9999.99 元的账单,分单位就是 999999,一个Int存得下,但如果你以后想加“年收入超过 2147 万”这种极端数据,Int 就有溢出风险。type字段单独存而不是靠categoryId判断,是因为统计报表里经常要同时查“支出总额”和“收入总额”,用单独字段可以直接在 SQL 里SUM(amountInCents) WHERE type=0,不需要 join 分类表。
2.3 架构选型:MVVM 是记账 APP 的默认答案,但这三个组件必须用对
个人记账 APP 源码的架构我不推荐 MVC,也不推荐去套复杂的 Clean Architecture。记账 APP 的业务逻辑不复杂,但数据流更新频繁——你记了一笔账,列表要刷新,统计要刷新,月度预算进度也要刷新。MVC 里Activity一个人干三份活,回调嵌套到怀疑人生。MVVM 加 Room 的LiveData或Flow,是 Android Studio 模板项目最顺手的组合。
三个核心组件的分工是:ViewModel持有数据、暴露状态,不持有Context;Repository做数据源分发,目前只有 Room,以后加了云端备份只用改这一层;Activity/Fragment只做渲染和事件转发。注意一个细节:ViewModel里不要直接暴露MutableLiveData给 View,要暴露不可变的LiveData,否则外部不小心调了setValue,数据流就乱了。
class MainViewModel(private val repository: BillRepository) : ViewModel() { private val _bills = MutableLiveData<List<Bill>>() val bills: LiveData<List<Bill>> = _bills fun loadBillsByMonth(year: Int, month: Int) { viewModelScope.launch { val start = getMonthStartTimestamp(year, month) val end = getMonthEndTimestamp(year, month) _bills.value = repository.getBillsBetween(start, end) } } }这里用了viewModelScope.launch而不是在Activity里开线程,原因是数据库操作不能跑在主线程,而viewModelScope自动绑定了 ViewModel 的生命周期,页面销毁时协程会被取消,避免内存泄漏。getMonthStartTimestamp这块逻辑需要注意时区,我下面的写法踩过坑,之后专门在避坑章节里展开。
3. 用 Room 搭建记账数据库:从建表到一次完整查询的源码落地
3.1 手动创建 Room 数据库和 DAO:比生成的模板多绕开三个坑
Room 是 Android Studio 官方推荐的 SQLite 抽象层,个人记账工具 APP 的源码里,数据库层通常是三件套:@Database注解的数据库类、@Dao接口、实体类。很多人直接用 Android Studio 的 Code Generator 生成模板,但模板默认不帮你处理数据库升级迁移,也不帮你处理Flow的背压问题。手写一遍,能更早暴露这些问题。
先看 DAO 接口。记账 APP 最核心的两个查询是“按时间区间查账单列表”和“按分类统计金额”,这两个查询写好了,列表页和统计页就各完成了一半。
@Dao interface BillDao { @Query("SELECT * FROM bill WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp DESC") fun getBillsBetween(start: Long, end: Long): Flow<List<Bill>> @Query(""" SELECT categoryId, SUM(amountInCents) AS total FROM bill WHERE type = 0 AND timestamp BETWEEN :start AND :end GROUP BY categoryId ORDER BY total DESC """) fun getExpenseSummary(start: Long, end: Long): Flow<List<CategoryTotal>> }getBillsBetween返回的是Flow<List<Bill>>而不是List<Bill>,这个取舍很关键。Flow意味着当数据表里插入新账单时,只要数据库发生变化,观察者会自动收到最新数据。这样记账编辑页关闭后,列表页不用手动调刷新方法,UI 会自动更新。代价是如果你不需要实时刷新,比如只导入历史数据批量插入,Flow会频繁发射数据,多消耗一点性能。记账 APP 的使用场景是低频写入,这个代价可以忽略。
getExpenseSummary里的SUM(amountInCents)返回的是Long类型的合计,不要在这里做除法转成元,精度问题交给 ViewModel 层处理。DAO 层只做聚合,不做展示格式化。
数据库类本身没有太多可说的,但有一个被问了无数次的细节:Room.databaseBuilder的fallbackToDestructiveMigration()函数,新手常常图省事直接调用。这个函数的作用是——数据库版本升级找不到迁移路径时,直接删除所有表重建。个人记账 APP 里如果用户升了个版本,旧账本全没了,这是灾难级事故。源码里不要出现这个调用,宁可让 APP 崩溃也别静默丢数据。
3.2 Repository 层:为未来可能加的云同步留一个接口
很多记账 APP 源码只写了 DAO 和 ViewModel 之间直接通信,省掉 Repository 层,结果等你想加个“导出备份到本地文件”功能时,发现逻辑没地方放。个人记账 APP 在结构上干净,很大程度是因为 Repository 把 DAO 调用和文件导出、CSV 生成等逻辑隔离开了。
class BillRepository(private val dao: BillDao) { fun getBillsBetween(start: Long, end: Long): Flow<List<Bill>> = dao.getBillsBetween(start, end) suspend fun insertBill(bill: Bill) { dao.insert(bill) } suspend fun exportToCsv(filePath: String, start: Long, end: Long) { val bills = dao.getBillsOnce(start, end) // 一次性查询,不返回 Flow // 这里写 CSV 文件生成逻辑,见第六章 } }getBillsOnce这种一次性查询函数是专门给导出功能准备的,DAO 里如果只有Flow版本,你就得first()取一次,用起来不顺手。所以我一般会建议 DAO 里同一个查询写两个版本:一个Flow给 UI 实时观察,一个挂起函数给后台任务。挂起函数必须用suspend关键字标识,Room 会在后台线程执行,不需要再包一层withContext(Dispatchers.IO)。这个过程容易犯的错是把suspend忘了,然后 Room 给你抛getBillsOnce cannot be called on main thread,报错信息倒是挺友好。
4. 记账编辑页的实现:输入过滤器、分类选择器与键盘遮挡的完整处理
4.1 金额输入框:拦截非法输入的正确姿势不是 ``
记账编辑页是整个 APP 交互最核心的部分,用户每次打开 APP 都会先碰它。第一个坑就是金额输入。很多源码会用android:inputType="numberDecimal"解决问题,但这个属性的表现并不理想——部分输入法即使设了numberDecimal,仍然能输入多个小数点,或者输入负号,或者在小数点后输到五位数。比如1.2.3、-5、0.33333这种脏数据,存进数据库后展示格式化会一团糟。
我习惯在源码里写一个InputFilter,拦截所有非法字符,同时限制小数位数为两位。这样无论用户用哪个输入法,软键盘再怎么飘,最终进到数据库的金额都是合法格式。
class AmountInputFilter : InputFilter { override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { val newText = dest.replace(dstart, dend, source.toString()) return if (isValidAmount(newText)) null else "" } private fun isValidAmount(value: String): Boolean { if (value.isEmpty()) return true if (value == ".") return true // 允许输入过程中出现一个孤立的小数点 val regex = Regex("^\\d{0,7}(\\.\\d{0,2})?$") return regex.matches(value) } }这段代码的isValidAmount中正则写的是\\d{0,7},意思是整数部分最多 7 位。这个上限是我个人的偏好——单笔账超过 1000 万,大概率是误输入。如果你要做企业级报销,这个值可以改,但个人记账场景 7 位足够。dstart和dend是 EditText 里被替换字符的起止位置,dest.replace用来模拟输入完成后的完整字符串,然后整体校验。这种方式比逐字符拦截更可靠,因为能正确处理“中间插入”和“整体粘贴替换”的情况。
绑定到 EditText 时注意,要追加到已有 filter 链上,不要直接把原来的 filter 覆盖掉,否则某些默认行为会失效。
val filters = editText.filters.toMutableList() filters.add(AmountInputFilter()) editText.filters = filters.toTypedArray()4.2 分类选择器:AlertDialog还是BottomSheet,不要给用户做选择题的界面
分类选择器是记账 APP 里容易被低估的交互点。用户记账的场景往往是“掏出手机、十秒记完、锁屏”,如果让他从二三十个分类里用下拉列表挑,每次操作多花五秒,三个月下来就是十个小时,他一定会卸载你的 APP。
源码里推荐的方案是底部弹出的BottomSheetDialog里放一个RecyclerViewGridLayout,把常见分类直接展示成网格,点击即选中。支出分类默认给这几种:餐饮、交通、购物、居住、娱乐、医疗、教育、其他;收入分类给:工资、兼职、理财、红包、其他。这样设计之后,用户两次点击就能完成一笔账务记录。
分类选择器和金额输入框不要用同一个页面。我见过一些简陋版本,把分类做成一个 Spinner 放在编辑页里,整个排版乱成一锅粥。正确的布局是:编辑页顶部是金额、中间是备注、底部是日期和分类入口,点击分类入口再弹底部面板。这样键盘弹出时,分类面板不会遮挡输入区域。
4.3 键盘遮挡保存按钮:adjustResize的边界条件
记账编辑页最常见的 UI 问题是——软键盘弹出来,底部的“保存”按钮被顶得看不到。新手普遍第一个想到的是android:windowSoftInputMode="adjustPan",把这个属性设上后键盘弹出时整个窗口上移,按钮是看到了,但顶部的金额输入框又被挡了。个人记账 APP 的编辑页布局大多是ScrollView包裹,正确的方案是adjustResize,让系统压缩可用高度,配合ScrollView滚动到焦点位置。
<activity android:name=".ui.edit.EditBillActivity" android:windowSoftInputMode="adjustResize" />一个容易忽略的细节:adjustResize只有在主题是Theme.Material.*或AppCompat派生的主题下才稳定生效,如果你用了Theme.Black或者自定义的无 ActionBar 主题,部分国内 ROM 上仍然会失效。这种问题没法在代码层面根治,只能靠真机测试。另一个常见做法是用 Android Studio 的KeyboardHeightHelper动态监听软键盘高度,自己重绘底部按钮位置,但那是不得已的方案,维护成本高,新手阶段不建议碰。
5. 记账列表页的 RecyclerView 与数据链表:刷新机制、空布局和分页的坑
5.1 为了不闪屏:DiffUtil 让列表只在数据变化时刷新
列表页在任何记账 APP 里都是门面。用户打开 APP 第一眼看到的就是最近账单列表,这里如果每次切换页面都闪一下白屏或者整体刷一下,体验立刻打折。RecyclerView 的notifyDataSetChanged()是原始用法,但个人记账工具 APP 源码里,我会强制自己用ListAdapter+DiffUtil而不是普通Adapter。
ListAdapter的submitList方法内部会用AsyncListDiffer在后台线程对比新旧数据,计算出差异后只刷新变化的那一项。好处是记账后返回列表页,如果你只插入了一笔新账单,那么列表只会在顶部多出一行,其他行的滚动位置纹丝不动。因为DiffUtil默认比较areItemsTheSame和areContentsTheSame,对象类型要实现equals,Room 生成的实体类不会自动重写equals,所以这里必须自己处理。
class BillAdapter : ListAdapter<Bill, BillAdapter.BillViewHolder>(DiffCallback) { object DiffCallback : DiffUtil.ItemCallback<Bill>() { override fun areItemsTheSame(oldItem: Bill, newItem: Bill): Boolean = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Bill, newItem: Bill): Boolean = oldItem == newItem } override fun onBindViewHolder(holder: BillViewHolder, position: Int) { val bill = getItem(position) holder.bind(bill) } }这里有个性能陷阱:areContentsTheSame用oldItem == newItem,如果Bill里多了一个List<Attachment>之类的复杂字段,每次比较都会递归对比,列表长时会有卡顿。个人记账 APP 的Bill都是扁平字段,这个写法够用。如果你加了备注、图片附件,就别用全字段 equals,改成只跟展示相关字段比较。
5.2 空布局:列表为空时别给用户留白屏
记账列表页第一个月大概率是空数据,如果 RecyclerView 直接摆在页面上,用户看到的是一整片空白,他会以为 APP 坏了而不觉得是“还没有记录”。完整源码里必须有一个空布局切换的逻辑。
推荐的做法是监听LiveData<List<Bill>>的结果:
viewModel.bills.observe(this) { list -> if (list.isEmpty()) { binding.rvBills.visibility = View.GONE binding.tvEmpty.visibility = View.VISIBLE } else { binding.rvBills.visibility = View.VISIBLE binding.tvEmpty.visibility = View.GONE adapter.submitList(list) } }observe回调里不只更新列表,还要同时管理空布局的可见性。很多源码里写两个观察者,一个管数据,一个管可见性,导致LiveData被通知两次,UI 状态闪跳。一个观察者全搞定是最省心的。空布局文案可以加一句引导,比如“点击右下角按钮记第一笔”,不要让用户对着空屏发呆。
5.3 加载更多:个人记账 APP 到底需不需要分页
我见过不少个人记账源码硬做“上拉加载更多”,每页加载 20 条,然后在一个几千条记录的本地数据库上用LIMIT OFFSET分页。这个设计有问题。本地数据库的查询速度非常快,几万条数据用一次查询全部加载出来,耗时不会超过 50 毫秒,而OFFSET分页在表数据增大后反而变慢。个人记账 APP 的使用强度,几年也就几千条记录,根本不需要分页。
真正需要的是“按时间分块”:列表页默认加载当月账单,往上滑动时加载上个月的账单,这个按月份滚动加载的方案不仅避免了分页的性能问题,还天然契合用户“这个月花了多少”的心智模型。SQL 上就是timestamp BETWEEN monthStart AND monthEnd,前面 DAO 已经写好了。要在源码里实现的,只是监听 RecyclerView 滚动到底部,再往前一个月查询。
binding.rvBills.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val layoutManager = recyclerView.layoutManager as LinearLayoutManager val lastVisible = layoutManager.findLastVisibleItemPosition() if (lastVisible >= adapter.itemCount - 1) { viewModel.loadPreviousMonth() } } })这段代码是滚动到底部触发加载上一月。注意loadPreviousMonth()一定不能阻塞主线程,Room 挂起函数天然满足这个要求,但如果你在 ViewModel 里做时间计算,要小心拿到的System.currentTimeMillis()只调用一次,不要在每次滚动时都去取“当前时间”算月份边界。
6. 记账源码的五个经典翻车现场:从金额错乱到系统时间篡改
6.1 金额用float存储:月底汇总永远差 0.01 元
现象:记了 20 笔账,列表里每笔看起来都对,但统计页的总金额比明细加起来少了 0.01 元,且这个差值每次都在变。原因:float的二进制浮点数不能用十进制精确表示,1.05f + 2.35f的真实结果是3.3999999,汇总后误差被放大。解决:所有金额在 Java/Kotlin 层用Long存储(单位分),输入时用BigDecimal做“元转分”换算再存库,输出时再换算回元。转换方法固定写在一个工具类里,全局只此一份。
object MoneyUtil { fun yuanToCents(yuan: String): Long { val bigDecimal = BigDecimal(yuan).multiply(BigDecimal(100)) return bigDecimal.setScale(0, RoundingMode.HALF_UP).longValueExact() } fun centsToYuan(cents: Long): String { val yuan = BigDecimal(cents).divide(BigDecimal(100), 2, RoundingMode.HALF_UP) return yuan.toPlainString() } }HALF_UP是四舍五入,用于记账场景最直观。注意longValueExact()在金额过大超出 Long 范围时抛异常,但个人记账用到这个边界的机会微乎其微。centsToYuan为什么返回String而不是Double?因为显示层要保证“100.00”而不是“100.0”,用字符串省去再格式化一步。
6.2 时间戳取错单位:统计报表一个月全是 1970 年
现象:某天新增账单后,列表页刷新正常,但统计页日期全部变成 1970 年 1 月。原因:系统返回System.currentTimeMillis()是毫秒,而有些第三方库或算法返回的是秒。查询月度范围时,用“秒级时间戳”和“毫秒级时间戳”混在一起做BETWEEN,条件直接失真。解决:项目内部统一常量标注单位,所有写入数据使用毫秒时间戳,接口或工具类传参数时在名称上体现单位。
const val TS_UNIT = "millis" // 全项目统一时间戳单位:毫秒 fun getMonthStartTimestamp(year: Int, month: Int): Long { val calendar = Calendar.getInstance().apply { clear() set(year, month - 1, 1, 0, 0, 0) } return calendar.timeInMillis }clear()这一步新手容易漏。不clear()会保留当前时间的时分秒,导致 1 号当天的数据被切到上个月的区间里。calendar.set(year, month - 1, 1, 0, 0, 0)中month - 1也是老坑,Calendar的月份从 0 开始,写错的话所有 1 月的账单会被统计进 2 月。
6.3 键盘弹出把整个布局挤变形
现象:编辑页点击金额输入框,键盘弹出后背景图被压缩变形,保存按钮跑到屏幕外。原因:windowSoftInputMode的默认模式是adjustPan,在某些国产 ROM 上表现不稳定,加上布局用了ScrollView加match_parent高度,挤压后布局错乱。解决:统一使用adjustResize,且根布局改为ScrollView包LinearLayout,键盘弹出时只缩短可滚动区域,不触发整体缩放。如果adjustResize在你的真机上仍然不生效,检查主题是否继承自AppCompat,然后尝试在onCreate里动态设置fitSystemWindows。
val decorView = window.decorView decorView.setOnApplyWindowInsetsListener { _, insets -> if (insets.systemWindowInsetBottom > 0) { // 键盘弹出,此时可以手动把按钮顶上去 } insets }这段是极端情况下的兜底方案,写在源码里没问题,但平时别让它执行。它在部分定制 ROM 上是唯一有效的方案,代价是代码可读性下降。
6.4 Room 数据库升级没有写 Migration:版本更新后用户账单全没了
现象:用户从 v1 升到 v2,打开 APP 发现所有分类和账单一律清空,如同重装。原因:v1 的建表语句里没有category分类字段,v2 实体类加了这个字段,Room 会发现 schema 不一致,默认抛异常IllegalStateException。如果你图省事加了fallbackToDestructiveMigration(),那就是直接丢弃数据。解决:写一个 Migration 类,在addMigrations()注册。这个源码片段建议每个记账工具都备好。
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE bill ADD COLUMN categoryName TEXT DEFAULT '' ") } }迁移 SQL 只能用ALTER TABLE能做的最小变更,不要在这个对象里做数据清洗。如果你想重命名表,SQLite 的ALTER TABLE RENAME存在新旧表约束名变化的坑,遇到复杂迁移时记一句话:先建新表、复制数据、删旧表、重命名。这个流程是 SQLite 数据库迁移最稳妥的四步。
6.5 RecyclerView 的notifyDataSetChanged()导致图片错乱
现象:列表每一条目带分类图标,快速上下滑动后,一个餐饮图标出现在娱乐条目上。原因:onBindViewHolder里加载图标是异步的,复用ViewHolder时上一轮的图片控件还持有旧分类资源,被新数据复用时来不及重置。解决:在onBindViewHolder开头先holder.imageView.setImageResource(placeholder),再做数据填充,或者使用支持自动取消请求的图片加载库。个人记账源码的图标来自本地资源,切换分类的逻辑本身简单,但千万别小看这个复用问题。
override fun onBindViewHolder(holder: BillViewHolder, position: Int) { val bill = getItem(position) holder.ivCategoryIcon.setImageResource(R.drawable.ic_placeholder) holder.ivCategoryIcon.setImageResource(getCategoryIcon(bill.categoryId)) }先设占位图再设真实图,这条规则适配所有 RecyclerView 场景,文字和颜色同样适用。很多界面“闪烁”“跳图”的玄学问题,多半就是这个原因。
7. 让记账工具更好用:账单导入导出的两种落地路线与权限处理
7.1 导出 CSV 到外部存储:别用Environment.getExternalStorageDirectory()
个人记账 APP 做到列表、统计之后,下一个被用户高频要求的功能就是“导出数据”。导出的目标格式最通用的是 CSV,Excel、Numbers、WPS 都能直接打开。源码实现上第一个坑是存储路径。Android 4.4 之后,直接写外部存储的公共目录需要WRITE_EXTERNAL_STORAGE权限,Android 10 开始Environment.getExternalStorageDirectory()基本拿不到读写权限。正确做法是写在应用外部专属目录,getExternalFilesDir(null),这个目录不用任何权限。用户通过系统文件管理器也能看到,只是路径深一些。
跟用户解释路径时,简洁说法是“导出之后请在文件管理器里搜索 APP 名称的文件夹”。下面这段是完整的 CSV 写入逻辑,需要流式写出,避免一次性把所有字符串拼进内存。
suspend fun exportToCsv(context: Context, start: Long, end: Long) { val bills = dao.getBillsOnce(start, end) val file = File(context.getExternalFilesDir(null), "ledger_$start_$end.csv") BufferedWriter(FileWriter(file)).use { writer -> writer.write("时间,分类,金额(元),类型,备注\n") bills.forEach { bill -> val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) .format(Date(bill.timestamp)) val amount = MoneyUtil.centsToYuan(bill.amountInCents) val type = if (bill.type == 0) "支出" else "收入" writer.write("$date,${bill.categoryName},$amount,$type,${bill.note}\n") } } }这段代码有两个容易被忽视的细节。use是Closeable的扩展函数,不管写入中途是否抛异常都会关闭 writer,比 try/finally 简洁。CSV 字段里如果备注本身包含英文逗号或换行,直接用$bill.note拼接会导致文件解析错列,解决的方案是备注字段用双引号包起来,字段内双引号再做一次转义。不过个人记账场景里备注乱输入标点的概率不高,等用户真遇到再补也不迟。
导出完成后的提示,建议用Toast加文件完整路径,方便用户找。
7.2 导入的三种回绝:防 CSV 注入、防重复、防格式不合规
有导出就有导入。不要只写“支持导入”四个字就完了,导入是比导出更容易翻车的功能。CSV 注入是首要防范点——如果 CSV 单元格里的内容以=或@开头,Excel 打开时可能把它当公式执行,这就是所谓的 CSV 注入攻击。导入源码里要过滤掉以等号开头的内容。
fun sanitizeForImport(value: String): String { if (value.startsWith("=") || value.startsWith("@") || value.startsWith("+")) { return "'" + value } return value }导入还要处理重复问题。用户先把数据导出到 CSV,改了几笔后重新导入,如果不查重,账本里会出现两套一模一样的记录。我用过的可靠方案是:导入时按(timestamp, amountInCents, note)做查重,这三项完全一致的记录跳过。查重用一条 DAO 方法返回Boolean,SQL 里SELECT COUNT(*) WHERE timestamp = ? AND amountInCents = ? AND note = ?,这个操作虽然多几条查询,但导入量几百条时可以接受。
格式不合规的情况也要兜底——文件缺列、金额写成了汉字、时间格式不对。源码里导入每条记录时用 try-catch 包住,解析失败的行记录到一个错误列表里,导入完成后一次性提示“成功导入 N 条,跳过 M 条”。不要一行失败就整个事务回滚,用户会一头雾水。
7.3 备份与恢复:把账本用 JSON 完整序列化
CSV 适合给人看、给 Excel 看,但如果你要做完整的备份恢复机制,CSV 不是最好的载体。它丢类型——收入和支出靠文本判断,它丢 schema——版本升级后 CSV 列少了,旧文件就废了。个人记账 APP 的全量备份我用 JSON,把所有表数据包进一个对象,保存为一个.json文件。恢复时先解析 JSON,再全套写入 Room。
数据序列化直接用Gson或kotlinx.serialization,不要手写拼接字符串。这个 JSON 文件的设计和源码里的实体类一一对应,版本字段预留一个schemaVersion,将来表结构变了,老备份还能靠版本号做一次迁移。恢复备份时无论数据量多小都建议包一个事务,用 Room 的runInTransaction执行批量插入,保证半路写失败时不会留下一个残缺账本。
事务这块踩过一次很深的坑:当时导入 200 条数据,没用事务,循环插入到第 57 条时分类 ID 外键冲突,整个 APP 直接崩,账本表里前面 56 条已经写进去了。数据库处于半完成状态,用户根本不知道哪些进去了哪些没进去。用runInTransaction之后,任何一条失败,全部回滚,一致性有保障。从那次之后,批量写数据库我都是事务先行。
我自己的习惯是:每季度手动导出一份 CSV 存到电脑,一份 JSON 备份放在云端。希望帮到你,该踩的坑都替你踩过了。
本文还有配套的精品资源,点击获取