焖饭与蒸蛋在同一秒结束,手机页面刚恢复显示,两个任务同时变成“已完成”。这时用户最需要知道的是:哪两件事现在要处理、哪一件已经确认,以及继续运行的烤蔬菜还有多久。若页面仍按照创建顺序从上到下展示,真正要处理的项目可能被挤到屏幕下方;若每秒都按剩余时间重新排序,三张卡片又会不断跳位置,反而让人难以点击。通知栏虽然能提供额外提醒,也无法代替应用本身清晰、稳定的任务排序。

KitchenTempo 的前四节已经把任务模型稳定为 KitchenTask 与 TimerPhase,采用 deadlineMs 推算运行时长,用 Preferences 保存版本化任务快照,并通过 ReminderRecord 区分到期、已发布通知和已确认。第五篇保持这条主线不变,处理同一时间轴上多任务竞争用户注意力的工程问题:到点事实批量结算,待确认状态优先展示;排序只响应语义变化,秒数更新不改变列表身份。

KitchenTempo 多计时器状态调度技术封面

一、为什么“剩余秒数最少的排前面”并不可靠

假设页面当前有三项任务:焖饭与蒸蛋已经结束、尚未确认;烤蔬菜正在运行,剩余约 4 分 20 秒。若完全依据 remainingMs 排序,两个完成项都为零,但已确认与待确认会被混在一起。若把每次通知发布成功视为任务已完成处理,又会把真正仍需用户看一眼的热锅任务排到后面。排序应当体现用户下一步动作的紧迫性,而不是把技术字段直接作为产品规则。

本篇选择四层优先级:FINISHED 且 acknowledged=false 的待确认任务置顶;RUNNING 的任务随后展示;PAUSED 放在运行项之后;IDLE 与已确认的完成项位于末尾。两项同级时按配置里的稳定任务顺序与业务 ID 决定,不依赖回调先后。这样页面从后台恢复时,即便两个截止时间只差几毫秒,也能稳定呈现相同顺序。

需要强调:UI 排序不是通知发布策略。前篇的 offerReminder() 仍负责系统通知授权判断与完成事件去重,不能因为排序到了第一位就重新调用 publish()。拒绝授权时页面依然展示“待确认”,并且不会弹出一个无限循环的授权请求。多个任务同时完成,也应先更新内存中的状态事实,再根据持久化策略分别尝试通知,不把通知发送失败当成计时失败。

DevEco Studio:稳定排序与待确认状态的页面开发示意

排序规则还需要对无障碍用户保持一致。视觉上置顶的任务,焦点遍历时也应在前;“待确认”应有文字而不是只有闪烁边框或红点。计时卡片的“已完成”状态与操作按钮之间要有清晰的对应关系,避免用户误以为点一下卡片本体就代表已经确认。任务优先级属于产品语义,不宜写死在一堆颜色判断里;将其独立成函数后,页面设计调整为大字体或横屏布局时还能复用相同规则。

同时到点与相差一秒到点是两个不同的测试边界。若第一项刚好在 setInterval 回调时到期、第二项在下一个回调之前到期,两次 UI 更新也应得到相同的最终排序。不能把“一秒内结束的任务都视为同一轮”当作事实,因为回调触发时刻并没有精确保证。真正可依赖的是每项保存下来的 deadlineMs,以及一个统一的当前时间采样值。

二、用纯函数产生固定的优先级,不在 build 中写业务状态

排序函数输入任务数组和现有的 ReminderRecord[],输出全新的排列数组;它不修改原始任务对象,不更新磁盘,也不制造提醒事件。由于上一节已经定义 completionKey(task),相同一轮计时的 key 可以关联用户确认状态。这里采用局部辅助函数计算优先级,没有引入任何新的 HarmonyOS 系统 API。

// entry/src/main/ets/logic/TimerPriority.ets
import { KitchenTask, TimerPhase } from '../model/KitchenTask';
import { ReminderRecord, completionKey } from '../model/ReminderRecord';

function wasAcknowledged(task: KitchenTask,
  records: ReminderRecord[]): boolean {
  const key: string = completionKey(task);
  return records.some((r: ReminderRecord) =>
    r.key === key && r.acknowledged);
}

function priority(task: KitchenTask,
  records: ReminderRecord[]): number {
  if (task.phase === TimerPhase.FINISHED &&
      !wasAcknowledged(task, records)) return 0;
  if (task.phase === TimerPhase.RUNNING) return 1;
  if (task.phase === TimerPhase.PAUSED) return 2;
  return 3;
}

export function orderTasks(source: KitchenTask[],
  records: ReminderRecord[]): KitchenTask[] {
  const initialOrder: Map<string, number> = new Map<string, number>();
  source.forEach((task: KitchenTask, index: number) =>
    initialOrder.set(task.id, index));
  return source.slice().sort((a: KitchenTask, b: KitchenTask): number => {
    const delta: number = priority(a, records) - priority(b, records);
    if (delta !== 0) return delta;
    const left: number = initialOrder.get(a.id) ?? 0;
    const right: number = initialOrder.get(b.id) ?? 0;
    return left - right;
  });
}

