【时光清单|03】HarmonyOS ArkTS 提醒服务实战:计算触发时间并防止重复注册

【时光清单|03】HarmonyOS ArkTS 提醒服务实战:计算触发时间并防止重复注册

提醒功能最危险的错觉,是"通知成功弹出一次,就等于每日提醒已经完成"。实际工程里,通知权限、内容筛选、触发时间、系统调度、重复注册和用户关闭提醒是六件不同的事。把它们揉进一个按钮回调,测试当天往往正常,过一天却可能不再触发,或者每次进入设置页都多注册一条相同任务。

时光清单 的真实源码已经实现了通知权限请求、三天内事项筛选、提醒文案拼装和基础文本通知发布。设置页在用户点击"通知提醒"后读取纪念日列表,并立即调用 sendDailyReminder()。但项目中没有后台任务或日程注册代码,sendDailyReminder 这个名字描述的是产品意图,当前行为仍是"立即筛选并发布一次"。

本文基于这条真实链路,先把现有能力说清楚,再设计可按项目目标 SDK 落地的提醒计划层:用纯函数计算下一触发时间,用注册指纹识别同一计划,用持久化状态保证幂等,用 Notification Kit 只承担通知展示。涉及系统后台调度时,应按目标 SDK 的官方后台任务能力和审核规则接入,不能把普通 publish() 当成定时器。

本文解决五个具体问题:

  1. 区分"通知授权""立即发布"和"未来触发"的能力边界。
  2. 复核三天窗口、置顶无关排序和提醒文案的真实算法。
  3. 计算下一个本地触发时刻,处理今天已过、月末和系统时间变化。
  4. 用稳定指纹防止相同计划重复注册,并允许配置变化后替换。
  5. 给出权限拒绝、空列表、重复调用、关闭提醒和重启恢复的验证矩阵。

本文唯一标记:CSDN-SERIES:ALL-163202226

阅读前先划清证据边界

这篇文章同时包含"当前事实"和"建议实现",两者不能混写。当前事实来自本地项目源码的静态复核:设置页的按钮回调请求通知权限,读取纪念日数据,再调用 ReminderService.sendDailyReminder();该方法只筛选距离今天零到三天的事项并立即提交基础文本通知。工程搜索没有发现提醒计划注册、系统后台调度、计划指纹、任务 ID、下一触发时间或当日执行键的持久化实现。因此,源码能够证明的是"用户点击后立即尝试发布一次",不能证明"系统每天自动触发"。

历史证据也必须单列。项目错误记录里存在与其他功能有关的历史构建成功信息,但没有提醒调度的注册、重启恢复、跨日触发或真机回归记录。那些旧构建记录不能替代本轮提醒功能的编译、安装和设备验证,更不能反推后台计划已经可用。本文不会把历史结果写成当前结果。

后文的 Planner、Registry、计划指纹、自然日执行键和任务状态都是针对真实缺口给出的建议实现。它们说明如何设计与验证,不代表仓库已经具备这些类或已经成功注册系统任务。具体后台能力仍需依据目标 SDK 官方文档选型,并在实际设备上完成注册、触发、重启、权限变化和关闭清理测试。

本次复核的核心来源如下,文件名用于让结论可以回到代码定位:

复制代码
ProfileView.ets          设置页按钮入口与用户提示
ReminderService.ets      权限请求、三天筛选、通知发布与取消
Anniversary.ets          纪念日字段及重复类型
module.json5             当前模块设备声明
form_config.json         卡片更新时间配置,不能当作通知调度证据

证据强度也要分级:代码阅读能证明调用关系和缺失边界;纯函数测试能证明时间算法;本地构建只能证明当前配置下可编译;只有系统注册回读、真实触发日志和设备通知结果,才能证明提醒计划实际生效。本轮没有运行新的工程构建,也没有执行真机触发,所以相关结果均保持"待验证"。

一、真实调用链:授权成功后立即发布

