尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android Studio记账本开发实战:Room数据库与MPAndroidChart图表

Android Studio记账本开发实战:Room数据库与MPAndroidChart图表 简介这是一套基于 Android Studio 开发的安卓记账本系统完整项目资源面向 Android 初学者、课程设计学生以及希望积累实战经验的开发者帮助解决个人收支记录与财务管理问题。压缩包共 70 个文件约 10.68MB以 22 个 xml 布局、18 个 png 图片、16 个 java 源码为主另含 gradle 构建脚本、properties 配置、apk 安装包、mp4 演示视频与 docx 说明文档覆盖从界面设计到数据存储的完整工程结构。系统实现登录注册、收支记账、账单编辑与按日期类别筛选查看、个人信息管理等功能涉及 SQLite 数据存储、网络请求验证与 UI 交互等知识点。已有 493 人学习读者可直接安装 apk 体验也可导入 Android Studio 调试源码对照演示视频与运行文档快速上手适合作为课程设计参考或二次开发基础。1. 从一张 Excel 记账表到 Android Studio 记账本为什么值得自己写一遍很多人记账的起点是一张 Excel 表日期、分类、金额、备注四列月底拉个 SUM 公式。用久了问题就来了——手机上记一笔要打开电脑分类改一次公式全乱想按周看趋势还得手动透视。于是「基于 Android Studio 开发的安卓记账本系统」成了安卓开发入门里被搜得最多的实战题目之一它同时踩中了三件事本地数据库读写、列表与表单交互、统计图表展示。这篇不是把某个现成仓库念一遍而是把我在 Android Studio 里从零搭一个能用的记账本时真正会做的选型、会写的代码、会踩的坑讲清楚。适合两类人刚学完 Activity 和 RecyclerView、想找个完整项目练手的安卓新手以及想给自己或小团队做一个离线记账工具、不想依赖任何云服务的开发者。全程离线、数据存本地、编译出 APK 装到真机上就能用这是这个方向最实在的价值。2. 技术选型Room、RecyclerView 与 MPAndroidChart 怎么搭动手之前先把技术栈定下来否则写到一半换库返工量比重新写还大。记账本系统的核心链路只有三条写入一笔账、按条件查询、把结果画出来。围绕这三条链路选型比盲目追新框架靠谱得多。2.1 数据层为什么选 Room 而不是裸 SQLite裸 SQLite 用SQLiteOpenHelper也能跑但建表、升级、游标关闭全靠手写字段一多就是血泪经验。Room 是 Google 官方在 SQLite 上的抽象层编译期校验 SQL字段改名时编译器直接报错而不是等到运行时崩在某个Cursor上。对记账本这种表结构会反复调整今天加个「账户」字段明天加个「是否报销」的项目编译期校验省下的调试时间非常可观。Room 的三个核心注解要记牢Entity定义表Dao定义增删改查Database把两者绑在一起。查询默认不能放在主线程否则直接抛异常这是它逼你写对线程模型的地方。2.2 列表与图表RecyclerView 负责流水MPAndroidChart 负责趋势流水列表用RecyclerView这是现在安卓列表的标准答案别再用ListView。它把 ViewHolder 复用逻辑抽出来滚动几千条记录也不卡。统计页用 MPAndroidChart饼图看分类占比、折线图看月度趋势接入成本低一行addEntry就能出图。选型上有个容易忽略的点图表库会显著增大 APK 体积如果只想要一个简单的分类占比用自定义 View 画个环形图也够不必为了一个饼图引入整个图表库。我一般会先问自己「这个图会不会被频繁改需求」会就上库不会就手画。2.3 依赖配置与版本对齐在app/build.gradle里加依赖注意 Room 的编译器要用annotationProcessorJava或kaptKotlin两者不能混。dependencies { // Room 运行时与编译器版本必须一致 implementation androidx.room:room-runtime:2.6.1 annotationProcessor androidx.room:room-compiler:2.6.1 // 列表与生命周期 implementation androidx.recyclerview:recyclerview:1.3.2 implementation androidx.lifecycle:lifecycle-viewmodel:2.7.0 // 图表库从 jitpack 拉取 implementation com.github.PhilJay:MPAndroidChart:v3.1.0 }逻辑说明room-runtime提供运行时 APIroom-compiler在编译期生成_Impl实现类两者版本不一致会报「Schema export directory is not provided」或找不到生成类。参数说明recyclerview版本跟随 AndroidX 主线即可lifecycle-viewmodel用来在配置变更比如旋转屏幕时保住查询结果避免每次旋转都重新查库。图表库走 jitpack需要在settings.gradle的repositories里加上maven { url https://jitpack.io }否则同步阶段就失败。提示Room 首次编译若报 schema 导出警告在Database上加exportSchema false即可个人项目不需要 schema 历史。3. 从建表到写入把一笔账存进本地数据库数据层是整个记账本的地基地基歪了后面列表和图表全是玄学 bug。这一章把实体、DAO、数据库实例三件套写完整并给出一个能直接跑的写入流程。3.1 定义 Entity字段类型和索引怎么定一笔账最少要有主键、金额、分类、备注、时间戳。金额用double还是long记账场景建议用long存「分」避免浮点误差累积——这是财务类应用的通用做法0.1 0.2 ! 0.3在钱上是不能接受的。Entity(tableName records, indices {Index(value createdAt)}) // 按时间查询频繁建索引 public class Record { PrimaryKey(autoGenerate true) public long id; ColumnInfo(name amount_cent) public long amountCent; // 金额以「分」为单位存储 ColumnInfo(name category) public String category; // 分类名如「餐饮」 ColumnInfo(name note) public String note; ColumnInfo(name created_at) public long createdAt; // 毫秒时间戳 }逻辑说明autoGenerate true让主键自增插入时不用手动赋值。indices给created_at建索引因为流水页几乎总是按时间倒序查没索引时数据量上千条后列表加载会明显变慢。参数说明amountCent存分展示时除以 100 并格式化为两位小数createdAt用System.currentTimeMillis()写入排序和分组都靠它。3.2 DAO查询、插入与聚合统计DAO 用接口加注解Room 在编译期生成实现。下面覆盖记账本最常用的四类操作。Dao public interface RecordDao { Insert long insert(Record record); // 返回新行 id Query(SELECT * FROM records ORDER BY created_at DESC) ListRecord queryAll(); Query(SELECT * FROM records WHERE created_at BETWEEN :start AND :end ORDER BY created_at DESC) ListRecord queryByRange(long start, long end); // 按分类聚合直接返回分类名与总金额分 Query(SELECT category, SUM(amount_cent) AS total FROM records WHERE created_at BETWEEN :start AND :end GROUP BY category) ListCategorySum sumByCategory(long start, long end); }逻辑说明Insert返回long是新记录的主键方便插入后立刻跳转到详情。queryByRange用时间区间过滤月度统计就传当月首尾毫秒。sumByCategory返回一个自定义结果类CategorySum字段名要和 SQL 里的别名对上否则 Room 编译报错。参数说明start、end是毫秒时间戳注意区间是闭区间算月末时别把下月第一毫秒算进来。CategorySum定义很简单public class CategorySum { public String category; public long total; // 单位分 }3.3 数据库实例与线程模型数据库实例必须全局唯一用单例否则多个实例同时写会锁冲突。Database(entities {Record.class}, version 1, exportSchema false) public abstract class AppDatabase extends RoomDatabase { public abstract RecordDao recordDao(); private static volatile AppDatabase INSTANCE; public static AppDatabase get(Context ctx) { if (INSTANCE null) { synchronized (AppDatabase.class) { if (INSTANCE null) { INSTANCE Room.databaseBuilder( ctx.getApplicationContext(), AppDatabase.class, ledger.db).build(); } } } return INSTANCE; } }逻辑说明双重检查锁保证多线程下只创建一个实例volatile防止指令重排。参数说明数据库文件名ledger.db存在应用私有目录卸载即清除符合离线记账的隐私预期。写入操作不能放主线程常见做法是丢进ExecutorService或ViewModel的协程里ExecutorService io Executors.newSingleThreadExecutor(); io.execute(() - { Record r new Record(); r.amountCent 2350; // 23.50 元 r.category 餐饮; r.note 午饭; r.createdAt System.currentTimeMillis(); AppDatabase.get(context).recordDao().insert(r); });逻辑说明单线程池保证写入顺序避免并发插入时主键乱序。参数说明amountCent 2350对应 23.50 元展示层再除以 100。写完记得在界面线程刷新列表否则用户会以为没存上。4. 界面与交互流水列表、记账表单和统计页怎么串起来数据能存了接下来是用户真正看到的部分。这一章把三个核心界面拆开讲重点在适配器写法和表单校验这两处是新手翻车最集中的地方。4.1 RecyclerView 适配器ViewHolder 与点击回调适配器负责把ListRecord渲染成一行行流水。核心是 ViewHolder 复用和点击回调分离。public class RecordAdapter extends RecyclerView.AdapterRecordAdapter.VH { private ListRecord data new ArrayList(); private OnItemClick onItemClick; public interface OnItemClick { void onClick(Record r); } public void setData(ListRecord list) { this.data list; notifyDataSetChanged(); // 数据量小时可接受 } public void setOnItemClick(OnItemClick l) { this.onItemClick l; } static class VH extends RecyclerView.ViewHolder { TextView tvCategory, tvAmount, tvNote; VH(View v) { super(v); tvCategory v.findViewById(R.id.tv_category); tvAmount v.findViewById(R.id.tv_amount); tvNote v.findViewById(R.id.tv_note); } } NonNull Override public VH onCreateViewHolder(NonNull ViewGroup parent, int viewType) { View v LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_record, parent, false); return new VH(v); } Override public void onBindViewHolder(NonNull VH h, int pos) { Record r data.get(pos); h.tvCategory.setText(r.category); h.tvAmount.setText(String.format(Locale.CHINA, -%.2f, r.amountCent / 100.0)); h.tvNote.setText(r.note); h.itemView.setOnClickListener(v - { if (onItemClick ! null) onItemClick.onClick(r); }); } Override public int getItemCount() { return data.size(); } }逻辑说明onCreateViewHolder只负责创建视图onBindViewHolder负责填数据两者分离是 RecyclerView 性能的关键。点击回调通过接口暴露给 Activity适配器不直接持有 Activity 引用避免内存泄漏。参数说明String.format用Locale.CHINA固定小数点格式避免某些地区用逗号做小数点导致显示错乱amountCent / 100.0转成元再格式化。4.2 记账表单输入校验与金额转换表单页最容易出的问题是用户输入非法金额直接崩或者空分类存进去。校验要在写入前做。EditText etAmount findViewById(R.id.et_amount); EditText etNote findViewById(R.id.et_note); String category 餐饮; // 由分类选择器给出 String amountStr etAmount.getText().toString().trim(); if (amountStr.isEmpty()) { Toast.makeText(this, 请输入金额, Toast.LENGTH_SHORT).show(); return; } double yuan; try { yuan Double.parseDouble(amountStr); } catch (NumberFormatException e) { Toast.makeText(this, 金额格式不对, Toast.LENGTH_SHORT).show(); return; } if (yuan 0 || yuan 1_000_000) { Toast.makeText(this, 金额超出合理范围, Toast.LENGTH_SHORT).show(); return; } long cent Math.round(yuan * 100); // 四舍五入到分逻辑说明先判空再try-catch解析最后做范围校验三层拦截把非法输入挡在数据库外。参数说明Math.round(yuan * 100)而不是(long)(yuan * 100)后者会截断23.999会变成2399而不是2400一分钱的差异在记账里会被用户投诉。上限设 100 万是防手滑多打几个零。4.3 统计页用 MPAndroidChart 画分类饼图统计页从 DAO 拿到ListCategorySum转成饼图数据。ListCategorySum sums AppDatabase.get(this).recordDao() .sumByCategory(monthStart, monthEnd); ListPieEntry entries new ArrayList(); for (CategorySum s : sums) { entries.add(new PieEntry(s.total / 100f, s.category)); } PieDataSet dataSet new PieDataSet(entries, 本月支出); dataSet.setColors(ColorTemplate.MATERIAL_COLORS); PieData pieData new PieData(dataSet); pieData.setValueFormatter(new PercentFormatter()); // 显示百分比 PieChart chart findViewById(R.id.pie_chart); chart.setData(pieData); chart.invalidate(); // 刷新逻辑说明PieEntry第一个参数是数值第二个是标签。PercentFormatter把数值显示成百分比比显示绝对金额更直观。参数说明s.total / 100f转成元注意用f保证是浮点除法invalidate()触发重绘数据变了不调用它图表不会更新这是新手最常见的「数据明明查到了图却没变」的原因。注意sumByCategory是数据库查询必须放在子线程统计页同样要用 Executor 或协程包一层否则主线程查询直接抛异常。5. 避坑与排查记账本开发里最容易翻车的五件事这一章全是踩过的坑每条按「现象 → 原因 → 解决」写遇到对应症状直接对号入座。5.1 列表数据更新了但界面没变现象插入一笔账后回到列表页数据没出现退出重进才有。原因notifyDataSetChanged没调用或者适配器持有的data引用和查询返回的不是同一个对象。解决查询后先adapter.setData(newList)再刷新确保适配器内部引用被替换如果用了 DiffUtil注意areItemsTheSame要比较主键id而不是对象地址。5.2 Room 查询报 Cannot access database on the main thread现象一打开统计页就崩日志提示主线程访问数据库。原因Room 默认禁止主线程查询防止卡 UI。解决所有 DAO 调用包进ExecutorService或ViewModel的viewModelScope查询结果通过回调或LiveData回传主线程更新 UI。别用allowMainThreadQueries()图省事那是给测试用的线上会卡顿。5.3 金额显示成 23.0 而不是 23.00现象列表里金额小数位不固定有的两位有的没有。原因double直接toString会去掉末尾零。解决统一用String.format(Locale.CHINA, %.2f, value)格式化并固定 Locale避免系统语言切换后小数点变逗号。5.4 真机测试时数据库文件找不到现象想用 Android Studio 的 Device File Explorer 看ledger.db翻遍目录找不到。原因数据库在应用私有目录/data/data/包名/databases/非 root 真机看不到。解决用模拟器API 30 以下或已 root 的真机查看更省事的做法是写一个导出功能把数据库复制到外部存储再拉出来。这也是为什么调试阶段我一般优先用模拟器。5.5 编译出的 APK 装到手机上闪退现象Android Studio 里跑得好好的Build APK装到真机一打开就闪退。原因多半是签名或 minSdk 不匹配或者用了真机不支持的 API。解决检查build.gradle里minSdk是否低于手机系统版本用adb logcat抓崩溃栈定位到具体类release 包必须签名未签名的 APK 部分系统直接拒绝安装。抓日志这一步别省闪退原因九成写在 logcat 里。6. 进阶技巧让记账本从能用到好用基础版本跑通后真正拉开体验差距的是几个细节。第一个是数据库迁移version从 1 升到 2 时如果直接改 Entity 不写Migration用户升级 App 后旧数据会全部丢失。正确做法是提供Migration(1, 2)并在里面写ALTER TABLE或者开发期用fallbackToDestructiveMigration()但上线前必须去掉。第二个是查询性能。流水页数据过万后queryAll一次全查会明显卡顿改成LIMIT加分页配合RecyclerView的滚动监听加载下一页。索引已经在建表时加了这一步主要是控制单次返回量。第三个是导出。用户最怕数据锁死在 App 里加一个「导出 CSV」按钮把records表写成文件存到Downloads目录用MediaStoreAPI 写入安卓 10 以上不需要存储权限。这一步做完记账本才算真正可用。进阶项触发时机关键 API数据库迁移修改 Entity 字段Migration/addMigrations分页查询流水超过 1000 条LIMIT 滚动监听CSV 导出用户需要备份MediaStoreContentResolver我自己的习惯是每加一个字段就先想好迁移脚本别等上线了才补那时候用户数据已经出问题了没有后悔药。这个项目从建表到出 APK认真做大概两三天但它把安卓本地存储、列表、图表三条主线全串了一遍作为练手项目的性价比很高。希望帮到你。本文还有配套的精品资源点击获取
返回列表