ArkUI Canvas:3DGS质检直方图零样本清屏与批次重绘【鸿蒙心迹】
示例工程:CloudHistogram|页面:HistogramPage|任务:CH-1707|批次版本:v3|分桶:[2, 4, 1, 0, 3]|样本总数:10。正文数据、代码和所有图片均为教学演示,图片并非真实 DevEco Studio 截图或真机运行实测。

一、图没错,错在把上一批的像素留了下来
我在整理一套 3DGS 候选帧质检工作台时,碰到一个容易被前端忽视的小问题。右侧列表换了批次,左侧质量分布图却还保留着上一批的高柱子。用户以为新一批的质量提升了,实际上只是画布没有清干净。Canvas 和普通声明式组件不太一样:修改一份数组,不等于画布上旧的矩形会自动消失。画布更像一块画板,本次绘制只负责把新东西涂上去,没画到的区域仍有机会保持原来的内容。
所以这次我不把篇幅花在点云重建算法上,也不把五个统计桶包装成模型能力。我只做一个可复现的演示页面:CloudHistogram 的 HistogramPage 固定显示任务 CH-1707,批次版本为 v3,五个质量区间的数量依次是 2、4、1、0、3,总样本数 10。它们是手工准备的样本数据,不是设备采集、算法推理或 AppGallery 审核结果。工程真正要检验的是:批次变化、空数据和迟到事件进入后,图形能否诚实地对应当前状态。
页面采用 320×180 的逻辑绘制区域,五根柱子之间保留固定间隔。空桶也要占位置,因为第 4 桶为 0 并不意味着第 5 桶应该向左挪一个格子。若图形只根据非空桶构造横坐标,视觉上便改变了统计区间的含义。我的约定是,无论数据怎样变化,横轴总有五个位置,数据缺失或计数为零时画空位,不偷偷调整分类顺序。
这里还要分清两个“空”:一个桶为零,意味着这个质量段暂时没有样本;五个桶总数全为零,才意味着当前批次没有有效样本。它们在 UI 上不该都显示“暂无数据”。前者保留完整图表和空柱;后者清屏后显示明确的空状态。把这两个条件混起来,质检人员就无法判断是质量分布偏移,还是整批压根没载入。
二、先把统计输入变成可靠的快照
画图之前我先处理数据的形状。业务层接过来的数组并不天然可信:它可能长度错误、夹带负数,或把小数当作计数。为了不让渲染代码每次都猜一遍,第一段函数直接生成不可含糊的五桶快照;不符合约定就返回错误,由页面展示数据错误态,绝不强行补齐凑数。这样做看着严格,但对于质检报表而言,误画往往比不画更难发现。
interface HistogramSnapshot {
taskId: string;
version: number;
bins: number[];
total: number;
}
function createSnapshot(taskId: string, version: number,
input: number[]): HistogramSnapshot {
if (taskId.length === 0 || !Number.isInteger(version) || version < 0) {
throw new Error('invalid task/version');
}
if (input.length !== 5) {
throw new Error('five bins required');
}
let total = 0;
input.forEach((value: number) => {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error('invalid bin count');
}
total += value;
});
if (!Number.isSafeInteger(total)) {
throw new Error('total overflow');
}
return { taskId, version, bins: input.slice(), total };
}
这里特意用了 slice() 复制数组。否则异步回调持有同一个可变数组,页面在绘图中途就可能读到半新半旧的数据。total 只从五个桶算出来,而不是信任外部又传来的一个总数字段。对演示数据 [2,4,1,0,3],函数应给出总数 10;将输入换成 [0,0,0,0,0],得到的总数才是 0。复制的是小型数字数组,不涉及大规模点云;真实项目如果处理百万级结构,需要单独考虑内存成本。
从状态设计上讲,我保留了 taskId 和 version,因为“是不是同一批次”必须先于“画成什么样”。仅比较时间戳可能会被终端时钟变化干扰;仅比较数组是否相等则会误把内容相同的新批次当作旧批次。这里用单调增加的业务版本号,只是 Demo 的前置协议约束,不宣称它是系统自动生成的版本信息。数据提供方如果做不到单调递增,页面就应该更换可验证的业务批次标识。

