鸿蒙系列开发之看不见流星雨几颗?我用华为云码道把猎户座流星雨观测台做成了鸿蒙应用

10 月 21 日深夜,猎户座流星雨迎来极大。它的母体是哈雷彗星留下的尘埃带,辐射点落在猎户座,赤经 95°、赤纬 +16°,极大夜的天顶每小时流量大约二十来颗——不是英仙座那种「满天都是」的场面,更像一场要耐心守的小雨。天气预报只会说晴或多云,不会回答「你今晚能看到几颗」。那个数取决于四件事:你站在哪一纬度、辐射点升到多高、月亮亮到什么程度、头顶的天空黑不黑。

我把这道估算题做成一个鸿蒙应用:猎户座流星雨观测台 orion-lab。它不联网、不读定位、不拉任何预报接口,打开就是一块能拖的观测台:纬度、经度、时刻、云量、光污染五样输入,出口是「每小时预计可见流星数」和一句观测建议。开发全程交给 华为云码道 CodeArts,模型用的是 GLM-5.2,范式是 ArkTS + ArkUI 声明式。我只负责把辐射点坐标、ZHR、月相公式和禁令写死,把仓库拉回本地点一遍。

  • 仓库(公开):https://atomgit.com/2601_95131637/orion-lab
  • 协议:MIT
  • 运行:DevEco Studio 5.0+ 打开工程,自动签名后运行到模拟器或真机
  • 测试:本地复跑 22/22 通过,HEAD bbde236
  • 权限:requestPermissions: [],一个都不申请
  • 网络:零联网,无远程资源

在这里插入图片描述
在这里插入图片描述

作品介绍:把「今晚能看几颗」算清楚

打开应用就能做这几件事。每一项都对应公式链上的一个输入,没有装饰性控件:

  1. 观测条件输入。纬度滑杆覆盖北纬 0–60°,经度滑杆覆盖东经 73–135°,正好框住中国大陆的教学域,不必处理南半球和西经的符号约定。观测时刻从 10 月 21 日 18:00 拖到次日 06:00,步进 30 分钟——流星雨是整夜的事,半小时一档足够看出辐射点从地平线下爬上来的过程,又不会让滑杆变成无意义的连续条。
  2. 云量四档点选。晴、少云、多云、阴,对应折减系数 0、0.25、0.6、1.0。云不是连续变量,人眼也分不出「0.37 的云」,所以做成离散档,而不是再加一根滑杆。
  3. 波特尔光污染九级点选。从 1 级的乡村暗夜(极限星等 6.8)到 9 级的市中心(3.7 等),每一级写死一张映射表。城市里站在路灯下,和郊区山脊上,看到的不是同一场雨。
  4. 弧形仰角面板。Canvas 自绘一条地平线和辐射点走过的弧。辐射点还在地平线下时不画星点,只写「未升起」——看不见就是看不见,不拿一条贴着地平线的弧假装它已经出来了。
  5. 结果卡。大字是每小时预计可见流星数,下面并列辐射点仰角、月亮照亮比例、月相名称,再跟一句观测建议。建议只有四档:辐射点未升起、月光过强、光污染过重、条件良好,优先级按这个顺序,先说最致命的那一条。
  6. 零权限启动。打开应用不会弹任何授权框,因为 module.json5 的 requestPermissions 是空数组。估算要用的数全是用户自己拨出来的。

整个工程的分层一眼能看完:

entry/src/main/ets/
├── domain/        # orion.ets:常量表 + 恒星时 + 仰角 + 月相 + 流量估算(纯函数)
├── security/      # sanitize.ets:数值夹取 + 档位分流 + 非有限数回退(纯函数)
├── ui/            # SkyPanel / ConditionList / ResultCard 三个组件
├── pages/         # Index.ets:状态持有与计算链装配
└── entryability/  # EntryAbility:默认入口

