HarmonyOS应用<节气通>开发第43篇:图表组件开发——数据可视化实践

引言

图表是数据可视化的核心载体。在节气通这类知识类应用中,折线图可以展示气温变化趋势、柱状图可以对比不同节气的特征数据、饼图可以展示学习进度分布等。HarmonyOS ArkUI 提供了内置的 <Chart> 组件,支持折线图、柱状图、散点图、饼图等多种类型,配合 Canvas 2D API 还能实现更复杂的自定义图表。

一个完善的图表组件应该具备以下特点:

  • 数据驱动:UI完全由数据模型渲染,与业务逻辑解耦
  • 自适应:根据容器尺寸自动调整布局
  • 交互友好:支持点击高亮、tooltip提示、缩放平移
  • 动画流畅:数据更新时有平滑的过渡动画
  • 主题适配:自动跟随应用的明暗主题

本文为实战总结版本 ,基于 ArkUI <Chart> 组件实现完整的折线图、柱状图组件,并包含自定义绘制方案。


学习目标

完成本文后,你将能够:

  • ✅ 理解ArkUI Chart组件的数据模型和配置体系
  • ✅ 实现带动画的折线图(气温趋势、学习曲线)
  • ✅ 实现分组柱状图(多维度数据对比)
  • ✅ 实现饼图(占比分布)
  • ✅ 添加图表交互(点击选中、Tooltip显示)
  • ✅ 使用Canvas 2D实现自定义图表
  • ✅ 封装可复用的通用图表组件

需求分析

功能模块设计

模块 功能描述 技术要点
折线图 展示连续数据的趋势变化 Line、数据点、渐变填充
柱状图 对比分类数据的大小差异 Bar、分组、圆角样式
饼图 展示各部分占总体的比例 Pie、扇区、标签引导线
图表交互 点击选中、Tooltip提示 onAreaChange、onTouch
图表动画 数据加载时的入场动画 animateTo + 数据插值
自定义图表 Canvas 2D 绘制特殊图表 CanvasRenderingContext2D

项目中的图表使用场景

场景 图表类型 数据来源 说明
节气气温走势 折线图 气象API 全年24节气温度曲线
学习统计面板 柱状图 本地记录 每周答题数量/正确率
知识掌握度 饼图 分类统计 各类别学习进度占比
节气时间轴 自定义Canvas 节气数据 圆形节气轮盘

Chart组件支持的图表类型

类型 枚举值 适用场景
折线图 Line 趋势变化、时序数据
柱状图 Bar 分类对比、排名
散点图 Scatter 相关性分析、分布
饼图 Pie 占比、构成
雷达图 Radar 多维评估
仪表盘 Gauge 进度、指标

核心实现

步骤1: 设计图表数据模型

typescript 复制代码
// models/ChartModel.ts

/** 图表数据点 */
export interface DataPoint {
  /** X轴值(标签) */
  label: string;
  /** Y轴值 */
  value: number;
  /** 可选:附加信息(用于Tooltip) */
  extra?: string;
}

/** 折线图数据系列 */
export interface LineSeries {
  /** 系列名称(图例显示) */
  name: string;
  /** 数据点数组 */
  data: DataPoint[];
  /** 线条颜色 */
  color: string;
  /** 是否填充线下区域 */
  fillArea: boolean;
}

/** 柱状图数据项 */
export interface BarDataItem {
  /** 分类名称 */
  category: string;
  /** 数值 */
  value: number;
  /** 颜色(可选,默认使用系列颜色) */
  color?: string;
}

/** 柱状图数据系列 */
export interface BarSeries {
  name: string;
  data: BarDataItem[];
  color: string;
}

/** 饼图扇区数据 */
export interface PieSegment {
  name: string;
  value: number;
  color: string;
}
设计说明

采用**系列(Series) + 数据点(DataPoint)**的两层结构:

复制代码
Chart
├── Series[0] (name="2025年", color="#4A9B6D")
│   ├── Point { label:"立春", value:3.2 }
│   ├── Point { label:"雨水", value:5.8 }
│   └── ...
├── Series[1] (name:"2026年", color="#E07B53")
│   ├── Point { label:"立春", value:4.1 }
│   └── ...

