Android服药提醒App开发全解析:Room数据库与AlarmManager实战
2026/9/13 12:43:16 网站建设 项目流程

简介:这是一套面向计算机专业本科生的Android毕业设计实战资源,聚焦老年人健康管理场景,实现服药时间智能提醒、用药记录与家属联动功能,适用于课程设计、期末大作业及毕设选题。资源共6个文件,含3个核心压缩包(含SpringBoot后端源码、Android前端完整工程、演示视频)、2份文本说明文档(部署指引与使用说明)及1个MySQL数据库脚本(medication.sql),总大小92.18MB,结构清晰、模块分离,便于分层学习与调试。已有334人下载学习,代码全程注释详尽,涵盖前后端接口对接、本地通知推送、数据库表设计与数据同步逻辑,配套部署教程已发布于作者技术博客。读者可直接导入Android Studio与IDEA运行,快速掌握Android+SpringBoot全栈开发流程,并复现高分毕设成果。

1. 项目概述与核心价值

最近几年,身边不少朋友在为家里的长辈操心,尤其是老人记性不好,经常忘记按时吃药,或者吃错剂量,这可不是小事。我自己也经历过,电话里反复叮嘱,但效果甚微。正好,不少计算机和软件工程专业的同学在做毕业设计时,也常常为选题发愁——既想有实际应用价值,技术栈又不能太复杂,还得能体现自己的综合能力。于是,“基于Android的老年人服药提醒App”这个题目,就成了一个绝佳的选择。它完美地融合了社会需求与技术实践,是一个典型的、能落地的移动应用开发项目。

这个项目听起来简单,但麻雀虽小,五脏俱全。它不仅仅是一个简单的定时闹钟。一个合格的服药提醒App,需要处理复杂的服药计划(比如一天三次、饭前饭后)、管理多种药品信息、提供清晰直观的交互界面,并且最关键的是,要有一个稳定可靠的本地数据库来持久化存储所有数据。用户(老人或家属)添加的每一种药、设定的每一个提醒时间、每一次的服药记录,都需要被妥善保存,即使App重启或手机关机,数据也不能丢失。这恰恰是检验一个Android开发者基本功的试金石:UI设计是否友好、后台服务如何保活、数据库操作是否高效、以及如何应对Android系统日益严格的电量优化策略。

对于毕业设计而言,这个项目能充分展示你在Android开发全流程上的能力:从需求分析、UI/UX设计,到核心功能实现(Activity, Service, BroadcastReceiver, SQLite数据库),再到测试与打包。你提交的不仅仅是一个能运行的APK文件和一个源码压缩包,更是一套解决实际问题的完整工程实践。接下来,我就以一个“过来人”的身份,拆解一下这个项目的设计与实现要点,希望能为你提供一份清晰的“地图”,避开我当年踩过的那些坑。

2. 项目整体设计与思路拆解

做一个App,最怕一开始就埋头写代码。思路不清,后面改起来会非常痛苦。对于老年人服药提醒App,我们需要先想清楚:它到底要为谁解决什么问题?核心功能有哪些?技术栈如何选型?

2.1 核心用户与需求分析

我们的用户主要有两类:老年人他们的子女/看护人。他们的需求侧重点不同:

  • 老年人:需求是极致的简单、清晰。字体要大,颜色对比要强烈,操作步骤要少,提示要明显(声音要大、振动要强)。他们可能不识字,所以图标识别性要高(比如一个药丸图标加一个时钟图标)。
  • 子女/看护人:需求是管理和安心。他们需要能远程(或初始时)为老人设置复杂的服药计划,能查看服药记录的历史,了解老人是否按时服药。有时还需要管理多种药品的库存。

因此,App的设计必须在这两者间取得平衡。一个常见的思路是设计两种模式:“长者模式”和“管理模式”。长者模式界面极度简化,只显示即将到来的提醒和“已服药”按钮;管理模式则包含药品库、计划设置、记录查看等完整功能。两者可以通过一个简单的切换入口(如长按某个区域)进行转换。