设置页中的 handleNotificationPermission() 是当前唯一入口:

复制代码
private async handleNotificationPermission(): Promise<void> {
  try {
    const granted = await ReminderService.requestPermission();
    if (granted) {
      const repo = AnniversaryRepository.getInstance();
      const items = await repo.getAll();
      await ReminderService.sendDailyReminder(items);
      promptAction.showToast({
        message: '通知提醒已开启',
        duration: 2000
      });
    } else {
      promptAction.showToast({
        message: '请在系统设置中允许通知权限',
        duration: 2000
      });
    }
  } catch (_e) {
    promptAction.showToast({
      message: '通知设置失败,请重试',
      duration: 2000
    });
  }
}

这条链路依次做了三件事:

  1. 调用系统界面请求通知授权。
  2. AnniversaryRepository 取得统一排序后的纪念日列表。
  3. 立即筛选近期事项并发布一条通知。

源码里没有保存"提醒已开启"配置,没有计算下次执行时间,也没有注册后台任务。因而当前 Toast 的"已开启"比真实行为更强:它容易让用户理解为以后每天都会自动提醒。工程上应把反馈改成"通知权限已开启"或"已发送测试提醒",直到调度注册也成功后再显示"每日提醒已开启"。

二、权限请求只回答一次交互结果

ReminderService.requestPermission() 封装了 Notification Kit 的授权请求:

复制代码
static async requestPermission(): Promise<boolean> {
  try {
    await notificationManager.requestEnableNotification();
    return true;
  } catch (e) {
    hilog.warn(
      0x0000,
      TAG,
      `requestPermission failed: ${JSON.stringify(e)}`
    );
    return false;
  }
}

返回 true 表示这次调用没有抛出异常,但业务层还应确认目标 SDK 下的权限查询语义:用户可能此前已授权、拒绝,或者系统设置发生变化。权限状态不能永久缓存在一个内存布尔值中,更不能因为曾经授权就跳过后续发布异常处理。

权限与计划状态应该分开:

状态 来源 含义
通知可用 Notification Kit 查询或请求结果 系统允许应用展示通知
用户启用每日提醒 本地 Preferences 用户希望创建提醒计划
计划已注册 调度层持久化状态 当前配置对应的任务已建立
最近发布结果 Notification Kit 调用结果 某次通知是否提交成功

用户关闭系统通知后,本地"每日提醒开关"可以保留,但页面应显示权限不可用并提供前往设置的清晰路径。反过来,系统允许通知也不代表用户同意应用每天注册提醒。

三、真实筛选算法:只选未来三天内的事项

当前 sendDailyReminder() 复用了 calcDaysRemaining(),筛选 0 <= days <= 3 的记录,再按目标时间升序:

复制代码
const upcoming = items
  .filter((item: Anniversary) => {
    const days = calcDaysRemaining(item.targetDate);
    return days >= 0 && days <= 3;
  })
  .sort((a: Anniversary, b: Anniversary) =>
    a.targetDate - b.targetDate
  );

if (upcoming.length === 0) return;

因此,已经过去的事项不会提醒,四天及以后也不会提醒。置顶状态不会影响提醒顺序,最早到来的事项才是主文案对象。这个决定是合理的:置顶属于列表展示偏好,不应该覆盖时间紧迫度。

calcDaysRemaining() 会把当前时间和目标时间都归一到本地零点:

复制代码
export function calcDaysRemaining(
  targetDate: number
): number {
  const now = new Date();
  const today = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate()
  ).getTime();
  const target = new Date(targetDate);
  const targetDay = new Date(
    target.getFullYear(),
    target.getMonth(),
    target.getDate()
  ).getTime();
  return Math.ceil(
    (targetDay - today) /
    (1000 * 60 * 60 * 24)
  );
}

在中国大陆本地时区,这能避免目标时间的小时和分钟影响"剩余几天"。若应用扩展到使用夏令时的地区,固定 24 小时除法仍需边界测试;更稳的日期算法可使用本地日期序号或逐日移动。

