【电路实验模拟|03】HarmonyOS ArkTS 交通灯实战:用定时状态切换红黄绿灯
**证据边界:**本文基于
D:/huawei/one6-9的 TrafficLightPage、ExperimentSimPage 和 Experiment 模型进行静态复核。源码可证明 traffic_light 入口复用通用实验视图,定时器每 32ms 更新 animationPhase,绘制时把相位等分为红、黄、绿三态,并通过运行、闭环、短路和过载条件控制 active。32ms 是动画刷新间隔,不是现实信号时长;当前实现不是可用于道路控制的安全状态机。本轮未构建、安装或真机测试。

在 HarmonyOS ArkUI 中实现一个红黄绿交通灯,表面上只需要三个圆和一个定时器。真正值得研究的是:怎样让时间推进、电路安全、运行状态、Canvas 绘制、页面生命周期和完成记录保持一致。
本文基于真实的"电路实验模拟"工程,分析 TrafficLightPage.ets 与共享组件 ExperimentSimView 的实现。工程中的交通灯不是连接真实 GPIO,也不是完整交通控制器,而是一个教学型 Canvas 模拟场景:用户点击运行后,animationPhase 由 32ms interval 推进,红、黄、绿三颗 LED 按相位轮转点亮;开关断开、短路或过载时,三盏灯都不会激活。
文章会准确区分现有源码和改进建议。当前实现有真实的三相轮转、相对坐标绘制、生命周期清理和一次性完成记录,但没有为红黄绿分别配置独立持续时间,也没有显式的交通灯枚举、倒计时或路口冲突相位。
一、从启动到Canvas重绘的状态链
交通灯效果并不是三个互不相关的布尔值,而是同一 animationPhase 经离散化后得到的唯一相位。定时器推动动画,安全条件决定是否允许灯态进入 active,Canvas 只消费最终状态。

二、页面、状态、绘制与记录的分层
TrafficLightPage 只负责把 traffic_light 标识交给 ExperimentSimView。通用视图集中处理定时器、安全规则、绘制和记录,减少多个实验页面复制生命周期代码的风险。

