鸿蒙应用开发系列之不用仪器测风级:我用华为云码道把蒲福风级做成了鸿蒙应用

秋风起时,人人都爱说「今天风真大」。多大算大?气象站有风速计,你出门没有。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 通过,HEAD 67ceaec
  • 权限requestPermissions: [],一个都不申请
  • 网络:零联网,无远程资源

这不是天气预报,也不是台风追踪。应用里只有一件事:给定风速数值,判定风级;或者反过来看一眼物象,反查风级。数值预报、台风路径、紫外线、体感温度全部砍掉。砍完之后,判定只剩一次区间比较。
在这里插入图片描述
正是带着这个想法,我参与了华为云码道 AI 编程挑战赛,用 华为云码道 CodeArts 代码智能体(模型 deepseek-v4-flash)独立完成了一个鸿蒙应用——蒲福风级观察台 beaufort-lab。前六篇挑战赛作品全是网页,这一篇特意换到鸿蒙赛道:ArkTS + ArkUI 声明式范式,API 12+,全程在码道对话里从零生成,再拉回本地用 DevEco Studio 编译、签名、跑上模拟器验证。

这个应用刻意做「减法」:不定位、不联网、不拉天气 API,requestPermissions 是货真价实的空数组。它只做两件事——输入风速判风级,或者点选看到的物象反查风级。开发中我把风级表、判定公式、权限红线、ArkTS 语法禁令全部写死在提示词里,并让 25 个测试用例与发布代码同源。

作品介绍:一张能点着玩的物象—风级对照表

打开应用就能做这几件事:

  1. 数值判定:拖动滑杆或在输入框键入风速(m/s),立刻得到风级、风级名称、该级风速区间、km/h 与节的换算。
  2. 物象反查:点选你看到的景象——「烟能表示风向」「吹起地面灰尘和纸张」「全树摇动,迎风步行不便」——直接得到对应风级。
  3. 弧形表盘:0–12 级画成一段 240° 的弧,当前级刻度放大、着色、加描边。两秒内能看清风在哪一段。
  4. 一句判定:例如「5.5 m/s,4 级 · 和风:吹起地面灰尘和纸张,小树枝摇动」。
  5. 深浅色两套色板,主题是海青色系;中文文案全部来自 resources/base|dark/element 资源文件,不硬编码在组件里。
  6. 零权限启动:打开应用不会弹任何授权框,因为 module.json5requestPermissions 是空数组。

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

entry/src/main/ets/
├── domain/        # beaufort.ets:常量表 + 判定 + 换算(纯函数)
├── security/      # sanitize.ets:空串/零值分流 + 夹取(纯函数)
├── ui/            # ScaleDial / SignList / ResultCard 三个组件
├── pages/         # Index.ets:状态派生与装配
└── entryability/  # EntryAbility:默认入口

domainsecurity 加起来不到两百行,却承担了全部业务规则;uipages 只做一件事——把算好的结果摆上屏幕。这个比例是故意的:规则集中在纯函数里,评审想验证判级对不对,不用启动模拟器,读文件就够。

页脚和 README 同一句免责:教学近似模型,物象判据为蒲福 1805 年陆上分级的教学化转述,不能替代气象站数据,不得用于航海、航空、高空作业等任何决策。

应用在 DevEco Studio 里自动签名后一键运行到模拟器,首屏、判定卡、物象反查三块核心交互的实拍如下:

DevEco Studio 运行到模拟器:蒲福风级观察台首屏,弧形风级表盘与当前风速判定卡就位

判定结果卡特写:当前风级与名称、风速区间,以及 km/h、节的换算一览

物象点选反查:点选「5 级 · 清风(有叶小树摇摆)」,列表高亮回显所选级

0–12 级物象列表完整滚览,页脚附教学近似免责声明

蒲福风级表(判据写死在常量表里)

名称风速 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 秒:

码道建仓对话:ag 认证、创建公开仓库、初始化三件套并推送,右下角可见模型选择

仓库地址:

https://atomgit.com/2601_95131637/beaufort-lab

第二条对话新建,并把仓库绑到 2601_95131637/beaufort-labmain。上一条建仓对话的上下文已经用掉一部分,继续往里塞开发需求,模型会在旧上下文里打转。

开发提示词里把尺子写死,不让模型自己挑一组「看起来像大风」的数。核心几行:

判定(输入 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 时走「输入 → sanitizeSpeedscaleForSpeed」的正向链路,点了物象就走「级数 → 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 不要叫 scaleopacitytranslate 这类名字——它们与 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 25fail 0,和汇报一致:

开发对话收尾:测试通过数量 25、提交哈希 67ceaec、仓库根目录树

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

码道开发对话执行中:ArkTS Canvas 表盘代码生成画面,左侧可见账号与仓库绑定

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

  1. 先建空仓,再开新对话写代码。 建仓那条上下文会消耗掉一部分,开发需求绑到仓库的 main,不要在同一条对话里接着写。
  2. 鸿蒙要写清范式和 API 版本。 「鸿蒙应用」四个字太泛,必须写明 ArkTS + ArkUI 声明式、API 12+,模型才知道生成 .ets 而不是网页。
  3. 权限红线写进提示词,也写进 README。 requestPermissions: [] 如果只出现在口头,模型顺手就会申请网络权限「为了拉天气数据」。这次 module.json5 里是货真价实的空数组。
  4. ArkTS 语法红线列出来。 不写明「不用 any、不用展开运算符」,模型会按 TS 习惯写,编译期才发现过不了。
  5. 尺子写进提示词,也写进测试。 「0 是无风」如果只是一句文案,模型会把空串当 0。sanitizeSpeed('0')===0sanitizeSpeed('')===5.5 必须同时出现在断言里。
  6. 让测试对象等于发布对象。 与其让模型写两份逻辑(一份给应用、一份给测试),不如用最小转译把 .ets 换名喂给 node。27 行脚本换来的确定性,值。
  7. 组件属性避开 ArkUI 内置通用属性名。 scaleopacitytranslate 这些名字是内置通用属性方法,自定义组件的 @Prop 撞上去,编译器按通用属性的类型签名校验。这次结果卡的风级属性直接叫 level,一步到位。
  8. 文案和色值全部资源化。 提示词里写明「中文文案进 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 自动签名后一键跑上模拟器,表盘、判定卡、物象反查全部按预期工作。

Logo

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

更多推荐