本文代码和配图用于说明工程设计,示例时间、订单号、日志及屏幕都是构造数据与技术示意;不表示已在 DevEco Studio 编译或真机跑通,也不表示接通系统闪控窗。所谓“候选提醒”,专指应用自行管理的展示列表。

一、先分清要处理的究竟是哪类消息

设想一个需要把消息转为轻量提醒的协作应用。后台连续推送同一条邀请两次,先到达的是旧版本,后到达的是新版本;另一条评论提醒仍有效,还有一条临时通知在应用打开之前已超过有效期。若直接把四条输入映射成四个可见卡片,用户会看到重复邀请,还会碰到点击后已经失效的入口。这不只是列表不好看,更会把业务层的“待处理”误当成系统已经展示的闪控窗。本例刻意把系统入口留空,只做展示之前的一道 TTL 与去重门禁。

这里的 TTL 是每条候选提醒的业务有效期限,不是设备通知权限的有效期限;“过期退场”也不等于向系统发送一个关闭闪控窗的指令。最容易混淆的是事件发生时间、设备收到事件的时间、过期绝对时间三个概念。真正用于判定是否活跃的是到期时间与可信当前时间的比较,收到时间只用于同版本事件的排序;如果服务端只给一个“还剩30秒”的相对值,应该在接收时先按约定换算,不能每次刷新又把30秒重新开始算。

二、输入数据与预期状态约定

PulseShelf 的固定演算时间是 2026-10-20 10:24,任务编号 FW-207。构造输入包括 O-702 的版本2与版本3、O-703 的版本1、O-704 的版本1。O-702 的版本3应该替代版本2;O-703 仍在有效期内;O-704 的过期时间比演算时刻早一分钟,应从候选集合中剔除。最终界面只放两条业务卡片:O-702 · v3 与 O-703 · v1。统计口径必须先说清楚:ACTIVE 2 是存活的唯一业务 ID 数,MERGED 1 是被较新版本替换的重复输入数,EXPIRED 1 是被 TTL 丢弃的条数,三者不能直接拿来相加当作收到的业务消息总量。

列表排序也需要稳定:本文先按到期时间升序,再按业务 ID 升序。这样即使不同源先后送来的两条有效提醒调换顺序,页面也不会无缘无故跳动。每条事件都显式带 version 和 receivedAt,后者只能解决版本相同而需要确定择优输入的情况,不应取代业务服务端的版本。尤其不能仅凭本地到达得晚就覆盖一条高版本状态,否则重试和网络乱序会把界面回滚。

三、第一段代码:纯函数只决定候选结果

下面的模型与 mergeActive 是应用自定义的 ArkTS 类型和普通集合运算,不是任何系统“闪控窗” API。把它写成无副作用函数的好处是:同样的输入与 now 就有同样的输出;即使页面关闭,也可以在 Node.js 层使用等价逻辑生成预期结果,或单独编写单元测试,而不用先准备一部支持特定系统功能的手机。

export interface NoticeEvent {
  id: string;
  text: string;
  version: number;
  receivedAt: number;
  expiresAt: number;
}

export function mergeActive(events: NoticeEvent[], now: number): NoticeEvent[] {
  const latest: Map<string, NoticeEvent> = new Map();
  for (const event of events) {
    if (!event.id || event.expiresAt <= now) { continue; }
    const old = latest.get(event.id);
    if (!old || event.version > old.version ||
        (event.version === old.version && event.receivedAt > old.receivedAt)) {
      latest.set(event.id, event);
    }
  }
  return Array.from(latest.values()).sort((a, b) =>
    a.expiresAt - b.expiresAt || a.id.localeCompare(b.id));
}

关键不是 Map 本身有多神奇,而是键只用业务 ID,值保留最新有效版本。一个业务事件可能包含状态、链接、展示文案,但那些字段不应一起塞进键里。若把完整 JSON 当键,字段变化会被误识别为另一条消息;若把数组索引当键,删除过期项之后组件复用可能对应错内容。过期检查放在入表之前,还有一个必要副作用:已经过期的高版本不会占住一个 ID,阻挡随后到达、但属于另一段有效期的新业务事件。实际生产中若服务端允许相同 ID 重开新生命周期,还要增加批次号作为复合主键,本文假设同一业务 ID 在本次窗口里不会复用。

四、第二段代码:固定样例核对合并路径

为了避免示意图上的时间和运行机器的实时时钟互相矛盾,先使用一组固定时间戳演算,代码里的日期全部明确写出东八区偏移。O-702 故意发送两条,版本3的文案与版本2不同;O-704 的到期时刻是10:23,早于 NOW。下列日志是期望的人工推演输出,而非真实 HiLog 截取。

import { NoticeEvent, mergeActive } from '../model/NoticeReducer';
const now: number = Date.parse('2026-10-20T10:24:00+08:00');
export const events: NoticeEvent[] = [
  { id: 'O-702', text: '旧版协作邀请', version: 2, receivedAt: now - 4000,
     expiresAt: Date.parse('2026-10-20T10:25:00+08:00') },
  { id: 'O-702', text: '收到新的协作邀请', version: 3, receivedAt: now - 2000,
     expiresAt: Date.parse('2026-10-20T10:25:00+08:00') },
  { id: 'O-703', text: '有新的评论消息', version: 1, receivedAt: now - 1000,
     expiresAt: Date.parse('2026-10-20T10:26:00+08:00') },
  { id: 'O-704', text: '临时通知', version: 1, receivedAt: now - 5000,
     expiresAt: Date.parse('2026-10-20T10:23:00+08:00') }
];
const active: NoticeEvent[] = mergeActive(events, now);
console.info(`[FW-207] merge O-702 version=3`);
console.info(`[FW-207] expire O-704`);
console.info(`[FW-207] render active=${active.length}`);

