【天体运行模拟|08】HarmonyOS ArkTS 单位换算实战:处理天文尺度、科学计数与精度

单位换算的界面通常不复杂,数值语义却很容易出错。长度、质量、时间、力和速度可以通过比例因子换到基准单位,摄氏度、华氏度与开尔文却带有偏移量;普通生活尺度用固定小数很直观,天文尺度则可能因为数值太大或太小,需要科学计数法和有效数字策略。只要把这几类规则混在一个 factors 数组里,页面看似能算,边界结果却未必可信。

"天体运行模拟"的 UnitConverterPage.ets 已经实现了一个可用的离线换算页:六个量纲组、22 个单位标签、输入值、源单位、目标单位、横向选择器和实时结果。比例型单位先换算到组内基准单位,再换到目标单位;温度则通过摄氏度中转。页面完全由 ArkUI 原生组件构成,没有网络依赖。

本文基于这份真实源码,复核当前算法、交互和精度边界,并给出支持 AU、光年、科学计数法和严格输入校验的演进方案。必须先说明:现有页面还没有 AU、光年或秒差距,也没有自动科学计数法;文章中的相关代码是建议实现,不会被描述成当前已上线能力。

唯一复核标记:CONVERTER-ONE13-SCALE-PRECISION-20260726:比例换算先归一到基准单位,温度使用仿射变换,天文尺度按有效数字格式化。

验证基线与源码实况

本文面向 HarmonyOS 5.0 及以上版本。实际工程应用版本为 1.0.0targetSdkVersion6.0.2(22)compatibleSdkVersion6.0.1(21),入口模块支持 phonetablet2in1

UnitConverterPage.ets 的静态数据统计如下:

量纲组 单位数 基准单位
长度 5
质量 4 千克
时间 4
3 牛顿
温度 3 摄氏度中转
速度 3 m/s

总计 6 组、22 个单位条目。当前有 5 组使用比例因子,温度组单独处理偏移;结果默认保留 6 位小数后去尾零,温度固定保留 4 位。天文单位数量为 0,科学计数格式分支为 0。

一、UnitGroup 表达了组内归一化规则

源码模型为:

ts 复制代码
interface UnitGroup {
  name: string
  units: string[]
  factors: number[]
}

例如长度组:

ts 复制代码
{
  name: '长度',
  units: [
    '米(m)',
    '千米(km)',
    '厘米(cm)',
    '毫米(mm)',
    '英尺(ft)'
  ],
  factors: [1, 1000, 0.01, 0.001, 0.3048]
}

每个 factor 表示"1 个该单位等于多少基准单位"。1 km 等于 1000 m,所以因子是 1000;1 cm 等于 0.01 m,所以因子是 0.01。

当前模型依赖两个数组下标完全对齐。若增加单位却忘记增加因子,运行时会得到 undefined 并传播为 NaN。更稳的方式是把名称和因子放进同一个对象。

二、比例型单位的核心公式

源码使用:

ts 复制代码
const fromFactor = group.factors[this.fromUnit]
const toFactor = group.factors[this.toUnit]
const result = val * fromFactor / toFactor

推导分两步:

text 复制代码
基准值 = 输入值 × 源单位因子
目标值 = 基准值 ÷ 目标单位因子

以 2 km 转 m 为例:

text 复制代码
2 × 1000 ÷ 1 = 2000 m

以 500 cm 转 m 为例:

text 复制代码
500 × 0.01 ÷ 1 = 5 m

只要组内单位都是纯比例关系,这个算法就可以双向换算。

三、温度为什么不能套比例因子

温度组虽然填写了 [1, 1, 1],实际代码检测组名后走专门函数:

ts 复制代码
if (group.name === '温度') {
  return this.convertTemperature(val).toFixed(4)
}

温度换算不是 y = ax,而是 y = ax + b。源码先统一到摄氏度:

ts 复制代码
let celsius: number = val
if (fromIdx === 1) {
  celsius = (val - 32) * 5 / 9
} else if (fromIdx === 2) {
  celsius = val - 273.15
}

再从摄氏度输出:

ts 复制代码
if (toIdx === 0) return celsius
if (toIdx === 1) return celsius * 9 / 5 + 32
return celsius + 273.15

这种"先到基准,再到目标"的结构与比例换算一致,只是每个单位需要缩放和偏移两个参数。

四、用仿射模型统一温度

不应继续依赖 group.name === '温度' 这种显示文本分支。可以为单位定义两个函数:

ts 复制代码
interface UnitDefinition {
  id: string
  label: string
  toBase: (value: number) => number
  fromBase: (value: number) => number
}

温度定义:

