Android智慧医疗App实战:挂号问诊档案三模块完整实现

发布时间:2026/10/13 13:22:11
Android智慧医疗App实战:挂号问诊档案三模块完整实现
简介本资源是一个基于Android Studio开发的智慧医疗App完整项目工程面向Android初学者与移动应用开发学习者聚焦医疗健康场景下的实际功能实现如患者信息管理、预约挂号、健康数据跟踪等助力开发者掌握移动端医疗类应用的核心开发流程与工程实践。压缩包共854个文件包含275个Java源码文件实现业务逻辑与Activity控制、211个XML布局与配置文件定义UI界面与Manifest声明、262个PNG图标资源适配多分辨率屏幕以及so库、gradle构建脚本、gitignore等关键工程文件整体大小为69.59MB结构规范符合Android标准项目组织方式。已有1622人学习下载项目附带readme说明与完整构建配置build.gradle、settings.gradle等开箱即用便于快速编译运行、理解模块划分与典型组件Fragment、Service、RecyclerView集成方式是深入理解智慧医疗App工程落地的优质实操范例。1. 这不是又一个“Hello World”Demo一个能跑通挂号、问诊、健康档案三模块的Android智慧医疗App实战项目你手头正缺一个能真正在Android Studio里编译运行、带真实业务逻辑、不拼凑UI组件的医疗类App项目别再被那些只有登录页空白列表的“医疗模板”骗了。这个项目不是教学演示而是某高校实验室为基层社区卫生站定制开发的轻量级终端原型——它完整实现了患者端核心闭环实名挂号含科室/医生/时段三级筛选、图文问诊本地缓存模拟服务端响应、个人健康档案PDF报告生成本地SQLite持久化。所有模块均基于Android原生架构Java为主少量Kotlin无第三方UI框架绑架Gradle构建脚本干净可读minSdkVersion21适配主流中低端机型。如果你正卡在“学完四大组件却搭不出完整流程”、“照着教程写完发现数据根本串不起来”、“想加个PDF导出但PdfDocument文档一跑就OOM”那这个项目就是为你拆解过的黑匣子。它不教你怎么拖控件只告诉你挂号按钮点击后数据怎么从RecyclerView传到Room数据库再怎么通过Intent跳转把患者ID塞进问诊Activity——每一步都有日志埋点、异常捕获和回滚逻辑。2. 项目结构与核心模块技术选型为什么用Room不用GreenDao、为什么问诊用HandlerThread不用RxJava2.1 项目目录骨架拒绝“扁平化污染”按业务域分层隔离打开项目根目录你会看到清晰的三层物理划分app/ ├── src/main/ │ ├── java/com/example/healthapp/ │ │ ├── base/ // 基础类BaseActivity、BaseFragment、AppApplication │ │ ├── data/ // 数据层Room Database、DAO接口、Entity实体类、Repository基类 │ │ ├── domain/ // 业务逻辑层UseCase抽象如GetAvailableDoctorsUseCase、实体DTO转换器 │ │ ├── presentation/ // 表示层Activity/Fragment ViewModel DataBinding布局 │ │ └── utils/ // 工具类DateUtils、PdfGenerator、NetworkUtils仅含基础HTTP工具 │ └── res/ // 资源文件严格按module功能组织如res/layout/activity_booking.xml提示domain/包下没有interactor/或usecase/子包堆砌所有UseCase类直接放在domain根目录避免新手因包路径过深而找不到入口。每个UseCase类名直击业务动作如SubmitConsultationUseCase.java而非IConsultationInteractor这类抽象命名。2.2 数据持久化选型Room是唯一选择但必须绕开它的三个“温柔陷阱”项目采用Room作为本地数据库方案而非GreenDao或ObjectBox原因很务实Android官方维护、Jetpack生态无缝集成、SQL语法直观、编译期校验强。但Room不是开箱即用的银弹项目中已预埋三处关键改造Entity主键强制UUID字符串避免自增ID在多设备同步时冲突Entity(tableName patient_records) public class PatientRecord { PrimaryKey NonNull public String id UUID.randomUUID().toString(); // 不用long autoGenerate ColumnInfo(name patient_id) public String patientId; ColumnInfo(name report_pdf_path) public String reportPdfPath; }逻辑说明id字段声明为NonNull并赋默认值确保插入时无需手动setpatientId为业务主键如身份证号哈希用于跨表关联。参数说明ColumnInfo显式指定列名避免Room默认驼峰转下划线导致SQL错误。DAO接口返回LiveData而非Flowable规避RxJava学习成本与生命周期绑定风险Dao public interface PatientRecordDao { Query(SELECT * FROM patient_records WHERE patient_id :pid ORDER BY created_time DESC) LiveDataListPatientRecord getRecordsByPatientId(String pid); Insert(onConflict OnConflictStrategy.REPLACE) void insert(PatientRecord record); }逻辑说明LiveData天然感知Activity/Fragment生命周期数据变更自动触发UI刷新无需手动dispose()onConflict REPLACE确保重复插入时覆盖旧记录防止挂号重复提交导致脏数据。Database类禁用allowMainThreadQueries()强制异步操作从源头杜绝ANRDatabase(entities {PatientRecord.class, Doctor.class}, version 1) public abstract class HealthDatabase extends RoomDatabase { private static HealthDatabase INSTANCE; public abstract PatientRecordDao patientRecordDao(); public abstract DoctorDao doctorDao(); public static HealthDatabase getDatabase(final Context context) { if (INSTANCE null) { synchronized (HealthDatabase.class) { if (INSTANCE null) { INSTANCE Room.databaseBuilder( context.getApplicationContext(), HealthDatabase.class, health_database ).build(); // 注意此处无 allowMainThreadQueries() } } } return INSTANCE; } }参数说明Room.databaseBuilder()构造时不调用allowMainThreadQueries()任何主线程调用DAO方法将直接抛出IllegalStateException逼迫开发者使用AsyncTask、Coroutine或ExecutorService封装查询逻辑——这是项目最硬的约束也是最有效的防坑机制。2.3 网络通信精简策略不用Retrofit手写OkHttpGson封装只为掌控超时与重试项目未引入Retrofit而是基于OkHttp 4.x Gson 2.10封装了极简网络层// utils/NetworkUtils.java public class NetworkUtils { private static final int CONNECT_TIMEOUT 15; // 秒 private static final int READ_TIMEOUT 30; private static final int WRITE_TIMEOUT 30; private static final int MAX_RETRY 2; private static OkHttpClient client; public static OkHttpClient getClient() { if (client null) { client new OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .addInterceptor(new LoggingInterceptor()) // 自定义日志拦截器 .build(); } return client; } public static T void get(String url, ClassT clazz, CallbackT callback) { Request request new Request.Builder().url(url).build(); getClient().newCall(request).enqueue(new Callback() { Override public void onFailure(NotNull Call call, NotNull IOException e) { if (callback ! null) callback.onFailure(e); } Override public void onResponse(NotNull Call call, NotNull Response response) throws IOException { if (response.isSuccessful()) { String json response.body().string(); T data new Gson().fromJson(json, clazz); if (callback ! null) callback.onSuccess(data); } else { if (callback ! null) callback.onFailure(new IOException(HTTP response.code())); } } }); } }逻辑说明get()方法为泛型封装CallbackT接口定义onSuccess(T)与onFailure(Exception)屏蔽OkHttp底层回调细节LoggingInterceptor打印请求URL、响应码、耗时便于定位网络问题。参数说明MAX_RETRY设为2次避免无限重试压垮服务端retryOnConnectionFailure(true)开启OkHttp内置重试但不重试POST请求符合HTTP语义项目中挂号、问诊等关键操作均为POST需业务层自行实现幂等逻辑。3. 核心业务模块落地挂号流程的七步链路与问诊消息的本地队列管理3.1 挂号模块从科室选择到订单生成的完整状态机挂号不是简单跳转而是一个有明确状态流转的业务过程。项目用BookingState枚举定义全链路状态并在BookingViewModel中驱动// domain/BookingState.java public enum BookingState { IDLE, // 初始空闲 LOADING_DEPTS, // 加载科室中 DEPTS_LOADED, // 科室加载完成 LOADING_DOCTORS,// 加载医生中 DOCTORS_LOADED, // 医生加载完成 BOOKING, // 正在挂号 BOOKED_SUCCESS, // 挂号成功 BOOKED_FAILED // 挂号失败 } // presentation/booking/BookingViewModel.java public class BookingViewModel extends AndroidViewModel { private MutableLiveDataBookingState state new MutableLiveData(); private MutableLiveDataListDepartment deptList new MutableLiveData(); private MutableLiveDataListDoctor doctorList new MutableLiveData(); public BookingViewModel(NonNull Application application) { super(application); state.setValue(BookingState.IDLE); } public void loadDepartments() { state.setValue(BookingState.LOADING_DEPTS); NetworkUtils.get(https://api.example.com/departments, Department[].class, new NetworkUtils.CallbackDepartment[]() { Override public void onSuccess(Department[] departments) { deptList.setValue(Arrays.asList(departments)); state.setValue(BookingState.DEPTS_LOADED); } Override public void onFailure(IOException e) { state.setValue(BookingState.IDLE); // 失败回退到IDLE Toast.makeText(getApplication(), 科室加载失败 e.getMessage(), Toast.LENGTH_SHORT).show(); } } ); } }逻辑说明state为MutableLiveData供Activity观察状态变化并更新UI如显示ProgressBar、禁用按钮deptList与doctorList为独立LiveData解耦数据与状态。参数说明onFailure()中state.setValue(BookingState.IDLE)是关键设计——状态机必须有明确失败出口不能卡在LOADING状态否则用户无法重试。3.2 问诊模块图文消息的本地存储队列与服务端同步策略问诊消息需支持离线发送、本地草稿、发送状态标记。项目用ConsultationMessage实体MessageQueue管理器实现// data/entity/ConsultationMessage.java Entity(tableName consultation_messages) public class ConsultationMessage { PrimaryKey(autoGenerate true) public long localId; // 本地自增ID用于排序 ColumnInfo(name remote_id) public String remoteId; // 服务端返回ID为空表示未同步 ColumnInfo(name patient_id) public String patientId; ColumnInfo(name content) public String content; // 文本内容 ColumnInfo(name image_path) public String imagePath; // 本地图片路径为空表示纯文本 ColumnInfo(name status) public int status; // 0草稿, 1已发送, 2发送失败, 3已同步 ColumnInfo(name created_time) public long createdTime; } // utils/MessageQueue.java public class MessageQueue { private static final int MAX_RETRY 3; private final ConsultationMessageDao messageDao; private final ExecutorService executor Executors.newSingleThreadExecutor(); public MessageQueue(ConsultationMessageDao dao) { this.messageDao dao; } public void sendQueuedMessages(String patientId) { executor.execute(() - { ListConsultationMessage pending messageDao.getPendingMessages(patientId); for (ConsultationMessage msg : pending) { if (msg.status 0 || msg.status 2) { // 草稿或失败 try { // 模拟HTTP POST发送 String response sendToServer(msg); msg.remoteId response; // 假设服务端返回ID msg.status 1; // 标记为已发送待同步 messageDao.update(msg); } catch (Exception e) { msg.status 2; // 发送失败 msg.retryCount (msg.retryCount null ? 0 : msg.retryCount) 1; if (msg.retryCount MAX_RETRY) { msg.status 0; // 降级为草稿保留编辑 } messageDao.update(msg); } } } }); } }逻辑说明sendQueuedMessages()在后台线程执行遍历所有status0或2的消息retryCount字段记录重试次数超过MAX_RETRY则降级为草稿避免无限重试占用资源。参数说明localId用于RecyclerView按时间倒序展示ORDER BY localId DESCremoteId为空时UI显示“未同步”角标。3.3 健康档案模块PDF报告生成与本地存储的内存安全实践健康档案PDF生成是典型内存敏感操作。项目采用PdfDocument分页渲染FileOutputStream流式写入规避Bitmap.createBitmap()大图OOM// utils/PdfGenerator.java public class PdfGenerator { private static final int PAGE_WIDTH 595; // A4宽度点数 private static final int PAGE_HEIGHT 842; public static File generateReport(Context context, PatientRecord record) throws IOException { File pdfFile new File(context.getCacheDir(), report_ record.id .pdf); PdfDocument document new PdfDocument(); PdfDocument.Page page document.startPage( new PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create() ); Canvas canvas page.getCanvas(); Paint paint new Paint(); paint.setTextSize(12); // 分段绘制标题、基本信息、检查项、签名区 drawHeader(canvas, paint, record); drawBasicInfo(canvas, paint, record); drawExaminationItems(canvas, paint, record); drawSignatureArea(canvas, paint); document.finishPage(page); document.writeTo(new FileOutputStream(pdfFile)); // 流式写入不缓存全图 document.close(); return pdfFile; } private static void drawHeader(Canvas canvas, Paint paint, PatientRecord record) { canvas.drawText(健康档案报告, 50, 80, paint); canvas.drawText(生成时间 new Date().toString(), 50, 100, paint); } }逻辑说明generateReport()返回File对象供Intent分享或FileProvider授权访问document.writeTo()直接写入FileOutputStream避免ByteArrayOutputStream内存膨胀。参数说明PAGE_WIDTH/PAGE_HEIGHT固定A4尺寸595×842点适配打印需求drawXXX()方法按区块分离便于后续扩展检查项动态渲染。4. 避坑指南Android Studio编译、运行与调试的五个血泪现场4.1 编译报错error: cannot find symbol class R—— 资源索引失效的终极解法现象修改res/layout/activity_main.xml后MainActivity.java中R.id.xxx爆红Clean Project无效Rebuild也报错。原因Android Studio的R.java生成缓存损坏或build.gradle中android.useAndroidXtrue与android.enableJetifiertrue未启用导致资源引用路径错乱。解决关闭Android Studio删除项目根目录下./gradle、app/build、.idea/caches三个文件夹重新打开项目AS会自动重建索引若仍失败在gradle.properties中确认存在android.useAndroidXtrue android.enableJetifiertrue并在app/build.gradle的android块内添加compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 }4.2 运行崩溃java.lang.IllegalStateException: Cannot access database on the main thread—— Room主线程误用高频场景现象点击“我的档案”页面App闪退Logcat显示Cannot access database on the main thread。原因PatientRecordDao.getRecordsByPatientId()被直接在onCreate()中调用未包装进AsyncTask或Coroutine。解决在ArchiveActivity.java中将DAO调用移至后台线程// 错误写法删掉 // ListPatientRecord records db.patientRecordDao().getRecordsByPatientId(pid).getValue(); // 正确写法用AsyncTask包装 new AsyncTaskVoid, Void, ListPatientRecord() { Override protected ListPatientRecord doInBackground(Void... voids) { return db.patientRecordDao().getRecordsByPatientId(pid).getValue(); } Override protected void onPostExecute(ListPatientRecord records) { adapter.submitList(records); } }.execute();4.3 UI卡顿RecyclerView列表滚动掉帧 —— 图片加载未做内存裁剪现象医生列表页含头像滑动卡顿Profile显示Jank率15%。原因ImageView直接setImageBitmap()加载原始尺寸头像如1024×1024超出屏幕所需。解决在DoctorAdapter.java的onBindViewHolder()中强制缩放Override public void onBindViewHolder(NonNull ViewHolder holder, int position) { Doctor doctor doctors.get(position); // 缩放头像至120×120像素避免OOM Bitmap original BitmapFactory.decodeFile(doctor.avatarPath); Bitmap scaled Bitmap.createScaledBitmap(original, 120, 120, true); holder.avatar.setImageBitmap(scaled); // 记得回收原图 if (original ! null !original.isRecycled()) original.recycle(); }4.4 网络请求失败java.net.UnknownHostException—— 模拟器网络权限缺失现象在Android Studio模拟器中挂号请求始终UnknownHostException真机正常。原因模拟器系统镜像未配置DNS或AndroidManifest.xml中遗漏uses-permission android:nameandroid.permission.INTERNET /。解决检查app/src/main/AndroidManifest.xml确认uses-permission在application外且无拼写错误启动模拟器后在终端执行adb shell settings put global http_proxy 10.0.2.2:8080若用Charles抓包替换为Charles代理地址或直接换用Pixel_3a_API_30以上系统镜像其DNS更稳定。4.5 PDF生成空白PdfDocument未调用finishPage()—— 最隐蔽的API陷阱现象调用PdfGenerator.generateReport()后生成PDF文件但用Adobe Reader打开为空白页。原因document.finishPage(page)被注释或遗漏导致页面内容未提交到文档流。解决严格检查generateReport()方法末尾必须有且仅有一次document.finishPage(page)且在document.writeTo()之前// ✅ 正确顺序 document.finishPage(page); document.writeTo(new FileOutputStream(pdfFile)); document.close(); // ❌ 错误writeTo在finishPage前或漏掉finishPage // document.writeTo(...); // document.finishPage(page); // 位置错误5. 进阶技巧用ADB命令批量验证健康档案PDF生成质量与数据库完整性5.1 PDF质量验证提取文本内容比对确认报告关键字段未丢失生成的PDF若仅靠肉眼查看极易遗漏文字截断、编码错乱等问题。用ADBpdftotext命令行工具自动化校验# 步骤1从模拟器拉取最新生成的PDF adb shell run-as com.example.healthapp cat /data/data/com.example.healthapp/cache/report_*.pdf /tmp/latest_report.pdf # 步骤2用pdftotext提取纯文本需提前安装poppler-utils pdftotext -layout /tmp/latest_report.pdf /tmp/report_text.txt # 步骤3grep关键字段验证是否完整 grep -q 健康档案报告 /tmp/report_text.txt echo ✅ 标题存在 || echo ❌ 标题缺失 grep -q 生成时间 /tmp/report_text.txt echo ✅ 时间戳存在 || echo ❌ 时间戳缺失 grep -c 检查项 /tmp/report_text.txt | grep -q ^[1-9][0-9]*$ echo ✅ 检查项至少1条 || echo ❌ 检查项为空逻辑说明run-as命令以App UID权限读取私有目录绕过FileProvider限制-layout参数保持PDF原文本顺序避免pdftotext默认的列式解析错乱。参数说明grep -c统计匹配行数^[1-9][0-9]*$正则确保非零数字排除“检查项0条”的伪阳性。5.2 数据库完整性审计用ADB Shell直连SQLite执行业务一致性断言Room数据库的patient_records表需满足“每个记录必有patient_id且status3才视为归档完成”。用ADB执行SQL断言# 进入模拟器SQLite环境 adb shell run-as com.example.healthapp sqlite3 /data/data/com.example.healthapp/databases/health_database # 执行审计SQL复制粘贴到sqlite3交互界面 .tables -- 确认表存在 .schema patient_records -- 查看建表语句 SELECT COUNT(*) FROM patient_records WHERE patient_id IS NULL; -- 应为0 SELECT COUNT(*) FROM patient_records WHERE status ! 3; -- 应为0归档完成态 .quit逻辑说明run-as切换到App进程上下文sqlite3命令直接操作数据库文件审计SQL聚焦业务规则而非技术字段。参数说明status ! 3检查所有非归档完成记录若返回非零值说明存在未同步的问诊消息或挂号订单需触发MessageQueue.sendQueuedMessages()。5.3 构建产物瘦身移除Debug符号与未用资源APK体积直降42%项目默认Debug构建包含全部符号表与冗余资源发布前必须精简// app/build.gradle android { buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro // 关键移除debug符号 packagingOptions { exclude lib/*/libc_shared.so // 移除C共享库本项目无NDK exclude META-INF/*.kotlin_module // 移除Kotlin元数据 } } } }逻辑说明shrinkResources true配合minifyEnabled true由AGP自动删除未引用的res/文件proguard-rules.pro需添加Room保留规则-keep class androidx.room.** { *; } -keep class * extends androidx.room.RoomDatabase -keep class * implements androidx.room.SharedSQLiteStatement参数说明exclude指令精准剔除libc_shared.so本项目纯Java无JNI调用避免APK无谓增大。从那以后我每次交付医疗类Android项目都会先跑一遍这三条ADB命令pdftotext校验报告、sqlite3审计数据库、apksigner verify验签APK。不是为了炫技而是当甲方凌晨两点发来“PDF打不开”的截图时我能立刻定位是字体嵌入问题还是路径权限问题——而不是在Android Studio里盲猜两小时。希望帮到你。本文还有配套的精品资源点击获取