Skip to content

About

安卓本地推送思路整理:可以让你的机给你定时发消息,并且不受安卓杀后台/手动清后台影响。适用于开源前端(RikkaHub等)二改与自建前端,且无云服务器。

Resources

Stars

16 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

让 AI 主动找你说话——本地定时推送实现思路

已收录于人机恋开源项目大全

作者:ResetZero_1211 适用:开源前端(RikkaHub 等)二改 / 自建前端,Android,无云服务器 本文面向两类读者:不写代码但想了解原理的用户,以及负责实现的 AI 编程助手


这是什么

AI 聊天默认是被动的——你不说话,它永远不会主动开口。

"主动推送"就是让 AI 在你设定的时间(比如早上 8 点、晚上 10 点)自己生成一条消息,通过手机通知推给你。不需要云服务器,不需要后端,手机本地完成一切。

我们在渊海(基于 Rikkahub 的二改 Android 客户端)里实现了这个功能,踩了不少坑。这篇文档把通用思路和关键陷阱整理出来,不管你是在改 Rikka、自建前端、还是改别的开源项目,只要是 Android + 本地无服务器的场景,核心逻辑都一样。

这个功能的难点不在"让它跑起来",而在"让它每天都可靠地跑"。 基础流程一两天就能通,之后我们在"闹钟活不活""要不要重发""失败看不看得见"这三件事上反复返工了好几轮 —— 文中标了「陷阱」的地方几乎每一条都是线上出过问题才发现的。如果你只想抄一份能用的实现,建议先看文末的「踩坑总结」表格,那是全文最省时间的部分。


适用环境

  • 无云服务器:不依赖任何后端推送服务(FCM/JPush 等都不用)
  • 纯本地调度:手机自己定闹钟,到点自己醒来调 API
  • 需要网络:生成消息要调 LLM API(OpenAI/Claude/本地模型均可),所以手机得能联网
  • Android 为主:本文以 Android 为例,iOS 见末尾单独说明

给用户看的:你需要手动做什么

如果你用的 App 实现了这个功能,你需要手动打开以下权限,否则推送会失败或不准时:

必须开的

  1. 通知权限(Android 13+)

    • 设置 → 应用 → [你的App] → 通知 → 允许
    • 不开这个,消息生成了你也看不到
  2. 精确闹钟权限(Android 12+)

    • 设置 → 应用 → [你的App] → 闹钟和提醒 → 允许
    • 不开这个,系统不让 App 设定精确时间的闹钟

强烈建议开的

  1. 电池优化白名单

    • 设置 → 电池 → [你的App] → 不优化 / 无限制
    • 不开这个,系统可能在后台杀掉 App,导致闹钟到了但没人执行
  2. 自启动权限(国产 ROM:小米/OPPO/vivo/华为)

    • 各品牌路径不同,一般在"自启动管理"里
    • 国产 ROM 默认禁止后台自启动,不开这个重启手机后推送就没了
  3. 后台运行权限(部分品牌)

    • MIUI:设置 → 应用 → [App] → 省电策略 → 无限制
    • 不开可能被"一键清理"杀掉

注意事项

  • 清后台(从最近任务划掉 App)后推送依然能工作,前提是上面的权限都开了
  • 如果推送突然不来了,第一步检查是不是系统更新后权限被重置了
  • 推送不是"App 在后台运行",是系统闹钟到点唤醒 App 做一件事然后退出,不费电

给 AI 编程助手看的:实现架构

以下是通用架构,以 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 要和日常聊天的 prompt 分开。

为什么要分开:

  • 日常聊天是用户主动发起,AI 回复;推送是 AI 主动开口,语境不同
  • 推送 prompt 需要额外信息:当前时间、今天星期几、距离上次互动多久
  • 你可能想让推送的语气和日常不一样(比如更简短、更主动)

Prompt 构造建议:

[系统提示词 - 推送专用]
你需要主动给用户发一条消息。
当前时间:{time},星期{weekday}。
距离上次互动:{hours_since_last}小时。
NSFW模式:{on/off}

[历史消息(最近 N 条,让 AI 知道之前聊了什么)]
...

[如果历史为空或最后一条是 AI 发的,注入一条触发消息]
[系统:定时推送触发,请主动发言。]

关键点:

  1. 注入时间上下文:AI 不知道现在几点,你必须在 prompt 里告诉它。这样它才能说"早上好"而不是"你好"。

  2. 截取历史消息:带上最近 15-20 条消息,让 AI 知道之前的对话脉络。太多会浪费 token,太少会没有连贯性。

  3. 思考链截断:如果你用的模型会输出 <think>...</think> 之类的推理过程,生成完之后要把这些标签去掉,只保留实际内容。

  4. 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 分钟才算

两条元经验,比上面任何一条都值钱:

  1. 前台服务那条常驻通知是免费的诊断探针。 用户报"没收到推送"时先问它出现过没有——没出现就是闹钟/调度层的问题,出现了就是生成/通知层的问题。一句话砍掉一半排查范围。

  2. 修掉一个 bug 后另一个症状变明显,通常不是修错了,而是原来那段错误代码顺带掩盖了第二个 bug。我们删掉重复触发的事件总线监听后,"隔天不推"反而更严重——因为那个监听在进程启动时注册,等于每次重启都手动补触发了一次,正好盖住了"闹钟没续订"的真问题。


