HarmonyOS开发:Canvas性能优化与高效绘制策略
HarmonyOS开发:Canvas性能优化与高效绘制策略
📌 核心要点:从绘制调用优化、路径合并批处理、脏区域重绘到硬件加速,系统掌握Canvas性能优化的核心策略,让复杂绘制场景的帧率从30fps提升到稳定60fps。
一、背景与动机
Canvas是HarmonyOS图形开发中最灵活也最容易"翻车"的组件。灵活在于——你可以用它在屏幕上画任何东西;容易翻车在于——稍不留神,性能就崩了。
我见过一个真实的案例:一个天气应用用Canvas绘制24小时温度曲线,数据点不多,才288个(每5分钟一个),但帧率只有25fps。开发者百思不得其解——“就这么点数据,怎么就卡了?”
打开代码一看,问题一目了然:每个数据点都单独创建了一个Path对象、设置了一次strokeStyle、调用了一次stroke()。288个点就是288次绘制调用,每次调用都要从CPU向GPU提交一次绘制命令,GPU在"接收命令→执行→等待下一个命令"之间反复切换,效率极低。
这就像去超市购物——如果你买100样东西,每拿一样就去收银台结账一次,那收银员(GPU)得崩溃。正确的做法是把100样东西都装进购物车,最后一次性结账。这就是**批处理(Batching)**的核心思想。
Canvas性能优化的本质,就是减少不必要的绘制工作,让每一次绘制调用都"物尽其用"。今天我们就来系统性地拆解Canvas性能优化的方方面面。
二、核心原理
2.1 Canvas性能瓶颈分析
Canvas的性能瓶颈通常出现在以下几个环节:
graph TD
A[Canvas绘制请求]:::primary --> B[CPU生成绘制命令]:::info
B --> C{命令提交瓶颈?}:::warning
C -->|绘制调用过多| D[CPU-GPU通信瓶颈]:::error
C -->|命令正常| E[GPU执行渲染]:::info
E --> F{渲染计算瓶颈?}:::warning
F -->|像素填充过多| G[Overdraw瓶颈]:::error
F -->|计算正常| H[帧缓冲输出]:::primary
D --> I[掉帧]:::error
G --> I
J[优化策略]:::primary
J --> K[减少绘制调用]:::info
J --> L[路径合并批处理]:::info
J --> M[脏区域重绘]:::info
J --> N[硬件加速]:::info
K -.-> D
L -.-> D
M -.-> G
N -.-> E
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef error fill:#F44336,stroke:#D32F2F,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
性能瓶颈的量化指标:
| 瓶颈类型 | 症状 | 典型原因 | 优化方向 |
|---|---|---|---|
| 绘制调用过多 | CPU占用高、GPU利用率低 | 每个元素单独绘制 | 批处理合并 |
| 路径操作过多 | stroke/fill耗时高 | 复杂Path未优化 | 路径简化 |
| Overdraw严重 | GPU填充率高 | 不必要的重叠绘制 | 裁剪和分层 |
| 全量重绘 | 每帧都重绘整个Canvas | 未使用脏区域 | 局部重绘 |
| 状态切换频繁 | 上下文状态设置耗时 | 频繁切换fillStyle等 | 状态排序 |
2.2 绘制调用优化原理
每一次Canvas的绘制调用(如fill()、stroke()、drawImage()),都要经历以下流程:
ArkTS调用 → 命令序列化 → 跨进程传输 → GPU命令解析 → GPU执行
这个流程的开销不在于GPU执行本身,而在于"命令提交"的过程。一次fill()可能只需要GPU 0.01ms来执行,但命令提交可能需要0.1ms。如果你调用1000次fill(),GPU执行时间只有10ms,但命令提交就要100ms——严重失衡。
优化核心:减少绘制调用次数,增加每次调用的"工作量"。
2.3 脏区域重绘原理
脏区域(Dirty Region)重绘是Canvas性能优化的利器。它的核心思想是:只重绘发生变化的区域,而不是每帧重绘整个Canvas。
全量重绘:每帧重绘 100% 的区域
脏区域重绘:每帧只重绘 5%-20% 的区域
性能提升:5-20倍
脏区域重绘的实现需要:
- 脏区域标记:记录哪些区域发生了变化
- 区域合并:将多个小脏区域合并为大区域(减少clip操作)
- 裁剪绘制:只在脏区域内执行绘制操作
三、代码实战
3.1 基础用法——绘制调用优化与路径合并
/**
* Canvas绘制调用优化示例
* 对比:逐个绘制 vs 批量绘制
*/
@Entry
@Component
struct DrawCallOptimization {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
@State drawMode: string = 'batch'; // 'single' | 'batch'
@State drawTime: number = 0;
build() {
Column() {
Canvas(this.context)
.width(360)
.height(360)
.backgroundColor('#F5F5F5')
.onReady(() => {
this.redraw();
})
Row() {
Button('逐个绘制')
.fontSize(14)
.height(36)
.backgroundColor(this.drawMode === 'single' ? '#F44336' : '#E0E0E0')
.fontColor(this.drawMode === 'single' ? Color.White : '#333333')
.onClick(() => {
this.drawMode = 'single';
this.redraw();
})
Button('批量绘制')
.fontSize(14)
.height(36)
.margin({ left: 8 })
.backgroundColor(this.drawMode === 'batch' ? '#4CAF50' : '#E0E0E0')
.fontColor(this.drawMode === 'batch' ? Color.White : '#333333')
.onClick(() => {
this.drawMode = 'batch';
this.redraw();
})
}
.margin({ top: 12 })
Text(`绘制耗时: ${this.drawTime.toFixed(2)}ms`)
.fontSize(16)
.margin({ top: 8 })
.fontColor(this.drawTime > 16 ? '#F44336' : '#4CAF50')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
/**
* 重新绘制
*/
private redraw(): void {
const startTime = Date.now();
const ctx = this.context;
const width = 360;
const height = 360;
ctx.clearRect(0, 0, width, height);
if (this.drawMode === 'single') {
this.drawSingleMode(ctx, width, height);
} else {
this.drawBatchMode(ctx, width, height);
}
this.drawTime = Date.now() - startTime;
}
/**
* 逐个绘制(低效)
* 每个圆单独一个Path、单独设置样式、单独fill
*/
private drawSingleMode(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const count = 500;
for (let i = 0; i < count; i++) {
const x = Math.random() * width;
const y = Math.random() * height;
const r = Math.random() * 10 + 3;
// ❌ 每个圆都是独立的绘制调用
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 0.7)`;
ctx.fill(); // 500次fill()调用!
}
}
/**
* 批量绘制(高效)
* 按颜色分组,相同颜色的圆合并到一个Path中
*/
private drawBatchMode(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const count = 500;
const colorGroups: Map<string, Array<{ x: number; y: number; r: number }>> = new Map();
// 第一步:按颜色分组
const colors = ['#e94560', '#0f3460', '#533483', '#00b4d8', '#4CAF50'];
for (let i = 0; i < count; i++) {
const color = colors[i % colors.length];
if (!colorGroups.has(color)) {
colorGroups.set(color, []);
}
colorGroups.get(color)!.push({
x: Math.random() * width,
y: Math.random() * height,
r: Math.random() * 10 + 3,
});
}
// 第二步:每种颜色只设置一次样式,合并到一个Path中
for (const [color, circles] of colorGroups) {
ctx.fillStyle = color; // 每种颜色只设置一次
ctx.beginPath();
for (const circle of circles) {
// ✅ 使用moveTo避免路径自动连接
ctx.moveTo(circle.x + circle.r, circle.y);
ctx.arc(circle.x, circle.y, circle.r, 0, Math.PI * 2);
}
ctx.fill(); // 每种颜色只调用一次fill(),总共5次!
}
}
}
性能对比:
| 模式 | fill()调用次数 | fillStyle设置次数 | 典型耗时 |
|---|---|---|---|
| 逐个绘制 | 500 | 500 | 25-40ms |
| 批量绘制 | 5 | 5 | 2-5ms |
从500次绘制调用降到5次,性能提升5-10倍!
3.2 进阶用法——脏区域重绘
脏区域重绘是Canvas性能优化的进阶技巧。下面的示例展示了如何实现一个高效的脏区域重绘系统。
/**
* 脏区域重绘管理器
* 核心思路:只重绘变化的区域,而不是整个Canvas
*/
class DirtyRegionManager {
private dirtyRegions: Rect[] = [];
private maxRegions: number = 10; // 最大脏区域数量
/**
* 标记一个区域为脏区域
*/
markDirty(x: number, y: number, width: number, height: number): void {
this.dirtyRegions.push({ x, y, width, height });
// 如果脏区域过多,合并所有区域为一个大区域
if (this.dirtyRegions.length > this.maxRegions) {
this.mergeAllRegions();
}
}
/**
* 获取所有脏区域
*/
getDirtyRegions(): Rect[] {
return [...this.dirtyRegions];
}
/**
* 判断是否有脏区域
*/
hasDirtyRegions(): boolean {
return this.dirtyRegions.length > 0;
}
/**
* 清空所有脏区域(重绘完成后调用)
*/
clearDirtyRegions(): void {
this.dirtyRegions = [];
}
/**
* 合并所有脏区域为一个大区域
*/
private mergeAllRegions(): void {
if (this.dirtyRegions.length === 0) return;
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
for (const region of this.dirtyRegions) {
minX = Math.min(minX, region.x);
minY = Math.min(minY, region.y);
maxX = Math.max(maxX, region.x + region.width);
maxY = Math.max(maxY, region.y + region.height);
}
this.dirtyRegions = [{
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
}];
}
/**
* 获取脏区域的包围盒
*/
getDirtyBounds(): Rect | null {
if (this.dirtyRegions.length === 0) return null;
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
for (const region of this.dirtyRegions) {
minX = Math.min(minX, region.x);
minY = Math.min(minY, region.y);
maxX = Math.max(maxX, region.x + region.width);
maxY = Math.max(maxY, region.y + region.height);
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
}
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
脏区域重绘在组件中的应用:
/**
* 脏区域重绘示例:可拖拽的圆形
* 只有被拖拽的圆形区域需要重绘
*/
@Entry
@Component
struct DirtyRegionDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private dirtyManager: DirtyRegionManager = new DirtyRegionManager();
@State circles: DraggableCircle[] = [];
private draggingIndex: number = -1;
private lastX: number = 0;
private lastY: number = 0;
private offscreenBg: OffscreenCanvas | null = null;
build() {
Column() {
Canvas(this.context)
.width(360)
.height(400)
.backgroundColor('#1a1a2e')
.onReady(() => {
this.initCircles();
this.initOffscreenBackground();
this.fullRedraw();
})
.onTouch((event: TouchEvent) => {
this.handleTouch(event);
})
Text('拖拽圆形体验脏区域重绘')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 8 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
/**
* 初始化圆形数据
*/
private initCircles(): void {
const colors = ['#e94560', '#0f3460', '#533483', '#00b4d8', '#4CAF50', '#FF9800'];
this.circles = [];
for (let i = 0; i < 6; i++) {
this.circles.push({
x: 60 + (i % 3) * 120,
y: 100 + Math.floor(i / 3) * 150,
radius: 40,
color: colors[i],
prevX: 60 + (i % 3) * 120,
prevY: 100 + Math.floor(i / 3) * 150,
});
}
}
/**
* 初始化离屏背景(静态内容只画一次)
*/
private initOffscreenBackground(): void {
this.offscreenBg = this.context.createOffscreenCanvas(360, 400);
const ctx = this.offscreenBg.getContext('2d');
// 绘制网格背景
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
ctx.lineWidth = 1;
for (let x = 0; x < 360; x += 30) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, 400);
ctx.stroke();
}
for (let y = 0; y < 400; y += 30) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(360, y);
ctx.stroke();
}
}
/**
* 全量重绘(初始化时使用)
*/
private fullRedraw(): void {
const ctx = this.context;
ctx.clearRect(0, 0, 360, 400);
// 绘制缓存的背景
if (this.offscreenBg) {
ctx.drawImage(this.offscreenBg, 0, 0);
}
// 绘制所有圆形
this.drawAllCircles(ctx);
}
/**
* 脏区域重绘(拖拽时使用)
*/
private dirtyRedraw(): void {
if (!this.dirtyManager.hasDirtyRegions()) return;
const ctx = this.context;
// 遍历所有脏区域
for (const region of this.dirtyManager.getDirtyRegions()) {
// 裁剪到脏区域
ctx.save();
ctx.beginPath();
ctx.rect(region.x, region.y, region.width, region.height);
ctx.clip();
// 只在脏区域内重绘
ctx.clearRect(region.x, region.y, region.width, region.height);
// 重绘背景(脏区域部分)
if (this.offscreenBg) {
ctx.drawImage(this.offscreenBg,
region.x, region.y, region.width, region.height,
region.x, region.y, region.width, region.height);
}
// 重绘与脏区域相交的圆形
for (const circle of this.circles) {
if (this.circleIntersectsRect(circle, region)) {
this.drawCircle(ctx, circle);
}
}
ctx.restore();
}
// 清空脏区域
this.dirtyManager.clearDirtyRegions();
}
/**
* 处理触摸事件
*/
private handleTouch(event: TouchEvent): void {
const touch = event.touches[0];
const x = touch.x;
const y = touch.y;
switch (event.type) {
case TouchType.Down: {
// 查找被点击的圆形
for (let i = this.circles.length - 1; i >= 0; i--) {
const c = this.circles[i];
const dist = Math.sqrt((x - c.x) ** 2 + (y - c.y) ** 2);
if (dist <= c.radius) {
this.draggingIndex = i;
this.lastX = x;
this.lastY = y;
break;
}
}
break;
}
case TouchType.Move: {
if (this.draggingIndex < 0) break;
const circle = this.circles[this.draggingIndex];
const dx = x - this.lastX;
const dy = y - this.lastY;
// 标记旧位置为脏区域
this.dirtyManager.markDirty(
circle.x - circle.radius - 5,
circle.y - circle.radius - 5,
circle.radius * 2 + 10,
circle.radius * 2 + 10
);
// 更新位置
circle.prevX = circle.x;
circle.prevY = circle.y;
circle.x += dx;
circle.y += dy;
// 标记新位置为脏区域
this.dirtyManager.markDirty(
circle.x - circle.radius - 5,
circle.y - circle.radius - 5,
circle.radius * 2 + 10,
circle.radius * 2 + 10
);
this.lastX = x;
this.lastY = y;
// 执行脏区域重绘
this.dirtyRedraw();
break;
}
case TouchType.Up: {
this.draggingIndex = -1;
break;
}
}
}
/**
* 绘制所有圆形
*/
private drawAllCircles(ctx: CanvasRenderingContext2D): void {
for (const circle of this.circles) {
this.drawCircle(ctx, circle);
}
}
/**
* 绘制单个圆形
*/
private drawCircle(ctx: CanvasRenderingContext2D, circle: DraggableCircle): void {
// 阴影
ctx.shadowColor = circle.color;
ctx.shadowBlur = 20;
ctx.beginPath();
ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2);
ctx.fillStyle = circle.color;
ctx.fill();
// 高光
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
const highlight = ctx.createRadialGradient(
circle.x - circle.radius * 0.3,
circle.y - circle.radius * 0.3,
0,
circle.x,
circle.y,
circle.radius
);
highlight.addColorStop(0, 'rgba(255, 255, 255, 0.3)');
highlight.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = highlight;
ctx.fill();
}
/**
* 判断圆形是否与矩形相交
*/
private circleIntersectsRect(circle: DraggableCircle, rect: Rect): boolean {
const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width));
const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height));
const distX = circle.x - closestX;
const distY = circle.y - closestY;
return (distX * distX + distY * distY) <= (circle.radius * circle.radius);
}
}
interface DraggableCircle {
x: number;
y: number;
radius: number;
color: string;
prevX: number;
prevY: number;
}
3.3 完整示例——Canvas性能优化实战
这个完整示例集成了绘制调用优化、路径合并、脏区域重绘和离屏缓存四大优化策略,并提供了性能对比面板。
/**
* Canvas性能优化实战
* 综合运用:批处理 + 脏区域 + 离屏缓存 + 状态排序
*/
@Entry
@Component
struct CanvasPerfOptimization {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private dirtyManager: DirtyRegionManager = new DirtyRegionManager();
// 离屏缓存
private gridOffscreen: OffscreenCanvas | null = null;
private chartOffscreen: OffscreenCanvas | null = null;
// 数据
@State dataPoints: ChartPoint[] = [];
@State highlightIndex: number = -1;
@State frameTime: number = 0;
@State drawCallCount: number = 0;
@State optimizedMode: boolean = true;
private canvasWidth: number = 360;
private canvasHeight: number = 300;
private animOffset: number = 0;
private animRunning: boolean = false;
build() {
Column() {
// 性能面板
Row() {
Column() {
Text(`${this.frameTime.toFixed(1)}ms`)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.frameTime > 16 ? '#F44336' : '#4CAF50')
Text('帧耗时')
.fontSize(10)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Center)
Divider().vertical(true).height(30).margin({ left: 16, right: 16 })
Column() {
Text(`${this.drawCallCount}`)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.drawCallCount > 50 ? '#F44336' : '#4CAF50')
Text('绘制调用')
.fontSize(10)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Center)
Divider().vertical(true).height(30).margin({ left: 16, right: 16 })
Column() {
Text(this.optimizedMode ? '优化' : '原始')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.optimizedMode ? '#4CAF50' : '#FF9800')
Text('渲染模式')
.fontSize(10)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height(60)
.padding({ left: 24, right: 24 })
.backgroundColor(Color.White)
.justifyContent(FlexAlign.Center)
// Canvas
Canvas(this.context)
.width(this.canvasWidth)
.height(this.canvasHeight)
.backgroundColor('#0f0c29')
.borderRadius(12)
.margin({ top: 12 })
.onReady(() => {
this.initData();
this.initOffscreenCaches();
this.startAnimation();
})
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Move) {
const x = event.touches[0].x;
// 计算最近的数据点索引
const stepX = this.canvasWidth / (this.dataPoints.length - 1);
this.highlightIndex = Math.round(x / stepX);
this.highlightIndex = Math.max(0, Math.min(this.dataPoints.length - 1, this.highlightIndex));
} else if (event.type === TouchType.Up) {
this.highlightIndex = -1;
}
})
// 切换按钮
Row() {
Button(this.optimizedMode ? '切换到原始模式' : '切换到优化模式')
.fontSize(14)
.height(40)
.layoutWeight(1)
.backgroundColor(this.optimizedMode ? '#4CAF50' : '#FF9800')
.onClick(() => {
this.optimizedMode = !this.optimizedMode;
})
}
.width('100%')
.padding(16)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
/**
* 初始化数据
*/
private initData(): void {
this.dataPoints = [];
for (let i = 0; i < 100; i++) {
this.dataPoints.push({
x: i,
value: Math.sin(i * 0.1) * 40 + 50 + Math.random() * 10,
});
}
}
/**
* 初始化离屏缓存
*/
private initOffscreenCaches(): void {
// 缓存网格背景
this.gridOffscreen = this.context.createOffscreenCanvas(
this.canvasWidth, this.canvasHeight
);
const gridCtx = this.gridOffscreen.getContext('2d');
this.drawGrid(gridCtx);
}
/**
* 绘制网格(只画一次,缓存到离屏Canvas)
*/
private drawGrid(ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D): void {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 1;
// 水平线
for (let y = 0; y < this.canvasHeight; y += 30) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(this.canvasWidth, y);
ctx.stroke();
}
// 垂直线
for (let x = 0; x < this.canvasWidth; x += 30) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, this.canvasHeight);
ctx.stroke();
}
}
/**
* 启动动画
*/
private startAnimation(): void {
this.animRunning = true;
const animate = () => {
if (!this.animRunning) return;
const startTime = Date.now();
this.animOffset += 0.02;
// 更新数据(模拟实时数据)
for (let i = 0; i < this.dataPoints.length; i++) {
this.dataPoints[i].value = Math.sin(i * 0.1 + this.animOffset) * 40 + 50 + Math.random() * 5;
}
if (this.optimizedMode) {
this.renderOptimized();
} else {
this.renderNaive();
}
this.frameTime = Date.now() - startTime;
requestAnimationFrame(animate);
};
animate();
}
/**
* 原始渲染(无优化)
*/
private renderNaive(): void {
const ctx = this.context;
let callCount = 0;
// 全量清屏
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
callCount++;
// 每帧重绘网格
this.drawGrid(ctx);
callCount += 20; // 约20条线
// 逐点绘制(每个点单独调用)
const stepX = this.canvasWidth / (this.dataPoints.length - 1);
// 绘制填充区域
ctx.beginPath();
ctx.moveTo(0, this.canvasHeight);
for (let i = 0; i < this.dataPoints.length; i++) {
const x = i * stepX;
const y = this.canvasHeight - this.dataPoints[i].value * 2;
ctx.lineTo(x, y);
}
ctx.lineTo(this.canvasWidth, this.canvasHeight);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, 0, 0, this.canvasHeight);
gradient.addColorStop(0, 'rgba(0, 180, 216, 0.3)');
gradient.addColorStop(1, 'rgba(0, 180, 216, 0)');
ctx.fillStyle = gradient;
ctx.fill();
callCount++;
// 绘制曲线
ctx.beginPath();
for (let i = 0; i < this.dataPoints.length; i++) {
const x = i * stepX;
const y = this.canvasHeight - this.dataPoints[i].value * 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = '#00b4d8';
ctx.lineWidth = 2;
ctx.stroke();
callCount++;
// 逐点绘制数据点(❌ 低效:每个点单独调用)
for (let i = 0; i < this.dataPoints.length; i++) {
const x = i * stepX;
const y = this.canvasHeight - this.dataPoints[i].value * 2;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fillStyle = '#00b4d8';
ctx.fill();
callCount++; // 100次fill调用!
}
// 高亮点
if (this.highlightIndex >= 0) {
const x = this.highlightIndex * stepX;
const y = this.canvasHeight - this.dataPoints[this.highlightIndex].value * 2;
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = '#e94560';
ctx.fill();
callCount++;
}
this.drawCallCount = callCount;
}
/**
* 优化渲染
*/
private renderOptimized(): void {
const ctx = this.context;
let callCount = 0;
// 全量清屏
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
callCount++;
// ✅ 使用离屏缓存的网格(1次drawImage代替20次stroke)
if (this.gridOffscreen) {
ctx.drawImage(this.gridOffscreen, 0, 0);
callCount++;
}
const stepX = this.canvasWidth / (this.dataPoints.length - 1);
// 绘制填充区域和曲线(合并为2次绘制调用)
ctx.beginPath();
ctx.moveTo(0, this.canvasHeight);
for (let i = 0; i < this.dataPoints.length; i++) {
const x = i * stepX;
const y = this.canvasHeight - this.dataPoints[i].value * 2;
ctx.lineTo(x, y);
}
ctx.lineTo(this.canvasWidth, this.canvasHeight);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, 0, 0, this.canvasHeight);
gradient.addColorStop(0, 'rgba(0, 180, 216, 0.3)');
gradient.addColorStop(1, 'rgba(0, 180, 216, 0)');
ctx.fillStyle = gradient;
ctx.fill();
callCount++;
// 曲线
ctx.beginPath();
for (let i = 0; i < this.dataPoints.length; i++) {
const x = i * stepX;
const y = this.canvasHeight - this.dataPoints[i].value * 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = '#00b4d8';
ctx.lineWidth = 2;
ctx.stroke();
callCount++;
// ✅ 批量绘制数据点(1次fill代替100次)
ctx.fillStyle = '#00b4d8';
ctx.beginPath();
for (let i = 0; i < this.dataPoints.length; i++) {
const x = i * stepX;
const y = this.canvasHeight - this.dataPoints[i].value * 2;
ctx.moveTo(x + 2, y);
ctx.arc(x, y, 2, 0, Math.PI * 2);
}
ctx.fill();
callCount++; // 1次fill代替100次!
// 高亮点
if (this.highlightIndex >= 0) {
const x = this.highlightIndex * stepX;
const y = this.canvasHeight - this.dataPoints[this.highlightIndex].value * 2;
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = '#e94560';
ctx.fill();
callCount++;
}
this.drawCallCount = callCount;
}
aboutToDisappear(): void {
this.animRunning = false;
if (this.gridOffscreen) this.gridOffscreen.release();
if (this.chartOffscreen) this.chartOffscreen.release();
}
}
interface ChartPoint {
x: number;
value: number;
}
四、踩坑与注意事项
坑点1:beginPath()忘记调用导致路径累积
这是Canvas开发中最常见的bug之一。如果你在绘制多个独立图形时忘记调用beginPath(),新的路径会追加到旧路径后面,导致所有图形被一起填充或描边。
// ❌ 错误:缺少beginPath,所有圆会被连在一起
for (const circle of circles) {
ctx.arc(circle.x, circle.y, circle.r, 0, Math.PI * 2);
ctx.fill(); // 每次fill都会填充从第一个圆到当前圆的所有路径!
}
// ✅ 正确:每个独立图形前调用beginPath
for (const circle of circles) {
ctx.beginPath();
ctx.arc(circle.x, circle.y, circle.r, 0, Math.PI * 2);
ctx.fill();
}
// ✅ 更好:批量绘制时用moveTo分隔
ctx.beginPath();
for (const circle of circles) {
ctx.moveTo(circle.x + circle.r, circle.y); // moveTo避免路径自动连接
ctx.arc(circle.x, circle.y, circle.r, 0, Math.PI * 2);
}
ctx.fill(); // 一次fill绘制所有圆
坑点2:频繁切换Canvas状态
每次修改fillStyle、strokeStyle、lineWidth、font等状态,Canvas内部都需要更新渲染管线。频繁切换状态会导致GPU状态机频繁刷新,性能下降。
优化策略:按状态分组绘制。 先画所有红色的元素,再画所有蓝色的元素,而不是红蓝交替画。
坑点3:shadowBlur的性能陷阱
shadowBlur是Canvas中最耗性能的属性之一。阴影需要额外的模糊计算,而且模糊半径越大,计算量呈指数级增长。一个shadowBlur = 20的fill操作,可能比没有阴影的fill慢10倍。
建议: 能用离屏Canvas预渲染阴影就预渲染,不要在动画循环中使用shadowBlur。
坑点4:clip()后忘记restore()
clip()会裁剪后续所有绘制操作到指定区域,如果你忘记restore(),后续所有绘制都会被裁剪,导致画面"消失"。每次clip()前都要save(),clip()使用完后restore()。
坑点5:Canvas分辨率与设备像素比不匹配
在高DPI设备上,如果Canvas的分辨率和显示尺寸一致,画面会模糊。但如果Canvas分辨率设为2倍,绘制开销也会变成4倍(面积翻倍)。需要根据场景权衡清晰度和性能。
// 适配高DPI
const dpr = display.getDefaultDisplaySync().densityDPI / 160;
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
ctx.scale(dpr, dpr); // 缩放绘制上下文
坑点6:clearRect的范围不完整
clearRect只清除指定矩形区域的内容。如果你绘制了超出Canvas边界的图形(如旋转后的矩形),clearRect(0, 0, width, height)可能无法完全清除。建议使用比Canvas稍大的清除范围,或者使用canvas.width = canvas.width强制清空。
坑点7:离屏缓存的尺寸与主Canvas不一致
如果离屏Canvas的尺寸和主Canvas不一致,drawImage时会发生缩放,这会引入额外的插值计算,降低性能。确保离屏Canvas的尺寸与主Canvas完全一致。
五、HarmonyOS 6适配说明
API差异
| API | HarmonyOS 5.0 | HarmonyOS 6.0 | 迁移建议 |
|---|---|---|---|
| CanvasRenderingContext2D | 基础2D绘制 | 新增Path2D对象支持 | 使用Path2D预构建路径 |
| drawImage() | 不支持异步 | 新增drawImageAsync() | 大图使用异步绘制 |
| fill()/stroke() | 同步执行 | 新增批量绘制API | 使用批量API减少调用次数 |
| Canvas硬件加速 | 默认开启 | 新增可配置的GPU渲染策略 | 根据场景选择渲染策略 |
| getImageData() | 全量获取 | 新增支持区域和格式参数 | 使用区域参数减少数据拷贝 |
行为变更
- Canvas渲染管线优化:6.0中Canvas的渲染管线从"立即模式"改为"延迟模式",绘制命令不会立即提交到GPU,而是在帧结束时批量提交,自动实现批处理优化
- Path2D对象:6.0新增Path2D对象,可以预构建路径并复用,避免每帧重新构建路径
- 硬件加速策略:6.0新增
RenderingStrategy枚举,可以选择PERFORMANCE(优先性能)或QUALITY(优先质量)模式 - Canvas纹理缓存:6.0自动缓存Canvas的GPU纹理,连续帧内容相同时不会重新上传
适配代码
/**
* HarmonyOS 6.0适配的Canvas绘制工具
*/
class CanvasDrawCompat {
private static instance: CanvasDrawCompat;
private isV6: boolean = false;
private constructor() {
this.isV6 = this.detectApiVersion();
}
static getInstance(): CanvasDrawCompat {
if (!CanvasDrawCompat.instance) {
CanvasDrawCompat.instance = new CanvasDrawCompat();
}
return CanvasDrawCompat.instance;
}
/**
* 批量绘制圆形(6.0优化)
*/
drawCirclesBatch(
ctx: CanvasRenderingContext2D,
circles: Array<{ x: number; y: number; r: number; color: string }>
): number {
let drawCalls = 0;
// 按颜色分组(减少状态切换)
const colorGroups = new Map<string, Array<{ x: number; y: number; r: number }>>();
for (const c of circles) {
if (!colorGroups.has(c.color)) {
colorGroups.set(c.color, []);
}
colorGroups.get(c.color)!.push({ x: c.x, y: c.y, r: c.r });
}
// 每种颜色一次绘制调用
for (const [color, group] of colorGroups) {
ctx.fillStyle = color;
ctx.beginPath();
for (const c of group) {
ctx.moveTo(c.x + c.r, c.y);
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
}
ctx.fill();
drawCalls++;
}
return drawCalls;
}
/**
* 使用Path2D预构建路径(6.0新增)
*/
buildPath2D(commands: (path: Path2D) => void): Path2D | null {
// 6.0新增Path2D
if (this.isV6 && typeof Path2D !== 'undefined') {
try {
const path = new Path2D();
commands(path);
return path;
} catch {
return null;
}
}
return null;
}
/**
* 使用预构建路径绘制(6.0优化)
*/
drawWithCachedPath(
ctx: CanvasRenderingContext2D,
path: Path2D | null,
fallbackDraw: () => void
): void {
if (path) {
// 6.0:直接使用缓存的Path2D
ctx.fill(path);
} else {
// 5.0降级:每帧重新构建路径
fallbackDraw();
}
}
private detectApiVersion(): boolean {
try {
return typeof Path2D !== 'undefined';
} catch {
return false;
}
}
}
六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ |
| 使用频率 | ⭐⭐⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐⭐ |
Canvas性能优化不是玄学,而是有章可循的工程实践。今天我们掌握了四大核心策略,它们就像工具箱里的四把利器,各有各的用武之地:
绘制调用优化是第一把利器——减少fill/stroke的调用次数,把1000次调用压缩到10次,性能立竿见影。核心口诀:同色合并、一次fill。
路径合并批处理是第二把利器——用moveTo分隔独立图形,把多个小路径合并为一个大路径,让GPU一次处理完。核心口诀:先分组、再合并、一次提交。
脏区域重绘是第三把利器——只重绘变化的区域,而不是每帧重绘整个Canvas。在拖拽、局部动画等场景中,性能提升可达5-20倍。核心口诀:标记脏区、裁剪绘制、用完清空。
离屏缓存是第四把利器——静态内容只画一次,缓存到离屏Canvas,每帧直接drawImage。核心口诀:静态预渲染、动态实时画、缓存即性能。
最后,记住一个原则:优化之前先测量,优化之后必验证。 不要凭感觉优化,要用数据说话。帧耗时从多少降到多少?绘制调用从多少减到多少?有了量化数据,优化才有方向,成果才有说服力。
- 点赞
- 收藏
- 关注作者
评论(0)