三、TrafficLightPage仍然是薄入口
真实入口页面如下:
import { ExperimentSimView } from '../ExperimentSimPage'
@Entry
@Component
struct TrafficLightPage {
build() {
Column() {
ExperimentSimView({
initialExpId: 'traffic_light',
initialTitle: '迷你红绿灯'
})
}
.width('100%')
.height('100%')
}
}
它只完成两件事:
- 指定实验 ID 为
traffic_light; - 指定页面标题为"迷你红绿灯"。
参数、运行按钮、计时器、Canvas、仪表、收藏和记录全部复用 ExperimentSimView。因此,阅读交通灯实现不能停留在入口页,必须沿 initialExpId 找到实验配置和 drawTrafficLightScene()。
四、交通灯实验的真实配置
Experiment.ets 中定义:
{
id: 'traffic_light',
name: '迷你红绿灯',
description: '用红、黄、绿三颗 LED 组合交通灯,学习多负载顺序控制。',
category: '基础模块',
level: '基础',
icon: $r('app.media.ic_exp_traffic_light'),
isFavorite: false,
params: [
{
name: '电池电压',
unit: 'V',
min: 3,
max: 12,
defaultValue: 5,
step: 1
},
{
name: '限流电阻',
unit: 'ohm',
min: 100,
max: 1000,
defaultValue: 330,
step: 10
}
]
}
默认值为 5V 和 330Ω。共享电流模型对普通 LED 使用固定 2V 压降:
const effectiveVoltage =
Math.max(0, this.voltage() - 2)
return effectiveVoltage / this.resistance()
默认电流约为:
I = (5V - 2V) / 330Ω
= 0.00909A
= 9.1mA
当前仪表显示的是整个模拟回路的单一电流值,不是红、黄、绿三条独立支路各自的电流。源码中的场景是顺序点亮教学模型,不是三通道驱动电路的精确求解。
三、时间状态来自 animationPhase
交通灯没有单独声明:
@State currentLight: 'red' | 'yellow' | 'green'
而是复用:
@State animationPhase: number = 0
运行后,定时器每 32ms 推进:
private startTimer(): void {
this.stopTimer()
this.timerId = setInterval(() => {
this.animationPhase =
(this.animationPhase + 0.035) % 1
this.drawCanvas()
this.persistLearningTime(false)
if (!this.isFinished &&
this.animationPhase > 0.96) {
this.isFinished = true
DataStore.incrementExperimentCount()
this.persistRecord()
}
}, 32)
}
animationPhase 始终处于 [0, 1)。Canvas 再把连续相位离散成三个区间:
const phase =
Math.floor(this.animationPhase * 3) % 3
const showRed =
active && phase === 0
const showYellow =
active && phase === 1
const showGreen =
active && phase === 2
所以真实顺序是:
phase 0 -> 红灯
phase 1 -> 黄灯
phase 2 -> 绿灯
然后回到红灯
本文的唯一核验标记是:
定时器只负责推进时间,当前灯色必须由时间状态唯一推导
五、计算当前实现的真实节拍
每帧推进 0.035,间隔约 32ms。
一个完整周期需要:
1 / 0.035 ≈ 28.57 帧
28.57 * 32ms ≈ 914ms
每个灯色占三分之一周期:
914ms / 3 ≈ 305ms
所以当前并不是现实交通灯常见的"红灯数十秒、黄灯数秒、绿灯数十秒",而是大约每 0.3 秒切换一次,完整红黄绿周期约 0.9 秒。
这对展示动画足够醒目,但文章和应用不能把它描述成真实道路配时。准确说法是"用动画相位快速演示红黄绿顺序控制"。
六、连续相位转离散状态的优点与限制
优点
- 只维护一个
animationPhase; - 光晕动画与灯色切换共享时间基准;
% 1自然形成循环;- Canvas 每帧可利用相位绘制呼吸效果;
- 不需要三个独立计时器。
限制
- 三种颜色持续时间固定相等;
- 无法单独配置黄灯时长;
- 没有倒计时;
- interval 延迟会直接影响节拍;
- 页面恢复后没有保存精确灯色;
- 业务状态隐藏在浮点区间里,不便测试。
如果交通灯只是视觉演示,连续相位很合适;如果要做课程实验、倒计时或多路口联动,应该把灯色和时间显式建模。
六、active 是所有灯光的总门禁
drawCanvas() 先计算:
const active =
this.isRunning &&
this.isClosedLoop() &&
!this.hasShortCircuit() &&
!this.hasOverload()
只有 active 为 true,交通灯场景才允许某一盏灯亮。
这建立了一个重要顺序:
先判断电路能否运行
再判断当前应该亮哪一种颜色
红灯状态不能绕过短路门禁,绿灯状态也不能绕过开关状态。这样不会出现"仪表显示短路风险,但绿灯仍发光"的冲突。
七、Canvas如何绘制三颗灯
交通灯场景使用相对坐标:
const left = w * 0.12
const right = w * 0.78
const top = h * 0.22
const bottom = h * 0.78
const midY = h * 0.5
先画主回路,再画交通灯外壳:
ctx.strokeStyle = '#55708A'
ctx.lineWidth = 2
const lampX = right
const lampTop = top + 18
const lampBottom = bottom - 18
ctx.strokeRect(
lampX - 28,
lampTop,
56,
lampBottom - lampTop
)
三颗灯在外壳中纵向分布:
const cy1 =
lampTop + (lampBottom - lampTop) * 0.2
const cy2 =
lampTop + (lampBottom - lampTop) * 0.5
const cy3 =
lampTop + (lampBottom - lampTop) * 0.8
然后分别调用:
this.drawLedAt(
ctx,
lampX,
cy1,
showRed,
AppColors.ACCENT_RED,
'R',
14,
11
)
this.drawLedAt(
ctx,
lampX,
cy2,
showYellow,
AppColors.ACCENT_ORANGE,
'Y',
14,
11
)
this.drawLedAt(
ctx,
lampX,
cy3,
showGreen,
AppColors.ACCENT_GREEN,
'G',
14,
11
)
每一帧最多只有一个 showXxx 为 true。其余灯使用关闭态颜色。
八、为什么不用三个 setInterval
一个直觉写法是:
红灯 timer
黄灯 timer
绿灯 timer
它会带来严重同步问题:
- timer 启动顺序不同;
- 暂停时要清理三份句柄;
- 页面离开可能漏清理;
- 重置时难以确定下一盏灯;
- interval 漂移后可能出现两个灯同时亮;
- 快速重复运行可能叠加六个甚至更多 timer。
当前工程只维护:
private timerId: number = -1
startTimer() 开始前调用 stopTimer():
private stopTimer(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId)
this.timerId = -1
}
}
单一时间源让灯色天然互斥,是正确方向。
八、显式状态机适合可配置配时
如果要支持真实课程配置,可以改成:
enum TrafficLightState {
RED = 'red',
YELLOW = 'yellow',
GREEN = 'green'
}
interface TrafficTiming {
redMs: number
yellowMs: number
greenMs: number
}
组件状态:
@State currentLight:
TrafficLightState = TrafficLightState.RED
@State remainingMs: number = 0
转换表:
private nextLight(
current: TrafficLightState
): TrafficLightState {
if (current === TrafficLightState.RED) {
return TrafficLightState.YELLOW
}
if (current === TrafficLightState.YELLOW) {
return TrafficLightState.GREEN
}
return TrafficLightState.RED
}
或按更符合实际产品定义的红 -> 绿 -> 黄 -> 红顺序配置。当前源码顺序是红 -> 黄 -> 绿,文章不替它改写事实。
九、不要把动画帧当成业务时钟
当前实现通过"每帧增加 0.035"推进时间。若系统繁忙,setInterval 回调不能保证精确按 32ms 执行,真实周期会变长。
更稳妥的做法是根据真实时间计算:
private cycleStartedAt: number = 0
private updatePhase(now: number): void {
const elapsed = now - this.cycleStartedAt
const cycleMs = 900
this.animationPhase =
(elapsed % cycleMs) / cycleMs
}
这样即使某一帧延迟,下一帧仍会回到正确相位。
进一步把业务状态与绘制相位分开:
业务时钟 -> 当前灯色、剩余时间
视觉时钟 -> 光晕、粒子、呼吸动画
低帧率不应改变红灯业务时长,动画暂停也不应使交通控制逻辑失真。
十、运行态、完成态和循环态的关系
当前代码在 animationPhase > 0.96 时:
if (!this.isFinished &&
this.animationPhase > 0.96) {
this.isFinished = true
DataStore.incrementExperimentCount()
this.persistRecord()
}
这大致发生在第一个完整周期结束前。isFinished 保证实验完成记录只写一次。
但是:
- timer 不会停止;
isRunning仍为true;- 红黄绿继续循环;
- 页面标签优先显示"运行中"。
因此"完成"在当前源码中的含义是"至少观察过一个周期并记录完成",不是"动画已经结束"。
这是合理的教学交互,但应在命名上更明确:
hasCompletedFirstCycle
比:
isFinished
更接近真实语义。
十一、暂停与重置必须恢复哪些状态
暂停:
private pauseExperiment(): void {
this.isRunning = false
this.stopTimer()
this.persistLearningTime()
this.drawCanvas()
}
暂停后不再推进相位,Canvas 重绘非激活状态,三盏灯全部关闭。当前实现没有保留"暂停时仍显示当前灯色但停止光晕"的视觉模式。
重置:
private resetExperiment(): void {
this.stopTimer()
this.isRunning = false
this.isFinished = false
this.isChecked = false
this.switchClosed = true
this.componentCount = 5
this.animationPhase = 0
this.paramValues =
this.paramDefs.map(p => p.defaultValue)
this.drawCanvas()
}
对交通灯来说,相位 0 对应红灯,但由于 isRunning=false,重置后不会点亮红灯,而是全部关闭。只有再次运行后红灯才激活。
十二、页面生命周期必须清理定时器
真实源码:
aboutToDisappear(): void {
this.stopTimer()
this.persistLearningTime()
}
这避免页面返回后 interval 继续:
- 修改
animationPhase; - 重绘不可见 Canvas;
- 累计学习时长;
- 写实验记录;
- 持有页面实例。
交通灯比静态 LED 更依赖生命周期清理,因为它一进入运行就持续定时更新。回归测试必须包含"运行中直接返回"。
十三、状态色不能只靠颜色表达
红黄绿本身就是颜色语义,但无障碍和审核层面仍不能只看颜色。当前 Canvas 使用:
R
Y
G
标签,这是有价值的辅助信息。
还可以增加:
- 仪表文字"当前:红灯";
- 图标或形状差异;
- 倒计时数字;
- 高对比边框;
- 状态切换时可选的轻量触觉反馈;
- 屏幕阅读描述。
关闭态颜色也要保证在浅色、深色和不同屏幕亮度下可区分。当前应用整体使用深色实验画布,但发布前仍需实际检查对比度。
十四、交通灯不是三个独立负载求解
当前场景在视觉上画三颗 LED,但电流模型仍只有一个:
currentValue()
三颗灯也共用:
5V
330Ω
固定 2V 压降
没有:
- 红、黄、绿分别的压降;
- 三个独立限流电阻;
- GPIO 输出通道;
- 分支电流;
- 驱动三极管;
- 多灯同时亮的冲突电流。
所以这是"多负载顺序控制的视觉教学",不是三通道硬件控制仿真。
如果要扩展,可建模:
interface LedChannel {
id: 'red' | 'yellow' | 'green'
forwardVoltage: number
resistance: number
outputHigh: boolean
}
然后分别计算每路电流,并增加"禁止两个冲突输出同时为高电平"的规则。
十五、当前红黄绿顺序是否合理
源码顺序:
红 -> 黄 -> 绿 -> 红
现实交通信号通常会根据道路规则采用不同序列,例如某些控制逻辑中绿灯结束后进入黄灯,再回红灯。本文不把现实规则强行套到教学代码上,但如果产品文案使用"真实交通灯控制",就必须重新确认业务顺序。
建议把顺序配置化:
interface TrafficPhase {
light: TrafficLightState
durationMs: number
description: string
}
const phases: TrafficPhase\[\] = [
{
light: TrafficLightState.RED,
durationMs: 5000,
description: '停止'
},
{
light: TrafficLightState.GREEN,
durationMs: 5000,
description: '通行'
},
{
light: TrafficLightState.YELLOW,
durationMs: 1500,
description: '准备停止'
}
]
这段是改进建议,当前源码仍使用三等分相位。
十六、四层架构让定时状态更可测
17.1 配置层
负责:
电压、电阻、灯色顺序、每相持续时间
17.2 状态机层
负责:
当前灯色、剩余时间、下一状态、暂停、重置
17.3 电路门禁层
负责:
开关、闭合、短路、过载、是否允许输出
17.4 渲染层
负责:
Canvas 外壳、LED 颜色、光晕、导线和仪表
副作用如学习时长与完成记录应独立控制,不应写在灯色绘制函数中。
十七、建议的纯函数测试
先测试相位映射:
function phaseToLight(
phase: number
): 'red' | 'yellow' | 'green' {
const index = Math.floor(phase * 3) % 3
if (index === 0) return 'red'
if (index === 1) return 'yellow'
return 'green'
}
测试表:
| animationPhase | 预期 |
|---|---|
| 0 | red |
| 0.1 | red |
| 0.333 | red 或边界前值,需定义 |
| 0.334 | yellow |
| 0.666 | yellow |
| 0.667 | green |
| 0.999 | green |
由于浮点边界敏感,建议用整数毫秒或整数相位索引作为业务状态。
再测试总门禁:
| isRunning | closed | short | overload | active |
|---|---|---|---|---|
| false | true | false | false | false |
| true | false | false | false | false |
| true | true | true | false | false |
| true | true | false | true | false |
| true | true | false | false | true |
十八、建议的UI与生命周期回归
运行流程
- 首次进入三灯全灭;
- 点击运行后首先出现红灯;
- 红灯后切换黄灯;
- 黄灯后切换绿灯;
- 任意时刻只有一盏灯激活;
- 一个周期只增加一次完成记录。
参数与风险
- 5V、330Ω 时电流约 9.1mA;
- 开关断开时电流归零;
- 电阻过小时不允许发光;
- 电压或电阻变化后仪表同步;
- 过载时状态和灯光一致。
重复操作
- 连续点击运行不叠加 timer;
- 暂停后相位停止变化;
- 再运行时从当前相位继续,或按产品定义重启;
- 重置后相位回 0;
- 运行中返回页面,timer 被清理;
- 再次进入不会继承旧页面 timer。
多设备
- 手机竖屏交通灯不裁切;
- 平板宽屏灯体比例不失真;
- 2in1 调整窗口后 Canvas 坐标正确;
- R/Y/G 标签清晰;
- 状态栏和底部安全区不遮挡控制按钮。
十九、性能优化:动画频率和持久化频率分离
当前每 32ms:
更新相位
重绘 Canvas
检查学习时长
检查完成记录
视觉刷新约 31FPS。对于简单交通灯足够,但学习时长不需要每帧检查。
可以拆成:
动画循环:按帧更新 Canvas
业务时钟:按真实时间计算灯色
持久化:每秒或暂停/离开时写入
完成记录:状态首次跨过周期边界时写一次
这样高频绘制不会携带不必要的存储副作用。
二十、当前源码的准确能力边界
已实现
- 独立
TrafficLightPage入口; traffic_light参数配置;- 5V 与 330Ω 默认值;
- 32ms 单一 interval;
animationPhase循环推进;- 红、黄、绿三等分相位;
- 每帧最多一灯激活;
- 短路、过载和断路总门禁;
- Canvas 相对坐标绘制;
- R/Y/G 文本标签;
- 页面离开清理 timer;
- 第一周期只写一次完成记录。
尚未实现或未证实
- 红黄绿独立持续时间;
- 现实道路配时;
- 倒计时;
- 行人灯;
- 多路口冲突相位;
- 三路独立电流求解;
- GPIO 或真实硬件控制;
- 后台持续运行;
- 真机多设备完整验证。
二十一、结语
当前交通灯实现的核心不是三个颜色,而是一个单一时间源:
setInterval
-> animationPhase
-> phase index
-> showRed/showYellow/showGreen
-> Canvas
再通过:
isRunning
&& isClosedLoop
&& !hasShortCircuit
&& !hasOverload
为所有灯色设置统一电路门禁。这种设计避免多个 timer 争抢状态,也让 Canvas 成为业务状态的纯视觉投影。
它的边界同样清楚:三色持续时间相等,完整周期约 0.9 秒,灯色顺序为红、黄、绿,完成记录发生在第一周期,动画会继续循环。只有把这些事实说清楚,才能在下一步安全演进到可配置配时、真实时间基准、倒计时和多路口状态机。
二十二、参考资料
- HarmonyOS 开发者:
@State装饰器与组件内状态 - HarmonyOS 开发者:Canvas 与 CanvasRenderingContext2D 自定义绘制
- HarmonyOS 开发者:ArkUI 概述
二十三、32ms刷新与信号时长不是一回事
源码中的 setInterval 回调间隔为 32ms,每次把 animationPhase 增加 0.035,并用取模保持在零到一之间。这个机制描述的是画面刷新与动画推进速度,而非红灯、黄灯、绿灯各自的业务持续时间。把 32ms 写成现实交通灯周期会越过源码证据,也会混淆渲染节拍和控制策略。
按当前增量估算,一次完整动画循环需要若干次回调,但 setInterval 并不保证精确调度:系统负载、页面生命周期和事件循环都会带来漂移。因此文章只把它解释为教学动画。若产品真的需要可配置持续时间,应以时间戳计算经过时间,并把红、黄、绿持续时长写入独立配置,而不是依赖固定回调次数。
二十四、三相位轮转如何保证单灯点亮
绘制函数通过 floor(animationPhase * 3) % 3 得到零、壹、贰三个相位,再分别生成 showRed、showYellow、showGreen。每个表达式同时要求 active 为真且 phase 命中自己的编号,因此正常情况下只有一个灯被点亮。其余灯仍被绘制,只是处于未激活样式。
这种单一 phase 派生多个显示值的方式比维护三个可写布尔状态更稳,因为不存在红灯和绿灯同时被业务代码写成 true 的组合。测试时应验证相位边界附近没有异常组合,并验证暂停、重置和安全故障发生后 active 立即变为 false。
二十五、安全门控必须先于视觉效果
active 的条件同时包含 isRunning、isClosedLoop、无短路和无过载。也就是说,动画相位即使持续拥有数值,只要实验未运行、回路未闭合或风险条件成立,灯就不应显示为正常通行状态。这个门控是教学界面的风险优先级,不等同于现实信号机的故障保护认证。
启动实验时先执行短路检查,命中后只重绘并返回,不进入运行态。暂停会停止定时器、保存学习时长并重绘;重置会先清理定时器,再恢复运行、完成、检查、开关、组件数量、动画相位和参数默认值。生命周期上还应确认页面离开时调用 stopTimer,避免后台回调继续占用资源。
二十六、Canvas只负责呈现,不负责定义规则
交通灯绘制函数先画导线和外壳,再按统一相位绘制红黄绿三颗灯,最后补电池、开关和电阻。它不应该反向修改实验状态,也不应该自行写入学习记录。业务状态、风险判断和绘制职责分开后,同一规则才能同时服务状态文字、结果摘要和自动检查。
当前 Canvas 的灯位按容器高度比例计算,适合在不同画布尺寸下保持相对布局。验收仍需覆盖窄屏、横屏和窗口缩放,观察灯壳是否变形、标签是否遮挡、底部安全区是否侵入。本文配图是结构示意图,不是应用运行截图,也不能替代设备验证。
二十七、参数与电流模型的证据边界
Experiment 模型为交通灯提供电池电压和限流电阻两个参数:电压范围为 3V 到 12V,默认 5V;电阻范围为 100Ω 到 1000Ω,默认 330Ω。通用实验视图还会计算电流、短路和过载,以决定 active 和结果摘要。
这些参数用于教学模拟,不代表现实交通信号灯的供电结构、驱动电路、冗余设计或道路规范。实际产品需要独立灯组驱动、故障检测、冲突相位互锁、看门狗、掉电策略以及经过认证的硬件与软件流程。文章不得据此声称当前项目具备真实道路控制能力。
二十八、定时器、完成记录与幂等性
startTimer 开始前先调用 stopTimer,避免重复点击启动叠加多个 interval。animationPhase 超过 0.96 且尚未完成时,代码把 isFinished 置为 true,递增实验次数并持久化记录;isFinished 门控避免同一轮动画重复累计。暂停和重置均清理定时器。
需要注意,完成阈值是教学任务的内部判据,不是交通灯完成了某种安全周期。自动化测试应连续快速点击启动、暂停和重置,确认始终只有一个定时器、实验次数只增加一次、学习时长没有负值或重复累计,并检查页面销毁后的回调停止情况。
二十九、建议验收矩阵
第一组验证相位:分别构造接近零、三分之一、三分之二和接近一的 animationPhase,确认红黄绿顺序及单灯互斥。第二组验证门控:组合未运行、未闭环、短路、过载和正常状态,确认只有正常组合允许 active。第三组验证生命周期:启动、重复启动、暂停、恢复、重置、返回页面,并记录 timerId 与绘制次数。
第四组验证参数边界:电压最小值、默认值、最大值以及电阻最小值、默认值、最大值,检查电流摘要和风险提示一致。第五组验证布局与可访问性:小屏、横屏、深浅色、系统字体放大和快速连续操作。只有构建、设备运行和上述矩阵通过后,才能声明交互稳定;本轮结论仅限源码静态证据。
补充验收记录应保留输入参数、运行状态、闭环状态、风险原因、动画相位、当前灯色、定时器状态和持久化结果,并注明测试设备与构建版本。这样才能区分公式错误、生命周期错误和单纯的绘制异常,也便于后续修改刷新策略时进行回归比较。