HarmonyOS7 别让图表库限制你:Canvas 自定义图表和数据可视化实战
文章目录前言Canvas 基础bindContent 和绘制 API实战折线图组件坐标计算和数据映射绘制逻辑动画和手势交互性能注意事项小结前言项目里经常需要展示数据图表。用三方库能跑通但样式定制起来很受限包体积还大。其实 ArkUI 的Canvas组件已经够用了API 设计和 Web 的 Canvas 2D 非常接近上手很快。这篇从零搭一个折线图组件支持数据点标注、手势缩放和入场动画。代码量不大但能覆盖 Canvas 绘图的核心知识。Canvas 基础bindContent 和绘制 APICanvas 组件通过bindContent拿到CanvasRenderingContext2D对象所有绘制操作都通过这个 context 完成Componentstruct CanvasBasic{privatecontext:CanvasRenderingContext2DnewCanvasRenderingContext2D(newRenderingContextSettings(true))build(){Canvas(this.context).width(100%).height(300).backgroundColor(#FFFFFF).onReady((){// 画个矩形this.context.fillStyle#4FC3F7this.context.fillRect(20,20,100,60)// 画段文字this.context.font16vp sans-serifthis.context.fillStyle#333333this.context.fillText(Hello Canvas,20,120)// 画条线this.context.strokeStyle#FF5722this.context.lineWidth2this.context.beginPath()this.context.moveTo(20,150)this.context.lineTo(200,200)this.context.stroke()})}}onReady是 Canvas 初始化完成后的回调绘制操作必须在这里面做。直接写在build里会报错因为 context 还没准备好。几个常用的绘制方法fillRect画填充矩形、strokeRect画描边矩形、arc画圆弧、bezierCurveTo画贝塞尔曲线、fillText绘制文字。路径类的操作需要beginPath开头stroke或fill结尾。实战折线图组件先看组件的接口设计。我希望这个图表用起来像这样LineChart({data:[12,45,28,67,39,52,71],labels:[周一,周二,周三,周四,周五,周六,周日],lineColor:#4FC3F7,showDots:true,animated:true})下面是完整实现分几个部分讲。坐标计算和数据映射interfaceChartConfig{data:number[]labels:string[]lineColor:stringshowDots:booleananimated:boolean}Componentstruct LineChart{Propconfig:ChartConfig{data:[],labels:[],lineColor:#4FC3F7,showDots:true,animated:true}privatecontext:CanvasRenderingContext2DnewCanvasRenderingContext2D(newRenderingContextSettings(true))privatepadding:number40// 图表内边距StateanimProgress:number0// 动画进度 0~1StatezoomScale:number1// 缩放比例StatescrollOffset:number0// 滚动偏移// 数据值映射到 Y 坐标privatemapY(value:number,min:number,max:number,chartHeight:number):number{constratio(value-min)/(max-min||1)returnthis.paddingchartHeight*(1-ratio)}// 索引映射到 X 坐标privatemapX(index:number,total:number,chartWidth:number):number{conststepchartWidth/(total-1||1)returnthis.paddingindex*step}}绘制逻辑privatedrawChart(){constctxthis.contextconstcanvasWidthctx.widthasnumberconstcanvasHeightctx.heightasnumberconstchartWidth(canvasWidth-this.padding*2)*this.zoomScaleconstchartHeightcanvasHeight-this.padding*2constdatathis.config.dataif(data.length0)returnconstminMath.min(...data)*0.9constmaxMath.max(...data)*1.1// 清空画布ctx.clearRect(0,0,canvasWidth,canvasHeight)// 1. 画网格线ctx.strokeStyle#E0E0E0ctx.lineWidth0.5constgridCount5for(leti0;igridCount;i){constythis.padding(chartHeight/gridCount)*i ctx.beginPath()ctx.moveTo(this.padding,y)ctx.lineTo(canvasWidth-this.padding,y)ctx.stroke()// Y 轴标签constlabelValuemax-(max-min)*(i/gridCount)ctx.fillStyle#999999ctx.font10vp sans-serifctx.fillText(labelValue.toFixed(0),5,y4)}// 2. 画折线带渐变填充constgradientctx.createLinearGradient(0,this.padding,0,canvasHeight-this.padding)gradient.addColorStop(0,this.config.lineColor40)// 带透明度gradient.addColorStop(1,this.config.lineColor05)ctx.beginPath()// 可见数据点数根据动画进度constvisibleCountMath.ceil(data.length*this.animProgress)for(leti0;ivisibleCount;i){constxthis.mapX(i,data.length,chartWidth)-this.scrollOffsetconstythis.mapY(data[i],min,max,chartHeight)if(i0){ctx.moveTo(x,y)}else{ctx.lineTo(x,y)}}// 描线ctx.strokeStylethis.config.lineColor ctx.lineWidth2.5ctx.lineJoinroundctx.stroke()// 3. 填充渐变区域if(visibleCount1){constlastXthis.mapX(visibleCount-1,data.length,chartWidth)-this.scrollOffsetconstfirstXthis.mapX(0,data.length,chartWidth)-this.scrollOffset ctx.lineTo(lastX,canvasHeight-this.padding)ctx.lineTo(firstX,canvasHeight-this.padding)ctx.closePath()ctx.fillStylegradient ctx.fill()}// 4. 画数据点if(this.config.showDots){for(leti0;ivisibleCount;i){constxthis.mapX(i,data.length,chartWidth)-this.scrollOffsetconstythis.mapY(data[i],min,max,chartHeight)// 白色外圈ctx.beginPath()ctx.arc(x,y,5,0,Math.PI*2)ctx.fillStyle#FFFFFFctx.fill()// 彩色内圈ctx.beginPath()ctx.arc(x,y,3,0,Math.PI*2)ctx.fillStylethis.config.lineColor ctx.fill()}}// 5. 画 X 轴标签ctx.fillStyle#999999ctx.font10vp sans-seriffor(leti0;idata.length;i){constxthis.mapX(i,data.length,chartWidth)-this.scrollOffset ctx.fillText(this.config.labels[i]||,x-12,canvasHeight-10)}}动画和手势交互build(){Canvas(this.context).width(100%).height(250).backgroundColor(#FFFFFF).borderRadius(12).onReady((){if(this.config.animated){// 入场动画折线从左往右画出来conststartTimeDate.now()constduration1000constanimate(){constelapsedDate.now()-startTimethis.animProgressMath.min(1,elapsed/duration)this.drawChart()if(this.animProgress1){requestAnimationFrame(animate)}}animate()}else{this.animProgress1this.drawChart()}})// 手势缩放双指控制 X 轴缩放.gesture(PinchGesture().onActionUpdate((event:GestureEvent){this.zoomScaleMath.max(1,Math.min(3,this.zoomScale*event.scale))this.drawChart()}))}入场动画用了requestAnimationFrame比setInterval更流畅和屏幕刷新率同步。缩放就是简单地调整zoomScale然后重绘。性能注意事项Canvas 重绘成本不低有几个点要注意别在onActionUpdate里无脑重绘。手势回调触发很频繁如果数据量大几百个数据点每帧全量绘制会卡。可以做节流或者只绘制可视区域的数据。数据不变时不要重复绘制。加个脏标记数据变了才重新绘制privateisDirty:booleanfalseupdateData(newData:number[]){this.config.datanewDatathis.isDirtytruerequestAnimationFrame((){if(this.isDirty){this.drawChart()this.isDirtyfalse}})}大量静态元素可以用离屏缓存。比如网格线、坐标轴这些不变的东西画一次缓存到一个OffscreenCanvas上每次绘制直接贴过来省去重复计算。// 缓存静态背景privatecacheCanvas:OffscreenCanvasnewOffscreenCanvas({width:800,height:500})privatedrawStaticBackground(){constcacheCtxthis.cacheCanvas.getContext(2d)// 在 cacheCtx 上画网格线、坐标轴...// 主绘制时直接 drawImage 过来this.context.drawImage(this.cacheCanvas,0,0)}小结Canvas 绘图在鸿蒙里是个很实用的能力做图表、画板、自定义进度条都用得上。API 和 Web Canvas 高度相似有前端经验的话上手很快。折线图这个例子可以在此基础上扩展——加多折线对比、加触摸高亮、加滑动窗口。核心思路都是一样的数据映射到坐标、逐帧绘制、手势驱动更新。如果项目里图表需求很多且样式复杂也可以考虑用三方库比如 mPaaS Charts。但简单图表我建议自己用 Canvas 画可控性高不用为了几个图表引入一个大库。