家政管理系统App开发实战:Room数据库与订单状态机设计

家政管理系统App开发实战:Room数据库与订单状态机设计 简介面向毕业设计及课程设计场景的家政管理系统APP项目源码包适合Android开发方向学生参考完整项目结构与业务实现。资源包含Android客户端与服务端相关代码涵盖用户、服务、订单、评价、支付等模块设计可帮助理解预约家政、服务分类、在线支付等功能的落地方式。压缩包共1728个文件以xml界面布局、png图片资源、json数据配置、java源码为主另含gradle构建脚本、aar依赖库及gradlew编译脚本整体约3.52MB层次清晰便于按模块查阅。目前已有60人学习下载。对需要完成类似毕业设计或课程设计的开发者而言这套项目在需求分析、系统设计、编码实现、测试优化等方面均有参考价值可在此基础上进行二次开发与功能扩展。1. 家政管理系统App一个选题为什么能变成完整毕业设计在计算机类毕业设计题库里家政管理系统app出现频率极高。原因很实际业务边界清晰用户、家政人员、服务项目、订单四类核心对象不会在演示前被需求发散拖垮技术难度也适中Android端做列表、表单、状态流转数据层用SQLite或Room完全能闭环不需要依赖第三方后端。适合目标是“从需求到交付一条线走通”的学生也适合想用一两个周末把老旧课程设计重构成现代工程的一线开发者。这篇文章要给的是从建表开始在一台Android Studio开发环境里把家政管理系统App从空工程推到可安装的签名APK重点放在数据模型、预约状态机和打包前最容易被扣分的三处细节。2. 数据模型先行家政App的表结构设计与Room选型2.1 为什么先定表结构再碰界面很多课程设计项目最先打开的是activity_main.xml拖几个按钮就开始写逻辑结果写到订单列表时发现缺字段、少关联只能回头改表。家政管理系统App的核心不是界面多花哨而是“谁能以什么身份看到哪些数据”所以表结构必须先能回答三件事用户登录后属于哪个角色预约单关联到了哪个家政人员和哪项服务订单状态从提交到完成经过了哪些可追溯的迁移。在选型上我一般直接用Room原因不是它比原生SQLite快而是它在编译期检查SQL、支持协程挂起函数、还自带Migration机制适合中期改表。原生SQLiteOpenHelper也能做但需要手写建表、升级、游标转对象放在毕业设计代码量里会占掉不少篇幅。Room会把这三件事收进DAO接口让后面写ViewModel时只需要调用挂起函数不用在业务层出现SQL字符串。2.2 四张核心表之间的关系与字段约束家政管理系统App需要的最小集合是四张表用户表、家政人员表、服务项目表、订单表。如果需要做评价再加一张评论表为了控制第一版复杂度下面的设计先把信用评价字段挂在订单上演示时也够讲清楚。表名用途关键字段角色tb_user登录账号与角色id, phone, password, role普通用户 / 管理员tb_worker家政服务人员资料id, name, service_type, price_per_hour工作人员tb_service可预约的服务项目id, name, duration, price定价tb_order预约与履约记录id, user_id, worker_id, service_id, status, create_time, remark用户 / 管理员这里要注意role字段建议用整型0代表普通用户1代表管理员。用字符串“user”“admin”当然能读但整型在MultiSelect和权限判断时更省事也更难手滑写错。tb_worker.service_type存的是服务项目名称例如“日常保洁”“家电清洗”这样列表页可以按分类筛选而不需要额外JOIN代价是服务项目重命名时需要同步更新课程设计里这个风险可以接受。从关系上看订单和用户、家政人员、服务项目分别是一对多关系。Room里可以只写逻辑关联不强制定义外键约束因为Android端删除用户时如果外键设置了CASCADE订单会连坐删除演示时容易显得数据“不翼而飞”。如果你更愿意保留外键务必在Entity上写清onDelete ForeignKey.CASCADE并在代码里用deleteAllOrdersByUser(userId)做二次确认。2.3 用Room定义实体与DAO的具体写法工程里新建data包然后添加User、Worker、Order三个实体。下面是一个可直接放进Android Studio的Kotlin示例Entity(tableName tb_user) data class User( PrimaryKey(autoGenerate true) val id: Int 0, val phone: String, val password: String, ColumnInfo(name role) val role: Int 0 ) Entity(tableName tb_worker) data class Worker( PrimaryKey(autoGenerate true) val id: Int 0, val name: String, ColumnInfo(name service_type) val serviceType: String, ColumnInfo(name price_per_hour) val pricePerHour: Double ) Entity(tableName tb_order) data class Order( PrimaryKey(autoGenerate true) val id: Int 0, ColumnInfo(name user_id) val userId: Int, ColumnInfo(name worker_id) val workerId: Int, ColumnInfo(name service_id) val serviceId: Int, val status: Int 0, ColumnInfo(name create_time) val createTime: Long System.currentTimeMillis() )这里的PrimaryKey(autoGenerate true)让主键由Room生成插入时返回的Long就是新记录ID后续更新状态要用它定位。role默认0意味着新注册账号默认是普通用户。createTime用Long而不是Date是因为存Long在排序和格式化时都更直接列表页可以用SimpleDateFormat转成“2025-03-12 14:30”这类显示。对应的DAO接口如下Dao interface OrderDao { Insert suspend fun insert(order: Order): Long Query(SELECT * FROM tb_order WHERE user_id :userId ORDER BY create_time DESC) suspend fun getOrdersByUser(userId: Int): ListOrder Query(UPDATE tb_order SET status :newStatus WHERE id :orderId AND status :expectStatus) suspend fun updateStatusConditional(orderId: Long, expectStatus: Int, newStatus: Int): Int Query(SELECT * FROM tb_order WHERE status :status) suspend fun getOrdersByStatus(status: Int): ListOrder }DAO里比较关键的是updateStatusConditional这个带期望状态的更新它把“先查后改”合并成一条条件更新语句。返回值是受影响行数如果并发状态下Order已经被人改过返回0ViewModel就可以拒绝这次迁移避免用户端重复接单或重复取消。这叫乐观锁不需要引入额外并发框架。2.4 建表时容易忽略的三个参数约束第一字段默认值不要乱设。tb_order.status默认0代表“已预约”在插入时必须保证调用方没有覆盖成空值。Room默认会给不可空类型生成NOT NULL约束但如果使用原生SQLite建表记得在所有业务字段后写NOT NULL和DEFAULT。第二price_per_hour用DOUBLE会出现精度误差SQLite里建议存成整型“分”界面再除以100显示元。第三表名和字段名统一用下划线风格Java/Kotlin侧用驼峰映射配合ColumnInfo能让SQL更接近自然语言打印SQL日志时也容易排查。检查数据时用Android Studio的App Inspection可以直接查表不需要把数据库导出来。3. 最小可运行版本从Android Studio空工程到家政App登录闭环3.1 确认Gradle依赖Room、Material与ViewBinding打开Android Studio新建Empty Views Activity项目语言选Kotlin。在app/build.gradle.kts里加入Room和Material组件dependencies { implementation(androidx.core:core-ktx:1.10.1) implementation(com.google.android.material:material:1.9.0) implementation(androidx.room:room-runtime:2.6.1) implementation(androidx.room:room-ktx:2.6.1) kapt(androidx.room:room-compiler:2.6.1) implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1) }如果项目不是kts而是Groovy对应地把implementation改成分号写法。room-ktx提供挂起DAO支持lifecycle-viewmodel-ktx提供viewModelScope。配置完点击Sync确认顶部没有红色报错再继续。新版本Android Studio会提示你启用KSP而不是kaptRoom同时支持两者课程设计里沿用kapt最不容易和教程出现版本错位。3.2 用SharedPreferences维持登录态的最小实现家政管理系统App的第一屏通常是登录页所以先做SessionManager把登录用户的信息存进应用私有SharedPreferencesclass SessionManager(context: Context) { private val prefs context.applicationContext .getSharedPreferences(session_prefs, Context.MODE_PRIVATE) fun saveLogin(userId: Int, role: Int) { prefs.edit().putInt(user_id, userId).putInt(role, role).apply() } fun getUserId(): Int prefs.getInt(user_id, -1) fun getRole(): Int prefs.getInt(role, 0) fun isLoggedIn(): Boolean getUserId() ! -1 fun logout() { prefs.edit().remove(user_id).remove(role).apply() } }这里用applicationContext而不是Activity作Context是为了防止内存泄漏。apply()是异步写内存再落盘适合这种小数据如果介意时序可以改用commit()但主线程上会有轻微阻塞。登录页拿到用户输入的手机号先查tb_user里是否有对应记录再对比密码成功后才调用saveLogin然后跳转到首页。管理员角色则在首页显示“订单管理”入口普通用户只显示“我的预约”。3.3 在列表页接上真实数据别再用静态数组列表页最常见的问题是拿ArrayAdapter硬编码几条测试数据冒充后台。正确做法是把数据放在数据库里首页登录后立刻从Room读取家政人员并展示。先创建一个MainViewModelclass MainViewModel(application: Application) : AndroidViewModel(application) { private val workerDao AppDatabase.getInstance(application).workerDao() private val _workers MutableLiveDataListWorker() val workers: LiveDataListWorker _workers fun loadWorkers() { viewModelScope.launch { _workers.value workerDao.getAllWorkers() } } }然后用RecyclerView展示。列表项布局里至少要有姓名、服务类型、每小时价格三个TextView。如果没装过数据库这里会遇到“数据库为空”所以最好在AppDatabase初始化后插入几条示例数据。一个稳妥的做法是登录页判断tb_worker里没有记录时用后台线程批量插入private fun seedWorkers() { viewModelScope.launch(Dispatchers.IO) { if (database.workerDao().count() 0) returnlaunch database.workerDao().insertAll( Worker(0, 王阿姨, 日常保洁, 50.0), Worker(0, 李师傅, 家电清洗, 80.0), Worker(0, 赵阿姨, 收纳整理, 60.0) ) } }seedWorkers()放在登录Activity的onCreate里调用能保证第一次进入首页就有数据可看。要注意Worker主键传0不会影响自增Room会自动分配新ID。3.4 第一个能跑通的家政App闭合了什么到这一步用户注册、登录、首页列表三个环节已经打通。注册页只需要向tb_user插入一条新数据登录页使用DAO按手机号查询首页读取家政人员。接下来再去加订单、接单、评价就不会出现“界面做完了但数据不知道怎么填”的尴尬。把APK装上真机或模拟器时常见报错是Cannot access database on the main thread那是因为DAO需要在协程或线程里执行另一个是Kapt: Unresolved reference多半是依赖坐标版本不一致把整个room-compiler版本改成和运行时一致即可。4. 订单状态机家政预约在ViewModel层如何可靠流转4.1 五个订单状态与谁可以触发迁移家政管理系统App预约业务最容易被忽视的是状态迁移。如果只在点击按钮时把status改成目标值会出现“已完成的订单还能被取消”“用户自己把订单状态改成服务中”这类演示翻车现场。我习惯把状态抽成一组常量统一放在OrderStatus中管理object OrderStatus { const val BOOKED 0 // 已预约等待管理员接单 const val ACCEPTED 1 // 已接单等待上门 const val IN_SERVICE 2 // 服务中 const val DONE 3 // 已完成 const val CANCELED 4 // 已取消 }状态迁移关系如下表当前状态可迁移到触发方必要条件BOOKEDACCEPTED / CANCELED管理员接单用户取消订单未支付、未开始ACCEPTEDIN_SERVICE / CANCELED管理员开始服务管理员取消用户未到场可以取消IN_SERVICEDONE家政服务人员点击完成服务时长已经记录DONE无不可再迁移订单结束CANCELED无不可再迁移订单作废4.2 为什么状态存整型常量而不是中文字符串直接把状态的字符串写进数据库在短平快项目里特别诱人调试时一眼就能看懂。但问题在于后续展示会散落一堆魔法字符串比如判断“是否进行中”要写status 服务中 || status 已接单一旦繁体文案、标点录入不一致条件就静默失效。整型常量则把比较收敛到一处数据库里永远是0到4的数字UI层用一个when(status)映射到界面文案。这样也方便做搜索WHERE status BETWEEN 1 AND 2就能筛出待上门和正在服务中的订单。4.3 用条件更新避免重复接单在ViewModel中处理“接单”动作时正确写法是直接操作DAO提供的条件更新class OrderViewModel(application: Application) : AndroidViewModel(application) { private val orderDao AppDatabase.getInstance(application).orderDao() fun acceptOrder(orderId: Long, onResult: (Boolean) - Unit) { viewModelScope.launch { val affectedRows orderDao.updateStatusConditional( orderId orderId, expectStatus OrderStatus.BOOKED, newStatus OrderStatus.ACCEPTED ) onResult(affectedRows 0) } } fun completeOrder(orderId: Long, onResult: (Boolean) - Unit) { viewModelScope.launch { val affectedRows orderDao.updateStatusConditional( orderId orderId, expectStatus OrderStatus.IN_SERVICE, newStatus OrderStatus.DONE ) onResult(affectedRows 0) } } }这里的expectStatus参数尤其重要。两个管理员同时点击同一笔单子的“接单”按钮SQLite只会让其中一条UPDATE命中另一条影响行数为0UI层收到false后可以弹提示“该预约已被接走”。相比先SELECT再UPDATE条件更新把判断放进数据库事务避免了两个请求间的时间差。Room的挂起函数默认运行在其内部事务中不需要额外加Transaction。4.4 状态迁移的边界校验写在UI还是ViewModel状态规则放哪一层决定你以后改逻辑时的成本。我会把“是否允许迁移”的判断放在ViewModel里DAO只负责执行条件更新Activity/Fragment只收集用户点击事件并显示结果。例如用户点击“取消订单”时ViewModel先判断当前订单是否还没进入IN_SERVICE再调条件更新。这样做的好处是将来如果改成后台管理端也能改状态只需要复用同一个ViewModel不会出现UI层和后台逻辑各写一套规则。另外需要注意createTime的记录时机。BOOOKED - ACCEPTED时应该写入accept_time虽然这个字段不在最初的四表字段里但后续计算“接单耗时”和“服务完成率”时都要用到。给tb_order加一个accept_time TEXT字段迁移时更新即可没有加字段前不要空着否则统计报表里会出现大量NULL。5. 打包前要过的三个坎权限、Android版本差异与数据库升级5.1 Android 6动态权限与Android 12闹钟权限的区别家政管理系统App里最常用的权限是拨打电话和读取相册分别对应“联系家政人员”和“上传凭证”。Android 6.0起危险权限必须在运行时申请只写Manifest不再够用。以拨打电话为例if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) ! PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.CALL_PHONE), REQUEST_CALL_PHONE ) } else { callWorker(phoneNumber) }申请回调里不要只判断grantResults[0]还要看shouldShowRequestPermissionRationale用于决定是否提示用户去设置页手动开启。Android 12开始SCHEDULE_EXACT_ALARM被列为特殊权限如果家政系统要做“服务前15分钟提醒”不能直接requestPermissions要跳转ACTION_REQUEST_SCHEDULE_EXACT_ALARM。课程设计如果时间紧张可以用AlarmManager的setWindow而非setExact避开特殊权限检查。5.2 明文HTTP与外部存储限制课程设计最常见的崩溃源后台管理系统如果约定用联发STS接口或自建ServerAndroid 9起默认禁止明文HTTP。调试时最容易在OkHttp回调里看到UnknownServiceException: CLEARTEXT communication not permitted。两个选择如果后台只在内网演示给network_security_config.xml只放开内网IP是干净做法如果图省事在AndroidManifest.xml的application标签加usesCleartextTraffictrue但答辩老师问“为什么不安全”时需要能回答。读写文件也建议只走getExternalFilesDir()或filesDir而不是Environment.getExternalStorageDirectory()。Android 11对分区存储有强限制课程设计里备份数据库或导出订单Excel时将文件写到应用专属外部目录不需要申请存储权限效果也最稳定。如果一定要保存到Download目录要用MediaStore.Downloads的createWriteRequest那套API对新手不太友好。5.3 数据库升级Migration与数据不丢的取舍运行中的App一旦改了实体字段Room会直接崩溃并提示IllegalStateException: Room cannot verify the data integrity。跳过崩溃最简单的手段是fallbackToDestructiveMigration()但用户数据全被清空答辩时如果展示“升级后数据还在”会成为加分点所以还是应该写Migrationval MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE tb_order ADD COLUMN accept_time INTEGER DEFAULT 0) } }在构建Room.databaseBuilder(...).addMigrations(MIGRATION_1_2).build()后旧数据会保留新增字段用默认值填充。注意ALTER TABLE只能加字段不能改字段名或删字段。如果某次需求改动很大更稳妥的方案是“新建临时表-复制数据-删除旧表-重命名”但那样Migration体量会变大课程设计阶段不建议频繁做。5.4 用adb验证安装结果和初次启动日志打包Build Generate Signed Bundle / APK后在命令行里执行下面这几条能确认APK是否真的装进模拟器、启动时有没有白屏或SQL报错adb install -r app-release.apk adb shell am start -n com.example.homeclean/.MainActivity adb logcat -s AndroidRuntime:E Room:V SQLiteLog:V-r表示覆盖安装保留应用数据am start负责拉起主界面后面-n后的组件名要与你实际包名和Activity路径一致。如果日志里出现SQLiteConstraintException说明插入数据违反NOT NULL约束回到实体类补默认值如果出现PackageManager相关报错多半是签名冲突卸载重装即可。把adb logcat日志导出txt答辩时还能作为测试证据。6. 把毕业设计做成作品五大验证点与最后一道检查第一点冷启动进入首页的耗时。家政管理App启动时需要读tb_worker列表如果数据库里只塞三条示例数据看不出性能问题。准备一百条测试数据在RecyclerView首屏加载时观察是否掉帧。我一般会在onCreate里打印System.currentTimeMillis()首帧时间超过800ms就要考虑把loadWorkers()放到lifecycleScope而不是主线程。第二点非空校验与输入过滤。注册页只限制手机号长度远远不够TextView要设置inputTypephone后端再做一次Regex校验。家政人员价格输入要禁止负数和超过两位小数用InputFilter或TextWatcher都行推荐前者代码量小class DecimalInputFilter(private val maxDigits: Int) : InputFilter { override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { val newText dest.subSequence(0, dstart).toString() source.subSequence(start, end) dest.subSequence(dend, dest.length) return if (newText.matches(Regex(^\\d{0,3}(\\.\\d{0,2})?$))) null else } }第三点订单状态在跨页面返回时是否同步。用户从订单详情页返回列表列表页数据可能还是旧状态。常见做法是在onResume里重新执行一次loadOrders()比依赖LiveData自动刷新更直观也能减少一次数据库观察者注册。主动刷新方法适合数据量不大的课程设计数据量超过五百条再考虑PagingSource。第四点管理员端权限验证。不能只靠隐藏按钮来阻止普通用户进入管理界面因为只要你往页面跳转时带上用户ID一个改包工具就能绕过UI。在管理Activity的onCreate里检查SessionManager.getRole()1不满足就finish()并弹出Toast提示“无权限”。这个检查虽然简单但能证明你理解客户端权限只是第一道防线。第五点数据库文件能导出。答辩时如果老师问“你的数据存在哪”你需要能现场拿出数据库路径。执行以下命令查看adb shell run-as com.example.homeclean cat databases/homeclean.db homeclean_backup.dbrun-as只在debug签名下对同一包名有效release包会拒绝执行。所以日常验证用debug包答辩演示用release包。如果换成adb root需要模拟器开启Root实际演示时不如run-as方便。最后在AndroidManifest.xml里加一句android:allowBackupfalse避免旧设备备份机制把登录态带出去这也是上架前一个不起眼但容易被安全测试抓住的点。本文还有配套的精品资源点击获取