图表是数据展示最直观的方式。HarmonyOS没有内置的图表库(没有echarts那种),图表要么用Canvas自己画,要么用Progress/DataPanel这类简化组件。这篇把柱状图、折线图、饼图、环形图全部用Canvas实现一遍,顺便讲清楚绘图流程。

Canvas基础

Canvas绘图需要RenderingContext2D:

private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

build() {
  Canvas(this.context)
    .width('100%')
    .height(200)
    .onReady(() => {
      this.drawChart();
    })
}

onReady是关键——Canvas组件准备完毕后才能绑定context绘图。在aboutToAppear里画是画不出来的,因为Canvas还没挂载。RenderingContextSettings(true)表示开启抗锯齿。

坐标系

Canvas坐标系左上角是(0,0),x向右增长,y向下增长。画图表需要先translate到绘图区域起点:

let ctx: CanvasRenderingContext2D = this.context;
let padding: number = 30;
let chartWidth: number = 320 - padding * 2;
let chartHeight: number = 200 - padding * 2;

padding给坐标轴和标签留空间。chartWidth/chartHeight是实际绘图区域。

柱状图

在这里插入图片描述

private drawBarChart(): void {
  let ctx: CanvasRenderingContext2D = this.context;
  let padding: number = 30;
  let w: number = 320;
  let h: number = 200;
  let chartW: number = w - padding * 2;
  let chartH: number = h - padding * 2;
  let data: number[] = [65, 45, 80, 55, 90, 70];
  let maxVal: number = 100;
  let barWidth: number = chartW / data.length * 0.6;
  let gap: number = chartW / data.length;

  // 清空画布
  ctx.clearRect(0, 0, w, h);

  // 画网格线
  ctx.strokeStyle = '#eeeeee';
  ctx.lineWidth = 1;
  for (let i: number = 0; i <= 4; i++) {
    let y: number = padding + (chartH / 4) * i;
    ctx.beginPath();
    ctx.moveTo(padding, y);
    ctx.lineTo(w - padding, y);
    ctx.stroke();
  }

  // 画柱子
  let colors: string[] = ['#1a73e8', '#43a047', '#ff9800', '#e53935', '#9c27b0', '#00897b'];
  for (let i: number = 0; i < data.length; i++) {
    let x: number = padding + gap * i + (gap - barWidth) / 2;
    let barH: number = data[i] / maxVal * chartH;
    let y: number = padding + chartH - barH;

    ctx.fillStyle = colors[i % colors.length];
    ctx.fillRect(x, y, barWidth, barH);

    // 数据标签
    ctx.fillStyle = '#333333';
    ctx.font = '10px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(data[i].toString(), x + barWidth / 2, y - 5);
  }
}

clearRect清空画布是必要的第一步——否则重绘时旧内容不会消失。fillRect画柱子,fillText画标签。每根柱子的x坐标根据gap和barWidth计算。

折线图

在这里插入图片描述

private drawLineChart(): void {
  let ctx: CanvasRenderingContext2D = this.context;
  let padding: number = 30;
  let w: number = 320;
  let h: number = 200;
  let chartW: number = w - padding * 2;
  let chartH: number = h - padding * 2;
  let data: number[] = [65, 45, 80, 55, 90, 70];
  let maxVal: number = 100;

  ctx.clearRect(0, 0, w, h);

  // 网格线
  ctx.strokeStyle = '#eeeeee';
  ctx.lineWidth = 1;
  for (let i: number = 0; i <= 4; i++) {
    let y: number = padding + (chartH / 4) * i;
    ctx.beginPath();
    ctx.moveTo(padding, y);
    ctx.lineTo(w - padding, y);
    ctx.stroke();
  }

  // 折线
  ctx.strokeStyle = '#1a73e8';
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let i: number = 0; i < data.length; i++) {
    let x: number = padding + (chartW / (data.length - 1)) * i;
    let y: number = padding + chartH - (data[i] / maxVal * chartH);
    if (i === 0) {
      ctx.moveTo(x, y);
    } else {
      ctx.lineTo(x, y);
    }
  }
  ctx.stroke();

  // 数据点
  ctx.fillStyle = '#1a73e8';
  for (let i: number = 0; i < data.length; i++) {
    let x: number = padding + (chartW / (data.length - 1)) * i;
    let y: number = padding + chartH - (data[i] / maxVal * chartH);
    ctx.beginPath();
    ctx.arc(x, y, 4, 0, 6.28);
    ctx.fill();
  }
}

moveTo画起点,lineTo画后续点,stroke连成线。arc画圆点,6.28≈2π画完整圆。折线图的核心是坐标转换:数据值→Canvas坐标。

面积图

面积图在折线图基础上加填充:

// 在折线stroke之后
ctx.fillStyle = 'rgba(26, 115, 232, 0.15)';
ctx.beginPath();
ctx.moveTo(padding, padding + chartH); // 左下角
for (let i: number = 0; i < data.length; i++) {
  let x: number = padding + (chartW / (data.length - 1)) * i;
  let y: number = padding + chartH - (data[i] / maxVal * chartH);
  ctx.lineTo(x, y);
}
ctx.lineTo(padding + chartW, padding + chartH); // 右下角
ctx.closePath();
ctx.fill();

关键:先从左下角moveTo,然后lineTo所有数据点,最后lineTo右下角,closePath闭合路径,fill填充半透明色。

饼图在这里插入图片描述

饼图用arc画扇区,每个扇区的起始角度根据数据占比计算:

private drawPieChart(): void {
  let ctx: CanvasRenderingContext2D = this.context;
  let centerX: number = 160;
  let centerY: number = 120;
  let radius: number = 80;

  let values: number[] = [35, 25, 20, 20];
  let colors: string[] = ['#1a73e8', '#43a047', '#ff9800', '#e0e0e0'];
  let labels: string[] = ['技术', '设计', '运营', '其他'];
  let total: number = 0;
  for (let i: number = 0; i < values.length; i++) { total += values[i]; }

  ctx.clearRect(0, 0, 320, 240);

  let startAngle: number = -Math.PI / 2; // 从12点方向开始
  for (let i: number = 0; i < values.length; i++) {
    let sweepAngle: number = (values[i] / total) * 2 * Math.PI;

    ctx.fillStyle = colors[i];
    ctx.beginPath();
    ctx.moveTo(centerX, centerY);
    ctx.arc(centerX, centerY, radius, startAngle, startAngle + sweepAngle);
    ctx.closePath();
    ctx.fill();

    // 标签
    let midAngle: number = startAngle + sweepAngle / 2;
    let labelX: number = centerX + Math.cos(midAngle) * (radius + 20);
    let labelY: number = centerY + Math.sin(midAngle) * (radius + 20);
    ctx.fillStyle = '#333333';
    ctx.font = '11px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(labels[i] + ' ' + values[i].toString() + '%', labelX, labelY);

    startAngle += sweepAngle;
  }
}

起始角度-PI/2是12点方向(默认3点方向是0)。sweepAngle根据数据占比计算。每个扇区:moveTo圆心→arc画弧→closePath闭合→fill填充。

标签位置在扇区中间角度、半径+20的位置。cos/sin计算极坐标到直角坐标。

环形图(甜甜圈图)

环形图就是饼图中间挖个圆:

// 画完饼图后
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(centerX, centerY, radius * 0.6, 0, 2 * Math.PI);
ctx.fill();

// 中间文字
ctx.fillStyle = '#333333';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('100%', centerX, centerY + 6);

用背景色画一个更小的圆覆盖饼图中心。0.6倍半径形成甜甜圈效果。

DataPanel组件

DataPanel是内置的环形图组件,比Canvas简单得多:

DataPanel({ values: [30, 20, 15, 35], max: 100, type: DataPanelType.Circle })
  .width(160)
  .height(160)
  .colors([[0x1a73e8, 0], [0x43a047, 0.3], [0xff9800, 0.5], [0xe53935, 0.8]])

DataPanelType有两个值:Circle(环形)和Line(线形)。values是各段数值,max是总值。colors数组中每个元素是[color, offset]。

但DataPanel功能有限——不支持标签、动画、交互。要做复杂图表还是得用Canvas。

图表动画

图表数据变化时加动画效果:

@State animProgress: number = 0;

aboutToAppear(): void {
  setTimeout(() => { this.animProgress = 1; }, 100);
}

// 在drawBarChart中
let barH: number = data[i] / maxVal * chartH * this.animProgress;

animProgress从0到1,柱子高度乘以这个系数实现从0增长到目标高度的动画。用animation修饰器或animateTo驱动animProgress变化。

更精细的控制用setInterval:

private startAnimation(): void {
  this.animProgress = 0;
  let startTime: number = Date.now();
  let duration: number = 800;
  let timer: number = setInterval(() => {
    let elapsed: number = Date.now() - startTime;
    if (elapsed >= duration) {
      this.animProgress = 1;
      clearInterval(timer);
    } else {
      this.animProgress = elapsed / duration;
    }
    this.context.clearRect(0, 0, 320, 200);
    this.drawBarChart();
  }, 16);
}

每16ms一帧(60fps),计算进度,重绘Canvas。setInterval在aboutToDisappear中clearInterval清理。

点击交互

Canvas本身不处理点击,需要用onClick计算点击位置对应哪个数据:

Canvas(this.context)
  .width('100%')
  .height(200)
  .onReady(() => { this.drawChart(); })
  .onClick((event: ClickEvent) => {
    let x: number = event.x;
    let y: number = event.y;
    // 判断点击了哪个柱子/数据点
    this.handleChartClick(x, y);
  })

event.x/y是点击位置,跟绘图坐标对应。判断哪个柱子被点击:计算x落在哪个gap区间。

绘图性能

Canvas每次重绘都要清空重画——没有局部更新能力。大数据量时注意:

  1. 避免频繁重绘:数据不变就不调draw方法
  2. 减少路径数量:多条线合用一个beginPath
  3. 离屏Canvas缓存:OffscreenCanvas预渲染静态部分
  4. 降低帧率:动画场景30fps就够了,不必60fps

踩坑清单

问题 原因 解决
Canvas空白 在onReady之前绘图 移到onReady回调里
重绘时残影 没clearRect 每次draw前clearRect
arc画圆不闭合 没closePath beginPath→arc→closePath→fill
文字对不齐 textAlign默认start 设textAlign=‘center’
坐标偏移 没考虑padding 所有坐标加上padding偏移
饼图从3点开始 起始角度0是3点方向 用-PI/2从12点开始
DataPanel颜色不对 colors格式错 [[color, offset], …]不是[color, offset]
动画卡顿 setInterval没清 aboutToDisappear中clearInterval
onClick坐标不准 Canvas有缩放 用display.densityPixels换算
fillText乱码 font格式不对 用’14px sans-serif’格式

图表开发的核心是坐标转换——把数据值映射到Canvas像素坐标。搞清楚padding、chartWidth、chartHeight的计算,任何图表都能画出来。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