四、文案聚合:一条通知承载多个临近事项

源码只取最早事项生成主句,并在有更多事项时追加数量:

复制代码
const top = upcoming[0];
const days = calcDaysRemaining(top.targetDate);
let text: string;

if (days === 0) {
  text = `今天是「${top.title}」的日子!`;
} else if (days === 1) {
  text = `明天就是「${top.title}」啦!`;
} else {
  text = `「${top.title}」还有 ${days} 天`;
}

if (upcoming.length > 1) {
  text += `,还有 ${upcoming.length - 1} 项即将到来`;
}

聚合而不是逐条发布,有两个好处:减少通知打扰,也避免同一批事项生成多个系统通知。需要补上的边界是标题长度和隐私显示。纪念日标题可能包含人名或私人事件,在锁屏通知中是否完整展示应由产品隐私策略决定。

可以把内容生成提取为纯函数,便于测试:

复制代码
export interface ReminderContent {
  title: string;
  text: string;
}

export function buildReminderContent(
  upcoming: Anniversary[]
): ReminderContent | undefined {
  if (upcoming.length === 0) return undefined;

  const top = upcoming[0];
  const days = calcDaysRemaining(top.targetDate);
  const first = days === 0
    ? `今天是「${top.title}」的日子!`
    : days === 1
      ? `明天就是「${top.title}」啦!`
      : `「${top.title}」还有 ${days} 天`;
  const suffix = upcoming.length > 1
    ? `,还有 ${upcoming.length - 1} 项即将到来`
    : '';
  return {
    title: '时光清单提醒',
    text: first + suffix
  };
}

纯函数不依赖系统通知权限,可以用固定日期和构造数据验证所有文案分支。

五、publish 只是展示请求,不负责未来调度

真实发布方法创建基础文本通知,固定使用 ID 1001

复制代码
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);

这里没有触发时间字段,也没有后台调度对象,因此调用发生时就提交通知。固定 ID 有利于识别应用自己的提醒通知,但不同系统版本对同 ID 再发布的展示效果仍应真机验证,不能把它直接当作"计划去重机制"。

更清晰的职责拆分是:

复制代码
ReminderPlanner   计算什么时候应该运行
ReminderRegistry  判断计划是否已注册、负责替换或取消
ReminderContent   筛选事项并生成文案
ReminderPublisher 调用 Notification Kit 展示通知

这样即使调度能力因设备、权限或系统策略变化,内容算法和通知发布仍能单独测试。当前 ReminderService 可以逐步拆成这些窄接口,而不是一次大改。

六、下一触发时间:今天未过就今天,已过就明天

假设产品允许用户选择每天 09:00 提醒,计划层需要从当前本地时间计算下一个触发时间。不要直接写"当前时间加 24 小时",因为跨月、跨年和时区变化都应由日历字段处理。

复制代码
export interface DailyReminderTime {
  hour: number;
  minute: number;
}

export function nextDailyTrigger(
  now: Date,
  time: DailyReminderTime
): number {
  const candidate = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate(),
    time.hour,
    time.minute,
    0,
    0
  );

  if (candidate.getTime() <= now.getTime()) {
    candidate.setDate(candidate.getDate() + 1);
  }
  return candidate.getTime();
}

边界定义必须明确:

  1. 当前时间早于 09:00,返回今天 09:00。
  2. 当前时间正好等于 09:00,使用 <= 后返回明天,避免同一时刻重复注册。
  3. 当前时间晚于 09:00,返回明天 09:00。
  4. 月末和年末由 setDate() 自动滚动。

如果产品允许"开启后立即发送一条测试通知",它应该是独立命令,不要复用每日计划的触发计算。否则用户下午开启 09:00 提醒时,既收到一条即时通知,又可能因错误注册再次收到。

