外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这次做的是一张真正会运行的通勤路线看板:河流、桥梁、分级道路、建筑和公园构成夜间城市底图,方向列车沿着五个精细站点持续移动,走过的路线变成薄荷绿色;点击“生成离屏路线卡”后,当前画面被绘制进 PixelMap,列车位置立即冻结;返回实时画布后,路线继续运行。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这段GIF来自真机连续截图,不是两张静态效果图拼接。前半段是 DrawContext.canvas 实时绘制,中间切到 PixelMap → drawImage 并保持不动,最后回到实时Canvas继续移动。

本次结果

操作画布链路真机结果
打开实验页RenderNode.draw() 获取 DrawContext.canvas路线按 30 FPS 请求持续刷新,列车位置和进度同步变化
暂停运行停止 Animator 回调等待 3 秒后,进度和绘制次数保持不变
生成离屏路线卡PixelMap → drawing.Canvas → drawImage当前路线冻结为快照,页面显示 OFFSCREEN
返回实时画布释放 PixelMap,重新调用 invalidate()从快照位置继续运行,绘制次数恢复增长
重置路线进度归零并重建 Animator列车回到“滨江公园”重新出发
退出页面取消 Animator、释放 PixelMap 和 RenderNode日志出现 CANVAS_RELEASE action=EXIT

真机最终拿到的直接画布尺寸是 1174 × 856 px。这条路线是应用内置演示数据,没有读取定位、网络或真实通勤信息,因此本实验不需要申请权限。

实验准备

项目实际环境
设备HUAWEI Mate 60 Pro
系统HarmonyOS 7.0
SDKAPI 26
开发方式ArkTS / ArkUI
图形接口@kit.ArkGraphics2D
图像接口@kit.ImageKit
运行权限不需要

华为官方把 ArkTS Canvas 的获取和显示分成两条路径:一种是从自定义 RenderNodedraw(context) 中直接拿到可上屏 Canvas;另一种是使用 PixelMap 创建离屏 Canvas,完成绘制后再通过已有显示链路呈现结果。本次把两条路径都放进同一个案例里。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第一步:让 RenderNode 直接拿到上屏 Canvas

页面没有使用 ArkUI 的 CanvasRenderingContext2D,而是自定义 RenderNode。系统触发 draw() 时,context.canvas 就是本次直接绘制的目标。

import {
  AnimatorResult,
  DrawContext,
  FrameNode,
  NodeController,
  RenderNode,
  Size,
  UIContext
} from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { drawing } from '@kit.ArkGraphics2D';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'CanvasDisplayLab';

interface RoutePoint {
  x: number;
  y: number;
}

class MetroRouteRenderNode extends RenderNode {
  private progressValue: number = 0;
  private snapshotPixelMap?: image.PixelMap = undefined;
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;
  private drawCountValue: number = 0;

  draw(context: DrawContext): void {
    const width: number = Math.max(1, Math.round(context.sizeInPixel.width));
    const height: number = Math.max(1, Math.round(context.sizeInPixel.height));
    this.canvasWidth = width;
    this.canvasHeight = height;
    this.drawCountValue += 1;

    if (this.snapshotPixelMap) {
      context.canvas.clear(0xFF08111F);
      context.canvas.drawImage(this.snapshotPixelMap, 0, 0);
    } else {
      this.drawRouteScene(context.canvas, width, height, this.progressValue);
    }
  }

  setProgress(progress: number): void {
    this.progressValue = Math.max(0, Math.min(1, progress));
    if (!this.snapshotPixelMap) {
      this.invalidate();
    }
  }
}

draw() 里没有额外的“提交上屏”操作。路线绘制结束后,直接画布的结果会跟随 RenderNode 显示。

setProgress() 只做两件事:保存动画进度、调用 invalidate() 请求下一次绘制。进入离屏模式后不再调用 invalidate(),快照因此保持不动。

第二步:把通勤路线画出来

第一版只画了规则网格、几块大矩形和实心站点,技术链路能跑,但画面仍然像调试图。我们先用 ImageGen 做了一张精细地图设计参考,确定河流、桥梁、道路、建筑、公园、站点和列车的层级,再把这些元素拆回 ArkGraphics 2D 图元。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这张图只用于设计,不是运行结果,也没有作为背景图塞进应用。最终真机地图仍由 drawing.PathPenBrushRectCircle 实时绘制。

直接画布和离屏画布调用同一个 drawRouteScene(),两种模式不会出现两套地图:

drawRouteScene(
  canvas: drawing.Canvas,
  width: number,
  height: number,
  progress: number
): void {
  canvas.clear(0xFF08111F);
  this.drawTerrainTexture(canvas, width, height);
  this.drawRiver(canvas, width, height);
  this.drawRoadNetwork(canvas, width, height);
  this.drawBuildings(canvas, width, height);
  this.drawPark(canvas, width, height);

  const stations: RoutePoint[] = this.routePoints(width, height);
  const sampledRoute: RoutePoint[] =
    this.sampleRoute(width, height, 14);
  this.drawRouteBase(canvas, width, height);
  this.drawCompletedRoute(
    canvas,
    sampledRoute,
    progress,
    width
  );
  this.drawStations(canvas, stations, progress, width);
  this.drawVehicle(canvas, sampledRoute, progress, width);
}

河流使用闭合 Path 填充水面,再分别叠加水面反光和岸线。两座桥与道路则使用三层不同宽度的 Pen,得到路基、路面和中心高光:

private drawRiver(
  canvas: drawing.Canvas,
  width: number,
  height: number
): void {
  const river: drawing.Path = new drawing.Path();
  river.moveTo(-width * 0.03, height * 0.54);
  river.cubicTo(
    width * 0.11, height * 0.59,
    width * 0.16, height * 0.72,
    width * 0.29, height * 0.73
  );
  river.cubicTo(
    width * 0.43, height * 0.75,
    width * 0.47, height * 0.91,
    width * 0.61, height * 1.04
  );
  river.lineTo(-width * 0.03, height * 1.04);
  river.close();
  this.fillPath(canvas, river, 0xFF071B30);

  const shore: drawing.Path = new drawing.Path();
  shore.moveTo(-width * 0.03, height * 0.54);
  shore.cubicTo(
    width * 0.11, height * 0.59,
    width * 0.16, height * 0.72,
    width * 0.29, height * 0.73
  );
  shore.cubicTo(
    width * 0.43, height * 0.75,
    width * 0.47, height * 0.91,
    width * 0.61, height * 1.04
  );
  this.strokePath(canvas, shore, 0xFF1D4960, width * 0.008);
  this.strokePath(canvas, shore, 0x886DD7E8, width * 0.002);
}

建筑仍然使用矩形,但每栋楼的尺寸、位置、屋顶内框和窗灯不同;公园使用闭合曲线,内部再画步道和树冠。它们都是背景层,亮度低于通勤路线,不和主信息抢层级。

五个站点全部使用画布宽高比例计算。路线不再直接连接折线,而是为四段路线分别提供二次贝塞尔控制点:

private routePoints(width: number, height: number): RoutePoint[] {
  return [
    { x: width * 0.08, y: height * 0.80 },
    { x: width * 0.28, y: height * 0.64 },
    { x: width * 0.50, y: height * 0.56 },
    { x: width * 0.72, y: height * 0.40 },
    { x: width * 0.91, y: height * 0.18 }
  ];
}

private routeControls(width: number, height: number): RoutePoint[] {
  return [
    { x: width * 0.17, y: height * 0.82 },
    { x: width * 0.37, y: height * 0.50 },
    { x: width * 0.64, y: height * 0.58 },
    { x: width * 0.82, y: height * 0.31 }
  ];
}

private routeCurvePath(
  width: number,
  height: number
): drawing.Path {
  const stations = this.routePoints(width, height);
  const controls = this.routeControls(width, height);
  const path: drawing.Path = new drawing.Path();
  path.moveTo(stations[0].x, stations[0].y);
  for (let index: number = 0;
    index < controls.length;
    index += 1) {
    path.quadTo(
      controls[index].x,
      controls[index].y,
      stations[index + 1].x,
      stations[index + 1].y
    );
  }
  return path;
}

路线底轨绘制四次:最外层阴影、蓝灰路轨、内层高光和细反光。已经走过的部分使用采样后的贝塞尔点重新构造 Path,再叠加薄荷色外发光、主线和白绿色芯线。

列车不再是圆点。代码读取前后两个采样点计算方向向量,再用五边形 Path 画出箭头胶囊,后面增加短尾迹;站点由半透明光环、外圈、深色内芯、彩色中心和四个方向刻度组成,终点单独使用琥珀色。

第三步:用 NodeController 把 RenderNode 放进页面

RenderNode 不能单独出现在 ArkUI 页面里。我们先创建 FrameNode,再把自定义 RenderNode 挂到根渲染节点上,最后通过 NodeContainer 显示。

class MetroCanvasController extends NodeController {
  private rootNode?: FrameNode = undefined;
  private readonly routeNode: MetroRouteRenderNode =
    new MetroRouteRenderNode();