这里把三个日志写为期望值,是为了让示意界面能够对照:合并 O-702 的版本3、剔除 O-704、最后剩两项。正式代码不应该无条件打印“合并成功”,而是让 mergeActive 返回结果与统计详情,按实际操作记录日志。还要为 O-702 的相同版本却不同 receivedAt、到期时间恰好等于 NOW、空 ID、负版本、时间戳溢出等输入构造反例。对于安全性更高的场景,时间应由服务端签发有效期并用服务端时间偏差修正;纯客户端系统时间不能充当支付或授权的最终裁决依据。

五、第三段代码:ArkUI只渲染结果,不负责决定是否有效

界面层只认识一个新的 @State 数组,并在固定预览里把演算得到的数组交给 ForEach。在真实应用中,可以在消息事件到达、页面获得焦点或计时触发时重新计算;但要给定时器配对清理,并在应用从后台恢复时主动重新比较当前时间,不能假设后台每秒都会稳定执行。本文选择按钮触发“重新核对”,简化生命周期;这也是为了避免暗示示例已经具备系统后台存活能力。

import { NoticeEvent, mergeActive } from '../model/NoticeReducer';
import { events } from '../model/MockEvents';
@Entry
@Component
struct PulseShelfPage {
  @State cards: NoticeEvent[] = [];
  private fixedNow: number = Date.parse('2026-10-20T10:24:00+08:00');
  aboutToAppear(): void { this.cards = mergeActive(events, this.fixedNow); }
  build() {
    Column({ space: 14 }) {
      Text('候选提醒 · FW-207').fontSize(24).fontWeight(FontWeight.Bold)
      Text(`ACTIVE ${this.cards.length} / MERGED 1 / EXPIRED 1`)
      List() {
        ForEach(this.cards, (item: NoticeEvent) => {
          ListItem() {
            Column({ space: 6 }) {
              Text(`${item.id} · v${item.version}`).fontWeight(FontWeight.Bold)
              Text(item.text)
            }.padding(16).width('100%')
          }
        }, (item: NoticeEvent) => item.id)
      }.width('100%')
      Button('重新核对候选').onClick(() => {
        this.cards = mergeActive(events, this.fixedNow);
      })
    }.padding(20)
  }
}

ForEach 的第三个参数明确给出稳定键 ID,使淘汰 O-704 后仍能识别 O-702 与 O-703。这只解决组件的身份识别;如果需要精细更新内部对象属性,还要按所用状态管理版本选择合适的可观察模型。本文采用替换整个 @State 数组的方式,避免原地改 Map 后忘记通知 UI。需注意固定时间只是测试向量,不能作为生产定时器的实现;真实运行时按钮处理函数应传 Date.now() 或经校正的时钟,而不是一直传同一个 fixedNow。

六、调试路径:让每一种异常都有可核对的现场

建议先跑纯逻辑用例,再接 UI:第一组输入只有 O-703,预期 ACTIVE 1;第二组加入 O-702 的两个版本,预期 ACTIVE 2、MERGED 1;第三组加入 O-704,预期 EXPIRED 1;最后模拟 10:26:01,检查两张卡片都已经离场。日志分别记录业务 ID、version、过期原因与最终可见数量,不记录用户私密文案。DevEco Studio 里查看状态变更时,应核对三件事:同一个 ID 是否只有一行、版本是否为3、计时到了是否需要一次重新计算。把“没弹出系统闪控窗”当作本例故障是不合适的,因为这里根本没有调用相应系统接口。

手机示意图刻意只保留 O-702 与 O-703 两项,顶部完整绘制时间、5G、Wi‑Fi、信号和电量,底部按钮为“重新核对候选”。图内 ACTIVE 2 / MERGED 1 / EXPIRED 1 与构造样例一致,配图没有伪装成实机测量截图。三处图片分别说明封面主题、逻辑与 HiLog 的对应点、最终可见卡片,不承担接口兼容性证明。

七、边界与技术结论

本例不包含系统闪控窗接入、不包含应用退到后台后持续保活,也没有接入真实推送链路。系统能力是否可用、权限如何声明、Beta 能力支持哪些机型,应以目标 SDK 的正式文档及实际设备测试为准。不同设备的窗口形态、系统调度时机及可用 UI 容器不由这个 Map 决定。收到消息时还应处理重复投递幂等、时间漂移、业务 ID 越界、缓存上限和撤回状态传播;这几个环节缺失,会让“只显示两个卡片”的示例看起来正确却无法长期运转。

落地前最值得保留的是责任分层:入口层收集候选、纯函数裁决有效与最新版本、ArkUI 渲染一个不可重复的列表,而真正的系统能力通过单独适配器对接。先把候选数据处理清楚,再去讨论系统展示方式,通常比在页面里混写请求、计时器与关闭窗口逻辑更容易定位问题。以上是可复现的构造案例与工程设计,不是对华为系统接口或性能的实测结论。

官方资料与核对说明

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