这种结构天然支持多系列对比(如两年气温对比),单系列时只传一个Series即可。


步骤2: 折线图组件 ------ 气温趋势

typescript 复制代码
// components/TrendLineChart.ets

import { LineSeries, DataPoint } from '../models/ChartModel';

@Component
export struct TrendLineChart {
  /** 数据系列(支持多条线) */
  @State seriesList: LineSeries[] = [];
  /** 图表宽度(由容器决定) */
  @State chartWidth: number = 300;
  /** 动画是否完成 */
  @State animationReady: boolean = false;

  /** 配置项 */
  private showLegend: boolean = true;
  private showGrid: boolean = true;
  private lineSmooth: boolean = true;   // 平滑曲线
  private showDot: boolean = true;      // 显示数据点
  private animationDuration: number = 800;

  aboutToAppear(): void {
    // 入场动画延迟触发
    setTimeout(() => {
      this.animationReady = true;
    }, 100);
  }

  build() {
    Column({ space: 12 }) {
      // 图例区域
      if (this.showLegend && this.seriesList.length > 0) {
        this.buildLegend()
      }

      // 图表主体
      if (this.seriesList.length > 0 && this.animationReady) {
        Chart(this.seriesList[0].data.map(p => ({ value: p.value })))
          .width('100%')
          .height(220)
          .onAreaChange((oldValue: Area, newValue: Area) => {
            // 记录实际渲染宽度
            if (newValue.width !== 0) {
              this.chartWidth = Number(newValue.width);
            }
          })
          // 配置坐标轴
          .xAxis({
            axisTick: { strokeColor: '#E8E8E8', size: 4 },
            axisLabel: {
              fontSize: 11,
              fontColor: '#999999',
              rotation: 0,
              labelCount: this.getXLabelCount()
            }
          })
          .yAxis({
            axisTick: { strokeColor: '#E8E8E8', size: 4 },
            axisLabel: {
              fontSize: 11,
              fontColor: '#999999',
              formatCallback: (value: number) => `${value.toFixed(1)}°`
            },
            min: 0,
            max: this.getYAxisMax()
          })
          // 网格线
          .grid(this.showGrid ? {
            horizontal: { strokeColor: '#F5F5F5', lineDash: [4, 4] },
            vertical: { visible: false }
          } : undefined)
          // 折线系列
          .charts(...this.buildLineDatasets())
      } else {
        // 占位(动画未开始时)
        Column()
          .width('100%')
          .height(220)
          .backgroundColor('#FAFAFA')
          .borderRadius(12)
      }
    }
    .width('100%')
  }

  /** 构建图例行 */
  @Builder
  buildLegend(): void {
    Row({ space: 16 }) {
      ForEach(this.seriesList, (series: LineSeries) => {
        Row({ space: 6 }) {
          // 颜色标识
          Row()
            .width(12)
            .height(3)
            .borderRadius(1.5)
            .backgroundColor(series.color)

          Text(series.name)
            .fontSize(12)
            .fontColor('#666666')
        }
      })
    }
    .justifyContent(FlexAlign.Center)
    .width('100%')
  }

  /** 构建 Chart 的 datasets(每条线一个 dataset) */
  private buildLineDatasets(): Array<LineDataSet> {
    return this.seriesList.map((series: LineSeries, index: number) => {
      const points = series.data.map(p => ({ value: p.value }));
      return {
        data: points as ChartData[],
        line: {
          width: 2.5,
          smooth: this.lineSmooth,
          color: series.color
        },
        symbol: this.showDot ? {
          size: 5,
          shape: SymbolType.CIRCLE,
          strokeColor: series.color,
          fillColor: '#FFFFFF'
        } : undefined,
        areaStyle: series.fillArea ? {
          color: [series.color + '20', series.color + '05']  // 渐变透明
        } : undefined
      } as LineDataSet;
    });
  }

  /** 计算X轴标签数量(避免过于密集) */
  private getXLabelCount(): number {
    const count = this.seriesList[0]?.data?.length || 0;
    if (count <= 7) return count;
    if (count <= 14) return Math.ceil(count / 2);
    return Math.ceil(count / 3);  // 24个节气时显示约8个标签
  }