  makeNode(uiContext: UIContext): FrameNode | null {
    try {
      this.rootNode = new FrameNode(uiContext);
      const rootRenderNode: RenderNode | null =
        this.rootNode.getRenderNode();

      if (rootRenderNode) {
        this.routeNode.frame = {
          x: 0,
          y: 0,
          width: 360,
          height: 300
        };
        this.routeNode.clipToFrame = true;
        rootRenderNode.appendChild(this.routeNode);
        rootRenderNode.clipToFrame = true;
      }
      return this.rootNode;
    } catch (error) {
      const err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG,
        'CANVAS_NODE_CREATE_FAIL code=%{public}d message=%{public}s',
        err.code, err.message);
      return null;
    }
  }

  aboutToResize(size: Size): void {
    this.routeNode.frame = {
      x: 0,
      y: 0,
      width: size.width,
      height: size.height
    };
    this.routeNode.invalidate();
  }
}

页面中的容器只有一行:

private readonly controller: MetroCanvasController =
  new MetroCanvasController();

build(): void {
  Column() {
    NodeContainer(this.controller)
      .width('100%')
      .height(310)
  }
}

真机进入页面后,直接画布已经开始刷新:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第四步:用 Animator 驱动实时重绘

路线使用 9 秒循环 Animator,请求帧率为 30 FPS。每次 onFrame 把进度交给 Controller,Controller 再交给 RenderNode。

private routeAnimator?: AnimatorResult = undefined;
private running: boolean = true;

private startAnimator(): void {
  this.routeAnimator = this.getUIContext().createAnimator({
    duration: 9000,
    easing: 'linear',
    delay: 0,
    fill: 'both',
    direction: 'normal',
    iterations: -1,
    begin: 0,
    end: 1
  });

  this.routeAnimator.setExpectedFrameRateRange({
    min: 30,
    max: 30,
    expected: 30
  });

  this.routeAnimator.onFrame = (progress: number): void => {
    this.controller.setProgress(progress);
  };
  this.routeAnimator.play();
}

private pauseRoute(): void {
  this.routeAnimator?.pause();
  this.running = false;
}

private resumeRoute(): void {
  this.routeAnimator?.play();
  this.running = true;
}

点击暂停时,列车、绿色路线和百分比停在同一个位置。精修版记录到暂停时为 32%、绘制次数 90;等待 3 秒再次读取,两个值仍然是 32%90

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第五步:把当前路线画进 PixelMap

离屏快照不是截取手机屏幕,而是按直接画布的实际像素尺寸创建一个可编辑 PixelMap,再用它构造新的 drawing.Canvas

async createSnapshot(): Promise<boolean> {
  const metrics = this.routeNode.getMetrics();
  if (metrics.width <= 1 || metrics.height <= 1) {
    return false;
  }

  const pixelBuffer: ArrayBuffer = new ArrayBuffer(
    metrics.width * metrics.height * 4
  );
  const options: image.InitializationOptions = {
    editable: true,
    pixelFormat: image.PixelMapFormat.RGBA_8888,
    size: {
      width: metrics.width,
      height: metrics.height
    }
  };

  const pixelMap: image.PixelMap =
    await image.createPixelMap(pixelBuffer, options);
  const offscreenCanvas: drawing.Canvas =
    new drawing.Canvas(pixelMap);

  this.routeNode.drawRouteScene(
    offscreenCanvas,
    metrics.width,
    metrics.height,
    metrics.progress
  );
  this.routeNode.setSnapshot(pixelMap);
  return true;
}

生成过程可以拆成四步:

  1. 读取直接画布的真实宽高和当前进度;
  2. width × height × 4 分配 RGBA 缓冲区;
  3. 创建 PixelMap 和离屏 drawing.Canvas
  4. 调用同一个 drawRouteScene(),再让 RenderNode 使用 drawImage() 显示 PixelMap。

真机生成第 1 张离屏路线卡后,状态从绿色 DIRECT 变成黄色 OFFSCREEN,显示方式变为 PixelMap → drawImage

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

快照生成时的进度为 59%、绘制次数为 162。等待 3 秒后再次读取,仍然是 59%162,离屏结果没有偷偷继续刷新。

第六步:返回实时画布并释放资源

返回实时模式时先释放旧 PixelMap,再调用 invalidate() 触发直接画布重绘:

showDirectCanvas(): void {
  this.releaseSnapshot();
  this.invalidate();
}

private releaseSnapshot(): void {
  const oldPixelMap: image.PixelMap | undefined =
    this.snapshotPixelMap;
  this.snapshotPixelMap = undefined;
  if (oldPixelMap) {
    oldPixelMap.release().catch((error: BusinessError) => {
      hilog.warn(DOMAIN, TAG,
        'CANVAS_PIXELMAP_RELEASE_FAIL code=%{public}d',
        error.code);
    });
  }
}

releaseAll(): void {
  this.releaseSnapshot();
  this.dispose();
}

页面退出时还会取消 Animator 的回调,避免离开页面后继续触发绘制。真机返回实时画布后,路线从快照位置继续前进,底部重新显示 DrawContext.canvas

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

实操中遇到的四个情况

