资讯详情

鸿蒙开发实战:用Canvas绘制可交互散点图自定义组件

📅 2026/9/16 4:30:02 | 华诺云谱 👁 阅读
鸿蒙开发实战:用Canvas绘制可交互散点图自定义组件
鸿蒙开发入门最容易被卡住的地方往往不是 ArkTS 语法而是“我想画点东西但不知道从哪下手”。散点图就是一个特别合适的练手项目数据模型简单、绘制逻辑直观、踩坑路径清晰做完之后你对 Canvas 绘制、自定义组件封装、状态管理这几个高频知识点都会有一个完整的感觉。这篇文章用的是一个我在实际项目里精简过的散点图组件只保留了核心绘制逻辑能直接跑起来也能让你看清每一步到底在干什么。这个组件适合三类人刚开始学鸿蒙自定义绘制、准备鸿蒙面试想补 Canvas 方向、或者手上刚好有数据可视化需求但不方便引第三方库。我会从设计思路、坐标换算、完整实现到排查实录一条线讲完所有代码基于 API 9用 DevEco Studio 打开就能编译。代码不是我凭空编的是把之前做图表库时踩过的坑浓缩出来所以有些细节会比其他教程啰嗦但都是真能用上的。1. 先想清楚再动手组件设计的四个关键决定做自定义组件最忌讳一上来就写绘制代码。散点图听上去简单但如果你没想清楚数据从哪来、组件怎么被调用、边界画成什么样写出来的东西大概率是“只适用于我自己那组数据”的一次性代码。我在动手前花了不少时间做设计决策这里把最主要的几个讲清楚。1.1 为什么自己画而不是先去引入图表库你在鸿蒙应用里要做散点图第一反应肯定是找第三方库。但实际调研一圈就会发现目前 HarmonyOS 生态里成熟的开源图表库不像 Web 端那么丰富很多库还在早期阶段功能和维护度都不稳定。就算找到能用的引入一个完整图表库来做一张散点图包体积、学习成本、定制成本全都要你买单。我更建议的关键决策是展示型散点图自己画。原因很简单散点图本质上就是画坐标系、画点、画文字三件事Canvas 原生 API 完全够用。自己画的另一个好处是你能完全控制交互细节比如长按标记某个点、点击弹出数据提示这些用别人的库反而要绕很多层接口。等以后真遇到复杂图表需求比如金融类的 K 线图、科学计算类的大规模数据可视化再引入成熟图表方案也不迟。1.2 组件输入长什么样数据模型设计任何图表组件的起点都是数据模型。散点图最简单接受的数据格式就是两个数组x 数组和 y 数组分别代表横纵坐标值。但实际项目里散点图往往还需要点的大小、颜色、标签这些附加信息所以我在设计时没有用“裸数组”而是定义了一个散点数据结构。类比我做过的业务场景有一次需要展示不同城市的房价与面积关系每个点除了横纵坐标还要区分一二三线城市。如果只传两个 number 数组颜色信息就得在组件外部另外维护组件内部的绘制逻辑会变得很别扭。所以数据模型里我加了 category 字段用来做分类着色。如果你用不到传默认值就行不增加使用成本。export class ScatterPoint { x: number; // 横轴业务值 y: number; // 纵轴业务值 category: number; // 分类编号默认 0用于颜色分组 label: string; // 点标签默认空字符串 constructor(x: number, y: number, category: number 0, label: string ) { this.x x; this.y y; this.category category; this.label label; } }1.3 横向边界第一版做到哪一步画散点图的时候特别容易想太多要不要支持缩放、要不要支持拖动、要不要动画、要不要空心点我在第一版里全部砍掉了只保留四个功能坐标轴绘制、数据点绘制、分类着色、长按标记选中点。为什么砍掉缩放和拖动因为这两项涉及矩阵变换和手势状态管理代码量会翻倍而且初期你很容易在交互与绘制的协同上写出 Bug。我更建议先把静态展示和基础选中交互做好跑通整套 Canvas 绘制流程后续再加手势也不迟。动画同理Canvas 动画在鸿蒙里要用逐帧刷新实现逻辑不难但会和状态联动叠加出很多边界问题不适合放在第一个版本里。1.4 绘制方案选型Canvas 是当前最优解鸿蒙里做自定义绘制有 Canvas 和 XComponent 两条路线。XComponent 更偏重型场景比如游戏渲染、音视频处理走的是 Native 渲染管线对新手和大多数业务场景来说太重了。我选 Canvas因为它与 ArkUI 组件树天然融合状态变化可以触发重绘点击事件可以通过组件手势系统回传正好满足散点图的全部需求。具体到 Canvas API核心对象是 CanvasRenderingContext2D获取方式是在 Canvas 组件的 onReady 回调里初始化。这里有个重要的鸿蒙特性Canvas 实例必须先初始化也就是拿到的渲染上下文是异步的不是组件创建完立刻就有。你要是把绘制逻辑写在 aboutToAppear 里会发现画布一片空白这是新手最容易踩的第一个坑。2. 核心细节拆解坐标换算、坐标轴与性能这个部分是全篇重点散点图组件的难点其实不在“画点”而在坐标换算和坐标轴绘制。很多教程会把坐标轴画得很粗糙刻度对不齐、文字被裁切、数据点跑到画布外观感很差。下面我把处理思路和细节展开讲。2.1 业务坐标怎么映射到画布像素屏幕上的每个像素都有实际坐标但业务数据是另一个量纲。直径 5 像素的点代表房价 5000 元一平必须通过线性映射把业务值转成像素坐标。散点图的坐标换算核心是等比映射公式像素坐标 padding (业务值 - 业务最小值) / (业务最大值 - 业务最小值) * (绘制区域长度 - 2 * padding)公式本身很简单但有几个细节直接决定成图效果。第一业务最大值和最小值不能直接用数据里的 min/max否则数据点会贴近边框观感很差。我一般会在数据基础上额外加 10% 的边距让数据在坐标系里“呼吸”一下。第二如果业务数据里最大最小值相等公式分母会变成 0必须有兜底判断。第三鸿蒙 Canvas 的坐标原点在画布左上角x 轴向右增长y 轴向下增长但数学里的散点图通常 y 轴向上为正所以 y 方向映射的时候要做一次翻转pixelY padding (1 - normalizedY) * drawAreaHeight。2.2 刻度、网格线与文本绘制的细节刻度绘制是最能区分“能用”和“好用”的细节。我的做法是定义几个可配置参数刻度数量axisCount、是否显示网格线showGrid、是否显示刻度文字showLabel。刻度计算采用均分策略也就是把坐标系区域按 axisCount 等分然后依次算出每段对应的业务值。文字绘制有几个坑要重点说。第一个坑是 textAlign 和 textBaseline 不设置会导致刻度文字和坐标轴对不齐。x 轴刻度文字建议居中显示y 轴刻度文字建议右对齐。第二个坑是刻度值不能“裸打印”浮点数会出现一长串小数需要做精度格式化我一般用保留两位小数的方式。第三个坑是 Canvas 的描边会以中心线向两侧扩散如果坐标轴线宽设成 1实际效果是半像素模糊。解决方法是把坐标轴线宽设为 2或者在奇数像素坐标处加 0.5 偏移我偷懒直接采用线宽 2 的方案效果稳定。2.3 散点绘制中的重叠与高亮数据点多了以后点与点会重叠视觉上分不清密度。处理这个问题的初步方案是设置透明度比如 fillStyle 用 rgba 格式而不是纯色重叠区域颜色会自然加深类似热力图的早期效果。代码实现上就是给每个点的颜色追加一个 alpha 通道值。高亮交互我用的是长按手势。用户在某个点上长按组件记录选中点索引绘制时给选中的点画一个更大半径的同心圆外环。这里有一个很容易被忽略的问题如何判断用户按在哪个点上屏幕像素坐标和业务坐标不是一个体系所以要把手势坐标先换算回业务坐标再遍历所有散点找距离最近且小于阈值的一个。换算公式就是第一节那个公式的反函数代码实现时刚好复用。2.4 性能好习惯减少无效绘制散点图数据量不大时性能不是问题但如果你做的是实时数据录入场景每秒几十帧刷新就会出现掉帧。我的经验是控制 invalidate 的时机和频率。鸿蒙 Canvas 的局部绘制 API 是 invalidate()调用后会触发整个 Canvas 重绘并没有 Web 端那种“只重绘某一块区域”的概念所以性能优化重点是减少不必要的 invalidate。具体做法有两条数据没变化时不触发重绘拖动或缩放过程中用 requestAnimationFrame 做节流。另外一个实用技巧是把静态内容缓存到离屏 Canvas。散点图中坐标轴和网格线几乎不变没有必要每次数据点更新都重画一遍。你可以创建一个离屏 Canvas 画好坐标轴主 Canvas 用 drawImage 把底图贴上去再叠加数据点这样重绘开销能明显下降。3. 完整实现一个可直接复用的散点图组件设计方案聊完下面进入实操。我尽量把每一步的代码和理由都讲清楚你照着敲一遍就能跑起来。3.1 环境说明与工程准备我用的开发环境是 DevEco Studio 4.0 及以上项目默认模板选择 Empty Ability语言用 ArkTSAPI 版本设置 API 9 或更高。需要提醒的是鸿蒙 SDK 版本迭代比较快API 名称在个别版本有调整比如早期版本的 onReady 回调在某些 SDK 版本表现为特定生命周期接口。如果你编译时发现个别 API 报红优先去 DevEco Studio 的 API 文档里搜索对应名称的最新写法。新建完工程后我习惯在 entry/src/main/ets 下建一个 components 目录专门放自定义组件。散点图组件放在 ScatterChart.ets页面文件放在 pages/Index.ets。这样目录结构清晰组件还能被其他页面复用到。3.2 数据模型与属性定义组件属性我设计了 7 个数据数组 points、画布内边距 padding、坐标轴颜色 axisColor、散点颜色数组 stageColors、是否显示网格 showGrid、刻度数量 axisCount、长按选中回调 onPointSelected。其中多数属性通过 Prop 装饰器暴露给外部因为它们是单向数据流。回调通过自定义事件机制传给外部。这里有一个细节要强调Prop 适合传递基本类型和简单对象但 points 是对象数组如果父组件数据变化频繁Prop 的深拷贝开销可能偏高。第一版这样写没问题后续要优化的话可以改成 Link 或者 ObjectLink让子组件和父组件共享同一份数据引用。Component export struct ScatterChart { private settings: RenderingContextSettings new RenderingContextSettings(true) private ctx: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings) Prop points: ScatterPoint[] [] Prop padding: number 30 Prop axisColor: string #888888 Prop stageColors: string[] [#0A59F7, #00B42A, #FF7D00, #F53F3F] Prop showGrid: boolean true Prop axisCount: number 5 onPointSelected: (point: ScatterPoint, index: number) void () {} private canvasWidth: number 0 private canvasHeight: number 0 private selectIndex: number -1 private minX: number 0 private maxX: number 0 private minY: number 0 private maxY: number 0 private drawableWidth: number 0 private drawableHeight: number 0 private plotLeft: number 0 private plotTop: number 0 private plotRight: number 0 private plotBottom: number 0 private touchOffsetX: number 0 private touchOffsetY: number 0 }3.3 绘制流程从坐标系到数据点组件渲染的核心是几个私有方法prepareData() 负责计算数据范围、calcPlotArea() 负责计算坐标系区域、drawAxes() 负责绘制坐标轴、drawScatter() 负责绘制数据点、dispatchDraw() 是统一入口。每次需要重绘时先清空画布再依次调用各绘制方法。private prepareData() { if (this.points.length 0) { return } let minX this.points[0].x let maxX this.points[0].x let minY this.points[0].y let maxY this.points[0].y this.points.forEach((p: ScatterPoint) { minX Math.min(minX, p.x) maxX Math.max(maxX, p.x) minY Math.min(minY, p.y) maxY Math.max(maxY, p.y) }) // 加 10% 边距避免数据贴在边框上 let padX (maxX - minX) * 0.1 let padY (maxY - minY) * 0.1 if (padX 0) padX 1 if (padY 0) padY 1 this.minX minX - padX this.maxX maxX padX this.minY minY - padY this.maxY maxY padY } private calcPlotArea() { // 左侧预留 30 像素放 y 轴刻度文字 this.plotLeft this.padding 20 this.plotTop this.padding this.plotRight this.canvasWidth - this.padding this.plotBottom this.canvasHeight - this.padding this.drawableWidth this.plotRight - this.plotLeft this.drawableHeight this.plotBottom - this.plotTop }绘制坐标轴的方法我会把 x 轴、y 轴和网格线统一处理。网格线先用细实线画出来颜色给浅灰色让用户能看到数据范围坐标轴线用深色加粗。刻度文字绘制时注意 textAlign 与 textBaseline 对齐避免文字遮挡轴线或超出画布。private drawAxes() { // 绘制网格线 if (this.showGrid) { this.ctx.strokeStyle #F0F0F0 this.ctx.lineWidth 1 for (let i 0; i this.axisCount; i) { let ratio i / this.axisCount let y this.plotTop ratio * this.drawableHeight this.ctx.beginPath() this.ctx.moveTo(this.plotLeft, y) this.ctx.lineTo(this.plotRight, y) this.ctx.stroke() let x this.plotLeft ratio * this.drawableWidth this.ctx.beginPath() this.ctx.moveTo(x, this.plotTop) this.ctx.lineTo(x, this.plotBottom) this.ctx.stroke() } } // 绘制坐标轴 this.ctx.strokeStyle this.axisColor this.ctx.lineWidth 2 this.ctx.beginPath() this.ctx.moveTo(this.plotLeft, this.plotTop) this.ctx.lineTo(this.plotLeft, this.plotBottom) this.ctx.lineTo(this.plotRight, this.plotBottom) this.ctx.stroke() // 绘制刻度文字 this.ctx.fillStyle #666666 this.ctx.font 12vp sans-serif this.ctx.textAlign right this.ctx.textBaseline middle for (let i 0; i this.axisCount; i) { let ratio i / this.axisCount let yVal this.maxY - ratio * (this.maxY - this.minY) let y this.plotTop ratio * this.drawableHeight this.ctx.fillText(yVal.toFixed(1), this.plotLeft - 8, y) } this.ctx.textAlign center this.ctx.textBaseline top for (let i 0; i this.axisCount; i) { let ratio i / this.axisCount let xVal this.minX ratio * (this.maxX - this.minX) let x this.plotLeft ratio * this.drawableWidth this.ctx.fillText(xVal.toFixed(1), x, this.plotBottom 8) } }坐标换算函数是最应该单独封装的一个方法。业务值转像素坐标写在 mapX 和 mapY 里选中点判断反过来用。这里有个细节散点图的 y 轴方向需要翻转写代码时一定记得plotBottom - ratio * drawableHeight而不是从 plotTop 加。private mapX(xVal: number): number { return this.plotLeft (xVal - this.minX) / (this.maxX - this.minX) * this.drawableWidth } private mapY(yVal: number): number { let ratio (yVal - this.minY) / (this.maxY - this.minY) return this.plotBottom - ratio * this.drawableHeight }绘制散点的方法中我按 category 分类设置填充色并且每个点都画了两个圆底层用不透明度较低的半透明圆做光晕效果上层用纯色圆做核心点。这个视觉处理借鉴了常见数据可视化库的做法散点的密度和边界都会更清楚。private drawScatter() { for (let i 0; i this.points.length; i) { let p this.points[i] let color this.stageColors[p.category % this.stageColors.length] let px this.mapX(p.x) let py this.mapY(p.y) // 光晕 this.ctx.globalAlpha 0.25 this.ctx.fillStyle color this.ctx.beginPath() this.ctx.arc(px, py, 8, 0, Math.PI * 2) this.ctx.fill() // 核心点 this.ctx.globalAlpha 1.0 this.ctx.fillStyle color this.ctx.beginPath() this.ctx.arc(px, py, 4, 0, Math.PI * 2) this.ctx.fill() // 选中态外环 if (this.selectIndex i) { this.ctx.strokeStyle color this.ctx.lineWidth 2 this.ctx.beginPath() this.ctx.arc(px, py, 12, 0, Math.PI * 2) this.ctx.stroke() } } }组件的 onReady 里需要获取 Canvas 尺寸并触发首轮绘制。这里要处理一个实际问题组件被放在容器里容器的尺寸只能在布局完成后才能拿到所以不要在 aboutToAppear 里读取宽高。通过 Canvas 组件的 onReady 回调拿到是相对可靠的方式。如果 Canvas 尺寸发生了改变比如页面从竖屏转横屏要重新读取宽高并重绘。private onCanvasReady() { // 这里假设 Canvas 组件设置了固定尺寸或者占满父容器 this.canvasWidth this.containerWidth this.canvasHeight this.containerHeight this.prepareData() this.calcPlotArea() this.dispatchDraw() } private dispatchDraw() { this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) this.drawAxes() this.drawScatter() }3.4 在页面中接入组件页面侧的使用方式非常直接。在 build 里声明 ScatterChart 组件传入散点数组和回调。这里要注意 Prop 的初始赋值时机组件首次渲染前必须能拿到数据所以数据初始化放在页面 aboutToAppear 里而不是 build 里临时生成。Entry Component struct Index { private scatterData: ScatterPoint[] [] State chartPoints: ScatterPoint[] [] aboutToAppear(): void { this.scatterData [ new ScatterPoint(1, 2, 0, A), new ScatterPoint(2, 4, 0, B), new ScatterPoint(3, 6, 0, C), new ScatterPoint(2.5, 7, 1, D), new ScatterPoint(4, 5, 1, E), new ScatterPoint(5, 8, 2, F) ] this.chartPoints this.scatterData } build() { Column() { ScatterChart({ points: this.chartPoints, onPointSelected: (p: ScatterPoint, index: number) { console.info(selected: ${p.label || p.x , p.y}) } }) .width(100%) .height(300) } .padding(16) .width(100%) .height(100%) .backgroundColor(#FFFFFF) } }3.5 长按选中手势坐标反算与命中检测选中交互在散点图组件里是比较容易出彩的功能。为了让用户长按某个散点区域时能选中对应数据点我需要在 Canvas 的 onTouch 事件里做坐标转换和命中检测。具体的转换过程是先获取手势相对 Canvas 的坐标然后判断坐标落点是否在绘制区域内最后遍历所有散点计算手势坐标与每个点像素坐标的欧氏距离找到最近的那个点。以下几点在实现时容易忽略第一手势坐标可能是相对于页面的需要通过事件对象的 x/y 属性换算成 Canvas 内部坐标。第二命中阈值要设得足够宽松用户手指头比点大得多阈值我一般取 20 像素这样虽然“精确点击”要求降低了但用户体验更友好。第三选中以后需要触发组件内重绘把外环画出来。private onTouchEvent(event: TouchEvent) { if (event.touches.length 0) { return } let touch event.touches[0] let x touch.x let y touch.y if (event.type TouchType.Down || event.type TouchType.Move) { this.selectIndex this.hitTest(x, y) this.dispatchDraw() if (this.selectIndex 0) { this.onPointSelected(this.points[this.selectIndex], this.selectIndex) } } } private hitTest(x: number, y: number): number { let nearestIndex -1 let nearestDist 20 for (let i 0; i this.points.length; i) { let px this.mapX(this.points[i].x) let py this.mapY(this.points[i].y) let dx px - x let dy py - y let dist Math.sqrt(dx * dx dy * dy) if (dist nearestDist) { nearestDist dist nearestIndex i } } return nearestIndex }这段代码里还有个细节TouchType.Move 也会触发 hitTest这意味着用户在画布上滑动时选中的点会跟着变。从交互上看类似于“悬停选中”在触屏上效果可以接受如果你只希望在长按时选中可以加一个时间判断记录按下时间戳超过 500ms 才进入选中逻辑。我第一版偷懒没加后面测试时发现滑动会误触选中后来补了一个isLongPress状态。3.6 还可以怎么扩展散点图组件的第一版实现了静态展示和长按选中后续扩展方向我简单说几个不展开实现。最容易加的是缩放和平移在 onTouch 里记录双指距离按比例换算成 x/y 方向的缩放系数绘制时把映射公式里的 min/max 值做偏移就行。其次可以加动画首次渲染时让散点从小半径渐变到大半径属于 Canvas 逐帧绘制代码结构上用 requestAnimationFrame 控制帧刷新。再加一个数据提示框长按选中点后在点旁边用一个小矩形绘制 label这就接近完整的数据标注功能。扩展时要记住核心原则坐标换算一个函数管到底手势状态机单独维护。很多自定义绘制组件后期失控就是因为把坐标换算、手势判断、数据渲染全混在一个生命周期里。4. 实战中踩过的坑与避坑清单这部分是拿真实调试经验换来的我尽量把遇到频率最高的问题列全。有些问题在官方文档里不会写但实际开发时一定会撞上。4.1 常见问题速查表问题现象根本原因解决方案画布一片空白绘制逻辑运行在 Canvas 初始化之前等 onReady 回调触发后再绘制数据点全部挤在左上角y 轴方向没有做翻转用 plotBottom - ratio * drawableHeight刻度文字重叠font 未设置 vp 单位导致默认字号过大设置 ctx.font 为 12vp sans-serif点与点间距过小看不清业务数据 max/min 间距太小绘制前先做归一化放大处理长按手势同时触发滚动事件被父组件拦截消费使用 GestureGroup 或 onTouch 配合竞争策略组件尺寸改变后图形变形Canvas 宽高没有重新读取在 onAreaChange 中重新读取并重绘重绘后坐标轴闪烁离屏缓存未生效每次全量重绘坐标轴缓存到 offscreen canvas某些中文标签显示为豆腐块未指定支持中文的字体设置 fontFamily 为 sans-serif选中点外环偏移严重手势坐标未考虑父容器偏移使用事件对象的 x/y 属性而不是页面全局坐标4.2 三个让我记忆深刻的调试现场第一个是“点画出来了但消失得很快”。调试时发现点击画布点没有出现然后重新编译又是好的我一度以为是缓存问题。后来打印日志才发现onTouch 里调用 dispatchDraw 之前没有判断 Canvas 是否已经初始化完成导致 Touch 事件先于 onReady 触发clearRect 把还没画出来的内容清掉了。解决办法是在 dispatchDraw 入口加一个初始化标记isInit未初始化直接返回。第二个是“刻度数字跑到画布外面”。因为我把 y 轴刻度文字设置了 textAlign 为 right但在计算文字位置时使用plotLeft - 8左侧留白不够时文字就会和边缘重叠。后面我统一把 y 轴左侧的 padding 加大到 20同时设置了 width 占满父容器就没有再出现过这个问题。这类布局计算问题靠的就是不断测试不同尺寸的机型。第三个是“列表页滑动时图表跟着抖动”。我把散点图放在 Swiper 页面里长按散点图会触发 Swiper 的翻页手势。排查后发现是长按手势和 Swiper 的水平滑动手势冲突了。解决方法是在图表组件外层包一个手势竞争策略把长按手势的优先级调高或者在长按识别到之后再向父组件请求禁用滚动。如果你用的是 onTouch 原生事件可以在 onTouch 里根据手势类型手动判断。4.3 给后来者的小建议根据我多次实现图表组件的经验建议你从现在开始就做好测试数据管理。可以准备一套包含极端情况的测试用例空数组、只有一个点、x 和 y 值相等、超大值和超小值混合。每次修改坐标换算代码后拿这些用例回归一遍能少走很多弯路。另外鸿蒙的 Canvas API 与 Web Canvas 很相似但细节差异也不少。比如字体设置需要用 vp 单位才能在不同屏幕上保持一致颜色解析支持标准和十六进制但某些浏览器里的 CSS 颜色名称在鸿蒙里可能不支持。所以在写代码时统一使用十六进制颜色和数字字号避免踩坑。最后我想说散点图组件虽是“小项目”但它几乎涵盖了自定义绘制组件的所有核心知识点包括数据模型设计、坐标映射、手势交互、性能优化和状态管理。做完这个再去画折线图、柱状图或者做更复杂的风场图其实都是在当前框架里加新绘制方法而已。你在实现过程中遇到的问题很多在未来的复杂图表场景里还会遇见早点解决后面会顺畅很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。