做HarmonyOS 6的数据可视化,很多人第一反应是拿起第三方库就开干。但真正在ArkUI里把折线图、饼状图、柱状图画明白,你会发现每条坑都比想象的要深一点。今天这篇不讲大概念,直接围绕“折线图、饼状图、柱状图”这三种最常用的图表,把我们实际项目中踩过的坐标映射、扇形绘制、X轴刻度处理、性能优化这些细节全部抖出来,希望对正在做鸿蒙可视化看板的同学有帮助。
1. 动手前先想明白:需求梳理与图表选型
1.1 图表不是用来“画”的,是用来回答问题的
拿到需求别急着堆代码。我习惯先问三个问题:这个图表给谁看?用户要从里面读到什么?数据变了之后图表需不需要动?
这三个问题直接决定了绘制优先级。折线图的核心是“趋势”,所以坐标轴、刻度、数据点的连续性比颜色的花哨程度重要;饼状图的核心是“占比”,所以百分比精度和扇形边界清晰度排第一;柱状图的核心是“对比”,这时候柱宽一致性、间距比例、数值标尺比什么都关键。我见过不少项目把大量精力花在渐变和阴影上,结果用户连基本数值都看不清,这就是本末倒置。
在HarmonyOS 6的ArkUI里做图表,起点是Canvas组件和CanvasRenderingContext2D。整体思路不复杂:先声明Canvas组件,在onReady回调里拿到渲染上下文,然后调用我们封装好的绘图函数画第一帧;之后每当数据变化,就触发一次重绘。复杂的地方在于,Canvas坐标系的原点在左上角、y轴向下,而业务数据往往是“越往上值越大”,这两套坐标系之间的转换,是所有图表实现里最容易出错的起点。
1.2 三条技术路线,我们为什么选了Canvas自绘
HarmonyOS 6上实现图表,我梳理过三条路线,也分别试过水。
第一条是Canvas自绘,也就是这篇博文的主线。所有绘制逻辑都由我们自己控制,不依赖任何第三方包,体积小、性能可控、视觉风格可以和App完全统一。代价是需要把坐标换算、刻度计算、文本排布这些底层逻辑都自己解决,前期工作量比其他方案大。但当你需要同时支持三种图表,并且要自定义tooltip和动画时,这种“学费”反而值得。
第二条是引开源图表库,比如OpenHarmony社区里有人移植的MPChart版本,也有针对ArkTS做适配的ECharts封装。优点是上手快,折线图、饼状图、柱状图几乎是填数据就行。但问题是这些库的版本跟进速度不一定跟得上HarmonyOS 6的API变化,遇到bug时你要么等更新,要么自己扒源码改。另外,很多库为了跨平台兼容,内部抽象层比较厚,在低端设备上开启动画会明显掉帧。
第三条是用Web组件套一个HTML页面,在里面跑原版ECharts。这个方案开发速度最快,前端生态里所有图表技巧都能直接用。但HarmonyOS里Web组件和原生层的通信要走postMessage,双向传数据有一些额外开销,而且视觉上始终隔了一层,很难做到和原生组件完全一致的触摸响应和转场效果。如果只是临时演示,这个方案够用;如果是长期产品,我不推荐。
从实际维护的角度看,我最终选了Canvas自绘。核心原因是:三种图表的绘制逻辑有大量可复用部分,比如坐标区域计算、刻度圆整、tooltip绘制,抽出基类后,后期扩展新图表反而比改第三方库更可控。
| 对比项 | Canvas自绘 | 开源图表库 | Web+ECharts |
|---|---|---|---|
| 开发量 | 较大 | 小 | 小 |
| 视觉一致性 | 完全可控 | 依赖库能力 | 受限 |
| 性能 | 最高 | 中等 | 中低 |
| 版本适配风险 | 无 | 有 | 有 |
| 动态交互 | 灵活 | 一般 | 一般 |
1.3 先用ArkTS的数据约束把地基打牢
在HarmonyOS 6里写图表,第一步其实是数据模型。ArkTS对类型检查比普通TypeScript严格得多,不支持无类型的JSON乱飞,也不建议用any。所以需要先用interface把图表的输入数据定死,比如:
interface ChartDataItem { label: string; value: number; color?: string; }这里有个很实用的经验:如果数据来自网络接口,在解析阶段就要做字段校验,别等到绘图时才发现value是字符串。ArkTS下JSON.parse返回的类型并不能自动帮你做运行时校验,所以最好封装一个parseChartData函数,把前端容错提前到入口处。这个习惯能帮你避免大量“绘制出来全是NaN”的迷惑现场。
我自己的项目架构里,还会再包一层ChartModel,把所有图表的公共配置放进去,比如字体大小、颜色板、单位、小数位数。这样折线图、饼状图、柱状图三个实现类只关注各自特有的绘制逻辑,公共部分全部复用,代码量瞬间少了一半。
2. 折线图实现:坐标轴规划与数据映射
2.1 坐标换算公式,用一遍就忘不掉
折线图的第一件事,就是把业务数据折算成画布上的像素坐标。前面说过,Canvas的y轴是向下的,而业务数值往往越大越靠上,所以换算时要做一次翻转。
假设绘图区左上角坐标为(paddingLeft, paddingTop),宽度为plotWidth,高度为plotHeight;数据为数组values,最小值为min,最大值为max。那么第i个数据点对应的坐标就是:
x = paddingLeft + (i / (count - 1)) * plotWidth y = paddingTop + (1 - (values[i] - min) / (max - min)) * plotHeight这套公式是折线图的核心,看起来简单,但有几个细节必须注意。count等于1时,分母count - 1会变成0,此时应直接把x设为绘图区中点。还有当max等于min时,数据全是同一个值,y坐标应该落在绘图区中部,而不是产生除零错误。
关于min和max的取值,我强烈建议不要直接从0开始。如果一组数据在80到90之间波动,从0画起,整条线会贴在天花板上,任何波动都看不出来。更合理的做法是给数据留出上下余量,比如向上取整到min * 0.9和max * 1.1,或者用下面的刻度圆整方式。
2.2 让Y轴刻度“看起来舒服”的圆整算法
直接拿数据最小值当坐标轴下限,会导致Y轴刻度出现一堆丑陋的数字,比如0、23.7、47.4。更好的做法是对min和max做一次“圆整”,让刻度落在1、2、5这类整数的倍数上。
我经常用的一个简化版算法是:先算出理想步长roughStep = (max - min) / 4,然后把这个步长映射到1、2、5的倍数序列,再反向算出新的min和max。
实现思路大致是这样:
function niceScale(min: number, max: number, tickCount: number): number[] { const range = max - min; if (range === 0) { return [min - 1, max + 1]; } const roughStep = range / tickCount; const power = Math.pow(10, Math.floor(Math.log10(roughStep))); const fraction = roughStep / power; let niceFactor = 1; if (fraction <= 1) { niceFactor = 1; } else if (fraction <= 2) { niceFactor = 2; } else if (fraction <= 5) { niceFactor = 5; } else { niceFactor = 10; } const niceStep = niceFactor * power; const niceMin = Math.floor(min / niceStep) * niceStep; const niceMax = Math.ceil(max / niceStep) * niceStep; return [niceMin, niceMax, niceStep]; }这段代码实现之后,X轴数据不管多奇怪,Y轴都只会显示2、4、6、8这种工整的刻度,观感舒服很多。网格线也可以直接用niceStep等分,画出来的背景既不拥挤也不稀疏。
2.3 折线主体的两种画法:直线还是平滑曲线
拿到所有坐标点之后,绘制折线有两种思路。
一种是纯直线,用beginPath、moveTo、lineTo逐点连起来。这种画法数据表现最忠实,适合监控系统、生产看板,因为用户需要准确判断某个时刻的具体数值,曲线反而会误导人。
另一种是平滑曲线,在数据点之间补贝塞尔曲线。视觉效果更柔和,适合展示趋势类数据,比如气温变化、流量走势。实现上最稳定的方式是使用quadraticCurveTo,把相邻两个数据点的中点作为控制点,这样曲线会在数据点处自然拐弯,不会出现过冲。
我的个人建议是:如果数据点是等间隔采集的,用平滑曲线很漂亮;如果数据点本身就是稀疏的异常点,那还是老老实实用直线加数据点标记,否则会画出非常奇怪的波浪。
2.4 渐变面积、数据点和点击探测的配合
折线图只画一条线往往显得单薄,我通常在折线下方补一个渐变面积。做法是把折线路径延伸到绘图区底部,形成一个闭合区域,再用createLinearGradient从顶部到底部生成一个透明度递减的渐变填充。这种处理在深色背景下尤其好看,企业级大屏经常这么干。
数据点标记也是个细节。数据点少的时候,在每个坐标点画一个半径2.5的小实心圆,用户更容易定位数值。数据点很多(比如超过60个)就不要画了,否则视觉上密密麻麻一片,反而看不清趋势。
点击探测是我的必做项。实现思路很朴素:在Canvas的onTouch事件里拿到手指坐标,然后遍历数据点,计算欧几里得距离,小于一定阈值就认为命中。要注意的是,阈值不要固定写死,最好根据屏幕密度缩放,比如12vp左右。命中后把该数据点高亮,并且在顶部画出自定义tooltip,显示label和value。如果数据量大,hitTest前可以先判断x方向是否落在某个数据点的“邻域区间”,用二分查找减少遍历次数。
3. 饼状图实现:扇形绘制与占比计算
3.1 扇形绘制的标准和弦:moveTo、arc、closePath
饼状图在Canvas里的实现换个口味的人很多,但套路就一条。首先确定圆心(cx, cy)和半径r,然后对每个数据项调用:
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, startAngle, endAngle, false); ctx.closePath(); ctx.fill();arc方法接收的是弧度制角度,起始角度从3点钟方向开始。但产品经理通常希望第一个扇形从12点钟方向开始,也就是-90度,所以我们对startAngle做偏移:
const total = items.reduce((sum, item) => sum + item.value, 0); let startAngle = -Math.PI / 2; for (const item of items) { const sweepAngle = (item.value / total) * 2 * Math.PI; drawSector(cx, cy, r, startAngle, startAngle + sweepAngle); startAngle += sweepAngle; }这个计算看起来简单,有几个坎要提前处理。第一,total为0时会导致除零,建议做兜底直接返回空状态。第二,数据里混入负数会导致扇形角度逆向,视觉上会互相覆盖,饼状图只适合非负数据,入口处就应该做数据清洗。第三,浮点累加会带来微小误差,最后一个扇形的结束角度最好强制等于起始角加2π,避免边缘出现一条不该有的缝隙。
3.2 百分比标注和标签防重叠
饼图的核心是占比,所以百分比文本一定要显示清楚。我习惯显示到一位小数,比如23.5%,而不是整数百分比,因为整数百分比加起来可能到不了100%,用户会起疑。
文本位置放在扇形角平分线上,距圆心的距离约为半径的0.65倍。角平分线角度就是起止角的平均值,对应坐标是:
labelX = cx + Math.cos(midAngle) * r * 0.65 labelY = cy + Math.sin(midAngle) * r * 0.65但这招在数据项多、单个扇形角度很小时会踩坑。比如一共12个扇形,其中两项都只有15%,标签就很容易挤在一起。我目前采用的办法是:先按角度排序,尽量保证标签散开;然后按顺序检查相邻两个标签的垂直间距,如果小于20px就做一个纵向错位。如果错位之后还是重叠,那就改成引线模式,在扇形外侧画一条折线指向标签文本。
3.3 图例和交互态的实现细节
很多人觉得图例是给饼图“锦上添花”,但其实图例承担了相当一部分信息表达。在HarmonyOS里,我习惯把图例放在图表右侧,每一个图例项由一个小色块加一段文字组成。点击图例项可以切换对应扇形的显示/隐藏,这时需要注意:total需要重算,不能让被隐藏的数据项继续参与角度分配。维护一个visible数组即可。
绘制时每个扇形描一层半透明白色边框,大约1px,可以让相邻扇形的边界更清晰;深色背景下这个细节非常重要,没有描边的饼图看起来像一坨混色颜料。
3.4 入场动画用累积角度实现
饼图入场动画很出效果,实现也不复杂。维护一个currentAngle变量,从0开始每帧增加到最终角度,然后重绘。每次重绘时,画到currentAngle之前的所有扇形,超过的部分先不画,视觉上就是扇形一个接一个“长”出来。
这里要提一句HarmonyOS动画和主线程的关系。动画循环里不要在每次绘制时new大量临时对象,尽量复用已有的Path和Brush对象,否则在低端机器上很容易触发垃圾回收导致的掉帧。
4. 柱状图实现:柱形绘制与X轴刻度处理
4.1 柱宽和间距,一上来就算清楚
柱状图最常见的翻车现场,是柱子之间一会儿宽一会儿窄,或者最后一根柱子直接超出绘图区。根源在于柱宽和间距没有按比例规划。
我推荐的算法是:已知绘图区宽度plotWidth、柱子数量n、柱间距与柱宽的比率gapRatio(一般取0.4到0.6),则柱宽为:
barWidth = plotWidth / (n * (1 + gapRatio) - gapRatio)第i根柱子的左侧x坐标是:
barX = paddingLeft + (plotWidth / n) * i + ((plotWidth / n - barWidth) / 2)这样不管数据是3根还是30根,总宽度都会正好撑满绘图区,不会出现多一根就溢出的bug。实际项目中数据量经常变化,每次刷新都要按当前count动态重算。
4.2 圆角柱子和渐变填充的实现技巧
设计师都喜欢圆角柱子,HarmonyOS的CanvasRenderingContext2D虽然提供roundRect接口,但兼容性和版本差异要自己确认。为了稳妥,我建议手写一个圆角矩形路径,只圆上方两个角,下方保持直角与X轴对齐:
function drawRoundBar(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x, y + h); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h); ctx.closePath(); ctx.fill(); }渐变填充方面,createLinearGradient(0, yBottom, 0, yTop)让柱体从下到上由深色变浅色,是很多可视化大屏的标配。如果要做“玻璃质感”,还可以在柱体左侧叠加一个半透明白色矩形,就像给柱子加了一条高光。
4.3 X轴刻度文字挤成一团的终极解法
柱状图的X轴标签是重灾区,尤其是多个汉字产品名。我试过旋转、省略号、触摸提示几种方案,最终沉淀出一套“自适应采样”的打法。
先设定一个单标签最大宽度,比如80px,然后算出绘图区能容纳的最大标签数labelLimit = floor(plotWidth / 80)。如果数据项数n大于labelLimit,就计算步长stride = ceil(n / labelLimit),只在索引能被stride整除的位置显示标签。这样柱子多的时候X轴不会糊成一团,柱子少的时候又能完整展示所有标签。
如果业务要求每个柱子都必须有标签,那就只能用旋转方案,旋转30度到45度之间。旋转后的文字需要把画笔原点平移到该柱子中心底部,再做旋转再fillText,同时要注意旋转后文字会向左下方延伸,要预留足够的底部padding,否则文字会被裁掉。
4.4 堆叠柱状图和点击高亮的视觉反馈
如果只是单系列柱状图,点击高亮很简单:把命中的柱子颜色加深,顶部显示数值。但堆叠柱状图复杂一点,因为一次点击需要判断命中的是堆叠中的哪一段。
我的做法是:维护每个堆叠段的累积高度,在onTouch事件里先判断x是否落在某根柱子范围内,再根据y坐标从上往下比对累积值。命中后,给该段加亮、给数值加粗,同时在tooltip里显示该段的名称和数值。这种交互看似微不足道,但用户在做数据比对时非常依赖这个反馈。
5. 三张图跑起来后的踩坑实录:常见问题排查与性能优化
5.1 Canvas在页面切换后变空白,重绘时机不对
这个坑最隐蔽。HarmonyOS页面在onDisappear后组件会被销毁,从后台回到页面时,onReady不一定再次触发。如果代码只依赖onReady做首次绘制,页面回来后图表就是一片白。
我的解决方法是把“绘制”封装成独立的refresh()函数,在onReady和onPageShow里都调用一次。另外,图表数据用@State管理,数据更新时通过属性的setter触发重绘,而不是在某个异步回调里直接操作画布。这样生命周期和UI状态可以保持同步。
5.2 每200ms刷新一次数据,界面卡成幻灯片
做实时看板时,我原本让定时器每200毫秒更新一次数据,结果滑动页面掉帧严重。后来优化了两层。
第一层是用requestAnimationFrame合并绘制。定时器只管更新数据模型,真正绘图放到动画帧回调里执行,这样即使一帧内数据更新了多次,Canvas也只重绘一次。
第二层是离屏Canvas缓存静态背景。坐标轴、网格线、刻度文字这些不变的画面,预先画在一个离屏Canvas上,每次重绘时先把离屏Canvas整体drawImage到主画布,再只画变化的部分。实测下来,一帧的重绘时间从十几毫秒降到了三毫秒以内。
5.3 横竖屏切换后图表变形
写死像素坐标是横竖屏崩坏的根源。正确做法是在组件布局完成后实时读取实际宽高,用onSizeChange或onAreaChange监听尺寸变化,然后重新计算绘图区并重绘。这里要特别注意,横屏时底部会被系统导航栏遮挡一部分,绘图区的padding要额外加上安全区的高度,否则最后一个刻度和底部标签会被盖住。
5.4 数据复用:同一份数据,三种图表三副面孔
最后分享一个架构上的体会。折线图、饼状图、柱状图经常用的是同一份数据,只是展示维度不同。比如产线一天的电量数据,折线图看整体趋势,柱状图对比不同时段,饼图看峰谷平占比。所以我在代码里把数据模型和图表绘制分开,同一份ChartDataItem数组可以直接丢给三种ChartView组件。需要新增一种图表时,只需要再写一个继承BaseChart的类,公共的图例、tooltip、坐标换算逻辑全部复用,工作量会比你想象的小很多。
6. 最后再分享几个让图表更有质感的细节
做完三张图之后,我花了很多时间在“打磨质感”上,几个细节让我觉得性价比特别高。
第一个是配色。三张图用同一套配色板,最多5种颜色,从较低的饱和度开始选,比如深蓝、青绿、暖橙、淡紫。别用那种纯红配纯绿的“直男配色”,大屏底下相当刺眼。
第二个是数字格式化。数值超过9999要显示千分位,超过百万要显示缩写加单位。饼图百分比、折线图的Y轴刻度、柱顶数值,统一走同一个formatValue函数,保证三张图风格一致。这个函数最好放在ChartModel里,别在三个类里各写一套。
第三个是空数据和异常数据状态。后端返回空数组时不要画一幅空图,可以绘制一条浅灰色“暂无数据”的提示文案。比一片空白专业得多。
第四个是参考线。比如业务有一个目标阈值,在折线图和柱状图里用一条红色虚线画上,用户一眼就能看出当前数据是超标还是达标。实现时只需要在绘制数据之前,把参考线的y坐标按照同样的坐标映射函数算出来,然后stroke一条虚线即可。
这几个细节花不了多少时间,但做完之后,图表从“能看清数值”变成了“有产品气质”。我做可视化的体会是,技术难点反而不是Canvas API的记忆,而是坐标系换算、刻度计算、标签防重叠、性能优化这些不起眼但决定成败的小事。把这些基础打牢,剩下的只是一次次迭代。