1. 画布正常运行,指标卡却一直显示 0

第一版把动态值作为普通参数传给 @Builder

this.buildEvidenceRow('画布尺寸', this.canvasSize)
this.buildEvidenceRow('绘制次数', `${this.drawCount}`)

日志里的绘制次数持续增长,但真机卡片一直保留首次构建值。最后把这三行动态 Text 直接放回组件构建树,让它们直接读取 @State,尺寸和次数才跟随刷新。

这个问题不影响 Canvas 本身,却会让验证页面给出错误状态,所以修复后重新安装并把所有截图重做了一遍。

2. 精细地图第一次上屏后,建筑仍然变成黑框

第一轮视觉精修已经加入河流、道路和公园,但建筑画刷先挂到 Canvas、后修改颜色,部分绘制仍沿用了挂载时的默认黑色。最终把颜色状态调整为固定顺序:

private fillRect(
  canvas: drawing.Canvas,
  left: number,
  top: number,
  right: number,
  bottom: number,
  color: number
): void {
  const brush: drawing.Brush = new drawing.Brush();
  brush.setAntiAlias(true);
  brush.setColor(color);
  canvas.attachBrush(brush);
  canvas.drawRect({
    left: left,
    top: top,
    right: right,
    bottom: bottom
  });
  canvas.detachBrush();
}

建筑主体、屋顶、窗灯、树冠和站点中心全部改成“先设颜色、再 attach、绘制、detach”。重新安装后,建筑恢复蓝灰层级,窗灯和站点中心也按设计色显示。

3. Alignment.CenterStart 编译失败

第一次编译使用了不存在的枚举值,ArkTS 编译器直接报错。当前 SDK 下改成 Alignment.Start 后通过:

Stack({ alignContent: Alignment.Start }) {
  // 进度底轨与前景轨
}

4. 打包阶段出现 spawn java ENOENT

ArkTS 编译已经完成,但打包进程找不到 java.exe。补齐 JBR 的 JAVA_HOME 和 PATH 后重新执行,最终干净构建通过:

$env:JAVA_HOME = '<DevEco Studio>/jbr'
$env:Path = '<DevEco Studio>/jbr/bin;' + $env:Path

最终真机日志

下面保留本次最终 HAP 的完整关键链路,应用标识已删除:

CANVAS_READY mode=DIRECT durationMs=9000 expectedFps=30 permissionRequired=false route=demo
CANVAS_RESIZE widthVp=425 heightVp=310
CANVAS_DRAW mode=DIRECT drawCount=1 width=995 height=829 progressPermille=0
CANVAS_DRAW mode=DIRECT drawCount=30 width=1174 height=856 progressPermille=101
CANVAS_DRAW mode=DIRECT drawCount=60 width=1174 height=856 progressPermille=213
CANVAS_DRAW mode=DIRECT drawCount=90 width=1174 height=856 progressPermille=324
CANVAS_PAUSE progressPermille=324 drawCount=90
CANVAS_RESUME progressPermille=324 drawCount=90
CANVAS_DRAW mode=DIRECT drawCount=120 width=1174 height=856 progressPermille=434
CANVAS_DRAW mode=DIRECT drawCount=150 width=1174 height=856 progressPermille=545
CANVAS_SNAPSHOT success=true snapshotNumber=1 width=1174 height=856 progressPermille=590
CANVAS_DIRECT_RESUME snapshotNumber=1 progressPermille=590
CANVAS_DRAW mode=DIRECT drawCount=180 width=1174 height=856 progressPermille=650
CANVAS_DRAW mode=DIRECT drawCount=210 width=1174 height=856 progressPermille=762
CANVAS_DRAW mode=DIRECT drawCount=240 width=1174 height=856 progressPermille=876
CANVAS_RELEASE action=EXIT mode=DIRECT drawCount=253 callbacks=250 snapshots=1 progressPermille=925

最后结果

本次实验完成了两条 Canvas 显示路径:

  • 直接路径使用 NodeContainer → NodeController → RenderNode.draw() → DrawContext.canvas,由 Animator 和 invalidate() 持续刷新;
  • 离屏路径使用 PixelMap → drawing.Canvas → drawRouteScene() → drawImage(),把当前路线冻结成快照;
  • 暂停、恢复、重置、生成快照、返回实时画布和退出释放都在真机上完成验证;
  • 最终干净构建成功,静态回归同时覆盖直接/离屏链路和精细地图图层。

本次没有测量 GPU 占用、功耗或真实交通数据,也没有把“请求 30 FPS”写成“稳定达到 30 FPS”。能够确认的是:真机直接画布持续绘制、暂停后状态不变、离屏 PixelMap 成功生成并显示、返回后实时绘制恢复。

官方资料:画布的获取与绘制结果的显示(ArkTS)

