无人机竞速俱乐部页面HarmonyOS 6的视觉特效层(信号波纹与领航灯)放置在 Stack 布局的最底层,通过 hitTestBehavior(HitTestMode.None) 让命中测试链路
在当下移动端跨平台与原生融合的浪潮中,声明式 UI 已经从一种新兴范式演变为构建复杂交互界面的主流选择。本文将以"翼语·无人机竞速俱乐部"这一面向 FPV(第一人称视角)竞速飞手社群的完整应用页面为分析对象,系统拆解其在 ArkTS 声明式范式下如何将"赛道文化"与"工程结构"深度融合。该页面并非一个简单的静态展示壳,而是一个集成了多层级导航、分段式内容流、实时驱动的视觉特效层、以及增删改查弹框体系的复合型业务界面。从底部四大主 Tab 的全局路由,到首页七个内容子 Tab 的分段切换,再到信号波纹扩散与领航灯闪烁的底层动效,整套实现以一种高度模块化、可拆解、可复用的方式组织起来。
从技术视角看,该页面体现了声明式 UI 的若干核心思想:状态驱动渲染、单向数据流、组件化组合、以及副作用与视图的分离。页面通过一组 @State 装饰的状态变量作为唯一数据源,所有可见的 UI 变化均由这些状态派生而来,从而保证了视图与数据的强一致性。与此同时,页面的视觉特效层(信号波纹与领航灯)被刻意放置在 Stack 布局的最底层,并通过 hitTestBehavior(HitTestMode.None) 让其彻底脱离命中测试链路,既保证了动效的持续呈现,又完全不会遮挡上层业务组件的点击交互——这种"动效不挡操作"的设计是高频交互场景下的关键工程考量。
更深层次地,该页面在数据建模上采用了 @Observed 装饰的类模型与轻量 interface 接口相结合的策略。需要参与可变集合操作的实体(如飞手动态、赛道、无人机、装备)被建模为 @Observed 类,以便在增删改时触发精准的局部刷新;而仅用于静态渲染的图表与课程数据则使用普通 interface,减少不必要的观测开销。这种"按需观测"的设计哲学,使得页面在承载大量静态数据的同时,仍能保持对动态数据的灵敏响应。本文将逐段拆解该页面的每一个结构单元,从色彩体系、数据模型、纯函数算法,到构建器组件、弹框状态机、以及最终的三层 Stack 渲染合成,力求为读者呈现一份可复用的声明式复杂页面工程蓝图。

第一章 整体架构与场景定位

``
// ============ 翼语·无人机竞速俱乐部(897)============
// 场景:FPV 竞速 / 飞手圈 / 赛道 / 装备
// 底部4主tab(首页/赛道/课堂/我的)+ 首页7内容tab(分段式)
// 特效:信号波纹扩散 + 领航灯闪(特效层位于底层,不遮挡点击)
// 弹框:新增(发翼语) / 编辑(编辑训练单) / 删除(注销报名·退课)
文件顶部的注释块以高度凝练的方式交代了整个页面的业务全貌与技术骨架。它首先点明了应用场景——FPV 竞速、飞手圈、赛道、装备四个核心业务域,这正是整个页面内容组织的逻辑主线。FPV(First Person View)竞速是一种飞手佩戴图传眼镜、以第一人称视角操控穿越机穿越门标的极限运动,其社群天然具有"装备党""技术流""战绩炫耀"等鲜明特征,因此页面在内容设计上紧紧围绕机型参数、圈速成绩、赛道难度、装备清单、飞手社交等维度展开。
注释中明确指出了导航的两层结构:底部四个主 Tab(首页、赛道、课堂、我的)构成了应用的一级路由骨架,而首页内部又嵌套了七个内容子 Tab(精选、机库、赛道图鉴、训练计划、飞行课堂、飞手圈、装备库)形成分段式内容流。这种"主 Tab 承载大场景、子 Tab 细分内容"的设计,使得首页成为一个"超级聚合页",在有限的屏幕空间内以可滑动切换的方式承载了远超普通单页的信息密度。值得注意的是,赛道、课堂、我的三个主 Tab 实际上是首页子 Tab 内容的独立入口复用,这种"聚合与独立并存"的策略兼顾了深度浏览与快速直达两种使用心智。
注释最后三行揭示了三个关键的工程决策:其一,视觉特效采用"信号波纹扩散 + 领航灯闪"两种叠加动效,呼应无人机图传信号与跑道领航灯的真实物理意象,强化场景沉浸感;其二,特效层被刻意安排在布局底层且不参与点击命中,这是"动效与交互解耦"的典型实践;其三,弹框体系围绕"新增、编辑、删除"三种最核心的数据操作展开,分别对应"发翼语""编辑训练单""注销报名·退课"三个具体业务动作,形成完整的 CRUD 闭环。这三个决策贯穿了后续所有代码的实现,是理解整个页面工程取舍的总纲。
---
## 第二章 色彩体系与 ColorPalette 接口设计
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
neon: string;
}
ColorPalette 接口是整个页面色彩体系的"契约层"。它以接口形式定义了页面所需的全量色彩语义槽位,共计十六个字段,覆盖了主色系(primary 三阶)、强调色(accent 二阶)、背景与卡片底色(bg、cardBg)、文本三阶层次(textPrimary、textSecondary、textHint)、边框色(border)、状态语义色(success、warning、danger)、以及纯白与霓虹色(white、neon)。这种以接口先行的方式,使得色彩体系具有了类型约束力——任何实现该接口的对象都必须提供全部十六个色彩字段,编译期即可发现遗漏,避免了"运行时取到 undefined 色值"这类隐蔽缺陷。
将色彩抽象为语义槽位而非直接使用十六进制字面量,是成熟前端工程的基本素养。在本接口中,primary 代表品牌主色(青绿色系),accent 代表行动号召强调色(橙红),neon 是用于圈速、霓虹赛道等"高光数据"的霓虹青色,danger 用于注销、退课等破坏性操作。这种语义化命名使得业务代码在引用色彩时表达的是意图而非外观,例如"这个按钮用 danger 色"比"这个按钮用 #FF5A6E"更易理解与维护。当未来需要切换整体主题(如增加亮色模式)时,只需替换 COLORS 常量的具体取值,而无需改动任何引用处的代码。
接口中文本色的三阶划分(textPrimary、textSecondary、textHint)尤其值得称道。在信息密集的页面中,并非所有文字同等重要:标题与数值需要高对比的 primary 文本色,副标题与正文使用 secondary,而时间戳、单位说明等辅助信息使用低对比的 hint 色。这种三阶层次让视觉焦点自然落在核心数据上,同时保留了辅助信息的可读性,是"视觉层级"在色彩维度的精细落地。配合 border 与 cardBg 的低饱和暗色,整个界面在暗色基底上形成了清晰但不刺眼的分层表达。
第三章 COLORS 常量与暗色主题策略

const COLORS: ColorPalette = {
primary: '#00C2A8',
primaryLight: '#4AE0CB',
primaryDark: '#008A78',
accent: '#FF6B57',
accentLight: '#E0F5F1',
bg: '#0C1114',
cardBg: '#151D22',
textPrimary: '#E6F0F2',
textSecondary: '#A9BCC2',
textHint: '#6B7F87',
border: '#22303A',
success: '#3ED598',
warning: '#F7C948',
danger: '#FF5A6E',
white: '#FFFFFF',
neon: '#00E5FF'
};
COLORS 常量是 ColorPalette 接口的具体实现,也是整个页面唯一的色彩真相源(single source of truth)。它被声明为顶层 const,意味着在模块加载时即完成初始化且不可变,所有组件统一通过 COLORS.xxx 引用,杜绝了散落各处的色值魔法字符串。从取值看,这是一套典型的"暗色赛博"主题:背景 #0C1114 是接近纯黑的深青墨色,卡片底 #151D22 略微提亮,两者形成微妙的纵深差;主色 #00C2A8 是带有科技感的青绿,强调色 #FF6B57 是暖橙红,霓虹色 #00E5FF 则是高饱和的电光青——这三色组合精准地营造出"夜场赛道、霓虹竞速"的氛围。
暗色主题的选择并非纯审美偏好,而是与 FPV 竞速场景深度绑定的设计决策。无人机飞手的核心训练场景之一是"夜场飞行",夜场赛道依赖 LED 拉烟与霓虹门标定位,因此页面采用暗色基底能在视觉上与真实夜飞场景形成呼应,增强社群用户的代入感。同时,暗色背景对 OLED 屏幕更省电,对长时间浏览飞手动态、训练数据的重度用户也更护眼。neon 色被专门保留给"最快圈速""霓虹赛道"等高光数据,在暗背景上形成强烈视觉锚点,引导用户关注关键成绩。
色彩对比度方面,文本三阶在暗背景上的表现也经过推敲:textPrimary 的 #E6F0F2 是近白的浅青,对 #0C1114 背景具有极高对比度,保证标题与数值的绝对清晰;textSecondary 的 #A9BCC2 是中度灰青,用于正文,对比度适中不疲劳;textHint 的 #6B7F87 偏暗,仅用于时间戳等次要信息,既保留可读性又不喧宾夺主。border 的 #22303A 是低饱和的暗青灰,作为卡片间的分隔既存在感弱又能勾勒边界。整套配色形成了一个从黑到亮、从冷到暖、从静到动的完整梯度,为后续所有组件的视觉表达奠定了统一基底。
第四章 PostItem 飞手动态数据模型

@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.time = time; this.likes = likes
}
}
PostItem 是飞手动态(即"翼语")的数据模型,代表飞手在社群中发布的短内容。它被 @Observed 装饰器标记,这意味着该类的实例在被 @State 或 @Observed 容器持有时,其属性变更会被声明式框架追踪,从而触发依赖该属性的 UI 局部刷新。字段包括唯一标识 id、昵称 nick、头像(此处用 emoji 字符串表示)avatar、正文 text、发布时间 time、点赞数 likes,覆盖了一条社交动态所需的全部展示要素。
@Observed 与构造函数的配合是 ArkTS 中处理可变模型的标准范式。构造函数接收六个参数并逐一赋值给实例字段,字段声明处的默认值(如 id: number = 0)既提供了类型标注,又保证了即便通过 new PostItem(...) 传参时某参数遗漏(虽然此处签名要求全部传入)也不会产生 undefined。将类 export 导出,说明该模型可能被其他模块复用,体现了数据模型与视图组件解耦的设计——模型是独立的、可测试的纯数据结构,不依赖任何 UI 上下文。
飞手动态作为页面中少数会动态增删的内容("发翼语"会新增,"注销报名"在特定分支会移除首条),选择 @Observed 类是恰当的。当用户点击"发翼语"弹框中的"发布"按钮后,新构造的 PostItem 会被 unshift 进 postList 数组,由于数组本身被 @State 持有且元素是 @Observed 实例,列表的 ForEach 会感知到集合变化并重新渲染。这种"模型可观测 + 集合状态驱动"的双层机制,是声明式 UI 实现可变列表的底层支撑。
第五章 DroneItem 无人机数据模型

@Observed
export class DroneItem {
id: number = 0
name: string = ''
frame: string = ''
battery: string = ''
status: string = ''
topSpeed: number = 0
constructor(id: number, name: string, frame: string, battery: string, status: string, topSpeed: number) {
this.id = id; this.name = name; this.frame = frame; this.battery = battery
this.status = status; this.topSpeed = topSpeed
}
}
DroneItem 描述了机库中每一架无人机的数据实体。字段 frame(机架类型,如"碳纤维 X 型"“涵道防护”)、battery(电池规格,如"6S 1300mAh")、status(状态,如"待命"“飞行中”“充电”)、topSpeed(极速,km/h)是 FPV 圈子中飞手最关心的核心参数。机架类型决定了无人机的用途分类(竞速、穿越、远航、室内),电池规格影响续航与放电特性,状态反映当前可用性,极速则是性能的直接体现。这些字段构成了机库展示卡片的全部数据来源。
将 status 设计为字符串而非枚举或数字编码,是一种"可读性优先"的取舍。字符串状态如"待命""飞行中"可以直接渲染到 UI 上无需映射表,代价是状态判断依赖字符串比较(如后续 statusColor 函数中 if (s === '待命'))。在数据量不大、状态种类有限的场景下,这种取舍是合理的——它减少了模型与渲染之间的转换层,让数据到视图的路径更短。topSpeed 作为 number 类型,便于在卡片中直接做数值插值与比较(如后续判断"竞速系"机型的极速门槛)。
DroneItem 同样被 @Observed 标记,这为未来扩展无人机状态的实时更新预留了能力。例如,若接入真实硬件遥测,无人机的 status 从"待命"变为"飞行中"、topSpeed 随实测更新时,对应的机库卡片能自动刷新而无需手动触发重绘。即便当前页面中 DRONE_LIST 是静态常量,使用 @Observed 类建模也保证了模型层的"可变性就绪",体现了"为变化预留扩展点"的工程前瞻性。
第六章 TrackItem 赛道数据模型

