1. 项目背景与需求拆解
1.1 这个应用在解决什么问题
数轴这个东西,从初中第一次接触有理数开始,就一直陪着我们。到了高中引入无理数、大学接触极限和微积分,数轴更是绕不开的基本工具。但有个痛点:课本上的数轴是静态的,画好了一个区间就固定在纸面上。学生想看 2/7 落在哪里、√2 和 1.41 差多少、π 到底在 3.14 还是 3.15 之间,靠一张静态图很难讲清楚。更麻烦的是,数字的大小跨度极大,从 0.001 到 10 亿,如果都按同样的刻度画,小数字根本看不见。
这次做第 133 个 HarmonyOS 应用实例,我选了一个小而巧的题目:实数数轴定位器。目标很简单——输入任意一个实数,应用立刻把它的位置标在数轴上,刻度自动适配,视野可以缩放和平移,精度和显示效果都做到位。它不是一个特别复杂的大项目,但把数学概念、坐标换算、图形绘制、交互手感这些环节全部串了起来,是一个非常典型的“小而全”的实战案例。
1.2 把需求拆成可落地的最小清单
动手之前,我先把需求拆成了五条,每一条都不是摆设:
- 支持输入任意实数,包括小数、整数、负数、分数,以及 π、√2 这些常见无理数。
- 在数轴上用醒目的标记点显示目标值的位置,同时显示数值标签。
- 刻度自适应:根据当前视野范围,自动选择合理的刻度间隔,不能挤成一团,也不能稀稀拉拉。
- 支持缩放和平移:看大数能缩小,看小数能放大,整个数轴可以拖动。
- 显示正向箭头、原点、刻度线和坐标值,保持数轴该有的数学规范。
这五条如果单独看,每一条都不难。但合在一起,就会涉及坐标换算、绘制性能、交互手感和边界条件处理,正好适合作为中等难度的实战项目来练手。
1.3 技术选型:ArkTS 与 Canvas 的组合
技术栈选择了 ArkTS 加 ArkUI 的声明式开发,绘图部分用 Canvas 组件。我的考虑是这样的:
页面结构、按钮状态、输入内容这些用声明式组件来管理非常合适,逻辑清晰、状态联动方便。但数轴本身需要画刻度线、箭头、标签、标记点,而且只要缩放手势一变,整条轴就要重新绘制。这种情况下如果我用一堆声明式组件去拼,比如每个刻度都放一个 Text 加一个 Divider,性能和代码量都不可接受。Canvas 是像素级控制,重绘一次非常干净,刻度数量再多也就是一次性画完。
打个比方:如果把这个项目比作画一幅地图,用声明式组件做,等于把每个路口都搭一个实体指示灯;用 Canvas 做,则是直接在一张纸上把整条路网画出来。地图一换,纸一换就没了,效率完全不同。
2. 整体设计与数据模型
2.1 页面布局与交互流程
整体布局是典型的“上中下”结构:顶部是输入区和定位按钮,中间是数轴画布,底部放缩放控制按钮。手机默认竖屏,这种布局最顺手。
交互流程也不复杂:用户先输入一个实数,点“定位”按钮,或者直接点键盘上的搜索键触发定位。应用解析字符串,如果成功就把目标值保存下来,然后调整视野中心到目标值附近,重绘数轴。底部的加减按钮负责缩放,每次点击把像素缩放比例放大或缩小一倍。缩放时视野中心尽量保持在屏幕中间,这样用户盯着的位置不会突然跑出屏幕。
我额外加了一个细节:点击定位之后,数轴会先把目标值放到视野中央,而不是保持原有视野。这个设计的理由是——定位器的核心诉求是“看到这个东西在哪”,如果目标值离当前视野很远,不调整中心的话会画在屏幕外,用户还以为没定位成功。
2.2 核心模型:从实数轴到屏幕像素
整个应用最核心的数据模型,就是实数轴坐标和屏幕像素坐标之间的换算。逻辑上它非常简单,就是一次线性变换。
我定义了两个关键参数:
- centerValue:当前视野中心对应的实数值。
- scalePx:每 1 个实数单位对应的像素数。
假设画布宽度为 viewWidth,那么某个实数 value 对应的屏幕横坐标就是:
pixelX = viewWidth / 2 + (value - centerValue) * scalePx反过来,如果用户点击了屏幕上某个位置,想知道它对应的实数值,就用:
value = centerValue + (pixelX - viewWidth / 2) / scalePx这个模型是整个应用的“地基”。所有刻度绘制、标记点定位、手势换算,翻来覆去用的都是这两条公式。实际写的时候我把它们封装成两个私有方法,后面所有地方都调这两个方法,绝对不要到处复制公式。
2.3 为什么用 Canvas 而不是声明式组件
很多第一次做绘图类应用的开发者,会习惯性地想:“我能不能直接用 Column 和 Text 拼一条数轴?” 我明确告诉你,可以做,但代价很大。
假设当前视野需要显示 20 个主刻度,每个主刻度下还有 5 个次刻度,加起来就是 100 多个元素。如果你用声明式组件,每个刻度都是一个组件节点,页面会瞬间塞进上百个节点,渲染压力和内存占用都会明显上升。而且每次缩放平移,这些节点的位置、文本内容都要重新计算,很难做到流畅。
Canvas 的重绘机制就舒服多了:每次只需要在一个 2D 上下文里连续画线、画字、画点,画完一次性呈现。刻度数量再多,也就是多画几十条线,性能完全可控。这也是我最后确定用 Canvas 的根本原因。
3. 难点攻破:刻度算法与精度控制
3.1 自适应刻度:1、2、5 黄金序列
数轴能不能用,刻度算法占了很大比重。如果我在屏幕宽度固定的情况下,每隔固定像素画一条刻度,视野一缩放就会出现两种极端:要么刻度挤成一团,要么稀疏得只剩一根轴。
解决办法是让刻度间隔“自适应”,也就是根据当前视野范围,算出一个合理的步长。这里我采用的是经典做法——从 1、2、5 及其 10 的幂次倍中选一个值。为什么必须是 1、2、5?因为这三个数字乘以任意 10 的幂,得到的刻度间隔在人类阅读习惯里最舒服。比如 0.1、0.2、0.5、1、2、5、10、20、50,这样的刻度间距不会出现 0.3 或者 7 这种奇怪的间隔,标签读起来一目了然。
具体算法的思路是这样的:
1. 计算当前视野能看到的实数范围 range 2. 设置一个目标刻度数量,比如 8 到 10 个主刻度比较合适 3. roughStep = range / 8,得到粗略步长 4. 取 roughStep 的数量级,归一化到 1 到 10 之间 5. 根据归一化后的值落在哪个区间,选择 1、2、5 或 10 6. 实际步长 = 选择值 × 10 的数量级这一步是整个自适应刻度算法的灵魂,写的时候要注意一个小细节:Math.pow 计算出来的 10 的幂,在浮点数运算下偶尔会多出极小误差,比如 10 的 -2 次方可能表示成 0.009999999999999998。我在拿到最终步长后,会做一个近似取整操作,保证刻度间隔是标准的 0.01、0.1、1、10 这样干净的数字。
3.2 刻度标签如何避免浮点误差
就算刻度间隔算对了,标签文本也藏着一个浮点陷阱。比如当前刻度间隔是 0.2,起始刻度是 -1,然后依次累加,得到的刻度值应该是 -1、-0.8、-0.6、-0.4…… 但由于二进制浮点数的表示方式,-0.8 实际存储的值可能是 -0.8000000000000001。如果直接把 number 转成字符串,屏幕上就会出现令人崩溃的“-0.8000000000000001”。
解决办法是:根据当前步长动态计算小数位数。我用一个函数,把步长转成字符串,然后数小数点后有几位,就保留几位。比如步长 0.2 就是 1 位小数,步长 0.01 就是 2 位小数。然后在绘制标签时调用 toFixed(decimals)。
这个方法能覆盖绝大多数情况。还有一个额外的小技巧:对计算结果做一次“就近取整”。比如某刻度值是 -0.8000000000000001,我先乘以 10 的 decimals 次方,再 Math.round,最后除以 10 的 decimals 次方,这样既能显示成 -0.8,又不会在运算中把误差反复放大。
3.3 输入解析:小数、分数与无理数
定位器如果只能输入小数,体验会显得很单薄。我在解析层做了三种格式的支持:
第一是普通十进制数,直接用 parseFloat 就能处理,包括负数和小数。
第二是分数,支持 “3/4”、“-2/7” 这种格式。解析逻辑也很简单:找到斜杠,把分子分母分别解析成数字,分母不为 0 就求商。
第三是特殊无理数,支持 “π”、“-π”、“√2”、“-√2”、“2√3” 这类常见写法。解析 π 直接返回 Math.PI;解析 √2 则要注意,字符“√”不是标准键盘上的符号,HarmonyOS 的输入法能不能输出来要看输入法,所以我同时兼容了 “sqrt(2)” 这种纯文本写法。
解析完成后还有一个校验动作:判断结果是否是有限数。如果用户输入的是 “1/0”,或者直接输入一长串乱码,结果会是 Infinity 或者 NaN,这种情况不能直接使用,必须弹提示告诉用户输入无效。
4. 核心代码实现:从骨架到完整绘制
4.1 状态与页面骨架
先看页面骨架。我用 @Entry 修饰一个组件,内部用 @State 管理输入文本、目标值、视野中心和缩放比例。画布上下文用 CanvasRenderingContext2D 对象保存在成员变量里。
@Entry @Component struct NumberAxisLocator { @State inputText: string = '' @State targetValue: number = 0 @State centerValue: number = 0 @State scalePx: number = 120 @State tipMessage: string = '' @State hasLocated: boolean = false private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D() private viewWidth: number = 0 private viewHeight: number = 0 private tickTimer: number = -1 build() { Column({ space: 12 }) { Row({ space: 8 }) { TextInput({ placeholder: '输入实数,如 3/4、√2、π', text: this.inputText }) .layoutWeight(1) .type(InputType.Normal) .onChange((value: string) => { this.inputText = value this.scheduleLocate() }) .onSubmit(() => { this.locate() }) Button('定位') .onClick(() => { this.locate() }) } .width('100%') .padding({ left: 12, right: 12 }) Canvas(this.canvasCtx) .width('100%') .height(420) .onReady(() => { this.viewWidth = this.getCanvasWidth() this.viewHeight = 420 this.drawNumberAxis() }) Row({ space: 20 }) { Button('缩小') .onClick(() => { this.scalePx = Math.max(2, this.scalePx / 2) this.drawNumberAxis() }) Button('放大') .onClick(() => { this.scalePx = Math.min(5000, this.scalePx * 2) this.drawNumberAxis() }) Button('重置') .onClick(() => { this.centerValue = 0 this.scalePx = 120 this.hasLocated = false this.drawNumberAxis() }) } .width('100%') .justifyContent(FlexAlign.Center) } .width('100%') .height('100%') .backgroundColor('#F5F6FA') } }这里有几个细节值得多说一句。
输入框的 onChange 里我没有直接定位,而是调用了 scheduleLocate,也就是一个 300 毫秒的延迟定位。这样用户连续输入时,不会每敲一个字符就重绘一次,而是等停顿之后才计算和绘制。这个防抖处理对体验的提升非常明显。
Canvas 的 onReady 只会在组件首次渲染完成时触发,我在这里获取画布的实际宽度。width 和 height 用固定值 420,这样在竖屏布局下不会出现画布高度塌陷的问题。如果你在真机上遇到画布塌陷成 0 高度的情况,多半是因为 Canvas 的父容器没有确定高度,这点我在后面的踩坑部分还会展开讲。
4.2 坐标换算与数轴绘制
绘图部分是整个代码的重头戏。我先把两个换算方法写死,后面所有绘制逻辑都调用它们。
private valueToPixel(value: number): number { return this.viewWidth / 2 + (value - this.centerValue) * this.scalePx } private pixelToValue(pixelX: number): number { return this.centerValue + (pixelX - this.viewWidth / 2) / this.scalePx }drawNumberAxis 这个方法做了四件事:清空画布、画主轴线、画刻度线和标签、画目标标记点。
private drawNumberAxis(): void { const ctx = this.canvasCtx if (!ctx) return ctx.clearRect(0, 0, this.viewWidth, this.viewHeight) // 绘制横向数轴主体 const axisY = Math.floor(this.viewHeight * 0.55) ctx.strokeStyle = '#4A4A4A' ctx.lineWidth = 2 ctx.beginPath() ctx.moveTo(0, axisY) ctx.lineTo(this.viewWidth, axisY) ctx.stroke() // 绘制箭头,这里偷懒用两个小三角形 this.drawArrow(axisY) // 计算自适应刻度步长 const step = this.calcStepSize() const minVisible = this.pixelToValue(0) const maxVisible = this.pixelToValue(this.viewWidth) const startTick = Math.ceil(minVisible / step) * step const decimals = this.getStepDecimals(step) // 绘制刻度线和标签 ctx.font = '13vp sans-serif' ctx.fillStyle = '#333333' ctx.strokeStyle = '#888888' ctx.lineWidth = 1 for (let value = startTick; value <= maxVisible; value += step) { const safeValue = this.roundByDecimals(value, decimals) const px = this.valueToPixel(safeValue) if (px < -50 || px > this.viewWidth + 50) continue // 主刻度线 ctx.beginPath() ctx.moveTo(px, axisY - 12) ctx.lineTo(px, axisY + 12) ctx.stroke() // 刻度标签 ctx.textAlign = 'center' ctx.textBaseline = 'top' ctx.fillText(safeValue.toFixed(decimals), px, axisY + 16) } this.drawTargetMark(axisY) }自适应刻度步长的计算,按我前面说的 1、2、5 序列来实现。它的输入是当前视野范围,输出是一个“好看”的步长值。
private calcStepSize(): number { const range = this.pixelToValue(this.viewWidth) - this.pixelToValue(0) const roughStep = range / 8 const exponent = Math.floor(Math.log10(roughStep)) const pow = Math.pow(10, exponent) let normalized = roughStep / pow let niceFactor = 1 if (normalized > 5) { niceFactor = 10 } else if (normalized > 2) { niceFactor = 5 } else if (normalized > 1) { niceFactor = 2 } const step = niceFactor * pow return this.roundByDecimals(step, this.getStepDecimals(step)) }这里我加了一个 roundByDecimals 的调用,目的就是清除浮点误差。具体实现是先把数字放大到整数级别再取整,然后缩小回去:
private roundByDecimals(value: number, decimals: number): number { const factor = Math.pow(10, decimals) return Math.round(value * factor) / factor }绘制目标标记点时,我先判断 hasLocated 是否为 true,如果是,就把目标值刻度映射到像素坐标,然后用一个圆形加一条垂直线来标出位置。标记点的横坐标要注意边界,如果刚好在视野范围外,就不画,但可以在画布边缘显示一个半透明的提示箭头。
4.3 定位、缩放与输入联动
定位的核心逻辑不复杂,关键是解析和边界处理要做全。
private locate(): void { const parsed = this.parseInput(this.inputText) if (parsed === null || !isFinite(parsed)) { this.tipMessage = '无法解析,请输入有效的实数' return } this.targetValue = parsed this.centerValue = parsed this.hasLocated = true this.drawNumberAxis() }parseInput 支持小数、分数、带根号的表达式:
private parseInput(text: string): number | null { const t = text.trim().replace(/\s+/g, '') if (t.length === 0) return null // 圆周率 if (t === 'π' || t.toLowerCase() === 'pi') return Math.PI if (t === '-π' || t.toLowerCase() === '-pi') return -Math.PI // 分数:3/4 或 -2/7 const fractionRegex = /^(-?\d+\.?\d*)\/(-?\d+\.?\d*)$/ const fractionMatch = fractionRegex.exec(t) if (fractionMatch) { const numerator = parseFloat(fractionMatch[1]) const denominator = parseFloat(fractionMatch[2]) if (denominator === 0) return null return numerator / denominator } // 根式:√2、-√2、sqrt(2) const sqrtRegex = /^(-?)√(\d+\.?\d*)$/ const sqrtMatch = sqrtRegex.exec(t) if (sqrtMatch) { const sign = sqrtMatch[1] === '-' ? -1 : 1 const radicand = parseFloat(sqrtMatch[2]) if (radicand < 0) return null return sign * Math.sqrt(radicand) } const sqrtTextRegex = /^(-?)sqrt\((\d+\.?\d*)\)$/i const sqrtTextMatch = sqrtTextRegex.exec(t) if (sqrtTextMatch) { const sign = sqrtTextMatch[1] === '-' ? -1 : 1 const radicand = parseFloat(sqrtTextMatch[2]) if (radicand < 0) return null return sign * Math.sqrt(radicand) } // 普通十进制 const plain = parseFloat(t) if (isNaN(plain)) return null return plain }这个解析器虽然不算完整,但已经覆盖了绝大多数教学场景。唯一需要注意的是,parseFloat 对“12abc”这种字符串只会解析出 12,不会报错,所以我会额外判断一下解析结果是否等于原始字符串去掉符号后的长度,避免把非法输入当成合法数字。
除了按钮缩放,我还加了一个手势支持的预留方案。Canvas 组件支持 onTouch 事件,可以在触摸事件里记录起点、终点和触点间距,实现拖拽和平滑缩放。不过手势部分要考虑多点触控的具体逻辑,我第一次做的时候并没有直接接到正式版本里,而是先在按钮缩放上把核心逻辑跑通,后面再补手势。
5. 实测效果与边界场景
5.1 常规输入实测
我把这个应用安装到测试机上,跑了几组典型输入。
输入 3.14159,点定位,应用把视野中心设置到 3.14159,刻度自动选为 0.5 间隔,标记点准确落在 3 和 3.5 之间,标签显示正常。
输入 3/4,解析结果 0.75,视野中心变成 0.75,刻度间隔选为 0.2,标记点落在 0.6 和 0.8 之间,位置准确。观察标签时发现刻度显示为“0.60、0.80、1.00”,这符合预期,因为步长 0.2 保留了一位小数后,显示效果非常整齐。
输入 √2,解析出 1.4142135623730951,应用把这个值设为视野中心。初看标记点落在 1.4 和 1.5 之间,但数值标签显示的是 1.41,说明标记点位置和标签对得上。如果再放大一步,可以看到标记点在 1.414 和 1.415 之间缓慢变化,这为理解“无理数是无限不循环小数”提供了很好的直观体验。
5.2 极端数值与大范围缩放
极端数值下最容易出问题的就是刻度显示。输入 9999999,初始视野以 9999999 为中心,范围大约是 [9999980, 10000020],刻度步长自动选为 5,标签就会显示成 9999980、9999985、9999990、9999995、10000000 这样的序列,信息密度正好。
再输入 0.000001,视野范围大约是从 -0.000004 到 0.000006,步长自动选为 0.000002,标签显示成 0.000006、0.000004、0.000002、0.000000 等。这里 decimals 的计算帮了大忙,刻度标签不会出现一长串浮点尾巴。
按照 1、2、5 序列自适应后,无论数值大小,刻度数量始终保持在 6 到 12 个之间,视觉上不会出现密集恐惧的刻度墙。
5.3 性能与内存实测
实测下来,Canvas 绘制一条完整数轴,包含 80 到 100 个刻度线和同等数量的文字标签,单次绘制耗时在 3 到 6 毫秒之间。手指滑动或者连续点击缩放,帧率稳定在 60 帧左右,没有出现掉帧或者内存持续上升的现象。
对比一下如果我用声明式组件画 100 个 Text,性能和内存表现都不如 Canvas。所以结论很明确:绘图类应用,Canvas 是正确的选择。
6. 常见问题与踩坑记录
6.1 刻度文字重叠怎么处理
刻度文字重叠是自适应刻度最容易暴露的问题。如果步长太小、标签太多,文字就会挤在一起。常见的触发场景是用户把视野缩得很小,比如从 1000 缩放到 0.001,如果刻度步长没跟上,屏幕上可能挤满几十个标签。
解决思路从两个方向入手:一方面调大目标刻度数量,让步长变大,减少标签总数;另一方面,当两个相邻刻度在像素上离得小于某个阈值时,强制合并或跳过一部分标签。我在 calcStepSize 中把默认的目标刻度数量设为 8 到 12 个,实际效果基本不会出现重叠。
实测中发现,如果用 20 个刻度,标签在窄屏上就很容易重叠,尤其是负数带负号的时候。我最终踩定 10 个左右是安全值。
6.2 画布模糊和坐标偏移
真机上出现过画布背景模糊、刻度线发虚的问题。原因是 Canvas 的 CSS 尺寸和物理像素尺寸不一致。HarmonyOS 的 Canvas 组件也遵循类似逻辑,如果不把画布的实际分辨率设置为物理像素大小,而是直接用 vp 逻辑尺寸,低分辨率下容易出现模糊。
解决办法是给 Canvas 设置一个适配的分辨率:在 onReady 里取到画布尺寸后,再根据屏幕的像素密度计算实际的 buffer 大小。由于 ArkUI 的 Canvas 内部会自动做一部分适配,大部分情况直接按逻辑尺寸绘制问题也不大,但我在绘制刻度线时会让线宽保持在 1vp 以上,避免出现半像素渲染导致的虚化。
6.3 Canvas 尺寸获取为 0 的诡异问题
这是新手最容易掉进去的坑:Canvas 的 onReady 回调里,直接 this.canvasCtx.width 获取到 0,或者 Canvas 画出来完全空白。
原因通常是 Canvas 组件在首次渲染时还没有完成布局,或者父容器的高度没有确定。解决方法是:在 onReady 里不要马上用画布的宽高,而是先用组件自身的 layoutWeight 和父容器来确定高度,或者干脆给 Canvas 一个固定高度值。我代码里用的 420 高度就是一劳永逸的做法。
另外还有一个细节:onReady 只在画布初始化时触发一次,如果因为页面状态变化导致画布重新创建,onReady 可能不会再次触发。所以不要在 onReady 里写太多业务初始化逻辑,只做尺寸读取和首次绘制就够。
6.4 浮点误差导致的标记抖动
放大到一定程度时,目标标记点会发生肉眼可见的抖动。这不是绘制逻辑出错,而是浮点精度问题。比如 scalePx 特别大时,valueToPixel 计算中 (value - centerValue) 可能达到很大的数值,再乘上特别大的像素比例,浮点尾数误差就会被放大。
解决办法有三个:第一,计算像素坐标后做一次就近取整,让刻度线和标记点在整数像素上渲染;第二,当 scalePx 超过某阈值时,不再继续放大,而是提示用户当前精度已达上限;第三,计算时尽量从 centerValue 相减,避免算整个轴线上所有刻度的累计误差。
我实际采用的是前两个策略,效果稳定。深究起来,Java 和 JavaScript 的浮点数处理都有类似问题,但 Canvas 绘图通常只要把像素取整,视觉上就完全看不出来了。
7. 复盘与后续扩展建议
7.1 可以继续加的能力
这个应用做到现在基本可用,但我心里很清楚它还有不少可以延伸的空间。
第一是手势缩放和平移。当前版本用的是按钮缩放,虽然稳但不够“原生”。对接 onTouch 事件,支持双指捏合缩放、单指拖拽平移,会让体验上一个台阶。做手势时要注意一个细节:缩放中心应该以两个手指的中点为锚点,这样不会出现“越缩放越跑偏”的尴尬。
第二是支持更多输入格式。比如输入 2^10 这样的指数表达式,或者直接支持用户在表达式里写加减乘除,比如 “(1+√5)/2”,这对展示黄金分割比之类的特殊数字会非常有帮助。
第三是增加单位刻度和次刻度。当前只画了主刻度,如果加上更细的子刻度线,数轴会更接近数学教材上的标准样式。实现上可以保留一个次刻度步长数组,根据主步长自动推导。
第四是保存历史定位记录。每次定位后,把输入值和对应位置存成一张列表,用户可以点击列表回到之前的定位点,方便对比多个数在数轴上的相对位置。
7.2 我的一点实际体会
做完这个项目,我最大的感受是:看起来越简单的东西,做起来越考验细节。一个数轴,你可能会觉得初中生都认得,但真要在屏幕上把它画好用,要处理坐标换算、自适应刻度、浮点误差、触摸交互,每个环节都有坑。
我最推荐的做法是,先把核心的坐标换算模型写对,然后所有绘制都围绕这两条公式展开,千万不要这边画一个刻度直接算像素,那边画一个标记又用另一套换算逻辑。统一接口、统一封装,是这种小项目不翻车的根本保障。
如果你也想练手,可以从最简版本开始:先做一个固定范围、固定刻度的数轴,跑通了再加自动刻度和缩放。一步到位反而容易把问题混在一起,到时候出了 bug 都不知道该从哪里查。
这个实例做下来,让我对 HarmonyOS 绘图链路和 ArkTS 的状态管理又熟悉了不少。后续我打算把手势和表达式解析补齐,把它做成一个完整的“数学可视化工具箱”。如果你也做了类似的版本,欢迎对照我这套代码里的算法思路,看看刻度自适应的部分还有没有更优雅的解法。