带学生练短跑,第一次跑 200 米,总有人站在第二道起跑线上嘀咕:“老师,第一道的人怎么在我们后面?”那不是别人抢跑,而是外道起跑线本来就要往前移。这个系列做到第 110 期,我打算做一个偏体育的 HarmonyOS 应用:确定起跑线模拟器。它不追求把田径场做得多漂亮,重点是把“起跑线为什么在这里”这件事讲清楚,顺便解决一个训练痛点——起跑反应时。对开发者来说,这是一个很典型的 ArkTS + Canvas + 状态机项目,计算量不大,但逻辑链条非常完整。
1. 为什么要把“起跑线”做成一个模拟器
1.1 体育训练里的真实需求
田径场上的起跑线,不是一个简单的“画一条线”的问题。100 米大家都在直道上,起跑线齐平,没什么争议;但是 200 米、400 米采用分道跑,外道跑的距离如果按内道半径算,天然多出一截,所以规则要求外道起跑线往前移。这个“前移量”就是前伸数。普通学生第一次看到外道起跑线时,几乎都会觉得有人偷跑。
起跑反应时的训练也是痛点。短跑比赛从发令枪响到运动员蹬离起跑器,优秀运动员通常在 120 到 180 毫秒之间。想在训练中反复练习“听枪起跑”,不能老是真人喊口令,因为真人发令的节奏容易被队员摸透。一个能随机延迟发令、自动测量反应时的工具,就能解决这个问题。
所以这个模拟器最终定位成两件事:一是把起跑线位置的计算逻辑做出来,用图形直观展示各道前伸数;二是模拟完整发令流程,测反应时、判抢跑、统计成绩。它不是一个测绘工具,也不是田径规则教学系统,而是一个介于“体育科普”和“训练辅助”之间的小应用。
1.2 这个模拟器到底模拟了什么
我在设计功能边界的时候,刻意没有把标准跑道所有项目都塞进来,只选了三个典型场景:
- 100 米:纯直道,各道起跑线齐平,前伸数为 0,作为对照组。
- 200 米:跑一个弯道加一个直道,前伸数按一个弯道长度差计算。
- 400 米:跑两个弯道,前伸数按两个弯道长度差计算。
核心流程是用户选择项目、选择道次,应用立即计算出当前道次相对第一道的前伸数,在画布上画出所有道次的起跑线位置,并标记当前道次。切到“反应训练”区域后,点击开始,应用按随机延迟依次进入“各就位”“预备”“鸣枪”三个阶段,用户必须在鸣枪后点击按钮模拟起跑动作,系统记录反应时,鸣枪前点击则判定抢跑。
这个设计不复杂,但每一步都有实际训练场景对应。对初学者来说,它能解决“起跑线凭什么不齐”的疑问;对练短跑的人来说,它就是一个能随身带着练反应时的工具。
1.3 项目技术选型与整体架构
技术栈用的是 DevEco Studio 4.0 以上版本、HarmonyOS API 10 左右的 Stage 模型,开发语言是 ArkTS,UI 框架用 ArkUI。选择这套组合的原因很简单:这个项目要拿 Canvas 画跑道示意图,还要处理多阶段定时器状态,ArkUI 的声明式状态管理刚好能让界面和计算逻辑同步更新。
整个应用不拆多模块,保持一个主页面,代码结构分三块:
- 工具层:
TrackMath.ets,封装跑道几何参数和前伸数计算。 - 视图层:主页面里的 Canvas 起跑线示意图。
- 交互层:发令状态机、反应时记录、统计展示。
后面所有章节都是围绕这三块展开的。先从前伸数的数学原理说起,因为如果这里算错了,画出来的图再漂亮也是错的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 起跑线的计算:标准跑道上的前伸数到底怎么来的
2.1 标准 400 米跑道的几何参数
国际田联标准 400 米跑道的内突沿半径是 36.50 米,分道宽 1.22 米,直道长度 84.39 米。绕第一道跑一圈正好 400 米,这个 400 米不是沿着内突沿本身量的,而是沿着距离内突沿外沿 0.30 米处的测量线量的。从第二道开始,每条道的测量线距离左侧分道线外沿 0.20 米。
很多人在这一步容易搞混。内突沿是跑道最内侧的边,第一道运动员实际跑的时候不可能完全压着内突沿,所以测量时向外放 0.30 米。第二道以及更外道的运动员,跑在分道线内侧还是外侧也有讲究,规则规定沿分道线外侧 0.20 米处测量。这 0.10 米的差值,正好是前伸数计算里最常见的“隐藏项”。
换算成半径就是:
- 第一道测量半径:36.50 + 0.30 = 36.80 米
- 第二道测量半径:36.50 + 1.22 + 0.20 = 37.92 米
- 第 n(n≥2)道测量半径:36.50 + (n-1) × 1.22 + 0.20 米
一个弯道的长度就是 π 乘以测量半径。第一道一个弯道约 115.61 米,两个弯道约 231.22 米,加上两段直道 168.78 米,正好 400 米。
2.2 前伸数公式推导与示例计算
200 米跑要经过一个弯道,外道运动员跑的弯道比第一道长,为了公平,起跑线就必须往前移。前移量等于该道弯道长度减去第一道弯道长度:
一个弯道前伸数 = π × (当前道测量半径 - 第一道测量半径)
400 米跑要经过两个弯道,前伸数翻倍:
两个弯道前伸数 = 2 × π × (当前道测量半径 - 第一道测量半径)
把第二道的数代入:37.92 - 36.80 = 1.12 米,乘以 π 约等于 3.52 米,这是 200 米第二道的前伸数。400 米再乘 2,约等于 7.04 米。这个数字和竞赛手册上的标准值是一致的。第八道的 400 米前伸数大概是 53.03 米,相当于整条直道的一大半,所以站在第八道起跑线上,你会看到自己比第一道领先一大截,这就是弯道半径差带来的视觉冲击。
需要注意的是,100 米在直道上跑,不经过弯道,各道起跑线齐平,前伸数为 0。代码里把弯道数设为 0,自然就返回 0,不需要单独写分支。
2.3 把公式写进 ArkTS 工具类
前伸数计算逻辑独立成工具类,不掺 UI 代码,方便以后扩展。下面这段代码是项目的核心:
typescript复制// TrackMath.ets
export class TrackMath {
static readonly DEFAULT_R: number = 36.5;
static readonly DEFAULT_LANE_WIDTH: number = 1.22;
static measureRadius(lane: number,
r: number = TrackMath.DEFAULT_R,
laneWidth: number = TrackMath.DEFAULT_LANE_WIDTH): number {
if (lane <= 1) {
return r + 0.30;
}
return r + (lane - 1) * laneWidth + 0.20;
}
static leadIn(lane: number,
bendCount: number,
r: number = TrackMath.DEFAULT_R,
laneWidth: number = TrackMath.DEFAULT_LANE_WIDTH): number {
if (lane <= 1 || bendCount <= 0) {
return 0;
}
const first = TrackMath.measureRadius(1, r, laneWidth);
const current = TrackMath.measureRadius(lane, r, laneWidth);
return bendCount * Math.PI * (current - first);
}
}
bendCount 是弯道数,100 米传 0,200 米传 1,400 米传 2。计算完成后,为了让界面显示友好,我会用 parseFloat(value.toFixed(2)) 转成保留两位小数的数字,再存进状态数组里。这样表格里显示的就是“第 2 道前伸数 7.04 米”,而不是一长串小数。
3. 用 Canvas 画一张能看懂前伸数的展开图
3.1 UI 布局与数据状态设计
主界面直接采用单页面上下结构:顶部是项目选择,中间是 Canvas 示意图,下面是道次切换和训练区域。状态管理用 ArkUI 的 @State 驱动,关键的几个状态是:
project:当前项目,类型是字符串,取值'100'、'200'、'400'。lane:当前选中的道次,1 到 8。leads:长度为 8 的数组,存放每个道次的前伸数,界面和 Canvas 都从这里取数。records:最近几次反应时记录,最多保留 10 条。avg:平均反应时。
项目切换和道次切换都会触发 updateLeads(),这个函数重新计算前伸数数组,然后立刻调 drawDiagram() 重绘画布。声明式 UI 的好处是界面上的文本会自动跟随 @State 变化,但 Canvas 不会自动重绘,所以每次数据变化后都要手动调用一次绘制函数。
3.2 为什么不直接画环形跑道
最初我想在 Canvas 里画一个从上往下看的环形跑道,弯道、直道、分道线都画出来,然后让不同道次的起跑线沿弧线错开。试了一下,问题在于:环形跑道上各道起跑线的错位是沿着弯道弧线方向的,在二维俯视图里画出来,角度偏移很小,第八道 53 米的前伸数在 300 像素宽的画布上根本看不出区别。
所以最终改成“展开图”画法:把每条跑道拉成一条水平直道,从上往下排列,最左侧是起跑线,最右侧是终点线。道次 1 在最上方,道次 8 在最下方,每个道次的起跑线按前伸数占最大前伸数的比例向右偏移。这样第八道明显比第一道靠右,一眼就能看出外道为什么要往前移。
这种展开图本质上是一个信息图,不是精确的三维场地还原,但它把“前伸数”这个抽象的数学概念变成了视觉对比。做教学工具的时候,信息准确比场景真实更重要。
3.3 绘制代码拆解
画布的逻辑宽度我固定为 360,高度 270。每条跑道的高度是 24 像素,1 到 8 道加上边距刚好放得下。起跑线横向偏移比例通过循环遍历 leads 数组计算,这里有一个小坑:ArkTS 的严格类型和 lint 规则不太建议直接用 Math.max(...arr) 展开数组求和,所以我写了一个循环先找出最大前伸数,再计算偏移比例。
typescript复制private drawDiagram(): void {
const ctx = this.context;
const w = 360;
const h = 270;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#F4F7F6';
ctx.fillRect(0, 0, w, h);
const laneCount = this.leads.length;
const laneH = 24;
const startY = 30;
const marginX = 50;
const maxAreaWidth = 240;
const maxLead = this.getMaxLead();
const scale = maxLead > 0 ? maxAreaWidth / maxLead : 0;
for (let i = 0; i < laneCount; i++) {
const y = startY + i * laneH;
const offset = this.leads[i] * scale;
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(marginX, y, maxAreaWidth + 20, laneH - 6);
ctx.strokeStyle = '#C0C8C6';
ctx.strokeRect(marginX, y, maxAreaWidth + 20, laneH - 6);
ctx.fillStyle = '#333333';
ctx.font = '12px sans-serif';
ctx.fillText((i + 1) + '道', 10, y + 14);
ctx.strokeStyle = i === this.lane - 1 ? '#E84040' : '#2B7A6E';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(marginX + offset, y - 2);
ctx.lineTo(marginX + offset, y + laneH - 4);
ctx.stroke();
ctx.fillStyle = '#666666';
ctx.fillText(this.leads[i].toFixed(2) + 'm', marginX + offset + 5, y + 14);
}
ctx.strokeStyle = '#999999';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(marginX + maxAreaWidth + 20, startY - 6);
ctx.lineTo(marginX + maxAreaWidth + 20, startY + 8 * laneH - 4);
ctx.stroke();
ctx.fillStyle = '#999999';
ctx.fillText('终点', marginX + maxAreaWidth + 4, startY + 8 * laneH + 4);
}
getMaxLead() 就是一个简单的循环,遍历 this.leads 找最大值。起点线用不同颜色区分当前选中道次,当前道次是红色,其他道次是绿色。每个道次右侧标注前伸数,数据更新后文字也会跟着变。
这里再补充一个细节:Canvas 组件在 ArkUI 里需要绑定一个 CanvasRenderingContext2D 对象,并且要设置 RenderingContextSettings。我在页面里是这样声明的:
typescript复制private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
然后在 build() 里写 <Canvas(this.context).width(360).height(270)...>。RenderingContextSettings(true) 里的 true 表示开启抗锯齿,画斜线或弧线时边缘会平滑很多。
4. 起跑反应训练:倒计时、发令与抢跑判定
4.1 发令状态机设计
反应训练模块是整个应用交互最复杂的部分,我用一个状态机管理发令流程。四个状态分别是:IDLE(空闲)、READY(各就位)、SET(预备)、GO(鸣枪)。用户点击“开始训练”后进入 READY,经过随机延迟进入 SET,再经过随机延迟进入 GO,然后等待用户点击“起跑”按钮。如果用户在 READY 或 SET 状态点击起跑,就判定为抢跑。
状态机的设计要点是延迟随机化。如果每次都是固定的“3、2、1”,练几次之后队员会形成节奏感,反而练不出听枪反应。我在代码里用 Math.random() 生成两段随机延迟:READY 到 SET 是 1500 到 4000 毫秒之间,SET 到 GO 是 500 到 1500 毫秒之间。这样每次发令节奏都不一样。
typescript复制startTrain(): void {
if (this.status === 'READY' || this.status === 'SET') {
return;
}
this.clearTimer();
this.status = 'READY';
this.tip = '各就位 —— 等待发令';
this.timerId = setTimeout(() => {
this.status = 'SET';
this.tip = '预备 —— 准备起跑';
this.timerId = setTimeout(() => {
this.status = 'GO';
this.tip = '鸣枪!点击起跑';
this.startTime = Date.now();
this.vibrateShort();
}, 500 + Math.random() * 1000);
}, 1500 + Math.random() * 2500);
}
4.2 反应时测量与记录
用户在 GO 状态点击“起跑”按钮时,当前时间减去 startTime,就是反应时。这里有个训练参数值得说明:人类听觉反应时理论上限大概在 100 毫秒左右,所以如果测出来小于 100 毫秒,大概率不是正常听枪反应,而是“压枪”预判了。国际田联规则里,反应时小于 100 毫秒会被直接判为抢跑。
我的处理是:程序里小于 100 毫秒给出“压枪嫌疑”的提示,100 到 300 毫秒是正常范围,超过 300 毫秒提示“反应偏慢”。每次记录都放进数组,保留最近 10 条,同时用 reduce 算出平均值显示在界面上。
typescript复制onRunnerTap(): void {
if (this.status === 'GO') {
const elapsed = Date.now() - this.startTime;
this.addRecord(elapsed);
if (elapsed < 100) {
this.tip = '反应时 ' + elapsed + ' ms,压枪嫌疑';
} else if (elapsed <= 300) {
this.tip = '反应时 ' + elapsed + ' ms,正常';
} else {
this.tip = '反应时 ' + elapsed + ' ms,偏慢';
}
this.status = 'RESULT';
} else if (this.status === 'READY' || this.status === 'SET') {
this.clearTimer();
this.status = 'IDLE';
this.tip = '抢跑!请重新开始';
}
}
private addRecord(ms: number): void {
const next = this.records.slice();
next.push(ms);
if (next.length > 10) {
next.shift();
}
this.records = next;
const total = next.reduce((sum, v) => sum + v, 0);
this.avg = Math.round(total / next.length);
}
4.3 定时器清理与页面生命周期
凡是用了 setTimeout 的页面,最怕的就是页面已经销毁了,定时器还活着,回调里访问已经释放的状态,直接报错。HarmonyOS 的页面生命周期里有 aboutToDisappear,我在这里统一清理定时器,同时在页面切到后台时也做了处理。
typescript复制aboutToDisappear(): void {
this.clearTimer();
}
onPageHide(): void {
this.clearTimer();
this.status = 'IDLE';
this.tip = '训练已暂停,回到页面后可重新开始';
}
private clearTimer(): void {
if (this.timerId >= 0) {
clearTimeout(this.timerId);
this.timerId = -1;
}
}
这样处理之后,即使用户在等待发令时切到后台再回来,也不会出现“一回来就枪响”的尴尬情况。训练类应用对状态一致性要求很高,宁可打断训练,也不能让用户觉得发令时机错乱。
5. 项目落地过程中踩过的坑
5.1 Canvas 坐标和尺寸不一致
第一个坑出现在 Canvas 绘制上。最初我把画布宽度设成 '100%',想在 onReady 里通过 this.context.width 拿到实际宽度来动态布局,结果发现拿到的值和预期差很多。不同机型的逻辑像素和物理像素不一样,context.width 在部分版本上返回的是物理像素宽度,而我绘制时用的是逻辑像素,导致线条和文字偏小或者错位。
后来我改成固定宽高,比如 360 * 270,这样绘制坐标是固定的,如果设备屏幕较宽,就让 Canvas 两边留白,不强行拉满。虽然屏幕适配不是最优,但对一个教学演示工具来说,稳定性比花哨的适配更重要。如果要进一步适配,可以在 onAreaChange 里拿组件实际宽度后,按比例缩放绘制坐标,但需要在每次改变时重绘,逻辑量会上升不少。
5.2 setTimeout 在后台被冻结
第二个坑和定时器有关。HarmonyOS 应用切到后台后,系统为了省电会挂起部分定时器。如果你的状态机正在 READY 阶段,切后台再回来,定时器可能已经不会按预期触发,甚至直接恢复执行时跳到 GO,导致用户没有任何准备就“鸣枪”。
这个问题不只在 HarmonyOS 上存在,移动端应用普遍如此。我的处理方式是在 onPageHide 里主动清掉所有定时器,把状态重置为 IDLE,提示用户训练已暂停。这样做虽然会打断一次训练,但保证了状态机永远是可靠的。训练数据宁可少一条,也不能给用户一个假成绩。
5.3 ArkTS 类型限制带来的编码习惯变化
第三个坑是 ArkTS 比传统 TypeScript 更严格。@State 数组里不能随意塞没有明确 interface 的对象,reduce 不传初始值会直接报错,Math.max(...arr) 这种展开写法在 lint 阶段也会被提示。你可能会觉得这些是小问题,但在做实操项目时,这些约束会逼着你把数据模型想清楚。
我建议一开始就给记录数据定义一个 interface,哪怕只有一个字段:
typescript复制interface ReactionRecord {
time: number;
}
然后在页面里用 ReactionRecord[] 类型声明数组。虽然 records 里只存了反应时一个数字,但有了类型,后续想增加时间戳、道次、项目等信息时,只需要扩展 interface,不需要大改逻辑。这种习惯在 ArkTS 项目里特别值得养成,因为编译器给的类型提示能帮你省掉很多排查时间。
5.4 振动权限和模拟器限制
最后提一下振动反馈。发令枪响时我用了短振动来增强“枪感”,但真机上的振动需要权限声明,在 module.json5 里加 ohos.permission.VIBRATE,否则调用振动接口会静默失败。另外,API 模拟器基本不支持振动,调试时只能看提示文字。我在代码里对振动调用做了 Promise 的 catch 处理,避免个别设备不支持时直接崩溃。
typescript复制import vibrator from '@ohos.vibrator';
private vibrateShort(): void {
try {
vibrator.startVibration({ type: 'time', duration: 80 }, { usage: 'alarm' })
.catch((err: Error) => {
console.error('vibrate failed: ' + JSON.stringify(err));
});
} catch (e) {
console.error('vibrate error: ' + JSON.stringify(e));
}
}
不要小看这个 try...catch。真机上振动接口的报错信息可能会通过异常抛出来,不捕获的话,应用会在最不应该出问题的环节突然僵住。对训练工具来说,发令瞬间卡顿是最不能接受的。
6. 实测效果与后续还能怎么玩
6.1 真机运行效果
我在 HarmonyOS 真机上完整跑了一遍流程。启动应用后默认显示 200 米项目