domain 和 security 是纯函数,不碰任何 @ohos 能力;ui 和 pages 只负责把算好的数摆上屏幕。规则集中在纯函数里,评审想核对「阴天是不是一定归零」,读测试就够,不必先把模拟器拉起来。

页脚和 README 同一句免责:教学近似模型,ZHR、辐射点与月相均取教学近似值,不替代 IMO 官方预报;夜间户外观测注意安全、保暖、结伴,远离公路与水域。 这句话不是装饰。流星计数本身就是概率,模型给出的是「值不值得出门」的量级,不是一张可以拿去和目视记录逐颗对账的预报单。

应用在 DevEco Studio 里自动签名后一键运行到模拟器,首屏实拍如下:

DevEco Studio 运行到模拟器:猎户座流星雨观测台首屏,深空蓝黑主题与弧形仰角面板就位

首屏特写:辐射点已升起 46.4°,纬度、经度、观测时刻三根滑杆与云量档位一字排开

首屏即工作台:弧形面板里能看到辐射点相对地平线的位置,三根滑杆分别控制纬度、经度、观测时刻,云量与波特尔档位点选——它们全部是领域层公式的输入。后面的实拍会跟着代码走:公式链的出口看结果卡,档位的联动看安全净化层,建仓与开发对话看开发过程一节。

领域模型:一条五步计算链

这个应用的本质是一条公式链。链上每一个天文参数都写成常量,不留「运行时再查一张表」的口子——查来的数进不了测试,写死的数可以。

export const RADIANT_RA_DEG: number = 95.0     // 辐射点赤经
export const RADIANT_DEC_DEG: number = 16.0    // 辐射点赤纬
export const ZHR_MAX: number = 25              // 天顶每小时流量
export const POPULATION_INDEX_R: number = 2.5  // 星群指数
export const REFERENCE_NEW_MOON_JULIAN_DAY: number = 2451550.1  // 参考新月
export const SYNODIC_MONTH_DAYS: number = 29.530588853          // 朔望月
export const J2000_JULIAN_DAY: number = 2451545.0               // J2000 历元
export const MAX_HOURLY_RATE: number = 500                      // 输出上限

这八个数各管一件事。辐射点赤经赤纬钉死猎户座;ZHR 取教学值 25,表示理想暗夜、辐射点在天顶时的每小时流量;星群指数 2.5 决定天空每亮一等,暗流星掉得有多快;参考新月儒略日和朔望月长度用来推月相;J2000 是恒星时公式的零点;500 是输出上限,防止极端组合算出一个没人信的天文数字。

从观测时刻到流星数,五步走。每一步都是纯函数,输入输出写在签名上,中间不藏状态。

第一步,把滑杆上的本地时刻换成自 J2000 起的天数 d。观测夜固定在 2026-10-21 这一晚,东八区 18:00 到次日 06:00。滑杆用「18 到 30」这一个数表达跨夜:21 是当晚九点,30 是次日六点。函数内部先减 8 小时得到世界时,再接到极大夜 18:00 对应的儒略日上。不让用户选日期,是因为这篇作品只回答「极大夜这一晚」,不是做一本万年历。

/* 本地(东八区)观测时刻 -> 儒略日。
 * localTotalHours: 当晚 18:00(值 18)到次日 06:00(值 30)。 */
export function julianDayFromLocalHours(localTotalHours: number): number {
  const h = finiteOr(localTotalHours, (LOCAL_HOUR_MIN + LOCAL_HOUR_MAX) / 2)
  const utHours = h - OBSERVATION_UTC_OFFSET_HOURS
  return OBSERVATION_BASE_JD_LOCAL18 + (utHours - 10) / 24
}

export function daysFromJ2000(julianDay: number): number {
  return finiteOr(julianDay, J2000_JULIAN_DAY) - J2000_JULIAN_DAY
}

滑杆拖过午夜时,界面上的「当晚」会换成「次日」,儒略日连续增加,不会在 24 点处跳回前一天。非有限输入回退到区间中点,不把 NaN 放进后面的三角公式。

