把下载、同步或者计时任务塞进闪控窗,最容易写出的方案是:业务每来一次进度,就往 LocalStorage 写一次。页面能动,百分比也没错,看上去已经够用了。真正的问题通常出现在事件变密之后:底层一秒回调几十次,进度只有个位数变化,剩余时间却来回抖;主页面、闪控窗页面和停止流程同时读写状态,最后一帧甚至可能在 STOPPED 之后又冒出来。

本文使用一个可复查的演示场景 FloatPulse / TransferFloatPage。任务号固定为 SYNC-FV-3012,演示时间为 20:18,总量 50.0 MB,进度从 41% 推进到 68%。64 次源回调最终只形成 8 次窗口提交,其中 54 次被合并、2 次因内容不变而丢弃。所有数字都是为了说明状态合同而设定的演示数据,不代表真实设备性能。

一、页面卡顿不是唯一问题,状态倒退才最难查

高频写入首先会带来多余刷新,但“多刷几次”还不是最危险的后果。源事件并不一定严格按业务时间到达:网络层、解码层和 UI 线程可能各有队列。如果把每个回调都当成最终事实,67% 之后收到一个晚到的 66%,窗口就会短暂倒退。用户看到的是一闪而过,日志里却可能只有两条都合法的记录。

第二个问题是字段不同步。百分比、已完成字节、速率和剩余时间常常由不同公式得到。若分四次写入 LocalStorage,页面可能在中间帧读到“68% + 旧速率 + 新剩余时间”。单个字段都没错,组合起来却不是同一时刻的快照。

第三个问题发生在停止阶段。FloatViewController.stop() 返回的 Promise 表示调用过程被受理,不等于窗口已完成停止;官方 API 明确要求以 onStateChange 收到 STOPPED 作为停止完成依据。同理,启动也应把“请求已发出”和“系统状态已确认”分开。若只看 Promise,在 STOPPING 期间继续放行进度提交,最后写入的那一帧就可能越过生命周期边界。

因此本文不把问题定义成“节流”。节流只回答多久执行一次,工程上还要回答:哪些字段必须原子出现,旧样本能否覆盖新样本,停止时谁负责封口,最后一帧是否允许强制提交,监听何时成对释放。

二、先冻结一份窗口快照,而不是散着写字段

FloatPulse 把闪控窗可见数据收拢为 TransferSnapshot。revision 代表业务样本顺序,lifecycleEpoch 代表当前闪控窗生命周期。前者阻止进度倒退,后者阻止上一次窗口实例的迟到提交。

这段代码解决什么问题:把页面所需字段变成一个不可拆分的业务快照,并把可提交条件写成纯函数。

export type TransferPhase = 'RUNNING' | 'PAUSED' | 'DONE' | 'FAILED';

export interface TransferSnapshot {
  taskId: string;
  revision: number;
  lifecycleEpoch: number;
  progress: number;
  doneBytes: number;
  totalBytes: number;
  speedKBps: number;
  etaSeconds: number;
  phase: TransferPhase;
}

export function canReplace(
  current: TransferSnapshot | undefined,
  incoming: TransferSnapshot,
  activeEpoch: number
): boolean {
  if (incoming.lifecycleEpoch !== activeEpoch) return false;
  if (incoming.taskId !== 'SYNC-FV-3012') return false;
  if (incoming.progress < 0 || incoming.progress > 100) return false;
  if (incoming.doneBytes > incoming.totalBytes) return false;
  return current === undefined || incoming.revision > current.revision;
}

这里没有比较回调抵达时间。抵达时间只是调度结果,不是业务顺序;真正能判断新旧的是源侧单调递增的 revision。进度也不能作为唯一顺序,因为暂停、失败重试或最后完成时可能出现进度相同但状态不同。revision 递增而 progress 不变是允许的,反过来则不能。

lifecycleEpoch 在每次新建控制器或重新加载窗口页面时递增。假设旧窗口为 30,新窗口为 31,旧队列即使晚到,也无法通过 activeEpoch === 31 的检查。实际项目不要把 epoch 存成页面局部变量后在多个地方各自加一,应由 Ability 级协调器单点维护。

三、250 毫秒窗口只保留最新事实