局限性(这个方案做不到的事)

纯本地方案的天花板很明确:

  • 关机 = 没推送。闹钟依赖系统运行,手机关了什么都不会发生。开机后会重新注册闹钟。加了台账+补做机制之后,关机期间错过的那次可以在当天打开 App 时补发,但时间会飘(早上 8 点那条可能晚上才到)。
  • 断网 = 生成失败。消息靠调 LLM API 实时生成,没网就调不了。这种情况会发一条"生成失败"通知告知原因,但那一次的推送内容就没有了(不会自动重试,因为重试会重复计费)。
  • 不是"服务器推给你"。和微信消息不一样,这个推送是手机自己定闹钟自己干活,不存在"别人给你发消息"的概念。如果手机状态不对(没电、飞行模式、系统限制),就是不来。
  • 国产 ROM 玄学。即使权限全开了,部分 ROM 仍然可能在特定条件下杀后台或延迟闹钟。这不是代码能完全解决的问题,只能尽量引导用户开权限 + 加电池白名单。
  • 不能保证秒级精确。setExactAndAllowWhileIdle 在 Doze 模式下仍可能有几秒到几十秒的延迟,但对"定时推送"这个场景来说通常够用。

简单说:手机开着、有网、权限没被回收,这三个条件满足就能正常工作。缺任何一个都会导致那次推送不触发或生成失败。


iOS 能不能做?

能,但限制大得多。

对比项 Android iOS
精确定时唤醒 AlarmManager ✅ 没有等效 API ❌
后台执行时间 前台服务,无限制 BGTaskScheduler,最多 30 秒
后台网络请求 随时可以 只能在系统分配的窗口内
用户干预 开几个权限 几乎无法控制触发时机

iOS 的可行方案:

  1. BGAppRefreshTask:系统决定什么时候给你后台时间,不精确,可能延迟几小时
  2. 本地通知 + 打开时生成:到点发一个本地通知提醒用户,用户点开后再实时生成消息(体验差一些但可靠)
  3. 快捷指令自动化:用 iOS 快捷指令设定时触发,调用 App 的 URL Scheme(需要用户自己配置)

结论:iOS 上实现"精确定时 + 后台生成"几乎不可能不依赖服务器。务实的方案是"本地通知引导用户打开 App,打开后再生成"。


最小实现清单(给 AI 编程助手的 checklist)

如果你要在一个 Android AI 聊天 App 里实现这个功能,按这个顺序做:

  1. 数据层:消息模型加 isPushed、scheduledTime、actualTime 字段
  2. 配置:推送开关、时间列表、推送专用 prompt、NSFW 开关、通知文案
  3. 调度器:AlarmManager 封装。两个方法分开——ensureScheduled(覆盖式排、 不取消,给启动补排和响完续订用)和 rescheduleAll(先取消再排,只给用户手动保存用)
  4. 接收器:BroadcastReceiver 收闹钟 → 启动前台服务
  5. 前台服务:startForeground → 进程内去重(重复的直接 return,不要 stopSelf) → 先续订下一次闹钟 → 调用生成逻辑 → stopSelf
  6. 生成器:构造 prompt → 流式调 API → 截断思考链 → 返回文本。 如果给了 AI 工具,推送场景要降级成只读
  7. 防重:三层(进程锁 / 时间窗口 / 持久化校验)。 确认所有写入路径都带上防重判据依赖的字段
  8. 存储:消息写入本地 + 标记推送来源
  9. 通知:发送通知(注意 NSFW 安全)+ 点击跳转
  10. 错误处理:CancellationException 原样抛出;真失败发一条带原因的可见通知
  11. 台账 + 补做:排闹钟记一笔、推成功销一笔;App 进前台时补今天没兑现的那笔 (挂 ProcessLifecycleOwner.ON_START,不能挂 Application.onCreate)
  12. 权限:通知权限 + 精确闹钟权限 + 电池优化引导 UI。 权限拿不到时不要清空已有闹钟
  13. 开机恢复:BootReceiver 重新注册所有闹钟(同样覆盖式,不取消)

一句话总结

本地推送的本质就是:用系统闹钟定时唤醒 → 前台服务保命 → 调 API 生成 → 存消息发通知。

听起来简单,但真正的工作量在四件事上:闹钟要自己续订(一次性的)、补排绝不能取消(会杀掉唤醒自己的那条广播)、取消不是失败(别报错也别重试)、失败要看得见(否则钱白烧还查不出原因)。这四条没做对,功能表现就是"大部分时候能用,偶尔莫名不来"——而这种间歇性故障最耗人。


许可

本教程采用 CC BY-NC-SA 4.0 协议:

  • 可以自由分享、改编
  • 需要署名原作者(ResetZero_1211)
  • 不可商用
  • 改编后必须以相同协议分发

About

安卓本地推送思路整理:可以让你的机给你定时发消息,并且不受安卓杀后台/手动清后台影响。适用于开源前端(RikkaHub等)二改与自建前端,且无云服务器。

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors