画个饼,给数据点颜色看看——在 HarmonyOS 模拟器上手搓一个饼图/环形图组件
前言最近接了个小活要在一份报告中展示用户来源分布。甲方给了个 Excel 表格里面有七组数据每个数据配一个标签。我盯着那堆数字看了半天忽然意识到他们真正想要的不是表格是一张饼图——大块儿的“自然流量”一眼就能看出来小块的“付费广告”也不会被忽略。饼图这东西就是给数字穿上戏服让它们在一张圆饼上争地盘。市面上画图表的库不少但 HarmonyOS 应用里我更喜欢自己用 Canvas 画。一方面不用多引入一个依赖另一方面画饼图、环形图这种简单的几何图形本身就几段扇形加几行文字没比调用库复杂多少还能精确控制每一像素的样子。于是上周某天晚上我打开 DevEco Studio 6.1.1 Beta1在 Pura X Max 模拟器上从零手写了一个饼图/环形图切换工具。它接收一个 JSON 数组自动算百分比给每个扇区分配颜色在 Canvas 上画出扇区并在旁边标上文字和图例。这篇文章就是那个晚上的记录里面有弧线怎么画、百分比怎么转成角度、环形图怎么在饼图上挖个洞以及怎么让图例乖乖排成一行。代码也给全你拷进模拟器换上自己的数据就能用。一、一个扇形是怎么在 Canvas 上“切”出来的饼图的每个扇区其实就是一个圆的一部分——数学上叫“扇形”。在 HarmonyOS 的 Canvas 里画扇形用的是arc方法配合moveTo和lineTo。核心思路是从圆心出发先moveTo到圆心然后画一条弧再lineTo回到圆心闭合路径填色描边。ctx.arc(cx, cy, radius, startAngle, endAngle, counterclockwise)接受六个参数圆心坐标、半径、起始角度、终止角度、是否逆时针。角度单位是弧度0 弧度在三点钟方向顺时针方向为正。我们习惯从 12 点钟方向开始画饼图也就是 -π/2 的位置所以每个扇区的起始角度就是上一个扇区的终止角度初始值设为-Math.PI / 2。给定一组数据如何算出每个扇区占多少角度首先把所有数据的value加起来得到总数然后每个数据的角度占比就是(value / total) * 2π。这样我们有了一个角度数组遍历它一边画扇形一边累加起始角度。画扇形的关键代码如下let startAngle -Math.PI / 2; for (let item of data) { let sweepAngle (item.value / total) * 2 * Math.PI; let endAngle startAngle sweepAngle; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle item.color; ctx.fill(); ctx.stroke(); startAngle endAngle; }ctx.arc画出的弧是圆周长的一部分moveTo到圆心再closePath自然就从弧的终点连回了圆心形成一个完整的扇形。整个过程非常直观就像用圆规画一个角然后把角的边涂上颜色。二、把饼图挖个洞——环形图的绘制技巧环形图就是饼图中间多了一个空白圆洞。看上去复杂实际上只需在画扇区时把“从圆心出发”改为“从内圆出发”。也就是说我们画的不是一个从圆心开始的扇形而是一个“扇环”——内半径和外半径之间的一圈厚片。Canvas 没有直接画扇环的方法但我们可以用路径拼接先用arc画外弧再从外弧终点画线到内弧终点实际上不是直线是径向线然后用arc逆时针画内弧再画线回到起点。这个路径用moveTo和arc组合起来就能实现。具体做法是从内圆的扇区起点开始moveTo(innerStartX, innerStartY)。画外弧arc(cx, cy, outerRadius, startAngle, endAngle)。画线到内圆终点lineTo(innerEndX, innerEndY)。画内弧逆时针arc(cx, cy, innerRadius, endAngle, startAngle, true)—— 注意这里true表示逆时针因为我们要反向画回起点。闭合路径。代码示例ctx.beginPath(); ctx.arc(cx, cy, outerRadius, startAngle, endAngle); ctx.arc(cx, cy, innerRadius, endAngle, startAngle, true); ctx.closePath(); ctx.fill();这种双弧线画法在 Canvas 中非常常用画环形图、进度环都用同一套逻辑。画出来的环形扇区内圆是空的正好可以用来放一个总数值或者标题让图表更有“仪表盘”的感觉。三、标签和图例——让饼图开口说话光有颜色还不够需要告诉观众每个扇区代表什么。这就需要标签和图例。标签一般放在扇区内部或旁边。放在内部时我们可以计算扇区中间角度的位置然后从圆心向外延伸一定距离比如半径的 60% 处画上文字。为了让文字居中可以用ctx.textAlign center和ctx.textBaseline middle。如果扇区太小文字可能会挤在一起那就省略不画。图例通常在图表下方或右侧用一个小色块加上文字说明。在我们的实现里Canvas 下方留出一块区域循环绘制色块矩形和标签文字。排列方式可以横向排成几行自动换行。为了方便我给每个图例项固定宽度用fillRect画色块fillText画文字。图例的数据和颜色直接从原始数据数组里拿。这样扇区和图例共用同一组颜色确保对应关系清晰。另一个小细节颜色分配。为了让饼图好看我预设了一组颜色数组比如[#FF6384,#36A2EB,#FFCE56,#4BC0C0,#9966FF,#FF9F40,#E7E9ED]。数据项数超过数组长度时可以循环使用。颜色用 HSL 生成也能保证视觉差异但这里为了简单直接用预设值。四、完整代码——画布上的饼图/环形图自由切换以下代码适配 DevEco Studio 6.1.1 Beta1、SDK22 语法Pura X Max 模拟器。新建 Empty Ability 项目替换entry/src/main/ets/pages/Index.ets。无需任何权限。/* * 饼图与环形图绘制 * 环境DevEco Studio 6.1.1 Beta1Pura X Max 模拟器SDK22 */ import { CanvasRenderingContext2D } from ohos.graphics.canvas; interface ChartData { label: string; value: number; color: string; } const COLORS: string[] [ #FF6384, #36A2EB, #FFCE56, #4BC0C0, #9966FF, #FF9F40, #E7E9ED ]; Entry Component struct Index { State isRing: boolean false; // 是否环形图 State data: ChartData[] []; private ctx: CanvasRenderingContext2D | null null; private canvasWidth: number 0; private canvasHeight: number 0; async aboutToAppear(): Promisevoid { // 初始化示例数据 this.data [ { label: 自然流量, value: 335, color: COLORS[0] }, { label: 付费广告, value: 210, color: COLORS[1] }, { label: 社交媒体, value: 190, color: COLORS[2] }, { label: 邮件营销, value: 140, color: COLORS[3] }, { label: 直接访问, value: 125, color: COLORS[4] } ]; } private onCanvasReady(ctx: CanvasRenderingContext2D): void { this.ctx ctx; this.canvasWidth ctx.canvas.width; this.canvasHeight ctx.canvas.height; this.drawChart(); } private drawChart(): void { if (!this.ctx) return; let ctx this.ctx; let w this.canvasWidth; let h this.canvasHeight; ctx.clearRect(0, 0, w, h); // 背景 ctx.fillStyle #FFFFFF; ctx.fillRect(0, 0, w, h); // 计算总和 let total 0; for (let item of this.data) { total item.value; } if (total 0) return; // 饼图/环形图中心与半径 let cx w / 2; let cy h * 0.35; // 上方留给图例 let outerRadius Math.min(w, h) * 0.3; let innerRadius this.isRing ? outerRadius * 0.5 : 0; // 绘制扇区 let startAngle -Math.PI / 2; // 从12点方向开始 for (let item of this.data) { let sweepAngle (item.value / total) * 2 * Math.PI; let endAngle startAngle sweepAngle; ctx.beginPath(); if (innerRadius 0) { // 环形扇区 ctx.arc(cx, cy, outerRadius, startAngle, endAngle); ctx.arc(cx, cy, innerRadius, endAngle, startAngle, true); ctx.closePath(); } else { // 饼图扇区 ctx.moveTo(cx, cy); ctx.arc(cx, cy, outerRadius, startAngle, endAngle); ctx.closePath(); } ctx.fillStyle item.color; ctx.fill(); ctx.strokeStyle #FFFFFF; ctx.lineWidth 2; ctx.stroke(); // 绘制标签在扇区中心 let midAngle startAngle sweepAngle / 2; let labelRadius outerRadius * 0.65; let labelX cx labelRadius * Math.cos(midAngle); let labelY cy labelRadius * Math.sin(midAngle); // 仅当扇区足够大时绘制 let percent Math.round((item.value / total) * 100); if (percent 5) { ctx.fillStyle #FFFFFF; ctx.font bold 12px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(${percent}%, labelX, labelY); } startAngle endAngle; } // 绘制图例 let legendX 40; let legendY h * 0.7; let itemHeight 25; let itemWidth 120; ctx.font 13px sans-serif; ctx.textAlign start; ctx.textBaseline middle; for (let i 0; i this.data.length; i) { let x legendX (i % 3) * (itemWidth 20); // 每行3个 let y legendY Math.floor(i / 3) * itemHeight; ctx.fillStyle this.data[i].color; ctx.fillRect(x, y - 5, 12, 12); ctx.fillStyle #333333; ctx.fillText(${this.data[i].label} (${this.data[i].value}), x 18, y); } } private switchMode(ring: boolean): void { this.isRing ring; this.drawChart(); } build() { Column() { Text(数据可视化) .fontSize(26) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 8 }) Text(饼图 / 环形图) .fontSize(15) .fontColor(#888) .margin({ bottom: 10 }) // 切换按钮 Row() { Button(饼图) .type(ButtonType.Capsule) .backgroundColor(!this.isRing ? #1976D2 : #EEEEEE) .fontColor(!this.isRing ? Color.White : #333) .fontSize(16) .layoutWeight(1) .onClick(() { this.switchMode(false); }) Button(环形图) .type(ButtonType.Capsule) .backgroundColor(this.isRing ? #1976D2 : #EEEEEE) .fontColor(this.isRing ? Color.White : #333) .fontSize(16) .layoutWeight(1) .margin({ left: 10 }) .onClick(() { this.switchMode(true); }) } .width(80%) .margin({ bottom: 12 }) Canvas() .width(100%) .height(420) .backgroundColor(#FFFFFF) .onReady((event) { let ctx event.context as CanvasRenderingContext2D; this.onCanvasReady(ctx); }) Text( 使用 Canvas arc 方法绘制扇形通过内外半径实现环形效果) .fontSize(12) .fontColor(#AAA) .width(90%) .textAlign(TextAlign.Center) .margin({ top: 8 }) } .width(100%) .height(100%) .backgroundColor(#FAFAFA) } }代码逻辑很清晰drawChart函数承担所有绘制工作根据isRing决定画实心扇形还是环形扇区。数据数组data包含标签、数值和颜色。每次切换模式或数据变化时重绘。标签绘制会跳过占比小于 5% 的扇区避免文字挤在一起。图例用简单的行列布局在 Canvas 底部绘制。运行效果把代码粘贴进 DevEco StudioRun 到 Pura X Max 模拟器。屏幕上方显示标题中间一张彩色饼图五色扇区比例分明每个大扇区中心标着百分比像 “34%”“21%”。下方图例列出流量来源和数值。点一下“环形图”按钮饼图中间立刻挖出一个空心圆扇区变成厚环整体立刻显得精致了几分。再点“饼图”又恢复实心。Canvas 绘制流畅切换瞬间完成没有任何延迟。总结这个小工具把数据可视化的几个核心技能串在了一起饼图/环形图的绘制arc方法的灵活应用环形扇区通过双弧线构建是 Canvas 中经典的图形绘制手段。角度与百分比的换算将数据占比转换为弧度扫过的角度再配合起始角度累加实现扇区连续排列。标签和布局设计在扇形中心放置文字需要计算中点角度和半径图例则用简单的行列布局逻辑。数据驱动绘图通过State绑定的数据数组和模式标记界面变化自动触发 Canvas 重绘整个过程无手动 DOM 操作。如果后续想扩展可以给数据加点交互比如点击扇区高亮、悬浮显示详细数值或者加一个简单的动画让扇区从零度展开。Canvas 在手数据可视化就不再是第三方库的专利想怎么画就怎么画。