前一阵项目里要加一个数据看板,首页需要展示用户任务完成比例的饼状图。我本来想直接找一个现成的鸿蒙图表库,翻了半天发现要么依赖太重,要么文档不全,要么就是维护到一半就停更了,最后决定自己用 ArkTS 和 Canvas 画一个简单的饼状图组件。整个过程不算复杂,但踩了不少坑,尤其是角度计算、单位换算和触摸命中这几个地方。这篇文章就把这个组件的完整实现思路和代码整理出来,适合正在做鸿蒙开发、又不想被三方图表库绑死的朋友参考。看懂之后,你不仅能拿到一个能直接用的饼状图组件,还能搞清楚 Canvas 绘图在鸿蒙里的底层逻辑。
1. 为什么自己写饼状图组件,而不是直接引入图表库
1.1 鸿蒙生态里三方图表库的真实处境
先说说我为什么没有直接用现成的图表库。鸿蒙生态发展到现在,三方库的数量确实多了不少,但图表这个领域一直比较尴尬。我调研了一圈,真正能在 HarmonyOS NEXT 上稳定跑的图表库就那么几个,有的只支持折线图和柱状图,饼状图支持得很勉强,有的需要你去手动适配 Canvas 的渲染上下文,还有的包体积动辄几 MB,为了一个饼图引入这样的依赖,性价比很低。
更重要的问题是可控性。三方图表库为了适配各种业务场景,会暴露大量配置项,但一旦遇到视觉稿上的特殊要求,比如某个扇区要单独高亮、图例要放在指定位置、或者数据刷新时要带一段自定义动画,你去翻源码改配置反而比直接画一个更费劲。饼状图的数学原理说白了很简单,就是百分比换算成角度,再画几个扇形,完全没有必要为了这个引入一个沉重的抽象层。
1.2 自绘方案的核心优势与边界
自己用 Canvas 画饼状图,最大的优势是三个:轻量、可控、符合鸿蒙原生的开发习惯。组件就一个 .ets 文件,不依赖任何三方包,放进项目里直接用,后续想改颜色、改半径、加动画,全在自己手里。而且 ArkUI 的 Canvas 能力其实挺完整的,路径绘制、填充、描边、变换都有,画饼状图绰绰有余。
不过我也得提醒一句,自绘方案是有边界的。如果你的图表需求是复杂交互的,比如拖拽排序、缩放、联动 tooltip,那还是建议认真评估商用图表库,毕竟那套交互逻辑自己从零写成本很高。但如果你只是需要一个静态展示或者轻交互的饼状图,自绘绝对是最优解。这篇文章讲的方案就是后一种场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的准备工作:Canvas坐标系、角度与单位换算
2.1 ArkUI Canvas 组件的最小可用模板
在鸿蒙里用 Canvas 画画,套路很固定。首先要创建一个 RenderingContextSettings,再把 CanvasRenderingContext2D 绑到 Canvas 组件上,最后在 onReady 回调里执行绘制。一个最基础的模板长这样:
typescript复制@Entry
@Component
struct CanvasDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
build() {
Column() {
Canvas(this.ctx)
.width('100%')
.height(240)
.onReady(() => {
// 在这里开始绘制
this.ctx.fillStyle = '#3D8BFF';
this.ctx.beginPath();
this.ctx.arc(120, 120, 60, 0, Math.PI * 2);
this.ctx.fill();
})
}
}
}
这里面有一个容易被忽略的点:RenderingContextSettings(true) 传入的 true 表示开启抗锯齿。如果你传的是 false,画出来的圆弧边缘会有明显的锯齿,尤其是深色背景下特别难看。我建议这个参数永远传 true,基本没有性能副作用。
2.2 坐标系、角度规则和 px/vp 换算
搞懂坐标系是写对饼状图的前提。Canvas 的坐标系和数学课本上不太一样,原点在左上角,x 轴向右为正,y 轴向下为正。所以一个标准圆,圆心在 (cx, cy),半径为 r,从 0 度开始画弧,0 度对应的位置是三点钟方向,角度顺时针增加。也就是说,90 度在六点钟方向,180 度在九点钟方向。
饼状图通常希望从十二点钟方向开始,也就是顶部起始,所以我们的起始角不能写 0,而要写 -Math.PI / 2。这是一个非常容易踩的坑,我第一次写出来整个饼图顺时针转了 90 度,看起来歪歪扭扭的,后来才发现是起始角度的问题。
除了角度,还有一个单位问题。CanvasRenderingContext2D 内部的绘制坐标单位是 vp,这没问题,但 ctx.width 和 ctx.height 返回的是像素值 px。而触摸事件的坐标单位是 vp。如果不做换算,在高分屏或者系统字体缩放比例不是 1 的时候,点击命中的位置和实际绘制的位置就会对不上。解决办法是在读取画布尺寸时用 px2vp 做一次转换:
typescript复制this.chartWidth = px2vp(this.ctx.width);
this.chartHeight = px2vp(this.ctx.height);
这样后续所有绘制计算和触摸判断都在同一个 vp 坐标系里进行,就不会出乱子。
2.3 尺寸变化的监听与重绘触发
Canvas 组件的尺寸不是固定不变的,比如屏幕旋转、窗口大小调整、甚至父组件布局变化,都会改变画布的实际宽高。如果只用 onReady 初始化一次尺寸,后续画布大小变了,图形还是按照旧尺寸画的,就会变形。
我的做法是同时监听 onAreaChange,在区域发生变化时重新读取尺寸并触发重绘。但要注意,onAreaChange 可能会触发得比较频繁,每次都重绘会造成不必要的开销,需要判断尺寸是否真的变了:
typescript复制.onAreaChange(() => {
const w = px2vp(this.ctx.width);
const h = px2vp(this.ctx.height);
if (Math.abs(w - this.chartWidth) > 0.5 || Math.abs(h - this.chartHeight) > 0.5) {
this.chartWidth = w;
this.chartHeight = h;
this.drawPie();
}
})
加一个 0.5 vp 的容差,可以避免因为像素取整导致的无意义重复绘制。
3. 饼状图核心实现:从数据模型到扇形绘制
3.1 数据结构设计与组件骨架
写组件之前,先把数据模型定好。一个饼状图的每个扇区最少需要三个信息:标签、数值、颜色。数值不一定是百分比,我们可以在组件内部统一算比例,这样调用方传原始数据就行,比如一个任务完成了 60 项,正在进行 25 项,未开始 15 项,传进去组件自己算。
typescript复制export interface PieDataItem {
label: string;
value: number;
color: string;
}
然后定义一个 PieChartComponent,用 @Prop 接收父组件传进来的数据。为什么用 @Prop 而不是 @State?因为这份数据是父组件持有的,子组件只负责展示,数据变化时由父组件把新数组传进来,子组件监听变化后重绘,这样职责划分是最清晰的。
typescript复制@Component
export struct PieChartComponent {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
@Prop data: PieDataItem[] = [];
private chartWidth: number = 0;
private chartHeight: number = 0;
private innerRadiusRatio: number = 0;
private progress: number = 1;
private activeIndex: number = -1;
private timerId: number = -1;
}
3.2 扇形绘制的主流程与核心绘制方法
绘制扇形是整个组件的核心。逻辑不复杂:先算出所有数据的总和,然后每个数据项对应的角度就是 value / total * 2 * Math.PI,从起始角开始依次累加,每个数据项用 moveTo 移到圆心,再 arc 画一段弧,最后 closePath 形成一个闭合扇形,填充颜色。
typescript复制private drawPie(): void {
const ctx = this.ctx;
const total = this.totalValue;
const width = this.chartWidth;
const height = this.chartHeight;
if (width <= 0 || height <= 0) {
return;
}
ctx.clearRect(0, 0, width, height);
if (total <= 0 || this.data.length === 0) {
return;
}
const outerR = this.outerRadius;
const innerR = this.outerRadius * this.innerRadiusRatio;
const cX = this.centerX;
const cY = this.centerY;
const scale = this.progress;
let startAngle = -Math.PI / 2;
this.data.forEach((item, index) => {
const angleSize = (item.value / total) * Math.PI * 2 * scale;
const endAngle = startAngle + angleSize;
const isActive = index === this.activeIndex;
const offset = isActive ? Math.min(8, outerR * 0.05) : 0;
const midAngle = startAngle + angleSize / 2;
const offsetX = Math.cos(midAngle) * offset;
const offsetY = Math.sin(midAngle) * offset;
ctx.save();
if (offsetX !== 0 || offsetY !== 0) {
ctx.translate(offsetX, offsetY);
}
ctx.beginPath();
ctx.arc(cX, cY, outerR, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = item.color;
ctx.fill();
if (isActive) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
startAngle = endAngle;
});
}
private get totalValue(): number {
return this.data.reduce((sum, item) => sum + item.value, 0);
}
private get centerX(): number {
return this.chartWidth / 2;
}
private get centerY(): number {
return this.chartHeight / 2;
}
private get outerRadius(): number {
return Math.min(this.chartWidth, this.chartHeight) / 2 - 12;
}
这里有几个细节值得展开说。
第一,clearRect 必须在绘制之前调用,否则上次绘制的内容会残留。如果数据刷新后扇区数量变少了,旧图不擦掉就会叠在一起。
第二,高亮状态下我用了 ctx.translate 实现“弹出”效果,把整个扇区沿着它的中线方向向外推一点。注意 translate 会影响后续绘制,所以用 ctx.save() 和 ctx.restore() 把它包起来,确保每个扇区绘制的坐标系是干净的。
第三,起始角 startAngle 是在循环外定义的,每个扇区绘制完后累加,这样相邻扇区才能无缝衔接。如果你的浮点精度导致线段之间有细小的缝隙,可以在 fill 之后再用同样的颜色 stroke 一次,边框宽度设成 0.5 左右,能有效遮住缝隙。
3.3 从实心饼图扩展出环形图
环形图在视觉上比实心饼图更轻盈,很多数据看板里都喜欢用。实现方式其实就是在画外圆的同时,再画一个反向的内圆,然后闭合路径,形成一个环带。核心代码是这样:
typescript复制// 画环带
ctx.beginPath();
ctx.arc(cX, cY, outerR, startAngle, endAngle);
ctx.arc(cX, cY, innerR, endAngle, startAngle, true);
ctx.closePath();
ctx.fill();
第二个 arc 的最后一个参数 true 表示逆时针方向,这样内圆弧的方向和外圆弧相反,闭合后得到的路径就是一个“空心”的扇形环。innerRadiusRatio 用来控制环的粗细,0 表示实心,0.6 表示内半径为外半径的 60%,这个比例可以根据设计稿调整。
如果是在实心扇形上一刀切地画一个白色内圆,也是一种偷懒方式,但那个方案在背景不是纯色时会露馅,而且没法实现半透明效果。用环带路径方式画出来的才是真正的透明空心,放到任何背景上都自然。
3.4 百分比标签与图例的排版
扇区画完之后,用户需要知道每个颜色代表什么。有两种常见的做法:一种是在扇区旁边画引导线和文字,另一种是在图下方放图例列表。考虑到“简单”这个定位,我选择了图例列表,代码更简洁,也不会出现扇区太小导致文字重叠的问题。
图例部分用 ForEach 渲染,每个图例项包含一个颜色圆点、一个标签和一个百分比文本。
typescript复制ForEach(this.data, (item: PieDataItem, index: number) => {
Row() {
Circle({ width: 10, height: 10 })
.fill(item.color)
Text(item.label)
.fontSize(12)
.fontColor('#333333')
Blank()
Text(this.formatPercent(item.value))
.fontSize(12)
.fontColor('#666666')
}
.width('100%')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
}, (item: PieDataItem, index: number) => item.label + index)
对应的百分比格式化方法:
typescript复制private formatPercent(value: number): string {
if (this.totalValue <= 0) {
return '0%';
}
return (value / this.totalValue * 100).toFixed(1) + '%';
}
这里用 .toFixed(1) 保留一位小数,避免出现 33.333333333% 这种不专业的展示。如果你的设计稿要求整数,可以改成 Math.round(),但要注意四舍五入后可能总和不是 100,这在视觉上问题不大。
3.5 颜色高亮与配色小技巧
@Prop 传入的颜色字符串,在高亮时如果只是加一根白色描边,区分度有时候不够。更明显的做法是将选中扇区的颜色加深,让用户一眼就能看出当前点中了哪一块。我写了一个简单的 darkenColor 方法,把 hex 颜色转成 RGB 后每个通道减 30:
typescript复制private darkenColor(hex: string): string {
let color = hex.replace('#', '');
if (color.length === 3) {
color = color.split('').map(c => c + c).join('');
}
const num = parseInt(color, 16);
const r = Math.max(0, (num >> 16) - 30);
const g = Math.max(0, ((num >> 8) & 0xff) - 30);
const b = Math.max(0, (num & 0xff) - 30);
return `rgb(${r}, ${g}, ${b})`;
}
这个方法不依赖任何三方库,也足够用了。缺点是如果传入的颜色本身很暗,减 30 后可能趋近于黑色,所以在实际使用中建议数据源里的颜色都选饱和度适中、明度偏高的色值。也可以把 30 改成 Math.min(30, 通道原值),但那样颜色变化不明显,我后来就直接规定项目的图表配色统一用一套预设色板,问题就没了。
4. 让图表动态化:点击高亮、数据刷新与入场动画
4.1 触摸命中检测:把点坐标换算成扇区索引
静态的饼状图只是一个“图片”,想要有交互,第一件事就是做命中检测。用户点击画布上的一个点,我们要判断这个点落在哪个扇区里。思路是:先判断点到圆心的距离是否在半径范围内,如果在,再计算这个点相对于圆心的角度,最后看这个角度落在哪个扇区的角度区间里。
typescript复制private handleTouch(event: TouchEvent): void {
if (event.type !== TouchType.Down) {
return;
}
if (this.progress < 1) {
return;
}
const touch = event.touches[0];
const x = touch.x - this.centerX;
const y = touch.y - this.centerY;
const distance = Math.sqrt(x * x + y * y);
if (distance < this.outerRadius * this.innerRadiusRatio || distance > this.outerRadius) {
this.resetActive();
return;
}
let angle = Math.atan2(y, x);
if (angle < 0) {
angle += Math.PI * 2;
}
let relAngle = angle + Math.PI / 2;
if (relAngle >= Math.PI * 2) {
relAngle -= Math.PI * 2;
}
const total = this.totalValue;
let cumulativeEnd = 0;
let hitIndex = -1;
for (let i = 0; i < this.data.length; i++) {
cumulativeEnd += (this.data[i].value / total) * Math.PI * 2;
if (relAngle <= cumulativeEnd) {
hitIndex = i;
break;
}
}
if (hitIndex !== this.activeIndex) {
this.activeIndex = hitIndex;
this.drawPie();
}
}
private resetActive(): void {
if (this.activeIndex !== -1) {
this.activeIndex = -1;
this.drawPie();
}
}
这段逻辑里最绕的部分是坐标系转换。我们在 2.2 里确定了起始角是 -Math.PI / 2,也就是顶部。而 Math.atan2 返回的是从 x 轴正方向开始、逆时针为负的角度,范围从 -Math.PI 到 Math.PI。为了让触摸角度和绘制角度处于同一个参照系,我先把它归一化到 0 到 2 * Math.PI 的范围,再整体加上 Math.PI / 2,就得到了相对起始角的偏移量。
这块我调试的时候花了不少时间,建议你写完之后用几个特殊点验证一下:点击正上方应该命中第一个扇区,点击正右方应该命中某个角度正好为 0 的边界位置,点击正下方应该命中大概在 180 度附近的扇区。
4.2 数据变化后如何正确触发重绘
@Prop 接收的数据如果变了,子组件不会自动重绘。需要在数据属性上加一个监听器,当数据数组变化时重新执行 drawPie。在 ArkUI 里可以通过 @Watch 实现:
typescript复制@Prop @Watch('onDataChange') data: PieDataItem[] = [];
private onDataChange(): void {
this.resetActive();
this.progress = 1;
this.drawPie();
}
注意 @Watch 回调会在数据被赋新值时触发,但如果是修改数组内部的某个元素,不一定会触发。所以在父组件里更新数据时,最好直接生成一个新数组,而不是 push 或者修改某个 item 的属性。我在项目里是这么写的:
typescript复制this.chartData = [
{ label: '已完成', value: 80, color: '#3D8BFF' },
{ label: '进行中', value: 10, color: '#36D1A3' },
{ label: '未开始', value: 10, color: '#FFBB3D' }
];
每次都是新建一个数组赋给 @State,这样引用发生了变化,子组件的 @Watch 才会稳定触发。
4.3 入场动画与定时器的资源管理
饼状图加载的时候,如果所有扇区一下子全部出现,视觉上略显生硬。我加了一个简单的入场动画:让 progress 从 0 慢慢变成 1,每帧重绘一次,扇区就会从起始位置依次“长”出来。核心代码是用 setInterval 做逐帧更新:
typescript复制private playAnimation(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId);
}
this.progress = 0;
const step = 0.02;
this.timerId = setInterval(() => {
this.progress += step;
if (this.progress >= 1) {
this.progress = 1;
clearInterval(this.timerId);
this.timerId = -1;
}
this.drawPie();
}, 16);
}
每 16 毫秒刷新一次,接近 60fps 的帧率。步长 0.02,总动画时长约为 800 毫秒,观感比较自然。如果你想要更精确的时长控制,可以记录动画开始的时间戳,每帧根据已过时间计算进度,但在这个场景下固定步长已经够用。
定时器一定要记得清理,否则组件销毁后定时器还在跑,轻则内存泄漏,重则触发空指针。在组件销毁回调 aboutToDisappear 里做清理:
typescript复制aboutToDisappear(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
如果你用的是 requestAnimationFrame 而不是 setInterval,也要在销毁时取消帧回调。我试过用 requestAnimationFrame 做,代码更细,但 setInterval 在这种大批量重绘场景下更省心。
5. 常见问题速查与性能优化建议
5.1 典型问题速查表
我把实际开发里最常遇到的问题整理成了一张表,基本都是我踩过的坑,按出现的频率排序:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| Canvas 画出来是空白 | 没有在 onReady 里初始化宽高,或者宽高为 0 | 在 onReady 中读取 ctx.width/height,并 px2vp 转换 |
| 扇形整体旋转了 90 度 | 起始角度写成了 0,没有从顶部开始 | 起始角设置为 -Math.PI / 2 |
| 点击扇区没反应或命中错位 | 触摸坐标是 vp,绘制尺寸是 px,没有统一单位 | 用 px2vp |
