十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Android服药提醒App开发:Room+AlarmManager+通知渠道全攻略

Android服药提醒App开发:Room+AlarmManager+通知渠道全攻略 简介面向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 配合得很自然。服药记录插入后界面上的“今日服药进度”可以自动更新不需要手动刷新答辩演示时会顺畅很多。维度纯 SQLiteRoomGreenDAOSQL 检查时机运行期编译期编译期学习成本低中中高LiveData 支持手动实现原生支持需要额外适配适合场景单表、逻辑少中小型项目大型项目、需要加密毕业设计推荐度可用推荐不推荐Room 的劣势是需要写 Entity、Dao、Database 三部分代码文件数量比纯 SQLite 多但这正好符合毕业设计对“项目结构完整”的要求设计说明书里也能多写一章架构分析。2.2 定时任务的三条路线AlarmManager、WorkManager、Handler 轮询服药提醒的核心是“到时间触发通知”定时任务的选型决定了整个 App 的可靠性。常见方案有三种这里先给结论。Handler 死循环轮询最不建议的方案。App 进程一旦被系统回收轮询立刻中断而且常驻后台比较费电答辩时很容易被追问“进程被杀怎么办”。WorkManager适合周期性的、对时间精度要求不高的任务比如每天同步一次数据。它的问题是最小周期是 15 分钟而且系统可能推迟执行不符合“上午 8 点提醒吃降压药”这种精确到分钟的需求。AlarmManagerAndroid 官方的闹钟服务用于在指定时间触发一次或周期性任务。它是系统级服务即使 App 进程不在也能通过 BroadcastReceiver 把事件拉起来。服药提醒场景下这是正确选择。具体到 AlarmManager 的 API有几个方法需要分清方法行为省电模式下的表现适用场景set()非精确闹钟可能延迟不推荐用于服药提醒setExactAndAllowWhileIdle()精确闹钟允许待机时触发正常触发普通药物的准点提醒setAlarmClock()精确闹钟最高优先级显示闹钟图标一定触发关键药物建议使用实现时还要注意 requestCode 不要写死成同一个数字。比如 5 条服药计划都传入 requestCode 1后注册的闹钟会覆盖先注册的。一般用数据库里的主键 ID 作为 requestCode这样每条提醒互相独立取消时也能精确定位。提示Android 12API 31开始使用精确闹钟需要在 Manifest 中声明 SCHEDULE_EXACT_ALARM 权限并且用户可以在系统设置里手动关闭该权限代码层面需要捕获 SecurityException 做降级处理。2.3 Android 13/14 的通知渠道与后台限制传统写法里直接 new Notification(...) 然后 notify() 的做法在 Android 8.0 以后已经失效了。Android 8.0API 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(): ListMedPlan 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: ListInt) { 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) ?: returnlaunch 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 指定完整类名且接收器需要设置 exportedtrue 才能收到系统广播。开机注册的逻辑还有个小坑数据库可能还没有初始化完成。如果 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。这个小细节做到了功能就算真的收尾了。本文还有配套的精品资源点击获取
返回列表