七、注册指纹:防重复的关键不是一个布尔值

"已经注册"不能只保存 true。当用户把时间从 09:00 改成 20:00、关闭某类提醒或应用升级了计划版本,旧计划必须被替换。

可以为计划生成稳定指纹:

复制代码
export interface ReminderPlan {
  hour: number;
  minute: number;
  windowDays: number;
  enabled: boolean;
  version: number;
}

export function planFingerprint(
  plan: ReminderPlan
): string {
  return [
    plan.version,
    plan.enabled ? 'on' : 'off',
    plan.hour,
    plan.minute,
    plan.windowDays
  ].join(':');
}

示例指纹 2:on:9:0:3 表示第二版计划、启用、每天 09:00、关注三天窗口。注册状态至少保存:

复制代码
export interface ReminderRegistration {
  fingerprint: string;
  schedulerTaskId: string;
  nextTriggerAt: number;
  registeredAt: number;
}

执行注册时先比较当前指纹:

  1. 没有旧状态:创建计划并保存注册信息。
  2. 指纹相同且任务仍有效:直接返回 already_registered
  3. 指纹不同:取消旧任务,创建新任务,再原子更新状态。
  4. 用户关闭:取消旧任务并清除注册状态。

这才是业务层面的去重。通知请求的 id、后台任务的任务 ID 和计划指纹是三个不同标识,不要互相替代。

八、幂等注册流程:先比较,再替换,最后落盘

注册器可以通过窄接口隔离具体平台调度 API:

复制代码
export interface ReminderScheduler {
  register(triggerAt: number): Promise<string>;
  cancel(taskId: string): Promise<void>;
  exists(taskId: string): Promise<boolean>;
}

export type RegisterResult =
  'created' | 'replaced' |
  'already_registered' | 'disabled';

核心流程如下:

复制代码
async function ensureReminderPlan(
  plan: ReminderPlan,
  old: ReminderRegistration | undefined,
  scheduler: ReminderScheduler,
  now: Date
): Promise<RegisterResult> {
  if (!plan.enabled) {
    if (old) await scheduler.cancel(old.schedulerTaskId);
    return 'disabled';
  }

  const fingerprint = planFingerprint(plan);
  if (old &&
      old.fingerprint === fingerprint &&
      await scheduler.exists(old.schedulerTaskId)) {
    return 'already_registered';
  }

  if (old) {
    await scheduler.cancel(old.schedulerTaskId);
  }

  const triggerAt = nextDailyTrigger(now, plan);
  const taskId = await scheduler.register(triggerAt);
  // 只有 register 成功后,才持久化新的 Registration。
  await saveRegistration({
    fingerprint,
    schedulerTaskId: taskId,
    nextTriggerAt: triggerAt,
    registeredAt: Date.now()
  });
  return old ? 'replaced' : 'created';
}

实际接入时,ReminderScheduler 必须使用目标 HarmonyOS SDK 官方支持的后台任务或提醒能力,并核对权限、触发精度、设备限制和 AppGallery 审核要求。普通应用不应通过常驻进程、无限定时器或隐藏保活绕过系统调度,这既不稳定,也可能触碰后台行为审核边界。

九、计划触发后还要再次计算内容

不要在注册 09:00 计划时就把当前通知正文永久保存。纪念日可能在触发前被新增、修改或删除。正确顺序是在计划真正被唤起时:

  1. 读取最新纪念日列表。
  2. 以触发时刻重新计算剩余天数。
  3. 筛选三天窗口并排序。
  4. 没有近期事项就不发布。
  5. 生成最新文案并调用 Notification Kit。
  6. 记录本次执行日期,避免同一自然日重复执行。

可以增加每日执行键:

复制代码
export function localDateKey(date: Date): string {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  return `${y}-${m}-${d}`;
}

export function executionKey(
  planFingerprint: string,
  date: Date
): string {
  return `${planFingerprint}@${localDateKey(date)}`;
}

注册去重解决"不要创建两条同样计划",执行去重解决"同一计划今天不要发布两次"。应用重启、调度重试或多个入口同时触发时,两层去重都需要。

十、取消边界:cancelAll 可能影响其他通知

真实源码提供:

复制代码
static async cancelAll(): Promise<void> {
  try {
    await notificationManager.cancelAll();
  } catch (e) {
    hilog.warn(
      0x0000,
      TAG,
      `cancelAll failed: ${JSON.stringify(e)}`
    );
  }
}

对只有一种通知的早期应用,cancelAll() 很直接。但如果以后加入习惯打卡提醒、备份完成通知或下载状态,它会一起取消。更稳的设计是保存各业务通知 ID,并按业务取消;关闭"纪念日提醒"时还要取消调度任务,而不仅是清除当前通知栏。

关闭流程应当是:

  1. 本地开关先进入"处理中",避免连续点击。
  2. 调度层取消已注册任务。
  3. 通知层按业务 ID 取消当前展示。
  4. 清除 ReminderRegistration
  5. 保存 enabled=false
  6. 页面显示关闭成功或明确失败。

如果取消调度失败,不要直接把本地状态改成关闭成功,否则后台任务可能继续执行,用户却看不到可关闭入口。

十一、异常传播:不要记录失败后仍提示成功

当前 publish() 捕获异常后只写 hilog,返回类型仍是 Promise<void>

复制代码
try {
  await notificationManager.publish(request);
  hilog.info(0x0000, TAG, 'Notification published');
} catch (e) {
  hilog.error(
    0x0000,
    TAG,
    `publish failed: ${JSON.stringify(e)}`
  );
}

上层 handleNotificationPermission() 因而无法知道发布失败,仍可能显示"通知提醒已开启"。可将结果改为判别联合:

复制代码
export type PublishResult =
  | { ok: true }
  | { ok: false; reason: string };

static async publish(
  title: string,
  text: string
): Promise<PublishResult> {
  try {
    await notificationManager.publish(
      createNotificationRequest(title, text)
    );
    return { ok: true };
  } catch (error) {
    hilog.error(0x0000, TAG, 'publish failed');
    return {
      ok: false,
      reason: 'notification_publish_failed'
    };
  }
}

日志里也应避免输出可能包含私密标题的完整对象。用户可见提示只说明操作失败和恢复方式,不暴露内部错误详情。

十二、持久化状态:配置、注册与执行记录分开

Preferences 适合保存少量提醒配置。建议不要把所有信息塞进一个 enabled 键,而是保存版本化结构:

复制代码
export interface ReminderState {
  schemaVersion: 1;
  plan: ReminderPlan;
  registration?: ReminderRegistration;
  lastExecutionKey?: string;
  updatedAt: number;
}

各字段职责清晰:

字段 何时更新 用途
plan 用户修改提醒配置 还原页面和计算指纹
registration 系统任务注册成功后 判断是否需要重新注册
lastExecutionKey 某日通知成功发布后 防止当日重复发布
updatedAt 任意配置变化 备份、迁移与冲突判断

写入顺序很重要。系统注册失败时不要提前保存新的 registration;通知发布失败时不要提前保存 lastExecutionKey,否则当天重试会被错误跳过。

十三、真机验证矩阵

提醒功能必须在真机上验证权限和系统行为,模拟器或代码阅读只能覆盖纯算法部分。

  1. 首次请求通知权限,允许后发送测试通知,标题和正文正确。
  2. 首次拒绝权限,页面不显示"提醒已开启",并提供恢复路径。
  3. 系统设置中关闭通知后返回应用,页面能识别不可用状态。
  4. 近期列表为空时不发布通知,也不把"无通知"当成异常。
  5. 今天、明天、两天、三天分别生成正确文案,四天后不进入列表。
  6. 多个事项同时临近时,只发布一条聚合通知。
  7. 同一计划连续调用 ensureReminderPlan(),第二次返回 already_registered
  8. 修改提醒时间后,旧任务被取消,新任务只保留一条。
  9. 关闭提醒后,调度任务、展示通知和注册状态都清理。
  10. 应用重启后读取注册状态,不重复创建相同任务。
  11. 系统时间跨过设定时刻,下一触发时间滚动到次日。
  12. 月末、年末和闰日的下一触发时间正确。
  13. 同一自然日调度重试两次,只成功发布一次。
  14. 修改或删除纪念日后,触发时使用最新内容。
  15. 不同设备版本上验证固定通知 ID 的实际替换或并存行为。