ts 复制代码
const CELSIUS: UnitDefinition = {
  id: 'celsius',
  label: '摄氏度(°C)',
  toBase: (value: number): number => value,
  fromBase: (value: number): number => value
}

const FAHRENHEIT: UnitDefinition = {
  id: 'fahrenheit',
  label: '华氏度(°F)',
  toBase: (value: number): number =>
    (value - 32) * 5 / 9,
  fromBase: (value: number): number =>
    value * 9 / 5 + 32
}

换算器只需:

ts 复制代码
const baseValue = from.toBase(input)
const result = to.fromBase(baseValue)

比例单位也能生成对应函数。算法不再知道"温度"这个中文名称,模型的语义更稳定。

五、当前精度因子有哪些近似

源码中的部分因子是精确或足够精确的:

  • 1 ft = 0.3048 m,定义值精确。
  • 1 dyn = 0.00001 N,即 10⁻⁵ N
  • 1 ms = 0.001 s

也有简化值:

  • 1 lb = 0.4536 kg,更常用值为 0.45359237 kg
  • 1 km/h = 0.2778 m/s,精确关系是 1 / 3.6
  • 1 mph = 0.4472 m/s,常用精确值为 0.44704 m/s

教学速查可以显示四位近似,但中间计算最好使用更精确常量,最后再格式化显示。否则多次往返换算会累计差异。

六、不要把显示精度当计算精度

当前非温度结果:

ts 复制代码
return result
  .toFixed(6)
  .replace(/\.?0+$/, '')

toFixed(6) 会先舍入,之后去掉尾零。它适合常见生活尺度,但会让非常小的非零结果变成 0

例如:

text 复制代码
1 dyn = 0.00001 N

仍可显示为 0.00001。但若加入更小单位或天文比例,结果小于 0.0000005 时就会被固定六位小数抹平。

计算层应保留原始 number,格式化层根据量级选择普通小数或科学计数法。

七、科学计数法需要按量级切换

推荐格式策略:

ts 复制代码
function formatNumber(value: number): string {
  if (!Number.isFinite(value)) {
    return '---'
  }

  const abs = Math.abs(value)
  if (abs === 0) return '0'

  if (abs >= 1e9 || abs < 1e-6) {
    return value.toExponential(6)
  }

  return value
    .toFixed(6)
    .replace(/\.?0+$/, '')
}

阈值不是唯一答案,但必须一致。天文距离经常超过 10⁹ m,微小力或比例也可能小于 10⁻⁶。科学计数法能同时避免超长整数和假零。

若产品面向中文用户,可把 1.496e+11 格式化为 1.496 × 10¹¹,但计算值仍保留普通 number

八、有效数字比固定小数位更适合天文尺度

固定 6 位小数回答"保留多少小数位",有效数字回答"保留多少有意义的数字"。例如:

text 复制代码
149 597 870 700 m

保留 6 位有效数字可以显示:

text 复制代码
1.49598 × 10¹¹ m

toFixed(6) 会产生很长的整数加无意义小数。

简单实现:

ts 复制代码
function formatSignificant(
  value: number,
  digits: number = 6
): string {
  if (!Number.isFinite(value)) return '---'
  if (value === 0) return '0'
  return Number(value.toPrecision(digits)).toString()
}

若要稳定保留尾零,应返回格式化字符串而不是再转回 Number

九、加入天文尺度必须先选基准单位

当前长度组以米为基准。可以继续扩展:

ts 复制代码
const AU_IN_METERS: number = 149_597_870_700
const LIGHT_YEAR_IN_METERS: number =
  9_460_730_472_580_800
const PARSEC_IN_METERS: number =
  3.085_677_581_491_367e16

然后新增:

ts 复制代码
{ id: 'au', label: '天文单位(AU)',
  factor: AU_IN_METERS }
{ id: 'ly', label: '光年(ly)',
  factor: LIGHT_YEAR_IN_METERS }
{ id: 'pc', label: '秒差距(pc)',
  factor: PARSEC_IN_METERS }

这些常量适合双精度浮点范围,但不能承诺任意位精确。页面应展示有效数字和近似标识。

十、天文单位与光年分别是什么

天文单位用于太阳系尺度:

text 复制代码
1 AU = 149 597 870 700 m

光年是光在真空中一个儒略年行进的距离:

text 复制代码
1 ly ≈ 9.4607304725808 × 10¹⁵ m

秒差距来自视差定义:

text 复制代码
1 pc ≈ 3.26156 ly

它们都是长度单位,不是时间单位。光年名称包含"年",但分类必须放在长度组,不能放进时间组。

十一、number 能处理到什么程度

