1. 项目概述:为什么需要自己写一个饼状图组件
在鸿蒙应用开发中,数据可视化是一个绕不开的需求。无论是健康应用的每日步数统计、记账应用的分类支出占比,还是后台管理系统的数据看板,饼状图都是最直观的展示方式之一。虽然当前HarmonyOS生态里已经有了一些第三方图表库,但很多情况下,项目里往往只需要一个简单的饼状图,为此引入一个庞大的图表库反而显得笨重。
这个项目要做的就是基于鸿蒙自带的Canvas组件,从零手写一个轻量级的饼状图组件。不依赖任何第三方库,完全用ArkTS语言和ArkUI框架的原生能力实现,核心功能包括:
- 数据驱动的饼图绘制,支持任意数量的数据项
- 每个扇区支持独立的颜色配置
- 支持扇形区域点击事件回调
- 支持图例展示,方便用户对照查看
- 代码量控制在200行左右,方便按需二次开发
这个组件适合以下几类开发者参考:
- 正在学习鸿蒙Canvas绘图API的初学者,可以通过这个案例理清
CanvasRenderingContext2D对象的核心方法 - 项目中有轻量级数据可视化需求,但不想引入重量级依赖的开发者
- 需要对饼状图进行定制化改造(比如加动画、加标注线、做环形图)的进阶玩家
我在实际开发中反复踩过不少坑,这篇博文就把完整的实现过程和问题排查思路都写出来,希望能让后来者少走弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鸿蒙Canvas绘图基础:先搞懂底层机制
2.1 为什么选择Canvas而不是SVG或其他方案
ArkUI框架提供了多种绘图方案,包括Canvas、Shape以及第三方渲染引擎。我最终选择Canvas,主要基于以下几点考虑:
Canvas是基于像素的即时模式绘图接口,每一帧画面都是通过执行绘图命令直接生成的。对于饼状图这种需要动态刷新、支持点击态反馈的场景,Canvas的灵活性要高很多。
Shape(包括Circle、Path等矢量组件)虽然也能画饼状图,但它更偏向静态展示。如果要做复杂的交互动画,用Shape会很别扭,每次更新都需要重建组件节点,性能开销反而更大。
另外,Canvas在鸿蒙里的API设计其实相当完整。从基础的beginPath、moveTo、lineTo、arc,到高级的createLinearGradient渐变、transform矩阵变换、drawImage图片绘制,该有的都有。这个项目虽然只用到其中一部分API,但了解这些基础能力对未来做更复杂的图表(比如折线图、柱状图)很有帮助。
2.2 Canvas组件在ArkTS中的基本用法
在使用Canvas前,需要明确几个核心概念。在鸿蒙的ArkUI框架中,Canvas是一个组件,它负责在页面上预留一块矩形区域;真正执行绘图操作的是通过Canvas组件绑定的CanvasRenderingContext2D对象。
具体来说,你需要先创建一个CanvasRenderingContext2D实例,然后把它的settings传给Canvas组件。一个最基础的声明如下:
typescript复制// 创建绘图上下文
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
build() {
Canvas(this.context)
.width(300)
.height(300)
.backgroundColor('#FFFFFF')
.onReady(() => {
// 在这里执行绘图操作
this.drawPie()
})
}
注意看这里的onReady回调——这是最容易被新手忽略的地方。Canvas组件在页面布局完成后才会触发onReady,这时候才能安全地获取画布尺寸并执行绘图。在组件的aboutToAppear生命周期里就急着调用绘图方法是拿不到正确的画布尺寸的,因为此时布局尚未完成。
RenderingContextSettings构造函数里的布尔值表示是否开启抗锯齿。设为true可以避免图形边缘出现锯齿状,建议一律开启,对性能影响微乎其微。
2.3 弧度制:绘制饼状图必须绕过的数学门槛
饼状图绘制的核心数学概念是弧度制。很多前端开发者习惯用角度思考问题,但Canvas的arc方法只接受弧度。
这里有一个简单的换算公式:
code复制弧度 = 角度 × π / 180
那么画出完整的圆弧就是2π,也就是360度。绘制饼状图时,我们需要把数据项的占比映射到弧度区间上。
举个例子,如果有两个数据项:A占60%,B占40%。那么:
- A的弧度跨度为
2π * 0.6,也就是216度 - B的弧度跨度为
2π * 0.4,也就是144度
在实际代码中,我们会维护一个“当前起始角度”变量,每画完一个扇区,就把这个起始角度累加上当前扇区的弧长。这里有一个容易被忽视的细节:角度计算时,起始位置是从3点钟方向开始,而不是12点钟方向。如果你希望第一块扇区从正上方(12点方向)开始,需要把起始角度偏移 -π/2。
3. 初始版本:10分钟实现一个静态饼状图
3.1 绘制扇形区域的核心算法
绘制一个扇形区域,逻辑上其实就三步:
- 移动画笔到圆心位置
- 画一条到圆弧起点的线段
- 以圆心为基准,从起始角度画到结束角度,形成弧线
- 闭合路径并填充颜色
用ArkTS代码表达:
typescript复制private drawSector(context: CanvasRenderingContext2D, centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, color: string) {
context.beginPath()
context.moveTo(centerX, centerY)
context.arc(centerX, centerY, radius, startAngle, endAngle, false)
context.closePath()
context.fillStyle = color
context.fill()
}
注意这里的context.arc方法,最后一个参数是布尔值,false表示按顺时针方向绘制。在Canvas坐标系中,角度是顺时针增加的,所以如果数据排列顺序是从小到大,用顺时针绘制就能确保扇区顺序跟数据顺序一致。
这里有一个实际的演示效果:假设有三个数据项,分别是[苹果50, 香蕉30, 橘子20],三个项目的颜色分别设为红色系、黄色系、橙色系。调用三次drawSector方法,就能得到一个完整的饼状图。
不过这个版本有一个明显的局限:扇区之间没有间隔,如果相邻两个扇区的颜色比较接近,视觉上很难区分边界。后续版本会加入“扇区间距”参数来优化这个问题。
3.2 完整代码:一个静态饼状图的页面实现
这里给出一个可以运行的最小示例,完整代码放在一个@Entry装饰的组件里:
typescript复制@Entry
@Component
struct SimplePieChartPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private data: number[] = [120, 80, 50, 30]
private colors: string[] = ['#FF6B6B', '#FFD93D', '#6BCB77', '#4D96FF']
private labels: string[] = ['直接访问', '搜索引擎', '邮件营销', '联盟广告']
build() {
Column({ space: 16 }) {
Canvas(this.context)
.width(300)
.height(300)
.backgroundColor('#FFFFFF')
.borderRadius(8)
.onReady(() => {
this.drawPieChart()
})
Text('流量来源分析')
.fontSize(16)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding(20)
.justifyContent(FlexAlign.Center)
}
private drawPieChart() {
const total = this.data.reduce((sum, item) => sum + item, 0)
let startAngle = -Math.PI / 2 // 从12点钟方向开始
this.context.clearRect(0, 0, 300, 300)
this.data.forEach((value, index) => {
const sliceAngle = (value / total) * Math.PI * 2
this.drawSector(
this.context,
150, 150, 100,
startAngle,
startAngle + sliceAngle,
this.colors[index]
)
startAngle += sliceAngle
})
}
private drawSector(context: CanvasRenderingContext2D, centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, color: string) {
context.beginPath()
context.moveTo(centerX, centerY)
context.arc(centerX, centerY, radius, startAngle, endAngle, false)
context.closePath()
context.fillStyle = color
context.fill()
}
}
运行这个页面,就能看到一个基础的彩色饼状图。每个扇区的大小和数据占比成正比,颜色依次从colors数组中取用。
4. 进阶封装:把饼状图组件化并加上交互能力
4.1 组件化改造:从页面逻辑到通用组件
静态饼状图只算完成了“画出来”这一步。真正在项目里使用,我们还需要把绘图逻辑抽离成一个独立的组件,让外部可以通过参数控制数据、颜色、尺寸,并响应点击事件。
这里我设计了一个PieChartComponent,对外暴露三个主要属性:
typescript复制@Component
export struct PieChartComponent {
// 图表数据:每个数据项的值
private data: number[] = []
// 每个数据项的颜色
private colors: string[] = []
// 每个数据项的标签,用于图例展示
private labels: string[] = []
// 触摸事件的回调
private onSliceClick: (index: number) => void = () => {}
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
// 记录每个扇区的角度区间,用于触摸命中判断
private angleRanges: Array<{ start: number, end: number }> = []
private centerX: number = 0
private centerY: number = 0
private radius: number = 0
}
组件化带来的最大好处是复用和隔离。页面上只需要声明组件并传入数据,内部状态和Canvas上下文完全封装在组件内部。后续如果要改图表样式,只改组件本身,不影响业务页面。
关于组件参数传递,这里需要提醒一个坑:@Prop装饰的变量是单向数据流,父组件更新时子组件会同步更新。但如果传入的是对象类型(比如数组),修改数组内部元素时,子组件可能不会自动感知到变化。这种情况下需要在父组件中重建数组引用(比如用展开运算符生成新数组),强制子组件刷新。
4.2 触摸事件与扇区命中检测
让饼状图响应点击事件,核心是处理好“触摸坐标到扇区的映射”。这里涉及两个数学问题:判断点击位置是否在圆内,以及计算点击位置对应的角度。
首先是坐标系的转换。Canvas组件的触摸事件回调会输出相对于组件左上角的坐标偏移量。我们需要把它转换为相对于圆心的偏移:
typescript复制.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Up) {
const x = event.touches[0].x - this.centerX
const y = event.touches[0].y - this.centerY
// 更新点击逻辑
}
})
然后是判断是否在圆内。通过勾股定理,如果 x*x + y*y <= radius*radius,说明点落在圆内。
接着计算角度。这里要特别小心,Math.atan2函数返回的角度范围是 [-π, π],而我们绘制扇区时用的起始角度是 -π/2 沿顺时针递增。两者需要做一个统一转换:
typescript复制let angle = Math.atan2(y, x)
// 统一到 [0, 2π) 的范围内
if (angle < 0) {
angle += Math.PI * 2
}
// 因为起始角度是 -π/2,相当于 3π/2(即 270 度),所以需要做偏移
let normalizedAngle = angle + Math.PI / 2
if (normalizedAngle >= Math.PI * 2) {
normalizedAngle -= Math.PI * 2
}
这个转换逻辑听起来绕,实际运行时也是一个很容易出bug的地方。我的建议是,在开发阶段,可以在点击回调里把计算出来的角度值用console.info打印出来,点几个不同方向的位置,观察是否按照预期返回对应的扇区索引,这样验证起来直观很多。
4.3 图例组件:让图表内容一目了然
一个没有图例的饼状图,用户只能看到几块颜色,完全不知道每块代表什么。所以我在组件里配套实现了一个简易图例。
图例的布局用Row和Column组合实现,每个图例项包含一个小色块和文本标签:
typescript复制@Builder
private LegendItem(color: string, label: string) {
Row({ space: 6 }) {
Circle({ width: 12, height: 12 })
.fill(color)
Text(label)
.fontSize(12)
.fontColor('#333333')
}
.height(24)
}
图例的数据从组件传入的labels数组和colors数组自动生成,无需额外配置。这里有一个设计取舍:图例放在饼图右侧还是底部,取决于图表的长宽比。如果饼图宽度较大,图例放底部;如果高度较大,图例放右侧。我的默认实现是底部排列,支持自动换行,通过FlexWrap属性控制。
4.4 完整组件代码与使用示例
把以上功能整合起来,组件的核心代码概览如下:
typescript复制@Component
export struct PieChartComponent {
@Prop data: number[]
@Prop colors: string[]
@Prop labels: string[]
private onSliceClick: (index: number) => void = () => {}
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private angleRanges: Array<{ start: number, end: number }> = []
private centerX: number = 0
private centerY: number = 0
private radius: number = 0
build() {
Column({ space: 12 }) {
Canvas(this.context)
.width('100%')
.aspectRatio(1)
.onReady(() => {
this.drawChart()
})
.onTouch((event) => {
this.handleTouch(event)
})
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(this.labels, (label: string, index: number) => {
this.LegendItem(this.colors[index], label)
}, (label: string, index: number) => `${label}-${index}`)
}
.width('100%')
.padding({ top: 8 })
}
}
private drawChart() {
// 获取canvas实际宽高
// 假设组件宽度通过onAreaChange获取
const total = this.data.reduce((sum, item) => sum + item, 0)
let startAngle = -Math.PI / 2
this.angleRanges = []
this.context.clearRect(0, 0, this.context.width, this.context.height)
this.data.forEach((value, index) => {
const sliceAngle = (value / total) * Math.PI * 2
this.angleRanges.push({ start: startAngle, end: startAngle + sliceAngle })
this.drawSector(this.context, this.centerX, this.centerY, this.radius, startAngle, startAngle + sliceAngle, this.colors[index], index)
startAngle += sliceAngle
})
}
}
使用方式非常简单:
typescript复制PieChartComponent({
data: [120, 80, 50, 30],
colors: ['#FF6B6B', '#FFD93D', '#6BCB77', '#4D96FF'],
labels: ['直接访问', '搜索引擎', '邮件营销', '联盟广告'],
onSliceClick: (index) => {
console.info(`点击了第${index}个扇区`)
}
})
5. 功能扩展:环形图、渐变色与动画效果
5.1 从饼状图到环形图的改造思路
环形图(也叫环形图,用圆环替代实心圆)在视觉效果上比饼状图更轻盈,尤其适合展示占比数据的场景。改造起来其实很简单:绘制扇形时,不用moveTo和closePath形成封闭扇形,而是把arc的起终点连接成弧形区域,然后设置strokeStyle和lineWidth来描边。
核心代码如下:
typescript复制private drawRingSector(context: CanvasRenderingContext2D, centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, color: string) {
context.beginPath()
context.arc(centerX, centerY, radius, startAngle, endAngle, false)
context.strokeStyle = color
context.lineWidth = 20
context.lineCap = 'round' // 圆头线帽,视觉更柔和
context.stroke()
}
这个方案的好处是改动极小,只需要把原来的drawSector替换为drawRingSector。线宽通过lineWidth参数控制,粗一点就有环形的视觉效果,细一点就更接近“棒棒糖图”的风格。
5.2 渐变色提升视觉质感
纯色填充虽然简单清晰,但在追求外观的商用场景中略显得单调。Canvas的createLinearGradient方法可以创建线性渐变,让每个扇区呈现柔和过渡。
以红色扇区为例:
typescript复制const gradient = this.context.createLinearGradient(
this.centerX - this.radius,
this.centerY - this.radius,
this.centerX + this.radius,
this.centerY + this.radius
)
gradient.addColorStop(0, '#FF6B6B')
gradient.addColorStop(1, '#EE5A24')
this.context.fillStyle = gradient
这样得到的效果是每个扇区从浅色到深色过渡,整个图表的层次感明显提升。如果想做更复杂的每扇区独立渐变,可以把渐变起止点的坐标改成扇形所在的方向,这就需要按角度计算坐标,代码会复杂一些。对于简单组件,统一用全局对角渐变已经够用。
5.3 加载动画:让图表“长”出来
静态图表在页面加载时有些突兀,加上一个简单的展开动画后,体验会好很多。实现思路是:用@State记录一个动画进度变量,从0逐步变化到1,绘制扇区时把结束角度乘以这个进度。
typescript复制@State private progress: number = 0
private drawChartWithAnimation() {
const animateTo = (target: number) => {
animateTo({
duration: 800,
curve: Curve.EaseOut,
onUpdate: (value: number) => {
this.progress = value
this.drawChart()
}
})
}
animateTo(1)
}
绘制时:
typescript复制const sliceAngle = (value / total) * Math.PI * 2 * this.progress
这样呈现的效果就是所有扇区从起始角度同步向外展开,大约0.8秒内完成动画,视觉上很顺畅。
需要额外说明的是,HarmonyOS的animateTo接口既支持在@State变量上做属性动画,也支持手动控制进度。上面的写法是通过onUpdate回调手动驱动Canvas重绘,灵活性更高,适合对绘图内容做逐帧控制。
6. 常见问题与排查技巧实录
6.1 扇区绘制方向不对
我之前遇到过一个情况:数据是正的,但绘制出来的扇区顺序和预期相反。后来发现是因为arc方法的最后一个参数(是否逆时针)传错了。
context.arc(centerX, centerY, radius, startAngle, endAngle, false)中的false表示顺时针绘制。在Canvas坐标系中,角度从0开始,顺时针方向递增。如果数据排列顺序是[50, 30, 20],且你想让第1个扇区紧挨着起始角度,那就必须用顺时针。
如果你想让扇区按逆时针方向排列,把false改为true就行。但要注意,startAngle和endAngle的顺序也要反过来,否则会画出大半个圆的错误效果。
6.2 Canvas尺寸为0导致什么都画不出来
这是新手高频问题。直接把Canvas的宽高写成'100%'然后依赖父容器撑开,但父容器如果设置了flexGrow之类属性,在onReady触发时可能还没完成布局,导致获取到的context.width和context.height都是0。
解决这个问题有几个思路:
- 给Canvas设定固定宽高,比如
.width(300).height(300) - 用
.aspectRatio(1)配合.width('100%'),让宽高比固定为1,容器一旦布局完成,Canvas尺寸就确定了 - 在
onReady里使用this.context.width之前,先打印日志确认尺寸是否正确
我强烈建议在onReady回调里加一行日志输出,确认宽高:
typescript复制.onReady(() => {
console.info(`Canvas尺寸: ${this.context.width} x ${this.context.height}`)
this.drawChart()
})
6.3 触摸事件坐标偏移
TouchEvent中的touches[0].x和touches[0].y是相对组件的坐标,而不是相对页面。如果Canvas组件在页面上有偏移(比如外层有padding或margin),计算圆心距离时就必须使用相对坐标,不能直接用页面坐标。
另一种情况是Canvas外层有translate或offset偏移,也可能导致触摸命中判断错位。排查时可以先画一个调试圆点,把每次触摸的坐标绘制到Canvas上,对比实际点击位置和理论位置是否一致。
6.4 数据更新后图表不刷新
前面提到过@Prop对数组内部变化的监听问题。如果父组件直接修改了data数组的元素(比如this.data[0] = 100),子组件的@Prop不会收到变化通知。
解决方案有两种:
- 在父组件中重建数组引用:
this.data = [...this.data],这样@Prop能检测到新引用并触发子组件刷新 - 使用
@Link双向绑定,但需要父组件传入状态变量,耦合度更高
我个人更推荐第一种方式,代码改动小,逻辑也更清晰。
6.5 性能考虑:避免频繁重绘
如果饼状图数据频繁变化(比如每秒钟刷新一次),每次都整个清空重绘的话,对低端设备的CPU占用会很明显。
优化策略是:把不变的底层元素(比如背景、图例)单独绘制到另一个Canvas图层上,只更新变化的扇区层。也可以考虑使用Canvas的invalidate接口只刷新局部区域。不过对大部分业务场景来说,饼状图数据变化频率不会太高,这个优化可以等真有问题时再做。
7. 实操心得与进一步扩展思路
这个简单饼状图组件从开始构思到最终跑通,我大概花了一个完整工作日的时间。回想起来,最花时间的不是绘制扇区本身,而是在触摸命中检测的角度换算上。当时忽略了atan2返回值和绘制起始角度的差异,连续几个点击都定位不到正确的扇区,最后是打印中间变量一步步对比才定位到问题。
从工程实践的角度看,有几点经验值得分享:
第一,组件封装时,参数校验一定要做。 比如data数组为空时应该给出默认显示,data和colors长度不一致时应该做兜底处理,防止越界访问。
第二,用ForEach渲染图例时,key生成器一定要保证唯一性。 如果两个标签文字相同,用标签本身做key会触发告警,最好拼接索引:${label}-${index}。
第三,Canvas的onReady触发时机。 在不同的HarmonyOS版本上行为可能略有差异,稳妥的做法包括在onReady里绘制,以及在页面onPageShow时重新检查一次是否需要重绘。
接下来这个组件还可以往几个方向扩展:
- 支持每个扇区独立设置“偏移半径”,实现强调某一项数据的“爆炸饼图”
- 在扇区中间显示百分比数字,这需要计算扇区的中心角度,再用
fillText绘制 - 支持数据排序,让占比大的扇区排在前面,提升可读性
- 导出Canvas为图片,比如调用
context.toDataURL,方便分享或保存
我个人在实际开发中最常用的是“爆炸饼图”效果,在汇报类页面里能很直观地突出核心指标。实现也不复杂:在drawSector时,对特定索引的扇区,把圆心坐标沿着扇区中心角方向偏移一段距离就行了。
这个组件目前已经在我的两个实际项目中稳定运行了,包括一个健康管理应用和一个运营数据看板。整体来说,自绘方案在性能、体积和可维护性上都满足需求,希望你也能基于这篇博文快速跑通自己的版本。
