ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Android装修服务系统设计:Room建模、状态机与文件适配实战

基于Android装修服务系统设计:Room建模、状态机与文件适配实战 简介基于Android的装修服务系统设计项目源码面向移动应用开发初学者、毕业设计及课程设计人群覆盖客户端、服务端与数据库的完整链路。资源共919个文件压缩包约34MB包含Java/XML源码、class编译文件、JSP页面、APK安装包及SQL脚本等其中大量class与java文件体现业务逻辑实现gif/png/jpg展示界面效果jar与so文件支撑功能库与底层能力。已有41人学习下载适合需要参考实际工程结构、快速搭建同类型应用的人群。包内除可运行的MobileClient.apk外还配有README.md项目说明、数据库脚本及服务器端配置能够帮助理解用户注册登录、服务预约、评价反馈等模块的设计思路以及Android客户端与MySQL、服务端的数据交互方式。1. 装修服务系统先把 Android 端的「业务骨架」想清楚拿到《基于Android的装修服务系统设计.zip》这类交付包第一反应不要是打开 Android Studio 找代码而是先回答一个问题业主打开 App 后到底想解决什么。装修行业最长的一条链是“预约量房 → 报价出图 → 签合同 → 施工 → 节点验收 → 竣工售后”移动端要做的不是把所有环节都塞进 App而是让业主在工地上也能看清单、改方案、确认增项、盯进度。这个标题的落脚点在“系统设计”意味着交付内容不只是几个页面而是一套能被评审、被答辩追问的数据结构和状态流转方案。适合正在做毕设或课设的 Android 工程师也适合想复刻装修 O2O 场景、但对业务建模缺少经验的初级开发者。先理解流程再谈代码否则界面堆得再多也经不起一句“状态从哪来”的追问。2. Android 端数据建模把报价单、工长与施工节点拆成 Room 表2.1 为什么不直接存 JSON而要建 6 张关联表装修服务系统的数据核心是“人—房—单—项”四层关系业主人、施工工长、装修房源、订单、订单下的预算项、施工节点。常见做法是把这些塞进一张大 JSON 里随接口下发本地再原样缓存页面直接读。这种方案在 Demo 阶段很快但一旦进入“增项确认”“节点驳回”“验收拍照留痕”问题就暴露了你无法用一条 SQL 查出某个订单当前处于哪个节点也无法保证多端同时改预算时丢数据。我一般会在 Room 里建 6 张表user、house、decorate_order、budget_item、work_node、node_log。三张主表管主体两张子表管明细与轨迹node_log专门记录每一次状态变更给需要留痕的装修审查留证据。这个设计有明确边界订单不复制装修公司的名称而是用company_id关联预算项不冗余存金额合计而是由明细实时算。表名核心字段作用userid、name、phone业主与工长账号houseid、user_id、address、area房源与户型基本信息decorate_orderid、house_id、company_id、status、start_date装修服务主订单含整体状态budget_itemid、order_id、name、amount、check_status预算与增项明细work_nodeid、order_id、node_type、status、plan_date、real_date施工节点计划与实际完成时间node_logid、node_id、from_status、to_status、op_user、op_time状态变更事实记录提示work_node里的node_type存的是节点编号不是中文名称。中文名称交给客户端对照表做映射避免出现“水电”和“水电工程”这种同义不同词的脏数据。这样拆完后续所有页面查询都变成“订单主表 子表 JOIN”的模式而不是在 JSON 里做内存过滤。Room 的Relation注解可以做一对多装配但我的习惯是保持 DAO 返回扁平结构避免自动装配在复杂查询下产生 N1 请求。2.2 Room 实体与 DAO 的最小可跑代码订单表 节点表先看订单实体。用 Room 2.x 的 Kotlin 写法Entity( tableName decorate_order, foreignKeys [ ForeignKey( entity HouseEntity::class, parentColumns [id], childColumns [house_id], onDelete ForeignKey.CASCADE ) ], indices [Index(house_id), Index(status)] ) data class OrderEntity( PrimaryKey(autoGenerate true) val id: Long 0, ColumnInfo(name house_id) val houseId: Long, ColumnInfo(name company_id) val companyId: Long, ColumnInfo(name status) val status: Int, ColumnInfo(name contract_amount) val contractAmount: Long, ColumnInfo(name start_date) val startDate: Long, ColumnInfo(name end_date) val endDate: Long )这里把house_id和status都建了索引前者支撑“我的房子”页面的按户查询后者支撑“进行中订单”的列表过滤。金额字段用Long而非Double装修报价精确到分避免浮点误差。时间戳用Long存毫秒展示层再格式化成“2026-05-01”排序和区间计算都比字符串快。节点表稍微复杂因为它要同时记录计划和实际时间Entity( tableName work_node, foreignKeys [ ForeignKey( entity OrderEntity::class, parentColumns [id], childColumns [order_id], onDelete ForeignKey.CASCADE ) ], indices [Index(order_id), Index(node_type)] ) data class WorkNodeEntity( PrimaryKey(autoGenerate true) val id: Long 0, ColumnInfo(name order_id) val orderId: Long, ColumnInfo(name node_type) val nodeType: Int, ColumnInfo(name status) val status: Int, ColumnInfo(name plan_date) val planDate: Long, ColumnInfo(name real_date) val realDate: Long? null )realDate用可空类型含义是“还没干完”。这个可空设计很有用所有统计延期、计算工期的 SQL 都拿real_date IS NULL来筛“尚未完成”的节点而不是去对比状态码。对应的 DAO 只暴露三个方法Dao interface WorkNodeDao { Query(SELECT * FROM work_node WHERE order_id :orderId ORDER BY node_type ASC) fun nodesOfOrder(orderId: Long): FlowListWorkNodeEntity Query(SELECT * FROM work_node WHERE order_id :orderId AND status :status LIMIT 1) suspend fun findNode(orderId: Long, status: Int): WorkNodeEntity? Insert(onConflict OnConflictStrategy.REPLACE) suspend fun insert(node: WorkNodeEntity): Long }三个方法分别解决“列表展示”“定位当前节点”“写入新节点”。Flow返回类型让界面自动订阅数据变化改一个节点进度页自动刷新不用手写刷新逻辑。如果不用 Flow 而用 suspend 一次性查询也可以在 Activity 收到返回后手动刷新但多端状态变更同时发生时手动刷新很容易漏掉某一次。2.3 状态字段用 Int 还是 String枚举映射与“防烂码”装修订单状态最容易写烂。有人直接在数据库里存“待开工”后来改了文案叫“等待开工”旧数据全得刷一遍。我一般用Int外加一组常量定义数据库只存数字UI 层做映射object OrderStatus { const val PENDING 0 // 待确认 const val SIGNED 1 // 已签约 const val IN_PROGRESS 2 // 施工中 const val DELIVERED 3 // 已交付 const val CLOSED 4 // 已归档 } fun orderStatusText(status: Int): String when (status) { OrderStatus.PENDING - 待确认 OrderStatus.SIGNED - 已签约 OrderStatus.IN_PROGRESS - 施工中 OrderStatus.DELIVERED - 已交付 OrderStatus.CLOSED - 已归档 else - 未知状态 }对应节点状态建议单独定义NodeStatus不要复用订单状态否则会出现“订单施工中、节点已竣工”的错位。下表是项目里常用的节点状态码一共 6 个正好对应进度条的 6 段状态码含义进度条位置0未开始0/51拆改完成1/52水电验收2/53泥木验收3/54油漆验收4/55安装完成5/5在 Android Studio 里联调时直接看常量定义和node_log表里的from_status/to_status比看一串“待开工”中文字符快得多也更容易写单元测测状态机。3. 施工进度的状态机从「已签约」到「已竣工」的进度条更新机制3.1 进度不是跑一个线程先定义 6 个受限回退状态很多人做进度条第一反应是开一个Handler或者协程循环“每 500 毫秒涨一格”。这是把业务进度和动画进度混为一谈。装修服务系统的进度条本质是对“施工节点状态”的可视化映射水电节点完成进度条才从 1/5 跳到 2/5。没有节点状态变化动画走得再顺都是假的。我把施工链定义为 6 个状态顺序执行但允许“驳回”回到前一个状态所以准确说是“正向推进、受限回退”的线性状态机enum class NodeType( val nodeCode: Int, val displayName: String, val defaultDays: Int ) { PENDING(0, 等待开工, 0), DEMOLITION(1, 拆改, 7), PLUMBING(2, 水电, 10), MASONRY(3, 泥木, 15), PAINTING(4, 油漆, 12), INSTALL(5, 安装, 8) }defaultDays是给工期估算用的参数。新建订单时客户端用这些默认值把 6 个节点批量写入work_node表之后只更新real_date。这样写有一个好处进度页的“计划日期 / 实际日期”可以天然并排展示用户一眼看出哪个节点延期了而不是对着订单的开始时间和结束时间自己算。状态机的迁移规则放在一个纯 Kotlin 类里不依赖 Android 框架方便写 JVM 单元测测object WorkFlow { fun canTransition(from: NodeType, to: NodeType): Boolean { return to.ordinal from.ordinal 1 } fun rejectTarget(from: NodeType): NodeType { return NodeType.entries[maxOf(0, from.ordinal - 1)] } }rejectTarget返回前一个节点对应“业主验收不合格打回重做”的场景。这里故意不提供跨级跳转施工管理里不允许从“等待开工”直接到“油漆”即使后台数据异常客户端状态机也要拦住。3.2 用 Flow 驱动进度条用 WorkManager 做延期提醒页面上的进度条组件可以自定义但数据源一定要来自节点表。常见做法是写一个WorkNodeViewModel用combine或map把当前订单的 6 个节点合成一个进度值class WorkProgressViewModel( private val workNodeDao: WorkNodeDao, private val orderId: Long ) : ViewModel() { val nodeList: StateFlowListWorkNodeEntity workNodeDao.nodesOfOrder(orderId) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val progress: StateFlowInt nodeList.map { list - if (list.isEmpty()) 0 else list.count { it.realDate ! null } * 100 / list.size }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) }progress的计算逻辑是“已完成节点数 / 总节点数 * 100”在原生 Android 进度条上设置setProgress即可。参数说明SharingStarted.WhileSubscribed(5000)表示界面订阅后开始收集页面退到后台 5 秒后自动停止省电如果改成Eagerly则会一直收集 Flow进度通知这种低频场景没必要常驻收集。延期提醒用 WorkManager 比用前台服务更稳。前台服务要常驻通知栏进程被杀后恢复逻辑在各家系统上差异很大WorkManager 由系统调度App 被清理后只要作业还没执行系统会按约束重新调度。下面是每日凌晨检查的周期任务val request PeriodicWorkRequestBuilderNodeRemindWorker(1, TimeUnit.DAYS) .setInitialDelay(1, TimeUnit.DAYS) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( node_remind, ExistingPeriodicWorkPolicy.UPDATE, request )PeriodicWorkRequest 参数建议值作用repeatInterval1 天周期任务间隔不能小于 15 分钟setInitialDelay1 天首次执行的延迟时间setBackoffCriteriaEXPONENTIAL, 30 秒失败后按指数退避重试ExistingPeriodicWorkPolicyUPDATE重复入队时覆盖旧任务避免堆积NodeRemindWorker里只做一件事查出所有plan_date小于当天且real_date为 null 的节点发一条通知。这样即使 App 没打开业主也能收到“水电节点计划今天完成”的提醒。3.3 状态并发冲突两个入口同时改节点怎么办施工过程中存在典型的并发写入业主在 App 里点“确认验收”工长端同时把节点标记为完成两个请求打到同一个节点。如果客户端不设防用户会看到状态先变“已完成”又弹回“进行中”。我一般会在 DAO 里用带条件的 UPDATE 做乐观锁Query( UPDATE work_node SET status :newStatus, real_date :realDate WHERE id :nodeId AND status :expectStatus ) suspend fun updateWithExpect( nodeId: Long, expectStatus: Int, newStatus: Int, realDate: Long ): Int这个 SQL 的逻辑是“只有当前状态等于预期值时才允许更新”返回值是被更新的行数。调用方拿到 0就说明状态已经被别人改过此时不覆盖而是重新查询节点再提示用户“节点状态已刷新”。参数说明expectStatus是乐观锁版本字段的替代品这里的版本就是原状态本身realDate必须与newStatus一起写入避免出现“状态已完工但时间没写”的半截记录。提示库存类系统喜欢单独加一个version字段在装修场景里节点状态本身就携带版本含义直接拿它做乐观锁更直观。后台若真要支持多人协作再加version也不迟。4. 拍照上传、文件预览与 Android 11~14 权限适配的坑位4.1 FileProvider 路径配置content:// URI 才是应用间交换的正确姿势装修服务系统最常用的文件能力是拍照留底开工前拍房屋原状、每个节点验收拍照片、增项确认拍现场。相机拍照的产物不能直接拿file://路径跨应用传递Android 7.0 之后系统会直接抛FileUriExposedException。常见做法是配置 FileProvider把真实路径包装成content://URI 再交给相机应用。在AndroidManifest.xml中声明provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /providerres/xml/file_paths.xml里按需开放目录不要图省事写root-path直接把整个存储暴露出去paths external-files-path namecamera_photos pathPictures/ / external-path namecontract_pdf pathDownload/ / /paths参数说明authorities要跟applicationId绑死不同包名的应用即使代码相同生成的content://com.xxx.fileprovider/...前缀也不同这是避免两个应用互相读取缓存文件的关键grantUriPermissionstrue表示临时授权给目标应用拍照、预览、分享都靠这个临时授权。实际调用时使用FileProvider.getUriForFile生成 URI再把 flag 加进 Intentval uri FileProvider.getUriForFile(context, ${context.packageName}.fileprovider, photoFile) val intent Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, uri) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) }FLAG_GRANT_WRITE_URI_PERMISSION只给相机写权限FLAG_GRANT_READ_URI_PERMISSION给后续读回图片用。二者缺一不可只有写权限时部分相机会在回写照片后无法让系统相册读到缩略图。4.2 相机拍照与相册选择的权限分水岭版本不同节奏不同权限适配是这类系统最烦的坑。下面是按 Android 版本梳理的核对表建议直接贴进开发文档Android 版本相机拍照相册选图进度通知Android 6~10CAMERA 运行时权限READ_EXTERNAL_STORAGE无需权限Android 11 ~ 12CAMERA 即可READ_EXTERNAL_STORAGE 或 SAF无需权限Android 13CAMERA系统照片选择器无需存储权限POST_NOTIFICATIONSAndroid 14CAMERA系统照片选择器可只选部分照片POST_NOTIFICATIONS这张表有个关键结论Android 13 之后“相册选图不再一定要存储权限”优先用系统照片选择器PickVisualMedia。只有“保存图片到相册”才需要写权限而 Android 10 之后写权限也被分区存储限制通常不需要再申请WRITE_EXTERNAL_STORAGE。拍照代码用ActivityResultContracts.TakePicture最省事private val takePicture registerForActivityResult(ActivityResultContracts.TakePicture()) { success - if (success) { // photoUri 指向的图片已经写入可以进入上传队列 } } takePicture.launch(photoUri)TakePicture合约内部已经处理了相机回传的权限边界只要提前构造好photoUri即可。注意photoUri指向的文件必须先创建为空文件且父目录已存在否则部分手机会返回 false 且不写入任何数据。相册选择走PickVisualMedia合约它返回的是一个可持续读取的 content Uri不需要存储权限但要注意从 Uri 读文件时要用contentResolver.openInputStream不能直接拼文件路径。4.3 事件分发装修详情页 banner 与纵向进度列表的滚动冲突详情页是装修服务系统的信息密度之王顶部横向图片 banner下面跟一条施工进度条再往下是各个节点的竖向卡片列表。这种布局最常见的 bug 是“手指在 banner 上横向滑动时整个页面竖向也在抖”根子是 Android 的事件分发机制里onInterceptTouchEvent对纵向滚动容器和横向ViewPager2的争夺。常见做法是把 banner 换成ViewPager2并固定高度外层纵向容器用NestedScrollView或RecyclerView。ViewPager2内部已经用RecyclerView处理了横向滑动所以冲突点集中在“横滑起始角度”的判定上。最稳的解法是不手动拦截而是设置ViewPager2的orientation为横向并确保其高度不超过屏幕一半androidx.viewpager2.widget.ViewPager2 android:idid/banner_pager android:layout_widthmatch_parent android:layout_height200dp /当横向滑动的ViewPager2高度被固定后父容器NestedScrollView几乎不会收到横向 move 事件事件分发自然不冲突。如果 banner 需要包裹内容高度方案是自定义一个不允许纵向参与嵌套滚动的ViewPager2重写onTouchEvent时判断横向位移是否大于纵向位移。还有一个容易忽略的点banner 内的图片本身如果套了普通的单点触摸监听会吞掉点击事件导致进度条节点点击无反应。图片一律用setOnClickListener不要用setOnTouchListener做点击。提示调试事件分发问题时给ViewPager2临时加一行android:overScrollModenever能排除阻尼动画带来的干扰确认无冲突后再决定是否保留。5. 验收这门「设计」用 adb、Monkey 与弱网模拟给项目打分5.1 用 adb shell 模拟进程被杀与数据恢复交付前用 adb 模拟“用户把 App 划掉再打开”的场景比手工反复滑动可靠。先跑一轮 Monkey 随机事件压测再杀进程、重新拉起观察数据是否从 Room 恢复adb shell monkey -p com.example.decoration 500 adb shell input keyevent KEYCODE_HOME adb shell am kill com.example.decoration adb shell am start -n com.example.decoration/.MainActivitymonkey 500产生 500 个伪随机事件用于暴露崩溃和卡顿真正验收恢复逻辑的是后面两条命令am kill只杀后台进程模拟系统回收am start用包名和 Activity 全限定名重新拉起。通过标准App 重新启动后应恢复到上一次浏览的详情页work_node表数据直读不回源。如果进程被杀后页面白屏或数据消失多半是 ViewModel 里直接持有了 Activity 引用或列表数据只存在内存中没有落 Room。5.2 弱网与上传容错用停网命令验证队列重试文件上传是装修服务系统对网络最敏感的路径。用如下命令断网再触发图片上传adb shell svc wifi disable adb shell svc data disable如果设备不允许 adb 直接执行svc命令退一步开飞行模式。断网后上传应进入重试队列网络恢复后自动补传而不是弹一个NetworkOnMainThreadException或者直接闪退。上传组件建议用 WorkManager 加NetworkType.CONNECTED约束失败时设置BackoffCriteria指数退避。验收标准断网 5 分钟重连3 张以上图片全部补传成功且数据库中每张图片的上传状态与服务器一致。5.3 答辩演示路径把验收项变成一张可勾选的自测表如果这是毕业设计或课程设计答辩时不要直接打开 Android Studio 展示代码要展示“验证过什么”。下面这张自测表可以贴在项目文档里演示时按顺序走编号验证场景操作方式通过标准01状态机正向推进依次完成 6 个节点进度条从 0 走到 100%02节点驳回当前节点执行驳回状态回退到上一个节点进度条同步回退03权限最小化验证Android 13 模拟器首次安装通知、相机权限分别在首次使用时弹出04数据持久化关掉 App 再进入订单和节点数据从 Room 恢复无加载闪白05图片补传断网上传 3 张图恢复网络队列自动补传无重复提交演示顺序建议从 04 开始先杀掉进程再恢复证明离线兜底成立再走 01 和 02 展示状态机最后走 05 展示网络容错。整个验收路径在 15 分钟内走完每一条都有数据可查。本文还有配套的精品资源点击获取
返回列表