这次做的是一张真正会运行的通勤路线看板:河流、桥梁、分级道路、建筑和公园构成夜间城市底图,方向列车沿着五个精细站点持续移动,走过的路线变成薄荷绿色;点击“生成离屏路线卡”后,当前画面被绘制进 PixelMap,列车位置立即冻结;返回实时画布后,路线继续运行。

这段GIF来自真机连续截图,不是两张静态效果图拼接。前半段是 DrawContext.canvas 实时绘制,中间切到 PixelMap → drawImage 并保持不动,最后回到实时Canvas继续移动。

本次结果

操作画布链路真机结果
打开实验页RenderNode.draw() 获取 DrawContext.canvas路线按 30 FPS 请求持续刷新,列车位置和进度同步变化
暂停运行停止 Animator 回调等待 3 秒后,进度和绘制次数保持不变
生成离屏路线卡PixelMap → drawing.Canvas → drawImage当前路线冻结为快照,页面显示 OFFSCREEN
返回实时画布释放 PixelMap,重新调用 invalidate()从快照位置继续运行,绘制次数恢复增长
重置路线进度归零并重建 Animator列车回到“滨江公园”重新出发
退出页面取消 Animator、释放 PixelMap 和 RenderNode日志出现 CANVAS_RELEASE action=EXIT

真机最终拿到的直接画布尺寸是 1174 × 856 px。这条路线是应用内置演示数据,没有读取定位、网络或真实通勤信息,因此本实验不需要申请权限。

实验准备

项目实际环境
设备HUAWEI Mate 60 Pro
系统HarmonyOS 7.0
SDKAPI 26
开发方式ArkTS / ArkUI
图形接口@kit.ArkGraphics2D
图像接口@kit.ImageKit
运行权限不需要

华为官方把 ArkTS Canvas 的获取和显示分成两条路径:一种是从自定义 RenderNodedraw(context) 中直接拿到可上屏 Canvas;另一种是使用 PixelMap 创建离屏 Canvas,完成绘制后再通过已有显示链路呈现结果。本次把两条路径都放进同一个案例里。

第一步:让 RenderNode 直接拿到上屏 Canvas

页面没有使用 ArkUI 的 CanvasRenderingContext2D,而是自定义 RenderNode。系统触发 draw() 时,context.canvas 就是本次直接绘制的目标。

import {
  AnimatorResult,
  DrawContext,
  FrameNode,
  NodeController,
  RenderNode,
  Size,
  UIContext
} from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { drawing } from '@kit.ArkGraphics2D';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'CanvasDisplayLab';

interface RoutePoint {
  x: number;
  y: number;
}

class MetroRouteRenderNode extends RenderNode {
  private progressValue: number = 0;
  private snapshotPixelMap?: image.PixelMap = undefined;
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;
  private drawCountValue: number = 0;

  draw(context: DrawContext): void {
    const width: number = Math.max(1, Math.round(context.sizeInPixel.width));
    const height: number = Math.max(1, Math.round(context.sizeInPixel.height));
    this.canvasWidth = width;
    this.canvasHeight = height;
    this.drawCountValue += 1;

    if (this.snapshotPixelMap) {
      context.canvas.clear(0xFF08111F);
      context.canvas.drawImage(this.snapshotPixelMap, 0, 0);
    } else {
      this.drawRouteScene(context.canvas, width, height, this.progressValue);
    }
  }

  setProgress(progress: number): void {
    this.progressValue = Math.max(0, Math.min(1, progress));
    if (!this.snapshotPixelMap) {
      this.invalidate();
    }
  }
}

draw() 里没有额外的“提交上屏”操作。路线绘制结束后,直接画布的结果会跟随 RenderNode 显示。

setProgress() 只做两件事:保存动画进度、调用 invalidate() 请求下一次绘制。进入离屏模式后不再调用 invalidate(),快照因此保持不动。

第二步:把通勤路线画出来

第一版只画了规则网格、几块大矩形和实心站点,技术链路能跑,但画面仍然像调试图。我们先用 ImageGen 做了一张精细地图设计参考,确定河流、桥梁、道路、建筑、公园、站点和列车的层级,再把这些元素拆回 ArkGraphics 2D 图元。

这张图只用于设计,不是运行结果,也没有作为背景图塞进应用。最终真机地图仍由 drawing.PathPenBrushRectCircle 实时绘制。

直接画布和离屏画布调用同一个 drawRouteScene(),两种模式不会出现两套地图:

drawRouteScene(
  canvas: drawing.Canvas,
  width: number,
  height: number,
  progress: number
): void {
  canvas.clear(0xFF08111F);
  this.drawTerrainTexture(canvas, width, height);
  this.drawRiver(canvas, width, height);
  this.drawRoadNetwork(canvas, width, height);
  this.drawBuildings(canvas, width, height);
  this.drawPark(canvas, width, height);

  const stations: RoutePoint[] = this.routePoints(width, height);
  const sampledRoute: RoutePoint[] =
    this.sampleRoute(width, height, 14);
  this.drawRouteBase(canvas, width, height);
  this.drawCompletedRoute(
    canvas,
    sampledRoute,
    progress,
    width
  );
  this.drawStations(canvas, stations, progress, width);
  this.drawVehicle(canvas, sampledRoute, progress, width);
}

河流使用闭合 Path 填充水面,再分别叠加水面反光和岸线。两座桥与道路则使用三层不同宽度的 Pen,得到路基、路面和中心高光:

private drawRiver(
  canvas: drawing.Canvas,
  width: number,
  height: number
): void {
  const river: drawing.Path = new drawing.Path();
  river.moveTo(-width * 0.03, height * 0.54);
  river.cubicTo(
    width * 0.11, height * 0.59,
    width * 0.16, height * 0.72,
    width * 0.29, height * 0.73
  );
  river.cubicTo(
    width * 0.43, height * 0.75,
    width * 0.47, height * 0.91,
    width * 0.61, height * 1.04
  );
  river.lineTo(-width * 0.03, height * 1.04);
  river.close();
  this.fillPath(canvas, river, 0xFF071B30);

  const shore: drawing.Path = new drawing.Path();
  shore.moveTo(-width * 0.03, height * 0.54);
  shore.cubicTo(
    width * 0.11, height * 0.59,
    width * 0.16, height * 0.72,
    width * 0.29, height * 0.73
  );
  shore.cubicTo(
    width * 0.43, height * 0.75,
    width * 0.47, height * 0.91,
    width * 0.61, height * 1.04
  );
  this.strokePath(canvas, shore, 0xFF1D4960, width * 0.008);
  this.strokePath(canvas, shore, 0x886DD7E8, width * 0.002);
}

建筑仍然使用矩形,但每栋楼的尺寸、位置、屋顶内框和窗灯不同;公园使用闭合曲线,内部再画步道和树冠。它们都是背景层,亮度低于通勤路线,不和主信息抢层级。

五个站点全部使用画布宽高比例计算。路线不再直接连接折线,而是为四段路线分别提供二次贝塞尔控制点:

private routePoints(width: number, height: number): RoutePoint[] {
  return [
    { x: width * 0.08, y: height * 0.80 },
    { x: width * 0.28, y: height * 0.64 },
    { x: width * 0.50, y: height * 0.56 },
    { x: width * 0.72, y: height * 0.40 },
    { x: width * 0.91, y: height * 0.18 }
  ];
}

private routeControls(width: number, height: number): RoutePoint[] {
  return [
    { x: width * 0.17, y: height * 0.82 },
    { x: width * 0.37, y: height * 0.50 },
    { x: width * 0.64, y: height * 0.58 },
    { x: width * 0.82, y: height * 0.31 }
  ];
}

private routeCurvePath(
  width: number,
  height: number
): drawing.Path {
  const stations = this.routePoints(width, height);
  const controls = this.routeControls(width, height);
  const path: drawing.Path = new drawing.Path();
  path.moveTo(stations[0].x, stations[0].y);
  for (let index: number = 0;
    index < controls.length;
    index += 1) {
    path.quadTo(
      controls[index].x,
      controls[index].y,
      stations[index + 1].x,
      stations[index + 1].y
    );
  }
  return path;
}

路线底轨绘制四次:最外层阴影、蓝灰路轨、内层高光和细反光。已经走过的部分使用采样后的贝塞尔点重新构造 Path,再叠加薄荷色外发光、主线和白绿色芯线。

列车不再是圆点。代码读取前后两个采样点计算方向向量,再用五边形 Path 画出箭头胶囊,后面增加短尾迹;站点由半透明光环、外圈、深色内芯、彩色中心和四个方向刻度组成,终点单独使用琥珀色。

第三步:用 NodeController 把 RenderNode 放进页面

RenderNode 不能单独出现在 ArkUI 页面里。我们先创建 FrameNode,再把自定义 RenderNode 挂到根渲染节点上,最后通过 NodeContainer 显示。

class MetroCanvasController extends NodeController {
  private rootNode?: FrameNode = undefined;
  private readonly routeNode: MetroRouteRenderNode =
    new MetroRouteRenderNode();

