茶器艺科智造HarmonyOS应用实战-60-五个@Watch连续触发五次drawOnce,拖动杯型参数为何雷达图反复重绘:用帧内合并收口Canvas更新
茶器艺科智造HarmonyOS应用实战-60-五个@Watch连续触发五次drawOnce,拖动杯型参数为何雷达图反复重绘:用帧内合并收口Canvas更新
ArchiveRadarCanvas 的五个 Prop 都 Watch 同一个回调。一次预设切换若连续更新五个参数,就可能多次进入 drawOnce(),而每次绘制都要清屏并重画网格、轴线、多边形和文字。单个 Watch 没有错,问题是多个字段共同描述一张雷达图,却没有共享一次“本轮只画最终状态”的调度。

本文以 Gitee 仓库 aiding521/chaqi-app-gitee 的 master 分支、提交 f671fcd8de277973bd7518a3c462f68384575f1e 的静态源码为事实基线。本文没有修改项目源码,也没有执行 HAP 构建、模拟器或真机验证;建议代码属于演进方案,不能描述成已经落地的功能。
一、五个 Watch 看见的是字段,用户完成的是一次操作
静态源码中,每个 Prop 的变化都会走同一个回调:
@Prop @Watch('onParamsChange') heightMm: number = 70;
private onParamsChange(): void { this.drawOnce(); }
如果预设切换在一次同步流程中依次写入五个参数,Watch 会分别通知;直接调用 drawOnce() 就会把一次业务操作拆成多次完整渲染。前几次绘制读到的是“部分字段已更新、其余字段还是旧值”的中间快照,最后一次才是完整的新预设。
本文不能仅凭静态源码断言设备已经卡顿,也没有测得确切的五次绘制耗时。可以确认的是调用结构允许重复重画,并且每次重画包含清屏、网格、轴线、多边形和文字。是否构成可感知性能问题,需要日志计数和 Profiler 取证。
二、合并目标是丢掉中间快照,不是丢掉最终状态
雷达图属于派生视图:画面由当前五个 Prop 和 Canvas 尺寸共同决定。对于同一轮连续更新,用户通常只关心全部字段落定后的最终图形,中间四张混合新旧参数的图没有独立业务价值。

因此可以把 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 改变 | 登记后续任务 | 0 | pending |
| pending | 第二至第五个 Prop 改变 | 只保留最新字段 | 0 | pending |
| pending | Canvas 尺寸改变 | 更新宽高,不重复登记 | 0 | pending |
| pending | 调度回调执行 | 复位标记并 drawOnce | 1 | idle |
| idle | 单个参数改变 | 登记后续任务 | 0 | pending |
| idle/pending | 组件已失效 | 取消或让回调失效 | 0 | idle |
表中“最多一次”针对同一轮连续更新,不代表持续拖动期间永远只画一次。用户持续产生新输入时,系统仍会在每次调度完成后接受下一轮请求。合并的目的是限制重复中间绘制,不是把实时预览变成松手后才更新。
六、测试必须断言最终参数与调用次数
原有边界用例骨架可以继续使用,但应把输入扩展为参数变更序列:
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 计数,而不是只凭肉眼
- 记录预设切换前的 drawOnce 累计次数。
- 一次更新五个 Prop,等待调度任务执行,比较次数增量。
- 在绘制入口记录五个最终值的摘要,确认不是中间快照。
- 单独修改一个参数,确认仍能触发一次重画。
- pending 期间触发尺寸变化,确认最终使用最新宽高。
- 连续拖动参数,记录每轮请求数、实际绘制数与最终值。
- 登记后立即离开页面,确认旧回调不触碰失效 Canvas。
Profiler 用于判断清屏、网格、轴线、多边形或文字中哪一部分真正昂贵;在没有数据前,不应直接承诺分层缓存必然带来收益。先验证合并是否减少冗余调用,再决定是否继续优化绘制内部。
九、合并更新最容易踩的五个坑
| 误修 | 问题 | 正确方向 |
|---|---|---|
| 给每个 Watch 都加 setTimeout | 仍会登记五个任务 | 所有 Watch 共用一个 pending |
| 用较长防抖延迟 | 拖动反馈可能滞后 | 只合并当前连续变化,实测再调度 |
| pending 时拒绝字段赋值 | 最终快照会丢值 | 只合并绘制,不阻止 Prop 更新 |
| drawOnce 后忘记复位 | 后续变化永远不能绘制 | 明确 idle/pending 转换 |
| 只做静态层缓存 | 没先证明瓶颈与失效条件 | 先计数与 Profile,再决定缓存 |
| 离页不处理待执行任务 | 回调可能访问旧 Canvas | 清句柄或核对 generation |
特别要区分“节流、去抖、合并”。本篇建议的最小代码只保证同一连续触发期间保留一个后续任务;不要未经测量把它包装成完整性能调度框架。
十、绘制状态留在组件,纯参数变换可独立测试

项目中 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 可以照常报告变化,但它们共用一次调度;执行时读取最终参数;尺寸走同一路径;组件失效后旧回调不再绘制。本文尚未实施和运行验证,以上仍是固定提交上的源码级建议。
更多推荐

所有评论(0)