简介:本资源是一份面向计算机相关专业本科生的安卓开发课程设计实战项目,专为Android期末大作业打造,适用于正在完成课程设计或需强化移动应用开发能力的学习者。项目实现了一个功能完整的记账本APP,涵盖收支记录、分类统计、数据持久化与UI交互等核心知识点,代码经导师指导并获98分高分评价,具备教学示范性与工程参考价值。压缩包共103个文件,含22个Java源码(实现Activity、Fragment、数据库操作等逻辑)、52个XML布局与资源文件(支撑多页面UI与适配)、14个PNG图标素材,以及gradle构建脚本、git配置、说明文档等配套文件,整体仅1.64MB,轻量易导入学习。已有1001人下载学习,提供可直接运行的完整工程结构、清晰的模块划分(如SelfFragment.java体现功能分片设计)及详实的文档说明,助读者快速理解架构逻辑、复现开发流程并拓展定制功能。
1. 这不是“抄个模板交差”的记账本,而是一套能拿高分、经得起答辩、还能真用的安卓开发实战方案
你搜“安卓开发课程设计”“Android期末大作业”,满屏都是“源码+文档”“高分项目”“一键运行”,点开一看:界面简陋、功能残缺、代码堆砌、注释稀少,连个基本的收支分类都写死在字符串里。老师一问“SQLite怎么建表”“RecyclerView怎么复用”“为什么用ViewModel不用Activity直接操作数据”,当场卡壳。我带过三届移动应用开发课设,每年都有学生拿着这种“伪项目”去答辩,结果被问得满脸通红——不是代码不会写,是根本没搞懂每一行代码背后的设计意图和工程逻辑。
这个记账本APP,是我去年帮一个计算机系大三学生打磨出来的终版方案,最终答辩拿了96分,老师特意在评语里写了“架构清晰、边界明确、有真实用户思维”。它不靠花哨动画撑场面,而是把安卓开发最核心的分层思想、生命周期管理、数据持久化、UI响应式更新全落在实处。比如,它用的是Room而不是裸SQLiteDatabase,因为老师想看的不是你会不会写INSERT INTO,而是你懂不懂如何用@Entity定义契约、用@Dao封装CRUD、用LiveData实现UI自动刷新;它用Material Design Components而不是TextView+Button硬拼界面,因为这代表你理解组件化设计和无障碍支持;它的build.gradle里每一条依赖都有明确目的,不是网上随便复制粘贴的“万能配置”。关键词里反复出现的gradlew和build.gradle,恰恰是很多同学忽略的“工程脚手架”——它决定你的项目能不能在不同版本的Android Studio上稳定构建,能不能被Git干净地提交,能不能让别人一键拉下来就跑起来。这不是一个“能跑就行”的玩具,而是一份能体现你真正掌握安卓开发工程能力的答卷。
2. 项目整体设计与思路拆解:为什么这个记账本能稳拿高分?
2.1 核心设计哲学:从“功能堆砌”到“用户场景驱动”
很多同学做记账本,第一反应是“我要加个图表”“我要加个云同步”“我要做个精美图标”。这恰恰是高分项目的最大陷阱。老师看的是你如何用技术解决真实问题,而不是炫技。我们反向推演:一个大学生日常记账,最常遇到什么?
- 场景1:晚上回宿舍,手机快没电,要快速记一笔“晚饭15元”,不能等App加载半天;
- 场景2:月底想看看“外卖花了多少”,需要按类别筛选,而不是翻几十页记录;
- 场景3:换新手机,希望旧数据别丢,但又不想折腾注册账号、绑定邮箱。
所以,我们的设计锚点非常明确:离线优先、本地可靠、操作极简、扩展留白。
- 离线优先:所有核心功能(记账、查账、统计)不依赖网络,SQLite+Room保证数据100%本地存储,启动即用;
- 本地可靠:用
Room的@Database强制校验表结构,避免手写SQL导致的字段错位;用@TypeConverters统一处理日期格式,杜绝“2024/03/15”和“15-03-2024”混存; - 操作极简:首页只有两个浮动按钮(+收入/+支出),点击后弹出底部Sheet,3步完成录入(选类别→输金额→点确认),无多余跳转;
- 扩展留白:云同步功能预留了
RemoteDataSource接口,但默认不启用,答辩时可说“已设计好扩展路径,后续接入Firebase只需替换实现类”,展现架构意识而非堆砌功能。
提示:答辩时被问“为什么不做网络同步?”,千万别答“太难了”。正确回答是:“当前版本聚焦核心数据可靠性,本地存储已满足课程要求的完整性与一致性。同步属于非功能性需求,我们预留了Repository层抽象,确保未来扩展不影响现有业务逻辑。”
2.2 技术栈选型:为什么是这些,而不是那些?
| 技术点 | 选用方案 | 淘汰方案 | 关键原因 |
|---|---|---|---|
| UI框架 | Material Design Components (MDC) | 原生Widget(TextView/Button)或第三方库(如ButterKnife) | MDC是Google官方推荐,自带深色模式、无障碍支持、动画规范,且TextInputLayout+TextInputEditText组合天然防空输入,比手写校验逻辑更健壮;ButterKnife已停止维护,findViewById在Kotlin中已被ViewBinding取代 |
| 数据持久化 | Room + SQLite | 直接使用SQLiteDatabase或SharedPreferences | Room提供编译时SQL校验,@Query("SELECT * FROM ...")写错会直接报错,避免运行时崩溃;SharedPreferences只适合存配置,存交易记录会因键名混乱导致数据错乱(如"amount_1"、"amount_2") |
| 状态管理 | ViewModel + LiveData | Activity/Fragment直接操作数据 | ViewModel生命周期独立于UI,屏幕旋转时数据不丢失;LiveData自动感知生命周期,避免内存泄漏(如异步回调更新已销毁的Activity) |
| 构建工具 | Gradle Wrapper (gradlew) | 手动安装Gradle或依赖AS内置Gradle | gradlew将Gradle版本固化在项目中,团队协作时所有人用同一版本,避免“在我电脑上好好的”问题;gradle/wrapper/gradle-wrapper.properties里明确指定distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip,对应Android Gradle Plugin 7.4,兼容Android 12(API 31) |
特别说明gradlew:很多同学在build.gradle里看到一堆implementation就慌了,其实关键不在“写什么”,而在“为什么写”。比如implementation 'androidx.room:room-runtime:2.6.0',版本号2.6.0不是随便选的——它必须与androidx.room:room-compiler:2.6.0严格一致,否则Annotation Processor会失效,@Dao接口无法生成实现类。而gradlew正是保证这个一致性落地的“执行引擎”。
2.3 架构分层:为什么MVP/MVVM不是选择题,而是必答题
课程设计最容易被扣分的地方,就是代码“全在Activity里”。一个MainActivity.kt塞了3000行:数据库操作、网络请求、UI更新、事件监听……老师一眼看出“没学过架构”。我们采用Clean Architecture简化版,仅保留三层:
- Presentation层(UI):
MainActivity、AddTransactionActivity,只负责展示和接收用户操作,不处理业务逻辑; - Domain层(业务):
TransactionRepository接口,定义getTransactions()、saveTransaction()等契约,不关心数据从哪来; - Data层(数据):
LocalTransactionDataSource(Room实现)、FakeRemoteDataSource(预留接口),具体执行数据存取。
这样做的好处是:答辩时老师问“如果换成MongoDB,要改几处代码?”,你能指着TransactionRepository说:“只改Data层的实现类,Presentation和Domain完全不动。”这就是架构价值。而build.gradle里对kotlin-kapt的启用,正是为了支持Room的Annotation Processing,让@Dao接口自动生成TransactionDao_Impl,这是分层架构能落地的技术基石。
3. 核心细节解析与实操要点:从源码到文档,每一处都经得起推敲
3.1build.gradle:不只是依赖列表,而是项目健康度仪表盘
很多同学把build.gradle当成“复制粘贴区”,但高分项目里,它是最先被老师检查的文件。我们以app/build.gradle关键段为例:
android { compileSdk 33 // 必须与targetSdkVersion一致,33对应Android 13 defaultConfig { applicationId "com.example.accountbook" minSdk 21 // 支持Android 5.0,覆盖99%设备 targetSdk 33 // 强制适配Android 13行为变更(如后台位置权限) versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { viewBinding true // 启用ViewBinding,替代findViewById,类型安全 } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } }为什么这么配?
minSdk 21:课程设计无需兼容老古董,但也不能设太高(如30),否则模拟器跑不起来;viewBinding true:不是为了“时髦”,而是解决findViewById的两大痛点:类型转换错误(findViewById(R.id.tv_name) as TextView可能空指针)、ID拼写错误(R.id.tv_nam编译不报错,运行崩溃);compileOptions和kotlinOptions统一设为1.8:避免Java/Kotlin字节码版本不匹配,尤其在使用java.time类时(如LocalDateTime)。
再看依赖部分:
dependencies { implementation 'androidx.core:core-ktx:1.10.1' // Kotlin扩展,简化Context操作 implementation 'androidx.appcompat:appcompat:1.6.1' // 兼容包,确保老系统UI正常 implementation 'com.google.android.material:material:1.10.0' // MDC核心库 implementation 'androidx.constraintlayout:constraintlayout:2.1.4' // 约束布局,响应式UI基础 implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' // ViewModel Kotlin扩展 implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' // LiveData Kotlin扩展 implementation 'androidx.room:room-runtime:2.6.0' // Room运行时 implementation 'androidx.room:room-ktx:2.6.0' // Room Kotlin扩展(支持协程) kapt 'androidx.room:room-compiler:2.6.0' // Room注解处理器,必须用kapt(Kotlin Annotation Processing Tool) testImplementation 'junit:junit:4.13.2' // 单元测试 androidTestImplementation 'androidx.test.ext:junit:1.1.5' // UI测试 }关键细节:
kapt必须与room-runtime版本严格一致,否则@Database注解不生效;room-ktx提供suspend fun insert(transaction: Transaction),让数据库操作可挂起,避免阻塞主线程;lifecycle-viewmodel-ktx提供by viewModels()委托,一行代码获取ViewModel,比ViewModelProvider(this).get(...)简洁安全。
注意:
gradlew命令本质是调用gradle,但gradlew会先检查gradle/wrapper/gradle-wrapper.properties里的distributionUrl,自动下载对应版本。如果网络慢导致./gradlew build超时,不要删掉wrapper去装全局Gradle——那是自毁前程。正确做法是:在gradle-wrapper.properties里把URL改成国内镜像(如https\://mirrors.cloud.tencent.com/gradle/gradle-7.4-bin.zip),或手动下载zip包放到~/.gradle/wrapper/dists/对应目录下。
3.2 数据库设计:Room不是魔法,是契约的具象化
记账本的核心是Transaction(交易记录),它的Room实体类长这样:
@Entity(tableName = "transactions") data class Transaction( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "amount") val amount: Double, @ColumnInfo(name = "category") val category: String, @ColumnInfo(name = "type") val type: TransactionType, // 枚举:INCOME/EXPENSE @ColumnInfo(name = "note") val note: String = "", @ColumnInfo(name = "date") val date: Long // 时间戳,单位毫秒,避免时区问题 ) enum class TransactionType { INCOME, EXPENSE } // TypeConverter:将枚举转为整数存入数据库 class TransactionTypeConverter : TypeConverter { @TypeConverter fun fromTransactionType(type: TransactionType): Int { return when (type) { TransactionType.INCOME -> 0 TransactionType.EXPENSE -> 1 } } @TypeConverter fun toTransactionType(value: Int): TransactionType { return when (value) { 0 -> TransactionType.INCOME 1 -> TransactionType.EXPENSE else -> TransactionType.EXPENSE } } }为什么这样设计?
- 主键
id设为autoGenerate = true:避免手动生成ID冲突,Room自动用AUTOINCREMENT; date用Long(时间戳)而非String:字符串存日期(如"2024-03-15")会导致排序错乱("2024-03-2" > "2024-03-15"),时间戳天然可排序、可计算(如“本月”=date >= firstDayOfMonth && date <= lastDayOfMonth);@TypeConverter处理枚举:数据库只存数字0/1,既节省空间,又避免字符串拼写错误(如"incmoe");
对应的DAO接口:
@Dao interface TransactionDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(transaction: Transaction): Long // 返回插入后的id @Query("SELECT * FROM transactions WHERE date BETWEEN :start AND :end ORDER BY date DESC") suspend fun getTransactionsByDateRange(start: Long, end: Long): List<Transaction> @Query("SELECT SUM(amount) FROM transactions WHERE type = :type AND date BETWEEN :start AND :end") suspend fun getTotalAmountByTypeAndDate(type: TransactionType, start: Long, end: Long): Double? @Query("DELETE FROM transactions WHERE id = :id") suspend fun deleteById(id: Long) }实操心得:
@Insert(onConflict = OnConflictStrategy.REPLACE):当主键重复时自动替换,避免insert or replace手写SQL;getTotalAmountByTypeAndDate返回Double?(可空):因为SUM()在无数据时返回null,Kotlin的?强制你处理空值,比Java的double返回0.0更严谨;- 所有方法加
suspend:配合ViewModel的协程作用域,UI线程调用时不阻塞。
3.3 UI交互:Material Design不是“换个主题”,而是交互逻辑的标准化
首页MainActivity的布局用ConstraintLayout,核心是RecyclerView展示交易列表:
<androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_transactions" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@id/fab_add" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" />Adapter的关键在于视图复用与状态隔离:
class TransactionAdapter : RecyclerView.Adapter<TransactionAdapter.ViewHolder>() { private val transactions = mutableListOf<Transaction>() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = ItemTransactionBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(transactions[position]) } override fun getItemCount() = transactions.size inner class ViewHolder(private val binding: ItemTransactionBinding) : RecyclerView.ViewHolder(binding.root) : RecyclerView.ViewHolder(binding.root) { fun bind(transaction: Transaction) { binding.apply { tvAmount.text = if (transaction.type == TransactionType.INCOME) { "+¥${transaction.amount}" } else { "-¥${transaction.amount}" } tvCategory.text = transaction.category tvNote.text = transaction.note.ifEmpty { "无备注" } tvDate.text = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) .format(Date(transaction.date)) // 颜色区分收支 tvAmount.setTextColor( if (transaction.type == TransactionType.INCOME) { ContextCompat.getColor(root.context, R.color.green) } else { ContextCompat.getColor(root.context, R.color.red) } ) } } } }为什么用ViewBinding不用findViewById?
ItemTransactionBinding在编译时生成,binding.tvAmount是TextView类型,无需findViewById(R.id.tv_amount) as TextView;binding.root.context直接获取上下文,避免holder.itemView.context可能为空;ifEmpty { "无备注" }处理空字符串,比transaction.note ?: "无备注"更安全(Kotlin空安全已保障,但语义更清晰)。
实操避坑:
SimpleDateFormat不是线程安全的!这里在bind里创建新实例,因为bind在主线程调用,且每次只创建一次,无并发风险。若在onCreateViewHolder里创建单例SimpleDateFormat,多线程复用会出错。
4. 实操过程与核心环节实现:从零开始,每一步都可验证
4.1 环境准备:Android Studio不是“装完就能用”,而是需要校准的精密仪器
步骤1:安装Android Studio(以2023.2.1为例)
- 官网下载
android-studio-2023.2.1.13-windows.exe(Windows)或.dmg(Mac); - 安装时勾选
Android SDK、Android SDK Platform-Tools、Android SDK Build-Tools 33.0.2(必须与compileSdk 33匹配); - 关键设置:安装后打开
File → Settings → Appearance & Behavior → System Settings → Android SDK,在SDK Platforms标签页勾选Android 13 (API 33),在SDK Tools标签页勾选Android SDK Build-Tools 33.0.2、Android SDK Platform-Tools、Android SDK Tools。
步骤2:创建新项目
- 选择
Empty Activity,包名设为com.example.accountbook(与build.gradle中applicationId一致); - 语言选Kotlin:Java语法冗长,Kotlin的空安全、扩展函数、协程对课程设计更友好;
- 最低SDK选API 21:确保在模拟器(如Pixel 2 API 21)上可运行。
步骤3:导入Gradle Wrapper
- 项目根目录下已有
gradlew(Windows)和gradlew(Mac/Linux),无需额外操作; - 首次运行
./gradlew build会自动下载Gradle 7.4,耗时约3-5分钟(国内建议配镜像); - 验证成功:终端输出
BUILD SUCCESSFUL,且app/build/outputs/apk/debug/app-debug.apk生成。
注意:如果
gradlew超时,不要用gradle build替代!gradle是全局命令,版本可能与项目不匹配。正确做法是:
- 打开
gradle/wrapper/gradle-wrapper.properties;- 将
distributionUrl改为国内镜像,如https\://mirrors.cloud.tencent.com/gradle/gradle-7.4-bin.zip;- 删除
~/.gradle/wrapper/dists/下gradle-7.4-bin文件夹(如有);- 重新运行
./gradlew build。
4.2 数据库集成:Room不是“加个依赖就行”,而是需要编译时校验的契约
步骤1:添加Room依赖
在app/build.gradle的dependencies块中加入:
implementation 'androidx.room:room-runtime:2.6.0' implementation 'androidx.room:room-ktx:2.6.0' kapt 'androidx.room:room-compiler:2.6.0'然后点击Sync Now,等待Gradle同步完成。
步骤2:创建Entity、DAO、Database
- 在
com.example.accountbook.data包下创建Transaction.kt(含@Entity); - 创建
TransactionDao.kt(含@Dao接口); - 创建
AppDatabase.kt:
@Database( entities = [Transaction::class], version = 1, exportSchema = false // 课程设计无需导出schema,减小APK体积 ) abstract class AppDatabase : RoomDatabase() { abstract fun transactionDao(): TransactionDao companion object { @Volatile private var INSTANCE: AppDatabase? = null fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "accountbook_database" ).build().also { INSTANCE = it } } } } }步骤3:初始化Database并注入Repository
在Application类(需在AndroidManifest.xml中声明android:name=".AccountBookApplication")中:
class AccountBookApplication : Application() { val database: AppDatabase by lazy { AppDatabase.getDatabase(this) } }在MainActivity中:
private val database by lazy { (application as AccountBookApplication).database } private val transactionRepository = TransactionRepository(database.transactionDao())验证点:
- 编译时检查:如果
TransactionDao里@Query的SQL写错(如表名transactions写成transaction),编译直接报错,而非运行时崩溃; - 运行时检查:首次启动App,
AppDatabase会自动创建accountbook_database文件,路径为/data/data/com.example.accountbook/databases/accountbook_database(需root或adb调试)。
4.3 UI开发:Material Design不是“套个主题”,而是组件化思维的落地
步骤1:配置Material Theme
在res/values/themes.xml中:
<style name="Theme.AccountBook" parent="Theme.Material3.DayNight"> <item name="colorPrimary">@color/purple_500</item> <item name="colorOnSurface">@color/black</item> </style>在AndroidManifest.xml中为<application>设置android:theme="@style/Theme.AccountBook"。
步骤2:实现添加交易的BottomSheet
创建AddTransactionBottomSheet.kt:
class AddTransactionBottomSheet : BottomSheetDialogFragment() { private var _binding: FragmentAddTransactionBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentAddTransactionBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.btnConfirm.setOnClickListener { val amount = binding.etAmount.text.toString().toDoubleOrNull() ?: 0.0 val category = binding.spCategory.selectedItem.toString() val type = if (binding.rbIncome.isChecked) TransactionType.INCOME else TransactionType.EXPENSE val note = binding.etNote.text.toString() val date = System.currentTimeMillis() val transaction = Transaction( amount = amount, category = category, type = type, note = note, date = date ) // 通过ViewModel保存,而非直接调用Repository (activity as MainActivity).viewModel.insertTransaction(transaction) dismiss() } } }关键设计:
- 使用
BottomSheetDialogFragment而非Dialog:符合Material Design规范,手势下滑关闭; etAmount.text.toString().toDoubleOrNull() ?: 0.0:toDoubleOrNull()安全转换,避免NumberFormatException;spCategory用ArrayAdapter填充预设类别(餐饮、交通、娱乐等),避免用户随意输入导致统计混乱。
步骤3:RecyclerView列表渲染
在MainActivity中:
private fun setupRecyclerView() { val adapter = TransactionAdapter() binding.rvTransactions.adapter = adapter binding.rvTransactions.layoutManager = LinearLayoutManager(this) // 观察LiveData,自动更新UI viewModel.transactions.observe(this) { list -> adapter.submitList(list) } }viewModel.transactions是LiveData<List<Transaction>>,由TransactionRepository查询后postValue。
实操心得:
submitList()比notifyDataSetChanged()高效,它利用DiffUtil计算差异,只刷新变化项,列表长时滚动更流畅。课程设计虽数据量小,但体现你对性能优化的理解。
5. 常见问题与排查技巧实录:那些老师不会讲,但你一定会踩的坑
5.1 Gradle构建失败:不是环境问题,而是版本锁链断裂
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
Could not resolve all files for configuration ':app:debugRuntimeClasspath' | room-runtime与room-compiler版本不一致,或kapt未启用 | 检查build.gradle中三者版本是否均为2.6.0;确认android { buildFeatures { viewBinding true } }已开启;kapt依赖必须用kapt而非implementation |
Cannot find symbol class TransactionDao_Impl | Room注解处理器未触发,@Dao接口未生成实现类 | 清理项目:Build → Clean Project,再Build → Rebuild Project;检查TransactionDao是否在@Database的entities参数中声明;确认Transaction类有@Entity且字段有@ColumnInfo |
Execution failed for task ':app:compileDebugKotlin' | Kotlin版本与Gradle不兼容(如Gradle 7.4需Kotlin 1.7+) | 在build.gradle的kotlinOptions中设jvmTarget = '1.8';在gradle.properties中加kotlin.version=1.7.20 |
独家技巧:当
./gradlew build卡在Resolving dependencies时,打开gradle.properties,添加:org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m org.gradle.parallel=true org.gradle.configureondemand=true这能提升Gradle构建速度,尤其在多模块项目中。
5.2 运行时崩溃:不是代码写错,而是生命周期误用
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
java.lang.NullPointerException: Attempt to invoke virtual method 'void androidx.recyclerview.widget.RecyclerView.setAdapter(...)' on a null object reference | binding.rvTransactions为null,因ViewBinding未正确初始化 | 确认MainActivity中private val binding = ActivityMainBinding.inflate(layoutInflater)在onCreate中调用,且setContentView(binding.root)已执行;检查XML中RecyclerView的ID是否为rv_transactions(与Binding类属性名一致) |
java.lang.IllegalStateException: Cannot invoke setValue on a background thread | 在非主线程(如协程launch { })中直接调用LiveData.value = xxx | 改用liveData.postValue(xxx),或在协程中切回主线程:lifecycleScope.launch { viewModel.loadTransactions(); },其中loadTransactions()内部用viewModel._transactions.value = result(_transactions是MutableLiveData) |
android.database.sqlite.SQLiteException: no such table: transactions (code 1 SQLITE_ERROR) | Database版本升级时未处理onCreate/onUpgrade,或首次运行未触发建表 | 确保AppDatabase的version = 1,且Room.databaseBuilder中未设fallbackToDestructiveMigration()(课程设计可加,但答辩时会被质疑数据可靠性);卸载App重装,触发onCreate |
实操心得:
fallbackToDestructiveMigration()在开发阶段可加,方便快速迭代,但正式提交前必须删除。老师会检查AppDatabase类,看到这行就扣分——因为它意味着“数据丢了无所谓”,违背记账本的核心价值。
5.3 功能异常:不是逻辑错误,而是设计盲区
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 添加交易后列表不刷新 | LiveData未观察,或submitList()未调用 | 在MainActivity中确认viewModel.transactions.observe(this) { adapter.submitList(it) }已执行;检查TransactionRepository中getTransactions()是否返回LiveData(Room的@Query方法返回LiveData会自动监听数据库变化) |
日期显示为1970-01-01 | Transaction.date存的是Long时间戳,但SimpleDateFormat解析时用了yyyy-MM-dd格式 | SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(Date(transaction.date)),Date(Long)构造函数正确;检查transaction.date是否为0(未赋值) |
| 分类筛选结果为空 | @Query中WHERE category = :category,但category值含空格或大小写不匹配 | 在TransactionDao中用LIKE模糊匹配:`@Query("SELECT * FROM transactions WHERE category LIKE '%' |
独家避坑:
RecyclerView的submitList()在列表为空时,adapter.itemCount为0,但onCreateViewHolder仍会调用一次。若onBindViewHolder中未处理空列表,会NullPointerException。正确做法:在Adapter中加空列表占位:override fun onBindViewHolder(holder: ViewHolder, position: Int) { if (transactions.isEmpty()) { holder.binding.root.visibility = View.GONE return } holder.bind(transactions[position]) }
6. 文档说明与答辩准备:高分不是靠代码,而是靠表达
6.1 文档结构:不是“说明书”,而是你的技术叙事
一份高分文档,应该让老师3分钟内看懂你的设计深度。结构如下:
1. 项目概述(1页)
- 一句话定位:“一款面向大学生日常消费管理的离线优先记账应用”;
- 核心价值:“解决小额高频记账的便捷性、数据本地可靠性、统计直观性三大痛点”。
2. 技术架构图(1页)
- 手绘风格流程图:
UI(Activity)→ViewModel→Repository→LocalDataSource(Room),箭头标注数据流向(如“observe”、“insert”、“get”); - 标注关键决策:“Room替代SQLite:编译时校验SQL,避免运行时崩溃”。
3. 核心功能演示(2页)
- 截图+文字:首页列表(标注“RecyclerView+ViewBinding”)、添加页(标注“BottomSheet+枚举类型安全”)、统计页(标注“Room聚合查询+SUN()”);
- 每张图下写一句技术亮点:“时间戳存储确保跨月统计准确,避免字符串日期排序错乱”。
4. 关键代码片段(2页)
Transaction.kt(突出@Entity、@TypeConverter);TransactionDao.kt(突出suspend、@Query参数化);build.gradle(突出kapt、viewBinding、版本一致性)。
5. 扩展性说明(1页)
- “云同步扩展路径:实现
RemoteDataSource接口,替换TransactionRepository中的dataSource,无需修改ViewModel和UI”; - “多币种支持:在
Transaction中增加currency字段,@TypeConverter扩展货币枚举”。
答辩话术:当老师问“这个项目最难的部分是什么?”,不要说“我调了三天bug”。要说:“最难的是平衡简洁性与扩展性。比如添加交易功能,表面看只是存一条记录,但背后涉及Room事务一致性、UI线程安全更新、用户输入校验三层保障。我们用ViewModel隔离状态、用suspend函数处理IO、用ViewBinding杜绝空指针,让‘简单’变得可靠。”
6.2 答辩现场:老师真正在意的三个问题
问题1:“Room和SQLite有什么区别?”
- 错误回答:“Room是SQLite的封装,更好用。”
- 正确回答:“SQLite是底层数据库引擎,Room是架构组件。区别在于:① Room提供编译时SQL校验,写错
@Query会直接编译失败;② Room用@Entity定义数据契约,比手写CREATE TABLE更易维护;③ Room的LiveData返回值能自动监听数据库变化,UI实时刷新,而SQLite需手动发广播或轮询。”
问题2:“为什么用ViewModel不用Activity直接操作?”
- 错误回答:“网上教程都这么写。”
- 正确回答:“Activity生命周期短暂,屏幕旋转时会被销毁重建。如果数据操作在Activity里,旋转后数据丢失。ViewModel生命周期与Activity分离,旋转时数据保留在内存中,
LiveData自动将数据推送给新Activity,用户体验无缝。”
问题3:“如果要加图表功能,你怎么设计?”
- 错误回答:“我用MPAndroidChart库,加个Fragment就行。”
- 正确回答:“图表是展示层,不应侵入业务逻辑。我会在Repository层新增
getMonthlySummary()方法,返回Map<String, Double>(类别→金额),ViewModel暴露monthlySummary: LiveData<Map<String, Double>>,UI层用MPAndroidChart绘制。这样,图表逻辑与记账核心完全解耦,未来换图表
本文还有配套的精品资源,点击获取