第二步,平恒星时。恒星时回答的是「此刻哪一片天正对着你」。它是一个只含乘加和取模的线性式,系数 360.98564736629 是地球相对恒星背景每天多转出来的那一度多一点;经度直接加进去,东经为正,所以同一时刻越往东,猎户座升得越早:

/* 平恒星时(度)。LST = (280.46061837 + 360.98564736629 * d + 经度) mod 360 */
export function localSiderealTime(d: number, longitudeDeg: number): number {
  const dd = finiteOr(d, 0)
  const lng = finiteOr(longitudeDeg, 0)
  return mod360(280.46061837 + 360.98564736629 * dd + lng)
}

取模收进 0–360,避免天数一大,角度就漂出圆周。d = 0、经度 = 0 时,这个函数必须给出 280.46061837——这条写进了测试,系数抄错一位,断言立刻红。

第三步,辐射点仰角的正弦。球面三角压成一行:sin(alt) = sin(lat)·sin(dec) + cos(lat)·cos(dec)·cos(LST − RA)。正弦而不是角度本身,是因为下一步的流量公式吃的就是 sin(alt):辐射点在天顶时因子为 1,贴着地平线时接近 0,落到地平线下则为负。输出用 clampUnit 钉在 [-1, 1],浮点噪声不会把 1.0000002 送进后面的乘法:

export function sinRadiantAltitude(d: number, latitudeDeg: number, longitudeDeg: number): number {
  const dd = finiteOr(d, 0)
  const latDeg = finiteOr(latitudeDeg, 30)
  const lngDeg = finiteOr(longitudeDeg, 0)
  const lstRad = localSiderealTime(dd, lngDeg) * DEG_TO_RAD
  const latRad = latDeg * DEG_TO_RAD
  const decRad = RADIANT_DEC_DEG * DEG_TO_RAD
  const raRad = RADIANT_RA_DEG * DEG_TO_RAD
  const s = Math.sin(latRad) * Math.sin(decRad)
    + Math.cos(latRad) * Math.cos(decRad) * Math.cos(lstRad - raRad)
  return clampUnit(s, 0)
}

纬度默认回退 30°、经度回退 0°,都是有限数兜底,不是业务默认值——业务默认值在页面状态里(北纬 30°、东经 105°),净化和兜底是两层事。

第四步,月相。以 2000 年 1 月 6 日那次新月为参考历元,相位是一个 0 到 1 的小数:0 附近新月,0.5 满月,再绕回 0。负余数要加 1 拉回正区间,否则月相会在跨周期时跳出 [0, 1]。照亮比例用余弦:(1 − cos(2π·phase)) / 2,新月为 0,满月为 1,形状对称,不需要再维护一张月相名称表:

/* 月相 phase = ((d − 4.9) / 29.530588853) mod 1(取正余数) */
export function moonPhase(d: number): number {
  const dd = finiteOr(d, MOON_PHASE_OFFSET_DAYS)
  const v = ((dd - MOON_PHASE_OFFSET_DAYS) / SYNODIC_MONTH_DAYS) % 1
  if (v < 0) {
    return v + 1
  }
  return v
}

/* 照亮比例 illum = (1 − cos(2π·phase)) / 2,钳制在 [0, 1]。 */
export function moonIllumination(phase: number): number {
  const p = finiteOr(phase, 0)
  const wrapped = p - Math.floor(p)
  const illum = (1 - Math.cos(2 * Math.PI * wrapped)) / 2
  return clampFraction(illum)
}

月相名称是照亮比例的旁路,不参与乘法:接近 0 或 1 的两端显示「新月」或「满月」,中间一律「峨眉/盈凸」。教学模型不区分上弦下弦,因为流量公式只关心有多亮,不关心亮的是左边还是右边。

第五步,全部乘起来。这是整条链的出口,也是界面上那个大数字的唯一来源:

/* HR = ZHR × max(0, sin(alt)) × r^(6.5 − LM) × (1 − 0.9×illum²) × (1 − cloud)
 * alt ≤ 0 → HR = 0;结果钳制 [0, 500];非有限输入各自回退默认值。 */
export function hourlyRate(altSin: number, lmLimit: number, illum: number, cloud: number): number {
  const alt = finiteOr(altSin, 0)
  if (alt <= 0) {
    return 0
  }
  const lm = finiteOr(lmLimit, 6.5)
  const i = clampFraction(illum)
  const c = clampFraction(cloud)
  const altFactor = clampNumber(alt, 0, 1, 0)
  const lightFactor = Math.pow(POPULATION_INDEX_R, 6.5 - lm)
  const moonFactor = 1 - 0.9 * i * i
  const cloudFactor = 1 - c
  const hr = ZHR_MAX * altFactor * lightFactor * moonFactor * cloudFactor
  return clampNumber(hr, 0, MAX_HOURLY_RATE, 0)
}

这个公式里有三个因子值得单独说,外加一条提前返回。

仰角因子就是 sin(alt),而且先看符号:小于等于 0 直接返回 0。地平线下的流星对观测者来说不存在,不是负数,也不是一个「待会就有」的预告。光污染因子 r^(6.5 − LM) 以 6.5 等为基准:LM = 6.5 时因子恰为 1。指数是 6.5 − LM,极限星等每低 1 等,因子就乘上一个 2.5——LM = 5.5 得到 2.5,LM = 4.5 得到 6.25。这两条不是口头估计,是手算尺子,和 Math.pow(2.5, 6.5 - lm) 逐字对应。月光因子 1 − 0.9×illum² 用平方而不是线性,因为月光的干扰在接近满月时才陡增:新月零损耗,半月只削去两成出头,满月则只留下一成。云量因子 1 − cloud 最干脆,阴天系数是 1,整项变 0,后面乘什么都没用。

四个因子相乘之后钳进 [0, 500]。上限不是物理定律,是展示纪律:教学组合就算漂了,界面上也不出现三位数以上的「每小时可见」。

建议不走这条乘法,单独由 adviceLevel 给出,四档、三条阈值、固定优先级:

  • 仰角正弦 ≤ 0 → 「辐射点未升起」。流星还没进视野,谈月光和光污染没有意义。
  • 否则月照亮比例 > 0.4 → 「月光过强」。实拍里 75% 就落在这一档,文案是「会压住暗流星,建议选择下半夜或月落后的窗口」。
  • 否则极限星等 < 5.0 → 「光污染过重」,建议换更暗的郊区或山野。
  • 三条都没触发 → 「条件良好」。

优先级写死为「未升起 > 月光 > 光污染」。一条建议只说当前最致命的那件事,不把四句话叠在一起。

这条公式链的实拍出口就是结果卡。纬度 55°N、东经 93°、次日 04:30、少云、波特尔 5 级:辐射点已升到 46.4°,月照亮 75%(峨眉/盈凸),每小时预计可见 20 颗。20 不是把 ZHR 原样贴上去,而是 25 与仰角正弦、波特尔 5 级对应的 5.3 等、75% 月光、0.25 云量五项乘完再取整的结果。观测建议落在月光档——大数字和下面那句话来自同一组输入,不是两套各说各话的文案。

结果卡特写:少云、波特尔 5 级条件下每小时预计可见 20 颗,月照亮比例 75%(峨眉月/盈凸),观测建议提示月光过强、宜选下半夜或月落后窗口

手算尺子:九条断言钉死公式

