作者:ResetZero_1211 适用:开源前端(RikkaHub 等)二改 / 自建前端,Android,无云服务器 本文面向两类读者:不写代码但想了解原理的用户,以及负责实现的 AI 编程助手
AI 聊天默认是被动的——你不说话,它永远不会主动开口。
"主动推送"就是让 AI 在你设定的时间(比如早上 8 点、晚上 10 点)自己生成一条消息,通过手机通知推给你。不需要云服务器,不需要后端,手机本地完成一切。
我们在渊海(基于 Rikkahub 的二改 Android 客户端)里实现了这个功能,踩了不少坑。这篇文档把通用思路和关键陷阱整理出来,不管你是在改 Rikka、自建前端、还是改别的开源项目,只要是 Android + 本地无服务器的场景,核心逻辑都一样。
这个功能的难点不在"让它跑起来",而在"让它每天都可靠地跑"。 基础流程一两天就能通,之后我们在"闹钟活不活""要不要重发""失败看不看得见"这三件事上反复返工了好几轮 —— 文中标了「陷阱」的地方几乎每一条都是线上出过问题才发现的。如果你只想抄一份能用的实现,建议先看文末的「踩坑总结」表格,那是全文最省时间的部分。
- 无云服务器:不依赖任何后端推送服务(FCM/JPush 等都不用)
- 纯本地调度:手机自己定闹钟,到点自己醒来调 API
- 需要网络:生成消息要调 LLM API(OpenAI/Claude/本地模型均可),所以手机得能联网
- Android 为主:本文以 Android 为例,iOS 见末尾单独说明
如果你用的 App 实现了这个功能,你需要手动打开以下权限,否则推送会失败或不准时:
-
通知权限(Android 13+)
- 设置 → 应用 → [你的App] → 通知 → 允许
- 不开这个,消息生成了你也看不到
-
精确闹钟权限(Android 12+)
- 设置 → 应用 → [你的App] → 闹钟和提醒 → 允许
- 不开这个,系统不让 App 设定精确时间的闹钟
-
电池优化白名单
- 设置 → 电池 → [你的App] → 不优化 / 无限制
- 不开这个,系统可能在后台杀掉 App,导致闹钟到了但没人执行
-
自启动权限(国产 ROM:小米/OPPO/vivo/华为)
- 各品牌路径不同,一般在"自启动管理"里
- 国产 ROM 默认禁止后台自启动,不开这个重启手机后推送就没了
-
后台运行权限(部分品牌)
- MIUI:设置 → 应用 → [App] → 省电策略 → 无限制
- 不开可能被"一键清理"杀掉
- 清后台(从最近任务划掉 App)后推送依然能工作,前提是上面的权限都开了
- 如果推送突然不来了,第一步检查是不是系统更新后权限被重置了
- 推送不是"App 在后台运行",是系统闹钟到点唤醒 App 做一件事然后退出,不费电
以下是通用架构,以 Android (Kotlin) 为例,但思路适用于任何平台。
用户设定推送时间(如 08:00, 22:00)
↓
系统闹钟到点触发(AlarmManager)
↓
启动前台服务(防止被系统杀)
↓
★ 先把下一次闹钟续订上(一次性闹钟,不续订就永远不再响)
↓
调用 LLM API 生成消息(带专用 prompt + 历史上下文)
↓
插入消息到本地存储 + 发送通知
前五步是"让它跑起来",第六步(失败可见 + 错过补做)是"让它可靠"。 标 ★ 的那步是我们花了最久才发现必须有的。
用什么:AlarmManager.setExactAndAllowWhileIdle()
为什么不用 WorkManager / JobScheduler:
- WorkManager 不保证精确时间,系统可能延迟 15 分钟以上
- 推送场景用户期望"我设了 8 点就 8 点来",必须用精确闹钟
关键实现点:
// 伪代码:设置精确闹钟
fun scheduleAlarm(hour: Int, minute: Int) {
val triggerTime = calculateNextTriggerTime(hour, minute)
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, // 到点唤醒设备
triggerTime,
pendingIntent
)
}
// 计算下次触发时间:如果今天的时间已过,就设明天
fun calculateNextTriggerTime(hour: Int, minute: Int): Long {
val target = today.atTime(hour, minute)
if (target <= now + 2.minutes) { // 2分钟安全边距
return tomorrow.atTime(hour, minute).toMillis()
}
return target.toMillis()
}陷阱:2 分钟安全边距,只能用在"用户手动保存设置"那一次 如果用户在 7:59 设了 8:00 的推送,不加边距会导致"设完就立刻触发"(因为算出来的时间 ≈ 当前时间)。加 2 分钟余量,让这种情况自动滚到明天。
但这个边距只该用在用户手动存设置的那一刻。我们把它用在了"每次进程启动都重排一遍"的路径上,结果反向咬人:用户在推送前 2 分钟内打开过一次 App,今天这一次就被顺延到明天了。所以算触发时间的函数要能区分这两种调用场合:
fun nextTriggerMillis(allowToday: Boolean): Long {
var target = today.atTime(hour, minute)
// allowToday=true(补排/续订):只要还没到就排今天,不留边距
// allowToday=false(用户手动保存):留 2 分钟边距
val threshold = if (allowToday) now else now + 2.minutes
if (target <= threshold) target = target.plusDays(1)
return target.toMillis()
}陷阱(最要紧的一个):setExactAndAllowWhileIdle 是一次性闹钟,响完必须自己续订
这个 API 不是"每天定时",它只响一次。响完如果没人重新排,就永远不再响了。我们最初只在"开机"和"用户保存设置"这两处排闹钟,等于重启一次才灵一次,表现是"用了一天多就不推了",特别难查。
正确做法:闹钟响起、前台服务启动后,在开始生成之前就先把下一次排上。放在生成之前是关键——生成可能失败、进程可能被杀,但明天的闹钟已经排好了,不会一次失败就永久失效。
// 前台服务里
onStartCommand {
startForeground(...)
scope.launch {
ensureScheduled(pushTimes) // 先续订,再生成
generateAndNotify()
}
}陷阱(血债):进程启动时"兜底补排"绝不能先取消再排
一次性闹钟可能被 ROM 清理、强停、崩溃弄丢,所以在 App 启动时补排一遍是合理的。但补排的实现方式极其讲究,我们在这上面栽了一次,症状是"清后台之后推送彻底不触发,连前台服务的通知都不出现"。
原因是这样的:闹钟响的时候进程往往是死的,系统必须先把进程创建起来才能派发广播。顺序是——
闹钟到点 → 系统创建进程 → Application.onCreate 跑完 → 才把广播投给 BroadcastReceiver
我们的兜底补排就写在 Application.onCreate 里,而它调用的是"先取消所有旧闹钟、再重新排"。取消里那句 pendingIntent.cancel() 会把 PendingIntent 本身作废——而此刻那条广播还在投递路上没送到,于是它被丢掉,onReceive 永远不会被调用。
等于:闹钟把进程唤醒,进程在启动过程中把唤醒自己的那个闹钟杀了。
这个 bug 只在清后台/冷启动时复现(进程一直活着的话 onCreate 早就跑完了,到点没人动闹钟),所以极容易误判成"ROM 杀后台"。
规矩:
- 进程启动时的补排只能覆盖式排,不取消任何东西。
setExact*对同一个 PendingIntent 是替换语义,重复排不会变成两个闹钟。 - 取消闹钟只用
alarmManager.cancel(pi),不要跟着调pendingIntent.cancel()。 - "先取消全部再重排"这种写法,只留给"用户明确改了配置"的入口。
// 兜底补排:覆盖式,不取消
fun ensureScheduled(times: List<PushTime>) {
times.forEachIndexed { i, t -> scheduleDailyPush(t, i, allowToday = true) }
}
// 用户手动保存才用这个:先取消再排
fun rescheduleAll(times: List<PushTime>) { ... }陷阱:别拿 FLAG_NO_CREATE 当"闹钟还挂着"的判据
我们本来想优化成"先查这个槽位排没排,只补缺失的"。但 PendingIntent.getBroadcast(..., FLAG_NO_CREATE) 返回非 null 只能证明PendingIntent 这个对象还在,不能证明闹钟还挂着(一次性闹钟响过之后对象可能仍然存在)。拿它当判据会漏补。既然覆盖式排本身是安全的,就别查了,直接覆盖。
陷阱:权限拿不到时,别把已有闹钟清空
rescheduleAll 的原始实现是"先取消 10 个槽位,再逐个排",而排的那一步遇到"没有精确闹钟权限"只打了一行警告就返回了——结果闹钟被清空且不补回,推送彻底死掉。取消之前先查权限,查不到就整个跳过、什么都别动。
陷阱:重启后闹钟丢失
Android 重启会清除所有 AlarmManager 闹钟。需要注册 BOOT_COMPLETED BroadcastReceiver,开机后重新设置所有闹钟(同样用覆盖式排,不要取消)。
权限声明(AndroidManifest.xml):
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />问题:闹钟触发后,你需要调 LLM API 生成消息,这可能需要 5-30 秒。在这段时间里,系统随时可能杀掉你的进程。
解法:启动一个前台服务(Foreground Service),告诉系统"我正在做重要的事,别杀我"。
// BroadcastReceiver 收到闹钟后,启动前台服务
class PushAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// 不要在这里直接调API!BroadcastReceiver 只有 10 秒生命
// 启动前台服务来做耗时操作
PushGenerationService.start(context, hour, minute)
}
}
// 前台服务
class PushGenerationService : Service() {
override fun onStartCommand(...) {
// 立刻变成前台服务(必须在 5 秒内调用)
startForeground(notificationId, buildNotification("正在生成消息..."))
// 在协程里调 API
scope.launch {
generateAndSendPush(hour, minute)
stopSelf() // 做完就停
}
}
}陷阱:BroadcastReceiver 里不能做耗时操作
BroadcastReceiver 的 onReceive 方法只有约 10 秒执行时间,超时会 ANR。必须把实际工作交给 Service。
陷阱:前台服务通知文案 前台服务必须显示一个通知。如果你的 App 有 NSFW 内容,建议通知文案写成无关紧要的东西(比如"同步中..."或自定义的安全文案),因为锁屏状态下任何人拿起手机都能看到这条通知。
顺带一个诊断技巧:这条常驻通知是 startForeground 第一时间发出去的,所以它是个免费的探针——用户报"没收到推送"时,先问这条通知出现过没有。没出现说明服务压根没启动(问题在闹钟/调度层),出现了但没收到消息说明是生成或通知环节的事。这一条把我们的排查范围直接砍掉一半。
陷阱(很隐蔽):catch (e: Exception) 会把协程的取消异常当成失败上报
服务被销毁时(用户清后台、系统回收)通常会 scope.cancel(),正在跑的协程会抛 CancellationException,它的 message 就是 Job was cancelled。如果你的错误处理是宽泛的 catch (e: Exception),它会把这个也抓住,然后弹一条"推送生成失败:Job was cancelled"给用户——把"进程被杀"伪装成"模型出错",排查方向直接被带偏。
规矩:CancellationException 必须单独先 catch 并原样抛出,不计入失败、不弹通知、不重试。这是 Kotlin 结构化并发的基本要求。
try {
generateAndSendPush(...)
} catch (e: CancellationException) {
throw e // 取消不是失败,原样抛出
} catch (e: Exception) {
sendFailureNotification(e.message) // 只有真失败才上报
}陷阱(自伤):防重复启动时调 stopSelf(startId) 会把正在跑的任务杀掉
我们在 onStartCommand 里做了"同一时刻的推送已在进行中就跳过"的保护,写成了:
if (!activePushKeys.add(pushKey)) {
stopSelf(startId) // ❌ 灾难
return START_NOT_STICKY
}问题在于这个 startId 是最新的那一个,而 stopSelf(startId) 在传入最新 id 时会把整个服务停掉 → 触发 onDestroy → scope.cancel() → 把先到的、正在跑的那次生成连带取消。本意是保护,结果是自伤,报出来的错就是上面那个 Job was cancelled。
正确写法:重复的这次直接 return 就行,别调 stopSelf,让先到的那次自己跑完、自己停。
陷阱:Android 12+ 不允许从后台启动前台服务
如果你想在 App 启动时做点什么(比如补发错过的推送),别写在 Application.onCreate 里——那时候进程仍然算后台,startForegroundService 会被系统拒掉(抛 ForegroundServiceStartNotAllowedException)。要挂在 ProcessLifecycleOwner 的 ON_START 上,那时才确定在前台。
闹钟广播触发的那条路径不受这个限制(精确闹钟有豁免),所以正常推送没问题,只有"启动时补做"这类需求会踩到。另外 BOOT_COMPLETED 也不保险:Android 14+ 限制了哪些前台服务类型可以从开机广播启动。
核心原则:推送用的 prompt 要和日常聊天的 prompt 分开。
为什么要分开:
- 日常聊天是用户主动发起,AI 回复;推送是 AI 主动开口,语境不同
- 推送 prompt 需要额外信息:当前时间、今天星期几、距离上次互动多久
- 你可能想让推送的语气和日常不一样(比如更简短、更主动)
Prompt 构造建议:
[系统提示词 - 推送专用]
你需要主动给用户发一条消息。
当前时间:{time},星期{weekday}。
距离上次互动:{hours_since_last}小时。
NSFW模式:{on/off}
[历史消息(最近 N 条,让 AI 知道之前聊了什么)]
...
[如果历史为空或最后一条是 AI 发的,注入一条触发消息]
[系统:定时推送触发,请主动发言。]
关键点:
-
注入时间上下文:AI 不知道现在几点,你必须在 prompt 里告诉它。这样它才能说"早上好"而不是"你好"。
-
截取历史消息:带上最近 15-20 条消息,让 AI 知道之前的对话脉络。太多会浪费 token,太少会没有连贯性。
-
思考链截断:如果你用的模型会输出
<think>...</think>之类的推理过程,生成完之后要把这些标签去掉,只保留实际内容。 -
NSFW 开关:在 prompt 层面控制,不需要两套提示词。告诉 AI 当前模式是什么,让它自己控制尺度。
问题:推送消息重复是最常见的 bug。原因很多:
- 闹钟可能因为系统原因触发两次
- 清后台重启进程后可能重新触发
- 有多条代码路径都能触发推送(事件总线 + 闹钟 + 调试按钮)
解法:三层防重
第一层:进程内互斥锁
→ 同一时间只能有一个推送任务在执行
→ 用 Mutex 或 synchronized + Set<正在执行的时间槽>
第二层:时间窗口去重
→ 记录每个时间槽最后一次执行的时间戳
→ 30秒内相同时间槽的重复请求直接丢弃
第三层:持久化校验(最重要)
→ 生成消息前检查数据库:今天这个时间槽是否已经有推送消息了
→ 写入数据库时再检查一次(事务内原子操作)
→ 即使前两层都失败了,这层也能兜住
我们踩过的大坑:最初代码里有两条路径能触发推送——AlarmManager 闹钟触发是一条,App 启动时事件总线监听也会触发。结果每次都是两条消息。
根因修复原则:单路径。推送建议只保留一条触发路径,多条路径并存是重复的根源。我们最终删掉了事件总线监听,所有推送只走:
AlarmManager → BroadcastReceiver → 前台服务 → 执行
其他路径(调试按钮除外)尽量不要能触发真正的推送流程。
补充一个教训:删掉那个事件总线监听之后,"隔一天多就不推了"的问题反而变严重了。因为那个监听是在进程启动时注册的,它顺带盖住了"一次性闹钟没续订"的坑(重启一次就相当于手动补触发了一次)。删掉之后底下的真问题彻底裸露。
所以:如果修掉一个 bug 之后另一个症状变明显了,很可能是原来那段错误代码顺带掩盖了第二个 bug,不要以为是修错了。
陷阱(防重被绕过):如果 AI 能用工具写数据,工具写的那条不带你的防重标记
这个坑很值得单独说,因为它只在"AI 带工具"的场景出现,而且表现是时好时坏(取决于模型这次想不想调工具)。
我们的三层防重判据全是 isPushed == true && scheduledTime == 某时刻 这两个字段。但推送生成时我们把完整的日历工具也给了模型,模型可以用工具自己往当天写留言——而工具写进去的那条消息不带 isPushed 和 scheduledTime,对三层防重完全隐形。
于是流程变成:AI 先用工具写了一条,生成器再把最终文本插一条,同一句话进去两遍,三层防重一层都没拦住。
解法:推送生成时把工具降级成只读。我们给构造工具的函数加了个 readOnly 参数,在 description、action 枚举、execute 三处都拦一遍(只改 description 是不够的,模型会无视)。AI 仍然能读任意日期做上下文,只是不能写。
更一般的原则:防重判据依赖某个字段时,要确认所有写入路径都会带上这个字段。有任何一条旁路能绕过,防重就是纸做的。
// 伪代码
fun sendNotification(message: String, settings: PushSettings) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(settings.notificationTitle) // 如 "Ken 给你留言了"
.setContentText(getDisplayText(message, settings))
.setContentIntent(buildOpenAppIntent()) // 点击跳转到对应页面
.build()
notificationManager.notify(id, notification)
}
// 通知内容策略
fun getDisplayText(message: String, settings: PushSettings): String {
return when (settings.contentMode) {
PLACEHOLDER -> settings.placeholderText // 安全文案,如"过来。"
MESSAGE_CONTENT -> message.take(80) // 显示消息前80字
}
}NSFW 安全策略:
- 如果 App 涉及敏感内容,建议通知正文不直接显示消息原文
- 可以用占位文案模式:通知只显示一句固定的安全文案(如"你有一条新消息")
- 用户点进 App 才能看到实际内容
- 毕竟锁屏上所有人都能看到通知,这算是隐私的第一道防线
前五步做完功能就跑得通了。但真正上手用一段时间,会发现剩下两类问题最磨人——"扣了钱但什么都没看到" 和 "今天这次就这么没了"。这一步专门处理这两件事。
我们最初的错误处理是"记个日志然后 return"。结果有几条路径会烧掉 API 额度但屏幕上什么都不显示:
- 后台网络中途断开,流式响应挂掉
- 模型只调了工具、最后没返回正文
- 工具调用轮数触顶抛错,被外层吞掉(这条已经计费好几轮了)
对用户来说这三种都表现为"推送没来",跟"闹钟没响"完全无法区分,排查起来极其痛苦。
所以生成失败时一定要发一条带原因的通知("推送生成失败:<原因>")。既是给用户的交代,也是最有效的排查线索——用户把那句原因念给你,你立刻就知道该查哪儿。
前面说过的反面:别把 CancellationException 也报成失败,那不是失败。
一次性闹钟被 ROM 清掉时,当天那次是整个丢掉的——续订只能救将来,救不回今天。所以需要一个补做机制:App 下次打开时,如果发现今天该推的没推成,就补一次。
难点全在怎么判断"没推成"。我们连着踩了两版:
第一版(错):判据是"这个时刻今天已经过点了 + 数据库里没有对应记录"。
结果:用户在下午存了一个早上 8 点的推送时间,这两个条件天然成立(今天压根没排过 8 点的闹钟,不是错过),于是一保存就立刻补发一条——表现为"定时变成立即触发"。而且这个检查挂在"App 进入前台"上,注册时 App 已经在前台,ON_START 立即重放,导致每次开 App 都来一条。
第二版(还是不够):改成记录"每个时刻最后一次排上的触发时间戳",要求它落在今天。 结果:服务是在生成之前就把闹钟续订成明天的(前面说过这是对的),记录随之指向明天,于是补做看不见"今天这次生成中途死了"。清后台杀掉服务的那种情况照样补不上。
第三版(可用):记一本**"已排待兑现"台账**。key 是 yyyy-MM-dd@HH:mm(闹钟被排到哪一天的哪个时刻),语义是"我为这个时刻排过闹钟,但还没成功推出去":
- 排闹钟时记一笔(记的是闹钟真正被排到的那天,不一定是今天)
- 推送成功落进数据库时销一笔
这样四种情形能干净区分开:
| 情形 | 台账状态 | 补不补 |
|---|---|---|
| 闹钟压根没响(被 ROM 清掉) | 条目还在、时刻已过 | 补 |
| 闹钟响了、推送成功 | 已销账 | 不补 |
| 闹钟响了、生成被取消(清后台杀服务) | 条目还在 | 补 |
| 用户下午存了个早上 8 点 | 闹钟排的是明天,今天没条目 | 不补 |
最后一行就是第一版那个 bug 被堵住的地方。
关键取舍:真失败也要销账。 因为失败已经发过可见通知了,台账留着的话每次开 App 都会重试一次,白烧 API。只有"被取消"才留着——那种情况用户什么都没收到,正该重试。这条规则一立,原先那个"每天只补一次"的内存计数器就多余了(而且它会挡掉这次合法的重试),直接删掉。
其他几个细节:
- 只补最近错过的那一笔。配了多个推送时间的话,一次性全补会让用户一开 App 连收好几条。
- 当天有效,过了当天作废。窗口给太短(比如 2 小时)等于没加——清后台之后用户可能到晚上才开 App。
- 喂给模型的是真实当前时间,所以补发的消息不会写出"早上好"这种错位的话。但要如实告诉模型延迟原因:我们原来的文案写死了"因检测到用户在线活跃而延迟",补发场景下这是假话(用户不在线,是闹钟被杀了),AI 会照着这个错误前提说话。
- 顺手清掉过期条目,别让台账无限增长。
┌─────────────────────────────────────────────────────────┐
│ 用户设定推送时间 → PushScheduler 注册 AlarmManager │
└─────────────────────────────────────────────────────────┘
│
▼ (到点触发)
┌─────────────────────────────────────────────────────────┐
│ PushAlarmReceiver (BroadcastReceiver) │
│ → 验证 intent → 启动前台服务 │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PushGenerationService (前台服务) │
│ → startForeground() → 防重检查(第一层) → 调用生成逻辑 │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PushNotificationManager │
│ → 防重检查(第二层:时间窗口) │
│ → 防重检查(第三层:数据库查重) │
│ → 调用 PushMessageGenerator │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PushMessageGenerator │
│ → 构造 prompt(推送专用 + 时间上下文 + 历史消息) │
│ → 工具降级为只读(防止绕过防重) │
│ → 流式调用 LLM API │
│ → 截断思考链标签 │
│ → 返回生成的消息文本 │
└─────────────────────────────────────────────────────────┘
│
┌───────────┴───────────┐
▼ ▼
(拿到内容) (失败 / 内容为空)
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ 写入本地(isPushed=true) │ │ 发"生成失败:<原因>"通知 │
│ + 台账销账 │ │ + 台账销账(避免重试烧钱) │
│ + 发送推送通知 │ │ │
└──────────────────────────┘ └──────────────────────────┘
※ 若中途被取消(清后台杀服务):不算失败、不发通知、
台账**不销账** → 下次打开 App 时补做
补做那条支路:
App 进入前台(ProcessLifecycleOwner.ON_START)
│
▼
查台账:今天有没有"排过但没兑现"且时刻已过的?
│
├─ 没有 → 什么都不做
│
└─ 有 → 启动前台服务(isCatchUp=true) → 走上面同一套生成流程
(告知模型真实延迟原因:闹钟被系统清掉了,不是用户在线)
按踩坑的代价排序,前四个是真正伤人的:
| 坑 | 表现 | 原因 | 解法 |
|---|---|---|---|
| 闹钟只响一次 | 用一天多就不推了,很难查 | setExactAndAllowWhileIdle 是一次性的,响完没人续订 |
前台服务里在生成之前先续订下一次 |
| 进程杀掉唤醒自己的闹钟 | 清后台后彻底不触发,连前台服务通知都没有 | Application.onCreate 跑在广播投递之前,里面的"补排"先取消了闹钟,pendingIntent.cancel() 把在途广播作废 |
启动时补排只能覆盖式排、不取消;别调 pendingIntent.cancel() |
| 取消被当成失败上报 | 弹"生成失败:Job was cancelled",像是模型出错 | 宽泛的 catch (e: Exception) 吞掉了 CancellationException |
单独 catch 并原样 throw,不计失败不弹通知 |
| 防重启动自伤 | 正在跑的生成被杀,报 Job was cancelled |
防重复时调 stopSelf(startId),最新 id 会停掉整个服务 |
重复的那次直接 return,别 stopSelf |
| 推送重复 | 一次触发收到两条消息 | 多条触发路径并存 | 删到只留一条路径 + 三层防重 |
| 防重被绕过 | 时好时坏地重复 | AI 用工具写的那条不带 isPushed/scheduledTime,对防重隐形 |
推送生成时工具降级只读(三处都拦) |
| 补做误触发 | 存了个过去的时间,立刻收到推送;每开 App 一条 | 判据只看"已过点且无记录",用户新设的时间天然满足 | 用"已排待兑现"台账,要求闹钟确实为今天排过 |
| 设完立刻触发 | 设 8:00 的推送,7:59 设完就来了 | 计算出的触发时间 ≈ 当前时间 | 加 2 分钟安全边距 |
| 安全边距反向咬人 | 推送前 2 分钟开过 App,今天这次没了 | 边距被用在了每次启动的补排上 | 边距只用于"用户手动保存"那一次 |
| 权限缺失把闹钟清空 | 推送彻底死掉,不再恢复 | 先取消全部、再排时被权限挡住,只打了行日志 | 取消前先查权限,查不到整个跳过 |
| 扣费但零显示 | 没收到推送,也不知道为什么 | 失败只记日志就 return | 失败发一条带原因的可见通知 |
| 锁屏后 API 断 | 消息生成到一半网络断了 | 系统休眠断开连接 | 前台服务 + WAKELOCK |
| 重启手机后不推 | 重启后闹钟全丢 | AlarmManager 重启清空 | BootReceiver 重新注册 |
| "在线活跃"误判 | 每次都说"检测到你在线" | 判断逻辑用了毫秒级时间比较 | 改为延迟超过 1 分钟才算 |
两条元经验,比上面任何一条都值钱:
-
前台服务那条常驻通知是免费的诊断探针。 用户报"没收到推送"时先问它出现过没有——没出现就是闹钟/调度层的问题,出现了就是生成/通知层的问题。一句话砍掉一半排查范围。
-
修掉一个 bug 后另一个症状变明显,通常不是修错了,而是原来那段错误代码顺带掩盖了第二个 bug。我们删掉重复触发的事件总线监听后,"隔天不推"反而更严重——因为那个监听在进程启动时注册,等于每次重启都手动补触发了一次,正好盖住了"闹钟没续订"的真问题。
纯本地方案的天花板很明确:
- 关机 = 没推送。闹钟依赖系统运行,手机关了什么都不会发生。开机后会重新注册闹钟。加了台账+补做机制之后,关机期间错过的那次可以在当天打开 App 时补发,但时间会飘(早上 8 点那条可能晚上才到)。
- 断网 = 生成失败。消息靠调 LLM API 实时生成,没网就调不了。这种情况会发一条"生成失败"通知告知原因,但那一次的推送内容就没有了(不会自动重试,因为重试会重复计费)。
- 不是"服务器推给你"。和微信消息不一样,这个推送是手机自己定闹钟自己干活,不存在"别人给你发消息"的概念。如果手机状态不对(没电、飞行模式、系统限制),就是不来。
- 国产 ROM 玄学。即使权限全开了,部分 ROM 仍然可能在特定条件下杀后台或延迟闹钟。这不是代码能完全解决的问题,只能尽量引导用户开权限 + 加电池白名单。
- 不能保证秒级精确。
setExactAndAllowWhileIdle在 Doze 模式下仍可能有几秒到几十秒的延迟,但对"定时推送"这个场景来说通常够用。
简单说:手机开着、有网、权限没被回收,这三个条件满足就能正常工作。缺任何一个都会导致那次推送不触发或生成失败。
能,但限制大得多。
| 对比项 | Android | iOS |
|---|---|---|
| 精确定时唤醒 | AlarmManager ✅ | 没有等效 API ❌ |
| 后台执行时间 | 前台服务,无限制 | BGTaskScheduler,最多 30 秒 |
| 后台网络请求 | 随时可以 | 只能在系统分配的窗口内 |
| 用户干预 | 开几个权限 | 几乎无法控制触发时机 |
iOS 的可行方案:
- BGAppRefreshTask:系统决定什么时候给你后台时间,不精确,可能延迟几小时
- 本地通知 + 打开时生成:到点发一个本地通知提醒用户,用户点开后再实时生成消息(体验差一些但可靠)
- 快捷指令自动化:用 iOS 快捷指令设定时触发,调用 App 的 URL Scheme(需要用户自己配置)
结论:iOS 上实现"精确定时 + 后台生成"几乎不可能不依赖服务器。务实的方案是"本地通知引导用户打开 App,打开后再生成"。
如果你要在一个 Android AI 聊天 App 里实现这个功能,按这个顺序做:
- 数据层:消息模型加
isPushed、scheduledTime、actualTime字段 - 配置:推送开关、时间列表、推送专用 prompt、NSFW 开关、通知文案
- 调度器:AlarmManager 封装。两个方法分开——
ensureScheduled(覆盖式排、 不取消,给启动补排和响完续订用)和rescheduleAll(先取消再排,只给用户手动保存用) - 接收器:BroadcastReceiver 收闹钟 → 启动前台服务
- 前台服务:startForeground → 进程内去重(重复的直接 return,不要 stopSelf) → 先续订下一次闹钟 → 调用生成逻辑 → stopSelf
- 生成器:构造 prompt → 流式调 API → 截断思考链 → 返回文本。 如果给了 AI 工具,推送场景要降级成只读
- 防重:三层(进程锁 / 时间窗口 / 持久化校验)。 确认所有写入路径都带上防重判据依赖的字段
- 存储:消息写入本地 + 标记推送来源
- 通知:发送通知(注意 NSFW 安全)+ 点击跳转
- 错误处理:
CancellationException原样抛出;真失败发一条带原因的可见通知 - 台账 + 补做:排闹钟记一笔、推成功销一笔;App 进前台时补今天没兑现的那笔
(挂
ProcessLifecycleOwner.ON_START,不能挂Application.onCreate) - 权限:通知权限 + 精确闹钟权限 + 电池优化引导 UI。 权限拿不到时不要清空已有闹钟
- 开机恢复:BootReceiver 重新注册所有闹钟(同样覆盖式,不取消)
本地推送的本质就是:用系统闹钟定时唤醒 → 前台服务保命 → 调 API 生成 → 存消息发通知。
听起来简单,但真正的工作量在四件事上:闹钟要自己续订(一次性的)、补排绝不能取消(会杀掉唤醒自己的那条广播)、取消不是失败(别报错也别重试)、失败要看得见(否则钱白烧还查不出原因)。这四条没做对,功能表现就是"大部分时候能用,偶尔莫名不来"——而这种间歇性故障最耗人。
本教程采用 CC BY-NC-SA 4.0 协议:
- 可以自由分享、改编
- 需要署名原作者(ResetZero_1211)
- 不可商用
- 改编后必须以相同协议分发