请添加图片描述

前言

在现代大前端开发与移动端开发中,数据可视化和复杂的自定义 UI 渲染一直是衡量开发者技术深度的重要标准之一。在华为的 HarmonyOS (鸿蒙) 系统中,ArkTS 为我们提供了非常强大的原生 Canvas 组件。

虽然市面上有很多现成的图表库(如 ECharts),但在某些特定场景下(例如极致的性能要求、极度定制化的交互UI、物联网设备的数据看板等),我们需要直接操作底层 API 来进行图形渲染。

本文将基于一份非常经典的 ArkTS Canvas 源码,带大家从零开始剖析如何利用 CanvasRenderingContext2D 绘制复杂的数学几何图形(如分形树、贝塞尔花瓣、太极图)以及企业级动态数据图表(如柱状图、折线图、饼图、雷达图等)。文章字数与干货拉满,建议先收藏再阅读!


一、 核心基础:Canvas 画布的环境初始化

在 ArkTS 中使用 Canvas,我们需要先准备好绘图上下文。这与 Web 前端的 HTML5 Canvas 非常类似,但写法上更贴合声明式 UI 的特点。

1.1 声明渲染上下文

我们需要实例化 RenderingContextSettingsCanvasRenderingContext2D。在这段代码中,作者创建了两个上下文,分别用于绘制静态几何图形和动态图表。

@Entry
@Component
struct Index {
  // 1. 开启抗锯齿设置,保证图形边缘平滑
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  // 2. 静态几何图形的上下文
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  // 3. 动态图表的上下文
  private chartCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  aboutToAppear() {
    // 组件即将出现时,启动动画循环
    this.animate()
  }
  // ... build() 方法略
}

💡 技术解析:

  • RenderingContextSettings(true) 中的 true 代表开启抗锯齿 (Anti-aliasing)。对于需要绘制圆形、斜线、曲线的场景,抗锯齿是必不可少的,否则图形边缘会出现明显的像素马赛克。
  • Canvas 组件在 build() 中通过 .onReady() 回调来确保画布已经挂载完毕,此时才可以调用 drawGeometry() 进行绘制。

二、 复杂几何图形的数学原理与代码剖析

图形学的基础是数学。在二维画布中,我们主要利用极坐标系与直角坐标系的转换来完成复杂图形的计算。

2.1 正多边形与星形:三角函数的巧妙运用

无论是正六边形还是五角星,核心思想都是在一个圆周上等间距地获取顶点,然后将它们连接起来。

核心代码截取
drawStar(ctx: CanvasRenderingContext2D, points: number, outerR: number, innerR: number, fill: string, stroke: string) {
  ctx.beginPath()
  // 乘以 2 是因为星形有内外两个顶点交替
  for (let i = 0; i <= points * 2; i++) {
    // 计算当前顶点的角度:从 -90度 (12点钟方向) 开始
    const angle = (i / (points * 2)) * 2 * Math.PI - Math.PI / 2
    // 偶数索引取外半径,奇数索引取内半径
    const r = i % 2 === 0 ? outerR : innerR
    
    // 极坐标转直角坐标公式
    const x = r * Math.cos(angle)
    const y = r * Math.sin(angle)
    
    if (i === 0) ctx.moveTo(x, y)
    else ctx.lineTo(x, y)
  }
  ctx.closePath()
  ctx.fillStyle = fill
  ctx.fill()
  ctx.strokeStyle = stroke
  ctx.lineWidth = 2
  ctx.stroke()
}

📐 数学原理解析

在屏幕坐标系中,向右是 X 轴正方向,向下是 Y 轴正方向。
我们在圆上取点时,使用的是极坐标转直角坐标的经典公式:

x = r ⋅ cos ⁡ ( θ ) x = r \cdot \cos(\theta) x=rcos(θ)

y = r ⋅ sin ⁡ ( θ ) y = r \cdot \sin(\theta) y=rsin(θ)

代码中 - Math.PI / 2 的作用是将起始角度逆时针旋转 90 度,使得多边形或星形的第一个顶点正对上方(即 12 点钟方向),这样看起来更符合人类的视觉习惯。

2.2 贝塞尔曲线花瓣:高级路径绘制

贝塞尔曲线(Bézier curve)是计算机图形学中最重要的一环,常用于绘制平滑的复杂曲线。

// ---- 4. 贝塞尔曲线花瓣 ----
ctx.save()
ctx.translate(340, 200) // 将坐标原点移至画布中心
const petalCount = 6    // 花瓣数量
for (let i = 0; i < petalCount; i++) {
  const angle = (i / petalCount) * 2 * Math.PI
  ctx.save()
  ctx.rotate(angle) // 根据花瓣索引旋转画布
  ctx.beginPath()
  ctx.moveTo(0, 0)
  // 三次贝塞尔曲线 (cp1x, cp1y, cp2x, cp2y, x, y)
  ctx.bezierCurveTo(20, -30, 50, -30, 40, 0)
  ctx.bezierCurveTo(50, 30, 20, 30, 0, 0)
  ctx.fillStyle = `hsla(${i * 60}, 80%, 65%, 0.7)` // 使用 HSL 颜色模型实现渐变色环
  ctx.fill()
  ctx.restore()
}