公式写完不算完。模型最容易做的事,是写出一个「看起来像天文学」的函数,再配几条四舍五入也能过的断言。所以尺子必须先于实现写死:每条都是一个可以手算的数,容差只留给周期回绕那种必然有浮点尾数的地方,其余用严格相等。

  • 辐射点仰角 alt ≤ 0 → HR = 0。地平线下就是看不见,不是负数。
  • sin(alt) = 1、LM = 6.5、illum = 0、cloud = 0 → HR = 25(ZHR 全额)。
  • LM = 5.5 → 光污染因子 = 2.5;LM = 4.5 → 6.25。
  • 满月 illum = 1 → 月光因子 = 0.1。
  • cloud = 1(阴天)→ HR = 0。
  • illum = 0(新月)→ 月光因子 = 1;phase = 0.5 → illum = 1。
  • 恒星时尺子:d = 0、经度 = 0 → LST = 280.46061837 mod 360。
  • 月相周期:phase(d + 29.530588853) ≈ phase(d)(±1e-6)。
  • 对任意输入随机扫描:HR ∈ [0, 500]、illum ∈ [0, 1]、sin(alt) ∈ [−1, 1],输出有限不抛错。

前六条钉的是出口,后三条钉的是中间齿轮。恒星时那条尤其较真:常数 280.46061837 必须原样出现,少抄一位小数,整晚的辐射点都会偏出几度,而界面上的仰角看起来「还是那么回事」。月相周期则防止有人用 29.5 这种两位小数代替朔望月——差一晚,极大夜的月相就能从峨眉滑到另一个名字。随机扫描不验证对不对,只验证「乱输入也不会炸」:NaN、无穷、负数纬度进得去,出得来的仍是有限数。

安全净化层:档位分流,非有限数回退

和上一篇一样,「人拨出来的数」和「领域层吃进去的数」之间隔着一道闸机。滑杆和点选在界面上看起来总是合法的,但领域函数会被测试拿去喂 NaN、无穷和越界索引。闸机的职责就是:有限数夹进教学域,非有限数退回一个事先写明的默认值,离散档位则走最近邻而不是四舍五入到不存在的第 5 档。

这次比风速多出来的是档位分流。云量和波特尔都是离散的,选档映射必须双向一致——从系数找回档位,再从档位取出系数,必须回到原点:

/* 云量档位:0=晴 1=少云 2=多云 3=阴 */
export function cloudIndexOf(cloudFactor: number): number {
  const f = finiteOr(cloudFactor, CLOUD_LEVELS[0])
  let nearest = 0
  let bestDistance = Number.MAX_VALUE
  for (let i = 0; i < CLOUD_LEVELS.length; i++) {
    const distance = Math.abs(CLOUD_LEVELS[i] - f)
    if (distance < bestDistance) {
      bestDistance = distance
      nearest = i
    }
  }
  return nearest
}

export function cloudFactorAt(index: number): number {
  const i = Math.round(finiteOr(index, 0))
  if (i < 0) {
    return CLOUD_LEVELS[0]
  }
  if (i >= CLOUD_LEVELS.length) {
    return CLOUD_LEVELS[CLOUD_LEVELS.length - 1]
  }
  return CLOUD_LEVELS[i]
}

cloudIndexOf 不设阈值表,而是逐档算绝对距离、取最近的一档。0.1 仍是晴,0.4 更靠近少云的 0.25 还是多云的 0.6,由距离说了算,不靠注释里的「大约」。cloudFactorAt 按索引取值,负数退回晴,超出末档退回阴。两个函数互为逆运算:表里每一个系数进来,出去还是它自己。这条性质写进测试之后,有人把 0.6 改成 0.55,往返断言会立刻红。

纬度、经度、时刻、云量、照亮的夹取全部收敛到一个 clampNumber(value, lo, hi, fallback)。有限数夹取,非有限数不夹取、直接回退。于是领域层对任意输入都输出有限数,绝不抛错——抛错会变成界面上的白屏,而白屏不是一种观测建议。

档位语义在模拟器上按一下就能复现:云量切到「阴」(cloud = 1.0)的瞬间,cloudFactor = 0,任意仰角与星等下每小时预计流星数直接归零——阴天就是看不见,公式直接说话。这正是手算尺子里 cloud = 1 → HR = 0 那条断言的实机版本。

