项目: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,纵轴绘制区域从 y=20 到 y=160;输入范围约定为60~100。因此第一个点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 segments=3 missing=2 status=GAP_SAFE'); } build() { Column({ space: 16 }) { Text('巡检曲线 · PT-2907').fontSize(20) Canvas(this.ctx).width(320).height(180) .onReady(() => { this.paint(); }) } } }上面 HiLog 所示的PT-2907 segments=3 missing=2 status=GAP_SAFE是人工编写的演示日志,并非运行工具采集到的记录。示意图将工程目录放在左侧,核心绘图代码放在中央,右侧模拟器视图显示三段曲线。这样做是为了让文章、代码和界面说同一件事,而不是用没有对应代码的截图证明功能已经实测通过。
五、只有把空集作为正常输入,画面才可信
我给这个样例安排了四类验收输入:八格静态样例、全部缺测、仅有单个合法值、混入非有限数值。第一类应得到三段;第二类不显示曲线;第三类只显示一个点;第四类把坏值作为断点,而不是让整个 Canvas 绘制失败。只有图上断点数、业务计数和日志汇总能够一一对应,才能谈得上通过样例层面的验收。
另一个验证方法是故意只改变缺测的位置,而不动有效值。假如把第三格的缺测换到第五格,线段边界必然跟着移动,但各有效值在横轴上的索引仍取决于原始采样位置。测试人员不必盯着颜色猜是否正确,只需记录八格的逐项标记,再对照渲染后的断点。对于自动化检查,应该直接断言拆段函数输出的索引集合,而不是只比较一张经过缩放的截图。屏幕图受字体、抗锯齿和设备密度影响,业务数组的判定结果才适合做稳定的回归基线。这里还应单独校验末尾孤立点是否存在,因为只判断连续折线段数很容易漏掉它。
这里还有一个容易遗漏的逻辑:segments=3指有效连续段数,绝不等于“已经收到了三次设备事件”;missing=2指当前八个静态样本里的两个缺测值,不代表真实蓝牙掉包数。业务状态GAP_SAFE仅说明绘图算法没有跨空点连接,不能替代采集质量评价,也不应推导健康建议。
手机屏幕是比例为9:16的纯界面演示,顶部状态栏保留时间、5G、Wi-Fi、蜂窝信号与电量百分比,不附加手机壳。正文对应的八格标签、六个有效点、两个缺口、三段绘图和GAP_SAFE状态会同时出现,便于读者拿原始数组自己核对。画面中的运行状态仍是示例数据,不是任何实际设备的心率读取结果。
六、页面退场与下一步真正要补的能力
从生命周期看,onReady保证初次画布准备完成后调用本次绘制;如果未来加入数据切换,业务层应在确认新数组后显式要求重画,并避免拿尚未完成尺寸测量的旧比例作图。当前没有 timer、Worker、文件句柄或蓝牙连接,也就没有可以假装执行的定时器释放、线程终止或设备断开操作。离开页面时,Canvas 的界面对象由框架管理;真实接入外部订阅时才需要在相应生命周期解除订阅。
这篇案例没有提供监测级别的采样纠错算法,也没有验证不同像素密度和高刷新场景下的绘图性能。工程上的保守结论是:先把无效样本排除、把数据坐标固定、把 Canvas 画笔按段重建,才能避免最明显的视觉造假。需要扩展到连续采集时,再补缓冲窗口、时间戳乱序处理、视口缩放和空值说明,不要把一个静态 Demo 的平稳显示当成产品已经完成。
官方核对资料:华为 Canvas 组件 API(Canvas(context)与onReady)、CanvasRenderingContext2D使用示例和 ArkTS 语言指南。文中分段与坐标映射均为业务层自行编写,并非华为提供的缺测修复能力。
- https://developer.huawei.com/consumer/en/doc/harmonyos-references-V3/ts-components-canvas-canvas-0000001333641081-V3
- https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-components-canvas-canvasgradient
- https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-overview