1. 项目缘起与整体设计思路
1.1 为什么选择学生信息管理系统作为Android入门实战项目
学生信息管理系统这个题目,乍一听像是计算机专业课程设计的“老面孔”,几乎每个学过编程的人都或多或少接触过。但恰恰是这种看似普通的项目,最适合用来检验一个Android开发者是否真正掌握了移动端开发的核心链路。我做过不下五个类似的管理类App,从最早的课程作业到后来给小型培训机构做的内部工具,踩过的坑、绕过的弯,远比那些花哨的UI项目要多得多。
这个项目的本质是什么?简单说,就是把学生、课程、成绩这三类核心数据,通过一个移动端界面进行增删改查,并且保证数据在本地持久化存储,应用重启后数据不丢失。听起来简单,但真正动手做的时候,你会发现涉及的知识点非常密集:Activity的生命周期管理、RecyclerView的高效列表渲染、SQLite数据库的建表与版本迁移、ContentProvider或Room框架的选型、Material Design的界面规范、甚至还要考虑数据校验和异常处理。
适合谁来参考?我认为有三类人最应该动手做一遍:第一类是刚学完Android基础组件、还没做过完整项目的新手,这个项目能帮你把零散的知识点串成一条线;第二类是想从Java转Kotlin的开发者,用这个项目练手可以快速熟悉Kotlin的语法特性和协程操作;第三类是需要一个“能跑起来”的模板项目来应付课程设计或毕业设计的同学,但我要提醒一句,直接抄代码意义不大,关键是理解每一层的设计意图。
1.2 技术选型:Java还是Kotlin,SQLite还是Room
技术选型这件事,我的原则一直是“看场景、看维护成本、看团队习惯”。这个项目我最终选择了Kotlin + Room + ViewModel + LiveData这套组合,原因有三。
第一,Kotlin的空安全特性在数据模型层能省掉大量判空代码。学生信息里的姓名、学号、班级这些字段,如果用Java写,每个getter都要考虑null的情况,而Kotlin的data class配合非空类型声明,编译期就能拦住大部分空指针问题。第二,Room作为SQLite的ORM封装,把建表、查询、更新这些操作从手写SQL变成了注解式声明,代码量减少至少百分之四十,而且编译期就能校验SQL语句的正确性,这对新手来说非常友好。第三,ViewModel + LiveData的组合解决了数据与界面生命周期绑定这个经典难题,屏幕旋转时数据不会丢失,异步查询也不会造成内存泄漏。
当然,如果你所在的团队还在用Java,或者课程要求必须用Java,那用Java + SQLiteOpenHelper也完全可行。我在下面也会给出两种方案的对比,方便你根据实际情况做选择。
| 对比维度 | Java + SQLiteOpenHelper | Kotlin + Room |
|---|---|---|
| 代码量 | 较多,需手写建表、增删改查SQL | 较少,注解式声明 |
| 编译期校验 | 无,SQL错误运行时才暴露 | 有,编译期检查SQL |
| 空安全 | 需手动判空 | 编译期空安全 |
| 学习曲线 | 平缓,但容易写出低效代码 | 稍陡,但长期维护成本低 |
| 适合场景 | 教学演示、简单项目 | 生产环境、中大型项目 |
1.3 项目整体架构:三层结构如何划分
我把整个项目拆成了三层:数据层、业务逻辑层、界面层。数据层负责与数据库打交道,包括实体类定义、DAO接口、数据库初始化;业务逻辑层由ViewModel承担,负责调用DAO、处理数据转换、暴露LiveData给界面;界面层就是Activity和Fragment,只负责展示数据和接收用户输入,不直接碰数据库。
这种分层的好处在于,每一层的职责非常清晰。比如你要换数据库,从Room换成SQLite原生实现,只需要改数据层,ViewModel和Activity几乎不用动。再比如你要加一个“按班级筛选学生”的功能,只需要在ViewModel里加一个过滤方法,界面层加一个下拉框即可。我见过很多新手把所有逻辑都写在Activity里,结果一个文件上千行,改一个功能要翻半天,这种写法在项目稍微复杂一点之后就是灾难。
提示:分层不是目的,而是手段。如果你的项目只有两三个界面,强行分层反而增加复杂度。但对于学生信息管理这种涉及多表关联、多种查询条件的项目,分层是必要的。
2. 核心细节解析与实操要点
2.1 数据模型设计:学生、课程、成绩三张表怎么建
数据模型是整个项目的地基,地基没打好,后面全是坑。这个项目涉及三个核心实体:学生、课程、成绩。学生表存基本信息,课程表存课程名称和学分,成绩表则是学生和课程的多对多关联,同时记录分数。
学生表的设计我建议包含这些字段:学号(主键,字符串类型,因为有些学校的学号包含字母)、姓名、性别、班级、入学年份、联系电话。这里有个细节要注意,学号作为主键比自增ID更好,因为学号是业务上的唯一标识,用学号做主键可以避免“同一个学生被录入两次”的问题。但代价是,如果学号需要修改,关联的成绩记录也要跟着改,所以我在实际项目中通常会加一个自增的id作为代理主键,学号加唯一索引。
课程表相对简单:课程编号(主键)、课程名称、学分、授课教师。成绩表是关联表:id(自增主键)、学号(外键)、课程编号(外键)、分数、考试时间。这里的外键约束在Room中可以通过@ForeignKey注解声明,但要注意,SQLite的外键约束默认是关闭的,需要在数据库初始化时手动开启。
@Entity(tableName = "students") data class Student( @PrimaryKey val studentId: String, val name: String, val gender: String, val className: String, val enrollmentYear: Int, val phone: String? ) @Entity(tableName = "courses") data class Course( @PrimaryKey val courseId: String, val courseName: String, val credit: Float, val teacher: String ) @Entity( tableName = "scores", foreignKeys = [ ForeignKey(entity = Student::class, parentColumns = ["studentId"], childColumns = ["studentId"], onDelete = CASCADE), ForeignKey(entity = Course::class, parentColumns = ["courseId"], childColumns = ["courseId"], onDelete = CASCADE) ] ) data class Score( @PrimaryKey(autoGenerate = true) val id: Long = 0, val studentId: String, val courseId: String, val score: Float, val examDate: Long )注意:onDelete = CASCADE表示删除学生时,该学生的所有成绩记录也会被自动删除。这个行为要谨慎使用,因为一旦误删学生,成绩数据就找不回来了。我在实际项目中更倾向于用软删除,即加一个isDeleted字段,查询时过滤掉已删除的记录。
2.2 数据库版本迁移:别等到上线才后悔
数据库版本迁移是很多新手容易忽略的问题。你的App第一版上线了,用户已经装了,第二版你给Student表加了一个“邮箱”字段,如果没有处理好迁移逻辑,用户更新后App直接崩溃。Room提供了Migration机制,你需要为每一个版本变化写一个Migration对象。
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE students ADD COLUMN email TEXT") } }然后在构建数据库时添加这个迁移:
Room.databaseBuilder(context, AppDatabase::class.java, "school.db") .addMigrations(MIGRATION_1_2) .build()这里有个经验之谈:每次修改实体类,都要同步修改版本号并添加对应的Migration。我建议在项目初期就把版本号管理起来,用一个常量类记录当前版本和所有历史迁移,避免后期混乱。另外,如果是在开发阶段,可以用fallbackToDestructiveMigration()来简化,但这意味着用户数据会丢失,生产环境绝对不能用。
2.3 界面交互设计:RecyclerView与Material Design的配合
学生列表界面是整个App使用频率最高的页面,性能至关重要。我用RecyclerView替代了早期的ListView,因为RecyclerView的ViewHolder复用机制更彻底,而且支持局部刷新。列表项布局用MaterialCardView包裹,每个卡片显示学生姓名、学号、班级,右侧放一个编辑图标和一个删除图标。
这里有个细节:点击卡片进入详情页,点击编辑图标直接弹出编辑对话框,点击删除图标弹出确认对话框。三种操作要区分清楚,不能混在一起。我见过有的项目整个卡片只有一个点击事件,用户想删除还得进详情页,体验很差。
Material Design的组件库提供了MaterialAlertDialogBuilder,用来做确认对话框非常方便。颜色方面,我建议用主题色作为主色调,删除操作用红色强调,但不要滥用红色,否则界面会显得很刺眼。
<com.google.android.material.card.MaterialCardView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" app:cardCornerRadius="8dp" app:cardElevation="2dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="16dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical"> <TextView android:id="@+id/tvName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="18sp" android:textStyle="bold" /> <TextView android:id="@+id/tvStudentId" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="14sp" android:textColor="?android:attr/textColorSecondary" /> </LinearLayout> <ImageButton android:id="@+id/btnEdit" android:layout_width="40dp" android:layout_height="40dp" android:src="@drawable/ic_edit" android:background="?attr/selectableItemBackgroundBorderless" /> <ImageButton android:id="@+id/btnDelete" android:layout_width="40dp" android:layout_height="40dp" android:src="@drawable/ic_delete" android:background="?attr/selectableItemBackgroundBorderless" /> </LinearLayout> </com.google.android.material.card.MaterialCardView>提示:ImageButton的background用?attr/selectableItemBackgroundBorderless可以获得水波纹效果,这是Material Design的规范做法,比自定义selector更省事。
3. 实操过程与核心环节实现
3.1 项目初始化与依赖配置
新建项目时,我选择Empty Activity模板,语言选Kotlin,最低API级别设为24(Android 7.0)。为什么是24?因为Room的某些特性在更低版本上需要额外兼容,而API 24已经覆盖了绝大多数活跃设备,没必要为了极少数老设备增加开发成本。
在app级别的build.gradle中添加Room和Lifecycle的依赖:
dependencies { def room_version = "2.6.1" def lifecycle_version = "2.7.0" implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation "androidx.recyclerview:recyclerview:1.3.2" implementation "com.google.android.material:material:1.11.0" }注意kapt插件的配置,在plugins块中要加上id 'kotlin-kapt'。Room的注解处理器通过kapt在编译期生成代码,如果忘了加这个插件,编译时会报“找不到Dao实现类”的错误。
3.2 数据库层完整实现
数据库层的代码我分成了四个文件:AppDatabase.kt(数据库入口)、StudentDao.kt、CourseDao.kt、ScoreDao.kt。每个DAO接口用@Dao注解标记,方法用@Insert、@Update、@Delete、@Query注解。
@Dao interface StudentDao { @Query("SELECT * FROM students ORDER BY studentId ASC") fun getAllStudents(): LiveData<List<Student>> @Query("SELECT * FROM students WHERE className = :className") fun getStudentsByClass(className: String): LiveData<List<Student>> @Query("SELECT * FROM students WHERE studentId = :studentId") suspend fun getStudentById(studentId: String): Student? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(student: Student) @Update suspend fun update(student: Student) @Delete suspend fun delete(student: Student) @Query("DELETE FROM students WHERE studentId = :studentId") suspend fun deleteById(studentId: String) }这里有几个关键点要解释。第一,查询方法返回LiveData,这样数据变化时界面会自动刷新,不需要手动重新查询。第二,插入和更新方法用suspend修饰,表示这是挂起函数,必须在协程中调用,避免阻塞主线程。第三,onConflict = OnConflictStrategy.REPLACE表示如果主键冲突就替换,这在“新增或更新”场景下很有用,但要注意它会先删除旧记录再插入新记录,如果有关联的外键,可能会触发级联删除。
AppDatabase的构建:
@Database( entities = [Student::class, Course::class, Score::class], version = 1, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun studentDao(): StudentDao abstract fun courseDao(): CourseDao abstract fun scoreDao(): ScoreDao companion object { @Volatile private var INSTANCE: AppDatabase? = null fun getInstance(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "school.db" ) .addMigrations(MIGRATION_1_2) .build() INSTANCE = instance instance } } } }注意:数据库实例必须用单例模式,否则多个实例同时操作数据库会导致锁竞争甚至数据损坏。我用双重检查锁实现单例,这是Android官方推荐的写法。
3.3 ViewModel与数据绑定
ViewModel层我创建了一个StudentViewModel,持有StudentDao的引用,暴露LiveData给Activity。同时,我加了一个filterClass的MutableLiveData,用来实现按班级筛选。
class StudentViewModel(private val studentDao: StudentDao) : ViewModel() { val allStudents: LiveData<List<Student>> = studentDao.getAllStudents() private val _filterClass = MutableLiveData<String>() val filteredStudents: LiveData<List<Student>> = _filterClass.switchMap { className -> if (className.isNullOrEmpty()) { studentDao.getAllStudents() } else { studentDao.getStudentsByClass(className) } } fun setFilter(className: String) { _filterClass.value = className } fun insert(student: Student) = viewModelScope.launch { studentDao.insert(student) } fun update(student: Student) = viewModelScope.launch { studentDao.update(student) } fun delete(student: Student) = viewModelScope.launch { studentDao.delete(student) } }switchMap是这里的关键,它把filterClass的变化转换成不同的查询结果。当filterClass为空时查全部,不为空时按班级查。viewModelScope是ViewModel自带的协程作用域,当ViewModel被销毁时,里面的协程会自动取消,不会造成内存泄漏。
ViewModel的创建需要用ViewModelProvider.Factory,因为构造函数需要传入DAO:
class StudentViewModelFactory(private val studentDao: StudentDao) : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { if (modelClass.isAssignableFrom(StudentViewModel::class.java)) { @Suppress("UNCHECKED_CAST") return StudentViewModel(studentDao) as T } throw IllegalArgumentException("Unknown ViewModel class") } }在Activity中这样使用:
val dao = AppDatabase.getInstance(this).studentDao() val viewModel = ViewModelProvider(this, StudentViewModelFactory(dao))[StudentViewModel::class.java]3.4 列表界面与适配器实现
RecyclerView的适配器我用ListAdapter配合DiffUtil,这样数据变化时只有真正改变了的项才会刷新,性能比notifyDataSetChanged好很多。
class StudentAdapter( private val onEdit: (Student) -> Unit, private val onDelete: (Student) -> Unit, private val onClick: (Student) -> Unit ) : ListAdapter<Student, StudentAdapter.ViewHolder>(DiffCallback) { companion object DiffCallback : DiffUtil.ItemCallback<Student>() { override fun areItemsTheSame(oldItem: Student, newItem: Student) = oldItem.studentId == newItem.studentId override fun areContentsTheSame(oldItem: Student, newItem: Student) = oldItem == newItem } class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val tvName: TextView = view.findViewById(R.id.tvName) val tvStudentId: TextView = view.findViewById(R.id.tvStudentId) val btnEdit: ImageButton = view.findViewById(R.id.btnEdit) val btnDelete: ImageButton = view.findViewById(R.id.btnDelete) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_student, parent, false) return ViewHolder(view) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val student = getItem(position) holder.tvName.text = student.name holder.tvStudentId.text = student.studentId holder.btnEdit.setOnClickListener { onEdit(student) } holder.btnDelete.setOnClickListener { onDelete(student) } holder.itemView.setOnClickListener { onClick(student) } } }在Activity中观察LiveData并提交数据:
viewModel.filteredStudents.observe(this) { students -> adapter.submitList(students) }提示:submitList是ListAdapter的方法,它会在后台线程计算差异,然后自动刷新界面。注意不要在主线程调用submitList后立即做耗时操作,否则可能看到列表闪烁。
3.5 新增与编辑对话框的实现
新增和编辑学生信息我用的是MaterialAlertDialogBuilder配合自定义布局。对话框里放TextInputLayout和TextInputEditText,这样输入框的提示、错误提示都有统一的样式。
fun showStudentDialog(student: Student? = null) { val dialogView = layoutInflater.inflate(R.layout.dialog_student, null) val etName = dialogView.findViewById<TextInputEditText>(R.id.etName) val etStudentId = dialogView.findViewById<TextInputEditText>(R.id.etStudentId) val etClass = dialogView.findViewById<TextInputEditText>(R.id.etClass) if (student != null) { etName.setText(student.name) etStudentId.setText(student.studentId) etStudentId.isEnabled = false etClass.setText(student.className) } MaterialAlertDialogBuilder(this) .setTitle(if (student == null) "新增学生" else "编辑学生") .setView(dialogView) .setPositiveButton("保存") { _, _ -> val name = etName.text.toString().trim() val studentId = etStudentId.text.toString().trim() val className = etClass.text.toString().trim() if (name.isEmpty() || studentId.isEmpty()) { Toast.makeText(this, "姓名和学号不能为空", Toast.LENGTH_SHORT).show() return@setPositiveButton } val newStudent = Student(studentId, name, "男", className, 2024, null) if (student == null) { viewModel.insert(newStudent) } else { viewModel.update(newStudent) } } .setNegativeButton("取消", null) .show() }这里有个细节:编辑时学号输入框设为不可编辑,因为学号是主键,改了会引发一系列关联问题。如果确实需要修改学号,应该走“删除旧记录+新增新记录”的流程,并且要处理成绩数据的迁移。
4. 常见问题与排查技巧实录
4.1 数据库操作报错“Cannot access database on the main thread”
这是新手最常遇到的错误之一。Room默认不允许在主线程做数据库操作,因为会阻塞UI导致卡顿甚至ANR。解决办法就是把所有数据库操作放到协程里,用suspend函数或者viewModelScope.launch包裹。
如果你确实需要在主线程做查询(比如在Application初始化时预加载数据),可以用allowMainThreadQueries(),但我强烈不建议这么做。我见过一个项目因为用了这个选项,列表滚动时频繁查询数据库,帧率直接掉到个位数。
4.2 LiveData不触发观察者回调
有时候你更新了数据,但界面没刷新。排查思路是这样的:第一,确认观察者是在主线程注册的,LiveData的observe方法必须在主线程调用;第二,确认你的查询返回的是LiveData而不是普通List,如果返回List,数据变化时不会自动通知;第三,检查数据库操作是否真的执行成功了,可以在DAO方法里加日志。
还有一种情况是,你在ViewModel里用了MediatorLiveData或者switchMap,但源LiveData没有变化,导致下游也不更新。这时候要检查源LiveData的setValue或postValue是否被调用了。
4.3 RecyclerView列表项点击无效
这个问题通常有三个原因:第一,item布局的根布局设置了clickable="true"或focusable="true",导致点击事件被根布局拦截;第二,RecyclerView的itemView没有设置点击监听,而是在onBindViewHolder里设置了但被其他View覆盖;第三,适配器的getItemCount返回了0,列表根本没渲染。
我的排查习惯是,先在onBindViewHolder里打日志,确认方法被调用了,然后再检查布局文件。如果是根布局的问题,把clickable和focusable去掉,或者在根布局上设置点击监听。
4.4 屏幕旋转后数据丢失
这个问题在早期Android开发中很常见,因为Activity重建会导致成员变量重置。用ViewModel + LiveData之后,数据保存在ViewModel中,屏幕旋转时ViewModel不会销毁,数据自然保留。但要注意,如果你的数据是存在Activity的成员变量里,而不是ViewModel里,那还是会丢。
另外,如果对话框在屏幕旋转时还开着,旋转后对话框会消失。解决办法是用DialogFragment替代AlertDialog,或者用ViewModel保存对话框的状态。
| 常见问题 | 排查方向 | 解决方案 |
|---|---|---|
| 主线程数据库报错 | 检查DAO方法是否在协程中调用 | 用viewModelScope.launch包裹 |
| LiveData不回调 | 检查observe是否在主线程、数据是否变化 | 用postValue替代setValue |
| 列表点击无效 | 检查布局clickable属性、getItemCount | 去掉根布局clickable,加日志 |
| 旋转后数据丢失 | 检查数据是否在ViewModel中 | 把数据移到ViewModel |
| 数据库迁移崩溃 | 检查版本号和Migration是否匹配 | 添加对应Migration或fallback |
4.5 独家避坑经验:三个容易忽略的细节
第一个细节:Room的@Query方法如果返回LiveData,查询是在后台线程执行的,但如果你返回的是普通List,Room会强制要求你在协程中调用。很多新手看到“Cannot access database on the main thread”就慌了,其实只要把返回类型改成LiveData或者加suspend修饰符就能解决。
第二个细节:RecyclerView的DiffUtil在计算差异时,如果列表项数量很大(比如上千条),计算过程可能耗时较长。虽然ListAdapter把计算放到了后台线程,但如果频繁submitList,还是可能造成卡顿。我的做法是加一个防抖处理,比如用Handler.postDelayed延迟提交,或者用DistinctUntilChanged过滤重复数据。
第三个细节:数据库的exportSchema选项。开发阶段设为false没问题,但如果团队协作或者需要做数据库版本管理,建议设为true,这样Room会把数据库的schema导出成JSON文件,方便对比不同版本的差异。导出路径在build.gradle里配置:
room { schemaDirectory "$projectDir/schemas" }这个文件建议提交到版本控制,这样每次修改实体类,都能看到schema的变化,避免迁移遗漏。
4.6 性能优化:让列表滚动如丝般顺滑
学生信息管理系统的列表通常不会太长,但如果一个班级有上百人,或者你要展示全校学生,性能就很重要了。我做了三件事来优化。
第一,给RecyclerView设置setHasFixedSize(true),这样当列表项大小固定时,RecyclerView不需要重新计算布局,滚动更流畅。第二,在item布局中避免使用weight属性,因为weight会导致额外的测量计算,我改用固定宽度加margin。第三,图片资源用VectorDrawable替代PNG,减少内存占用,而且矢量图在任何分辨率下都清晰。
还有一个容易被忽略的点:RecyclerView的缓存。默认情况下,RecyclerView会缓存一定数量的ViewHolder,但如果你的item布局很复杂,可以适当增大缓存数量:
recyclerView.setItemViewCacheSize(20) recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)不过要注意,缓存太多会占用内存,要根据实际情况调整。我一般先用默认值,遇到性能问题再调。
4.7 数据校验与异常处理
学生信息录入时,数据校验不能只靠界面层的判空。我在ViewModel里也加了一层校验,因为ViewModel可能被多个界面调用,不能假设每个调用方都做了校验。
fun insert(student: Student): Boolean { if (student.studentId.isBlank()) return false if (student.name.isBlank()) return false if (student.studentId.length > 20) return false viewModelScope.launch { studentDao.insert(student) } return true }另外,数据库操作要用try-catch包裹,因为可能遇到磁盘空间不足、数据库锁等问题。我通常会在Repository层统一处理异常,把错误信息通过LiveData暴露给界面,而不是让异常直接崩溃。
sealed class Result<out T> { data class Success<T>(val data: T) : Result<T>() data class Error(val message: String) : Result<Nothing>() }这个Result类可以用来包装数据库操作的结果,界面层根据Success或Error分别处理。虽然增加了一些代码量,但项目的健壮性会好很多。
4.8 测试与调试技巧
调试数据库相关的问题,我强烈推荐用Android Studio自带的Database Inspector。在App运行时,打开App Inspection面板,可以实时查看数据库表的内容,还可以直接执行SQL语句。这比打日志方便多了,尤其是排查数据不一致的问题。
另外,单元测试也很重要。Room提供了inMemoryDatabaseBuilder,可以在内存中创建数据库,测试完自动销毁,不会污染真实数据。
@Before fun setup() { val context = ApplicationProvider.getApplicationContext<Context>() db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() .build() studentDao = db.studentDao() } @After fun tearDown() { db.close() } @Test fun insertAndGetStudent() = runTest { val student = Student("2024001", "张三", "男", "计算机1班", 2024, null) studentDao.insert(student) val students = studentDao.getAllStudents().getOrAwaitValue() assertEquals(1, students.size) assertEquals("张三", students[0].name) }提示:getOrAwaitValue是一个扩展函数,用来在测试中同步获取LiveData的值。因为LiveData是异步的,直接调用value可能拿到null。
5. 功能扩展与进阶方向
5.1 成绩管理模块的集成
学生信息管理只是基础,加上成绩管理才算完整。成绩模块的核心是关联查询:给定一个学生,查出他所有课程的成绩;给定一门课程,查出所有学生的成绩。这需要用到JOIN查询。
@Query(""" SELECT s.name, c.courseName, sc.score FROM scores sc INNER JOIN students s ON sc.studentId = s.studentId INNER JOIN courses c ON sc.courseId = c.courseId WHERE sc.studentId = :studentId """) fun getStudentScores(studentId: String): LiveData<List<StudentScore>>StudentScore是一个POJO,用来接收查询结果。Room会自动把查询结果的列映射到POJO的字段上,字段名要对应,或者用@ColumnInfo注解指定映射关系。
成绩录入界面我建议用Spinner选择课程,用EditText输入分数,保存时校验分数在0到100之间。成绩列表用RecyclerView展示,按课程分组,每组显示平均分和最高分。
5.2 数据导出与备份
很多老师需要把学生信息导出成Excel或者CSV文件。Android中可以用Apache POI库生成Excel,但POI体积较大,如果只是简单导出,用CSV更轻量。
fun exportToCsv(students: List<Student>, outputStream: OutputStream) { val writer = BufferedWriter(OutputStreamWriter(outputStream)) writer.write("学号,姓名,性别,班级,入学年份\n") students.forEach { student -> writer.write("${student.studentId},${student.name},${student.gender},${student.className},${student.enrollmentYear}\n") } writer.flush() writer.close() }导出路径用getExternalFilesDir()获取,这样不需要存储权限,而且卸载App时文件会自动清理。如果要保存到公共目录,需要用MediaStore API,适配Android 10以上的分区存储。
5.3 多用户与权限控制
如果这个系统要给多个老师使用,就需要加登录和权限控制。简单的做法是在数据库里加一个users表,存用户名和密码哈希。登录后根据角色(管理员/普通教师)决定能看哪些班级的数据。
密码存储绝对不能用明文,要用BCrypt或者Argon2做哈希。Android中可以用jBCrypt库,或者用MessageDigest做SHA-256加盐。但要注意,SHA-256加盐的安全性不如BCrypt,因为BCrypt的计算成本可调,能抵抗暴力破解。
权限控制可以在ViewModel层做,根据当前用户的角色过滤查询结果。比如普通教师只能查自己班级的学生,管理员可以查全部。
5.4 界面美化与用户体验提升
功能做完之后,界面美化是提升用户体验的关键。我通常会做这几件事:第一,用Material Design的Color System定义主题色,确保整个App的颜色统一;第二,给列表项加过渡动画,用DefaultItemAnimator或者自定义动画;第三,空列表时显示一个友好的提示,而不是一片空白;第四,加载数据时显示进度条,避免用户以为App卡死了。
空列表提示可以用一个简单的TextView加ImageView组合:
<LinearLayout android:id="@+id/emptyView" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:orientation="vertical" android:visibility="gone"> <ImageView android:layout_width="120dp" android:layout_height="120dp" android:src="@drawable/ic_empty" android:alpha="0.3" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="暂无学生信息" android:textColor="?android:attr/textColorSecondary" /> </LinearLayout>然后在观察LiveData时切换可见性:
viewModel.filteredStudents.observe(this) { students -> adapter.submitList(students) emptyView.visibility = if (students.isEmpty()) View.VISIBLE else View.GONE recyclerView.visibility = if (students.isEmpty()) View.GONE else View.VISIBLE }5.5 从本地数据库到云端同步
如果多个设备需要共享数据,就需要云端同步。最简单的方案是用Firebase Realtime Database或者Firestore,但要注意数据结构和本地数据库的映射。我的做法是本地用Room做主存储,云端做备份和同步,同步时用时间戳解决冲突。
同步逻辑我通常放在WorkManager里,定期在后台执行。WorkManager可以设置网络约束,只在有网络时同步,而且系统会自动处理重试和电量优化。
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS) .setConstraints(Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build()) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( "data_sync", ExistingPeriodicWorkPolicy.KEEP, syncRequest )这个方案的好处是,即使App被杀死,WorkManager也会在合适的时机执行同步任务。但要注意,WorkManager的最小周期是15分钟,不能设置更短。
6. 项目复盘与个人体会
这个项目我从头到尾做了三遍,第一遍用Java + SQLite,第二遍用Kotlin + Room,第三遍加了协程和Flow。每一遍都有新的收获,也踩了不同的坑。
最大的体会是,数据库设计真的不能偷懒。第一遍做的时候,我把学生和成绩放在一张表里,结果一个学生有多门成绩时,数据冗余严重,更新一个学生的姓名要改好几条记录。后来拆成三张表,虽然查询复杂了一点,但数据一致性和扩展性好了很多。
第二个体会是,异步操作一定要处理好。早期我用AsyncTask做数据库查询,代码嵌套严重,而且容易内存泄漏。换成协程之后,代码简洁了很多,而且viewModelScope自动管理生命周期,省心不少。
第三个体会是,界面和逻辑一定要分离。我见过太多项目把数据库操作写在Activity里,结果Activity代码上千行,改一个功能要翻半天。用ViewModel做中间层之后,Activity只负责展示和交互,逻辑都在ViewModel里,测试也方便。
如果你正在做这个项目,我的建议是:先把数据层写好,用单元测试验证增删改查没问题,再做界面。界面可以先做最简单的版本,能跑通就行,然后再逐步美化。不要一开始就追求完美的UI,功能跑通了,UI随时可以调。
最后分享一个小技巧:在开发阶段,我习惯在Application的onCreate里预置一些测试数据,这样每次安装App都有数据可以看,不用手动录入。发布前把预置数据的代码注释掉即可。
class MyApp : Application() { override fun onCreate() { super.onCreate() // 开发阶段预置测试数据 // seedDatabase() } private fun seedDatabase() { val dao = AppDatabase.getInstance(this).studentDao() CoroutineScope(Dispatchers.IO).launch { dao.insert(Student("2024001", "张三", "男", "计算机1班", 2024, "13800000001")) dao.insert(Student("2024002", "李四", "女", "计算机1班", 2024, "13800000002")) dao.insert(Student("2024003", "王五", "男", "计算机2班", 2024, null)) } } }这个项目虽然叫“学生信息管理系统”,但它的核心模式可以套用到任何管理类App上:库存管理、客户管理、任务管理,本质上都是增删改查加数据持久化。把这套模式吃透,再学其他框架就是触类旁通的事。