1. 实例拆解:分段函数绘制的核心思路
1.1 这个实例到底在解决什么问题
很多做鸿蒙应用开发的朋友,入门的时候接触的往往都是界面跳转、列表展示、数据持久化这类常规操作。但真正到了做工具类应用、教育类应用或者图形处理相关功能时,会遇到一个绕不开的需求——在屏幕上把数学函数、数据曲线、离散点图给画出来。
分段函数绘制就是这一类需求的典型代表。它不是画一条简单的直线或者圆,而是要把一个由多个不同表达式拼接而成的函数,在同一个坐标系里准确呈现出来。举例来说,一个三段式的分段函数:
- 当 x < 0 时,f(x) = -x
- 当 0 ≤ x < 1 时,f(x) = 1
- 当 x ≥ 1 时,f(x) = 2x - 1
这种函数在高中物理、数学的很多场景里都会出现,比如阶跃函数、绝对值函数、取整函数,本质上都是分段函数。在HarmonyOS应用里把这个功能做出来,一方面需要处理数学层面的逻辑——区间判定、断点处理、值域计算;另一方面需要处理图形绘制层面的逻辑——坐标变换、像素映射、路径生成。两者结合,才是一个完整可用的绘图能力。
这个实例适合谁看?我建议三类朋友重点关注:第一,正在做鸿蒙教育类App、需要绘制数学曲线或者物理运动轨迹的开发者;第二,对Canvas组件有一定了解、但没系统做过坐标系处理和图形变换的新手,可以通过这个实例把Canvas的底层机制吃透;第三,想了解HarmonyOS应用如何用ArkTS语言做图形渲染的进阶学习者。
1.2 为什么选Canvas而不是其他方案
在HarmonyOS里画图,方案其实有好几种。如果只是展示一张静态图片,直接用Image组件加载资源文件就行;如果只画一个简单的进度条,使用Progress组件或者SwiperIndicator这类现成组件就能搞定。但分段函数绘制这类需求,既要求数据驱动、又要求交互响应,还得处理复杂的坐标变换,最合适的方案就是Canvas。
有人可能会问,用Web组件加载一个网页,然后用网页里的Canvas或者SVG去绘制,不是更简单吗?确实,Web组件可以做到,但带来的问题也很明显——要通过JavaScript桥接层和ArkTS进行数据通信,绘制性能受WebView渲染引擎限制,而且资源占用明显更高。对于需要频繁刷新、甚至后续可能做触摸交互的图形应用来说,原生Canvas才是更可靠的选择。
HarmonyOS的Canvas组件底层基于渲染引擎,通过CanvasRenderingContext2D对象提供的API来绘制内容。这套API的命名习惯和Web Canvas高度相似——beginPath、moveTo、lineTo、stroke这些方法,做过Web前端开发的人几乎零成本上手。但有一个关键区别要注意:HarmonyOS的CanvasRenderingContext2D在设置线条样式、填充样式等属性时,用的是内置的枚举值或者特定的字符串格式,和Web的CSS属性写法不完全一样。比如设置颜色,Web里是fillStyle = "red",HarmonyOS里则是fillStyle = "#FF0000"这种颜色字符串,格式上更严格,必须用#AARRGGBB八位十六进制,前两位是Alpha通道。
1.3 坐标系设计和数据模型怎么定
这是整个实例最核心的设计决策。屏幕坐标系的原点在左上角,x轴向右、y轴向下,而数学坐标系的原点通常在画布中心,y轴向上。如果不做变换直接绘制,函数图像会被上下翻转,且位置完全不对。
我的做法分成三层来拆解:
第一层是数学坐标系,用真实的x值和y值,比如x范围从-10到10,y值由函数表达式计算得出。这一层的数据不需要关心屏幕上某个像素点到底在哪。
第二层是画布坐标系,也就是Canvas的实际像素坐标,需要把数学坐标转换成屏幕上对应的位置。转换公式是:
canvasX = originX + x * scale canvasY = originY - y * scaleoriginX和originY是原点在画布上的位置,scale是每一单位长度对应的像素数。因为屏幕y轴向下,数学y轴向上,所以这里是originY - y * scale而不是加。
第三层是视图坐标系,也就是Canvas组件实际在页面上的布局位置和尺寸。Canvas组件放置的位置会决定绘图区域的左上角坐标,这一层和触摸事件的坐标处理直接相关。
至于数据模型,我定义了一个分段函数配置的接口,每条分段包含三部分信息:表达式类型、区间的左边界和右边界、以及区间内的不连续标记。表达式用字符串传入,代码里再做一个轻量的解析器来求值。之所以不直接写死函数体,是为了后面扩展——用户可以自己输入分段表达式,动态生成图像,这也是后续版本可以做的一个亮点功能。
2. 开发前的环境准备与工程初始化
2.1 DevEco Studio创建工程时的配置要点
关于环境配置,我用的是DevEco Studio 4.0以上版本,配合HarmonyOS SDK API 9。如果你用的是API 8或者更早的版本,Canvas的部分API可能不完整,建议至少升级到API 9。
创建工程时选“Empty Ability”模板,在Project Type那里选“Application”,Compatible SDK选一个合理的版本。我测试时用的是HarmonyOS 4.2真机,API版本是12,所以我的Compatible SDK配置为"4.0.0(10)",但为了保证兼容性,最小兼容版本我设置得比较低,这样老设备也能跑。这里有个经验:在build-profile.json5里配置兼容版本时,不要追求太高,否则老设备装不上;也不要太低,否则新API用不了。建议以你的测试真机系统版本为基准,向下兼容一两个版本就够用。
工程初始化完成后,主要涉及三个文件:
index.ets:主页面,负责布局和交互逻辑CanvasDraw.ets(自定义组件):封装绘图的核心逻辑FunctionParser.ets(工具类):负责解析分段函数表达式
这种分层方式的好处是,绘图逻辑和业务页面解耦,后面如果要做多个函数叠加绘制,或者把绘图能力复用到其他界面,直接调用自定义组件就行。
2.2 页面布局如何规划
页面布局我采用了上中下三层结构:顶部是标题和控制按钮,中间是绘图画布,底部是当前分段函数的表达式展示区域。
中间的绘图区域是关键。我用了Canvas组件,设置width为"100%",height为420vp,然后用onReady回调获取组件的实际宽高——这一步很重要,因为Canvas绘制时要用到真实像素值,而不是vp单位的布局尺寸。这里踩过一个坑:如果直接写死Canvas的宽高值,在屏幕密度不同的真机上会显示异常——文字大小和线条粗细看起来不一致。所以正确做法是在onReady里通过this.canvasWidth = detail.width拿实际宽度,后续所有绘制都基于这个值。
底部区域放了一个Text组件,显示当前分段函数的表达式文本。因为HarmonyOS的Text组件支持\n换行,而$$无法直接渲染成数学公式,所以我直接用纯文本展示,比如写"f(x)= -x, x<0"这种形式,可读性没问题。
2.3 工程里需要关注的权限声明
因为这个实例主要是本地图形绘制,不涉及网络请求、位置信息等敏感权限,所以module.json5里基本不需要额外声明权限。不过如果你决定把“绘制结果保存到相册”这个功能加上,那就要在module.json5里申请ohos.permission.WRITE_IMAGEVIDEO或者使用safe picker的方式让用户主动授权。按照我的经验,能用safe picker就用safe picker,它在HarmonyOS里是一个系统级的文件访问框,用户主动选择保存位置,应用不需要申请任何存储权限,隐私合规上也省心很多。真机调试时用hdb连接,也不需要在工程里额外配权限,那是开发者模式的范畴,和应用的运行权限不冲突。
3. 核心实现:Canvas绘制分段函数的完整过程
3.1 坐标轴与网格线的绘制细节
绘图的第一步不是画函数曲线,而是把参考系搭好,否则图出来了人眼也没法判断值的大小。
坐标轴的画法比较直接:从画布左边缘画一条水平线到右边缘,再从画布底边缘画一条垂直线到顶边缘。但要注意的是,坐标轴的线宽和颜色要控制好,太粗会遮挡后面的函数曲线,太细在深色页面上看不清。我给坐标轴设置了2vp的线宽,颜色用#FF333333;而网格线用1vp,颜色用#FFE0E0E0,这样主次分明。
网格线生成时,需要根据scale值计算合适的步长。这里有一个细节要说明:如果固定把每一格定为1个单位,当scale是100px时,一格的宽度是100px,看着还行;但如果你把scale放大到200px甚至更大时,网格就变得稀疏,数格子不方便;反过来scale很小的时候,网格又密得糊成一片。所以我写了一个自动计算步长的函数:根据画布宽度和当前缩放级别,先估算出大概需要15到20列网格,然后取一个整数作为步长。
代码逻辑是这样:
function calcStep(pixelSpan: number, targetCount: number): number { let rawStep = pixelSpan / targetCount; // 将步长归到 1、2、5、10 这类整数上 let power = Math.pow(10, Math.floor(Math.log(rawStep) / Math.LN10)); let fraction = rawStep / power; let niceFraction; if (fraction < 1.5) { niceFraction = 1; } else if (fraction < 3) { niceFraction = 2; } else if (fraction < 7) { niceFraction = 5; } else { niceFraction = 10; } return niceFraction * power; }这样不管画布多大、缩放多少倍,网格线的密度都会保持在一个舒适的范围内。x轴和y轴的网格线分别生成,先用浅色画网格,再画坐标轴,最后在原点的位置加上一个小的空心圆作为标记。
3.2 分段函数定义与表达式求值
分段函数的定义,我设计成一个table数组,每行存一段区间配置:
interface SegmentConfig { expression: string; // 表达式描述,如 "-x", "1", "2*x-1" start: number; // 区间左边界(含) end: number; // 区间右边界(含) startInclusive: boolean; endInclusive: boolean; discontinuity: boolean; // 该段右端点是否与下一段断开 }这个设计里startInclusive和endInclusive用来描述开闭区间,分段函数中经常出现“某段包含端点,下一段不包含端点”的情况。比如上面的例子,第二段0 ≤ x < 1,左闭右开;第三段x ≥ 1,左闭。如果不区分开闭,直接把区间写成start <= x <= end,那么x=1这个点会被两段同时命中,绘制时就会出现重叠的毛刺。
表达式求值,我没有引入第三方数学库,自己写了一个简易的解析器。支持的操作符包括:+、-、*、/、^(幂运算),以及常用的数学函数sin、cos、tan、sqrt、abs、log。因为这些操作符优先级不同,为了不引入太复杂的语法分析,我采用了一个比较取巧的办法——先做字符串预处理,把所有函数名统一替换成对应的JS内置函数,然后利用Math对象求值。
我用了一个比较安全的做法:
function evaluateExpression(expr: string, x: number): number { // 替换 x 为具体数值 let prepared = expr.replace(/x/gi, `(${x})`); // 替换函数名 prepared = prepared.replace(/sin/g, "Math.sin"); prepared = prepared.replace(/cos/g, "Math.cos"); prepared = prepared.replace(/sqrt/g, "Math.sqrt"); prepared = prepared.replace(/abs/g, "Math.abs"); // 处理幂运算:将 ^ 替换为 ** prepared = prepared.replace(/\^/g, "**"); try { return Function(`"use strict"; return (${prepared});`)(); } catch (e) { return NaN; } }需要说明的是,上面的写法是一种便于理解的思路展示,实际HarmonyOS应用不允许使用Function构造器动态执行代码(会有安全策略限制),所以我在正式工程里做的是自己实现的递归下降解析器。如果你在真机上跑的时候发现Function被拦截,不要慌,换用解析器方案即可。
3.3 采样策略与断点处理
函数曲线的绘制,本质上就是用无数个短线段逼近真实曲线。理论上采样点越多越精确,但点多到一定数量后性能会下降;采样点太少,曲线会呈现明显的折线感,像锯齿一样。我采用的采样策略是:在默认状态下,每0.02个单位长度取一个采样点,也就是在x范围为-10到10时,一共采集约1000个点。这个精度画一般的多项式函数已经足够平滑了,绘制的性能在真机上也不会有压力。
但采样策略的核心不在于多少个点,而在于分段断点的处理。分段函数在断点处往往是不连续的,比如第一段f(x) = -x在x=0时值为0,但第二段f(x) = 1在x=0时值为1,图像在x=0处有一条从0跳到1的竖线间隙。如果我直接用线性数组把所有点连接起来,那么画出来的会是一条从(0,0)到(0,1)的垂直线,这在数学上是错误的——这个点根本没有定义,函数图像应该在这里断开。
解决办法是在遍历采样点的时候增加一个连续性判断。具体逻辑是:如果当前点所在的分段编号和上一个点不同,或者当前点是一个区间边界但不属于该区间的包含范围,就断开路径——调用moveTo而不是lineTo。这里有一组关键代码逻辑:
let penDown = false; for (let i = 0; i < sampleCount; i++) { let x = minX + i * step; let result = findSegmentAndEvaluate(x); if (result === null) { penDown = false; continue; } let canvasX = originX + x * scale; let canvasY = originY - result.y * scale; if (!penDown) { ctx.moveTo(canvasX, canvasY); penDown = true; } else { ctx.lineTo(canvasX, canvasY); } } ctx.stroke();findSegmentAndEvaluate函数遍历每一个分段配置,判断x落在哪个区间,然后根据startInclusive和endInclusive决定是否命中。如果x落在所有区间的缝隙里,比如两个区间的间隔区域,函数返回null,绘制路径断开。
这个断点处理是整个实例里最容易忽略、但也是最显示功力的地方。很多初学者的实现,图看着差不多,但放大看细节就露馅了——该断的地方连起来了,该实心的端点是空心。处理好这些细节,图形才是真正“数学上正确”的绘制。
3.4 动态调整与重绘机制
为了让示例更实用,我在页面底部加了一个slider滑动条,用来动态缩放视图。滑动范围从0.5到3.0,默认值是1.0,滑动的数值实时换算成scale倍数。这样用户可以对分段函数进行局部放大,观察断点处的细节。
Canvas的重绘机制这里有一个重要概念——CanvasRenderingContext2D的绘制不会自动清空,如果你直接调用绘制函数,新的图形会叠加在旧的图形上。所以在每次重新绘制前,必须先调用ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)清空整块画布,然后依次重绘网格、坐标轴、函数曲线、标注文本。这套“清空-重绘”的流程在HarmonyOS里没有diff优化机制,但因为绘图量不大,真机上的性能是跑得满的。
此外还实现了一个刷新按钮,点击后随机生成一组新的分段函数配置并重绘。这是为了演示动态数据驱动绘图的流程——真实业务场景中,函数表达式可能来自用户输入、文件读取或者后台下发,数据的来源变化不能影响绘图的稳定性。
4. 真机调试与常见问题排查
4.1 使用hdb进行无线调试的完整流程
这个实例在DevEco Studio自带的Previewer里预览,Canvas绘制效果其实和真机有差异,尤其是线条的清晰度、字体渲染、坐标点击的位置偏差。所以强烈建议直接上真机调试。HarmonyOS 4.2开始,无线调试功能已经比较成熟了,整个过程不需要插USB线,方便很多。
开启无线调试的步骤,我按自己实际操作过的流程整理一下:
- 手机上进入“设置 - 系统和更新 - 开发人员选项”,打开“无线调试”开关。此时手机会显示一个ip地址和端口号,格式类似
192.168.1.100:5555。 - 命令行使用hdb工具连接:
hdb tconn 192.168.1.100:5555连接成功后命令行会提示connected。如果不成功,大概率是手机和电脑不在同一个局域网内,或者端口被占用。 3. 在DevEco Studio中,点击Run按钮会自动发现已连接的设备。如果下拉列表里没出现,可以手动在终端执行hdb list确认连接状态。
需要注意,手机上的无线调试端口可能会在重新开关开发者选项后变化,所以每次真机调试前,最好重新确认端口号,不要一直用老地址去连,否则会浪费时间在排查“为什么连不上”上。
无线调试还有一个细节:电脑和手机都要保持在同一Wi-Fi网络下,且网络不能开启AP隔离。公司办公网络如果开了访客隔离,可能出现设备扫描不到的情况,这时候要么换热点,要么老老实实USB连接。
4.2 常见的绘制问题排查
分段函数绘制这个项目虽然不复杂,但实际开发中我至少遇到四类问题,这里逐个说一下排查思路。
第一类是画面模糊。在真机上Canvas绘制的线条偶尔会有边缘发虚的情况,原因通常是把Canvas当成普通组件设置了宽高,但组件实际渲染时的物理像素和逻辑像素转换有偏差。解决办法是,在onReady回调里获取实际像素宽度,然后按像素值去计算坐标系,不要用vp的预设值去估算。另外,绘制时设置ctx.lineWidth为1.5vp或者2vp,模糊感会明显降低。个人经验是1vp的线在多数屏幕上容易发虚,2vp观感最好。
第二类是函数曲线超出画布范围。当用户输入的函数值很大,比如f(x) = x^2在x=10时y=100,换算成像素后会跑到画布外面。我的处理方法是,在绘制循环里加一个越界判断,如果canvasY超出画布高度,就断开路径,不做绘制;如果持续越界,就不改变penDown状态,否则会在边界线处画出不必要的连接线。
第三类是断点显示不正确。这个问题的根源几乎都在区间开闭判断上。我调试时发现,某一段的end和下一段的start数值相等时,比如都是1,如果两段都把1当作包含端点,那么在x=1处会出现两个点,曲线会在同一个x位置画两个不同的y值,形成一条竖线。解决方法是严格按照数学定义配置开闭区间。我还写了一个单元测试,把几个典型的边界值(比如x=0、x=1)传入findSegmentAndEvaluate,验证返回的分段编号是否符合预期,实测这个方法非常有效。
第四类是性能卡顿。在采样点很多(比如超过5000个)时,或者页面同时做了动画刷新时,Canvas绘制会出现掉帧。排查时先确定瓶颈在哪:是循环计算量大,还是绘制调用次数多。我的优化思路是:
- 减少无效采样:在函数值变化很小的平直区域,可以跳过多余的采样点,每5个点取1个。
- 合并绘制指令:一段连续曲线用一条路径绘制,不要每画一个点就
stroke一次。把所有moveTo、lineTo都放在同一个beginPath和stroke之间,绘制效率会有数量级的提升。 - 避免在绘制循环里做复杂的字符串操作和对象创建,避免触发垃圾回收抖动。
4.3 一个容易被忽视的Canvas细节:字体与文本对齐
绘制函数曲线后,通常还要在图像上标注分段表达式或者坐标轴刻度数字。这里有个小坑——Canvas上的文本绘制和Text组件的文本渲染不完全一样。Canvas的fillText方法需要手动设置font属性,而且文本对齐方式通过textAlign控制,可选的枚举值是left、right、center、start、end,和Web的CSS TextAlign保持一致。实际开发中,如果坐标轴刻度文字的位置总是差几个像素,往往是textAlign和textBaseline没有配合好。
我的做法是:x轴刻度文字统一用textAlign = 'center',textBaseline = 'top',这样文字显示在刻度点的正上方;y轴刻度文字统一用textAlign = 'right',textBaseline = 'middle',让文字在刻度点左侧居中。这样可以基本保证刻度和文字位置的视觉对齐。
4.4 后续功能扩展的思路
这个实例做完之后,我心里其实列了几个扩展方向,分享出来给感兴趣的朋友参考。
第一是支持触摸交互。目前只是自动绘制和滑动条缩放,如果加上触摸拖拽平移坐标系,就能让用户在观察函数时自由调整视角,这在教育类App里是刚需。实现思路是在Canvas组件上挂载onTouch事件,把触摸的坐标和当前的坐标系变换关联起来,检测到拖拽手势时修改originX和originY,然后触发重绘。
第二是支持多函数叠加。把数据模型从单一的分段函数改成函数数组,每条曲线用不同的颜色和样式绘制,配合图例组件展示每条函数对应的表达式。这个功能对可视化对比两个函数的大小关系非常有用。
第三是表达式输入。目前表达式是代码里预置的,如果要让用户自行输入,就需要一个安全、可靠的表达式解析器。我之前提到的递归下降解析器是一个方向,另外也可以考虑把常用函数封装成模板按钮,用户通过点击拼接表达式,降低解析的复杂度。
我实际测试下来,在这些扩展里,坐标系交互和触摸处理的耦合度最高,也是后面所有功能的基石。如果你打算继续往下做,建议先把坐标变换相关的方法独立成一个类,专门维护数学坐标和画布坐标的转换关系,这样无论增加触摸平移还是缩放,都能在一个地方修改,不会到处打补丁。
最后分享一个我写绘图代码的习惯:每加一个新功能前,先用简单数据验证绘图逻辑是否正确,比如画一条直线y=x,再画一个常数函数y=2,确认坐标系方向和颜色都没有问题后,再往里面填分段函数逻辑。这样可以避免“最后一步才发现是坐标方向错了”这种返工情况。做图形开发,耐心和细节修正往往比写代码本身更重要。