复数这个东西,高中课本里说它是“虚数”,但等到你做信号处理、电路分析、图形变换的时候才知道它一点都不虚。最近我在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 页面功能规划:从输入到反馈的闭环
整个应用我从功能上拆成了三块:
- 输入区:两个复数的实部、虚部,以及运算类型选择。输入复数的形式我用了最直观的“实部+虚部”双输入框,没有让用户去敲
a+bi这种字符串,因为字符串解析在处理负数、空格、小数点的时候容易出各种边界问题,开发效率和用户体验都差一些。 - 图形展示区:复平面坐标系,包含实轴、虚轴、网格线、向量、运算结果标识点。这一块是最核心的视觉输出。
- 说明区:展示当前运算的数学表达式和关键结果数据,比如模长、辐角、实部虚部分别是什么。这一块看似简单,但恰恰是“可视化”之外最有价值的信息补充。
三个区域的排布,我最终选了上下结构:输入区和说明区放在屏幕下方,图形展示区占据上方大块空间。因为复平面是一个二维坐标空间,横向空间越充足,坐标系的可读性就越高。这里还有一个细节,就是让坐标系根据设备分辨率自适应,保证在手机和平板上都能完整展示。
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的正负直接返回正确的方位角。这是一个细节,但直接决定箭头方向是否正确。
运算结果点的绘制相对简单,画一个带边框的实心圆即可,但要注意结果点可能与输入点重合,比如算2+3i减去2+3i时结果就是原点。这时候我专门在结果点周围加了一个半透明的外圈,确保用户能看到原点处有结果而不是什么都没画。
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作为容差,当前端用户输入2+0i除以0+0i时,界面会弹出提示而不是渲染一个无穷大坐标导致绘制异常。
辐角的计算同样推荐用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加了一个点击事件,用户点哪里,坐标轴下方就显示那个点对应的复数值。这个功能调试起来相当好用,别人用你的应用时也觉得非常直观,等于免费送了一个坐标读取器。类似的小交互,在数学可视化类应用里往往比复杂功能更受欢迎。