☰
Android人员管理系统实战:从Room数据库到ViewModel架构
2026/10/13 15:53:23 网站建设 项目流程

简介:基于Android Studio开发的人员管理系统是一套完整的课程设计实践资源,面向Android初学者及高校学生,重点演示了用户登录、SQLite数据库增删改查等核心功能的实现方式。压缩包共包含3291个文件,约85.15MB,以XML界面布局、JSON与Gradle配置、Java/Kotlin源码、DEX编译文件等类型为主,可从中看到项目构建、资源组织与数据库操作的完整结构。资源覆盖登录界面设计、数据验证、SharedPreferences与SQLite持久化、RecyclerView列表展示、Material Design样式适配等环节,同时涉及Gradle构建配置、APK安装调试以及运行时权限、异步处理等进阶知识点,并附带APK及调试文件,便于直接运行对比效果。目前已有1425人学习下载,适合用于课程设计参考、Android入门练手或作为人员管理类应用的开发蓝本。

1. 人员管理系统不是增删改查那么简单:一个值得认真对待的Android实战方向

用Android Studio做一个人员管理系统,听起来是每个初学者都能上手的课设项目:员工表、部门表、加几个按钮,CRUD一套走完,界面能跑就交差。但真正动手后你会发现,这个方向藏着一整套工程问题:联系人的增删改查只是一个壳,系统要能区分管理员和普通员工,要能处理部门调整带来的级联数据,要能在应用被杀掉之后还能恢复未保存的表单,甚至还要考虑不同屏幕尺寸下表格怎样才不挤成一团。这些才是"人员管理系统"这个标题真正值钱的地方。

这篇笔记面向两类读者:一类是准备做课程设计或毕业设计的学生,想把这个题目做得像产品而不是Demo;另一类是刚入行的Android开发,想借这个业务场景把Room、ViewModel、RecyclerView这些组件串起来用。我会把一个最小可用的人员管理系统从选型、建表、写界面到避坑逐个讲清,代码可以直接照抄,参数和边界我会在每段后补明白。

2. 技术选型先把地基打牢:数据库选Room还是SQLite,架构分几层

2.1 数据库方案:SQLite是老熟人,但我优先选Room

人员管理系统的核心数据是员工信息、部门信息和登录账号。数据量不大,常见的做法是直接用Android自带的SQLite,很多教程也是这样教的:写一个SQLiteOpenHelper的子类,在onCreate里执行建表语句,然后通过SQLiteDatabase的execSQL和query做增删改查。这套路在项目只有一两张表、三五个界面时确实很快,但人员管理系统一旦加角色权限、加部门树、加考勤记录,表之间的关系会迅速变复杂,手写SQL的维护成本就上来了。

Room是Google在Jetpack里推出的ORM框架,底层封装的还是SQLite,但它把SQL写进了注解里,编译期就能发现SQL拼写错误,不用等运行到那个方法才崩溃。我用Room的另一个原因是LiveData和Flow支持:数据库里的员工表一变化,界面上的RecyclerView会自动刷新,不用自己手动调adapter.notifyDataSetChanged。对人员管理这种"频繁改动、需要即时刷新"的场景,这个特性非常省心。

选型时还要考虑团队习惯。如果项目组里有人已经写过大量SQLite代码,短期迁移Room有点成本;但如果是从零开始一个新的Android Studio项目,我的建议是直接上Room。下面这一段是Room的依赖配置,放到模块的build.gradle里:

dependencies { def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" // 如果要用协程访问数据库,再加下面这行 implementation "androidx.room:room-ktx:$room_version" }

这段配置里要留意的是版本号。2.6.1是同一系列里较新的稳定版,如果你项目里的compileSdk是34以下,建议把版本降到2.5.x,否则可能出现元数据编译告警。另一点是annotationProcessor这行,很多新手漏掉它,结果Room的注解完全不生效,运行时报"EmployeeDao没有实现类"——这个坑很常见,后面避坑章节会细说。

2.2 架构只分两层还不够,ViewModel把数据和界面隔开

数据库定了,紧接着要定的是代码结构。人员管理系统常见的做法是把Activity当作"什么都能干"的上帝类:在onCreate里建数据库、查数据、绑定ListView、写点击事件。这个写法跑通没问题,但业务一复杂,一个MainActivity冲到上千行,改一处联动逻辑要上下翻半天。

我一般会把项目分成三层:界面层(Activity和Adapter)、数据层(Dao和数据库)以及中间的连接层(ViewModel和Repository)。ViewModel的作用是让数据在Activity重建时不丢失——人员管理里很典型的场景是管理员正在编辑一个员工的信息,手机屏幕一旋转,Activity重新创建,手动在onCreate里重查的数据经常对不上,ViewModel可以缓存员工表单的临时状态。下面是ViewModel的最小写法:

class EmployeeViewModel(application: Application) : AndroidViewModel(application) { private val repository: EmployeeRepository val allEmployees: LiveData<List<EmployeeEntity>> init { val db = AppDatabase.getDatabase(application) val dao = db.employeeDao() repository = EmployeeRepository(dao) allEmployees = repository.getAllEmployees() } fun insert(employee: EmployeeEntity) { viewModelScope.launch { repository.insert(employee) } } }

这段代码里有几个参数值得细说。构造参数传Application而不是一个Context,是为了在进程被杀后AppDatabase还能通过application上下文重新取实例,避免内存泄漏;allEmployees用LiveData包装,界面层直接观察这个数据源,数据库一有变化UI自动跟着变;viewModelScope.launch把插入操作切到后台线程,协程的调度由ViewModel自己管理,不需要在Activity里写线程切换。

Repository这层很多人觉得多余,实际它是给"换数据源"留的后路。现在用Room,以后如果人员数据要同步到服务端,Repository里可以把本地Dao和远程接口合并成一个数据源,ViewModel的代码完全不用动。人员管理系统即便只是单机版,我建议也保留这一层,因为需求方今天说"先做本地版",明天就可能说"加个同步按钮"。

2.3 表结构设计要提前想清楚三条线

人员管理系统的表设计比想象中更讲究。最少要有三张表:员工表、部门表、账号表。员工表里至少要有员工ID、姓名、手机号、职位、部门ID、入职日期、头像路径;部门表要有部门ID、部门名称、上级部门ID;账号表要有关联的员工ID、用户名、密码、角色。如果这三张表之间外键不加约束,删除部门时就会留下大量"无部门"的孤儿员工。

我对表结构的设计建议是:员工和部门用外键关联,部门之间用自关联表示层级;账号和员工用一对一关联。密码默认存加盐后的哈希值,不要存明文。Room里用@Entity注解定义表结构,下面是一张员工表的定义:

@Entity( tableName = "employee", foreignKeys = [ ForeignKey( entity = DepartmentEntity::class, parentColumns = ["deptId"], childColumns = ["deptId"], onDelete = ForeignKey.RESTRICT ) ], indices = [Index(value = ["deptId"])] ) data class EmployeeEntity( @PrimaryKey(autoGenerate = true) val empId: Long = 0, val name: String, val phone: String, val position: String, @ColumnInfo(name = "dept_id") val deptId: Long, @ColumnInfo(name = "hire_date") val hireDate: String, @ColumnInfo(name = "avatar_path") val avatarPath: String? )

字段上要重点看两个约束。第一个是@PrimaryKey(autoGenerate = true),自增主键生成后由Room自动回填到empId字段,插入时不需要手动赋值;第二个是ForeignKey的onDelete设成RESTRICT,意思是如果要删除部门,而这个部门下还有员工,删除操作会被拒绝,这比CASCADE悄悄删掉员工要安全得多——部门误删后员工数据还能保住。indices里的索引建在deptId上,按部门筛选员工时查询速度会快很多,数据量过千后这个索引的效果就能感觉出来。

3. 把人员管理系统拆开做:从建库到列表显示的核心实现

3.1 先建Dao层:CRUD之外还要想好查询条件

Dao层是数据访问的入口,人员管理系统的查询场景比想象中多:按姓名模糊搜索、按部门筛选、按入职时间排序、判断手机号是否已存在。这些都要在Dao里写成方法,而不是每次在Activity里拼SQL字符串。Room的查询注解在编译期会做语法校验,写错字段名编译直接失败,这是它比手写SQLite强的地方。

@Dao interface EmployeeDao { @Query("SELECT * FROM employee ORDER BY hire_date DESC") fun getAllEmployees(): LiveData<List<EmployeeEntity>> @Query("SELECT * FROM employee WHERE name LIKE '%' || :keyword || '%'") fun searchByName(keyword: String): LiveData<List<EmployeeEntity>> @Query("SELECT * FROM employee WHERE dept_id = :deptId") fun getEmployeesByDept(deptId: Long): LiveData<List<EmployeeEntity>> @Query("SELECT * FROM employee WHERE phone = :phone LIMIT 1") suspend fun findByPhone(phone: String): EmployeeEntity? @Insert suspend fun insert(employee: EmployeeEntity): Long @Update suspend fun update(employee: EmployeeEntity) @Delete suspend fun delete(employee: EmployeeEntity) }

几个要点说清楚。searchByName里用了LIKE拼接,参数keyword会被Room自动转义,不需要担心SQL注入;findByPhone配合LIMIT 1,手机号重复判断时只需要取到一条记录就够了;insert方法的返回值是Long,这正是新插入员工的empId,后续要生成账号表关联时可以直接用这个值。所有方法都加了suspend,强制调用方在协程里执行,避免误放到主线程卡顿。

3.2 数据库连接与升级:单例写法是标准配置

人员管理系统的数据库实例在整个应用里应该是唯一的。如果每个Activity都调用Room.databaseBuilder创建一次,数据库连接会越开越多,最终出现"数据库被锁"的异常。标准的做法是把数据库实例放在一个类里用静态方式持有,应用进程存活期间只初始化一次。

@Database( entities = [EmployeeEntity::class, DepartmentEntity::class, AccountEntity::class], version = 1, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun employeeDao(): EmployeeDao abstract fun departmentDao(): DepartmentDao abstract fun accountDao(): AccountDao 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, "employee_management.db" ).build().also { INSTANCE = it } } } } }

