在 ArkUI 中,当需要绘制复杂的自定义图表(如金融走势图、雷达图、自定义环状图)时,Canvas 组件配合 CanvasRenderingContext2D 是最强大的工具。它提供了命令式的 2D 绘图能力,让开发者能够精确控制每一个像素的渲染。
以下是使用 Context2D 绘制复杂图表的核心机制:
一、 核心初始化与生命周期
Canvas 的绘制必须在其初始化完成的事件回调 onReady 中进行。在这个阶段,画布的物理尺寸已经确定,可以安全地获取宽高并进行绘制。
javascript
// 1. 声明上下文并启用抗锯齿(提升图表渲染质量)
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
build() {
Canvas(this.context)
.width('100%')
.height(260)
.onReady(() => {
// 2. 在 onReady 中调用绘制方法
this.drawChart();
})
}
二、 复杂图表的绘制思路(以折线图为例)
绘制复杂图表通常分为三个核心步骤:
1. 数据坐标系映射
图表绘制的本质是将"数据点"映射到"像素坐标"。
- 计算绘制区域:预留出 Padding(如左侧留给 Y 轴价格标签,底部留给 X 轴日期标签)。
- 坐标转换公式 :将数据值归一化到 0~1 之间,再乘以图表实际高度。注意屏幕 Y 轴向下为正,因此需要翻转 Y 轴:
y = top + (1 - (value - yMin) / yRange) * chartHeight。 - 边界 Padding:为了防止折线紧贴图表边缘,建议对数据的最大最小值增加 10%~15% 的缓冲区间。
2. 绘制背景与网格线
利用 beginPath、moveTo、lineTo 和 stroke 绘制网格,并使用 fillText 在对应位置标注刻度文字。设置 textBaseline = 'middle' 可以让文字在网格线上垂直居中。
3. 绘制数据折线与渐变填充
- 平滑折线 :遍历数据点,使用
lineTo连接。设置ctx.lineJoin = 'round'可以让折线的拐角变得圆滑。 - 渐变填充 :使用
ctx.createLinearGradient创建从上到下的线性渐变,通过addColorStop设置颜色过渡,最后调用ctx.fill()将折线下方区域填充,增强视觉层次感。
javascript
@Entry
@Component
struct LineChartDemo {
// 1. 声明上下文并启用抗锯齿(提升图表渲染质量)
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
// 模拟数据源
private chartData: number[] = [30, 55, 40, 70, 60, 85, 90, 75, 95, 80];
build() {
Column() {
Canvas(this.context)
.width('100%')
.height(300)
.backgroundColor('#FAFAFA')
.onReady(() => {
// 2. 在 onReady 中执行绘制逻辑
this.drawChart();
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding(20)
}
// 核心绘制方法
private drawChart() {
const ctx = this.context;
const width = ctx.width;
const height = ctx.height;
// ================= 步骤一:数据坐标系映射 =================
// 1.1 计算绘制区域,预留 Padding
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// 1.2 计算数据的边界(增加 10% 缓冲区间,防止折线紧贴边缘)
const yMin = Math.min(...this.chartData) * 0.9;
const yMax = Math.max(...this.chartData) * 1.1;
const yRange = yMax - yMin;
// 坐标转换辅助函数
const getX = (index: number) => padding.left + (index / (this.chartData.length - 1)) * chartWidth;
const getY = (value: number) => padding.top + (1 - (value - yMin) / yRange) * chartHeight;
// ================= 步骤二:绘制背景与网格线 =================
ctx.lineWidth = 1;
ctx.strokeStyle = '#E0E0E0';
ctx.fillStyle = '#888888';
ctx.font = '12vp sans-serif';
ctx.textBaseline = 'middle'; // 让文字在网格线上垂直居中
// 绘制 4 条水平网格线
for (let i = 0; i <= 3; i++) {
const y = padding.top + (chartHeight / 3) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
// 计算并标注 Y 轴刻度文字
const labelValue = Math.round(yMax - (yRange / 3) * i);
ctx.fillText(labelValue.toString(), 5, y);
}
// ================= 步骤三:绘制数据折线与渐变填充 =================
// 3.1 构建路径并绘制平滑折线
ctx.beginPath();
ctx.lineJoin = 'round'; // 让折线拐角变得圆滑
ctx.lineWidth = 3;
ctx.strokeStyle = '#1890FF';
this.chartData.forEach((value, index) => {
const x = getX(index);
const y = getY(value);
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// 3.2 渐变填充
// 创建从上到下的线性渐变
const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom);
gradient.addColorStop(0, 'rgba(24, 144, 255, 0.4)'); // 顶部颜色
gradient.addColorStop(1, 'rgba(24, 144, 255, 0.0)'); // 底部完全透明
// 闭合路径以形成填充区域
ctx.lineTo(getX(this.chartData.length - 1), height - padding.bottom);
ctx.lineTo(getX(0), height - padding.bottom);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
}
}
三、 进阶技巧与性能优化
1. 使用 Path2D 构建复杂路径
对于包含多个独立形状(如环状统计图、复杂几何图形)的图表,推荐使用 Path2D 对象。你可以先通过 arc、rect 等方法构造理想的路径,最后统一调用 ctx.stroke(path) 或 ctx.fill(path) 进行绘制。这比频繁调用 beginPath 性能更好。
2. 离屏渲染(OffscreenCanvas)
当图表极其复杂,或者需要频繁重绘(如实时数据刷新)时,直接在主画布上操作会消耗大量性能。此时可以使用离屏画布:
- 创建一个
OffscreenCanvas作为缓冲区。 - 在离屏画布上完成所有复杂的图形绘制。
- 使用
transferToImageBitmap()将离屏画布内容转为图像。 - 在主画布的
onReady中通过transferFromImageBitmap(image)一次性渲染上屏。
3. 文本测量与精确排版
在绘制图表标签时,如果需要精确对齐,可以使用 ctx.measureText(text) 方法获取文本的实际渲染宽度,从而动态计算文本的 X 轴坐标,避免文字重叠或超出边界。
4. 动态重绘机制
每次数据更新需要重绘图表时,务必先调用 ctx.clearRect(0, 0, width, height) 清空画布,然后再执行绘制逻辑,否则新旧图形会叠加在一起。
javascript
@Entry
@Component
struct AdvancedChartDemo {
// 1. 声明上下文并启用抗锯齿
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
// 2. 声明离屏画布(OffscreenCanvas)作为缓冲区
private offscreenCanvas: OffscreenCanvas = new OffscreenCanvas(800, 600);
private offscreenCtx: CanvasRenderingContext2D = this.offscreenCanvas.getContext(this.settings);
@State chartData: number[] = [30, 55, 40, 70, 60, 85, 90, 75, 95, 80];
@State canvasWidth: number = 0;
@State canvasHeight: number = 0;
build() {
Column() {
Canvas(this.context)
.width('100%')
.height(300)
.backgroundColor('#FAFAFA')
.onReady(() => {
// 记录主画布尺寸
this.canvasWidth = this.context.width;
this.canvasHeight = this.context.height;
// 触发首次绘制
this.renderChart();
})
Button('模拟数据更新')
.margin({ top: 20 })
.onClick(() => {
// 模拟数据变化,触发动态重绘
this.chartData = this.chartData.map(() => Math.floor(Math.random() * 100));
this.renderChart();
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding(20)
}
private renderChart() {
const ctx = this.context;
const width = this.canvasWidth;
const height = this.canvasHeight;
// ================= 技巧 4:动态重绘机制 =================
// 每次更新前,必须清空主画布,防止新旧图形叠加
ctx.clearRect(0, 0, width, height);
// 1. 在离屏画布上完成复杂绘制
this.drawStaticBackground(width, height);
// 2. 将离屏画布内容转为 ImageBitmap 并一次性渲染上屏
const bitmap = this.offscreenCanvas.transferToImageBitmap();
ctx.transferFromImageBitmap(bitmap);
// 3. 在主画布上绘制动态折线(避免被离屏缓存覆盖)
this.drawDynamicLine(ctx, width, height);
}
// ================= 技巧 2:离屏渲染静态背景 =================
private drawStaticBackground(width: number, height: number) {
const ctx = this.offscreenCtx;
// 清空离屏画布
ctx.clearRect(0, 0, width, height);
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
ctx.lineWidth = 1;
ctx.strokeStyle = '#E0E0E0';
ctx.fillStyle = '#888888';
ctx.font = '12vp sans-serif';
ctx.textBaseline = 'middle';
// 绘制网格线与 Y 轴标签
for (let i = 0; i <= 3; i++) {
const y = padding.top + (chartHeight / 3) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
ctx.fillText((100 - (100 / 3) * i).toFixed(0), 5, y);
}
// ================= 技巧 3:文本测量与精确排版 =================
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'];
labels.forEach((label, index) => {
const x = padding.left + (index / (labels.length - 1)) * chartWidth;
// 使用 measureText 获取文本真实宽度,实现完美的水平居中
const textWidth = ctx.measureText(label).width;
ctx.fillText(label, x - textWidth / 2, height - padding.bottom + 20);
});
}
// ================= 技巧 1:使用 Path2D 构建复杂路径 =================
private drawDynamicLine(ctx: CanvasRenderingContext2D, width: number, height: number) {
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const getX = (index: number) => padding.left + (index / (this.chartData.length - 1)) * chartWidth;
const getY = (value: number) => padding.top + (1 - value / 100) * chartHeight;
// 使用 Path2D 对象构建折线路径,减少频繁 beginPath 的开销
const linePath = new Path2D();
this.chartData.forEach((value, index) => {
const x = getX(index);
const y = getY(value);
if (index === 0) {
linePath.moveTo(x, y);
} else {
linePath.lineTo(x, y);
}
});
// 统一调用 stroke 绘制平滑折线
ctx.lineJoin = 'round';
ctx.lineWidth = 3;
ctx.strokeStyle = '#1890FF';
ctx.stroke(linePath);
// 绘制数据点(同样复用 Path2D)
const pointsPath = new Path2D();
this.chartData.forEach((value, index) => {
const x = getX(index);
const y = getY(value);
pointsPath.moveTo(x + 4, y);
pointsPath.arc(x, y, 4, 0, Math.PI * 2);
});
ctx.fillStyle = '#FFFFFF';
ctx.strokeStyle = '#1890FF';
ctx.lineWidth = 2;
ctx.fill(pointsPath);
ctx.stroke(pointsPath);
}
}
核心技巧解析:
- Path2D 批量绘制 :代码中将折线和数据点分别封装到了
linePath和pointsPath中。这意味着无论数据量有多大,最终都只向 GPU 提交一次stroke和fill指令,极大降低了上下文切换开销。 - 离屏渲染(OffscreenCanvas) :网格线、坐标轴标签等静态元素在
offscreenCtx中绘制完毕后,通过transferToImageBitmap()转为位图,再由主画布transferFromImageBitmap()一次性上屏。这在数据高频刷新时,能避免每帧都重新计算和绘制网格线。 - measureText 精确对齐 :在 X 轴标签绘制时,通过
ctx.measureText(label).width获取文字真实宽度,再使用x - textWidth / 2进行偏移,彻底解决了不同字体、不同长度文字无法完美居中的痛点。 - 动态重绘(clearRect) :在
renderChart方法的第一行,严格调用了ctx.clearRect(0, 0, width, height)。这是 Canvas 动画和交互的基石,确保每次数据更新时,上一帧的残留图形被彻底清除。
四、 高级交互体验
1、缩放、平移与 Tooltip
在复杂的图表(如金融走势图)中,用户通常需要查看局部细节。通过引入交互逻辑,可以大幅提升体验:
- 缩放与平移(Pinch-to-zoom & Pan) :结合
GestureGroup实现双指捏合缩放和单指拖拽平移。焦点缩放逻辑需跟随手指位置,动态更新数轴范围(Viewport)。 - 点击选中与 Tooltip:通过精准的碰撞检测(Hit Test),当用户轻触数据点或柱体时,高亮该节点并弹出详细浮窗(Tooltip)。
- 平滑过渡动效:内置动画引擎,在数据更新时实现平滑过渡,避免图表生硬跳变。
1. 缩放与平移(Pinch-to-zoom & Pan)
利用 ArkUI 的 GestureGroup 配合 GestureMode.Parallel,可以实现缩放和平移的无缝切换。焦点缩放逻辑需跟随手指位置,动态更新数轴范围(Viewport)。
javascript
.gesture(
GestureGroup(GestureMode.Parallel,
// 处理单指平移
PanGesture()
.onActionUpdate((event: GestureEvent) => {
this.touchHandler.handleTouchMove(event.fingerList[0].localX, event.fingerList[0].localY);
this.drawChart();
}),
// 处理双指缩放
PinchGesture()
.onActionUpdate((event: GestureEvent) => {
this.touchHandler.handlePinch(event.scale, event.pinchCenterX, event.pinchCenterY);
this.drawChart();
})
)
)
2. 点击选中与 Tooltip(碰撞检测)
当用户轻触数据点时,需进行精准的碰撞检测(Hit Test)。为了兼顾移动端的操作便利性,通常以数据点为圆心,扩大 10 像素作为触摸区域。命中后,使用 canvas.measureText() 获取文字宽度,动态计算气泡尺寸并绘制圆角背景。
javascript
// 扩大命中区域,提升移动端选中体验
const pointRadius = (config.pointRadius ?? CHART_DEFAULTS.pointRadius) + 10;
// 气泡始终出现在数据点上方,避免被手指遮挡
const bubbleY = pt.y - 35;
2、 架构设计:数据与渲染解耦
为了提升渲染效率与可扩展性,复杂的图表绘制应遵循"职责单一"原则,将逻辑分层:
- 模型层 (Model) :纯粹持有数据和配置(如
LineChartData、Axis、Viewport)。 - 计算层 (Computator):统一接管坐标计算(如将业务数据转换为像素坐标),避免在渲染层产生性能瓶颈。高度抽象的映射使渲染层无需修改代码即可应对屏幕分辨率变化。
- 渲染层 (Renderer) :图表的心脏,每种图表对应独立的
Renderer类,直接操作CanvasRenderingContext2D进行绘制,类似游戏渲染循环。 - 组件层 (Component) :利用 ArkUI 的
@Component封装成标准组件,开发者只需声明式传入数据即可使用。
3、 多手势并行(GestureGroup)实战
在图表交互中,缩放和平移往往需要无缝切换。ArkUI 的 GestureGroup 配合 GestureMode.Parallel 可以完美实现这一需求:
javascript
.gesture(
GestureGroup(GestureMode.Parallel,
PanGesture() // 处理单指平移
.onActionUpdate((event: GestureEvent) => {
this.touchHandler.handleTouchMove(event.fingerList[0].localX, event.fingerList[0].localY);
this.drawChart();
}),
PinchGesture() // 处理双指缩放
.onActionUpdate((event: GestureEvent) => {
this.touchHandler.handlePinch(event.scale, event.pinchCenterX, event.pinchCenterY);
this.drawChart();
})
)
)
五、 底层性能优化与避坑指南
在鸿蒙设备上,流畅的绘图体验至关重要,需特别注意以下底层细节:
- Canvas 宽高获取的"坑" :
width('100%')是逻辑像素(vp),而 Canvas 内部context.width / context.height拿到的是物理像素(已自动乘以 vp 转换)。务必在onReady回调中获取宽高,在此之前获取到的值会是 0。 - 避免使用
onAreaChange:onAreaChange会频繁触发,而 Canvas 尺寸在首次布局后就固定了。onReady在 Canvas 准备好后只触发一次,最适合用于初始化画图。 - 减少不必要的重绘 :使用
@Watch和条件渲染来避免冗余重绘。将状态更新限制在真正需要更新视图的地方,避免在不需要的场景下频繁触发onDraw。 - 图形渲染硬件加速 :尽量将动画、图片加载等放在异步任务中处理,使用
requestAnimationFrame()进行平滑动画渲染,避免 CPU 密集型任务阻塞主线程。 - 混合绘制与离屏缓冲 :充分利用
beginPath、bezierCurveTo等原生指令实现复杂视觉效果。对于极高频的刷新场景,可构想并应用离屏绘制(Offscreen Canvas)以减少主线程占用。