
簡介本資源是一套完整的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 ? 未引用布局文件。本文還有配套的精品資源點擊獲取