尧图精选

鸿蒙开发实例:用ArkTS实现二次函数最值计算与图像绘制

🕒 发布时间:2026/9/8 17:01:43 📁 来源:尧图网络
开发鸿蒙应用做到第259个实例我反而回头选了一道中学数学题二次函数最值问题。有人可能会问这不是用笔算两分钟就能出结果的东西吗值得做成一个 HARMONYOS 应用实例我的看法是恰恰是这种“看起来简单”的题目最能练一个应用的完整骨架。输入系数、判断二次函数开口方向、求解顶点、处理闭区间边界、再把函数曲线画出来这一串动作下来界面布局、类型处理、数学计算、Canvas 绘图、真机调试全部覆盖了比那些一上来就堆十几个页面的示例有用得多。如果你正在学 ArkTS 和 ArkUI或者你打算做数学类的教育工具但不知道怎么落地这篇实例应该能给你一个非常完整的参考。文章里写的不是我临时拼凑的 Demo而是我实际在 DevEco Studio 里跑通、又在真机上验证过的一个小工具输入 a、b、c再输入一个区间应用会告诉你这个二次函数在实数范围和在指定区间上的最大最小值同时画出抛物线、标出顶点和对称轴。1. 为什么第 259 个实例要选这道题看似简单链路却完整先聊动机。HARMONYOS 应用实例我做了很多期大部分示例要么是业务界面要么是系统能力调用。但业务界面容易堆组件系统能力又依赖特定硬件两者都不太适合拿来练手。二次函数最值问题不一样它是一道你能够在十分钟内厘清逻辑、花上一个晚上把界面和图像都做出来的题目。1.1 这个工具最终给谁用我做完之后的第一反应是这东西确实能给三类人用。第一类是初中生或高中生他们输入一个函数就能直接看到顶点、对称轴和区间最值尤其是“区间不包含顶点时最值在端点取到”这种容易混淆的概念用图像验证一遍会清晰很多。第二类是教数学的老师做课件的时候需要现场生成函数图像比起打开电脑里的专业数学软件一个装在手机里的小工具更方便。第三类是刚接触鸿蒙开发的人他们不需要理解复杂的硬件特性只需要顺着一条清晰的主线把代码写完就能得到成就感。为了让三方都用得顺手我没有把功能做得很宽只保留了几个最核心的动作输入参数、看结论、看图。1.2 我最终定下来的交互闭环整个应用的交互流程并不复杂核心就是“一条线走完”在六个输入框中分别填入系数 a、b、c 和区间左端点 left、右端点 right。点击“计算”按钮或者输入结束后触发计算。结果卡片依次显示开口方向、对称轴、顶点坐标、实数范围内的最值、闭区间上的最大值和最小值以及取得最值对应的 x 值。下方 Canvas 自动绘制抛物线并标出对称轴和顶点。点击“随机示例”可以快速载入一组典型数值方便检查图像显示是否正常。这个闭环没有引入外部依赖也没有复杂的状态管理但它把常见的输入组件、业务类型封装、格式化输出、Canvas 自绘、异常输入处理都带出来了。后面的几个章节我就按照代码的推进顺序来写如果你是先写计算逻辑再拼界面的类型读起来应该会很顺。2. 先写清楚数学条件求最值的逻辑骨架与边界处理写 UI 之前我先把最值计算单独拎出来封装好了。这样做不是因为数学难而是因为如果直接把这些判断散落在页面文件里后面增加随机示例或者换一个入口页面时会非常痛苦。2.1 从顶点式到代码映射二次函数的标准形式是:f(x) ax² bx c其中 a 不能等于 0。所有关于最值的讨论其实都围绕顶点式来展开f(x) a(x - h)² k这里h -b / (2a)k c - b² / (4a) (4ac - b²) / 4a在代码里我不是直接用字符串去拼公式而是把这些计算收敛到一个分析器中。下面是一段我在工程里实际使用的核心类型代码以 ArkTS 的 interface 描述分析结果// common/quadratic.ts export interface QuadraticParams { a: number; b: number; c: number; } export interface QuadraticAnalysis { params: QuadraticParams; vertexX: number; vertexY: number; axisX: number; openUp: boolean; realMinX: number | null; realMinY: number | null; realMaxX: number | null; realMaxY: number | null; intervalMinX: number | null; intervalMinY: number | null; intervalMaxX: number | null; intervalMaxY: number | null; } export function analyzeQuadratic(params: QuadraticParams, left: number, right: number): QuadraticAnalysis { const { a, b, c } params; const vertexX -b / (2 * a); const vertexY c - (b * b) / (4 * a); const openUp a 0; const analysis: QuadraticAnalysis { params: params, vertexX: vertexX, vertexY: vertexY, axisX: vertexX, openUp: openUp, realMinX: null, realMinY: null, realMaxX: null, realMaxY: null, intervalMinX: null, intervalMinY: null, intervalMaxX: null, intervalMaxY: null }; // 整个实数范围内a 0 开口向上有最小值a 0 开口向下有最大值 if (openUp) { analysis.realMinX vertexX; analysis.realMinY vertexY; } else { analysis.realMaxX vertexX; analysis.realMaxY vertexY; } // 闭区间上的最值不能只靠开口方向判断需要用候选点法 const candidatesX: number[] [left, right]; if (left vertexX vertexX right) { candidatesX.push(vertexX); } let minY Number.MAX_VALUE; let maxY -Number.MAX_VALUE; let minX left; let maxX left; for (let i 0; i candidatesX.length; i) { const x candidatesX[i]; const y a * x * x b * x c; if (y minY) { minY y; minX x; } if (y maxY) { maxY y; maxX x; } } analysis.intervalMinX minX; analysis.intervalMinY minY; analysis.intervalMaxX maxX; analysis.intervalMaxY maxY; return analysis; }这段代码的核心逻辑是候选点法。对于定义在闭区间[left, right]上的连续函数最值只可能出现在三类点上区间左端点、区间右端点、区间内部的极值点。二次函数的唯一极值点就是顶点所以只要判断顶点横坐标是否落在区间内再加上两个端点一起比较就能得到区间最值。2.2 区分实数范围和闭区间范围这里有一个我见过很多初学者踩坑的地方直接说“开口向上所以函数没有最大值”但用户输入了一个闭区间后函数在这个闭区间上一定有最大值和最小值。如果没有指定区间讨论的是整个实数域那么结论确实只有一半a 0开口向上整个实数范围内存在最小值vertexY没有最大值。a 0开口向下整个实数范围内存在最大值vertexY没有最小值。一旦给定了区间哪怕开口向上区间端点也可能成为最大值哪怕开口向下区间端点也可能成为最小值。我在这版工具里把两类结果都展示出来就是为了避免这种混淆。上面的分析器也处理了“区间写反”的情况。我的界面上额外做了一次校验如果right left就弹出提示并中止计算。这个判断不放在分析器里因为分析器只负责算已知合理输入而用户交互层的职责是保证参数合理。2.3 输入校验a 不能为 0空输入不能参与计算用户在输入框里填入的值本质上都是字符串。通过parseFloat转换后可能出现NaN也可能因为用户把 a 填成 0让“二次函数”变成了“一次函数”。遇到这些情况我会在入口处统一拦截function parseInput(text: string): number { if (text.trim() ) { return Number.NaN; } return Number.parseFloat(text); } function validateParams(): boolean { const a parseInput(this.aText); const b parseInput(this.bText); const c parseInput(this.cText); const left parseInput(this.leftText); const right parseInput(this.rightText); if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c) || Number.isNaN(left) || Number.isNaN(right)) { this.toastMessage(请完整填写所有数值); return false; } if (Math.abs(a) 1e-12) { this.toastMessage(二次项系数 a 不能为 0); return false; } if (right left) { this.toastMessage(区间右端点需要不小于左端点); return false; } return true; }很多人觉得输入校验不重要但真机演示的时候只要有人随手把 a 清空应用很可能表现异常。后来我很庆幸一开始就处理了这些边界解析输入后先判断NaN再判断 a 是否近似为 0最后判断左右边界逻辑。这个顺序不能倒过来因为如果先比较right left遇到NaN时比较结果永远是 false用户看到的是莫名其妙的异常而不是友好提示。3. ArkUI 布局与交互一道数学题如何变成一个顺手的小工具计算逻辑准备好了接下来就是界面。这个应用的界面复杂度不高但我还是按照真实产品的思路把它拆成了几个区域输入区、表达式预览区、结果卡片区和图像区。所有区域放在一个纵向滚动的容器中避免小屏手机上内容被截断。3.1 输入区用 Row 还是 Grid 更合适ArkUI 中常用的布局是 Row、Column 和 Grid。系数输入需要显示“a ”“b ”“c ”这种标签并且每个标签后面接一个 TextInput所以最简单清晰的方式是一行一个输入项或者用三到五个等宽列。我最后选择了每一个系数占一个输入卡片的方式因为参数之间存在严格的数学语义让它们在同一行内整齐排列比纵向罗列更直观。以三个系数为例界面结构大致是Row({ space: 8 }) { this.buildParamInput(a , this.aText, (value: string) { this.aText value; }, 1); this.buildParamInput(b , this.bText, (value: string) { this.bText value; }, -3); this.buildParamInput(c , this.cText, (value: string) { this.cText value; }, 2); } .width(100%);为了不让页面重复代码太多我把参数输入封装成了一个私有方法。这个方法里使用TextInput作为输入组件并且设置了键盘类型为小数键盘Builder buildParamInput(label: string, value: string, onChange: (value: string) void, defaultValue: number) { Column({ space: 4 }) { Text(label) .fontSize(14) .fontColor(#666) .width(100%); TextInput({ text: value, placeholder: defaultValue.toString() }) .type(InputType.NUMBER_DECIMAL) .onChange(onChange) .height(44) } .layoutWeight(1) }这里有一个使用细节值得提一下TextInput的text参数需要传入一个字符串。用户在输入过程中ArkUI 会通过onChange把最新字符串带出来。如果你只是把字符串保存在临时变量里、没有绑定到State界面上的内容不会及时更新。为了让“随机示例”按钮能够改变输入框的值必须保证aText、bText这些变量是State。3.2 表达式的格式化输出用户想看到的不只是系数输入框而是像f(x) x² - 3x 2这样的标准表达式。如果直接把 a、b、c 拼起来很容易得到f(x) 1x^2-3x2这种很丑的结果。我封装了一个专门负责格式化表达式的方法处理了三类细节系数为 1 时不显示“1x²”直接显示“x²”。系数为 -1 时显示“-x²”而不是“-1x²”。正负项之间用空格和加减号隔开避免出现两个符号连在一起。参考代码片段如下function formatTerm(coef: number, exp: number): string { let head: string; if (coef 1) { head ; } else if (coef -1) { head -; } else { head ${coef}; } if (exp 2) { return ${head}x²; } if (exp 1) { return ${head}x; } return ${coef}; } export function buildExpression(a: number, b: number, c: number): string { let result ; const ax2 formatTerm(a, 2); const bx formatTerm(b, 1); if (ax2 ! ) { result ax2; } if (b ! 0) { result b 0 result.length 0 ? ${bx} : ${bx}; } if (c ! 0) { result c 0 result.length 0 ? ${c} : ${c}; } return result ? 0 : result; }这个函数还有很多可以打磨的地方比如a 0.5时可以显示“0.5x²”如果希望更接近数学排版还可以把系数转换成带分数形式不过目前用到的场景已经足够。对于负数项formatTerm返回的字符串自带负号所以拼接时不用额外处理。比如 b -3bx是“-3x”最终表达式就是“x² - 3x 2”。3.3 结果卡片的文案策略计算结果的展示不是把几个数字丢到屏幕上那么简单。我问了自己一个问题用户看这个工具时最想确认的第一眼信息是什么答案是“顶点和区间最值”。所以在卡片设计里我把两行关键信息做成大字号开口方向抛物线开口向上/向下。顶点坐标(2.0, -1.0)同时显示对称轴x 2.0。接下来才是闭区间上的最大值和最小值。为了让结论不产生歧义我在文案上明确写清是在区间[left, right]上的取值在区间 [-10, 10] 上 最小值 y -1.0当 x 2.0 最大值 y 132.0当 x -10.0这里其实藏着一个值得展开的细节闭区间上最大最小值对应的 x 值不一定唯一。比如在对称区间上取常数的退化情形或者在最大值落在一侧端点而顶点也在区间内时严格说可能有多个 x 取得相同最值。但作为教学工具我认为先给出一个取值点即可界面下方画出的曲线也能让用户直观看到其他位置的情况。4. Canvas 自绘抛物线坐标系换算与曲线渲染心得界面上的数字结论再清晰也不如一条真实的抛物线直观。这个应用里最花时间、也最能看出开发者功力的部分是用 Canvas 把曲线画出来。很多新手在这块卡住并不是画线的方法不会而是数学坐标系与屏幕坐标系的转换没想明白。4.1 数学坐标到 Canvas 坐标换算ArkUI 的 Canvas 坐标系和数学平面直角坐标系有一个显著差异屏幕的 y 轴是向下增长的。数学里的点(0, 0)越往上 y 越大但在屏幕上越往上 y 越小。也就是说如果一个点的数学 y 值是 10屏幕坐标需要拿canvasHeight去减掉换算后的高度差。我首先在组件中通过onAreaChange拿到画布的实际宽高然后封装了两个转换函数// 画布实际宽高 private canvasWidth: number 0; private canvasHeight: number 0; // 数学范围 private viewLeft: number -10; private viewRight: number 10; private viewBottom: number -10; private viewTop: number 10; private toPixelX(mx: number): number { const ratio this.canvasWidth / (this.viewRight - this.viewLeft); return (mx - this.viewLeft) * ratio; } private toPixelY(my: number): number { const ratio this.canvasHeight / (this.viewTop - this.viewBottom); return (this.viewTop - my) * ratio; }这段代码中的关键是(this.viewTop - my)。当数学 y 值越大像素值越小点就越靠近画布上方。很多人在写这个函数时习惯用my - viewTop结果图像上下颠倒最后还得靠debugger一步步查。4.2 用采样点实现曲线绘制二次函数的图像是一条连续的抛物线但落到 Canvas 上时我们只能用多个离散点连接成折线。只要采样点足够密肉眼就看不出折线感。我的方案是把用户选择的区间均匀切分成 100 到 200 个点然后逐点计算 y 值并连线。在 ArkTS 中实现时我会先设置CanvasRenderingContext2D的线条样式再开始beginPath、循环lineTo最后strokeprivate drawCurve(): void { const ctx this.ctx; ctx.lineWidth 2; ctx.strokeStyle #007DFF; ctx.beginPath(); const samples 160; const startX this.viewLeft; const endX this.viewRight; const step (endX - startX) / (samples - 1); let first true; for (let i 0; i samples; i) { const mx startX step * i; const my this.calcY(mx); const px this.toPixelX(mx); const py this.toPixelY(my); if (first) { ctx.moveTo(px, py); first false; } else { ctx.lineTo(px, py); } } ctx.stroke(); }这里采样的初衷是避免曲线在某个区域变化太快时出现明显的直线段。为了视觉效果我通常把采样点控制在 120 ~ 200 之间。数量太少会导致顶点附近不圆润数量太多又不会带来本质提升反而增加无意义的计算。4.3 顶点、对称轴与坐标轴怎么标顶点是二次函数最重要的几何特征所以我在曲线上用一个小实心圆标出来并在旁边附上坐标文本private drawVertex(mx: number, my: number, label: string): void { const ctx this.ctx; const px this.toPixelX(mx); const py this.toPixelY(my); ctx.beginPath(); ctx.fillStyle #E84026; ctx.arc(px, py, 4, 0, 2 * Math.PI); ctx.fill(); ctx.font 12px sans-serif; ctx.fillStyle #E84026; ctx.textAlign left; ctx.fillText(label, px 8, py - 8); }对称轴画成虚线会更清晰。我用setLineDash([4, 4])设置虚线样式然后从屏幕上方到下方画一条竖直向下的线。但有一个细节是如果对称轴本身不在当前的可见 x 范围内那么不需要画这条虚线。因为只有对称轴横坐标落在视图区间内虚线才有意义。坐标轴的绘制同样需要注意y 轴并不是一定要从画面内部穿过的实线而是根据数学原点的位置动态判断是否在可视范围内。如果 x 0 在viewLeft和viewRight之间那么 y 轴应该显示为一条实线如果不在范围内就只保留屏幕边缘的边框线。放在这个例子里默认区间[-10, 10]包含 0所以坐标轴会正常显示。4.4 视图范围自适应默认的抛物线在[-10, 10]区间上可能很高也可能很低。如果 y 的范围设置不当曲线要么冲出去要么缩成一团。我采用的办法是先预先计算采样点的最小y和最大y然后向外扩展 10% 作为画布的viewTop和viewBottomlet yMin Number.MAX_VALUE; let yMax -Number.MAX_VALUE; for (let i 0; i samples; i) { const mx startX step * i; const my this.calcY(mx); yMin Math.min(yMin, my); yMax Math.max(yMax, my); } const padding (yMax - yMin) * 0.1; this.viewBottom yMin - padding; this.viewTop yMax padding;当抛物线开口向上且顶点很高、曲线很陡时如果 yMax 和 yMin 相差太大图会变得很扁。这种场景下我建议对 y 方向做适当截断或者提示用户缩小 x 区间。我在代码中做的是如果高度差超过 10000就自动把观察窗口切换到顶点附近保证图像主体清晰可读。5. 从模拟器到真机参数用例、HDB 调试与交付前检查代码写完之后并不是能在预览器上跑起来就万事大吉。预览器里的 Canvas 尺寸、键盘弹出、字体渲染和真机存在不少差异。尤其是这个应用涉及输入框、计算、绘图三个环节一步出问题都容易被误判成功能 Bug所以我有一套自己的验证流程。5.1 用数组典型用例验证算法算法正确性不靠肉眼看而是靠用例说线。我把几组已知结果的参数放进了“随机示例”里每一组都经过手算验证用例abc区间顶点区间最小值区间最大值开口向上1-32[-10, 10](1.5, -0.25)x1.5, y-0.25x-10, y132开口向下-121[0, 4](1, 2)x4, y-7x1, y2顶点不在区间10-4[4, 6](0, -4)x4, y12x6, y32对称区间24-6[-3, 1](-1, -8)x-1, y-8端点比较取更大者随机示例按钮的作用不只是演示更是在我改完代码后快速回归算法的一个入口。按一次按钮界面上的六个输入框就会载入一组参数我可以立刻用肉眼比对结果卡片中的数字和手算结果是否一致。5.2 真机部署与 HDB 命令行调试经验在 DevEco Studio 中真机运行通常很简单连接设备后点击 Run 按钮即可。但在开发过程中我发现只靠编辑器里的 Log 面板找问题很 inefficient有些布局只在真机上闪烁日志又不会打到哪里。HarmonyOS 的调试工具链里经常会用到 HDB 这类命令行能力不同版本的工具命令名称可能有差异但思路是一致的先列出已连接的设备再应用进程里查看 println 或日志输出。下面是我常用的大致命令形态# 列出设备 hdb devices # 连接无线调试设备设备 IP 与端口需在开发者选项里查看 hdb connect 192.168.x.x:port # 过滤应用日志 hdb shell hilog | grep QuadraticDemo打开无线调试后真机就不需要一直插着 USB 线。对于需要频繁抬手看画面的数学工具来说非常方便。我不需要反复弯腰插线设置好一次后面在同一个局域网内都能直接部署。需要注意的是模拟器上 Canvas 的 onAreaChange 触发时机和真机略有不同前几次真机运行时曲线画不出来但模拟器一切正常。后来我把绘制逻辑触发点从onReady调整到首页的onPageShow和参数变更后的redraw问题才解决。5.3 三个我实际遇到、也建议你检查的坑第一个坑是TextInput里输入负号。用户输入-3时如果键盘没有正确弹出符号键可能会输入成数字3再手动补负号体验很差。实际在模拟器里受到系统软键盘影响InputType.NUMBER_DECIMAL的键盘布局在不同版本上并不一致所以我在帮助文案里写明了“可以直接输入负号”。第二个坑是深色模式。默认的黑色文字在深色主题下可能变成深色导致用户看不清结果。我在结果卡片上统一用了语义化颜色资源并在代码里给 Canvas 的网格和坐标轴使用了“跟随主题”的颜色值而顶点标注和曲线则使用品牌色避免整张图在深色模式下糊成一团。第三个坑是中文换行。表达式预览区的文本如果太长比如 a、b、c 都是多位负数时Text组件默认可能换行看起来很奇怪。我给表达式区域设置了maxLines(1)并允许textOverflow({ overflow: TextOverflow.Ellipsis })至少在窄屏上不会崩版。用户如果实在要完整查看可以长按输入框旁的计算结果复制到备忘录中确认。6. 把计算和绘图拆成两个独立模块后扩展比想象中更顺利这个应用我本来以为一天就能写完真正落地时发现大部分时间花在了“让结果在不同边界条件下都正确展示”上。反而是最初把计算逻辑单独抽成quadratic.ts、绘图逻辑单独抽成 Canvas 画图类之后后面加功能变得非常轻松。6.1 从二次函数到通用函数绘图器很多看似独立的数学函数绘图逻辑其实是共通的。我现在已经把这个项目的绘图部分抽象成了“输入一个(x: number) number的函数画出对应的曲线”。这意味着只要把原来的calcY从“二次函数”替换成别的表达式这个工具就能画出三次函数、正弦函数、绝对值函数等多种图像。在二次函数之上下一步很自然地做的就是求根当Δ b² - 4ac大于等于 0 时用求根公式直接显示两个 x 截距并和顶点标注在同一条曲线上。这个功能所需的抛物线数据、坐标系、采样方式已经全部现成真正需要新增的只是把(-b ± sqrt(Δ)) / (2a)算出来再判断截距是否落在当前视图区间内。6.2 对教育类应用的后续建议如果你做的是教学工具建议在界面上把顶点式和一般式并列展示。顶点式能让学生一眼看出对称轴和顶点但一般式更贴近题目原始给法。我的做法是在表达式预览区下方增加一行小字“顶点式f(x) 1(x - 1.5)² - 0.25”。这行文字同样通过函数生成本质上就是把 a、h、k 格式化后拼接出来。另外曲线图下方最好配上“缩放区间”的能力。用户想看 x 100 附近的函数行为时如果只能靠手动输入区间左端点右端点多少有些繁琐。做一个“放大”“缩小”按钮把viewLeft和viewRight沿中心点向外扩或向内缩操作的成就感会强很多。6.3 我最后留在代码里的一条经验做这类小工具真正麻烦的不是算法本身而是数值展示的规范性。比如浮点数计算后-0.0000000001很容易显示成-0顶点坐标可能看起来是(1.5, -0.0000001)。在我的实现里所有结果显示前都会做一次近似处理function smartNumber(value: number): string { if (Math.abs(value) 1e-9) { return 0; } const rounded Math.round(value * 1000000) / 1000000; return ${rounded}; }这样一个简单的函数解决了两类问题一是消除浮点尾巴二是避免输出NaN时让用户看到难看的报错。实际使用时我几乎在每一个 Text 绑定数字的地方都调用了它真机演示时结果界面才会干干净净。如果你也想写一个类似的数学小应用我的建议是先不要急着追求页面动画和渐变效果把上面提到的输入边界、结果格式化、Canvas 坐标转换这三样做扎实应用就已经具备了日常使用的稳定性。剩下再补一点随机样例和异常提示体验就不会比市面上收费的数学工具差太多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →