Android服药提醒App开发:Room+AlarmManager+通知渠道全攻略
2026/9/15 14:48:35 网站建设 项目流程

简介:面向Android毕业设计场景的老年人服药提醒App完整项目包,采用Android前端与Java后台(SpringBoot/SSM)的分离式架构,并搭配MySQL数据库,适合计算机相关专业学生用于毕业设计、课程设计或期末大作业。项目代码包含详细注释,从界面布局到后台接口均有清晰说明,新手也能循序渐进地理解前后端交互逻辑;整个压缩包共4个文件,总体积69.1MB,内含源代码ZIP、数据库脚本SQL及部署说明TXT,覆盖环境准备、导入配置、运行调试等关键步骤。资源已通过严格调试,代码可正常运行,部署说明中还给出MySQL 5.7、JDK、AndroidStudio等环境建议,帮助减少踩坑;模块划分清晰,便于在此基础上进行二次扩展。目前已有53人参与学习下载,对需要快速搭建完整毕设方案的同学来说具备实际参考价值,也可直接用于课程设计与期末大作业的参考资料。

1. 基于 Android 的老年人服药提醒 App,毕业设计里最容易被低估的一条线

一个高血压老人一天要吃 4 种药,上午、中午、晚上、睡前各自不同,子女上班没人盯着,漏服一次血压就波动。这是老年人服药提醒 App 最典型的落地场景,也是 Android 毕业设计里出现频率很高的题目。这个题目看着不难,做扎实了并不容易:它要求你同时处理数据持久化、定时任务、系统通知三大块,还要面对 Android 12 之后越来越严格的闹钟与后台限制。很多人把精力花在界面好不好看上,结果答辩时被问一句“App 被杀后提醒还响吗”就答不上来。

这条技术线值得认真拆一遍。下面按“设计思路 → 数据库与通知实现 → 完整链路与排错 → 加分项”四个层次展开,所有代码基于 Android Studio 开发环境、Kotlin 语言和 Room 数据库,不依赖第三方云服务,适合作为毕业设计源码的基础框架。

2. 服药提醒 App 的骨架:技术选型、定时机制与高版本 Android 的适配策略

2.1 数据库选型:为什么我推荐 Room 而不是裸写 SQLite

毕业设计里最常见的做法是直接继承 SQLiteOpenHelper 写数据库操作,网上的老代码也基本都是这个套路。但对于服药提醒这种表结构固定、查询条件较多的项目,我建议用 Room。Room 是 Android 官方的 ORM 框架,底层仍然是 SQLite,但它在编译期就会检查 SQL 语句是否正确,表名写错、字段名写错会在编译阶段直接报错,而不是等到运行期崩掉。对 Android 开发经验不多的学生来说,这个特性价值很大。

另一个优点是 Room 和 LiveData、Flow 配合得很自然。服药记录插入后,界面上的“今日服药进度”可以自动更新,不需要手动刷新,答辩演示时会顺畅很多。

维度纯 SQLiteRoomGreenDAO
SQL 检查时机运行期编译期编译期
学习成本中高
LiveData 支持手动实现原生支持需要额外适配
适合场景单表、逻辑少中小型项目大型项目、需要加密
毕业设计推荐度可用推荐不推荐

Room 的劣势是需要写 Entity、Dao、Database 三部分代码,文件数量比纯 SQLite 多,但这正好符合毕业设计对“项目结构完整”的要求,设计说明书里也能多写一章架构分析。

2.2 定时任务的三条路线:AlarmManager、WorkManager、Handler 轮询

服药提醒的核心是“到时间触发通知”,定时任务的选型决定了整个 App 的可靠性。常见方案有三种,这里先给结论。

Handler + 死循环轮询:最不建议的方案。App 进程一旦被系统回收,轮询立刻中断,而且常驻后台比较费电,答辩时很容易被追问“进程被杀怎么办”。

WorkManager:适合周期性的、对时间精度要求不高的任务,比如每天同步一次数据。它的问题是最小周期是 15 分钟,而且系统可能推迟执行,不符合“上午 8 点提醒吃降压药”这种精确到分钟的需求。

AlarmManager:Android 官方的闹钟服务,用于在指定时间触发一次或周期性任务。它是系统级服务,即使 App 进程不在,也能通过 BroadcastReceiver 把事件拉起来。服药提醒场景下这是正确选择。

具体到 AlarmManager 的 API,有几个方法需要分清:

方法行为省电模式下的表现适用场景
set()非精确闹钟可能延迟不推荐用于服药提醒
setExactAndAllowWhileIdle()精确闹钟,允许待机时触发正常触发普通药物的准点提醒
setAlarmClock()精确闹钟,最高优先级,显示闹钟图标一定触发关键药物,建议使用

实现时还要注意 requestCode 不要写死成同一个数字。比如 5 条服药计划都传入 requestCode = 1,后注册的闹钟会覆盖先注册的。一般用数据库里的主键 ID 作为 requestCode,这样每条提醒互相独立,取消时也能精确定位。

提示:Android 12(API 31)开始,使用精确闹钟需要在 Manifest 中声明 SCHEDULE_EXACT_ALARM 权限,并且用户可以在系统设置里手动关闭该权限,代码层面需要捕获 SecurityException 做降级处理。

2.3 Android 13/14 的通知渠道与后台限制

传统写法里直接 new Notification(...) 然后 notify() 的做法在 Android 8.0 以后已经失效了。Android 8.0(API 26)引入了通知渠道(NotificationChannel)概念,通知必须归属于某个渠道才能显示。对服药提醒 App 来说,我通常会建两个渠道,一个是“服药提醒”渠道,优先级设为最高;另一个是“系统消息”渠道,优先级默认,用于展示用药记录同步结果之类的消息。

除了通知,还要处理电池优化白名单。国产手机厂商的后台管理策略尤其激进,小米、华为、OPPO 默认都会限制自启动。可以在代码里引导用户跳转到系统设置页,把 App 加入电池优化白名单。这个功能不是毕业设计的核心,但写进设计说明书里会显得考虑全面。

val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) intent.data = Uri.parse("package:$packageName") startActivity(intent)

ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 会弹出系统对话框,用户点击允许后,App 会加入电池优化白名单,减少被系统杀死或延迟闹钟的概率。需要说明的是,这个页面必须在用户主动操作时调用,不能在一进 App 就弹,否则会被应用商店审核拒绝。

3. 在 Android Studio 里把服药提醒的数据库与通知模块先跑通

3.1 建表:服药计划表与服药记录表的核心字段

先看两张表的 SQL 设计,这是后续所有代码的基础。第一张表存“计划”,即什么药、什么时候吃、吃多少。第二张表存“记录”,即某次提醒是否被确认。

CREATE TABLE med_plan ( id INTEGER PRIMARY KEY AUTOINCREMENT, med_name TEXT NOT NULL, -- 药物名称,如 苯磺酸氨氯地平片 dosage TEXT DEFAULT '', -- 剂量描述,如 5mg/次 remind_time TEXT NOT NULL, -- 提醒时间,格式 HH:mm,如 08:00 repeat_days TEXT NOT NULL, -- 重复星期,如 1,2,3,4,5 代表周一到周五 is_active INTEGER DEFAULT 1, -- 是否启用,1启用 0停用 create_time TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE med_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL, -- 关联 med_plan.id remind_date TEXT NOT NULL, -- 提醒日期,格式 yyyy-MM-dd status INTEGER DEFAULT 0, -- 0未确认 1已服药 2已跳过 confirm_time TEXT, -- 用户点击“已服药”的时间 FOREIGN KEY(plan_id) REFERENCES med_plan(id) );

