☰
鸿蒙应用图表自绘实战:Canvas绘制折线图、饼状图与柱状图全解析
2026/10/10 6:55:45 网站建设 项目流程

数据可视化这种功能,在开发任务单上永远写着“需求简单、工作量一般”,但真正动手做的人都知道,图表是典型的“看着不起眼,做起来想骂人”的模块。我最近在一个运动健康类的鸿蒙应用项目里,一口气同时做了折线图、饼状图和柱状图三种图表,分别用来展示一周运动时长趋势、支出类别占比和月度统计对比。折腾完这一轮,我最大的感受是:在 HarmonyOS 应用里做数据可视化,把 Canvas 自绘这条路走通,比在第三方图表库里修修补补要踏实得多。

这篇文章不打算讲怎么拖拽控件、怎么配置属性,而是从数据模型、坐标换算、绘制逻辑到踩坑经验,完整拆解三类基础图表的自绘思路。适合正在做鸿蒙应用、准备在应用里加图表,又不想被第三方库文档坑到的开发者参考。你会发现,图表自绘的核心不是 API 调用,而是几个关键的数学关系。

1. 为什么我放弃了现成的图表库

1.1 三方图表库在鸿蒙生态下的真实处境

先说结论:鸿蒙生态里不是没有图表库,但成熟度和维护状态参差不齐。有些图表库是从其他平台移植过来的,API 风格还带着原平台的影子;有些库只支持固定的几个图表类型,想加一点定制就要去翻源码改样式;还有一些库更新节奏很慢,新版本系统的基础组件升级后,库作者未必能及时跟上兼容性调整。

我一开始也试过集成第三方图表组件做原型验证。当时遇到的问题是:折线图坐标轴刻度文字死活居中不了,饼状图的标签一多就叠成一团,柱状图的圆角在真机上渲染出来边缘发虚。这些问题单拎出来都不算致命,但攒在一起,意味着每次产品改一点视觉需求,都要跑到库的内部逻辑里去找修改点——这种维护成本在项目后期会越来越疼。

另外,图表库的体积也是一个隐患。一个完整功能的图表库压缩后加上依赖关系,打进来的代码量可能比你自己写的图表模块多出好几倍。对于一个追求包体积和启动速度的应用来说,用不到的功能却要一并承担,性价比很低。

1.2 Canvas 自绘的真正优势

后来我下定决心自己做,核心原因有三个。

第一是掌控力。数据是我们自己的,绘制逻辑也是自己的,大到坐标轴范围、小到每个像素点的颜色,全都透明可控。产品说“这块区域要加个淡色背景”,我改一行代码就行,不用去查库的配置项支持不支持。

第二是定制成本低。图表库提供的是通用方案,真实项目里的图表往往需要和整体设计语言统一:品牌色、圆角、渐变、字体、间距,每一项都可能偏离库的默认样式。自绘意味着所有视觉细节都掌握在自己手里,而且实现方式毫不复杂——Canvas 的 API 在鸿蒙上已经很成熟了,画线、画弧、填充、渐变都有对应能力。

第三是轻量干净。最后交付的图表代码就是几个 ts 文件,页面按需引入,没有任何多余的依赖。代码审查跑起来也快,依赖树干干净净,不会在出问题时不知道是哪一层库引起的。

当然,如果只是做原型验证,或者需求本来就是展示静态数据、没有深度定制需要,用三方库完全没问题。但凡是图表进入了正式业务流、需要频繁调整和交互,自绘的长期收益非常明显。

2. 先别急着画图,数据模型才是地基

图表能不能画好,一半取决于数据模型设计。我在动手写绘制代码之前,先把三种图表的数据结构统一了,后续开发省掉了大量重复劳动。

2.1 用一个统一的结构描述三种图表

折线图需要一组 X 轴分类名和对应的 Y 轴数值;饼状图需要一组名称和占比数值;柱状图和折线图在数据结构上几乎一样。所以定义一个最简单的数据接口就够了:

interface ChartData { label: string; // X轴分类名,或者饼状图的扇区名称 value: number; // 对应的数值 }

一行数据,三种图表通吃。实际项目中数据源通常来自服务端接口,不同接口返回的字段名五花八门,我在网络层就做了字段映射,统一转成这个结构再交给图表组件。这样做的好处是,图表组件内部不需要关心业务字段叫什么,只认label和value。

可能有读者会问,如果后续要展示多维数据怎么办?比如折线图两条线,柱状图分组对比。这个结构也能扩展:页面维护一个ChartData[][]二维数组,每一组代表一个系列,绘制方法遍历数组即可。第二层的数据模型仍然复用同一个接口,不用为每种图表单独建模。

2.2 Y 轴刻度计算:让坐标轴看起来“专业”的数学逻辑

这是最容易忽略、但最影响图表观感的地方。如果直接把数据的最大值当成 Y 轴上限,比如数据峰值是 87,坐标轴刻度就会是 0、20、40、60、80、87——最上面那个刻度值很难看,而且刻度的间距也不均匀。

正确的做法是把最大值向上取整到一个“好看”的数字,再按这个数字平均分隔刻度。我的计算逻辑如下:

function calcNiceMax(maxValue: number, tickCount: number): number { if (maxValue <= 0) { return 1; } const roughStep = maxValue / tickCount; const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); const normalized = roughStep / magnitude; let niceStep: number; if (normalized <= 1) { niceStep = 1; } else if (normalized <= 2) { niceStep = 2; } else if (normalized <= 2.5) { niceStep = 2.5; } else if (normalized <= 5) { niceStep = 5; } else { niceStep = 10; } return Math.ceil(maxValue / (niceStep * magnitude)) * (niceStep * magnitude); }

思路是:先算出每个刻度的大致步长,再把这个步长归一到 1、2、2.5、5、10 这些“整数感”强的数字上,最后把最大值向上取整到步长的整数倍。这样无论数据最大值是 87 还是 1230,Y 轴坐标都是整齐的 20、40、60、80、100,或者 500、1000、1500,视觉上专业得多。

2.3 坐标换算:业务数据怎么变成画布坐标

绘制图表首先要理解 Canvas 的坐标系。Canvas 的坐标原点在左上角,X 轴向右,Y 轴向下——这意味着业务上的“最大值”对应画布上的“最上方”,坐标换算必须把数值倒过来。

假设画布绘图区域的高度是chartHeight,顶部预留空间是paddingTop,数据最大值是maxValue,那么某个值value对应的画布 Y 坐标是:

y = paddingTop + (maxValue - value) / maxValue * chartHeight

当value等于maxValue时,y等于paddingTop,即绘图区顶部;当value等于 0 时,y等于paddingTop + chartHeight,即绘图区底部。这个公式是所有柱状图、折线图共用的核心。

X 轴坐标相对简单。折线图和柱状图通常有 N 个分类点,需要把 N 个点均匀分布在绘图区宽度chartWidth内部:

x = paddingLeft + index / (N - 1) * chartWidth // 折线图,首尾留边距 x = paddingLeft + index * slot + (slot - barWidth) / 2 // 柱状图,居中

其中折线图用N - 1是因为要让第一个点和最后一个点分别落在绘图区的两端,而柱状图用 slot 均分是为了每根柱子居中留间隙。这两个公式我在项目里几乎一字不改地复用了超过十处,值得写进自己的代码库。

这里必须多说一句:柱状图的 Y 轴起点永远从 0 开始,不允许像折线图那样把坐标轴截断。柱状图的视觉长度直接反映数值大小,如果从非 0 值开始,柱子的高度比例会严重失真,用户看到两根柱子差距很小,实际数值可能差了十倍——这是数据可视化中最容易犯的误导性设计错误。

3. 折线图:从直连折线到平滑曲线

折线图适合展示时间序列和趋势变化。我的应用里用它展示一周内每天的运动时长,数据点不多,但同样踩了不少细节坑。

3.1 坐标系与网格线绘制

画折线图之前,先把坐标系画出来。网格线让读者更容易对应数值,我一般画 4 到 5 条水平线,均匀分布在 Y 轴刻度上。