本例选择 250 ms 合并窗口,不是因为这个数值具有系统含义,而是因为进度类信息不需要逐样本可见。业务仍可完整记录关键事件,闪控窗只消费最新可展示快照。对于比分、告警或交易状态,不能直接照搬这一策略:不可覆盖的事件必须走独立通道。

这段代码解决什么问题:在固定窗口内合并可覆盖的进度样本,同时拒绝旧 revision 和跨生命周期样本。

export class FloatSnapshotCoalescer {
  private pending?: TransferSnapshot;
  private committed?: TransferSnapshot;
  private timerId: number = -1;
  private sealed: boolean = false;

  constructor(
    private readonly activeEpoch: () => number,
    private readonly commit: (value: TransferSnapshot) => void
  ) {}

  enqueue(value: TransferSnapshot): void {
    if (this.sealed || !canReplace(this.pending ?? this.committed,
      value, this.activeEpoch())) return;
    this.pending = value;
    if (this.timerId !== -1) return;
    this.timerId = setTimeout(() => this.flushLatest(), 250);
  }

  flushLatest(force: boolean = false): void {
    if (this.timerId !== -1) clearTimeout(this.timerId);
    this.timerId = -1;
    const next = this.pending;
    this.pending = undefined;
    if (!next || this.sealed) return;
    if (!force && this.committed && sameView(this.committed, next)) return;
    if (!canReplace(this.committed, next, this.activeEpoch())) return;
    this.commit(next);
    this.committed = next;
  }

  seal(commitFinal: boolean): void {
    if (commitFinal) this.flushLatest(true);
    this.sealed = true;
    if (this.timerId !== -1) clearTimeout(this.timerId);
    this.timerId = -1;
    this.pending = undefined;
  }
}

sameView 只比较窗口真正展示的字段。例如速度从 812.1 KB/s 变成 812.4 KB/s,界面都显示 812 KB/s,就没有必要提交。这个判断应基于格式化后的视图语义,而不是简单 JSON.stringify 整个对象,否则隐藏字段变化仍会触发刷新。

seal() 的顺序有意把“需要的最后一帧”放在封口之前。完成任务时可强制提交 DONE / 100%,用户主动停止窗口时则不一定要补进度帧。两者不能共用一个无参数的 dispose(),因为它们对最后一帧的语义不同。

下图是根据本文字段制作的 DevEco Studio 风格演示配图,不是实机 IDE 截图。图中核心位置只有三处标注:revision 门禁、250 ms 合并窗口和 STOPPED 后的封口日志。

四、LocalStorage 是状态通道,不是重新加载页面的按钮

FloatViewController.setUIContext(path, storage) 可把 LocalStorage 传给闪控窗页面。官方文档还提醒,重复调用 setUIContext 会先销毁旧 UIContent 再加载新内容。因此更新进度时不应反复调用它;正确做法是启动前绑定一次页面和存储,之后只更新同一个存储实例中的快照。

这段代码解决什么问题:建立控制器、LocalStorage 与页面的单一归属,并让状态监听在关闭时成对解绑。

import { floatView } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

export class FloatPulseOwner {
  private controller?: floatView.FloatViewController;
  private storage: LocalStorage = new LocalStorage();
  private lifecycleEpoch: number = 31;

  private readonly onState = (info: floatView.FloatViewStateChangeInfo): void => {
    const state = String(info.state);
    this.storage.setOrCreate('windowState', state);
    if (state === 'STOPPED') this.coalescer.seal(false);
  };

  private readonly coalescer = new FloatSnapshotCoalescer(
    () => this.lifecycleEpoch,
    (snapshot: TransferSnapshot) => {
      this.storage.setOrCreate('snapshot', snapshot);
    }
  );

  async open(ctx: common.UIAbilityContext): Promise<void> {
    if (!floatView.isFloatViewEnabled()) return;
    this.controller = await floatView.create({
      context: ctx,
      templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
    });
    this.controller.onStateChange(this.onState);
    await this.controller.setUIContext('pages/TransferFloatPage', this.storage);
    await this.controller.start();
  }

  async close(): Promise<void> {
    this.coalescer.seal(false);
    await this.controller?.stop();
    // 真正完成仍以 onStateChange 的 STOPPED 为准
  }

  releaseListener(): void {
    this.controller?.offStateChange(this.onState);
  }
}