💡 技术解析:

  • 状态隔离: 这里大量使用了 ctx.save()ctx.restore()。在循环内旋转画布(ctx.rotate)前保存状态,画完一片花瓣后恢复状态,确保每一片花瓣的计算都是独立的,不会发生旋转角度累加的错误。
  • 三次贝塞尔 API: bezierCurveTo 需要两个控制点和一个终点。通过控制点拉伸曲线,两段贝塞尔曲线完美拼凑成了一个水滴/花瓣的形状。

2.3 分形树:递归算法的艺术

分形树是典型的递归 (Recursion) 在图形界面的应用。每一个树枝的分叉都可以看作是一棵更小的树。

drawBranch(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, len: number, depth: number) {
  if (depth <= 0) return // 递归终止条件

  // 计算树枝终点坐标
  const nx = x + len * Math.cos(angle)
  const ny = y + len * Math.sin(angle)
  
  ctx.beginPath()
  ctx.moveTo(x, y)
  ctx.lineTo(nx, ny)
  // 随着深度减小,树枝颜色变浅,线条变细
  ctx.strokeStyle = `hsl(30, 60%, ${30 + depth * 8}%)`
  ctx.lineWidth = depth * 1.2
  ctx.stroke()
  
  // 递归调用:向左侧和右侧偏转产生新分支
  this.drawBranch(ctx, nx, ny, angle - 0.5, len * 0.7, depth - 1)
  this.drawBranch(ctx, nx, ny, angle + 0.5, len * 0.7, depth - 1)
}

核心逻辑:
每深入一层,树枝长度 len 缩减为原来的 70% (len * 0.7),角度分别向两侧偏移 0.5 弧度。当递归深度 depth 归零时停止。这寥寥数行代码就能渲染出极其复杂的自然树木形态。


三、 动态数据可视化:手搓各种图表

现代应用离不开数据报表。接下来我们将拆解图表绘制模块。

3.1 动态渲染机制与假数据生成

要让图表“动”起来,我们需要引入定时器。

animate() {
  // 每 2 秒刷新一次图表模拟数据更新
  setInterval(() => {
    this.drawCharts()
  }, 2000)
  setTimeout((): void => this.drawCharts(), 100)
}

// 随机数据生成器
randomData(count: number, min: number, max: number): number[] {
  const arr: number[] = []
  for (let i = 0; i < count; i++) {
    arr.push(Math.floor(min + Math.random() * (max - min)))
  }
  return arr
}

提示:在实际生产环境中,请尽量使用 requestAnimationFrame 来处理丝滑的高帧率动画,setInterval 主要用于定时拉取后端数据。

3.2 柱状图 (Bar Chart):坐标系映射与渐变填充

柱状图的核心在于将实际数据映射到 Canvas 像素高度上。

// 数据映射逻辑片段
data.forEach((v, i) => {
  // 计算柱子高度,假设满分 100 对应 chartH
  const barH = (v / 100) * chartH 
  const x = 35 + i * (barW + 8)
  const y = chartTop + chartH - barH // Y轴向下为正,需要反向计算起点

  // 创建线性渐变对象
  const grad = ctx.createLinearGradient(x, y, x, chartTop + chartH)
  grad.addColorStop(0, colors[i % colors.length])
  grad.addColorStop(1, colors[i % colors.length] + '44') // 增加透明度

  ctx.fillStyle = grad
  ctx.beginPath()
  // 鸿蒙专属增强 API:带圆角的矩形 [左上, 右上, 右下, 左下]
  ctx.roundRect(x, y, barW, barH, [4, 4, 0, 0]) 
  ctx.fill()
})

亮点: 代码使用了 ctx.roundRect。传统的 HTML5 Canvas 画圆角矩形极其痛苦,需要用四个 arcTo 拼接,而 ArkTS 提供了非常贴心的 roundRect 原生支持,极大提高了绘制效率。

3.3 雷达图 (Radar Chart):多维数据呈现

雷达图是游戏面板和能力评估中最常见的图表。它其实是正多边形代码的变体

// 绘制数据填充区域
ctx.beginPath()
data.forEach((v, i) => {
  const angle = (i / sides) * 2 * Math.PI - Math.PI / 2
  // 根据数据百分比计算当前点距离圆心的实际半径
  const radius = (v / 100) * r 
  const x = radius * Math.cos(angle)
  const y = radius * Math.sin(angle)
  if (i === 0) ctx.moveTo(x, y)
  else ctx.lineTo(x, y)
})
ctx.closePath()
// 半透明填充
ctx.fillStyle = 'rgba(171, 71, 188, 0.25)'
ctx.fill()

3.4 堆叠面积图 (Stacked Area Chart)

