☰
Android记账APP源码实战:SQLite到Room迁移与真机调试指南
2026/10/10 4:47:03 网站建设 项目流程

简介:这是一份基于Android Studio开发的个人记账工具APP完整源码,面向Android初学者与进阶开发者,聚焦安卓基础开发能力训练与实用功能实现。项目覆盖收支记录、日/月汇总统计、历史账单查询、收支图表分析(柱状图+百分比)、关键词搜索等核心记账场景,界面简洁流畅,适合作为课程设计、毕业设计或自学实战项目。资源包共164个文件,含35个Java业务逻辑与数据库操作类、38个XML布局与资源定义文件、77个PNG图标与UI素材,辅以Gradle构建配置及必要脚本(如gradlew.bat),总大小仅346KB,轻量易导入。已有2779人学习下载,代码结构清晰,包含DBManager数据库封装、MPAndroidChart图表集成、自定义软键盘与对话框、Fragment滑动页面切换等典型实践模块,可直接运行并深入理解Android四大组件、SQLite增删改查及UI定制全流程。

1. 为什么一个“个人记账APP源码”在2024年仍值得从Android Studio里亲手跑通一遍?

不是所有带“源码”二字的项目都配得上你点开.gradle文件夹的那一下双击。这个标题里的“Android Studio开发的个记账工具APP源码”,本质是一份可调试、可修改、可离线运行的轻量级财务行为建模样本——它不追求接入银行API或生成税务报表,而是用最朴素的SQLite+RecyclerView+Material Design组件,把“今天午饭花了28块”这件事,从用户点击、数据落盘、列表刷新到夜间模式切换,全链路暴露在你眼皮底下。
我见过太多新手卡在“下载了源码却编译不过”的第一关:Gradle版本错配、AndroidX迁移残留、targetSdkVersion与模拟器API等级打架……也见过老手直接跳过这类项目,觉得“太简单”,结果在自己写的复杂记账App里,为一个日期选择器的时区bug调了三天。这恰恰说明:记账不是功能堆砌,而是状态流、数据一致性、本地持久化边界和用户心理预期的精密咬合。如果你正想快速验证一个想法(比如“能不能用Room替代SQLiteOpenHelper?”“手势删除账单时如何加Undo Snackbar?”),或者需要一份干净、无商业SDK污染、注释密度适中的Android基础工程模板——这份源码就是你该打开的第一页。它不教你怎么发版上架,但会告诉你,当用户按下“保存”按钮后,那一毫秒里,CPU、内存、磁盘到底发生了什么。


2. 从解压到真机运行:四步走通标准Android Studio导入流程

拿到源码压缩包后,别急着点开app/src/main/java。先做三件事:确认压缩包结构是否含build.gradle(Project级)、settings.gradle、app/子目录;检查是否有.gitignore或local.properties残留;用文本编辑器快速扫一眼gradle/wrapper/gradle-wrapper.properties里的distributionUrl。这些动作花不了30秒,却能避开80%的“Sync失败”。

2.1 环境准备:Android Studio版本与Gradle的黄金匹配表

这不是玄学,是Google官方文档白纸黑字写死的兼容规则。本项目源码若未声明特殊版本,按2024年主流实践,默认采用Android Studio Giraffe(2022.3.1) + Gradle 8.0 + AGP 8.0.2组合。为什么不是最新版?因为AGP 8.1+强制要求JDK 17,而大量旧记账源码的build.gradle里还写着sourceCompatibility JavaVersion.VERSION_1_8——硬升会导致lambda表达式不支持等编译错误。

提示:不要在AS里点“Upgrade Gradle”自动更新!手动改gradle-wrapper.properties更可控:
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
同时在Project级build.gradle(或build.gradle.kts)中指定AGP:
id 'com.android.application' version '8.0.2' apply false

2.2 导入操作:拒绝“Open”,必须用“Import Project”

这是新手翻车第一高发区。直接双击build.gradle或用File → Open,AS会以“Gradle项目”模式加载,但可能忽略local.properties路径配置,导致ANDROID_HOME未识别。正确姿势是:
File → New → Import Project → 选中解压后的根目录 → 勾选“Use customizable gradle wrapper” → Finish

导入过程中,AS会自动下载Gradle 8.0-bin并解析依赖。若卡在“Resolving Dependencies”,检查是否因国内网络导致google()仓库超时——此时需在Project级build.gradle的repositories块中,将google()置于mavenCentral()之前(顺序影响缓存命中率),并确认buildscript和plugins两处repositories均做了同样调整。

