bitArt 系列 03:Canvas 渲染

📅 2026/7/14 20:08:26
bitArt 系列 03:Canvas 渲染
像素画编辑器最容易被低估的地方是渲染。表面上看它只是把一个string[]颜色数组画成方块。但只要用户开始连续拖动画布就会变成高频更新区域。每移动一次手指都可能触发像素修改、图层合成、Canvas 重绘和 UI 状态更新。如果每次都全量重绘体验很快会出现卡顿、闪烁或触控延迟。bitArt 的画布渲染主要分两层PixelCanvasView.etsArkUI 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; } } }真正绘制一个区间时只调用一次fillRectcontext.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 );它还给清理区域额外扩了 2pxconst 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、手写板、地图标注、图像编辑或涂鸦白板都可以沿用同一判断先拆开数据源和渲染层再为高频路径设计合并刷新和局部刷新。下一篇可以继续拆触控坐标映射缩放、平移、多指手势和连续笔触是如何接起来的。