  /** 计算Y轴最大值(留出顶部空间) */
  private getYAxisMax(): number {
    let maxVal = 0;
    for (const series of this.seriesList) {
      for (const point of series.data) {
        if (point.value > maxVal) maxVal = point.value;
      }
    }
    // 向上取整到最近的整数+5
    return Math.ceil(maxVal) + 5;
  }
}
使用示例
typescript 复制代码
// 在页面中使用折线图
@State chartData: LineSeries[] = [];

aboutToAppear() {
  this.chartData = [
    {
      name: '2025年',
      color: '#4A9B6D',
      fillArea: true,
      data: [
        { label: '立春', value: 3.2 }, { label: '雨水', value: 5.8 },
        { label: '惊蛰', value: 9.1 }, { label: '春分', value: 12.5 },
        { label: '清明', value: 15.3 }, { label: '谷雨', value: 18.2 },
        { label: '立夏', value: 21.5 }, { label: '小满', value: 24.8 },
        // ... 更多节气数据
      ]
    },
    {
      name: '2026年',
      color: '#E07B53',
      fillArea: false,
      data: [
        { label: '立春', value: 4.1 }, { label: '雨水', value: 6.2 },
        // ...
      ]
    }
  ];
}

build() {
  Scroll() {
    Column({ space: 16 }) {
      Text('全年气温走势')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)

      TrendLineChart({ seriesList: this.chartData })
    }
    .padding(16)
  }
}

步骤3: 分组柱状图 ------ 学习统计

typescript 复制代码
// components/BarChartComponent.ets

import { BarSeries, BarDataItem } from '../models/ChartModel';

@Component
export struct BarChartComponent {
  /** 数据系列 */
  @State barSeries: BarSeries[] = [];
  /** 动画就绪 */
  @State ready: boolean = false;

  aboutToAppear(): void {
    setTimeout(() => { this.ready = true; }, 150);
  }

  build() {
    Column({ space: 10 }) {
      if (this.ready && this.barSeries.length > 0) {
        Chart(this.getFlatData())
          .width('100%')
          .height(240)
          .xAxis({
            axisTick: { strokeColor: '#EEEEEE' },
            axisLabel: {
              fontSize: 11,
              fontColor: '#888888'
            }
          })
          .yAxis({
            min: 0,
            axisTick: { strokeColor: '#EEEEEE' },
            axisLabel: {
              fontSize: 11,
              fontColor: '#888888'
            }
          })
          .grid({
            horizontal: { strokeColor: '#F8F8F8' }
          })
          .charts(...this.buildBarDatasets())
      } else {
        Column().width('100%').height(240).backgroundColor('#FAFAFA').borderRadius(12)
      }
    }
    .width('100%')
  }

  /** 将多维数据展平为 Chart 所需格式 */
  private getFlatData(): ChartData[] {
    // 取第一个系列的分类数作为X轴基准
    const categories = this.barSeries[0]?.data || [];
    return categories.map(() => ({ value: 0 })) as ChartData[];
  }

  /** 构建柱状图 datasets */
  private buildBarDatasets(): BarDataSet[] {
    return this.barSeries.map((series: BarSeries) => ({
      data: series.data.map((item: BarDataItem) =>
        ({ value: item.value }) as ChartData
      ),
      barStyle: {
        width: 20,
        radius: [4, 4, 0, 0],  // 顶部圆角
        color: series.color
      }
    } as BarDataSet));
  }
}
使用示例
typescript 复制代码
@State weeklyStats: BarSeries[] = [
  {
    name: '答题数',
    color: '#4A9B6D',
    data: [
      { category: '周一', value: 12 },
      { category: '周二', value: 19 },
      { category: '周三', value: 8 },
      { category: '周四', value: 25 },
      { category: '周五', value: 15 },
      { category: '周六', value: 30 },
      { category: '周日', value: 22 },
    ]
  }
];

// UI 中使用
Text('本周学习统计')
BarChartComponent({ barSeries: this.weeklyStats })