@Database注解里的version参数要特别留意,它的意思是数据库结构版本。第一次建库填1,以后只要表结构有改动(新增字段、新增表),这个版本号就要加1,并且要写Migration迁移代码,否则老用户升级App后会直接崩溃。exportSchema设为false是因为这个项目不需要导出数据库结构给外部工具检查,省掉一个编译告警。

getDatabase方法的写法是一道经典的单例模板。@Volatile保证多线程下INSTANCE变量的可见性,synchronized锁住整个初始化块,防止两个线程同时创建实例;databaseBuilder传入的是context.applicationContext而不是Activity的this,这样数据库实例不会持有Activity的引用,避免内存泄漏。数据库文件名employee_management.db会在应用的私有目录里自动创建,不需要手动去assets里放初始数据。

3.3 界面层:用RecyclerView把员工列表装起来

ListVew在人员管理系统里还能用,但RecyclerView已经成为主流,因为它的ViewHolder复用机制在列表滚动时性能好得多。员工列表需要展示姓名、部门和职位,还要根据角色决定长按或点击打开不同的操作面板。RecyclerView需要一个Adapter和一个ViewHolder,下面是Adapter的核心骨架:

class EmployeeAdapter( private val clickListener: (EmployeeEntity) -> Unit ) : ListAdapter<EmployeeEntity, EmployeeAdapter.EmployeeViewHolder>(DiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmployeeViewHolder { val binding = ItemEmployeeBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return EmployeeViewHolder(binding) } override fun onBindViewHolder(holder: EmployeeViewHolder, position: Int) { val employee = getItem(position) holder.binding.tvName.text = employee.name holder.binding.tvDept.text = "部门ID: ${employee.deptId}" holder.binding.tvPosition.text = employee.position holder.itemView.setOnClickListener { clickListener(employee) } } private class DiffCallback : DiffUtil.ItemCallback<EmployeeEntity>() { override fun areItemsTheSame(oldItem: EmployeeEntity, newItem: EmployeeEntity) = oldItem.empId == newItem.empId override fun areContentsTheSame(oldItem: EmployeeEntity, newItem: EmployeeEntity) = oldItem == newItem } }

这里有两个容易被忽略的点。第一,继承的是ListAdapter而不是RecyclerView.Adapter,它内部带有DiffUtil的计算,调用submitList时只会刷新变化的行,而不是整个列表重绘,员工数量几百人时区别不明显,但上千人后滚动和刷新流畅度差一个档次。第二,clickListener通过构造参数传进来,Adapter不关心点击后跳到哪个页面,具体的跳转逻辑放在Activity里,这样Adapter可以被多个页面复用。

onBindViewHolder里用字符串模板直接拼"部门ID: ${employee.deptId}"只是临时方案,实际项目中这里应该查一下部门表把ID换成部门名称。我一般的做法是先在Activity层把部门表的数据load成Map<Long, String>,再传给Adapter,这样Adapter里不用访问数据库,保持界面层的数据单向流动。

