原生鸿蒙像素画板实战 03:Canvas 高性能渲染
像素画编辑器最容易被低估的地方是渲染。
表面上看,它只是把一个 string[] 颜色数组画成方块。但只要用户开始连续拖动,画布就会变成高频更新区域。每移动一次手指,都可能触发像素修改、图层合成、Canvas 重绘和 UI 状态更新。如果每次都全量重绘,体验很快会出现卡顿、闪烁或触控延迟。
bitArt 的画布渲染主要分两层:
PixelCanvasView.ets:ArkUI Canvas 组件,负责触摸、缩放、平移、调度刷新。PixelCanvasRendererService.ets:渲染服务,负责真正把像素矩阵画到 Canvas 上。
1. Canvas 组件只接收最终像素
PixelCanvasView 接收的是已经合成好的 pixels,而不是完整项目模型:
@Prop @Watch('handlePixelsChange') pixels: string[] = [];
@Prop @Watch('redraw') gridVisible: boolean = true;
@Prop @Watch('handleCanvasSizeChange') canvasWidth: number = 24;
@Prop @Watch('handleCanvasSizeChange') canvasHeight: number = 24;
@Prop @Watch('handleDirtyVersionChange') dirtyVersion: number = 0;
@Prop dirtyMinRow: number = 0;
@Prop dirtyMinCol: number = 0;
@Prop dirtyMaxRow: number = 0;
@Prop dirtyMaxCol: number = 0;
这个设计让渲染组件保持单一职责。它不关心这些像素来自当前图层、合成图层还是动画预览,只负责把传入的数据画出来。
真正的图层合成发生在 LayerCanvasService,页面把合成结果传给 Canvas。这样渲染组件不需要理解图层栈,也避免把业务逻辑塞进 Canvas 组件。
2. 16ms 合并刷新:不要每个状态变化都立即画
在 ArkUI 中,属性变化可能很频繁。尤其是连续绘制时,像素数组、脏区域版本、工具状态都有可能变动。如果每次 watch 回调都直接重绘,会浪费很多帧。
bitArt 使用一个 16ms 的调度窗口:
const CANVAS_REDRAW_FRAME_MS: number = 16;
private scheduleCanvasRedraw(fullRedraw: boolean): void {
this.pendingFullRedraw = this.pendingFullRedraw || fullRedraw;
if (this.redrawTimerId >= 0) {
return;
}
this.redrawTimerId = setTimeout(() => {
this.redrawTimerId = -1;
this.captureDirtyBounds(true);
if (this.pendingFullRedraw || !this.drawDirtyCanvas()) {
this.drawCanvas();
}
this.pendingFullRedraw = false;
}, CANVAS_REDRAW_FRAME_MS);
}
这里的思路类似“按帧合并”。16ms 约等于 60fps 的一帧时间,多个变化会被合并到一次 Canvas 操作里。
这个小设计对画布类应用非常重要。它能减少重复绘制,也能避免 UI 线程被大量同步渲染调用占满。
3. 全量绘制路径:先算画布位置,再画像素
全量绘制由 PixelCanvasRendererService.drawPixelGrid 完成。它先根据视口、缩放和平移计算实际绘制区域:
const drawWidth = baseWidth * zoomScale;
const drawHeight = baseHeight * zoomScale;
const drawLeft = (viewportWidth - drawWidth) / 2 + panOffsetX;
const drawTop = (viewportHeight - drawHeight) / 2 + panOffsetY;
const cellWidth = drawWidth / gridWidth;
const cellHeight = drawHeight / gridHeight;
这几个变量决定了像素矩阵如何映射到屏幕:
drawWidth/drawHeight:缩放后的画布尺寸。drawLeft/drawTop:画布在视口中的左上角。cellWidth/cellHeight:每个像素格子的屏幕尺寸。
然后清背景、画像素、画边框和网格:
context.fillStyle = PixelCanvasRendererService.normalizeBackgroundColor(backgroundColor);
context.fillRect(0, 0, viewportWidth, viewportHeight);
PixelCanvasRendererService.drawPixelRuns(
context,
pixels,
gridWidth,
drawLeft,
drawTop,
cellWidth,
cellHeight,
0,
0,
gridHeight - 1,
gridWidth - 1
);
注意这里没有逐像素直接 fillRect,而是交给了 drawPixelRuns。
4. 同色连续段批绘:把相邻像素合成一次 fillRect
像素画里经常会出现连续同色区域,比如背景、色块、线条。逐像素绘制会产生大量 fillRect 调用。bitArt 按行扫描相邻的同色像素,把一段连续色块合并为一次绘制。
核心逻辑是:每一行从左到右扫描,把连续相同颜色的像素合并成一个区间。
private static drawPixelRuns(context: CanvasRenderingContext2D, pixels: string[], gridWidth: number,
drawLeft: number, drawTop: number, cellWidth: number, cellHeight: number,
startRow: number, startCol: number, endRow: number, endCol: number): void {
for (let row = startRow; row <= endRow; row++) {
let runColor = '';
let runStartCol = startCol;
for (let col = startCol; col <= endCol + 1; col++) {
const color = col <= endCol ? (pixels[row * gridWidth + col] || '') : '';
if (col === startCol) {
runColor = color;
runStartCol = col;
continue;
}
if (color === runColor) {
continue;
}
PixelCanvasRendererService.drawPixelRun(
context, runColor, drawLeft, drawTop, cellWidth, cellHeight,
row, runStartCol, col - 1
);
runColor = color;
runStartCol = col;
}
}
}
真正绘制一个区间时,只调用一次 fillRect:
context.fillStyle = color;
context.fillRect(
drawLeft + startCol * cellWidth,
drawTop + row * cellHeight,
(endCol - startCol + 1) * cellWidth + 0.5,
cellHeight + 0.5
);
如果一行 64 个像素都是同一种颜色,原本要 64 次绘制,现在只需要 1 次。对于大面积色块,这个优化非常划算。
5. 网格线不是永远都该画
像素画编辑器通常需要网格线,但网格线也很贵。画布缩小时,每个像素格子可能只有 2px 或 3px,再画网格线不仅浪费性能,还会让画面变脏。
bitArt 用两个条件决定是否画网格:
static shouldDrawGrid(gridSize: number, cellSize: number): boolean {
if (cellSize < PixelCanvasRendererService.MIN_GRID_CELL_SIZE) {
return false;
}
return gridSize <= 128;
}
也就是说:
- 单个格子太小,不画网格。
- 画布尺寸超过 128,也不画网格。
这是一个很实用的规则。用户缩小画布看整体时,不需要每条网格线;用户放大局部编辑时,网格才有价值。
6. 脏区域刷新:只重画改过的矩形
连续绘制时,很多时候只改变一个很小区域。如果每次全量清屏再重画全部像素,会浪费大量工作。
bitArt 通过 dirtyVersion 和四个边界值记录变化区域:
@Prop @Watch('handleDirtyVersionChange') dirtyVersion: number = 0;
@Prop dirtyMinRow: number = 0;
@Prop dirtyMinCol: number = 0;
@Prop dirtyMaxRow: number = 0;
@Prop dirtyMaxCol: number = 0;
组件会先尝试局部刷新:
private drawDirtyCanvas(): boolean {
if (!this.canDrawDirtyCanvas()) {
return false;
}
PixelCanvasRendererService.drawPixelGridDirty(
this.context,
this.pixels,
this.canvasWidth,
this.canvasHeight,
this.viewportWidth,
this.viewportHeight,
this.canvasBaseWidth,
this.canvasBaseHeight,
this.zoomScale,
this.panOffsetX,
this.panOffsetY,
this.pendingDirtyMinRow,
this.pendingDirtyMinCol,
this.pendingDirtyMaxRow,
this.pendingDirtyMaxCol,
this.canvasBackgroundColor
);
return true;
}
但它不是无条件局部刷新。canDrawDirtyCanvas 里有几个保护条件:
if (!this.canvasReady || this.pixelBleedEnabled || !this.pendingDirtyAvailable ||
this.dirtyVersion <= this.lastRenderedDirtyVersion) {
return false;
}
if (this.gridVisible && PixelCanvasRendererService.shouldDrawGrid(...)) {
return false;
}
这里的取舍很细。开启像素柔化时,像素之间会互相采样,局部刷新容易出现边界不一致,所以回退全量刷新。网格线可见时,局部清除可能破坏网格连续性,也回退全量刷新。
7. 局部刷新如何清理旧像素
局部刷新不是简单把新像素盖上去。因为橡皮擦可能把有色像素变成透明,如果不先清理旧区域,旧颜色会残留。
drawPixelGridDirty 会先把脏区域对应的屏幕矩形填回背景色,再绘制这一区域的新像素:
const clearLeft = drawLeft + startCol * cellWidth;
const clearTop = drawTop + startRow * cellHeight;
const clearRight = drawLeft + (endCol + 1) * cellWidth;
const clearBottom = drawTop + (endRow + 1) * cellHeight;
context.fillStyle = PixelCanvasRendererService.normalizeBackgroundColor(backgroundColor);
context.fillRect(clearRectLeft, clearRectTop, clearRectRight - clearRectLeft, clearRectBottom - clearRectTop);
PixelCanvasRendererService.drawPixelRuns(
context,
pixels,
gridWidth,
drawLeft,
drawTop,
cellWidth,
cellHeight,
startRow,
startCol,
endRow,
endCol
);
它还给清理区域额外扩了 2px:
const clearRectLeft = Math.floor(clearLeft - 2);
const clearRectTop = Math.floor(clearTop - 2);
const clearRectRight = Math.ceil(clearRight + 2);
const clearRectBottom = Math.ceil(clearBottom + 2);
这个小扩边可以减少浮点计算和抗锯齿边缘带来的残留问题。
8. 像素柔化单独限流
bitArt 还有一个 pixelBleedEnabled,用于降低像素网格感。它不是简单画方块,而是做采样和插值:
const renderScale = 1 + strengthRatio * 2.4;
const renderLimit = PixelCanvasRendererService.resolveSmoothRenderLimit(gridWidth, gridHeight);
const renderCols = Math.min(renderLimit, Math.max(gridWidth, Math.ceil(gridWidth * renderScale)));
这种绘制明显比普通方块贵,所以组件对柔化强度变化也做了 16ms 延迟刷新:
schedulePixelBleedRedraw(): void {
if (this.pixelBleedRedrawTimerId >= 0) {
return;
}
this.pixelBleedRedrawTimerId = setTimeout(() => {
this.pixelBleedRedrawTimerId = -1;
this.drawCanvas();
}, 16);
}
高频滑动 slider 时,这能避免每一个数值变化都触发昂贵重绘。
9. 怎么确认这些优化真正生效
bitArt 的 Canvas 渲染没有依赖特别复杂的底层能力,但它做对了几个关键工程取舍:
- Canvas 组件只接收最终像素,职责清晰。
- 使用 16ms 调度窗口合并多次刷新。
- 把同一行的连续同色像素合并,减少
fillRect调用。 - 网格线按缩放和画布尺寸决定是否绘制。
- 普通绘制优先尝试脏区域刷新。
- 柔化、网格等不适合局部刷新的场景自动回退全量绘制。
验证时可以分别画单点、长直线和大面积填充,并在 24×24、128×128、256×256 画布上观察连续拖动。单点应只更新小范围,长直线不能出现断裂,大面积填充可以回退完整重绘;开启像素柔化后,拖动期间应保持轻量预览,松手后再恢复完整效果。还可以在渲染入口记录全量与局部绘制次数,确认普通笔触没有持续走全量路径。
这些优化不只适用于像素画编辑器。只要 HarmonyOS 应用里有高频 Canvas、手写板、地图标注、图像编辑或涂鸦白板,都可以沿用同一判断:先拆开数据源和渲染层,再为高频路径设计合并刷新和局部刷新。
下一篇可以继续拆触控坐标映射:缩放、平移、多指手势和连续笔触是如何接起来的。
更多推荐


所有评论(0)