ArkTS 的 number 使用双精度浮点语义。它能表示约 10³⁰⁸ 的量级,覆盖常见天文单位换算,但整数精确表示只到 2⁵³ - 1

这意味着:

  • 9.46 × 10¹⁵ 可表示,但不是每一米都能精确区分。
  • 进行比例换算通常够用。
  • 不适合要求逐米精确的超大整数审计。
  • 多次往返可能出现末位误差。

单位换算页应把结果定位为科学展示与学习辅助,避免显示超过数据能力的虚假小数位。

十二、输入解析不能只靠 parseFloat

源码:

ts 复制代码
const val = parseFloat(this.inputValue)
if (isNaN(val)) return '---'

parseFloat('12abc') 会得到 12,这可能让用户误以为整个输入被接受。更严格的校验可以使用正则:

ts 复制代码
const NUMBER_PATTERN: RegExp =
  /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/

function parseStrictNumber(
  input: string
): number | undefined {
  const value = input.trim()
  if (!NUMBER_PATTERN.test(value)) {
    return undefined
  }
  const parsed = Number(value)
  return Number.isFinite(parsed) ? parsed : undefined
}

这个模式支持 -273.15.51.496e11,拒绝 12km 和无限值。

十三、科学计数输入要保留编辑体验

用户输入 1e 时,表达式尚未完成。如果每次 onChange 都立刻判为错误并弹提示,会造成干扰。

可分为:

  • 编辑态:保留原字符串,结果暂时显示 ---
  • 提交态:失焦或点击换算时显示具体错误。
  • 合法态:实时更新结果。

当前页面没有独立提交按钮,采用实时结果。最合适的是对不完整输入静默显示占位符,不要清空用户文本。

十四、绝对零度需要业务校验

温度公式可以计算:

text 复制代码
-300 °C = -26.85 K

数学结果存在,物理上却低于绝对零度。若页面定位为纯代数换算,可以显示;若定位为物理学习工具,应提示:

ts 复制代码
if (toKelvin(value, unit) < 0) {
  return {
    ok: false,
    message: '该温度低于绝对零度'
  }
}

同样,开尔文输入本身不应小于 0。规则要由产品定位决定,但不能在不同方向表现不一致。

十五、用对象替代平行数组

推荐结构:

ts 复制代码
type QuantityKind =
  | 'length'
  | 'mass'
  | 'time'
  | 'force'
  | 'temperature'
  | 'speed'

interface UnitDefinition {
  id: string
  label: string
  symbol: string
  toBase: (value: number) => number
  fromBase: (value: number) => number
}

interface UnitGroup {
  id: QuantityKind
  name: string
  units: UnitDefinition[]
}

优点:

  • 标签与换算规则不会错位。
  • id 不受中文显示文案变化影响。
  • 温度无需特殊组名判断。
  • 可为每个单位增加精度、说明和别名。
  • 测试可以按单位 ID 枚举。

十六、切换量纲时重置下标是必要的

源码选择新组后:

ts 复制代码
this.selectedGroup = index
this.fromUnit = 0
this.toUnit = 1

不同组单位数量不同。如果保留旧下标,例如从 5 个长度单位切到 3 个力单位,可能越界。重置到 0 和 1 是正确的防护。

更进一步,模型初始化时应断言每组至少有两个单位,否则 toUnit = 1 仍会越界。

十七、页面状态与结果都是派生关系

页面只保存:

  • selectedGroup
  • inputValue
  • fromUnit
  • toUnit

结果由 convertValue() 动态计算,没有维护额外 @State result。这避免输入变化后忘记同步结果。

当数据量很小、计算为纯函数时,派生结果比手工状态更可靠。若未来计算需要高精度库或异步服务,再考虑缓存或 ViewModel。

十八、横向选择器保护窄屏布局

量纲组、源单位和目标单位都使用横向 Scroll,因此长标签不会把卡片挤坏。选中源单位使用主色,选中目标单位使用绿色,视觉上区分输入和输出。

应继续验证:

  • 系统字体放大。
  • 天文单位(AU) 等长标签。
  • 手机横屏与小窗口。
  • 2in1 鼠标滚轮和触控板横向操作。

关闭滚动条后,最好让下一项露出一部分,提示仍可滚动。

十九、结果文本需要约束超长值

当前结果使用 28vp 粗体:

ts 复制代码
Text(this.convertValue())
  .fontSize(28)
  .fontWeight(AppFonts.WEIGHT_BOLD)

加入天文尺度后,普通十进制字符串可能超过卡片宽度。可以:

  • 默认科学计数法。
  • 设置 maxLines(1)
  • 使用 textOverflow
  • 对结果区域提供横向滚动或复制。
  • 在平板上扩大结果区,但不依赖超宽布局。

