
引言
图表是数据可视化的核心载体。在节气通这类知识类应用中,折线图可以展示气温变化趋势、柱状图可以对比不同节气的特征数据、饼图可以展示学习进度分布等。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 时使用区域检测而非逐像素判断
- 避免在
onAreaChange或onTouch回调中进行重计算
最佳实践清单
在实现图表功能时,请逐项检查:
- 数据模型使用 Series + DataPoint 结构,便于扩展多系列
- Chart 组件设置了明确的 width 和 height
- 数据为空时有占位/空状态处理
- Y轴最大值动态计算并留有余量(+5~10%)
- X轴标签过多时自动减少显示数量
- 图表入场有平滑动画(opacity/translate/stagger)
- 图表颜色跟随主题或使用语义化配色
- Canvas 自定义图表每次重绘前 clearRect
- 数值格式化(如温度加°、百分比加%、大数字简化)
- 图表支持暗色模式(网格线/文字颜色调整)