步骤4: 饼图 ------ 占比分布

typescript 复制代码
// components/PieChartComponent.ets

import { PieSegment } from '../models/ChartModel';

@Component
export struct PieChartComponent {
  @State segments: PieSegment[] = [];
  @State ready: boolean = false;

  aboutToAppear(): void {
    setTimeout(() => { this.ready = true; }, 200);
  }

  build() {
    Column({ space: 16 }) {
      if (this.ready && this.segments.length > 0) {
        Row({ space: 24 }) {
          // 左侧饼图
          Chart(this.segments.map(s => ({ value: s.value })))
            .width(160)
            .height(160)
            .pie({
              ringStrokeWidth: 0,     // 实心饼图
              colors: this.segments.map(s => s.color)
            })

          // 右侧图例
          Column({ space: 10 }) {
            ForEach(this.segments, (seg: PieSegment) => {
              Row({ space: 8 }) {
                Row()
                  .width(10)
                  .height(10)
                  .borderRadius(5)
                  .backgroundColor(seg.color)

                Column({ space: 2 }) {
                  Text(seg.name)
                    .fontSize(13)
                    .fontColor('#333333')

                  Text(`${this.calcPercent(seg.value)}%`)
                    .fontSize(11)
                    .fontColor('#999999')
                }
                .alignItems(HorizontalAlign.Start)
              }
            })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      } else {
        Column()
          .width('100%')
          .height(180)
          .backgroundColor('#FAFAFA')
          .borderRadius(12)
      }
    }
    .width('100%')
  }

  /** 计算百分比 */
  private calcPercent(value: number): number {
    const total = this.segments.reduce((sum, s) => sum + s.value, 0);
    if (total === 0) return 0;
    return Math.round((value / total) * 100);
  }
}
使用示例
typescript 复制代码
@State learningProgress: PieSegment[] = [
  { name: '天文历法', value: 35, color: '#4A9B6D' },
  { name: '农事活动', value: 25, color: '#E07B53' },
  { name: '民俗文化', value: 20, color: '#5B8EC4' },
  { name: '养生保健', value: 15, color: '#F0A050' },
  { name: '诗词文学', value: 5, color: '#9B7BB8' },
];

PieChartComponent({ segments: this.learningProgress })

步骤5: Canvas 2D 自定义图表 ------ 节气轮盘

当内置 Chart 无法满足需求时(如圆形节气轮盘、环形进度),使用 Canvas 2D 手动绘制。

typescript 复制代码
// components/SolarTermWheel.ets

@Component
export struct SolarTermWheel {
  private canvasContext: CanvasRenderingContext2D | null = null;
  private terms: string[] = [
    '立春','雨水','惊蛰','春分','清明','谷雨',
    '立夏','小满','芒种','夏至','小暑','大暑',
    '立秋','处暑','白露','秋分','寒露','霜降',
    '立冬','小雪','大雪','冬至','小寒','大寒'
  ];
  @State activeIndex: number = 0;  // 当前节气索引

  build() {
    Column() {
      Canvas(this.canvasContext)
        .width('100%')
        .height(280)
        .onReady(() => {
          this.canvasContext = this.getContext('2d') as CanvasRenderingContext2D;
          this.drawWheel();
        })
        .onClick((event: ClickEvent) => {
          // 根据点击位置判断选中的节气
          const idx = this.detectClickedTerm(event.x, event.y);
          if (idx >= 0) {
            this.activeIndex = idx;
            this.drawWheel();
          }
        })

      // 当前节气名称
      Text(`当前: ${this.terms[this.activeIndex]}`)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .margin({ top: 12 })
    }
    .width('100%')
    .padding(16)
  }

  /** 绘制节气轮盘 */
  private drawWheel(): void {
    const ctx = this.canvasContext;
    if (!ctx) return;

    const canvasWidth = 340;   // 假设画布宽度
    const centerX = canvasWidth / 2;
    const centerY = 140;
    const outerR = 110;       // 外圈半径
    const innerR = 55;        // 内圈半径
    const termCount = this.terms.length;
    const angleStep = (2 * Math.PI) / termCount;

    // 清空画布
    ctx.clearRect(0, 0, canvasWidth, 280);

    // 绘制外圈背景
    ctx.beginPath();
    ctx.arc(centerX, centerY, outerR, 0, 2 * Math.PI);
    ctx.fillStyle = '#F8F7F2';
    ctx.fill();

    // 绘制每个节气的扇区和文字
    for (let i = 0; i < termCount; i++) {
      const startAngle = i * angleStep - Math.PI / 2;  // 从12点钟方向开始
      const endAngle = startAngle + angleStep;
      const midAngle = startAngle + angleStep / 2;

      // 扇区填充色
      const isActive = (i === this.activeIndex);
      ctx.beginPath();
      ctx.moveTo(centerX, centerY);
      ctx.arc(centerX, centerY, outerR, startAngle, endAngle);
      ctx.closePath();
      ctx.fillStyle = isActive ? '#4A9B6D' :
        (i % 2 === 0 ? '#FFFFFF' : '#EDF2EB');
      ctx.fill();

      // 扇区边框
      ctx.strokeStyle = '#E0DDD6';
      ctx.lineWidth = 0.5;
      ctx.stroke();

      // 文字位置(在内外半径中间)
      const textR = (outerR + innerR) / 2;
      const textX = centerX + textR * Math.cos(midAngle);
      const textY = centerY + textR * Math.sin(midAngle);

      ctx.save();
      ctx.translate(textX, textY);
      ctx.rotate(midAngle + Math.PI / 2);  // 文字沿径向排列
      ctx.fillStyle = isActive ? '#FFFFFF' : '#555555';
      ctx.font = isActive ? 'bold 12px sans-serif' : '11px sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(this.terms[i], 0, 0);
      ctx.restore();
    }

    // 绘制中心圆
    ctx.beginPath();
    ctx.arc(centerX, centerY, innerR - 5, 0, 2 * Math.PI);
    ctx.fillStyle = '#4A9B6D';
    ctx.fill();

    // 中心文字
    ctx.fillStyle = '#FFFFFF';
    ctx.font = 'bold 14px sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText('二十四\n节气', centerX, centerY);
  }

  /** 根据点击坐标判断选中的节气索引 */
  private detectClickedTerm(x: number, y: number): number {
    const centerX = 170;
    const centerY = 140;
    const dx = x - centerX;
    const dy = y - centerY;
    const distance = Math.sqrt(dx * dx + dy * dy);

    if (distance < 55 || distance > 110) return -1;  // 点击在内圈或外圈之外

    let angle = Math.atan2(dy, dx) + Math.PI / 2;  // 转换为从12点开始的角度
    if (angle < 0) angle += 2 * Math.PI;

    const termCount = this.terms.length;
    const angleStep = (2 * Math.PI) / termCount;
    return Math.floor(angle / angleStep) % termCount;
  }
}
Canvas 2D 核心 API 使用总结
API 用途 本文使用处
ctx.arc() 绘制圆弧/扇区 轮盘扇区、中心圆
ctx.beginPath/closePath/fill/stroke 路径绘制流程 每个扇区
ctx.save/restore 保存恢复坐标系状态 文字旋转前保存
ctx.translate/rotate 坐标变换 文字沿径向排列
ctx.fillText 绘制文本 节气名称
ctx.clearRect 清空画布 重绘前清空
Math.atan2 计算角度 点击位置→扇区索引

步骤6: 图表动画策略

方案一:opacity 渐入 + translateY 上移
typescript 复制代码
@State opacityVal: number = 0;
@State offsetY: number = 20;

aboutToAppear(): void {
  animateTo(
    { duration: 600, curve: 'easeOut', delay: 100 },
    () => {
      this.opacityVal = 1;
      this.offsetY = 0;
    }
  );
}

// UI 中绑定
Column() {
  TrendLineChart({ seriesList: this.data })
}
.opacity(this.opacityVal)
.translate({ y: this.offsetY })
方案二:柱状图逐根升起(stagger)
typescript 复制代码
@State barHeights: number[] = [];
@State barOpacities: number[] = [];

aboutToAppear(): void {
  const count = this.rawData.length;
  this.barHeights = new Array(count).fill(0);
  this.barOpacities = new Array(count).fill(0);

  this.rawData.forEach((_, index) => {
    setTimeout(() => {
      animateTo({ duration: 400, curve: 'easeOut' }, () => {
        this.barHeights[index] = this.rawData[index].value;
        this.barOpacities[index] = 1;
      });
    }, index * 80);  // 错位80ms
  });
}
方案三:饼图扇区依次展开
typescript 复制代码
@State pieAngles: number[] = [];  // 每个扇区的当前角度比例

aboutToAppear(): void {
  this.pieAngles = new Array(this.segments.length).fill(0);

  this.segments.forEach((_, index) => {
    setTimeout(() => {
      animateTo({ duration: 500, curve: 'easeOut' }, () => {
        this.pieAngles[index] = 1;  // 0→1 表示展开
      });
    }, index * 100);
  });
}

架构总览

复制代码
┌─────────────────────────────────────────────┐
│               页面层                          │
│                                             │
│  Index / StatsPage / ProfilePage             │
│    │                                        │
│    ├─ TrendLineChart (折线图)                │
│    ├─ BarChartComponent (柱状图)             │
│    ├─ PieChartComponent (饼图)               │
│    └─ SolarTermWheel (Canvas轮盘)           │
│                                             │
├─────────────────────────────────────────────┤
│              组件层                           │
│                                             │
│  ┌─────────────┐  ┌──────────────────────┐ │
│  │ <Chart> 组件 │  │ <Canvas> 2D 绘制     │ │
│  │ (系统内置)   │  │ (自定义图表)          │ │
│  └──────┬──────┘  └──────────┬───────────┘ │
│         │                     │             │
├─────────┴─────────────────────┴─────────────┤
│              数据模型层                        │
│                                             │
│  DataPoint / LineSeries / BarSeries         │
│  PieSegment / BarDataItem                   │
│                                             │
└─────────────────────────────────────────────┘

关键注意事项

1. Chart 组件必须在有明确宽高的容器中

Chart 需要确定的尺寸才能正确计算坐标系。如果父容器使用了 Flex 或相对布局,务必确保 Chart 有明确的 width 和 height。

2. 数据为空时的降级处理

当数据尚未加载完成或为空数组时,应显示占位元素而非空白,避免布局抖动。

typescript 复制代码
if (this.seriesList.length > 0) {
  Chart(...)  // 正常渲染
} else {
  Column()   // 占位骨架屏
    .width('100%')
    .height(220)
    .backgroundColor('#FAFAFA')
    .borderRadius(12)
}

3. Canvas 坐标系注意点

  • Canvas 的 (0,0) 在左上角
  • arc() 角度以弧度为单位,从3点钟方向开始(顺时针)
  • atan2(y, x) 返回 -π ~ π,需转换处理
  • 每次 drawWheel() 前必须 clearRect() 清空

4. 图表颜色遵循主题

图表颜色应使用主题色变量或语义化颜色,而非硬编码:

typescript 复制代码
// 推荐方式:从主题配置获取
import { LightThemeColors } from './ThemeModel';
const primaryColor = LightThemeColors.primaryColor;

5. 大数据量下的性能优化

  • 折线图超过100个数据点时考虑采样或聚合
  • Canvas 绑定 onClick 时使用区域检测而非逐像素判断
  • 避免在 onAreaChangeonTouch 回调中进行重计算

最佳实践清单

在实现图表功能时,请逐项检查:

  • 数据模型使用 Series + DataPoint 结构,便于扩展多系列
  • Chart 组件设置了明确的 width 和 height
  • 数据为空时有占位/空状态处理
  • Y轴最大值动态计算并留有余量(+5~10%)
  • X轴标签过多时自动减少显示数量
  • 图表入场有平滑动画(opacity/translate/stagger)
  • 图表颜色跟随主题或使用语义化配色
  • Canvas 自定义图表每次重绘前 clearRect
  • 数值格式化(如温度加°、百分比加%、大数字简化)
  • 图表支持暗色模式(网格线/文字颜色调整)

相关链接