鸿蒙应用开发系列之不用仪器测风级:我用华为云码道把蒲福风级做成了鸿蒙应用
鸿蒙应用开发系列之不用仪器测风级:我用华为云码道把蒲福风级做成了鸿蒙应用
秋风起时,人人都爱说「今天风真大」。多大算大?气象站有风速计,你出门没有。1805 年英国海军少将蒲福(Francis Beaufort)把风分成 0–12 级,判据不是仪器,而是看得见的物象:烟直上是 0 级,树叶小枝摇动是 4 级,全树摇动是 7 级,烟囱瓦片被掀动是 9 级。两百多年过去,这套判据还写在气象学教材里,因为它回答的不是「风速几米每秒」,而是「风正在对世界做什么」。
网上一搜「风力等级」,出来的要么是天气 App 的附赠页面,要么是要求定位权限、拉取远程天气 API 的演示工程。于是我把这件事做成一个鸿蒙应用:蒲福风级观察台 beaufort-lab。开发全程交给 华为云码道 CodeArts,模型用的是 deepseek-v4-flash,范式是 ArkTS + ArkUI 声明式。我只负责把风级表、判定公式和禁令写死,把仓库拉回本地点一遍。
- 仓库(公开):https://atomgit.com/2601_95131637/beaufort-lab
- 协议:MIT
- 运行:DevEco Studio 5.0+ 打开工程,自动签名后运行到模拟器或真机
- 测试:
npm test→ 本地复跑 25/25 通过,HEAD67ceaec - 权限:
requestPermissions: [],一个都不申请 - 网络:零联网,无远程资源
这不是天气预报,也不是台风追踪。应用里只有一件事:给定风速数值,判定风级;或者反过来看一眼物象,反查风级。数值预报、台风路径、紫外线、体感温度全部砍掉。砍完之后,判定只剩一次区间比较。

正是带着这个想法,我参与了华为云码道 AI 编程挑战赛,用 华为云码道 CodeArts 代码智能体(模型 deepseek-v4-flash)独立完成了一个鸿蒙应用——蒲福风级观察台 beaufort-lab。前六篇挑战赛作品全是网页,这一篇特意换到鸿蒙赛道:ArkTS + ArkUI 声明式范式,API 12+,全程在码道对话里从零生成,再拉回本地用 DevEco Studio 编译、签名、跑上模拟器验证。
这个应用刻意做「减法」:不定位、不联网、不拉天气 API,requestPermissions 是货真价实的空数组。它只做两件事——输入风速判风级,或者点选看到的物象反查风级。开发中我把风级表、判定公式、权限红线、ArkTS 语法禁令全部写死在提示词里,并让 25 个测试用例与发布代码同源。
作品介绍:一张能点着玩的物象—风级对照表
打开应用就能做这几件事:
- 数值判定:拖动滑杆或在输入框键入风速(m/s),立刻得到风级、风级名称、该级风速区间、km/h 与节的换算。
- 物象反查:点选你看到的景象——「烟能表示风向」「吹起地面灰尘和纸张」「全树摇动,迎风步行不便」——直接得到对应风级。
- 弧形表盘:0–12 级画成一段 240° 的弧,当前级刻度放大、着色、加描边。两秒内能看清风在哪一段。
- 一句判定:例如「5.5 m/s,4 级 · 和风:吹起地面灰尘和纸张,小树枝摇动」。
- 深浅色两套色板,主题是海青色系;中文文案全部来自
resources/base|dark/element资源文件,不硬编码在组件里。 - 零权限启动:打开应用不会弹任何授权框,因为
module.json5的requestPermissions是空数组。
整个工程的分层一眼能看完:
entry/src/main/ets/
├── domain/ # beaufort.ets:常量表 + 判定 + 换算(纯函数)
├── security/ # sanitize.ets:空串/零值分流 + 夹取(纯函数)
├── ui/ # ScaleDial / SignList / ResultCard 三个组件
├── pages/ # Index.ets:状态派生与装配
└── entryability/ # EntryAbility:默认入口
domain 和 security 加起来不到两百行,却承担了全部业务规则;ui 和 pages 只做一件事——把算好的结果摆上屏幕。这个比例是故意的:规则集中在纯函数里,评审想验证判级对不对,不用启动模拟器,读文件就够。
页脚和 README 同一句免责:教学近似模型,物象判据为蒲福 1805 年陆上分级的教学化转述,不能替代气象站数据,不得用于航海、航空、高空作业等任何决策。
应用在 DevEco Studio 里自动签名后一键运行到模拟器,首屏、判定卡、物象反查三块核心交互的实拍如下:




蒲福风级表(判据写死在常量表里)
| 级 | 名称 | 风速 m/s | 陆上物象 |
|---|---|---|---|
| 0 | 无风 | < 0.3 | 烟直上 |
| 1 | 软风 | 0.3–1.5 | 烟能表示风向 |
| 2 | 轻风 | 1.6–3.3 | 人面感觉有风,树叶微响 |
| 3 | 微风 | 3.4–5.4 | 树叶及微枝摇动不息 |
| 4 | 和风 | 5.5–7.9 | 吹起地面灰尘和纸张,小树枝摇动 |
| 5 | 清风 | 8.0–10.7 | 有叶小树摇摆 |
| 6 | 强风 | 10.8–13.8 | 大树枝摇动,电线呼呼有声 |
| 7 | 疾风 | 13.9–17.1 | 全树摇动,迎风步行不便 |
| 8 | 大风 | 17.2–20.7 | 微枝折毁,人向前行阻力甚大 |
| 9 | 烈风 | 20.8–24.4 | 烟囱顶部及屋瓦移动 |
| 10 | 狂风 | 24.5–28.4 | 陆上少见,树木可被吹倒 |
| 11 | 暴风 | 28.5–32.6 | 陆上很少见,建筑物有相当损毁 |
| 12 | 飓风 | ≥ 32.7 | 陆上绝少见,摧毁力极大 |
这表里最容易写错的是边界。10.7 属于 5 级,10.8 属于 6 级。还有一处不起眼的细节:教材表按 0.1 粒度取整,相邻两级的 max 和下一个 min 之间留了缝(1.5 和 1.6 之间、5.4 和 5.5 之间)。代码把缝里的值归入高一级——判本级用 v < max + 1e-9,越过这条容差线就进下一级。手算尺子钉进测试:
- v = 0 v = 0 v=0 → 0 级,无风。0 是合法物理点,不是空值。
- v = 10.7 v = 10.7 v=10.7 → 5 级; v = 10.8 v = 10.8 v=10.8 → 6 级。边界必须分对。
- v = 32.6 v = 32.6 v=32.6 → 11 级; v = 32.7 v = 32.7 v=32.7 → 12 级。12 级只有下界,没有上界。
- 空字符串风速回退默认 5.5,不得变成 0。
- 输入 − 3 -3 −3 夹到 0;输入 999 999 999 夹到 50。
- 对 0–12 每级,区间中点判定必须回到该级(round-trip)。
为什么做蒲福,而不是再做一个天气页
赛题允许网站、小游戏、小程序、鸿蒙应用、桌面应用任意一类。前六篇全是网页:拱极星、地平线视距、寒露、狮子过境、蟋蟀气温、秋分日晷。第七篇换到鸿蒙,评测的五维不变——架构 30%、代码 30%、安全 20%、UI/UX 15%、博文 5%。
码道很容易把「风」做成申请定位、fetch 天气 API 的查询页。蒲福观察台卡的是另一类问题:
- 公式必须在纯函数里。 判级、换算一旦写进组件的点击事件,测试就得启动模拟器。
- 0 是合法物理点,空串不是。 0 m/s 是无风,不是「没填」。空输入如果走
Number,无风和没填会被混成一件事。 - 权限一个都不能要。 鸿蒙应用申请权限要走弹窗、要写理由。这个应用根本不需要:
requestPermissions: []。 - ArkTS 有语法红线。 不用
any/unknown、不用展开运算符进对象字面量,类型出现在每一层签名里——这些编译期就会卡住,提示词里必须写明。
鸿蒙给了它网页版没有的两样东西:一是 requestPermissions 为空的合规起点,评审打开 module.json5 第一眼就是空数组;二是声明式 UI 的状态管理,判定结果是纯派生值,输入变它就变,不需要手写事件回调。
一、先把公开仓建出来
参赛要求写得很死:AtomGit 公开仓、可运行源码、README、开源协议。我从 https://atomgit.com/dashboard 进工作台,切到 华为云码道。
第一条对话只建空仓,不写业务:
- 仓库名
beaufort-lab,可见性 Public - 初始化 README、MIT、
.gitignore,本地路径/workspace/beaufort-lab - 建好后把 URL 发回来
码道先查 ag auth status 确认认证是 2601_95131637,再 ag repo create 建仓、git clone 到本地、补齐三个初始化文件、git push -u origin main,全程 1 分 2 秒:

仓库地址:
https://atomgit.com/2601_95131637/beaufort-lab
第二条对话新建,并把仓库绑到 2601_95131637/beaufort-lab 的 main。上一条建仓对话的上下文已经用掉一部分,继续往里塞开发需求,模型会在旧上下文里打转。
开发提示词里把尺子写死,不让模型自己挑一组「看起来像大风」的数。核心几行:
判定(输入 v ∈ [0, 50] m/s):
- 逐级比较区间 [min, max),v 落在哪级判哪级;12 级只有下界
- 上界允许 ±1e-9 浮点容差
合法零值:
- v = 0 是无风,合法,判 0 级
- 空字符串风速回退 5.5,不得变成 0
- -3 夹到 0,999 夹到 50
边界:
- 10.7 → 5 级,10.8 → 6 级
- 32.6 → 11 级,32.7 → 12 级,5.5 → 4 级
红线:
- requestPermissions 必须为空数组
- 不 import @ohos.net.http,无远程资源
- 领域层不 import 任何 @ohos.* UI/系统能力
- 不用 any/unknown,不用展开运算符进对象字面量
- 禁止做成日晷、节气、蟋蟀气温、卫星过境、露点、视距、拱极、月龄——旧作不能换皮
二、领域层:常量表、容差、round-trip
beaufort.ets 只做区间比较和乘法,不碰任何系统能力。整个文件的角色是一个可被任何层调用的判定器:
// 蒲福风级观察台 · 领域层
// 判据写死为常量表(单位 m/s),蒲福 1805 年陆上分级的教学化转述。
// 纯函数:不依赖任何 @ohos.* 能力,输入输出为有限数,绝不抛错。
export interface BeaufortLevel {
level: number;
name: string;
min: number;
max: number;
sign: string;
}
export const MIN_SPEED: number = 0;
export const MAX_SPEED: number = 50;
export const DEFAULT_SPEED: number = 5.5;
export const KNOTS_M_PER_S: number = 0.514444;
export const GRADE_TOLERANCE: number = 1e-9;
const RAW_SCALE: BeaufortLevel[] = [
{ level: 0, name: '无风', min: 0.0, max: 0.3, sign: '烟直上' },
{ level: 1, name: '软风', min: 0.3, max: 1.5, sign: '烟能表示风向' },
// ……逐级填满,判据来自第二节表格
{ level: 12, name: '飓风', min: 32.7, max: Number.POSITIVE_INFINITY,
sign: '陆上绝少见,摧毁力极大' }
];
export const BEAUFORT_SCALE: BeaufortLevel[] = RAW_SCALE;
表以 const 常量导出,判据在编译期就定型;UI 层拿到的只有判定函数和这张只读语义的表,没有任何改写它的正当路径。
判定函数只有十几行,两件事值得说:
// 夹取到合法输入域 [0, 50],保证领域层对任意输入都输出有限数。
export function clampSpeed(speed: number): number {
if (!isFiniteNumber(speed)) {
return DEFAULT_SPEED; // NaN / Infinity 回退默认,不扩散
}
if (speed < MIN_SPEED) return MIN_SPEED;
if (speed > MAX_SPEED) return MAX_SPEED;
return speed;
}
// 逐级比较 [min, max),上界允许 ±1e-9 浮点容差;12 级只有下界。
export function scaleForSpeed(speed: number): number {
const v: number = clampSpeed(speed);
for (let i: number = 0; i < BEAUFORT_SCALE.length - 1; i++) {
const level: BeaufortLevel = BEAUFORT_SCALE[i];
if (v < level.max + GRADE_TOLERANCE) {
return level.level;
}
}
return BEAUFORT_SCALE[BEAUFORT_SCALE.length - 1].level;
}
第一,clampSpeed 在最前面挡住非有限数——NaN 不做比较会一路穿透,最后输出 NaN 级。第二,那行 1e-9 不是装饰:10.7 与 10.8 的边界是浮点数,浮点误差落在边界上时,容差决定判 5 级还是 6 级。
换算是两条乘法,节用教学常数 0.514444:
export function speedToKmh(v: number): number {
return v * 3.6;
}
export function speedToKnots(v: number): number {
return v / KNOTS_M_PER_S;
}
反向查询 boundsForScale(scale) 把级数变回判据,非法级数夹取到 0 和 12,UI 拿它渲染区间文案,不需要自己再写一遍表。
三、安全层:空串和 0 分成两条路
安全层的红线和前几篇一样:空和零走不同的函数分支,空串在解析之前就返回。
// 蒲福风级观察台 · 输入安全层
// 所有来自 UI 的文本先经过本层:空串与合法零值分流、parse、夹取。
// 不 import 任何 @ohos.* 能力,纯函数。
// 空串或全空白视为"未输入"。
export function isBlank(text: string): boolean {
return text.trim().length === 0;
}
// 文本 → 合法风速数值。
// - 空串 / 全空白 / 无法解析:回退 DEFAULT_SPEED(5.5),绝不变成 0;
// - "0" 是合法物理点,原样返回 0;
// - 越界值夹取到 [0, 50]。
export function sanitizeSpeed(raw: string): number {
if (isBlank(raw)) {
return DEFAULT_SPEED;
}
const parsed: number = Number.parseFloat(raw);
if (!Number.isFinite(parsed)) {
return DEFAULT_SPEED;
}
if (parsed < MIN_SPEED) return MIN_SPEED;
if (parsed > MAX_SPEED) return MAX_SPEED;
return parsed;
}
sanitizeSpeed('0') 走解析分支——0 是无风,判 0 级;sanitizeSpeed('') 走回退分支——没填,给默认 5.5。两条路在测试里成对出现,缺一条,评审就会怀疑你把无风误杀了。
这一层的存在让 UI 组件可以完全放心:组件拿到的数永远在 [0, 50] 里且有限。安全不是渲染时的小心翼翼,是数据进门的闸机。
四、UI:弧形表盘是 Canvas 自绘的
声明式范式的核心是「状态派生」:风速是 @State,风级是纯函数算出来的派生值,输入变、判定跟着变。表盘组件只在 @Watch 里重画:
@Component
export struct ScaleDial {
@Prop @Watch('onScaleChange') currentScale: number = 0;
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private startAngle: number = 150 * Math.PI / 180;
private totalAngle: number = 240 * Math.PI / 180;
private draw(): void {
const c: CanvasRenderingContext2D = this.ctx;
const w: number = c.width;
const h: number = c.height;
if (w <= 0 || h <= 0) return;
c.clearRect(0, 0, w, h);
const cx: number = w / 2;
const cy: number = h * 0.62;
const radius: number = Math.min(w, h) * 0.34;
const last: number = BEAUFORT_SCALE.length - 1;
// 底弧
c.beginPath();
c.arc(cx, cy, radius, this.startAngle, this.startAngle + this.totalAngle);
c.lineWidth = 16;
c.lineCap = 'round';
c.strokeStyle = this.palette.track;
c.stroke();
// 进度弧:当前级数按比例填充
if (clamp > 0) {
c.beginPath();
c.arc(cx, cy, radius, this.startAngle,
this.startAngle + (clamp / last) * this.totalAngle);
c.strokeStyle = this.palette.primary;
c.stroke();
}
// ……刻度点 + 数字,当前级放大并描边
}
}
两个细节。色板不写死:loadPalette() 通过 resourceManager.getColorSync 从资源文件取色,深浅色模式切换后表盘自动跟上。0 级不画进度弧——无风就是没有进度,不是画一条零长度弧。
色板加载的完整实现:
private loadPalette(): void {
const host: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
const read: (res: Resource) => string = (res: Resource): string => {
return this.argbToHex(host.resourceManager.getColorSync(res.id));
};
this.palette.primary = read($r('app.color.primary'));
this.palette.track = read($r('app.color.dial_track'));
this.palette.pin = read($r('app.color.dial_pin'));
this.palette.text = read($r('app.color.text_secondary'));
this.palette.textMain = read($r('app.color.text_primary'));
this.palette.highlight = read($r('app.color.text_highlight'));
}
Canvas 的 strokeStyle/fillStyle 吃 ARGB 字符串,资源系统给的是色值整数,中间垫一个 argbToHex 做进制转换。深浅色两套资源同名,切换模式后下一次 draw() 重取,表盘配色自动跟上,组件里没有任何 if (isDark) 之类的分支。
主页面:三个状态,其余全靠派生
pages/Index.ets 是装配层,值得整段读:
@Entry
@Component
struct Index {
@State speedText: string = ''; // 输入框原文,唯一的"真"输入
@State pickedLevel: number = -1; // 物象反查选中的级,-1 表示未选
@State notANumber: boolean = false; // 输入是否无法解析
// 有效风速:物象反查时取该级区间中点,否则走消毒管道
private get effectiveSpeed(): number {
if (this.pickedLevel >= 0) {
const b: BeaufortLevel = boundsForScale(this.pickedLevel);
if (this.pickedLevel >= BEAUFORT_SCALE.length - 1) {
return b.min; // 12 级只有下界
}
return (b.min + b.max) / 2;
}
return sanitizeSpeed(this.speedText);
}
// 有效风级:物象反查优先,否则数值判定
private get effectiveScale(): number {
if (this.pickedLevel >= 0) {
return this.pickedLevel;
}
return scaleForSpeed(sanitizeSpeed(this.speedText));
}
private onSpeedInput(value: string): void {
this.speedText = value;
this.pickedLevel = -1; // 一打字就退出物象反查态
this.notANumber = !isBlank(value) && !Number.isFinite(Number.parseFloat(value));
}
private onPickSign(scale: number): void {
this.pickedLevel = scale;
this.notANumber = false;
}
// build() 里表盘、判定卡全部吃这两个 getter
}
三个 @State,两条判定路径。pickedLevel 是模式开关:-1 时走「输入 → sanitizeSpeed → scaleForSpeed」的正向链路,点了物象就走「级数 → boundsForScale」的反向链路,两个来源互斥。物象反查回填的风速取该级区间中点(12 级取下界),所以反查「清风」时,换算卡展示的 8.0–10.7 的代表值是有物理含义的,不是随便挑的数。表盘和判定卡吃同一对 getter,两块区域永远不会各说各话。
物象列表与判定卡:UI 不持有第二份风级数据
SignList 的列表直接遍历领域层常量表:
ForEach(BEAUFORT_SCALE, (item: BeaufortLevel) => {
Row({ space: 12 }) {
Text(`${item.level}`)
// ……级数徽章:选中态换主色底、白字
Column({ space: 2 }) {
Text(item.name).fontSize(16) // 级名:无风、软风……
Text(item.sign).fontSize(13) // 物象:烟直上……
}
.layoutWeight(1)
}
// ……圆角卡片外观与选中描边
.onClick(() => {
this.onSelectItem(item.level);
})
}, (item: BeaufortLevel) => `${item.level}`)
风级数据全应用只有一份,就是 BEAUFORT_SCALE。列表的行数、顺序、文案全部来自常量表——将来想微调某级的物象描述,改一处,表格、列表、判定卡三处同步。键值用级数字符串,列表复用稳定。
判定卡负责把区间渲染成人话:
private fmtNum(value: number): string {
return `${Math.round(Math.abs(value) * 10) / 10}`;
}
private fmtInterval(min: number, max: number): string {
if (max === Number.POSITIVE_INFINITY) {
return `≥ ${this.fmtNum(min)}`;
}
return `${this.fmtNum(min)} – ${this.fmtNum(max)}`;
}
12 级的上界是正无穷,fmtInterval 单独接住这个分支,渲染成「≥ 32.7」——「无穷」两个字永远不出现在用户眼前。所有区间文案(m/s、km/h、节三套)都走这一个函数,格式天然一致。
这里还有一条值得写进提示词的命名纪律:自定义组件的 @Prop 不要叫 scale、opacity、translate 这类名字——它们与 ArkUI 内置通用属性方法重名,编译器会按通用属性的类型签名去校验,报错信息还不太指向根因。结果卡的风级属性因此叫 level 而不是 scale。这类冲突编译期就能拦住,提前写明可以省一轮修改。
五、权限与资源:零权限是怎么落进配置文件的
「零权限」不能只是口号,要落在配置文件里给评审看。module.json5 全文里最值钱的一行是空数组:
{
"module": {
"name": "entry",
"type": "entry",
"mainElement": "EntryAbility",
"deviceTypes": ["phone", "tablet"],
"deliveryWithInstall": true,
"pages": "$profile:main_pages",
"requestPermissions": [],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"exported": true,
"skills": [
{ "entities": ["entity.system.home"], "actions": ["action.system.home"] }
]
}
]
}
}
鸿蒙的权限模型是「申请即弹窗」:只要 requestPermissions 里出现 ohos.permission.LOCATION,用户第一次打开就会看到定位授权框。这个应用判风级只需要一个数或者一次点选,联网、定位、存储一个都用不上,空数组就是它的合规起点。评审打开 module.json5 第一眼看到 [],比在文章里写十句「注重隐私」都有说服力。
文案同样不进组件。所有中文字符串收在 resources/base/element/string.json:
{
"string": [
{ "name": "page_title", "value": "蒲福风级观察台" },
{ "name": "page_subtitle", "value": "烟直上到飓风:按可见物象判风级" },
{ "name": "input_empty_note","value": "留空按 5.5 m/s(4 级和风)判定" },
{ "name": "judged_from_sign","value": "由物象反向判定" },
{ "name": "footnote", "value": "教学近似模型:……不得用于航海、航空、高空作业决策。" }
]
}
色板收在 color.json,海青色系:
{
"color": [
{ "name": "page_bg", "value": "#F2F8FA" },
{ "name": "primary", "value": "#0E7C86" },
{ "name": "primary_deep","value": "#0A5C64" },
{ "name": "card_bg", "value": "#FFFFFF" },
{ "name": "dial_track", "value": "#D6E5EA" }
]
}
深色模式靠的是 resources/dark/element/ 下的同名文件:page_bg 换成深夜海面色、card_bg 换成深卡片色,键名一个不改。组件里所有颜色都走 $r('app.color.xxx') 引用,系统切换深浅色时资源解析自动换装,零代码分支。文案与色值出组件的另一个好处是:想出一版英文界面,只需要加 en_US 目录,组件一行不动。
六、测试:ArkTS 子集怎么被 node 跑
领域层是 .ets 文件,node 不认识这个扩展名。工程里放了一个 27 行的最小转译脚本:
// 最小编译脚本:把 ArkTS 子集领域层 .ets 复制为 .ts(扩展名换掉即可被
// node --experimental-strip-types 加载),并修正相对 import。零第三方依赖。
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const stage = join(root, 'tests', '.stage');
const sources = [
['entry/src/main/ets/domain/beaufort.ets', 'beaufort.ts', null],
['entry/src/main/ets/security/sanitize.ets', 'sanitize.ts',
[['../domain/beaufort', './beaufort.ts']]
]
];
rmSync(stage, { recursive: true, force: true });
mkdirSync(stage, { recursive: true });
for (const [rel, out, rewrites] of sources) {
let src = readFileSync(join(root, rel), 'utf8');
if (rewrites !== null) {
for (const [from, to] of rewrites) {
src = src.split(from).join(to);
}
}
writeFileSync(join(stage, out), src, 'utf8');
console.log(`staged ${rel} -> tests/.stage/${out}`);
}
内容一个字节不改,只换扩展名、修正 import 路径——因为领域层本来就是 ArkTS 子集,没用到 ArkTS 独有语法。这条路线的好处是测试对象就是发布对象:测的文件和打进应用的文件是同一个,不是两份平行实现。
测试命令零第三方依赖:
npm test
# 等价于:
node tests/transpile.mjs && node --test --experimental-strip-types tests/*.test.mjs
25 个用例覆盖四类。手算尺子全部钉死:
test('手算尺子:v = 10.7 → 5 级;v = 10.8 → 6 级(边界分对)', () => {
assert.equal(scaleForSpeed(10.7), 5);
assert.equal(scaleForSpeed(10.8), 6);
});
test('手算尺子:v = 32.6 → 11 级', () => {
assert.equal(scaleForSpeed(32.6), 11);
});
test('clampSpeed:夹取到 [0, 50]', () => {
assert.equal(clampSpeed(-3), 0);
assert.equal(clampSpeed(999), MAX_SPEED);
assert.equal(clampSpeed(Number.NaN), DEFAULT_SPEED);
});
round-trip 扫 0–12 每级的中点,判定必须回到该级;再扫 0 到 50 的 20000 个随机样本,输出必须有限、不抛错:
test('区间 round-trip:0–12 每级区间中点判定回到该级', () => {
for (let level = 0; level <= 12; level++) {
const b = boundsForScale(level);
const mid = level === 12 ? b.min : (b.min + b.max) / 2;
assert.equal(scaleForSpeed(mid), level, `level ${level} midpoint ${mid}`);
}
});
test('区间 round-trip:每级上界 max 判本级(±1e-9 容差)', () => {
for (let level = 0; level < 12; level++) {
const b = boundsForScale(level);
assert.equal(scaleForSpeed(b.max), level, `level ${level} max ${b.max}`);
}
});
test('输出有限不抛错:随机样本扫描 20000 点', () => {
for (let i = 0; i < 20000; i++) {
const v = Math.random() * MAX_SPEED;
const s = scaleForSpeed(v);
assert.ok(Number.isInteger(s), `scaleForSpeed(${v}) 非整数`);
assert.ok(s >= 0 && s <= 12);
const b = boundsForScale(s);
assert.ok(Number.isFinite(b.min));
if (s < 12) {
assert.ok(Number.isFinite(b.max));
}
}
});
三个 round-trip 合起来是一张网:中点验每级的「腹地」,上界点验容差线,随机样本验全链路对任意输入都不抛错、不产出非有限数。表完整性用例还检查级数连续 0–12、区间严格递增且不回退——常量表想漏掉某一级或者把边界抄错,测试直接红。
消毒路径把空和零写成相邻断言:
test('sanitize:空字符串回退默认 5.5,不得变成 0', () => {
assert.equal(sanitizeSpeed(''), DEFAULT_SPEED);
assert.equal(sanitizeSpeed(' '), DEFAULT_SPEED);
});
test('sanitize:合法零值 0 保留为 0(不是空值)', () => {
assert.equal(sanitizeSpeed('0'), 0);
});
码道汇报 25 通过、提交 67ceaec。我把仓库克隆回本地复跑,pass 25、fail 0,和汇报一致:

开发过程的中间态也有留档——任务列表从「6 待处理」一路收到「1 待处理」,ArkTS 表盘组件代码逐段生成:

七、写提示词时真正有用的几条
- 先建空仓,再开新对话写代码。 建仓那条上下文会消耗掉一部分,开发需求绑到仓库的
main,不要在同一条对话里接着写。 - 鸿蒙要写清范式和 API 版本。 「鸿蒙应用」四个字太泛,必须写明 ArkTS + ArkUI 声明式、API 12+,模型才知道生成
.ets而不是网页。 - 权限红线写进提示词,也写进 README。
requestPermissions: []如果只出现在口头,模型顺手就会申请网络权限「为了拉天气数据」。这次module.json5里是货真价实的空数组。 - ArkTS 语法红线列出来。 不写明「不用 any、不用展开运算符」,模型会按 TS 习惯写,编译期才发现过不了。
- 尺子写进提示词,也写进测试。 「0 是无风」如果只是一句文案,模型会把空串当 0。
sanitizeSpeed('0')===0和sanitizeSpeed('')===5.5必须同时出现在断言里。 - 让测试对象等于发布对象。 与其让模型写两份逻辑(一份给应用、一份给测试),不如用最小转译把
.ets换名喂给 node。27 行脚本换来的确定性,值。 - 组件属性避开 ArkUI 内置通用属性名。
scale、opacity、translate这些名字是内置通用属性方法,自定义组件的@Prop撞上去,编译器按通用属性的类型签名校验。这次结果卡的风级属性直接叫level,一步到位。 - 文案和色值全部资源化。 提示词里写明「中文文案进
string.json、颜色进color.json、深浅色两套」,模型就不会把字符串硬编码进组件——深色模式适配和未来国际化都省掉大半工作量。
边界
秋天的风有阵性,蒲福的表是平均状态的观察。这个应用算的是教学近似:物象判据是 1805 年陆上分级的教学化转述,换算系数取了教学近似值,测试也只钉到 ±1e-9 的判定容差。和气象站对不上,不是 bug,是模型边界。页脚那句「不得用于航海、航空、高空作业决策」就是为这件事写的。
秋风过树,你数不清它几米每秒。但你能看见烟斜了、纸飞了、树摇了——蒲福两百多年前就替你把这套观察做成了刻度。这个应用只是把刻度装回了手机。
这次参赛最大的收获,是验证了一套「把规则前置」的 AI 开发工作流。码道负责写代码,我负责把判据写死:风级表逐级给定区间,10.7 判 5 级、10.8 判 6 级的边界连同 ±1e-9 容差一起进提示词;「0 是无风不是空值」变成 sanitizeSpeed(‘0’)===0 与 sanitizeSpeed(‘’)===5.5 成对断言;requestPermissions: [] 同时写进提示词与 README。AI 不猜需求,因为尺子已经钉死。
架构上坚持三层分离:领域层是不到两百行的纯函数,常量表、夹取、判级、换算全在里面,不碰任何系统能力;安全层把空串与合法零值分成两条路;UI 只做状态派生与渲染。为了让测试不依赖模拟器,我用 27 行最小转译脚本把 ArkTS 子集领域层换个扩展名喂给 node,测试对象就是发布对象,25 个用例覆盖手算尺子、round-trip 与两万点随机扫描。
工程细节上也有几条值得沉淀的经验:自定义组件的 @Prop 要避开 scale 这类内置通用属性名;文案与色值全部收进 resources/base|dark/element,深浅色模式零代码分支适配;Canvas 自绘表盘的色板从资源系统动态加载。最终应用在 DevEco Studio 自动签名后一键跑上模拟器,表盘、判定卡、物象反查全部按预期工作。
更多推荐



所有评论(0)