无人机竞速行业基于HarmonyOS ArkTS API 24 ColorPalette 接口定义了颜色体系的结构,NavItem 接口定义了导航项的数据结构
一、技术背景
1.1 HarmonyOS 声明式 UI 框架
HarmonyOS 作为华为推出的分布式操作系统,其声明式 UI 开发范式是整个前端架构的核心基石。与传统的命令式编程不同,声明式 UI 允许开发者通过描述"界面应该是什么样子"来构建用户界面,而非逐条编写"如何改变界面"的指令。在 ArkUI 框架中,每一个组件都是一个可组合的、自描述的结构体,开发者通过 @Component 装饰器标记一个结构体为 UI 组件,通过 build() 方法描述组件的渲染结果。这种范式极大地提升了开发效率,使得 UI 代码更具可读性和可维护性。
声明式 UI 的核心优势在于状态驱动视图更新。当应用状态发生变化时,框架会自动计算差异并最小化地更新 UI,而不需要开发者手动操作 DOM 或视图对象。在 HarmonyOS 中,这一机制通过 ArkUI 的差异化渲染引擎实现,它能够精确追踪状态变化与 UI 之间的依赖关系,确保只有受影响的部分才会被重新渲染。这种细粒度的更新机制在复杂应用中尤为重要,能够显著提升运行时性能。
ArkUI 框架提供了丰富的内置组件,包括基础组件(Text、Column、Row、Stack 等)、容器组件(Scroll、List、Grid 等)、表单组件(Button、TextInput、TextArea 等)以及媒体组件等。这些组件通过链式调用的方式进行属性设置,形成了流畅的声明式 DSL(领域特定语言)。开发者可以通过 .fontSize()、.backgroundColor()、.borderRadius() 等方法链式配置组件样式,代码结构清晰直观。
在跨设备能力方面,HarmonyOS 的声明式 UI 框架天生支持响应式布局。通过百分比宽度、layoutWeight 属性以及弹性布局(Flex),UI 可以自适应不同尺寸的屏幕。这对于 FPV 竞速社区这类需要在手机、平板甚至智慧屏上运行的应用来说尤为关键。框架提供的 FlexAlign、VerticalAlign 等对齐枚举,以及 space 参数控制子元素间距,都使得布局代码更加简洁和语义化。
1.2 ArkTS 语言特性
ArkTS 是 HarmonyOS 应用开发的首选语言,它在 TypeScript 的基础上进行了扩展和优化,专为声明式 UI 开发而设计。ArkTS 保留了 TypeScript 的类型系统和语法特性,同时增加了一系列装饰器(Decorator)来支持状态管理、组件定义和生命周期管理。这种设计使得前端开发者可以平滑过渡到 HarmonyOS 开发,同时享受到原生级别的性能体验。
装饰器系统是 ArkTS 最具特色的语言特性之一。@Entry 装饰器标记页面入口组件,@Component 装饰器标记自定义组件,@State 装饰器声明组件内部状态,@Observed 装饰器标记可被观察的数据类。这些装饰器共同构成了 ArkTS 的响应式编程模型。值得注意的是,@Observed 装饰器需要与 @ObjectLink 配合使用才能实现深层响应,但在本项目中,由于数据数组整体通过 @State 管理并通过数组替换的方式触发更新,@Observed 的主要作用是标记数据模型类的语义,为后续的性能优化预留空间。
ArkTS 的类型系统继承自 TypeScript,支持接口(interface)、类(class)、枚举(enum)等高级类型特性。在本项目中,ColorPalette 接口定义了颜色体系的结构,NavItem 接口定义了导航项的数据结构,这些接口不仅提供了类型安全保障,也使得代码的自文档化程度更高。类型系统在编译期就能捕获大量潜在错误,显著提升了代码质量和可维护性。
纯函数式的工具函数是 ArkTS 开发中的常见模式。在本项目中,propAngle、trailGlow、barH、lapColor 等函数都是无副作用的纯函数,它们接收输入参数并返回计算结果,不依赖外部状态也不修改外部数据。这种函数式编程风格使得逻辑更易于测试和推理,也便于在多个组件中复用。纯函数的计算结果可以被缓存,进一步优化性能。
1.3 HarmonyOS 生态与跨设备能力
HarmonyOS 的核心设计理念是"一次开发,多端部署",这一理念通过分布式软总线、分布式数据管理和分布式任务调度等技术实现。对于 FPV 穿越机竞速社区这类应用而言,跨设备能力意味着用户可以在手机上查看赛道信息,在平板上进行训练计划编辑,甚至在智慧屏上观看竞速直播,所有数据实时同步。这种无缝的跨设备体验是传统单设备应用无法比拟的。
分布式能力不仅体现在数据同步上,还体现在组件的可迁移性上。HarmonyOS 支持将 UI 组件从一台设备迁移到另一台设备,保持用户操作的连续性。例如,用户在手机上浏览圈速榜时,可以一键将排行榜"流转"到平板上继续查看,界面状态完全保持一致。这种能力为 FPV 竞速场景提供了丰富的想象空间——飞手可以在眼镜端看第一视角画面,在手机上调参,在平板上分析赛后数据。
在生态层面,HarmonyOS 提供了完善的开发工具链,包括 DevEco Studio 集成开发环境、ArkUI 组件库、方舟编译器等。方舟编译器(ArkCompiler)能够将 ArkTS 代码编译为高效的机器码,实现接近原生的运行性能。对于包含大量动画和特效的 FPV 社区应用来说,性能尤为关键——螺旋桨旋转特效、赛道光带流动效果都需要流畅的渲染,方舟编译器的静态优化能力为此提供了坚实保障。
HarmonyOS 的原子化服务(Atom Service)也是生态的重要组成部分。应用可以拆分为多个独立的原子服务,用户无需安装完整应用即可使用特定功能。对于 FPV 竞速社区来说,可以将"圈速榜查询"、"赛道查找"等功能封装为原子服务,用户通过服务中心即可快速访问,降低了用户的使用门槛,也提升了应用的传播效率。
1.4 @Observed 装饰器与状态管理机制
在 ArkTS 的状态管理体系中,@Observed 装饰器扮演着至关重要的角色。它用于标记一个类为"可观察类",使得该类的实例属性变化能够被 UI 框架追踪,从而触发视图更新。与 @State 装饰器管理基本类型和简单对象不同,@Observed 专注于类对象的细粒度观察,当配合 @ObjectLink 装饰器使用时,可以实现子组件对嵌套对象属性的精准监听,避免不必要的整体重渲染。
状态管理的层次结构是理解 ArkUI 性能的关键。@State 是组件内部状态,变化会触发当前组件的重新渲染;@Prop 是单向传递的属性,从父组件传递到子组件;@Link 是双向绑定的属性,父子组件共享同一份数据;@Observed + @ObjectLink 则实现了嵌套对象的深层观察。合理选择状态管理装饰器,是保证应用性能的重要手段。在本项目中,主组件使用 @State 管理所有列表数据和 UI 状态,通过数组整体替换的方式触发更新,这种方式在数据量不大的情况下简洁高效。
状态管理的另一个重要方面是状态的生命周期。@State 变量的生命周期与所属组件一致,组件创建时初始化,组件销毁时释放。对于需要在组件间共享的状态,可以使用 AppStorage 或 PersistentStorage 进行全局状态管理。在本项目中,由于所有状态都集中在主组件中管理,通过 Builder 方法传递给子视图,因此不需要额外的全局状态管理方案。这种集中式状态管理虽然在大型应用中可能面临性能瓶颈,但对于中等复杂度的应用来说,具有结构清晰、调试方便的优势。
状态更新的触发机制也是值得深入探讨的话题。在 ArkUI 中,状态更新通过"赋值"操作触发——只有当 @State 装饰的变量被整体重新赋值时,才会触发相关 UI 的更新。对于数组和对象类型的状态,直接修改数组元素或对象属性并不会触发更新,必须通过数组方法(如 unshift、filter 返回新数组)或对象整体替换的方式来触发。本项目中的 doAdd 方法使用 unshift 添加新元素,doDel 方法使用 filter 返回新数组,都是符合这一机制的正确写法。
1.5 FPV 无人机竞速行业的技术特点与挑战
FPV(First Person View,第一人称视角)穿越机竞速是近年来快速崛起的新兴无人机运动,飞手通过佩戴 FPV 眼镜,以穿越机机载摄像头的实时画面进行操控,在复杂的赛道中高速飞行并竞速。这项运动融合了技术、竞技和观赏元素,对配套的社区应用提出了独特的技术挑战。赛道数据的精确建模是基础挑战之一。一条专业的 FPV 赛道包含长度、弯道数量、赛道宽度、难度等级、所在区域等多个维度的数据,如何在移动端高效展示这些信息并提供良好的检索体验,是应用设计的核心问题。
圈速计时与排行榜系统是 FPV 竞速社区的核心功能。竞速运动的核心是"更快",因此圈速数据的精度、实时性和排名算法的公平性至关重要。本项目中的圈速榜采用了金银铜三色标记前三名,大字号突出显示成绩,配合柱状图展示飞手速度对比,这些设计都服务于"竞技感"的营造。在技术实现上,排行榜需要支持分页加载、实时刷新、好友排名筛选等功能,数据结构的设计需要兼顾查询效率和更新性能。
装备参数的专业展示是另一个行业特色。FPV 穿越机的装备体系极为复杂,从电机的 KV 值、拉力、重量,到图传的功率、频率,再到桨叶的尺寸、螺距,每一个参数都影响飞行性能。电机图鉴采用表格式布局,将型号、KV、拉力、重量等参数以对齐的列展示,便于飞手横向对比。这种表格式设计在信息密度和可读性之间取得了平衡,是专业装备类应用的典型选择。如何在有限的屏幕空间内展示尽可能多的专业参数,同时保持界面的美观和易用,是行业应用设计的永恒课题。
训练计划与社区互动的结合,是提升用户粘性的关键。FPV 飞手需要系统性的训练来提升技术,训练营功能提供了分阶段的训练计划,支持目标值调整和完成状态追踪。飞手圈则提供了社交功能,飞手可以发布动态、分享飞行日志、交流改装经验。社区的活跃度取决于内容的质量和互动的便利性,发动态弹框的简洁设计、动态卡片的信息层次,都直接影响用户的发帖意愿和浏览体验。
二、项目概览
2.1 功能模块总览

穿云 FPV 穿越机竞速社区是一款面向无人机竞速爱好者的垂直社区应用,集赛道查询、圈速排行、装备图鉴、社区交流和训练管理于一体。应用采用深色科技风设计,以青色为主色调、玫红色为强调色,营造出强烈的科技感和竞技氛围。整体架构采用底部 4 主 Tab + 首页 7 内容 Tab 的双层导航结构,确保用户能够快速访问各个功能模块。
四个主 Tab 分别是首页、赛道、装备和我的。首页作为应用的入口页面,聚合了最核心的功能和内容,通过 7 个内容 Tab 进行二级导航。这 7 个内容 Tab 包括精选、穿越赛道、圈速榜、电机图鉴、图传商城、飞手圈和训练营,采用两排 4+3 的布局形式,在有限的空间内容纳了丰富的功能入口。赛道 Tab 专注于赛道库的完整展示,装备 Tab 则整合了电机参数图鉴和图传商城,我的 Tab 提供个人信息、装备管理和快捷入口等功能。
应用的视觉特效是其一大亮点。螺旋桨旋转特效通过 setInterval 定时器驱动 tick 状态变量,配合 propAngle 函数计算每个桨叶的旋转角度,实现了四个螺旋桨以不同相位持续旋转的动画效果。赛道光带流动特效则通过 glow 状态变量和 trailGlow 函数计算每个光点的透明度,模拟了光带沿赛道流动的视觉效果。这两束光带分别采用青色和玫红色,与应用的主色调和强调色相呼应,增强了整体视觉的一致性。
弹框系统包含三种类型:发布飞手动态弹框、调整训练计划弹框和删除确认弹框。三种弹框共享统一的遮罩层组件 modalOverlay,各自拥有独立的内容区域。弹框的显示与隐藏通过布尔型状态变量控制,采用 Stack 布局叠加在主内容之上,zIndex 设置为 999 确保弹框始终处于最上层。这种统一的弹框架构保证了交互体验的一致性,也便于后续扩展更多弹框类型。
2.2 整体架构设计
从架构设计的角度来看,本项目采用了单组件多 Builder 的架构模式。整个页面由一个 @Entry 装饰的 Page 组件构成,所有的 UI 片段都通过 @Builder 装饰器定义为组件内部的构建方法。这种架构的优势在于状态管理高度集中——所有的状态变量都定义在主组件中,所有的业务方法也都在主组件中实现,避免了复杂的组件间状态传递。对于中等复杂度的应用来说,这种模式开发效率高、调试方便,是一种务实的选择。
在数据层面,项目采用了静态数据与动态状态分离的设计。POST_LIST、RACE_LIST、LAP_LIST 等顶层常量作为初始数据源,在组件初始化时赋值给对应的 @State 变量(如 posts、trains、gearCards)。用户的操作(发布动态、删除训练计划、下架装备)会修改这些状态变量,从而触发 UI 更新。这种设计使得数据流向清晰——从静态常量到组件状态,再到 UI 渲染,每一步都可追溯。
布局结构采用经典的垂直分层设计:头部(header)+ 内容区(mainContent)+ 底部导航(bottomBar)。内容区根据当前选中的主 Tab 和子 Tab 动态切换不同的页面 Builder。特效层(fxLayer)通过 Stack 布局叠加在内容之上,并且设置了 hitTestBehavior 为 None,确保特效不会拦截用户的触摸事件。弹框层同样通过 Stack 叠加,在需要时显示,zIndex 为 999。这种多层 Stack 的布局方式是 ArkUI 中实现悬浮元素的标准做法。
从代码组织的角度来看,文件采用了自顶向下的结构:先是设计令牌(颜色体系),然后是数据模型、静态数据、图表数据、导航常量、工具函数,最后是主组件。这种组织方式符合阅读习惯——读者先了解设计规范和数据结构,再看业务逻辑和 UI 实现。每个部分之间用注释分隔线标记,形成了清晰的视觉区块,提升了代码的可读性。
2.3 关键技术亮点

