前言
在 Android 开发圈流传着一个共识:不要轻易做闹钟或提醒类 App。
原因很简单:碎片化严重、后台限制严苛、厂商 ROM 魔改。你可能写了几千行代码,最后输给了小米系统的"一键加速"。
最近我开发了一个名为 **「小确幸」** 的生活助手 App,核心需求只有一个:即便 App 被清后台,提醒也必须 100% 触达。
在这篇文章里,我不会罗列 API,而是复盘我是如何从"不可靠"走向"双保险"的。
Android 定时提醒的终极防线:双保险机制实战
一、需求拆解:不仅仅是闹钟
「小确幸」不仅仅是一个计时器,它需要承载复杂的业务逻辑:
- 定时提醒:对接服务器工单,全屏强提醒(类似 12306 的抢票提醒)。
- 事件闹钟:支持离线设置(生日、纪念日),即便断网也要响。
- 未来信:时间胶囊,指定日期解锁。
核心挑战:如何在前台交互流畅的同时,保证后台进程在被系统"追杀"时依然存活?
二、初版方案的夭折
最初,我采用了最传统的方案:
- UI 层:Handler + Runnable(负责倒计时显示)。
- 后台层 :
AlarmManager+BroadcastReceiver(负责触发)。
结果 :在 Android 9 及以下运行良好,但在 Android 10+ 和部分国产 ROM 上,清后台即失效。
三、深度排查:Android 10 的隐形墙
经过一周的日志追踪(Logcat)和源码比对,我发现问题出在 PendingIntent 上。
在 Android 10 (API 29) 中,Google 进一步收紧了对后台执行的限制。系统会判定:
"如果 App 不在前台,且通过 AlarmManager 发送广播(Broadcast),该广播可能被延迟或直接丢弃。"
而在我的代码中,事件闹钟正是使用了 PendingIntent.getBroadcast()。
对比分析
| 特性 | PendingIntent.getBroadcast | PendingIntent.getService |
|---|---|---|
| 触发组件 | BroadcastReceiver | Service |
| Android 10+ 限制 | 极高 (容易被 Doze 模式拦截) | 较低 (系统更倾向于唤醒服务) |
| 适用场景 | 轻量级通知 | 复杂逻辑、耗时操作、强提醒 |
结论:对于需要强保活的闹钟,Service 比 Receiver 更安全。
四、终局方案:双保险机制
为了保证 100% 的触达率,我重构了架构,引入了 Foreground Service(前台服务) 作为核心防线。
1. 架构图
┌─────────────────────────┐
│ App 运行中 │
│ Handler (UI 倒计时) │◄─────┐
└──────────┬──────────────┘ │
│ │
▼ │ 清后台 / 关机重启
┌─────────────────────────┐ │
│ AlarmManager (系统级) │──────┤
└──────────┬──────────────┘ │
▼ │
┌─────────────────────────┐ │
│ PendingIntent.Service │──────┘
└──────────┬──────────────┘
▼
┌─────────────────────────┐
│ 前台服务 (Foreground) │
│ 全屏 Activity + 铃声 + 震动 │
└─────────────────────────┘
markdown
### 2. 核心代码改造
**关键点:放弃 Broadcast,拥抱 Service。**
`public void setEventAlarm(Context context, long triggerAtMillis) {
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
Intent intent = new Intent(context, AlarmTriggerService.class);`
scss
// 关键改动:FLAG_IMMUTABLE 是 Android 12+ 的强制要求
PendingIntent pi = PendingIntent.getService(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
// 使用 setExactAndAllowWhileIdle 确保 Doze 模式下也能触发
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAtMillis, pi);
}
}
markdown
**前台服务的保活策略:**
为了让 Service 不被系统轻易回收,必须在 `onStartCommand` 中启动前台通知:
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// 构建 Notification Channel (Android 8+ 必须)
startForeground(NOTIFICATION_ID, buildNotification());
kotlin
// 处理闹钟逻辑(启动全屏 Activity)
handleAlarm(intent);
return START_STICKY; // 被杀死后自动重启
}
yaml
---
## 五、体验优化:一次只响一个
在实现技术可靠性的同时,体验也很重要。
如果服务器积压了 10 条工单,用户打开 App 瞬间收到 10 个全屏闹钟,体验是灾难性的。
**优化方案:队列式消费。**
1. 拉取工单列表。
2. 判断当前是否有正在显示的 Alert。
3. 如果有,等待;如果没有,只取第一条触发。
4. 用户点击"确认" -> 标记为已解决 -> 触发下一个。
---
## 六、总结与思考
1. **不要对抗系统,要利用规则**:我们无法阻止系统杀进程,但我们可以用 `Foreground Service` 告诉系统"我很重要"。
2. **API 选择的陷阱**:`BroadcastReceiver` 适合轻量通知,`Service` 才适合强提醒。
3. **厂商适配**:即便代码完美,也要引导用户开启自启动权限(这是国产 ROM 最后的倔强)。
---
## 七、项目信息
* **项目名称**:小确幸 (Small Blessings)
* **技术栈**:Android (Java) + Spring Boot
* **GitHub**:[点击查看源码](#) (此处替换为你的真实链接)
**如果你对 Android 后台调度、WorkManager 与 AlarmManager 的取舍有不同看法,欢迎在评论区理性讨论!**