@Observed
export class TrackItem {
id: number = 0
name: string = ''
laps: string = ''
level: string = ''
progress: number = 0
constructor(id: number, name: string, laps: string, text: string, level: string, progress: number) {
this.id = id; this.name = name; this.laps = laps; this.level = level
this.progress = progress
}
}
TrackItem 代表一条赛道的训练记录实体。字段包括赛道名称 name、圈数说明 laps(如"三圈"“六圈”)、难度等级 level(入门/进阶/高级/顶级)、完赛度 progress(0-100 的百分比数值)。这套字段设计直接服务于赛道图鉴与训练计划两个页面的展示需求:名称与难度是赛道的"身份标识",圈数是训练量的度量,完赛度则是飞手在该赛道上的进展可视化。
值得注意的是 laps 与 level 都被设计为 string 而非 number 或枚举。laps 取值如"三圈""四圈"包含中文量词,若用 number 还需在渲染时拼接"圈"字,用 string 则可直接展示,减少模板中的字符串拼接噪音。level 同理,“入门”“进阶”“高级”"顶级"这些中文标签直接作为数据存储,配合后续的 levelColor 函数做字符串到颜色的映射,形成了"数据即展示文案"的简洁路径。progress 作为 number 是必要的,因为它要驱动进度条的 width('${it.progress}%') 宽度百分比计算,必须是数值才能参与运算。
TrackItem 是页面中实际参与增删改最活跃的模型。"编辑训练单"会基于它构造新实例替换原条目,"注销报名"会从赛道列表移除首条。它被 @State trackList 持有且元素为 @Observed 实例,因此 splice 替换或删除后,ForEEach 能感知并刷新。构造函数第四个参数名为 text 但实际赋值给 this.level,这是一个值得留意的小细节——参数命名与字段命名不完全一致,但赋值语义正确,不影响运行,体现了在快速迭代中命名一致性可适当放宽的工程务实态度。
第七章 GearItem 装备数据模型