2.3 编译前必检:三个关键配置文件的手动校验

文件路径必查项错误现象修复命令
app/build.gradlecompileSdk是否≥33,targetSdk是否≤34安装时报INSTALL_FAILED_VERIFICATION_FAILUREcompileSdk 34
targetSdk 34
app/src/main/AndroidManifest.xml<application>内是否含android:usesCleartextTraffic="true"Android 9+真机无法访问HTTP接口(如有网络记账同步)添加android:usesCleartextTraffic="true"(仅调试期)
local.properties是否含sdk.dir=/path/to/your/android/sdkSync时报Failed to find target with hash string 'android-34'手动创建该文件,写入SDK路径(Windows用反斜杠,Mac/Linux用正斜杠)

2.4 首次运行:选对设备比写代码更重要

模拟器不是万能解药。很多记账APP依赖存储权限或传感器(如摇一摇记账),而默认Pixel模拟器可能禁用SD卡挂载。强烈建议首次运行直连真机:

  1. 开启手机USB调试(设置→关于手机→连点7次版本号)
  2. 在AS的Device Selector中选中设备(显示为SM-G998U或MI 13等真实型号)
  3. 点击Run按钮(绿色三角)
  4. 若弹出“App not installed”,检查手机是否允许“未知来源应用”安装(设置→安全→安装未知应用→选中你的电脑名)

成功启动后,你会看到一个简洁的首页:顶部Toolbar、中间FloatingActionButton、下方RecyclerView账单列表。此时,立刻长按FAB按钮——这是绝大多数记账源码的“添加账单”入口,也是验证数据持久化的第一道关卡。


3. 核心功能拆解:从SQLiteOpenHelper到Room数据库迁移实操

记账APP的灵魂不在UI动画,而在“钱去哪儿了”这个问题的答案是否经得起断电考验。原始源码大概率使用SQLiteOpenHelper封装CRUD,但2024年新项目应优先采用Room——它用注解生成SQL,把DAO层错误提前到编译期。下面教你如何在不重写业务逻辑的前提下,完成平滑迁移。

3.1 数据模型分析:识别Entity、DAO与Database三要素

先定位源码中的数据库类。常见命名有DBHelper.java、AccountDBHelper.java或MySQLiteHelper.java。打开它,找到onCreate()方法内的建表SQL,例如:

CREATE TABLE account ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL, type TEXT NOT NULL, -- 'income' or 'expense' category TEXT, note TEXT, date TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

这就是Room的@Entity基础。新建data/entity/Account.kt:

@Entity(tableName = "account") data class Account( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "amount") val amount: Double, @ColumnInfo(name = "type") val type: String, // income/expense @ColumnInfo(name = "category") val category: String? = null, @ColumnInfo(name = "note") val note: String? = null, @ColumnInfo(name = "date") val date: String, // ISO 8601 format: "2024-05-20" @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis() )

注意:@ColumnInfo显式声明列名,避免Room默认驼峰转下划线(如createdAt→created_at)导致与旧表不匹配;createdAt用Long存毫秒时间戳,比String存"2024-05-20 14:30"更易排序和计算。

3.2 DAO接口定义:把SQL语句翻译成Kotlin函数

在data/dao/AccountDao.kt中创建接口:

@Dao interface AccountDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(account: Account): Long @Query("SELECT * FROM account WHERE date BETWEEN :start AND :end ORDER BY created_at DESC") suspend fun getAccountsByDateRange(start: String, end: String): List<Account> @Query("SELECT SUM(amount) FROM account WHERE type = 'income' AND date LIKE :month || '%'") suspend fun getIncomeOfMonth(month: String): Double? @Query("DELETE FROM account WHERE id = :id") suspend fun deleteById(id: Long) @Update suspend fun update(account: Account) }

关键点:@Query中BETWEEN用于日期范围查询(如“本月所有账单”),LIKE :month || '%'实现模糊匹配(month="2024-05"→"2024-05%");suspend标记让函数可在协程中调用,避免阻塞主线程。

3.3 Database构建:连接Entity与DAO的桥梁

新建data/database/AppDatabase.kt:

@Database( entities = [Account::class], version = 2, // 从1升级到2,触发onUpgrade exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao companion object { @Volatile private var INSTANCE: AppDatabase? = null fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: buildDatabase(context).also { INSTANCE = it } } } private fun buildDatabase(context: Context): AppDatabase { return Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "account_database" ) .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) // 首次创建时可预置测试数据 val db = INSTANCE?.accountDao() db?.insert(Account(amount = 100.0, type = "income", date = "2024-05-20")) } }) .fallbackToDestructiveMigration() // 开发期快捷方案,上线前需改用Migration .build() } } }

fallbackToDestructiveMigration()是开发期的后悔药:当version升级时,自动删库重建,避免手写Migration。但上线前必须替换为addMigrations(MIGRATION_1_2),否则用户升级APP会丢失所有数据。

3.4 依赖注入:在Activity中获取Database实例

在MainActivity.kt的onCreate()中:

private lateinit var database: AppDatabase private lateinit var accountDao: AccountDao override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) database = AppDatabase.getDatabase(this) accountDao = database.accountDao() // 示例:插入一条测试账单 lifecycleScope.launch { val id = accountDao.insert(Account( amount = 28.5, type = "expense", category = "Food", note = "Lunch at canteen", date = "2024-05-20" )) Log.d("DB", "Inserted account id: $id") } }

lifecycleScope确保协程随Activity生命周期自动取消,防止内存泄漏;Log.d输出ID可验证插入成功(非0值即成功)。


4. 避坑指南:记账APP开发中5个血泪经验换来的高频问题排查

这些坑,我都在凌晨三点的Logcat里见过它们的真实面目。不是理论推演,是真机上反复重装、抓包、断点后总结的生存法则。

4.1 现象:RecyclerView列表空白,Logcat无报错,但accountDao.getAll()返回空List

原因:数据库表名或列名大小写不一致。SQLite在Linux/Android上区分大小写,而Room默认将Kotlin属性名createdAt转为created_at,但旧SQLiteOpenHelper建表时可能用了created_at或createdat。
解决:用adb shell进入设备,执行adb shell run-as your.package.name,然后sqlite3 databases/account_database,输入.schema查看实际表结构,对比Room@Entity和@ColumnInfo是否完全匹配。强制统一用小写下划线命名。

4.2 现象:添加账单后,重启APP数据消失

原因:Room.databaseBuilder()未传入context.applicationContext,而是传了this(Activity实例)。当Activity销毁重建,Database实例被GC,新实例连接的是空数据库。
解决:严格使用context.applicationContext,并在Application类中初始化Database(推荐),或像3.4节那样在BaseActivity中统一获取。

4.3 现象:日期筛选失效,getAccountsByDateRange("2024-05-01", "2024-05-31")返回空

原因:数据库中date字段存的是"2024/05/20"或"20-05-2024"格式,而SQLBETWEEN要求ISO 8601标准("2024-05-20")。字符串比较时"2024/05/20" > "2024-05-31"为真,导致范围错乱。
解决:统一日期格式。在插入前用SimpleDateFormat("yyyy-MM-dd").format(Date())标准化;或在@TypeConverter中处理(见4.5)。

4.4 现象:夜间模式切换后,FloatingActionButton颜色不变,或Toolbar文字消失

原因:主题未继承Theme.Material3.DayNight,或themes.xml中<item name="colorOnSurface">?attr/colorOnSurface</item>未正确引用。Material 3组件依赖动态色值,硬编码#FFFFFF会失效。
解决:检查app/src/main/res/values/themes.xml,确保<style name="Theme.YourApp" parent="Theme.Material3.DayNight">;在colors.xml中定义<color name="md_theme_light_onSurface">#1C1B1F</color>等,而非直接写死。

4.5 现象:@TypeConverter不生效,Room报Cannot figure out how to save this field into database

原因:@TypeConverter类未被@TypeConverters注解引用,或转换器未声明为static(Java)或companion object(Kotlin)。
解决:新建data/converters/DateConverter.kt:

class DateConverter { @TypeConverter fun fromTimestamp(value: Long?): Date? { return value?.let { Date(it) } } @TypeConverter fun dateToTimestamp(date: Date?): Long? { return date?.time } }

然后在@Database注解中添加:
@TypeConverters(DateConverter::class)
并在Account实体中将createdAt: Long改为createdAt: Date,Room会自动调用转换器。


5. 进阶验证:用Instrumented Test跑通“添加-查询-删除”完整链路

写单元测试不是为了应付KPI,而是给你的记账逻辑上一道保险。当某天你重构了分类统计算法,一个./gradlew connectedAndroidTest就能告诉你:用户昨天记的3笔外卖,今天是否还能被正确归入“Food”类别并计入月度支出。

5.1 测试环境搭建:启用AndroidJUnitRunner与Test Rules

在app/build.gradle的android块中确认:

testOptions { unitTests.returnDefaultValues = true androidTestOptions { execution 'ANDROIDX_TEST_ORCHESTRATOR' } }

在dependencies中添加:

androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.room:room-testing:2.6.1'

room-testing提供TestingService,可创建内存数据库避免污染真实数据;ANDROIDX_TEST_ORCHESTRATOR确保每个测试用例在独立进程运行,互不干扰。

5.2 编写首个Instrumented Test:验证账单增删查原子性

在app/src/androidTest/java/com/yourpackage/AccountDaoTest.kt中:

@RunWith(AndroidJUnit4::class) class AccountDaoTest { private lateinit var database: AppDatabase private lateinit var dao: AccountDao @Before fun createDb() { // 使用内存数据库,测试结束自动销毁 database = Room.inMemoryDatabaseBuilder( InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java ).build() dao = database.accountDao() } @After fun closeDb() { database.close() } @Test fun insertAndQueryAccount() = runBlocking { // 插入一笔收入 val income = Account( amount = 5000.0, type = "income", category = "Salary", date = "2024-05-20" ) val id = dao.insert(income) // 查询所有收入 val incomes = dao.getAccountsByType("income") // 断言:只有一条,且金额匹配 assertThat(incomes).hasSize(1) assertThat(incomes[0].amount).isEqualTo(5000.0) assertThat(incomes[0].id).isEqualTo(id) } @Test fun deleteAccount() = runBlocking { val expense = Account(amount = 15.0, type = "expense", date = "2024-05-21") val id = dao.insert(expense) // 删除 dao.deleteById(id) // 再查,应为空 val remaining = dao.getAccountsByDateRange("2024-05-21", "2024-05-21") assertThat(remaining).isEmpty() } }

runBlocking是测试中安全的协程启动方式;assertThat来自Truth库(已由junit依赖引入),比assertEquals报错信息更清晰;Room.inMemoryDatabaseBuilder确保测试数据库不落地,速度极快。

5.3 运行测试与结果解读:从Logcat到Coverage Report

在Android Studio中,右键AccountDaoTest.kt→Run 'AccountDaoTest'。测试通过时,底部Run窗口显示绿色对勾;失败时,会精准定位到哪一行assertThat不满足,并打印实际值与期望值。例如:

Expected: is <5000.0> but: was <0.0>

这说明insert()返回的id为0,而Room规定autoGenerate=true时id应为正数——立刻回头检查@PrimaryKey是否漏写了autoGenerate = true。

进阶技巧:在build.gradle中启用代码覆盖率:

android { testOptions { unitTests.all { jacoco { includeNoLocationClasses = true } } } }

执行./gradlew createDebugCoverageReport后,报告位于app/build/reports/coverage/debug/index.html,可直观看到AccountDao.kt中哪些分支未被测试覆盖(比如getIncomeOfMonth()的空结果分支)。

5.4 真机压力测试:模拟用户连续记账100次的稳定性

自动化测试不能替代真实场景。写一个简单的Monkey测试脚本,在真机上狂点FAB 100次:

# 先编译测试APK ./gradlew assembleAndroidTest # 推送并运行(替换your.package.name) adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk adb shell am instrument -w -r \ -e debug false \ -e class 'com.yourpackage.AccountDaoTest#insertAndQueryAccount' \ com.yourpackage.test/androidx.test.runner.AndroidJUnitRunner

但更贴近用户的,是用adb shell input tap模拟手动操作。记录FAB在屏幕上的坐标(用adb shell getevent -l或第三方App获取),然后循环点击:

for i in {1..100}; do adb shell input tap 900 1800 # 假设FAB在(900,1800) adb shell input keyevent 66 # 按回车确认 sleep 0.5 done

运行后,用adb shell dumpsys meminfo your.package.name | grep "TOTAL"观察内存是否持续增长——如果从80MB涨到200MB,说明存在Cursor未关闭或Bitmap未回收的泄漏。

我坚持给每个记账功能写至少一个Instrumented Test,不是因为流程要求,而是某次上线后,用户反馈“删除账单后,月度统计数字没变”。我翻了3小时代码,最后发现是getIncomeOfMonth()里忘了加WHERE type='income'。那个测试用例本该在PR阶段就红掉。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询