ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

HarmonyOS Canvas绘制复平面:复数运算可视化应用开发实战

HarmonyOS Canvas绘制复平面:复数运算可视化应用开发实战 复数这个东西高中课本里说它是“虚数”但等到你做信号处理、电路分析、图形变换的时候才知道它一点都不虚。最近我在HarmonyOS上做了一款“复数运算可视化”的小应用说到底就是把复数的加减乘除、模长、幅角这些抽象概念放到一个可交互的复平面上画出来。这篇文章我打算把整个实现思路、界面搭建、数学计算、踩坑记录都摊开讲给正在折腾HarmonyOS应用开发或者想在移动端做数学可视化的小伙伴一个能直接参考的样板。这个实例适合两类人一类是已经入门ArkUI、想找一个综合练手项目的中级开发者另一类是对复数运算本身感兴趣、想用图形化方式理解数学的教学爱好者。我们最终要做的效果是输入两个复数选择运算方式屏幕上的复平面坐标系会立刻画出对应的向量、运算结果点以及必要的辅助虚线。整个过程不需要任何第三方库基于HarmonyOS自带的Canvas组件就能完成。1. 项目定位与整体设计思路1.1 为什么选择“复数运算可视化”作为应用实例先说说项目选题的动机。很多HarmonyOS示例代码都在做列表、登录、扫码这类业务向的东西很少碰到带数学计算和图形绘制的场景。复数的可视化天然涉及两件最考验开发能力的事一是坐标系的数学映射二是自定义绘制。如果能把这两块吃透以后你去做图表库、画板、游戏里的UI层都会顺手得多。从产品角度看复数运算可视化不是纯粹的玩具它有实在的使用场景。比如电子专业的同学在学交流电路时阻抗和导纳都是复数拿这个工具一画就能看出串并联后的相位变化再比如学《信号与系统》的时候单位圆上的复数对应着不同频率的响应旋转因子画出来比背公式直观多了。我最早想起做这个就是因为前阵子给朋友讲欧拉公式讲了半天不如画一个点在单位圆上绕一圈来得清楚。1.2 技术选型为什么坚持用Canvas而不是第三方图表库做图形可视化摆在我面前的有两条路一是引入第三方图表库比如ECharts或者MPAndroidChart的鸿蒙适配版本二是老老实实用ArkUI自带的Canvas组件手绘。经过一番思考我选了后者原因很简单这个场景的图形结构非常固定撑死就是坐标轴、向量箭头、圆、文字标注这几样引入一个重量级图表库反而要大材小用。更关键的一点是Canvas手绘能让我完全掌控渲染细节。比如拖动坐标轴缩放的时候第三方库需要去翻文档看它支不支持而自己画的坐标系想怎么改就怎么改。而且HarmonyOS的Canvas API设计得已经比较成熟了基础的路径绘制、弧线、渐变都支持得很好画一个复平面完全够用。在HarmonyOS里实现自定义绘制核心是使用Canvas组件配合CanvasRenderingContext2D对象。大体结构是这样的Entry Component struct ComplexPlanePage { private settings: RenderingContextSettings new RenderingContextSettings(true) private context: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings) build() { Canvas(this.context) .width(100%) .height(70%) .onReady(() { // 初始化完成后立即绘制坐标系 this.drawCoordinateSystem() }) } }RenderingContextSettings构造参数里那个true是开启抗锯齿画直线、圆的时候边缘会平滑很多。第一次接触Canvas的朋友容易忽略这个参数结果画出来的线条跟狗啃过似的其实就是这里没开。1.3 页面功能规划从输入到反馈的闭环整个应用我从功能上拆成了三块输入区两个复数的实部、虚部以及运算类型选择。输入复数的形式我用了最直观的“实部虚部”双输入框没有让用户去敲abi这种字符串因为字符串解析在处理负数、空格、小数点的时候容易出各种边界问题开发效率和用户体验都差一些。图形展示区复平面坐标系包含实轴、虚轴、网格线、向量、运算结果标识点。这一块是最核心的视觉输出。说明区展示当前运算的数学表达式和关键结果数据比如模长、辐角、实部虚部分别是什么。这一块看似简单但恰恰是“可视化”之外最有价值的信息补充。三个区域的排布我最终选了上下结构输入区和说明区放在屏幕下方图形展示区占据上方大块空间。因为复平面是一个二维坐标空间横向空间越充足坐标系的可读性就越高。这里还有一个细节就是让坐标系根据设备分辨率自适应保证在手机和平板上都能完整展示。2. 复数的数学原理与坐标映射方案2.1 复数的几何意义与四则运算要在屏幕上画出复数先得把数学概念和图形语义对应起来。一个复数z a bi在复平面上对应一个点(a, b)同时也可以看作是从原点指向该点的一个向量。这个向量有两个特征值模长|z| sqrt(a² b²)表示向量的长度幅角θ arctan(b/a)表示向量与实轴正方向的夹角。复数的四则运算在几何上有非常直观的呈现加法两个复数相加等价于两个向量按照平行四边形法则进行合成。减法两个复数相减得到的是从减数指向被减数的差向量恰好对应两点之间的相对位置。乘法模长相乘、幅角相加反映在图上就是原向量进行缩放并旋转。除法模长相除、幅角相减反之缩放并反向旋转。我在应用的说明区里会把几何语义和公式一起显示出来比如乘法结果显示“将z1的向量拉伸|z2|倍再逆时针旋转arg(z2)”这样用户不光能对照公式还能理解图形上为什么结果点出现在那个位置。这也正是可视化应用脱离“计算器”定位的关键用户看到的不只是数字而是数字变化背后的几何逻辑。2.2 世界坐标到屏幕坐标的转换画坐标系绕不开坐标映射的问题。数学里我们习惯实轴向右、虚轴向上但Canvas的屏幕坐标系是x轴向右、y轴向下。如果不做转换直接画虚轴方向就反了画出来的图形整体上下颠倒看着非常别扭。所以我在代码里建了一个转换函数/** * 复平面坐标转屏幕坐标 * param point 复平面坐标 { x: 实部, y: 虚部 } * param origin 原点对应的屏幕坐标 * param scale 像素缩放比例 */ private toScreen(point: Point, origin: Point, scale: number): Point { return { x: origin.x point.x * scale, y: origin.y - point.y * scale } }核心就一行逻辑屏幕y坐标等于原点y坐标减去复平面y坐标乘以缩放比例。减号是关键它把坐标系方向翻转了。实际做项目的时候最好把toScreen和配套的toMath函数都封装在工具类里方便后续在做点击取点、框选缩放时反向转换。缩放比例的选取也需要动点脑筋。画面默认要显示实部[-8, 8]、虚部[-8, 8]的范围那么缩放比例就由Canvas的宽高决定// 在canvas的onReady回调中计算 const width this.context.width const height this.context.height // 留出边距防止最边缘的点被截断 const margin 40 this.scale Math.min((width - margin * 2) / 16, (height - margin * 2) / 16)这里除以16是因为范围长度是8-(-8)16左右留白是防止点在边界被裁剪掉。如果画布是300px宽缩放比例大约是13.75正好能容纳一个[-8,8]的坐标窗口。2.3 交互设计的几个决策点可视化应用不只是“画出来”还得考虑用户怎么理解画面。我在交互设计上做了三个决策第一两种图形表达模式。默认模式是“向量视角”从原点出发画出两个输入复数的向量如果用户勾选了“点视角”则只显示坐标点本身不画箭头。向量模式适合看加减法的合成过程点模式适合看乘除法的位置变化两种模式互补。第二结果高亮与辅助线。运算结果用填充色圆点突出显示并且从结果点向实轴和虚轴分别画一条虚线虚线与坐标轴的交叉点会标注出实部、虚部的具体数值。这样用户看图就能直接读出结果的三要素不需要再回到输入框去看数字。第三旋转观察。我加了一个“生成旋转轨迹”的按钮可以把z1向量按z2的模长逐帧旋转把连续旋转过程中每个时刻的结果点连成曲线。这个功能其实是在展示z1 * z2的另一种理解——复数乘法在连续旋转中的轨迹形态。这个功能花了不少功夫做出来之后所有看过的朋友都觉得特别直观。3. ArkUI界面与核心绘制实现3.1 页面结构用相对布局稳住大画面页面结构我用的是Column加Stack的组合。顶部是一个Stack容器里面放Canvas和坐标刻度文字层底部是输入控件和说明卡片整体用Column纵向排布。这里有一个经验不要让Canvas直接铺满屏幕宽度最好给左右各留一点padding否则坐标轴最边上的刻度字容易贴边看起来不精致。输入控件方面我用了GridRow和GridCol做响应式排列。四个输入框实部1、虚部1、实部2、虚部2在手机上两两排成两行在平板上可以变成一行四个。关键是输入框的type要设置为InputType.NUMBER_DECIMAL允许输入小数和负号要不然用户想算负数的复数都输不进去。给一个关键的UI代码片段GridRow({ columns: { sm: 2, md: 4 }, gutter: { x: 12, y: 12 } }) { GridCol() { TextInput({ placeholder: 实部1, text: this.real1 }) .type(InputType.NUMBER_DECIMAL) .onChange((value: string) { this.real1 value }) } GridCol() { TextInput({ placeholder: 虚部1, text: this.imag1 }) .type(InputType.NUMBER_DECIMAL) .onChange((value: string) { this.imag1 value }) } // ... 实部2、虚部2 同理 }3.2 坐标轴与网格线的绘制把基础图形画到规范坐标轴这部分看起来简单做得细致与否直接决定应用的质感。有两点必须注意一是刻度要分主次主轴上的整数刻度画长线中间的小刻度画短线这样坐标范围一目了然二是轴上的数值标注必须有否则用户根本分不清一个点到底对应实部几、虚部几。我在绘制时把逻辑分了三层网格层用浅灰色画单位网格线透明度设为0.3辅助定位但不干扰主视觉。坐标轴层实轴和虚轴用深色实线加粗绘制同时用箭头标出正方向。标注层以axisFontSize绘制刻度数字实轴数字写在轴线下方虚轴数字写在轴线左侧。绘制坐标轴的代码结构大致如下private drawCoordinateSystem() { const ctx this.context ctx.clearRect(0, 0, this.context.width, this.context.height) this.drawGrid() this.drawAxes() this.drawTicks() }这里我强调一下clearRect的重要性。HarmonyOS的Canvas不会在每次绘制前自动清空画布如果你刷新数据后忘记清空新的图形会和旧的叠在一起出现严重的残影。这一点跟网页Canvas的行为是一样的很多从Android自定义View转过来的朋友容易忽略。3.3 向量与运算结果的可视化绘制向量是复数可视化里的主角。我画向量不是简单画一条线而是用箭头收尾private drawVector(start: Point, end: Point, color: string) { const ctx this.context ctx.beginPath() ctx.moveTo(start.x, start.y) ctx.lineTo(end.x, end.y) ctx.strokeStyle color ctx.lineWidth 2.5 ctx.stroke() // 绘制箭头 const angle Math.atan2(end.y - start.y, end.x - start.x) const arrowLen 12 const arrowAngle Math.PI / 6 ctx.beginPath() ctx.moveTo(end.x, end.y) ctx.lineTo(end.x - arrowLen * Math.cos(angle - arrowAngle), end.y - arrowLen * Math.sin(angle - arrowAngle)) ctx.moveTo(end.x, end.y) ctx.lineTo(end.x - arrowLen * Math.cos(angle arrowAngle), end.y - arrowLen * Math.sin(angle arrowAngle)) ctx.stroke() }箭头的角度计算用atan2而不是atan原因在于atan无法区分向量处在哪个象限而atan2能根据x和y的正负直接返回正确的方位角。这是一个细节但直接决定箭头方向是否正确。运算结果点的绘制相对简单画一个带边框的实心圆即可但要注意结果点可能与输入点重合比如算23i减去23i时结果就是原点。这时候我专门在结果点周围加了一个半透明的外圈确保用户能看到原点处有结果而不是什么都没画。4. 复数计算模块与状态管理设计4.1 复数运算工具类的实现计算逻辑我抽成了一个独立的工具类ComplexUtil包含加、减、乘、除、模长、辐角等静态方法。之所以抽成独立类是因为UI层和计算层解耦之后后续如果要加单元测试或者复用给其他页面都很方便。核心计算代码大概长这样export class ComplexUtil { static add(a: Complex, b: Complex): Complex { return { real: a.real b.real, imag: a.imag b.imag } } static multiply(a: Complex, b: Complex): Complex { return { real: a.real * b.real - a.imag * b.imag, imag: a.real * b.imag a.imag * b.real } } static divide(a: Complex, b: Complex): Complex | null { const denominator b.real * b.real b.imag * b.imag if (Math.abs(denominator) 1e-10) return null // 除数为零 return { real: (a.real * b.real a.imag * b.imag) / denominator, imag: (a.imag * b.real - a.real * b.imag) / denominator } } }这里有个重要的工程判断除数为零的判断不能写成denominator 0因为浮点数运算中分母可能是一个极小的非零值但在数学上等价于零。我用1e-10作为容差当前端用户输入20i除以00i时界面会弹出提示而不是渲染一个无穷大坐标导致绘制异常。辐角的计算同样推荐用Math.atan2(imag, real)这样得到的角度范围是[-π, π]如果后续需要显示在[0, 2π)区间再加一个if (angle 0) angle 2 * Math.PI的判断即可。4.2 状态管理与数据流HarmonyOS的ArkUI框架使用State装饰器管理组件状态。我在页面里维护了这些状态变量State real1: string 3 State imag1: string 4 State real2: string 0 State imag2: string 2 State currentOp: string State result: Complex { real: 3, imag: 6 } State isVectorMode: boolean true当用户修改任何一个输入框的值或者切换运算类型我都通过onChange事件触发handleCalculate()方法重新计算结果并调用drawAllShapes()重绘画布。这种“单向数据流”的模式很清晰数据在状态变量里绘制函数只负责读状态变量并渲染不直接修改状态。不过HarmonyOS的状态管理有一个性能坑State修饰的变量变化会触发组件重绘。如果你的页面里有大量的State字符串变量在频繁变化比如输入框每次敲键都触发计算和重绘低端设备上可能会出现掉帧。我的优化方案是输入框的onChange只更新字符串状态在onSubmit或者说在用户松手后的防抖时间内才做计算和绘制。具体实现可以用一个setTimeout做300毫秒防抖避免每次按键都全量重绘画布。4.3 重绘性能优化Diff运算与分层绘制在真机上调试时我发现一个现象输入变化导致重绘时如果每次都把网格、坐标轴、刻度、向量全部重画一遍会有肉眼可见的闪烁。原因是网格和坐标轴的绘制路径太长重绘耗时接近40毫秒刚好卡在掉帧边界。优化思路是分层。我在onReady阶段先把网格、坐标轴、刻度绘制到一个离屏Canvas上之后用户交互时只重绘上层的向量和结果点。HarmonyOS里可以通过创建离屏Canvas来处理也可以用OffscreenCanvas取决于SDK版本。我实际用的是提前把静态背景保存为PixelMap然后在主Canvas上通过drawImage快速贴背景再绘制动态内容这样重绘耗时就降到10毫秒左右。给一个伪代码示例// 初始化阶段只绘制一次背景 private prepareBackground() { // 根据Canvas尺寸创建离屏画布 const offCanvas new OffscreenCanvas(this.canvasWidth, this.canvasHeight) const offCtx offCanvas.getContext(2d) offCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) this.drawGridAndAxes(offCtx) // 保存为ImageData或bitmap供后续使用 this.backgroundBitmap offCanvas.transferToImageBitmap() } // 重绘阶段贴背景 画动态层 private drawDynamicLayer() { const ctx this.context ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) ctx.drawImage(this.backgroundBitmap, 0, 0) this.drawVectors() this.drawResultPoint() }如果你的SDK版本不支持离屏Canvas还有一个折中方案把背景绘制逻辑抽成一个函数drawDynamicLayer里只重绘向量和结果静态背景在onReady里画一次之后不动它但前提是你得保证上层图形不会用clearRect把背景清掉。我用的是画布分层方案更干净。5. 实操过程、调试踩坑与经验总结5.1 从零到跑的完整步骤如果你要跟着复现这个项目我建议的步骤是在DevEco Studio里新建一个Empty Ability工程语言选ArkTS兼容版本设置成API 9以上。先把页面骨架搭起来放一个铺满的Canvas组件确认onReady回调能正常执行。实现drawCoordinateSystem先画坐标轴和网格线不涉及数据输入方便验证坐标映射是否正确。添加输入区控件绑定State变量。实现ComplexUtil计算工具类先在控制台用console.log打印计算结果保证加减乘除四则运算正确。把计算结果显示到画布上画向量和结果点。最后做交互优化比如切换布局模式、添加旋转轨迹功能。这套顺序的核心思路是先静态后动态、先核心后外围。每次改动只引入一个变量出了bug也容易定位。5.2 我踩过的几个坑坑一Canvas的onReady时机问题。一开始我在aboutToAppear里就初始化Canvas并尝试绘图结果发现this.context拿到了但页面宽度还是0。后来明白这两个生命周期函数执行的时机不同和布局相关的尺寸信息必须等onReady回调触发后才能安全获取。解决方案是把所有依赖尺寸的初始化逻辑都挪到onReady里。坑二负数输入框的正则校验。使用InputType.NUMBER_DECIMAL虽然支持小数但有些输入法会屏蔽负号输入。用户想输入-3的时候发现输不进去体验非常断裂。我的解决方式是在输入框边上加一个正负号切换的按钮点击后自动给当前值取反规避了输入法兼容性问题。坑三字体大小在不同设备上的适配。坐标轴的刻度数字如果设置成固定12fp在平板设备上会显得很小。我按屏幕宽度动态计算字体大小Math.max(10, Math.min(16, this.canvasWidth / 45))这样小屏不显拥挤大屏不显小气。坑四除数为0的显示处理。刚开始没有做除数检查用户输入除数为0时界面直接显示了NaN。更麻烦的是NaN传进Canvas绘制坐标时线路坐标变成空路径整个图形不刷新。后来工具类里统一做了非法值检测把异常情况返回null页面显示“除数为0无法计算”的提示问题消失。5.3 真机调试与交互体验的优化建议开发过程中我用的是HarmonyOS模拟器加真机两种方式。这里分享一个经验模拟器上Canvas渲染没问题不代表真机没问题主要是因为真机屏幕尺寸、像素密度、字体渲染差异都会带来布局偏移。建议在项目初期就准备好一台真机每次都同步验证。关于性能我在中低端设备上实测了重绘逻辑。优化前每次按键触发全量重绘帧率能掉到45fps左右优化后使用离屏背景加动态分层绘制帧率稳定在60fps。这个数据对普通用户来说感知不强但如果你后续要加拖拽旋转或者连续动画性能优化必须提前做。最后再分享一个小技巧在绘制结果点的时候把鼠标/手指按下的位置实时显示为当前复数坐标。我后来给Canvas加了一个点击事件用户点哪里坐标轴下方就显示那个点对应的复数值。这个功能调试起来相当好用别人用你的应用时也觉得非常直观等于免费送了一个坐标读取器。类似的小交互在数学可视化类应用里往往比复杂功能更受欢迎。
返回列表