ctx.strokeStyle = '#EEEEEE'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); // 虚线网格,视觉上更轻 for (let i = 0; i <= 4; i++) { const y = paddingTop + (i / 4) * chartHeight; ctx.beginPath(); ctx.moveTo(paddingLeft, y); ctx.lineTo(canvasWidth - paddingRight, y); ctx.stroke(); } ctx.setLineDash([]); // 画完网格线恢复实线

网格线画完,把每个数据点换算成坐标数组。这一步的结果保存下来,后续画折线、画数据点、画渐变填充都要用到这份坐标。把“坐标换算”和“实际绘制”拆开,是折线图模块最重要的代码组织技巧。

3.2 折线绘制与渐变填充

折线本身很直接:moveTo到第一个点,然后lineTo依次连接后面所有点,最后stroke。

ctx.strokeStyle = '#5B8FF9'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } ctx.stroke();

这一步最容易翻车的是忘记设置lineJoin。默认的直角连接在数据点稠密的时候会显得特别扎眼,设置成round后拐角平滑很多,整体质感立刻不一样。

很多产品会要求折线下方有一层淡色渐变填充。这个效果的做法是:先画路径到最后一个点,然后lineTo到最后一个点的底部、再lineTo到第一个点的底部,闭合路径后填充从主题色到透明的垂直渐变:

const gradient = ctx.createLinearGradient(0, paddingTop, 0, paddingTop + chartHeight); gradient.addColorStop(0, 'rgba(91, 143, 249, 0.30)'); gradient.addColorStop(1, 'rgba(91, 143, 249, 0.01)'); ctx.fillStyle = gradient; ctx.fill();

注意渐变填充用的路径和折线路径是独立的,要先画完折线再单独构建闭合路径填充,否则填充会把折线盖住。

3.3 中点贝塞尔:把尖角折线变成平滑曲线

直连折线的视觉语言是“数据有突变、每个点都是独立采样”,这在很多场景下没问题。但如果数据本身是连续变化的(比如心率、体温、连续七天的趋势),折线尖角看起来就比较生硬。用二次贝塞尔曲线在中点处过渡,可以让曲线平滑而不偏离原始数据。

ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); // 用相邻点的中点作为贝塞尔控制点的锚点 for (let i = 1; i < points.length - 1; i++) { const midX = (points[i].x + points[i + 1].x) / 2; const midY = (points[i].y + points[i + 1].y) / 2; ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY); } ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); ctx.stroke();

这个技巧的关键在于控制点取的是当前数据点本身,终点取的是当前数据点和下一点的中点。这样曲线经过每个原始点的位置,但连接处是圆滑过渡的,既保留数据真实性,又去除生硬尖角。当初我看网上不少平滑方案是直接对每个点做贝塞尔三次曲线,控制点计算复杂,效果反而容易过冲,中点方案是最平衡的。

3.4 数据点标记与最小间距判断

最后一个细节:数据点周围画小圆点,需要判断相邻点的间距。如果数据点有几十个甚至上百个,每个点都画圆圈,最后就是一坨实心黑点,又卡又丑。我的处理是,先计算相邻两点 X 方向的像素间距,小于 20 像素就不画圆圈标记,只用连线表达趋势;大于 20 像素才逐个画圆点,并在第一个和最后一个数据点上方标注数值。

这个“间距阈值”看起来不起眼,但决定了图表在小屏和大屏上的观感。不同设备宽度不同,同样的数据在宽屏上间距够、在窄屏上就挤成一团,所以阈值判断不是写死逻辑,而是每次绘制时动态计算。

4. 饼状图:扇区、标签避让与环形改造

饼状图适合展示占比关系。我这边做的是支出类别占比,六七个类目,扇区不算多,但标签重叠问题让我第二次推翻重画。

4.1 角度计算与扇形绘制

饼状图的起点不是坐标换算,而是角度分配。每个扇区的角度和数值成正比:

sweepAngle = value / total * 2π

绘制时首先计算total,接着从-π/2(12 点钟方向)起手,逆时针或顺时针遍历每个数据项,累加起始角度。

const total = data.reduce((sum: number, item: ChartData) => sum + item.value, 0); let startAngle = -Math.PI / 2; // 预定义一组高对比度色板 const colors = ['#5B8FF9', '#5AD8A6', '#F6BD16', '#E8684A', '#6DC8EC', '#9270CA']; data.forEach((item: ChartData, index: number) => { const sweepAngle = total > 0 ? (item.value / total) * Math.PI * 2 : 0; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, startAngle + sweepAngle, false); ctx.closePath(); ctx.fillStyle = colors[index % colors.length]; ctx.fill(); startAngle += sweepAngle; });

这里最容易犯的错误是不先moveTo(centerX, centerY)就直接arc——虽然arc会自动和圆心连线,但路径方向会导致后续填充出现奇怪的闭合边。先移动到圆心,再画弧并closePath,出来的扇区边缘才干净。

另外,total的除零判断必须放在前面。后端返回空数据或者全零数据时,所有sweepAngle都等于 0,扇区画不出来,但这不应该导致页面崩溃或者出现异常弧线。我在真实项目里遇到一次接口波动返回了空数组,正是因为提前做了兜底,页面才没白屏。

4.2 扇区间隙:让每个区域“呼吸”

第一次画出来的饼状图,各个扇区边界死死贴在一起,颜色深的情况下切分线很不清晰。后来我改成每个扇区之间留出一点白色缝隙:把sweepAngle减去一个很小的固定值(比如 0.03 弧度),再执行arc。视觉效果立刻精致起来,每个扇区像是有独立轮廓一样,读者看图时能够更快地分离不同区域。

间隙值不能设得太大,否则小扇区会“消失”。如果某个扇区本来就小于 0.05 弧度,再减去 0.03 就等于没画出来。我加了一个条件:只有当sweepAngle > 0.06时才应用间隙,小于这个阈值的扇区照常完整绘制。

4.3 标签引出线与避让策略

标签是饼状图最难的部分。小扇区的标签文字很容易叠在一起,我试过几种策略,最终稳定下来的方案是“极坐标引出 + 水平对齐”。

先计算每个扇区的中间角度midAngle = startAngle + sweepAngle / 2,然后沿这个角度的方向引出一条线,末端在半径外加 20 像素左右的位置,再画一个色点,文本放在色点旁边。关键策略是判断文字在圆心的哪一侧:

  • 当midAngle在-π/2到π/2之间时,文字位于圆的右侧,文本左对齐,即textAlign = 'start'。
  • 否则文字位于圆左侧,文本右对齐,即textAlign = 'end'。

这个判断解决了大多数标签排版问题。剩下的零星重叠,我用了一个“最小间距避让”逻辑:维护一个标签 Y 坐标的数组,每次画新标签之前检查它和上一个标签的间距,如果小于 18 像素,就把新标签的 Y 坐标向下推到安全距离之外。这种方法不是完美的全局最优排版,但实现成本低,在扇区数量小于 10 的场景下完全够用。

4.4 中心镂空:环形图比实心饼图更好用

画完所有扇区后,再在圆心位置用背景色填充一个半径为radius * 0.6的圆,实心饼状图就变成了环形图。在圆环中心可以放总数值或者居中说明文字,比如“总支出 3280 元”。

实测下来,环形图的用户阅读效率明显高于实心饼图:圆心区域提供了额外信息展示位,而扇区的角度大小仍然清晰可辨。如果你的产品没有明确要求“必须实心”,直接做环形图基本不会错。我在应用里最终上线的也是环形图版本,中间显示“本周 7 天”这样的总结文案,页面信息量立刻提升了一个档次。

5. 柱状图:柱宽、分组与点击高亮

柱状图是三兄弟里最“老实”的,没有折线那么多样式变化,也没有饼图那么难搞的标签。但柱状图的细节藏在柱宽计算和交互反馈里。

5.1 单系列柱状图:柱宽与间隙的数学分配

