1. 为什么叫“buzz”:先想清楚提醒系统要解决的问题
1.1 从“震一下”到“轻打扰”的产品定义
很多开发拿到提醒类需求,第一反应就是问“用哪个推送 SDK”。但我做这个小工具的时候,反而先盯着名字看了很久。“buzz”在英文里既是蜜蜂的嗡嗡声,也是一种高频、短促、持续的信号。这个词恰好概括了我想要的产品形态:用户不需要看长文案,不需要弹一屏数据,他只需要感知到“有一件事该注意了”。
所以我把 buzz 的产品定义压缩成三条:第一,提醒文案不超过十个字;第二,默认不主动打开应用;第三,所有提醒必须能被一键静默。这三条听起来像产品经理在说漂亮话,但实际上会直接反推技术选型。比如“默认不主动打开应用”意味着不能依赖推送回调里的跳转逻辑;“一键静默”意味着所有通知必须支持批量取消,而不是一条条去删。很多团队做提醒功能,是把通知当成内容平台来做,消息越做越重,用户越用越烦。而 buzz 的做法刚好相反——把通知里的内容剥离掉,只保留“时间 + 标题 + 振动模式”三个字段,其他一切都是多余的。
1.2 选型前先列好三张能力清单
提醒类项目最常见的返工原因,是需求阶段没有把平台能力边界列清楚。Web 端能振动的浏览器只有部分支持,Android 的通知渠道和振动器是两套逻辑,iOS 的后台限制又是另一套规则。这里直接给出我整理的一张能力清单:
| 能力项 | Web | Android | iOS |
|---|---|---|---|
| 系统通知 | Notification API | NotificationChannel | UNUserNotificationCenter |
| 振动接口 | navigator.vibrate(iOS Safari 不支持) | VibrationEffect | UIImpactFeedbackGenerator(仅瞬时触感,不可持续) |
| 本地定时提醒 | 受限,页面关闭后不可靠 | AlarmManager | UNCalendarNotificationTrigger |
| 可靠的后台提醒 | 需 Web Push 配合服务端 | Doze 模式可能延迟 | 系统通知可靠性高,但受用户设置约束 |
这份清单的价值在于,它让我们在做架构之前就接受了几个现实:Web 端想做到“页面关闭后准时提醒”,必须引入推送服务;Android 要防杀后台,必须处理厂商白名单;iOS 的本地通知最可靠,但用户可以在系统层面关掉声音和通知。于是选型原则就变得很清晰——buzz 的定位是用户自行设定的提醒工具,核心数据都在本地,没有服务端状态,因此优先使用本地通知,而不是上来就搭一套远程推送体系。只有跨设备同步、需要服务端下发消息时才引入 Web Push 或 APNs/FCM,否则只是为了一个站会提醒去搭建推送服务,纯属自找麻烦。
2. Web 端:浏览器里也能做出“震一下”的完整链路
2.1 navigator.vibrate:先确认浏览器允不允许你震
浏览器振动 API 看起来很简单,一个navigator.vibrate(pattern)就结束了,但实际调用限制非常多。先给出一段可以直接用的封装代码:
function buzz(pattern = [200, 100, 200]) { if (!navigator.vibrate) { console.warn('当前浏览器不支持振动 API'); return false; } const result = navigator.vibrate(pattern); if (!result) { console.warn('振动被拒绝,通常是因为当前调用不在用户手势上下文中'); } return result; }这里有几个关键点需要理解。pattern是震动和暂停交替的毫秒数组,比如[200, 100, 200]表示震 200ms、停 100ms、再震 200ms。数组第一位如果是 0,表示立即开始震动。另一个重要限制是:Chrome 系浏览器只允许在用户手势的调用栈中执行振动,页面加载完成后直接调用,navigator.vibrate会返回false,而且不会抛任何异常,非常容易踩坑却不自知。
还有一个无法绕过的事实:iOS Safari 完全不支持振动 API。因此在 Web 端做 buzz,必须设计降级方案,比如弹系统通知的同时播放一段短提示音,或者用页面级的视觉闪烁代替。我见过有人试图用setInterval反复调用振动来模拟持续振动,这种做法不可控,而且超出振动周期后浏览器会自动停止,几乎没有效果。正确的做法是让振动模式本身短促有力,一击即止。
2.2 Notification 权限请求:不要在用户刚进站时弹窗
浏览器通知权限是一个“一次拒绝,后续很难挽回”的入口。很多网站一加载就弹出“允许通知吗”,用户大概率直接拒绝,而且以后每次访问都要面对这个结果。正确做法是等用户做出“开启提醒”这个主动操作时再请求权限。下面是我在 buzz 的网页版设置页里用的一段代码:
async function enableBuzz() { if (!('Notification' in window)) { alert('当前浏览器不支持系统通知'); return; } const permission = await Notification.requestPermission(); if (permission === 'granted') { new Notification('buzz 已开启', { body: '提醒会通过系统通知发到这里', tag: 'buzz-welcome', }); } }这段代码的要点有两个。第一,requestPermission()必须放进用户点击事件的回调里,这样既符合浏览器的手势要求,也能让用户理解“我点了按钮,所以浏览器问我权限”的逻辑。第二,通知对象里的tag字段特别重要,同一个 tag 的新通知会直接覆盖旧通知,避免同一任务多次提醒时在桌面上堆出一串卡片。桌面端 Chrome 的 Notification 在 Windows 上会显示为系统 toast,正文不要重复标题内容,用户扫一眼就够。
2.3 页面关闭后怎么办:别指望纯前端定时
这是 Web 端最容易产生误解的地方。很多人以为写了setTimeout或 Service Worker 里的定时逻辑,就能在页面关闭后按时振动。实际上 Service Worker 可以随时被浏览器回收,它没有一个可靠的定时器机制;而 Periodic Background Sync 需要站点被安装为 PWA,并且触发周期由浏览器决定,通常无法做到“明天上午九点提醒我”。真正可靠且适合提醒场景的方案是 Web Push,由服务端在目标时间投递消息,Service Worker 收到 push 事件后再弹通知。
这里是一份可以直接用的 Service Worker 监听代码:
self.addEventListener('push', (event) => { const payload = event.data ? event.data.json() : {}; const options = { body: payload.body || '', icon: payload.icon || '/icon.png', tag: payload.tag || 'buzz', vibrate: payload.pattern || [200, 100, 200], data: { url: payload.url || '/' }, }; event.waitUntil(self.registration.showNotification(payload.title || 'buzz', options)); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.notification.data.url) { event.waitUntil(clients.openWindow(event.notification.data.url)); } });showNotification的 options 里有一个vibrate参数,Chromium 系浏览器会把它转换成系统通知的振动效果,这是页面关闭后还能“震一下”的有效路径。如果只做页面存活期间的提醒,setTimeout加 Notification 就够了;一旦要求页面关闭后也提醒,就必须搭一个最小推送服务。对于 buzz 这类的个人效率工具,我的建议是桌面端用 Web Push,移动端引导用户使用系统日历或原生 App,不要在 Web 里硬造一个后台闹钟。
3. 移动端:Android/iOS 的振动与通知差异,怎么统一适配
3.1 Android:通知渠道和 VibrationEffect 是两套逻辑
Android 的提醒链路里,新手最容易混淆的是“通知渠道的振动”和“应用内主动振动”的区别。通知渠道的振动模式只负责系统弹出通知时的行为,而Vibrator是应用进程主动调振动器。对一个提醒产品来说,应该优先把振动配置放进 NotificationChannel 里,这样用户可以直接在系统设置中单独关掉某一类提醒的振动,而不用卸载整个应用。
val channel = NotificationChannel( "buzz_work", "工作提醒", NotificationManager.IMPORTANCE_HIGH ).apply { enableVibration(true) vibrationPattern = longArrayOf(0, 200, 100, 200) } notificationManager.createNotificationChannel(channel)这里有一个在实际开发中很微妙的取舍:NotificationChannel 的振动模式是创建后整体生效,不是每条通知都能动态指定自定义 pattern。如果产品想让用户自定义振动节奏,有两个可行方案。一是为每种模式预创建独立的 Channel,例如“轻拍频道”“紧急频道”;二是在用户点击“试听振动”按钮时直接用VibrationEffect.createWaveform播放自定义节奏,真实通知则使用 Channel 预置的模式。前者的缺点是 Channel 数量多了会让系统设置页显得臃肿,后者的好处是干净、可控。我项目里最终选了方案二,因为真实通知的振动由系统统一管理,稳定性更好。
3.2 iOS:通知和“触感反馈”的边界要分清
iOS 的情况和 Android 完全不同。本地通知由UNUserNotificationCenter管理,到点后由系统弹通知,可靠性非常高。但这不意味着你可以控制一切:用户可能关掉了某个 App 的声音,可能开着静音模式或专注模式,如果系统层面决定不响不震,代码没有任何办法绕过。
也就是说,在 iOS 上开发提醒功能时,要接受一个现实:你可以保证通知“到达”,但无法保证用户“感知到”。
这里要特别提醒一个容易被误用的功能:UIImpactFeedbackGenerator。它提供的是瞬时触感反馈,适合按钮点击、下拉刷新这样的交互瞬间,不适合作为提醒手段。因为 App 在后台时进程不活跃,根本调不到这个 API;而且它的振感强度太轻,用作提醒很容易被忽略。所以我的建议是:通知类提醒全部走UNNotificationRequest,触感反馈只用于 App 内的即时预览。
iOS 本地通知还有一个隐藏细节:用户即使已经全局授权了通知,也可能在“设置-通知”里对这个 App 关闭“声音”或“横幅”。所以在业务层面要有一个自己的提示,比如在设置页显示“请确认系统通知已开启”,而不是默认用户一定会收到提醒。
3.3 统一接口设计:双平台的 BuzzService
为了避免业务层写满 if-Android-if-iOS 的代码,我实现了一个非常薄的抽象接口。核心思想是上层只描述“什么时候、以什么方式提醒一个叫 x 的任务”,完全不感知平台差异。
interface BuzzService { fun schedule(request: BuzzRequest) fun cancel(taskId: String) fun preview(pattern: Pattern) } data class BuzzRequest( val taskId: String, val title: String, val body: String?, val triggerAt: Long, val pattern: Pattern, val channelKey: String )Android 端的实现用AlarmManager设置精确闹钟,到点后通过 NotificationManager 发一条带 Channel 振动配置的通知;iOS 端则把triggerAt转成UNCalendarNotificationTrigger或UNTimeIntervalNotificationTrigger,交给系统调度。preview方法在 Android 上用VibrationEffect播放自定义节奏,在 iOS 上用UIImpactFeedbackGenerator。这个接口的价值在于,未来如果要加一个“长安提醒”或“正点报时”功能,只需要填BuzzRequest,不用再碰各平台 API。
4. 提醒疲劳是一个工程问题:频率控制与去重策略
4.1 用户卸载你的应用,往往是因为“太多”
做提醒功能的团队往往盯着“触达率”看,但用户感知是另一回事。后台数据很容易证明通知疲劳的存在:推送频率和用户关闭通知权限的概率是正相关的。一个提醒工具如果每天都在制造一堆“低价值震动”,用户一定会在某个下午直接关掉通知权限,甚至卸载。
所以 buzz 的设计目标从来不是“最大化触达”,而是“在用户可接受范围内提醒”。具体落到工程上,我设了几条硬规则:同一任务默认每天最多提醒一次;紧急任务最多提醒两次;同类任务必须合并;系统提供一个“今日免打扰”总开关。这些规则必须在调度入口执行,而不是在通知发出后反悔。
4.2 自定义振动模式:节奏感比参数更重要
用户不会去读“振动毫秒数”这种配置项,但人对节奏非常敏感。我把振动模式做成了几个预设,用户只需要选择“轻拍”“标准”“紧急”,实际对应这些真实参数:
| 模式 | pattern 数组 | 适用场景 |
|---|---|---|
| 轻拍 | [60, 40, 60] | 一般提醒,如喝水、起身活动 |
| 标准 | [200, 100, 200] | 会议、吃药、定时任务 |
| 紧急 | [400, 200, 400, 200, 800] | 闹钟、关键事项 |
pattern 数组里的 0 表示立即开始震动,整体时长建议控制在 3 秒以内,超过这个长度就会让人烦躁。设计“试听振动”按钮时,顺手解决了浏览器手势限制的问题——用户点击试听按钮,本身就是一个合法的用户手势,浏览器不会拒绝这次振动调用。这个交互点值得保留,因为它同时满足了“可感知”和“可配置”两个需求。
4.3 去重、时间窗口与退避:这些逻辑必须在调度入口处理
提醒系统的去重逻辑看起来简单,但经常被放到错误的位置。正确的做法是在触发后的第一时间做“是否可以发送”的判断,而不是等通知弹出来再想怎么撤回。我用三个机制把这层逻辑固定下来:
第一个是去重键。每个任务在一天内只保留一次提醒,去重键是taskId + 日期,触发成功后就写入本地存储,同一天内再次触发直接跳过。第二个是合并策略。对于同类事件,新通知使用与旧通知相同的id或tag去覆盖它,而不是让通知栏堆积一排重复卡片。第三个是时间窗口。配置里有一个dailyStart和dailyEnd,例如早上七点到晚上十点之外触发时,直接丢弃或延迟到第二天早上,绝不带着“深夜打扰用户”的风险去死守调度。
再加一条我评估后的退避规则:如果某个任务用户点掉了提醒但没有真正完成,那么相同任务的第二次提醒间隔要大于第一次的两倍。代码实现上其实就是一句话:每次发送后记录lastSentAt,下一次发送前比较时间差,不满足就直接 return。这些规则不复杂,但它们决定了一个提醒工具是让用户觉得贴心,还是觉得烦人。
5. 排查实录:从“毫无反应”到“疯狂震动”的四个典型坑
5.1 坑一:iOS 静默通知不等于通知
我踩过的最莫名其妙的坑,是在 iOS 上配置了静默推送,也就是content-available: 1,本意是“后台收到消息后自己弹本地通知”。结果真机放着不动,任何反应都没有,日志里也没有报错。后来才反应过来:静默通知的根本特性就是不展示任何 UI,系统只在合适的时机唤醒 App,而且低电量模式、后台 App 刷新关闭时,它根本不会及时唤醒进程。
从这里得到的教训是:提醒类功能必须使用可展示类型的通知。如果只想“后台唤醒再自己弹”,别把赌注压在静默推送的到达时间上。排查 iOS 通知问题还有一个笨办法但很有效:真机上把“设置-通知-对应 App”里每一项开关逐个检查一遍,再配合日志确认系统是否拦截。
5.2 坑二:Chrome 里 vibrate 静默失败
在 Web 端排查振动问题时,最容易困惑的一点是:navigator.vibrate(pattern)返回false,但没有任何报错。Chrome 要求振动调用必须在用户手势的调用栈中,页面加载后直接调用会被静默拒绝。这个限制很符合用户利益,否则网页可以在后台随意让手机震动,体验会很糟糕。
解决方式有两个层面。交互层面,把振动调用绑定到“试听振动”这类按钮的 click 事件里;技术层面,如果定时器到期后想在页面还开着时振动,必须在用户点击时提前保存一个“允许振动”的状态,并确保后续振动调用是在用户手势的上下文里,或者直接通过showNotification里的vibrate参数让系统处理。我最终采用的就是后者,因为系统通知的振动不受手势上下文限制。
5.3 坑三:Android 锁屏几分钟后提醒消失
这是所有提醒类应用都会遇到的“国产 ROM 特有问题”。现象是应用前台运行时一切正常,锁屏几分钟后 buzz 就消失了。背后的原因大家都很熟悉:Android 原生的 Doze 模式,加上各家厂商的“自动管理”“智能省电”“自启动限制”,会把后台进程挂起,精确闹钟到了点也发不出来。
排查时不要上来就让用户关闭整个电池优化,那样既不专业,也会吓到用户。正确流程是:先用adb shell dumpsys deviceidle查看设备是否进入了深度 Doze;再检查应用是否被系统加入电池优化白名单;最后引导用户打开厂商管家里的“允许后台运行”单项开关。如果用户拒绝,尊重他,同时把这次失败记录到日志里。
| 现象 | 可能原因 | 验证方法 |
|---|---|---|
| 前台正常,锁屏不响 | Doze / 厂商后台限制 | dumpsys deviceidle,检查自启动权限 |
| 提醒延迟很久才到 | 未使用 setExactAndAllowWhileIdle | 检查 AlarmManager 类型 |
| 系统设置里找不到通知渠道 | Channel 未在发通知前创建 | 检查创建顺序 |
5.4 坑四:通知重复振动没有尽头
Android 上还有一种“疯狂震动”的故障:连续来两条相同任务的通知,通知栏被新通知覆盖了,但振动还是每次都发生,甚至叠加。原因是NotificationManager.notify(id)用相同 id 确实会覆盖旧通知,但系统通知声音和振动不会因此被合并。发送前先cancel(id)再notify,可以减少重复振动;再配合前面说的去重键,把同一天内的重复触发直接拦截。这个问题的本质是提醒系统把“覆盖展示”和“去重调度”混为一谈,实际上它们是两件不同的事。
6. 把 buzz 做成可配置服务:调度语义、电量与落地建议
6.1 配置文件长什么样
如果把 buzz 从一个固定提醒功能扩展成一个可供用户编辑的提醒服务,最自然的方式是提供一份配置化的任务列表。不要给用户看 cron 表达式,对外暴露的配置模型可以很简单,代码内部再转成平台调度。
{ "tasks": [ { "id": "morning-pill", "enabled": true, "title": "吃药提醒", "schedule": { "type": "daily", "at": "08:00", "days": [1, 2, 3, 4, 5, 6, 7] }, "buzz": { "channel": "health", "pattern": "standard" }, "limits": { "maxPerDay": 1, "window": ["07:00", "22:00"] } } ] }这套配置的核心在于幂等:同一个配置重复下发,不应该产生重复提醒。所以每次schedule被调用时,第一件事永远是 cancel,然后再创建新的调度。否则用户在设置页保存两次配置,系统里就有两份触发源,到一个点就会双重提醒,这种问题在测试时很难发现,但用户一定会遇到。
6.2 双平台调度的幂等语义:先取消再创建
Android 端使用AlarmManager.setExactAndAllowWhileIdle时要注意权限问题,Android 12 以上的精确闹钟需要SCHEDULE_EXACT_ALARM权限,而且这个权限不是默认授予的,用户可以在系统设置中关闭。对提醒类应用来说,权限被拒绝时的降级方案是使用setWindow或setAndAllowWhileIdle,虽然可能延迟几分钟,但至少比完全不提醒强。iOS 端则通过UNUserNotificationCenter的removePendingNotificationRequests(withIdentifiers:)先取消,再用同一个 id 添加新通知。遵循“先取消再创建”的顺序,能把很多难以复现的重叠问题消灭在萌芽里。
6.3 电量与性能:真正费电的不是振动,是点亮屏幕和网络
最后说一下性能和电量。很多人以为“振动很费电”,实测下来并非如此,振动电机的功耗在毫瓦级别,一次几百毫秒的标准振动对整机续航几乎无感。我在一台中端 Android 设备上连续触发一百次标准振动,电池百分比都没有可见变化。真正费电的是为了提醒而拉活 App、发起网络请求、点亮屏幕这些动作。
所以 buzz 的原则是只负责提醒,不负责在提醒时加载内容。用户点击提醒进入应用后,如果需要冷启动,再去初始化业务;除此之外,提醒链路本身不碰网络,不持有唤醒锁,不主动点亮屏幕。这个原则写起来容易,但要落实在代码里,意味着所有提醒触发逻辑都必须做“最小动作检查”:不启动完整 Application、不初始化推送 SDK、不预加载页面。让提醒回归提醒本身,这也是“buzz”这个名字给我最大的启发。如果我现在再做一个类似功能的工具,大概率还是会从“少振一次、精准振动、随时可关闭”这三个设计点出发,把提醒当成一个独立的轻量服务来做,而不是塞在业务 App 的一个角落里慢慢膨胀。