本项目在技术实现上有多个值得关注的亮点。首先是特效系统的实现方式。与传统的动画 API 不同,本项目通过 setInterval 定时器驱动状态变量 tick 和 glow 的变化,再通过纯函数计算每个动画元素的当前状态(角度、透明度)。这种"状态驱动动画"的方式完全符合声明式 UI 的编程范式——动画只是状态变化的视觉表现。每 60ms 触发一次状态更新,对于装饰性特效来说已经足够流畅,同时控制了性能消耗。
第二个亮点是颜色体系的系统化设计。通过 ColorPalette 接口定义颜色令牌的结构,再通过 COLORS 常量提供具体的颜色值,所有组件都引用 COLORS 中的具名颜色而非硬编码的色值。这种设计令牌(Design Token)的方式有多重好处:一是确保视觉一致性,所有地方使用相同的颜色值;二是便于主题切换,只需修改 COLORS 对象即可更换整个应用的配色方案;三是提高代码可读性,具名颜色比十六进制色值更具语义。
第三个亮点是列表渲染中 key 的精细化设计。在 ForEach 循环中,第三个参数(key 生成函数)的设计直接影响列表的更新性能。本项目中,不同列表采用了不同的 key 策略:赛道列表使用 ID 作为 key,动态列表组合 ID 和索引值,导航列表使用 label 加索引。这种根据数据特点选择 key 的做法体现了对性能的关注——对于 ID 唯一的数据使用 ID 作为 key,对于可能出现重复 ID 的场景则组合索引值确保唯一性。
第四个亮点是纯函数工具库的设计。propAngle、trailGlow、barH、lapColor、levelColor、stageColor、iconBg、motorBg 等工具函数全部是纯函数,它们接收输入、返回结果,没有副作用。这种函数式的设计使得动画计算、颜色计算、图标背景计算等逻辑可以被轻松地在多个 Builder 中复用,同时也便于单元测试。特别是 iconBg 函数,它通过计算 emoji 图标的 Unicode 编码之和并取模来决定背景色,这种确定性的哈希策略为每个图标提供了稳定且多样化的背景色,无需额外的配置数据。
三、逐段代码分析
3.1 颜色体系接口定义

interface ColorPalette {
bg: string;
cardBg: string;
cardBg2: string;
primary: string;
primaryDim: string;
accent: string;
accentDim: string;
title: string;
subTitle: string;
hint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
gold: string;
silver: string;
bronze: string;
dark: string;
}
颜色体系是整个应用视觉设计的基础,ColorPalette 接口以类型安全的方式定义了所有颜色令牌。该接口包含 19 个颜色属性,按照功能语义进行命名,而非根据颜色外观命名。这种"语义化命名"是设计令牌的最佳实践——当颜色值变化时,语义名称仍然有效,不会出现"名为 green 实际是 blue"的尴尬情况。
接口中的颜色可以分为几个层次:背景色层(bg、cardBg、cardBg2、dark)构成了应用的深度感,从页面背景到卡片背景再到次级卡片背景,颜色逐层变亮,形成视觉层次。主色层(primary、primaryDim)用于主要操作和强调元素,强调色层(accent、accentDim)用于次要强调和对比色。文字色层(title、subTitle、hint)提供了三级文字对比度,确保信息的可读性。功能色层(success、warning、danger)用于传递状态信息。
金银铜三色(gold、silver、bronze)的设置是本项目的行业特色——竞速排行榜的前三名需要用奖牌颜色来区分,这三种颜色直接对应着竞技运动中的荣誉等级。这种设计细节虽然在代码中只占三行,但对用户体验的提升是显著的——用户一眼就能识别出前三名,排行榜的竞技感和仪式感由此建立。
颜色体系接口定义了契约,但不提供具体值。这种接口与实现分离的设计使得颜色值的修改变得更加安全——只要接口不变,替换颜色值不会影响类型检查。在团队协作中,设计师可以通过修改 COLORS 常量来调整配色,而不需要关心这些颜色被用在什么地方;开发者则可以通过接口获得完整的类型提示,减少拼写错误。
3.2 颜色常量实现

