HarmonyOS ArkGraphics 2D Canvas上屏实操——做一个会运行、能冻结的通勤路线看板

这次做的是一张真正会运行的通勤路线看板:河流、桥梁、分级道路、建筑和公园构成夜间城市底图,方向列车沿着五个精细站点持续移动,走过的路线变成薄荷绿色;点击“生成离屏路线卡”后,当前画面被绘制进 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 |
| SDK | API 26 |
| 开发方式 | ArkTS / ArkUI |
| 图形接口 | @kit.ArkGraphics2D |
| 图像接口 | @kit.ImageKit |
| 运行权限 | 不需要 |
华为官方把 ArkTS Canvas 的获取和显示分成两条路径:一种是从自定义 RenderNode 的 draw(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.Path、Pen、Brush、Rect 和 Circle 实时绘制。
直接画布和离屏画布调用同一个 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;
}
生成过程可以拆成四步:
- 读取直接画布的真实宽高和当前进度;
- 按
width × height × 4分配 RGBA 缓冲区; - 创建
PixelMap和离屏drawing.Canvas; - 调用同一个
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 |
| SDK | API 26 |
| 开发方式 | ArkTS / ArkUI |
| 图形接口 | @kit.ArkGraphics2D |
| 图像接口 | @kit.ImageKit |
| 运行权限 | 不需要 |
华为官方把 ArkTS Canvas 的获取和显示分成两条路径:一种是从自定义 RenderNode 的 draw(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.Path、Pen、Brush、Rect 和 Circle 实时绘制。
直接画布和离屏画布调用同一个 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;
}
生成过程可以拆成四步:
- 读取直接画布的真实宽高和当前进度;
- 按
width × height × 4分配 RGBA 缓冲区; - 创建
PixelMap和离屏drawing.Canvas; - 调用同一个
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)
更多推荐

所有评论(0)