在 ArkUI 中当需要绘制复杂的自定义图表如金融走势图、雷达图、自定义环状图时Canvas组件配合CanvasRenderingContext2D是最强大的工具。它提供了命令式的 2D 绘图能力让开发者能够精确控制每一个像素的渲染。以下是使用 Context2D 绘制复杂图表的核心机制一、 核心初始化与生命周期Canvas 的绘制必须在其初始化完成的事件回调onReady中进行。在这个阶段画布的物理尺寸已经确定可以安全地获取宽高并进行绘制。// 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()将折线下方区域填充增强视觉层次感。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)清空画布然后再执行绘制逻辑否则新旧图形会叠加在一起。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。.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()获取文字宽度动态计算气泡尺寸并绘制圆角背景。// 扩大命中区域提升移动端选中体验 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可以完美实现这一需求.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。避免使用onAreaChangeonAreaChange会频繁触发而 Canvas 尺寸在首次布局后就固定了。onReady在 Canvas 准备好后只触发一次最适合用于初始化画图。减少不必要的重绘使用Watch和条件渲染来避免冗余重绘。将状态更新限制在真正需要更新视图的地方避免在不需要的场景下频繁触发onDraw。图形渲染硬件加速尽量将动画、图片加载等放在异步任务中处理使用requestAnimationFrame()进行平滑动画渲染避免 CPU 密集型任务阻塞主线程。混合绘制与离屏缓冲充分利用beginPath、bezierCurveTo等原生指令实现复杂视觉效果。对于极高频的刷新场景可构想并应用离屏绘制Offscreen Canvas以减少主线程占用。