小伴(XiaoBan)是一款 Kotlin + Jetpack Compose 编写的 Android 主动提醒 App。它把“用户一句话”变成一条结构化提醒,把“前台 App 场景”变成一次主动关心,再用“本地硬规则 + 可选 LLM 柔性判断”的方式送出通知。本文不是产品功能介绍,而是从工程实现角度记录:自然语言解析、场景识别、规则引擎、Agent 三态协议、动作注册表、双通道调度以及真机验证中的关键设计。
项目概览
| 模块 | 实现 | 关键点 |
|---|---|---|
| 自然语言创建提醒 | NaturalLanguageParser + RepeatRule | 规则版解析,可单测,解析不了就退回结构化表单 |
| 场景识别 | AccessibilityService + UsageStatsManager | 双数据源取较大值,解决厂商系统延迟 |
| 提醒引擎 | ReminderEngine | 纯 Kotlin 规则链,无 Android 依赖 |
| 柔性提醒 Agent | AgentPipeline + LangChain4j / DeepSeek | REPLY / SKIP / DEFER 三态协议,本地兜底 |
| 动作语义注册 | ReminderActionCatalog | NLP、引擎、通知、计划页共用一份注册表 |
| 定时调度 | AlarmManager + WorkManager | 精确闹钟 + 两分钟兜底,REPLACE / KEEP 策略分离 |
| 数据与隐私 | Room + DataStore | 显式 migration,本地优先,API Key 走 Keystore |
| 测试与验收 | 19 个 JVM 单测 + Debug API | 命令行驱动真机,结果落库可查 |
一、自然语言解析:先把规则做扎实,再谈“智能”
小伴的第一条链路是“你说一句话,它创建提醒”。这里我没有一开始就上 LLM,而是先用一个确定性规则解析器覆盖最常见的中文表达:
明天下午 3 点提醒我交报告每天 22 点提醒我吃药工作久了提醒我喝水
核心是 NaturalLanguageParser:先用正则抽取优先级、场景触发词、标签,再解析相对时间 / 具体时刻,最后用 RepeatRule 表达一次性、每天、每周等重复规则。
sceneTrigger = detectSceneTrigger(text)
val tags = Regex("#([\\p{L}\\p{N}_\\-]+)").findAll(text) .map { it.groupValues[1] } .toList()text = text.replace(Regex("#[\\p{L}\\p{N}_\\-]+"), "").trim()
val parsedRepeat = parseRepeat(text)val timeInfo = parseTime(text)val dueTime = when { timeInfo.relativeMinutes != null -> System.currentTimeMillis() + timeInfo.relativeMinutes * 60_000L timeInfo.hour != null -> nextOccurrence(repeat, timeInfo.hour, timeInfo.minute, timeInfo.dayOffset) else -> null}这个设计有几个明确取舍:
- 规则优先,不追求覆盖所有语法。提醒创建需要确定性,用户不会希望“明天下午 3 点”被模型解释成别的时间。
- 场景触发词交给注册表。
detectSceneTrigger不自己维护关键词,而是调用ReminderActionCatalog.matchNlp(text),为后面“新增一类提醒语义只改一处”铺路。 - 解析不完备时退回结构化表单。NLP 不是唯一入口,用户随时可以切换表单,避免“必须说对一句话”的挫败感。
NOTE这也是小伴整体架构的缩影:凡是影响时间、权限、用户意愿的地方,都用确定性代码;模型只负责“文案与时机”这类可以柔性的部分。
二、场景识别:Accessibility + UsageStats 双数据源
小伴“主动”的底气来自它知道用户正在用什么 App、连续用了多久、今天累计用了多久。但 Android 厂商系统让这件事并不简单:
AccessibilityService能拿到实时的前台窗口切换,但覆盖安装后可能被系统关闭;UsageStatsManager能提供今日累计和系统级会话,但部分厂商(如 HONOR MagicOS)更新延迟严重;- 厂商推送、透明 Activity、输入法窗口会打断真实 App 会话。
因此实现采用了 Accessibility 实时 + UsageStats 累计 的双数据源策略。
Accessibility 侧的核心是“窗口稳定后再切换会话”。抖音等 App 启动时可能先出现厂商推送页,如果立刻切换会话,会把真实使用计时打断:
override fun onAccessibilityEvent(event: AccessibilityEvent?) { val pkg = event?.packageName?.toString() ?: return if (pkg.isBlank() || pkg == candidatePackage) return candidatePackage = pkg candidateJob?.cancel() candidateJob = scope.launch { try { // 抖音等 App 启动时会短暂出现厂商推送/透明 Activity,等待窗口稳定后再切换会话。 delay(FOREGROUND_SETTLE_MS) if (candidatePackage != pkg) return@launch commitForegroundPackage(pkg) } catch (_: CancellationException) { // 新窗口到来会替换候选。 } }}周期采样侧由 AndroidSceneRecognizer 负责:查询最近一次前台 App,过滤后台、系统界面和用户排除的包,达到动态阈值后才落库。
val hasLauncher = context.packageManager.getLaunchIntentForPackage(current.packageName) != nullif (!ScenePackageFilter.isTrackableForeground(current.packageName, prefs.excludedPackages, hasLauncher)) return nullif (current.durations.continuousMs < DynamicEventPolicy.thresholdMs(prefs.dynamicThresholdMinutes)) return null只记录“真实用户 App 的持续会话”,短暂误触、后台运行和系统过渡界面不会污染统计。两条数据源还会按时间重叠去重,并在返回小伴或周期采样时从 UsageEvents 恢复最近 2 小时内已结束的合格会话。
三、规则引擎先守门:可靠的事不交给模型
这是小伴最核心的设计判断:不让模型决定“要不要提醒”的硬边界,只让模型决定“怎么说、是否自然”。
ReminderEngine 被刻意写成纯 Kotlin,不依赖任何 Android 类,方便 JVM 单测。它的 evaluate() 是一条清晰的规则链:
if (!p.reminderEnabled) return null
// 勿扰时段:默认 23:00-08:00;关闭勿扰或重要日程可突破if (p.dndEnabled && isInDnd(context.now, p.dndStartHour, p.dndEndHour) && !hasImportantDue) { return null}
// 每小时主动提醒上限if (activeInHour >= p.maxPerHour && !hasUserCreatedDue) return null
// 1. 日程到期或用户创建的场景计划:最高优先级for (schedule in context.dueSchedules) { if (timeDue || sceneDue) { return ReminderDecision(...) }}
// 2. App 阈值触发,叠加反馈学习带来的冷却 / 降频if (dailyReached || continuousReached) { return ReminderDecision(...)}规则不通过就直接停止,不调用模型。这样做既省电省钱,也避免 LLM 的不稳定输出影响核心可靠性。
ReminderEngine 只输出一个 ReminderDecision(intent、priority、channel、reason),不负责发通知。后面的 AgentPipeline 才根据这个决策决定具体文案和是否柔性跳过 / 延后。
四、ReminderActionCatalog:把提醒语义收敛到一张注册表
早期新增一类提醒语义,需要散改 NLP、提醒引擎、通知标题、计划页描述等多个硬编码分支。后来我把这些语义统一收进 ReminderActionCatalog:
- 一个动作 = 一个稳定的意图 ID + 一层语义元数据;
- 包含 NLP 匹配谓词、场景类别、连续 / 累计阈值、通知标题、计划页触发描述;
- NLP、引擎、通知、计划页、Agent 上下文全部从注册表读取。
例如“护眼休息”只需要注册一条 Spec:
"eye_rest" to Spec( id = "eye_rest", label = "护眼休息", nlpMatcher = { it.contains("护眼") || it.contains("眼睛") }, sceneTriggerId = "eye_rest", fixedTitle = "眼睛休息一下", matchCategories = setOf( AppCategory.STUDY, AppCategory.WORK, AppCategory.READING, AppCategory.VIDEO ), continuousMs = ReminderEngine.FOCUS_CONTINUOUS_MS, dailyMs = ReminderEngine.FOCUS_DAILY_MS, triggerHint = "长时间用眼后休息一下")Spec.sceneReached() 是纯函数,提醒引擎在判断场景日程时直接调用它。新增提醒语义的成本因此从“改五个分支”降低到“注册一条 + 补兜底文案”。
fun sceneReached(scene: SceneEventEntity, now: Long): Boolean { if (matchCategories.isEmpty()) return false if (AppCategory.from(scene.appCategory) !in matchCategories) return false val continuousOk = continuousMs != null && scene.durationMs >= continuousMs val dailyOk = dailyMs != null && scene.dailyDurationMs >= dailyMs if (continuousLateNightOnly) return (continuousOk && isLateNight(now)) || dailyOk return continuousOk || dailyOk}五、Agent 三态协议:REPLY / SKIP / DEFER
通过硬规则后,小伴才进入 AgentPipeline。这里没有用复杂的模型函数调用,而是让 LLM 输出一个严格的三态协议:
REPLY|家常短句SKIP|原因DEFER|分钟|文案关键约束是“越权必须回退”:
- 用户创建的提醒(
schedule_due:/scene_schedule:)只允许REPLY,不能跳过或延后; - App 阈值触发的提醒(
app_limit:)不可SKIP,可以DEFER; - 输出含糊、协议不对、或越权,一律回退本地文案库。
val userCreated = decision.reason.startsWith("schedule_due:") || decision.reason.startsWith("scene_schedule:")val appLimit = decision.reason.startsWith("app_limit:")val canSoftSkip = !userCreated && !appLimitval canDefer = !userCreatedparseAction 是纯函数,专门负责严格解析:
clean.startsWith("REPLY|", ignoreCase = true) -> { val message = clean.substringAfter('|').trim().take(40) if (message.isBlank()) Outcome(true, local, "local_invalid") else Outcome(true, message, "agent")}clean.startsWith("SKIP|", ignoreCase = true) && canSoftSkip -> ...clean.startsWith("DEFER|", ignoreCase = true) && canDefer -> ...else -> Outcome(true, local, "local_invalid")LLM 并不是“一次失败就放弃”。实现里做了两层重试:
- 调用异常重试一次,用于网络抖动;
- 协议矫正重试一次,当第一次输出不合规时,把“上次输出 + 只允许协议行”作为纠正提示再问一次。
val corrective = "$brief\n注意:你上一次输出不符合协议:$firstRaw。现在只能输出 REPLY|… / SKIP|… / DEFER|分钟|… 中的一行,不要其它内容。"Agent 的上下文也不是让模型自行决定要看什么,而是由 AgentToolRegistry 在调用前预读取只读信息:候选事件、近期反馈、当前时间,拼进 user 消息。这样每次判断都是独立上下文,且不依赖模型的函数调用能力,对 DeepSeek / 自定义 OpenAI 兼容端点都更稳定。
六、双通道调度:AlarmManager 准点,WorkManager 兜底
定时提醒的可靠性同样很关键。小伴使用双通道:
AlarmManager.setExactAndAllowWhileIdle负责准点唤醒;- WorkManager 在到点后 2 分钟作为持久化兜底,防止厂商系统拦截闹钟。
这里最容易踩的坑是“恢复任务时把自己的 Worker 取消掉”。所以调度策略必须区分场景:
fun scheduleNext(context: Context, schedule: ScheduleEntity) { scheduleAlarm(context, schedule) enqueueBackup(context, schedule, ExistingWorkPolicy.REPLACE)}
/** 应用/系统恢复时不得替换已经到点、正在启动的 Worker。 */private fun restoreNext(context: Context, schedule: ScheduleEntity) { scheduleAlarm(context, schedule) enqueueBackup(context, schedule, ExistingWorkPolicy.KEEP)}兜底 Worker 发送前还会重新读取 Room,再检查 enabled 和 dueTime,防止旧任务把下一周期提前发出:
if (!schedule.enabled) return Result.success()val dueTime = schedule.dueTime ?: return Result.success()val now = System.currentTimeMillis()// 旧兜底任务与周期推进竞态时,绝不能把下一次提醒提前发出。if (dueTime > now + 30_000L) { ReminderScheduler.scheduleNext(applicationContext, schedule) return Result.success()}周期提醒在发送后计算下一次 dueTime 并重新入队,一次性提醒则自动 enabled = false,避免重复触发。
七、真机验证:从“靠手感”到“命令行驱动”
项目在 HONOR BVL-AN00 真机上验证,踩过不少厂商系统特有的坑:
- MagicOS 的
UsageStatsManager更新延迟,导致 QQ 等 App 达到阈值仍不显示; - Accessibility 在覆盖安装后可能被系统关闭;
- 厂商推送、透明 Activity 和输入法窗口会打断真实 App 会话。
对应的工程手段是:双数据源取较大值、700ms 稳定判定、包名必须有桌面启动入口、恢复历史会话时按时间重叠去重。
除了 19 个 JVM 单测,小伴还内置了一个 Debug 专用测试入口(仅 debug 包存在)。通过 adb shell am start 可以直接驱动内部逻辑:
# 创建提醒(自然语言解析 + AlarmManager/WorkManager 双通道调度)adb shell am start -n com.xiaoban.app.debug/com.xiaoban.app.debugapi.TestApiActivity \ --es cmd create --es text "1分钟后提醒我喝水"
# 注入场景事件并跑提醒引擎(force 忽略勿扰,便于随时测)adb shell am start -n com.xiaoban.app.debug/com.xiaoban.app.debugapi.TestApiActivity \ --es cmd scene --es category WORK --el durationMs 2700000 --ez force true
# 直接跑「场景→agent→LLM→通知」整链路adb shell am start -n com.xiaoban.app.debug/com.xiaoban.app.debugapi.TestApiActivity \ --es cmd agent结果回执写入 Room 的 reminder_history,真机验收因此变成了“命令可触发、结果可查询”的闭环。
收获与下一步
小伴这个项目最有趣的不是“用上了 LLM”,而是想清楚了一个问题:
在 AI 参与的产品里,哪些判断必须绝对可靠,哪些判断可以交给模型去“发挥”?
我的答案是:时间、权限、用户意愿、隐私边界由本地硬规则守护;语气、时机、是否打扰由 Agent 做柔性决策。“规则优先、AI 柔性”的分层架构,让应用既稳定又有人情味。
下一步值得探索的方向:
- 更丰富的提醒动作与场景规则;
- 更细粒度的打扰感知(日历、地理位置等);
- 把 Agent 决策做得更可解释,让用户看到“小伴为什么现在提醒我”;
- 更灵动的 UI 设计
- 完善正式签名与发布流程,把 M6 打磨阶段走完。
如果你也对“有温度的软件”感兴趣:提醒不只是通知,也可以是一种陪伴。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时