本地单元测试适合覆盖时间计算、指纹和文案;真机测试负责权限弹窗、通知展示、后台调度精度和系统设置变化。两者不能互相替代。

十四、常见故障与排查顺序

现象 常见原因 修复方向
点击开启后只提醒一次 只调用了 publish() 接入官方调度层并保存注册状态
每次进入设置都多一条计划 没有计划指纹 注册前比较指纹与任务有效性
修改时间后新旧时刻都触发 只新增未取消 指纹变化时先取消旧任务
Toast 成功但没有通知 publish() 吞掉异常 向上返回发布结果
当天收到两次相同提醒 只有注册去重 增加自然日执行键
关闭后仍继续提醒 只调用 cancelAll() 同时取消调度任务并清注册状态
下午开启 09:00 立即又注册今天 触发比较边界错误 候选时刻 <= now 时滚到明天
提醒内容还是旧标题 注册时固化正文 触发时重新读取仓库

排查时按"用户配置、系统权限、注册状态、触发时间、执行键、通知发布"六层逐步确认。看到通知没出现时,不要第一步就重复注册,否则原本的单一故障会变成重复任务问题。

十五、重复日期必须先换算下一次发生日

Anniversary 模型已经声明了不重复、每年重复和每月重复等类型,但当前 sendDailyReminder() 直接把记录中的 targetDate 交给 calcDaysRemaining()。这意味着静态源码只能证明原始日期与今天之间的差值计算,不能证明每年或每月重复事项会自动换算到下一次发生日。若目标日期已经过去,直接相减还可能把本应在下个周期提醒的事项排除在零到三天窗口之外。

建议把"下一次发生日"做成内容筛选之前的纯函数,并且让输入、输出和异常边界清晰。下面只是接口与控制流示意,不是当前源码:

复制代码
type RepeatMode = 'none' | 'yearly' | 'monthly';

interface OccurrenceInput {
  targetDate: Date;
  repeatMode: RepeatMode;
  now: Date;
}

function resolveNextOccurrence(input: OccurrenceInput): Date | undefined {
  // 建议实现:按本地自然日换算,再处理月末、闰日和已过时刻。
  // none 且日期已过时返回 undefined;重复事项返回不早于今天的下一次日期。
  return undefined;
}

每月重复必须定义"31 日遇到短月"的产品规则,是落在当月最后一天,还是跳过该月;每年重复必须定义 2 月 29 日在平年的处理方式。这里不存在天然唯一答案,代码不能替产品做决定。还要统一时区和自然日归一化方式,避免一部分逻辑用本地时间,另一部分用 UTC,最终在午夜附近产生前后一天的偏差。纯函数测试至少覆盖月底、年底、闰年、夏令时环境和用户手动修改系统时间后的重新计算。

十六、注册幂等与执行幂等是两把锁

计划指纹解决的是"相同配置不要重复注册",自然日执行键解决的是"同一计划当天不要重复发布"。两者保护的阶段不同,不能只保留其中一个。当前源码里没有这两种状态,因此后面的代码仍属于建议方案。

指纹输入应只包含会改变任务语义的规范化字段。对象序列化前先固定字段顺序与时间格式,避免同一配置因为属性顺序或默认值表达不同而得到两个指纹:

复制代码
interface FingerprintInput {
  enabled: boolean;
  hour: number;
  minute: number;
  windowDays: number;
  schemaVersion: number;
}

function normalizePlan(plan: ReminderPlan): FingerprintInput {
  return {
    enabled: plan.enabled,
    hour: plan.hour,
    minute: plan.minute,
    windowDays: plan.windowDays,
    schemaVersion: 1
  };
}

执行键则应关联计划和本地自然日。只有通知发布确认成功后才能保存该键;若在发布之前保存,进程中断或发布失败会让当天后续重试被错误跳过:

复制代码
function buildExecutionKey(fingerprint: string, now: Date): string {
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, '0');
  const day = String(now.getDate()).padStart(2, '0');
  return `${fingerprint}:${year}-${month}-${day}`;
}

如果系统可能并发唤起两个执行实例,仅仅"先读键、后写键"仍有竞态:两个实例都可能读到空值,然后各发布一次。最终实现需要根据所用持久化能力选择串行队列、互斥边界或原子状态转换,并在设备日志中验证并发路径。本文没有把某一种同步手段写成既成事实,因为当前工程还没有该执行器,也没有并发触发证据。

十七、注册与落盘顺序决定状态是否可信

提醒状态不是页面开关的镜像,而是系统任务结果的本地记录。建议注册流程先读取旧状态并验证旧任务,再决定复用、替换或新建。新任务注册成功后才能保存任务 ID、指纹和下一触发时间;保存失败时要留下可诊断结果,不能直接提示"已开启"。

可以让服务向页面返回判别联合,使成功、无变化和失败都具有明确含义:

复制代码
type EnsurePlanResult =
  | { ok: true; action: 'registered'; taskId: string }
  | { ok: true; action: 'already_registered'; taskId: string }
  | { ok: false; stage: 'permission' | 'cancel' | 'register' | 'persist' };

配置变化时,"先取消旧任务再注册新任务"和"先注册新任务再取消旧任务"各有风险。前者在新注册失败时会丢失原任务,后者在旧取消失败时可能短暂并存。工程应结合系统接口是否支持更新、任务数量限制和可查询性选择策略,并在返回值中记录失败阶段。无论选择哪种顺序,都不能在系统操作失败后把本地状态写成成功,也不能仅凭本地任务 ID 推断系统任务仍然有效。

关闭提醒同样是一段状态迁移:先阻止新执行,再取消已知系统任务,按产品边界处理已经展示的通知,最后清理或标记本地注册状态。当前 cancelAll() 只能证明它会请求取消应用通知,不能证明后台任务已经撤销,因为仓库中尚无后台任务注册与取消实现。若应用未来还有其他业务通知,直接取消全部通知还可能扩大影响,应优先按本功能拥有的通知 ID 和任务 ID 清理。

十八、触发时重读数据,避免注册时固化旧内容

计划任务注册时保存的是"何时唤起",不是未来通知的完整正文。用户可能在注册后修改标题、删除纪念日或调整重复方式;若把正文永久固化在任务参数里,真正触发时就会展示旧数据。建议触发处理器只接收最小计划标识,启动后重新读取仓库、换算下一次发生日、筛选窗口、生成聚合文案,再调用 Publisher。

触发处理器的建议边界可以写成下面这样:

复制代码
interface TriggerResult {
  status: 'published' | 'empty' | 'duplicate' | 'failed';
  executionKey?: string;
}

async function handleReminderTrigger(planId: string): Promise<TriggerResult> {
  // 建议流程:读取当前计划和当前事项,检查当日执行键,再尝试发布。
  // 只有发布成功后才提交新的 executionKey。
  return { status: 'failed' };
}

空列表不是系统错误。若当天没有零到三天内的事项,处理器应返回 empty,不发布空通知,也不把它包装成"提醒服务故障"。是否记录当天已检查,需要按产品重试策略决定:如果数据可能在当天稍后发生变化,过早写入执行键会阻止新事项得到提醒;如果系统只允许每天一次固定触发,则应在下一次用户编辑后主动重算计划。这个选择需要产品规则、调度能力和测试用例共同约束。

