茶器艺科智造HarmonyOS应用实战-60-五个@Watch连续触发五次drawOnce,拖动杯型参数为何雷达图反复重绘:用帧内合并收口Canvas更新

ArchiveRadarCanvas 的五个 Prop 都 Watch 同一个回调。一次预设切换若连续更新五个参数,就可能多次进入 drawOnce(),而每次绘制都要清屏并重画网格、轴线、多边形和文字。单个 Watch 没有错,问题是多个字段共同描述一张雷达图,却没有共享一次“本轮只画最终状态”的调度。

第60篇封面

本文以 Gitee 仓库 aiding521/chaqi-app-giteemaster 分支、提交 f671fcd8de277973bd7518a3c462f68384575f1e 的静态源码为事实基线。本文没有修改项目源码,也没有执行 HAP 构建、模拟器或真机验证;建议代码属于演进方案,不能描述成已经落地的功能。

一、五个 Watch 看见的是字段,用户完成的是一次操作

静态源码中,每个 Prop 的变化都会走同一个回调:

@Prop @Watch('onParamsChange') heightMm: number = 70;
private onParamsChange(): void { this.drawOnce(); }

如果预设切换在一次同步流程中依次写入五个参数,Watch 会分别通知;直接调用 drawOnce() 就会把一次业务操作拆成多次完整渲染。前几次绘制读到的是“部分字段已更新、其余字段还是旧值”的中间快照,最后一次才是完整的新预设。

本文不能仅凭静态源码断言设备已经卡顿,也没有测得确切的五次绘制耗时。可以确认的是调用结构允许重复重画,并且每次重画包含清屏、网格、轴线、多边形和文字。是否构成可感知性能问题,需要日志计数和 Profiler 取证。

二、合并目标是丢掉中间快照,不是丢掉最终状态

雷达图属于派生视图:画面由当前五个 Prop 和 Canvas 尺寸共同决定。对于同一轮连续更新,用户通常只关心全部字段落定后的最终图形,中间四张混合新旧参数的图没有独立业务价值。

第60篇流程图

因此可以把 Watch 的职责从“立刻绘制”改成“请求绘制”。第一次变化登记一个待执行任务,后续变化只更新组件字段,不再登记更多任务;待任务真正执行时,drawOnce() 读取最新的五个 Prop,于是一次绘制覆盖这一轮所有变化。

这与忽略更新不同。合并期间每个 Prop 仍按框架规则更新,只有昂贵的派生绘制被推迟到当前连续变化之后。收口不变量是:没有变化时不绘制;一轮变化至少有一次最终绘制;同一轮最多保留一个待执行任务。

三、用 pending 标记把直接调用改成请求调度

最小建议代码如下:

private redrawPending: boolean = false;
private onParamsChange(): void {
  if (this.redrawPending) return;
  this.redrawPending = true;
  setTimeout(() => { this.redrawPending = false; this.drawOnce(); }, 0);
}

第一次 Watch 把 redrawPending 设为 true,并安排一个后续任务;同一同步更新链上的其余 Watch 看到 true 后直接返回。回调执行前先把标记复位,再调用 drawOnce()。如果绘制期间又发生新变化,下一次 onParamsChange() 仍能登记后续绘制,不会永久吞掉更新。

要准确描述这段代码:setTimeout(..., 0) 表示延后到后续任务并合并当前连续触发,并不天然等于与显示器垂直同步的“严格一帧”。若项目要保证按渲染帧调度,需要结合目标 HarmonyOS API 和运行证据选择合适机制,不能仅凭标题把 0ms timer 宣称为 frame callback。

四、Canvas 尺寸变化也应走同一个入口

宽高改变会影响网格中心、半径、轴线与文字位置,因此也不能绕过合并调度:

private onCanvasAreaChanged(w: number, h: number): void {
  this.wPx = w; this.hPx = h;
  this.onParamsChange();
}