柱状图的核心是“一根柱子代表一个分类,柱子的高度等于数值大小”。柱宽不能写死,因为不同屏幕宽度下,同样的柱子比例观感完全不同。我的做法是通过槽位(slot)来分配:

const slot = chartWidth / data.length; // 每个分类占用的宽度 const barWidth = slot * 0.6; // 柱子占槽位的 60%,剩下 40% 留白 data.forEach((item, index) => { const barHeight = (item.value / maxValue) * chartHeight; const x = paddingLeft + index * slot + (slot - barWidth) / 2; const y = paddingTop + (maxValue - item.value) / maxValue * chartHeight; ctx.fillStyle = '#5B8FF9'; ctx.fillRect(x, y, barWidth, barHeight); });

barWidth = slot * 0.6这个比例是我调试下来比较舒服的:柱子和间隙的比例接近 6:4,视觉上既不会觉得柱子太宽笨重,也不会因为间隙太大显得稀疏。如果数据分类特别多,可以把比例降到 0.5;分类很少时升到 0.7,都可以灵活调。

5.2 多系列分组柱状图:一个槽位画多根柱子

某次版本迭代,产品要求在月度统计页同时对比“本月”和“上月”两组数据。数据结构变成二维数组后,绘制逻辑也要对应调整:

// groups: ChartData[][] const groupSlot = chartWidth / groups.length; const innerSlot = groupSlot / groups[0].length; const barWidth = innerSlot * 0.7; groups.forEach((series, seriesIndex) => { series.forEach((item, index) => { const barHeight = (item.value / maxValue) * chartHeight; const x = paddingLeft + index * groupSlot + seriesIndex * innerSlot + (innerSlot - barWidth) / 2; const y = paddingTop + (maxValue - item.value) / maxValue * chartHeight; ctx.fillStyle = colors[seriesIndex % colors.length]; ctx.fillRect(x, y, barWidth, barHeight); }); });

关键点在于:同一组内的多根柱子共享一个分类槽位,组内柱子的位置是groupSlot加上seriesIndex * innerSlot。这样组间间距天然大于组内间距,读者能一眼看出哪几根柱子是一组。

分组柱状图还要额外给每种系列配上图例说明。我在组件底部画了两个小色块和对应文本,用户才能知道蓝色是“本月”、绿色是“上月”。没有图例的分组柱状图等于信息不完整。

5.3 点击命中检测:怎么知道用户点了哪根柱子

柱状图最常见的交互是点击某根柱子查看明细。Canvas 绘制的图形不像 ArkUI 的按钮那样自带点击事件,需要自己实现命中检测。

思路很简单:拿到点击位置的 X 坐标,换算回数据索引。

private hitTest(x: number): number { if (x < paddingLeft || x > canvasWidth - paddingRight) { return -1; } const slot = chartWidth / data.length; const index = Math.floor((x - paddingLeft) / slot); return index >= 0 && index < data.length ? index : -1; }

命中后更新一个selectedIndex状态变量,重绘时对被选中的柱子做两件事:换一种更深的颜色,或者在柱子顶部绘制该柱子的数值文本。这套机制实现简单,但给用户的反馈感非常直接。

在 Canvas 上绑定触摸事件时还要注意坐标系转换。如果 Canvas 组件在页面中有偏移,触摸事件的坐标可能是组件相对坐标而不是页面坐标,需要先做一次偏移换算。我在调试时踩过这个坑,点击第一根柱子一直命中到第三根,排查后才发现是坐标没减掉 Canvas 组件的左边距。

6. 三种图表联调时的共性问题

三种图表独立跑通后,联调阶段又冒出一堆共性问题。这些问题不解决,图表在真机上就是“偶尔不动、偶尔卡顿、旋转一下布局错乱”。

6.1 数据刷新后为什么画布不动

声明式 UI 框架下,@State数据变化会自动驱动 UI 更新,但 Canvas 绘制的内容是命令式的,不在声明式渲染追踪范围内。也就是说,即使你把@State chartData赋了新值,画布上的图形也不会自动变化,必须手动触发重绘。