面积图是在折线图的基础上填充下方区域。堆叠面积图的难点在于第二组数据的值需要累加在第一组数据之上

// 第二层面积(叠加)
ctx.beginPath()
ctx.moveTo(20, chartTop + chartH)
d2.forEach((v, i) => {
  // 核心:dv 是两组数据的总和
  const dv = d1[i] + v 
  ctx.lineTo(20 + i * stepX, toY(dv))
})
ctx.lineTo(20 + (points - 1) * stepX, chartTop + chartH)
ctx.closePath()


四、运行图片展示

在这里插入图片描述
在这里插入图片描述

四、 图表类型与适用场景对比汇总

为了帮助大家在实际业务中更好地选择图表类型,特整理如下表格:

图表类型 (Chart Type) 代码实现核心 API 视觉表现与难点 适用业务场景
柱状图 (Bar) roundRect, createLinearGradient 矩形高度映射,底部对齐,渐变渲染效果好。 各组别数据大小对比(如:各月销售额)。
折线图 (Line) lineTo, arc (画数据点) 需计算坐标系步长(stepX),通过 moveTolineTo 连接。 表现数据随时间的变化趋势(如:股票走势)。
饼图 (Pie) arc, 角度累加器 需先 reduce 求和,然后按比例将 2 π 2\pi 2π 划分成不同扇形角度。 展现各部分占整体的比例(如:用户终端占比)。
雷达图 (Radar) lineTo + 极坐标转换数学公式 背景需绘制多层同心多边形,数据点通过极坐标按比例计算。 多维度能力指标评估(如:游戏人物五维图、绩效考核)。
面积图 (Area) lineTo, fill() 闭合路径 折线图向下闭合路径并填充透明度颜色;堆叠需累加 Y 值。 强调数量随时间演变趋势的同时,突出累积量。

五、 ArkTS Canvas 核心 API 速查表

在源码中,我们大量使用了图形绘制的底层 API。为了巩固知识,整理核心方法说明表:

方法/属性名 核心作用与说明 最佳实践/注意事项
save() / restore() 保存/恢复当前的绘图状态(如旋转角度、原点位置、当前颜色配置)。 必考点。改变坐标系前必须 save,画完必须 restore,否则后续所有图形都会乱套。
translate(x, y) 移动画布的原点(0, 0)至指定坐标。 画复杂图形前将原点移至图形中心,可极大简化坐标数学计算。
beginPath() 开启一条全新的路径。 每次画独立的线/多边形前必须调用,否则 stroke() 会把之前的线重新画一遍,导致严重卡顿。
closePath() 闭合路径(自动将终点与起点连线)。 适用于画封闭多边形,不仅视觉闭合,还能防止线段交接处出现缺口(LineJoin)。
bezierCurveTo 绘制三次贝塞尔曲线。 用于需要完美弧度平滑过渡的场景(如花瓣、水滴、拟态 UI)。
createRadialGradient 创建径向渐变(从内向外发散)。 源码中的“渐变圆环”即使用了此 API。需配合 addColorStop 使用。

六、 进阶与性能优化建议 (Bonus)

当你在生产环境中大规模手撸 Canvas 图表时,可能会遇到性能瓶颈(尤其是在低端设备或刷新率要求极高的动画场景中)。以下是给中高级开发者的优化建议:

  1. 避免在 render/draw 循环中做对象分配:
    animate 或者 setInterval 回调中,尽量避免反复 new 新的对象或反复定义庞大的数组。应当把变量提为类属性(复用对象内存),减少 GC(垃圾回收)带来的瞬时卡顿。
  2. 离屏渲染 (Offscreen Canvas):
    如果背景网格(如雷达图的网格背景、折线图的 XY 轴与坐标线)是不变的,只有数据在动,应该用一个隐藏的离屏 Canvas 预先画好背景,然后在主动画循环中利用 drawImage 直接把背景贴上去。这能节省 50% 以上的绘制开销。
  3. 减少浮点运算:
    Canvas 渲染时,坐标如果带有过多的小数位,底层会触发次像素渲染(Sub-pixel rendering)从而消耗更多 CPU。对于不需要极致精度的坐标,可以用 Math.floor() 或位运算 | 0 进行取整处理。
  4. 提防 save/restore 滥用:
    虽然 saverestore 非常好用,但它们涉及到状态栈的压栈出栈操作。如果只是改一下颜色(fillStyle),直接覆盖赋值即可,没必要 save/restore。只在涉及矩阵变换(translate, rotate, scale)时才使用它们。

总结

通过对这段 ArkTS 代码的拆解,我们不仅复习了三角函数、极坐标等数学知识在图形学上的应用,还完整走通了企业级图表可视化的底层渲染逻辑。

Canvas 就像是一张白纸和一盒水彩笔,框架为你提供的是笔触的大小、颜色的调配,而最终能画出什么惊艳的作品,完全取决于开发者对数学的理解与对代码架构的把控力。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