@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
constructor(id: number, name: string, desc: string, icon: string) {
this.id = id; this.name = name; this.desc = desc; this.icon = icon
}
}
GearItem 是装备库列表项的数据模型,结构最为精简:名称 name、描述 desc、图标 icon(emoji 字符串)。装备在本页面中属于相对静态的展示类内容——用户浏览装备清单与"去补给"入口,但不直接对装备做增删改,因此模型字段只需支撑展示。尽管如此,它依然被 @Observed 标记,保持了与其他三个模型一致的装饰风格,体现了数据模型层的统一约定。
icon 字段用 emoji 字符串(如"🕶️"“🎮”“📡”)代替真实图片资源,是一种在原型与演示场景下高效的视觉填充策略。emoji 无需额外图片资产、无网络加载延迟、且天然适配各平台字体渲染,使得整个页面在无任何静态资源依赖的情况下即可呈现丰富的图标视觉。这种"零资源依赖"的特性也让该页面具备极强的可移植性——只需一套 ArkTS 代码即可在任何支持的环境运行,无需打包图片。desc 字段则承载装备的一句话功能说明,如"沉浸图传"“双杆操控”,为用户理解装备用途提供上下文。
虽然当前装备数据是静态的,但 @Observed 的存在意味着若未来装备库支持"收藏""补给状态切换"等交互,模型层已具备观测能力。这种"模型统一观测、按需驱动"的一致性设计,降低了团队协作时的认知负担——所有数据模型遵循同一套装饰与构造规范,开发者无需为每个模型记忆不同的可变性约定。
第八章 图表接口设计:LapChartItem 与 BandChartItem
interface LapChartItem {
label: string;
value: number;
}
interface BandChartItem {
label: string;
value: number;
color: string;
}
LapChartItem 与 BandChartItem 是两个服务于图表渲染的轻量接口。LapChartItem 仅有标签 label 与数值 value,用于"最近圈速走势"柱状图,每一项代表一圈的圈速秒数。BandChartItem 在此基础上多了 color 字段,用于"飞行时长构成"横条占比图,每一项带自身颜色。两者的字段设计完全由其渲染需求反推得出:柱状图需要统一的颜色映射函数(lapColor),所以不带颜色;横条图每条颜色不同,所以自带颜色——接口字段的差异精准反映了两种图表的配色策略差异。
选择 interface 而非 class 来建模图表数据,是基于"不可变性"与"轻量性"的考量。图表数据在页面生命周期内是只读的,不会发生字段级的变更,因此不需要 @Observed 的观测能力,也不需要构造函数。interface 在 TypeScript/ArkTS 中是结构化类型,数据以对象字面量形式直接定义即可满足契约,语法更简洁,运行时也更轻。这种"可变模型用 @Observed class、只读数据用 interface"的二元策略,是本页面数据建模的核心方法论,它在"观测开销"与"表达灵活性"之间取得了平衡。
value 字段在两者中都为 number,因为图表的高度、宽度百分比都依赖数值运算。LapChartItem.value 是秒数(越低越好),会传给 barH 函数计算柱高、传给 lapColor 函数映射颜色;BandChartItem.value 是百分比,直接用于 width('${it.value}%')。这两个接口虽小,却清晰地体现了"数据形状由视图需求决定"的设计思路——不为数据加任何多余字段,也不缺任何渲染所需字段,接口即视图契约。
第九章 课程与模式接口:CourseItem 与 ModeItem
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface ModeItem {
name: string;
desc: string;
icon: string;
hot: boolean;
}
CourseItem 描述飞行课堂的一门课程:标题 title、所属周次 week、学习进度 progress、主题色 color。ModeItem 描述一种飞行玩法模式:名称 name、描述 desc、图标 icon、是否热门 hot 布尔标记。这两个接口分别服务于"飞行课堂"页面的课程进度列表与"精选"页面的玩法四格入口,字段集合各自由其展示需求推导而来。
CourseItem 自带 color 字段而非通过函数映射,是因为课程主题色是数据驱动的预设色(每门课有自己的标识色),不依赖规则推断,直接存储更直观。progress 为 number 以驱动进度条宽度。ModeItem 的 hot 布尔值用于在四格入口上渲染"HOT"角标,这是一种典型的"标记字段驱动条件渲染"模式——布尔值直接参与 Text(it.hot ? 'HOT' : ' ') 的三元判断,无需额外的映射函数,保持了模板的简洁。
两个接口都用 interface 而非 @Observed class,与图表接口一致,反映了页面中"展示类只读数据一律轻量化"的统一原则。课程进度与玩法模式在页面内不会动态变更(课程的"退课"操作是删除整个列表首项而非修改某门课字段),因此无需观测能力。这种对数据可变性的精准判断,使得页面避免了为只读数据背负不必要的观测开销,是性能与表达力平衡的体现。
第十章 POST_LIST 飞手动态静态数据
const POST_LIST: PostItem[] = [
new PostItem(1, '穿门狂魔', '🚪', '三号门连续两个翻滚过弯,圈速干到 28.4 秒,新机调校立功。', '3分钟前', 102),
new PostItem(2, '低空掠海', '🌊', '贴水飞行两米高度,图传稳得像地面站,太爽了。', '17分钟前', 95),
new PostItem(3, '焊台常客', '🔌', '又炸一台,电机座脱焊,以后一律打热熔胶加固。', '32分钟前', 81),
new PostItem(4, '模拟器小子', '🎮', 'Liftoff 连练三小时肌肉记忆,实机明显更稳了。', '1小时前', 70),
new PostItem(5, '眼镜党', '🕶️', '换了 SKY02 的瞳距之后头晕感消失,沉浸感直接拉满。', '2小时前', 63),
new PostItem(6, '电池管家', '🔋', '6S 1300 循环 30 次开始掉压,准备退役做地面测试。', '3小时前', 55),
new PostItem(7, '夜间飞手', '🌙', '夜场 LED 拉烟效果绝了,粉色灯带在天上画弧线。', '5小时前', 48),
new PostItem(8, '老白频段', '📡', '提醒:周末赛场 5.8G 拥挤,记得提前扫频锁定干净频点。', '昨天', 76)
];
POST_LIST 是飞手动态的初始数据集,以 PostItem 实例数组形式提供。八条动态的内容极具 FPV 圈子的真实质感:有炫耀圈速成绩的"穿门狂魔"、有分享飞行体验的"低空掠海"、有复盘炸机事故的"焊台常客"、有讨论模拟器训练的"模拟器小子"、有谈论图传眼镜体验的"眼镜党"、有电池维护心得的"电池管家"、有夜场拉烟的"夜间飞手"、以及发布赛场频段提醒的"老白频段"。每条动态的昵称、头像 emoji、正文、时间、点赞数都经过精心设计,覆盖了 FPV 社群中"成绩、体验、事故、训练、装备、维护、夜飞、资讯"八大话题维度。
时间字段从"3分钟前"到"昨天"递增,点赞数从 102 到 48 大致递减("老白频段"例外为 76,因其实用性强),这种排序模拟了真实社交信息流的时间倒序与热度衰减。值得注意的是,初始数据使用 new PostItem(...) 构造而非对象字面量,因为 PostItem 是 class,必须通过构造函数实例化。数据被声明为顶层 const,但它在组件中会被 @State postList 引用(postList: PostItem[] = POST_LIST),从而具备了运行时可变性——"发翼语"会向其 unshift 新条目,"退课"分支会 splice 移除首条。
静态数据预置是原型与 MVP 阶段的高效策略。它让页面在没有后端接口的情况下即可呈现完整、真实、有质感的内容,便于设计评审与交互验证。同时,由于数据通过 @Observed 类实例化且被 @State 持有,未来替换为接口请求的数据时,只需保证返回的数据形状一致(同样是 PostItem 实例数组),UI 层无需任何改动即可衔接真实后端,这种"静态数据先行、接口无缝替换"的路径降低了从原型到生产的迁移成本。
第十一章 DRONE_LIST 机库静态数据
const DRONE_LIST: DroneItem[] = [
new DroneItem(1, '夜枭 5 寸', '碳纤维 X 型', '6S 1300mAh', '待命', 142),
new DroneItem(2, '蜂鸟 3 寸', '涵道防护', '4S 850mAh', '飞行中', 108),
new DroneItem(3, '长距离 7 寸', '远航机架', '6S 3000mAh', '充电', 126),
new DroneItem(4, '竞速 5 寸 Pro', '赛事专用架', '6S 1500mAh', '待命', 156),
new DroneItem(5, '穿越小 2 寸', '室内机', '2S 450mAh', '维修', 72),
new DroneItem(6, '电影挂载机', '载重平台', '12S 5000mAh', '待命', 88),
new DroneItem(7, '练习老机', '一代机架', '5S 1200mAh', '退役', 118),
new DroneItem(8, '原型测试机', '自组架', '6S 1100mAh', '调试', 134)
];
DRONE_LIST 是机库的初始无人机清单,包含八架不同定位的无人机。从尺寸看覆盖了 2 寸(室内)、3 寸(穿越)、5 寸(竞速主流)、7 寸(远航)等主流规格;从机架类型看有碳纤维 X 型、涵道防护、远航机架、赛事专用架、载重平台等;从电池看覆盖 2S 到 12S 的全电压等级;从状态看包含待命、飞行中、充电、维修、退役、调试六种状态;极速从 72 到 156 km/h 跨度极大。这套数据 essentially 是 FPV 无人机生态的微缩百科,让机库页面具备了极高的信息密度与真实感。
数据中的命名也颇具匠心:"夜枭"暗示夜飞属性、"蜂鸟"暗示小巧灵活、"长距离"直白表达远航定位、"竞速 5 寸 Pro"强调赛事级别、"原型测试机"体现自组极客气质。这些命名不仅是标识,更是飞手圈文化的浓缩。status 字段的六种取值恰好覆盖了无人机生命周期的全部阶段,与后续 statusColor 函数的分支一一对应,形成数据与渲染的闭环。
DRONE_LIST 在页面中被多处复用:精选页的"机库一览"双列卡片、机库页的详细列表都基于它渲染。由于它是顶层 const 且未被任何 @State 重新持有(页面中 @State 持有的是 postList 与 trackList),DRONE_LIST 本身不可变,所有渲染都是只读消费。这种"一份静态数据多处只读复用"的设计,避免了数据冗余,也保证了机库信息在不同页面间的一致性——用户在精选页与机库页看到的无人机清单永远一致。
第十二章 TRACK_LIST 赛道静态数据
const TRACK_LIST: TrackItem[] = [
new TrackItem(1, '城市峡谷', '三圈', '入门', 100),
new TrackItem(2, '森林回廊', '三圈', '进阶', 84),
new TrackItem(3, '工业废墟', '四圈', '进阶', 62),
new TrackItem(4, '滨海大道', '三圈', '入门', 90),
new TrackItem(5, '立体车库', '五圈', '高级', 44),
new TrackItem(6, '夜间霓虹', '四圈', '高级', 28),
new TrackItem(7, '峡谷瀑布', '三圈', '进阶', 70),
new TrackItem(8, '全能王决赛', '六圈', '顶级', 12)
];
TRACK_LIST 是赛道图鉴的初始数据集,包含八条赛道。赛道命名极具画面感:"城市峡谷"暗示楼宇间穿行、"森林回廊"暗示树冠隧道、"工业废墟"暗示锈迹斑驳的厂房、"滨海大道"暗示海风与公路、"立体车库"暗示多层螺旋、"夜间霓虹"呼应夜场主题、"峡谷瀑布"暗示水雾与落差、"全能王决赛"则是终极挑战。圈数从三圈到六圈递增,难度从入门到顶级递增,完赛度从 100% 到 12% 递减——这种"难度越高、完赛度越低"的反相关关系真实模拟了飞手在不同赛道上的训练进展规律。
level 字段的四档(入门、进阶、高级、顶级)与后续 levelColor 函数的四分支精确对应,完赛度 progress 则直接驱动进度条宽度。与 POST_LIST 不同,TRACK_LIST 被组件的 @State trackList 持有(trackList: TrackItem[] = TRACK_LIST),这意味着它具备运行时可变性——"编辑训练单"会替换某条赛道,"注销报名"会移除首条赛道。这种将静态初始数据赋值给 @State 变量的模式,既保留了初始内容的丰富性,又开放了运行时操作的能力。
赛道数据的完赛度设计也暗含业务叙事:完赛度 100% 的"城市峡谷"代表已征服的入门赛道,而完赛度仅 12% 的"全能王决赛"代表正在挑战的顶级赛道。这种从易到难、从已完成到进行中的梯度,为训练计划页面提供了天然的排序依据——训练计划页正是取 trackList.slice(0, 5) 的前五条作为待办训练项,配合序号高亮(前三名用 neon 色突出)形成"排行榜式"的训练激励视觉。
第十三章 GEAR_LIST 装备静态数据
const GEAR_LIST: GearItem[] = [
new GearItem(1, 'FPV 眼镜', '沉浸图传', '🕶️'),
new GearItem(2, '遥控器', '双杆操控', '🎮'),
new GearItem(3, '图传天线', '蘑菇天线', '📡'),
new GearItem(4, '锂电充电器', '智能平衡充', '🔌'),
new GearItem(5, '焊接台', '组装维修', '🔧'),
new GearItem(6, '桨叶套装', '三叶竞速桨', '🌀'),
new GearItem(7, '电调', '60A 数字电调', '⚙️'),
new GearItem(8, '测频仪', '频点扫描', '📶')
];
GEAR_LIST 是装备库的初始清单,包含八件 FPV 核心装备。从"FPV 眼镜"(飞手的"眼睛")、“遥控器”(飞手的"双手")、“图传天线”(信号链路)、“锂电充电器”(能源保障)、“焊接台”(自组维修)、“桨叶套装”(动力输出)、“电调”(电机控制)、到"测频仪"(频点管理),这套清单完整覆盖了 FPV 飞手从操控、图传、能源、维修、动力到频谱管理的全装备链路,体现了 FPV 是一项"硬件密集型"爱好的事实。
每件装备的 desc 都是一句话功能概括,如"沉浸图传"“双杆操控”“智能平衡充”,既专业又精炼,为非硬核用户提供了理解装备用途的入口。icon emoji 与装备用途高度匹配:眼镜 🕶️、遥控器 🎮、天线 📡、充电器 🔌、焊接台 🔧、桨叶 🌀、电调 ⚙️、测频仪 📶,这种视觉与语义的强关联降低了用户的认知成本。装备数据在页面中被多处只读复用:训练计划页的"装备速查"双列卡片、装备库页的列表都基于它渲染。
GEAR_LIST 与 DRONE_LIST 一样是顶层 const 且不被 @State 持有,因此完全不可变。这种设计源于装备在本页面中仅作展示与"去补给"入口跳转,不涉及增删改操作。将不涉及变更的数据排除在 @State 之外,减少了状态管理的复杂度,也避免了不必要的观测与重渲染开销,是状态最小化原则的体现。
第十四章 LAP_CHART 圈速图表数据
const LAP_CHART: LapChartItem[] = [
{ label: '第1圈', value: 32 },
{ label: '第2圈', value: 30 },
{ label: '第3圈', value: 29 },
{ label: '第4圈', value: 31 },
{ label: '第5圈', value: 28 },
{ label: '第6圈', value: 27 },
{ label: '第7圈', value: 26 }
];
LAP_CHART 是"最近圈速走势"柱状图的数据源,包含七圈的圈速记录(秒数)。数据呈现出一个"先降后升再持续下降"的真实波动曲线:从第1圈的 32 秒,到第3圈降至 29 秒,第4圈反弹至 31 秒(可能因失误或路线偏差),随后持续优化至第7圈的 26 秒——这是飞手在一场训练中逐步进入状态、不断优化圈速的真实写照。波动而非单调下降的曲线更具可信度,因为真实竞速中圈速受门标通过精度、过弯姿态、电池放电曲线等多因素影响。
数据采用对象字面量形式定义,满足 LapChartItem 接口的结构化契约,无需 new 构造。label 用"第N圈"格式,value 为纯数值秒数。这套数据在精选页的圈速柱状图中被 ForEach 遍历,每一项的 value 传给 barH(it.value, 32) 计算柱高、传给 lapColor(it.value) 映射颜色,label 显示在柱底。max 参数取 32(即第一圈的最慢值),使得最快的第7圈(26 秒)柱子最高,形成"越快越高"的视觉直觉——这与通常"数值越大柱越高"的柱状图相反,是 FPV 圈速"越低越好"特性的特殊处理,通过 barH 函数的取反映射实现。
将图表数据独立为顶层常量,使得图表的视觉表现可独立于组件调整。若需展示不同场次的圈速,只需替换 LAP_CHART 的内容,柱状图的渲染逻辑无需改动。这种数据与渲染的解耦,使得图表具备数据驱动的灵活性,也为未来接入真实圈速历史数据预留了替换点。
第十五章 BAND_CHART 机型占比数据
const BAND_CHART: BandChartItem[] = [
{ label: '竞速 5 寸', value: 68, color: '#00C2A8' },
{ label: '穿越 3 寸', value: 46, color: '#FF6B57' },
{ label: '远航 7 寸', value: 32, color: '#00E5FF' },
{ label: '室内 2 寸', value: 18, color: '#F7C948' },
{ label: '航拍平台', value: 10, color: '#3ED598' }
];
BAND_CHART 是"我的飞行时长构成"横条占比图的数据源,包含五种机型的飞行时长百分比。每项自带 color 字段,颜色取自 COLORS 体系中的主色、强调色、霓虹色、警告色、成功色,使五条横条各自呈现不同色彩,形成色彩斑斓的占比可视化。数据按 value 从大到小排列(68→10),符合占比图"从主到次"的视觉排序惯例,让用户第一眼就能识别出主导机型(竞速 5 寸占 68%,符合竞速俱乐部飞手以 5 寸竞速机为主的画像)。
需注意这些百分比相加为 174,并非 100,因此这并非严格的"占比"而更接近"相对时长指数"。这种处理在演示场景下可接受——它强调的是各机型的相对飞行强度对比而非精确占比。每项的 value 直接用于横条 width('${it.value}%'),由于最大值 68 未超过 100,横条宽度均能正常显示在容器内。label 固定宽度 70 用于左侧标签列对齐,value 在右侧以"68%"格式显示数值,形成"标签—横条—数值"三栏对齐的经典占比图布局。
BAND_CHART 与 LAP_CHART 的配色策略差异体现了图表设计的因地制宜:柱状图需要根据数值阈值动态变色(lapColor 函数),所以数据不带颜色;横条图每条颜色是预设的固定语义色,所以数据自带颜色。这种"按图表类型决定数据是否携带颜色字段"的设计,避免了不必要的字段冗余,也让数据接口更贴合每种图表的实际渲染需求。
第十六章 MODE_LIST 与 COURSE_LIST 业务数据
const MODE_LIST: ModeItem[] = [
{ name: 'FPV 竞速', desc: '计时 · 门标 · 圈速', icon: '🏁', hot: true },
{ name: '自由飞', desc: '穿行 · 翻滚 · 特技', icon: '🛸', hot: true },
{ name: '夜场飞行', desc: 'LED · 拉烟 · 编队', icon: '🌙', hot: false },
{ name: '户外远航', desc: '航线 · 续航 · 巡视', icon: '🧭', hot: false }
];
const COURSE_LIST: CourseItem[] = [
{ title: '安全法规与空域', week: '第 1 周', progress: 100, color: '#00C2A8' },
{ title: '模拟器基础悬停', week: '第 2 周', progress: 90, color: '#FF6B57' },
{ title: '自机组装与焊接', week: '第 3-4 周', progress: 64, color: '#00E5FF' },
{ title: '姿态模式飞行', week: '第 5-6 周', progress: 36, color: '#F7C948' },
{ title: '竞速路线策略', week: '第 7-8 周', progress: 14, color: '#00C2A8' }
];
MODE_LIST 定义了四种飞行玩法模式,hot 标记将"FPV 竞速"与"自由飞"标为热门(带 HOT 角标),“夜场飞行"与"户外远航"为非热门。这四种模式覆盖了 FPV 的核心玩法光谱:竞速(计时门标)、自由飞(特技翻滚)、夜场(LED 拉烟)、远航(航线巡视),让用户在精选页四格入口快速定位自己的兴趣方向。desc 用”·“分隔的关键词短语(如"计时 · 门标 · 圈速”)精炼概括每种模式的核心要素。
COURSE_LIST 定义了飞行课堂的五门课程,按周次从第1周到第7-8周递进,进度从 100% 到 14% 递减,呈现一个"已完成→进行中→未开始"的学习路径。课程内容设计遵循 FPV 飞手成长的真实路径:先学安全法规与空域(理论基础)、再练模拟器悬停(基础肌肉记忆)、接着自机组装焊接(硬件能力)、然后姿态模式飞行(进阶操控)、最后竞速路线策略(高阶竞技)。这种课程编排既是教学大纲,也是飞手能力树的可视化。
COURSE_LIST 每项自带 color,五门课的主题色循环使用 COLORS 体系中的四色(青绿、橙红、霓虹青、警告黄、青绿),形成色彩交替的进度条视觉。progress 为 number 驱动进度条宽度,week 字符串直接展示周次。这套数据在飞行课堂页与课堂主 Tab 中渲染,是"我的学习进展"的核心展示内容。两类数据都用对象字面量定义,体现了只读展示数据轻量化的统一原则。
第十七章 NAV_LIST 与 SUB_NAV_LIST 导航配置
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🛸', label: '首页' },
{ icon: '🏁', label: '赛道' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['精选', '机库', '赛道图鉴', '训练计划', '飞行课堂', '飞手圈', '装备库'];
NavItem 接口与 NAV_LIST 常量定义了底部导航栏的四个主 Tab 配置。NavItem 仅含图标 icon 与文字 label 两字段,是导航项的最简契约。四个 Tab 的 emoji 图标选择也颇具语义:🛸 飞碟代表首页(总览)、🏁 终点旗代表赛道、📚 书本代表课堂、👤 人形代表我的,图标与标签形成双重语义锚定,即便用户忽略文字也能凭图标识别 Tab 功能。将导航配置抽为数据数组而非硬编码在构建器中,使得 Tab 项的增删改只需修改数组即可,构建器的 ForEach 渲染逻辑无需改动。
SUB_NAV_LIST 是首页的七个内容子 Tab 标签数组,纯字符串数组,代表了首页分段式内容流的七个频道:精选、机库、赛道图鉴、训练计划、飞行课堂、飞手圈、装备库。这七个频道覆盖了 FPV 竞速俱乐部用户的核心内容消费场景——从精选聚合内容,到机库管理无人机、赛道图鉴浏览赛道、训练计划跟踪进度、飞行课堂学习课程、飞手圈社交互动、装备库补给装备,形成完整的内容闭环。用纯字符串数组而非对象数组,是因为子 Tab 只需展示文字标签与选中态,不需要图标等额外字段,最简数据结构即可满足需求。
两个导航配置数组共同定义了页面的两层路由结构。NAV_LIST 驱动 mainTab 状态(0-3)切换主 Tab,SUB_NAV_LIST 驱动 subTab 状态(0-6)切换首页子内容。这种"数据驱动的导航配置"使得路由结构高度可配置——若未来需要增加主 Tab 或调整子 Tab 顺序,只需修改数组内容,渲染逻辑与状态管理逻辑完全复用,体现了配置即数据的声明式优势。
第十八章 纯函数 barH 与 lapColor 柱状图算法
function barH(v: number, max: number): number {
return Math.round(108 * (max - v + 24) / (max - 22));
}
function lapColor(v: number): string {
if (v > 30) {
return '#FF6B57';
} else if (v > 27) {
return '#00C2A8';
}
return '#00E5FF';
}
barH 函数是圈速柱状图的高度计算核心,其数学逻辑颇为巧妙。常规柱状图中"数值越大柱越高",但 FPV 圈速"秒数越低越好",若直接用秒数做高度,最快的圈反而柱最矮,违背视觉直觉。barH 通过 (max - v + 24) / (max - 22) 的取反映射,将"低秒数"映射为"高柱子"。具体地,以 max=32 为例:第1圈 32 秒代入得 (32-32+24)/(32-22)=24/10=2.4,乘以 108 约得 26(矮柱);第7圈 26 秒代入得 (32-26+24)/10=30/10=3,乘以 108 得 324 取整后受容器高度限制——实际上经 Math.round 后第7圈柱高约 324 但被外层 height(140) 容器约束。+24 与 -22 这两个魔数是为了让映射后的高度落在合理的视觉区间,避免极端值导致柱子过高或过矮。
lapColor 函数根据圈速秒数返回三档颜色:大于 30 秒用橙红 #FF6B57(偏慢,警示)、27-30 秒用青绿 #00C2A8(正常)、27 秒及以下用霓虹青 #00E5FF(快,高光)。这种阈值驱动的三色映射让柱状图不仅显示高度差异,还通过颜色传递"快慢"的语义判断,用户扫一眼即可识别哪些圈是快圈、哪些是慢圈,是数据可视化中"色彩编码"的典型应用。
两个函数都是纯函数(无副作用、相同输入相同输出),且为顶层声明而非组件方法。纯函数的顶层声明使其可在任何组件外独立测试与复用,不依赖组件实例的 this 上下文。将渲染算法与组件解耦,是保持组件构建器简洁、算法可独立验证的良好实践。barH 中使用 Math.round 保证返回整数值,避免浮点高度导致亚像素渲染模糊,是对渲染清晰度的细节把控。
第十九章 信号波纹函数族:waveR/waveA/waveX/waveY
function waveR(tick: number, i: number): number {
return 30 + ((tick + i * 2) % 6) * 26;
}
function waveA(tick: number, i: number): number {
return 0.18 - ((tick + i) % 6) * 0.02;
}
function waveX(tick: number, i: number): number {
return 90 + ((tick * 3 + i * 173) % 500);
}
function waveY(tick: number, i: number): number {
return 140 + i * 200;
}
信号波纹函数族由四个纯函数组成,共同驱动三个波纹圆圈的动态扩散动画。waveR 计算波纹半径:基准 30,加上 (tick + i*2) % 6 * 26 的周期递增,每 tick 半径增长 26,6 个 tick 后取模归零重新扩散,形成"从 30 扩散到 30+5*26=160 再回到 30"的循环。waveA 计算透明度:基准 0.18,每 tick 减 0.02,6 个 tick 后从 0.18 衰减到 0.08 再归零,形成"扩散越大越透明"的自然衰减效果,模拟信号波从中心向外扩散时能量递减的物理意象。
waveX 与 waveY 计算波纹圆心的位置。waveX 使用 tick*3 + i*173 的复合取模,i*173 这个素数系数确保三个波纹(i=0,1,2)的水平起始位置相互错开,tick*3 使每个波纹随时间缓慢水平漂移,% 500 限制在屏幕宽度范围内循环。waveY 则采用 140 + i*200 的固定分层策略——三个波纹分别位于 y=140、340、540 的垂直位置,形成上中下三层分布。这种"水平漂移 + 垂直分层"的组合,让三个波纹在屏幕上既不重叠扎堆,又各自有动态位置变化,营造出动感的信号场效果。
整个波纹动画的驱动机制是:aboutToAppear 中启动的 90ms 定时器每 tick 递增 this.tick,fxLayer 构建器中 ForEach 三个波纹(i=0,1,2),每个波纹的 radius、opacity、translate 均绑定 waveR/waveA/waveX/waveY(this.tick, i)。由于 tick 是 @State,每次递增触发 fxLayer 重新求值,波纹参数随之更新,形成连续动画。取模运算保证动画无缝循环,无需复杂的缓动函数或动画框架,仅靠纯函数与定时器即可实现流畅的信号扩散动效,是轻量动效的典范。
第二十章 领航灯函数族:beaconA/beaconX/beaconY
function beaconA(tick: number, i: number): number {
return (tick + i) % 2 === 0 ? 0.7 : 0.2;
}
function beaconX(tick: number, i: number): number {
return 40 + ((tick * 8 + i * 121) % 600);
}
function beaconY(tick: number, i: number): number {
return 80 + ((tick * 11 + i * 61) % 620);
}
领航灯函数族驱动十个闪烁光点(beacon)的动态表现。beaconA 使用 (tick + i) % 2 的奇偶判断实现"明暗交替闪烁"——偶数 tick 时透明度 0.7(亮),奇数 tick 时 0.2(暗),由于 i 的偏移,十个灯并非同步闪烁而是错相闪烁,形成"星群呼吸"般的随机感闪烁效果。这种简单的奇偶二值透明度切换,配合 90ms 的 tick 间隔,恰好模拟了跑道边领航灯的频闪节奏,呼应无人机夜飞时地面引导灯的真实场景。
beaconX 与 beaconY 计算每个灯的位置。beaconX 使用 tick*8 + i*121 的取模,beaconY 使用 tick*11 + i*61 的取模。两个系数 8 和 11 互质,使得每个灯的 x、y 位移速度不同步,轨迹形成不规则的斜向漂移而非水平或垂直直线。i*121 与 i*61 的素数系数确保十个灯初始位置充分散布,避免聚集。% 600 与 % 620 将灯限制在屏幕范围内循环。这种"互质系数 + 素数偏移"的伪随机布局策略,无需真正的随机数生成器,仅靠确定性算术即可产生视觉上"随机分布且持续漂移"的效果,且由于是确定性的,每次运行表现一致,便于调试与复现。
十个领航灯与三个信号波纹共同构成 fxLayer 特效层的全部内容。两者叠加形成"大波纹缓慢扩散 + 小光点快速闪烁"的动静结合的视觉层次,模拟了无人机图传信号(波纹)与跑道引导灯(光点)的双重意象。所有特效参数均由纯函数从 tick 与 i 推导,无任何随机性,保证动画可复现;同时所有计算为简单算术运算,性能开销极低,即便在低端设备上也能流畅运行 90ms 频率的动画刷新。
第二十一章 状态色彩函数:statusColor/frameTagColor/levelColor
function statusColor(s: string): string {
if (s === '待命') {
return '#3ED598';
} else if (s === '飞行中' || s === '充电' || s === '调试') {
return '#00C2A8';
} else if (s === '维修') {
return '#F7C948';
}
return '#6B7F87';
}
function trackProgress(s: string): number {
return 0;
}
function frameTagColor(g: string): string {
if (g.indexOf('竞速') >= 0 || g.indexOf('赛事') >= 0) {
return '#FF6B57';
} else if (g.indexOf('远航') >= 0 || g.indexOf('载重') >= 0) {
return '#00E5FF';
}
return '#00C2A8';
}
function levelColor(s: string): string {
if (s === '入门') {
return '#3ED598';
} else if (s === '进阶') {
return '#00C2A8';
} else if (s === '高级') {
return '#FF6B57';
}
return '#F7C948';
}
statusColor 将无人机状态字符串映射为语义色:待命→成功绿(就绪可用)、飞行中/充电/调试→主色青绿(活跃中)、维修→警告黄(需关注)、其他(含退役)→hint 灰(不活跃)。这种映射让机库卡片的状态色条与状态标签一眼可辨,是"状态可视化"的标准实践。trackProgress 函数当前恒返回 0,是一个预留的桩函数,为未来根据状态动态计算赛道进度预留扩展点。
frameTagColor 通过 indexOf 子串匹配判断机架类型归属:含"竞速"或"赛事"返回橙红(竞速系)、含"远航"或"载重"返回霓虹青(远航系)、其他返回主色青绿(通用系)。这种子串匹配而非精确等于的策略,容错性更强——如"赛事专用架"包含"赛事"即可命中竞速系,无需穷举所有机架全名。levelColor 将赛道难度四档映射为四色:入门→绿、进阶→青绿、高级→橙红、顶级→黄,形成从冷到暖的难度梯度色阶。
三个色彩映射函数共同构成了页面的"语义色彩引擎"。它们将业务字符串(状态、机架、难度)转化为 COLORS 体系中的具体色值,使得业务数据到视觉色彩的映射规则集中可维护。若需调整状态色或难度色,只需修改对应函数的返回值,所有引用处自动生效。这种"色彩映射函数集中管理"的设计,比将颜色逻辑散落在各构建器中更具可维护性,也保证了同一业务概念在整个页面中色彩表现的一致性。
第二十二章 PageDroneRacingClub 组件状态变量
@Entry
@Component
struct PageDroneRacingClub {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'race'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State postList: PostItem[] = POST_LIST
@State trackList: TrackItem[] = TRACK_LIST
@State fxTimer: number = -1
组件 PageDroneRacingClub 被 @Entry 与 @Component 装饰,标记为页面入口组件。其状态变量集可分为四组:导航状态(mainTab、subTab)、特效状态(tick、glow、fxTimer)、弹框状态(addOpen、editOpen、delOpen、editIdx、delTarget)、表单状态(editName、editStyle、editNote、addTitle、addContent)、数据状态(postList、trackList)。所有状态均被 @State 装饰,确保任何变更都能触发依赖视图的精准刷新。
导航状态 mainTab(0-3)与 subTab(0-6)是页面路由的核心驱动,它们的取值决定了 mainContent 与首页子内容渲染哪个构建器。特效状态中 tick 是动画驱动器,每 90ms 自增触发波纹与领航灯重算;glow 是一个 0/1 切换的辅助状态(当前在 fxLayer 中未直接使用但已预留);fxTimer 存储定时器 ID 用于清理。弹框状态中三个布尔(addOpen、editOpen、delOpen)分别控制三种弹框的显隐,editIdx 记录编辑目标索引,delTarget(‘race’ 或 ‘course’)区分删除目标是赛道还是课程。
表单状态是最值得关注的细节:editName/editStyle/editNote 与 addTitle/addContent 分别是编辑弹框与新增弹框的输入字段临时存储。它们被设计为组件级 @State 而非弹框内部的局部状态,是因为弹框的输入需要与"打开弹框时预填数据"(openEdit 中读取 trackList[idx] 预填)和"确认时回写数据"(doEdit/doAdd 中读取表单值写回列表)双向交互。将表单状态提升到组件级,使得弹框的打开、编辑、提交三个阶段共享同一组状态,避免了状态在组件与弹框间传递的复杂度。postList 与 trackList 是唯一两个持有可变集合的状态,分别驱动飞手动态与赛道的增删改渲染。
第二十三章 生命周期:aboutToAppear 与 aboutToDisappear
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToAppear 是组件即将挂载时的生命周期回调,在此启动了特效动画的心跳——一个 90ms 间隔的 setInterval。每次回调执行两个操作:tick 自增 1 驱动波纹与领航灯的参数更新,glow 在 0/1 间切换作为辅助特效状态。90ms(约 11fps)的刷新率看似低于常见 60fps,但由于特效是缓慢扩散的波纹与明暗交替的光点,低频刷新已能呈现流畅视觉,且大幅降低了 CPU 与渲染开销,是"视觉效果与性能消耗"的精心权衡。定时器 ID 存入 fxTimer 状态以便后续清理。
aboutToDisappear 是组件即将销毁时的回调,负责清理 aboutToAppear 中创建的定时器。通过判断 fxTimer > 0 确认定时器存在后调用 clearInterval 清除,并将 fxTimer 重置为 -1 标记已清理。这种"创建即记 ID、销毁即清 ID"的对称管理,是避免内存泄漏与僵尸定时器的标准实践。若遗漏清理,即便组件销毁后定时器仍会持续回调,尝试更新已不存在的组件状态,引发错误或内存泄漏。
将动画驱动放在生命周期而非组件内的动画 API(如 animateTo)中,是一种"命令式定时器 + 声明式状态"的混合范式。它的优势在于:动画参数由纯函数计算,逻辑可独立测试与调整;动画状态(tick)是普通 @State,可被任意构建器消费;不依赖特定动画框架的 API 约束。代价是需要手动管理定时器生命周期。在本页面这种"全局背景特效"场景下,这种范式比逐组件的入场动画 API 更合适,因为特效需要持续运行而非一次性触发。
第二十四章 弹框状态管理方法:openAdd/openEdit/openDelA/openDelB
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.trackList.length) {
this.editIdx = index;
this.editName = this.trackList[index].name;
this.editStyle = this.trackList[index].level;
this.editNote = this.trackList[index].laps;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'race';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
这四个方法负责弹框的"打开前预处理"。openAdd 清空新增表单的两个字段(标题、内容)后打开新增弹框,确保每次打开都是空白表单,避免残留上次输入。openEdit 接收目标索引,先做边界校验(index >= 0 && index < this.trackList.length)防止越界,然后将目标赛道的 name、level、laps 预填到编辑表单字段,实现"编辑即预填"的体验——用户打开编辑弹框即看到当前值,修改后保存。无论索引是否有效,editOpen 最终都会设为 true(若索引无效则表单保持默认空值),保证调用方点击后弹框一定出现。
openDelA 与 openDelB 是删除弹框的两种打开方式,通过 delTarget 字段区分删除目标:‘race’ 表示注销赛道报名(从 trackList 删首条),‘course’ 表示退课(从 postList 删首条)。这种"同一弹框、不同目标"的设计复用了删除弹框的 UI,仅需一个 delTarget 状态变量即可切换文案与删除逻辑,是弹框复用的典型手法。两个方法分别绑定到机库页的"注销报名"按钮与课堂页的"退课"按钮。
这组 open 方法体现了"打开弹框是一个有预处理的动作"的设计思想。弹框的显隐不仅是布尔切换,还伴随表单初始化或数据预填,将这些副作用集中在 open 方法中,而非散落在各 onClick 回调里,提升了代码的内聚性与可读性。调用方只需调用 this.openAdd() 或 this.openEdit(idx),无需关心弹框内部需要哪些状态准备,降低了调用方的认知负担。
第二十五章 业务动作方法:doAdd/doEdit/doDel
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的翼语', '🛸', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.trackList.length) {
this.trackList.splice(this.editIdx, 1, new TrackItem(this.trackList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'race' && this.trackList.length > 0) {
this.trackList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
doAdd 处理"发翼语"的提交逻辑:仅当标题非空时,构造一个新 PostItem(id 固定 999、昵称"我的翼语"、头像 🛸、时间为"刚刚"、初始点赞 0)并通过 unshift 插入 postList 头部。unshift 而非 push 保证新动态出现在列表顶部,符合社交信息流"最新在上"的惯例。插入后无论是否成功都关闭弹框。新构造的 PostItem 因 postList 是 @State 且元素 @Observed,插入会触发飞手圈与精选页动态列表的刷新,新动态立即出现在顶部。
doEdit 处理"编辑训练单"的保存:校验 editIdx 有效后,使用 splice(editIdx, 1, newItem) 的"替换式 splice"——删除索引处 1 个元素并插入新元素,等效于原地替换。新 TrackItem 保留了原 id,但 name/laps/level 取自编辑表单字段,progress 固定重置为 40(表示重新训练)。这种"保留 id、替换内容"的更新方式,保证了引用稳定性(id 不变)同时更新了业务字段。doDel 根据分支删除:‘race’ 删 trackList 首条(注销报名)、‘course’ 删 postList 首条(退课),均有非空校验防止空数组 splice 报错。
三个 do 方法体现了"提交即关闭"的交互惯例——无论操作成功与否,最后都将对应弹框布尔设为 false 关闭弹框。这种"乐观关闭"策略简化了交互流程,用户点击确认后弹框立即消失,数据变更在后台同步发生。splice 与 unshift 直接操作 @State 数组,依赖框架的数组变更观测触发 ForEach 重渲染,无需手动调用刷新方法,是声明式范式的核心便利。
第二十六章 fxLayer 特效层构建器
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2], (i: number) => {
Column()
.width(waveR(this.tick, i) * 2)
.height(waveR(this.tick, i) * 2)
.borderRadius(waveR(this.tick, i))
.backgroundColor(COLORS.neon)
.opacity(waveA(this.tick, i))
.translate({ x: waveX(this.tick, i), y: waveY(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (i: number) => {
Column()
.width(6)
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.primaryLight)
.opacity(beaconA(this.tick, i))
.translate({ x: beaconX(this.tick, i), y: beaconY(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
fxLayer 是特效层的渲染单元,以 @Builder 装饰为可复用的构建器方法。它以 Stack 为容器,内含两个 ForEach:第一个遍历 [0,1,2] 渲染三个信号波纹圆,每个圆是 Column 组件,宽高均为 waveR(tick,i)*2(直径),borderRadius 设为 waveR(半径)使其为圆形,背景色 neon 霓虹青,透明度 waveA,通过 translate 定位到 waveX/waveY 计算的坐标。第二个 ForEach 遍历 [0..9] 渲染十个领航灯光点,每个是 6x6 的小圆点,背景 primaryLight,透明度 beaconA 随 tick 闪烁,位置由 beaconX/beaconY 计算。
整个 fxLayer 的关键在于末尾的 .hitTestBehavior(HitTestMode.None)。这一属性将特效层完全排除出命中测试链路,意味着即便特效层覆盖了整个屏幕(width/height 100%),其上的点击事件会穿透特效层传递到下层或上层的业务组件。这是"动效不挡操作"的核心实现——用户在波纹与光点之上仍可正常点击按钮、滑动列表、切换 Tab,特效层对交互完全透明。若缺少此设置,特效层的全屏覆盖会拦截所有点击,导致页面交互瘫痪。
Stack 容器的 alignContent: Alignment.TopStart 设定了子元素默认左上对齐,但波纹与光点的实际位置由 translate 精确控制,对齐方式主要影响初始锚点。ForEach 的键函数 (i) => i.toString() 使用索引字符串作为 key,由于波纹与光点的数量固定(3 个和 10 个),key 稳定不变化,复用效率高。每次 tick 变更触发 fxLayer 重新求值时,ForEach 不会增删子项,仅更新现有子项的 width/height/opacity/translate 属性,是高效的属性级更新而非重建。
第二十七章 header 头部构建器
@Builder
header() {
Column({ space: 8 }) {
Row() {
Text('🛸')
.fontSize(26)
Column() {
Text('翼语竞速俱乐部')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('FPV · 赛道 · 自组 · 夜航')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
.layoutWeight(1)
Text('报名飞行')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.accent)
.borderRadius(14)
}
.width('100%')
Row({ space: 8 }) {
// 四个统计卡片:今日圈速/飞行时长/炸机次数/积分排名
}
.width('100%')
}
.width('100%')
.padding({ top: 10, bottom: 8 })
}
header 构建器渲染页面顶部信息区,分为两行。第一行是品牌区:左侧 🛸 图标,中间是"翼语竞速俱乐部"标题与"FPV · 赛道 · 自组 · 夜航"副标题(用 layoutWeight(1) 占据剩余空间),右侧是"报名飞行"的强调色按钮。这种"图标—标题副标题—行动按钮"的三段式头部布局是移动端页面的经典范式,既传达品牌身份,又提供核心行动入口。标题用 textPrimary 高对比色加粗,副标题用 textHint 低对比小字,形成主次分明的品牌表达。
第二行是四个等宽统计卡片(layoutWeight(1) 均分):今日圈速 26.4s(neon 霓虹青,高光数据)、飞行时长 3.6h(primary 主色)、炸机次数 2 次(warning 警告黄,警示数据)、积分排名第 6 名(danger 红,竞争数据)。每个卡片含上下两行文字:上行是 9px 的 label(textHint 灰),下行是 13px 加粗的数值(语义色)。四张卡片将飞手最关心的四项核心指标以仪表盘形式集中展示,是"数据驾驶舱"设计模式的应用。
四个卡片的数值色彩刻意区分:圈速用 neon(成绩高光)、时长用 primary(常规主色)、炸机用 warning(警示)、排名用 danger(竞争压力),每种数据类型用色彩语义传递其性质。卡片背景统一用 cardBg 暗色,圆角 10,padding 8,视觉上形成四个等大的数据格子。整个 header 以 Column(space:8) 组织两行,顶部 padding 10、底部 8,为后续内容留出呼吸空间。header 在所有主 Tab 下都显示(位于 mainContent 顶部),保证品牌区与核心数据的全局可见性。
第二十八章 subNav 分段导航构建器
@Builder
subNav() {
Scroll() {
Row({ space: 6 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Column({ space: 3 }) {
Text(item)
.fontSize(12)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
Column()
.width(this.subTab === idx ? 18 : 0)
.height(3)
.borderRadius(2)
.backgroundColor(this.subTab === idx ? COLORS.neon : COLORS.cardBg)
...
}
.padding({ left: 12, right: 12, top: 7, bottom: 5 })
.backgroundColor(this.subTab === idx ? COLORS.primaryDark : COLORS.cardBg)
.borderRadius(10)
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 4, bottom: 10 })
}
subNav 构建器渲染首页的七个内容子 Tab,以横向可滚动的方式排列。外层 Scroll 设为水平滚动(ScrollDirection.Horizontal)并隐藏滚动条(BarState.Off),内层 Row(space:6) 排列七个 Tab 项。每个 Tab 项是 Column(space:3),内含标签文字与一条指示条。选中态通过三元表达式全面控制视觉:选中时文字加粗、白色、背景 primaryDark 深青、指示条宽 18 高 3 的 neon 霓虹条;未选中时文字常规、textSecondary 灰、背景 cardBg、指示条宽度 0 隐藏。
指示条的"宽 18 显/宽 0 隐"是一种巧妙的显隐切换——不通过 visibility 属性而通过宽度 0/18 的过渡,配合 borderRadius 形成胶囊状高亮条。每个 Tab 项的 onClick 仅执行 this.subTab = idx,依靠 @State subTab 的变更触发整个 subNav 与首页内容的重新渲染。由于 subTab 变更影响的是整个 mainContent 中首页部分的 if-else 分支,切换 Tab 会同时更新导航高亮与内容区显示的页面构建器。
横向滚动设计容纳了七个 Tab 的宽度——若屏幕宽度不足以平铺七个 Tab,用户可左右滑动查看更多。这是"内容多于屏幕宽度"场景的标准解法,比缩小 Tab 字号或折行更优雅。padding({top:4,bottom:10}) 为导航区提供上下间距。subNav 仅在首页主 Tab(mainTab===0)下显示,其他主 Tab 直接展示对应内容无需子导航,这种"首页聚合多频道、其他 Tab 专注单一内容"的非对称设计,让首页成为信息中枢而其他 Tab 是快捷直达入口。
第二十九章 pageFeatured 精选页面构建器
@Builder
pageFeatured() {
Column({ space: 12 }) {
// 赛事横幅
Row({ space: 14 }) {
Text('🏁').fontSize(44)
Column({ space: 6 }) {
Text('周末夜战 · 霓虹赛道积分赛')...
Text('六圈制 · LED 夜航 · 冠军积分翻倍 · 限 16 席')...
Text('去报名')...
}
}
.backgroundColor(COLORS.primaryDark)
.borderRadius(16)
// 飞行模式四格
// 圈速柱状图
// 机型占比横条
// 机库双列
// 飞手动态(前3条)
}
}
pageFeatured 是首页"精选"子 Tab 的内容构建器,是整个页面信息密度最高的聚合区,包含六个内容板块。第一块是赛事横幅:左侧大号 🏁 图标(44px),右侧三行文字——赛事标题"周末夜战 · 霓虹赛道积分赛"、规则说明"六圈制 · LED 夜航 · 冠军积分翻倍 · 限 16 席"、"去报名"行动按钮。横幅用 primaryDark 深青背景、16 圆角,是精选页的视觉焦点与核心行动召唤,引导用户报名参加夜战赛事。
第二块是飞行模式四格,遍历 MODE_LIST 渲染四个等宽卡片(layoutWeight(1)),每个含图标、模式名、描述(单行省略)、HOT 角标(hot 为 true 时显示红色 HOT,否则空格占位保持高度一致)。第三块是圈速柱状图,遍历 LAP_CHART 渲染七根柱子,柱高由 barH 计算、颜色由 lapColor 映射,底部标注数值与圈次标签,顶部有"最快圈 26 秒"的 neon 高亮。第四块是机型占比横条,遍历 BAND_CHART 渲染五条进度条,每条自带颜色与百分比。第五块是机库双列,遍历 DRONE_LIST 以 idx % 2 === 0 分组渲染双列卡片。第六块是飞手动态前 3 条(postList.slice(0,3)),每条含头像、昵称、时间、正文(2 行省略)、点赞数。
精选页的六个板块从"赛事召唤"到"模式选择"到"成绩可视化"到"机型分析"到"机库概览"到"社群动态",形成一条从"被吸引→了解玩法→看自己成绩→看自己装备→看社群动态"的内容消费动线。这种由"公共信息"逐步过渡到"个人数据"再到"社交内容"的编排,符合用户在首页"先浏览后深入"的浏览心智。每个板块独立成卡(cardBg 背景、圆角),板块间以 space:12 间距分隔,视觉上模块分明。
第三十章 pageHangar 机库页面构建器
@Builder
pageHangar() {
Column({ space: 10 }) {
Row() {
Text('机库').fontSize(16).fontWeight(FontWeight.Bold)...
Text('登记新机').fontSize(12).fontColor(COLORS.primary)
.onClick(() => { this.openAdd(); })
}
ForEach(DRONE_LIST, (it: DroneItem) => {
Column({ space: 6 }) {
Row() {
Column().width(4).height(38).backgroundColor(statusColor(it.status))
Column({ space: 4 }) {
Row() {
Text(it.name)...
Text(it.status).fontColor(statusColor(it.status))...
}
Row({ space: 8 }) {
Text(`🧩 ${it.frame}`)...
Text(`🔋 ${it.battery}`)...
}
}
}
Row({ space: 10 }) {
Text(`极速 ${it.topSpeed} km/h`)...
Text(frameTagColor(it.frame) === '#FF6B57' ? '竞速系' : '通用系')...
}
}
.backgroundColor(COLORS.cardBg).borderRadius(12)
}, ...)
Button().backgroundColor(COLORS.danger).onClick(() => { this.openDelA(); })
Text('注销报名')...
}
}
pageHangar 是机库子 Tab 的内容构建器,以列表形式展示全部无人机。顶部是标题行,右侧"登记新机"入口复用了 openAdd 弹框(与发翼语共享新增弹框)。列表主体遍历 DRONE_LIST,每架无人机渲染为一张卡片,卡片左侧是 4px 宽 38px 高的状态色条(statusColor 映射),右侧是无人机信息:名称加粗、状态标签(状态色)、机架与电池(带 emoji 前缀)、极速与系列标签(frameTagColor 判断竞速系/通用系)。
状态色条是机库卡片的核心视觉设计——一根细长的彩色竖条紧贴卡片左侧,颜色随无人机状态变化(待命绿、飞行中青、维修黄等),用户扫一眼列表即可凭色条快速定位特定状态的无人机,是"状态可视化"在列表场景的高效应用。系列标签通过 frameTagColor 的返回值判断"竞速系"或"通用系",用对应颜色渲染,为无人机的用途分类提供快速识别。
列表底部是一个 danger 红色的"注销报名"按钮,绑定 openDelA 打开删除弹框(delTarget=‘race’)。这里使用了一个特殊的实现技巧:Button() 组件本身不设文字,而是在其下方用 Text('注销报名') 配合负 margin(margin({left:-70,top:-32}))叠加定位到按钮中央。这种"空 Button + Text 叠加"的写法虽然不如直接用 Button 的文字参数简洁,但实现了对按钮文字样式的独立控制(字号、颜色、字重),体现了在某些样式需求下"组合优于单组件"的灵活思路。
第三十一章 pageTracks 赛道图鉴构建器
@Builder
pageTracks() {
Column({ space: 10 }) {
Text('赛道图鉴')...
ForEach(this.trackList, (it: TrackItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('🏁').fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name)...
Text(it.level).fontColor(levelColor(it.level))...
}
Text(`🔁 ${it.laps}`)...
}
}
Stack({ alignContent: Alignment.Start }) {
Row().width('100%').height(6).backgroundColor(COLORS.border)
Row().width(`${it.progress}%`).height(6).backgroundColor(levelColor(it.level))
}
Row() {
Text(`完赛度 ${it.progress}%`)...
Text('编辑训练单').onClick(() => { this.openEdit(idx); })
}
}
.backgroundColor(COLORS.cardBg).borderRadius(12)
}, ...)
}
}
pageTracks 渲染赛道图鉴列表,遍历 @State trackList(注意是可变的 trackList 而非静态 DRONE_LIST)。每条赛道渲染为一张卡片,含:左侧 🏁 图标、赛道名称加粗、难度标签(levelColor 映射色)、圈数说明(带 🔁 emoji)、完赛度进度条、以及"编辑训练单"入口。进度条采用 Stack 叠加两层 Row:底层是 100% 宽的 border 灰色背景条,上层是 ${progress}% 宽的难度色填充条,形成经典的进度条视觉。
进度条颜色与难度标签颜色一致(均用 levelColor(it.level)),形成"难度色贯穿卡片"的视觉统一——入门赛道绿条、进阶青条、高级橙条、顶级黄条,用户通过颜色即可判断赛道难度梯度。完赛度数值显示在进度条下方左侧,"编辑训练单"入口在右侧,绑定 openEdit(idx) 打开编辑弹框并预填该赛道数据。这里 ForEach 的第二参数是 idx,因为编辑操作需要知道目标索引。
pageTracks 是一个被多处复用的构建器——它既在首页"赛道图鉴"子 Tab 渲染,又在"赛道"主 Tab(mainTab===1)中作为独立内容渲染。这种"一个构建器多入口复用"的设计,保证了赛道列表在不同入口的视觉与行为一致,且只需维护一份代码。由于它消费的是 @State trackList,编辑或删除操作后列表会自动刷新,所有引用处同步更新。
第三十二章 pageTraining 训练计划构建器
@Builder
pageTraining() {
Column({ space: 10 }) {
Text('训练计划')...
ForEach(this.trackList.slice(0, 5), (it: TrackItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`).fontColor(idx < 3 ? COLORS.neon : COLORS.textHint)...
Column({ space: 3 }) {
Text(it.name)...
Text(`${it.laps} · ${it.level}`)...
}
Text(`${it.progress}%`).fontColor(levelColor(it.level))...
}
.backgroundColor(COLORS.cardBg).borderRadius(10)
}, ...)
Text('装备速查')...
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
// 左列装备卡片
// 右列装备卡片
}
}
}, ...)
}
}
pageTraining 渲染训练计划与装备速查两个板块。训练计划取 trackList.slice(0,5) 的前五条赛道,每条渲染为带序号的行:序号 ${idx+1}(前三名用 neon 霓虹色高亮,后两名用 textHint 灰)、赛道名称、圈数与难度、完赛度百分比(难度色)。前三名序号高亮的设计将训练计划转化为"排行榜"视觉,激励用户完成排名靠前的训练项。这种"序号+高亮"的列表样式是任务/计划类页面的常见激励手法。
装备速查板块遍历 GEAR_LIST,以 idx % 2 === 0 分组渲染双列卡片,与精选页机库双列的实现模式一致。每个装备卡片含图标、名称、描述、"去补给"入口。双列布局通过 if (idx < GEAR_LIST.length) 与 if (idx + 1 < GEAR_LIST.length) 的边界判断,安全处理奇数个元素的末尾单列情况,避免越界访问。
pageTraining 的双板块设计将"训练任务"与"所需装备"并列展示,形成"要练什么→需要什么装备"的关联引导。训练计划消费 @State trackList(前五条),装备速查消费静态 GEAR_LIST(全部八条)。两个板块在同一个 Column 中以 space:10 间距垂直排列,"装备速查"标题有 margin({top:4}) 微调与上方训练列表的间距。这种"任务+工具"的内容组合,为飞手提供了"看计划即知装备"的一站式信息。
第三十三章 pageCourse 飞行课堂构建器
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('飞行课堂')...
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title)...
Text(it.week)...
}
Stack({ alignContent: Alignment.Start }) {
Row().width('100%').height(8).backgroundColor(COLORS.border)
Row().width(`${it.progress}%`).height(8).backgroundColor(it.color)
}
Row() {
Text('已学')...
Text(`${it.progress}%`)...
}
}
.backgroundColor(COLORS.cardBg).borderRadius(12)
}, ...)
Button().backgroundColor(COLORS.danger).onClick(() => { this.openDelB(); })
Text('退课')...
}
}
pageCourse 渲染飞行课堂的课程进度列表,遍历静态 COURSE_LIST。每门课程渲染为一张卡片,含:课程标题加粗、周次说明、学习进度条(8px 高,自带 it.color 主题色)、"已学"标签与进度百分比。进度条与赛道进度条实现一致(Stack 叠加两层 Row),但颜色取自课程数据自带的 color 字段而非映射函数,因为课程主题色是预设的固定色而非规则推断。
列表底部是 danger 红色的"退课"按钮,绑定 openDelB 打开删除弹框(delTarget=‘course’)。与机库页的"注销报名"按钮一样,采用"空 Button + Text 叠加负 margin"的实现。退课操作在 doDel 中会从 postList(注意是 postList 而非 courseList,因为 COURSE_LIST 是静态 const 不可变,退课的"首门课程"实际映射到移除 postList 首条作为演示效果)移除首条。
pageCourse 在首页"飞行课堂"子 Tab 与"课堂"主 Tab(mainTab===2)两处复用。它消费的是静态 COURSE_LIST,因此课程列表本身不随退课操作变化(退课影响的是 postList),这种"展示数据与操作目标的错位"是演示场景下的简化处理——在真实应用中,退课应操作一个可变的 courseList 状态。但作为原型,这种简化不影响页面功能的演示,且通过 delTarget 区分保证了两类删除操作的独立触发。
第三十四章 pageCircle 飞手圈构建器
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('飞手圈')...
Text('发翼语').onClick(() => { this.openAdd(); })
}
ForEach(this.postList, (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar).fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick)...
Text(it.time)...
}
Text(it.text).fontSize(13)...
Text(`👍 ${it.likes} · 💬 回复`)...
}
}
.backgroundColor(COLORS.cardBg).borderRadius(12)
}, ...)
}
}
pageCircle 渲染飞手圈的完整动态流,遍历 @State postList 的全部条目(不切片)。每条动态渲染为一张卡片:左侧大号头像 emoji(26px)、右侧昵称加粗与时间、正文(13px,不限制行数完整展示)、底部点赞数与"💬 回复"入口。与精选页飞手动态(仅前 3 条、正文 2 行省略)不同,飞手圈是完整的社交信息流,展示全部动态且正文不截断,是"沉浸式社交浏览"场景的呈现。
"发翼语"入口位于标题行右侧,绑定 openAdd 打开新增弹框。新增的翼语会 unshift 到 postList 头部,由于飞手圈遍历的是完整 postList,新动态会立即出现在列表顶部,给用户即时的发布反馈。这种"发布即见"的交互是社交应用的基本体验要求。每条动态的"👍 点赞数 · 💬 回复"行提供了社交互动的入口暗示,虽然当前未实现点赞与回复的具体逻辑,但视觉上已为社交互动预留了入口。
pageCircle 仅在首页"飞手圈"子 Tab 渲染,是飞手社群社交的核心入口。它消费 @State postList,因此发翼语与退课(delTarget=‘course’ 时移除 postList 首条)都会影响其展示。动态卡片以 space:10 间距排列,每张卡片 cardBg 背景、12 圆角、12 padding,形成舒适的社交信息流阅读体验。
第三十五章 pageTools 装备库构建器
@Builder
pageTools() {
Column({ space: 10 }) {
Text('装备库')...
Row({ space: 8 }) {
// 装机区 / 能源区 / 图传区 三格
Column({ space: 4 }) { Text('🛠️')... Text('装机区')... Text('焊台 · 螺丝')... }
Column({ space: 4 }) { Text('🔋')... Text('能源区')... Text('锂电 · 充电')... }
Column({ space: 4 }) { Text('📡')... Text('图传区')... Text('天线 · 眼镜')... }
}
ForEach(GEAR_LIST, (it: GearItem) => {
Row({ space: 10 }) {
Text(it.icon).fontSize(20)
Column({ space: 3 }) {
Text(it.name)...
Text(it.desc)...
}
Text('补给')...
}
.backgroundColor(COLORS.cardBg).borderRadius(10)
}, ...)
}
}
pageTools 渲染装备库,分为上下两部分。上部是三个等宽分类入口(layoutWeight(1) 均分):装机区(🛠️ 焊台·螺丝)、能源区(🔋 锂电·充电)、图传区(📡 天线·眼镜),每个含大号图标、分类名加粗、一句话说明。这三个分类覆盖了 FPV 装备的三大功能域——组装维修、能源管理、图传链路,为用户提供了装备的顶层分类导航。
下部是完整的装备列表,遍历 GEAR_LIST 八件装备,每件渲染为一行:图标、名称加粗、描述、"补给"入口按钮。与训练计划的"去补给"入口不同,这里的"补给"入口用 border 色背景加 primary 文字渲染为胶囊按钮样式,视觉上更突出。每行以 space:10 排列,cardBg 背景、10 圆角、10 padding,形成紧凑的装备清单列表。
pageTools 仅在首页"装备库"子 Tab 渲染,是装备补给的核心入口。上部分类入口与下部清单列表的"分类+列表"组合,既提供了宏观的装备分类导航,又提供了微观的逐件装备浏览与补给入口,满足用户"先找分类再看具体装备"与"直接浏览全部装备"两种浏览心智。装备数据来自静态 GEAR_LIST,只读消费,不涉及增删改。
第三十六章 pageMine 我的页面构建器
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🦅').fontSize(40)
Column({ space: 4 }) {
Text('翼语竞速手')...
Text('Lv.5 · 飞行 86 次 · 积分 1240')...
}
}
.backgroundColor(COLORS.cardBg).borderRadius(14)
// 我的赛道(前3条)
Column({ space: 10 }) {
Text('我的赛道')...
Text(`${this.trackList.length} 条`)...
ForEach(this.trackList.slice(0, 3), ...)
}
// 我的翼语(前3条)
Column({ space: 10 }) {
Text('我的翼语')...
Text(`${this.postList.length} 条`)...
ForEach(this.postList.slice(0, 3), ...)
}
}
}
pageMine 渲染"我的"个人中心页面,仅在"我的"主 Tab(mainTab===3)显示。页面分为三个板块。第一是用户信息卡:左侧 🦅 鹰图标(40px),右侧"翼语竞速手"昵称加粗与"Lv.5 · 飞行 86 次 · 积分 1240"等级与统计数据。这张卡片是用户身份的核心展示,level、飞行次数、积分三项数据概括了用户的飞手画像。
第二是"我的赛道"板块,标题行右侧显示 ${this.trackList.length} 条 的赛道总数,下方列表取 trackList.slice(0,3) 展示前三条赛道的名称与圈数。第三是"我的翼语"板块,标题行右侧显示 ${this.postList.length} 条 的翼语总数,下方列表取 postList.slice(0,3) 展示前三条翼语的昵称与时间。两个板块的计数动态绑定 @State 数组的 length,因此发翼语、编辑赛道、注销报名等操作后,计数会自动更新,给用户"操作生效"的数据反馈。
pageMine 通过动态计数(trackList.length 与 postList.length)将用户的操作行为反映到个人中心的统计数字上,是"操作可见"的体验设计。即便用户在飞手圈发了翼语后切到"我的"页,翼语条数计数会即时增加,形成操作与反馈的闭环。三个板块以 space:12 间距排列,每个板块 cardBg 背景、14 圆角、14 padding,视觉上形成三张层次分明的个人数据卡。
第三十七章 modalOverlay 与弹框体系
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.6)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
})
}
.width('100%')
.height('100%')
}
modalOverlay 是三种弹框共享的遮罩层构建器。它渲染一个全屏的黑色半透明遮罩(#000000 背景、0.6 透明度),覆盖在页面内容之上,将视觉焦点引导至弹框主体。遮罩的 onClick 回调同时将三个弹框布尔(addOpen、editOpen、delOpen)设为 false,实现"点击遮罩区域关闭任意弹框"的交互。这种"遮罩点击即关闭"是模态弹框的标准交互惯例,用户无需寻找关闭按钮,点击弹框外的遮罩即可退出。
遮罩被三种弹框的 Stack 容器共同引用——在 build 方法中,addOpen、editOpen、delOpen 三个条件分支各自渲染一个 Stack,每个 Stack 内首先调用 this.modalOverlay() 渲染遮罩,然后渲染对应的弹框主体。这种"遮罩共享、主体各异"的组合方式,保证了三种弹框的遮罩视觉与行为完全一致,仅需维护一份 modalOverlay 代码。遮罩使用纯黑而非深色主题色,是因为纯黑半透明能最大程度地压暗背景内容,形成强烈的"模态聚焦"效果。
值得注意的是遮罩的透明度 0.6 是经过权衡的取值——太透明(如 0.3)则背景内容仍清晰可见,焦点引导弱;太不透明(如 0.9)则背景几乎全黑,丧失上下文感。0.6 恰好让背景内容"可见但暗淡",用户既能聚焦弹框,又保留对背景页面位置的感知,是模态遮罩的常用最佳实践值。
第三十八章 addModalBody 新增弹框主体
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布翼语').fontSize(17).fontWeight(FontWeight.Bold)...
TextInput({ placeholder: '一句话飞行心得…', text: this.addTitle })
.onChange((v: string) => { this.addTitle = v; })
TextArea({ placeholder: '补充细节:机型、赛道、圈速、炸机复盘…', text: this.addContent })
.onChange((v: string) => { this.addContent = v; })
Row({ space: 10 }) {
Button().backgroundColor(COLORS.bg).onClick(() => { this.addOpen = false; })
Text('取消')...
Button().backgroundColor(COLORS.primary).onClick(() => { this.doAdd(); })
Text('发布')...
}
}
.backgroundColor(COLORS.cardBg)
}
addModalBody 是"发翼语"新增弹框的主体内容。它以 Column(space:12) 组织:标题"发布翼语"、一行 TextInput(标题输入,placeholder 引导"一句话飞行心得")、一个 TextArea(正文输入,placeholder 引导补充机型、赛道、圈速、炸机复盘等细节,90px 高)、底部双按钮行。TextInput 与 TextArea 的 text 参数绑定 @State addTitle 与 addContent,onChange 回调将输入值同步回状态,形成受控输入组件。
placeholder 文案的设计颇具用心:"一句话飞行心得"引导用户简短表达,"补充细节:机型、赛道、圈速、炸机复盘"则列举了 FPV 动态的常见内容维度,既是输入引导也是内容灵感提示,降低了用户的"写什么"门槛。这种"场景化 placeholder"是提升用户输入意愿的细节设计。底部双按钮采用与机库注销按钮相同的"空 Button + Text 叠加负 margin"实现:左侧取消按钮 bg 深色背景、右侧发布按钮 primary 主色背景,文字通过 Text 叠加。
"发布"按钮绑定 doAdd,将输入的 addTitle 构造为新 PostItem 插入 postList。整个弹框主体 cardBg 背景、16 padding,宽度在外层 Stack 中被设为 88%、最大高度 80%、圆角 16、zIndex 999。这种"88% 宽、80% 最大高、居中浮层"的尺寸规格,使弹框在屏幕中央占据适中面积,既容纳内容又不遮挡过多背景,是移动端弹框的常见尺寸约定。
第三十九章 editModalBody 编辑弹框主体
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('编辑训练单').fontSize(17).fontWeight(FontWeight.Bold)...
TextInput({ placeholder: '赛道名称', text: this.editName })
.onChange((v: string) => { this.editName = v; })
TextInput({ placeholder: '难度(如 进阶)', text: this.editStyle })
.onChange((v: string) => { this.editStyle = v; })
TextArea({ placeholder: '圈数与训练安排', text: this.editNote })
.onChange((v: string) => { this.editNote = v; })
Row({ space: 10 }) {
Button().onClick(() => { this.editOpen = false; })
Text('取消')...
Button().backgroundColor(COLORS.primary).onClick(() => { this.doEdit(); })
Text('保存')...
}
}
.backgroundColor(COLORS.cardBg)
}
editModalBody 是"编辑训练单"弹框的主体,结构与新增弹框高度一致,但字段不同:标题"编辑训练单"、三个输入字段(赛道名称、难度、圈数与训练安排)、底部取消与保存按钮。三个 TextInput/TextArea 分别绑定 editName、editStyle、editNote 三个 @State,这些状态在 openEdit(index) 时已从目标赛道预填,因此用户打开弹框即看到当前值,可基于现有值修改。
编辑弹框的 placeholder 文案也针对各字段定制:“赛道名称”“难度(如 进阶)”“圈数与训练安排”,其中难度的 placeholder 给出了"如 进阶"的示例值,引导用户按已有的难度档位格式输入,减少格式歧义。这种"带示例的 placeholder"是降低用户输入错误的细节设计。"保存"按钮绑定 doEdit,基于 editIdx 将表单值构造为新 TrackItem 替换原条目。
编辑弹框与新增弹框的结构对称性(标题+输入字段+双按钮行)体现了"弹框模板化"的设计思想。两者共享相同的布局骨架,仅字段数量与文案不同。若未来抽取统一的弹框外壳组件,只需将标题、字段配置、按钮回调参数化即可,当前虽为独立实现,但结构一致为未来重构预留了便利。弹框主体的样式(cardBg 背景、16 padding、12 间距)与新增弹框完全一致,保证了三种弹框的视觉统一。
第四十章 delModalBody 删除弹框主体
@Builder
delModalBody() {
Column({ space: 12 }) {
Text(this.delTarget === 'race' ? '注销赛事报名' : '退出飞行课堂')
.fontSize(17).fontWeight(FontWeight.Bold)...
Text(this.delTarget === 'race' ? '将移除首条赛道训练记录,确认执行?' : '将退出首门进行中的课程,确认执行?')
.fontSize(13).fontColor(COLORS.textSecondary)
Row({ space: 10 }) {
Button().onClick(() => { this.delOpen = false; })
Text('取消')...
Button().backgroundColor(COLORS.danger).onClick(() => { this.doDel(); })
Text('确认')...
}
}
.backgroundColor(COLORS.cardBg)
}
delModalBody 是删除确认弹框的主体,其核心特征是"根据 delTarget 动态切换文案"。标题在 delTarget==='race' 时显示"注销赛事报名",否则显示"退出飞行课堂";说明文字相应切换为"将移除首条赛道训练记录"或"将退出首门进行中的课程"。这种"单弹框双用途"通过一个状态变量切换文案,复用了整个弹框的布局与交互,是弹框复用的典范。
说明文字用 textSecondary 灰色 13px,与标题的白色 17px 加粗形成主次对比,清晰传达"即将执行的破坏性操作"的后果。底部双按钮中,确认按钮使用 COLORS.danger 红色背景(而非新增/编辑弹框的 primary 主色),通过颜色强化"这是破坏性操作"的警示语义,是"色彩传达操作性质"在按钮上的应用。用户在点击红色确认按钮前,已有标题、说明、按钮色三重视觉提示该操作的破坏性。
"确认"按钮绑定 doDel,根据 delTarget 分支执行删除。"取消"按钮仅关闭弹框。删除弹框是三种弹框中最简的——无输入字段,仅文字说明与双按钮,因为删除操作不需要用户输入数据,只需确认意图。这种"确认型弹框"的极简结构,与新增/编辑的"表单型弹框"形成对比,三种弹框覆盖了 CRUD 中 Create(新增)、Update(编辑)、Delete(删除)三种操作,Read(查看)则由列表本身承担,形成了完整的 CRUD 弹框体系。
第四十一章 bottomBar 底部导航构建器
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label)
.fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.mainTab = idx;
})
}, (it: NavItem) => it.label)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
bottomBar 渲染底部四大主 Tab 导航栏。以 Row 为容器,遍历 NAV_LIST 四个 Tab 项,每个项是 Column(space:2) 含图标与文字,通过 layoutWeight(1) 等分宽度。选中态通过三元表达式控制:选中时图标不透明(opacity 1)、文字加粗、primary 主色;未选中时图标半透明(0.55)、文字常规、textHint 灰。这种"透明度+字重+颜色"三重对比的选中态表达,使当前 Tab 一目了然。
图标用 opacity 区分选中态(1 vs 0.55)而非颜色变化,是因为 emoji 图标本身是彩色的,无法通过 fontColor 改色,透明度成为最简洁的视觉区分手段。文字则通过 fontColor 与 fontWeight 双重区分。每个 Tab 项的 onClick 仅设 this.mainTab = idx,依赖 @State mainTab 变更触发 mainContent 的 if-else 分支切换,实现主 Tab 路由。
底部栏整体 height(56) 固定高度、cardBg 背景、顶部左右 16 圆角(borderRadius({topLeft:16, topRight:16})),形成"顶部圆角浮于内容之上"的视觉。56px 高度是移动端底部 Tab 栏的常见取值,足够容纳图标+文字两行且不过分占用屏幕空间。底部栏在 build 中位于 mainContent 之下,始终可见,是全局导航的持久入口。
第四十二章 mainContent 主内容分发
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
this.subNav()
Scroll() {
Column() {
if (this.subTab === 0) { this.pageFeatured() }
else if (this.subTab === 1) { this.pageHangar() }
else if (this.subTab === 2) { this.pageTracks() }
else if (this.subTab === 3) { this.pageTraining() }
else if (this.subTab === 4) { this.pageCourse() }
else if (this.subTab === 5) { this.pageCircle() }
else { this.pageTools() }
}
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
} else if (this.mainTab === 1) {
Scroll() { this.pageTracks() }
} else if (this.mainTab === 2) {
Scroll() { this.pageCourse() }
} else {
Scroll() { this.pageMine() }
}
}
}
mainContent 是主内容区的分发构建器,实现了两层路由的核心逻辑。首先无条件渲染 header(头部在所有主 Tab 下可见),然后根据 mainTab 分四个分支:mainTab=0(首页)时渲染 subNav 子导航与一个垂直 Scroll,Scroll 内根据 subTab 的 0-6 七个值通过 if-else 链渲染对应的七个页面构建器;mainTab=1(赛道)直接渲染 pageTracks;mainTab=2(课堂)渲染 pageCourse;mainTab=3(我的)渲染 pageMine。
首页分支的 if-else 链是七路分发,每个分支调用一个页面构建器。这种"if-else 链式分发"在 ArkTS 中是条件渲染的标准写法——仅 subTab 对应的分支会被渲染,其余分支的构建器不执行,保证了只有当前子页面的组件树被创建,避免了七个页面同时挂载的性能浪费。每次 subTab 变更,旧分支卸载、新分支挂载,完成子页面切换。外层 Scroll 设为垂直滚动、layoutWeight(1) 占据 header 与 bottomBar 之间的全部剩余空间。
非首页主 Tab 的内容也包裹在垂直 Scroll 中,保证内容超出屏幕时可滚动浏览。三个非首页分支复用了首页的子页面构建器(pageTracks、pageCourse),实现了"赛道主 Tab 即赛道图鉴子页""课堂主 Tab 即飞行课堂子页"的内容复用。这种"主 Tab 是子页的快捷直达入口"的设计,让用户无需先进入首页再切子 Tab,直接从底部导航即可到达高频内容,缩短了操作路径。所有 Scroll 均设 scrollBar(BarState.Off) 隐藏滚动条,保持视觉简洁,并统一 padding({left:14,right:14,bottom:20}) 提供内容边距。
第四十三章 build 顶层布局与三层 Stack 合成
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() { this.modalOverlay(); Column() { this.addModalBody() } }
.width('88%').constraintSize({ maxHeight: '80%' }).borderRadius(16).zIndex(999)
}
if (this.editOpen) {
Stack() { this.modalOverlay(); Column() { this.editModalBody() } }
.width('88%').constraintSize({ maxHeight: '80%' }).borderRadius(16).zIndex(999)
}
if (this.delOpen) {
Stack() { this.modalOverlay(); Column() { this.delModalBody() } }
.width('88%').constraintSize({ maxHeight: '80%' }).borderRadius(16).zIndex(999)
}
}
.width('100%')
.height('100%')
}
build 是组件的渲染入口,以一个顶层 Stack 合成四层内容,层叠顺序从底到顶依次为:背景层、特效层、业务层、弹框层。第一层是全屏 Column 背景底色(COLORS.bg 深青墨色),作为整个页面的基底。第二层是 fxLayer 特效层(信号波纹与领航灯),位于背景之上、业务之下,通过 hitTestBehavior(None) 不拦截点击。第三层是业务层 Column,包含 mainContent(主内容)与 bottomBar(底部导航),是用户交互的主体。第四层是三个条件弹框,仅当对应布尔为 true 时渲染。
四层 Stack 的层叠顺序是精心设计的:特效层在业务层之下,保证波纹与光点作为"背景氛围"存在而不遮挡业务内容;弹框层在业务层之上(通过 zIndex(999) 显式提升),保证弹框覆盖在最顶层。特效层的 hitTestBehavior(None) 是关键——它让特效层在视觉上位于业务层之下(层叠顺序低),但在命中测试上完全透明(点击穿透),使得业务层的按钮、列表等组件能正常响应点击,不受特效层全屏覆盖的影响。
三个弹框的条件渲染(if (this.addOpen) 等)使得弹框仅在实际需要时挂载,关闭时立即卸载,避免了弹框常驻 DOM 的开销。每个弹框是一个 Stack,内含 modalOverlay(遮罩)与一个 Column(弹框主体),主体宽 88%、最大高 80%、16 圆角、zIndex 999。Stack 默认居中对其子元素,使弹框主体在屏幕中央显示。constraintSize({maxHeight:'80%'}) 限制弹框最大高度,当内容过多时弹框不会超出屏幕 80% 高度(配合内部滚动),是弹框内容自适应的安全约束。
第四十四章 整体导航与渲染流程
上图展示了页面的三条核心流程:特效动画循环、导航路由分发、弹框操作闭环。特效动画循环从页面挂载启动定时器开始,每 90ms 驱动 tick 自增,触发 fxLayer 重新求值并更新波纹与领航灯参数,形成持续动画。导航路由分发由用户点击底部 Tab 或子 Tab 触发,根据 mainTab 与 subTab 的取值在 if-else 链中选择对应页面构建器渲染。弹框操作闭环由行动按钮触发,经 open 方法预处理后显示弹框,用户确认后经 do 方法执行数据变更,最终触发列表刷新。
三条流程通过 @State 状态变量解耦联动:tick 驱动特效、mainTab/subTab 驱动路由、addOpen/editOpen/delOpen 驱动弹框、postList/trackList 驱动列表。任何状态的变更都会自动触发依赖该状态的构建器重新求值,无需手动调用刷新。这种"状态即真相、渲染即状态投影"的声明式范式,使得复杂页面的各条流程能独立运行又协调统一。
第四十五章 弹框状态机与数据流
弹框系统的状态机如上图所示,以"关闭态"为中心,可切换到四种打开态。每种打开态都有两条回到关闭态的路径:取消(含遮罩点击)与确认执行。新增态与编辑态是表单型,需用户输入数据;两种删除态是确认型,仅需用户确认。状态机的转移完全由 addOpen、editOpen、delOpen 三个布尔与 delTarget 字符串驱动,任意时刻最多一个弹框打开(虽然代码未显式互斥,但交互上不会同时触发多个 open 方法)。
数据流方面,新增弹框的 addTitle/addContent 表单状态经 doAdd 写入 postList;编辑弹框的 editName/editStyle/editNote 经 doEdit 写入 trackList;删除弹框经 doDel 从 trackList 或 postList 移除首条。postList 与 trackList 作为 @State 数组,其变更触发所有消费它们的构建器(pageFeatured、pageCircle、pageTracks、pageTraining、pageMine 等)刷新,完成"操作→数据→视图"的闭环。
第四十六章 数据模型关系与消费关系
上图展示了数据模型、静态数据、组件状态与构建器之间的消费关系。数据模型层区分了 @Observed 类(可观测可变模型)与 interface(轻量只读结构)。静态数据层将初始数据通过 new 实例化或字面量形式持有。组件状态层中,postList 与 trackList 引用静态数据作为初始值,从而具备了运行时可变性。消费构建器层中,各页面构建器按需消费状态或静态数据:消费 @State 的构建器(pageTracks、pageCircle 等)会随数据变更刷新,消费静态 const 的构建器(pageHangar、pageCourse 等)保持只读稳定。
这种分层关系清晰展现了"可变与只读的边界":postList 与 trackList 是唯一两个可变数据源,所有增删改操作最终都落在它们之上;其余静态数据(DRONE_LIST、GEAR_LIST、LAP_CHART 等)全程只读。这种"最小可变集"的设计,使得状态管理复杂度被控制在最小范围,开发者只需追踪两个 @State 数组即可理解页面的全部动态行为。
第四十七章 特效层与业务层的解耦机制
特效层与业务层的解耦是本页面工程架构的核心亮点。特效层由独立的定时器驱动(90ms tick),通过纯函数计算波纹与光点参数,渲染为 fxLayer 构建器。业务层由 mainTab/subTab 路由驱动,渲染 header、页面内容、bottomBar。两层在 Stack 中层叠,特效在下、业务在上,通过 hitTestBehavior(None) 实现点击穿透——特效层的视觉存在不影响业务层的交互响应。
这种解耦带来了三个工程优势:其一,特效逻辑与业务逻辑完全独立,修改特效不影响业务,修改业务不影响特效,降低了变更的耦合风险;其二,特效层的性能消耗(定时器、参数计算、属性更新)与业务层的渲染消耗分离,便于分别定位性能瓶颈;其三,若需移除特效(如低端设备降级),只需在 build 中移除 this.fxLayer() 一行,业务层完全不受影响,具备降级的便利性。
第四十八章 技术对比与设计决策总表
下表从二十余个技术维度对比本页面的实现方案与替代方案,分析其优势与权衡:
| 技术维度 | 本实现方案 | 替代方案 | 优势 | 劣势/权衡 |
|---|---|---|---|---|
| 色彩管理 | ColorPalette 接口 + COLORS 常量 | 各处散落十六进制字面量 | 类型约束防遗漏、语义化命名、主题可替换 | 接口定义增加少量代码量 |
| 主题策略 | 暗色赛博主题 | 亮色或跟随系统 | 契合 FPV 夜飞场景、OLED 省电、沉浸感强 | 暗色对部分用户可读性要求高 |
| 数据模型(可变) | @Observed class | 普通 class 或 interface | 属性变更触发局部刷新、观测能力就绪 | 观测有微量运行时开销 |
| 数据模型(只读) | interface + 字面量 | 统一用 class | 轻量无观测开销、语法简洁、结构化类型 | 不具备观测能力(但只读无需) |
| 导航结构 | 主 Tab + 子 Tab 两层 | 单层平铺或抽屉导航 | 信息密度高、聚合与直达并存 | 两层状态协同稍复杂 |
| 子 Tab 布局 | 横向 Scroll + 选中指示条 | 固定均分或 Tab 组件 | 容纳 7 个 Tab、可滚动、指示条显隐优雅 | 需手动管理 Scroll 与选中态 |
| 特效驱动 | setInterval 90ms + 纯函数 | animateTo 动画 API 或 lottie | 逻辑可独立测试、无框架依赖、可控性强 | 需手动管理定时器生命周期 |
| 特效层命中 | hitTestBehavior(None) | visibility 或层级调整 | 点击完全穿透、动效不挡操作 | 需理解 HitTestMode 语义 |
| 波纹算法 | 取模循环半径与透明度 | 缓动函数或物理模拟 | 无缝循环、确定性可复现、算术简单 | 不如物理模拟真实 |
| 领航灯布局 | 互质系数伪随机 | Math.random 或预设坐标 | 确定性、分布均匀、无需随机种子 | 视觉随机感不如真随机 |
| 状态色彩映射 | 集中纯函数(statusColor 等) | 各处内联三元或查表 | 规则集中可维护、一致性保证 | 函数需覆盖全部分支 |
| 柱状图高度 | barH 取反映射 | 直接用数值或线性映射 | 适配"越低越好"语义、纯函数可测 | 含魔数需注释说明 |
| 弹框管理 | 三布尔 + delTarget 区分 | 统一弹框状态枚举 | 简单直观、复用删除弹框 | 未显式互斥(交互上不冲突) |
| 弹框遮罩 | 共享 modalOverlay 构建器 | 各弹框独立遮罩 | 一致性强、代码复用、点击关闭统一 | 共享 onClick 含三个赋值 |
| 弹框主体 | 独立构建器(add/edit/del) | 统一弹框配置驱动 | 各自独立易调整、结构清晰 | 布局骨架有重复 |
| 列表渲染 | ForEach + 键函数 | ForEach 无键或用索引 | 键稳定高效复用、diff 精准 | 需保证键唯一性 |
| 双列布局 | idx % 2 分组 + 边界判断 | Grid 或 Flex wrap | 不依赖 Grid 组件、兼容性好 | 奇数末尾需特殊处理 |
| 表单状态 | 组件级 @State | 弹框内部局部状态 | 打开预填与提交回写共享状态、简化传递 | 组件状态数增多 |
| 按钮文字 | 空 Button + Text 叠加负 margin | Button 文字参数 | 文字样式独立可控 | 实现稍 hack、不如直接参数简洁 |
| 进度条 | Stack 叠加两层 Row | Progress 组件 | 自定义颜色与圆角灵活、无组件依赖 | 需手动管理两层宽度 |
| 数据可变性边界 | 仅 postList 与 trackList 可变 | 全部可变或全部只读 | 状态最小化、复杂度可控 | 静态数据无法运行时变更 |
| 页面复用 | 构建器多入口复用 | 每入口独立实现 | 一份代码多处复用、一致性保证 | 复用处行为耦合 |
| 条件渲染 | if-else 链式分发 | Switch 或策略映射 | 直观易读、ArkTS 原生支持 | 分支多时链路较长 |
| 生命周期 | aboutToAppear/Disappear 管理定时器 | 组件内 animateTo 自动管理 | 定时器可控可清理、逻辑透明 | 需手动对称管理创建与销毁 |
| 图标资源 | emoji 字符串 | 图片资源或 SVG | 零资源依赖、跨平台、无加载延迟 | 视觉风格受字体限制 |
| 文本层次 | textPrimary/Secondary/Hint 三阶 | 二元(主/次)或单色 | 视觉层级精细、焦点自然 | 色值需精心调对比度 |
总结
综上所述,"翼语·无人机竞速俱乐部"这一页面以约一千七百余行 ArkTS 代码,构建了一个集多层导航、分段内容流、实时动效、CRUD 弹框体系于一体的复合型声明式页面。其工程价值不仅在于功能的完整实现,更在于各层设计的解耦与内聚——色彩体系以接口契约约束、数据模型按可变性分层、特效层与业务层以命中测试隔离、弹框以状态机驱动、路由以双层 if-else 分发。这些设计决策共同构成了一个可维护、可扩展、可降级的页面工程骨架。
从架构层面看,该页面体现了声明式 UI 的核心方法论:状态是唯一真相源,视图是状态的投影,所有交互最终归结为状态变更。页面的十六个 @State 变量精准覆盖了导航、特效、弹框、表单、数据五个维度,且可变数据集合被严格限制为 postList 与 trackList 两个,形成了"最小可变集"的状态管理边界。这种最小化使得页面的动态行为完全可追踪——任何 UI 变化都能溯源到具体的状态变更,调试与推理路径清晰。
从性能层面看,特效层采用 90ms 低频定时器驱动纯函数计算,避免了高频动画的性能压力;特效层通过 hitTestBehavior(None) 完全脱离命中测试,保证业务层交互不受干扰;条件渲染使得弹框与非当前子页面不常驻组件树,减少了不必要的挂载开销;ForEach 的键函数保证了列表 diff 的高效复用。这些性能考量使得页面在承载丰富视觉与交互的同时,仍能保持流畅运行。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:

从可维护性层面看,纯函数(barH、lapColor、waveR、statusColor 等)与构建器(fxLayer、header、pageFeatured 等)的顶层/方法级分离,使得算法逻辑与视图渲染各自独立可测;色彩映射函数集中管理语义色规则,避免散落;构建器的多入口复用(pageTracks、pageCourse)减少了代码重复;弹框的模板化结构(modalOverlay 共享、三主体对称)为未来统一抽象预留了空间。这些组织方式使得页面的维护与演进成本可控。
更多推荐



所有评论(0)