看见闪电再数秒:基于华为云码道的一个零联网的鸿蒙听雷测距应用
看见闪电再数秒:基于华为云码道的一个零联网的鸿蒙听雷测距应用

看见闪电,心里默数,听到雷再除以三,是很多人都会的算法。三秒大约一公里,好记。空气冷一点、热一点,声速会跟着变:0°C 的干空气大约 331.3 米每秒,每升高 1°C 大约再加 0.606 米每秒。15°C 时是 340.39;20°C、数满 5 秒,直线距离是 1717.1 米。
我把这道估算做成一个鸿蒙应用:听雷测距台 thunder-lab。它不联网、不读定位、不拉气象接口。打开就是两根滑杆:气温、光声间隔。出口是直线距离、当时的声速、光走完同一段路要多久,以及一句按间隔分档的躲避建议。开发交给 华为云码道 CodeArts,模型用 GLM-5.2,界面是 ArkTS + ArkUI。声速系数、分档阈值和手算尺子写在提示词里,模型和测试用的是同一组数。
- 仓库(公开):https://atomgit.com/2601_95131637/thunder-lab
- 协议:MIT
- 运行:DevEco Studio 打开工程根目录,签名后跑到模拟器或真机
- 测试:本地复跑 36/36 通过
- 码道提交:
5e817ac(其下是ab0f750、a5d05cc) - 实测:HarmonyOS 模拟器与 DevEco Pura X View 上,屏上距离与手算一致
- 权限:
requestPermissions: [] - 网络:零联网


看见闪电再数几秒,听到雷除以三,是很多人用来估距离的办法。三秒大约一公里,好记,可声速会随气温变:0°C 的干空气大约 331.3 米每秒,每升高 1°C 大约再加 0.606 米每秒。我把这道估算做成鸿蒙应用「听雷测距台 thunder-lab」。打开就是气温和光声间隔两根滑杆,给出直线距离、声速、光延迟,以及一句分档的躲避建议。不联网,不读定位,不申请权限。
开发交给华为云码道 CodeArts,模型用 GLM-5.2,界面是 ArkTS 与 ArkUI。声速系数和手算尺子写进提示词,36 项测试与屏上数字用的是同一组数。15°C、间隔 5 秒,距离是 1701.9 米。作品已公开开源,协议 MIT。
文章目录
作品介绍:数秒,再乘上当时的声速
打开应用,每一项都挂在公式上:
- 气温。滑杆从 -20°C 到 40°C,默认 15°C。超出区间会夹回来,异常输入回到 15°C。
- 光声间隔。0 到 90 秒,默认 5 秒。0 秒表示闪电和雷声同时到达,距离是 0。
- 雷暴示意。Canvas 自绘闪电和向外的声浪弧。间隔为 0 时弧去掉,改成「闪电就在附近」。
- 结果卡。直线距离(米和千米)、声速、光延迟(微秒)、四档躲避建议。
- 页脚。写明这是干空气里的直线估算,并提醒听到雷声进入坚固建筑。
默认 15°C、间隔 5 秒。声速 340.39 米每秒,距离 1701.9 米,光走完这段路只要 5.6771 微秒。光延迟把「先看见、后听见」量化:人感觉光是瞬间的,数字上也就是几微秒。

把间隔拖到 0,距离变成 0.0 米,示意区不再画声浪:

45 秒落在「听得见雷声,仍在雷暴范围内」。15°C 下 340.39 × 45 = 15317.5 米,屏上是 15.3 千米:

61 秒跨过 60 秒那一档,建议换成远雷。距离 20763.8 米,光延迟 69.2605 微秒,声浪弧拉到示意区边缘:

这四张是 uitest 在本机 HarmonyOS 模拟器上点出来的,数字和手算对得上。
同一组 15°C、61 秒在 DevEco Studio 里又看过。工程是 harmonyApp,设备是 Pura X View 7.0.0(26.0.0)。屏上是 20763.8 米,声速 340.39 米每秒,20.76 千米:
手机画面拉近,结果卡上是距离、千米和声速:

再往下,是光走完同一段路的时间,以及这一档建议。69.2605 微秒,文案是远雷:

默认这一屏可以手算复原。气温 15°C 时,声速是 (331.3 + 0.606 \times 15 = 340.39) 米每秒。间隔 5 秒,直线距离是 (340.39 \times 5 = 1701.95) 米,屏上保留一位小数,写成 1701.9 米,也就是 1.70 千米。同一段路的光延迟是 (1701.95 / 299792458 \times 10^6 \approx 5.6771) 微秒。5 秒落在 30 秒以内,建议档是 1:「间隔不足30秒,雷击很近,尽快躲避」。
45 秒仍用同一声速:(340.39 \times 45 = 15317.55) 米,屏上 15317.5 米、15.3 千米。它已经跨过 30 秒,还没到 60 秒,所以停在「听得见雷声,仍在雷暴范围内」。61 秒是 (340.39 \times 61 = 20763.79) 米,屏上 20.76 千米,进入远雷那一档。三张图换的只是秒数,公式没有换。
领域模型:四个纯函数
业务全在 entry/src/main/ets/domain/thunder.ets。没有类,没有 UI,四个函数加一组常量。
声速是线性式。气温不是有限数时,退回 15°C 那一档,声速始终是一个可读的数:
export const SOUND_T0: number = 331.3;
export const SOUND_K: number = 0.606;
export function speedOfSound(celsius: number): number {
if (!Number.isFinite(celsius)) {
return SOUND_T0 + SOUND_K * 15;
}
return SOUND_T0 + SOUND_K * celsius;
}
距离是声速乘秒数。0 秒和负数都是 0。结果落在 0 到 40000 米之间,40°C、90 秒大约 32 公里,仍在这个区间里。气温或秒数不是有限数时,按「15°C、5 秒」计算。
export function strikeDistance(celsius: number, seconds: number): number {
if (!Number.isFinite(celsius) || !Number.isFinite(seconds)) {
const fallbackSpeed: number = SOUND_T0 + SOUND_K * 15;
const fallbackDist: number = fallbackSpeed * 5;
return Math.min(Math.max(fallbackDist, 0), DISTANCE_MAX);
}
if (seconds <= 0) {
return 0;
}
const speed: number = speedOfSound(celsius);
const dist: number = speed * seconds;
return Math.min(Math.max(dist, 0), DISTANCE_MAX);
}
光延迟用真空光速 299792458 米每秒,换成微秒。距离不是正数时返回 0。这个数很小,用来对照「光可以当成立刻到」。
export const LIGHT_MPS: number = 299792458;
export function lightDelayMicros(meters: number): number {
if (!Number.isFinite(meters) || meters <= 0) {
return 0;
}
return (meters / LIGHT_MPS) * 1e6;
}
1701.9 米对应大约 5.6771 微秒,20763.8 米对应 69.2605 微秒。人先看见闪电、再听见雷,差的是声音走完这段路的时间;光在同一段路上只留下小数点后的微秒。
躲避建议按间隔分档,不按算出来的米数分档。阈值是:<= 0 为 0 档,< 30 为 1 档,< 60 为 2 档,其余为 3 档。30 秒进入 2 档,60 秒进入 3 档。文案在资源里,函数只返回 0 到 3。
export const ADVICE_THRESHOLD_NEAR: number = 0;
export const ADVICE_THRESHOLD_30: number = 30;
export const ADVICE_THRESHOLD_60: number = 60;
export function adviceLevel(seconds: number): number {
if (!Number.isFinite(seconds)) {
return 0;
}
if (seconds <= ADVICE_THRESHOLD_NEAR) {
return 0;
}
if (seconds < ADVICE_THRESHOLD_30) {
return 1;
}
if (seconds < ADVICE_THRESHOLD_60) {
return 2;
}
return 3;
}
| 档 | 间隔 | 文案 |
|---|---|---|
| 0 | ≤ 0 秒 | 闪电就在附近,立即进入坚固建筑 |
| 1 | < 30 秒 | 间隔不足30秒,雷击很近,尽快躲避 |
| 2 | < 60 秒 | 听得见雷声,仍在雷暴范围内 |
| 3 | ≥ 60 秒 | 远雷,仍不是解除警报 |
60 秒、15°C 大约 20 公里,屏上会标成远雷,并保留页脚里的躲避提醒。
手算尺子:先算完再交给模型
提示词里的尺子,和 tests/thunder.test.ts 里的断言是同一批数。容差 1e-6,和手算对齐。
声速:
- T = 0 → 331.3
- T = 15 → 340.39
- T = 20 → 343.42
- T = -10 → 325.24
距离:
- T = 0,t = 3 → 993.9 米
- T = 20,t = 5 → 1717.1 米
- T = 15,t = 30 → 10211.7 米
- 任意气温,t ≤ 0 → 0
- 40°C、90 秒仍落在 [0, 40000]
光延迟不把微秒写成死数,断言比较的是 1000 / 299792458 * 1e6,光速常量锁在 299792458。
建议档位在分界上各有断言:adviceLevel(0) = 0,29.9 → 1,30 → 2,59.9 → 2,60 → 3,负数回到 0 档。另外各有 1000 次随机输入,结果保持有限,距离落在 0 到 40000 米。
安全净化:0 秒必须还是 0
滑杆给出的是有限数,净化层仍单独放在 security/sanitize.ets。气温夹到 [-20, 40],间隔夹到 [0, 90]。异常输入回到默认:气温 15,间隔 5。
0 秒表示闪电和雷声同时到达。测试写明 sanitizeSeconds(0) === 0,净化之后仍是 0,不会被填成默认 5 秒。页面上的 0 秒截图就是这条规则。
export const CELSIUS_MIN: number = -20;
export const CELSIUS_MAX: number = 40;
export const CELSIUS_DEFAULT: number = 15;
export const SECONDS_MIN: number = 0;
export const SECONDS_MAX: number = 90;
export const SECONDS_DEFAULT: number = 5;
export function sanitizeCelsius(celsius: number): number {
if (!Number.isFinite(celsius)) {
return CELSIUS_DEFAULT;
}
return Math.min(Math.max(celsius, CELSIUS_MIN), CELSIUS_MAX);
}
export function sanitizeSeconds(seconds: number): number {
if (!Number.isFinite(seconds)) {
return SECONDS_DEFAULT;
}
return Math.min(Math.max(seconds, SECONDS_MIN), SECONDS_MAX);
}
气温滑杆每一步 1°C,间隔滑杆每一步 1 秒。回调写回页面前会再过一遍这两个函数,界面上的数字和领域层拿到的数字是同一对。
主页面:两个状态,其余都是算出来的
Index.ets 只有 tempCelsius 和 intervalSeconds 两个 @State。声速、距离、光延迟、建议档不另存一份,每次绘制都从这两个数现算。
@Entry
@Component
struct Index {
@State tempCelsius: number = 15;
@State intervalSeconds: number = 5;
private getSafeTemp(): number {
return sanitizeCelsius(this.tempCelsius);
}
private getSafeInterval(): number {
return sanitizeSeconds(this.intervalSeconds);
}
private getDistance(): number {
return strikeDistance(this.getSafeTemp(), this.getSafeInterval());
}
}
页面是一个 Scroll,里面按顺序放标题、雷暴示意、两根滑杆、结果卡和页脚。底色 #1A0E2E,标题用 #FFD700。滑杆回调先净化再写状态:
ConditionList({
tempCelsius: this.getSafeTemp(),
intervalSeconds: this.getSafeInterval(),
onTempChange: (value: number) => {
this.tempCelsius = sanitizeCelsius(value);
},
onIntervalChange: (value: number) => {
this.intervalSeconds = sanitizeSeconds(value);
}
})
ConditionList 把当前值写在标签旁边,滑杆本身只负责把新值交出去。气温从 -20 到 40,间隔从 0 到 90,步进都是 1:
Slider({
value: this.intervalSeconds,
min: 0,
max: 90,
step: 1,
style: SliderStyle.OutSet
})
.trackColor('#4A3B6E')
.selectedColor('#FFD700')
.blockColor('#FFD700')
.onChange((value: number) => {
this.onIntervalChange(value);
})
结果卡拿到的是已经算好的米数,千米、声速和光延迟在卡片内部再换算一次。距离保留一位小数,声速保留两位,光延迟保留四位,和截图上的 1701.9 m、340.39 m/s、5.6771 μs 是同一套格式。
private getDistanceKm(): number {
return this.distanceMeters / 1000;
}
private getSoundSpeed(): number {
return speedOfSound(this.tempCelsius);
}
private getLightDelay(): number {
return lightDelayMicros(this.distanceMeters);
}
建议文案不拼资源名。四档 if / else 各自写死 $r('app.string.advice_0') 到 advice_3,字符串仍在 string.json:
if (adviceLevel(this.intervalSeconds) === 0) {
Text($r('app.string.advice_0'))
} else if (adviceLevel(this.intervalSeconds) === 1) {
Text($r('app.string.advice_1'))
} else if (adviceLevel(this.intervalSeconds) === 2) {
Text($r('app.string.advice_2'))
} else {
Text($r('app.string.advice_3'))
}
雷暴示意是一块 280 高的 Canvas。distanceMeters 和 intervalSeconds 带 @Watch,滑杆一动就重绘。间隔大于 0 时画四圈声浪,颜色跟着建议档走:近雷偏红,雷暴范围内用金,远雷用紫。间隔为 0 时不画弧,示意区改成「闪电就在附近」。
@Prop @Watch('onInputsChanged') distanceMeters: number = 0;
@Prop @Watch('onInputsChanged') intervalSeconds: number = 0;
private onInputsChanged(): void {
this.drawScene();
}
private drawSoundArcs(ctx: CanvasRenderingContext2D, cx: number, cy: number): void {
const maxRadius: number = 120;
const arcCount: number = 4;
const level: number = adviceLevel(this.intervalSeconds);
const arcColor: string = level <= 1 ? '#FF6B6B' : level === 2 ? '#FFD700' : '#7B68EE';
for (let i: number = 0; i < arcCount; i++) {
const radius: number = 30 + (i + 1) * (maxRadius / (arcCount + 1));
ctx.strokeStyle = arcColor;
ctx.globalAlpha = 0.8 - i * 0.15;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
}
}
圆心下方再写一行距离:满 1 千米用 km,否则用米。所以 5 秒那张示意区标的是公里数,0 秒那张则只留闪电。整页放在 Scroll 里,距离、光延迟、建议和页脚可以顺着往下看。45 秒那张是间隔滑到雷暴范围内的一档,数字和公式一致。
目录:公式、净化、界面分开
码道按这个目录交代码。领域层不引用 ArkUI,界面不自己写声速公式。
entry/src/main/ets/
domain/thunder.ets 声速、距离、光延迟、建议档
security/sanitize.ets 气温和间隔的取值范围
pages/Index.ets 两个状态和整页排布
ui/ConditionList.ets 两根滑杆
ui/ResultCard.ets 距离、声速、光延迟、建议
ui/StormPanel.ets Canvas 闪电和声浪
tests/thunder.test.ts 手算尺子
tests/sanitize.test.ts 0 秒和夹取
tests/transpile.mjs .ets 换成 .ts 再测
中文文案集中在 entry/src/main/resources/base/element/string.json。标题是「听雷测距台」,页脚写明声速取 331.3 + 0.606×气温 的干空气线性式,并提醒进入坚固建筑,远离树木、高处、水域和金属物。
开发过程:先建空仓,再写应用
码道上分成两条对话。第一条建公开空仓:仓库名 thunder-lab,初始化 README、MIT、.gitignore。模型是 GLM-5.2-ArkTS-SPARK。