示例把 LocalStorage、控制器和监听都归给 FloatPulseOwner。页面只订阅快照,不直接操作控制器。这样做的直接收益是:闪控窗被系统关闭、标题栏关闭或业务主动停止时,收口路径都回到同一个 owner。

代码中的字符串化状态用于演示日志,项目里应按当前 SDK 的枚举类型处理,不能把截图里的文案当成 API 常量。还要注意 onStateChange 重复注册可能报错;如果 open() 有重入可能,必须在更外层加串行门闩。本文不重复讨论“重复启动”的方案,只强调状态提交与停止封口。

五、运行页只显示可解释的 8 次提交

演示运行页显示 SYNC-FV-3012 在 20:18 的汇总:源回调 64、窗口提交 8、合并 54、相同视图丢弃 2,当前 34.0 / 50.0 MB、68%、812 KB/s、剩余 20 秒。这组数字必须成套出现,因为它们来自同一个 snapshot。

页面右上角的红色说明指向“64 → 8”,目的不是证明性能提升,而是提醒调试时分别计数。只看 UI 帧率无法知道究竟是源事件少了,还是合并器在工作。推荐至少保留四个诊断计数:received、coalesced、sameViewDropped、committed。四者关系不一定严格相加,因为非法或过期样本应另设 rejected。

合并也不能掩盖业务失败。如果 phase 从 RUNNING 变成 FAILED,即使距离上次提交不足 250 ms,也应走 flushLatest(true) 或专用立即提交路径。可覆盖的是连续进度,不可覆盖的是状态边界。把两类事件都塞进同一个 debounce,是很多“偶发不显示失败”的来源。

六、STOPPED 之后,定时器和监听都必须有答案

详情页把生命周期与最后一次提交摆在一起:IDLE → STARTING → STARTED → STOPPING → STOPPED,lifecycleEpoch = 31,最后提交 revision 为 864,STOPPED 后拒绝 3 个旧样本,活动计时器从 1 归零。红圈只标“STOPPED 后封口”和“timer 1→0”。

这段代码解决什么问题:让完成、主动关闭和异常停止分别选择最后一帧策略,并确保定时器与监听最终归零。

onTaskDone(finalValue: TransferSnapshot): void {
  this.coalescer.enqueue({ ...finalValue, phase: 'DONE', progress: 100 });
  this.coalescer.flushLatest(true);
}

async onUserClose(): Promise<void> {
  this.coalescer.seal(false);
  await this.controller?.stop();
}

onStoppedConfirmed(): void {
  this.coalescer.seal(false);
  this.controller?.offStateChange(this.onState);
  this.controller = undefined;
  this.lifecycleEpoch += 1;
}

这里要避免一个细小但常见的错误:先把 controller 设为 undefined,再尝试 offStateChange。那会让监听解绑语句静默失去对象。正确顺序是封口、解绑、清引用、推进 epoch。若还有 onRectChange 等其他监听,也要保存原回调引用并逐一解除,不能用新建的同形函数去解绑。

异常路径同样需要封口。若 start() 抛错但控制器已经创建,应根据当前 SDK 状态和回调决定是否停止、解绑与清理,不能只在成功分支释放。页面销毁不等同闪控窗停止;闪控窗可以在主窗口退到后台后继续显示,因此不应机械地把主页面 aboutToDisappear 当作资源终点。

七、把“流畅”拆成一张可执行验收表

这类功能的验收不应只写“观察进度是否顺滑”。更有用的是把合同拆成几组:

  1. 单调性:乱序输入 66、68、67 时,最终只允许提交 68;相同进度但 FAILED 必须可见。
  2. 原子性:百分比、字节、速度、ETA 必须来自同一 revision,不能出现跨帧拼接。
  3. 生命周期:STOPPING 后普通样本不再入队,STOPPED 后 pending、timer、监听均归零。
  4. 重建:新窗口 epoch 32 启动后,epoch 31 的任何回调都不能写入。
  5. 能力边界:先用 isFloatViewEnabled() 判断;闪控窗 API 从 26.0.0 起提供并仅适用于 Stage 模型,目标设备仍需按当前 SDK 验证。

演示的 250 ms 不是推荐常量。计时器类任务可以 500~1000 ms,短耗时传输可能需要更快,包含不可覆盖业务事件时则应分流。好的合并器不追求“提交越少越好”,而是让每次提交都具有可解释的业务意义。

八、结语:先定义最后一帧,再谈每一帧

在落地前,还需要把几个容易被“节流”二字遮住的工程判断单独说明。

1. sameView 比较的是用户可见语义

源快照往往比窗口展示丰富。比如内部可能保存瞬时速率、平滑速率、网络类型、分片号和重试次数,窗口却只展示整数百分比、四舍五入后的速率与剩余秒数。如果 sameView 比较全部源字段,任何内部抖动都会变成 UI 刷新;如果只比较百分比,FAILED、PAUSED 等关键相位又可能被吞掉。

比较器最好先生成一个 TransferViewModel,其中只包含任务名、格式化百分比、格式化字节、格式化速率、ETA 文案和相位,再逐字段比较。格式化逻辑也要确定:速度低于 1 MB/s 时是否显示 KB/s,ETA 超过一小时如何显示,未知 ETA 是 -- 还是“计算中”。这些看似文案细节,实际上决定了什么时候一次源变化值得穿过合并器。

不要把格式化结果反向写回源快照。源快照保留完整精度,视图模型承担展示舍入,诊断日志保存 revision 与必要原值。这样遇到“窗口显示 812 KB/s,但日志是 812.4”时,团队知道是格式化规则,不会误判为数据错乱。

2. 定时窗口不能制造饥饿

若实现成每来一个样本就取消旧定时器并重新计时,在持续高频流量下,窗口可能永远等不到安静的 250 ms,形成饥饿。本例只有第一个样本负责启动定时器,后续样本只替换 pending;定时器到点一定执行一次。这更接近固定窗口的 latest 语义。

另一种需求是“最多 250 ms 更新一次,但流量停止时立即补最后一帧”。可以在源侧结束事件到来时强制 flush,而不是把 debounce 和 throttle 混在一个难以解释的实现里。团队应给策略起准确名字,例如 fixed-window-latest,并在日志记录窗口起止 revision。名称清楚,评审时才容易发现策略与场景不匹配。

计时器还要考虑页面线程长任务。setTimeout(250) 不保证恰好 250 ms 执行,只表示到期后获得调度资格。因此不要用定时器触发时刻推算传输速度;速度应来自业务层采样时间。UI 合并器只决定展示频率,不应成为业务计时器。

3. 最后一帧有三种,不是一种

任务自然完成时,最后一帧是业务终态,应优先提交 DONE / 100%,再等待窗口关闭或由用户决定保留。用户主动关闭闪控窗时,最后一帧是“最后已确认可见快照”,不需要为了好看伪造一个更高百分比。系统或异常触发 STOPPED 时,最后一帧则是诊断事实:合并器应立即封口,并把未提交 pending 的 revision 记入日志,不能在窗口已经消失后补写。

这三条路径如果都调用 dispose(),读代码的人无法知道是否会补帧。更好的接口名称是 completeWithFinal()、closeWithoutFinal()、abortAndSeal(),即使内部最终都清理定时器,调用点表达的业务承诺不同。

4. 监听回调要有稳定身份

onStateChange 与 offStateChange 成对,不只是数量成对,回调对象也要是同一引用。把箭头函数直接写在两处,形状相同但对象不同,解绑可能失效。本文把 onState 放成只读成员,就是为了让注册、注销和测试探针都指向同一对象。

重复注册也需要显式防护。系统文档给出了 repeated operations 相关错误,项目不能假设框架会替业务幂等。Owner 可以维护 listenerAttached,成功注册后置真,解绑成功或控制器结束后置假。日志不要只写“registered”,还要带 lifecycleEpoch=31,否则多次打开窗口后无法判断是哪一代监听。

5. LocalStorage 更新和控制器生命周期是两条线

LocalStorage 负责让加载到闪控窗的 ArkUI 页面看到状态变化,控制器负责系统窗口的创建、启动、停止和监听。二者关联,但不能混成一个布尔值。可能出现控制器已创建、页面内容尚未加载;页面内容已加载、start Promise 尚未返回;Promise 已返回、STARTED 回调尚未确认;主窗口已经不可见、闪控窗仍正常显示。