条件联动演示:云量切到「阴」的瞬间,每小时预计流星数归零——阴天就是看不见,公式直接说话

波特尔等级到极限星等也是一张写死的表,不接外部星空亮度接口。1 级 6.8 等到 9 级 3.7 等,中间按肉眼可感的台阶下降,不是等差数列——从 3 级到 4 级掉 0.5 等,从 1 级到 2 级只掉 0.2 等,因为暗夜那一端人眼已经接近极限,城市这一端则掉得更快。越界不外推,回退到 5 级的 5.3 等,也就是「城乡结合部」这一档,既不明亮得离谱,也不假装用户站在荒漠里:

const BORTLE_LM_TABLE: Array<Array<number>> = [
  [1, 6.8], [2, 6.6], [3, 6.3], [4, 5.8], [5, 5.3],
  [6, 5.0], [7, 4.6], [8, 4.2], [9, 3.7]
]

主页面:五个状态,一条派生链

pages/Index.ets 是装配层,不是第二套天文引擎。它只持有五个 @State:纬度、经度、时刻、云量档、波特尔档。仰角、月相、极限星等、每小时流星数全部是派生方法,输入一变就整链重算,没有任何一份「算好了再缓存」的副本。缓存一旦存在,滑杆和结果卡就会各说各话。

@Entry
@Component
struct Index {
  @State latitude: number = 30
  @State longitude: number = 105
  @State localHour: number = 21
  @State cloudIndex: number = 0
  @State bortleIndex: number = 4

  private currentAltSin(): number {
    const lat = clampLatitude(this.latitude)
    const lng = clampLongitude(this.longitude)
    return sinRadiantAltitude(this.currentDays(), lat, lng)
  }

  private currentIllum(): number {
    return moonIllumination(moonPhase(this.currentDays()))
  }

  private currentLm(): number {
    return bortleToLm(bortleLevelAt(this.bortleIndex))
  }

  private currentHourlyRate(): number {
    const cloud = cloudFactorAt(this.cloudIndex)
    return hourlyRate(this.currentAltSin(), this.currentLm(),
      this.currentIllum(), cloud)
  }
  // build() 里滑杆变 → 派生方法重算 → 面板与结果卡自动跟上
}

默认值选在北纬 30°、东经 105°、当晚 21:00、晴、波特尔第 5 档。这是中国腹地、天刚黑、还没被云和灯光为难的一个起点,打开应用不用先拨半天才能看到一张有内容的结果卡。时刻越过 24 之后,页眉从「当晚」换成「次日」,时钟用 hour % 24 显示,内部仍保留 24 以上的连续值,儒略日不会在午夜被重置。

任何输入变化都会触发整条链重算。链上每一步都是纯函数,一次拖动的成本可以忽略,因此没有节流、没有「松手再算」。

这里有一条命名纪律,写在提示词里,也写在组件上。自定义组件的 @Prop 不要叫 scale、opacity 这类名字——它们与 ArkUI 内置通用属性方法重名,编译器会按通用属性的类型签名去校验,报错还不太指向根因。仰角面板的属性因此叫 radiantAltSin。同理,Canvas 的对齐枚举是类型不是值,ctx.textAlign 只能赋字符串字面量 'center',不能写 CanvasTextAlign.CENTER。这两条提前写明,比等编译器指出来再改一轮便宜。

开发过程:两条对话,从空仓到 HEAD

码道上的开发和上一篇一样,故意拆成两条对话。建仓那一轮会把认证、克隆、推送的上下文吃掉一块;如果接着在同一条对话里写领域层,后面的公式和尺子就挤在一条越来越长的历史里。第一条对话因此只做一件事——建空仓,不写业务代码:

  • 仓库名 orion-lab,可见性 Public
  • 初始化 README、MIT、.gitignore,本地路径 /workspace/orion-lab
  • 建好后把仓库 URL 发回来

