
简介一款面向安卓初学者的个人消费记账应用源码包围绕日常收支记录、账单分类、按时间段查询消费情况及单笔备注说明等核心功能展开适合正在练习安卓界面布局与数据存储的开发者参考也可作为课程设计或个人练手的完整项目范本。资源共30个文件压缩包仅67KB包含10个png界面截图或图标、8个xml布局与资源配置、8个java业务逻辑源码以及project、classpath等工程配置整体采用src、res、gen等标准安卓工程目录组织便于直接导入开发环境阅读和定位功能模块。目前已有247人学习下载这份源码可以帮助理解账单数据如何录入、分类、查询与备注以及各功能模块之间的调用关系如果对默认界面效果不太满意也能在此基础上自行美化UI甚至扩展图表统计、多账户管理等进阶能力。1. 先看门道这个记帐APP源码包下载之前先想清楚三件事记帐类APP是个人开发最常练手的方向但市面上绝大多数成品APP你装上之后坚持不过一周就删了。原因不是功能少而是记一笔账要点的次数太多、统计口径又不贴合自己习惯。这个源码包的价值不在“能记帐”而在它暴露了一套完整的本地数据流从界面录入、数据落库、按月汇总到图表展示所有代码都在你手里想要“今天午饭花了多少钱”这种统计改两行SQL就能实现。适合谁想学Android数据层开发的人、想给自己定制一个离线记帐工具的人、以及刚接手别人工程需要快速看懂结构的人。直接说结论这个方向值得做但源码不是拿来就跑的你要先弄懂三件事——表怎么设计、日期怎么处理、统计怎么聚合。下面我按实际改造成本最大的顺序拆开讲。2. 拆开源码包先把架构和数据模型看明白再动手2.1 源码目录怎么摆的先分清界面层、逻辑层和数据层解压rar之后第一眼看到的是一套标准的Android工程结构。常见做法是app目录下按功能分包而不是按层分包。比如你可能会看到activity、fragment、adapter、db、model这几个包。这种分包方式对新手友好但有个隐患业务逻辑容易散落在Activity里后面改统计规则会很痛苦。看源码的顺序我推荐从model包开始再看db包最后看activity。因为数据模型决定了整个APP能做什么界面只是数据的投影。// 一个典型的记帐数据模型字段直接对应数据库表列 public class RecordModel { private long id; // 主键自增 private long timeMillis; // 记账时间戳单位毫秒 private int type; // 0支出1收入 private double amount; // 金额保留两位小数 private int categoryId; // 关联分类表 private String note; // 备注可空 // getter / setter 省略 }这里有三个字段值得注意timeMillis用long存时间戳而不是存字符串这是记帐APP最基本的一条规范——所有统计都要按时间范围过滤数字比较远比字符串比较快而且不受日期格式影响type用int区分收支而不是用字符串“支出/收入”是为了SQL里SUM(CASE WHEN type 0 ...)这种写法方便amount用double看似省事但如果你要做精确的余额计算后面我会讲到更好的方案。2.2 账本的核心是表结构category表的字段设计与类别树记帐APP最容易被低估的是分类表。很多源码把分类写死在代码里比如一个字符串数组{餐饮, 交通, 购物}。这样做前期开发快但用户想加一个“宠物”分类就得改代码重新发版属于典型的给自己挖坑。规范的做法是建一张分类表让用户能在设置页增删改。常见表结构如下CREATE TABLE category ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- 分类名称如 餐饮 type INTEGER NOT NULL DEFAULT 0, -- 0支出分类1收入分类 icon TEXT, -- 图标资源名或Unicode字符 sort_order INTEGER DEFAULT 0, -- 排序权重越小越靠前 is_default INTEGER DEFAULT 0 -- 是否系统预置分类 );这里type字段特别关键收入分类和支出分类必须分开否则统计页画饼图时收入分类混在支出数据里会直接算错比例。icon字段建议直接存Unicode字符或图片资源名而不要存图片路径。原因是记帐APP图标数量少、体积小存资源名在换主题时可以自动切换存路径会导致文件还在、引用丢了的情况。如果你接手源码时发现分类是写死的我建议第一件事就是把它改成表驱动。别小看这个改动它决定了后面预算功能、报表筛选能不能做起来。改完分类表之后所有用到分类名的地方都要通过categoryId去查而不是在代码里硬编码。2.3 一种更省事的写法记录表上直接冗余category_name表结构设计里有争议的一点记帐记录表到底要不要冗余分类名字段。第三范式说不要但实际做统计报表时每次都要JOIN分类表查名字查询语句复杂而且一旦分类改名历史账单的显示会跟着变——这有时候反而是好事有时候是坏事。我见过一个改动成本极低的做法在record表里加一列category_name在写入记录时冗余保存当时的分类名。这样统计页直接从记录表取值不JOIN速度更快。分类改名只影响之后的新记录历史账单保持原样对个人记帐场景来说反而更像“当时的真实记录”。CREATE TABLE record ( id INTEGER PRIMARY KEY AUTOINCREMENT, time_millis INTEGER NOT NULL, type INTEGER NOT NULL, amount REAL NOT NULL, category_id INTEGER, category_name TEXT, note TEXT, create_time TEXT DEFAULT (datetime(now, localtime)) );当然冗余字段的代价是更新分类名时要决定策略是连历史记录一起改还是只改之后的。这个源码如果没做分类管理功能你加这个字段也不会影响现有逻辑。注意create_time用了SQLite默认值生成它是字符串格式只作展示用千万不要用它来做统计——排序和区间过滤必须用time_millis。2.4 Gradle依赖里藏着技术栈先看懂用了哪些库再决定改哪里任何一个Android工程app/build.gradle的 dependencies 块就是技术栈清单。打开这个源码的构建文件基本能看到三类依赖AndroidX基础库、UI控件库、图表库。这三类的选型决定了代码的写法边界。dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 implementation androidx.recyclerview:recyclerview:1.3.0 implementation com.github.PhilJay:MPAndroidChart:v3.1.0 implementation androidx.sqlite:sqlite:2.3.0 }看依赖清单其实是在看作者的取舍用了RecyclerView说明列表是虚拟化长列表没用什么三方网络库说明这个APP纯离线图表库用的是MPAndroidChart这是Android上最成熟的图表方案饼图和柱状图直接能用。如果你看到implementation而不是api说明作者在控制依赖传递这是好习惯。如果没有依赖锁定版本升级某个库之后可能遇到API变动建议动手前先确认能否编译过。3. 把工程跑起来从解压到真机安装的完整步骤3.1 环境对不上先别慌SDK版本与Gradle插件版本怎么对齐拿到源码第一件事不是看代码是先确认能不能编译。这一步会拦下大半新手。Android工程的build.gradle有两个关键版本号Gradle插件版本和SDK编译版本。插件版本太老新版Android Studio打不开SDK版本太高本机没装对应Platform又会下载失败。我一般会先打开根目录的build.gradle看Gradle插件版本号。常见写法如下plugins { id com.android.application version 7.4.0 apply false }这里7.4.0对应Gradle 7.5左右Android Studio的版本不能太旧。如果你的Studio版本是较新的大概率能直接打开。真正容易出问题的是compileSdk指向的数字比你本机SDK Platform版本高。遇到这种情况有两种处理把compileSdk降到本机已有的版本或者让Android Studio自动下载缺失的SDK。3.2 导入Android Studio之后第一件事改三个必改的配置项导入成功的标志是Gradle Sync跑完没有红色报错。但Sync通过不等于能跑还有三个配置项我建议立刻改掉要不然第一次运行就会翻车。android { namespace com.example.account_book compileSdk 34 defaultConfig { applicationId com.example.account_book minSdk 21 targetSdk 34 } buildFeatures { viewBinding true } }第一个是applicationId这个名字是安装包的唯一标识建议改成你自己的包名不然以后想上架或装两个版本会冲突第二个是minSdk这个源码如果写的都是新APIminSdk 21以下的老设备跑不起来不用为了兼容而降级第三个是ViewBinding这个源码如果用的是findViewById开启ViewBinding后重构空间很大。改完这三个配置再Sync一次基本就能装到手机上。3.3 首次运行常见的四个失败信号与处理顺序真正点Run之后会出现几种典型失败按出现概率排个序。第一是编译报错错误信息定位到具体Java文件多半是SDK版本引起的API差异处理方法是按报错提示替换API第二是安装失败常见原因是手机USB调试没开或驱动问题切到Wifi调试能避开第三是打开APP闪退这时要看Logcat里的AndroidRuntime报错九成是数据库初始化问题——表没建、字段对不上、空指针第四是白屏但没闪退这通常是主题里Activity没注册或布局加载异常。处理顺序我总结为先看Gradle版本再看SDK版本然后看minSdk和targetSdk差了多少——这个差距过大时系统会触发一些兼容模式行为比如文件路径访问限制导致数据库打不开。记帐APP如果打不开数据库所有功能都白搭。4. 把核心记账链路改成自己的从加一笔到看统计报表4.1 写一笔账单插入记录与更新余额的事务处理记帐APP最核心的操作就是记一笔用户选择分类、输入金额、保存。看起来简单但如果你的APP还有“总余额”或“本月结余”这种概念插入记录时必须同时更新汇总数据这里就涉及事务。SQLite的事务并不复杂但很多人会忘记结果就是记一笔成功、余额没变或者余额变了、记录没插上。正确的写法是用SQLite的beginTransaction包住两个操作public void insertRecord(RecordModel record) { SQLiteDatabase db dbHelper.getWritableDatabase(); db.beginTransaction(); try { ContentValues values new ContentValues(); values.put(time_millis, record.getTimeMillis()); values.put(type, record.getType()); values.put(amount, record.getAmount()); values.put(category_id, record.getCategoryId()); values.put(note, record.getNote()); db.insert(record, null, values); // 可选更新月度汇总表或余额表 db.execSQL(UPDATE summary SET total total ? WHERE month ?, new Object[]{record.getType() 0 ? -record.getAmount() : record.getAmount(), monthStr}); db.setTransactionSuccessful(); } catch (Exception e) { e.printStackTrace(); } finally { db.endTransaction(); } }这里有两个关键点setTransactionSuccessful()必须在endTransaction()之前调用否则事务会回滚monthStr建议直接用yyyy-MM这种字符串从time_millis格式化出来不要自己拼年月。注意金额符号要看你们的设计如果type0是支出往汇总表里写的时候要转成负数这个符号约定要跟统计SQL一致否则图表数据会差一倍。4.2 按月份切数据日期区间的查询写法与时分秒边界记帐APP统计页最常见的操作就是“查看某个月的支出”。看似简单的WHERE time_millis BETWEEN ? AND ?翻车率极高。原因在于边界时间你选了5月那5月1日0点0分0秒要包含5月31日23点59分59秒也要包含差一毫秒都不行。我见过很多源码直接写BETWEEN 月初时间戳 AND 月末时间戳但月末时间戳取的是6月1日0点这样会把6月1日0点0分0秒的记录也算进去几乎不可见但确实存在。Calendar calendar Calendar.getInstance(); calendar.set(Calendar.YEAR, year); calendar.set(Calendar.MONTH, month - 1); calendar.set(Calendar.DAY_OF_MONTH, 1); calendar.set(Calendar.HOUR_OF_DAY, 0); calendar.set(Calendar.MINUTE, 0); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); long startTime calendar.getTimeInMillis(); calendar.add(Calendar.MONTH, 1); calendar.add(Calendar.MILLISECOND, -1); long endTime calendar.getTimeInMillis();这段代码的意图是先定位到当月1日0点然后add(Calendar.MONTH, 1)跳到下个月1日0点再回退1毫秒得到的endTime就是本月最后一天的23点59分59秒999毫秒。用这个区间查询才不会有边界漏数据。别小看这个细节记账APP里所有按月统计的Bug百分之八十都出在这里。4.3 统计页的饼图和柱状图MPAndroidChart的数据装配图表库选型几乎决定了统计页的写代码体验。MPAndroidChart的用法是先建数据集合再装配成DataSet最后set进Chart。饼图的每一块对应一个分类的金额占比柱状图的每一根柱子对应一天或一个月的支出总额。PieChart pieChart findViewById(R.id.pie_chart); ArrayListPieEntry entries new ArrayList(); for (CategoryStat stat : categoryStats) { entries.add(new PieEntry((float) stat.totalAmount, stat.categoryName)); } PieDataSet dataSet new PieDataSet(entries, 分类支出); dataSet.setColors(getCategoryColors(categoryStats.size())); PieData data new PieData(dataSet); pieChart.setData(data); pieChart.invalidate();这段代码背后有个坑PieEntry的第二个参数是label它要显示在图例里如果分类名是中文要注意字体和长度显示不全的问题——通常设置pieChart.setUsePercentValues(true)会让显示变成百分比占用空间小一些。如果统计结果为空要先判空否则图表空白区域特别大如果某个分类金额为0MPAndroidChart虽然不会崩但会在图例里保留一个0项建议装配时过滤掉。柱状图同理注意BarEntry的x值是按0、1、2排的不是日期数值你要维护一个 xIndex 到日期的映射点击柱子时才知道是哪一天。这个映射丢了柱状图就只能看形状点不准。4.4 预算预警的逻辑不该写在Adapter里单独抽一个BudgetChecker很多人拿到源码想加的第一个功能就是预算。常见做法是在Adapter里对比当月支出和预算额度超了就变红。这个做法在小规模数据没问题但一旦你加了“月初重置”“按分类预算”“超出比例阈值”这些规则Adapter会膨胀到没法看而且每次数据刷新都要重算。我建议把预算判断抽成一个独立的类输入当月支出总额和预算配置输出一个状态对象public class BudgetChecker { public enum BudgetStatus { NORMAL, WARNING, EXCEEDED } public static BudgetCheckResult check(double monthTotal, double budget, double warningRatio) { BudgetCheckResult result new BudgetCheckResult(); double ratio budget 0 ? monthTotal / budget : 0; if (ratio 1.0) { result.status BudgetStatus.EXCEEDED; } else if (ratio warningRatio) { result.status BudgetStatus.WARNING; } else { result.status BudgetStatus.NORMAL; } result.ratio ratio; return result; } }这里把预警阈值warningRatio当参数传进来的意义是不同分类可以有不同的预警比例餐饮类可能用到80%就警告交通类90%才警告。返回值里同时带status和ratioUI层拿到状态决定颜色拿到比例决定进度条宽度。预算逻辑单独成类之后UnitTest可以直接测不需要启动模拟器。这个源码如果原本没有预算功能加这个类也不影响其他代码改动面很小。5. 记帐APP的常见坑与排查顺序从数据库升级到键盘遮挡5.1 数据库升级把老用户数据搞没了onUpgrade漏写导致的翻车现象APP版本更新后用户打开应用发现历史账单全没了或者直接闪退。原因SQLiteOpenHelper的onUpgrade没有实现或写成了db.execSQL(DROP TABLE record)。这是最常见的翻车点。解决不要用DROP再重建要按版本号迁移。比如你的数据库从版本1升到版本2字段只加了一个budget列正确的写法是Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion 2) { db.execSQL(ALTER TABLE record ADD COLUMN budget REAL DEFAULT 0); } if (oldVersion 3) { db.execSQL(ALTER TABLE record ADD COLUMN icon TEXT DEFAULT ); } }每个版本只执行自己需要的增量语句而不是所有版本都执行一遍。这样即使用户从版本1直接升到版本3也会按顺序执行到全部语句。记住任何DROP TABLE都要谨慎你的APP没有云同步的话数据掉了就是永久掉了。5.2 日期统计差了8小时时区与SimpleDateFormat的坑现象用户在晚上23点记了一笔账统计页里这笔账归到了第二天。或者“今天支出”一直是0。原因源码里用了SimpleDateFormat(yyyy-MM-dd)转换时间戳但没指定时区系统默认时区是UTC的话本地时间21点就变成了UTC的13点日期不会变但如果你在东八区23点会变成第二天15点吗不会UTC转本地会差8小时跨日问题只出现在0点到8点之间。解决存时间戳用毫秒显示日期时强制指定时区。SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd, Locale.CHINA); sdf.setTimeZone(TimeZone.getDefault()); String dateStr sdf.format(new Date(timeMillis));更稳的方案是在统计SQL里直接用datetime(time_millis/1000, unixepoch, localtime)来格式化SQLite自带的localdate处理已经考虑了时区偏移。注意这里time_millis/1000是单位转换如果表里存的是纳秒就会差一千倍先确认字段单位再写SQL。5.3 软键盘把输入框顶飞windowSoftInputMode没配好现象在记帐页面输入金额时弹出的键盘把输入框和按钮顶出屏幕用户看不到“保存”按钮。原因AndroidManifest里Activity没有配置windowSoftInputMode默认行为是系统自行调整窗口大小布局没有滑动能力就被挤压变形。解决在记帐Activity上设置合适的软键盘模式。activity android:name.activity.EditRecordActivity android:windowSoftInputModeadjustResize /同时保证根布局是ScrollView或使用android:windowSoftInputModeadjustPan。哪个效果好实测下来adjustResize配合ScrollView最稳妥键盘弹出时布局压缩、ScrollView能滚到输入框位置adjustPan是整体上移布局复杂时会挡到其他元素。还有一种情况是底部有RecyclerView的页面键盘弹出会导致列表重新layout这时候要检查android:windowSoftInputModeadjustNothing的替代方案或者收起键盘代替调整窗口。5.4 图标在低版本上显示成方框vector兼容与vectorDrawables.useSupportLibrary现象某个分类的图标在Android 7以上的手机显示正常在Android 6上显示成方框或者干脆不显示。原因项目只用了vector图标但没开兼容模式。Android 5.0以下原生不支持vector drawable需要在编译配置里开开关让support库处理。解决android { defaultConfig { vectorDrawables.useSupportLibrary true } }除了开开关还要检查引用方式。如果是在布局里直接android:srcdrawable/ic_food要在ImageView上加上app:srcCompat这个属性而不是android:src。这是个老生常谈的细节但很多源码里混用了两种写法导致同一个图标有的页面能用、有的页面显示方框。记帐APP分类图标一般都有几十个如果不想逐个排查把图标统一换成Unicode emoji字符也能避开这个问题。5.5 查询卡UI主线程查大表与游标没关的问题现象打开统计页时界面卡顿一两秒或者在不断记帐几个月之后APP启动变慢。原因统计SQL在主线程执行数据量大了以后全表扫描耗时明显。解决统计类查询移到子线程用回调更新UI。new Thread(() - { Cursor cursor db.rawQuery(SELECT ..., null); ListCategoryStat list new ArrayList(); while (cursor.moveToNext()) { // 解析到 list } cursor.close(); runOnUiThread(() - renderPieChart(list)); }).start();注意上面的cursor.close()位置在循环外、回调前顺序不能反否则游标没释放、第二次查询内存泄漏。如果每次记帐都很卡检查有没有在主线程做数据库写入除了beginTransaction把多次写包到一起减少IO次数之外还可以用setWriteAheadLoggingEnabled(true)打开WAL模式读写并行不阻塞。6. 进阶做法把这套源码改造成能长期使用的工具6.1 给数据上后悔药CSV导出与自动备份记帐数据丢了是最痛苦的这一小节讲给源码加一个“后悔药”功能。简单可靠的方案是加一个“导出CSV”按钮把record表全部导出到Downloads目录下这样即使APP被卸载数据还在。File file new File(Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOWNLOADS), account_backup.csv); FileOutputStream fos new FileOutputStream(file); OutputStreamWriter osw new OutputStreamWriter(fos, StandardCharsets.UTF_8); osw.write(时间,类型,分类,金额,备注\n); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.CHINA); while (cursor.moveToNext()) { osw.write(sdf.format(new Date(cursor.getLong(0))) ,); osw.write(cursor.getString(2) ,); osw.write(cursor.getInt(1) 0 ? 支出, : 收入,); osw.write(cursor.getDouble(3) ,); osw.write(cursor.getString(4) \n); } osw.close();这段代码覆盖了三个关键点导出时加了UTF-8 BOM头否则Excel打开中文会乱码日期格式化成可读字符串方便人看而不是机器看类型转成了中文。如果想要更强的“后悔药”建议每周自动向应用私有目录归档一份数据库文件归档用文件复制而不是导出CSV因为恢复时直接替换数据库文件比解析CSV快得多、也不会丢字段。6.2 后续改造的优先顺序从Room迁移到远程同步前的最后一站如果你打算长期维护这个APP我建议改造顺序是先把分类表驱动改完再加CSV导出然后考虑把SQLiteOpenHelper换成Room。不要一上来就上Room因为源码现有的SQL语句和查询逻辑已经能跑强行迁移会引入大量ORM概念反而增加出错面。等现有功能稳定了再按Room的Entity、Dao、Database三层重新组织迁移时保留数据库版本号和迁移脚本用户数据不会丢。最后讲一个我自己的教训记帐源码改到最后总是会忍不住加“美观的动画”和“复杂的手势操作”结果是把核心的记帐路径搞复杂了自己都不想打开。我现在的习惯是每加一个功能前先问一句“这个功能会让我多记一笔账吗”——答案是否。把这句话分享给你希望帮到你。本文还有配套的精品资源点击获取