const COLORS: ColorPalette = {
bg: '#12131F',
cardBg: '#1C1E30',
cardBg2: '#242743',
primary: '#00E5FF',
primaryDim: '#0E5A6B',
accent: '#FF3D71',
accentDim: '#6E1F3C',
title: '#F2F6FF',
subTitle: '#AEB8D4',
hint: '#6E7896',
border: '#2E3352',
success: '#3DF5A0',
warning: '#FFD54F',
danger: '#FF5252',
white: '#FFFFFF',
gold: '#FFD54F',
silver: '#B0BEC5',
bronze: '#FF8A65',
dark: '#0B0C14'
};
COLORS 常量是 ColorPalette 接口的具体实现,提供了深色科技风主题的完整颜色方案。背景色采用深蓝紫色调(#12131F),这种颜色比纯黑更有层次感,在 OLED 屏幕上也能呈现出深邃的效果。卡片背景色(#1C1E30)比页面背景稍亮,次级卡片背景(#242743)更亮一些,三级背景的亮度差异创造了清晰的视觉层次,用户可以直观地感知内容的嵌套关系。
主色选择了青色(#00E5FF),这是科技感设计的经典选择——青色让人联想到电子、数据和未来感,非常契合无人机竞速这项科技运动的调性。主色的暗色版本(#0E5A6B)用于需要降低对比度的场景,如选中态的背景等。强调色选择了玫红色(#FF3D71),与青色形成冷暖对比,在深色背景上非常醒目,用于价格、热度等需要突出的信息。
文字颜色分为三个等级:标题色(#F2F6FF)接近白色但略带蓝调,与整体冷色调协调;副标题色(#AEB8D4)提供中等对比度,用于次要文字;提示色(#6E7896)对比度最低,用于辅助信息和占位符。三级文字色的设计遵循了 WCAG 可访问性指南的精神——主要文字确保高对比度可读,次要文字适当降低对比度以建立视觉层次。
功能色的选择也经过了精心考量:成功色(#3DF5A0)是清新的薄荷绿,警告色(#FFD54F)是醒目的金黄色,危险色(#FF5252)是鲜明的红色。这些功能色不仅在语义上符合用户的心理预期(绿色表示通过、黄色表示警告、红色表示危险),而且在色相上与主色调和谐统一,不会显得突兀。
3.3 飞手动态数据模型

@Observed
export class PostItem {
id: number = 0
nick: string = ''
icon: string = ''
text: string = ''
likes: number = 0
comments: number = 0
tag: string = ''
time: string = ''
constructor(id: number, nick: string, icon: string, text: string,
likes: number, comments: number, tag: string, time: string) {
this.id = id; this.nick = nick; this.icon = icon; this.text = text
this.likes = likes; this.comments = comments; this.tag = tag; this.time = time
}
}
PostItem 类是飞手动态的数据模型,使用 @Observed 装饰器标记为可观察类。该类包含 8 个属性,覆盖了一条社区动态的核心信息:唯一标识(id)、发布者昵称(nick)、发布者头像图标(icon)、动态正文(text)、点赞数(likes)、评论数(comments)、内容标签(tag)和发布时间(time)。这些属性共同构成了动态卡片的完整数据来源。
@Observed 装饰器的使用值得深入分析。在 ArkUI 中,如果一个类被 @Observed 装饰,那么该类实例的属性变化可以被框架追踪。但需要注意的是,这种追踪需要配合 @ObjectLink 装饰器在子组件中使用才能生效。在本项目中,PostItem 数组通过 @State 管理,数组整体的变化(如添加、删除元素)会触发 UI 更新,但数组元素内部属性的变化(如点赞数增加)如果直接修改,可能不会触发更新。尽管如此,使用 @Observed 仍然是良好的实践——它明确表达了"这是一个数据模型类"的语义,也为后续可能的性能优化留下了空间。
构造函数的设计采用了参数赋值的简洁写法,将 8 个参数一次性赋值给实例属性。这种写法虽然紧凑,但参数数量较多时容易出错。在 TypeScript 中,可以通过参数属性(parameter properties)的方式进一步简化,但在 ArkTS 中,为了保持代码的清晰性,显式赋值仍然是推荐的做法。构造函数中的分号分隔写法在一行内完成多个赋值,是一种常见的代码压缩技巧,但也在一定程度上降低了可读性。
数据模型使用 emoji 作为图标(icon 属性),这是一个巧妙的设计选择。emoji 是 Unicode 字符,不需要额外的图片资源,可以直接通过 Text 组件渲染,既减少了包体积,又保证了跨平台的一致性。每个飞手的头像用一个与飞行相关的 emoji 表示,既直观又有趣味性。这种"emoji 优先"的图标策略在轻量级应用中非常实用,可以大幅减少资源文件的数量。
3.4 赛道数据模型
@Observed
export class RaceItem {
id: number = 0
name: string = ''
len: number = 0
turns: number = 0
width: number = 0
level: string = ''
zone: string = ''
hot: number = 0
constructor(id: number, name: string, len: number, turns: number,
width: number, level: string, zone: string, hot: number) {
this.id = id; this.name = name; this.len = len; this.turns = turns
this.width = width; this.level = level; this.zone = zone; this.hot = hot
}
}
RaceItem 类是赛道的数据模型,同样使用 @Observed 装饰器标记。这个类有一个值得注意的设计特点:它被复用于多种场景。除了表示赛道信息外,它还被用作装备商品的数据模型(GEAR_LIST)和电机参数的数据模型(MOTOR_LIST)。在不同场景下,属性的含义有所不同——例如 len 在赛道场景下表示长度(米),在装备场景下表示价格,在电机场景下表示拉力(克);turns 在赛道场景下表示弯道数,在电机场景下表示 KV 值。
这种"模型复用"的设计有利有弊。优势在于减少了类的数量,代码更加简洁,特别是当多个列表的展示逻辑相似时,可以共用渲染代码。劣势在于属性的语义变得模糊,len 到底表示长度、价格还是拉力,需要根据上下文判断,增加了理解成本。在实际项目中,如果数据结构差异较大,建议为不同场景定义独立的数据模型类,以获得更好的类型安全性和代码可读性。但在本项目这样的原型或演示项目中,模型复用是一种可以接受的权宜之计。
从赛道数据的角度来看,RaceItem 的 8 个属性涵盖了赛道的关键信息维度。level 属性表示赛道难度等级,采用 SSS、SS、S、A 等竞速游戏中常见的等级命名方式,飞手们一看就能理解。hot 属性表示赛道热度,是一个综合性指标,可能包含参赛人数、收藏数、分享数等因素。zone 属性表示赛道所在区域,便于飞手按地理位置筛选赛道。
width 属性(赛道宽度)是 FPV 竞速赛道的特色参数。赛道宽度直接影响飞行难度——越窄的赛道对飞手的操控精度要求越高。在城市穿越场景中,窄巷赛道可能只有 3 米宽,而开阔的赛道可以达到 12 米以上。将赛道宽度作为独立参数展示,体现了应用的专业性——它不仅告诉飞手"这条赛道有多少弯",还告诉飞手"这些弯有多宽",后者对飞行策略的制定同样重要。
3.5 圈速榜数据模型
@Observed
export class LapItem {
id: number = 0
rank: number = 0
flyer: string = ''
drone: string = ''
time: string = ''
best: string = ''
constructor(id: number, rank: number, flyer: string, drone: string,
time: string, best: string) {
this.id = id; this.rank = rank; this.flyer = flyer; this.drone = drone
this.time = time; this.best = best
}
}
LapItem 类是圈速榜的数据模型,用于表示排行榜中的一条记录。与 PostItem 和 RaceItem 相比,LapItem 的属性数量较少(6 个),但每一个都紧扣"竞速排行"的核心主题。rank 字段存储当前排名,flyer 是飞手名称,drone 是使用的机型,time 是当前圈速成绩,best 是历史最佳成绩。
圈速时间(time 和 best)使用字符串类型而非数字类型,这是一个值得讨论的设计选择。使用字符串的好处是可以直接格式化显示(如 “8.62s”),不需要在渲染时进行数字到字符串的转换。但缺点是失去了数值计算能力——如果需要对成绩进行排序、计算差值或统计分析,就需要先解析为数字。在实际的生产应用中,通常会将时间存储为数字类型(毫秒或秒),在展示层再进行格式化。本项目采用字符串存储可能是出于简化演示代码的考虑。
best 字段的设计体现了竞速排行榜的专业深度。当前成绩和历史最佳成绩同时展示,让用户既能看到飞手的当前状态,也能了解其实力上限。在竞速运动中,"最佳成绩"往往比"当前成绩"更能代表飞手的真实水平,因为当前成绩可能受到当天状态、设备故障等因素影响。同时展示两个成绩,也为飞手提供了自我超越的目标感——“我的当前成绩距离最佳还有多少差距”。
drone 字段(使用机型)的设置是 FPV 竞速社区区别于其他排行榜的特色。在 FPV 竞速中,装备对成绩的影响非常大,不同的机型配置可能导致显著的成绩差异。标注飞手使用的机型,一方面让排行榜信息更加透明,另一方面也为装备选择提供了参考——"用什么机型的飞手成绩最好"本身就是飞手们非常关心的话题。
3.6 训练计划数据模型
@Observed
export class TrainItem {
id: number = 0
name: string = ''
stage: string = ''
targetTime: number = 0
targetSpeed: number = 0
unit: string = ''
done: boolean = false
constructor(id: number, name: string, stage: string, targetTime: number,
targetSpeed: number, unit: string, done: boolean) {
this.id = id; this.name = name; this.stage = stage
this.targetTime = targetTime; this.targetSpeed = targetSpeed
this.unit = unit; this.done = done
}
}
TrainItem 类是训练计划的数据模型,用于训练营功能。该类包含 7 个属性,覆盖了训练项目的完整信息:训练名称(name)、难度阶段(stage)、目标时长(targetTime)、目标速度(targetSpeed)、速度单位(unit)和完成状态(done)。这些属性共同描述了一个训练任务的目标参数和当前进度。
stage 属性表示训练的难度阶段,分为"初阶"、“中阶”、"高阶"三个等级。这种分级设计符合技能提升的规律——飞手从基础动作开始练习,逐步进阶到高难度动作。每个阶段的训练项目有不同的目标参数,初阶训练时间长但速度要求低,高阶训练时间短但速度要求高。stageColor 函数根据阶段返回不同的颜色(初阶绿色、中阶青色、高阶金色),通过颜色编码强化了难度等级的视觉区分。
targetTime 和 targetSpeed 是两个核心目标参数,分别从时间维度和速度维度定义训练要求。双维度目标设定是专业训练计划的常见做法——单纯追求速度可能导致动作变形,单纯保证时间可能缺乏强度挑战,两者结合才能全面衡量训练效果。在训练计划编辑弹框中,这两个参数都是可调整的,用户可以根据自己的实际情况修改目标值,体现了训练计划的个性化和灵活性。
done 属性是一个布尔值,表示训练是否完成。这个简单的状态字段是训练营功能的核心交互点——它决定了训练项显示"✅ 完成"还是"⏳ 进行",也影响着整体训练进度的计算。完成状态的切换是用户最频繁的交互操作之一,设计上需要确保操作的便捷性和反馈的即时性。在本项目中,完成状态通过文字和颜色的变化给出清晰反馈,绿色表示完成,黄色表示进行中,语义明确。
3.7 静态数据初始化
const POST_LIST: PostItem[] = [
new PostItem(1, '穿云·老K', '🚁', '凌晨四点的机库,新桨叶首飞,收杆后竞速掉到 8.9s,目标 8.6!', 46, 12, '飞行日志', '2小时前'),
new PostItem(2, '巷口穿越机', '🛸', '桥洞连续三穿成功,悬停倒飞稳定,调高 D 值后手感终于对了。', 38, 9, '技巧分享', '5小时前'),
new PostItem(3, '闪电扳机', '⚡', '3寸机换装 2207 电机,起飞重量 286g,推力比接近 9:1,暴力!', 52, 15, '改装记录', '昨天'),
new PostItem(4, '低空研究所', '📡', '城市里 5.8G 图传干扰严重,推荐 1.6G 定制频率配定向天线,实测稳定 3 公里。', 61, 22, '图传方案', '昨天'),
new PostItem(5, '飞越天际线', '🌆', '傍晚 6 点拉锯 1.2km 返回,途中穿越废弃厂房,第一视角太震撼了。', 29, 7, '航拍记录', '2天前'),
new PostItem(6, '舵机大师', '🎮', '新到一套 ELRS 915 高频头,延迟 3.9ms,穿桥动作零延迟,爱了。', 44, 11, '装备开箱', '2天前'),
new PostItem(7, '夜航猫', '🌙', '加了 LED 灯条夜间竞速,朋友说像流星,就是续航掉了 2 分钟。', 33, 8, '夜航体验', '3天前'),
new PostItem(8, '城市滑翔者', '🏙️', '本周 27 架次训练完成,单圈最好成绩又刷新,冲进城市联赛 32 强!', 57, 18, '训练打卡', '3天前')
];
POST_LIST 常量是飞手动态的初始数据,包含 8 条预设的动态内容。这些数据不是随机生成的,而是经过精心设计的——每条动态都展示了 FPV 竞速社区的一个典型内容类型,包括飞行日志、技巧分享、改装记录、图传方案、航拍记录、装备开箱、夜航体验和训练打卡。8 条动态覆盖了社区内容的主要品类,让首次打开应用的用户就能感受到社区的活跃度和内容丰富度。
每条动态的文案都具有强烈的场景感和专业感。“凌晨四点的机库”、“桥洞连续三穿”、“调高 D 值后手感终于对了”、“推力比接近 9:1”、“ELRS 915 高频头,延迟 3.9ms”——这些细节充满了圈内黑话,让 FPV 爱好者看到后会产生强烈的认同感。对于垂直社区应用来说,内容的专业度是吸引核心用户的关键,模拟数据的质量直接影响产品演示的效果。
数据的点赞数和评论数也经过了合理的排布:最高的 61 赞 22 评论,最低的 29 赞 7 评论,形成了自然的分布。这种不均匀的分布比整齐划一的数据更有真实感。时间字段从"2小时前"到"3天前"递减,模拟了动态按时间倒序排列的真实场景。标签字段(tag)为每条动态打上了内容分类标签,便于用户筛选感兴趣的内容,也为后续的内容推荐功能打下了数据基础。
静态数据的使用方式是"初始值模式"——在组件初始化时,将 POST_LIST 赋值给 @State 装饰的 posts 变量,之后用户的操作(如发布新动态)修改的是 posts 而非 POST_LIST。这种模式确保了初始数据的纯净性,POST_LIST 作为"种子数据"始终保持不变,而 posts 作为"运行时数据"承载用户的修改。在需要重置数据的场景下,可以随时重新从 POST_LIST 初始化。
3.8 图表数据结构
interface PowerChartItem {
label: string;
value: number;
}
const POWER_CHART: PowerChartItem[] = [
{ label: 'SkyFury', value: 1520 },
{ label: 'Bolt', value: 1610 },
{ label: 'Nova', value: 980 },
{ label: 'Ghost', value: 1240 },
{ label: 'Cobra', value: 1105 }
];
interface SpeedChartItem {
label: string;
value: number;
}
const SPEED_CHART: SpeedChartItem[] = [
{ label: '老K', value: 62 },
{ label: '滑翔', value: 58 },
{ label: '扳机', value: 55 },
{ label: '夜航', value: 49 },
{ label: '低空', value: 52 }
];
图表数据模块定义了两种图表数据结构:PowerChartItem(电机拉力图)和 SpeedChartItem(飞手速度图)。两者具有相同的结构——都包含 label(标签)和 value(数值)两个字段,但分别定义了独立的接口。这种"结构相同但语义不同"的接口设计,是类型安全和语义清晰之间的权衡。如果只定义一个通用的 ChartItem 接口,代码会更简洁,但会失去类型层面的语义区分;分别定义则增加了代码量,但每个接口的用途更加明确。
POWER_CHART 数据展示了 5 款电机的拉力对比,单位是克(g)。数据范围从 980g 到 1610g,覆盖了从中阶到高端的电机拉力区间。柱状图的高度通过 barH 函数计算,以最大值 1610 为基准,将数值映射到 10-98 的高度百分比范围。这种"归一化"的柱状图计算方式是数据可视化中的基础技术——将原始数据值映射到像素高度,确保最大的数据项占满可用高度,其他项按比例缩放。
SPEED_CHART 数据展示了 5 位飞手的平均速度对比,单位是 km/h。数据范围从 49 到 62,差距不大但足以体现水平差异。这个数据被用在两个地方:精选页面的"阵营热度"横向条形图,以及圈速榜页面的速度柱状图。同一组数据以不同的图表形式在不同页面展示,既保证了数据的一致性,又为不同场景提供了最合适的可视化方式。
图表数据与业务数据(如 MOTOR_LIST、LAP_LIST)是分离的,这种设计有其合理性。图表数据是为可视化服务的摘要数据,通常经过聚合、筛选和排序等处理,与原始业务数据的结构不同。将图表数据独立定义,可以使得图表渲染组件只关注"如何画柱状图",而不需要关心"数据从哪里来"。在实际应用中,图表数据通常由后端接口提供聚合结果,前端只负责渲染,这种前后端分离的设计在本项目的静态数据中也有所体现。
3.9 导航配置常量
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🏁', label: '首页' },
{ icon: '🛣️', label: '赛道' },
{ icon: '🔧', label: '装备' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: NavItem[] = [
{ icon: '⭐', label: '精选' },
{ icon: '🛣️', label: '穿越赛道' },
{ icon: '⏱️', label: '圈速榜' },
{ icon: '⚙️', label: '电机图鉴' },
{ icon: '🛒', label: '图传商城' },
{ icon: '💬', label: '飞手圈' },
{ icon: '📋', label: '训练营' }
];
导航配置是应用信息架构的代码化表达。NavItem 接口定义了导航项的基本结构——图标(icon)加标签(label),简洁而通用。两个导航列表分别对应底部主导航(4 项)和首页内容导航(7 项)。导航项的顺序决定了 Tab 的排列顺序,数组的索引值与选中状态(mainTab、subTab)一一对应,这种索引驱动的导航切换方式实现简单、性能高效。
主导航的 4 个 Tab 遵循了经典的移动端应用导航模式:首页(内容聚合)、赛道(核心功能)、装备(商业功能)、我的(个人中心)。这种"内容-功能-商业-个人"的四 Tab 结构在移动应用中非常普遍,用户已经形成了使用习惯。图标选择也很有讲究——方格旗代表首页或开始,公路代表赛道,扳手代表装备或工具,人头代表个人中心,每个图标都能直观传达对应的功能含义。
子导航的 7 个 Tab 是首页内容的二级分类,涵盖了 FPV 社区的主要内容品类:精选(内容推荐)、穿越赛道(赛道库)、圈速榜(排行榜)、电机图鉴(装备参数)、图传商城(电商)、飞手圈(社区)、训练营(训练管理)。7 个 Tab 的排列采用两排 4+3 的布局,第一排 4 个、第二排 3 个。这种非对称布局在视觉上更有节奏感,也避免了一排 7 个 Tab 过于拥挤的问题。在代码实现中,通过 Row 组件的 justifyContent(FlexAlign.SpaceBetween) 实现均匀分布。
导航数据与导航 UI 的分离是一种良好的设计实践。导航项存储为数据数组(NAV_LIST、SUB_NAV_LIST),UI 通过 ForEach 循环渲染。如果需要调整导航顺序、增加或删除导航项,只需要修改数据数组即可,不需要修改渲染逻辑。这种数据驱动的思想贯穿了整个声明式 UI 范式——数据是真相的来源,UI 只是数据的可视化映射。
3.10 动画计算纯函数
function propAngle(tick: number, phase: number): number {
return (tick * 26 + phase) % 360;
}
function trailGlow(glow: number, i: number): number {
return 0.25 + ((glow * 37 + i * 53) % 100) / 100 * 0.65;
}
propAngle 和 trailGlow 是两个核心的动画计算函数,分别用于螺旋桨旋转和赛道光带流动特效。这两个函数都是纯函数——相同的输入永远产生相同的输出,没有副作用。纯函数式的动画计算使得动画逻辑与组件状态完全解耦,易于理解、测试和复用。
propAngle 函数计算螺旋桨的旋转角度。参数 tick 是帧计数器,每 60ms 递增 1;参数 phase 是相位偏移,用于让多个螺旋桨以不同的起始角度旋转,避免同步旋转的机械感。计算逻辑是:tick 乘以角速度 26(每帧旋转 26 度),加上相位偏移,然后对 360 取模,确保角度在 0-360 度范围内循环。每帧 26 度 × 约 16.7 fps ≈ 每秒 434 度,即约 1.2 转/秒,这个速度对于装饰性的螺旋桨特效来说恰到好处——足够动感但不会让人眩晕。
trailGlow 函数计算赛道光带中每个光点的透明度。参数 glow 是另一个帧计数器(与 tick 同步递增),参数 i 是光点索引。计算逻辑相对复杂:先计算 (glow * 37 + i * 53) % 100 得到一个 0-99 的值,除以 100 得到 0-1 的比例,乘以 0.65 得到最大透明度增量,再加上基础透明度 0.25。最终的透明度范围是 0.25 到 0.90。37 和 53 这两个质数系数确保了不同光点的亮度变化不同步,创造出流动的视觉效果——当 glow 递增时,每个光点的透明度以不同的节奏变化,整体呈现出光带向前流动的感觉。
这两个函数的设计体现了"程序化动画"的思想。相比于预定义的关键帧动画,程序化动画通过数学公式实时计算每一帧的状态,具有无限循环、参数可控、内存占用小等优势。特别是对于需要连续运行的装饰性特效来说,程序化动画是一种非常高效的实现方式。在 ArkUI 中,由于状态驱动的渲染机制,程序化动画天然适合——只需要更新状态变量,框架会自动重新计算并渲染动画元素。
3.11 颜色映射工具函数
function lapColor(i: number): string {
if (i === 0) { return '#FFD54F' }
if (i === 1) { return '#B0BEC5' }
if (i === 2) { return '#FF8A65' }
return '#3E4A66';
}
function levelColor(level: string): string {
if (level === 'SSS') { return '#FF3D71' }
if (level === 'SS') { return '#FF8A65' }
if (level === 'S') { return '#FFD54F' }
return '#3DF5A0';
}
function stageColor(stage: string): string {
if (stage === '初阶') { return '#3DF5A0' }
if (stage === '中阶') { return '#00E5FF' }
return '#FFD54F';
}
颜色映射函数是数据到视觉的翻译层。lapColor、levelColor、stageColor 三个函数分别将排名索引、赛道等级、训练阶段映射为对应的颜色值。这些函数是数据可视化的基础——它们将抽象的数据属性转化为用户可以直观感知的颜色编码,大幅提升了信息的读取效率。
lapColor 函数为排行榜的前三名分别赋予金、银、铜三色,第四名及以后使用灰蓝色。这种配色直接对应着竞技体育中的奖牌体系,用户无需阅读文字就能快速识别排名等次。值得注意的是,函数的输入是排名索引(从 0 开始)而非排名数字(从 1 开始),这是因为在 ForEach 循环中通常使用索引来调用此函数。这种设计与使用场景紧密匹配,减少了调用时的转换工作。
levelColor 函数将赛道难度等级映射为颜色:SSS 级(最高难度)用玫红色,SS 级用橙红色,S 级用金黄色,A 级及以下用绿色。颜色从"危险"的红橙色调过渡到"安全"的绿色调,与难度等级的心理预期一致——越难的赛道用越"警示"的颜色。这种颜色与难度的对应关系在游戏和竞技场景中非常普遍,用户已经形成了认知习惯。
stageColor 函数将训练阶段映射为颜色:初阶用绿色(表示入门、安全),中阶用青色(表示进阶、专业),高阶用金色(表示高级、荣誉)。这种颜色递进既符合技能提升的心理感受(从新手到高手),也与应用的主色调体系保持一致。绿色是成功色,青色是主色,金色是奖牌色,三种颜色各有含义又和谐统一。
3.12 图标背景哈希函数
function iconBg(icon: string): string {
let sum = 0;
for (let i = 0; i < icon.length; i++) { sum += icon.charCodeAt(i); }
const c = sum % 3;
if (c === 0) { return '#24304F' }
if (c === 1) { return '#3A2440' }
return '#243E3A';
}
function motorBg(i: number): string {
if (i === 0) { return '#123B45' }
if (i === 1) { return '#45122B' }
return '#232742';
}
iconBg 函数是一个精巧的设计——它通过计算 emoji 图标的 Unicode 编码之和并对 3 取模,来决定图标的背景色。这种"确定性哈希"的方式有几个显著优势:第一,同一个图标永远对应同一个背景色,保证了视觉一致性;第二,不同的图标有很大概率获得不同的背景色,增加了列表的视觉多样性;第三,不需要为每个图标单独配置背景色,减少了数据配置的工作量。
函数的实现原理很简单:遍历图标的每个字符(emoji 可能由多个 Unicode 码点组成),累加每个字符的 charCode 值,然后对 3 取模得到 0、1 或 2,分别对应三种背景色。三种背景色分别是蓝色调(#24304F)、紫色调(#3A2440)和绿色调(#243E3A),都是深色系的低饱和度颜色,与整体的深色主题协调,同时又有足够的色相差异来区分不同的图标。
motorBg 函数则采用了更简单的策略——前两条电机记录使用特殊的强调背景色(青色和玫红色调),第三条及以后使用默认背景色。这种设计的目的是突出排行榜前两名的电机,营造"冠亚军"的视觉效果。第一条用青色(主色)背景,第二条用玫红色(强调色)背景,与整体配色体系呼应,同时也让电机列表的前两项更加醒目,引导用户关注顶级产品。
这两个背景色函数虽然实现简单,但在设计思路上体现了"用程序生成设计"的理念。相比于为每一项手动指定背景色,程序化生成的方式更加高效和一致,也为大规模数据列表提供了可扩展的视觉区分方案。在数据量很大的场景下,这种策略的优势会更加明显。
3.13 主组件状态定义
@Entry
@Component
struct Page {
@State mainTab: number = 0;
@State subTab: number = 0;
@State tick: number = 0;
@State glow: number = 0;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State addNick: string = '';
@State addText: string = '';
@State editIdx: number = -1;
@State editTime: string = '';
@State editSpeed: string = '';
@State delTarget: string = '';
@State delId: number = -1;
@State nextId: number = 100;
@State posts: PostItem[] = POST_LIST;
@State trains: TrainItem[] = TRAIN_LIST;
@State gearCards: RaceItem[] = GEAR_LIST;
@State fxTimer: number = -1;
主组件 Page 的状态定义是整个应用的状态中枢,共定义了 19 个 @State 变量。这些变量可以分为几类:导航状态(mainTab、subTab)、动画状态(tick、glow)、弹框状态(addModal、editModal、delModal)、弹框表单数据(addNick、addText、editIdx、editTime、editSpeed、delTarget、delId)、ID 计数器(nextId)、列表数据(posts、trains、gearCards)和定时器句柄(fxTimer)。
导航状态只有两个变量:mainTab 控制底部 4 个主 Tab 的切换,subTab 控制首页 7 个内容 Tab 的切换。两个索引型变量就能驱动整个应用的页面路由,这种简单直接的状态设计得益于单组件架构——所有页面都在同一个组件中,通过条件渲染切换显示,不需要复杂的路由系统。对于功能模块不太多的应用,这种"索引 + 条件渲染"的导航方式实现成本极低,运行效率也很高。
弹框状态的设计采用了"三布尔变量"模式——每个弹框对应一个布尔状态变量。这种模式的好处是每个弹框的显示状态独立明确,控制简单。但弹框数量较多时,状态变量会随之增加。另一种常见的模式是使用一个枚举类型的状态变量(如 activeModal: ModalType | null),通过枚举值标识当前显示哪个弹框。两种模式各有优劣,三布尔模式更直观,枚举模式更简洁。在弹框数量不多(3 个)的情况下,三布尔模式是合理的选择。
列表数据(posts、trains、gearCards)是从顶层常量初始化的。初始化时使用 POST_LIST 等常量直接赋值,这意味着初始状态下这些数组引用的是常量数组。需要注意的是,在 ArkTS 中,如果直接修改 @State 数组的内部元素(如 this.posts[0].likes++),不会触发 UI 更新;必须通过数组方法修改数组本身的引用(如 unshift、filter 返回新数组)才能触发更新。本项目中的所有数据修改操作都遵循了这一规则。
3.14 生命周期与特效定时器
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = (this.tick + 1) % 720;
this.glow = (this.glow + 1) % 720;
}, 60);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
生命周期钩子函数是组件与运行时环境交互的关键节点。aboutToAppear 在组件即将显示时调用,aboutToDisappear 在组件即将消失时调用。这两个函数通常用于资源的申请和释放——在组件显示前启动定时器、注册事件监听、请求数据;在组件消失后清除定时器、取消监听、释放资源,防止内存泄漏。
特效定时器的实现非常简洁。setInterval 每 60 毫秒执行一次回调,将 tick 和 glow 各递增 1,然后对 720 取模。720 这个数值的选择有其考量——它是 360 的 2 倍,也是 24 的 30 倍,有很多因数,可以被多种动画周期整除。取模操作确保了数值不会无限增长,避免了数值溢出的风险(虽然在 JavaScript/ArkTS 中数值溢出不是严重问题,但保持数值在合理范围内仍然是良好的实践)。
60 毫秒的间隔意味着约 16.7 fps 的动画帧率。这个帧率对于装饰性特效来说是一个平衡点——足够流畅以营造动感,但又不会消耗过多的系统资源。相比于 60fps(每帧 16.7ms)的全动画标准,16.7fps 只需要约 1/4 的计算量和渲染量。对于螺旋桨旋转和光带流动这类非关键动画,降低帧率是一种有效的性能优化手段。
aboutToDisappear 中的清理逻辑非常重要。如果不在组件销毁时清除定时器,定时器会继续运行,导致内存泄漏和性能问题。判断条件 if (this.fxTimer > 0) 确保了只有在定时器存在的情况下才执行清除操作,避免了对无效句柄调用 clearInterval。清理后将 fxTimer 重置为 -1,这是一种防御性编程——即使 aboutToDisappear 被多次调用,也不会出现问题。
3.15 发布动态业务逻辑
openAdd(): void {
this.addNick = '';
this.addText = '';
this.addModal = true;
}
doAdd(): void {
if (this.addText.length === 0) { return }
this.posts.unshift(new PostItem(this.nextId,
this.addNick.length > 0 ? this.addNick : '穿云飞手',
'🚁', this.addText, 0, 0, '新动态', '刚刚'));
this.nextId++;
this.addModal = false;
}
发布动态功能包含两个方法:openAdd 打开弹框,doAdd 执行发布操作。这两个方法的职责划分很清晰:openAdd 负责准备弹框状态(重置表单、显示弹框),doAdd 负责业务逻辑(数据校验、创建新动态、更新列表、关闭弹框)。这种"打开-执行"的两阶段模式是弹框交互的标准范式,在本项目的三个弹框中都采用了类似的结构。
openAdd 方法在打开弹框前先重置表单数据(addNick 和 addText 清空),确保每次打开弹框时都是空白状态,不会残留上一次的输入内容。这是一个容易被忽视但影响用户体验的细节——如果不重置表单,用户上次输入但未提交的内容会保留,可能造成困惑。对于编辑类弹框,重置逻辑会更复杂(需要加载待编辑的数据),但对于新增类弹框,清空是标准做法。
doAdd 方法中有几个值得关注的实现细节。首先是数据校验:if (this.addText.length === 0) { return } 确保了动态内容不能为空,但昵称可以为空(为空时使用默认昵称"穿云飞手")。其次是新动态的创建:使用 new PostItem() 构造新实例,ID 使用自增的 nextId,图标使用默认的 🚁,点赞和评论数初始化为 0,标签设为"新动态",时间设为"刚刚"。第三是插入位置:使用 unshift 将新动态添加到数组开头,确保最新发布的动态显示在列表最顶部。
nextId 自增计数器是客户端生成唯一 ID 的简单方案。在纯前端演示项目中,这种方式完全够用——只要保证在当前会话内 ID 唯一即可。但在实际生产环境中,ID 通常由后端服务生成(如数据库自增 ID 或 UUID),客户端提交数据时不携带 ID,由后端分配后返回。前端使用 nextId 的场景通常是乐观更新——先在本地创建一条带临时 ID 的记录,待后端返回真实 ID 后再替换。
3.16 编辑训练计划业务逻辑
openEdit(index: number): void {
this.editIdx = index;
const t = this.trains[index];
if (t) {
this.editTime = t.targetTime.toString();
this.editSpeed = t.targetSpeed.toString();
}
this.editModal = true;
}
doEdit(): void {
if (this.editIdx < 0 || this.editIdx >= this.trains.length) { return }
const nt = Number(this.editTime);
const ns = Number(this.editSpeed);
if (nt <= 0 || ns <= 0) { return }
this.trains[this.editIdx].targetTime = nt;
this.trains[this.editIdx].targetSpeed = ns;
this.editModal = false;
}
编辑训练计划功能同样遵循"打开-执行"的两阶段模式。openEdit 方法接收一个索引参数,根据索引找到对应的训练项,将其目标时长和目标速度转换为字符串后存入编辑表单状态,然后显示弹框。这里有一个细节:使用 if (t) 进行空值检查,防止索引越界导致的运行时错误。虽然在正常调用路径下索引应该总是有效的,但防御性编程能够应对异常情况。
doEdit 方法执行编辑操作,包含多重校验。第一层校验是索引范围检查:editIdx 必须在有效范围内,否则直接返回。第二层校验是数值有效性检查:将字符串转换为数字后,必须大于 0。这两层校验确保了数据的基本有效性,防止非法数据写入状态。校验通过后,直接修改数组元素的属性值,然后关闭弹框。
这里有一个需要注意的技术点:直接修改 this.trains[this.editIdx].targetTime 是否会触发 UI 更新?在 ArkUI 中,如果 trains 是用 @State 装饰的数组,而数组元素是 @Observed 装饰的类对象,那么修改对象的属性是否触发更新取决于具体情况。在本项目中,由于 TrainItem 类使用了 @Observed 装饰器,理论上其属性变化应该能被追踪。但在实际运行中,这种深层属性变化的触发机制可能受到多种因素影响。如果发现修改属性后 UI 不更新,可以通过数组整体替换的方式(如 this.trains = [...this.trains])来强制触发更新。
表单数据使用字符串类型存储(editTime、editSpeed),而不是数字类型,这是因为 TextInput 组件的输入值是字符串。在提交时再转换为数字,这种"输入用字符串、存储用数字"的模式是表单处理的常见做法。TextInput 的 onChange 回调接收字符串参数,直接存入字符串状态是最自然的方式,避免了每次输入都进行类型转换的开销。
3.17 删除确认业务逻辑
openDel(target: string, id: number): void {
this.delTarget = target;
this.delId = id;
this.delModal = true;
}
doDel(): void {
if (this.delTarget === 'train') {
this.trains = this.trains.filter((t: TrainItem) => t.id !== this.delId);
} else if (this.delTarget === 'gear') {
this.gearCards = this.gearCards.filter((g: RaceItem) => g.id !== this.delId);
}
this.delModal = false;
}
删除功能是三个弹框中最灵活的——同一个删除确认弹框可以服务于多种删除场景。openDel 方法接收两个参数:target 表示删除类型(‘train’ 或 ‘gear’),id 表示要删除的记录 ID。这种"目标类型 + ID"的设计模式使得一个删除弹框可以复用在多种场景下,避免了为每种删除操作单独创建弹框的重复代码。
doDel 方法根据 delTarget 的值执行不同的删除操作。如果是训练计划,就从 trains 数组中过滤掉指定 ID 的项;如果是装备,就从 gearCards 数组中过滤掉指定 ID 的项。使用 filter 方法返回新数组是正确的做法——它不会修改原数组,而是创建一个新数组,将新数组赋值给 @State 变量后触发 UI 更新。这种不可变数据(Immutable Data)的操作方式是声明式 UI 中状态更新的推荐模式。
删除操作使用 ID 而非索引作为定位依据,这是一个重要的设计选择。相比于索引,ID 是数据的固有属性,不受数组排序、筛选等操作的影响,更加稳定可靠。特别是在数据可能动态变化的场景下,使用索引可能导致"删错项"的 bug。例如,如果在用户点击删除按钮和确认删除之间,数组发生了变化(如新增了一项),那么原来的索引就不再对应正确的数据项。而使用 ID 则不会有这个问题。
删除确认弹框的文案也是根据 delTarget 动态变化的——训练计划显示"确认删除该训练计划?删除后训练记录将不可恢复",装备显示"确认下架该装备?下架后商品将移出我的机架"。这种上下文相关的文案提升了用户体验,让用户明确知道自己在执行什么操作、会产生什么后果。危险操作的二次确认是交互设计的基本原则,特别是对于不可逆的删除操作。
3.18 特效层 Builder
@Builder
fxLayer() {
Stack() {
// 螺旋桨旋转组(右上)
Row({ space: 8 }) {
ForEach([0, 1, 2, 3], (i: number) => {
Text('🌀')
.fontSize(22)
.rotate({ angle: propAngle(this.tick, i * 47) })
}, (i: number) => i.toString())
}
.position({ x: 300, y: 180 })
.hitTestBehavior(HitTestMode.None)
// 赛道光带流动(底部)
Row() {
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
Text('●')
.fontSize(12)
.fontColor(COLORS.primary)
.opacity(trailGlow(this.glow, i))
.margin({ left: i * 22 })
}, (i: number) => i.toString())
}
.position({ x: 24, y: 420 })
.hitTestBehavior(HitTestMode.None)
// 光带2
Row() {
ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
Text('●')
.fontSize(10)
.fontColor(COLORS.accent)
.opacity(trailGlow(this.glow + 30, i))
.margin({ left: i * 18 })
}, (i: number) => i.toString())
}
.position({ x: 200, y: 380 })
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height('100%')
}
特效层(fxLayer)是应用视觉效果的核心实现。它使用 Stack 作为容器,内部包含三组独立的动画元素:螺旋桨旋转组、第一赛道光带和第二赛道光带。Stack 布局使得这些元素可以自由定位在页面的任意位置,而不受正常文档流的约束。每组动画元素都通过 .position() 方法精确定位,并设置 .hitTestBehavior(HitTestMode.None) 以穿透触摸事件,确保特效不会影响用户与下方内容的交互。
螺旋桨旋转组由 4 个螺旋桨 emoji(🌀)组成,水平排列,间距 8px。每个螺旋桨的旋转角度通过 propAngle(this.tick, i * 47) 计算,其中相位偏移为 i * 47 度。47 度的相位差使得四个螺旋桨的旋转不同步,呈现出更加自然的旋转效果。如果相位差为 0,四个螺旋桨会同向同速旋转,看起来像一个整体,缺乏层次感;而 47 度的质数相位差确保了每个螺旋桨的角度都不相同,视觉上更加丰富。
第一赛道光带由 6 个圆点组成,使用青色(COLORS.primary),每个圆点的透明度通过 trailGlow(this.glow, i) 计算。圆点之间通过 margin({ left: i * 22 }) 实现间距——每个圆点的左边距等于其索引乘以 22px。这种累积式间距的写法与使用 Row 的 space 参数效果类似,但提供了更精细的控制。当 glow 状态变化时,每个圆点的透明度以不同的节奏变化,整体呈现出光带流动的视觉效果。
第二赛道光带使用了玫红色(COLORS.accent),尺寸稍小(10px),位置也不同。它的透明度计算使用 trailGlow(this.glow + 30, i),即在 glow 的基础上加了 30 的相位偏移。这个相位偏移确保了两束光带的流动效果不同步——青色光带和玫红色光带以略微不同的节奏流动,增加了视觉的层次感和复杂度。如果两束光带完全同步,视觉效果会显得单调和机械。
.hitTestBehavior(HitTestMode.None) 是一个非常重要但容易被忽视的属性。它设置了组件的命中测试行为为"不响应",即该组件不参与触摸事件的命中检测,触摸事件会直接穿透到下方的组件。对于装饰性特效来说,这个属性是必需的——如果特效层拦截了触摸事件,用户就无法与下方的按钮、列表等交互元素进行操作。在 ArkUI 中,Stack 布局的子组件默认会拦截触摸事件,因此显式设置穿透模式是必要的。
3.19 头部 Builder
@Builder
header() {
Column() {
Row() {
Text('穿云 FPV')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('竞技 · 穿越 · 航拍')
.fontSize(11)
.fontColor(COLORS.hint)
.margin({ left: 8, top: 6 })
Row() {
Text('🔔')
.fontSize(16)
Text('🏆')
.fontSize(16)
.margin({ left: 14 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
}
.width('100%')
Row({ space: 10 }) {
Text('🔍')
.fontSize(14)
Text('搜索机型 / 赛道 / 飞手…')
.fontSize(13)
.fontColor(COLORS.hint)
Text('📷')
.fontSize(15)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.height(38)
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(19)
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 18, right: 18, top: 16, bottom: 14 })
.backgroundColor(COLORS.bg)
}
头部(header)是应用的品牌展示区和全局操作区,采用上下两行布局。第一行是标题栏,左侧是应用名称"穿云 FPV"加副标题"竞技 · 穿越 · 航拍",右侧是通知和排行榜两个功能入口图标。第二行是搜索栏,提供机型、赛道、飞手的全站搜索功能,右侧还有一个扫码或拍照入口。整个头部的背景色与页面背景一致,与下方内容区域形成视觉上的连贯。
标题行的布局体现了"主标题 + 副标题 + 右侧操作"的经典模式。应用名称使用 22px 粗体白色字,是头部最醒目的元素;副标题使用 11px 灰色字,字号小、颜色淡,作为补充信息存在,不抢夺主标题的注意力。右侧的两个图标使用 layoutWeight(1) + justifyContent(FlexAlign.End) 的组合推到最右边,这是 ArkUI 中将内容推到行尾的标准做法——中间的弹性容器占据所有剩余空间,内部内容右对齐。
搜索栏的设计有几个细节值得关注。首先,高度设为 38px,圆角设为 19px(即高度的一半),形成了完全的圆角矩形(胶囊形状),这是移动端搜索框的常见造型。其次,搜索图标和提示文字左对齐,右侧的相机图标右对齐,通过 layoutWeight(1) 实现两端对齐的效果。第三,搜索框的背景色使用 cardBg(卡片背景色),比页面背景稍亮,形成了可点击的视觉暗示。虽然这里的搜索框只是静态展示(没有绑定输入事件),但其视觉设计已经完整传达了搜索功能的含义。
头部的内边距设置也很讲究:左右各 18px,顶部 16px,底部 14px。顶部比底部多 2px 的不对称设计,是为了在视觉上平衡状态栏的空间——手机屏幕顶部有状态栏(显示时间、电量等),头部内容需要与状态栏保持一定距离,而底部与内容区的距离可以稍小。这种细微的间距调整虽然用户可能不会明确意识到,但会在潜意识中觉得界面更加舒适协调。
3.20 内容导航 Builder
@Builder
subNav() {
Row() {
ForEach(SUB_NAV_LIST, (item: NavItem, i: number) => {
Column({ space: 3 }) {
Text(item.icon)
.fontSize(16)
Text(item.label)
.fontSize(10)
.fontColor(this.subTab === i ? COLORS.primary : COLORS.subTitle)
}
.width(76)
.padding({ top: 7, bottom: 7 })
.borderRadius(12)
.backgroundColor(this.subTab === i ? COLORS.cardBg2 : COLORS.cardBg)
}, (item: NavItem, i: number) => item.label + i.toString())
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.justifyContent(FlexAlign.SpaceBetween)
.backgroundColor(COLORS.bg)
}
内容导航(subNav)是首页的二级导航栏,展示 7 个内容 Tab。每个导航项采用"图标在上、文字在下"的垂直布局,宽度固定为 76px,选中态使用主色文字和次级卡片背景,未选中态使用副标题色文字和默认卡片背景。导航项之间通过 Row 的 justifyContent(FlexAlign.SpaceBetween) 均匀分布在整行宽度内。
选中态的视觉反馈通过条件表达式实现:this.subTab === i ? COLORS.primary : COLORS.subTitle 控制文字颜色,this.subTab === i ? COLORS.cardBg2 : COLORS.cardBg 控制背景色。选中项同时改变文字颜色和背景色,提供了双重视觉反馈,确保用户能够清晰识别当前所在的 Tab。相比于只改变文字颜色的设计,双色变化的选中反馈更加明确,在深色主题下尤为重要——单一颜色变化可能不够醒目。
导航项的 key 生成函数使用了 item.label + i.toString() 的组合策略。由于导航项的 label 是唯一的(7 个 Tab 的名称各不相同),理论上只用 label 作为 key 就足够了。但加上索引值作为后缀是一种保险策略——即使未来出现了 label 重复的情况(比如修改导航配置时的疏忽),key 仍然是唯一的,不会导致 ForEach 渲染异常。这种"双重保险"的 key 设计在数据量不大时不会有性能损失,却能提高代码的健壮性。
导航栏的背景色与页面背景相同(COLORS.bg),但导航项本身有独立的背景色。这种"透明导航栏 + 不透明导航项"的设计使得导航栏与页面背景融为一体,而导航项又有足够的视觉存在感。12px 的圆角给导航项增添了圆润感,与整体的圆角设计语言保持一致。上下各 8px 的内边距为导航栏提供了呼吸空间,避免导航项过于贴近边缘。
3.21 精选页顶部区域
@Builder
feedTop() {
Column() {
Row() {
Column({ space: 4 }) {
Text('本周城市联赛 · 32强')
.fontSize(12)
.fontColor(COLORS.primary)
Text('穿云·老K 领先 0.42s')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('决赛圈:滨江港区 · 周六 19:00')
.fontSize(11)
.fontColor(COLORS.hint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🏆')
.fontSize(44)
}
.width('100%')
.padding(16)
.backgroundColor('#1E2545')
.borderRadius(16)
精选页顶部区域(feedTop)是首页的视觉焦点,包含一个赛事横幅、三个数据统计卡片和两个数据图表。赛事横幅是最上方的亮点区域,采用深蓝色背景(#1E2545),与页面的深紫色背景形成微妙的色调差异,营造出"特别推荐"的视觉感受。横幅左侧是赛事信息,采用三级文字结构——青色标签、白色标题、灰色辅助信息——信息层次分明。
赛事信息的文字排版是一个很好的信息层级设计案例。"本周城市联赛 · 32强"是分类标签,使用主色青色,字号 12px,告诉用户这是什么类型的内容;"穿云·老K 领先 0.42s"是核心信息,使用白色粗体,字号 18px,是整条横幅中最醒目的文字,一眼就能抓住用户注意力;"决赛圈:滨江港区 · 周六 19:00"是补充信息,使用灰色,字号 11px,提供时间地点等细节。三级文字的字号、颜色、粗细各不相同,形成了清晰的视觉层次,用户可以按照重要性依次读取。
右侧的奖杯 emoji(🏆)使用 44px 的大字号,作为横幅的视觉锚点。大尺寸的图标在视觉上有很强的吸引力,与左侧的文字形成图文搭配,避免了纯文字的单调感。奖杯图标与赛事主题高度契合,强化了"竞技"和"荣誉"的氛围。在移动端设计中,合理使用大尺寸图标可以有效提升界面的视觉冲击力和趣味性。
横幅的 16px 圆角与整体设计语言一致,背景色 #1E2545 是一个精心调配的深蓝色——比默认的卡片背景更有"专属感",暗示这是一个特殊的、重要的内容区块。这种"特殊区块使用特殊背景色"的设计手法在内容型应用中非常常见,它通过视觉差异来传达内容的重要性差异,引导用户的注意力流向。
3.22 数据统计卡片与图表
Row({ space: 10 }) {
Column({ space: 4 }) {
Text('累计飞行')
.fontSize(10)
.fontColor(COLORS.hint)
Text('1,286 圈')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
Column({ space: 4 }) {
Text('本周里程')
.fontSize(10)
.fontColor(COLORS.hint)
Text('342 km')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
Column({ space: 4 }) {
Text('坠机次数')
.fontSize(10)
.fontColor(COLORS.hint)
Text('37 次')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
.width('100%')
.margin({ top: 12 })
数据统计卡片采用三列等宽布局,展示累计飞行圈数、本周里程和坠机次数三个核心指标。每个卡片都是"标签 + 数值"的上下结构,标签使用小号灰色字,数值使用大号粗体白色字。三个卡片通过 layoutWeight(1) 平均分配行宽,间距 10px,整体呈现出整齐的网格感。这种数据卡片组是数据仪表盘类界面的基本单元,简洁、直观、信息密度适中。
第三个卡片"坠机次数"的数值使用了强调色(COLORS.accent),这是一个很有意思的设计决策。坠机次数是一个"负面指标",用醒目的玫红色突出显示,既符合危险或警示的语义,又在视觉上形成了对比——前两个正面指标是白色,第三个负面指标是红色,用户一眼就能区分。如果三个数据都用白色,虽然整齐但缺乏重点;用颜色来区分指标性质,是提升信息传达效率的有效手段。
每个卡片的内边距为 12px,圆角为 12px,背景色为 cardBg。这些尺寸参数与整体设计系统保持一致,体现了设计令牌的价值——所有组件使用统一的间距和圆角参数,确保了视觉风格的一致性。.alignItems(HorizontalAlign.Start) 让卡片内容左对齐,而不是居中对齐,这更符合文字的阅读习惯——用户从左到右阅读,左对齐的文字更容易快速扫描。
图表区域包含两个并排的图表:左侧是电机拉力对比柱状图,右侧是阵营热度横向条形图。两个图表各占一半宽度,构成一个 2 列的图表网格。柱状图使用垂直柱形展示 5 款电机的拉力数据,柱高通过 barH 函数计算,颜色统一使用主色青色。条形图使用水平条展示 5 位飞手的热度数据,条宽同样通过 barH 函数计算,颜色使用强调色玫红。两个图表一竖一横、一青一红,形成了有趣的视觉对比。
3.23 飞手动态列表
ForEach(this.posts.slice(0, 5), (p: PostItem, i: number) => {
Row() {
Text(p.icon)
.fontSize(30)
.width(44)
.textAlign(TextAlign.Center)
.padding({ top: 6, bottom: 6 })
.backgroundColor(iconBg(p.icon))
.borderRadius(22)
Column({ space: 4 }) {
Row() {
Text(p.nick)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text(p.time)
.fontSize(10)
.fontColor(COLORS.hint)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
Text(p.text)
.fontSize(12)
.fontColor(COLORS.subTitle)
.lineHeight(18)
.maxLines(2)
Row({ space: 16 }) {
Text('❤️ ' + p.likes)
.fontSize(10)
.fontColor(COLORS.hint)
Text('💬 ' + p.comments)
.fontSize(10)
.fontColor(COLORS.hint)
Text(p.tag)
.fontSize(9)
.fontColor(COLORS.primary)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (p: PostItem, i: number) => p.id.toString() + i.toString())
飞手动态列表是社区内容的核心展示形式,每条动态采用左侧头像 + 右侧内容的经典布局。头像使用 emoji 图标,30px 字号,44px 宽度,圆形背景(22px 圆角),背景色通过 iconBg 函数根据图标计算得出。右侧内容区域包含三行:第一行是昵称和时间,第二行是动态正文,第三行是点赞数、评论数和标签。
正文文字设置了 .maxLines(2) 和 .lineHeight(18),限制最多显示两行,超出部分会被省略。这是列表类界面的标准做法——如果每条动态都展示完整内容,列表会变得很长,用户浏览效率低下。两行的限制既能展示足够的内容让用户判断是否感兴趣,又能保持列表的紧凑性。行高 18px 对应 12px 的字号,行高比约为 1.5,是阅读体验较好的行高比例。
底部操作行包含三个元素:点赞数、评论数和标签。点赞和评论使用相同的样式(灰色小字 + emoji 前缀),间距 16px。标签使用主色青色,字号更小(9px),通过 layoutWeight(1) 推到最右边。标签的右对齐布局使得操作行形成"左互动数据 + 右内容标签"的结构,信息分类清晰。标签使用主色突出显示,强化了内容分类的存在感,也为用户提供了按标签筛选的视觉暗示。
动态列表在精选页只显示前 5 条(this.posts.slice(0, 5)),而在飞手圈页面显示全部。这种"首页摘要 + 全量页面"的模式是内容型应用的常见设计——首页展示最热门或最新的几条内容,吸引用户点击进入专门的页面查看更多。切片操作是轻量级的,不会修改原数组,符合不可变数据的最佳实践。
3.24 赛道列表页
@Builder
pageRace() {
Scroll() {
Column({ space: 12 }) {
Text('🛣️ 城市穿越赛道库')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
.width('100%')
ForEach(this.races(), (r: RaceItem) => {
Row() {
Column({ space: 5 }) {
Text(r.level)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(r.level))
Text(r.zone)
.fontSize(9)
.fontColor(COLORS.hint)
}
.width(64)
.alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#20233B')
.borderRadius(10)
Column({ space: 4 }) {
Text(r.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Row({ space: 14 }) {
Text('长度 ' + r.len + 'm')
.fontSize(10)
.fontColor(COLORS.subTitle)
Text('弯数 ' + r.turns)
.fontSize(10)
.fontColor(COLORS.subTitle)
Text('宽 ' + r.width + 'm')
.fontSize(10)
.fontColor(COLORS.subTitle)
}
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
Column({ space: 3 }) {
Text('热度')
.fontSize(9)
.fontColor(COLORS.hint)
Text(r.hot.toString())
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width(52)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}, (r: RaceItem) => r.id.toString())
}
.width('100%')
.padding({ left: 18, right: 18, bottom: 24 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
赛道列表页展示完整的赛道库,每条赛道采用"难度等级 + 赛道信息 + 热度"的三栏布局。左侧是难度等级标签,居中的深色圆角方块内显示等级(如 SSS、SS)和所在区域;中间是赛道名称和三个参数(长度、弯数、宽度);右侧是热度值,用大号玫红色数字突出显示。三栏布局在有限的宽度内合理分配了信息空间,让用户可以快速扫描赛道的关键信息。
难度等级标签的设计参考了竞速游戏中的等级徽章风格。深色背景(#20233B)上的亮色等级文字,加上圆角矩形的轮廓,形成了鲜明的"徽章"视觉效果。等级文字使用 levelColor 函数着色——SSS 用玫红色、SS 用橙红色、S 用金黄色、A 用绿色——颜色从热到冷对应难度从高到低。这种颜色编码让用户无需阅读文字就能快速感知赛道难度。
赛道参数行展示了三个核心数据:长度、弯数、宽度。这三个参数从不同维度描述了赛道的特点——长度决定了单圈时间,弯数反映了赛道的复杂度,宽度影响飞行的难度和安全性。三个参数使用相同的样式(浅灰色小字),均匀分布在一行中,信息密度高但不显拥挤。对于 FPV 爱好者来说,这些参数的组合就能在脑海中勾勒出赛道的大致样貌。
热度值放在最右侧,使用 18px 粗体玫红色字,是整条记录中最醒目的元素。将热度值高亮显示,既符合"热门内容优先"的产品逻辑,也在视觉上形成了节奏感——每条记录的右侧都有一个"锚点",用户的视线可以沿着这些锚点快速向下扫描。热度是一个综合性指标,它的具体计算逻辑(参赛人数 + 收藏数 + 分享数的加权和)被封装在数据层,UI 层只负责展示最终的数值。
3.25 圈速榜页
@Builder
pageLap() {
Scroll() {
Column({ space: 12 }) {
Text('⏱️ 圈速排行榜')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
.width('100%')
Row({ space: 10 }) {
ForEach(SPEED_CHART, (c: SpeedChartItem, i: number) => {
Column({ space: 5 }) {
Text(c.label)
.fontSize(10)
.fontColor(COLORS.subTitle)
Text(c.value.toString())
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Column()
.width(30)
.height(barH(c.value, 62))
.borderRadius(6)
.backgroundColor(i === 0 ? COLORS.gold : COLORS.accent)
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
}, (c: SpeedChartItem) => c.label)
}
.width('100%')
.height(140)
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.SpaceAround)
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
圈速榜页是竞技感最强的页面,由速度柱状图和排行榜两部分组成。顶部的速度柱状图以可视化的方式展示飞手之间的速度对比,柱状图的容器高度固定为 140px,柱形从底部向上生长(justifyContent(FlexAlign.End)),形成标准柱状图的视觉效果。柱宽固定为 30px,5 个柱形通过 justifyContent(FlexAlign.SpaceAround) 均匀分布。
第一名的柱形使用金色(COLORS.gold),其他使用玫红色(COLORS.accent),这种设计突出了冠军的特殊地位。在竞技排行榜中,第一名总是最受关注的,用金色单独标识是一种经典的设计手法,与奖牌的颜色体系一致。柱形的高度通过 barH(c.value, 62) 计算,最大值 62 对应最高的柱形,其他值按比例缩放。barH 函数返回的是百分比高度(10 + v/max * 88),即最高占 98%,最低占 10%,确保最短的柱形也有可见的高度。
排行榜列表是圈速榜的核心部分。每条记录包含排名、飞手信息和成绩三个区域。排名数字使用 18px 粗体,颜色通过 lapColor 函数确定——第一名金色、第二名银色、第三名铜色、第四名及以后灰蓝色。前三名的背景色也稍亮一些(#232848),进一步强化了前三名的荣誉感。这种"颜色 + 背景"的双重区分方式让前三名在列表中非常突出,用户一眼就能找到领奖台位置。
成绩区域使用 16px 粗体青色字显示当前圈速,下方用小号灰色字标注最佳成绩。青色主色的成绩数字是列表中最醒目的元素,符合"圈速是竞速核心"的产品定位。最佳成绩的补充展示则提供了更多维度的信息——有些飞手可能当前状态不佳但历史成绩很好,有些则可能正在上升期。同时展示两个成绩让排行榜的信息更加立体和公正。
3.26 电机图鉴与商城页
@Builder
pageGear() {
Scroll() {
Column({ space: 12 }) {
Text('⚙️ 电机参数图鉴')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
.width('100%')
// 表格式:表头
Row() {
Text('型号')
.fontSize(10)
.fontColor(COLORS.hint)
.layoutWeight(1)
Text('KV')
.fontSize(10)
.fontColor(COLORS.hint)
.width(56)
.textAlign(TextAlign.End)
Text('拉力g')
.fontSize(10)
.fontColor(COLORS.hint)
.width(60)
.textAlign(TextAlign.End)
Text('重量g')
.fontSize(10)
.fontColor(COLORS.hint)
.width(52)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 6 })
电机图鉴页采用表格式布局展示电机参数,这是专业装备类应用的经典设计。表格由表头和数据行组成,四列分别是型号、KV 值、拉力和重量。表头文字使用小号灰色字,右对齐数值列,与数据行的对齐方式保持一致。表格布局的优势在于数据对齐整齐、横向对比方便——飞手可以很容易地沿着某一列向下扫描,比较不同电机的同一参数。
数据行的背景色通过 motorBg 函数确定,前两行使用特殊的深色背景(第一行青色调、第二行玫红色调),其余行使用默认背景。这种"斑马纹"的变体设计既为前两名电机增加了视觉重点,又保持了列表的整体可读性。每一行的高度由内边距(上下各 9px)和内容高度共同决定,行与行之间有 6px 的间距,形成了清晰的行分隔。
数值列的右对齐设计是表格可读性的关键细节。数字右对齐后,个位、十位、百位分别对齐,用户可以通过数字的"长度"直观判断数值大小,比较起来更加容易。如果数字左对齐,长短不一的数字会让比较变得困难。型号列左对齐、数值列右对齐的混合对齐方式,是数据表格的标准最佳实践。
电机图鉴下方还包含了图传商城的列表,与装备 Tab 的内容形成了呼应。这种"图鉴 + 商城"的组合页面设计很有商业价值——飞手在查看电机参数时,自然会产生购买相关装备的需求,下方的商城列表正好满足了这一需求。从"看参数"到"买装备"的转化路径被压缩到同一个页面内,减少了用户的操作步骤,提升了转化率。
3.27 训练营与 VS 对决卡
@Builder
pageTrain() {
Scroll() {
Column({ space: 12 }) {
Text('📋 训练营')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
.width('100%')
// VS 对决卡
Row() {
Column({ space: 4 }) {
Text('老K')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('62 km/h')
.fontSize(12)
.fontColor(COLORS.primary)
Text('胜率 78%')
.fontSize(9)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Text('VS')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Column({ space: 4 }) {
Text('滑翔者')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('58 km/h')
.fontSize(12)
.fontColor(COLORS.accent)
Text('胜率 65%')
.fontSize(9)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding(16)
.backgroundColor('#1E2545')
.borderRadius(14)
训练营页面的顶部是一张 VS 对决卡,展示两位飞手的对阵信息。左边是"老K"(主色青色),右边是"滑翔者"(强调色玫红色),中间是"VS"字样(强调色)。两位飞手的信息对称排列——名字、速度、胜率,三层结构完全对应。这种对称的 VS 布局在竞技类应用中非常常见,它直观地传达了"对抗"的概念,激发用户的竞争欲望和参与热情。
对决卡使用深蓝色背景(#1E2545),与精选页的赛事横幅使用相同的背景色,形成了"重点内容区块"的视觉语言。用户看到深蓝色背景的卡片,就会意识到这是一个特殊的、重要的内容区域。14px 的圆角和 16px 的内边距与整体设计系统一致,保持了视觉风格的统一性。
训练计划列表是训练营页面的核心功能。每个训练项包含训练名称、阶段标签、目标参数和状态操作。训练名称使用 13px 粗体白色字,阶段标签使用彩色小字(通过 stageColor 函数着色),目标参数(时长和速度)使用灰色小字展示。右侧显示完成状态——已完成显示绿色"✅ 完成",进行中显示黄色"⏳ 进行"——再往右是编辑和删除两个操作按钮。
每个训练项的操作按钮(编辑和删除)使用 emoji 图标表示,点击区域由图标本身的大小决定。这种图标按钮的设计在移动端很常见,节省空间且直观。但需要注意确保点击区域足够大——根据移动端可访问性指南,可点击区域的最小尺寸建议为 44x44px,以适应手指触摸的精度。在本项目中,emoji 图标的点击区域可能偏小,在实际生产应用中可能需要增加 padding 或使用更大的触摸区域。
3.28 个人中心页
@Builder
pageMine() {
Scroll() {
Column({ space: 12 }) {
Row() {
Text('🕶️')
.fontSize(46)
.padding({ top: 8, bottom: 8 })
.backgroundColor(iconBg('🕶️'))
.borderRadius(26)
Column({ space: 4 }) {
Text('穿云·老K')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('ID: FPV-0851 · 城市联赛32强')
.fontSize(10)
.fontColor(COLORS.hint)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
Text('🏆')
.fontSize(24)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
个人中心页是用户信息和个人功能的聚合页面。顶部是用户信息卡片,采用大头像 + 用户名称 + 荣誉标识的布局。头像使用 FPV 眼镜 emoji(🕶️),46px 的大尺寸,圆形背景(26px 圆角),背景色通过 iconBg 函数计算。用户名称是 17px 粗体白色字,下方是 ID 和段位信息,使用 10px 灰色字。右侧的奖杯图标推到最右边,作为荣誉的视觉象征。
个人数据卡片展示三个核心数据:累计圈数、飞行里程和最佳圈速。三个数据等宽排列,每个都是"数值 + 标签"的上下结构。最佳圈速的数值使用主色青色突出显示,因为圈速是 FPV 竞速最核心的指标。这种数据卡片组与精选页的统计卡片采用相同的设计模式,体现了组件设计的一致性——相同类型的信息使用相同的展示形式,降低用户的学习成本。
"我的装备机架"部分展示用户已拥有的装备列表,每条装备包含图标、名称和状态(“在架”),右侧有删除按钮。列表的展示形式与商城页的装备列表类似,但更简洁——没有价格和库存信息,只有最基本的装备信息和状态。"在架"标签使用成功色绿色,表示装备已入库可用。这个功能模块连接了商城(购买装备)和个人中心(管理装备),形成了完整的使用闭环。
快捷入口区域提供了三个常用功能的快速访问:飞行报告、训练日志和配件商城。三个入口等宽排列,每个都是"图标 + 文字"的垂直布局,使用次级卡片背景(cardBg2)。快捷入口的设计遵循了"常用功能前置"的原则——把用户最常用的功能入口放在个人中心的显眼位置,减少用户的操作路径。图标 + 文字的组合比纯文字更直观,也比纯图标更明确,是快捷入口的标准设计。
3.29 弹框遮罩与三种弹框体
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => { onClose(); })
}
@Builder
addModalBody() {
Column({ space: 14 }) {
Row() {
Text('💬 发布飞手动态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('✕')
.fontSize(16)
.layoutWeight(1)
.textAlign(TextAlign.End)
.onClick(() => { this.addModal = false; })
}
.width('100%')
弹框系统由遮罩层和弹框体两部分组成。modalOverlay 是一个通用的遮罩层 Builder,接收一个 onClose 回调函数作为参数。遮罩层是一个全屏的半透明黑色层(#66000000,即 40% 透明度的黑色),点击遮罩层会触发 onClose 回调关闭弹框。遮罩层有两个作用:一是视觉上突出弹框内容,让用户注意力集中在弹框上;二是拦截弹框下方的交互,防止用户在弹框打开时误操作底层内容。
addModalBody 是发布动态弹框的内容体,采用垂直布局,包含标题栏、昵称输入框、内容输入区和发布按钮。标题栏左侧是弹框标题(带 emoji 图标),右侧是关闭按钮(✕)。关闭按钮使用 layoutWeight(1) + textAlign(TextAlign.End) 的方式推到最右边,这是 ArkUI 中实现"两端对齐"的标准做法。点击关闭按钮会直接将 addModal 设为 false,关闭弹框。
表单区域包含两个输入控件:昵称输入(TextInput,单行)和内容输入(TextArea,多行)。TextInput 高度 36px,是移动端单行输入框的标准高度。TextArea 高度 100px,可以容纳多行文字。两个输入控件都通过 onChange 回调将输入内容同步到对应的状态变量(addNick、addText)。输入框的 placeholder 文字提供了输入提示,昵称输入框还提示了默认值信息,帮助用户了解输入规则。
发布按钮使用 Button 组件 + Text 组件的组合实现,背景色为强调色玫红,文字为白色粗体。按钮的高度为 42px,圆角 21px(即高度的一半),形成完全圆角。按钮上方的 Text 组件通过负 margin(margin({ top: -32 }))定位到 Button 上面,这种"按钮 + 叠加文字"的写法是 ArkUI 中的常见技巧。相比于直接设置 Button 的文字属性,这种方式提供了更灵活的文字样式控制。
编辑训练计划弹框和删除确认弹框也遵循类似的结构模式。编辑弹框包含两个输入字段(目标时长、目标均速)和一个保存按钮,保存按钮使用主色青色,与发布动态的强调色按钮形成区分——发布是"创建"动作,用强调色吸引注意;编辑是"修改"动作,用主色表示确认。删除确认弹框则包含警告图标、提示文字和两个按钮(取消、确认),确认按钮使用危险色红色,明确传达"危险操作"的语义。
3.30 底部导航栏与主内容路由
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (item: NavItem, i: number) => {
Column({ space: 2 }) {
Text(item.icon)
.fontSize(20)
Text(item.label)
.fontSize(10)
.fontColor(this.mainTab === i ? COLORS.primary : COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.onClick(() => { this.mainTab = i; })
}, (item: NavItem, i: number) => item.label + i.toString())
}
.width('100%')
.padding({ top: 6, bottom: 10 })
.backgroundColor(COLORS.cardBg)
}
底部导航栏(bottomBar)是应用的全局导航组件,采用经典的 Tab Bar 设计。4 个导航项通过 ForEach 循环从 NAV_LIST 数据数组渲染,每个导航项是"图标 + 文字"的垂直布局,使用 layoutWeight(1) 平均分配宽度。选中态通过文字颜色变化来反馈——选中时使用主色青色,未选中时使用灰色。图标始终保持原色(emoji 的固有颜色),只通过文字颜色区分选中状态,这种设计简洁且足够清晰。
导航项的点击事件直接修改 mainTab 状态变量,触发 UI 更新。由于 mainTab 是 @State 装饰的状态变量,任何对它的赋值都会导致依赖它的 UI 部分重新渲染。在本项目中,mainContent 方法根据 mainTab 的值渲染不同的页面内容,因此点击底部 Tab 会立即切换页面。这种状态驱动的导航切换方式代码量极少,运行效率极高。
底部导航栏的背景色为 cardBg(卡片背景色),与内容区的 bg 背景色形成微妙差异,强化了底部导航的"层"感。顶部 6px + 底部 10px 的不对称内边距设计,是为了适配手机底部的安全区域(Home Indicator)——底部需要更多空间来避开系统手势区域。在实际的 HarmonyOS 应用中,可以通过安全区域 API 来动态获取安全区域的高度,实现更精确的适配。
mainContent 方法实现了主 Tab 的路由逻辑。它通过 if-else 链判断当前 mainTab 的值,渲染对应的页面内容。首页(mainTab === 0)的内容最复杂,包含 header、subNav 和根据 subTab 切换的 7 个页面;其他三个 Tab(赛道、装备、我的)的结构相对简单,都是 header 加对应页面。这种条件渲染的路由方式是单组件架构的典型实现,相比于独立的路由系统,它更简单直接,但在页面数量很多时会导致 mainContent 方法过于庞大。
3.31 主 build 方法与整体布局结构
build() {
Stack() {
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
if (this.addModal) {
Column() {
this.modalOverlay(() => { this.addModal = false; })
Column() {
this.addModalBody()
}
.width('100%')
.padding(20)
.position({ y: 220 })
}
.width('100%')
.height('100%')
.zIndex(999)
}
build 方法是组件的入口,定义了整个页面的布局结构。它使用 Stack 作为根容器,堆叠了三层内容:主内容层(Column + mainContent + bottomBar)、特效层(fxLayer)和弹框层(三个条件渲染的弹框)。Stack 布局的子组件按照顺序从上到下堆叠——第一个子组件在最底层,最后一个在最上层。通过 zIndex 属性可以手动控制堆叠顺序,弹框层设置了 zIndex(999) 确保始终在最上层。
主内容层使用 Column 垂直布局,上面是 mainContent(占满剩余空间),下面是 bottomBar(固定高度)。这种"内容 + 底部导航"的结构是移动端应用的标准布局。mainContent 的高度通过 Column 的弹性分配自动填满剩余空间——bottomBar 的高度由内容决定,mainContent 占据剩下的所有空间。在 ArkUI 中,Column 的子组件默认按内容高度排列,如果需要让某个子组件填满剩余空间,可以使用 layoutWeight(1)。
特效层(fxLayer)在主内容层之上,由于设置了 hitTestBehavior(HitTestMode.None),它不会拦截触摸事件,用户可以正常与下方的内容交互。这种"视觉在上、交互在下"的效果是装饰性特效的标准实现方式。如果没有设置穿透,特效层会拦截所有触摸事件,导致下方的按钮、列表等无法操作,这是初学者容易犯的错误。
弹框层使用条件渲染——只有当对应的布尔状态变量为 true 时,弹框才会被渲染到界面上。每个弹框包含遮罩层和弹框体两部分,弹框体通过 .position({ y: 220 }) 定位在屏幕的特定位置。三个弹框的定位 y 值各不相同(220、240、260),因为它们的内容高度不同,需要根据内容调整位置以获得最佳的视觉效果。弹框容器设置了 zIndex(999),确保弹框始终显示在所有内容之上。
build 方法作为整个组件的渲染入口,其结构的清晰性直接影响代码的可维护性。本项目的 build 方法结构非常清晰——从底层到顶层依次是主内容、特效、弹框,每层的职责明确,嵌套层次合理。这种扁平的 Stack 结构相比于深层嵌套的结构,更易于理解和维护,也有利于性能优化——框架可以更高效地计算每层的渲染范围。
四、应用运行流程图
上图展示了应用从初始化到渲染再到交互的完整流程。整个流程以状态为中心——所有用户操作最终都归结为状态的变更,状态变更触发框架的差异更新机制,最终反映到 UI 上。动画特效通过定时器周期性地更新 tick 和 glow 状态,驱动特效层持续渲染。弹框的显示与隐藏、列表数据的增删改、Tab 页面的切换,无一不是状态驱动的结果。这种"状态即真相"的编程模型是声明式 UI 的核心思想。
五、技术对比表格
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 颜色体系 | ColorPalette 接口 + COLORS 常量 | 语义化命名,接口与实现分离,支持主题切换 | 减少硬编码色值,统一引用降低包体积 |
| 状态管理 | @State 集中式管理 + @Observed 数据类 | 单组件内集中管理所有状态,数据流清晰 | 数组整体替换触发更新,适合中小规模数据 |
| 动画实现 | setInterval 驱动状态 + 纯函数计算 | 状态驱动动画,程序化计算,无需关键帧 | 16.7fps 控制计算量,纯函数计算开销小 |
| 导航切换 | 索引变量 + 条件渲染 | 简单直接,无需路由库,代码量少 | 整页切换时重渲染较多,适合页面数量少的场景 |
| 列表渲染 | ForEach + key 生成函数 | 数据驱动渲染,key 策略按数据特点定制 | 精细化 key 设计减少不必要的 DOM 重建 |
| 弹框系统 | 布尔状态 + Stack 叠加 + 遮罩层 | 统一遮罩组件,多弹框复用,zIndex 控制层级 | 条件渲染,不显示时不占资源,弹框数量增加不影响基础性能 |
| 图表实现 | 纯 CSS 柱状图或条形图 + barH 函数 | 无需图表库,轻量实现,与主题色一致 | DOM 元素少,渲染快,适合简单数据可视化 |
| 图标方案 | emoji 字符图标 | 零资源依赖,跨平台一致,支持彩色 | 减少图片资源请求,字体渲染性能优于图片加载 |
| 背景色生成 | iconBg 哈希函数 + 预设色板 | 确定性哈希,图标与背景色稳定对应 | 无需配置数据,算法生成减少存储开销 |
| 数据初始化 | 顶层常量 + @State 赋值 | 种子数据与运行数据分离,便于重置 | 常量引用共享,初始化时不复制数据 |
| 表单处理 | 字符串状态 + onChange 同步 | 输入即同步,无需额外状态管理库 | 实时同步可能触发频繁重渲染,输入框数量少时无压力 |
| 删除操作 | filter 返回新数组 + ID 定位 | 不可变数据操作,ID 定位稳定可靠 | 数组过滤会创建新数组,大数据量时需考虑性能 |
| 布局系统 | Column 或 Row 或 Stack + Flex | 声明式 Flex 布局,响应式适配 | 原生布局引擎,性能优于 JS 计算布局 |
| 生命周期 | aboutToAppear / aboutToDisappear | 对称式资源申请与释放 | 及时清理定时器防止内存泄漏 |
| 组件拆分 | @Builder 方法 + 单组件 | 方法级复用,状态共享便利 | 减少组件实例数量,降低通信开销 |
| 特效穿透 | hitTestBehavior(HitTestMode.None) | 视觉在上,交互在下,互不干扰 | 避免事件拦截,减少无效命中检测 |
| 文字排版 | 三级文字色 + 多行省略 | 标题或副标题或提示 三级层次,信息分级 | maxLines 减少渲染内容,提升长列表滚动性能 |
| 圆角设计 | 多级圆角(8/10/12/14/16/19/21) | 元素越大圆角越大,符合物理直觉 | 统一圆角体系,视觉一致性强 |
| 内边距体系 | 多级 padding(6/8/10/12/14/16/18) | 8px 基准网格,间距有节奏感 | 标准化间距减少随意性,提升开发效率 |
六、详细总结
6.1 架构设计总结
本项目采用了单组件多 Builder 的架构模式,将整个应用的 UI 和逻辑集中在一个 Page 组件中,通过 @Builder 装饰器将 UI 拆分为多个可复用的构建方法。这种架构在中等复杂度的应用中具有明显优势:开发效率高,不需要设计组件间的通信协议;调试方便,所有状态都在一个地方;状态共享便利,各个 Builder 可以直接访问组件的状态变量。对于功能模块在 10 个左右、数据量在百级以内的应用,这种架构是高效且可靠的选择。
架构的层次结构清晰:根布局使用 Stack 堆叠主内容层、特效层和弹框层;主内容层采用 Column 垂直布局,分为头部、内容区和底部导航;内容区通过状态变量驱动条件渲染,切换不同的页面。这种多层堆叠 + 垂直分层的布局结构符合移动端应用的常见模式,也与 ArkUI 框架的设计理念高度契合。每个 Builder 方法职责单一、命名清晰,代码的可读性和可维护性都较好。
从可扩展性的角度来看,当前架构也存在一定的局限性。随着功能模块的增加,主组件的状态变量会越来越多,build 方法和 mainContent 方法的条件分支会越来越长,最终可能导致组件过于庞大臃肿。当应用复杂度提升到一定程度时,需要考虑将部分功能拆分为独立的子组件,通过 @Prop / @Link / @ObjectLink 等装饰器进行父子组件间的状态传递。合理的组件拆分是架构演进的必然方向。
设计令牌体系(Design Token)的引入是架构设计的一大亮点。通过 ColorPalette 接口和 COLORS 常量统一定义和管理颜色,确保了整个应用视觉风格的一致性。这种将设计规范代码化的做法,使得设计调整变得简单高效——只需要修改 COLORS 常量中的一个值,整个应用所有引用该颜色的地方都会自动更新。设计令牌可以进一步扩展到间距、圆角、字号等维度,形成完整的设计系统。
6.2 状态管理总结
状态管理采用了集中式的 @State 方案,所有状态变量都定义在主组件中,包括导航状态、动画状态、弹框状态、表单数据和列表数据。集中式状态管理的最大优势是可预测性——数据流向清晰,状态变更的来源唯一,调试时可以很容易地追踪状态变化的原因。在开发阶段,集中式状态减少了理解成本,开发者不需要在多个组件间追踪状态传递链路。
状态更新严格遵循了"赋值触发"原则。对于数组类型的状态,通过 unshift、filter 等方法改变数组引用,从而触发 UI 更新。对于对象属性的修改(如编辑训练计划时修改 targetTime 和 targetSpeed),由于 TrainItem 使用了 @Observed 装饰器,属性变更可以被框架追踪。这种混合的更新策略在实际运行中需要仔细验证,确保所有状态变更都能正确触发 UI 刷新。如果遇到更新不生效的情况,可以通过数组整体替换的方式作为兜底方案。
状态的分类管理也做得比较好。不同类型的状态有不同的命名前缀和使用模式:导航状态(mainTab、subTab)是数字索引型,动画状态(tick、glow)是计数器型,弹框状态(addModal、editModal、delModal)是布尔开关型,表单状态(addNick、addText、editTime 等)是字符串型,列表数据(posts、trains、gearCards)是数典型。清晰的状态分类有助于理解每个状态的用途和生命周期。
从性能角度看,集中式状态管理的主要瓶颈在于状态变更的影响范围。一个状态变量的变化会触发整个组件的重新渲染,即使只有很小一部分 UI 依赖这个状态。在本项目中,动画状态(tick、glow)的变化最为频繁(每 60ms 一次),每次变化都会导致整个组件的 build 方法重新执行,框架需要遍历整个 UI 树来计算差异。对于简单应用来说这不是问题,但对于复杂应用,可能需要通过拆分组件或使用 @Watch 等手段来缩小更新范围。
6.3 性能优化总结
本项目在性能优化方面做了多方面的努力,虽然整体架构相对简单,但许多细节处都体现了对性能的关注。动画帧率控制是最显著的优化点——选择 60ms 的间隔(约 16.7fps)而非更高的帧率,在视觉效果和性能消耗之间取得了平衡。对于装饰性的背景特效来说,用户不会刻意关注动画的流畅度,降低帧率是一种非常有效的性能优化手段。
列表渲染的 key 设计也是性能优化的重要一环。ForEach 循环的 key 生成函数直接影响列表更新时的 DOM 复用率。本项目为不同类型的列表设计了不同的 key 策略:对于 ID 稳定的数据(如赛道列表),直接使用 ID 作为 key;对于可能有重复 ID 风险的场景(如动态列表),组合 ID 和索引确保唯一性。合理的 key 设计可以最大化列表项的复用率,减少 DOM 的创建和销毁操作,提升列表的滚动性能和更新性能。
纯函数式的动画计算也有助于性能优化。propAngle 和 trailGlow 等纯函数的计算开销很小,主要是简单的算术运算和取模操作。相比于复杂的动画库或物理引擎,程序化计算的性能优势明显。而且纯函数的计算结果可以被缓存——如果两帧之间输入值相同,可以直接复用之前的计算结果。虽然本项目没有实现缓存机制,但纯函数的设计为后续的优化预留了空间。
内存管理方面,aboutToDisappear 生命周期中及时清理定时器的做法是正确的。在单页面应用中,组件的创建和销毁可能比较频繁,如果不及时清理定时器和事件监听,会导致内存泄漏和性能下降。60ms 的定时器如果泄漏,不仅会持续消耗 CPU 资源,还可能因为持有组件引用而阻止垃圾回收。养成在生命周期钩子中对称地申请和释放资源的习惯,是避免内存问题的关键。
6.4 交互设计总结
应用的交互设计遵循了移动端应用的常见模式,同时结合了 FPV 竞速行业的特点,在功能性和易用性之间取得了良好的平衡。底部 Tab 导航是用户最熟悉的导航模式,4 个 Tab 的数量也在最佳范围内(通常建议 3-5 个),用户可以轻松地单手操作。首页的二级导航采用两排 4+3 的布局,在有限的空间内容纳了 7 个功能入口,虽然比标准的 Tab 导航复杂一些,但通过图标 + 文字的组合和选中态的明确反馈,用户仍然可以快速理解和使用。
弹框交互的设计简洁而完整。三个弹框都遵循了"标题 + 内容 + 操作按钮"的标准结构,提供了多种关闭方式(点击遮罩层、点击关闭按钮、点击取消按钮),符合用户的心理预期。删除确认弹框的二次确认设计尤为重要——对于不可逆的危险操作,二次确认是防止误操作的标准手段。弹框的文案根据操作类型动态变化,确保用户明确知道自己在执行什么操作。
列表交互的设计注重信息的层次感。每条列表项都有明确的视觉层次——最重要的信息用最大最亮的字体,次要信息依次降低字号和对比度。这种视觉引导使得用户可以快速扫描列表,找到自己感兴趣的内容。颜色编码的大量使用(难度等级颜色、排名颜色、阶段颜色、状态颜色)也大幅提升了信息的读取效率——用户可以通过颜色快速识别信息的性质,而不需要逐字阅读。
动画和特效的运用克制而到位。螺旋桨旋转和赛道光带流动都是背景装饰性特效,它们增强了应用的科技感和动感,但不会干扰用户的正常操作。特效层的触摸穿透设置确保了这一点。相比于过度使用动画导致的界面花哨和性能问题,这种"少而精"的特效策略更加成熟和专业。动画的速度和幅度也经过了调校,不会过快导致视觉疲劳,也不会过慢显得迟钝。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
6.5 总结

从可扩展性的角度来看,本项目的架构在功能扩展和数据扩展两个维度上都有一定的潜力,但也存在需要改进的地方。功能扩展方面,增加新的页面或弹框相对简单——增加一个新的页面只需要添加一个 @Builder 方法并在 mainContent 中增加一个条件分支;增加一个新的弹框只需要添加一个布尔状态变量、一个打开方法和一个弹框体 Builder。这种模式虽然有重复代码,但扩展路径清晰,对于渐进式开发很友好。
数据扩展方面,增加新的数据类型需要定义新的 @Observed 类、新的静态数据和新的列表渲染逻辑。目前 RaceItem 类被复用于赛道、装备、电机等多种场景,这种权宜之计在数据类型较少时可以接受,但随着数据类型的增加,属性语义的模糊会成为维护的负担。建议为每种数据类型定义独立的模型类,虽然代码量会增加,但类型安全性和可维护性会大幅提升。
组件级别的可扩展性还有提升空间。目前所有 UI 都在一个组件中通过 @Builder 方法实现,如果需要在其他页面复用某个 UI 片段(如动态卡片、赛道卡片),就会遇到困难。将常用的 UI 模式抽离为独立的自定义组件(@Component),通过 @Prop 接收数据参数,可以大幅提升 UI 的复用性和可维护性。特别是对于卡片类组件(动态卡、赛道卡、装备卡),抽离为独立组件的收益非常明显。
设计系统的可扩展性较好。颜色体系已经通过接口和常量实现了标准化,未来可以轻松扩展到深色或浅色主题切换、多品牌配色等场景。间距和圆角也遵循了一定的规律(以 2px 为增量单位),可以进一步整理为标准化的间距令牌和圆角令牌。完善的设计系统不仅能提升开发效率,也是实现多主题、多品牌的基础。
更多推荐



所有评论(0)