字段说明:remind_time 存字符串而不是时间戳,是为了方便按“今天 08:00 有哪些药”来查询,字符串比较在 SQLite 中就能完成。repeat_days 用逗号分隔的数字表示星期,周一到周日分别对应 1 到 7,这样 SELECT 时用 FIND_IN_SET 风格的 LIKE 查询即可实现“只在工作日提醒”。

需要留意的坑是 med_record 的 plan_id 外键。SQLite 默认不启用外键约束,需要在数据库连接时执行 PRAGMA foreign_keys = ON。Room 中可以在 RoomDatabase.Callback 的 onOpen 里设置。

val callback = object : RoomDatabase.Callback() { override fun onOpen(db: SupportSQLiteDatabase) { super.onOpen(db) db.execSQL("PRAGMA foreign_keys = ON") } }

不启用外键约束,删除计划时不会级联删除记录,时间长了会出现一批“孤儿记录”,统计服药率时数据就对不上。

3.2 Room 的 Entity、DAO 与 Database 三段式

Room 需要写三个文件。Entity 对应表结构,DAO 定义操作接口,Database 是入口。

@Entity(tableName = "med_plan") data class MedPlan( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "med_name") val medName: String, @ColumnInfo(name = "dosage") val dosage: String = "", @ColumnInfo(name = "remind_time") val remindTime: String, @ColumnInfo(name = "repeat_days") val repeatDays: String, @ColumnInfo(name = "is_active") val isActive: Boolean = true, @ColumnInfo(name = "create_time") val createTime: String = "now" )

DAO 部分给出核心的增删改查接口。注意 @Query 里用 plan_id = :planId 这种参数绑定格式,不要手工拼接 SQL 字符串,Room 不支持 String 拼接。

@Dao interface MedPlanDao { @Query("SELECT * FROM med_plan WHERE is_active = 1 ORDER BY remind_time ASC") suspend fun getActivePlans(): List<MedPlan> @Query("SELECT * FROM med_plan WHERE id = :id") suspend fun getPlanById(id: Long): MedPlan? @Insert suspend fun insertPlan(plan: MedPlan): Long @Update suspend fun updatePlan(plan: MedPlan) @Delete suspend fun deletePlan(plan: MedPlan) @Query("DELETE FROM med_plan WHERE id = :id") suspend fun deletePlanById(id: Long) }

@Insert 方法返回值如果是 Long,会自动拿到新插入行的主键 ID,这个 ID 就是后面注册 AlarmManager 用的 requestCode。DAO 方法用 suspend 关键字,Room 会自动把数据库操作放到子线程执行,避免主线程卡顿。

Database 类则定义一个单例对象:

@Database(entities = [MedPlan::class, MedRecord::class], version = 1, exportSchema = false) abstract class AppDatabase : RoomDatabase() { abstract fun medPlanDao(): MedPlanDao abstract fun medRecordDao(): MedRecordDao companion object { @Volatile private var instance: AppDatabase? = null fun get(context: Context): AppDatabase { return instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "med_reminder.db" ).addCallback(callback).build().also { instance = it } } } } }

databaseBuilder 的第三个参数是数据库文件名,源码包里导出的数据库文件就是 med_reminder.db。version 参数很重要,如果后续修改了表结构,必须把 version 加 1,并且提供 Migration 升级策略,否则 App 会直接崩溃。

3.3 通知渠道与点击跳转:Android 8 以后必须写的 NotificationChannel

在创建通知之前,先创建通知渠道。这段代码应该在主 Activity 的 onCreate 里调用一次。

fun createNotificationChannel(context: Context) { val channel = NotificationChannel( "med_reminder_channel", "服药提醒", NotificationManager.IMPORTANCE_HIGH ).apply { description = "用于服药到点提醒" enableVibration(true) vibrationPattern = longArrayOf(0, 500, 300, 500) } val manager = context.getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) }