初始顺序由最早加载的任务数组确定。如果希望在多次恢复后保持稳定,就应在快照中保留创建顺序或显式 sortIndex;不要仅依赖查询返回顺序。示例只有三项预置任务,恢复时依然使用同一组业务 ID,因而可通过稳定源顺序得到一致结果。生产项目还应防止重复 ID,否则 Map 的索引会覆盖,ForEach 也无法正确复用行组件。

有一个很容易犯的错误:在 build() 里直接调用一个会修改 this.tasks 的 sort(),使渲染过程同时承担业务变更。状态一更新就进入重排,重排又触发观察,代码很难推理。上面的函数只返回一份新数组;页面在完成事件、暂停继续、确认完成这些语义状态变化之后再调用,定时刷新只更新时钟展示所需的值。

优先级与稳定业务 ID 的排序规则

三、只让一只可见时钟驱动显示,不创建三组后台循环

前几篇已经在页面 onPageShow() 与 onPageHide() 中管理刷新循环。本节将这个约束继续收紧:页面最多有一个 setInterval;对每个 KitchenTask 保留真实的 deadlineMs、remainingMs 与 phase;UI 每秒从 Date.now() 计算显示值。任务不需要各自创建计时器,也不应把 setInterval 的调用次数当作经过的秒数。

// entry/src/main/ets/components/TimerCard.ets
import { KitchenTask, TimerPhase } from '../model/KitchenTask';

@Component
export struct TimerCard {
  @ObjectLink task: KitchenTask;
  @Prop nowMs: number;

  private textOfRemaining(): string {
    const ms: number = this.task.phase === TimerPhase.RUNNING ?
      Math.max(0, this.task.deadlineMs - this.nowMs) :
      this.task.remainingMs;
    const secs: number = Math.ceil(ms / 1000);
    const mm: string = Math.floor(secs / 60).toString().padStart(2, '0');
    const ss: string = (secs % 60).toString().padStart(2, '0');
    return `${mm}:${ss}`;
  }

  build() {
    Row() {
      Text(this.task.name).fontSize(19).fontWeight(FontWeight.Medium)
      Blank()
      Text(this.task.phase === TimerPhase.FINISHED ?
        '已完成' : this.textOfRemaining())
        .fontSize(24).fontColor('#123A52')
    }.padding(18).width('100%')
      .backgroundColor('#FFFFFF').borderRadius(18)
  }
}

@ObjectLink 沿用第一篇的可观察类对象,@Prop nowMs 负责从页面接收当前显示时间。TimerCard 没有保存第二份任务真相。图中页面顶部有“2 项待确认”的汇总,卡片顺序为焖饭、蒸蛋、烤蔬菜;焖饭与蒸蛋显示“已完成 · 待确认”,烤蔬菜显示 04:20。这些是同一组示例输入在某个时刻的设计效果,不应当在正式项目里把 04:20 写死成倒计时常量。

页面维护两个关注点:orderedTasks 负责身份与顺序,nowMs 负责画面刷新。状态发生改变时先完成业务结算和快照确认,再重新计算顺序。刷新间隔到达时,如果没有新的任务完成,就只更新 nowMs,不做列表重排。如果刚好跨过截止时间,则批量识别所有到期任务、一次性更新状态并持久化,然后才触发排序与提醒处理。

// KitchenTimerPage.ets:与已有任务状态、恢复函数配合
@State private nowMs: number = Date.now();
@State private orderedTasks: KitchenTask[] = [];
private tickerId: number = -1;

private refreshOrder(): void {
  this.orderedTasks = orderTasks(this.tasks, this.reminders);
}

private startVisibleClock(): void {
  if (this.tickerId !== -1) return;
  this.nowMs = Date.now();
  this.tickerId = setInterval(() => {
    this.nowMs = Date.now();
    const hasDue: boolean = this.tasks.some((task: KitchenTask) =>
      task.phase === TimerPhase.RUNNING &&
      task.deadlineMs <= this.nowMs);
    if (hasDue) this.handleDueTasks(this.nowMs);
  }, 1000);
}

private stopVisibleClock(): void {
  if (this.tickerId === -1) return;
  clearInterval(this.tickerId);
  this.tickerId = -1;
}

上面 handleDueTasks() 是把第二篇 settleFinished()、第三篇快照保存与第四篇 offerReminder() 串起来的页面协调方法,而不是新的系统接口。执行顺序是先对所有运行且已过期的任务结算到 FINISHED,一次形成新快照,确认写入后更新 orderedTasks;然后针对每一个尚未确认、尚未发布且不在 inFlight 集合的完成键尝试提醒。实际页面应在 onPageShow() 中完成恢复后再调用 startVisibleClock(),在 onPageHide() 与 aboutToDisappear() 清理循环,防止重复订阅。