十九、验收证据要覆盖从计划到通知的完整链路

一个时间计算单元测试通过,只能证明给定输入下纯函数输出符合预期;一个本地构建通过,只能证明当前工具链接受代码;一次手动通知出现,只能证明权限与发布链路在当时可用。要宣称"每日提醒已经完成",还需保留系统注册结果、任务回读、跨日触发、进程退出后触发、设备重启后恢复、权限撤销、配置替换、关闭清理和同日去重等证据。

建议每条验收记录都包含设备与系统版本、应用版本、计划配置、注册任务标识、预计触发时间、实际触发时间、执行键、通知结果和清理结果。涉及用户标题时应脱敏,日志不保存私人纪念日内容。调度时间若受系统节能策略影响,应记录允许的误差窗口,不把"延迟触发"直接等同于"未触发",也不能在没有真实观测时声称精确到分钟。

本轮文章复核没有执行上述设备实验,因此这里只给出验收方法,不给出通过率、触发精度或兼容设备数量。实现完成后,应先用最小测试计划验证一台目标设备,再扩展到项目声明支持的设备范围;当前模块配置只声明 phone,不应把手机源码外推为平板、折叠屏或其他设备已经通过。

总结

时光清单ReminderService 已经把权限请求、近期事项筛选、聚合文案和 Notification Kit 发布收在一个可读边界内,但它当前完成的是即时通知链路,不是每日后台计划。工程上的下一步不是给方法改个名字,而是补上计划层和状态模型。

稳定提醒需要两次计算、两层去重:注册时计算下一触发时间并用计划指纹避免重复任务;真正触发时重新读取数据并用自然日执行键避免重复发布。权限、计划和展示结果分别记录,关闭时同时撤销调度和通知。这样才能让"已开启提醒"成为可验证的产品事实,而不是一次成功弹窗带来的错觉。

AI 辅助声明:本文由 AI 辅助整理,现状分析基于 D:\huawei\one8ReminderService.etsProfileView.etsAnniversary.ets 与模块配置的真实源码复核;计划层代码为针对当前边界的架构建议,具体系统调度接口需按项目目标 HarmonyOS SDK 的官方文档接入并真机验证。

相关推荐
李蚊子16 分钟前
从代理提醒到真实响铃:懒熊闹钟的鸿蒙开发实践
前端·harmonyos
GKxx19 分钟前
在 HarmonyOS 上从源码构建 GCC 16(gcc/g++ + libstdc++ + libsanitizer):完整记录
c++·华为·harmonyos·鸿蒙·gcc
贾伟康21 分钟前
【时光清单|04】HarmonyOS ArkTS 通知服务实战:管理权限、渠道和点击跳转
harmonyos·arkts·系统通知·notificationkit·wantagent
独守一片天1 小时前
HarmonyOS|鸿蒙新生态服务卡片设计与状态同步
华为·harmonyos
黑臂麒麟1 小时前
HarmonyOS 7 碰一碰实战:手机轻触电脑,精准插入图片到指定位置
华为·智能手机·arkts·鸿蒙
黑臂麒麟1 小时前
Harmony鸿蒙实战应用5:随手账本——搜索筛选与分类统计
华为·app·arkts·鸿蒙
2501_919749031 小时前
华为鸿蒙免费自拍对比APP—小羊自拍
华为·harmonyos·鸿蒙
贾伟康1 小时前
【时光清单|02】HarmonyOS ArkTS 习惯打卡实战:处理连续天数、补签和日期切换
harmonyos·arkts·数据持久化·日期处理·习惯打卡
大锅盖14 小时前
围绕夜航深蓝与霓虹青的跨境画卷构建 HarmonyOS ArkUI 出境旅行向导平台
华为·harmonyos