NotificationChannel 构造方法的第二个参数是用户可见的渠道名称,会显示在系统设置的应用通知页面里。IMPORTANCE_HIGH 表示需要弹出横幅提醒并伴随声音,如果设为 IMPORTANCE_DEFAULT 则只在通知栏显示一条静默通知,老人很容易错过。vibrationPattern 定义了震动节奏:立即震动 500 毫秒、停 300 毫秒、再震动 500 毫秒。

接着构造通知本身。Android 12 开始,PendingIntent 必须显式指定可变性,FLAG_IMMUTABLE 是默认推荐值:

val intent = Intent(context, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( context, planId.toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification = NotificationCompat.Builder(context, "med_reminder_channel") .setSmallIcon(R.drawable.ic_med_reminder) .setContentTitle("服药时间到") .setContentText("${plan.medName} ${plan.dosage}") .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .setContentIntent(pendingIntent) .build() NotificationManagerCompat.from(context).notify(planId.toInt(), notification)

setAutoCancel(true) 表示用户点击通知后自动移除。如果不设置,通知会一直挂在通知栏上,用户可能反复看到同一个提醒。notify 的第一个参数是通知 ID,这里沿用 planId,这样修改闹钟后重发通知可以覆盖旧通知,不会出现同一药物多条重复通知。

提示:通知渠道一旦创建,渠道名称和重要性等级就不能再通过代码修改,只有用户能在系统设置里手动调整。所以开发阶段要提前想好渠道分类,不要上线后才发现“服药提醒”渠道的优先级低了。

3.4 定时任务的注册与生效

AlarmManager 的注册代码固定写在广播接收器的配套工具类里,方便服务端和前端共用。

fun scheduleAlarm(context: Context, plan: MedPlan) { val alarmManager = context.getSystemService(AlarmManager::class.java) val intent = Intent(context, MedReminderReceiver::class.java).apply { putExtra("plan_id", plan.id) } val pendingIntent = PendingIntent.getBroadcast( context, plan.id.toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val parts = plan.remindTime.split(":") val calendar = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, parts[0].toInt()) set(Calendar.MINUTE, parts[1].toInt()) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) if (before(Calendar.getInstance())) { add(Calendar.DAY_OF_YEAR, 1) } } alarmManager.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent ) }

Calendar 判断当前时间是否已经晚于提醒时间,如果已过,则自动加一天,保证闹钟不会注册到过去的时间点导致立即触发。setExactAndAllowWhileIdle 的第二个参数是触发的时间戳,单位是毫秒,必须传绝对时间而不是延时时间。第三个参数是 PendingIntent,系统在触发时会发送这个广播。

MedReminderReceiver 收到广播后,第一步查询数据库拿到计划信息,第二步创建通知。因为广播接收器的 onReceive 运行在主线程,数据库查询要用子线程或者协程处理,否则可能触发 ANR。

4. 把一条“到点提醒”链路完整串起来,以及最容易翻车的几个地方

4.1 从添加药物到闹钟响起的完整流程

在界面上点击“添加药物”,输入名称、剂量、时间并勾选重复日期后,保存按钮的点击逻辑如下:

fun savePlan(medName: String, dosage: String, time: String, repeatDays: List<Int>) { val plan = MedPlan( medName = medName, dosage = dosage, remindTime = time, repeatDays = repeatDays.joinToString(","), isActive = true ) val planId = viewModel.insertPlan(plan) viewModel.scheduleAlarmFor(planId) }

第一步把 MedPlan 对象插入数据库,Room 返回的自增主键 planId。第二步调用 scheduleAlarmFor 注册闹钟。注意顺序不能反过来,因为闹钟注册依赖数据库返回的 ID。

闹钟被触发后,Receiver 里的处理逻辑:

class MedReminderReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val planId = intent.getLongExtra("plan_id", -1L) if (planId == -1L) return CoroutineScope(Dispatchers.IO).launch { val db = AppDatabase.get(context) val plan = db.medPlanDao().getPlanById(planId) ?: return@launch val notificationManager = NotificationManagerCompat.from(context) notificationManager.notify(planId.toInt(), buildReminderNotification(context, plan)) } } }

