十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS ArkTS开发:直线与圆方程可视化绘图工具实践

HarmonyOS ArkTS开发:直线与圆方程可视化绘图工具实践 最近把手里的鸿蒙平板翻出来折腾HarmonyOS应用开发想验证一下ArkTS做图形类工具到底顺不顺手就挑了一个高中数学里特别经典的题材直线和圆的方程。这个HarmonyOS应用实例最后做成了一个小而完整的交互式绘图工具——输入斜率和截距或者圆心和半径Canvas区域实时画出坐标系、直线和圆的图像并且自动生成对应的方程文本。对正在学ArkUI绘图、状态管理、坐标变换的开发者来说这个例子几乎把声明式UI的几个核心坑都踩了一遍很适合照着敲一遍。这个例子的目标用户很明确第一类是刚开始接触HarmonyOS应用开发、想找一个没有网络依赖、纯本地图形渲染的练手项目的人第二类是高中数学老师或者辅导孩子的家长想要一个随手就能调节参数、把方程和图像对照呈现的小工具。整篇文章会把设计思路、坐标转换、Canvas绘制、参数联动和踩坑记录都拆开讲你照着节奏走最终能得到一个可运行、可继续扩展的App。1. 项目背景与整体设计思路1.1 为什么选“直线和圆的方程”作为应用实例我做开发练习一直有个习惯就是不喜欢只做“按钮和列表”那种CRUD应用。HarmonyOS应用实例那么多绝大多数都停留在表单、列表、弹窗想要真正把ArkUI的绘制能力吃透必须找带图形的场景。直线和圆的方程是高中数学解析几何的入门内容特点非常鲜明代数和几何互相印证同一个对象有两种完全不同的表达。方程写出来是符号图像画出来是几何两者需要一套严谨的坐标映射才能统一到一个画布上。这个特点天然适合做可视化工具。另外一个原因是复杂度适中。它不需要网络、不依赖第三方SDK一张Canvas加几个滑动条就能跑起来。但如果只把它当成简单的“画线”Demo又太亏了。处理斜率无穷大、圆越界、坐标转换、参数响应式刷新这些问题时你被迫去思考绘图引擎的底层逻辑而这恰恰是日常业务开发里最难练到的部分。所以这个题材对“练内功”非常有价值。1.2 功能需求拆解与界面规划动手之前先把功能清单列清楚。我最终圈定了下面这些核心能力绘制带网格、刻度和坐标轴的平面直角坐标系作为所有图形的基础底图。直线模式用斜截式 y kx b 作为输入模型通过两个滑动条分别调节斜率k和截距b。圆模式用标准式 (x-a)² (y-b)² r² 作为输入模型通过三个滑动条调节圆心横坐标a、纵坐标b和半径r。图形随着参数变化实时重绘保证“参数一变图像立刻跟着变”。在画布上方动态显示当前方程文本用于和图像对照。界面规划上我采用了“上下结构”顶部放一个标题栏和一个方程文本标签中间是固定高度的Canvas绘图区底部是参数控制面板。控制面板内部用两个Button做直线和圆的模式切换切换之后只显示当前模式相关的滑动条。之所以不用Tab是因为Tab组件在这里重了两个按钮足够而且更容易做自定义样式。整体Code结构在ArkUI里就是典型的Column套Row套Column没有复杂的嵌套后续很容易加功能。1.3 技术选型ArkTS ArkUI CanvasHarmonyOS应用开发的技术栈现在已经很明确ArkTS负责业务逻辑和数据类型声明ArkUI负责声明式UI搭建Canvas组件负责光栅化绘图。这套组合里我重点依赖了三样东西声明式UI的状态驱动机制参数用State装起来UI引用这些状态后值一变页面自动刷新。CanvasRenderingContext2D这个绘图上下文提供类似Web Canvas的APImoveTo、lineTo、arc这些都有学习成本很低。DevEco Studio自带的Previewer和模拟器能快速看效果不需要每次都部署到真机。有人可能会问为什么不用社区里的echarts或者mpandroidchart这类图表库这类库确实好看但它们是“图表组件”适合展示现成数据曲线不适合做“可参数化探索的数学工具”。而且引入第三方库会增加依赖和包体积这个场景用原生Canvas的画法完全可控性能也足够。2. 数学模型与绘图的底层原理2.1 直线和圆的方程开发者需要理解的最小模型在写代码之前得先把要画的数学模型在代码里“翻译”一遍。直线的斜截式是 y kx b。这里k是斜率b是y轴截距。这个形式最简单参数少适合作为第一版输入模型。但有一个隐患当直线接近垂直时k的绝对值会变得非常大甚至趋近于无穷给绘制带来边界处理问题。后面我会专门讲怎么处理。圆的方程我选标准式 (x-a)² (y-b)² r²。它直接给出圆心坐标(a, b)和半径r在Canvas里对应一条简单的arc指令。如果你以后想支持一般式 x² y² Dx Ey F 0也只需要做一次配方变换把D、E、F换算成圆心和半径后台逻辑不变。这些数学关系其实每个人在学校都学过为什么还要单独提因为在代码里“y kx b”不是一行打印出来的字符串而是一组驱动绘图的数值。你要清楚知道给Canvas的moveTo和lineTo输出的是屏幕坐标而k和b定义的是数学坐标这中间隔着一层坐标系转换原理讲清楚了代码才不容易翻车。2.2 屏幕坐标系与数学坐标系的转换这个坑是几乎所有Canvas绘图新手都会踩的。数学课上画的平面直角坐标系原点在中间y轴向上为正而屏幕坐标系的原点在左上角x轴向右为正y轴向下为正。如果不做任何处理直接拿y值去画你会发现所有的图形上下颠倒圆跑到了期待位置的上方直线看起来斜率也反了。转换公式其实不复杂。先假设画布宽为width、高为height原点在画布中心的屏幕坐标为(originX, originY)scale表示一个数学单位对应多少个像素也就是网格的像素间距。那么任意数学坐标点(mx, my)转换到屏幕坐标(sx, sy)的公式是sx originX mx * scale sy originY - my * scale反过来屏幕坐标转数学坐标是mx (sx - originX) / scale my (originY - sy) / scale核心就是y轴方向取反。我写了一个具体例子方便直观理解如果画布是360 x 360原点在画布中心scale为40那么数学点(1, 2)会落在屏幕的(200, 120)位置而不是(200, 280)。这个例子我建议你自己在纸上推一遍之后写代码时就不容易搞混。2.3 Canvas绘图的底层逻辑HarmonyOS的Canvas组件用法和Web Canvas非常接近。先声明一个RenderingContextSettings和一个CanvasRenderingContext2D然后把context传给Canvas组件在onReady回调里开始绘制。这里有个关键点onReady只在组件第一次布局完成时触发一次如果参数变了它不会自动重新绘制。所以你必须自己在参数变化的回调里主动调用“清屏重绘”的方法。我习惯把绘制流程封装成一个drawGraph()方法内部顺序是clearRect清空画布、drawGrid画网格、drawAxes画坐标轴、drawLineGraph或drawCircleGraph画当前图形。每层之间用不同的strokeStyle区分颜色图形层和网格层分开不仅逻辑清晰后面想加“隐藏网格”功能时也只要在该层加个条件判断。3. 核心代码实现3.1 数据结构与状态管理先定义参数模型。直线和圆虽然是两种图形但在ArkTS里可以用class把它们组织好。我用两个类分别封装直线和圆的参数然后定义一个枚举保存当前模式。// 当前模式直线 或 圆 enum GraphMode { Line 0, Circle 1 } // 直线方程y k * x b class LineModel { k: number 1 b: number 0 } // 圆方程(x - a)^2 (y - b)^2 r^2 class CircleModel { a: number 0 b: number 0 r: number 2 }逻辑模型归逻辑模型页面状态还是得用State装饰的字段管理这样ArkUI的响应式系统才能感知变化并刷新UI。State currentMode: GraphMode GraphMode.Line State lineK: number 1 State lineB: number 0 State circleA: number 0 State circleB: number 0 State circleR: number 2为什么这么设计如果只用一个State去装“当前参数对象”滑动条变化时要先改对象再赋值给状态多一层间接操作。直接把k、b、a、b、r拆成五个State字段虽然字段多了点但每个滑动条都直接绑定一个字段逻辑最直白。等以后参数多了再考虑引入一个生成常量类抽公共配置那是后话。3.2 坐标系与网格绘制先把基础底图做出来。下面的代码绘制了一个从-6到6范围的网格每个数学单位对应GRID_SIZE个像素private drawGrid() { this.ctx.strokeStyle #E8E8E8 this.ctx.lineWidth 1 for (let i -6; i 6; i) { // 垂直网格线 const sx ORIGIN_X i * GRID_SIZE this.ctx.beginPath() this.ctx.moveTo(sx, 0) this.ctx.lineTo(sx, CANVAS_HEIGHT) this.ctx.stroke() // 水平网格线 const sy ORIGIN_Y - i * GRID_SIZE this.ctx.beginPath() this.ctx.moveTo(0, sy) this.ctx.lineTo(CANVAS_WIDTH, sy) this.ctx.stroke() } }网格线画完后用更粗更深一点的线画两条坐标轴并写上刻度数字。刻度数字直接用fillText渲染位置要稍微错开避免和轴线重叠。这里有两个小细节一是坐标轴的颜色和网格线明显区分否则画面会糊成一片二是刻度数字的文字大小在平板上12px刚好手机上可以考虑14px先在Previewer里调好再上真机。3.3 直线绘制与边界裁剪问题直线的绘制分为两步根据k、b在数学坐标系中找出两个点再转换到屏幕坐标后用moveTo和lineTo连接。表面看就是这么简单但如果直接取“x等于可视范围左边界”和“x等于右边界”这两个点来计算y当斜率绝对值很大时计算出的屏幕坐标会跑到画布外面画出来的线段不仅超界有时候甚至看起来整条线消失不见——你以为程序出bug其实只是你的两个端点都在可视区域外并且连线和视口没有交点。我在这个例子里采用了一套简化但有效的策略当斜率绝对值大于1时改用y轴范围反推x轴范围保证至少一个端点落在视口内部。核心代码如下private drawLineGraph(k: number, b: number) { this.ctx.strokeStyle #2196F3 this.ctx.lineWidth 2.5 const mathXMin (0 - ORIGIN_X) / GRID_SIZE const mathXMax (CANVAS_WIDTH - ORIGIN_X) / GRID_SIZE const mathYMin (CANVAS_HEIGHT - ORIGIN_Y) / GRID_SIZE const mathYMax (0 - ORIGIN_Y) / GRID_SIZE let x1 mathXMin let x2 mathXMax let y1 k * x1 b let y2 k * x2 b // 斜率绝对值较大时用y方向的范围反推x避免端点越界 if (Math.abs(k) 1) { y1 mathYMin y2 mathYMax x1 (y1 - b) / k x2 (y2 - b) / k if ((x1 mathXMin x2 mathXMin) || (x1 mathXMax x2 mathXMax)) { return } } this.ctx.beginPath() this.ctx.moveTo(this.mathToScreenX(x1), this.mathToScreenY(y1)) this.ctx.lineTo(this.mathToScreenX(x2), this.mathToScreenY(y2)) this.ctx.stroke() }注意这其实是一个工程取舍按k的绝对值切分简单好懂覆盖了绝大多数参数组合但并非数学上严格的线段裁剪。如果你想把直线的显示做得更严谨建议去了解一下Cohen-Sutherland或Liang-Barsky算法这两个算法能精确求出线段和视口的交集代码也不长。我在教学场景里用简化方案已经够用了没有引入额外复杂度。3.4 圆的绘制与半径换算圆在Canvas里天然友好一条arc就能搞定。关键是把数学坐标系里的圆心坐标和半径都转换成屏幕坐标圆心用mathToScreenX/mathToScreenY转换半径直接乘以GRID_SIZE得到屏幕像素长度。private drawCircleGraph(a: number, b: number, r: number) { this.ctx.strokeStyle #F44336 this.ctx.lineWidth 2.5 this.ctx.beginPath() this.ctx.arc( this.mathToScreenX(a), this.mathToScreenY(b), r * GRID_SIZE, 0, 2 * Math.PI ) this.ctx.stroke() }这里有一个很容易忽略的问题Canvas的arc不支持负半径如果用户在滑动条上把半径r拖到0代码会直接报错或者什么都不画。所以滑动条的最小值要设为0.1而且在绘制前做一次if (r 0) return双保险。另外如果半径很大圆会超出画布但Canvas会自动裁剪这不用你操心真正需要关心的是用户能不能看清整条曲线所以滑动条的max值要根据网格范围来定我设置为5因为画布单方向最多显示6个单位。4. 界面装配与交互联动4.1 参数面板与滑动条绘图区画好了接下来要把它和UI装配起来。HarmonyOS应用实例里最常见的做法是用Column把画布和控件纵向排列再配合if条件渲染不同模式的参数面板。下面这段代码是核心骨架我把模式切换和参数控件都放在其中build() { Column() { Text(this.buildEquationText()) .fontSize(18) .fontWeight(FontWeight.Bold) .margin({ top: 16, bottom: 8 }) Canvas(this.ctx) .width(CANVAS_WIDTH) .height(CANVAS_HEIGHT) .backgroundColor(#FAFAFA) .borderRadius(8) .margin(12) .onReady(() { this.drawGraph() }) Row({ space: 12 }) { Button(直线方程) .backgroundColor(this.currentMode GraphMode.Line ? #2196F3 : #999999) .onClick(() { this.currentMode GraphMode.Line this.drawGraph() }) Button(圆的方程) .backgroundColor(this.currentMode GraphMode.Circle ? #F44336 : #999999) .onClick(() { this.currentMode GraphMode.Circle this.drawGraph() }) } .margin({ top: 8 }) if (this.currentMode GraphMode.Line) { Column() { Text(斜率 k ${this.lineK.toFixed(1)}) Slider({ value: this.lineK, min: -5, max: 5, step: 0.1 }) .onChange((value: number) { this.lineK value this.drawGraph() }) Text(截距 b ${this.lineB.toFixed(1)}) Slider({ value: this.lineB, min: -5, max: 5, step: 0.1 }) .onChange((value: number) { this.lineB value this.drawGraph() }) } } else { Column() { Text(圆心 a ${this.circleA.toFixed(1)}) Slider({ value: this.circleA, min: -5, max: 5, step: 0.1 }) .onChange((value: number) { this.circleA value this.drawGraph() }) Text(圆心 b ${this.circleB.toFixed(1)}) Slider({ value: this.circleB, min: -5, max: 5, step: 0.1 }) .onChange((value: number) { this.circleB value this.drawGraph() }) Text(半径 r ${this.circleR.toFixed(1)}) Slider({ value: this.circleR, min: 0.1, max: 5, step: 0.1 }) .onChange((value: number) { this.circleR value this.drawGraph() }) } } } }有一点需要重点提醒Slider组件的value参数虽然是双向绑定的但为了触发Canvas重绘我仍然在每个onChange回调里手动调用了一次drawGraph()。这是因为State驱动的是ArkUI组件树刷新它不会自动通知CanvasRenderingContext2D重新绘制。如果你发现改了参数图形不更新十有八九就是漏了这一步。4.2 方程文本实时生成只画图像不加方程文本这个工具的教育价值就少了一半。我在Text组件里调用buildEquationText()根据当前模式生成对应的数学表达式字符串。为了把各种边界情况处理好k等于0、b为负数、圆心的正负号我专门封装了一个方法private buildEquationText(): string { if (this.currentMode GraphMode.Line) { const k this.lineK const b this.lineB let kPart if (Math.abs(k) 0.05) { kPart 0 } else if (Math.abs(k - 1) 0.05) { kPart x } else if (Math.abs(k 1) 0.05) { kPart -x } else { kPart ${k.toFixed(1)}x } if (Math.abs(b) 0.05) { return y ${kPart} } const sign b 0 ? : - return y ${kPart} ${sign} ${Math.abs(b).toFixed(1)} } else { const a this.circleA const b this.circleB const r this.circleR const aPart a 0 ? (x - ${a.toFixed(1)}) : (x ${Math.abs(a).toFixed(1)}) const bPart b 0 ? (y - ${b.toFixed(1)}) : (y ${Math.abs(b).toFixed(1)}) return ${aPart}² ${bPart}² ${r.toFixed(1)}² } }这个文本生成逻辑不复杂但很能体现“从用户角度思考”方程里的正负号要让用户一眼看出变化规律。比如圆心在(-2, 1)时显示成(x 2)² (y - 1)²再合适不过你要是生硬地输出(x - -2)²那就闹笑话了。4.3 布局与视觉细节交互做完了还得把视觉调舒服。我在实际开发里调整过几次发现几个值得注意的点第一画布背景用接近白色但带一点灰度的#FAFAFA比纯白看着柔和网格线用#E8E8E8坐标轴用#333333这样图层之间有明显的层次感。第二直线和圆的颜色固定为蓝色系和红色系保证用户在不看文字的情况下也能通过颜色区分当前图形类型。第三滑动条控件的宽度要和画布对齐左右留相同的边距否则视觉上会歪。还有一个容易被忽略的细节切换直线/圆模式时旧图形要立刻清掉只保留新模式的图像。我的做法是drawGraph()一开始就调用ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)把画布彻底清空然后再从头画底图。这样即使多次快速切换也不会出现残影。5. 常见问题与踩坑记录5.1 高频问题速查表开发过程中我踩了不少坑有些问题网上资料不多这里整理成一张速查表方便你对照排查现象根本原因解决方案改了滑动条图形不刷新State驱动的是组件树刷新Canvas不会自动重绘在onChange里手动调用drawGraph()直线斜率很大时线条消失或飞出屏幕根据x范围计算出的端点超出视口线段与视口无交点用y范围反推x或使用直线裁剪算法图形上下颠倒没做数学坐标系到屏幕坐标系的y轴转换用 sy originY - my * scale圆半径拖到0时出现异常arc不支持负半径滑动条min设为0.1绘制前做r0判断频繁拖动滑动条卡顿每次onChange都做完整重绘缺少必要剪裁减少无关绘制网格部分可以提缓存成离屏位图真机上文字模糊Canvas渲染分辨率没有适配设备像素比根据设备像素比调整Canvas尺寸表格里的每一项都是实操中会真实撞上的问题尤其是第一行几乎每个人都会遇到。我排查“改参数图形不刷新”这个问题时一度以为是Canvas API的问题后来看了文档和源码才反应过来Canvas的绘制是命令式的它不在ArkUI的响应式系统管辖范围内必须手动触发重绘。5.2 几个容易忽略的实现细节除了上面这些明显报错还有一些“不报错但效果不对”的细节比如刻度文字和网格线重叠。画刻度数字时要给数字加上偏移量让数字位于轴线的一侧而不是压在线上。直线的k值在小数位变化时buildEquationText里对k1的判断要用误差范围不要直接比较浮点数。因为滑动条的step是0.1用户拖到正好k1时内部浮点值可能是0.9999999直接比较会出错。坐标轴箭头。默认的Canvas没有提供“画箭头”API如果你希望坐标系更接近课本风格可以自己用两条短线段在轴端点拼出箭头代码量不大但对视觉观感提升很明显。这些细节不会让程序崩溃但会直接影响使用体验。尤其是方程文本里的浮点显示我记得初次版本用户把k拖到1时文本显示的居然是y 1.0x虽然没错但总感觉不如y x干净。后来我用误差范围判断解决了该简化的简化该保留一位小数的保留一位小数。5.3 后续可以扩展的方向这个HarmonyOS应用实例做到现在这个程度其实已经适合日常使用。但如果想继续进阶我建议往下面几个方向加功能直线和圆的位置关系计算。根据圆心到直线距离d与半径r的大小关系自动提示“相交、相切、相离”这是高中数学的重点也是很好的算法练习。更丰富的直线方程输入。把用户输入的“一般式”Ax By C 0转成“斜截式”显示需要处理B为0的特殊情况垂直线x const。多图形叠加。允许用户同时画多条直线和多个圆再叠加显示就是一个小型几何探究工具。支持手势缩放和平移。利用PinchGesture和PanGesture让用户自由缩放网格范围这会把图形交互体验提升一大截。我目前正在做的是第一个方向也就是直线与圆交点计算。它的核心代码不复杂先算圆心到直线的距离再根据d和r的关系求交点坐标但放到HarmonyOS里做实时联动时各种边界情况还是会反复出现挺锻炼人的。最后说点个人体会。做这类偏工具型的HarmonyOS应用实例最大的收获不是把某个API背熟了而是建立了一种“数据模型-界面状态-绘图命令”三者联动的直觉。以后无论做图表组件、可视化大屏还是小游戏这套思路都能直接迁移。如果你现在也在学ArkTS强烈建议找个图形相关的题材自己动手做一遍投入产出比远高于刷一堆语法示例。
返回列表