先写入最新尺寸,再请求重画,能让待执行任务读到新宽高。如果尺寸回调和五个 Prop 在相近时段一起发生,它们也可被同一个 pending 合并。绘制前仍应确认宽高有效;本文没有现成代码证明具体无效值处理,应在实际实现中按组件当前约定核对。

尺寸变化与参数变化的区别在于触发源不同,但它们影响同一张最终位图。复用“请求重画”是合理的;复用“修改参数”则不合理,尺寸不应伪装成第六个杯型参数。

五、调度器只有 idle 与 pending 两个核心状态

当前状态事件动作绘制次数下一状态
idle第一个 Prop 改变登记后续任务0pending
pending第二至第五个 Prop 改变只保留最新字段0pending
pendingCanvas 尺寸改变更新宽高,不重复登记0pending
pending调度回调执行复位标记并 drawOnce1idle
idle单个参数改变登记后续任务0pending
idle/pending组件已失效取消或让回调失效0idle

表中“最多一次”针对同一轮连续更新,不代表持续拖动期间永远只画一次。用户持续产生新输入时,系统仍会在每次调度完成后接受下一轮请求。合并的目的是限制重复中间绘制,不是把实时预览变成松手后才更新。

六、测试必须断言最终参数与调用次数

原有边界用例骨架可以继续使用,但应把输入扩展为参数变更序列:

interface BoundaryCase<T> {
  name: string;
  input: T;
  expected: string;
}

const cases: BoundaryCase<string>[] = [
  { name: 'empty', input: '', expected: 'reject' },
  { name: 'normal', input: 'valid', expected: 'accept' },
  { name: 'repeat', input: 'valid', expected: 'idempotent' }
];

本篇至少要覆盖四类序列:单字段更新最终绘制一次;五字段同步更新仍只执行一次待调度绘制;pending 期间尺寸改变时最终使用新尺寸;第一轮回调执行后再改参数能够开始第二轮。测试替身可给 drawOnce() 计数,并记录绘制时读取的五个值。

只断言次数也不够。一个错误实现可能确实只画一次,却画的是第一个字段变化后的旧快照。必须同时断言调用次数和最终快照,才能证明“合并而不丢失”。

七、离开组件时不能让 pending 回调落到旧画布

延后执行引入了新的生命周期边界:登记任务后,组件可能在回调运行前消失。资源仍应由创建它的组件负责清理:

private disposeOwnedResources(): void {
  // timer/listener只由创建它的组件清理
  // 异步结果写状态前核对taskId或pageGeneration
  // 可恢复任务先保存业务事实,再释放进程句柄
}

若保存了 timerId,可以在消失时清理;若所用调度机制不能或不适合取消,就用组件代次或 active 标记让旧回调在 drawOnce() 前退出。无论哪种方式,清理后都要让 redrawPending 回到可解释状态,避免组件再次出现时一直认为已有任务等待。

Canvas context 和待调度句柄都是组件运行时资源,不应持久化。需要保留的是决定图形的业务参数;页面重建后应根据当前参数重新请求一次绘制。

八、验证要给 drawOnce 计数,而不是只凭肉眼

  1. 记录预设切换前的 drawOnce 累计次数。
  2. 一次更新五个 Prop,等待调度任务执行,比较次数增量。
  3. 在绘制入口记录五个最终值的摘要,确认不是中间快照。
  4. 单独修改一个参数,确认仍能触发一次重画。
  5. pending 期间触发尺寸变化,确认最终使用最新宽高。
  6. 连续拖动参数,记录每轮请求数、实际绘制数与最终值。
  7. 登记后立即离开页面,确认旧回调不触碰失效 Canvas。

Profiler 用于判断清屏、网格、轴线、多边形或文字中哪一部分真正昂贵;在没有数据前,不应直接承诺分层缓存必然带来收益。先验证合并是否减少冗余调用,再决定是否继续优化绘制内部。

九、合并更新最容易踩的五个坑

