2026/10/14 21:14:17

ArkUI Canvas:巡检曲线缺测断点分段绘制与边界守护【鸿蒙心迹】

ArkUI Canvas:巡检曲线缺测断点分段绘制与边界守护【鸿蒙心迹】 项目PulseTrace页面TraceReviewPage任务PT-2907说明本文全部数据、日志和画面均为人工构造的演示未声称进行 DevEco 编译、真机采集或医疗检测。一、这条折线最容易画错的不是颜色做离线巡检数据回看时我遇到过一个很容易被漂亮界面掩盖的问题某一段没有采集到数据折线却从上一笔直接连到了下一笔。看起来只是多了一小段斜线放到业务里却等于替不存在的读数补了一条变化趋势。对于一次断续采样宁可明确留下空档也不能用顺滑的曲线冒充连续证据。这次我用PulseTrace做一个可以反复推演的静态样例页面叫TraceReviewPage任务号固定为PT-2907。八个等间距样本依次是70、72、缺测、81、79、77、缺测、74合计六个有效点、两个缺口。正确的画法不是一条总折线而是三段彼此独立的线段集合前两点一段中间三点一段最后一个点保留为孤立点。业务状态记为GAP_SAFE不是“已判定身体健康”。我把本篇目标收敛到一个很具体的工程问题把缺测当成真实的断点同时把脏数字、坐标缩放与页面生命周期关在不同边界内。没有引入蓝牙、传感器权限或数据库因为这些能力解决不了“未采集的数据被画成已采集”这个根因。二、先决定什么才是合法的采样第一处容易出错的是数据模型。有人会在数组里把缺测写成零再在绘图时过滤零值。这样不仅把“没有数据”和“测到了零”混为一谈还会让纵轴范围计算失真。这里显式使用null代表缺测样本自身带有固定索引index。索引不因过滤而重排否则末尾的74会被画到第六格而不是第八格。解决这个问题时我选择先在纯函数中拆段。函数只接受业务数组既不接触 Canvas 也不接触页面实例。对于超出预设显示范围、非有限数值或缺失索引的输入当前样例按缺测处理如果产品要显示越界标记应另加数据质量通道而不是悄悄把值压到边界上。interface PulseSample { index: number; bpm: number | null; } const READINGS: PulseSample[] [ { index: 0, bpm: 70 }, { index: 1, bpm: 72 }, { index: 2, bpm: null }, { index: 3, bpm: 81 }, { index: 4, bpm: 79 }, { index: 5, bpm: 77 }, { index: 6, bpm: null }, { index: 7, bpm: 74 } ]; function isValid(sample: PulseSample): boolean { return Number.isInteger(sample.index) sample.index 0 sample.index 7 sample.bpm ! null Number.isFinite(sample.bpm) sample.bpm 60 sample.bpm 100; } function splitValidRuns(samples: PulseSample[]): PulseSample[][] { const runs: PulseSample[][] []; let active: PulseSample[] []; for (const sample of samples) { if (!isValid(sample)) { if (active.length 0) { runs.push(active); active []; } continue; } if (active.length 0 sample.index ! active[active.length - 1].index 1) { runs.push(active); active []; } active.push(sample); } if (active.length 0) { runs.push(active); } return runs; }这里的active只在一次拆段计算中存在离开函数便没有需要释放的外部句柄。函数不会修改原数组因此页面重新绘制、切换数据源或重复点击“检查”时分段结果不会出现历史状态残留。对于跳号的有效样本也会强制拆段避免数组中遗漏一格时自动跨格连线但真实接入仍需在入模前校验重复索引和采样时序。对于空数组会得到零段对于全是缺测的输入也得到零段并在界面上展示“暂无有效曲线”不能误画出一条水平线。示例八格正好拆出三段图上应能数出两个不被连接的间隔。三、坐标系不是直接拿屏幕像素凑出来的第二个问题来自 Canvas 的宽高和数据含义混用。演示画布的逻辑尺寸固定为320×180左右留白各20纵轴绘制区域从 y20 到 y160输入范围约定为60100。因此第一个点70对应(20,125)末尾第八格74对应(300,111)。这些是用于讲解的画布逻辑坐标并不是手机物理像素更不是任意设备上的最终密度值。我不希望将样本数组过滤后再map((value, i) ...)因为那会改写原始横轴位置。坐标映射只读取固定index并且同时校验索引和量纲。若把八格替换成更多点位应重新计算间隔不要沿用当前40的水平步进。若以后改为响应式布局必须以实际 Canvas 绘图坐标系重新计算缩放与留白。interface PlotPoint { x: number; y: number; } function toPlotPoint(sample: PulseSample): PlotPoint { if (!isValid(sample) || sample.index 0 || sample.index 7) { throw new Error(PT-2907: INVALID_SAMPLE); } const bpm: number sample.bpm as number; return { x: 20 sample.index * 40, y: 160 - (bpm - 60) * 3.5 }; }这段不是为了炫数学。只有坐标映射保持独立调试时才能回答“是数据缺失导致的断点还是视图缩放导致的错位”。toPlotPoint会对不合法的调用直接报错要求调用方先做isValid门禁避免null被强行算成数值。画布尺寸变化后应重新初始化比例不能只在视觉上拉伸旧折线示例选择固定宽高正是为了把可验证的演示边界写清楚。四、Canvas 每次重绘都必须从干净画面开始接下来才让绘图组件参与工作。CanvasRenderingContext2D通过RenderingContextSettings创建并在 Canvas 的onReady后执行绘制beginPath、moveTo、lineTo与stroke只负责绘制当前分段最后的孤立点单独用小方块标出来。这里不使用平滑贝塞尔曲线因为平滑插值会把本来明确的折点改造成并不存在的过渡。绘制入口必须先调用clearRect清理本次画面否则当后续样本由八格变成空列表旧图还留在屏幕上用户会把缓存图误认为最新结果。每一段重新beginPath也避免路径对象意外把缺口两侧连接起来。下面是本例完整可追踪的渲染关键路径。Entry Component struct TraceReviewPage { private settings: RenderingContextSettings new RenderingContextSettings(true); private ctx: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings); private paint(): void { this.ctx.clearRect(0, 0, 320, 180); const runs: PulseSample[][] splitValidRuns(READINGS); this.ctx.lineWidth 3; this.ctx.strokeStyle #087F8C; this.ctx.fillStyle #087F8C; for (const run of runs) { const first: PlotPoint toPlotPoint(run[0]); if (run.length 1) { this.ctx.fillRect(first.x - 3, first.y - 3, 6, 6); continue; } this.ctx.beginPath(); this.ctx.moveTo(first.x, first.y); for (let i 1; i run.length; i) { const p: PlotPoint toPlotPoint(run[i]); this.ctx.lineTo(p.x, p.y); } this.ctx.stroke(); } console.info(PT-2907 segments3 missing2 statusGAP_SAFE); } build() { Column({ space: 16 }) { Text(巡检曲线 · PT-2907).fontSize(20) Canvas(this.ctx).width(320).height(180) .onReady(() { this.paint(); }) } } }上面 HiLog 所示的PT-2907 segments3 missing2 statusGAP_SAFE是人工编写的演示日志并非运行工具采集到的记录。示意图将工程目录放在左侧核心绘图代码放在中央右侧模拟器视图显示三段曲线。这样做是为了让文章、代码和界面说同一件事而不是用没有对应代码的截图证明功能已经实测通过。五、只有把空集作为正常输入画面才可信我给这个样例安排了四类验收输入八格静态样例、全部缺测、仅有单个合法值、混入非有限数值。第一类应得到三段第二类不显示曲线第三类只显示一个点第四类把坏值作为断点而不是让整个 Canvas 绘制失败。只有图上断点数、业务计数和日志汇总能够一一对应才能谈得上通过样例层面的验收。另一个验证方法是故意只改变缺测的位置而不动有效值。假如把第三格的缺测换到第五格线段边界必然跟着移动但各有效值在横轴上的索引仍取决于原始采样位置。测试人员不必盯着颜色猜是否正确只需记录八格的逐项标记再对照渲染后的断点。对于自动化检查应该直接断言拆段函数输出的索引集合而不是只比较一张经过缩放的截图。屏幕图受字体、抗锯齿和设备密度影响业务数组的判定结果才适合做稳定的回归基线。这里还应单独校验末尾孤立点是否存在因为只判断连续折线段数很容易漏掉它。这里还有一个容易遗漏的逻辑segments3指有效连续段数绝不等于“已经收到了三次设备事件”missing2指当前八个静态样本里的两个缺测值不代表真实蓝牙掉包数。业务状态GAP_SAFE仅说明绘图算法没有跨空点连接不能替代采集质量评价也不应推导健康建议。手机屏幕是比例为9:16的纯界面演示顶部状态栏保留时间、5G、Wi-Fi、蜂窝信号与电量百分比不附加手机壳。正文对应的八格标签、六个有效点、两个缺口、三段绘图和GAP_SAFE状态会同时出现便于读者拿原始数组自己核对。画面中的运行状态仍是示例数据不是任何实际设备的心率读取结果。六、页面退场与下一步真正要补的能力从生命周期看onReady保证初次画布准备完成后调用本次绘制如果未来加入数据切换业务层应在确认新数组后显式要求重画并避免拿尚未完成尺寸测量的旧比例作图。当前没有 timer、Worker、文件句柄或蓝牙连接也就没有可以假装执行的定时器释放、线程终止或设备断开操作。离开页面时Canvas 的界面对象由框架管理真实接入外部订阅时才需要在相应生命周期解除订阅。这篇案例没有提供监测级别的采样纠错算法也没有验证不同像素密度和高刷新场景下的绘图性能。工程上的保守结论是先把无效样本排除、把数据坐标固定、把 Canvas 画笔按段重建才能避免最明显的视觉造假。需要扩展到连续采集时再补缓冲窗口、时间戳乱序处理、视口缩放和空值说明不要把一个静态 Demo 的平稳显示当成产品已经完成。官方核对资料华为 Canvas 组件 APICanvas(context)与onReady、CanvasRenderingContext2D使用示例和 ArkTS 语言指南。文中分段与坐标映射均为业务层自行编写并非华为提供的缺测修复能力。https://developer.huawei.com/consumer/en/doc/harmonyos-references-V3/ts-components-canvas-canvas-0000001333641081-V3https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-components-canvas-canvasgradienthttps://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-overview