最优先的是正确格式化,而不是缩小字体到不可读。

二十、多设备与安全区

页面根容器已经使用全宽、全高,并加上状态栏和底栏高度:

ts 复制代码
.width('100%')
.height('100%')
.padding({
  top: this.statusBarHeight,
  bottom: this.bottomBarHeight
})

手机保持单卡片结构即可。平板和 2in1 可限制卡片最大宽度并居中,避免输入行横跨整屏。软键盘弹出后要保证目标单位和结果仍可到达;当前根页面不是整体滚动容器,较小窗口或大字体下需要实测是否被键盘遮挡。

二十一、关键测试向量

输入 源单位 目标单位 预期
1 km m 1000
100 cm m 1
1 ft m 0.3048
1 t kg 1000
60 min h 1
1 kN N 1000
1 dyn N 0.00001
0 °C °F 32
32 °F °C 0
0 °C K 273.15
36 km/h m/s 约 10.0008,受当前近似因子影响
abc 任意 任意 ---

如果使用精确的 1/3.6,36 km/h 应严格得到 10 m/s。这个对比能直接暴露近似因子带来的误差。

加入天文单位后的新增测试:

text 复制代码
1 AU -> m = 1.495978707 × 10¹¹
1 ly -> AU ≈ 63241.077
1 pc -> ly ≈ 3.26156

二十二、属性测试比逐例测试更强

对所有比例单位,可以验证往返性质:

ts 复制代码
const converted = convert(value, from, to)
const restored = convert(converted, to, from)
expectClose(restored, value, tolerance)

还可以验证:

  • 同单位换算结果等于输入。
  • 零值在比例单位间仍为零。
  • 正比例单位保持正负号。
  • 所有因子均为有限正数。
  • 每组至少两个单位。
  • 单位 ID 全局或组内唯一。

浮点结果不要直接使用 ===,应按绝对误差或相对误差比较。

二十三、发布前检查

  • 比例型单位统一经过基准单位。
  • 温度使用带偏移的仿射变换。
  • 单位标签与规则不使用平行数组。
  • parseFloat 不接受尾随垃圾字符。
  • 支持合法科学计数输入。
  • 小量级不会被 toFixed(6) 误显示为零。
  • 大量级自动使用科学计数法。
  • 显示有效数字与计算精度分离。
  • AU、光年和秒差距归入长度。
  • 精确常量与展示近似值分离。
  • 结果长文本不溢出。
  • 软键盘和底部安全区不遮挡控件。
  • 手机、平板、2in1 完成同一组测试向量。

二十四、总结

当前 UnitConverterPage.ets 已经形成一条清晰的本地换算链路:用户输入字符串,选择量纲和两个单位,比例型单位通过基准因子换算,温度通过摄氏度中转,结果实时派生并在 ArkUI 卡片中显示。切换量纲时重置单位下标、使用横向滚动保护窄屏,都是可靠的首版选择。

面向天体应用继续演进时,核心不是简单追加几个大数字,而是重构单位模型、使用精确常量、严格解析科学计数输入,并按有效数字输出。比例、仿射、格式化和物理边界应分层处理;只有计算值与展示值各自守住职责,单位换算才不会在尺度放大后失去可信度。

说明:本文基于真实 HarmonyOS/ArkTS 源码进行整理,部分文字与示例由 AI 辅助生成;所有现有能力与建议改造已明确区分。

相关推荐
lilian23312 分钟前
HarmonyOS 7 新特性(十三)|3DGS 模型加载、交互与性能回退
3d·交互·harmonyos
2501_919749031 小时前
华为鸿蒙免费反诈APP—小羊反诈
华为·harmonyos·鸿蒙
2501_919749031 小时前
华为鸿蒙免费视频播放器—小羊免费播放器
华为·harmonyos·鸿蒙
贾伟康2 小时前
【天体运行模拟|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
harmonyos·arkts·数据持久化·状态管理·preferences
黑臂麒麟2 小时前
Harmony鸿蒙实战应用10:随手账本——发布检查与最终验收
华为·app·arkts·鸿蒙
贾伟康3 小时前
【天体运行模拟|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
harmonyos·arkts·arkui·响应式布局·多设备适配
大雷神3 小时前
HarmonyOS AR Engine高精几何重建实战——扫描纸盒并测量体积
华为·ar·harmonyos
用户0934077735143 小时前
HarmonyOS WPS Open SDK 实践:registerApp 鉴权与就绪门禁
android·typescript·harmonyos
特立独行的猫a4 小时前
仓颉语言原生 Coding Agent:cjh · 仓颉语言实现的 Harness
ai·agent·harmonyos·仓颉·cangjie·harness