mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2506 字
7 分钟
小伴 XiaoBan:从自然语言到主动陪伴的 Android 提醒系统

小伴(XiaoBan)是一款 Kotlin + Jetpack Compose 编写的 Android 主动提醒 App。它把“用户一句话”变成一条结构化提醒,把“前台 App 场景”变成一次主动关心,再用“本地硬规则 + 可选 LLM 柔性判断”的方式送出通知。本文不是产品功能介绍,而是从工程实现角度记录:自然语言解析、场景识别、规则引擎、Agent 三态协议、动作注册表、双通道调度以及真机验证中的关键设计。

项目概览#

模块实现关键点
自然语言创建提醒NaturalLanguageParser + RepeatRule规则版解析,可单测,解析不了就退回结构化表单
场景识别AccessibilityService + UsageStatsManager双数据源取较大值,解决厂商系统延迟
提醒引擎ReminderEngine纯 Kotlin 规则链,无 Android 依赖
柔性提醒 AgentAgentPipeline + LangChain4j / DeepSeekREPLY / SKIP / DEFER 三态协议,本地兜底
动作语义注册ReminderActionCatalogNLP、引擎、通知、计划页共用一份注册表
定时调度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) != null
if (!ScenePackageFilter.isTrackableForeground(current.packageName, prefs.excludedPackages, hasLauncher)) return null
if (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 && !appLimit
val canDefer = !userCreated

parseAction 是纯函数,专门负责严格解析:

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 并不是“一次失败就放弃”。实现里做了两层重试:

  1. 调用异常重试一次,用于网络抖动;
  2. 协议矫正重试一次,当第一次输出不合规时,把“上次输出 + 只允许协议行”作为纠正提示再问一次。
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,再检查 enableddueTime,防止旧任务把下一周期提前发出:

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 打磨阶段走完。

如果你也对“有温度的软件”感兴趣:提醒不只是通知,也可以是一种陪伴。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

小伴 XiaoBan:从自然语言到主动陪伴的 Android 提醒系统
https://hajim1.art/posts/xiaoban-android-companion-reminder/
作者
Takamatsu Tomori
发布于
2026-08-27
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录