拓冰建站拓冰建站
首页 / 资讯中心 / 正文

中医大夫助理Android源码解析:SQLite与ListView实践

简介这是一份面向Android开发者与中医信息化方向学习者的完整项目源码实现中医大夫在诊断、病历记录与中药知识查询方面的辅助功能。源码包共125个文件压缩包约1.55MB涵盖20个Java源文件、49个class文件、16个XML布局与配置文件、10个SQL数据库脚本以及可直接安装的APK便于对照分析界面定义、业务逻辑与数据存储的衔接。包内类文件按功能模块组织包括中药详情、方剂查询、病历编辑与识别适配等呈现一个中医诊疗助手的全貌SQL脚本和数据库辅助类揭示了Android端本地存储的实现方式适合作为健康医疗类应用的起步参考。当前已有172人学习下载对于想系统掌握Android分模块开发的初、中级开发者而言具备不错的阅读与复用价值。1. 中医大夫助理 Android 源码包先读结构再谈改造拿到android应用源码zz-doctor中医大夫助理信息系统源码.zip后最先注意到的不是代码而是resources.ap_、zz_doctor.apk、jarlist.cache和一排.class文件。这说明它不是能直接放进 Android Studio 的完整工程而是 APK 构建中间产物或反编译导出目录。正因为如此阅读它的价值反而更高可以从DbHelper、MedicineQuery、RxRecipeDetailEdit这些类名反推一个中医大夫助理系统的数据流和界面层级。下面会先把整个结构还原出来再把数据库、处方编辑、关键词高亮 Adapter 逐层拆开适合做医疗记录类 Android 应用的开发者也适合刚学 SQLite 与 ListView 联动的同学。2. 从打包结构和类名反推 zz-doctor 的整体分层先别急着找 Activity 入口。把 zip 拖到本地后第一件事是按扩展名归类弄清楚哪些是运行产物、哪些能辅助阅读。下面是我把文件列表映射回 APK 原始目录后的对照表。文件APK 构建阶段对应位置用途resources.ap_res/资源打包中间文件把布局、字符串、图片等资源编译后的二进制资源索引配合zz_doctor.apk使用zz_doctor.apkAPK 根目录安装包本体内含classes.dex、resources.arsc、AndroidManifest.xml*.classbuild/intermediates/javac/...编译后的 Java 字节码保留了类名、方法名和局部变量名可用javap -c阅读jarlist.cacheGradle 构建缓存记录项目依赖的 jar 列表与运行时逻辑无关可忽略有了这个分层下一步就能按类名恢复业务模块。.class文件不是.java不能直接拿过来改但它保留了完整的方法签名和字段结构配合反编译插件阅读体验接近源码。2.1 按类命名还原模块边界压缩包里列出的.class文件数量不多恰好覆盖了一个小型“中医处方助手”的骨架DbHelper数据库帮助类负责 SQLite 建表、升级和增删改查。WelcomeRxRecipeQuery应用打开后的第一个查询页可能兼作入口页。MedicineQuery与MedicineDetail药材列表查询页和药材详情页。RxRecipeDetail与RxRecipeDetailEdit处方详情页与处方编辑页。IdentifiedStringAdapter列表适配器作用是把查询关键词在结果里标识出来。这里没有看到网络请求相关的类也没有ContentProvider或Service说明它的业务几乎完全跑在本地。中医大夫在诊室使用这类工具时经常处于没有稳定内网的环境离线 SQLite 是最稳的方案。如果以后要做成团队协作版本再往上层加 Retrofit 也不是难事但现在这个项目核心是“单机知识库 处方记录”。2.2 为什么 class 文件比 dex 更容易阅读通常在已安装 APK 里拿到的是classes.dex要看代码得先用jadx或baksmali反编译。而这份 zip 直接提供.class字节码说明它来自 Gradle 的build/intermediates目录或是某个即时编译缓存目录。.class文件保留了原来的包路径和局部变量表用javap -c能直接看到精准到每条指令的字节码也可以放进 IDEA 自带的反编译插件里转成可读的 Java 代码。注意.class不等于.java它只是给你“读”的不是给你“改”的。想看可维护的工程结构仍然要自己根据这些类重新建模块。模块边界清晰之后数据层的设计就成了重点。3. 数据层设计从 DbHelper 倒推 SQLite 表结构3.1 三张表的建模思路既然项目叫“中医大夫助理”数据层一定要承载两类核心业务查药材、记处方。DbHelper在onCreate里最合理的做法是建三张表medicine存药材字典rx_recipe存处方主表rx_recipe_detail存处方明细。下面是我按类名和行为补全的建表 SQLCREATE TABLE medicine ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, alias TEXT, property TEXT, meridian TEXT, efficacy TEXT, dosage TEXT, contraindication TEXT ); CREATE TABLE rx_recipe ( id INTEGER PRIMARY KEY AUTOINCREMENT, patient_name TEXT, diagnosis TEXT, title TEXT, created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE TABLE rx_recipe_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipe_id INTEGER NOT NULL, medicine_id INTEGER NOT NULL, dose TEXT, note TEXT, FOREIGN KEY(recipe_id) REFERENCES rx_recipe(id), FOREIGN KEY(medicine_id) REFERENCES medicine(id) );第一张表medicine的关键不在id而在property、meridian、efficacy这些中医字段。字段文本一旦写得不统一比如“微温”和“微 温”查询端用LIKE %温%时结果会不稳定所以入库前要做一次 trim。第二张表rx_recipe记录“给谁看、诊断是什么、什么时候开的方”created_at使用 SQLite 的datetime(now,localtime)避免自己在 Java 层拼时间字符串。第三张表rx_recipe_detail是对多对多的拆解没有它一个处方里多味药就只能塞进一个长字符串后面想统计“哪味药最常用”就非常痛苦。3.2 DbHelper 的初始化与升级逻辑DbHelper继承SQLiteOpenHelper后核心就是onCreate和onUpgrade。常见写法是public class DbHelper extends SQLiteOpenHelper { private static final String DB_NAME zz_doctor.db; private static final int DB_VERSION 1; public DbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE medicine (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, ...)); db.execSQL(CREATE TABLE rx_recipe (id INTEGER PRIMARY KEY AUTOINCREMENT, patient_name TEXT, ...)); db.execSQL(CREATE TABLE rx_recipe_detail (id INTEGER PRIMARY KEY AUTOINCREMENT, recipe_id INTEGER, ...)); initMedicineData(db); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(DROP TABLE IF EXISTS rx_recipe_detail); db.execSQL(DROP TABLE IF EXISTS rx_recipe); db.execSQL(DROP TABLE IF EXISTS medicine); onCreate(db); } }这里有个重点DB_NAME是数据库文件名DB_VERSION是 Schema 版本号。以后如果只加一列应该把DB_VERSION改成 2再在onUpgrade里写ALTER TABLE迁移。示例代码里使用DROP TABLE再重建只是演示项目为了快速重置数据别直接抄进生产环境。一个医疗记录应用如果把历史处方一次性清掉后果不是闪退能比的。3.3 离线场景下内置药材数据的初始化大夫打开应用时不可能等网络同步所以onCreate里通常会写入一批常用药材作为本地字典。数量不大时用ContentValues逐条插入就好private void initMedicineData(SQLiteDatabase db) { db.beginTransaction(); try { ContentValues cv new ContentValues(); cv.put(name, 人参); cv.put(property, 甘、微苦微温); cv.put(meridian, 归脾、肺、心、肾经); cv.put(efficacy, 大补元气复脉固脱); db.insert(medicine, null, cv); cv.clear(); cv.put(name, 黄芪); cv.put(property, 甘微温); cv.put(meridian, 归脾、肺经); cv.put(efficacy, 补气升阳固表止汗); db.insert(medicine, null, cv); db.setTransactionSuccessful(); } finally { db.endTransaction(); } }beginTransaction让几百条insert变成一个事务如果中间某条数据因为约束失败之前写入的也会一起回滚不会出现“内置药材只插了一半”的情况。setTransactionSuccessful()一定要在finally的endTransaction()之前调用这个顺序写反了事务不会提交。3.4 给界面层提供查询方法MedicineQuery页面最终需要的是一个能按关键词查药材的方法public ListMedicine queryMedicine(String keyword) { SQLiteDatabase db getReadableDatabase(); ListMedicine result new ArrayList(); String sql SELECT * FROM medicine WHERE name LIKE ? OR alias LIKE ? OR efficacy LIKE ?; String arg % keyword %; Cursor cursor db.rawQuery(sql, new String[]{arg, arg, arg}); while (cursor.moveToNext()) { Medicine m new Medicine(); m.setId(cursor.getLong(cursor.getColumnIndexOrThrow(id))); m.setName(cursor.getString(cursor.getColumnIndexOrThrow(name))); m.setEfficacy(cursor.getString(cursor.getColumnIndexOrThrow(efficacy))); result.add(m); } cursor.close(); return result; }LIKE的三个参数分别绑定到name、alias、efficacy这样搜“人参”能命中名字搜“补气”能命中功效。数据库量级在一两千条时这个写法响应很快但如果以后把全国中草药库都塞进去就要考虑给name和efficacy建索引或者改用 SQLite 的 FTS5 全文检索。4. 处方查询与编辑链路WelcomeRxRecipeQuery 到 RxRecipeDetailEdit4.1 Welcome 不是闪屏而是工作台初次看到WelcomeRxRecipeQuery时容易把它当成欢迎页。仔细看命名WelcomeRxRecipeQuery拆开是 Welcome RxRecipe Query更像是“进入应用后的处方查询首页”。这类类在代码里通常继承Activity布局由搜索框、ListView 和若干按钮组成。搜索框输入药名或功效关键词后调用DbHelper.queryMedicine()拿到结果再把结果交给IdentifiedStringAdapter。public class WelcomeRxRecipeQuery extends Activity { private EditText searchInput; private ListView resultList; private DbHelper dbHelper; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_welcome); dbHelper new DbHelper(this); searchInput findViewById(R.id.search_input); resultList findViewById(R.id.result_list); resultList.setOnItemClickListener((parent, view, position, id) - { Intent intent new Intent(this, RxRecipeDetail.class); intent.putExtra(recipe_id, id); startActivity(intent); }); } public void onSearchClick(View view) { String keyword searchInput.getText().toString().trim(); ListMedicine medicines dbHelper.queryMedicine(keyword); resultList.setAdapter(new IdentifiedStringAdapter(this, medicines, keyword)); } }这段代码里最容易踩坑的是OnItemClickListener的id参数。如果ListView用的是ArrayAdapter这个id只是列表索引不是数据库主键。正确做法是把Medicine.getId()先放进实体对象然后在点击事件里通过position取出来再放到Intent。如果项目里用的是CursorAdapterid才能直接当成数据库_id使用。4.2 详情与编辑页通过 recipe_id 联动处方详情页RxRecipeDetail要做的不是编辑而是展示。它根据recipe_id查出主表字段和明细列表并在点击“编辑”时跳到RxRecipeDetailEdit。编辑页保存成功后调用setResult(RESULT_OK)详情页收到结果后重查数据库并刷新列表。这里的关键是不要把编辑结果通过 Intent 原样传回因为明细是一个集合序列化麻烦而数据库本来就是唯一事实源。public class RxRecipeDetailEdit extends Activity { private DbHelper dbHelper; private long recipeId; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); recipeId getIntent().getLongExtra(recipe_id, -1L); dbHelper new DbHelper(this); if (recipeId -1L) { finish(); return; } loadRecipeDetail(recipeId); } public void onSaveClick(View view) { String title titleInput.getText().toString(); String diagnosis diagnosisInput.getText().toString(); dbHelper.updateRecipe(recipeId, title, diagnosis); setResult(RESULT_OK); finish(); } }getLongExtra(recipe_id, -1L)里的-1L是哨兵值。如果某个入口忘记传recipe_id页面会在onCreate里直接finish()避免对不存在的处方做无意义的更新。这种防御性写法在多个页面互相跳转时很有用尤其是接手别人留下的工程时。4.3 明细节点的“先删后插”事务写法编辑页最大的风险是明细不一致。假设处方原本有 3 味药用户在编辑页删掉第 2 味又新增了 2 味。如果不做全量同步很容易出现某一味药在界面上看不到、数据库里还留着的情况。常见做法是“先删后插”先删除该处方在rx_recipe_detail里的所有行再把当前列表重新插入。这样写最直观适合每个处方明细不超过 20 条的场景。SQLiteDatabase db dbHelper.getWritableDatabase(); db.beginTransaction(); try { db.delete(rx_recipe_detail, recipe_id ?, new String[]{String.valueOf(recipeId)}); for (RxRecipeDetailItem item : items) { ContentValues cv new ContentValues(); cv.put(recipe_id, recipeId); cv.put(medicine_id, item.medicineId); cv.put(dose, item.dose); cv.put(note, item.note); db.insert(rx_recipe_detail, null, cv); } db.setTransactionSuccessful(); } finally { db.endTransaction(); }这段逻辑必须放进事务。没有事务时如果删除成功但插入中间失败处方会变成“只剩部分药材”的脏数据。加了事务后删除和插入要么全部成功要么全部回滚。db.beginTransaction()与db.endTransaction()必须成对出现使用try/finally而不是try/catch因为endTransaction()无论是否异常都要执行。5. IdentifiedStringAdapter 的高亮实现与验证方法5.1 用 SpannableString 做命中关键词标识IdentifiedStringAdapter是一个处理“带标识文本”的 BaseAdapter。在列表项里把命中的关键词用底色或颜色区分出来比直接 setText 更直观也方便大夫在搜索结果里快速定位是哪里命中了“补气”或者“温”。public class IdentifiedStringAdapter extends BaseAdapter { private final Context context; private final ListMedicine data; private final String keyword; private static class ViewHolder { TextView nameView; TextView efficacyView; } Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView LayoutInflater.from(context).inflate(R.layout.item_medicine, parent, false); holder new ViewHolder(); holder.nameView convertView.findViewById(R.id.medicine_name); holder.efficacyView convertView.findViewById(R.id.medicine_efficacy); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } Medicine medicine data.get(position); holder.nameView.setText(highlight(medicine.getName(), keyword)); holder.efficacyView.setText(highlight(medicine.getEfficacy(), keyword)); return convertView; } private SpannableString highlight(String text, String keyword) { SpannableString spannable new SpannableString(text); if (keyword ! null !keyword.isEmpty()) { int index text.indexOf(keyword); while (index 0) { spannable.setSpan( new BackgroundColorSpan(0x33FF9800), index, index keyword.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); index text.indexOf(keyword, index keyword.length()); } } return spannable; } }convertView复用不能省否则快速滚动时列表会频繁 inflate出现卡顿。关键词高亮用BackgroundColorSpan而不用ForegroundColorSpan是因为底色不会和药名原有的语义颜色冲突。SPAN_EXCLUSIVE_EXCLUSIVE表示新插入内容不会被带上同一套 Span避免多次刷新后颜色叠成一块。5.2 安装后用 adb 验证页面栈资源包里的zz_doctor.apk可以直接装到模拟器验证。先拿到真实包名aapt dump badging zz_doctor.apk | grep package再从AndroidManifest.xml里找到WelcomeRxRecipeQuery对应的完整类名大概率是包名.WelcomeRxRecipeQuery然后启动adb shell am start -n 包名/.WelcomeRxRecipeQuery如果界面正常弹出说明这个类确实是可启动的入口页面。想确认跳转顺序到底是不是从WelcomeRxRecipeQuery到RxRecipeDetail可以打开应用点击一条查询结果再执行adb shell dumpsys activity activities | grep -E topResumedActivity|mFocusedApp输出里会显示当前前台 Activity 的完整类名。用这个方法比对每一步点击后的 Activity 变化就能验证你在源码里推断的页面链路是否和实际安装包一致。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门