建议把诊断态至少拆为 controllerReady、contentReady、startRequested、systemState 和 sealed。这样发生空白窗时,能区分路径错误、页面加载失败、系统状态未到达与存储没有初值。只保留 isOpen 会让多个阶段都落成 true 或 false,日志失去定位价值。

首次加载前还应先写入初始快照。否则页面在订阅建立时可能拿到 undefined,随后才跳到 41%,造成一次没有业务意义的空态闪烁。初始快照可以是 RUNNING / 41%,也可以是明确的 PREPARING,但必须由业务合同决定,不能由组件默认值碰运气。

6. 诊断计数必须能闭合

FloatPulse 的演示统计为:received 64、committed 8、coalesced 54、sameViewDropped 2。这里没有 rejected,是因为示例只展示合法同代样本。在压力测试中,推荐再增加 staleRevisionRejected、epochRejected、invalidRangeRejected、afterSealRejected 和 forcedFlush。

这些计数要在一个生命周期内归零,并在 STOPPED 时打印汇总。若跨窗口累计,64 次输入可能混入上一代,数值失去解释力。若每个组件各记一份,最后又无法证明总和。最稳妥的归属仍是 Ability 级 owner:源事件进入它,合并器和控制器也由它持有,诊断页只读取快照。

日志应避免逐样本打印完整对象。高频日志本身会改变调度,甚至掩盖原问题。正常模式记录窗口汇总与拒绝原因,诊断模式才采样打印 revision;任务名或内容可能包含用户数据,日志中只使用内部 taskId。

7. 真实设备验证要覆盖用户动作

模拟器或演示图适合解释逻辑,最终验收仍应在支持闪控窗能力的目标设备进行。至少覆盖:主窗口前台时打开;退到后台后继续显示;连续切换前后台;标题栏关闭;业务完成后保留终态;网络断开进入 FAILED;快速重复点击打开和关闭;系统回收主页面但闪控窗仍存在的边界。

每个动作都要检查四件事:系统状态回调顺序、最后提交 revision、活动 timer 数量、监听数量。视觉“看起来没问题”不能替代这四项,因为监听泄漏可能要多次进入页面后才出现,旧 epoch 提交也可能只在特定线程时序下暴露。

另外,闪控窗属于较新的系统能力,文档标注从 26.0.0 起支持,并要求 Stage 模型与对应系统能力判断。文章没有把 HarmonyOS 7 当成所有设备都自动可用的同义词;版本、设备、权限和分发边界仍以当前官方文档、SDK 声明与目标设备验证为准。

最后还要检查进程恢复。若任务本身由独立业务服务继续执行,而窗口 owner 被重建,新 owner 不能沿用内存中的 revision 起点。它应先向任务仓库读取当前权威快照和任务代次,再创建新的 lifecycleEpoch;闪控窗显示的是恢复时的最新事实,不负责猜测丢失区间。若业务层无法提供单调 revision,就需要把任务代次与序号一起持久化,或者明确在恢复时开启新序列。仅依赖百分比会让重试后的 12% 看起来比旧任务的 68% 更旧,从而被错误拒绝。

这一点也说明,合并器不应拥有任务真相。它只是一个有生命周期的展示适配器:输入来自权威仓库,输出流向 LocalStorage,销毁后可以无损重建。把传输控制、重试策略和文件句柄都塞进合并器,会让窗口停止与任务停止再次耦合,回到文章开头的问题。

闪控窗的难点不在于把页面显示出来,而在于主页面退出、系统窗口继续、业务源高速变化、停止回调异步到达时,谁仍有资格修改用户看到的事实。

FloatPulse 的取舍可以压缩成四句话:用完整快照替代散字段;用 revision 代替抵达时间;用生命周期 epoch 隔离旧窗口;用 STOPPED 回调确认真正终点。差分合并只是中间手段,最后写入和资源收口才是工程边界。

参考资料:

  • 华为 HarmonyOS 闪控窗开发指南:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/float-view-guide
  • OpenHarmony @ohos.window.floatView API 参考镜像(用于核对接口签名,项目以当前华为 SDK 为准):https://github.com/openharmony-rs/openharmony-docs/blob/master/zh-cn/application-dev/reference/apis-arkui/js-apis-floatView.md
  • 华为 ArkUI LocalStorage 指南:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-localstorage
Logo

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

更多推荐