如果应用一共有几十个计时器,刷新频率与列表节点数还需要结合 DevEco Profiler 做实际测量。不能凭空声称拆出 TimerCard 之后帧率一定提高:子组件仍然会读取传入的 nowMs,本来就需要更新显示值。优化的确定性收益在于减少不必要的状态变化和对象重排,而不是保证任何机型都达到某个毫秒指标。可先观察刷新时是否生成新的业务数组、是否误触发存储、是否无故新增通知事件,再根据真实测量决定需要将更新时间粒度改为两秒或只刷新可见行。

四、竞争发生在异步完成处理,而不在一秒的数字变化

即使只有三个任务,同时到点仍可能撞上异步写盘。一种不正确的写法是循环每项任务分别把它设成 FINISHED 并立即调用 store.enqueue();后续循环可能拿到前一项尚未落盘的旧快照,造成状态覆盖。更稳妥的做法是:从当前任务列表筛选出所有到期项,生成一份合并后的候选快照,通过已经存在的串行保存入口写入一次,返回成功后再开展通知副作用。

批量结算的页面协调函数可以写成下面这样。它没有创造一个新的计时系统,而是在现有 TimerStore(已按 B-04 扩展为接受 ReminderSnapshotV3)中复用串行 enqueue()。settling 防止连续刷新重入;成功落盘之后才改可观察对象,再调用前篇实现的 offerReminder()。如果写入失败,内存保持原有任务状态,下一次可在读取并核对存储后重新尝试,不把未知结果当成已确认。

// KitchenTimerPage.ets:与 B-04 的提醒记录、存储共用
private settling: boolean = false;

private async handleDueTasks(now: number): Promise<void> {
  if (this.settling || !this.ready) return;
  const due: KitchenTask[] = this.tasks.filter((t: KitchenTask) =>
    t.phase === TimerPhase.RUNNING && t.deadlineMs <= now);
  if (due.length === 0) return;
  this.settling = true;
  try {
    const dueIds: Set<string> = new Set<string>(due.map((t: KitchenTask) => t.id));
    const nextRows: TimerRow[] = this.tasks.map((t: KitchenTask): TimerRow => ({
      id: t.id, name: t.name, durationMs: t.durationMs,
      remainingMs: dueIds.has(t.id) ? 0 : t.remainingMs,
      deadlineMs: t.deadlineMs,
      phase: dueIds.has(t.id) ? TimerPhase.FINISHED : t.phase
    }));
    const next: ReminderSnapshotV3 = {
      version: 3, savedAtMs: now, tasks: nextRows,
      reminders: this.reminders.slice()
    };
    await this.store.enqueue(next);
    due.forEach((t: KitchenTask) => {
      t.phase = TimerPhase.FINISHED;
      t.remainingMs = 0;
    });
    this.refreshOrder();
    for (const task of due) {
      const id: number = task.id === 'rice' ? 2001 :
        (task.id === 'egg' ? 2002 : 2003);
      await this.offerReminder(task, id);
    }
  } catch (_e) {
    this.notice = '到期状态尚未确认保存,请核对后重试';
  } finally {
    this.settling = false;
  }
}

示例固定了三个任务的通知 ID;当应用支持动态添加任务时,ID 映射应由持久化的任务记录分配,不能让所有新任务都落到 2003。同时,示例只保证这条页面协调链使用单一入口,不能据此宣称所有外部写盘场景都有跨进程事务保障。

此处不能把“系统通知 Promise 已返回”误解释为“用户必定看到通知”;授权关闭、系统展示策略与用户未读都不等于任务时钟失败。每个完成事件保留 delivered 和 acknowledged,界面确认按钮只更新当前事件对应的记录。用户确认焖饭后,焖饭卡片可以移到底部,但蒸蛋仍保持待确认优先。卡片使用 task.id 作为 ForEach Key,避免先后顺序变化导致错误复用局部组件状态。

同时到点结算、快照写入与通知副作用的时序

验证不要只看“秒数正常走”。第一组:让焖饭和蒸蛋使用相同截止时间,切后台后等待到期再回前台,确认两条任务都成为完成态且只有一份合并后的业务快照。第二组:在界面前台连续暂停与继续烤蔬菜,确认其卡片不会因为每次刷新在列表中跳动。第三组:关闭通知授权,确认两个已完成任务仍然在页面置顶且可逐条确认。第四组:多次触发 onPageShow(),确认页面最多注册一个刷新计时器,恢复时不出现多个并行通知发送入口。

测试还应覆盖调系统时间和杀进程这两条边界。当前 Date.now() 基于系统墙上时钟,设备改时间会影响截止值推算;普通页面刷新器也不是操作系统级精准唤醒能力。正式用于食品安全关键控制时,不能以这个示例替代经过验证的定时硬件或系统级可靠告警。对于发布版,下一篇会集中完成异常场景回归、资源释放和任务状态的一致性收尾。

参考华为开发者文档:自定义组件生命周期、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-observed-and-objectlink/@Observed 与 @ObjectLink、NotificationManager、ForEach 渲染控制。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