2.2 核心功能模块设计

基于以上分析,我们可以将App拆解为以下几个核心模块:

  1. 药品管理模块:负责药品信息的增删改查。每条药品信息至少包括:药品名称、图片(可选)、规格(如10mg/片)、用法用量(如“一次一片”)、注意事项(如“餐后服用”)。
  2. 服药计划模块:这是App的大脑。用户可以基于药品创建计划,设定提醒周期(每日、每周特定几天)、具体时间点(如08:00, 12:30, 19:00)、开始与结束日期。一个计划可以关联一种或多种药品(比如同时吃降压药和降糖药)。
  3. 提醒与通知模块:这是App的“嘴巴”。到了预定时间,需要以最高优先级的方式通知用户。这涉及到Android的AlarmManager(用于精确定时)和Notification(用于显示提醒)。考虑到Android 8.0(API 26)以上的后台限制,我们需要使用AlarmManager.setExactAndAllowWhileIdle()来确保定时可靠,并创建一个前台服务(ForegroundService)来播放提示音和振动,防止被系统“杀死”。
  4. 服药记录模块:每次用户点击“已服药”或“跳过”,都需要生成一条记录。记录应包括:关联的药品/计划、应服药时间、实际操作时间、操作类型(已服/跳过)。这是数据追溯和健康管理的基础。
  5. 数据持久化模块(数据库):以上所有模块产生的数据,都需要一个安全可靠的“仓库”。这就是我们选择SQLite的原因——它是一个轻量级、无需网络、集成在Android系统内的关系型数据库,非常适合本地结构化数据存储。

2.3 技术选型与架构考量

  • 开发环境:毫无疑问,Android Studio是官方首选,对Kotlin和Java的支持都最好,模拟器和调试工具齐全。
  • 开发语言Kotlin已成为Android开发的官方推荐语言,其空安全、简洁的语法能极大减少崩溃和代码量。对于毕业设计,使用Kotlin绝对是加分项。当然,用纯Java实现也完全没问题。
  • 数据库框架:直接裸写SQLiteOpenHelper和ContentValues虽然可以,但代码繁琐易错。强烈推荐使用Room Persistence Library。它是Google官方推荐的SQLite对象映射库,属于Android Jetpack组件的一部分。它能让你用注解(Annotation)来定义数据实体(Entity)和数据库操作(DAO),编译器会帮你生成大部分样板代码,安全又高效。这对于毕业设计展示你对现代Android开发架构的理解非常有帮助。
  • 定时任务:如前所述,使用AlarmManager设置精确闹钟。切勿使用Handler.postDelayedTimer,因为它们在应用进程被杀死后就失效了。
  • 界面架构:建议采用MVVM(Model-View-ViewModel)模式。配合LiveDataKotlin Flow,可以实现数据驱动UI更新,让UI代码更清晰,并且能更好地处理屏幕旋转等配置变化。这也是当前业界的主流实践。

注意:很多初学者会想加入“云端同步”功能,以实现子女远程管理。这个想法很好,但会极大增加毕业设计的复杂度和风险(需要后端服务器、用户系统、网络通信、数据安全等)。我强烈建议第一期只做本地版本,专注于Android端本身的健壮性。云端功能可以作为“未来展望”在论文中提及,但不要在核心实现中引入,以免项目失控。

3. 核心细节解析与实操要点

有了整体设计,我们来深入几个最核心、最容易出问题的技术细节。

3.1 数据库设计:使用Room定义实体与关系

数据库设计是项目的基石。我们至少需要三张表:药品表、计划表、记录表。它们之间存在关联。

首先,在app/build.gradle中添加Room的依赖:

