开篇先说实话很多学生学实数这一章时对无理数在数轴上的位置始终有点玄乎。老师在黑板上画一个数轴标上0、1、2然后说√2大概在这个位置、π在这个位置但一个静态的示意图没法让学生反复拖动、比较、观察规律。我平时业余在做HarmonyOS应用某次和一个教数学的朋友聊起这个话题他随口说了句要是学生能在手机上自己输入一个数然后看它到底落在数轴哪里理解起来就快多了。于是就有了这个实战小项目一个运行在HarmonyOS设备上的实数数轴定位器。输入√2、3/4、π这种表达式应用立刻把它在数轴上可视化地定位出来还带刻度、标注和数值对比。这篇文章就把完整的设计思路、绘制算法、踩过的坑一并写出来适合正在学习HarmonyOS应用开发的朋友参考也适合想做教育类小工具的人互相交流。1. 这个应用到底要解决什么静态数轴图解决不了的问题1.1 学生卡住的地方其实不是计算我一开始以为学生学习实数数轴最关键的是能不能算出近似值后来发现并不是。初中生对于√2≈1.414这种结论背得很熟但他们真正缺乏的是空间感——这个数到底在原点和2之间的哪个位置1.414凭什么更靠近1.5而不是1.4更不要说π这种3.14159……和22/7这种分数之间的关系了。这种空间感的建立靠纸上的静态图很难做到。原因是数轴本身是连续的、可伸缩的而课本里的图只有固定几个刻度。学生没法自己放大去看√2和1.414之间的距离也没法同时把一个分数和一个无理数放在同一条轴上对比大小。实数数轴定位器最核心的价值就是把抽象的实数和数轴上的具体位置这条映射关系变成可交互、可反复验证的体验。用户输入什么数轴就实时响应什么这比任何静态图都直观。1.2 功能边界不是花架子聚焦定位这件事第一版我没有做太多功能只锁定了几件最核心的事输入一个实数表达式在数轴上显示它精确对应的位置预设一批常见的数比如√2、√3、π、2/3、-√2一键切换在信息区展示当前数的近似小数、平方值等辅助信息支持多个数同时标记方便比较大小数轴刻度能根据数值范围自动调整避免出现刻度挤成一团的尴尬。我刻意没有做的东西包括解方程、画函数图像、做复数运算。这些功能看起来更炫但会冲淡数轴定位这个核心场景。开发时最重要的是把一条数轴的绘制逻辑做扎实一旦这块稳定了其他功能都可以在这个基础上叠加。2. 从实数到像素这个应用所有的正确性都建立在一套映射公式上2.1 数轴本身就是一条一维坐标系在做这个应用之前我想先理清楚数轴的本质。数轴就是一个直线型的坐标系任何一个实数都能在这条直线上找到唯一对应的点。要在屏幕上把它画出来本质上就是做一次坐标变换x_pixel centerX realValue × pixelsPerUnit其中centerX是原点在画布上的像素横坐标pixelsPerUnit表示1个单位的实数对应多少个像素。比如我设定pixelsPerUnit 80那么1对应原点右移80像素-1对应原点左移80像素√2≈1.414就对应原点右移约113像素。这个公式是整个应用的地基。所有绘制逻辑——轴线、刻度、数字标签、定位标记——最后都要回到这个公式上来。所以我把这个映射封装成唯一的方法避免在代码里到处写手动的坐标计算/** * 实数坐标转画布像素坐标 * param realValue 目标实数 * returns 对应的像素横坐标 */ realToPixel(realValue: number): number { return this.centerX realValue * this.pixelsPerUnit; }2.2 动态刻度算法步长按1-2-5序列走数轴如果一直只显示[-5, 5]这种固定范围那输入1000的时候整条轴就只剩一条直线啥都看不到。所以必须根据当前输入的数动态决定显示范围和刻度密度。我的做法是先确定显示范围。假设置目标数为x显示半径R max(|x| × 1.2, 1)也就是说至少能显示±1的范围确保常规数值都有参照物。然后在这个范围内选择合适的刻度步长让刻度数保持在8~15个之间。这里用到了经典的1-2-5步长策略/** * 根据可视区间范围计算一个合适的刻度步长 */ niceStep(range: number, targetTicks: number): number { // 先根据期望刻度数得到一个粗略步长 const roughStep range / targetTicks; // 求出数量级比如 4.7 - 116 - 100.08 - 0.01 const magnitude Math.pow(10, Math.floor(Math.log10(roughStep))); // 归一化到 1~10 之间然后就近匹配到 1 / 2 / 5 / 10 const normalized roughStep / magnitude; let step: number; if (normalized 1) { step 1; } else if (normalized 2) { step 2; } else if (normalized 5) { step 5; } else { step 10; } return step * magnitude; }这一步做出来以后效果立竿见影。输入3/7≈0.428时数轴自动使用0.1的步长输入-92时自动切到10的步长。刻度标签不再重叠数轴也从死图变成了一个真正有生命力的工具。2.3 精度问题double够不够用写定位器的时候绕不开一个疑虑double类型能精确表示√2吗答案是否定的√2是无理数任何有限位数都无法精确表示。但实际上我们也不需要精确表示它教育场景里能看到7~10位小数已经绰绰有余。我在代码中统一使用64位浮点数存储显示时根据数值大小自动保留有效位数。比如定位π的时候信息面板显示3.14159265而不是一大串不友好的数字。这里有一个细节不要直接把double转字符串而是用toPrecision或toFixed配合数值大小判断。我实测下来显示6~8位有效数字在手机上阅读体验最好。3. ArkUI界面搭建输入区、数轴画布、预设操作区三分天下3.1 为什么选择Canvas而不是直接贴一张数轴图片这是开发之初选型时纠结过的一个问题。方案有两个一是用ArkUI的StackRow组件手动摆一条伪数轴二是用Canvas画。第一个方案里刻度标签可以用Text对齐实现看起来简单但一旦需要动态变化和频繁缩放重绘用组件拼装的方式会让UI树的复杂度爆炸而且做不了垂直参考线、圆点标记这种精细控制。所以最终选了Canvas组件配合CanvasRenderingContext2D绘制。ArkUI的Canvas可以把绘制指令一次性提交渲染性能有保障而且绘制代码完全可以集中在一个函数族里后续维护也方便。界面划分为三块顶部输入区TextInput 定位按钮中间画布区Canvas承载数轴及定位标记底部预设区一排常用数的快捷按钮 信息展示文本。3.2 状态管理一个State承载全部关键变量ArkUI的声明式框架最舒服的一点是状态变化后UI自动刷新。我用State维护了三个核心变量State inputValue: string ; State currentList: number[] []; State scaleLevel: number 1;每次定位操作就把解析出的数值push到currentList里之后重绘方法读取这个数组把每个标记点都画出来。这里有个好处支持多标记对比模式几乎不需要额外做架构上的调整只需要让currentList是一个数组而不是单一变量。3.3 绘制函数拆分一支笔五件事我把数轴的绘制拆成了五个函数每个函数只干一件事drawAxis画水平轴线、左右箭头、原点drawTicks根据刻度步长遍历并绘制刻度短线drawLabels在刻度下方绘制数字标签drawMarker在定位点绘制红色圆点与垂直参考线drawInfoPanel在角落绘制当前数值的近似信息。绘制入口统一是draw()方法内部依次调用上述函数。代码可读性好了很多排查问题也快——比如后来发现刻度标签和轴线重叠我只改了drawLabels函数完全不影响其他绘制逻辑。private draw(ctx: CanvasRenderingContext2D) { ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.drawAxis(ctx); this.drawTicks(ctx); this.drawLabels(ctx); this.drawMarkers(ctx); this.drawInfoPanel(ctx); }4. 表达式解析把√21这种字符串变成数轴上准确的坐标4.1 支持的输入格式比想象中要精心设计用户不可能每次都输入一个纯小数。既然定位的是实数那么输入形式必须贴近数学课上的习惯。所以我支持了这几种格式十进制小数-2.75、0.3333分数3/4、-5/2根式√2、√7含π的表达式π、2π、π/3简单的加减乘除组合√21、2√3-1。这里最需要注意的一点是容错要放在解析的第一位。TextInput里用户输入什么都有可能比如打错符号、输入√-4、或者输入3/0。解析失败时不能崩溃要给出明确的提示信息。4.2 解析器设计白名单校验 模式匹配我没有用完整的表达式解析器对于这个场景来说属于杀鸡用牛刀。我采用的是白名单校验 模式分派的方式parseExpression(raw: string): number | null { let expr raw.trim().toLowerCase().replace(/\s/g, ); // 白名单校验不是预期字符直接拒绝 if (!/^[0-9\-*/()^.√πsqrt]$/.test(expr)) { return null; } // 处理分数 if (expr.includes(/)) { const parts expr.split(/); if (parts.length ! 2) return null; const numerator parseFloat(parts[0]); const denominator parseFloat(parts[1]); if (denominator 0) return null; return numerator / denominator; } // 处理根式√2 或 sqrt(2) if (expr.startsWith(√)) { const radicand parseFloat(expr.slice(1)); return radicand 0 ? Math.sqrt(radicand) : null; } if (expr.startsWith(sqrt() expr.endsWith())) { const radicand parseFloat(expr.slice(5, -1)); return radicand 0 ? Math.sqrt(radicand) : null; } // 处理含π的表达式 if (expr.includes(π)) { const replaced expr.replace(/π/g, (${Math.PI})); return this.safeEval(replaced); } // 普通数值 return parseFloat(expr); }这里我刻意没有在最开始就使用Function动态求值而是先处理了分数和根式这两种最常用的情况。只有遇到带π的表达式时才把一个做字符白名单校验后的字符串交给safeEval去计算。这个safeEval内部基于Math对象查找进行计算不接受任何赋值、分号或危险关键字把风险控制在可接受范围内。4.3 多标记的标注排列避免文字重叠的土办法定位一个数很简单但连续定位三个数之后会出现新问题比如定位√2、1.414、1.5时三个标记点挨得很近标签文字全部叠在一起根本看不清谁是谁。我采用的解决方案很朴素但很有效对每个标记点先判断它与上一个标记点的像素距离如果小于30像素就把当前标记的标签往上偏移20像素如果再近就偏移到上半轴之外。借助数轴上方的空间把标注错开。效果不完美但足够保证教学场景中同时对比两三个数时的可读性。未来如果要做十个数以上的批量对比就得引入更完备的标签避让算法但那是另一个项目的故事了。5. 真机实测后修掉的问题每一个都值得记录5.1 画布整体发虚和线条毛刺根因是设备像素比没对齐第一版在模拟器里看起来很正常一上真机就露馅线条发虚、刻度细线像被锯齿啃过。查了原因才发现是设备像素比DPR问题。HarmonyOS的逻辑像素和物理像素在部分设备上并不一致Canvas如果不考虑DPR相当于拿逻辑分辨率去画图系统再以物理像素去渲染就会产生模糊。解决办法是在Canvas初始化时设置宽高等于逻辑尺寸乘以DPR并在绘制前调用ctx.scale(dpr, dpr)private initCanvas() { const dpr this.getDevicePixelRatio(); this.ctx.scale(dpr, dpr); }这一行改动之后画布清晰度立刻不一样了。同样的坑在做自定义绘图时经常遇到建议所有用Canvas的朋友一上来就处理这个。5.2 定位大数时刻度数字巨大定位小数时又全挤在原点附近这种问题本质上是显示范围策略的问题。第一版我固定显示[-10, 10]输入12的时候就超出范围了输入0.001的时候12个刻度全挤在中心两像素内毫无意义。后来我引入了动态范围算法显示半径R max(|x| × 1.2, 1)。这样输入12时范围变成[-14.4, 14.4]输入0.001时范围维持[-1, 1]0.001和原点靠得很近这其实是真实的数轴关系——0.001在数轴上本来就贴近0。与此同时我在信息面板中显示近似值让学生既看得见位置关系也读得到精确数值。但这里还有一个补充策略提供放大/缩小按钮改变pixelsPerUnit。默认依据范围计算像素密度但用户想看清0.001附近的细节时可以通过放大手动调整到合适的视野。教学演示时这一招非常管用。5.3 连续输入触发高频重绘300ms防抖解决卡顿一开始我把重绘挂在TextInput的onChange上每个字符触发一次。用户在输入12345的过程中会触发5次绘制而且每次都要重新计算刻度、遍历绘制在低端真机上能感觉到明显掉帧。我的解法很直接引入一个300ms的防抖定时器。用户停止输入300ms后才触发解析和重绘。这样12345在输入过程中只会在停顿后重绘一次完全不卡。private handleInput(value: string) { this.inputValue value; this.clearInputTimer(); this.inputTimer setTimeout(() { this.tryLocateCurrentInput(); }, 300); }5.4 遗漏的问题刻度标签的取舍还有一个很有意思的坑。当显示范围特别大的时候比如[-1200, 1200]以步长200绘制刻度标签满屏的-1000、-800、-600会非常拥挤。我的解决办法是引入标签跳过策略当可视宽度内的整数刻度数超过12个时奇偶位标签二选一显示也就是隔一个刻度打一个数字。虽然字面上少了些信息量但整体画面干净得多学生读起来反而更清楚。6. 来自真实课堂的反馈和后续想继续扩展的方向6.1 对比模式才是老师们真正高频使用的功能这个应用做完后我自己拿着模拟项目和几位老师交流了一下。结果让我有点意外最受欢迎的功能不是单点定位而是同时画出几个数、互相比较。比如把√2、1.414、3/2放在一条轴上学生立刻看出1.414确实比1.5小而且离√2非常近。所以我在下一个小版本里把currentList从单值模式扩展成多值模式每一笔标记用不同颜色区分。预设按钮区也改了逻辑点击追加就把当前值加入列表点击清空才重置。这个改动不算大但实用价值提升了一个量级。6.2 随堂练习可以做但别做成电子题库有些老师提到最好内置随机出题功能让学生拖拽或点选判断某个标记点对应的实数值。我的想法是出题交互可以做但我不想把它做成一个刷题存档系统。我更倾向做成显示一个标记学生输入自己认为的正确值应用比对并显示误差这种轻量练习。它可以培养学生的估算能力而不是纯粹的机械记忆。下一步我打算在Canvas上增加点击取点功能学生点击数轴上某个位置应用显示该点对应的实数近似值反过来学生也可以输入精确值看标记的实际落点。这种双向反馈闭环才是理解数轴概念的加速器。6.3 关于HarmonyOS多设备适配的一点思考最后说说多设备这件事。这个应用目前在手机上运行很流畅但数轴这种大视觉、宽幅内容的工具在平板上使用手感和教学效果明显更好。HarmonyOS的适配思路是不用写多套UI逻辑而是利用断点能力和栅格布局把画布区域在平板上放大输入区和信息区放在侧边。另外我还考虑做一个桌面端窗口模式让老师在电脑上投屏讲解时用更大的画面展示数轴。HarmonyOS在这类多设备协同场景里天生的优势很明显。这个实例目前还只是个开始但它已经让我验证了一条路教育类工具的技术门槛并不高真正难的是把一个抽象概念用可视化手段讲到学生心里去。做开发者能顺手帮到某个困在√2到底在哪里的孩子那就挺值。
阅读完成 · 觉得有帮助?