YAOTU INSIGHTS

【时光清单|04】HarmonyOS ArkTS 通知服务实战:管理权限、渠道和点击跳转

【时光清单|04】HarmonyOS ArkTS 通知服务实战:管理权限、渠道和点击跳转
【时光清单04】HarmonyOS ArkTS 通知服务实战管理权限、渠道和点击跳转在业务代码里出现一个名为NotificationService的类不代表通知链路已经完整。一个可上线的通知模块至少要回答四个问题什么内容值得提醒系统是否允许展示通知应归入哪类消息用户点击后如何落到应用内的正确页面。时光清单的真实源码把其中两个部分分开了NotificationService.ets只负责筛选未来七天内的纪念日并生成单条文案ReminderService.ets才调用kit.NotificationKit请求通知授权、构造基础文本通知并发布。当前工程没有在通知请求中附加点击代理也没有在EntryAbility中解析通知路由参数。因此“管理权限、渠道和点击跳转”是基于现状要补齐的工程边界而不是已经存在的隐藏能力。本文按当前仓库真实源码拆解一套可复核的分层方案领域服务只生成通知候选和文案平台适配层管理授权与通知类别路由契约把通知点击转换成应用内导航。这样既能避免页面直接拼NotificationRequest也不会让纯业务函数承担系统 API 和生命周期责任。本文重点包括复核当前七天窗口与提醒文案的真实实现。区分通知权限、用户业务开关和消息类别。设计稳定通知 ID避免不同业务互相覆盖。用窄路由参数实现通知点击后的详情页跳转。处理冷启动、热启动、无效 ID、权限拒绝和取消通知等边界。本文唯一标记CSDN-SERIES:ALL-163202512阅读前先区分当前事实、历史证据与建议实现当前事实来自D:\huawei\one8的静态源码复核。可以确认NotificationService不依赖系统通知 API只筛选剩余 0 到 7 天的事项并生成文案ReminderService才调用kit.NotificationKit请求通知开关、筛选剩余 0 到 3 天的事项、构造基础文本请求并调用publish()设置页只有在用户点击“通知提醒”后才进入这条调用链。当前发布请求使用固定id: 1001和SlotType.SERVICE_INFORMATION。被检查的请求里没有点击代理或路由参数EntryAbility.onCreate()虽然接收Want但没有解析通知点击信息源码中也没有找到sendDailyReminder()的后台调度入口。cancelAll()会取消本应用全部通知无法按某个纪念日精确取消。历史证据只引用有明确记录的内容。PROJECT_ERRORS.md中没有通知权限、通知展示、点击路由或调度的专项验证记录。文档里出现的历史assembleHap通过属于其他 2026 年 5 月修复不能拿来证明当前通知代码已经构建、已经在设备显示或已经通过发布审核。建议实现包括业务提醒开关、稳定通知 ID、点击代理、版本化路由、冷启动与热启动处理、精确取消和后台调度。它们是基于现有缺口提出的设计不是当前代码已经拥有的隐藏能力。本文实际复核范围如下service/NotificationService.ets service/ReminderService.ets views/ProfileView.ets views/PrivacyPolicyView.ets entryability/EntryAbility.ets data/AnniversaryRepository.ets model/Anniversary.ets routes/RouteNames.ets module.json5 PROJECT_ERRORS.md本文没有执行新的 HAP 构建、权限弹窗、通知展示、锁屏展示、点击跳转、冷启动、热启动、后台调度或精确取消测试因此这些结果都保留为待验证项。一、当前 NotificationService 是领域筛选器真实文件只有两个静态方法没有导入 Notification Kitimport { Anniversary, calcDaysRemaining } from ../model/Anniversary; export class NotificationService { static getUpcomingReminders( items: Anniversary[] ): Anniversary[] { return items .filter((item: Anniversary) { const days calcDaysRemaining(item.targetDate); return days 0 days 7; }) .sort((a: Anniversary, b: Anniversary) a.targetDate - b.targetDate ); } static getReminderText( item: Anniversary ): string { const days calcDaysRemaining(item.targetDate); if (days 0) { return 今天是「${item.title}」的日子; } if (days 1) { return 明天就是「${item.title}」啦; } return 「${item.title}」还有 ${days} 天; } }这段代码做的是领域决策决策真实规则是否进入候选剩余天数在 0 到 7 之间是否包含过期事项不包含排序依据targetDate升序今天文案“今天是……”明天文案“明天就是……”其余文案“还有 N 天”它没有权限请求、消息类别、通知 ID、发布、取消或点击跳转。把这个类保留为纯业务层反而是优点它可以用构造数据直接单元测试不需要 HarmonyOS 运行环境。二、平台发布在 ReminderService不要混淆两层项目中的ReminderService才真正接触系统通知import { notificationManager } from kit.NotificationKit; static async requestPermission(): Promiseboolean { try { await notificationManager .requestEnableNotification(); return true; } catch (e) { hilog.warn( 0x0000, TAG, requestPermission failed: ${ JSON.stringify(e) } ); return false; } }发布请求使用基础文本内容和SERVICE_INFORMATION类别const request: notificationManager.NotificationRequest { id: 1001, content: { notificationContentType: notificationManager.ContentType .NOTIFICATION_CONTENT_BASIC_TEXT, normal: { title, text } }, notificationSlotType: notificationManager.SlotType .SERVICE_INFORMATION }; await notificationManager.publish(request);因此当前真实结构可以概括为NotificationService 负责候选筛选、排序、单条文案 ReminderService 负责授权请求、聚合文案、Notification Kit 发布、全部取消后续重构时不必把两者强行合并。更合适的方向是给它们更准确的名字例如ReminderContentService与HarmonyNotificationPublisher再新增独立的点击路由层。三、权限状态不能等同于业务开关设置页当前在点击“通知提醒”时请求系统授权成功后立即发送一次提醒。完整产品至少有两类状态系统通知权限由 HarmonyOS 管理用户可在系统设置中修改。应用提醒开关由用户在“时光清单”内选择保存在本地。两者组合后才得到实际行为系统权限应用开关页面状态是否发布允许开启已开启是允许关闭已关闭否拒绝开启需要授权否拒绝关闭已关闭否不要在每次进入页面时自动拉起授权弹窗。更符合体验的流程是用户主动开启提醒时请求拒绝后说明用途再次操作时根据系统规则引导到设置而不是循环打扰。可以定义业务层可读状态export type NotificationAccessState | enabled | permission_required | disabled_by_user; export function resolveAccessState( systemAllowed: boolean, reminderEnabled: boolean ): NotificationAccessState { if (!reminderEnabled) { return disabled_by_user; } return systemAllowed ? enabled : permission_required; }这个函数不负责拉弹窗只把两个来源的状态转成页面文案和控件状态。四、消息类别不是装饰字段当前请求使用notificationSlotType: notificationManager.SlotType .SERVICE_INFORMATION消息类别会影响系统对通知的组织和用户设置体验。开发者应根据通知真实用途选择类别不能为了更显眼而滥用高优先级、进行中通知或与业务不符的类型。纪念日临近提示属于普通服务信息而不是通话、导航、媒体播放或持续进度。平台适配层可以把业务类别映射集中起来export type BusinessNoticeType anniversary | habit | backup; export function resolveSlotType( type: BusinessNoticeType ): notificationManager.SlotType { switch (type) { case anniversary: case habit: case backup: return notificationManager .SlotType.SERVICE_INFORMATION; } }即使当前三个业务都映射到同一类别集中映射仍有价值以后调整不会散落在多个页面代码审查也能快速确认是否存在不当类别。华为的通知消息管理规则还要求通知内容与样式符合场景不应利用通知故意后台拉起应用、唤醒其他应用或诱导跳转。通知点击应服务于用户刚刚看到的内容而不是把用户送到无关首页或外部下载页。五、通知 ID 要按业务稳定生成真实发布代码固定使用id: 1001。只有一种提醒时很简单如果未来习惯、备份和纪念日都发布通知全部使用同一个 ID 可能互相覆盖全部使用随机 ID又会堆积重复消息。可以为业务划分 ID 空间export class NotificationIds { static readonly ANNIVERSARY_BASE 100000; static readonly HABIT_BASE 200000; static readonly BACKUP_RESULT 300001; } export function stableNumber( value: string ): number { let hash 0; for (let i 0; i value.length; i) { hash ((hash 5) - hash) value.charCodeAt(i); hash | 0; } return Math.abs(hash); } export function anniversaryNoticeId( anniversaryId: string ): number { return NotificationIds.ANNIVERSARY_BASE stableNumber(anniversaryId) % 90000; }同一纪念日得到稳定 ID更新文案时仍能定位同一通知不同业务落在不同区间取消时不容易误伤。哈希存在理论碰撞如果业务规模扩大可保存 ID 映射或使用平台允许的组合标识。六、点击跳转先定义路由契约通知点击不应把整个AnniversaryJSON 塞进参数。通知可能在记录修改或删除后才被点击旧快照会产生过时内容。更稳的做法只传业务类型、实体 ID 和协议版本export interface NotificationRoute { version: 1; action: open_anniversary; anniversaryId: string; source: local_notification; } export function createAnniversaryRoute( id: string ): NotificationRoute { return { version: 1, action: open_anniversary, anniversaryId: id, source: local_notification }; }点击后应用用anniversaryId重新查询仓库。若记录已删除回到列表并提示“该事项已不存在”而不是打开一张过期详情。路由协议需要版本号因为应用升级后通知栏里可能仍保留旧通知。解析器遇到未知版本时应安全回退首页不要直接解构并假定字段存在。七、NotificationRequest 中的点击代理由平台层构造完整通知请求应由平台适配层统一创建并在目标 SDK 支持的请求字段中附加指向本应用的点击代理。由于具体 WantAgent 创建签名会随 SDK 版本演进业务层不要直接依赖它可以先抽象为窄接口export interface NotificationClickAgent { readonly platformValue: object; } export interface ClickAgentFactory { createForRoute( route: NotificationRoute ): PromiseNotificationClickAgent; } export interface LocalNotice { id: number; title: string; text: string; type: BusinessNoticeType; route?: NotificationRoute; }发布器的步骤是校验通知 ID、标题和正文。根据业务类型映射消息类别。路由存在时通过工厂创建本应用的点击代理。构造NotificationRequest。调用notificationManager.publish()。返回明确的成功或失败结果。点击代理只能指向用户预期的本应用页面不能借通知无关拉起其他应用。接入时以项目当前 HarmonyOS SDK 的官方 WantAgent 与 NotificationRequest 文档为准并在真机验证冷、热启动行为。八、EntryAbility 要同时处理冷启动与热启动当前EntryAbility.onCreate()接收Want但没有解析通知路由onCreate( want: Want, launchParam: AbilityConstant.LaunchParam ): void { hilog.info( DOMAIN, TAG, Ability onCreate ); DataStore.getInstance() .initSync(this.context); AppStore.bootstrap(this.context); DataStore.getInstance() .init(this.context); this.hydratePersistentState(); }点击通知可能发生在两种状态应用未运行走onCreate随后窗口和页面栈才建立。应用已运行系统把新的 Want 交给已有 Ability需在对应回调处理。路由不能在页面栈尚未初始化时直接pushPath()。可以先暂存“待处理路由”等Index页面和NavPathStack准备好后消费export class PendingRouteStore { private static pending: NotificationRoute | null null; static put(route: NotificationRoute): void { PendingRouteStore.pending route; } static take(): NotificationRoute | null { const value PendingRouteStore.pending; PendingRouteStore.pending null; return value; } }take()读取后立即清空可以避免窗口重建或页面重新出现时重复跳转。九、路由解析必须拒绝脏参数通知参数来自应用自己创建的 Want但仍可能是旧版本、字段缺失或被系统恢复后的异常值。解析器应显式校验export function parseNotificationRoute( params: Recordstring, Object ): NotificationRoute | undefined { const version params[routeVersion]; const action params[routeAction]; const id params[anniversaryId]; if (version ! 1) return undefined; if (action ! open_anniversary) { return undefined; } if (typeof id ! string || id.length 0 || id.length 128) { return undefined; } return { version: 1, action: open_anniversary, anniversaryId: id, source: local_notification }; }不要把参数直接拼进文件路径、URL 或数据库语句。这个项目只需要把 ID 交给AnniversaryRepository.getById()查不到就回退。十、导航恢复先查最新实体再进入详情项目的列表页当前通过this.pathStack.pushPath({ name: RouteNames.DETAIL, param: JSON.stringify(item) });通知点击更适合用 ID 重新查询async function openNotificationRoute( route: NotificationRoute, repo: AnniversaryRepository, pathStack: NavPathStack ): Promiseopened | not_found { const item await repo.getById( route.anniversaryId ); if (!item) return not_found; pathStack.pushPath({ name: RouteNames.DETAIL, param: JSON.stringify(item) }); return opened; }这样详情页仍沿用现有参数格式而通知层只持有稳定 ID。以后路由改为详情页自己按 ID 加载也只需修改这一处适配。热启动还要防止重复入栈用户连续点同一通知或系统重复分发 Want 时如果当前已经是同一详情不应再次 push。可以用消费令牌、当前路由比较或replacePath策略处理。十一、发布结果要向上返回当前ReminderService.publish()捕获异常后只写日志上层不知道是否成功。通知服务更适合返回判别结果export type NoticePublishResult | { ok: true; id: number } | { ok: false; reason: | permission_denied | invalid_content | publish_failed; };平台错误不要原样暴露给用户日志也不应输出完整私人标题。页面只需要知道是否需要授权、内容是否无效、系统发布是否失败。由上层决定 Toast、重试或设置入口。发布前还应限制内容export function normalizeNoticeText( value: string, maxLength: number ): string { const text value.trim(); if (text.length maxLength) return text; return text.slice(0, maxLength - 1) …; }通知标题和正文不要重复私人事项可提供“隐藏详细标题”的用户选项锁屏时只显示“有一项重要日子即将到来”。十二、取消要精确不要默认 cancelAll真实ReminderService.cancelAll()会取消应用全部通知static async cancelAll(): Promisevoid { try { await notificationManager.cancelAll(); } catch (e) { hilog.warn( 0x0000, TAG, cancelAll failed: ${JSON.stringify(e)} ); } }如果应用未来只有纪念日通知它可以作为“一键清空通知栏”功能如果加入习惯提醒和备份通知关闭某一个功能时应按 ID 精确取消。建议区分用户操作取消范围删除某个纪念日取消该实体对应通知关闭纪念日提醒取消纪念日 ID 区间和调度计划清空所有应用通知明确确认后调用cancelAll()关闭系统通知权限不自动改写业务数据取消系统通知与取消未来调度也不是一回事。若后台任务仍存在它下一次可能继续尝试发布关闭提醒时必须同时处理调度层。十三、通知规范与上架边界通知是用户可见的系统级界面不能只从“API 能不能调通”判断完成。至少应遵守这些边界只在用户明确开启后发送可预期的提醒。标题、正文和跳转目标保持一致。不伪装其他应用不隐藏应用来源。不用普通纪念日提醒冒充通话、导航或进行中任务。不通过通知故意后台拉起无关流程。点击只进入本应用与当前内容相关的页面。提供关闭提醒和清理通知的可达入口。隐私政策与实际通知内容保持一致。可对照华为官方通知消息管理规则检查样式、内容分类和点击跳转约束。对于本地通知与目标 SDK API再以项目构建时对应版本的 HarmonyOS 官方文档为准。十四、验证矩阵纯业务层可以先跑快速测试今天、明天和第七天进入候选第八天不进入。已经过期的事项被排除。多条候选按目标日期升序。三种文案分支正确。长标题被安全截断。相同纪念日 ID 生成稳定通知 ID。不同业务 ID 空间不冲突。路由缺版本、缺 action、空 ID 时解析失败。真机重点验证系统行为首次允许通知后发布成功。拒绝权限后不继续发布并能恢复设置。通知类别与业务用途一致。同 ID 再发布的更新或覆盖行为符合预期。应用关闭时点击通知冷启动后进入正确详情。应用前台时点击通知不重复创建 Ability 或重复入栈。事项已删除后点击旧通知安全回退列表。连续点击通知不会叠加多个同一详情页。删除事项后对应通知被精确取消。关闭纪念日提醒不会取消无关业务通知。深浅色下通知落地页文字、状态栏与返回路径清晰。手机之外的设备若不在模块deviceTypes范围不宣称已适配。当前module.json5只声明phone因此文章和上架材料都不应把平板通知跳转写成已验证能力。十五、常见故障与排查顺序现象常见原因修复方向有文案却没有通知只调用NotificationService经过平台 Publisher 发布Toast 成功但通知没出现发布异常被吞掉返回明确发布结果点击通知只进首页没有点击代理或路由参数构造版本化路由冷启动偶尔不跳详情页面栈尚未就绪暂存路由页面就绪后消费热启动叠加多个详情页重复 Want 未去重比较当前路由或消费令牌旧通知打开旧标题参数携带完整实体只传 ID点击后重查关闭纪念日提醒清掉全部通知使用cancelAll()按业务 ID 精确取消习惯通知覆盖纪念日通知所有业务固定 ID 1001划分稳定 ID 空间排查时从领域候选开始再查权限、请求内容、通知 ID、点击代理、Ability 收参和页面栈。把每层输入输出记录为非敏感诊断信息比反复点击发布按钮更容易定位问题。十六、先解决七天候选与三天发布的规则分叉当前有两套很接近但并不相同的筛选规则。NotificationService.getUpcomingReminders()接受 0 到 7 天ReminderService.sendDailyReminder()又独立实现 0 到 3 天。前者没有被后者复用。只看类名很容易误以为“七天候选服务”决定了实际通知真实发布路径却只使用后三天规则。这种分叉未必是错误也可能是产品有意区分“应用内即将到来列表”和“系统通知窗口”。问题在于规则没有被命名。未来有人把七天改成十四天时可能只改其中一处测试也难以判断哪一个窗口才是产品承诺。建议把窗口变成显式策略并让调用方说明场景// 建议实现当前源码仍有两段独立筛选。 export interface ReminderWindow { minDays: number; maxDays: number; } export const IN_APP_UPCOMING: ReminderWindow { minDays: 0, maxDays: 7 }; export const SYSTEM_NOTICE: ReminderWindow { minDays: 0, maxDays: 3 };筛选函数只负责执行策略文案和发布都复用同一批候选。这样文章、设置页说明和测试可以明确写“应用内展示未来七天系统提醒未来三天”而不是用两个魔法数字猜产品意图。重复事项还需要额外确认当前calcDaysRemaining()直接读取targetDate没有在通知服务中把 yearly 或 monthly 重复规则换算到下一次发生日期不能据此宣称重复纪念日提醒已经完整。十七、权限请求返回 true不代表通知已经展示requestPermission()返回true只说明requestEnableNotification()没有抛出异常。随后sendDailyReminder()可能因为没有 0 到 3 天候选而直接返回也可能在publish()中遇到异常。当前publish()把异常记录后返回void上层仍会继续显示“通知提醒已开启”。因此这个 Toast 更接近“权限流程未抛错”不是“通知已成功展示”的可靠证据。建议返回分阶段结果让页面使用准确文案// 建议实现。 export type ReminderRunResult | { ok: true; state: published; noticeId: number } | { ok: true; state: no_candidate } | { ok: false; state: permission_denied | publish_failed };用户允许权限但近期没有事项时可以提示“提醒权限已开启暂无三天内事项”发布失败时显示“设置已保留本次通知发送失败”只有收到平台发布成功结果后才提示“已发送测试提醒”。即便publish()返回成功也仍然不能仅凭方法返回值证明用户在通知栏实际看见消息最终展示、同 ID 覆盖和系统策略仍需设备侧观察。日志同样要保持克制。当前错误日志使用JSON.stringify(e)没有主动记录标题正文这是好边界后续若增加请求诊断也只记录结果枚举、通知 ID 和平台错误码不写私人事项标题、备注或完整路由参数。十八、业务开关要持久化但不能伪装系统权限当前设置页“通知提醒”是一个可点击菜单项不是可读取的开关。源码没有持久化“用户希望接收纪念日提醒”的布尔偏好也没有在页面重进时恢复启用状态。隐私政策说明通知权限可选、拒绝不影响核心功能这与用户主动触发授权的入口是一致的但完整设置体验还需要把应用偏好和系统权限分开显示。建议状态模型至少包含// 建议实现不代表当前 DataStore 已有这些键。 interface ReminderPreference { enabledByUser: boolean; privacyMode: show_title | hide_title; noticeWindowDays: 1 | 3 | 7; updatedAt: number; }enabledByUser是应用自己的偏好系统权限仍由 Notification Kit 或系统设置决定。用户关闭业务开关时应停止未来调度并精确取消纪念日通知但不应替用户改写系统总权限系统权限被关闭时也不应悄悄把业务偏好改成关闭否则用户重新授权后无法恢复原选择。锁屏隐私是通知业务不可忽略的一层。纪念日标题可能包含姓名、关系或私人事件默认展示完整标题前应确认产品策略。提供“隐藏详情”后锁屏通知可以只写“有一项重要日子即将到来”点击进入应用后再读取本地最新记录。本文只确认隐私页面提到了通知用途未验证系统锁屏样式或隐私选项。十九、所谓每日提醒目前没有调度证据方法名sendDailyReminder()容易让人理解为“每天自动执行”但静态调用搜索只发现ProfileView.handleNotificationPermission()在用户点击入口后调用它。被检查的模块配置和 Ability 中没有通知专用的定时任务、后台任务或系统调度入口。因此“Daily”描述的是函数意图不是已建立的执行频率。建议先定义调度与发布的接口边界具体 API 再按目标 SDK 官方文档落地// 建议接口当前源码没有对应调度实现。 export interface ReminderScheduler { enable(policy: ReminderSchedulePolicy): Promisevoid; disable(): Promisevoid; rescheduleAfterDataChange(): Promisevoid; } export interface ReminderSchedulePolicy { localHour: number; windowDays: number; }调度触发后仍然调用同一个领域筛选与发布器不要在后台入口复制第三套规则。新增、编辑、删除纪念日后需要判断是否重算计划关闭提醒时同时取消计划设备重启、时区变化和系统日期变化后是否恢复都要以实际平台能力和设备日志验证。“每天 09:00”也不应写成绝对准点承诺除非目标平台 API 明确保证并经过设备观察。更稳妥的产品文案是说明提醒时间范围或系统可能根据资源策略调整工程记录则保存预期窗口与实际回调时间。二十、通知链路要按证据层级验收通知功能不能用一次 Toast 或一次方法返回值作为整体完成证据。可以把验收分为五层静态源码确认候选、文案、权限调用和请求字段本地构建确认类型与 API 可编译设备权限确认允许、拒绝和再次设置系统展示确认类别、同 ID 行为、锁屏隐私和取消导航生命周期确认冷启动、热启动、脏参数、记录删除和重复点击。每次设备测试至少记录“动作、设备与系统版本、前置权限、候选数据、预期结果、实际通知 ID、实际跳转、关键日志时间”。通知没有出现时先确认是否存在三天内候选再看权限状态、请求构造与发布结果点击只到首页时再查点击代理、Want 参数、Ability 回调和页面栈是否就绪。按层排查比反复重新授权更容易定位。当前文章只完成静态源码层并明确引用没有通知专项记录的历史边界。没有执行的构建、真机展示、点击跳转、后台调度、发布审核、用户数据和平台评分一律不写成已经完成。模块当前只声明phone其他设备只有在配置、构建、运行和材料都实际验证后才能进入对外说明。总结时光清单当前的NotificationService实际上是一个清晰的提醒内容领域服务筛选七天窗口、排序并生成文案Notification Kit 的权限与发布则在ReminderService。这份拆分为完整通知架构提供了良好起点但点击跳转和精确取消还没有进入真实源码。可靠的通知链路应保持三层边界领域层决定提醒什么平台层决定如何授权、分类、发布和取消路由层决定点击后如何安全进入最新业务状态。再配合稳定通知 ID、版本化参数、冷/热启动双路径和真机验证通知才会从“一次能弹出”升级成可维护、可审核、可回退的系统能力。