dependencies { def room_version = "2.5.0" implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" // 如果使用Kotlin,用kapt;Java用annotationProcessor implementation "androidx.room:room-ktx:$room_version" // 可选,提供Kotlin扩展支持 }

然后,我们开始定义实体(Entity),也就是数据库表。

1. 药品实体 (Medicine)

@Entity(tableName = "medicines") data class Medicine( @PrimaryKey(autoGenerate = true) val medicineId: Long = 0, val name: String, // 药品名称 val description: String? = null, // 描述/注意事项 val dosage: String, // 单次用量,如“一次一片” val stock: Int, // 库存数量 val imagePath: String? = null // 本地图片路径 )

2. 服药计划实体 (MedicineSchedule)这是最复杂的一张表。它需要存储重复规则。

@Entity( tableName = "schedules", foreignKeys = [ForeignKey( entity = Medicine::class, parentColumns = ["medicineId"], childColumns = ["medicineId"], onDelete = ForeignKey.CASCADE // 药品删除时,关联计划也删除 )] ) data class MedicineSchedule( @PrimaryKey(autoGenerate = true) val scheduleId: Long = 0, val medicineId: Long, // 关联的药品ID val scheduleName: String, // 计划名称,如“早上降压药” val enabled: Boolean = true, // 计划是否启用 // 时间相关字段:我们存储每天的具体时间点(如08:00),而不是完整的日期时间戳 val hourOfDay: Int, // 小时 (0-23) val minute: Int, // 分钟 (0-59) // 重复规则:可以用位运算表示一周的哪几天,例如周一到周日用7位二进制表示 val repeatDays: Int, // 例如:0b0011111 表示周一到周五 (周一是最低位) val startDate: Long, // 计划开始日期的时间戳 val endDate: Long? = null // 计划结束日期,null表示无限期 )

这里repeatDays的设计是个小技巧。我们用整数的每一位代表一周中的一天(例如,最低位bit0代表周一)。如果某天需要服药,对应的位就设为1。这样,要判断今天(假设是周三)是否需要服药,只需检查repeatDays的第3位(bit2)是否为1即可。这比存储一个字符串如“Mon,Tue,Wed,Thu,Fri”要高效得多。

3. 服药记录实体 (MedicineRecord)

@Entity( tableName = "records", foreignKeys = [ForeignKey( entity = MedicineSchedule::class, parentColumns = ["scheduleId"], childColumns = ["scheduleId"], onDelete = ForeignKey.SET_NULL // 计划删除后,记录保留,但关联ID为空 )] ) data class MedicineRecord( @PrimaryKey(autoGenerate = true) val recordId: Long = 0, val scheduleId: Long?, // 关联的计划ID,可为空 val medicineName: String, // 记录药品名称(冗余存储,防止计划删除后看不到药名) val scheduledTime: Long, // 应该服药的时间戳 val actionTime: Long, // 实际操作的时间戳 val actionType: Int // 操作类型:0-未操作,1-已服用,2-跳过 )

这里在MedicineRecord中冗余存储了medicineName,这是一个典型的“空间换时间”和保证数据可读性的设计。即使关联的MedicineSchedule被删除,我们依然能在历史记录中看到当时吃的是什么药。

接下来,我们需要定义数据访问对象(DAO)。

@Dao interface MedicineDao { @Insert suspend fun insertMedicine(medicine: Medicine): Long @Update suspend fun updateMedicine(medicine: Medicine) @Query("SELECT * FROM medicines ORDER BY name ASC") fun getAllMedicines(): Flow<List<Medicine>> // 使用Flow,便于在ViewModel中观察 @Insert suspend fun insertSchedule(schedule: MedicineSchedule): Long // 查询某一天所有需要激活的计划 @Query(""" SELECT * FROM schedules WHERE enabled = 1 AND (:currentTimeMillis >= startDate) AND (endDate IS NULL OR :currentTimeMillis <= endDate) AND ((repeatDays >> :dayOfWeek) & 1) = 1 ORDER BY hourOfDay, minute """) suspend fun getSchedulesForDay(currentTimeMillis: Long, dayOfWeek: Int): List<MedicineSchedule> }

最后,创建数据库抽象类。

@Database( entities = [Medicine::class, MedicineSchedule::class, MedicineRecord::class], version = 1, exportSchema = false // 毕业设计可以设为false简化,正式项目建议维护schema ) abstract class AppDatabase : RoomDatabase() { abstract fun medicineDao(): MedicineDao companion object { // 单例模式,避免同时打开多个数据库实例 @Volatile private var INSTANCE: AppDatabase? = null fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "medicine_reminder_db" // 数据库文件名 ).build() INSTANCE = instance instance } } } }

3.2 精准定时提醒:AlarmManager与前台服务的配合

这是项目的核心难点。Android为了省电,对后台任务限制非常严格。我们的提醒必须在准确的时间点触发,即使App在后台或设备休眠。

1. 设置定时任务我们通常在用户创建或修改服药计划时,为每一个计划时间点设置一个闹钟。注意,一个每日重复的计划,我们实际上只需要设置一次闹钟,并让其重复。

class AlarmScheduler(private val context: Context) { fun scheduleAlarm(schedule: MedicineSchedule) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val intent = Intent(context, ReminderReceiver::class.java).apply { putExtra("schedule_id", schedule.scheduleId) putExtra("medicine_name", schedule.scheduleName) } val pendingIntent = PendingIntent.getBroadcast( context, schedule.scheduleId.toInt(), // 使用scheduleId作为requestCode,确保唯一性 intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE // API 31+需要IMMUTABLE ) // 计算第一次触发的时间 val calendar = Calendar.getInstance().apply { timeInMillis = System.currentTimeMillis() set(Calendar.HOUR_OF_DAY, schedule.hourOfDay) set(Calendar.MINUTE, schedule.minute) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) // 如果今天这个时间已经过了,就设置为明天 if (timeInMillis <= System.currentTimeMillis()) { add(Calendar.DAY_OF_YEAR, 1) } } // 关键API:setExactAndAllowWhileIdle 即使在低电耗模式下也会触发 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { alarmManager.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, // 唤醒设备 calendar.timeInMillis, pendingIntent ) } else { alarmManager.setExact( AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent ) } } fun cancelAlarm(scheduleId: Long) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val intent = Intent(context, ReminderReceiver::class.java) val pendingIntent = PendingIntent.getBroadcast( context, scheduleId.toInt(), intent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE ) pendingIntent?.let { alarmManager.cancel(it) } } }

2. 接收广播并启动前台服务当闹钟触发时,会唤醒一个BroadcastReceiver。但BroadcastReceiver的执行时间很短(约10秒),我们必须在这里启动一个前台服务来执行长时间操作(如播放持续提示音、显示持续通知)。

class ReminderReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val scheduleId = intent.getLongExtra("schedule_id", -1L) val medicineName = intent.getStringExtra("medicine_name") ?: "服药时间到" // 启动前台服务来处理提醒 val serviceIntent = Intent(context, ReminderForegroundService::class.java).apply { putExtra("schedule_id", scheduleId) putExtra("medicine_name", medicineName) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent) } else { context.startService(serviceIntent) } } }

别忘了在AndroidManifest.xml中注册Receiver和服务。

3. 实现前台服务前台服务必须提供一个持续的通知,否则系统会很快停止它。

class ReminderForegroundService : Service() { private val NOTIFICATION_ID = 1 private val CHANNEL_ID = "reminder_channel" override fun onCreate() { super.onCreate() createNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val medicineName = intent?.getStringExtra("medicine_name") ?: "服药时间到" // 1. 创建通知 val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("服药提醒") .setContentText("该服用 $medicineName 了!") .setSmallIcon(R.drawable.ic_pill_reminder) // 使用一个药丸图标 .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_ALARM) .setAutoCancel(false) // 不让用户滑动取消 .setOngoing(true) // 持续通知 .build() // 2. 启动前台服务 startForeground(NOTIFICATION_ID, notification) // 3. 播放提示音和振动(这里需要处理音频焦点和权限) playReminderSoundAndVibrate() // 4. 这里可以启动一个全屏的Activity来强制用户交互(可选,但对老人很有效) val fullScreenIntent = Intent(this, ReminderAlertActivity::class.java).apply { putExtra("medicine_name", medicineName) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } startActivity(fullScreenIntent) return START_NOT_STICKY } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, "服药提醒", NotificationManager.IMPORTANCE_HIGH ).apply { description = "用于显示服药提醒通知" lockscreenVisibility = Notification.VISIBILITY_PUBLIC } val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } } override fun onBind(intent: Intent?): IBinder? = null }

实操心得:前台服务的通知渠道(Notification Channel)在Android 8.0以上是必须的,且创建后无法通过代码修改其重要性等设置,用户可以在系统设置中管理。因此,通知的标题和内容要清晰明了。对于老年人,可以考虑将通知优先级设为最高,并启用“覆盖勿扰模式”的权限(需要引导用户手动授权)。

3.3 长者模式UI设计要点

UI设计直接决定了老人能否独立使用。以下是一些关键原则和实现技巧:

  1. 字体与对比度:所有关键文字的textSize至少设为18sp以上,按钮文字更大。使用TextViewautoSizeTextType属性让其自适应。背景与文字的颜色对比度要足够高,避免使用浅灰文字配白色背景。
  2. 触摸目标:所有可点击区域(按钮、卡片)的minHeightminWidth至少设为48dp(Android官方推荐值),确保手指容易点中。
  3. 简化流程:在长者模式下,主界面可能只显示一个大的倒计时卡片和当前/下一次的服药信息。一个巨大的、颜色鲜艳的“我已服药”按钮应该放在最显眼的位置。点击后,可以有一个简单的确认对话框(字体也要大),然后直接记录并关闭提醒。
  4. 反馈明确:任何操作都要有明确的反馈。点击按钮时要有视觉(如颜色变化)和触觉(振动)反馈。成功服药后,可以播放一个简短的、愉悦的确认音效。
  5. 图标胜过文字:多用国际通用的图标,如药丸、时钟、日历、勾选标记。避免使用抽象或文化特定的图标。

实现上,你可以使用两个不同的Activity或Fragment来分别承载“长者模式”和“管理模式”,通过一个设置项或一个隐藏的触发方式(如在主界面连续点击5次版本号)来切换。布局文件(XML)要分开维护,逻辑代码可以共享ViewModel。

4. 实操过程与核心环节实现

让我们聚焦于一个核心用户旅程的实现:用户添加一个新药品并设置一个每日服药提醒

4.1 第一步:创建添加药品界面

我们创建一个AddMedicineActivity,包含一个简单的表单。

<!-- activity_add_medicine.xml 简化版 --> <ScrollView> <LinearLayout orientation="vertical"> <TextView text="药品名称" textSize="18sp"/> <EditText android:id="@+id/etMedicineName" hint="请输入药品名称" textSize="20sp" minHeight="48dp"/> <TextView text="用法用量" textSize="18sp"/> <EditText android:id="@+id/etDosage" hint="例如:一次一片,一日三次" textSize="20sp"/> <TextView text="当前库存" textSize="18sp"/> <EditText android:id="@+id/etStock" inputType="number" textSize="20sp"/> <Button android:id="@+id/btnSaveMedicine" text="保存药品" textSize="22sp" minHeight="60dp"/> </LinearLayout> </ScrollView>

在Activity中,我们处理保存逻辑,并导航到设置提醒计划界面。

class AddMedicineActivity : AppCompatActivity() { private lateinit var viewModel: MedicineViewModel private lateinit var binding: ActivityAddMedicineBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityAddMedicineBinding.inflate(layoutInflater) setContentView(binding.root) viewModel = ViewModelProvider(this).get(MedicineViewModel::class.java) binding.btnSaveMedicine.setOnClickListener { val name = binding.etMedicineName.text.toString().trim() val dosage = binding.etDosage.text.toString().trim() val stock = binding.etStock.text.toString().toIntOrNull() ?: 0 if (name.isEmpty()) { Toast.makeText(this, "请输入药品名称", Toast.LENGTH_SHORT).show() return@setOnClickListener } // 在ViewModel中保存药品 viewModel.insertMedicine(name, dosage, stock).observe(this) { success -> if (success) { // 保存成功,获取刚插入的药品ID,并跳转到设置计划页面 val medicineId = viewModel.lastInsertedId // 假设ViewModel能获取到 val intent = Intent(this, AddScheduleActivity::class.java).apply { putExtra("medicine_id", medicineId) putExtra("medicine_name", name) } startActivity(intent) finish() // 关闭当前Activity } else { Toast.makeText(this, "保存失败", Toast.LENGTH_SHORT).show() } } } } }

4.2 第二步:创建服药计划设置界面

这是功能最复杂的界面,需要选择重复日期和具体时间。

class AddScheduleActivity : AppCompatActivity() { private lateinit var binding: ActivityAddScheduleBinding private lateinit var alarmScheduler: AlarmScheduler private var selectedDays = 0 // 用位存储选中的星期几 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityAddScheduleBinding.inflate(layoutInflater) setContentView(binding.root) alarmScheduler = AlarmScheduler(applicationContext) val medicineId = intent.getLongExtra("medicine_id", -1L) val medicineName = intent.getStringExtra("medicine_name") ?: "" binding.tvMedicineName.text = "为 $medicineName 设置提醒" // 时间选择器 binding.btnSelectTime.setOnClickListener { val calendar = Calendar.getInstance() TimePickerDialog(this, { _, hour, minute -> binding.tvSelectedTime.text = String.format("%02d:%02d", hour, minute) // 存储hour和minute到变量 }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show() } // 星期选择器(一组CheckBox) val dayCheckBoxes = listOf( binding.cbMon, binding.cbTue, binding.cbWed, binding.cbThu, binding.cbFri, binding.cbSat, binding.cbSun ) dayCheckBoxes.forEachIndexed { index, checkBox -> checkBox.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { selectedDays = selectedDays or (1 shl index) // 设置对应位为1 } else { selectedDays = selectedDays and (1 shl index).inv() // 设置对应位为0 } } } binding.btnSaveSchedule.setOnClickListener { val timeText = binding.tvSelectedTime.text.toString() if (timeText.isEmpty() || selectedDays == 0) { Toast.makeText(this, "请选择时间和重复日期", Toast.LENGTH_SHORT).show() return@setOnClickListener } val (hour, minute) = timeText.split(":").map { it.toInt() } val schedule = MedicineSchedule( medicineId = medicineId, scheduleName = "$medicineName 提醒", hourOfDay = hour, minute = minute, repeatDays = selectedDays, startDate = System.currentTimeMillis(), // 从今天开始 endDate = null // 无限期 ) // 保存到数据库并设置闹钟 viewModel.insertSchedule(schedule).observe(this) { newScheduleId -> if (newScheduleId > 0) { schedule.scheduleId = newScheduleId alarmScheduler.scheduleAlarm(schedule) Toast.makeText(this, "提醒设置成功!", Toast.LENGTH_LONG).show() finish() } } } } }

4.3 第三步:在主界面展示今日提醒

主界面(长者模式)需要实时展示即将到来的提醒。我们可以使用ViewModel配合LiveDataFlow,从数据库查询并观察数据变化。

class MainViewModel(application: Application) : AndroidViewModel(application) { private val repository: MedicineRepository // 数据仓库层,封装数据库操作 val todaySchedules: LiveData<List<MedicineSchedule>> init { repository = MedicineRepository(AppDatabase.getDatabase(application).medicineDao()) todaySchedules = repository.getTodaySchedules() // 返回LiveData } } // 在Repository中实现查询 class MedicineRepository(private val medicineDao: MedicineDao) { fun getTodaySchedules(): LiveData<List<MedicineSchedule>> { val calendar = Calendar.getInstance() val currentTime = calendar.timeInMillis val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) // 注意:Calendar周日是1,周一是2... // 需要转换为我们定义的位顺序(周一是最低位) val myDayOfWeekBitIndex = (dayOfWeek + 5) % 7 return medicineDao.getSchedulesForDayAsLiveData(currentTime, myDayOfWeekBitIndex) } }

在DAO中,我们需要将之前的suspend函数改为返回LiveData的查询。

@Query(""" SELECT * FROM schedules WHERE enabled = 1 AND (:currentTimeMillis >= startDate) AND (endDate IS NULL OR :currentTimeMillis <= endDate) AND ((repeatDays >> :dayOfWeek) & 1) = 1 AND (hourOfDay > :currentHour OR (hourOfDay = :currentHour AND minute > :currentMinute)) ORDER BY hourOfDay, minute LIMIT 3 -- 只显示接下来最近的3个提醒 """) fun getUpcomingSchedules( currentTimeMillis: Long, dayOfWeek: Int, currentHour: Int, currentMinute: Int ): LiveData<List<MedicineSchedule>>

这样,主界面的UI(如一个RecyclerView)就可以观察这个LiveData,并在数据变化时自动更新,显示接下来的服药计划。

5. 常见问题与排查技巧实录

在实际开发中,你一定会遇到各种各样的问题。下面是我总结的几个最典型的“坑”及其解决方案。

5.1 闹钟不响或不准时

这是最常见的问题,90%的原因出在Android的省电策略上。

  • 问题现象:设置的提醒在特定时间没有触发,或者延迟很久才触发。
  • 排查步骤
    1. 检查AlarmManager API:确保在Android 6.0 (M) 及以上版本使用了setExactAndAllowWhileIdle()。在低版本上使用setExact()
    2. 检查PendingIntentPendingIntentrequestCode必须唯一,否则新的闹钟会覆盖旧的。通常用scheduleId作为requestCode是个好主意。FLAG_IMMUTABLE在API 31+是必须的。
    3. 检查设备状态:很多手机厂商(华为、小米、OPPO、Vivo等)有自带的“省电模式”、“应用自启动管理”、“后台耗电限制”功能。你的App必须引导用户去系统设置里,为你的应用开启“允许自启动”、“允许后台活动”、“忽略电池优化”等权限。可以在App启动时检查并弹出指引。
    4. 测试方法:不要在模拟器上简单测试后就认为没问题。务必在真实的、安装了多个应用的手机上测试,并锁屏等待一段时间。
  • 解决方案
    • BroadcastReceiver被触发时,立即写一条日志到文件或发一个通知,确认闹钟是否真的被触发。
    • 实现一个“测试提醒”功能,设置一个1分钟后的闹钟,快速验证整个链条是否通畅。
    • 在应用内添加清晰的权限引导页面,图文并茂地教用户如何到系统设置中授予必要权限。

5.2 数据库操作崩溃或数据不一致

多线程操作数据库或实体类定义错误会导致崩溃。

  • 问题现象Cannot access database on the main thread,或数据插入后查询不到。
  • 排查步骤
    1. 主线程检查:Room默认不允许在主线程执行数据库操作(除非在构建时调用.allowMainThreadQueries(),但强烈不建议)。确保所有数据库操作都在协程、LiveDataRxJava等异步上下文中进行。
    2. 实体类定义:检查@Entity类中的所有字段是否都是Room支持的数据类型(基本类型、String、ByteArray等)。如果字段是自定义类,需要用@TypeConverter
    3. 数据库升级:如果你在开发过程中修改了实体类(比如增加了一个字段),但没有增加数据库版本号或提供迁移策略,App升级后会崩溃。开发阶段可以设置.fallbackToDestructiveMigration(),但正式发布前必须处理好迁移。
  • 解决方案
    • 使用viewModelScope.launch(Dispatchers.IO) { ... }来在ViewModel中执行挂起的数据库操作。
    • 为所有非基本类型字段编写TypeConverter。例如,如果需要存储List<String>,可以将其转换为用逗号分隔的字符串进行存储。
    • 使用版本控制和迁移。在@Database注解中增加version,并实现Migration子类。

5.3 前台服务通知不显示或无法播放声音

  • 问题现象:提醒触发后,没有通知栏提示,或者有通知但没有声音。
  • 排查步骤
    1. 通知渠道:Android 8.0以上,必须创建通知渠道(Notification Channel)并设置正确的重要性(IMPORTANCE_HIGH)。
    2. 声音和振动权限:从Android 10开始,对后台播放声音有更严格的限制。确保你的前台服务通知在创建时设置了正确的类别(CATEGORY_ALARM)和提示音。
    3. 应用通知权限:用户可能手动关闭了你App的通知权限。需要在代码中检查并引导用户开启。
  • 解决方案
    // 创建高重要性渠道 val channel = NotificationChannel( CHANNEL_ID, "服药提醒", NotificationManager.IMPORTANCE_HIGH // 设置为HIGH ).apply { description = "用于显示服药提醒通知" lockscreenVisibility = Notification.VISIBILITY_PUBLIC enableVibration(true) vibrationPattern = longArrayOf(0, 500, 200, 500) // 振动模式 // 设置提示音,使用系统默认的闹钟声 val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) setSound(soundUri, AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ALARM) .build()) }
    在App首次启动时,检查通知权限并请求授权。

5.4 应用被“杀死”后提醒失效

  • 问题现象:用户手动从最近任务列表划掉App后,未来的提醒全部失效。
  • 问题根源:这是正常现象。当App进程被完全杀死后,你注册的AlarmManager闹钟和BroadcastReceiver依然在系统层面有效。但是,当闹钟触发时,系统会尝试启动你的App进程来运行BroadcastReceiver。如果厂商系统限制严格(或用户设置了极端省电),这个启动过程可能会失败。
  • 解决方案
    • 引导用户加白名单:这是最根本的解决办法。在App内提供指引。
    • 使用WorkManager作为后备WorkManager是Jetpack组件,用于处理可延迟的后台任务。虽然它不保证精确时间,但系统会尽量在合适的时间(比如设备充电且联网时)执行。你可以设置一个每日一次的PeriodicWorkRequest,让它检查未来24小时的计划,并重新设置一遍AlarmManager的闹钟。这样即使App被杀死后重启,也能恢复提醒。这相当于一个“心跳”和自我修复机制。

5.5 时间处理与时区问题

  • 问题现象:用户出国后,提醒时间错乱。
  • 解决方案:在存储和计算所有时间时,永远使用UTC时间戳(System.currentTimeMillis())或Instant。只在显示给用户看的时候,才根据用户的当前时区(TimeZone.getDefault())进行转换。AlarmManagerRTC_WAKEUP也是基于系统时钟(UTC),这样能最大程度避免时区变化带来的问题。

开发这样一个完整的App,从设计到实现,再到测试和优化,是一个系统工程。它考验的不仅仅是编码能力,更是对Android系统机制的理解、对用户体验的把握以及对异常情况的处理能力。当你最终看到自己开发的App,能够稳定地在长辈手机上准时响起提醒,那种成就感是无可比拟的。这份毕业设计,也将成为你求职简历上一个非常扎实和有说服力的项目经验。

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

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

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

立即咨询