3.4 组装流程:从Activity到观察者模式的一整条链路

数据库、Dao、ViewModel、Adapter都准备好了,最后一步是在Activity里把它们串起来。流程是:拿到ViewModel的LiveData,调用observe方法监听,数据一变就提交给Adapter,Adapter自动计算差异刷新界面。以下是MainActivity里的关键代码:

class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var adapter: EmployeeAdapter private lateinit var viewModel: EmployeeViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) viewModel = ViewModelProvider( this, AndroidViewModelFactory(application) )[EmployeeViewModel::class.java] adapter = EmployeeAdapter { employee -> // 点击跳转员工详情页面 val intent = Intent(this, EmployeeDetailActivity::class.java) intent.putExtra("empId", employee.empId) startActivity(intent) } binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.adapter = adapter viewModel.allEmployees.observe(this) { employees -> adapter.submitList(employees) } } }

ViewModelProvider里用的工厂是AndroidViewModelFactory,因为自定义的ViewModel构造参数是Application类型,默认的ViewModelProvider.NewInstanceFactory不会传这个参数,会直接报"无法实例化ViewModel"的异常。observe的生命周期绑定在this上,Activity销毁时观察者自动移除,不会发生内存泄漏。

这套链路跑通后,整个数据流是单向的:数据库变化 → Room发出通知 → LiveData活跃 → observe回调 → submitList刷新界面。人员管理系统的增删改查都只需要在ViewModel里调对应方法,界面不用关心数据从哪来。后面要加"删除员工"功能,只需在Adapter加一个长按事件,然后调用viewModel.delete(employee)就可以,成本很低。

4. Android人员管理系统避坑实录:4个常见问题与排查方法

4.1 主线程操作数据库导致卡顿甚至ANR

现象:在人员管理界面做一次插入操作,界面卡住两三秒,严重时弹出"应用程序无响应"的对话框。

原因:Room默认不允许在主线程访问数据库。虽然手写SQLite时不拦着,可以让主线程执行query,但人员管理系统的数据量增长后(尤其加了部门树和账号表之后),全表查询和事务操作会阻塞UI渲染,导致卡顿。

解决:所有数据库操作放到子线程。我在上面的代码里已经把所有Dao方法定义成suspend,配合viewModelScope.launch使用,Room会在内部自动切换到子线程执行。还有一个检测办法:开发阶段在build.gradle里开启StrictMode,一旦检测到主线程的磁盘IO操作,日志会立刻打红色警告,比ANR弹窗更容易定位。

4.2 屏幕旋转后表单数据丢失,录入到一半的员工信息全没了

现象:管理员正在添加一个新员工,填到手机号时屏幕旋转,界面重建,之前填的姓名和职位全变成空白。

原因:Activity重建后所有成员变量重新初始化,EditText里的内容如果没有特意保存,生命周期走完就丢了。

解决:这是ViewModel最典型的使用场景。把EditText的输入内容放到ViewModel里,用afterTextChanged监听实时更新,Activity重建后从ViewModel恢复这些值。如果项目里没接ViewModel,退一步可以用onSaveInstanceState存Bundle,但那只适合少量简单字段,内容一多代码会很乱。

4.3 删掉一个部门后,界面上的员工部门名称显示异常

现象:删除部门时因为外键约束RESTRICT生效,有员工的部门根本删不掉,但有的项目把它改成CASCADE,结果员工被自动删掉,数据信息一片狼藉。

原因:外键约束策略没想清楚。RESTRICT是强保护,CASCADE是级联清理,中间还有SET_NULL策略。人员管理系统的业务场景是"部门没了人还在",员工需要被移到待分配部门,而不是被删除。

解决:把外键的onDelete设成SET_NULL,但前提是部门ID字段要允许为空(类型要改成Long?)。我在2.3节里给的示例用的是RESTRICT,那是数据安全优先的做法。实际业务中更灵活的是先检查员工表里有没有挂在该部门下的记录,有就先修改这些员工的deptId,再删除部门,这个判断逻辑放在ViewModel里比较合适。

4.4 Room建表版本升级没写迁移,老用户升级后直接崩溃

现象:开发阶段改过一次表结构(比如给员工表加了一个邮箱字段),把version从1改成2,然后安装旧版本App,覆盖安装新版本,一打开就闪退,日志里报RoomMigrationException。

原因:Room只认version和Migration列表。version变了但没提供从1到2的迁移方案,它就不知道新表长什么样,直接抛异常。

原因:Room只认version和Migration列表。version变了但没提供从1到2的迁移方案,它就不知道新表长什么样,直接抛异常。

解决:版本升级时添加一个Migration对象,在迁移回调里执行ALTER TABLE语句:

val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE employee ADD COLUMN email TEXT") } }

然后在databaseBuilder后面追加.addMigrations(MIGRATION_1_2)。特别提醒:开发阶段如果数据库里的数据不重要,直接卸载App重装比写迁移快得多;但App一旦分发出去,迁移代码就必须写完整,这是上线前要反复检查的点。还有一个容易漏的:只升version不写迁移,测试机上可能因为"碰巧没装过旧版"而测不出来,这正是数据库升级最容易被忽视的一处玄学。

5. 把人员管理系统做得更像产品:三个值得立即用上的进阶技巧

5.1 用ViewModel缓存未提交的表单,旋转屏幕再也不丢数据

我在避坑章节讲了表单数据丢失的问题,这里给出一个标准化做法。在ViewModel里定义一个可观察的字段,比如"当前正在编辑的员工草稿",界面的EditText通过监听器实时把内容写进ViewModel,Activity销毁时ViewModel实例还活着,重建后只要在onCreate里从ViewModel把草稿值填回EditText即可。

实现上可以不用LiveData,直接用一个普通class保存草稿对象,然后在onSaveInstanceState里只存一个"当前是否有草稿"的布尔值。这样做的收益是逻辑清晰:页面只负责展示和收集输入,数据状态归ViewModel管,人员管理这种"录入密集型"界面用这个方案改动最小、效果最明显。

下面是一个简单草稿类的写法:

class EmployeeDraft { var name: String = "" var phone: String = "" var position: String = "" var deptId: Long = -1L }

在Activity里创建两个实例,一个draft用来接收EditText输入,另一个savedDraft在onSaveInstanceState时保存draft的副本。重建后用savedDraft恢复表单,然后清空savedDraft。这套逻辑不需要引入额外的缓存库,处理表单页面足够。

5.2 列表刷新用DiffUtil,别再用notifyDataSetChanged

员工列表每次回到页面都全量刷新,用notifyDataSetChanged是能跑,但列表滚动位置会跳到顶部,用户刚翻到第50个人,回来一看又回到第1个。改用ListAdapter后这个行为会自然消失,因为DiffUtil只会精确刷新变化的那几行。注意submitList一定要在数据真正变化时调用,如果在onResume里每次都无条件submit,DiffUtil虽然会做判断,但频繁调用仍然有额外开销。

我习惯把submitList封装在Adapter内部,对外只暴露一个updateData方法,接收新的List,内部去判断旧列表和空列表的情况。这样界面层调用的只有一行代码:adapter.updateData(employees)。如果要从列表里删除一个员工,也走专门的deleteItem方法,避免界面层直接修改列表引用造成DiffUtil误判。

5.3 记住当前登录状态,App重启后不用重新登录

人员管理系统通常有个登录页,但每次冷启动都要重新输密码非常影响使用体验。常见做法是把登录成功的会话信息存进SharedPreferences,存的值不要用明文密码,而是存一个随机生成的会话令牌,同时记录当前登录的员工ID和角色。App启动时先检查本地会话是否存在,存在就跳主页,不存在才显示登录页。退出登录时清掉这些字段。

一个要注意的细节是:SharedPreferences的提交要用apply而不是commit。commit是同步写磁盘,在UI线程调用会造成轻微卡顿;apply是异步写,速度更快。登录会话这种不关心写入时机的轻量数据,用apply完全够。如果安全意识再强一点,可以把会话令牌用EncryptedSharedPreferences加密存储,只是这个库依赖额外的security组件,不做强制要求。

做完这几个打磨,这个人员管理系统在功能完整度上就已经相当像一个正规应用了:数据有缓存、界面不丢状态、刷新不闪跳、重启免登录。回看整个过程,最花时间的其实不是CRUD本身,而是这些"细节坑"——它们才是决定这个项目是否值得写进简历的分水岭。坦白说,我第一次做人员管理系统时,也栽过数据库版本升级的跟头,那种"开发时一切正常、交付后一升级就崩"的教训,比任何教程都记得牢。希望这一篇能帮你在动手前就把这些坑绕过去,把时间留给真正有意义的功能设计。

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

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

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

立即咨询