资讯动态

智慧医疗预约挂号Android端设计与高并发实践

发布时间:2026/9/16 21:42:16 来源:尧图企业网站定制
简介基于Android的智慧医疗预约挂号系统设计项目面向毕业设计、课程设计与期末大作业场景适合Android开发学习者参考涵盖从需求分析到界面实现与接口交互的完整流程。压缩包共114个文件大小约4.86MB包含java源码、xml布局、gradle构建脚本、项目报告docx及图片资源等目录组织便于直接阅读与调试。目前已有33人学习下载适合需要快速搭建医疗预约类App框架的读者。项目采用MVC模式构建Android客户端配合Java后端与MySQL数据库实现注册登录、挂号预约、排班查看、支付与消息推送等功能并强调HTTPS传输与数据加密。附带的项目报告docx详细记录了需求分析、数据库设计、接口设计与测试用例可帮助理解整套系统的前后端协作方式也能为同类设计报告提供写作思路。1. 为什么智慧医疗预约挂号的 Android 端要单独做设计早上八点整门诊楼的号源池被瞬间清零后台预约接口十秒内被同一个科室的请求打满——这是智慧医疗预约挂号系统上线后遇到的第一道坎。基于 Android 的智慧医疗预约挂号系统设计核心不只是把窗口挂号搬到手机上而是要在放号时段承受高并发的同时保证同一个号不会被两个人挂走让患者端看到的科室列表、排班日期和余号数字始终与后台一致。这套设计覆盖「号源数据、后端服务、Android 客户端」整条链路客户端决定了大部分体验页面加载快不快、弱网下会不会重复扣费、余号和后台能不能对得上。适合正在做医疗信息化项目的 Android 工程师也适合拿这个课题做系统设计或课程作业的开发者。方案不绑定特定云厂商后端接口协议定好之后用 Android Studio 加一组常规 REST 服务就能落地技术栈横向怎么选都不影响主链路。2. 号源排班的数据模型与并发控制设计预约挂号系统的命门是「号源」这个有限资源。平时一个科室一天几百个号谁来谁有系统没有压力放号时间一到同一个排班的请求会在几秒内被几千次命中。这里最忌讳的设计是把「医生一天有 30 个号」直接做成 doctor 表上的一个数字字段每次挂号就去 update 这个数字。因为同一行会被大量事务争抢行锁连接池很快被打满而且根本无法回答「上午还剩几个号」这类患者真正关心的问题。2.1 号源模型排班、号段与号源的三层结构常见做法是把号源拆成三层排班描述「哪个医生哪天出诊、总量多少」号段描述「上午还是下午」号源描述「具体第几个位置」。这样拆有三个直接好处。第一余号统计能精确到号段患者看到日历上每个日期同时显示上午和下午的余号而不是一整天一个笼统数字。第二锁的粒度变细同一医生的并发压力分散到多个号段上而不是整个医生一天的所有号一起被锁。第三后续要做「医生停诊换号」「指定时段优先」时只需要调整排班状态不用动预约记录。很多项目原型阶段图省事一张表同时存排班和余号等要加「取消预约回补号源」时才发现要同时改三个接口的 SQL。模型拆开后回补只是把排班的余数字段加回去再把预约记录改成已取消两步在同一个事务里完成逻辑清楚很多。2.2 预约挂号核心表结构与字段约束2.2.1 排班表与号源表怎么建排班表是整套系统的核心表CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL, period TINYINT NOT NULL COMMENT 1-上午 2-下午 3-晚间, total_count INT NOT NULL DEFAULT 0, remain_count INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-正常 1-停诊, UNIQUE KEY uk_doc_date (doctor_id, work_date, period) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两条约束最关键。uk_doc_date唯一索引保证同一医生同一天同一号段只有一条排班记录这是防重复排班的第一道防线。version字段留给乐观锁扣减号源时会用到。total_count 和 remain_count 分开存避免每次扣减都从总量反推也方便审计放号量调整。排班数据通常由后台提前一周生成生成时要处理节假日规则和医生停诊安排客户端只查 status0 的记录再按日期分组渲染。日期用 DATE 类型不要用时间戳替代跨时区计算在医院场景里只会添乱。2.2.2 预约记录表和幂等键患者点「确认挂号」后落库的是预约记录CREATE TABLE register_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, slot_no INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消 3-已完成 4-已退号, idempotent_key VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_idem (idempotent_key), UNIQUE KEY uk_sched_slot (schedule_id, slot_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_sched_slot是业务上最硬的约束同一排班下的同一个号位只能被一个人预约成功即便上层代码出了 bug数据库也会挡下重复挂号。idempotent_key是客户端生成的唯一请求标识和「用户连点两次确认」直接相关生成规则一般是时间戳加用户 ID 加随机数做哈希长度 32 位足够。status 的状态流转要收敛散落的魔法数字是后期改不动代码的根源status含义允许的下一状态0待支付1 已支付、2 已取消1已支付3 已完成、4 已退号2已取消无3已完成无状态变更全部由服务端 service 层处理Android 客户端只根据接口返回码判断下一步跳支付页还是提示成功。2.3 并发挂号的两种锁策略2.3.1 乐观锁扣减号源放号瞬间同一排班被大量并发命中最简单的防超卖是乐观锁一条 SQL 完成UPDATE schedule SET remain_count remain_count - 1, version version 1 WHERE id #{scheduleId} AND remain_count 0 AND version #{oldVersion};执行后判断受影响行数为 1 说明扣减成功继续创建预约记录为 0 说明余号已空或版本号被抢先更新直接返回「号源不足」。remain_count 0是最后一道闸即使版本号判断被绕过数据库也不会把余号扣成负数。单库单表部署时乐观锁配合唯一索引已经能覆盖绝大多数挂号系统的并发量。2.3.2 Redis 分布式锁处理跨节点竞争乐观锁的问题在于扣号源、创建预约记录、生成待支付订单三步在同一个事务里要连续操作多张表。行锁在高峰期会带来大量锁等待和死锁重试最终一致性能保证但接口响应时间明显变长。所以多实例部署时会再给「提交预约」加一把分布式锁String lockKey lock:schedule: scheduleId; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 扣减号源 创建记录 生成订单同一个事务 } finally { if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }锁粒度是单个排班不是整个医生或科室不同医生的预约请求互不阻塞。过期时间设 5 秒超时直接走「系统繁忙」提示客户端不要无限重试。释放前先比较 value 再删除防止锁过期后被别的请求设置、误删别人的锁。乐观锁和分布式锁不是二选一单机用乐观锁足够多实例就在乐观锁外面包一层 Redis 锁把对同一排班的写操作串行化用一条配置开关控制是否启用压测后决定上线参数。3. Android Studio 里的客户端分层与接口对接客户端在 Android Studio 里组织工程目标是不引入重型框架让「页面、状态、数据」三条线互相解耦。医疗项目的 Android 端有几个现实约束患者手里的手机配置参差医院内部 Wi-Fi 质量时好时坏排班数据查询频繁但余号必须实时。这些约束直接决定技术选型。3.1 工程结构与 Gradle 依赖怎么选客户端采用 MVVM 加 Kotlin 协程界面用 XML 加 RecyclerView图片加载用 GlideRoom 只缓存科室名称这类低频数据排班和余号一律实时拉取。排班不做本地持久化的原因很简单余号是强实时数据缓存几分钟就会让患者到医院才发现号已经被挂完投诉全部打到导诊台。android { namespace com.hospital.register compileSdk 34 defaultConfig { applicationId com.hospital.register minSdk 23 targetSdk 34 versionCode 1 versionName 1.0.0 } } dependencies { implementation androidx.core:core-ktx:1.13.1 implementation androidx.appcompat:appcompat:1.7.0 implementation androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 implementation com.google.android.material:material:1.12.0 implementation androidx.recyclerview:recyclerview:1.3.2 implementation com.squareup.retrofit2:retrofit:2.11.0 implementation com.squareup.retrofit2:converter-gson:2.11.0 implementation com.squareup.okhttp3:logging-interceptor:4.12.0 implementation com.github.bumptech.glide:glide:4.16.0 }minSdk 23 对应 Android 6.0覆盖医院里还在服役的旧机型compileSdk 用 34对应 Android SDK 34追新没有实际收益。Retrofit 和 OkHttp 选经过验证的版本号不要用还在 RC 阶段的构建。协程依赖不需要单独加lifecycle-viewmodel-ktx 已经带上了 kotlinx-coroutines 的传递依赖。3.1.1 一个文件一段职责View 层只做两件事把 ViewModel 的状态渲染到列表把用户点击转发给 ViewModel。ViewModel 层持有页面全部状态用密封类描述加载中、成功、失败三种状态。Repository 层只做数据获取不关心界面。这个分层对挂号场景特别重要因为页面状态多——加载中、余号不足、网络异常、重复提交——如果状态判断散落在 Activity 里排障时根本不知道当前页面的状态是哪个分支赋上去的。3.2 Retrofit 接口设计与统一响应体接口协议是这套系统里最容易扯皮的部分我习惯先定四个约定成功码统一 0业务错误用 4100 段业务码HTTP 状态码只承担传输层语义列表接口统一 page/pageSize 分页。这样 Android 端拦截器只需要处理 401 和 5xx其余全部走业务码分支。data class ApiResponseT( val code: Int, val message: String, val data: T? ) interface RegisterApi { GET(api/departments) suspend fun getDepartments(): ApiResponseListDepartment GET(api/schedules) suspend fun getSchedules( Query(doctorId) doctorId: Long, Query(date) date: String ): ApiResponseListScheduleVO POST(api/appointments) suspend fun createAppointment( Body request: AppointmentRequest ): ApiResponseAppointmentResult }挂起函数配合协程天然避免回调地狱也方便在 Repository 层做统一的异常转译。业务码在各自端定义成常量避免魔法数字常用的几组业务码含义客户端动作0成功按业务跳转4101号源不足提示并刷新余号4102重复预约展示已有预约记录4103排班已停诊返回科室列表3.2.1 OkHttp 超时与公共拦截器医院 Wi-Fi 丢包率高超时参数不能照抄普通互联网应用。连接超时 10 秒、读写超时 15 秒是比较合适的区间再长用户会以为应用死了再短弱网下几乎不可用。val okHttpClient OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .addInterceptor { chain - val request chain.request().newBuilder() .header(Authorization, Bearer ${TokenStore.get()}) .header(X-Client-Type, android) .build() chain.proceed(request) } .build()拦截器统一附加登录态和客户端标识后端可以按客户端类型做版本控制和调用统计。X-Client-Type 头比 User-Agent 好解析不用维护一套正则。3.2.2 401 后的 token 刷新与重放登录态过期是挂号过程中最常被忽略的异常路径。患者选了半天科室提交时突然 401如果直接踢回登录页体验很差。常见做法是在拦截器里捕获 401刷新 token 后重放一次原始请求class AuthInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request chain.request().newBuilder() .header(Authorization, Bearer ${TokenStore.get()}) .build() val response chain.proceed(request) if (response.code 401 TokenStore.refresh()) { response.close() return chain.proceed(chain.request().newBuilder() .header(Authorization, Bearer ${TokenStore.get()}) .build()) } return response } }TokenStore.refresh() 内部要加锁或串行化否则多个并发请求同时收到 401会触发多次刷新后端刷新接口得做防重。重放仍失败就清登录态回登录页这是最后兜底。3.3 余号查询的缓存策略OkHttp 的默认缓存策略可能命中过期的响应余号接口必须走网络。做法是在响应拦截器里统一写入 Cache-Control 头禁用本地缓存.addInterceptor { chain - val response chain.proceed(chain.request()) response.newBuilder() .header(Cache-Control, no-cache) .build() }需要注意这个头只对「余号」这类强实时接口生效科室列表这种低频数据不建议禁缓存。区分方式可以按 URL 路径判断或者后端在响应头里显式声明客户端不做猜测。4. 号源日历与预约提交的代码落地前两章把模型和客户端骨架搭好这一章落到患者真正操作的三个页面科室列表、号源日历、提交预约。这三段代码是整套系统里最容易被问「能不能这样写」的部分边界条件特别多。4.1 科室列表和排班数据的加载链路4.1.1 用 Sealed Interface 管理加载状态页面状态用密封接口定义比用多个 LiveData 拼状态清晰得多sealed interface HomeUiState { data object Loading : HomeUiState data class Success( val departments: ListDepartment, val scheduleMap: MapLocalDate, ListScheduleVO ) : HomeUiState data class Error(val message: String) : HomeUiState } class HomeViewModel( private val repo: RegisterRepository ) : ViewModel() { private val _uiState MutableStateFlowHomeUiState(HomeUiState.Loading) val uiState: StateFlowHomeUiState _uiState.asStateFlow() fun loadHome() { viewModelScope.launch { _uiState.value HomeUiState.Loading runCatching { repo.loadHome() } .onSuccess { _uiState.value HomeUiState.Success(it) } .onFailure { e - _uiState.value HomeUiState.Error(e.message ?: 网络异常请稍后重试) } } } }StateFlow 保证界面在旋转后能拿到当前状态不会因为 Activity 重建重新触发网络请求。runCatching 捕获所有异常并转成 Error 状态UI 层根据状态决定显示加载动画还是错误提示加重试按钮。4.1.2 日期数据与号源合并排班页需要一个滚动日期条常见逻辑是展示从今天起 14 天的窗口医院一般允许提前 7 到 14 天预约fun buildDateList(start: LocalDate, days: Int): ListDateItem (0 until days).map { offset - val date start.plusDays(offset.toLong()) DateItem( date date, label if (offset 0) 今天 else 周${date.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.CHINA)}, day date.dayOfMonth, remainCount scheduleMap[date]?.sumOf { it.remainCount } ?: 0 ) }合并逻辑是纯内存操作后端一次返回 14 天的排班列表客户端按日期分组余号是当天所有号段 remainCount 之和。查无记录的日期余号为 0日期条目置灰不可点。这里有一个容易踩的坑不要为了「显示好看」把余号为 0 的日期隐藏患者需要看到哪天有号、哪天没号隐藏会让人以为系统坏了。4.2 号源选择的交互状态保存患者选中某个日期和号段后旋转屏幕或应用被系统回收选中状态不能丢。存放位置应该是 ViewModel 而不是 Adapter 或 Activity 的字段private val _selection MutableStateFlowPairLocalDate, Int?(null) val selection: StateFlowPairLocalDate, Int? _selection.asStateFlow() fun selectPeriod(date: LocalDate, period: Int) { _selection.value date to period }每次点击日期或号段先更新 StateFlowUI 根据 StateFlow 变化刷新选中高亮。提交按钮的可用状态也由它驱动避免出现「选号段之前就能点提交」的非法状态。4.3 提交预约幂等键、失败重试与按钮防抖4.3.1 幂等键的生成与复用提交预约是整套系统里唯一需要严格幂等的操作。患者的「确认」按钮在弱网下可能发出两次请求核心是幂等键的生成时机和复用suspend fun createAppointment(req: AppointmentRequest): ResultAppointmentResult { val idempotentKey UUID.randomUUID().toString() try { val resp api.createAppointment(req.copy(idempotentKey idempotentKey)) return if (resp.code 0) Result.success(resp.data) else Result.failure(BusinessException(resp.code, resp.message)) } catch (e: IOException) { return Result.failure(e) } }幂等键在第一次提交时生成应用进程内保存。用户点「重试」时复用同一个 key不能重新生成——第一次请求可能其实成功了只是响应丢了重试如果换 key 会导致挂两次号。服务端靠 register_record 表上的 uk_idem 唯一索引去重第二次请求直接返回第一次的结果。业务错误和网络错误要分开处理4101 号源不足属于业务错误提示后刷新余号不重试IOException 可以提示重试因为请求状态未知。提示幂等键的生成放在业务动作开始处不要在拦截器里给所有请求统一生成。GET 请求用不上POST 请求也不是每个都需要幂等。4.3.2 按钮防抖与支付回跳提交按钮的防抖不止是「禁用 500 毫秒」而是进入提交中状态后一直禁用成功跳转或失败恢复时才重新可用btnSubmit.isEnabled false viewModel.submit() // StateFlow 驱动 loading 状态 // 失败回调里恢复 btnSubmit.isEnabled true提交成功拿到的是订单号客户端拉起收银台。这里有一条硬规矩预约状态以后端回调为准支付宝或微信的客户端支付成功回调只能作为提示「已支付」的参考不能用来更新预约记录状态。支付完成回到应用通过 scheme 深链或应用间拉起回调处理Activity 要配 launchMode 防止支付页回来时重建堆栈。5. Android 客户端上线前必查的细节与验收方法客户端功能跑通不等于能上线。按下面三个顺序检查能把大多数线上客诉挡在发版之前。5.1 列表页的图片与内存开销医生头像来自医院图片服务器很多没有自动裁剪缩略图的能力原图可能两三兆。Glide 必须显式限制尺寸和格式Glide.with(itemView) .load(doctor.avatarUrl) .override(120, 120) .format(DecodeFormat.PREFER_RGB_565) .into(ivAvatar)override 强制按 120 像素解码RGB_565 比 ARGB_8888 少一半内存头像这种没有渐变透明需求的图片看不出差异。科室列表几十个头像区别不大但患者翻页时 RecyclerView 会反复复用 item不做限制很容易出现滚动卡顿。5.2 用真机模拟连点与弱网重复提交的验证不能只靠手点用 adb 模拟连点更接近真实误触# 连续点击预约按钮 50 次坐标按真机分辨率换算 adb shell for i in $(seq 1 50); do input tap 540 1200; sleep 0.05; done跑完去服务端查 register_record 表按 idempotent_key 分组统计每个 key 的记录只能有一条。弱网场景直接在 Android Studio 里给模拟器设置网络丢包和延迟档位验证两个行为超时后页面有重试入口重试请求返回的是第一次的预约结果而不是报「重复预约」。这两条过了线上最常见的两类投诉——「扣了两次钱」和「明明挂上了说没挂上」——基本能杜绝。5.3 一组可执行的验收清单检查项方法通过标准并发不超卖100 并发请求同一排班成功数等于余号数无负数重复提交幂等adb 连点 50 次同一幂等键仅一条记录弱网超时模拟 50% 丢包提示重试不崩溃不卡死旋转屏状态选中号段后旋转选中状态保留内存占用Profile 连续翻页 5 分钟无持续增长的 GC 波动清单里的并发项建议直接写个脚本打真实接口不要用模拟器里的并发测试模拟器网络栈和真机差异太大。adb 脚本里的 tap 坐标记得按目标真机分辨率换算不同机型按钮位置不同脚本跑的时候全程盯着画面确认点在按钮上。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价