误修问题正确方向
给每个 Watch 都加 setTimeout仍会登记五个任务所有 Watch 共用一个 pending
用较长防抖延迟拖动反馈可能滞后只合并当前连续变化,实测再调度
pending 时拒绝字段赋值最终快照会丢值只合并绘制,不阻止 Prop 更新
drawOnce 后忘记复位后续变化永远不能绘制明确 idle/pending 转换
只做静态层缓存没先证明瓶颈与失效条件先计数与 Profile,再决定缓存
离页不处理待执行任务回调可能访问旧 Canvas清句柄或核对 generation

特别要区分“节流、去抖、合并”。本篇建议的最小代码只保证同一连续触发期间保留一个后续任务;不要未经测量把它包装成完整性能调度框架。

十、绘制状态留在组件,纯参数变换可独立测试

第60篇结构图

项目中 entry 负责产品入口,libraryhsp 负责页面和交互,libraryhar 负责模型、算法、路由与通用服务。Canvas context、redrawPending、timerId 与尺寸属于组件运行状态,应留在 HSP/entry;如果雷达图存在无 UI 依赖的数值归一化或坐标计算,可在不反向依赖 Canvas 的前提下放入共享层。

entry / libraryhsp -> 页面、组件、生命周期
libraryhar        -> 纯类型、纯函数、可持久化模型
tests             -> 边界、幂等、恢复序列
runtime evidence  -> 日志、设备行为、服务读回

这种分层使纯坐标计算能覆盖边界值,而“是否在正确时机只画一次”继续由组件级测试和运行日志验证。两类证据不能互相替代。

十一、静态基线不能冒充性能结论

baseline: f671fcd8de277973bd7518a3c462f68384575f1e
scope: source-level proposal
build: not run
simulator/device: not run
network/account: not run
result: static boundary identified; implementation pending

当前可以确认五个 Prop 共用 Watch 回调、回调直接 drawOnce(),以及一次绘制包含多个图层。没有执行构建、模拟器、真机或 Profiler,因此不能给出帧率提升、CPU 降幅或拖动流畅度结论。实现后若只通过编译,也仍需补调用计数和设备侧测量。

十二、用一次预设切换复现并验收

最直接的实验是给 onParamsChange()、调度登记点与 drawOnce() 分别计数。切换一个会同时改变五个 Prop 的预设,记录 Watch 次数、登记次数、实际绘制次数以及绘制时的参数摘要。预期不是 Watch 只触发一次,而是多个 Watch 只产生一个待执行绘制。

场景操作序列必须观察的证据不能替代的证据
预设切换连续更新五个 Prop多次通知、一次登记、最终快照画面最终看起来正确
单项拖动参数连续变化每轮绘制使用当时最新值只看拖动结束结果
尺寸同变参数更新→尺寸回调一次绘制使用新尺寸没有明显闪烁
二次更新第一轮执行→再次修改能登记第二轮首轮已成功
快速离开登记→组件消失旧任务不访问 Canvas页面没有崩溃
无变化保持参数不动不新增绘制CPU 曲线偶然平稳

如需记录延后任务的归属,可以沿用操作轨迹:

interface OperationTrace {
  taskId: string;
  generation: number;
  startedAtMs: number;
  finishedAtMs?: number;
  terminalSource?: 'ui' | 'service' | 'restore' | 'cancel';
}

function canApplyResult(trace: OperationTrace, currentGeneration: number): boolean {
  return trace.generation === currentGeneration &&
    trace.finishedAtMs === undefined;
}

本篇更关注 redrawGeneration、请求次数与实际绘制次数。是否复用通用 OperationTrace 应由现有类型体系决定,不能为了形式统一而把 Canvas 绘制伪装成网络任务。

收口标准是:五个 Watch 可以照常报告变化,但它们共用一次调度;执行时读取最终参数;尺寸走同一路径;组件失效后旧回调不再绘制。本文尚未实施和运行验证,以上仍是固定提交上的源码级建议。

Logo

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

更多推荐