仓库地址:https://atomgit.com/2601_95131637/thunder-lab
第二条对话新建,绑定 2601_95131637/thunder-lab 的 main。提示词一次写完五块:主题(只做测距,不联网,不申请权限)、四个函数和常量、手算尺子、目录和 ArkTS 约定、测试命令与「提交并 push 到 origin/main」。

码道交出领域层、净化层、三个组件和页面,跑通测试并推送。ab0f750 是应用本身,5e817ac 是 README 里的测试说明,汇报 36 项通过。

我把仓库克隆到本机,用 DevEco 自带的 Node 复跑:
ℹ tests 36
ℹ pass 36
ℹ fail 0
和汇报一致。assembleHap 通过后,HAP 装进模拟器 127.0.0.1:5555,包名 com.thunderlab.app。上面的运行图,包括 DevEco 里 Pura X View 那三张,都是这次装上去的,屏上的米数和手算尺子一致。
权限与资源
module.json5 里和安全有关的一行是空数组:
"deviceTypes": ["phone", "tablet"],
"pages": "$profile:main_pages",
"requestPermissions": []
气温和秒数都是滑杆,用不到定位、网络、麦克风或存储。清单里不写这些权限,首次打开不会弹出授权。中文文案在 string.json,组件里只有 $r('app.string.xxx')。页面用紫黑底,距离数字和闪电示意放在深色上。
测试:测的就是要发布的那份 .ets
领域层是 ArkTS,但没有装饰器,也没有 ArkUI。tests/transpile.mjs 只做两件事:把 thunder.ets、sanitize.ets 拷成 .ts,给相对 import 补上扩展名。tests/.transpiled/ 进了 .gitignore,仓库里不会出现第二份公式。
node tests/transpile.mjs
node --test --experimental-strip-types tests/thunder.test.ts tests/sanitize.test.ts
零第三方依赖。DevEco 自带的 Node 就能跑。断言和提示词里的尺子是同一批数,例如 0°C、间隔 3 秒:
test('T=0, t=3 → 993.9 m', () => {
assert.ok(Math.abs(strikeDistance(0, 3) - 993.9) < 1e-6);
});
test('T=15 → 340.39', () => {
assert.ok(Math.abs(speedOfSound(15) - 340.39) < 1e-6);
});
test('adviceLevel(30) = 2', () => {
assert.equal(adviceLevel(30), 2);
});
331.3 × 3 = 993.9,331.3 + 0.606 × 15 = 340.39。30 秒整进入 2 档,29.9 秒仍是 1 档。本地复跑 36 项,和码道汇报的通过数一致。
提示词里一次写清的几件事
- 先建空仓,再开新对话。 开发对话绑定仓库的
main,在这个仓里写应用并推送。 - 声速系数写清。
331.3和0.606同时出现在提示词和断言里,手算和测试是同一组数。 - 0 秒单独写明。 间隔为 0 时距离是 0,对应「闪电就在附近」。
- 档位边界成对写。
29.9是 1 档,30是 2 档,30 秒和 60 秒落在哪一档都有断言。 - 建议文案走资源字面量。 四档各自引用
$r('app.string.advice_0')到advice_3。 - 测试跑的就是要发布的那份代码。
.ets换扩展名再跑,仓库里不另放一份算法。
总结
听雷测距台把「闪电之后数几秒」收成两个滑杆和四个纯函数。华为云码道按写好的尺子交出领域层、净化层和界面,36 个测试与发布代码同源,本地复跑 36/36。HAP 在 HarmonyOS 模拟器里跑通,屏上的 1701.9 米、15317.5 米、20763.8 米和手算一致。
看见闪电,心里默数,听到雷再除以三,是很多人都会的算法。三秒大约一公里,好记;空气冷热一变,声速也会变。0°C 的干空气大约 331.3 米每秒,每升高 1°C 大约再加 0.606 米每秒。15°C 时是 340.39;20°C、数满 5 秒,直线距离是 1717.1 米。我把这道估算做成鸿蒙应用「听雷测距台 thunder-lab」。它不联网、不读定位、不拉气象接口。打开就是气温和光声间隔两根滑杆,算出距离、声速、光延迟和一句躲避建议。
开发交给华为云码道 CodeArts,模型用 GLM-5.2,界面是 ArkTS 与 ArkUI。声速系数、分档阈值和手算尺子写在提示词里,模型和测试用的是同一组数。作品已公开开源,协议 MIT,仓库是 https://atomgit.com/2601_95131637/thunder-lab 。权限清单为空,首次打开不会弹出授权。本地复跑 36 项测试全部通过,HAP 装进 HarmonyOS 模拟器,并在 DevEco 的 Pura X View 上看过,屏上距离与手算一致。
默认 15°C、间隔 5 秒,声速 340.39 米每秒,距离 1701.9 米,光延迟 5.6771 微秒。间隔为 0 时,距离是 0.0 米,示意区改成「闪电就在附近」。45 秒仍在雷暴范围内,15317.5 米;61 秒进入远雷,20763.8 米,光延迟 69.2605 微秒。换的只是秒数,公式没有换。光延迟把「先看见、后听见」收成微秒。中文文案在资源里,标题为听雷测距台。
业务收在四个纯函数里:声速、直线距离、光延迟、建议档。声速按 331.3 加 0.606 乘气温。距离是声速乘秒数,0 秒和负数都是 0,结果落在 0 到 40000 米。光延迟用真空光速 299792458 米每秒,换成微秒。躲避建议只看间隔:不超过 0 秒立即进入坚固建筑,不足 30 秒是雷击很近,不足 60 秒仍在雷暴范围内,60 秒及以上标成远雷,页脚仍提醒躲避。
页面只保存气温和间隔,其余每次绘制现算。气温夹在 -20°C 到 40°C,间隔夹在 0 到 90 秒。0 秒净化之后仍是 0,不会被填成默认 5 秒。雷暴示意用 Canvas 自绘,滑杆一动就重绘:近雷偏红,雷暴范围内用金,远雷用紫。
码道先建空仓,再绑定仓库写应用。提示词一次写清公式、尺子、目录和测试。码道交出领域层、净化层和界面,提交 ab0f750 与 5e817ac,汇报 36 项通过。本机复跑同样是 36 通过、0 失败。测试跑的就是要发布的那份代码。屏上的 1701.9 米、15317.5 米、20763.8 米,和手算尺子一致。零权限、零联网。感谢华为云码道,公式、测试和界面一次交齐。
更多推荐



所有评论(0)