  makeNode(uiContext: UIContext): FrameNode | null {
    try {
      this.rootNode = new FrameNode(uiContext);
      const rootRenderNode: RenderNode | null =
        this.rootNode.getRenderNode();

      if (rootRenderNode) {
        this.routeNode.frame = {
          x: 0,
          y: 0,
          width: 360,
          height: 300
        };
        this.routeNode.clipToFrame = true;
        rootRenderNode.appendChild(this.routeNode);
        rootRenderNode.clipToFrame = true;
      }
      return this.rootNode;
    } catch (error) {
      const err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG,
        'CANVAS_NODE_CREATE_FAIL code=%{public}d message=%{public}s',
        err.code, err.message);
      return null;
    }
  }

  aboutToResize(size: Size): void {
    this.routeNode.frame = {
      x: 0,
      y: 0,
      width: size.width,
      height: size.height
    };
    this.routeNode.invalidate();
  }
}

页面中的容器只有一行:

private readonly controller: MetroCanvasController =
  new MetroCanvasController();

build(): void {
  Column() {
    NodeContainer(this.controller)
      .width('100%')
      .height(310)
  }
}

真机进入页面后,直接画布已经开始刷新:

第四步:用 Animator 驱动实时重绘

路线使用 9 秒循环 Animator,请求帧率为 30 FPS。每次 onFrame 把进度交给 Controller,Controller 再交给 RenderNode。

private routeAnimator?: AnimatorResult = undefined;
private running: boolean = true;

private startAnimator(): void {
  this.routeAnimator = this.getUIContext().createAnimator({
    duration: 9000,
    easing: 'linear',
    delay: 0,
    fill: 'both',
    direction: 'normal',
    iterations: -1,
    begin: 0,
    end: 1
  });

  this.routeAnimator.setExpectedFrameRateRange({
    min: 30,
    max: 30,
    expected: 30
  });

  this.routeAnimator.onFrame = (progress: number): void => {
    this.controller.setProgress(progress);
  };
  this.routeAnimator.play();
}

private pauseRoute(): void {
  this.routeAnimator?.pause();
  this.running = false;
}

private resumeRoute(): void {
  this.routeAnimator?.play();
  this.running = true;
}

点击暂停时,列车、绿色路线和百分比停在同一个位置。精修版记录到暂停时为 32%、绘制次数 90;等待 3 秒再次读取,两个值仍然是 32%90

第五步:把当前路线画进 PixelMap

离屏快照不是截取手机屏幕,而是按直接画布的实际像素尺寸创建一个可编辑 PixelMap,再用它构造新的 drawing.Canvas

async createSnapshot(): Promise<boolean> {
  const metrics = this.routeNode.getMetrics();
  if (metrics.width <= 1 || metrics.height <= 1) {
    return false;
  }

  const pixelBuffer: ArrayBuffer = new ArrayBuffer(
    metrics.width * metrics.height * 4
  );
  const options: image.InitializationOptions = {
    editable: true,
    pixelFormat: image.PixelMapFormat.RGBA_8888,
    size: {
      width: metrics.width,
      height: metrics.height
    }
  };

  const pixelMap: image.PixelMap =
    await image.createPixelMap(pixelBuffer, options);
  const offscreenCanvas: drawing.Canvas =
    new drawing.Canvas(pixelMap);

  this.routeNode.drawRouteScene(
    offscreenCanvas,
    metrics.width,
    metrics.height,
    metrics.progress
  );
  this.routeNode.setSnapshot(pixelMap);
  return true;
}

生成过程可以拆成四步:

  1. 读取直接画布的真实宽高和当前进度;
  2. width × height × 4 分配 RGBA 缓冲区;
  3. 创建 PixelMap 和离屏 drawing.Canvas
  4. 调用同一个 drawRouteScene(),再让 RenderNode 使用 drawImage() 显示 PixelMap。

真机生成第 1 张离屏路线卡后,状态从绿色 DIRECT 变成黄色 OFFSCREEN,显示方式变为 PixelMap → drawImage

快照生成时的进度为 59%、绘制次数为 162。等待 3 秒后再次读取,仍然是 59%162,离屏结果没有偷偷继续刷新。

第六步:返回实时画布并释放资源

返回实时模式时先释放旧 PixelMap,再调用 invalidate() 触发直接画布重绘:

showDirectCanvas(): void {
  this.releaseSnapshot();
  this.invalidate();
}

private releaseSnapshot(): void {
  const oldPixelMap: image.PixelMap | undefined =
    this.snapshotPixelMap;
  this.snapshotPixelMap = undefined;
  if (oldPixelMap) {
    oldPixelMap.release().catch((error: BusinessError) => {
      hilog.warn(DOMAIN, TAG,
        'CANVAS_PIXELMAP_RELEASE_FAIL code=%{public}d',
        error.code);
    });
  }
}

releaseAll(): void {
  this.releaseSnapshot();
  this.dispose();
}

页面退出时还会取消 Animator 的回调,避免离开页面后继续触发绘制。真机返回实时画布后,路线从快照位置继续前进,底部重新显示 DrawContext.canvas

实操中遇到的四个情况

