简介这是一套面向高校安卓课程设计与移动医疗开发学习者的完整项目资料源自大三学期课程作业由两人协作约两个月完成涵盖Android客户端、后端数据接口与简易Web管理后台适合想了解SpringBoot、jFinal与安卓端联调流程的初学者参考。压缩包共1106个文件约74.32MB包含52个java源码、157个xml布局与配置、235个json数据文件、67张png界面截图、11个jar依赖及apk安装包等另附一份约13000字、60页Word格式课程报告按论文结构书写便于对照理解系统设计与实现思路。目前已有1017人学习下载。读者可从中获取App端与后台源码、数据库脚本、接口设计参考及完整项目文档用于课程设计模仿、答辩准备或后续功能完善与参赛改造对移动医疗类项目实践有一定借鉴价值。1. 从一张挂号单说起Android 信息化医疗服务系统到底在解决什么早上七点半医院门诊大厅已经排起长队。窗口工作人员在纸质表格上核对身份证、翻找病历本、手写挂号单患者攥着单子跑上跑下。这个场景在很多基层医疗机构仍然每天上演。基于 Android 的信息化医疗服务系统要做的就是把这条链路从纸上搬到手持终端和自助机里患者用手机或现场平板完成建档、挂号、缴费、查报告医护用 Android 平板查房、开医嘱、扫码核对管理者在后台看实时数据。它适合两类人一类是想做医疗信息化毕设或课程设计的学生需要一套能跑通、能演示、能讲清架构的完整方案另一类是中小医院或诊所的 IT 负责人预算有限、没有大型 HIS 厂商支持想用 Android 终端加自建服务端把核心流程先数字化。这篇文章不讲空泛概念只讲这套系统怎么分层设计、Android 端和服务端怎么对接、哪些参数必须提前定死、哪些坑我踩过之后再也不碰。2. 系统分层与选型为什么 Android 端不能直接连数据库2.1 四层架构的职责边界一套能落地的信息化医疗服务系统常见做法是拆成四层接入层Android 手机、平板、自助一体机、网关层统一鉴权、限流、路由、业务服务层挂号、缴费、报告、医嘱等微服务或单体模块、数据层MySQL 存业务数据、Redis 存会话与号源缓存、对象存储放影像和 PDF 报告。Android 端只跟网关层说话绝不直连数据库。原因很直接移动端一旦被反编译数据库连接串就是裸奔而且号源扣减、缴费状态这类操作必须放在服务端做事务客户端只负责展示和提交。提示如果只是毕设演示业务服务层可以先做成单体 Spring Boot 应用但网关层和 Android 端的接口契约要按最终形态设计后面拆微服务时不用改客户端。2.2 Android 端技术栈选型理由Android 端我一般会选Kotlin JetpackViewModel LiveData/Flow Room Retrofit OkHttp。Kotlin 的空安全和协程能大幅减少回调地狱Room 做本地缓存让患者在弱网环境下也能看到最近一次的就诊记录Retrofit 负责 REST 接口OkHttp 加拦截器统一塞 Token 和打印日志。UI 层如果要做复杂表单和列表用RecyclerView ViewBinding就够了不必上 Compose除非团队已经熟悉。最低 API 等级建议定在API 26Android 8.0覆盖绝大多数在用设备同时能用到通知渠道和后台限制等新特性。2.3 服务端接口契约的三个硬约束第一所有写操作必须带幂等键。挂号、缴费这类接口客户端生成一个 UUID 作为requestId服务端用它做去重防止用户连点或网络重试导致重复扣号。第二时间统一用服务端时间。号源开放、预约截止都依赖服务端时钟客户端时间不可信。第三分页参数固定为page和sizesize上限设 50防止有人拉全量数据。下面是一个挂号接口的请求体示例{ requestId: a1b2c3d4-e5f6-7890-abcd-ef1234567890, patientId: P20240001, deptId: D001, doctorId: DOC1001, scheduleId: SCH20240520001, visitDate: 2024-05-20 }requestId由客户端生成服务端在 Redis 里以它为 key 做SETNX过期时间设 10 分钟。scheduleId对应排班表主键服务端据此扣减号源。visitDate只传日期具体时段由排班决定避免客户端传错时间格式。3. 从零跑通 Android 端环境、网络层与本地缓存3.1 开发环境与项目骨架先装Android Studio建议用较新的稳定版SDK 里勾选 API 26 到 API 34。新建项目选 Empty Views Activity语言选 Kotlin包名用com.example.medcare。在build.gradle.kts里加依赖dependencies { implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0) implementation(androidx.lifecycle:lifecycle-livedata-ktx:2.7.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(com.squareup.retrofit2:retrofit:2.9.0) implementation(com.squareup.retrofit2:converter-gson:2.9.0) implementation(com.squareup.okhttp3:logging-interceptor:4.12.0) }room-ktx提供协程支持kapt是注解处理器Retrofit 的 Gson 转换器负责 JSON 解析。版本号写死避免构建时解析最新版导致意外升级。3.2 Retrofit 网络层与 Token 拦截器网络层用一个单例ApiClient暴露 Retrofit 实例。关键在 OkHttp 的拦截器里统一加Authorization头并在 401 时触发重新登录object ApiClient { private const val BASE_URL https://your-server.com/api/ private var token: String? null fun setToken(t: String?) { token t } private val client OkHttpClient.Builder() .addInterceptor { chain - val req chain.request().newBuilder() .addHeader(Authorization, Bearer ${token ?: }) .addHeader(Content-Type, application/json) .build() chain.proceed(req) } .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .build() val service: MedApi by lazy { Retrofit.Builder() .baseUrl(BASE_URL) .client(client) .addConverterFactory(GsonConverterFactory.create()) .build() .create(MedApi::class.java) } }connectTimeout设 10 秒readTimeout设 15 秒医院内网 Wi-Fi 偶尔抖动太短会频繁超时太长用户等不及。Token 存在内存里App 重启后从 EncryptedSharedPreferences 恢复不要明文写进 SharedPreferences。3.3 Room 本地缓存与号源列表展示号源列表变化不频繁但用户可能反复查看。用 Room 建一张schedule表缓存最近查询的排班Entity(tableName schedule) data class ScheduleEntity( PrimaryKey val scheduleId: String, val deptName: String, val doctorName: String, val visitDate: String, val remainCount: Int, val updatedAt: Long ) Dao interface ScheduleDao { Query(SELECT * FROM schedule WHERE visitDate :date ORDER BY deptName) fun observeByDate(date: String): FlowListScheduleEntity Insert(onConflict OnConflictStrategy.REPLACE) suspend fun upsertAll(list: ListScheduleEntity) }observeByDate返回 FlowUI 层订阅后数据一变自动刷新。upsertAll在每次网络请求成功后调用把最新号源写进本地。remainCount为 0 时 UI 置灰按钮但最终扣减以服务端为准本地只做展示。4. 服务端核心模块号源扣减、缴费状态与报告推送4.1 号源扣减的防超卖设计号源扣减是整套系统最容易翻车的地方。常见做法是在 MySQL 里用UPDATE schedule SET remain_count remain_count - 1 WHERE schedule_id ? AND remain_count 0靠行锁保证原子性。但高并发下数据库压力大我一般会在 Redis 里先做预扣-- KEYS[1] schedule:stock:{scheduleId} -- ARGV[1] 扣减数量 local stock tonumber(redis.call(GET, KEYS[1]) or -1) if stock tonumber(ARGV[1]) then return -1 end return redis.call(DECRBY, KEYS[1], ARGV[1])Lua 脚本保证判断和扣减原子执行。返回 -1 表示库存不足返回其他值表示扣减后的余量。Redis 扣成功后再异步写 MySQL 并落一条挂号记录。如果 MySQL 写失败要有补偿任务把 Redis 库存加回去。这个补偿逻辑必须做否则 Redis 和 MySQL 会不一致。4.2 缴费状态机与回调幂等缴费状态只有四个待支付、支付中、已支付、已退款。状态流转必须单向不允许从已支付直接跳回待支付。第三方支付回调可能重复发送所以回调接口要用requestId做幂等PostMapping(/pay/callback) public String payCallback(RequestBody PayCallbackDto dto) { String key pay:callback: dto.getRequestId(); Boolean first redisTemplate.opsForValue().setIfAbsent(key, 1, 24, TimeUnit.HOURS); if (Boolean.FALSE.equals(first)) { return SUCCESS; // 重复回调直接返回成功 } orderService.markPaid(dto.getOrderId(), dto.getTradeNo()); return SUCCESS; }setIfAbsent返回 true 表示第一次处理false 表示重复。重复时直接返回成功避免第三方一直重试。markPaid里再校验订单当前状态只有支付中才能改成已支付。4.3 报告推送与 Android 端通知检查报告出来后服务端通过 WebSocket 或轮询通知 Android 端。WebSocket 心跳机制建议客户端每 30 秒发一次 ping服务端 60 秒没收到就断开。Android 端用OkHttp的 WebSocket 监听收到消息后发本地通知val ws OkHttpClient().newWebSocket( Request.Builder().url(wss://your-server.com/ws/report).build(), object : WebSocketListener() { override fun onMessage(webSocket: WebSocket, text: String) { val report Gson().fromJson(text, ReportNotify::class.java) NotificationHelper.show(context, 报告已出, report.title) } } )通知渠道在 Android 8.0 以上必须创建渠道 ID 固定为report_channel重要性设为IMPORTANCE_HIGH否则通知不弹横幅。5. 避坑与排查那些让我加班到凌晨的细节5.1 现象挂号成功但号源没减原因Redis 预扣成功但 MySQL 更新失败后没有补偿或者补偿任务没跑。解决在挂号记录表加status字段标记预扣成功、落库成功、已补偿。定时任务扫描预扣成功超过 5 分钟的记录回滚 Redis 库存并把记录置为已补偿。5.2 现象Android 端列表重复加载原因LiveData在配置变更如旋转屏幕后重新观察触发第二次请求。解决用ViewModel持有数据observe只在onCreate里调一次或者用Flow加stateIn做去重。不要直接在onResume里发请求。5.3 现象缴费回调偶尔丢失原因第三方回调超时时间短服务端处理慢导致对方重试但重试时requestId变了。解决回调接口的幂等键不能用第三方传的requestId要用自己订单号orderId。第三方每次重试订单号不变才能正确去重。5.4 现象报告 PDF 在 Android 端打不开原因服务端返回的是content://或file://路径Android 7.0 以上禁止直接暴露file://。解决服务端返回 HTTPS 下载链接Android 端用DownloadManager或 OkHttp 下载到应用私有目录再用FileProvider生成content://URI 交给系统阅读器。5.5 现象弱网下重复提交挂号原因用户点击后请求超时但服务端实际已处理用户又点一次。解决客户端在请求发出后禁用按钮直到收到响应或超时同时服务端用requestId幂等。双保险。6. 进阶技巧用接口幂等键和本地队列把弱网体验拉回来弱网是医院场景的常态电梯里、地下室、老楼 Wi-Fi 死角都会断。我的习惯是在 Android 端加一个本地待提交队列用户提交挂号或缴费时先把请求写进 Room 的pending_request表再尝试发网络。成功就删记录失败就留在表里由WorkManager在网络恢复后重试。重试时requestId不变服务端幂等键保证不会重复扣号。Entity(tableName pending_request) data class PendingRequest( PrimaryKey val requestId: String, val url: String, val method: String, val body: String, val createdAt: Long )WorkManager的约束设NetworkType.CONNECTED重试策略用BackoffPolicy.EXPONENTIAL初始延迟 10 秒最大延迟 5 分钟。这样用户在地下车库点了挂号走到地面网络恢复后自动提交不用重新填一遍。另一个技巧是接口版本号放在 URL 里比如/api/v1/register。后面服务端升级接口老版本 App 还能继续用/v1不至于一升级就全量用户闪退。这个习惯我从做第一个医疗项目就养成了后来每次迭代都庆幸留了这条路。验证方法很简单用 Android Studio 的 Network Profiler 看请求耗时用 Charles 或 Fiddler 模拟弱网延迟 2000ms、丢包 30%观察待提交队列是否按预期重试。如果重试三次后仍失败给用户一个明确的「已保存网络恢复后自动提交」提示而不是一个红色报错。希望帮到你。本文还有配套的精品资源点击获取