画布Canvas:2D绘图上下文(Context2D)绘制复杂图表(33)

在 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. 绘制背景与网格线

利用 beginPathmoveTolineTostroke 绘制网格,并使用 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 对象。你可以先通过 arcrect 等方法构造理想的路径,最后统一调用 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);
  }
}
核心技巧解析:
  1. Path2D 批量绘制 :代码中将折线和数据点分别封装到了 linePathpointsPath 中。这意味着无论数据量有多大,最终都只向 GPU 提交一次 strokefill 指令,极大降低了上下文切换开销。
  2. 离屏渲染(OffscreenCanvas) :网格线、坐标轴标签等静态元素在 offscreenCtx 中绘制完毕后,通过 transferToImageBitmap() 转为位图,再由主画布 transferFromImageBitmap() 一次性上屏。这在数据高频刷新时,能避免每帧都重新计算和绘制网格线。
  3. measureText 精确对齐 :在 X 轴标签绘制时,通过 ctx.measureText(label).width 获取文字真实宽度,再使用 x - textWidth / 2 进行偏移,彻底解决了不同字体、不同长度文字无法完美居中的痛点。
  4. 动态重绘(clearRect) :在 renderChart 方法的第一行,严格调用了 ctx.clearRect(0, 0, width, height)。这是 Canvas 动画和交互的基石,确保每次数据更新时,上一帧的残留图形被彻底清除。

四、 高级交互体验

1、缩放、平移与 Tooltip

在复杂的图表(如金融走势图)中,用户通常需要查看局部细节。通过引入交互逻辑,可以大幅提升体验:

  1. 缩放与平移(Pinch-to-zoom & Pan) :结合 GestureGroup 实现双指捏合缩放和单指拖拽平移。焦点缩放逻辑需跟随手指位置,动态更新数轴范围(Viewport)。
  2. 点击选中与 Tooltip:通过精准的碰撞检测(Hit Test),当用户轻触数据点或柱体时,高亮该节点并弹出详细浮窗(Tooltip)。
  3. 平滑过渡动效:内置动画引擎,在数据更新时实现平滑过渡,避免图表生硬跳变。
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、 架构设计:数据与渲染解耦

为了提升渲染效率与可扩展性,复杂的图表绘制应遵循"职责单一"原则,将逻辑分层:

  1. 模型层 (Model) :纯粹持有数据和配置(如 LineChartDataAxisViewport)。
  2. 计算层 (Computator):统一接管坐标计算(如将业务数据转换为像素坐标),避免在渲染层产生性能瓶颈。高度抽象的映射使渲染层无需修改代码即可应对屏幕分辨率变化。
  3. 渲染层 (Renderer) :图表的心脏,每种图表对应独立的 Renderer 类,直接操作 CanvasRenderingContext2D 进行绘制,类似游戏渲染循环。
  4. 组件层 (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();
      })
  )
)

五、 底层性能优化与避坑指南

在鸿蒙设备上,流畅的绘图体验至关重要,需特别注意以下底层细节:

  1. Canvas 宽高获取的"坑"width('100%') 是逻辑像素(vp),而 Canvas 内部 context.width / context.height 拿到的是物理像素(已自动乘以 vp 转换)。务必在 onReady 回调中获取宽高,在此之前获取到的值会是 0。
  2. 避免使用 onAreaChangeonAreaChange 会频繁触发,而 Canvas 尺寸在首次布局后就固定了。onReady 在 Canvas 准备好后只触发一次,最适合用于初始化画图。
  3. 减少不必要的重绘 :使用 @Watch 和条件渲染来避免冗余重绘。将状态更新限制在真正需要更新视图的地方,避免在不需要的场景下频繁触发 onDraw
  4. 图形渲染硬件加速 :尽量将动画、图片加载等放在异步任务中处理,使用 requestAnimationFrame() 进行平滑动画渲染,避免 CPU 密集型任务阻塞主线程。
  5. 混合绘制与离屏缓冲 :充分利用 beginPathbezierCurveTo 等原生指令实现复杂视觉效果。对于极高频的刷新场景,可构想并应用离屏绘制(Offscreen Canvas)以减少主线程占用。
相关推荐
风华圆舞2 小时前
鸿蒙 Flutter 页面怎么感知防窥状态并调整 UI 可见性
flutter·ui·harmonyos
小雨下雨的雨2 小时前
HarmonyOS ArkUI训练营入门-组件掌握系列-Grid 网格布局深度解析-PC版本
学习·华为·harmonyos·鸿蒙·鸿蒙系统
Davina_yu12 小时前
定时器与任务调度:setTimeout与setInterval的正确使用(19)
harmonyos·鸿蒙·鸿蒙系统
祭曦念13 小时前
【共创季稿事节】鸿蒙原生ArkTS布局深度解析_GridRow_Row_Column混合栅格布局实战
华为·harmonyos
kiros_wang13 小时前
鸿蒙 ArkUI:V1 与 V2 装饰器全面对比与迁移指南
ubuntu·华为·harmonyos
古德new14 小时前
鸿蒙PC迁移:Photoflare Qt 图片编辑器鸿蒙PC适配全记录
qt·编辑器·harmonyos
不羁的木木14 小时前
HarmonyOS 6.1.0 创新特性技术精讲之沉浸光感
华为·harmonyos
JOJO数据科学15 小时前
JupyterLab Electron 鸿蒙 PC 适配全记录:从 Python 原生崩溃到 node-static 本地工作台
python·electron·harmonyos
CHB16 小时前
HDC2026 演讲实录|AI 驱动的跨端进化:利用 uni-agent 快速构建高性能鸿蒙应用
uni-app·harmonyos