【OpenHarmony/HarmonyOs 】函数图像绘制实践:ArkTS 表达式解析与 Canvas 曲线采样

项目类型:OpenHarmony / HarmonyOS ArkTS 数学学习应用
项目名称:数学视界
对应主题:端侧 AI、全新视觉与交互体验、禁止 AI 识图
关键词:函数图像、Canvas、表达式解析、端侧计算、ArkTS、数学可视化 📈

一、为什么函数图像适合写成一篇独立文章?

在数学学习 App 中,函数图像是一个非常典型的“端侧智能”场景。它不需要拍照、不需要 AI 识图、不需要上传数据,只要用户输入函数表达式,应用就可以在本地完成解析、采样和绘制。

数学视界的 CanvasBoard.ets 中已经支持函数图像绘制:

  • 用户输入表达式,比如 sin(x)x^2sqrt(x)
  • 程序把表达式转换为可计算形式;
  • 在当前坐标范围内对 x 进行采样;
  • 计算每个采样点的 y;
  • 把数学坐标转换成 Canvas 坐标;
  • 连成平滑曲线。

这篇文章就围绕这个流程展开,重点写函数图像如何在 ArkTS 中本地绘制。

二、函数图像的数据结构

画板中函数图像和几何模型分开存储:

@State functionGraphs: FuncGraph[] = []
@State functionInput: string = ''
@State graphRange: DrawGraphRange = {
  xMin: -10,
  xMax: 10,
  yMin: -10,
  yMax: 10
}

这样设计有两个好处:

  1. 函数图像可以和圆、椭圆、双曲线同时存在;
  2. 函数表达式是结构化数据,可以收藏、重绘、分享。

对于数学学习场景来说,保存表达式比保存截图更有意义。截图只能看,表达式可以继续编辑。

三、绘制入口:遍历所有函数图像

画板中有一个统一绘制入口:

drawAllFunctionGraphs(ctx: CanvasRenderingContext2D): void {
  for (let i: number = 0; i < this.functionGraphs.length; i++) {
    const graph: FuncGraph = this.functionGraphs[i]
    this.drawSingleFunction(
      ctx,
      graph.expr,
      graph.color,
      graph.label,
      graph.lineWidth ?? 2
    )
  }
}

这里可以看到,每条函数图像至少需要:

  • expr:函数表达式;
  • color:曲线颜色;
  • label:图例标签;
  • lineWidth:线宽。

这就让多个函数同屏对比成为可能,比如:

  • y = x
  • y = x^2
  • y = sin(x)
  • y = log(x)

学生可以很直观地观察不同函数的形状差异。

四、核心绘制逻辑:采样 x,计算 y,再连线

单条函数图像的绘制逻辑如下:

drawSingleFunction(
  ctx: CanvasRenderingContext2D,
  expr: string,
  color: string,
  label: string,
  lineWidth: number
): void {
  if (expr === '') return

  const exprLower: string = expr.replace(/\s+/g, '').toLowerCase()

  ctx.strokeStyle = color
  ctx.lineWidth = lineWidth
  ctx.beginPath()

  let started: boolean = false
  const step: number =
    (this.graphRange.xMax - this.graphRange.xMin) /
    this.canvasWidth * 0.5

  for (let mathX: number = this.graphRange.xMin; mathX <= this.graphRange.xMax; mathX += step) {
    const mathY: number = this.evaluateExpr(exprLower, mathX)
    if (isFinite(mathY)) {
      const pt: DrawPoint = this.mathToCanvas(mathX, mathY)
      if (!started) {
        ctx.moveTo(pt.x, pt.y)
        started = true
      } else {
        ctx.lineTo(pt.x, pt.y)
      }
    } else {
      started = false
    }
  }

  ctx.stroke()
}

这段代码的重点有三个:

  1. step 根据坐标范围和画布宽度动态计算;
  2. 每个 mathX 都调用 evaluateExpr() 得到 mathY
  3. 如果结果不是有限数,就断开曲线,避免把不连续点硬连起来。

例如 log(x)x <= 0 时没有实数结果,此时 isFinite(mathY) 会避免绘制错误线段。

五、表达式求值:把数学写法转换成 JS/ArkTS 可计算写法

用户输入的表达式通常是数学写法,比如:

x^2
sin(x)
sqrt(x)
ln(x)
abs(x)

程序需要把它转换成运行时能计算的形式:

evaluateExpr(expr: string, x: number): number {
  try {
    let e: string = expr.replace(/x/g, `(${x})`)
    e = e.replace(/\^/g, '**')
    e = e.replace(/pi/g, `${Math.PI}`)
    e = e.replace(/e(?![x])/g, `${Math.E}`)
    e = e.replace(/sin\(/g, `Math.sin(`)
    e = e.replace(/cos\(/g, `Math.cos(`)
    e = e.replace(/tan\(/g, `Math.tan(`)
    e = e.replace(/sqrt\(/g, `Math.sqrt(`)
    e = e.replace(/log\(/g, `Math.log10(`)
    e = e.replace(/ln\(/g, `Math.log(`)
    e = e.replace(/abs\(/g, `Math.abs(`)
    e = e.replace(/exp\(/g, `Math.exp(`)

    const fn: Function = new Function(`"use strict"; return (${e})`)
    return fn() as number
  } catch {
    return NaN
  }
}

这一段体现了函数绘制的核心思路:

  • x 替换成当前采样点;
  • ^ 替换成幂运算 **
  • sin/cos/tan 映射到 Math
  • log/ln/sqrt/abs/exp 映射到标准数学函数;
  • 计算失败则返回 NaN

注意:当前画板函数绘制使用 new Function 来快速验证表达式。项目中的科学计算器普通算术部分则手写了解析器,没有使用 new Function。如果未来要强化安全性,可以把画板表达式也改造成同一套白名单解析器。

六、为什么要在本地绘制,而不是 AI 识图?

函数学习有两种路线:

  • 拍照识别题目,再让 AI 画图;
  • 用户输入表达式,端侧直接绘图。

数学视界选择后者。原因很明确:

  • 🔐 不需要相机权限;
  • 🚫 不上传试卷图片;
  • ⚡ 本地计算,响应快;
  • 🧠 学生能理解表达式和图像之间的对应关系;
  • 📦 表达式可以收藏和复用。

对学习类应用来说,“自己输入,自己观察变化”比“拍照等答案”更有学习价值。

七、图例显示:让多函数对比更清晰

当函数有标签时,会绘制一个小图例:

if (label !== '') {
  ctx.fillStyle = color
  ctx.fillRect(10, 10, 20, 3)
  ctx.fillStyle = this.getColor('#333333', '#EEEEEE')
  ctx.font = '11px sans-serif'
  ctx.textAlign = 'left'
  ctx.fillText(label, 36, 16)
}

这个小细节很适合多函数对比。例如学生同时画:

  • y = x
  • y = 2x
  • y = x + 2

图例可以帮助他们理解斜率、截距变化对图像的影响。

八、深色模式下的可读性

函数图像不是普通 UI,它有坐标轴、网格、标签、曲线。如果只是简单把背景变黑,很容易出现曲线或文字看不清的问题。

项目中通过 getColor() 处理深浅色:

getColor(lightColor: string, darkColor: string): string {
  return this.isDarkMode ? darkColor : lightColor
}

绘制坐标轴时也会切换颜色:

ctx.strokeStyle = this.getColor('#333333', '#EEEEEE')
ctx.fillStyle = this.getColor('#555555', '#CCCCCC')

这样在深色背景下,坐标轴、刻度、标签仍然清晰。

九、可以继续优化的方向

当前函数图像绘制已经能满足基础学习需求,但还可以继续增强:

  1. 表达式解析改为安全白名单解析器;
  2. 支持隐式乘法,比如 2x 自动识别为 2*x
  3. 支持分段函数;
  4. 支持导数图像;
  5. 支持函数零点、极值点标注;
  6. 支持图像交点求解;
  7. 支持函数收藏后重新加载。

这些能力都不需要云端 AI,完全可以在端侧逐步实现。

十、总结

这篇文章围绕“函数图像绘制”展开,和另一个物理项目里的 Canvas 动画文章有明显区别。它更关注数学表达式、采样、坐标映射和本地计算。

核心实现包括:

  • 📈 用 functionGraphs 保存函数表达式;
  • 🧮 用 evaluateExpr() 把表达式转换成本地可计算结果;
  • 🧭 用 mathToCanvas() 将数学坐标映射到屏幕;
  • ✂️ 用 isFinite() 处理不连续点;
  • 🌙 用深色模式颜色映射保证坐标轴和标签可读;
  • 🔐 避免 AI 识图和图片上传,保护学习隐私。

数学学习应用的端侧能力,不一定非要接大模型。像函数图像绘制这种“输入表达式,立即生成可视化结果”的能力,本身就是非常实用的端侧智能。🚀

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