我最初的写法是在onReady回调里画了一次图表,后来接口数据返回后调用this.chartData = newData,结果画布纹丝不动。排查后才发现 Canvas 的绘制是一次性的,数据变化后需要再调用一遍绘制方法。

正确的做法是把绘制逻辑抽成一个drawChart()方法,数据变化后主动调用。我给@State数组挂上@Watch回调,数据变化时在回调里执行this.drawChart(),或者在网络请求的.then()里手动触发。记得每次绘制开头先clearRect清空画布,否则新旧图形会叠在一起。

6.2 数据量一大就卡:降采样策略

折线图在数据点超过几百个之后,绘制时间明显上升,真机上一刷一卡。排查绘制瓶颈后发现主要耗时在lineTo的次数太多,以及文字标签逐个fillText。

如果处理的是时间序列数据(比如一整天的分钟级数据 1440 个点),直接全部绘制既费性能又没意义——屏幕宽度就 400 多像素,根本画不下 1440 个点。我实现了一个最简单的降采样:按画布宽度分桶,每个像素桶内只保留最大值和最小值。

function downsample(data: ChartData[], bucketCount: number): ChartData[] { const buckets: ChartData[] = []; const step = Math.max(1, Math.floor(data.length / bucketCount)); for (let i = 0; i < data.length; i += step) { const slice = data.slice(i, Math.min(i + step, data.length)); const maxItem = slice.reduce((a, b) => a.value > b.value ? a : b); const minItem = slice.reduce((a, b) => a.value < b.value ? a : b); buckets.push(maxItem); if (minItem !== maxItem) { buckets.push(minItem); } } return buckets; }

这个策略学名叫 Min-Max 降采样,虽然不保证每个峰值都刚好被采样到,但能保留每个区间内的上下极值,画出来的折线基本不会丢失波峰波谷,比均匀抽点好看得多。数据量小于 500 时一般不需要降采样,超过 1000 就开启。

6.3 动画与性能的平衡

图表没有动画会显得死板,但动画实现不好又容易掉帧。我追求的动画效果很简单:折线从 0 到 100 逐步画出,柱状图高度从底部逐步生长,饼状图扇区逐步展开。

实现方式没有引入动画库,用一个progress变量从 0 到 1 变化,配合定时器驱动重绘。折线图按progress比例只绘制前 N 个数据点;柱状图绘制高度乘以progress;饼状图每个扇区的结束角度乘以progress。定时器结束前记得clearInterval,避免页面销毁后还有动画循环在跑。

实测下来,动画时长控制在 500 到 800 毫秒比较合适,再长用户就会觉得操作卡。动画过程中如果用户切走了页面,要在aboutToDisappear里停止动画,否则会导致 Canvas 上下文泄漏。

6.4 尺寸适配问题

Canvas 组件的宽高不能写死。折叠屏展开、旋转屏幕、不同分辨率下,组件实际尺寸都会变化,如果绘制方法用的是初始化时缓存的宽高,画出来的图表就是错位的。

我的处理方式是在绘制方法开头重新读取 Canvas 组件当前的实际宽高,所有坐标计算都基于这份实时数据。如果项目里有页面级的状态监听,可以在onAreaChange回调里触发一次重绘,确保旋转屏幕后图表不会变形。

这里有一条调试经验:开发阶段我会在 Canvas 上临时画出网格线和坐标轴刻度文本,数据映射问题一眼就能看出来。上线前再关掉这个调试模式,流程非常简单,但能省下大量用console.log打印坐标的时间。

三种图表做完后,我把绘制逻辑抽象成了三个独立的模块文件,每个模块只负责一种图表的坐标计算和绘制。后续产品提了三次改动——折线图换主色、饼状图加上中间总结文案、柱状图增加点击下钻——每一样都只动了对应模块,数据模型和页面逻辑完全没受影响。这正是自绘图表长期价值的最好证明。最后再分享一个小技巧:图表组件里加一个debug布尔开关,开发期打开它显示网格和坐标数值,排查数据映射问题快得不是一点半点,上线前关掉即可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询