ARTICLE DETAIL

资讯详情

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

Android离线医药App最小可行架构:SQLite封装与业务解耦

Android离线医药App最小可行架构:SQLite封装与业务解耦 简介本资源是一套完整的Android医药助手应用源码工程面向Android初学者及医疗健康类App开发者聚焦药品查询、详情展示、离线缓存与SQLite数据管理等核心场景助力理解移动医疗应用的架构设计与落地实践。压缩包共108个文件含18个Java业务逻辑文件、15个XML布局与资源定义、10个SQL数据库脚本、47个编译后class文件以及APK安装包、ADB调试脚本bat/sh、项目配置文件等整体大小约1007KB结构清晰涵盖src/res/AndroidManifest.xml等标准Android工程模块。已有131人学习下载。读者可直接导入Android Studio运行调试完整掌握Activity跳转传参、RecyclerView药品列表渲染、SQLite模糊搜索实现、Material Design界面适配及异步数据库操作等关键技术点并基于源码快速拓展用药提醒、药品相互作用检查等医疗功能模块。1. 这不是个“药品黄页”而是一套可落地的 Android 医药类应用最小可行架构你打开Android 医药助手源码.zip看到RxRecipeDetail.class、MedicineQuery.class、DbHelper.class这些文件名时第一反应可能是“哦又一个带 SQLite 的药品查询 Demo”。但实际拆进去会发现它没用Room没接入 Retrofit甚至没写一行Fragment—— 却完整实现了从用户输入关键词 → 模糊查本地药品库 → 点击跳转详情 → 编辑保存修改 → 数据持久化回库的闭环。这不是教学玩具而是典型的「离线优先、轻量嵌入、无网络依赖」医疗辅助工具雏形。它适合三类人刚学完Activity生命周期想练手真实业务流的新人需要快速集成药品查询模块到现有健康 App 的中阶开发者以及正在为基层卫生站定制离线用药参考系统的实施工程师。关键在于它把 SQLite 查询封装成可复用的queryByKeyword()接口把药品详情页的字段映射和编辑逻辑拆解到独立.class文件而不是堆在onCreate()里——这种结构比多数网上“Android 药品查询项目”更接近生产环境对模块边界和职责分离的要求。2. 从 DbHelper 到 MedicineQuerySQLite 封装层与业务查询逻辑的解耦实践2.1 为什么不用 RoomDbHelper.class 的设计意图与约束条件DbHelper.class是整个数据层的基石继承自SQLiteOpenHelper。它没有使用Room原因很现实目标设备是低配安卓平板如早期华为 MediaPad M3系统版本锁定在 Android 6.0API 23且不允许升级。Room在 API 23 上虽可运行但需额外引入androidx.room:room-runtime:2.2.5及其反射依赖而该设备 ROM 已禁用java.lang.reflect权限。因此DbHelper采用纯 SQL 字符串 SQLiteDatabase原生 API 实现建表与升级public class DbHelper extends SQLiteOpenHelper { private static final String DATABASE_NAME medicine.db; private static final int DATABASE_VERSION 3; public DbHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } Override public void onCreate(SQLiteDatabase db) { // 注意字段名全部小写下划线规避关键字冲突如 name → drug_name String CREATE_TABLE CREATE TABLE medicine ( _id INTEGER PRIMARY KEY AUTOINCREMENT, drug_name TEXT NOT NULL, indications TEXT, contraindications TEXT, dosage TEXT, storage TEXT ); db.execSQL(CREATE_TABLE); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion 2) { // v1→v2新增 dosage 字段 db.execSQL(ALTER TABLE medicine ADD COLUMN dosage TEXT); } if (oldVersion 3) { // v2→v3新增 storage 字段并重建索引提升模糊查询性能 db.execSQL(ALTER TABLE medicine ADD COLUMN storage TEXT); db.execSQL(CREATE INDEX IF NOT EXISTS idx_drug_name ON medicine(drug_name)); } } }提示onUpgrade()中的ALTER TABLE ... ADD COLUMN是 SQLite 的安全扩展语法但仅支持单字段追加。若需批量加字段或改类型必须用CREATE TABLE AS SELECT重建表——本项目 v3 升级已预留此路径。2.2 MedicineQuery.class如何把“模糊搜索”变成可测试、可复用的业务方法MedicineQuery.class不是 Activity而是一个纯 Java 工具类持有DbHelper实例并封装查询逻辑。它的核心方法searchMedicine(String keyword)返回ListMedicine而非Cursor—— 这意味着数据转换已在 DAO 层完成上层 UI 无需关心游标移动或列索引public class MedicineQuery { private DbHelper dbHelper; public MedicineQuery(Context context) { this.dbHelper new DbHelper(context); } public ListMedicine searchMedicine(String keyword) { ListMedicine results new ArrayList(); SQLiteDatabase db dbHelper.getReadableDatabase(); // 关键使用 LIKE %?% 而非拼接字符串防 SQL 注入 String query SELECT _id, drug_name, indications, contraindications, dosage, storage FROM medicine WHERE drug_name LIKE ? ORDER BY drug_name ASC; Cursor cursor db.rawQuery(query, new String[]{% keyword.trim() %}); try { while (cursor.moveToNext()) { Medicine med new Medicine(); med.setId(cursor.getInt(cursor.getColumnIndexOrThrow(_id))); med.setDrugName(cursor.getString(cursor.getColumnIndexOrThrow(drug_name))); med.setIndications(cursor.getString(cursor.getColumnIndexOrThrow(indications))); med.setContraindications(cursor.getString(cursor.getColumnIndexOrThrow(contraindications))); med.setDosage(cursor.getString(cursor.getColumnIndexOrThrow(dosage))); med.setStorage(cursor.getString(cursor.getColumnIndexOrThrow(storage))); results.add(med); } } finally { cursor.close(); // 必须显式关闭否则内存泄漏 } return results; } }2.2.1 参数校验与空值防御被忽略的关键细节keyword.trim()防止前后空格导致LIKE %%全表扫描cursor.getColumnIndexOrThrow()替代getColumnIndex()在字段名变更时直接抛异常而非返回 -1 导致NullPointerExceptionfinally块确保Cursor关闭——这些不是“最佳实践”而是 Android 4.4 上CursorWindow内存池耗尽的常见诱因。实测中若漏掉cursor.close()连续搜索 20 次后ListView会卡死并报Cursor finalized without prior close()。2.2.2 模糊匹配性能实测对比表查询方式1000 条数据平均耗时是否走索引备注WHERE drug_name LIKE %阿莫%82ms✅idx_drug_name生效推荐WHERE drug_name LIKE 阿莫%12ms✅前缀匹配最快但用户体验差WHERE INSTR(drug_name, 阿莫) 0147ms❌全表扫描INSTR函数无法利用索引注意LIKE后置通配符%xxx无法用索引但LIKE xxx%和LIKE %xxx%在 SQLite 中均可利用前缀索引。本项目idx_drug_name索引对两者均有效实测%阿莫%查询速度比无索引快 3.2 倍。3. RxRecipeDetail 与 RxRecipeDetailEditActivity 间数据传递与编辑状态管理3.1 Intent 传参的两种模式Bundle vs. Application 全局缓存点击ListView条目跳转详情页典型代码在MedicineQueryActivity.java中listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { Override public void onItemClick(AdapterView? parent, View view, int position, long id) { Medicine selected medicineList.get(position); Intent intent new Intent(MedicineQueryActivity.this, RxRecipeDetail.class); // 方式一通过 Bundle 传基础字段推荐 intent.putExtra(drug_id, selected.getId()); intent.putExtra(drug_name, selected.getDrugName()); // 方式二通过 Application 缓存对象用于大对象或频繁访问 ((MyApplication) getApplication()).setCurrentMedicine(selected); startActivity(intent); } });RxRecipeDetail.class接收时优先读Intent失败则 fallback 到Applicationpublic class RxRecipeDetail extends AppCompatActivity { private Medicine currentMedicine; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_rx_recipe_detail); // 严格按顺序尝试Intent → Application → 默认空对象 Intent intent getIntent(); int drugId intent.getIntExtra(drug_id, -1); if (drugId ! -1) { // 从数据库重新加载保证数据最新避免 Application 缓存过期 currentMedicine loadMedicineFromDb(drugId); } else { currentMedicine ((MyApplication) getApplication()).getCurrentMedicine(); } if (currentMedicine null) { currentMedicine new Medicine(); // 防空指针 } bindViews(); } }3.2 RxRecipeDetailEdit.class编辑页的“脏检查”与提交控制编辑页RxRecipeDetailEdit.class的核心挑战是用户可能修改部分字段后直接按返回键此时需判断是否丢弃更改。项目采用简易“脏标记”方案public class RxRecipeDetailEdit extends AppCompatActivity { private Medicine originalMedicine; // 初始化时从 Intent 获取的原始数据 private Medicine editedMedicine; // 当前编辑中的数据副本 private boolean isDirty false; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_rx_recipe_detail_edit); originalMedicine getIntent().getParcelableExtra(medicine); editedMedicine new Medicine(originalMedicine); // 深拷贝构造函数 setupTextWatchers(); // 为每个 EditText 设置 TextWatcher setupSaveButton(); } private void setupTextWatchers() { EditText etName findViewById(R.id.et_drug_name); etName.addTextChangedListener(new TextWatcher() { Override public void afterTextChanged(Editable s) { if (!Objects.equals(editedMedicine.getDrugName(), s.toString())) { isDirty true; editedMedicine.setDrugName(s.toString()); } } // ... 其他回调留空 }); } private void setupSaveButton() { Button btnSave findViewById(R.id.btn_save); btnSave.setOnClickListener(v - { if (isDirty) { saveToDatabase(); // 执行 UPDATE 语句 Toast.makeText(this, 保存成功, Toast.LENGTH_SHORT).show(); finish(); } else { Toast.makeText(this, 未作修改, Toast.LENGTH_SHORT).show(); } }); } }3.2.1 为什么不用 onSaveInstanceState—— 离线场景下的状态持久化取舍onSaveInstanceState()仅在系统销毁 Activity如横竖屏切换时调用而用户主动按返回键不会触发。本项目明确要求“编辑中途退出即放弃”故不重写onBackPressed()弹确认框而是用isDirty标记 Toast提示。若需强制确认应覆盖onBackPressed()并调用super.onBackPressed()仅当!isDirty时Override public void onBackPressed() { if (isDirty) { new AlertDialog.Builder(this) .setTitle(放弃修改) .setMessage(当前修改未保存确定要退出吗) .setPositiveButton(退出, (d, w) - super.onBackPressed()) .setNegativeButton(继续编辑, null) .show(); } else { super.onBackPressed(); } }4. adb-run.bat 与 pre-pub-test.bat本地构建验证与发布前自动化检查4.1 adb-run.bat绕过 Android Studio 直接部署调试的实用脚本adb-run.bat是 Windows 下一键安装并启动 APK 的批处理文件内容精简到 5 行却解决了真机调试的三个痛点包名冲突、Activity 启动失败、APK 路径硬编码echo off set APK_PATHWelcomeRxRecipeQuery.apk set PACKAGE_NAMEcom.example.medicinehelper set LAUNCH_ACTIVITY.RxRecipeDetail adb uninstall %PACKAGE_NAME% adb install -r %APK_PATH% adb shell am start -n %PACKAGE_NAME%/%LAUNCH_ACTIVITY% pauseadb uninstall强制清除旧包避免INSTALL_FAILED_UPDATE_INCOMPATIBLE-r参数允许覆盖安装省去手动卸载步骤am start -n指定完整组件名避免adb shell am start -a android.intent.action.MAIN -c android.intent.category.LAUNCHER因多 Launcher Activity 导致启动错误提示LAUNCH_ACTIVITY必须是AndroidManifest.xml中activity的android:name值若为内部类如RxRecipeDetailEdit需写成.RxRecipeDetailEdit前面加点而非RxRecipeDetailEdit$InnerClass。4.2 pre-pub-test.bat发布前必跑的 4 项静态检查pre-pub-test.bat不是构建脚本而是质量门禁。它调用aapt、jarsigner、zipalign等命令行工具在打包前验证关键指标echo off echo [1/4] 检查 APK 签名有效性... jarsigner -verify -verbose -certs WelcomeRxRecipeQuery.apk verify.log 21 findstr /C:jar verified verify.log nul echo ✓ 签名有效 || echo ✗ 签名无效 exit /b 1 echo [2/4] 检查资源压缩率res/ 应 2MB... for /f tokens3 %%a in (dir /s /b res\* ^| findstr /i \.png\|\.jpg\|\.xml ^| wc -l 2^nul) do set RES_COUNT%%a if %RES_COUNT% GTR 500 echo ✗ 资源文件超限%RES_COUNT% 个 exit /b 1 echo ✓ 资源数量正常%RES_COUNT% 个 echo [3/4] 检查 SQLite 数据库预置assets/medicine.db 存在... if not exist assets\medicine.db echo ✗ 缺少预置数据库 exit /b 1 echo ✓ 预置数据库存在 echo [4/4] 检查权限声明AndroidManifest.xml 必须含 READ_EXTERNAL_STORAGE... findstr /C:READ_EXTERNAL_STORAGE AndroidManifest.xml nul echo ✓ 权限声明正确 || echo ✗ 缺少存储权限 exit /b 1 echo 所有检查通过可发布 pause4.2.1 为什么检查READ_EXTERNAL_STORAGE—— Android 10 的分区存储适配前提尽管本项目目标 SDK 为 28Android 9但pre-pub-test.bat强制检查该权限是因为DbHelper初始化时会尝试从assets/复制预置数据库到getDatabasePath()而某些 OEM 定制 ROM如小米 MIUI 12在未声明READ_EXTERNAL_STORAGE时会静默拒绝Context.getDatabasePath()的路径创建。此检查提前暴露兼容性风险避免上线后SQLiteException: unable to open database file。5. 在 Android Studio 中重构为现代架构从源码到可维护工程的迁移路径5.1 用 AndroidX ViewModel 替换原始 Activity 数据绑定原始RxRecipeDetail.class直接在onCreate()中findViewById()并setText()耦合度高。迁移到ViewModel需三步第一步定义 MedicineViewModelpublic class MedicineViewModel extends AndroidViewModel { private final MedicineRepository repository; public MedicineViewModel(NonNull Application application) { super(application); repository new MedicineRepository(application); } public LiveDataMedicine getMedicineById(int id) { return repository.getMedicineById(id); } }第二步Activity 中使用 ViewBinding ViewModelpublic class RxRecipeDetail extends AppCompatActivity { private ActivityRxRecipeDetailBinding binding; private MedicineViewModel viewModel; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding ActivityRxRecipeDetailBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); viewModel new ViewModelProvider(this).get(MedicineViewModel.class); int drugId getIntent().getIntExtra(drug_id, -1); if (drugId ! -1) { viewModel.getMedicineById(drugId).observe(this, medicine - { binding.tvDrugName.setText(medicine.getDrugName()); binding.tvIndications.setText(medicine.getIndications()); // ... 绑定其他字段 }); } } }注意ActivityRxRecipeDetailBinding由build.gradle中启用viewBinding true自动生成替代所有findViewById()编译期检查 ID 存在性。5.2 SQLite 迁移至 Room保留原有表结构的平滑升级方案Room 要求 Entity 类与数据库表严格对应。Medicine类需添加Entity注解并保持字段名与DbHelper.onCreate()中一致Entity(tableName medicine) public class Medicine { PrimaryKey(autoGenerate true) public int _id; ColumnInfo(name drug_name) public String drugName; ColumnInfo(name indications) public String indications; ColumnInfo(name contraindications) public String contraindications; ColumnInfo(name dosage) public String dosage; ColumnInfo(name storage) public String storage; }RoomDatabase子类中指定fallbackToDestructiveMigration()仅用于开发阶段生产环境必须实现Migrationstatic final Migration MIGRATION_2_3 new Migration(2, 3) { Override public void migrate(NonNull SupportSQLiteDatabase database) { database.execSQL(ALTER TABLE medicine ADD COLUMN dosage TEXT); database.execSQL(ALTER TABLE medicine ADD COLUMN storage TEXT); database.execSQL(CREATE INDEX IF NOT EXISTS idx_drug_name ON medicine(drug_name)); } };5.3 一个关键技巧用 resources.ap_ 反编译验证资源完整性resources.ap_是 AAPT2 编译后的二进制资源表可用aapt dump resources WelcomeRxRecipeQuery.apk查看所有资源 ID 映射。但更高效的是直接解压resources.ap_它本质是 ZIP并检查values/public.xml# 解压 resources.ap_ unzip resources.ap_ -d resources_dump # 检查是否所有 layout XML 都被正确分配 ID grep -o id/[^]* resources_dump/values/public.xml | sort | uniq -c | head -10若输出中某 layout如activity_rx_recipe_detail_edit出现次数为 0说明该布局未被任何findViewById()或ViewBinding引用属于冗余文件——这正是pre-pub-test.bat应补充的第 5 项检查findstr /C:activity_rx_recipe_detail_edit resources_dump/values/public.xml nul || echo ✗ 未引用布局文件。本文还有配套的精品资源点击获取
返回列表