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

资讯详情

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

Android个人记账本源码解析:SQLite存储与MVC架构实战

Android个人记账本源码解析:SQLite存储与MVC架构实战 简介这是一份基于Android平台开发的个人记账本毕业设计项目源码面向正在做移动开发课题或入门Android应用设计的读者。项目以Eclipse为开发环境采用SQLite存储账目数据严格遵循MVC分层模式并用StarUML绘制用例图与包图便于从整体上理解记账功能的模块划分与数据流转。资源包共399个文件约10.45MB包含41个Java源文件、54个XML界面与配置、118个class编译文件、159张png图片以及jar依赖库和可直接安装的apk其中源码、数据库、配套报告均齐全。已有352人学习下载。通过学习这套项目可掌握记账类应用常见的新增、修改、删除、分类统计与报表展示等功能的实现思路同时参考Eclipse下的项目导入方法、SQLite建表与Dao层读写、Fragment与对话框交互等关键代码对完成毕业设计或课程项目有直观的借鉴价值。1. 一个老项目正好用来理解Android记账App的完整骨架拿到这份基于Android的个人记账本源码时先看文件列表会发现它不是当下Android Studio里的标准工程形态而是Eclipse时代的产物resources.ap_、jarlist.cache、一堆散落的class文件源码包由AddDialogActivity、UpdateRecordActivity、BaobiaoFragment、TixingFragment、SelectShouzhiDao这些类构成。这反而是一个很好的学习样本因为它把记账类App最核心的链路——记账输入、流水存储、分类查询、月度报表、提醒事项——用最朴素的MVC加SQLite方式完整实现了没有引入Room、GreenDAO这类ORM框架所有数据库操作都能直接看到SQL语句。适合两类读者一是拿它做课设、毕设改造的在校生二是想快速搭一个本地记账原型的Android开发者。笔者在迁移和重打包过程中踩过一些Eclipse走向Android Studio的坑这篇文章会一并讲透。2. 从class文件反推包结构Eclipse工程里的MVC分层与模块职责拿到一个没有源码包结构的class列表第一反应是先把每个类的职责对号入座。记账本项目的核心类都在src目录下从类名可以反推出清晰的包结构这也是MVC模式在Android端最朴素的一种落地方式。2.1 按文件名还原MVC三层结构先看下面这个从class列表归纳出来的对应关系理解每个文件为什么被设计出来比看懂代码更重要。类名推测包名MVC角色职责AddDialogActivitycom.example..activityController弹窗式记账输入处理收入/支出表单提交UpdateRecordActivityactivityController修改已有记录通过Intent携带id定位要更新的行DecDialogActivityactivityController可能是删除或统计子功能与Dialog样式Activity一致性处理BaobiaoFragmentfragmentView Controller报表页按月汇总收支TixingFragmentfragmentView Controller提醒页基于AlarmManager或简单日期比对SelectShouzhiDaodaoModel封装对收支表的查询、按条件筛选UpdateShouzhiDaodaoModel封装对收支表的更新、删除、插入这个设计的Model层不是裸露的ContentValues教条式操作而是统一收敛在Dao类里。Activity层只负责拿用户输入、调Dao方法、刷新ListView/Adapter不直接拼SQL。你在UpdateRecordActivity里看到的逻辑大概率是取出Intent传入的id用UpdateShouzhiDao按id更新金额、分类、日期字段。SelectShouzhiDao则承担了查询某月流水、查询全部、按类型过滤几种典型操作。// 请把类名替换成自己工程里的实际类名 public class SelectShouzhiDao { private SQLiteDatabase db; public SelectShouzhiDao(Context context) { // 单例或直接new均可 db new MyDBHelper(context).getWritableDatabase(); } public Cursor getAllRecords() { // 注意参数表名、列名要和SQLiteOpenHelper建表语句完全一致 return db.query(t_shouzhi, null, null, null, null, null, record_date DESC); } public Cursor getRecordsByMonth(String month) { // 参数month格式约定为2024-05用LIKE做前缀匹配 return db.query(t_shouzhi, new String[]{_id, type, amount, category, record_date}, record_date LIKE ?, new String[]{month %}, null, null, record_date ASC); } public void close() { if (db ! null db.isOpen()) { db.close(); } } }这段代码的逻辑说明query方法七参数是Android原生SQLite最常用的查询形态——表名、要返回的列名、where条件、条件占位值、group by、having、order by。这里record_date LIKE ?用2024-05%匹配某月所有记录不传null数组时不能省略。设计上close()显式释放连接但如果在Application里使用单例则建议由SQLiteOpenHelper统一维护连接生命周期不要在页面销毁时关库。参数说明record_date的存储格式建议统一为yyyy-MM-dd字符串type用0/1区分支出收入而不是汉字后续在BaobiaoFragment里做sum聚合时会更方便。2.2 Eclipse导出资源文件的机制与resources.ap_resources.ap_这个文件很多没碰过Eclipse ADT的开发者会疑惑其实它是Eclipse编译中间产物存的是打包后的资源文件索引包含布局、字符串、图片等。换成Android Studio之后它被merged_res目录和res.ap_取代。编译时它先于dex生成最终aapt会把它的内容合并进APK。看到这个文件基本可以判断该项目是Eclipse ADT 22左右时代的产物。迁移到Android Studio时不需要保留它但要注意旧的res目录中layout、drawable命名若包含特殊字符AAPT2会直接报错需要统一转成小写加下划线风格。谈到MVC在Android的落地点这里有一个常见误判——Activity既承担View又承担Controller。更准确的理解是XML布局才是ViewActivity拿着findViewById去更新控件同时监听按钮事件执行逻辑它扮演的是Controller和View之间的中间协调者。本项目里BaobiaoFragment在onActivityCreated里初始化ListView和适配器又通过SelectShouzhiDao取数据这其实就是Controller的行为而item_detail.xml只是被动展示的View。理解这一层再去看AddDialogActivity里setContentView后的初始化逻辑整个代码脉络就很清晰了。中间件层面这个项目没有引入第三方库ListView用的适配器多数是SimpleAdapter或继承BaseAdapter的自定义适配器。查看BaobiaoFragment源码时注意SimpleAdapter的from和to参数——from对应Cursor里列的别名to对应R.id控件数组两者长度必须一致且顺序对应这是新手最容易写歪的地方。3. SQLite建模与Dao层增删改查把收支流水完整落库SQLite是Android内置的关系型数据库就算现在有Room直接基于SQLiteOpenHelper写CRUD仍然是理解数据库原理的必修课。本项目的建表语句、字段设计、Dao层封装是从零复现一个记账App的最佳切入点。3.1 建表语句设计与SQLiteOpenHelper实现一张核心流水表通常包含以下字段_id主键自增、type收支类型、amount金额、category分类、note备注、record_date日期。日期存储成TEXT类型比时间戳整数更直观而且报表里按月LEFT(date, 7)直接就能分组比strftime转换省事不少。-- 在SQLiteOpenHelper的onCreate里执行 CREATE TABLE t_shouzhi ( _id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER NOT NULL DEFAULT 0, -- 0支出 1收入别用布尔值保留扩展位 amount REAL NOT NULL, -- 单位是元若需更高精度可改存分(integer) category TEXT NOT NULL, -- 餐饮交通工资等 note TEXT, -- 备注允许为空 record_date TEXT NOT NULL, -- 格式yyyy-MM-dd create_time TEXT DEFAULT (datetime(now, localtime)) );建表的逻辑说明用type加NOT NULL DEFAULT 0比直接写死两个表干净查询时WHERE type 1即可。amount用REAL能支持小数但如果对线上精确度有要求更稳的做法是存整数分。record_date不加默认值强制由上层传入避免时区问题。create_time作为审计字段报表统计用不到但排查这条记录什么时候加的很有效。SQLiteOpenHelper的版本管理和升级策略onUpgrade里先判断oldVersion再执行ALTER TABLE或重建表。常见做法是先备份旧表再重建public class MyDBHelper extends SQLiteOpenHelper { private static final String DB_NAME account.db; private static final int DB_VERSION 1; public MyDBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE t_shouzhi (...)); db.execSQL(CREATE TABLE t_remind (...)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion 2) { db.execSQL(ALTER TABLE t_shouzhi ADD COLUMN settle_flag INTEGER DEFAULT 0); } if (oldVersion 3) { db.execSQL(CREATE INDEX idx_date ON t_shouzhi(record_date)); } } }这个升级策略特别说明一点不要一上来就DROP TABLE重建这会把用户历史数据全部清空。ALTER TABLE加字段、加索引是常规手段。DB_VERSION每次你要改动Schema时手动加1。3.2 Dao层增删改查的完整实现UpdateShouzhiDao这个类名的定位是写操作包含插入、更新、删除。下面给出一套笔者常用的实现结构和原项目思路一致但更完整。public class UpdateShouzhiDao { private SQLiteDatabase db; public UpdateShouzhiDao(Context context) { MyDBHelper helper new MyDBHelper(context); db helper.getWritableDatabase(); } // 插入一条记录返回新行的row ID失败返回-1 public long insertRecord(int type, double amount, String category, String note, String recordDate) { ContentValues values new ContentValues(); values.put(type, type); values.put(amount, amount); values.put(category, category); values.put(note, note); values.put(record_date, recordDate); return db.insert(t_shouzhi, null, values); } // 按id更新注意更新前先确认该行存在 public int updateRecordById(int id, double newAmount, String newCategory, String newDate) { ContentValues values new ContentValues(); values.put(amount, newAmount); values.put(category, newCategory); values.put(record_date, newDate); return db.update(t_shouzhi, values, _id?, new String[]{String.valueOf(id)}); } // 删除记录返回受影响行数 public int deleteRecordById(int id) { return db.delete(t_shouzhi, _id?, new String[]{String.valueOf(id)}); } public void close() { if (db ! null db.isOpen()) { db.close(); } } }这段代码的逻辑说明db.insert的第二个参数nullColumnHack在多数情况下直接传null即可它只在values为空且建表时有NOT NULL字段时才用来指定一个可赋NULL的列名。update和delete的where子句都用了?占位符避免字符串拼接注入风险——虽然单机应用威胁小但这是好习惯。参数说明id要从Intent或Adapter.getItem里取类型是int或longtoString后再放进String[]。注意db.update返回值是受影响行数调用方要判断结果是0时给出记录不存在的提示避免用户以为修改成功。3.3 增删改与Activity层的联动UpdateRecordActivity的典型流程是onCreate里从Intent.getIntExtra(record_id, -1)拿到要编辑的id用SelectShouzhiDao查一次把现有数据显示到EditText用户点保存时调用UpdateShouzhiDao.updateRecordById成功后setResult(RESULT_OK)并finish()。AddDialogActivity则是弹窗输入点确定后调insertRecord然后回到主列表页刷新适配器。主界面(Fragment/Activity) ↓ 点击添加按钮 AddDialogActivityDialog样式 ↓ 校验非空 UpdateShouzhiDao.insertRecord(...) ↓ 返回结果 刷新ListView的Adapter重新查一次SelectShouzhiDao.getAllRecords()这里有一个容易踩的坑数据量小的时候无所谓数据一旦上百条你在onResume里每次全量query再notifyDataSetChanged界面会明显卡顿。更好的做法是只在onActivityResult或setOnItemChangedListener回调里去刷新那一条或增量重新查询。本项目里数据量不大全量查询可接受但笔者建议保留一个游标列改造成reloadFromDB()方法后续接数据库性能优化时只改这一处。4. 报表与提醒BaobiaoFragment按月统计和TixingFragment的周期提醒实现记账App的爽点不在记账本身而在月底看报表和临近还款日的提醒。BaobiaoFragment就是这个项目的输出窗口TixingFragment则是低频但必要的补充模块。这一章把这两个功能的实现路径拆开讲。4.1 BaobiaoFragment的GroupBy聚合查询与结果呈现报表页的核心是用一条带GROUP BY和SUM的SQL把某月所有流水按分类型汇总或按日期汇总。例如展示这个月每一天的花费总额-- 按日汇总某月数据注意WHERE里用record_date做前缀匹配 SELECT substr(record_date, 1, 10) AS day, SUM(CASE WHEN type 0 THEN amount ELSE 0 END) AS total_out, SUM(CASE WHEN type 1 THEN amount ELSE 0 END) AS total_in FROM t_shouzhi WHERE record_date LIKE 2024-05% GROUP BY day ORDER BY day ASC;substr截出yyyy-MM-dd作为分组维度SUM(CASE WHEN...)这个写法比WHERE type 0后单独查两次效率高。放到Java里用SQLiteDatabase.rawQuery执行这条SQL把Cursor循环取出来塞进ListMapString, Object再交给SimpleAdapter。public ListMapString, Object getMonthSummary(String month) { ListMapString, Object list new ArrayList(); String sql SELECT substr(record_date,1,7) AS month, SUM(CASE WHEN type0 THEN amount ELSE 0 END) AS total_out, SUM(CASE WHEN type1 THEN amount ELSE 0 END) AS total_in FROM t_shouzhi WHERE record_date LIKE ? GROUP BY month; Cursor cursor db.rawQuery(sql, new String[]{month %}); while (cursor.moveToNext()) { MapString, Object item new HashMap(); item.put(month, cursor.getString(cursor.getColumnIndex(month))); item.put(out, cursor.getDouble(cursor.getColumnIndex(total_out))); item.put(in, cursor.getDouble(cursor.getColumnIndex(total_in))); list.add(item); } cursor.close(); return list; }逻辑说明getColumnIndex在API 33以前不推荐用字符串索引因为重命名列会崩但这里列名是SQL里用AS起的别名完全可控。rawQuery返回的Cursor必须手动close()否则多次切换页面会导致SQLiteException: database is locked。参数说明month传2024-05拼接上%就能匹配全月数据如果你只想看支出WHERE type0也可以强制下推到SQL里比在Java里做if判断更高效。UI呈现上有两种做法一种是ListView直接罗列每天的收支汇总另一种是转成BaseAdapter展示柱状比例。项目里的BaobiaoFragment更接近前者。如果要做得更好看可以在这个ListMap的基础上用MPAndroidChart画饼图但原项目零依赖建议保持原风格把精力放在SQL和Adapter的对应关系上。4.2 用Bundle与Fragment配合完成页面传参报表页往往需要从主界面接收到要查看哪个月这个参数。由于Fragment不能直接像Activity一样用Intent传值通行做法是newInstance(Bundle)静态工厂方法。public static BaobiaoFragment newInstance(String month) { BaobiaoFragment fragment new BaobiaoFragment(); Bundle args new Bundle(); args.putString(target_month, month); fragment.setArguments(args); return fragment; } Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view inflater.inflate(R.layout.fragment_baobiao, container, false); String targetMonth getArguments() ! null ? getArguments().getString(target_month) : ; // 用targetMonth去调用SelectShouzhiDao.getMonthSummary return view; }这个小节要讲清楚一个关键陷阱getArguments()在Fragment被系统因配置变更重建后会保留但如果你在onCreateView里直接访问getIntent()一定会空指针因为Intent属于Activity不属于Fragment。这里用setArguments隔离了Fragment对宿主Activity的依赖是Fragment组件化的基础写法。4.3 TixingFragment的周期提醒逻辑提醒模块在这个项目里常见实现方式是先查t_remind表获得提醒日期和标题然后使用AlarmManager设置一次性闹钟不要求走系统日历API。public void setOneTimeRemind(Context context, int remindId, long triggerAtMillis) { AlarmManager manager (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); Intent intent new Intent(context, RemindReceiver.class); intent.putExtra(remind_id, remindId); PendingIntent pendingIntent PendingIntent.getBroadcast( context, remindId, intent, PendingIntent.FLAG_UPDATE_CURRENT); // 注意API 19以上set()不再是精确闹钟需要setExactAndAllowWhileIdle() if (Build.VERSION.SDK_INT Build.VERSION_CODES.KITKAT) { manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); } else { manager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); } }这里注意一个行为边界setExactAndAllowWhileIdle在Android 12API 31之后对普通应用有调度限制精确闹钟每分钟只能触发几次。对个人记账本来说提醒周期通常是按天或按周触发频率低不踩精确闹钟配额但如果原项目里有每分钟一个的重复提醒就要检查待办是否被系统静默丢掉了。提醒页面的Receiver收到广播后弹通知或直接更新TixingFragment列表即可。TixingFragment本身还有一个作用维护提醒项的新增、删除。它和记账不同数据表结构简单只有id、title、remind_time三列增删改查的套路和UpdateShouzhiDao一模一样。所以从建模角度可以直接复用MyDBHelper加一个t_remind表不用另写一个DatabaseHelper。5. 从Eclipse到Android Studio迁移步骤、Gradle替换与APK重签名拿到源码后绝大多数人的第一诉求是让它跑起来。由于项目是Eclipse工程直接拖进Android Studio会卡在Gradle配置上这一章给出实操性极强的迁移路径。5.1 迁移项目的正确步骤不要直接Open整个Eclipse工程目录建议新建空白工程再把源码与资源搬进去。具体操作如下新建Android Studio工程包名改成和原项目一致。拷贝src下所有.java到app/src/main/java对应包目录。拷贝res目录到app/src/main/res检查layout文件名是否包含大写或连字符。拷贝AndroidManifest.xml的activity注册列表Eclipse里新增的Activity都要手动注册到application节点。替换build.gradle中的compileSdk老项目可能依赖com.android.support库需要替换成androidx或降级支持库版本。android { compileSdk 33 defaultConfig { applicationId com.example.account minSdk 16 targetSdk 33 versionCode 1 versionName 1.0 } } dependencies { // androidx版本依赖替代原Eclipse里的android-support-v4.jar implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 }逻辑说明compileSdk不强制和targetSdk一致但不要低于targetSdk。原项目如果用的Theme.AppCompat.Light需要把主题从Theme.Material换成Theme.Material.Light或Theme.AppCompat.Light否则Activity启动会闪退并报You need to use a Theme.AppCompat theme。参数说明minSdk建议设为16或更保守的15太高会把老设备排除太低会影响新版本API的员使用——本项目的DatePickerDialog、AlarmManager在API 16以上都兼容。5.2 编译报错的四类常见问题迁移之后大概率会遇到下面几类问题。第一是R类路径错误。Eclipse的R会自动生成在包根部而Android Studio的R类在build里不会和源码中的import com.example.R冲突但有一种情况你会看到报错——老的library工程依赖了R.id换成Gradle后新库的R类路径变了需要全局替换成当前包名的R。这个报错的特征是cannot find symbol variable R解决方法是删除源码里的import com.example.xxx.R让IDE自动识别本包R。第二是ActionBarActivity不存在或getSupportActionBar()空指针。替换成AppCompat后Activity继承AppCompatActivity并确保setContentView之前执行supportRequestWindowFeature(Window.FEATURE_NO_TITLE)或设置无ActionBar主题。第三是CursorAdapter的列名需完全匹配。老的SimpleCursorAdapter构造方法里Cursor里的列要在from数组中写全如果用的别名查询漏了列ListView会直接空指针或显示空。排查方法是Log出来Cursor的getColumnNames()。第四是Fragment的getActivity()在onAttach之前调用。Eclipse时代允许在某些时机拿到宿主Activity但Android X版本下在onAttach回调前访问getActivity()返回null。把所有getActivity()调用挪到onViewCreated或之后。5.3 APK重签名与验证原项目包里自带了一个MyAccountApp.apk这是Eclipse用debug密钥签名的安装包。如果你改了源码想重新出包默认Android Studio会使用~/.android/debug.keystore进行debug签名。给用户分发前建议用独立的签名文件。# 生成签名密钥alias是别名-validity单位是天 keytool -genkey -v -keystore account_release.jks -keyalg RSA \ -keysize 2048 -validity 36500 -alias account # 对未签名APK进行v1签名老设备兼容 jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 \ -keystore account_release.jks app-release-unsigned.apk account # 使用zipalign优化对齐 zipalign -v 4 app-release-unsigned.apk account_align.apk # 用apksigner进行v2签名API 24以上推荐 apksigner sign --ks account_release.jks --out final.apk account_align.apk # 验证签名结果 apksigner verify --verbose final.apk逻辑说明jarsigner和apksigner是两套签名方案v1能兼容4.x老系统v2签名更快且更难被篡改。zipalign必须放在v1签名之后、v2签名之前因为对齐操作会改动文件内容先对齐再签v2就能保证v2校验通过。参数说明-validity 36500表示密钥有效期100年时间太短会要求你重签APK导致已安装应用升级失败-alias就是你要在这个keystore里创建的密钥名apksigner里也要严格一致。如果你的APK没做分版本适配直接v1v2双签名最稳。最后一点技巧验证新装的APK是否能读取老版本数据库最好用adb shell run-as 包名 ls databases确认数据库文件确实创建出来再用adb exec-out cat导出查看版本号。如果遇到SQLiteDatabaseLockedException通常是你上一个Activity没执行close()排查方法是在Dao的close()里打印当前线程的Log栈。本文还有配套的精品资源点击获取
返回列表