1. 画布正常运行,指标卡却一直显示 0

第一版把动态值作为普通参数传给 @Builder

this.buildEvidenceRow('画布尺寸', this.canvasSize)
this.buildEvidenceRow('绘制次数', `${this.drawCount}`)

日志里的绘制次数持续增长,但真机卡片一直保留首次构建值。最后把这三行动态 Text 直接放回组件构建树,让它们直接读取 @State,尺寸和次数才跟随刷新。

这个问题不影响 Canvas 本身,却会让验证页面给出错误状态,所以修复后重新安装并把所有截图重做了一遍。

2. 精细地图第一次上屏后,建筑仍然变成黑框

第一轮视觉精修已经加入河流、道路和公园,但建筑画刷先挂到 Canvas、后修改颜色,部分绘制仍沿用了挂载时的默认黑色。最终把颜色状态调整为固定顺序:

private fillRect(
  canvas: drawing.Canvas,
  left: number,
  top: number,
  right: number,
  bottom: number,
  color: number
): void {
  const brush: drawing.Brush = new drawing.Brush();
  brush.setAntiAlias(true);
  brush.setColor(color);
  canvas.attachBrush(brush);
  canvas.drawRect({
    left: left,
    top: top,
    right: right,
    bottom: bottom
  });
  canvas.detachBrush();
}

建筑主体、屋顶、窗灯、树冠和站点中心全部改成“先设颜色、再 attach、绘制、detach”。重新安装后,建筑恢复蓝灰层级,窗灯和站点中心也按设计色显示。

3. Alignment.CenterStart 编译失败

第一次编译使用了不存在的枚举值,ArkTS 编译器直接报错。当前 SDK 下改成 Alignment.Start 后通过:

Stack({ alignContent: Alignment.Start }) {
  // 进度底轨与前景轨
}

4. 打包阶段出现 spawn java ENOENT

ArkTS 编译已经完成,但打包进程找不到 java.exe。补齐 JBR 的 JAVA_HOME 和 PATH 后重新执行,最终干净构建通过:

$env:JAVA_HOME = '<DevEco Studio>/jbr'
$env:Path = '<DevEco Studio>/jbr/bin;' + $env:Path

最终真机日志

下面保留本次最终 HAP 的完整关键链路,应用标识已删除:

CANVAS_READY mode=DIRECT durationMs=9000 expectedFps=30 permissionRequired=false route=demo
CANVAS_RESIZE widthVp=425 heightVp=310
CANVAS_DRAW mode=DIRECT drawCount=1 width=995 height=829 progressPermille=0
CANVAS_DRAW mode=DIRECT drawCount=30 width=1174 height=856 progressPermille=101
CANVAS_DRAW mode=DIRECT drawCount=60 width=1174 height=856 progressPermille=213
CANVAS_DRAW mode=DIRECT drawCount=90 width=1174 height=856 progressPermille=324
CANVAS_PAUSE progressPermille=324 drawCount=90
CANVAS_RESUME progressPermille=324 drawCount=90
CANVAS_DRAW mode=DIRECT drawCount=120 width=1174 height=856 progressPermille=434
CANVAS_DRAW mode=DIRECT drawCount=150 width=1174 height=856 progressPermille=545
CANVAS_SNAPSHOT success=true snapshotNumber=1 width=1174 height=856 progressPermille=590
CANVAS_DIRECT_RESUME snapshotNumber=1 progressPermille=590
CANVAS_DRAW mode=DIRECT drawCount=180 width=1174 height=856 progressPermille=650
CANVAS_DRAW mode=DIRECT drawCount=210 width=1174 height=856 progressPermille=762
CANVAS_DRAW mode=DIRECT drawCount=240 width=1174 height=856 progressPermille=876
CANVAS_RELEASE action=EXIT mode=DIRECT drawCount=253 callbacks=250 snapshots=1 progressPermille=925

最后结果

本次实验完成了两条 Canvas 显示路径:

  • 直接路径使用 NodeContainer → NodeController → RenderNode.draw() → DrawContext.canvas,由 Animator 和 invalidate() 持续刷新;
  • 离屏路径使用 PixelMap → drawing.Canvas → drawRouteScene() → drawImage(),把当前路线冻结成快照;
  • 暂停、恢复、重置、生成快照、返回实时画布和退出释放都在真机上完成验证;
  • 最终干净构建成功,静态回归同时覆盖直接/离屏链路和精细地图图层。

本次没有测量 GPU 占用、功耗或真实交通数据,也没有把“请求 30 FPS”写成“稳定达到 30 FPS”。能够确认的是:真机直接画布持续绘制、暂停后状态不变、离屏 PixelMap 成功生成并显示、返回后实时绘制恢复。

官方资料:画布的获取与绘制结果的显示(ArkTS)

Logo

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

更多推荐