码道先查 ag auth status 确认认证是 2601_95131637,再 ag repo create 建仓、克隆到本地、补齐三个初始化文件、git push -u origin main,全程 1 分 22 秒:

码道建仓对话:ag 认证、创建公开仓库 orion-lab 并初始化三件套

建仓完成:1 分 22 秒,仓库 URL 与初始化清单

仓库地址:https://atomgit.com/2601_95131637/orion-lab

空仓的意义是给第二条对话一个干净的 main。README、协议和忽略规则已经在远端,开发对话不用再花回合解释「仓库长什么样」。

第二条对话新建,绑定 2601_95131637/orion-lab 的 main,模型选 GLM-5.2。提示词一次写死五块:主题与边界(只做极大夜、不联网、不申请权限)、领域常量与五步函数签名、九条手算尺子、分层目录和 ArkTS 语法红线、测试命令与提交要求。能手算的数不留给模型「查一下」,能编译期拦住的写法提前列成禁令。模型照单执行,自由发挥的空间只剩变量名和注释措辞:

开发提示词发送:主题边界、领域模型、手算尺子、架构红线一次写死

权限与资源:零权限的合规起点

module.json5 里最值钱的一行是空数组:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "deviceTypes": ["phone", "tablet"],
    "requestPermissions": [],
    "abilities": [ /* EntryAbility:entity.system.home */ ]
  }
}

鸿蒙的权限模型是「写进清单就会在首次使用时面对用户」。定位、网络、存储,这个应用一个都用不上:纬度经度是滑杆,时刻是滑杆,云量和星等是点选。真去申请 LOCATION,用户看到的第一屏会是授权框,而不是辐射点。评审打开 module.json5 看见 [],比文章里写十句「注重隐私」都直接。

中文文案全部收在 resources/base/element/string.json。四档建议、月相三个名字、「辐射点已升起 / 未升起」、「颗/小时」,组件里只有 $r('app.string.xxx'),没有一处拼接用户输入。色板收在 color.json,深空蓝黑是默认主题;深色模式靠 resources/dark/element/ 下同名文件换装,键名不动,组件里没有 if (isDark)。想加一版英文界面,只需要补 en_US 目录,计算链一行都不用改。

测试:还是那条「测试对象=发布对象」路线

领域层是 .ets 文件,node 不认识这个扩展名,但文件内容是 TypeScript 的一个子集:没有装饰器,没有 ArkUI,只有函数和常量。与其让模型再抄一份「测试专用」的逻辑,不如把发布文件换个扩展名直接跑。转译脚本因此只做两件事——复制,以及给相对 import 补上 .ts:

const sources = [
  {
    src: path.join(root, 'entry', 'src', 'main', 'ets', 'domain', 'orion.ets'),
    out: path.join(outDir, 'domain', 'orion.ts')
  },
  {
    src: path.join(root, 'entry', 'src', 'main', 'ets', 'security', 'sanitize.ets'),
    out: path.join(outDir, 'security', 'sanitize.ts')
  }
];

function rewriteImports(code) {
  return code.replace(/(\b(?:from|import)\s+(?:type\s+)?['"])(\.[^'"]+?)(['"])/g,
    (match, head, target, tail) => {
      if (target.endsWith('.ts') || target.endsWith('.ets') || target.endsWith('.json')) {
        return head + target + tail;
      }
      return head + target + '.ts' + tail;
    });
}

正则只动 from / import 后面的相对路径,其余字符原样落地。tests/generated/ 进了 .gitignore,生成物不进版本库,避免「仓库里有两份领域层、不知该信哪份」。22 个用例覆盖四类:手算尺子的全部条目、波特尔九级逐级核对、月相走过一个朔望月后回到同一相位、随机样本的有限性扫描。测试命令零第三方依赖,DevEco 自带的 Node 就够:

node tests/prepare.mjs && node --test --experimental-strip-types tests/cases/

码道汇报 22 通过、提交 7787a12。开发对话里,领域层、组件、资源、测试逐项落地,任务列表从多项待处理一路清零:

开发对话执行中:领域层、组件、资源、测试逐项落地

我把仓库克隆回本地(DevEco 自带 Node v24),复跑结果:

ℹ tests 22
ℹ pass 22
ℹ fail 0

与汇报一致。随后本地用 DevEco 的 hvigor 命令行执行 assembleHap,ArkTS 严格模式编译通过,应用打包成功。收尾时 GLM-5.2 还做了一轮核验:工作区与远程 HEAD 对齐、as string 之类类型断言残留逐项 grep 清零,确认没有暗雷才交付:

GLM-5.2 收尾核验:工作区与远程对齐、as string 残留检查通过

写提示词时真正有用的几条

  1. 先建空仓,再开新对话写代码。 建仓那条上下文会消耗掉一部分,开发需求绑到仓库的 main,不要在同一条对话里接着写。
  2. 天文常数全部写死。 辐射点坐标、ZHR、星群指数、参考新月历元、朔望月长度,一个都不让模型自己查——它查来的数没法验,写死的数可以进测试。
  3. 公式链条拆成纯函数列出来。 恒星时、仰角、月相、流量各一步,每步的输入输出写进提示词,模型只会照着搭,不会自作主张合并。
  4. 尺子写进提示词,也写进测试。 「地平线下 HR=0」如果只是一句文案,模型可能输出负数。hourlyRate(-0.5, 6.5, 0, 0) === 0 必须出现在断言里。
  5. 档位映射要求双向一致。 云量「选档→系数」「系数→选档」互逆,这条性质直接变成测试,档位改了测试立刻红。
  6. ArkTS 语法红线提前列。 不用 any/unknown、不用展开运算符进对象字面量、组件属性避开内置通用属性名、Canvas 枚举是类型不能当值——提前写明,编译期零意外。
  7. 文案和色值全部资源化。 提示词里写明「中文文案进 string.json、颜色进 color.json、深浅色两套」,深色模式和未来国际化都省掉大半工作量。
  8. 让测试对象等于发布对象。 最小转译脚本把 .ets 换名喂给 node,这条路线连续两篇验证有效,零第三方依赖。

边界

流星雨的流量预报是概率,不是点名。ZHR 定义在理想暗夜、辐射点位于天顶、观测者持续注视的条件下;真到了野外,一阵云、一次低头看手机、一颗被树梢挡住的亮流星,都会让「数出来的颗数」和「公式给的颗数」对不上。这个应用把 ZHR 取成常数 25,月相取平均朔望月,月光和云量用教学形式的乘子,经纬度也框在北纬 0–60°、东经 73–135°。它回答的是极大夜这一晚、这一块教学域里的量级,不替代国际流星组织的实时预报,也不外推到南半球或西经。

和目视记录对不上,不是实现失误,是模型到此为止。页脚那句「夜间户外观测请注意安全、保暖并结伴出行,远离公路与水域」写的是另一件更具体的事:算完值得出门,路还是要自己看。

流星雨的浪漫在于它不好提前数清。这个应用只是把「值不值得出门」拆成纬度、时刻、云量和月光四道可以拨动的题。剩下的,抬头。

总结

这次参赛延续并强化了前几篇沉淀的 AI 开发工作流。码道负责写代码,我负责把规则写死:天文常数、公式链、手算尺子、ArkTS 语法红线在提示词里一次钉死,GLM-5.2 照单执行,建仓 1 分 22 秒,开发对话一次跑通测试与提交。领域层 196 行纯函数承载全部天文计算,安全净化层负责档位分流与非有限数回退,UI 层五个状态撑起整条派生链——测试不启动模拟器,22 个用例与发布代码同源。本地复跑 22/22、hvigor 命令行编译通过,全链路可验证。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