三、Canvas 的责任是清屏、坐标、再绘制
Canvas 是命令式绘图接口。官方示例使用 RenderingContextSettings、CanvasRenderingContext2D 和 Canvas(...).onReady 获取可绘制时机。这里沿用这套基础接口,不引入不存在的“自动 diff 画布”能力。第二段代码展示页面关键部分:首次 onReady 才绘制;每次接受新快照时,都先对整块逻辑区域做 clearRect,再画五根柱子。关键不是哪一种颜色,而是所有分支都走同一个“整帧重建”的入口。
@Entry
@Component
struct HistogramPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private ready: boolean = false;
private current: HistogramSnapshot = createSnapshot('CH-1707', 3, [2, 4, 1, 0, 3]);
@State private statusText: string = 'v3 · 10 个样本';
private paint(s: HistogramSnapshot): void {
if (!this.ready) { return; }
this.ctx.clearRect(0, 0, 320, 180);
if (s.total === 0) {
this.ctx.fillStyle = '#60788A';
this.ctx.fillText('暂无有效样本', 108, 92);
return;
}
const maxCount = Math.max(1, ...s.bins);
s.bins.forEach((count: number, index: number) => {
const height = Math.round(count / maxCount * 112);
this.ctx.fillStyle = '#0D978F';
if (height > 0) {
this.ctx.fillRect(34 + 57 * index, 148 - height, 30, height);
}
});
}
build() {
Column({ space: 14 }) {
Text('CloudHistogram · CH-1707').fontSize(20)
Text(this.statusText).fontSize(15)
Canvas(this.ctx).width(320).height(180)
.backgroundColor('#F3F8FA')
.onReady(() => { this.ready = true; this.paint(this.current); })
}.padding(16).width('100%')
}
}
示例把画布坐标与组件尺寸都设为 320×180,避免只扩大组件外框、绘图坐标却仍按旧尺寸计算。真实项目还需要根据布局实测结果重新映射绘图区域,尤其折叠屏展开、分屏或字体缩放后,不能直接照搬固定数值。我没有在这里声称 clearRect 会释放所有图形资源:它只清除指定区域的绘制内容。示例不创建 ImageBitmap,也没有额外图片解码句柄需要释放;一旦加入离屏画布或外部像素对象,生命周期还得另行管理。
比例尺我用当前桶的最大计数归一化,而不是用总数。这样五个桶中最大值 4 的柱高固定为 112,其余高度由比例决定。这个选择适合观察“本批内部结构”,却不适合比较不同批次的绝对规模。所以在产品页面上必须同时展示总数 10,不能只让高柱看起来一样高。真正跨批对比需要固定刻度上界或并排对比方案,这次不做,以免模糊这篇的边界。
四、迟到版本不能重画旧统计
只把画布清干净还不够。假如 v4 已经进入页面,v3 的异步统计回调晚一点才回来,即便它也执行 clearRect,仍会把新图洗成旧图。所以第三段代码补的是事件门禁:验证同一个任务,版本号必须严格更大,然后才更新快照、文案和绘图。截图展示的是 v3 的静态示意态;下面的 v4 空样本与旧版回执只是验收输入,不代表我真的接过远端消息。
// 以下方法放入 HistogramPage 中,与上段的 current/paint 配合。
private accept(next: HistogramSnapshot): void {
if (next.taskId !== this.current.taskId) { return; }
if (next.version <= this.current.version) { return; }
this.current = createSnapshot(next.taskId, next.version, next.bins);
this.statusText = `v${this.current.version} · ${this.current.total} 个样本`;
this.paint(this.current);
}
// 可复现的人工输入:
// accept(createSnapshot('CH-1707', 4, [0, 0, 0, 0, 0]));
// accept(createSnapshot('CH-1707', 3, [2, 4, 1, 0, 3])); // 被拒绝
这里的资源边界也有一个小细节:onReady 只处理画布准备好以后首次绘制,如果数据比画布先到,则 accept 可以先更新内存快照,待 onReady 再绘制最新一份。反过来,画布已就绪后数据到达,则立即清屏重画。这样避免了为了等待 Canvas 而额外引入计时器轮询,也不用担心忘记清除轮询任务。不过如果页面退出后仍有业务订阅回调,就必须在上层解除订阅或者使回调失效;本文没有伪造某个具体订阅 API。
异常路径同样需要明说。如果输入分桶长度不是 5,createSnapshot 会抛错。生产应用应在接入层捕获异常,记录批次标识和错误类别,展示“统计数据不可用”,而不是继续把半截图留在页面。若希望错误态也立即清空旧图,应提供统一的 renderError 清屏入口,不能简单 return。这种分支可以在后续工程扩展,但此处选择把非法数据拒绝在入画布之前,便于读者分清数据校验与 Canvas 自身的职责。

五、用验收清单而不是一张漂亮图收尾
我给这套示例列了四条可操作检查。第一,v3 数据显示五个固定槽位,第四槽为零但第五槽仍在最右;第二,把 v4 数据全部改成零,应先清除旧柱再显示空态,不能残留任何 v3 的高度;第三,随后送进迟到的 v3,不应改变状态;第四,把画布挂载前送来的有效新批次暂存起来,挂载后只绘制最新快照。四条各自检查不同层面的缺陷,单看初始页面是发现不了的。
这些是建议的验收步骤,不是已经完成的设备测试记录。本素材包中的白色 DevEco 开发界面、右侧模拟器和底部 HiLog 都是示意排版,日志是为讲解状态路径而编排的演示文字;纯手机图也不是设备截屏。代码展示了可以移植到 ArkUI 工程的核心思路,但并未声称在特定 HarmonyOS SDK 版本上编译通过。涉及布局单位、画布重建时机和状态驱动的细节,仍应在目标设备和 API 级别下验证。
对我来说,这个小改动最有价值的地方不是画出了五根柱子,而是把业务快照、绘图动作和批次门禁拆开。统计数据由业务层负责可信,Canvas 只负责把一帧画对,异步回执要先过版本检查。将来即使把五个桶换成更多质量等级,或者把柱形图替换成折线,真正应该沿用的也是这三个边界,而不是某一行 fillRect 的颜色参数。
官方核对资料: 华为 Canvas 组件参考;华为 CanvasGradient 与 Canvas 绘制示例。资料用于核对组件、上下文和绘制方法;本例的批次版本、桶算法与错误状态均是应用自定义逻辑。
更多推荐




所有评论(0)