做HarmonyOS应用开发,绕不开数据展示这关。尤其HarmonyOS 6开放了更完整的Canvas能力之后,折线图、饼状图、柱状图这些数据可视化组件,已经完全可以自己动手画出来,不需要再依赖一堆重量级第三方库。我接手过的几个鸿蒙项目里,凡是涉及报表、监控面板、统计分析页面的,最后都回归到自绘这条路——因为可控性强、包体积小、和ArkUI的状态管理配合也最顺。这篇东西就是把我踩过的坑、沉淀下来的套路一次性说清楚:从坐标计算到动效交互,从渐变色到性能优化,给出一套可以直接抄作业的方案。
如果你正在做HarmonyOS 6应用,又刚好被图表需求卡住,这篇文章就是给你准备的。不管你是刚接触ArkTS的初学者,还是已经在搞复杂应用的老手,只要按照后面的步骤走,至少能省下一周以上的试错时间。
1. 数据可视化在HarmonyOS 6中的整体设计思路
1.1 为什么一定要自己用Canvas画,而不是直接搬第三方图表库
先说说最核心的决策。很多开发者一上来就问:有没有现成的HarmonyOS图表库?有,但基本都停留在“能跑”的阶段。真到生产环境,你会发现三个很难忍的问题:第一,大多数第三方图表库是直接从Web端移植过来的,内部大量依赖DOM和浏览器事件模型,在ArkTS环境下兼容性欠佳,偶尔能编译过,但运行期各种诡异报错;第二,定制成本极高,想改个颜色、加个动画、调整坐标轴刻度,得去翻源码,改完还要自己维护fork;第三,包体积动不动就几百KB,对端侧应用来说太奢侈。
自绘Canvas其实没有那么难。HarmonyOS 6的Canvas组件底层是Skia引擎,提供了一套完整的2D绘制接口,弧线、渐变、阴影、Clipping、矩阵变换都有,性能足够撑起常见的商用电表。自己画,意味着所有细节都在你手里:坐标轴刻度密度可以随数据自动调整,颜色渐变可以按品牌规范精确控制,点击命中区域能精确到像素。更重要的是,图表的状态可以轻松绑定到@State变量上,数据一变,UI自动刷新,这和ArkUI的响应式模型完美契合。
1.2 三种图表的适用场景与选型逻辑
任何图表库都要面对的问题,先想清楚再动手。折线图适合展示连续变化,比如Y轴传感器数据、股价、实时流量,它强调的是趋势和波动;柱状图适合对比分类数据,比如各月份销量、不同区域的用户量,它强调的是高低大小;饼状图的本质是占比,在HarmonyOS 6里我通常建议做成环形图,中间空出来放总量或者关键指标,视觉效果和可读性都优于实心饼图。
选型时还要考虑一个数据量级的因素。折线图最多能接受一千个点以上(配合降采样),柱状图分类数一般不超过30根,饼状图扇区不要超过8个——超过8个的时候,建议把小于5%的合并成一个“其他”类。这个经验值在后期看板类项目里特别有用,能避免做出来的图变成“糊成一团”的灾难现场。
1.3 数据模型与UI解耦:写代码前先定接口
在动手画图之前,先把数据接口定好。这是我最想强调的一点。很多新手一边画一边改数据结构,最后图表逻辑和数据源纠缠成一坨,换个数据源就要动绘制代码。
我常用的做法是定义统一的图表数据模型,比如折线图的数据点结构:
interface LinePoint { xLabel: string; // X轴刻度文案,比如"1月" value: number; // Y轴数值 } interface LineChartData { seriesName: string; color: string; points: LinePoint[]; smooth?: boolean; // 是否使用平滑曲线 }绘制层完全不关心数据是从数据库来的、从网络来的,还是用户手动输入的。只要最终能转成这个结构,图表就能画。这样后续想加多数据源支持、换数据格式,都只需要动适配层,绘制逻辑那几百行代码永远不用大改。
2. 折线图实现:坐标系、刻度与平滑曲线
2.1 坐标系转换:把数据值变成Canvas像素坐标
折线图的一切基础,是坐标系转换。Canvas的原点在左上角,X轴向右,Y轴向下,但图表里的坐标系通常是Y轴向上,所以需要做一个映射函数。假设Canvas宽度为w,高度为h,左边距paddingLeft,右边距paddingRight,上边距paddingTop,下边距paddingBottom,那么绘图区域的宽度是plotW = w - paddingLeft - paddingRight,高度是plotH = h - paddingTop - paddingBottom。
X轴坐标映射很简单:如果有n个数据点,且第一个点和最后一个点要留在绘图区边缘内侧,通常给X轴加上“两侧留白一点”,那么第i个点的X像素坐标就是:
x = paddingLeft + (i * (plotW / (n - 1)))Y轴坐标映射则需要知道数据的最小值minVal和最大值maxVal。如果直接数据里取,min=0时柱状图还说得过去,但折线图如果数据波动在60到80之间,Y轴从0开始会让折线挤在顶部,完全看不到波动细节。所以我的习惯是Y轴范围自动向上取整,并且留出10%的上下余量。计算公式:
range = maxVal - minVal yMin = minVal - range * 0.1 yMax = maxVal + range * 0.1然后在把数据值value映射成Y像素坐标时:
y = paddingTop + plotH - ((value - yMin) / (yMax - yMin)) * plotH这一段逻辑是所有图表共用的,建议抽成一个独立的ChartMath工具类,后面柱状图、饼状图也都会用到,饼状图虽然不用线性映射,但角度转弧度的换算逻辑也放这里。
2.2 网格线与X轴刻度标签的绘制
网格线看着简单,但很多人第一版画出来总是密密麻麻或者对不齐。关键是要做“刻度数量自适应”。比如绘图区高度只有300像素,你画10条横线,每条间隔30像素,正好;如果高度有500像素,还是只画5条,间隔100像素,就太稀疏了。
我的做法是先定一个合理的网格间距范围,比如60像素到100像素之间,然后用区间高度除以期望间距,得到刻度数,再取整并微调。示例代码如下:
drawGrid(ctx: CanvasRenderingContext2D, plotH: number, valueMax: number) { // 计算合适的刻度数:区间高度在60~100像素一个刻度 const idealStep = 80; let tickCount = Math.floor(plotH / idealStep); tickCount = Math.max(tickCount, 2); // 实际值步长取整 const valueStep = this.calcStep(valueMax, tickCount); ... } private calcStep(max: number, tickCount: number): number { const roughStep = max / tickCount; const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); const normalized = roughStep / magnitude; let step; if (normalized < 1.5) step = 1; else if (normalized < 3) step = 2; else if (normalized < 7) step = 5; else step = 10; return step * magnitude; }这样画出来的网格线,数值都是整数倍,比如100、200、300,看起来比较专业。X轴的刻度标签还需要处理文案过长的情况——最好的办法是截断加省略号,或者按宽度自适应旋转,但旋转会让阅读体验变差,所以我一般优先采用“抽取关键点显示”策略,比如一共30个数据点,X轴只显示其中5个刻度。
2.3 折线路径绘制与平滑圆滑处理
画折线本身很简单,遍历所有点,moveTo第一个点,然后lineTo后面的点,最后stroke。但如果直接用直线连接,数据点一多,折线看起来会显得很生硬,视觉上不够“精致”,这就是为什么会有人问“echarts折线图x轴刻度”看着很柔滑,其实背后是贝塞尔插值。
HarmonyOS的Canvas API提供了quadraticCurveTo和bezierCurveTo。我采用三次贝塞尔曲线做平滑处理:对中间每个点,取前一个点和后一个点的中点为控制点。这样每两个数据点之间生成的曲线是连续的,且平滑度可以调整。简单实现如下:
drawSmoothLine(ctx: CanvasRenderingContext2D, points: Point[]) { ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length - 1; i++) { const xc = (points[i].x + points[i + 1].x) / 2; const yc = (points[i].y + points[i + 1].y) / 2; ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc); } // 补上最后一个点 ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); ctx.stroke(); }这里有实操细节:曲线数据点的起始和结束处容易翘起来,所以一般首尾两个点直接用直线连向相邻控制点,或者给曲线算法额外加两个不渲染的虚拟点。我常用的做法是把第一个点复制一次作为虚拟起点,最后一个点复制一次作为虚拟终点,这样画出来的曲线首尾是平滑进入而不是突兀转向。
2.4 数值标签与触摸动态高亮
静态折线图可以了,但如果要上“可触摸高亮”的交互,事情就多一点——这也是HarmonyOS 6上看板类应用比较常见的需求。思路是用Canvas组件绑定触摸事件:
Canvas(this.context) .onTouch((event: TouchEvent) => { if (event.type === TouchType.Down || event.type === TouchType.Move) { const touchX = event.touches[0].x; const touchY = event.touches[0].y; // 找到X轴上最近的数据点 const index = this.getNearestIndex(touchX); this.selectedIndex = index; } }) .onTouch((event: TouchEvent) => { if (event.type === TouchType.Up) { this.selectedIndex = -1; } })getNearestIndex的做法就是遍历所有的X像素坐标,找到绝对差值最小的那个下标,然后invalidate()触发重绘。重绘时,在selectedIndex位置画一个高亮圆圈,竖直虚线,以及一个浮层小标签显示具体的数值。浮层标签要注意边界:如果选中点太靠近Canvas右边缘,标签应该向左偏移,否则会画出屏幕。这是一个很细节但实际体验区别很大的点。
3. 柱状图实现:布局、渐变与点击反馈
3.1 分类柱的宽度计算与留白策略
柱状图最基础的问题是柱子的宽度。你要根据分类数量、绘图区宽度、柱子之间的间距来反推柱子宽度。公式很直接:
const categoryGap = 20; // 柱间间距 const groupWidth = plotW / categoryCount; const barWidth = groupWidth - categoryGap;如果柱状图是多系列并排的,比如“上月销量”和“本月销量”两组数据,那就更复杂一点。这时每个分组内部有多个柱子,组内柱间距一般取柱宽度的20%左右:
const groupCount = categoryCount; const groupWidth = plotW / groupCount; const barCount = seriesCount; const barWidth = (groupWidth - categoryGap * 2 - barGap * (barCount - 1)) / barCount;这里我踩过一个坑:如果计算出来的barWidth小于1,整根柱子会比一个像素还窄,画出来就是一根“细线”,根本没法看。必须在计算后加一个最小值保护,比如barWidth = Math.max(barWidth, 2)。另外在屏幕宽度很小且分类很多的场景下,不如直接切换成横向柱状图,这样分类标签可以竖着放,可读性会提升很多。
3.2 带圆角和渐变色的柱体绘制
HarmonyOS Canvas支持离屏Canvas和LinearGradient渐变。柱状图默认的纯色填满太生硬,加上渐变和圆角会是完全不同的观感。很多人问“echarts柱状图设置渐变色”怎么处理,其实在自绘里更加自由。
先创建渐变对象:
const gradient = new LinearGradient(0, yBottom, 0, yTop); // 垂直方向渐变 gradient.addColorStop(0, "#FF7B2E"); gradient.addColorStop(1, "#FFB36E"); ctx.fillStyle = gradient;注意两个细节。第一,LinearGradient参数是坐标值,渐变方向和色谱顺序都得按实际绘图区算,不能写死;第二,如果要让每根柱子有细微差异,比如用颜色深浅标记数据高低,可以按数值比例调整透明度,但不要每根柱子都新建渐变对象——性能会打折,后面性能章节我会细说。
圆角柱体的实现不能直接期望Canvas有原生圆角矩形API,需要手动路径绘制。一个圆角矩形的路径可以用arcTo实现:
roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }注意:圆角半径r如果大于柱宽的一半,绘制结果会变成胶囊甚至变形。所以我一般设置r = Math.min(radius, barWidth / 2 - 1),同时把y坐标下移r,让底部两个角保持尖角(模拟柱子落在地上的感觉),只给顶部两个角做圆角,视觉上更自然。
3.3 点击命中检测与“鼠标点到哪显示哪”的效果
热词里有一条“鼠标点那儿在哪儿显示柱状图”,放到移动端就是“点击柱子显示详情”。自绘实现这个效果不复杂,核心是做一个点与矩形区域的命中测试。
实现思路是:在绘制柱状图时,把每根柱子的矩形边界和它对应的数据对象存到一个数组里,当作“热区表”:
interface BarHitArea { id: string; x: number; y: number; w: number; h: number; data: any; }每次点击事件,遍历热区表,判断触摸点是否落在矩形内。命中后,记录当前点击柱子的下标,然后重绘,在柱子上方绘制数值标签,同时在柱子顶部画一条白色细线高亮。
这里有一个很值得说的细节:柱子的实际绘制区域可以比命中区域略小,意思是把热区扩大2~3像素,这样用户点在柱子边缘上也能触发反馈,操作体验会好很多。肉眼不可见,但体验差异明显。
3.4 顶部数值标签的避让策略
柱状图顶部要显示数值时,标签之间在柱子很窄的情况下很容易重叠。我常用的解决法是:如果柱宽小于30像素,就不显示每根柱子的数值,而是把数值放到点击后的浮层里;如果柱宽大于30像素,标签文字就水平居中画在柱子上方,同时在文字下方画一条短横线连接柱子,这下既能有时尚感,又能防止浮层遮挡。
如果实在想让所有数值都显示,可以使用“阶梯错位”策略:相邻柱子的数值标签上下交替偏移,比如奇数索引数值标签在柱子顶上10像素,偶数索引在柱子顶上25像素。这是一个不算完美的应急方案,但在很多电视大屏场景下,它比隐藏数值要实用。
4. 饼状图(环形图)实现:角度运算与扇区动效
4.1 扇区绘制流程与角度计算
环形图其实比实心饼图好用,所以我的实现默认是环形。第一步是把数值转成角度:
const total = data.reduce((sum, item) => sum + item.value, 0); let startAngle = -Math.PI / 2; // 从12点钟方向开始画 data.forEach((item, index) => { const sweepAngle = (item.value / total) * Math.PI * 2; // 绘制扇区 从 startAngle 到 startAngle + sweepAngle startAngle += sweepAngle; });用Canvas绘制环形扇区,实际上是用圆弧描边而非填充扇形。常见做法是用ctx.arc方法绘制一段圆弧,设lineWidth为环的厚度,strokeStyle为扇区颜色。为了确保扇区之间有间隔缝,可以让每段弧的起止角度都向内收缩一点,比如各收缩0.02弧度,这样扇区间会有很细的分割线效果,视觉上更清晰。
4.2 环形中间区域显示总量与占比
环形图的中间空出来的区域,正好放总量信息。这个在HarmonyOS里用Canvas画文字必须手动计算文字基线。文字居中不能简单用textAlign和textBaseline,还得考虑Canvas的实际尺寸。注意Canvas的文字缩放比例,特别是上屏适配的时候。
我的典型做法是:
ctx.font = "28vp sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = "#333333"; ctx.fillText(this.totalValue.toString(), centerX, centerY - 10);总量文案下面再画一行小字说明,比如“总销售额(万元)”,这样可以在一张图里传递两层信息。但要注意,中间文字的宽度不能超过内环直径,如果总量数字很长,需要做缩位处理,比如用“万”“亿”等单位缩写,这点在真实业务里特别常见。
4.3 点击扇区分离与数据排序
环形图常见的交互是点击某个扇区,让这一块向外“弹出”一点,同时把其余扇区颜色降饱和。这里需要在绘制时对每个扇区加一个偏移量:
const offset = (index === this.selectedIndex) ? 12 : 0; const offsetAngle = startAngle + sweepAngle / 2; const offsetX = Math.cos(offsetAngle) * offset; const offsetY = Math.sin(offsetAngle) * offset; // 绘制时圆心坐标加上offsetX/offsetY注意偏移方向的精度问题。如果按角度偏移但角度是从原始坐标计算的,选中扇区的中心线必须和原来保持一致,这样弹出的效果才自然。另外动画这里最好做200~300毫秒的插值,不要让扇区“啪”地一下跳过去,通过逐帧更新偏移量实现平滑过渡,在Canvas里可以结合ArkUI的animateTo能力或者自己用requestAnimationFrame驱动。
4.4 图例与百分比标签的摆放
环形图的图例通常是横向排列在图表下方或右侧。自绘图例其实很简单:先画一个小色块,再画文字。色块尺寸一般固定在12×12像素,色块与文字间距8像素,图例之间的间距根据Canvas宽度自适应换行。
百分比标签通常直接画在扇区外侧,做法是取扇区中心角度,然后以半径+偏移量去计算标签位置。需要特别处理小扇区,如果扇区角度小于0.1弧度,标签直接画在扇区旁边容易重叠,建议统一集中到右侧用引线拉出来,或者在命中交互时才显示。全都显示的结果就是屏幕上一堆数字叠在一起,得不偿失。
5. 常见问题与排查技巧实录
5.1 线条和文字发虚、有锯齿怎么办
这事我前前后后排查过很久。Canvas本身是基于像素绘制的,当你把Canvas的分辨率设置得和设备物理像素不一致时,会出现明显的锯齿。解决法是用Canvas组件的像素尺寸乘上设备像素比(dpr)来设置画布的实际尺寸,再用CSS尺寸保持逻辑大小。
在HarmonyOS里,Canvas组件的宽高通常使用vp单位,绘制时获取到的单位也是vp,但在部分设备上Canvas的默认像素比不是1:1。我一般做这样一个初始化:
const dpr = this.context.densityDPI / 160; // 近似获取dpr this.canvasWidth = this.rawWidth * dpr; this.canvasHeight = this.rawHeight * dpr; this.context.scale(dpr, dpr);这样绘制的时候所有坐标仍然按vp逻辑值写,但最终呈现是物理像素级清晰度。如果还发虚,检查一下是否在某次重绘时忘记resetTransform,导致scale叠加。建议在onDraw开头先做reset或者显式setTransform(1,0,0,1,0,0)。
5.2 数据更新后图表不刷新或者出现残影
HarmonyOS的Canvas重绘不像Web那样可以用requestAnimationFrame随时刷,它有自己的一套刷新机制。我在项目里最常犯的错是调用this.invalidate()时机不对——在修改了@State变量但Canvas绘制上下文还没准备好时刷新,结果什么都没画。
要记住的是:图表数据变化后,更新驱动变量,然后在aboutToAppear或者状态变更回调里触发invalidate。如果涉及复杂动画,更稳妥的写法是用Canvas组件的onReady事件先确保上下文可用,再执行首次绘制。
残影则是因为没有清屏。每次绘制前,先ctx.clearRect(0, 0, canvasWidth, canvasHeight),或者用fillStyle填充一个底色并rectangle覆盖全图。如果背景是透明的,也必须先clearRect,否则上一帧的图形会留在画布上。
5.3 横竖屏旋转或不同尺寸设备上图表变形
这是HarmonyOS多设备适配里很典型的问题。同一个图表可能在手机、平板、车机上显示,宽度差异很大。解决办法是尺寸不要用绝对值,所有间距、字体、柱子宽度都要按比例缩放。我通常把所有尺寸定义成相对于绘图区宽度的比例,比如标题字号 = plotW * 0.03,柱子最大宽度 = plotW / 12,然后再套上最小值和最大值的夹逼。
同时监听页面尺寸变化。HarmonyOS 6中可以使用onSizeChange回调更新Canvas尺寸,或者把图表拆分到独立的@Component里,绑定了Flex布局,让Canvas撑满父容器,父容器尺寸变化后,传给Canvas的宽高同步变化,再触发重绘。
5.4 大数据量下折线图卡顿的优化策略
几百上千个数据点的折线图,如果每帧都全部重绘,在部分中低端设备上会明显掉帧。解决办法有三个层次:第一是降采样,比如屏幕上每个像素点最多只绘制一个数据点,先对数据进行抽稀,常见算法有均匀抽稀和LTTB(Largest Triangle Three Buckets)算法;第二是使用Canvas的离屏缓冲,把静态网格线、坐标轴先缓存在离屏Canvas上,每次重绘只绘制数据线和交互层;第三是“脏矩形”更新,只在数据变化的区域重绘,但这在Canvas组件上比较难实现,不如直接改牺牲一点内存用双Canvas分层。
折线图的触摸高亮如果也要在大数据量下保持流畅,建议对命中检测的数据索引用二分查找,不要每次触摸都线性遍历几千个点,那样在低端机器上滑动时会有肉眼可见的卡顿。实测中,2万点以内线性遍历还扛得住,超过这个量级必须上二分查找或索引映射。
5.5 常用排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 图表空白不显示 | Canvas上下文未就绪或数据源为空 | 确认onReady回调、打印数据数组长度 |
| 图形位置偏移 | 坐标系映射没算padding | 检查paddingLeft/Right是否包含刻度标签宽度 |
| 线条断续 | 路径未beginPath或点坐标有NaN | 打印点坐标,检查数据是否有空值 |
| 汉字显示为方块 | 字体不支持中文字体族 | 指定系统字体如"HarmonyOS Sans SC" |
| 动画卡顿 | 每帧绘制时创建新对象或渐变 | 把渐变对象、颜色值缓存起来复用 |
| 柱状图柱子重叠 | 分组宽度计算错误 | 检查barWidth和barGap的计算顺序 |
| 饼状图扇形重叠 | 偏移方向计算用了原始圆心 | 确认偏移时圆心同步平移 |
6. 一个完整的折线图组件实现参考
最后放一个精简但能直接运行的折线图组件框架,把前面讲的图表数学、网格、绘图、触摸串起来。这段代码在ArkTS语言规范下可以编译运行,后续扩展柱状图、饼状图时,只要替换绘制函数即可。
@Component export struct LineChart { private settings: ChartSettings = { padding: { left: 50, right: 20, top: 30, bottom: 40 } }; @Prop data: LinePoint[] = []; @State selectedIndex: number = -1; private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); private canvasWidth: number = 0; private canvasHeight: number = 0; build() { Canvas(this.ctx) .width('100%') .height('100%') .onReady(() => { this.canvasWidth = this.ctx.width; this.canvasHeight = this.ctx.height; this.draw(); }) .onTouch((event: TouchEvent) => this.handleTouch(event)) } private draw() { if (!this.ctx || this.data.length < 2) return; const dpr = this.getDpr(); this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.ctx.scale(dpr, dpr); const plotW = this.canvasWidth - this.settings.padding.left - this.settings.padding.right; const plotH = this.canvasHeight - this.settings.padding.top - this.settings.padding.bottom; const minVal = Math.min(...this.data.map(p => p.value)); const maxVal = Math.max(...this.data.map(p => p.value)); const range = maxVal - minVal || 1; const yMin = minVal - range * 0.1; const yMax = maxVal + range * 0.1; const points: Point[] = this.data.map((p, i) => { const x = this.settings.padding.left + (i * plotW / (this.data.length - 1)); const y = this.settings.padding.top + plotH - ((p.value - yMin) / (yMax - yMin)) * plotH; return { x, y, label: p.xLabel, value: p.value }; }); this.drawGrid(plotW, plotH, yMin, yMax); this.drawAxis(points, yMin, yMax); this.drawLine(points); this.drawLabels(points); if (this.selectedIndex >= 0) { this.drawHighlight(points[this.selectedIndex]); } } private handleTouch(event: TouchEvent) { if (event.type === TouchType.Down || event.type === TouchType.Move) { const touchX = event.touches[0].x; // 简单线性查找,大数据量换二分查找 let nearest = 0; let minDist = Number.MAX_VALUE; this.data.forEach((_, i) => { const x = this.settings.padding.left + (i * this.chartPlotWidth() / (this.data.length - 1)); const dist = Math.abs(x - touchX); if (dist < minDist) { minDist = dist; nearest = i; } }); this.selectedIndex = nearest; this.draw(); } else { this.selectedIndex = -1; this.draw(); } } }这段代码省略了settings定义、getDpr、drawGrid等几个私有方法,但主流程完整。注意onTouch里event.touches[0]的坐标值是vp,和Canvas内部画布坐标是否一致取决于你的Canvas有没有被scale过,实际项目中建议统一在绘制里做坐标变换,触摸位置也做同等变换,不然会出现“点不准”的奇怪问题。
写在最后的实操心得
我自己从接触HarmonyOS 6的Canvas到做出三个能上线的图表组件,大概花了两周多时间,期间踩得最深的坑反而不是绘制本身,而是“想当然”。比如以为Canvas的原点和尺寸一定和布局坐标一致,结果一上真机全部偏出屏幕;又比如以为触摸事件的坐标可以直接用来做命中检测,结果在部分平板上差了十几个像素。核心原因都是没有先搞清楚Canvas组件的坐标系统和物理像素之间的关系。
所以如果你准备上手,我的建议是先做一个小测试页面:画一个十字交叉线和几个固定点,分别在不同尺寸和dpr的设备上跑一遍,确认基础坐标映射没问题了,再往里填图表逻辑。这一步花费不到半小时,能帮你节省后面好几天定位问题的精力。另外一个体会是,图表的代码不要写得“太抽象”,过度的封装会让人调试时不知道数据流走到哪一步。我项目里就是拆成几个独立的component,折线图归折线图,柱状图归柱状图,公共部分只抽坐标计算和颜色工具类,这样谁都能接手维护,也方便针对单张图做专项优化。图表这条路,本质上没有尽头,但把基础搞扎实了,后面再怎么折腾都不会偏太远。