调度器用 Dispatchers.IO 切到子线程查询数据库,查询完成后再用 handler 切回主线程创建通知。为简化代码这里直接在协程里执行,注意最终必须在主线程调用 notify,否则会抛异常。

再把“已服药”按钮的回调补上,这里涉及第二张表 med_record 的插入逻辑:

fun confirmMedication(planId: Long) { val record = MedRecord( planId = planId, remindDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()), status = 1, confirmTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) ) viewModel.insertRecord(record) }

这样当天的服药记录就落到第二张表。第二天同一计划的记录是新的行,统计时按 remind_date 和 plan_id 分组即可得到连续多天的服药率。

4.2 设备重启后闹钟丢失

AlarmManager 注册的闹钟在设备重启后会全部清空,这是一条必须处理的系统行为。App 需要监听 BOOT_COMPLETED 广播,在开机后重新注册所有启用的计划。

class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) { val planDao = AppDatabase.get(context).medPlanDao() CoroutineScope(Dispatchers.IO).launch { val plans = planDao.getActivePlans() plans.forEach { plan -> MedReminderScheduler.scheduleAlarm(context, plan) } } } } }

Manifest 中需要声明 RECEIVE_BOOT_COMPLETED 权限。注意 Android 12 以上,显式声明组件时如果 Intent 的 action 是 BOOT_COMPLETED,组件必须用 android:name 指定完整类名,且接收器需要设置 exported="true" 才能收到系统广播。

开机注册的逻辑还有个小坑:数据库可能还没有初始化完成。如果 App 从未打开过,数据库文件根本不存在,这时注册闹钟会拿到一个空的计划列表。所以在开机完成后创建默认计划数据,或者在首次启动时根据数据库里的计划批量注册,二选一即可。

4.3 时区变化、应用被杀与重复提醒

用户跨时区旅行时,Calendar.getInstance() 获取的是当前时区的系统时间,但 AlarmManager 的 RTC_WAKEUP 是基于用户设定的本地时钟的。如果时区改变,已注册的闹钟会偏移,需要监听 ACTION_TIMEZONE_CHANGED 广播并重新调度所有计划。

应用被用户从后台“最近任务”滑动移除时,普通 BroadcastReceiver 不会收到通知。通过 setExactAndAllowWhileIdle 注册的闹钟有一定概率被延迟,但不至于完全失效。要对用户讲清楚这一点,否则测试时易老人把 App 一滑就抱怨提醒没响。

还有一种情况是通知已经发出,但用户没有看到就锁屏了。代码里可以对 med_record 的状态做校验,如果通知发出后 10 分钟用户没有点击“已服药”,再发一条“催药通知”。这条催药逻辑用一个短的延时 Handler 就能实现,不需要再注册闹钟。

4.4 常见的运行期崩溃与解决方法

开发阶段最容易遇到的几个异常,都在 Android Studio 的日志里长一个样:

  • “App is not defined” 通常是代码中引用了不存在的资源 ID,或者某个控件还没有在布局中声明就调用 findViewById。检查 R 文件的 id 是否存在,布局文件名是否正确。
  • “Unable to start receiver” 多是因为 Manifest 里声明的 Receiver 类路径写错,或 onClick 没有传入正确的 context。对于导出的 BroadcastReceiver,必须显示 exported 属性。
  • “SQLiteConstraintException” 是因为插入数据违反了 NOT NULL 约束,检查 med_plan 表中 med_name 或 remind_time 是否传入了空字符串。

这些在毕业设计答辩时不是核心亮点,但能直接说出解决思路,比“我改了代码就好了”要有说服力。

5. 让设计说明书多两个亮点:导出服药记录与桌面快捷方式

5.1 导出服药记录 CSV 到 Download 目录

