像素画编辑器最容易被低估的地方是渲染。

表面上看,它只是把一个 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、手写板、地图标注、图像编辑或涂鸦白板,都可以沿用同一判断:先拆开数据源和渲染层,再为高频路径设计合并刷新和局部刷新。

下一篇可以继续拆触控坐标映射:缩放、平移、多指手势和连续笔触是如何接起来的。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