ARTICLE DETAIL

资讯详情

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

Android记账APP源码解析:从Gradle配置到Room数据库与打包签名

Android记账APP源码解析:从Gradle配置到Room数据库与打包签名 简介这份资源是一套基于 Android Studio 开发的个人记账工具 APP 完整源码面向具备 Android 基础、希望通过实战项目巩固知识点的开发者与在校学生可解决日常收支记录、账单统计与消费分析等需求。项目功能覆盖当天收支记录、当日与当月收支总额计算、历史账单查看、每月收支图表与百分比分析以及按记录类型搜索全部收支情况界面简约、滑动流畅。压缩包共 164 个文件约 346KB以 35 个 java 源文件、38 个 xml 布局与资源文件、77 个 png 图片资源为主另含 gradle 构建脚本、properties 配置及数据库管理类等结构完整可直接导入运行。技术点涵盖基础 View 控件与布局绘制、Activity 跳转传值、Fragment 滑动切换、自定义对话框与软键盘、列表与网格适配器、SQLite 数据库增删改查、drawable 样式定义以及 MPAndroidChart 柱状图绘制。目前已有 2779 人学习下载适合作为课程设计、毕业设计或 Android 入门进阶的参考范例。1. 从一份 Android 个人记账 APP 源码说起它到底能跑出什么很多人第一次拿到「基于 Android Studio 开发的个人记账工具 APP 源码」时脑子里想的是一键运行、界面漂亮、功能齐全。现实往往相反Gradle 同步卡在下载依赖、SDK 版本对不上、数据库表结构看不懂、打包出来的 APK 装到手机上闪退。这份源码真正的价值不在于「拿来就能用」而在于它是一套完整的、可拆解的 Android 本地数据管理范例——记账这件事天然包含增删改查、分类统计、图表展示、本地持久化几乎覆盖了 Android 入门到进阶的全部核心链路。这篇文章面向三类人想通过一个真实项目自学 Android 开发的新手、需要快速交付一个记账类工具 APP 的独立开发者、以及手里已经有一份记账源码但跑不起来想搞懂每一层的人。我会从工程结构、数据库设计、核心页面实现、打包签名一路讲到避坑和进阶改造所有步骤都基于 Android Studio 的标准工作流不依赖任何特定仓库地址或版本号你手里的源码是什么版本就按什么版本来。2. 把源码跑起来Android Studio 工程结构与 Gradle 配置拆解2.1 拿到源码后先看哪几个文件一份标准的 Android 记账 APP 源码根目录下通常有这些关键文件settings.gradle、build.gradle项目级、app/build.gradle模块级、gradle/wrapper/gradle-wrapper.properties、AndroidManifest.xml。很多人一上来就点 Run结果 Gradle 同步失败其实问题就藏在这几个文件里。先看gradle-wrapper.properties它决定了用哪个版本的 Gradle 来构建distributionBaseGRADLE_USER_HOME distributionPathwrapper/dists distributionUrlhttps\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBaseGRADLE_USER_HOME zipStorePathwrapper/distsdistributionUrl里的版本号决定了构建工具链。如果你本地没有这个版本Android Studio 会尝试下载网络不好就会卡住。常见做法是把它改成你本地已经缓存过的版本或者用 Android Studio 自带的 Gradle 版本。注意不要盲目改成最新版Gradle 大版本之间 API 差异很大源码里的构建脚本可能不兼容。再看app/build.gradle这里定义了 compileSdk、minSdk、targetSdk 和所有依赖android { namespace com.example.accountbook compileSdk 34 defaultConfig { applicationId com.example.accountbook minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.11.0 implementation androidx.recyclerview:recyclerview:1.3.2 implementation androidx.room:room-runtime:2.6.1 annotationProcessor androidx.room:room-compiler:2.6.1 }compileSdk和targetSdk要和你本地安装的 SDK 平台匹配。如果你只装了 SDK 33而源码写的是 34同步就会报错。解决办法是在 Android Studio 的 SDK Manager 里勾选对应版本或者把这两个值改成你已有的版本。minSdk决定最低支持的 Android 版本记账类 APP 一般设 24Android 7.0就够了覆盖绝大多数设备。dependencies块里是第三方库。Room 是 Google 官方的 ORM 框架记账 APP 几乎都用它来做本地数据库。如果源码用的是 Room你需要确认annotationProcessor或kapt配置正确否则编译时不会生成数据库操作类。2.2 Gradle 同步失败的三种典型情况和处理第一种依赖下载超时。现象是 Gradle 同步进度条卡在某个依赖上不动最后报Could not resolve或Connection timed out。原因通常是默认仓库地址在国内访问不稳定。解决办法是在项目级build.gradle或settings.gradle里把仓库地址换成国内镜像// settings.gradle dependencyResolutionManagement { repositories { maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/central } maven { url https://maven.aliyun.com/repository/gradle-plugin } google() mavenCentral() } }把阿里云镜像放在前面google()和mavenCentral()作为兜底。这样大部分依赖都能快速拉到。第二种Gradle 和 AGP 版本不匹配。现象是报The project is using an incompatible version of the Android Gradle plugin。原因是gradle-wrapper.properties里的 Gradle 版本和build.gradle里声明的 AGP 版本有对应关系。常见做法是查一下 AGP 版本对应的最低 Gradle 版本把两边对齐。比如 AGP 8.0 需要 Gradle 8.0 以上AGP 7.4 需要 Gradle 7.5 以上。第三种JDK 版本不对。现象是报Unsupported class file major version或Android Gradle plugin requires Java 17。AGP 8.x 要求 JDK 17AGP 7.x 要求 JDK 11。在 Android Studio 的Settings → Build, Execution, Deployment → Build Tools → Gradle里可以指定 Gradle JDK 版本。如果你系统里装了多个 JDK这里一定要选对。2.3 数据库层Room 实体和 DAO 的对应关系记账 APP 的核心是数据。一份典型的源码里数据库层通常包含三个部分Entity实体类对应一张表、DAO数据访问对象定义增删改查方法、Database数据库持有类。先看 EntityEntity(tableName transactions) public class Transaction { PrimaryKey(autoGenerate true) public int id; ColumnInfo(name amount) public double amount; ColumnInfo(name category) public String category; ColumnInfo(name type) public int type; // 0 支出1 收入 ColumnInfo(name note) public String note; ColumnInfo(name date) public long date; // 时间戳 }Entity注解声明这是一张表tableName指定表名。PrimaryKey(autoGenerate true)表示主键自增。ColumnInfo用来指定列名不写的话默认用字段名。type字段用 int 区分收入和支出这是记账 APP 最常见的设计。再看 DAODao public interface TransactionDao { Insert void insert(Transaction transaction); Delete void delete(Transaction transaction); Update void update(Transaction transaction); Query(SELECT * FROM transactions ORDER BY date DESC) ListTransaction getAllTransactions(); Query(SELECT SUM(amount) FROM transactions WHERE type :type AND date BETWEEN :start AND :end) double getTotalByTypeAndDate(int type, long start, long end); }Query里写的是标准 SQLRoom 会在编译时校验语法。getTotalByTypeAndDate这个方法就是用来做月度统计的——传入类型收入或支出和时间范围返回总金额。记账 APP 的统计页面就是靠这类聚合查询驱动的。Database 类把上面两者串起来Database(entities {Transaction.class}, version 1, exportSchema false) public abstract class AppDatabase extends RoomDatabase { private static AppDatabase instance; public abstract TransactionDao transactionDao(); public static synchronized AppDatabase getInstance(Context context) { if (instance null) { instance Room.databaseBuilder( context.getApplicationContext(), AppDatabase.class, account_book_db ).build(); } return instance; } }version 1是数据库版本号。如果你后续改了表结构这个版本号必须加一并且提供 Migration否则用户升级 APP 后数据库会崩溃。这是记账类 APP 迭代时最容易翻车的地方之一。3. 核心页面实现从记账录入到分类统计的完整链路3.1 记账录入页输入校验和日期选择记账录入页是整个 APP 使用频率最高的页面。它需要处理金额输入、分类选择、日期选择、备注输入以及保存时的数据校验。布局文件通常用 ConstraintLayout 或 LinearLayout 嵌套。金额输入框用EditText配合inputTypenumberDecimal限制只能输入数字和小数点EditText android:idid/et_amount android:layout_widthmatch_parent android:layout_heightwrap_content android:inputTypenumberDecimal android:hint请输入金额 android:maxLength10 /inputTypenumberDecimal会弹出带小数点的数字键盘maxLength10防止用户输入过长导致数值溢出。注意numberDecimal在部分输入法上仍然允许输入多个小数点所以代码里还要做二次校验。保存逻辑private void saveTransaction() { String amountStr etAmount.getText().toString().trim(); if (TextUtils.isEmpty(amountStr)) { Toast.makeText(this, 请输入金额, Toast.LENGTH_SHORT).show(); return; } double amount; try { amount Double.parseDouble(amountStr); } catch (NumberFormatException e) { Toast.makeText(this, 金额格式不正确, Toast.LENGTH_SHORT).show(); return; } if (amount 0) { Toast.makeText(this, 金额必须大于零, Toast.LENGTH_SHORT).show(); return; } Transaction transaction new Transaction(); transaction.amount amount; transaction.category selectedCategory; transaction.type selectedType; transaction.note etNote.getText().toString().trim(); transaction.date selectedDate; new Thread(() - { AppDatabase.getInstance(this).transactionDao().insert(transaction); runOnUiThread(() - { Toast.makeText(this, 保存成功, Toast.LENGTH_SHORT).show(); finish(); }); }).start(); }这里有几个关键点。第一Double.parseDouble可能抛异常必须用 try-catch 包住。第二金额必须大于零否则统计逻辑会出错。第三数据库操作不能在主线程执行否则会报NetworkOnMainThreadException类似的Room cannot access database on the main thread错误。常见做法是开子线程或者用 Room 自带的AsyncTask、ExecutorService更现代的方式是配合 LiveData 和 ViewModel。日期选择用DatePickerDialogprivate void showDatePicker() { Calendar calendar Calendar.getInstance(); new DatePickerDialog(this, (view, year, month, dayOfMonth) - { Calendar selected Calendar.getInstance(); selected.set(year, month, dayOfMonth); selectedDate selected.getTimeInMillis(); tvDate.setText(year - (month 1) - dayOfMonth); }, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show(); }selectedDate存的是时间戳存进数据库后统计查询用BETWEEN就能按月筛选。3.2 账单列表页RecyclerView 适配器与数据刷新账单列表用 RecyclerView 展示适配器是核心。一个典型的 Adapter 长这样public class TransactionAdapter extends RecyclerView.AdapterTransactionAdapter.ViewHolder { private ListTransaction data new ArrayList(); private OnItemLongClickListener longClickListener; public void setData(ListTransaction newData) { this.data newData; notifyDataSetChanged(); } NonNull Override public ViewHolder onCreateViewHolder(NonNull ViewGroup parent, int viewType) { View view LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_transaction, parent, false); return new ViewHolder(view); } Override public void onBindViewHolder(NonNull ViewHolder holder, int position) { Transaction item data.get(position); holder.tvCategory.setText(item.category); holder.tvNote.setText(item.note); holder.tvAmount.setText((item.type 0 ? - : ) item.amount); holder.tvAmount.setTextColor(item.type 0 ? 0xFFE53935 : 0xFF43A047); holder.tvDate.setText(formatDate(item.date)); holder.itemView.setOnLongClickListener(v - { if (longClickListener ! null) { longClickListener.onLongClick(item); } return true; }); } Override public int getItemCount() { return data.size(); } static class ViewHolder extends RecyclerView.ViewHolder { TextView tvCategory, tvNote, tvAmount, tvDate; ViewHolder(View itemView) { super(itemView); tvCategory itemView.findViewById(R.id.tv_category); tvNote itemView.findViewById(R.id.tv_note); tvAmount itemView.findViewById(R.id.tv_amount); tvDate itemView.findViewById(R.id.tv_date); } } }notifyDataSetChanged()是最简单的刷新方式但它会重绘所有 item。数据量大时会有性能问题更好的做法是用DiffUtil。不过对于记账 APP 这种数据量通常只有几百条的场景notifyDataSetChanged()完全够用。长按删除的逻辑adapter.setOnItemLongClickListener(item - { new AlertDialog.Builder(this) .setTitle(删除记录) .setMessage(确定要删除这条记录吗) .setPositiveButton(删除, (dialog, which) - { new Thread(() - { AppDatabase.getInstance(this).transactionDao().delete(item); ListTransaction updated AppDatabase.getInstance(this) .transactionDao().getAllTransactions(); runOnUiThread(() - adapter.setData(updated)); }).start(); }) .setNegativeButton(取消, null) .show(); });删除后重新查询全量数据再刷新这是最稳妥的做法。不要直接在内存列表里删掉再notifyItemRemoved因为数据库和内存可能不同步尤其是多页面共享数据时。3.3 统计页按月聚合查询和饼图绘制统计页是记账 APP 的差异化功能。核心逻辑是按月查询收入和支出总额以及各分类的占比。月度汇总查询public void loadMonthlySummary(int year, int month) { Calendar start Calendar.getInstance(); start.set(year, month - 1, 1, 0, 0, 0); start.set(Calendar.MILLISECOND, 0); Calendar end Calendar.getInstance(); end.set(year, month - 1, start.getActualMaximum(Calendar.DAY_OF_MONTH), 23, 59, 59); end.set(Calendar.MILLISECOND, 999); long startTime start.getTimeInMillis(); long endTime end.getTimeInMillis(); new Thread(() - { double income AppDatabase.getInstance(this) .transactionDao().getTotalByTypeAndDate(1, startTime, endTime); double expense AppDatabase.getInstance(this) .transactionDao().getTotalByTypeAndDate(0, startTime, endTime); runOnUiThread(() - { tvIncome.setText(收入 income); tvExpense.setText(支出 expense); tvBalance.setText(结余 (income - expense)); }); }).start(); }start.getActualMaximum(Calendar.DAY_OF_MONTH)会自动处理不同月份的天数包括闰年二月。这比硬编码 30 或 31 可靠得多。分类占比用饼图展示。如果源码里用了 MPAndroidChart添加依赖后在布局里放一个PieChart然后构造PieEntry列表ListPieEntry entries new ArrayList(); for (CategorySum item : categorySums) { entries.add(new PieEntry((float) item.total, item.category)); } PieDataSet dataSet new PieDataSet(entries, ); dataSet.setColors(ColorTemplate.MATERIAL_COLORS); PieData pieData new PieData(dataSet); pieChart.setData(pieData); pieChart.invalidate();PieEntry的第一个参数是数值第二个是标签。invalidate()触发重绘。如果源码没用第三方图表库而是自己用 Canvas 画那就需要看懂onDraw里的角度计算逻辑——通常是把每个分类的金额除以总额得到百分比再乘以 360 度。4. 打包与签名从 Android Studio 到可安装 APK 的最后一公里4.1 生成签名密钥和配置签名信息调试版 APK 只能装在开了 USB 调试的设备上要发给别人用必须打正式包。第一步是生成签名密钥keytool -genkeypair -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-key-alias-keystore指定密钥库文件名-validity 10000表示有效期约 27 年-alias是密钥别名。执行后会提示输入密码和证书信息密码一定要记住后续每次打包都要用。然后在app/build.gradle里配置签名android { signingConfigs { release { storeFile file(../my-release-key.jks) storePassword 你的密码 keyAlias my-key-alias keyPassword 你的密码 } } buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro signingConfig signingConfigs.release } } }minifyEnabled true开启代码混淆和压缩shrinkResources true移除未使用的资源。这两个选项能显著减小 APK 体积但也可能把反射用到的类混淆掉导致运行时崩溃。如果打包后闪退先把这两个改成 false 排查。4.2 混淆规则记账 APP 最容易踩的坑Room 和 Gson 这类库依赖反射混淆时必须保留相关类。proguard-rules.pro里至少要有-keep class androidx.room.** { *; } -keep class com.example.accountbook.entity.** { *; } -keepclassmembers class * { androidx.room.* methods; }第一行保留 Room 框架类第二行保留你自己的实体类把包名换成实际包名第三行保留所有被 Room 注解标记的方法。如果用了 Gson 做 JSON 序列化还要加-keep class com.google.gson.** { *; }和实体类的保留规则。打包命令./gradlew assembleReleaseWindows 下用gradlew.bat assembleRelease。生成的 APK 在app/build/outputs/apk/release/目录下。安装到手机前确认AndroidManifest.xml里的versionCode和versionName已经更新否则覆盖安装会失败。5. 避坑与排查记账 APP 源码改造中的五个血泪教训5.1 数据库版本升级后 APP 闪退现象改了 Entity 加了一个字段重新运行 APP 直接崩溃报IllegalStateException: Room cannot verify the data integrity。原因Room 在version不变的情况下检测到表结构变了认为数据库被篡改。或者version加了但没提供 Migration。解决开发阶段可以临时用fallbackToDestructiveMigration()但这会清空用户数据只能用于调试。正式做法是写 Migrationstatic final Migration MIGRATION_1_2 new Migration(1, 2) { Override public void migrate(SupportSQLiteDatabase database) { database.execSQL(ALTER TABLE transactions ADD COLUMN account TEXT); } };然后在databaseBuilder里.addMigrations(MIGRATION_1_2)。记住version加一Migration 必须从旧版本号到新版本号。5.2 金额用 double 导致统计出现 0.01 误差现象几笔支出加起来统计结果和手动算的差几分钱。原因double 是浮点数二进制无法精确表示某些十进制小数累加时误差会放大。解决金额用BigDecimal存储和计算或者用 int 存「分」为单位。如果源码已经用了 double至少在统计查询后用BigDecimal做一次四舍五入BigDecimal bd new BigDecimal(total).setScale(2, RoundingMode.HALF_UP); double fixedTotal bd.doubleValue();5.3 RecyclerView 数据刷新后位置错乱现象删除一条记录后列表滚动位置跳回顶部或者显示的数据和实际不符。原因notifyDataSetChanged()会重绘全部 itemRecyclerView 丢失了之前的滚动状态。另外如果在子线程直接修改了data列表再调notifyDataSetChanged()可能触发IllegalStateException。解决数据变更后重新查询数据库在主线程调用setData并notifyDataSetChanged()。如果在意滚动位置可以在刷新前记录layoutManager.findFirstVisibleItemPosition()刷新后scrollToPosition回去。5.4 打包 release 后网络请求或数据库操作失效现象debug 版一切正常release 版打开就闪退或功能异常。原因混淆把反射用到的类名改了或者minifyEnabled移除了某些动态加载的类。解决先关掉minifyEnabled和shrinkResources打一个包测试确认是混淆问题后逐步添加 keep 规则。重点检查 Room 实体、Gson 模型类、以及任何用Class.forName或反射调用的地方。5.5 日期选择器在 Android 12 以上显示异常现象DatePickerDialog在 Android 12 及以上设备上样式错乱或者点击后不回调。原因Android 12 引入了新的 Material You 主题旧版DatePickerDialog的样式可能与新主题冲突。解决在styles.xml里为 DatePickerDialog 指定主题或者改用 Material 的MaterialDatePickerMaterialDatePickerLong picker MaterialDatePicker.Builder.datePicker() .setTitleText(选择日期) .build(); picker.addOnPositiveButtonClickListener(selection - { selectedDate selection; tvDate.setText(formatDate(selection)); }); picker.show(getSupportFragmentManager(), date_picker);MaterialDatePicker返回的是 UTC 时间戳如果直接用来做本地日期显示需要做时区转换。6. 进阶改造让这份记账源码真正变成你自己的工具跑通源码只是起点。真正让一份记账 APP 源码产生价值的是把它改成符合自己使用习惯的工具。我一般会从三个方向动手数据导出、预算提醒、以及多设备同步的替代方案。数据导出最实用。记账数据存在 SQLite 里用户想导出来做进一步分析可以加一个导出 CSV 的功能public void exportToCsv(ListTransaction transactions, File outputFile) throws IOException { try (FileWriter writer new FileWriter(outputFile)) { writer.write(日期,类型,分类,金额,备注\n); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd, Locale.getDefault()); for (Transaction t : transactions) { writer.write(sdf.format(new Date(t.date)) ,); writer.write((t.type 0 ? 支出 : 收入) ,); writer.write(t.category ,); writer.write(t.amount ,); writer.write((t.note null ? : t.note.replace(,, )) \n); } } }注意备注里的逗号要替换成中文逗号否则 CSV 列会错位。导出路径用getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)这样不需要申请存储权限Android 10 以上也能正常写入。预算提醒是另一个高频需求。在 Database 里加一张budget表记录每月总预算和分类预算。每次插入支出后查询当月支出总额超过预算的 80% 就发通知。通知用NotificationManager和NotificationChannelAndroid 8.0 以上必须创建渠道if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { NotificationChannel channel new NotificationChannel( budget_alert, 预算提醒, NotificationManager.IMPORTANCE_HIGH); notificationManager.createNotificationChannel(channel); }关于多设备同步我不建议在记账 APP 里直接做账号体系和云端同步——那会把项目复杂度拉高一个数量级。更务实的替代方案是导出加密的 CSV 文件通过用户自己的文件同步工具比如系统自带的云盘备份来搬运。这样既不用维护服务端也不用处理用户认证和隐私合规问题。最后说一个我自己的习惯每次改完数据库结构先在AppDatabase里把version加一写好 Migration然后手动在旧版本 APP 上插入几条测试数据再覆盖安装新版本确认数据没丢、没崩。这个流程看起来笨但能挡住 90% 的数据库升级事故。记账 APP 的用户可以容忍界面丑但绝对不能容忍账目丢失。希望帮到你。本文还有配套的精品资源点击获取
返回列表