做一个数学可视化功能时,最容易被低估的需求就是分段函数绘制。我刚接到这个功能时也觉得不难:不就是在画布上取点、连线吗?真正做下去才发现,分段函数不是“把一个普通函数在固定区间里截断”那么简单——定义域边界怎么处理,断点左右两边是否真的相连,函数值趋近无穷时怎么避免画出恐怖的竖线,屏幕坐标系方向和数学坐标系反过来又该怎么换算。这些细节堆在一起,哪怕只是一个非常简单的高中分段函数,也能把第一次实现的人折磨得够呛。
这个实例里的主体是 HarmonyOS 应用中的分段函数绘制,我用的方案是 ArkTS + ArkUI 自带的 Canvas 画布,没有引入任何第三方图表库。整个实现包括:分段函数的数据建模、世界坐标到屏幕坐标的变换、坐标轴与网格绘制、逐段采样连线、跳跃间断点处理,然后配合缩放和平移手势让用户能交互式查看图像。如果你正在做鸿蒙版课件、学习工具或者工程计算类的应用,这个实例的实现思路和代码骨架基本可以直接复用。
1. 函数曲线不是图表:这个需求为什么值得单独做一次设计
1.1 真实需求:不是画图,是让学生把变化规律看出来
我这次做分段函数,起因是一个课堂演示工具:老师输入一个分段函数,学生能直接在手机或平板上看到曲线形状,并且能手动放大某个局部,观察“分段点附近函数到底怎么变化”。
最开始我差点把“普通函数绘图”和“分段函数绘图”当成同一件事处理,后来发现两者最大的不同在于:普通函数往往定义域完整,遍历一遍 x 坐标基本没问题;分段函数则由若干段组成,每一段有自己的定义域,段与段之间可能有间隙、有跳跃、有单独成立的端点。如果只是把所有段拼在一起按连续函数去采样画线,结果多半会出现一条莫名其妙的斜线,比如函数在 x=0 处没有定义,前一秒曲线还在上方,后一秒直接掉到下方,中间被错误连起来。
所以最核心的设计矛盾不是“用什么曲线颜色”,而是“怎么让图像在数学上依然严谨”。既然面向的是课堂场景,任何一条不该出现的连线都会让学生产生误解,这点必须从数据建模阶段就开始规避。
1.2 普通函数绘制思路为什么套不上分段函数
如果是一个像 y = x² 这样的完整定义域函数,最简单的绘制思路是:确定可视范围的最小 x 和最大 x,从 minX 到 maxX 均匀取几百个点,每个点都计算 y,然后把屏幕坐标连起来。
这个思路对分段函数有两个致命问题:
第一,分段点不一定落在采样点上。假设采样步长是 0.1,而分段点在 0.37,那么没有任何一个采样点正好在分界处。曲线经过分界点时,两侧表达式已经切换了,但绘制代码浑然不知,仍然会把前一个采样点和后一个采样点直接连成直线,这样在分界点附近会出现误差极大的斜线,个别情况下甚至画出跨越整个屏幕的线段。
第二,分段函数可能在某个局部没有定义。例如一个函数只定义在 [-6, -1) 和 (1, 6],采样范围如果从 -6 一直扫到 6,中间 [-1,1] 这一段没有函数值。如果计算返回 NaN 或者某个约定值,而绘制代码没有显式处理,那些没有定义的区域也可能被连线覆盖。
这两点是“函数图像”区别于“折线图”的核心差异。普通折线图的数据点是真实存在的测量值或计算值,中间缺失区域本来就该留空;而分段函数里“这段没有定义”不是数据缺失,是数学表达式的天然结构。绘制逻辑必须明确知道每个定义域的起点和终点,而不是靠采样时偶然跳过某个值。
1.3 为什么不引入现成的图表库
HarmonyOS 生态里也有不少图表库,比如基于 Canvas 封装的统计图表组件,画折线图、柱状图、饼图都很方便。但我这次没有用,一个很重要的原因是:图表库面向的是“数据可视化”,它通常假设横轴是一组离散的类别或者等间隔的时间点,曲线只是把已有数据点连起来。分段函数则需要一个“随时计算任意 x 的函数值”的数学引擎,而且要能处理定义域、开闭区间、无穷大这些不属于常规图表数据模型的东西。
另一个原因是定制成本。课堂上可能要放大到某个分段点附近看极限行为,此时坐标轴刻度需要动态重算、网格线需要随缩放变化、曲线要重新按当前可视范围采样。把这些逻辑硬塞进通用图表库,很多时候比直接用 Canvas 重写一遍还麻烦。考虑下来,索性从 0 自己维护渲染管线,所有行为都可控。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把坐标系搬进 ArkTS:画一切函数的基础
2.1 为什么屏幕坐标系会和数学坐标系“打架”
所有刚接触 Canvas 绘制的人都会遇到这个问题:数学坐标系的原点通常在图形中间,y 轴向上为正;而屏幕坐标系的原点在左上角,x 轴向右为正,y 轴向下为正。
如果你直接把数学里的 (x, y) 当作 Canvas 的像素坐标画上去,画出来的图像要么上下颠倒,要么整体偏移到屏幕右上角。所以第一步不是写绘制代码,而是写一个负责坐标系转换的 Viewport 类。这个类本质上是维护“世界坐标系”到“屏幕坐标系”之间的映射关系,后续所有绘制都只跟世界坐标打交道,只有真正调用 Canvas API 时才转成屏幕坐标。
我在工程里是这样定义的 Viewport 核心内容:
- centerX、centerY:当前视口中心对应的世界坐标;
- pixelsPerUnit:每个世界单位对应多少个屏幕像素,相当于缩放比例;
- canvasWidth、canvasHeight:画布的宽高。
有了这三个要素,就能实现世界坐标和屏幕坐标的双向转换。双向转换非常重要,因为绘制网格线时需要把世界坐标的整数刻度变成屏幕坐标,而手势识别拿到的是屏幕坐标的拖动偏移,又需要反向换算成世界坐标的移动量。
2.2 Viewport 类的设计与转换实现
在 ArkTS 里,我建议把 Viewport 单独写成一个类,最好不要直接散落在页面组件里,因为后面缩放、平移、Canvas 重绘都要访问它。下面是我整理过的核心代码结构:
typescript复制export class Viewport {
centerX: number = 0;
centerY: number = 0;
pixelsPerUnit: number = 60;
canvasWidth: number = 0;
canvasHeight: number = 0;
worldToScreenX(worldX: number): number {
return this.canvasWidth / 2 + (worldX - this.centerX) * this.pixelsPerUnit;
}
worldToScreenY(worldY: number): number {
return this.canvasHeight / 2 - (worldY - this.centerY) * this.pixelsPerUnit;
}
screenToWorldX(screenX: number): number {
return this.centerX + (screenX - this.canvasWidth / 2) / this.pixelsPerUnit;
}
screenToWorldY(screenY: number): number {
return this.centerY - (screenY - this.canvasHeight / 2) / this.pixelsPerUnit;
}
get visibleWorldWidth(): number {
return this.canvasWidth / this.pixelsPerUnit;
}
get visibleWorldHeight(): number {
return this.canvasHeight / this.pixelsPerUnit;
}
}
worldToScreenY 方法中 y 方向做了减号处理,这是因为世界坐标 y 向上,屏幕坐标 y 向下,两个方向相反。如果把这里忘了,后续所有曲线都会呈现镜像效果。
中心点换算还有一个很实际的好处:后续做平移时,只需要修改 centerX 和 centerY,不需要修改已经生成好的曲线坐标数组;缩放时只需要修改 pixelsPerUnit,也就是每单位像素数。代码逻辑非常集中。
2.3 初始可视范围是怎么定下来的
写分段函数时,用户一般不太关心整条数轴,而是关心函数主要的形态变化。如果初始可视范围太大,曲线在屏幕中间变成一小条折线,看起来完全没有重点;如果初始范围太小,曲线又可能超出屏幕看不见。
我这里的处理是让调用方传入一个建议的初始世界范围,或者根据分段函数所有分段的定义域自动推导一个范围。比如分段定义域分别从 -6 到 -1、-1 到 1、1 到 6,那初始 x 范围就取 -7 到 7,再根据屏幕宽高比反推 y 范围,保证横向纵向等比显示。
等比显示的具体做法是:先确定世界坐标下想看到的宽度 targetWorldWidth,然后根据画布宽高比算世界高度:
code复制targetWorldHeight = targetWorldWidth * canvasHeight / canvasWidth;
接着在初始化时把 viewport 的 pixelsPerUnit 设置成 canvasWidth / targetWorldWidth,centerX 设为定义域中心,centerY 设为 0 附近。这样保证函数不会因为屏幕比例问题被压扁或拉长。
代码上可以抽一个 initializeViewport 方法,传入函数定义域和画布宽高后自动设置像素比例和中心点。后续用户通过手势缩放时,再动态调整这几个字段即可。
3. 分段函数建模:用数据结构把“数学规则”表达清楚
3.1 每个分段需要记录什么信息
画分段函数之前,先要回答一个问题:在程序里怎么表达一个分段函数?
常见做法是写一个大的 if-else,例如:
code复制if (x < -1) return x + 2;
else if (x < 1) return x * x;
else return 3 - x;
这种写法适合快速跑通,但不利于后续的“逐段绘制”“断点检测”“手势缩放后重采样”,因为你没法枚举函数由哪些段组成,也没法判断某个 x 属于哪一段。所以我选择把每个分段建模成一个 Segment 对象,而不是散落的 if 分支。
每个分段至少需要四个信息:
fromX:该分段定义域的左边界;toX:该分段定义域的右边界;includeLeft/includeRight:左边界和右边界是否包含在定义域内;expression:从 x 计算 y 的函数表达式。
includeLeft 和 includeRight 在数学上对应闭区间和开区间。比如 x < 1 这一段,includeRight 就是 false,表示 x=1 不属于这一段。如果不记录开闭信息,到绘制端点时就根本不知道 x=1 这个点能不能画实心点。
我的接口定义大概是:
typescript复制export interface PiecewiseSegment {
fromX: number;
toX: number;
includeLeft: boolean;
includeRight: boolean;
expression: (x: number) => number;
}
实际传参时可以给一个默认值,例如默认左闭右开,大部分教科书式的分段函数都可以用这种方式描述。
3.2 为什么不能用“枚举所有屏幕 x”的方式代替分段结构
有些人可能会觉得奇怪:反正绘制时是从屏幕左到屏幕右,逐像素取 x,那直接对每个屏幕 x 调用一个大的 if-else 不就能得到正确图像吗?
理论上确实可以,前提是 if-else 逻辑覆盖了所有分段,并且对定义域空隙返回了空值。但实践中这样有一个明显坏处:计算结构不可复用。比如用户想根据当前缩放级别只绘制某一小段,或者想列出函数有哪些不连续点,或者想把某一段单独高亮,都需要重新写一遍判断逻辑。
把分段抽象成数组,还有个额外优势:可以自动判断某个 x 落在哪个分段上,进而判断当前采样点是否跨过了分段边界。跨边界时就要抬笔,避免把两个互不相连的分段错误连接。
我实现了一个 PiecewiseFunction 类,内部保存排序后的分段数组,暴露两个方法:
valueAt(x: number): number:返回某个 x 对应的函数值;getSegments():返回所有分段列表,绘制层可以逐个分段单独绘制。
valueAt 内部会遍历分段,与边界做开闭判断。如果 x 没有落在任何分段内,返回 NaN,这是一个非常合适的“空值”标志。
3.3 一个可以直接用的 PiecewiseFunction 类
下面是我在实际工程里用的简化版本:
typescript复制export class PiecewiseFunction {
private segments: PiecewiseSegment[];
constructor(segments: PiecewiseSegment[]) {
this.segments = segments.slice().sort((a, b) => a.fromX - b.fromX);
}
valueAt(x: number): number {
for (let i = 0; i < this.segments.length; i++) {
const seg = this.segments[i];
const leftOk = x > seg.fromX || (x === seg.fromX && seg.includeLeft);
const rightOk = x < seg.toX || (x === seg.toX && seg.includeRight);
if (leftOk && rightOk) {
return seg.expression(x);
}
}
return Number.NaN;
}
getSegments(): PiecewiseSegment[] {
return this.segments;
}
}
构造时排序一次,保证后面逐段绘制时从左到右处理。valueAt 中先做一次线性扫描,虽然理论上有更高效的区间查找算法,但分段函数的分段数量通常很少,线性扫描最简单可靠。
使用示例:
typescript复制const piecewise = new PiecewiseFunction([
{ fromX: -6, toX: -1, includeLeft: true, includeRight: false, expression: x => x + 3 },
{ fromX: -1, toX: 1, includeLeft: true, includeRight: false, expression: x => -x * x + 3 },
{ fromX: 1, toX: 6, includeLeft: true, includeRight: true, expression: x => 2 / x }
]);
这里 x=1 被第二段的 includeRight=false 排除,同时被第三段的 includeLeft=true 包含,函数定义明确。如果你想让 x=1 处没有函数值,只需要把第三段的 includeLeft 改成 false,左侧端点就会空缺。
4. 绘制流程:坐标轴、网格与分段曲线的完整实现
4.1 Canvas 的准备与画布尺寸获取
ArkUI 里使用 Canvas 组件,需要先创建一个 CanvasRenderingContext2D 对象,在 build 方法中把它传给 Canvas。组件加载完成后才能准确拿到画布宽高,所以通常会在 onReady 回调里做初始化。
我在组件里大概是这个结构:
typescript复制private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private viewport: Viewport = new Viewport();
build() {
Column() {
Canvas(this.ctx)
.width('100%')
.height('100%')
.onReady(() => {
this.initCanvasSize();
this.drawScene();
})
}
}
initCanvasSize 里的具体处理是:从 this.ctx.width 和 this.ctx.height 读取画布的像素尺寸,然后把这些尺寸赋值给 viewport.canvasWidth 和 viewport.canvasHeight。如果 Canvas 组件所在的容器在首帧时还没有完成布局,onReady 时机可能拿到的宽高仍是 0,因此最好再配合 onAreaChange 兜底,当组件尺寸变化时重新刷新 viewport 并绘制。
宽高拿不准是 Canvas 开发中最常见的隐藏坑,不是绘制逻辑的问题,而是画布上下文自己还没准备好,就开始按 0 宽度计算所有坐标,自然什么都画不出来。
4.2 坐标轴和网格线的画法
坐标轴不是必须的,但做函数图像没有坐标轴基本等于没有参考系。我的绘制顺序是先画网格,再画坐标轴,最后画函数曲线。
画网格的关键是从当前可视范围算出“合适的世界坐标步长”。如果只是固定每隔 1 个单位画一条线,当缩放很小时网格会密到看不清,当缩放很大时网格又稀疏得没有参考价值。所以需要动态计算步长,这里我用了一个所谓“nice step”的思路:先根据可视世界宽度估算期望的网格线数量,然后取接近 1、2、5 倍十进制的步长。
核心计算逻辑:
typescript复制function niceWorldStep(worldWidth: number, targetGridLines: number = 8): number {
const roughStep = worldWidth / targetGridLines;
const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
const normalized = roughStep / magnitude;
let factor = 1;
if (normalized >= 7.5) {
factor = 10;
} else if (normalized >= 3.5) {
factor = 5;
} else if (normalized >= 1.5) {
factor = 2;
}
return factor * magnitude;
}
拿到步长后,从可视范围的最小位置向上取整,依次画竖线和横线。竖线代表世界坐标 x = 整数刻度;横线代表世界坐标 y = 整数刻度,但竖线和横线只有在屏幕可见范围内才需要画完整,减少不必要的 Canvas 绘制操作。
画坐标轴时,先计算世界坐标原点 (0,0) 对应的屏幕点,如果这个点在画布内,就在这个位置画两条垂直的轴线,并给箭头线留一点长度。坐标轴上通常还要标注刻度数字,这里可以使用 Canvas 的 fillText 方法,在刻度线旁边把世界坐标值写出来。字体大小建议控制在 10-12,避免遮挡太多曲线。
4.3 逐段生成路径:如何防止那条“幽灵斜线”
曲线绘制是整个实例最核心的部分。我的方案是:不是从全屏左到右一次性绘制,而是拿出分段函数的所有分段,逐段渲染。每一段都调用一次 beginPath,这样段与段天然断开,绝不会出现连续 Path 把两个区域错误连接的问题。
但逐段渲染并不是所有问题的终点。采样数量、采样点分布、函数值溢出画布或为 NaN 时怎么处理,都需要专门设计。我总结一下我的绘制流程。
首先,单段内采样时,按“当前可视范围内该段占多少个屏幕像素”来定步长。理想情况是每屏幕像素至少一个采样点,避免曲线看起来有明显折角;但采样点上限也需要控制,我一般设置 40 到 400 之间,具体由可视宽度和 pixelsPerUnit 决定。
然后,遍历该段的每个采样 x,调用 piecewise.valueAt 计算 y。若 y 是 NaN 或超出画布一定范围,就将画笔抬起,也就是不把当前点连到上一个点。这一步是为了处理两类问题:
- 函数值无穷大或非常大,如果不裁剪,一条从画面底部直接穿到顶部的竖线会毁掉整个视觉;
- 段内有局部无定义点,例如某些分段函数在某点挖掉一个洞。
最后,每一段绘制完成后再 stroke 一次。下面是我整理的代码思路:
typescript复制function drawPiecewiseFunction(ctx: CanvasRenderingContext2D, viewport: Viewport, pf: PiecewiseFunction) {
const segments = pf.getSegments();
const leftWorldX = viewport.screenToWorldX(0);
const rightWorldX = viewport.screenToWorldX(viewport.canvasWidth);
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const segStart = Math.max(seg.fromX, leftWorldX);
const segEnd = Math.min(seg.toX, rightWorldX);
if (segEnd <= segStart) {
continue;
}
const visibleWorldSpan = segEnd - segStart;
const pixelSpan = visibleWorldSpan * viewport.pixelsPerUnit;
const steps = Math.ceil(Math.min(pixelSpan, viewport.canvasWidth));
ctx.beginPath();
ctx.strokeStyle = '#1A73E8';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
let penDown = false;
for (let j = 0; j <= steps; j++) {
const t = j / steps;
const wx = segStart + visibleWorldSpan * t;
const wy = pf.valueAt(wx);
if (Number.isNaN(wy) || !Number.isFinite(wy)) {
penDown = false;
continue;
}
const sx = viewport.worldToScreenX(wx);
const sy = viewport.worldToScreenY(wy);
if (sy < -viewport.canvasHeight || sy > viewport.canvasHeight * 2) {
penDown = false;
continue;
}
if (!penDown) {
ctx.moveTo(sx, sy);
penDown = true;
} else {
ctx.lineTo(sx, sy);
}
}
ctx.stroke();
}
}
注意这里我用 visibleWorldSpan * viewport.pixelsPerUnit 来计算该段实际占据的像素宽度,再折算成采样步数,保证采样点数量随用户缩放自动变化。缩放越大,像素越多,采样点越密,曲线就越平滑。我没有让采样点固定为某个常量,因为固定常量在放大会出现明显折线,在缩小又会浪费算力。
如果你需要处理定义域端点是否画实心点、空心点,那是进一步细化的事。对绝大多数简单的课堂教学工具来说,曲线本身画对了,比端点符号更重要,空心点可以后续通过小圆形填充补充。
5. 缩放、平移与重绘:让分段函数真正可以被“观察”
5.1 手势绑定的思路:平移改变中心点,缩放改变像素比例
如果只是静态展示一张图,前几章的内容已经够用。但课堂场景下,学生通常想放大某个分段点,看看曲线是怎么趋近的;或者拖动画面,把超出初始范围的部分拖进来。这时候就必须加手势交互。
平移的核心是:手指在屏幕上移动了多少像素,世界坐标就要反向移动多少距离。假设手指向右拖动 30 像素,观察者会觉得图像跟着手指向右移动,等价于视口中心向左移动了 30 / pixelsPerUnit 个世界单位。
我通常给 Canvas 绑定 PanGesture,手势开始时记录当前视口中心点,手势更新时根据 offsetX 和 offsetY 重新计算 centerX 和 centerY,然后触发重绘。这样做的好处是锚定起始位置,不会因为累加误差出现漂移。
缩放的核心则简单得多:只需要把 pixelsPerUnit 乘以或除以一个缩放因子。PinchGesture 的 event.scale 通常会给出相对于手势开始时的比例。你可以在手势开始时保存初始的 pixelsPerUnit,在手势更新时把初始值乘以当前 scale,就能得到新的 pixelsPerUnit。
缩放的数学逻辑虽然简单,但有个交互细节需要注意:如果缩放中心固定为画布中心,用户双指捏合时,手指下方的那个点会随着缩放“滑走”,体验不够自然。更精细的实现需要去计算手势焦点的世界坐标,让缩放前后焦点对应的世界坐标保持在同一个屏幕位置,这需要额外做一点坐标换算。如果你的第一版只是演示用,可以先以画布中心缩放,后续再升级为焦点缩放。
5.2 交互后重绘:是不是每次都要重新采样
平移或缩放一旦发生,最直接的反应是重新调用 drawScene,把网格、坐标轴、曲线全部画一遍。
这里有人可能会担心性能,尤其分段曲线每次要重新计算几百次函数值,会不会卡顿。实际情况是,分段函数的分段数量通常很少,每个分段的表达式计算成本很低,几百次计算对现在的移动端 CPU 来说就是一瞬间的事情。真正影响性能的不是计算,而是 Canvas 重绘面积以及每次 stroke 的数量。
我这边采用了一种比较省事但也足够稳的优化策略:网格和曲线都全量重绘,但把绘制曲线时的采样上限控制在合理范围。前面步骤里 steps 的上限已经隐含限制在 viewport.canvasWidth,也就是该段的采样点最多等于画布像素宽度,不会出现缩放后一次计算几千个点的情况。
如果你的分段表达式非常复杂,比如内部包含高次积分或非线性拟合,建议把每段采样结果缓存成 Float32Array,只在缩放级别改变时重新采样;平移时直接用缓存坐标整体偏移绘制即可。我在目前实例里没有这么做,因为复杂度不值当。
5.3 一个实际遇到的性能问题:连续拖动导致画面闪烁
我在开发过程中遇到过一个很典型的交互问题:连续快速拖动画布时,画面出现闪烁和轻微延迟。排查下来发现,问题出在手势事件回调里执行了太多与 UI 无关的 this.drawScene(),且每次 drawScene 都会重新创建 Canvas 的渐变色、阴影等对象。
对于简单的函数图像,没有必要每帧都启用抗锯齿之外的高开销渲染特性。所以我建议:
- 不要在 drawScene 内部创建复杂的 CanvasGradient,使用纯色 stroke 即可;
- 线条宽度固定时,不需要每次重新设置 shadowBlur 等属性;
- 手势事件回调只更新数据视口,不直接做节点状态绑定,避免触发 ArkUI 的额外布局。
ArkUI 的 Canvas 并不是天然适合高频率重绘的动画框架,但分段函数这种低频交互场景,控制好这几个点通常不会有问题。
6. 真机验证阶段:从无线调试到实际出现的三个悬案
6.1 预览器不是终点,真机颜色和缩放都会变
先用 DevEco Studio 的 Previewer 预览时,分段函数图像基本能正确显示,坐标轴、平移缩放也都正常。但等到真机跑起来,我发现曲线显示模糊,线条颜色看起来和设计稿有些色差,字体也比预览器里小了不少。这个现象很典型,根因是不同设备屏幕的像素密度不同,而 Canvas 绘制上下文默认的坐标系与屏幕物理像素之间需要处理 density 适配。
如果你在 Canvas 上画的线宽是 1,在高密度屏上看起来会特别细,甚至可能出现发虚的情况。针对鸿蒙的 ArkUI Canvas,比较稳妥的做法是检查设备像素密度或者使用 vp 单位去理解画布坐标,而不是用物理像素理解。真机调试时不能只看预览器效果,至少要在一台高分屏设备上确认一次。
6.2 HarmonyOS 4.2 真机无线调试与 hdb 连接过程
调试 Canvas 问题时,我最常用到的是 hdb 工具。HarmonyOS 4.2 的开发者选项中有一个无线调试功能。开启后,手机会显示一个 IP 端口地址,我一般这样连接:
- 在手机上打开开发者选项,开启 USB 调试和无线调试;
- 确保电脑和手机连接在同一个局域网;
- 在电脑终端执行
hdb connect 192.168.x.x:port,这里的地址和端口以手机无线调试页面显示为准; - 连接成功后执行
hdb shell hilog,或者直接在 DevEco Studio 的 Log 窗口观察 Hilog 输出。
无线调试最大的价值是不用来回拔插数据线。我在调试 Canvas 宽高问题时,需要反复改代码、装应用、看日志,如果每次都要插线非常麻烦。无线调试配好以后,应用通过 DevEco Studio 部署到设备,我就能稳定查看 Canvas 相关日志。配置时如果连接不上,优先检查手机和电脑是否在同一网段,以及手机是否弹出了允许调试的授权框。
6.3 三个 Canvas 绘制悬案的定位与修复
我把这次实例开发中遇到的最有价值的三类问题记录下来,它们都不是特别复杂,但都很容易让人在绘制函数时浪费几个晚上。
第一个是曲线没有绘制出来。现象是坐标轴有,网格有,但曲线区域是空的。我通过 hdb 抓到的日志表明,初始化时 viewport.canvasWidth 仍是 0,导致绘制函数里的可视世界范围也为 0,采样循环直接跳过。修复方法是把曲线绘制放到 onReady 里执行,并且在 onAreaChange 中做二次重绘。只有拿到真实画布宽高后,才能开始采样。
第二个是分段点处出现了一条竖直的斜线。这个 bug 很经典,原因是所有分段的点都被收集到同一个 Path 里。从左侧分段最后一个采样点直接 lineTo 到右侧分段第一个采样点,中间没有任何抬笔操作。修复成“每段一个 beginPath”之后,这条斜线立刻消失。后来我又在代码里加了 penDown = false 的兜底逻辑,即使某个采样点异常,后续点也不会硬连。
第三个是画面出现汉字乱码,或者文本位置明显错位。原因是绘制刻度数字时,我没有考虑不同字体宽度,直接用固定横坐标写了 fillText。数字位数发生变化时,文字会和坐标轴重叠。通常的做法是先测量文本宽度,再根据对齐方式计算文本的左上角坐标。对于刻度文字,我一般会让它在刻度线下方居中对齐,这样 10 和 -10 的观感会比较统一。
这三个问题验证了一个观点:分段函数绘制的难点往往不是数学部分,而是“采样、路径、坐标、尺寸”这些工程细节有没有被认真处理。只要数据结构把定义域边界表达清楚,绘制逻辑再按段隔离,大部分诡异视觉效果都能迎刃而解。
最后说一个我在这个实例里实际养成的习惯:凡是画曲线类功能,都会把“要不要抬起画笔”画成一张小表格来梳理。函数值在定义域内但超出画布,抬笔;函数值不在定义域内,抬笔;碰到分段边界,抬笔。这个习惯帮我排掉了很多潜在问题,也让我后来再做其他数学函数绘制时,第一版成活率明显高了很多。