加分功能是让用户把服药记录导出成 CSV 文件,方便家人查看或交给社区医生。安卓 10 以后,导出文件用 MediaStore 写入到 Download 目录最稳定,不需要申请存储权限。

fun exportRecords(context: Context) { val resolver = context.contentResolver val contentValues = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, "med_records_${System.currentTimeMillis()}.csv") put(MediaStore.Downloads.MIME_TYPE, "text/csv") } val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) uri ?: return resolver.openOutputStream(uri)?.use { outputStream -> outputStream.bufferedWriter().use { writer -> writer.write("日期,药物,剂量,状态,确认时间\n") AppDatabase.get(context).medRecordDao().getAllRecords().forEach { record -> writer.write("${record.remindDate},${record.medName},${record.dosage},${record.status},${record.confirmTime}\n") } } } }

导出逻辑需要关联查询 plan_id 对应的药物名称和剂量,代码里可以先在 DAO 中定义一个数据类,通过 JOIN 查询一次性返回记录和药物信息。MediaStore 会自动处理文件去重和命名冲突,DISPLAY_NAME 里加时间戳能避免同一天导出多次时互相覆盖。

这段功能值得放在设计说明书的“系统实现”章节里。它演示了 ContentProvider 的用法、文件流操作和关系型数据库的多表查询,正好回答“数据库模块怎么设计的”这类问题,而且演示效果直观。

5.2 验证提醒是否生效的三种手段

开发阶段快速验证闹钟是否注册成功,有两种手段比盯着手机看通知栏更高效。

第一种手段是查系统闹钟队列。在连上 Android Studio 的状态下,在终端执行 adb 命令:

adb shell dumpsys alarm | grep "MedReminderReceiver"

如果输出里能看到对应的 PendingIntent 记录,说明闹钟已经注册。注意 grep 的字符串要和 Manifest 中声明的 Receiver 完全一致,这里的类名是 MedReminderReceiver。

第二种手段是在代码里加日志,每次 scheduleAlarm 时打印以下信息:

Log.d("MedReminderScheduler", "计划ID=${plan.id}, 时间=${plan.remindTime}, 触发时间=${calendar.timeInMillis}")

然后在 Android Studio 的 Logcat 界面过滤 MedReminderScheduler 标签,就能看到每次注册的准确触发时间,方便和日历时间对比。

第三种手段是修改系统时间为提醒前的一分钟,然后观察是否触发。注意修改系统时间只在开发者选项开启“自动日期时间”关闭后才有效,测试完记得恢复。

5.3 给老人做的一处简化:桌面小组件快速确认

对于老年用户,解锁手机、打开 App、找到对应药物、点击确认,路径太长。一个小改动是给 App 增加一个桌面小组件(App Widget),上面展示今天需要服药的时间和药品名,点击后直接打开确认页。

Widget 的 onUpdate 里读取数据库并刷新视图:

override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { val plans = AppDatabase.get(context).medPlanDao().getActivePlansBlocking() val views = RemoteViews(context.packageName, R.layout.widget_med_list).apply { val sb = StringBuilder() plans.forEach { plan -> sb.append(plan.remindTime).append(" ") .append(plan.medName).append("\n") } setTextViewText(R.id.widget_text, sb.toString()) } appWidgetIds.forEach { id -> appWidgetManager.updateAppWidget(id, views) } }

这里用了一个阻塞版的数据读取方法,是大致正确的,因为 Widget 的 onUpdate 本身就运行在系统特制的 Broadcast 进程上下文,时间窗口较短,比较重的数据库操作需要搬到 JobIntentService 里做。这个设计点不用展开,写在说明书的“扩展功能”一节即可。

最后提醒一件事:吃药记录导出的 CSV 文件,默认编码是 UTF-8,如果用户用 Excel 打开中文会乱码。导出时给 CSV 加一个 UTF-8 BOM 头,三行代码就能解决,具体做法是写出第一个字符前先写入字节序列 EF BB BF。这个小细节做到了,功能就算真的收尾了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询