文章目录
- 前言
- Canvas 基础:bindContent 和绘制 API
- 实战:折线图组件
- 坐标计算和数据映射
- 绘制逻辑
- 动画和手势交互
- 性能注意事项
- 小结
前言
项目里经常需要展示数据图表。用三方库能跑通,但样式定制起来很受限,包体积还大。其实 ArkUI 的Canvas组件已经够用了,API 设计和 Web 的 Canvas 2D 非常接近,上手很快。
这篇从零搭一个折线图组件,支持数据点标注、手势缩放和入场动画。代码量不大,但能覆盖 Canvas 绘图的核心知识。
Canvas 基础:bindContent 和绘制 API
Canvas 组件通过bindContent拿到CanvasRenderingContext2D对象,所有绘制操作都通过这个 context 完成:
@Componentstruct CanvasBasic{privatecontext:CanvasRenderingContext2D=newCanvasRenderingContext2D(newRenderingContextSettings(true))build(){Canvas(this.context).width('100%').height(300).backgroundColor('#FFFFFF').onReady(()=>{// 画个矩形this.context.fillStyle='#4FC3F7'this.context.fillRect(20,20,100,60)// 画段文字this.context.font='16vp sans-serif'this.context.fillStyle='#333333'this.context.fillText('Hello Canvas',20,120)// 画条线this.context.strokeStyle='#FF5722'this.context.lineWidth=2this.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:CanvasRenderingContext2D=newCanvasRenderingContext2D(newRenderingContextSettings(true))privatepadding:number=40// 图表内边距@StateanimProgress:number=0// 动画进度 0~1@StatezoomScale:number=1// 缩放比例@StatescrollOffset:number=0// 滚动偏移// 数据值映射到 Y 坐标privatemapY(value:number,min:number,max:number,chartHeight:number):number{constratio=(value-min)/(max-min||1)returnthis.padding+chartHeight*(1-ratio)}// 索引映射到 X 坐标privatemapX(index:number,total:number,chartWidth:number):number{conststep=chartWidth/(total-1||1)returnthis.padding+index*step}}绘制逻辑
privatedrawChart(){constctx=this.contextconstcanvasWidth=ctx.widthasnumberconstcanvasHeight=ctx.heightasnumberconstchartWidth=(canvasWidth-this.padding*2)*this.zoomScaleconstchartHeight=canvasHeight-this.padding*2constdata=this.config.dataif(data.length===0)returnconstmin=Math.min(...data)*0.9constmax=Math.max(...data)*1.1// 清空画布ctx.clearRect(0,0,canvasWidth,canvasHeight)// 1. 画网格线ctx.strokeStyle='#E0E0E0'ctx.lineWidth=0.5constgridCount=5for(leti=0;i<=gridCount;i++){consty=this.padding+(chartHeight/gridCount)*i ctx.beginPath()ctx.moveTo(this.padding,y)ctx.lineTo(canvasWidth-this.padding,y)ctx.stroke()// Y 轴标签constlabelValue=max-(max-min)*(i/gridCount)ctx.fillStyle='#999999'ctx.font='10vp sans-serif'ctx.fillText(labelValue.toFixed(0),5,y+4)}// 2. 画折线(带渐变填充)constgradient=ctx.createLinearGradient(0,this.padding,0,canvasHeight-this.padding)gradient.addColorStop(0,this.config.lineColor+'40')// 带透明度gradient.addColorStop(1,this.config.lineColor+'05')ctx.beginPath()// 可见数据点数(根据动画进度)constvisibleCount=Math.ceil(data.length*this.animProgress)for(leti=0;i<visibleCount;i++){constx=this.mapX(i,data.length,chartWidth)-this.scrollOffsetconsty=this.mapY(data[i],min,max,chartHeight)if(i===0){ctx.moveTo(x,y)}else{ctx.lineTo(x,y)}}// 描线ctx.strokeStyle=this.config.lineColor ctx.lineWidth=2.5ctx.lineJoin='round'ctx.stroke()// 3. 填充渐变区域if(visibleCount>1){constlastX=this.mapX(visibleCount-1,data.length,chartWidth)-this.scrollOffsetconstfirstX=this.mapX(0,data.length,chartWidth)-this.scrollOffset ctx.lineTo(lastX,canvasHeight-this.padding)ctx.lineTo(firstX,canvasHeight-this.padding)ctx.closePath()ctx.fillStyle=gradient ctx.fill()}// 4. 画数据点if(this.config.showDots){for(leti=0;i<visibleCount;i++){constx=this.mapX(i,data.length,chartWidth)-this.scrollOffsetconsty=this.mapY(data[i],min,max,chartHeight)// 白色外圈ctx.beginPath()ctx.arc(x,y,5,0,Math.PI*2)ctx.fillStyle='#FFFFFF'ctx.fill()// 彩色内圈ctx.beginPath()ctx.arc(x,y,3,0,Math.PI*2)ctx.fillStyle=this.config.lineColor ctx.fill()}}// 5. 画 X 轴标签ctx.fillStyle='#999999'ctx.font='10vp sans-serif'for(leti=0;i<data.length;i++){constx=this.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){// 入场动画:折线从左往右画出来conststartTime=Date.now()constduration=1000constanimate=()=>{constelapsed=Date.now()-startTimethis.animProgress=Math.min(1,elapsed/duration)this.drawChart()if(this.animProgress<1){requestAnimationFrame(animate)}}animate()}else{this.animProgress=1this.drawChart()}})// 手势缩放:双指控制 X 轴缩放.gesture(PinchGesture().onActionUpdate((event:GestureEvent)=>{this.zoomScale=Math.max(1,Math.min(3,this.zoomScale*event.scale))this.drawChart()}))}入场动画用了requestAnimationFrame,比setInterval更流畅,和屏幕刷新率同步。缩放就是简单地调整zoomScale然后重绘。
性能注意事项
Canvas 重绘成本不低,有几个点要注意:
别在onActionUpdate里无脑重绘。手势回调触发很频繁,如果数据量大(几百个数据点),每帧全量绘制会卡。可以做节流,或者只绘制可视区域的数据。
数据不变时不要重复绘制。加个脏标记,数据变了才重新绘制:
privateisDirty:boolean=falseupdateData(newData:number[]){this.config.data=newDatathis.isDirty=truerequestAnimationFrame(()=>{if(this.isDirty){this.drawChart()this.isDirty=false}})}大量静态元素可以用离屏缓存。比如网格线、坐标轴这些不变的东西,画一次缓存到一个OffscreenCanvas上,每次绘制直接贴过来,省去重复计算。
// 缓存静态背景privatecacheCanvas:OffscreenCanvas=newOffscreenCanvas({width:800,height:500})privatedrawStaticBackground(){constcacheCtx=this.cacheCanvas.getContext('2d')// 在 cacheCtx 上画网格线、坐标轴...// 主绘制时直接 drawImage 过来this.context.drawImage(this.cacheCanvas,0,0)}小结
Canvas 绘图在鸿蒙里是个很实用的能力,做图表、画板、自定义进度条都用得上。API 和 Web Canvas 高度相似,有前端经验的话上手很快。
折线图这个例子可以在此基础上扩展——加多折线对比、加触摸高亮、加滑动窗口。核心思路都是一样的:数据映射到坐标、逐帧绘制、手势驱动更新。
如果项目里图表需求很多且样式复杂,也可以考虑用三方库(比如 mPaaS Charts)。但简单图表我建议自己用 Canvas 画,可控性高,不用为了几个图表引入一个大库。