一、技术背景

1.1 HarmonyOS 声明式 UI 框架

HarmonyOS 是华为推出的分布式操作系统,其声明式 UI 开发范式是构建现代化移动应用的核心技术基石。与传统命令式 UI 开发不同,声明式 UI 允许开发者以"描述界面应该是什么样子"的方式编写代码,由框架负责底层的渲染调度和状态同步。这种开发模式极大地提升了开发效率,降低了 UI 状态不一致带来的 Bug 风险。

声明式 UI 框架的核心思想在于数据驱动视图。开发者通过定义状态变量与 UI 元素之间的绑定关系,当状态发生变化时,框架会自动计算差异并更新需要重绘的部分。这种机制类似于 React 的虚拟 DOM 思想,但在 HarmonyOS 中,它与 ArkUI 引擎深度集成,能够充分利用原生渲染能力,实现更高效的绘制性能。

在 ArkTS 中,声明式 UI 通过 @Component 装饰器标记的结构体来实现。每个结构体代表一个自定义组件,其 build() 方法描述了组件的 UI 结构。组件可以嵌套组合,形成完整的界面树。框架提供了丰富的内置组件(如 Column、Row、Stack、Text、Button 等),以及链式调用的属性设置方式,使得布局代码既简洁又直观。

声明式 UI 还带来了更好的可测试性和可维护性。由于 UI 是状态的纯函数映射,相同的状态输入总是产生相同的界面输出,这使得单元测试和快照测试变得更加容易。同时,组件化的设计模式也促进了代码复用,开发者可以将复杂界面拆分为多个独立的小组件,分别开发和维护。
在这里插入图片描述

1.2 ArkTS 语言特性

ArkTS 是 HarmonyOS 应用开发的首选语言,它基于 TypeScript 扩展而来,在保持 TypeScript 语法兼容性的同时,增加了一系列面向声明式 UI 开发的专属特性。ArkTS 的设计目标是在类型安全、开发效率和运行性能之间取得最佳平衡。

在类型系统方面,ArkTS 继承了 TypeScript 强大的静态类型检查能力,支持接口、泛型、联合类型等高级类型特性。这使得开发者在编译期就能发现大量潜在的类型错误,显著提升了代码质量。同时,ArkTS 对类型系统进行了针对性优化,在 UI 构建场景中能够提供更精确的类型推断,减少了冗余的类型注解。

装饰器(Decorator)是 ArkTS 中极具特色的语法特性。@Component@Entry@State、@Observed、@Builder 等装饰器为声明式 UI 开发提供了强大的元编程能力。每个装饰器都有明确的语义:@Component 标记自定义组件,@Entry 标记页面入口,@State 标记组件内部响应式状态,@Observed 标记可被观察的数据类,@Builder 标记可复用的 UI 构建函数。这些装饰器协同工作,构成了完整的状态管理和组件化体系。

在性能方面,ArkTS 运行时(方舟运行时)采用了 AOT(Ahead-of-Time)编译技术,能够将 ArkTS 代码提前编译为高效的机器码,避免了 JavaScript 解释执行的性能损耗。同时,方舟运行时还实现了细粒度的内存管理和垃圾回收优化,特别针对移动设备的资源限制进行了调优,确保应用在中低端设备上也能流畅运行。

1.3 HarmonyOS 生态与跨设备能力

HarmonyOS 的核心理念是"一次开发,多端部署",其分布式能力使得应用能够在手机、平板、手表、车机、智慧屏等多种设备形态上运行,并实现设备间的无缝协同。对于潮流运动类应用而言,这种跨设备能力具有独特的价值——用户可以在手机上规划夜滑路线,在手表上实时查看运动数据,在智慧屏上分享刷街精彩瞬间。

分布式软总线是 HarmonyOS 跨设备能力的技术底座。它通过统一的通信协议,将不同设备连接成一个虚拟的"超级终端",实现了设备间的高速数据传输和能力调用。开发者无需关心底层的网络通信细节,只需使用分布式数据管理和分布式任务调度的高级 API,即可构建跨设备协同的应用体验。

在 UI 适配层面,ArkUI 提供了完整的响应式布局方案。通过百分比尺寸、拉伸权重(layoutWeight)、媒体查询等机制,应用界面能够自动适应不同屏幕尺寸和分辨率。对于本项目这样的移动端应用,虽然主要面向手机场景,但其基于 Column/Row/Stack 的弹性布局设计天然具备跨设备适配的潜力,只需少量调整即可扩展到平板等大屏设备。

HarmonyOS 还拥有丰富的原子化服务(Atom Service)生态。原子化服务是一种无需安装、即点即用的轻量级应用形态,特别适合运动类场景中的快速使用需求。用户可以通过服务中心、小艺建议等入口直接拉起夜滑路线查询、装备比价等功能,无需下载完整应用,极大地降低了用户的使用门槛。

1.4 @Observed 装饰器与状态管理机制

状态管理是声明式 UI 开发中的核心议题。在 ArkTS 中,状态管理机制由一系列装饰器共同构成,其中 @Observed 装饰器扮演着关键角色。与 @State 装饰器用于标记组件内部的基本类型状态不同,@Observed 用于装饰整个类,使得该类的实例属性变化能够被 UI 框架观察和响应。

当一个类被 @Observed 装饰后,其实例对象的属性变更会被自动追踪。如果某个 @State 变量持有该类的实例(或实例数组),当实例的属性发生变化时,依赖该属性的 UI 部分会自动触发重新渲染。这种机制使得开发者可以使用面向对象的方式组织数据模型,同时保持声明式 UI 的响应式特性。

@Observed 的实现原理基于属性拦截技术。在运行时,框架会为被装饰类的属性添加 getter/setter 拦截器,当属性被读取时记录依赖关系,当属性被修改时触发更新通知。这种细粒度的观察机制确保了只有真正依赖变更属性的 UI 节点才会被重绘,从而最大限度地提升渲染性能。

在本项目中,@Observed 装饰器被应用于四个核心数据模型类:RouteItem(路线)、NightItem(夜滑计划)、SkateItem(装备)和 SkateFriendItem(轮友动态)。这些类的实例分别存储在组件的 @State 数组中,当数组元素的属性被修改时(例如在 doEdit 方法中修改夜滑计划的路线名称和时间),相关的列表项会自动更新,无需手动调用刷新方法。

1.5 潮流运动行业的技术特点与挑战

潮流运动(尤其是夜滑轮滑)作为一个快速增长的细分赛道,具有独特的行业技术特点和挑战。首先,夜滑运动具有强烈的社交属性和社区属性,用户不仅是运动参与者,更是内容创作者和社交节点。这要求应用必须具备完善的社区互动功能,如动态发布、点赞评论、车队阵营等,以满足用户的社交需求。

其次,夜滑运动的场景特殊性对 UI 设计提出了特殊要求。夜间使用场景意味着界面需要在低光环境下保持良好的可读性,同时不能过于刺眼影响视觉适应。本项目采用深紫色系的暗色调设计,配合霓虹光晕特效,既符合夜滑运动的潮流调性,又兼顾了夜间使用的视觉舒适度。这种"霓虹暗黑"风格已经成为潮流运动类应用的标志性设计语言。

第三,运动数据的可视化是核心需求之一。用户需要直观地了解自己的刷街里程、运动频次、路线偏好等数据。本项目通过自定义柱状图、进度条、数据卡片等多种可视化形式,将周里程统计、车队热度排行、个人运动数据等信息以直观的方式呈现给用户,降低了数据理解的门槛。

第四,装备电商是潮流运动社区的重要变现渠道。轮滑鞋、护具、夜光轮等专业装备具有较高的客单价和复购率,而社区内容与装备推荐的结合能够形成"内容种草-社区讨论-装备购买"的完整商业闭环。本项目的装备铺模块以双列宫格的形式展示商品,配合热门标签和库存信息,为用户提供了便捷的装备选购入口。

最后,安全性是夜滑运动不可忽视的重要议题。应用不仅需要提供路线的照明条件、路面状况、坡度信息等安全参考,还需要通过安全教育内容培养用户的安全意识。本项目在刷街里程页面专门设置了"刷街安全三条"模块,提醒用户佩戴头盔、安装反光装备、靠右行驶,体现了运动类应用的社会责任担当。

二、项目概览

本项目是一款面向潮流运动爱好者的夜滑轮滑社区平台,采用 HarmonyOS ArkTS 声明式 UI 框架开发。应用整体采用深紫霓虹的暗黑风格设计,契合夜间轮滑的场景氛围,同时通过闪烁的霓虹光晕和流动的轮滑轨迹光点特效,营造出强烈的潮流运动感和视觉冲击力。

应用的功能架构分为四大主模块和七大内容子模块。四大主 Tab 分别为:首页、路线、装备、我的,构成了应用的底层导航骨架。首页模块进一步细分为七个内容子 Tab:精选、夜滑路线、刷街里程、车队阵营、教学招新、装备铺、轮友圈,采用两排 4+3 的布局形式呈现,既保证了功能入口的丰富性,又避免了单排 Tab 过多导致的可读性下降。

头部区域采用潮流运动电商风格设计,包含定位信息、天气提示、搜索购物入口、今夜刷街大卡(展示全城夜滑人数和活跃度图表)以及热门路线横向滚动列表。头部区域没有使用动画效果,保持了信息展示的稳定性和可读性,与内容区域的动态特效形成动静对比。

特效层是本项目的一大亮点。霓虹光晕闪烁效果和轮滑轨迹光点划过效果通过 setInterval 定时器驱动,以 580 毫秒的频率交替切换状态。特效层仅覆盖内容区域而不遮挡头部,采用 hitTestBehavior(HitTestMode.None) 设置确保特效不会干扰用户的正常交互操作。这种"视觉增强但不干扰功能"的设计思路,体现了对用户体验的细致考量。

弹框系统采用条件渲染结合层级控制的方案实现。应用包含三种弹框:发布轮友动态弹框、编辑夜滑计划弹框、删除/退队确认弹框。每种弹框都由半透明遮罩层和内容体两部分组成,通过 if 条件语句控制显示与隐藏,使用 constraintSize 限制最大高度,通过 zIndex 确保弹框始终显示在最上层。这种实现方式简洁高效,能够满足大多数移动端弹框场景的需求。

整体架构采用单组件多 Builder 的组织模式。所有功能模块都集中在一个 Page 组件中,通过 @Builder 装饰器将不同的 UI 片段封装为可复用的构建函数。这种模式在中小规模应用中具有开发效率高、状态管理简单的优势。状态数据集中在组件的 @State 变量中,数据流转清晰直观,便于理解和维护。

三、逐段代码分析

3.1 颜色体系定义:ColorPalette 接口与 COLORS 常量

interface ColorPalette {
  bg: string;
  card: string;
  card2: string;
  title: string;
  sub: string;
  line: string;
  neon: string;
  neonDeep: string;
  pink: string;
  cyan: string;
  purple: string;
  lime: string;
  red: string;
  orange: string;
  mask: string;
  white: string;
}

const COLORS: ColorPalette = {
  bg: '#171226',
  card: '#211B36',
  card2: '#2B2344',
  title: '#F0EBFF',
  sub: '#9C91BE',
  line: '#382E52',
  neon: '#B14BFF',
  neonDeep: '#7A2CCB',
  pink: '#FF4D9D',
  cyan: '#3DE8E0',
  purple: '#6C4BD8',
  lime: '#B8F04D',
  red: '#FF5A6E',
  orange: '#FF9E3D',
  mask: '#0D0A18CC',
  white: '#FFFFFF'
};

颜色体系是整个应用视觉风格的基石。本项目采用 TypeScript 接口定义颜色规范,再通过常量对象提供具体的色值实现,这种"接口+常量"的分离模式具有良好的可维护性和可扩展性。如果未来需要支持多主题切换(例如浅色模式),只需定义新的 ColorPalette 实现即可,无需修改组件代码。

颜色命名采用语义化策略,而非直接使用颜色名称。例如 bg 代表背景色、card 代表卡片色、title 代表标题文字色、neon 代表霓虹主色调。这种命名方式使得代码的可读性大大提升,开发者在编写 UI 时无需关心具体的色值,只需根据语义选择合适的颜色变量。同时,如果设计方案需要调整某个颜色的色值,只需修改常量定义一处即可,所有引用该颜色的地方会自动生效。

在色值选择上,应用构建了一套完整的深紫色系暗色主题。背景色 #171226 是深紫黑色,为整个界面奠定了夜间氛围。卡片色分为两级:card#211B36)用于主要卡片容器,card2#2B2344)用于卡片内部的次级容器,通过微妙的明暗差异形成视觉层次。文字色同样分为主次两级:title#F0EBFF)用于主要标题,sub#9C91BE)用于次要说明文字,形成了清晰的信息层级。

强调色系统是本项目颜色设计的亮点。霓虹紫(#B14BFF)作为主色调贯穿全局,配合深霓虹紫(#7A2CCB)形成渐变效果。此外还配置了粉色、青色、紫色、青柠绿、红色、橙色等多种强调色,分别用于不同的语义场景:粉色用于热度和点赞,青色用于距离和时间,青柠绿用于路面和安全,红色用于警告和删除,橙色用于预告和状态。这种多彩的强调色系统既符合潮流运动的年轻调性,又通过色彩语义帮助用户快速理解信息。

遮罩色 mask 的定义也值得关注,它使用了带 Alpha 通道的八进制色值 #0D0A18CC,其中 CC 代表约 80% 的不透明度。这种半透明遮罩在弹框场景中既能突出前景内容,又能让用户感知到背后的界面上下文,是移动端弹框设计的标准做法。

3.2 路线数据模型:RouteItem 类

@Observed
export class RouteItem {
  id: number = 0;
  name: string = '';
  city: string = '';
  dist: string = '';
  surface: string = '';
  light: string = '';
  slope: string = '';
  hot: number = 0;
  constructor(id: number, name: string, city: string, dist: string, surface: string, light: string, slope: string, hot: number) {
    this.id = id; this.name = name; this.city = city; this.dist = dist;
    this.surface = surface; this.light = light; this.slope = slope; this.hot = hot;
  }
}

RouteItem 是描述夜滑路线的核心数据模型,使用 @Observed 装饰器使其具备响应式观察能力。该类包含八个属性,分别从不同维度刻画一条夜滑路线的特征:唯一标识 id、路线名称 name、所在城市 city、路线距离 dist、路面状况 surface、照明条件 light、坡度情况 slope,以及热度值 hot

属性类型的选择体现了对数据特性的精准把握。idhot 使用 number 类型,因为它们是数值型数据,可能需要参与排序或计算。而 distsurfacelightslope 等使用 string 类型,因为它们本质上是描述性文本,不需要进行数学运算。这种设计虽然牺牲了一定的类型严谨性,但换来了更大的灵活性,便于直接从后端接口获取数据并展示。

构造函数采用多参数全量初始化的方式,所有属性都在构造函数中一次性赋值。这种设计在数据量不大、属性数量固定的场景下非常适用,代码简洁明了。构造函数内将多条赋值语句压缩在同一行(使用分号分隔),这是一种常见的代码紧凑化技巧,在不影响可读性的前提下减少了代码行数。

@Observed 装饰器的应用是该数据模型的关键技术点。被装饰后,RouteItem 实例的每个属性变化都会被 ArkUI 框架自动追踪。当 RouteItem 实例存储在 @State 数组中并在 UI 上展示时,如果某个属性被修改(例如热度值更新),对应的 UI 元素会自动刷新,无需手动触发。这种响应式机制大大简化了数据驱动 UI 的开发工作。

在业务语义层面,RouteItem 的属性设计充分考虑了夜滑运动的实际需求。距离和路面状况决定了路线的难度等级,照明条件直接关系到夜滑的安全性,坡度情况影响着轮滑体验的舒适度,热度值则反映了路线的受欢迎程度。这些维度组合在一起,为用户提供了选择路线时的全面参考信息。

3.3 夜滑计划数据模型:NightItem 类

@Observed
export class NightItem {
  id: number = 0;
  route: string = '';
  date: string = '';
  fee: string = '';
  quota: string = '';
  leader: string = '';
  gear: string = '';
  status: string = '';
  constructor(id: number, route: string, date: string, fee: string, quota: string, leader: string, gear: string, status: string) {
    this.id = id; this.route = route; this.date = date; this.fee = fee;
    this.quota = quota; this.leader = leader; this.gear = gear; this.status = status;
  }
}

NightItem 类用于描述夜滑活动计划,是连接路线资源和用户参与的核心模型。与 RouteItem 的静态信息属性不同,NightItem 更多关注活动的组织和参与信息,包括路线关联、时间安排、费用名额、领队信息、装备要求和活动状态等。

八个属性构成了夜滑计划的完整信息画像。route 属性存储路线名称,以字符串形式与 RouteItem 进行逻辑关联。date 属性使用描述性的时间文本(如"本周六 20:00"),而非标准的日期时间格式,这种设计在面向用户展示时更加友好直观,符合移动端应用的常见做法。fee 属性记录活动费用,支持"免费"或具体金额两种形式。

quota 属性是一个巧妙的设计,它将剩余名额和总名额组合在一个字符串中(如"余 15/30"),既包含了数量信息,又包含了比例信息。在展示时可以直接使用该字符串,无需额外计算。当然,这种设计也意味着如果需要单独获取剩余名额或总名额进行业务逻辑判断,就需要进行字符串解析,存在一定的便利性与灵活性的权衡。

leader 属性记录领队信息,gear 属性记录装备要求,这两个属性体现了夜滑活动的社群属性和安全属性。领队是夜滑活动的组织者和安全保障者,装备要求则是确保参与者安全的必要条件。status 属性记录活动的报名状态,如"报名中"“热抢”“常规”"预告"等,配合 nightStatusColor 函数可以实现不同状态的差异化视觉呈现。

该类同样使用 @Observed 装饰器,使得夜滑计划的编辑功能(修改路线名称和时间)能够实时反映在 UI 上。在 doEdit 方法中,只需直接修改实例的 routedate 属性,列表中的对应项就会自动更新,这就是声明式 UI 响应式机制的威力所在。

3.4 装备数据模型:SkateItem 类

@Observed
export class SkateItem {
  id: number = 0;
  name: string = '';
  cat: string = '';
  price: string = '';
  brand: string = '';
  spec: string = '';
  stock: number = 0;
  hot: string = '';
  icon: string = '';
  constructor(id: number, name: string, cat: string, price: string, brand: string, spec: string, stock: number, hot: string, icon: string) {
    this.id = id; this.name = name; this.cat = cat; this.price = price; this.brand = brand;
    this.spec = spec; this.stock = stock; this.hot = hot; this.icon = icon;
  }
}

SkateItem 类是装备电商模块的数据载体,完整描述了一件轮滑装备商品的各项属性。与前两个数据模型相比,SkateItem 的属性更多(九个),覆盖了商品展示所需的各个维度,包括商品名称、分类、价格、品牌、规格、库存、热度标记和图标。

cat 属性表示商品分类,如"轮滑鞋"“轮组”“轴承”“护具”"配件"等。分类信息在电商场景中至关重要,它既是商品筛选和导航的依据,也是用户理解商品属性的重要参考。在本项目中,分类信息以标签形式展示在商品名称下方,帮助用户快速识别商品类型。

price 属性采用字符串类型存储价格,这是一个值得讨论的设计选择。使用字符串的好处是可以直接包含货币符号(如"¥2680"),展示时无需格式化处理。但缺点是不便于进行价格计算、排序或比较操作。在实际项目中,如果涉及复杂的价格逻辑(如折扣计算、价格区间筛选等),通常会将价格存储为数值类型,在展示层再进行格式化。

stock 属性使用 number 类型存储库存数量,这是合理的设计,因为库存数量经常需要进行增减运算。hot 属性则使用字符串类型存储热度标记,其值通常为 emoji 符号(如"🔥")或空字符串,这种设计简单直接,适合用于标记是否为热门商品的场景。

icon 属性是一个很有特色的设计,它使用 emoji 字符作为商品图标。这种做法的优势在于:无需额外的图片资源,减少了应用体积;emoji 是系统原生支持的,渲染性能好;可以通过文字颜色和大小来控制图标的外观。当然,emoji 图标的局限性在于样式不够统一,无法展示复杂的商品细节,但在原型开发和轻量级应用中,这是一种高效且富有表现力的方案。

3.5 轮友圈数据模型:SkateFriendItem 类

@Observed
export class SkateFriendItem {
  id: number = 0;
  nick: string = '';
  avatar: string = '';
  text: string = '';
  likes: number = 0;
  replies: number = 0;
  tag: string = '';
  time: string = '';
  constructor(id: number, nick: string, avatar: string, text: string, likes: number, replies: number, tag: string, time: string) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text;
    this.likes = likes; this.replies = replies; this.tag = tag; this.time = time;
  }
}

SkateFriendItem 类是社区社交模块的核心数据模型,描述了轮友圈中的一条动态内容。该类的设计体现了典型社交平台动态流的数据结构特征,包含用户信息、内容信息、互动信息和时间标签四大类属性。

用户信息由 nick(昵称)和 avatar(头像)组成。头像同样采用 emoji 字符方案,与装备图标的设计思路一致。这种设计在保持轻量级的同时,也为社区增添了一份活泼有趣的氛围。每个用户可以选择不同的 emoji 作为自己的形象标识,形成个性化的视觉符号。

内容信息的核心是 text 属性,存储动态的正文内容。tag 属性则为动态打上分类标签,如"夜滑预告"“刷街战报”“挑战记录”“教学笔记”"装备评测"等。标签系统在社区内容运营中具有重要作用,它既可以帮助用户快速筛选感兴趣的内容类型,也便于平台进行内容推荐和话题组织。

互动信息包括 likes(点赞数)和 replies(评论数)两个数值型属性。这两个数据是衡量内容热度和用户参与度的关键指标。在 UI 展示中,点赞数通常使用醒目的颜色(如本项目中的粉色)来突出显示,以引导用户的互动行为。评论数则相对弱化,作为辅助信息展示。

time 属性记录动态的发布时间,采用相对时间的描述方式(如"1小时前"“昨天”“2天前”)。相对时间比绝对时间更符合移动端用户的阅读习惯,用户可以直观地感受到内容的新鲜程度。在实际项目中,通常会将绝对时间戳存储在后端,前端根据当前时间动态计算相对时间描述。

3.6 图表数据结构:WeekChartItem 与 CrewItem

interface WeekChartItem {
  day: string;
  val: number;
  color: string;
}

const weekCharts: WeekChartItem[] = [
  { day: '周一', val: 18, color: '#6C4BD8' },
  { day: '周二', val: 26, color: '#7A2CCB' },
  { day: '周三', val: 42, color: '#B14BFF' },
  { day: '周四', val: 35, color: '#FF4D9D' },
  { day: '周五', val: 68, color: '#FF9E3D' },
  { day: '周六', val: 92, color: '#B8F04D' },
  { day: '周日', val: 74, color: '#3DE8E0' }
];

WeekChartItem 接口定义了周统计柱状图的数据项结构,包含日期标签 day、数值 val 和柱体颜色 color 三个属性。与前面的 @Observed 类不同,这里使用普通的 interface 来定义数据结构,因为图表数据通常是静态展示的,不需要响应式观察能力。

weekCharts 常量数组预置了一周七天的里程统计数据。数据设计符合实际生活规律:周一到周四数值较低(18-42),周五开始攀升(68),周六达到峰值(92),周日略有回落(74)。这种数据分布真实地反映了夜滑运动的时间规律——工作日参与人数少,周末参与人数多,周六是活动高峰。

颜色配置同样经过精心设计,从周一到周日呈现出由冷到暖再到冷的色彩渐变。周一周二是深紫色(低活跃度),周三周四过渡到粉紫色,周五橙色(升温),周六青柠绿(峰值),周日青色(回落)。这种颜色与数值的联动设计,使得柱状图不仅通过高度传达数据大小,还通过颜色强化了数据的情绪感知,提升了数据可视化的表现力。

interface CrewItem {
  label: string;
  pct: number;
  color: string;
}

const crewList: CrewItem[] = [
  { label: '光轴主力队 · 平花竞速', pct: 93, color: '#B14BFF' },
  { label: '夜行街头队 · 刹车对抗', pct: 76, color: '#FF4D9D' },
  { label: '滑板混编队 · 碗池街道', pct: 61, color: '#3DE8E0' },
  { label: '新人养成队 · 零基础营', pct: 44, color: '#B8F04D' }
];

CrewItem 接口定义了车队阵营热度排行的数据结构,包含车队标签 label、百分比数值 pct 和进度条颜色 color。车队阵营是本应用社区运营的重要功能,通过将用户划分为不同的车队阵营,营造竞争氛围,提升用户活跃度和粘性。

车队名称和特色的命名设计富有创意。"光轴主力队·平花竞速"定位为核心主力,主打平花和竞速项目,热度最高(93%)。"夜行街头队·刹车对抗"主打街头风格和刹车技巧,热度次之(76%)。"滑板混编队·碗池街道"融合了滑板文化,热度中等(61%)。"新人养成队·零基础营"面向新手用户,热度相对较低(44%)。四个车队形成了不同的定位和梯度,满足了不同水平和兴趣的用户需求。

3.7 全局数据与导航常量

const routeList: RouteItem[] = [
  new RouteItem(1, '滨江灯光带全线', '上海·徐汇滨江', '12.6km', '沥青·平整', '全程照明', '缓坡为主', 4862),
  new RouteItem(2, '二环夜骑环线', '成都·锦城湖', '18.2km', '绿道·塑胶', '照明良好', '两处坡道', 3917),
  new RouteItem(3, '珠江琶醍段', '广东·广州', '8.4km', '砖石·部分粗糙', '霓虹密集', '平路', 3544),
  // ... 共8条路线数据
];

const NAV_LIST: NavItem[] = [
  { icon: '🛼', label: '首页' },
  { icon: '🗺️', label: '路线' },
  { icon: '🎒', label: '装备' },
  { icon: '🧑‍🎤', label: '我的' }
];

全局数据常量是应用内容的基础数据源。项目在文件顶层定义了多个数据数组,包括路线列表 routeList、夜滑计划列表 nightList、装备列表 skateList、轮友动态列表 friendList 以及底部导航列表 NAV_LIST。这些常量在组件加载时即可使用,为界面渲染提供初始数据。

路线数据 routeList 包含八条精选夜滑路线,覆盖了上海、成都、广州、北京、杭州、重庆、苏州、昆明等多个城市。每条路线的数据都经过精心设计,城市名称、距离、路面状况、照明条件、坡度情况、热度值等信息相互呼应,构建出真实可信的路线画像。例如重庆洪崖洞崖线的"复合·多台阶"路面和"大坡警告"坡度,与重庆山城的地理特征高度吻合。

导航常量 NAV_LIST 定义了底部 Tab 的四个入口。每个导航项包含图标和标签两部分,图标使用 emoji 字符,与整体的轻量级设计风格保持一致。四个 Tab 的图标选择也颇具巧思:轮滑鞋(🛼)代表首页社区,地图(🗺️)代表路线探索,背包(🎒)代表装备商城,人物(🧑‍🎤)代表个人中心,图标与功能的对应关系直观易懂。

NavItem 接口的定义虽然简单(只有 iconlabel 两个属性),但它将底部导航的数据结构抽离出来,使得导航配置与渲染逻辑分离。如果需要修改导航项的文字或图标,只需调整数据常量,无需修改 UI 代码。这种数据驱动的设计思想是声明式 UI 开发的最佳实践之一。

除了直接使用的全局常量外,项目还在组件内部维护了一份 @State 状态数组(nightsskatesfriends)。这些状态数组初始值与全局常量相同,但它们是可变的,用于支持增删改等交互操作。全局常量相当于"数据源",状态数组相当于"视图模型",两者各司其职,保证了原始数据的纯净性和视图数据的可变性。

3.8 顶层纯函数工具集

function weekBarH(val: number): string {
  return (val * 0.85).toString() + '%';
}

function lightColor(l: string): string {
  if (l === '全程照明' || l === '灯光强') { return COLORS.lime; }
  if (l === '照明良好') { return COLORS.cyan; }
  if (l === '路灯一般') { return COLORS.orange; }
  return COLORS.red;
}

function nightStatusColor(s: string): string {
  if (s === '热抢') { return COLORS.red; }
  if (s === '报名中') { return COLORS.cyan; }
  if (s === '预告') { return COLORS.orange; }
  return COLORS.sub;
}

顶层纯函数是 ArkTS 项目中常见的工具函数组织方式。这些函数定义在组件外部的文件顶层,不依赖任何组件状态,只根据输入参数计算输出结果,符合函数式编程中"纯函数"的定义。纯函数具有可预测性、可测试性和无副作用等优点,非常适合用于封装数据转换、格式化、映射等通用逻辑。

weekBarH 函数用于计算周统计柱状图的柱体高度。它接收一个数值参数,将其乘以 0.85 后转换为百分比字符串。乘以 0.85 的目的是为柱体预留一定的顶部空间,避免最高的柱子顶到容器上边缘,这是一种常见的图表视觉优化技巧。函数返回的百分比字符串可以直接作为 Column 的 height 属性值,实现了数据到视觉的直接映射。

lightColor 函数是一个典型的语义颜色映射函数。它根据路线的照明条件描述返回对应的颜色值:全程照明和灯光强返回青柠绿(最佳),照明良好返回青色(良好),路灯一般返回橙色(一般),其他情况返回红色(较差)。这种映射逻辑将业务语义与视觉表现解耦,使得 UI 代码中只需调用函数即可获得正确的颜色,无需关心具体的颜色判断规则。

nightStatusColor 函数与 lightColor 类似,用于夜滑计划的状态颜色映射。热抢状态返回红色(紧张刺激),报名中返回青色(正常进行),预告返回橙色(即将到来),其他状态返回次要文字色。颜色的选择与状态的紧急程度和吸引力相匹配,帮助用户通过色彩快速感知活动状态。

function trailX(seed: number, tick: number): number {
  return ((seed * 47 + tick * 9) % 92) + 4;
}

function trailY(seed: number, tick: number): number {
  return ((seed * 31 + tick * 5) % 88) + 6;
}

function neonShadowColor(glow: boolean): string {
  return glow ? '#B14BFF' : '#3DE8E0';
}

function skateIconBg(seed: number): string {
  const arr: string[] = ['#2B2344', '#251E3C', '#2F2650', '#282046'];
  return arr[seed % 4];
}

trailXtrailY 是一对用于计算轮滑轨迹光点位置的函数。它们接收种子值 seed 和计时器节拍 tick 两个参数,通过数学运算生成随时间变化的坐标值。算法使用了乘法和取模的组合来产生伪随机运动效果:seed * 47seed * 31 为每个光点赋予不同的初始偏移量,tick * 9tick * 5 使位置随时间变化,取模运算确保坐标在有效范围内,最后的加法运算提供基础偏移。

这种基于数学公式的动画实现方式非常高效。与逐帧存储动画数据相比,只需一个 tick 变量就能驱动所有光点的运动,内存占用极低。与 CSS 动画相比,这种 JavaScript 驱动的方式可以实现更复杂的运动轨迹和更灵活的控制。当然,由于使用了 setInterval 驱动,动画的流畅度依赖于定时器的精度和主线程的繁忙程度,在复杂场景下可能需要考虑使用 requestAnimationFrame 等更高级的动画方案。

neonShadowColor 函数根据霓虹光晕的开关状态返回不同的颜色,发光时返回紫色,熄灭时返回青色。这种双色交替的设计使得霓虹闪烁效果更加丰富多变,而非简单的明暗切换。skateIconBg 函数则根据索引值返回四种深浅略有差异的背景色之一,为装备图标背景提供微妙的视觉变化,避免了所有图标背景完全一致的单调感。

3.9 主组件状态定义

@Entry
@Component
struct Page {
  @State mainTab: number = 0;
  @State subTab: number = 0;
  @State tick: number = 0;
  @State glow: boolean = false;
  // 弹框状态
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delTarget: string = 'skate';
  @State editRoute: string = '';
  @State editDate: string = '';
  @State addNick: string = '';
  @State addText: string = '';

主组件 Page 的状态变量定义是整个应用的状态中枢。使用 @Entry@Component 装饰器标记为页面入口组件,struct 关键字定义结构体,这是 ArkTS 声明式组件的标准写法。组件内部使用 @State 装饰器定义了多个响应式状态变量,这些变量的变化会触发 UI 的自动更新。

mainTabsubTab 是两个核心的导航状态变量。mainTab 控制底部四大主 Tab 的切换(0-首页、1-路线、2-装备、3-我的),subTab 控制首页内七个内容子 Tab 的切换(0-精选、1-夜滑路线、2-刷街里程、3-车队阵营、4-教学招新、5-装备铺、6-轮友圈)。使用数字索引而非字符串作为 Tab 标识,在数组遍历和条件判断时更加方便高效。

tickglow 是特效相关的状态变量。tick 是一个递增的计数器,作为轨迹光点动画的时间参数;glow 是一个布尔开关,控制霓虹光晕的闪烁状态。两个变量都由 setInterval 定时器驱动更新,tick 每次加 1,glow 每次取反。这种设计将动画的时间驱动与视觉表现分离,tick 提供连续的时间维度,glow 提供离散的开关状态。

弹框相关的状态变量构成了另一组重要状态。三个布尔变量 addModaleditModaldelModal 分别控制三种弹框的显示与隐藏。editIdx 记录当前编辑或删除操作的目标索引,delTarget 记录删除操作的目标类型(‘night’ 或 ‘skate’)。editRouteeditDate 存储编辑夜滑计划时的表单数据,addNickaddText 存储发布动态时的表单数据。

这些弹框相关的状态变量体现了"表单数据与弹框状态分离"的设计模式。弹框的开关状态与表单内容数据各自独立,打开弹框时填充初始数据,用户输入时实时更新表单数据,确认提交时使用表单数据执行业务操作。这种模式使得弹框的显示逻辑和数据逻辑解耦,代码结构更加清晰。

  // 列表
  @State nights: NightItem[] = [
    new NightItem(1, '滨江灯光带·周六场', '本周六 20:00', '免费', '余 15/30', '队长·阿夜', '必须佩戴头盔+护具', '报名中'),
    // ... 共6条夜滑计划数据
  ];
  @State skates: SkateItem[] = [
    new SkateItem(1, '竞速轮滑鞋·Viper Pro', '轮滑鞋', '¥2680', 'Rollerblade', '4×110mm 大三轮', 12, '🔥', '🛼'),
    // ... 共8件装备数据
  ];
  @State friends: SkateFriendItem[] = [
    new SkateFriendItem(1, '队长阿夜', '🌙', '滨江周六场预告:…', 412, 33, '夜滑预告', '1小时前'),
    // ... 共5条动态数据
  ];
  fxTimer: number = -1;

三个 @State 数组 nightsskatesfriends 是应用的核心列表数据,分别对应夜滑计划、装备商品和轮友动态。与顶层常量数组不同,这些状态数组是可变的,支持增删改操作,是列表 UI 的数据来源。

数组的初始化采用内联方式,直接在声明时填充初始数据。这种方式在原型开发和演示项目中非常常见,优点是代码集中、一目了然。但在实际项目中,通常会将数据获取逻辑抽离到网络请求或本地存储模块中,组件初始化时通过异步方式加载数据,以实现数据与视图的真正分离。

fxTimer 是一个普通的实例变量(非 @State),用于存储 setInterval 返回的定时器 ID。它不需要响应式追踪,因为其值的变化不需要触发 UI 更新。将定时器 ID 保存在实例变量中是为了在组件销毁时(aboutToDisappear)能够清除定时器,防止内存泄漏。使用 -1 作为初始值,表示定时器尚未启动,这是一种常见的哨兵值设计。

3.10 生命周期钩子:aboutToAppear 与 aboutToDisappear

  aboutToAppear(): void {
    this.fxTimer = setInterval(() => {
      this.tick = this.tick + 1;
      this.glow = !this.glow;
    }, 580);
  }

  aboutToDisappear(): void {
    clearInterval(this.fxTimer);
  }

生命周期钩子是组件与外部环境交互的重要时机。ArkUI 框架为组件提供了多个生命周期回调函数,aboutToAppearaboutToDisappear 是其中最常用的两个。aboutToAppear 在组件即将出现在屏幕上时调用,适合执行初始化操作;aboutToDisappear 在组件即将从屏幕上移除时调用,适合执行清理操作。

aboutToAppear 中启动了一个特效定时器,这是整个应用动态效果的驱动力。setInterval 设置为 580 毫秒的间隔,每次触发时执行两个操作:tick 计数器加 1,glow 状态取反。580 毫秒的间隔经过精心选择——足够慢以保证性能友好,又足够快以维持视觉上的闪烁感。

定时器回调函数使用箭头函数定义,这一点非常重要。箭头函数会捕获定义时的 this 上下文,确保在回调执行时 this 仍然指向组件实例。如果使用普通函数定义,setInterval 回调中的 this 会指向全局对象(或 undefined),导致无法正确访问组件的状态变量。

aboutToDisappear 中清除了定时器,这是一个必须的资源清理操作。如果不清除定时器,即使组件已经被销毁,定时器回调仍然会继续执行,试图访问已销毁的组件状态,可能导致内存泄漏或运行时错误。在组件开发中,"谁创建谁销毁"是一条重要的资源管理原则,定时器、事件监听、订阅连接等都需要在合适的生命周期节点进行清理。

值得注意的是,tick 的值会随着时间不断增长,如果应用长时间运行,这个数值可能会变得很大。虽然在实际使用中用户不会长时间停留在同一个页面,但从技术严谨性的角度出发,可以考虑在适当的时候对 tick 进行取模重置,例如 this.tick = (this.tick + 1) % 10000,防止数值溢出。

3.11 弹框控制方法

  openAdd(): void {
    this.addNick = '';
    this.addText = '';
    this.addModal = true;
    this.editModal = false;
    this.delModal = false;
  }

  openEdit(idx: number): void {
    this.editIdx = idx;
    this.editRoute = this.nights[idx].route;
    this.editDate = this.nights[idx].date;
    this.addModal = false;
    this.editModal = true;
    this.delModal = false;
  }

弹框控制方法是连接用户交互与弹框展示的桥梁。项目定义了四个打开弹框的方法:openAdd(发布动态)、openEdit(编辑夜滑计划)、openDelNight(删除夜滑计划)、openDelSkate(删除装备)。每个方法都遵循相似的模式:设置相关数据、打开目标弹框、确保其他弹框关闭。

openAdd 方法的逻辑比较简单:清空昵称和内容输入框、打开发布弹框。清空输入框是为了每次打开弹框时都有一个干净的初始状态,避免上一次的输入残留影响用户体验。同时显式将另外两个弹框状态设为 false,虽然在当前逻辑下三个弹框不会同时打开,但这种防御性编程可以避免状态不一致的风险。

openEdit 方法稍复杂一些,它需要接收一个索引参数 idx,并根据索引从 nights 数组中取出对应的数据填充到编辑表单中。这种"打开时填充数据"的模式在编辑场景中非常典型:用户点击编辑按钮时,将当前数据加载到表单中供用户修改,用户确认后再将表单数据写回原数据对象。

方法中同时设置了 editIdx 变量,用于记录当前编辑的是哪一项。这个索引在保存修改时(doEdit 方法)会被用来定位要更新的数组元素。使用索引而非 ID 来定位元素在小型应用中是可行的,但在数据可能被增删的场景下,索引可能会发生变化,此时更安全的做法是使用唯一 ID 进行查找。

  openDelNight(idx: number): void {
    this.delTarget = 'night';
    this.editIdx = idx;
    this.addModal = false;
    this.editModal = false;
    this.delModal = true;
  }

  openDelSkate(idx: number): void {
    this.delTarget = 'skate';
    this.editIdx = idx;
    this.addModal = false;
    this.editModal = false;
    this.delModal = true;
  }

openDelNightopenDelSkate 是两个删除确认弹框的打开方法。它们共用同一个删除确认弹框(delModal),通过 delTarget 变量来区分删除的目标类型(夜滑计划或装备)。这种"一框多用"的设计模式减少了重复代码,提高了弹框组件的复用性。

两个方法的代码高度相似,只有 delTarget 的赋值不同。从代码重构的角度来看,可以进一步抽象为一个通用的 openDel(target: string, idx: number) 方法,通过参数传递目标类型。但当前这种分开定义的方式也有其优势:方法名语义明确,调用点的可读性更好,且每个方法可以独立扩展(例如将来删除夜滑计划和删除装备的前置逻辑可能不同)。

在每个打开弹框的方法中,都显式地将其他弹框的状态设为 false。这种做法确保了同一时间只有一个弹框处于打开状态,维持了弹框系统的状态一致性。虽然在正常的用户操作流程中不会出现同时打开多个弹框的情况,但作为基础设施代码,处理好边界情况是良好的工程实践。

3.12 弹框数据操作方法

  doAdd(): void {
    if (this.addText.length > 0) {
      const n = this.friends.length + 1;
      this.friends.push(new SkateFriendItem(n, this.addNick.length > 0 ? this.addNick : '新轮友', '🛼', this.addText, 0, 0, '新动态', '刚刚'));
      this.addModal = false;
    }
  }

doAdd 方法处理发布轮友动态的提交逻辑。方法首先检查内容文本是否非空,这是一个基本的表单验证——空内容的动态没有意义,不应该被提交。验证通过后,创建一个新的 SkateFriendItem 实例并添加到 friends 数组的末尾。

新动态的 ID 计算方式比较简单:this.friends.length + 1。这种方式在只有添加操作时可以正常工作,但如果存在删除操作,就可能出现 ID 重复的问题(例如删除了最后一条再添加新的,ID 会和被删除的那条相同)。在实际项目中,通常会使用更可靠的 ID 生成策略,如自增计数器、时间戳、UUID 等。

昵称的处理有一个默认值逻辑:如果用户填写了昵称就使用用户输入的昵称,否则使用默认的"新轮友"。这种设计降低了发布门槛,用户可以匿名发布动态。头像使用固定的轮滑鞋 emoji(🛼),新动态的标签设为"新动态",时间设为"刚刚",点赞数和评论数初始化为 0,这些都符合社交应用的常规设计。

  doEdit(): void {
    if (this.editIdx >= 0 && this.editIdx < this.nights.length) {
      this.nights[this.editIdx].route = this.editRoute;
      this.nights[this.editIdx].date = this.editDate;
      this.editModal = false;
    }
  }

doEdit 方法处理夜滑计划编辑的保存逻辑。方法首先进行边界检查,确保 editIdx 在有效范围内。这是一个非常重要的防御性检查,防止数组越界访问导致的运行时错误。特别是在数据可能被异步修改的场景下,这种检查尤为必要。

检查通过后,将表单中的 editRouteeditDate 值写回到 nights 数组中对应索引的元素上。由于 NightItem 类使用了 @Observed 装饰器,属性的赋值操作会被框架追踪,UI 会自动更新以反映修改后的数据。整个过程无需手动刷新列表、无需触发重绘,这就是声明式 UI 响应式编程的优雅之处。

编辑完成后关闭弹框,用户可以立即在列表中看到修改后的结果。这种即时反馈的体验非常流畅,用户无需离开当前页面或手动刷新就能确认修改是否生效。

  doDel(): void {
    if (this.delTarget === 'night') {
      if (this.editIdx >= 0 && this.editIdx < this.nights.length) {
        this.nights.splice(this.editIdx, 1);
        this.delModal = false;
      }
    } else {
      if (this.editIdx >= 0 && this.editIdx < this.skates.length) {
        this.skates.splice(this.editIdx, 1);
        this.delModal = false;
      }
    }
  }

doDel 方法处理删除操作,根据 delTarget 的值执行不同的删除逻辑。如果目标是 ‘night’,则从 nights 数组中删除对应索引的元素;如果目标是 ‘skate’(或其他值,作为默认分支),则从 skates 数组中删除。

使用 splice 方法删除数组元素是 JavaScript/TypeScript 中的标准做法。splice(this.editIdx, 1) 表示从索引 editIdx 开始删除 1 个元素。由于 skatesnights 都是 @State 装饰的数组,数组内容的变化会被框架检测到,列表 UI 会自动更新,被删除的项会从界面上消失。

方法中嵌套了两层边界检查:外层通过 delTarget 判断删除类型,内层检查索引是否在有效范围内。这种双重检查确保了删除操作的安全性。不过,代码中 else 分支处理的是非 ‘night’ 的所有情况,包括 ‘skate’ 和其他可能的值。如果 delTarget 被意外设置为无效值,删除操作仍然会执行(针对 skates 数组),这可能不是预期行为。更严谨的做法是使用 else if (this.delTarget === 'skate') 明确判断,而在 else 分支中处理异常情况。

3.13 特效层 Builder:霓虹光晕与轨迹光点

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2], (s: number) => {
        Column() {
          Text('✦')
            .fontSize(14 + s * 6)
            .fontColor(neonShadowColor(this.glow))
            .textShadow({ radius: 12, color: neonShadowColor(this.glow), offsetX: 0, offsetY: 0 })
            .opacity(this.glow ? 1 : 0.35)
        }
        .position({ x: 10 + s * 32, y: 12 + s * 10 })
      }, (s: number) => s.toString())

fxLayer 是特效层的构建函数,使用 @Builder 装饰器标记。@Builder 是 ArkTS 中的重要特性,它允许将 UI 片段封装为可复用的函数,在 build() 方法中通过 this.xxx() 的方式调用。与自定义组件相比,@Builder 更轻量,适合封装组件内部的 UI 片段。

特效层使用 Stack 布局作为容器,因为多个光点元素需要叠加在一起,通过 position 属性进行绝对定位。Stack 的对齐方式设为 TopStart,即左上角对齐,这样 position 的坐标原点就在容器的左上角,符合常规的坐标系习惯。

第一组特效是三枚霓虹光晕星号(✦),通过 ForEach 遍历 [0, 1, 2] 数组生成。每个星号的大小、位置和颜色都根据索引 s 计算得出:字号从 14px 开始,每个递增 6px;水平位置从 10px 开始,每个右移 32px;垂直位置从 12px 开始,每个下移 10px。这种参数化的生成方式使得三个星号形成错落有致的视觉效果。

星号的颜色和透明度都与 this.glow 状态联动。neonShadowColor 函数根据 glow 的值在紫色和青色之间切换,opacity 在 1 和 0.35 之间切换。配合 textShadow 属性添加的发光阴影,形成了霓虹灯一明一暗的闪烁效果。阴影的半径设为 12,偏移量为 0,这意味着光晕均匀地向四周扩散,呈现出柔和的发光效果。

      ForEach([3, 4, 5, 6, 7, 8, 9, 10], (s: number) => {
        Column() {
          Text('·')
            .fontSize(s % 3 === 0 ? 12 : 8)
            .fontColor(s % 2 === 0 ? COLORS.pink : COLORS.cyan)
            .opacity(this.glow ? 0.95 : 0.2)
        }
        .position({ x: trailX(s, this.tick), y: trailY(s + 2, this.tick) })
      }, (s: number) => s.toString())
    }
    .width('100%')
    .height('100%')
    .clip(true)
  }

第二组特效是八个轮滑轨迹光点(·),通过 ForEach 遍历 [3, 4, 5, 6, 7, 8, 9, 10] 生成。这些光点模拟了轮滑鞋在地面上留下的闪光轨迹,是整个特效层的动态核心。

光点的大小和颜色都通过索引的模运算来产生变化。每第三个光点(索引能被 3 整除)字号为 12px,其余为 8px,形成大小错落的效果。颜色则根据索引的奇偶性在粉色和青色之间交替,丰富了色彩层次。

光点的位置是动态计算的,通过 trailXtrailY 函数根据种子值 s 和当前节拍 this.tick 计算得出。随着 tick 的不断递增,每个光点的位置都会持续变化,形成光点在界面上"划过"的动画效果。由于每个光点有不同的种子值,它们的运动轨迹各不相同,整体呈现出一种随机而有序的视觉效果。

光点的透明度同样与 glow 状态联动,发光时几乎不透明(0.95),熄灭时非常暗淡(0.2)。这种同步闪烁使得霓虹光晕和轨迹光点形成统一的视觉节奏,增强了整体的协调感。

特效层容器设置了 clip(true) 属性,确保超出容器边界的光点部分会被裁剪掉,不会溢出到其他区域。特效层的尺寸设为 100% 宽高,使其充满父容器,在后续的布局中,它会被精确地放置在内容区域之上。

3.14 头部 Builder:潮流运动电商风格

  @Builder
  header() {
    Column({ space: 10 }) {
      Row({ space: 8 }) {
        Column() { Text('📍').fontSize(15) }
        .width(32).height(32).borderRadius(16).backgroundColor(COLORS.card)
        .justifyContent(FlexAlign.Center)
        Column({ space: 2 }) {
          Text('徐汇·滨江灯光带').fontSize(11).fontColor(COLORS.sub)
          Text('晴夜 24°C · 风力2级·宜刷街').fontSize(9).fontColor(COLORS.cyan)
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Row({ space: 6 }) {
          Column() { Text('🔍').fontSize(14) }
          .width(30).height(30).borderRadius(15).backgroundColor(COLORS.card)
          .justifyContent(FlexAlign.Center)
          Column() { Text('🛒').fontSize(14) }
          .width(30).height(30).borderRadius(15).backgroundColor(COLORS.card)
          .justifyContent(FlexAlign.Center)
        }
      }
      .width('100%')

header 构建函数负责渲染页面头部区域,采用典型的潮流运动电商风格设计。头部整体使用 Column 垂直布局,内部包含三行内容:定位与搜索栏、今夜刷街大卡、热门路线横滚列表。各行之间通过 space: 10 设置间距,形成清晰的视觉分区。

第一行是定位信息和功能入口的横向布局。左侧是定位图标和位置天气信息:一个圆形图标按钮显示定位 emoji(📍),旁边是位置名称和天气提示。位置名称"徐汇·滨江灯光带"使用次要文字色,天气信息"晴夜 24°C · 风力2级·宜刷街"使用青色强调色,并加入"宜刷街"的趣味文案,增强了运动社区的氛围。

中间使用 Column().layoutWeight(1) 作为弹性占位,将左右两侧的内容推开。这是 ArkUI 中实现两端对齐布局的常用技巧,类似于 CSS 中的 flex: 1margin-left: auto

右侧是搜索和购物车两个功能入口,以圆形图标的形式呈现。图标按钮统一使用卡片背景色和圆角设计,与定位按钮的视觉风格保持一致。搜索和购物车是电商类应用的标配功能入口,放置在头部右上角符合用户的使用习惯。

所有图标按钮都采用"Column 套 Text"的结构实现,通过设置 Column 的宽高和圆角形成圆形按钮,内部的 Text 显示 emoji 图标。这种实现方式简洁高效,无需额外的图片资源,同时保持了视觉上的一致性。

      // 今夜刷街大卡
      Row({ space: 12 }) {
        Column({ space: 3 }) {
          Text('今夜全城上街人数').fontSize(10).fontColor('#EBD9FF')
          Text('2317').fontSize(30).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          Text('人 · 同比 +32%').fontSize(9).fontColor('#EBD9FF')
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Column({ space: 5 }) {
          Row({ space: 4 }) {
            ForEach([0, 1, 2, 3, 4, 5], (b: number) => {
              Column()
                .width(9)
                .height(b < 4 ? 8 + b * 7 : 0)
                .borderRadius(4)
                .backgroundColor(b < 3 ? COLORS.pink : COLORS.cyan)
            }, (b: number) => b.toString())
          }
          .alignItems(VerticalAlign.Bottom)
          Text('夜滑活跃度 · 爆表').fontSize(8).fontColor('#EBD9FF')
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.neonDeep)
      .linearGradient({
        angle: 135,
        colors: [['#7A2CCB', 0], ['#FF4D9D', 1]]
      })

"今夜刷街大卡"是头部最醒目的视觉元素,一个渐变背景的大卡片展示今夜的夜滑数据。卡片使用紫粉渐变(从 #7A2CCB#FF4D9D,135度角),营造出强烈的霓虹潮流感。

卡片左侧展示核心数据:“今夜全城上街人数"和醒目的数字"2317”,以及同比增长"+32%"的趋势信息。大数字使用 30px 的粗体白色文字,是整个卡片的视觉焦点。这种"大数字+小标签"的信息展示方式是数据卡片的经典设计,能够快速传达关键指标。

卡片右侧是一个简化的活跃度柱状图,由六个小柱体组成。前四个柱体有高度(从 8px 递增到 29px),后两个高度为 0(不可见),形成"爆表"的视觉隐喻。前三根柱子为粉色,第四根为青色,颜色变化增加了图表的层次感。柱状图下方标注"夜滑活跃度 · 爆表",强化了数据的趣味性。

整个大卡片使用 linearGradient 属性设置渐变背景,这是 ArkUI 提供的强大视觉属性。渐变角度设为 135 度,即从左上到右下的方向,颜色数组中每个元素是一个 [颜色值, 位置] 的二元组,0 代表起点,1 代表终点。渐变效果使得卡片不再是单调的纯色块,而是具有丰富的色彩层次和视觉深度。

      // 热门路线横滚
      Scroll() {
        Row({ space: 10 }) {
          ForEach(routeList, (r: RouteItem) => {
            Column({ space: 4 }) {
              Text('🛼').fontSize(18)
              Text(r.name.length > 7 ? r.name.slice(0, 7) : r.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
              Text(r.dist).fontSize(8).fontColor(COLORS.cyan)
            }
            .width(92).padding({ top: 8, bottom: 8 }).borderRadius(10)
            .backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          }, (r: RouteItem) => r.id.toString())
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .height(72)

头部的第三部分是热门路线横向滚动列表。使用 Scroll 组件包裹 Row,并设置 scrollable(ScrollDirection.Horizontal) 实现横向滚动。滚动条设为隐藏状态(BarState.Off),保持界面的简洁美观。

列表中的每个路线卡片都是固定宽度(92px)的纵向布局,从上到下依次是轮滑鞋图标、路线名称和距离。路线名称做了长度截断处理,如果超过 7 个字符就截取前 7 个,避免文字过长导致布局错乱。这种截断处理在移动端列表中非常常见,配合省略号或截断提示可以获得更好的视觉效果。

卡片背景使用卡片色,与头部的深色背景形成对比,增强了卡片的层次感。距离文字使用青色强调色,与整体的霓虹配色体系保持一致。整个滚动区域的高度固定为 72px,确保在有限的头部空间内展示足够的内容,同时不会占用过多的垂直空间。

热门路线横滚列表是一种高效的内容导航方式。它在有限的横向空间内以卡片流的形式展示多个路线入口,用户可以通过左右滑动浏览更多选项,点击卡片即可进入对应的路线详情。这种设计既丰富了头部的内容层次,又提供了便捷的导航入口。

3.15 内容 Tab 导航 Builder:两排 4+3 布局

在这里插入图片描述

  @Builder
  subNav() {
    Column({ space: 8 }) {
      Row({ space: 6 }) {
        ForEach(['精选', '夜滑路线', '刷街里程', '车队阵营'], (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(10)
              .fontColor(this.subTab === i ? COLORS.white : COLORS.sub)
              .fontWeight(this.subTab === i ? FontWeight.Bold : FontWeight.Normal)
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .borderRadius(14)
          .backgroundColor(this.subTab === i ? COLORS.neon : COLORS.card)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.subTab = i; })
        }, (t: string) => t)
      }
      .width('100%')

subNav 构建函数负责渲染首页的七个内容子 Tab 导航,采用两排 4+3 的布局形式。第一排四个 Tab(精选、夜滑路线、刷街里程、车队阵营),第二排三个 Tab(教学招新、装备铺、轮友圈)。这种双排布局在 Tab 数量较多时比单排更节省空间,同时避免了 Tab 文字过小或横向滚动带来的操作不便。

每个 Tab 项使用 Column 包裹 Text,通过 layoutWeight(1) 实现等分宽度,确保同一排的所有 Tab 宽度一致。Tab 的背景色、文字色和字重都根据选中状态(this.subTab === i)动态变化:选中时使用霓虹紫背景配白色粗体文字,未选中时使用卡片色背景配次要文字色。这种多维度的状态反馈(颜色+字重+背景)使得选中状态非常明确,用户可以快速识别当前所在的 Tab。

点击事件通过 onClick 设置,点击时将 subTab 设为对应的索引值。由于 subTab@State 变量,赋值操作会触发 UI 更新,Tab 的选中样式和下方的内容区域都会同步切换。整个交互过程流畅自然,完全由声明式状态驱动。

ForEach 的第二个参数(键生成函数)使用 Tab 文本 t 作为键值。由于 Tab 名称是唯一的,使用名称作为键可以确保列表项的正确复用。与使用索引作为键相比,使用数据本身的唯一标识作为键在列表数据变化时能够提供更准确的 Diff 计算和更稳定的动画效果。

      Row({ space: 6 }) {
        ForEach(['教学招新', '装备铺', '轮友圈'], (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(10)
              .fontColor(this.subTab === i + 4 ? COLORS.white : COLORS.sub)
              .fontWeight(this.subTab === i + 4 ? FontWeight.Bold : FontWeight.Normal)
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .borderRadius(14)
          .backgroundColor(this.subTab === i + 4 ? COLORS.neon : COLORS.card)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.subTab = i + 4; })
        }, (t: string) => t)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
  }

第二排 Tab 的实现方式与第一排基本相同,只有两个关键区别。一是 Tab 数量为三个而非四个,由于同样使用 layoutWeight(1) 等分宽度,每个 Tab 会比第一排的更宽一些,形成上密下疏的视觉节奏。

二是索引计算方式不同:第二排的选中判断使用 this.subTab === i + 4,点击赋值使用 this.subTab = i + 4。这是因为 subTab 是一个连续的 0-6 索引,第一排占 0-3,第二排占 4-6,所以第二排的索引需要加上偏移量 4。这种设计虽然简单直接,但存在一定的耦合性——如果第一排的 Tab 数量发生变化,第二排的偏移量也需要相应调整。

整个子 Tab 导航区域的外边距设置为左右 12px、上下 8px,与头部和内容区域的间距保持协调。两排 Tab 之间的间距为 8px,同一排内 Tab 之间的间距为 6px,通过不同层级的间距营造出清晰的视觉结构。

从架构设计的角度来看,将七个 Tab 的名称硬编码在两个 ForEach 数组中,虽然实现简单,但可维护性一般。如果 Tab 数量或顺序需要调整,需要同时修改两处代码。一种更优雅的实现方式是将七个 Tab 名称放在一个数组中,然后通过 Array.slice() 分别取出前四个和后三个进行渲染,实现数据与布局的分离。

3.16 精选 Tab Builder:横幅横滚 + 大数字路线榜

  @Builder
  tabPick() {
    Scroll() {
      Column({ space: 10 }) {
        Scroll() {
          Row({ space: 10 }) {
            ForEach([0, 1, 2], (b: number) => {
              Column({ space: 6 }) {
                Text(b === 0 ? '🛼 夜滑季开滑' : (b === 1 ? '✨ 夜光轮上新' : '🎓 零基础免费学'))
                  .fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
                Text(b === 0 ? '每周三六 全城集结' : (b === 1 ? '七彩感应 回头率拉满' : '奥体新人场报名'))
                  .fontSize(9).fontColor('#EBD9FF')
                Row({ space: 4 }) {
                  Text(b === 0 ? '去集结' : (b === 1 ? '去抢购' : '去报名')).fontSize(9)
                    .fontColor(COLORS.neonDeep).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                    .borderRadius(10)
                    .backgroundColor(COLORS.white)
                }
              }
              .width(200).padding(14).borderRadius(14).alignItems(HorizontalAlign.Start)
              .backgroundColor(b === 0 ? COLORS.neonDeep : (b === 1 ? COLORS.pink : COLORS.cyan))
            }, (b: number) => b.toString())
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

tabPick 是精选 Tab 的内容构建函数,是首页的默认展示内容。精选页面内容最丰富、信息量最大,包含横幅轮播、天气时段卡片和热门路线排行榜三个主要模块。

顶部的横幅横滚区域是运营活动的重要展示位。三张横幅卡片分别推广"夜滑季开滑"“夜光轮上新”"零基础免费学"三个主题,对应活动、商品和教学三条业务线。每张横幅都是固定宽度(200px)的纵向布局,包含标题、副标题和行动按钮三行文字。

横幅的背景色各不相同:第一张用深霓虹紫,第二张用粉色,第三张用青色。多彩的背景色形成了强烈的视觉冲击力,吸引用户注意力。行动按钮使用白色背景配深霓虹紫文字,在彩色背景上格外醒目,引导用户点击参与。

横幅内容使用三元表达式根据索引 b 动态生成,这种方式在只有少量固定项的情况下比较简洁。但如果横幅数量增加或内容需要动态配置,更好的做法是定义一个横幅数据数组,将标题、副标题、按钮文字、背景色等信息结构化存储,然后通过 ForEach 遍历渲染。

        Text('— 今晚热门路线 —').fontSize(11).fontColor(COLORS.sub).width('100%').textAlign(TextAlign.Center)

        Row({ space: 10 }) {
          Column({ space: 6 }) {
            Text('🌡️').fontSize(26)
            Text('夜滑条件').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('晴夜 24°C · 风力 2 级').fontSize(9).fontColor(COLORS.cyan)
            Text('能见度佳·适宜长距').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12)
          .backgroundColor(COLORS.card).alignItems(HorizontalAlign.Start)

          Column({ space: 6 }) {
            Text('⏱️').fontSize(26)
            Text('黄金时段').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('20:00-22:30').fontSize(9).fontColor(COLORS.pink)
            Text('灯光全开·人流适中').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12)
          .backgroundColor(COLORS.card).alignItems(HorizontalAlign.Start)
        }
        .width('100%')

分隔标题"今晚热门路线"使用居中对齐的装饰性文字,左右用破折号延伸,是一种常见的列表分隔设计。下方是两张并排的信息卡片:夜滑条件和黄金时段,分别用天气和时钟 emoji 作为图标,直观易懂。

夜滑条件卡片展示天气温度和风力信息,用青色强调具体数值,提示用户当前的夜滑环境条件。黄金时段卡片展示最佳夜滑时间 20:00-22:30,用粉色强调时间段,告诉用户什么时候出门最合适。两张卡片布局完全对称,使用 layoutWeight(1) 等分宽度,形成规整的双列布局。

这两张卡片虽然信息简单,但对夜滑用户来说非常实用。天气和时间是决定夜滑体验的关键因素,将这些信息前置展示,可以帮助用户快速判断今晚是否适合刷街、几点出门最好,体现了应用的场景化思维。

        // 大数字热度路线榜
        ForEach(routeList, (r: RouteItem, i: number) => {
          Row({ space: 12 }) {
            Column() {
              Text((i + 1).toString()).fontSize(18)
                .fontColor(COLORS.white).fontWeight(FontWeight.Bold)
            }
            .width(44).height(52).borderRadius(12)
            .backgroundColor(i === 0 ? COLORS.pink : (i < 3 ? COLORS.neon : COLORS.purple))
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)
            Column({ space: 4 }) {
              Text(r.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(r.city + ' · ' + r.surface).fontSize(8).fontColor(COLORS.sub)
              Row({ space: 6 }) {
                Text(r.dist).fontSize(8).fontColor(COLORS.cyan)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(6).backgroundColor(COLORS.card2)
                Text(r.light).fontSize(8).fontColor(lightColor(r.light))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(6).backgroundColor(COLORS.card2)
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 2 }) {
              Text(r.hot.toString()).fontSize(14).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
              Text('热度').fontSize(7).fontColor(COLORS.sub)
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (r: RouteItem) => r.id.toString())

热门路线排行榜是精选页面的核心内容,以列表形式展示八条路线,按热度从高到低排列。每条路线采用横向三栏布局:左侧是排名数字,中间是路线信息,右侧是热度值。

排名数字的设计是一个亮点。第一名使用粉色背景,第二名和第三名使用霓虹紫背景,第四名及以后使用紫色背景。通过背景色的区分,前三名显得格外醒目,强化了排行榜的竞争感。数字使用白色粗体,字号 18px,在彩色背景上非常突出。数字块的尺寸为 44×52px,略带长方形,呈"奖牌"的视觉意象。

中间的路线信息区域是内容的主体,包含路线名称、城市路面信息、距离标签和照明标签。路线名称使用 12px 粗体标题色,是最主要的信息。城市和路面信息组合在一行,使用次要文字色,作为补充说明。距离和照明信息以标签形式展示,使用卡片二级背景色作为标签底色,距离用青色、照明用动态颜色(根据照明条件变化),形成清晰的信息标签体系。

右侧的热度值使用粉色粗体大字号展示,配合"热度"小字标签,直观地呈现每条路线的受欢迎程度。粉色作为强调色在暗色背景上非常醒目,引导用户关注热度数据,激发从众心理。

3.17 夜滑路线 Tab Builder:三列属性卡

  @Builder
  tabRoute() {
    Scroll() {
      Column({ space: 10 }) {
        Text('全国夜滑圣地 TOP8').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach(routeList, (r: RouteItem, i: number) => {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Column() { Text('🛼').fontSize(22) }
              .width(44).height(44).borderRadius(22)
              .border({ width: 2, color: i < 3 ? COLORS.pink : COLORS.line })
              .backgroundColor(COLORS.card2)
              .justifyContent(FlexAlign.Center)
              Column({ space: 3 }) {
                Text(r.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(r.city + ' · 坡况:' + r.slope).fontSize(8).fontColor(COLORS.sub)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('No.' + (i + 1).toString()).fontSize(10).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
            }
            .width('100%')

tabRoute 是夜滑路线 Tab 的内容构建函数,以卡片列表的形式展示全国八大夜滑圣地。与精选页面的排行榜不同,这里的路线卡片信息更全面,采用"标题行+属性行"的双层结构。

每张卡片的标题行包含三个元素:左侧是圆形图标,中间是路线名称和副信息,右侧是排名编号。圆形图标使用轮滑鞋 emoji,外圈有 2px 的边框,前三名使用粉色边框,其余使用线条色边框,以此突出前三名的特殊地位。这种边框高亮的方式比精选页面的全背景数字块更加含蓄,但同样能传达排名的层次感。

路线名称使用粗体标题色,是视觉焦点。副标题展示城市和坡况信息,使用次要文字色。排名编号"No.X"使用青色粗体,放置在卡片右上角,简洁明了。

            Row({ space: 6 }) {
              Column({ space: 2 }) {
                Text('距离').fontSize(7).fontColor(COLORS.sub)
                Text(r.dist).fontSize(10).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
              }
              .layoutWeight(1).padding(8).borderRadius(10).backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
              Column({ space: 2 }) {
                Text('路面').fontSize(7).fontColor(COLORS.sub)
                Text(r.surface).fontSize(10).fontColor(COLORS.lime).fontWeight(FontWeight.Bold)
              }
              .layoutWeight(1).padding(8).borderRadius(10).backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
              Column({ space: 2 }) {
                Text('照明').fontSize(7).fontColor(COLORS.sub)
                Text(r.light).fontSize(9).fontColor(lightColor(r.light)).fontWeight(FontWeight.Bold)
              }
              .layoutWeight(1).padding(8).borderRadius(10).backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
              Column({ space: 2 }) {
                Text('热度').fontSize(7).fontColor(COLORS.sub)
                Text(r.hot.toString()).fontSize(10).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
              }
              .layoutWeight(1).padding(8).borderRadius(10).backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
            }
            .width('100%')
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
          .border({ width: i < 3 ? 1 : 0, color: COLORS.neon })
        }, (r: RouteItem) => r.id.toString())
      }
      .width('100%')
      .padding(12)
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }

卡片的第二行是四列属性标签,分别展示距离、路面、照明和热度四个核心维度。每个属性都是一个独立的小卡片,使用卡片二级背景色,内部纵向排列标签名和属性值。标签名使用 7px 的次要文字色,属性值使用 10px 的彩色粗体,形成清晰的主次层次。

四个属性使用四种不同的强调色:距离用青色、路面用青柠绿、照明用动态色(根据照明条件变化)、热度用粉色。色彩的语义化使用使得用户可以通过颜色快速识别信息类型,提高了信息的获取效率。特别是照明属性使用 lightColor 函数动态着色,用户一眼就能看出路线照明条件的好坏。

卡片的外边框也有排名差异化设计:前三名卡片有 1px 的霓虹色边框,其余卡片没有边框。这是一种微妙的视觉强化方式,既突出了热门路线,又不会过于张扬。配合标题行图标的边框颜色变化,形成了多维度的排名视觉体系。

整个列表使用 Scroll 组件包裹,支持垂直滚动浏览。滚动条设置为隐藏状态,保持界面的简洁。列表内容区域的内边距为 12px,卡片之间的间距为 10px,空间分配合理,信息密度适中。

3.18 刷街里程 Tab Builder:周柱状图 + 车队看板

在这里插入图片描述

  @Builder
  tabMile() {
    Scroll() {
      Column({ space: 12 }) {
        Text('本周全城夜滑里程(万米)').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        Row({ space: 10 }) {
          ForEach(weekCharts, (c: WeekChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(20)
                .height(weekBarH(c.val))
                .borderRadius(6)
                .backgroundColor(c.color)
              Text(c.day).fontSize(8).fontColor(COLORS.sub)
            }
            .alignItems(HorizontalAlign.Center)
          }, (c: WeekChartItem) => c.day)
        }
        .width('100%')
        .height(150)
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .alignItems(VerticalAlign.Bottom)

tabMile 是刷街里程 Tab 的内容构建函数,包含周统计柱状图、车队阵营热度排行和安全提示三个模块。页面以数据可视化为主,直观展示夜滑运动的统计信息。

周柱状图是页面的核心可视化组件,展示一周七天的夜滑里程数据。每个柱子是一个固定宽度(20px)的 Column,高度通过 weekBarH 函数根据数值动态计算。柱子使用圆角(borderRadius: 6)呈现,视觉上更加柔和友好。柱子下方是星期标签,使用次要文字色。

柱状图的容器高度固定为 150px,底部对齐(alignItems(VerticalAlign.Bottom)),这样柱子会从底部向上生长,符合常规柱状图的视觉习惯。容器使用卡片背景色和圆角边框,形成一个独立的图表卡片区域。

每根柱子的颜色由数据项的 color 属性决定,从周一到周日呈现紫-粉-橙-绿-青的渐变序列。这种多彩的柱状图设计比单色柱状图更具视觉吸引力,同时颜色的冷暖变化也暗含了活跃度从低到高再回落的情绪表达。

        Text('车队阵营热度').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach(crewList, (c: CrewItem) => {
          Column({ space: 6 }) {
            Row({ space: 8 }) {
              Text('🏁').fontSize(14)
              Text(c.label).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
              Column().layoutWeight(1)
              Text(c.pct.toString() + '%').fontSize(10).fontColor(c.color).fontWeight(FontWeight.Bold)
            }
            .width('100%')
            Row() {
              Column().height(8).borderRadius(4).backgroundColor(c.color).width(c.pct.toString() + '%')
            }
            .width('100%')
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card2)
        }, (c: CrewItem) => c.label)

车队阵营热度排行是另一组数据可视化内容,以进度条的形式展示四个车队的热度百分比。每行包含车队名称、百分比数值和进度条三部分。车队名称前面有一个终点旗 emoji(🏁),呼应竞速比赛的主题。

百分比数值使用与进度条相同的颜色显示,数值和图形形成呼应。进度条是一个高度为 8px 的圆角柱体,宽度通过百分比字符串设置,直接使用 c.pct.toString() + '%' 作为 width 属性值。这种将数据值直接映射为宽度百分比的做法非常简洁高效,ArkUI 框架会自动计算实际像素宽度。

车队卡片使用卡片二级背景色,与上方的主卡片形成层次区分。四张车队卡片纵向排列,每张之间通过外层 Column 的 space: 12 设置间距。卡片内的信息行和进度条之间的间距为 6px,信息密度合理。

        Text('刷街安全三条').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        Row({ space: 8 }) {
          Column({ space: 4 }) {
            Text('⛑️').fontSize(20)
            Text('戴头盔').fontSize(10).fontColor(COLORS.title)
            Text('夜滑保命第一件').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('✨').fontSize(20)
            Text('装反光').fontSize(10).fontColor(COLORS.title)
            Text('夜光轮+反光条').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('🛑').fontSize(20)
            Text('靠右行').fontSize(10).fontColor(COLORS.title)
            Text('禁止并排占道').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
        }
        .width('100%')
      }

刷街安全三条是页面底部的安全教育模块。三张并排的卡片以图文结合的方式展示三条核心安全规则:戴头盔、装反光、靠右行。每张卡片顶部是一个相关 emoji 图标(头盔、闪光、停止标志),中间是规则名称,底部是补充说明。

三卡等宽排列,使用 layoutWeight(1) 实现均分。卡片使用主卡片背景色,与上方车队卡片的二级背景形成对比。图标使用 20px 字号,标题使用 10px 标题色,说明文字使用 8px 次要文字色,信息层级清晰。

安全提示放在里程统计页面是一个很有意义的设计选择。用户在查看自己的运动数据时,往往处于对运动最关注的心理状态,此时呈现安全提示更容易被用户接受和记住。这种"数据+教育"的组合方式,体现了运动类应用对用户安全的责任担当。

3.19 车队阵营 Tab Builder:对阵卡

  @Builder
  tabCrew() {
    Scroll() {
      Column({ space: 10 }) {
        Text('本周车队对抗榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach([0, 1, 2, 3], (k: number) => {
          Row({ space: 10 }) {
            Column({ space: 4 }) {
              Text(k % 2 === 0 ? '光轴主力队' : '夜行街头队').fontSize(11).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
              Text('积分 ' + (286 - k * 42).toString()).fontSize(8).fontColor(COLORS.sub)
            }
            .layoutWeight(1).padding(10).borderRadius(12)
            .backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)

            Column({ space: 2 }) {
              Text('VS').fontSize(13).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
              Text('第' + (k + 1).toString() + '场').fontSize(7).fontColor(COLORS.sub)
            }
            .width(44)
            .alignItems(HorizontalAlign.Center)

            Column({ space: 4 }) {
              Text(k % 2 === 0 ? '滑板混编队' : '新人养成队').fontSize(11).fontColor(COLORS.lime).fontWeight(FontWeight.Bold)
              Text('积分 ' + (264 - k * 38).toString()).fontSize(8).fontColor(COLORS.sub)
            }
            .layoutWeight(1).padding(10).borderRadius(12)
            .backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          }
          .width('100%')
        }, (k: number) => k.toString())

tabCrew 是车队阵营 Tab 的内容构建函数,展示车队对抗赛的战况。页面分为两个主要部分:车队对抗榜和入队福利。对抗榜是核心内容,以对阵卡的形式展示四场对抗赛的双方队伍和积分。

每张对阵卡采用"左队-VS-右队"的三栏布局。左右两侧的队伍卡片等分宽度,中间的 VS 标识固定宽度 44px。左侧队伍名使用粉色,右侧队伍名使用青柠绿,两种颜色形成鲜明的对比,强化了对阵双方的视觉区分。

VS 标识使用青色粗体大字,配合"第X场"的场次编号小字,突出了比赛的仪式感。整体设计模仿了体育赛事对阵表的视觉语言,能够有效激发用户的竞争心理和参与热情。

队伍积分通过公式 286 - k * 42(左队)和 264 - k * 38(右队)动态计算,呈现出积分逐场递减的趋势,模拟了积分榜的真实数据分布。这种用公式生成演示数据的方式在原型开发中非常高效,无需手动编写每个数据项。

队伍的分配使用奇偶判断:偶数场次左队是"光轴主力队"、右队是"滑板混编队",奇数场次左队是"夜行街头队"、右队是"新人养成队"。这样四场比赛中四个车队各出现两次,形成了循环赛的对阵格局。

        Text('入队福利').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        Row({ space: 8 }) {
          Column({ space: 4 }) {
            Text('🎽').fontSize(20)
            Text('队服套装').fontSize(10).fontColor(COLORS.title)
            Text('入队即发反光款').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('🎓').fontSize(20)
            Text('免费教学').fontSize(10).fontColor(COLORS.title)
            Text('高阶队员一对一带').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('🏥').fontSize(20)
            Text('运动保险').fontSize(10).fontColor(COLORS.title)
            Text('全队意外险覆盖').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
        }
        .width('100%')
      }

入队福利部分展示加入车队阵营的三项权益:队服套装、免费教学和运动保险。三卡并排布局,与安全提示卡片的结构类似。每项福利都有对应的 emoji 图标(运动服、毕业帽、医院),图标下方是福利名称和补充说明。

队服套装强调"反光款",与夜滑运动的安全性紧密相关;免费教学强调"一对一带",突出个性化指导的价值;运动保险强调"全队意外险覆盖",给用户提供安全保障的心理预期。三项福利分别从归属感、成长感和安全感三个维度吸引用户加入车队,是典型的用户运营转化设计。

车队阵营功能是社区应用提升用户粘性的重要手段。通过将用户划分到不同的阵营中,营造团队竞争的氛围,可以有效增强用户的归属感和参与感,提高日活和留存。从产品设计的角度来看,车队阵营是一个连接内容社区和用户体系的重要枢纽。

3.20 教学招新 Tab Builder:课程卡

  @Builder
  tabTeach() {
    Scroll() {
      Column({ space: 10 }) {
        Text('教学与招新').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach([['零基础入门', '八字步·安全摔倒', '免费', '每周三 19:00'], ['平花初级', 'Crazy·双前·后剪', '¥200/期', '每周五 20:00'], ['刹车特训', 'T刹·八字刹·铲刹', '¥260/期', '每周六 18:00'], ['速降技巧', '压步·弯道·控速', '¥320/期', '每周日 17:00'], ['夜滑领队营', '路线规划·编队指挥', '¥480/期', '每月首周']], (c: string[]) => {
          Row({ space: 12 }) {
            Column() { Text('🎓').fontSize(22) }
            .width(46).height(46).borderRadius(14)
            .backgroundColor(COLORS.neonDeep)
            .justifyContent(FlexAlign.Center)
            Column({ space: 4 }) {
              Text(c[0]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text('内容:' + c[1]).fontSize(8).fontColor(COLORS.sub)
              Text('时间:' + c[3]).fontSize(8).fontColor(COLORS.sub)
              Column().layoutWeight(1)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 2 }) {
              Text(c[2]).fontSize(11).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
              Text('报名').fontSize(7).fontColor(COLORS.sub)
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (c: string[]) => c[0])
      }

tabTeach 是教学招新 Tab 的内容构建函数,展示轮滑教学课程列表。课程数据使用二维字符串数组存储,每门课程包含四个信息项:课程名称、教学内容、价格和时间。这种以数组索引访问数据的方式虽然不够类型安全,但在演示项目中非常灵活便捷。

每张课程卡片采用横向三栏布局:左侧是课程图标,中间是课程信息,右侧是价格和报名入口。课程图标使用毕业帽 emoji(🎓),背景为深霓虹紫色,圆形尺寸 46px,视觉上醒目且有辨识度。

中间的课程信息区域包含三行文字:课程名称(粗体标题色)、教学内容(次要文字色)、上课时间(次要文字色)。课程名称是最主要的信息,使用最大字号和粗体。教学内容列出了课程涵盖的具体技巧动作,用点号分隔多个技巧名称,信息密度很高。上课时间则告知用户课程的排期,便于用户安排时间。

右侧的价格区域突出展示课程费用,使用青色粗体大字号,下方是"报名"小字标签。价格信息的视觉权重很高,符合电商类列表的设计惯例——价格是用户决策的重要因素,需要醒目展示。免费课程使用"免费"字样,与付费课程的价格格式形成区分。

五门课程从零基础到高阶领队形成了完整的成长路径:零基础入门(免费)→ 平花初级 → 刹车特训 → 速降技巧 → 夜滑领队营。价格从免费到 ¥480/期 逐级递增,难度和专业性也逐步提升。这种课程体系设计既为新手提供了低门槛的入门通道,也为进阶用户提供了持续学习的路径,有利于用户的长期留存和价值转化。

3.21 装备铺 Tab Builder:双列宫格

  @Builder
  tabSkate() {
    Scroll() {
      Column({ space: 12 }) {
        Text('夜滑装备铺').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach([0, 2, 4, 6], (r: number) => {
          Row({ space: 10 }) {
            ForEach([r, r + 1], (gi: number) => {
              if (gi < this.skates.length) {
                Column({ space: 6 }) {
                  Column() { Text(this.skates[gi].icon).fontSize(28) }
                  .width(52).height(52).borderRadius(14)
                  .backgroundColor(skateIconBg(gi))
                  .justifyContent(FlexAlign.Center)
                  Text(this.skates[gi].name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold).maxLines(1)
                  Text(this.skates[gi].cat + ' · ' + this.skates[gi].spec).fontSize(8).fontColor(COLORS.sub)
                  Row({ space: 4 }) {
                    Text(this.skates[gi].price).fontSize(11).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
                    Text(this.skates[gi].hot).fontSize(9)
                  }

tabSkate 是装备铺 Tab 的内容构建函数,以双列宫格的形式展示轮滑装备商品。双列宫格是电商类应用中常见的商品展示布局,它在信息密度和视觉舒适度之间取得了很好的平衡,比单列列表能展示更多商品,比三列宫格有更大的展示空间。

宫格布局通过两层 ForEach 实现:外层遍历 [0, 2, 4, 6] 控制行数,内层遍历 [r, r + 1] 生成每行的两个商品。这种通过索引计算实现的多列布局,在列数固定的场景下是一种简单直接的做法。内层的 if (gi < this.skates.length) 判断是边界保护,防止最后一行商品数量不足时出现数组越界。

每个商品卡片是纵向布局,从上到下依次是:商品图标、商品名称、分类规格、价格热度和操作按钮。商品图标区域是 52×52px 的圆角方形,使用 skateIconBg 函数根据索引选择四种略有差异的背景色之一,避免了所有图标背景完全一致的单调感。图标是 28px 的 emoji,是商品的视觉代表。

商品名称使用 10px 粗体标题色,设置 maxLines(1) 限制为单行显示,超长的商品名称会被自动截断并添加省略号。分类和规格信息组合在一行,使用 8px 次要文字色,作为商品的补充描述。

价格行使用青色粗体展示价格,旁边是热度标记 emoji(如果有的话)。价格是电商商品中最核心的信息之一,使用强调色和粗体突出显示符合用户的浏览习惯。热门标记(🔥)则起到社会认同的作用,引导用户关注热销商品。

                  Row({ space: 6 }) {
                    Button().width(56).height(22).borderRadius(11).backgroundColor(COLORS.neonDeep)
                      .onClick(() => { this.openDelSkate(gi); })
                    Text('下架').fontSize(8).fontColor(COLORS.white).margin({ left: -46 })
                    Button().width(56).height(22).borderRadius(11).backgroundColor(COLORS.card2)
                      .onClick(() => { this.addModal = false; })
                    Text('详情').fontSize(8).fontColor(COLORS.sub).margin({ left: -46 })
                  }
                }
                .layoutWeight(1).padding(12).borderRadius(12)
                .backgroundColor(COLORS.card)
                .alignItems(HorizontalAlign.Center)
              }
            }, (gi: number) => gi.toString())
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)
        }, (r: number) => 'row' + r.toString())
      }
    }
  }

商品卡片底部是两个操作按钮:下架和详情。这里的按钮实现方式比较特殊,使用了透明的 Button 组件叠加 Text 文字,通过负边距将文字定位到按钮上方。这种做法可能是为了绕过 Button 组件的某些默认样式限制,实现自定义的按钮外观。

"下架"按钮使用深霓虹紫背景配白色文字,"详情"按钮使用卡片二级背景配次要文字色。视觉上"下架"按钮更醒目,暗示了其主要操作的地位。不过从用户体验的角度来看,在面向消费者的装备铺页面中,"详情"应该是更主要的操作,"下架"更像是商家后台的管理功能。此处按钮的设计可能反映了演示项目以功能展示为主的特点。

按钮点击事件的实现也有值得注意的地方。"下架"按钮调用 this.openDelSkate(gi) 打开删除确认弹框,传入当前商品的索引。"详情"按钮则执行 this.addModal = false,这看起来更像是一个占位或测试用的操作,而非真正的商品详情跳转。在实际项目中,详情按钮通常会导航到商品详情页面或打开详情弹框。

商品卡片使用 layoutWeight(1) 等分宽度,确保同一行的两个卡片宽度一致。卡片使用主卡片背景色和 12px 圆角,内边距 12px,整体风格与应用的其他卡片保持统一。宫格行之间的间距为 12px,同行卡片之间的间距为 10px,布局规整有序。

3.22 轮友圈 Tab Builder:动态列表

  @Builder
  tabFriends() {
    Scroll() {
      Column({ space: 10 }) {
        Row({ space: 8 }) {
          Button().width(110).height(32).borderRadius(16).backgroundColor(COLORS.neon)
            .onClick(() => { this.openAdd(); })
          Text('📝 发布动态').fontSize(10).fontColor(COLORS.white).margin({ left: -102 })
          Column().layoutWeight(1)
          Text('按最新排序').fontSize(9).fontColor(COLORS.sub)
        }
        .width('100%')

tabFriends 是轮友圈 Tab 的内容构建函数,展示用户发布的动态内容流。轮友圈是社区应用的核心功能之一,是用户生成内容(UGC)的主要载体,也是社区活跃度和用户粘性的重要驱动力。

页面顶部是操作栏,左侧是"发布动态"按钮,右侧是排序方式提示。发布按钮使用霓虹紫背景配白色文字,尺寸较大(110×32px),视觉上非常醒目,引导用户发布内容。按钮的实现方式与装备铺中的按钮类似,使用透明 Button 叠加 Text 文字,通过负边距调整文字位置。

排序提示"按最新排序"使用次要文字色,字号较小(9px),放置在右上角。这个提示虽然简单,但它告诉用户当前的排序规则,降低了用户的认知成本。在实际项目中,这里通常可以点击切换排序方式(最新/最热/关注等)。

        ForEach(this.friends, (f: SkateFriendItem) => {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Column() { Text(f.avatar).fontSize(24) }
              .width(44).height(44).borderRadius(12)
              .backgroundColor(COLORS.card2)
              .justifyContent(FlexAlign.Center)
              Column({ space: 2 }) {
                Text(f.nick).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(f.time + ' · ' + f.tag).fontSize(8).fontColor(COLORS.sub)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('🪪 队员认证').fontSize(7).fontColor(COLORS.cyan)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(8).backgroundColor(COLORS.card2)
            }
            .width('100%')
            Text(f.text).fontSize(10).fontColor(COLORS.sub).width('100%').lineHeight(16)
            Row({ space: 16 }) {
              Text('❤️ ' + f.likes.toString()).fontSize(9).fontColor(COLORS.pink)
              Text('💬 ' + f.replies.toString()).fontSize(9).fontColor(COLORS.sub)
              Text('⤴ 分享').fontSize(9).fontColor(COLORS.sub)
              Column().layoutWeight(1)
              Button().width(48).height(20).borderRadius(10).backgroundColor(COLORS.card2)
                .onClick(() => { this.openDelSkate(-1); this.editIdx = f.id - 1; this.delTarget = 'friend'; })
              Text('删').fontSize(8).fontColor(COLORS.red).margin({ left: -38 })
            }
            .width('100%')
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (f: SkateFriendItem) => f.id.toString())
      }
    }
  }

动态列表是轮友圈的主体内容,每条动态采用标准的社交 Feed 卡片结构:头部(用户信息)、正文(动态内容)、底部(互动操作)。这种三段式布局是社交平台的经典设计,用户已经形成了成熟的使用习惯。

动态头部左侧是用户头像(44×44px 的圆角方形,emoji 图标),中间是用户昵称和时间标签,右侧是认证标识。用户昵称使用 11px 粗体标题色,是头部最主要的信息。时间和标签组合在一起(如"1小时前 · 夜滑预告"),使用次要文字色,提供上下文信息。

"队员认证"标签是一个有趣的设计,使用身份证 emoji(🪪)配合青色文字,放置在二级背景色的圆角标签中。认证标识可以增加用户的可信度和身份感,对于社区氛围的建设有积极作用。在实际项目中,认证体系可以进一步扩展,如领队认证、达人认证、官方认证等,形成完整的用户身份体系。

动态正文使用 10px 的次要文字色,行高 16px,宽度 100%。文字颜色选择次要色而非标题色,可能是为了让整体界面不那么刺眼,符合暗色主题的阅读舒适度。行高设置为 16px(约为字号的 1.6 倍),保证了多行文本的可读性。

动态底部是互动操作区,包含点赞、评论、分享和删除四个操作。点赞数使用粉色强调,突出互动数据,激发用户的点赞欲望。评论和分享使用次要文字色,视觉权重较低。删除按钮使用红色文字,放在最右侧,功能明确但不突出,避免误操作。

删除按钮的点击事件处理值得关注。它调用 this.openDelSkate(-1) 并手动设置 editIdxdelTarget。这里复用了装备删除的弹框打开函数,但传入的参数是 -1(无效索引),然后立即覆盖了 editIdxdelTarget 的值。这种做法虽然能达到目的,但逻辑上不够清晰——调用了一个语义不匹配的函数,依赖副作用来实现目标。更好的做法是增加一个专门的 openDelFriend 方法,保持函数语义的一致性。

3.23 主 Tab 页面 Builders:路线/装备/我的

  // ============ 主tab1 路线页 ============
  @Builder
  pageRoute() {
    Scroll() {
      Column({ space: 12 }) {
        Text('🗺️ 夜滑路线总览').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach(routeList, (r: RouteItem) => {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Column() { Text('🛼').fontSize(24) }
              .width(48).height(48).borderRadius(12)
              .backgroundColor(COLORS.neonDeep)
              .justifyContent(FlexAlign.Center)
              Column({ space: 3 }) {
                Text(r.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(r.city + ' · ' + r.slope).fontSize(9).fontColor(COLORS.sub)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('🔥' + r.hot.toString()).fontSize(10).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
            }
            .width('100%')
            Row({ space: 6 }) {
              Text(r.dist).fontSize(9).fontColor(COLORS.cyan)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8).backgroundColor(COLORS.card2)
              Text(r.surface).fontSize(9).fontColor(COLORS.lime)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8).backgroundColor(COLORS.card2)
              Text(r.light).fontSize(9).fontColor(lightColor(r.light))
                .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8).backgroundColor(COLORS.card2)
            }
            .width('100%')
          }
          .width('100%').padding(14).borderRadius(14)
          .backgroundColor(COLORS.card)
        }, (r: RouteItem) => r.id.toString())
      }
      .width('100%')
      .padding(12)
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

除了首页内的七个子 Tab 外,应用还有三个独立的主 Tab 页面:路线(pageRoute)、装备(pageSkate)和我的(pageMine)。这些页面在底部导航切换到对应 Tab 时展示,结构和内容与首页中的相关子 Tab 有所区别,更加专注于各自的核心功能。

pageRoute 是路线主页面,展示完整的夜滑路线总览。与首页子 Tab 中的路线列表(tabRoute)相比,主页面的路线卡片尺寸更大、信息更突出。卡片内边距为 14px(子 Tab 中为 12px),图标为 48px(子 Tab 中为 44px),整体视觉权重更高。

卡片的布局结构与子 Tab 版本类似:标题行 + 属性标签行。但细节上有所不同:主页面的图标使用深霓虹紫背景(而非子 Tab 中的二级背景色),属性标签使用稍大的字号(9px vs 8px)和更多的内边距(8px/3px vs 6px/2px)。这些差异化设计体现了"主页面信息更突出、子页面信息更紧凑"的布局策略。

页面标题"🗺️ 夜滑路线总览"使用 15px 粗体标题色,比子 Tab 中的标题(13px)更大,因为主页面有更独立的视觉层级。页面背景使用 COLORS.bg 深色背景,与首页内容区域的背景保持一致。

  // ============ 主tab2 装备页 ============
  @Builder
  pageSkate() {
    Scroll() {
      Column({ space: 12 }) {
        Text('🎒 装备商城').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach(this.skates, (s: SkateItem) => {
          Row({ space: 12 }) {
            Column() { Text(s.icon).fontSize(26) }
            .width(56).height(56).borderRadius(14)
            .backgroundColor(COLORS.card2)
            .justifyContent(FlexAlign.Center)
            Column({ space: 4 }) {
              Text(s.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(s.brand + ' · ' + s.spec).fontSize(8).fontColor(COLORS.sub)
              Row({ space: 6 }) {
                Text(s.cat).fontSize(8).fontColor(COLORS.cyan)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(6).backgroundColor(COLORS.card2)
                Text('库存 ' + s.stock.toString()).fontSize(8).fontColor(COLORS.sub)
                Text(s.hot).fontSize(9)
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 4 }) {
              Text(s.price).fontSize(13).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
              Button().width(52).height(22).borderRadius(11).backgroundColor(COLORS.card2)
                .onClick(() => { this.openDelSkate(this.skates.indexOf(s)); })
              Text('下架').fontSize(8).fontColor(COLORS.red).margin({ top: -18 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (s: SkateItem) => s.id.toString())
      }
    }
  }

pageSkate 是装备主页面,以列表形式展示所有装备商品。与子 Tab 中的双列宫格(tabSkate)不同,主页面采用单列列表布局,每件商品占据一行,展示的信息更完整。

列表卡片采用横向布局:左侧是商品图标(56×56px),中间是商品信息,右侧是价格和操作按钮。这种单列列表布局比双列宫格能展示更多的商品信息,如品牌、库存等,适合作为商城的主列表视图。

商品信息区域包含名称、品牌规格和标签行三行内容。品牌和规格组合在副标题中(如"Rollerblade · 4×110mm 大三轮"),为用户提供更全面的商品信息。标签行包含分类标签(青色)、库存数量(次要色)和热度标记,信息密度更高。

价格使用 13px 的青色粗体,比子 Tab 中的价格(11px)更大更醒目,符合商城页面的商品展示逻辑。下架按钮在价格下方,使用红色文字,与子 Tab 中的按钮样式一致。

按钮的点击事件使用 this.skates.indexOf(s) 来获取当前商品的索引。这种方式与子 Tab 中直接使用循环索引 gi 不同,它通过对象引用查找索引,代码更具语义性。但性能上略差(需要遍历数组查找),且如果数组中有相同的对象引用可能导致错误。在使用 ForEach 的场景下,直接使用索引参数通常更直接高效。

  // ============ 主tab3 我的页 ============
  @Builder
  pageMine() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 12 }) {
          Column() { Text('🧑‍🎤').fontSize(30) }
          .width(64).height(64).borderRadius(18)
          .backgroundColor(COLORS.neonDeep)
          .justifyContent(FlexAlign.Center)
          Column({ space: 4 }) {
            Text('光轴·夜行灯').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('平花中阶 · 夜滑 156 次').fontSize(9).fontColor(COLORS.sub)
            Text('累计里程 1286km · 队龄 2 年').fontSize(8).fontColor(COLORS.cyan)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%').padding(16).borderRadius(16)
        .backgroundColor(COLORS.card)

pageMine 是"我的"页面,即个人中心。个人中心是用户管理自己信息和功能的聚合入口,通常包含用户信息、数据统计、功能菜单等模块。本项目的"我的"页面分为三个主要部分:用户信息卡片、数据统计四宫格、功能菜单列表。

用户信息卡片是页面顶部最醒目的元素,展示用户头像、昵称、等级和运动数据。头像尺寸为 64×64px,是应用中最大的头像尺寸,深霓虹紫背景,大圆角(18px),视觉上非常突出。用户昵称使用 14px 粗体标题色,是页面中最大的文字之一。

用户信息的设计体现了运动社区的特色。除了基本的昵称外,还展示了轮滑等级(平花中阶)、夜滑次数(156次)、累计里程(1286km)和队龄(2年)。这些数据既是用户运动成就的展示,也是激励用户持续参与的动力。等级和里程使用青色强调色,增加了数据的视觉权重。

        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('156').fontSize(18).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
            Text('夜滑次数').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('1286').fontSize(18).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
            Text('累计公里').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('9').fontSize(18).fontColor(COLORS.lime).fontWeight(FontWeight.Bold)
            Text('打卡路线').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('42').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
            Text('圈内徽章').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
        }
        .width('100%')

数据统计四宫格展示用户的四项核心运动数据:夜滑次数、累计公里、打卡路线、圈内徽章。四个数据项等分宽度,使用相同的卡片样式,形成规整的四列布局。每个数据项由大数字和小标签组成,大数字使用 18px 粗体,小标签使用 8px 次要文字色。

四个数据使用四种不同的强调色:夜滑次数用粉色、累计公里用青色、打卡路线用青柠绿、圈内徽章用橙色。多彩的数据展示既美观又便于区分,同时与应用整体的霓虹多彩风格保持一致。数据颜色的选择也暗含语义:粉色代表热情(次数)、青色代表距离(公里)、绿色代表探索(路线)、橙色代表成就(徽章)。

数据统计区域是个人中心的核心价值所在,它将用户的运动成就量化展示,给予用户成就感和继续努力的动力。从产品设计的角度来看,数据统计是用户留存的重要抓手——用户积累的数据越多,离开的成本就越高。

        ForEach(['我的报名', '里程记录', '徽章墙', '常用装备', '意外保险', '设置'], (s: string) => {
          Row({ space: 10 }) {
            Text('›').fontSize(16).fontColor(COLORS.neon)
            Text(s).fontSize(11).fontColor(COLORS.title)
            Column().layoutWeight(1)
            Text('>').fontSize(12).fontColor(COLORS.sub)
          }
          .width('100%').padding(14).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (s: string) => s)
      }
    }
  }

功能菜单列表是个人中心的入口聚合,包含六个功能项:我的报名、里程记录、徽章墙、常用装备、意外保险、设置。每个菜单项都是一行文字,左侧有一个霓虹色的装饰符号(›),中间是功能名称,右侧是箭头指示(>)。

菜单项的设计简洁明了,遵循了移动端设置页面的通用设计模式。装饰符号增加了视觉细节,箭头符号暗示了可点击和跳转的含义。功能名称使用 11px 的标题色,大小适中,保证了可读性的同时不会占用过多空间。

六个功能项涵盖了用户可能需要的主要操作:活动管理(我的报名)、数据查看(里程记录)、成就系统(徽章墙)、装备管理(常用装备)、安全保障(意外保险)、系统设置(设置)。功能布局合理,覆盖了用户生命周期的各个环节。

3.24 弹框遮罩与弹框体 Builders

  // ============ 遮罩 ============
  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.mask)
      .onClick(() => { onClose(); })
  }

弹框系统由遮罩层和内容体两部分组成。modalOverlay 是遮罩层的构建函数,它接收一个 onClose 回调函数作为参数,用于在用户点击遮罩时关闭弹框。这种将关闭逻辑作为参数传入的设计模式,使得遮罩层可以被多种弹框复用,每种弹框可以有自己的关闭处理逻辑。

遮罩层的实现非常简洁:一个全屏的 Column,背景色使用半透明的 COLORS.mask#0D0A18CC,约 80% 不透明度),点击时调用传入的关闭回调。遮罩层的作用有两个:一是视觉上突出弹框内容,通过半透明暗色背景将用户的注意力引导到前景弹框;二是交互上提供点击空白区域关闭弹框的快捷操作,这是移动端弹框的标准交互模式。

@Builder 函数支持参数传递,这是它比普通组件更灵活的地方之一。通过函数参数,可以向 Builder 中传递数据或回调,实现动态的 UI 构建。在本项目中,modalOverlay 接收 onClose 回调参数,就是这种特性的典型应用。

  // ============ 新增弹框 ============
  @Builder
  addModalBody() {
    Column({ space: 12 }) {
      Text('发布轮友动态').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      TextInput({ placeholder: '昵称(可留空)', text: this.addNick })
        .fontSize(11).height(38).borderRadius(10)
        .backgroundColor(COLORS.card2).fontColor(COLORS.title)
        .onChange((v: string) => { this.addNick = v; })
      TextInput({ placeholder: '分享你的刷街战报或翻车瞬间…', text: this.addText })
        .fontSize(11).height(60).borderRadius(10)
        .backgroundColor(COLORS.card2).fontColor(COLORS.title)
        .onChange((v: string) => { this.addText = v; })
      Row({ space: 10 }) {
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
          .onClick(() => { this.addModal = false; })
        Text('取消').fontSize(11).fontColor(COLORS.sub).margin({ left: -66 })
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.neon)
          .onClick(() => { this.doAdd(); })
        Text('发布').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -66 })
      }
      .width('100%')
    }
    .width('86%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor(COLORS.card)
  }

addModalBody 是发布动态弹框的内容体。弹框标题为"发布轮友动态",使用 14px 粗体标题色,是弹框中最大的文字。弹框包含两个输入框:昵称输入和内容输入。昵称输入框高度为 38px,提示"昵称(可留空)“,告知用户可以匿名发布。内容输入框高度为 60px,提示文案"分享你的刷街战报或翻车瞬间…”,既说明了用途,又带有社区趣味的调性。

输入框的样式统一:使用卡片二级背景色、10px 圆角、11px 字号、标题色文字。输入框的值与组件的 @State 变量(addNickaddText)双向绑定,通过 onChange 回调实时更新状态变量的值。这种受控组件的模式是声明式 UI 中表单处理的标准做法。

弹框底部是两个操作按钮:取消和发布。按钮等分宽度,使用 layoutWeight(1) 实现。取消按钮使用二级背景色配次要文字色,发布按钮使用霓虹紫背景配白色粗体文字,视觉权重上发布按钮明显更突出,引导用户完成发布操作。

弹框内容的宽度设为 86%(屏幕宽度的 86%),这是移动端弹框的常见宽度比例——既不会太宽显得笨拙,也不会太窄影响内容展示。弹框使用 16px 圆角和卡片背景色,与应用的整体设计风格一致。

  // ============ 编辑弹框 ============
  @Builder
  editModalBody() {
    Column({ space: 12 }) {
      Text('调整夜滑计划').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      TextInput({ placeholder: '路线名称', text: this.editRoute })
        .fontSize(11).height(38).borderRadius(10)
        .backgroundColor(COLORS.card2).fontColor(COLORS.title)
        .onChange((v: string) => { this.editRoute = v; })
      TextInput({ placeholder: '集合时间 如 本周六 20:00', text: this.editDate })
        .fontSize(11).height(38).borderRadius(10)
        .backgroundColor(COLORS.card2).fontColor(COLORS.title)
        .onChange((v: string) => { this.editDate = v; })
      Text('提示:雨天自动顺延至下一可滑日,请留意群通知').fontSize(8).fontColor(COLORS.sub).width('100%')
      Row({ space: 10 }) {
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
          .onClick(() => { this.editModal = false; })
        Text('取消').fontSize(11).fontColor(COLORS.sub).margin({ left: -66 })
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.neon)
          .onClick(() => { this.doEdit(); })
        Text('保存').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -66 })
      }
      .width('100%')
    }
    .width('86%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor(COLORS.card)
  }

editModalBody 是编辑夜滑计划弹框的内容体。结构与发布弹框类似:标题 + 输入框 + 提示文字 + 操作按钮。输入框有两个:路线名称和集合时间,都是单行输入框(高度 38px)。

输入框的占位提示文字很有指导性。路线名称的占位符直接说明输入内容,集合时间的占位符还给出了格式示例(“如 本周六 20:00”),帮助用户理解期望的输入格式。这种"示例型占位符"是提升表单可用性的有效手段。

提示文字"雨天自动顺延至下一可滑日,请留意群通知"是一个贴心的设计,它告知用户活动的雨天政策,管理用户预期。虽然只是一行小字,但体现了对用户体验的细致考量。在实际项目中,这类辅助文案往往能有效减少用户的疑问和投诉。

底部的操作按钮与发布弹框相同,只是确认按钮的文字从"发布"变成了"保存"。按钮样式、布局、颜色方案完全一致,保持了弹框系统的设计统一性。

  // ============ 删除确认弹框 ============
  @Builder
  delModalBody() {
    Column({ space: 12 }) {
      Text('确认操作').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Text(this.delTarget === 'night' ? '退出该场夜滑后名额立即释放,已购夜光手环费用不退,确认吗?' : '下架该装备后将从装备铺列表移除,确认继续吗?')
        .fontSize(10).fontColor(COLORS.sub).textAlign(TextAlign.Center)
      Row({ space: 10 }) {
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
          .onClick(() => { this.delModal = false; })
        Text('再想想').fontSize(11).fontColor(COLORS.sub).margin({ left: -82 })
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.red)
          .onClick(() => { this.doDel(); })
        Text('确认').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -58 })
      }
      .width('100%')
    }
    .width('86%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor(COLORS.card)
  }

delModalBody 是删除确认弹框的内容体。与前两种弹框不同,删除弹框没有输入框,只有标题和确认文案。弹框的核心是让用户确认删除操作,避免误删。

确认文案根据删除目标(delTarget)动态变化。如果是退出夜滑活动,文案说明"名额立即释放,已购夜光手环费用不退"的后果;如果是下架装备,文案说明"将从装备铺列表移除"的后果。这种差异化的提示文案非常重要,它让用户清楚地知道操作的具体后果,做出明智的决策。特别是涉及费用的操作,明确的后果提示可以有效避免用户纠纷。

确认文案使用居中对齐(textAlign(TextAlign.Center)),这是确认弹框的常见排版方式——居中的文字显得更正式、更重要,引导用户认真阅读。文案使用 10px 的次要文字色,虽然颜色较浅,但在弹框聚焦的场景下仍然清晰可读。

底部按钮的设计与前两种弹框有一个重要区别:确认按钮使用红色背景而非霓虹紫。红色是警告和危险的语义色,用在删除确认弹框中,既符合"危险操作"的语义,又能引起用户的警觉,防止误操作。取消按钮的文字从"取消"变成了"再想想",语气更加温和,带有一点挽留的意味,这是一种有趣的文案设计技巧。

3.25 底部导航 Builder

  @Builder
  bottomBar() {
    Row({ space: 0 }) {
      ForEach(NAV_LIST, (ni: NavItem, i: number) => {
        Column({ space: 3 }) {
          Text(ni.icon).fontSize(18)
          Text(ni.label)
            .fontSize(9)
            .fontColor(this.mainTab === i ? COLORS.pink : COLORS.sub)
            .fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1)
        .padding({ top: 6, bottom: 6 })
        .alignItems(HorizontalAlign.Center)
        .onClick(() => { this.mainTab = i; this.subTab = 0; })
      }, (ni: NavItem) => ni.label)
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .border({ width: 1, color: COLORS.line })
  }

bottomBar 构建函数负责渲染底部导航栏。底部导航是移动端应用最常见的导航模式之一,它将应用的主要功能入口固定在屏幕底部,用户可以通过点击快速切换不同的功能模块。

导航栏使用 Row 横向布局,遍历 NAV_LIST 数组生成四个 Tab 项。每个 Tab 项使用 Column 纵向布局,包含图标和文字标签两行内容。图标使用 18px 的 emoji,标签使用 9px 的文字。图标在上、文字在下的布局是底部导航的标准形式,符合用户的视觉习惯。

Tab 的选中状态通过文字颜色和字重区分:选中时使用粉色粗体,未选中时使用次要文字色常规字重。与内容子 Tab 的背景色切换不同,底部导航只改变文字样式,保持了导航栏整体的简洁性。图标本身不随选中状态变化,这在一定程度上降低了选中状态的辨识度,但也避免了导航栏过于花哨。

每个 Tab 的点击事件会做两件事:设置 mainTab 为当前索引,同时将 subTab 重置为 0。重置子 Tab 的设计很重要,它确保了用户切换主 Tab 后再切回首页时,总是显示默认的精选页面,而不是停留在上次浏览的子 Tab。这种"重置策略"符合大多数用户的预期,但也有产品选择"记忆策略"(记住用户上次的位置),两种策略各有优劣,需要根据具体产品定位来选择。

导航栏的背景使用卡片色,顶部有 1px 的线条色边框,与内容区域形成视觉分隔。导航栏的 Tab 项使用 layoutWeight(1) 等分宽度,确保四个 Tab 的宽度一致。Tab 的上下内边距为 6px,整体高度紧凑,不会占用过多的屏幕空间。

3.26 主 build 布局结构:Stack 多层叠加

  build() {
    Stack() {
      Column() {
        this.header()
        if (this.mainTab === 0) {
          Column() {
            this.subNav()
            if (this.subTab === 0) {
              this.tabPick()
            } else if (this.subTab === 1) {
              this.tabRoute()
            } else if (this.subTab === 2) {
              this.tabMile()
            } else if (this.subTab === 3) {
              this.tabCrew()
            } else if (this.subTab === 4) {
              this.tabTeach()
            } else if (this.subTab === 5) {
              this.tabSkate()
            } else {
              this.tabFriends()
            }
          }
          .layoutWeight(1)
          .width('100%')
          .alignItems(HorizontalAlign.Center)
          .backgroundColor(COLORS.bg)
        } else if (this.mainTab === 1) {
          this.pageRoute()
        } else if (this.mainTab === 2) {
          this.pageSkate()
        } else {
          this.pageMine()
        }
        this.bottomBar()
      }
      .width('100%')
      .height('100%')

build() 方法是组件的核心,它描述了整个页面的 UI 结构。本项目的主布局采用 Stack 堆叠布局,将界面分为三层:主内容层、特效层和弹框层。Stack 布局允许子组件堆叠在一起,后声明的组件显示在上方,非常适合实现浮层、弹框等叠加效果。

最底层(第一层)是主内容层,使用 Column 纵向布局,从上到下依次是头部、内容区域和底部导航。这种"头-身-尾"的三段式布局是移动端应用的经典结构,用户已经形成了成熟的使用习惯。

内容区域的显示逻辑通过多层 if-else 条件判断实现。首先根据 mainTab 判断当前是哪个主 Tab:如果是首页(mainTab === 0),则显示子 Tab 导航和对应的子 Tab 内容;如果是其他主 Tab,则显示对应的页面内容。首页内部再根据 subTab 判断显示哪个子 Tab 的内容。

这种多层条件渲染的方式是声明式 UI 中实现页面导航的基本方法。与命令式的页面跳转不同,声明式的导航通过状态变量控制哪些组件显示、哪些隐藏。状态变化时,框架自动计算差异并更新 UI,开发者无需关心具体的 DOM 操作。

首页的内容区域使用 Column 包裹子 Tab 导航和内容,并设置 layoutWeight(1) 使其占据剩余空间。这样头部和底部导航保持固定高度,中间内容区域自适应填充,是移动端弹性布局的标准做法。

      // 特效层仅覆盖内容区(头部之下)
      Column() {
        this.fxLayer()
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.End)
      .hitTestBehavior(HitTestMode.None)

Stack 的第二层是特效层。特效层被一个 Column 包裹,Column 的高度为 100%,但内容使用 justifyContent(FlexAlign.End) 底部对齐。这样特效层的顶部会有一段空白,空白区域正好对应头部的高度,从而实现了"特效仅覆盖内容区、不遮挡头部"的效果。

这是一个巧妙的布局技巧。由于特效层的内容是从底部开始排列的,而特效层的容器高度是 100%,所以特效的顶部会留出一段空间。这段空间正好让下方的头部透出来,不会被特效遮挡。与使用 position 精确计算偏移量相比,这种基于 Flex 对齐的实现方式更加简洁和鲁棒,不需要知道头部的确切高度。

hitTestBehavior(HitTestMode.None) 是一个非常关键的属性设置。它指定特效层不响应任何触摸事件,触摸事件会穿透特效层传递到下方的内容层。如果没有这个设置,特效层会拦截所有的触摸事件,导致用户无法与下方的内容进行交互。在实现视觉装饰层、背景动效等场景时,HitTestMode.None 是必不可少的设置。

      // 弹框层
      if (this.addModal) {
        Column() {
          this.modalOverlay(() => { this.addModal = false; })
          Column() {
            this.addModalBody()
          }
          .constraintSize({ maxHeight: '80%' })
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
      if (this.editModal) {
        Column() {
          this.modalOverlay(() => { this.editModal = false; })
          Column() {
            this.editModalBody()
          }
          .constraintSize({ maxHeight: '70%' })
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
      if (this.delModal) {
        Column() {
          this.modalOverlay(() => { this.delModal = false; })
          Column() {
            this.delModalBody()
          }
          .constraintSize({ maxHeight: '60%' })
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

Stack 的第三层(最上层)是弹框层。三种弹框各自独立,通过 if 条件判断是否显示。每种弹框的结构相同:全屏 Column 作为容器,内部包含遮罩层和弹框内容体。容器使用 justifyContent(FlexAlign.Center) 使弹框内容居中显示。

遮罩层和弹框内容体在同一个 Column 中纵向排列。由于遮罩层是全屏的,弹框内容体实际上是叠加在遮罩层之上的,这是因为 Column 的第一个子组件(遮罩层)已经占据了全部空间,第二个子组件(弹框内容)会显示在遮罩层的上方,形成居中的弹框效果。

constraintSize 属性用于限制弹框内容的最大高度。不同的弹框有不同的最大高度限制:发布动态弹框最高 80%,编辑弹框最高 70%,删除确认弹框最高 60%。高度限制与弹框内容的复杂度成正比——内容越多的弹框允许的最大高度越大。这种限制确保了弹框不会因为内容过多而撑满整个屏幕,始终保持"弹框"的视觉形态。

zIndex(999) 用于确保弹框内容显示在最上层。虽然 Stack 布局默认后声明的组件在上方,但显式设置 zIndex 可以更明确地控制层级关系,特别是在多层叠加的复杂场景下。999 是一个很大的值,确保弹框始终在其他内容之上。

整个 Stack 的背景色设置为 COLORS.bg,确保页面背景统一。Stack 的宽高都是 100%,充满整个屏幕。这种三层 Stack 布局(内容层 + 特效层 + 弹框层)是本项目架构设计的核心,清晰地分离了不同层级的视觉元素,实现了丰富的叠加效果。

四、应用运行全流程 Mermaid 图

以下流程图展示了从组件初始化到界面渲染,再到用户交互和数据更新的完整运行流程。

mainTab=0 首页

mainTab=1 路线

mainTab=2 装备

mainTab=3 我的

0 精选

1 路线

2 里程

3 车队

4 教学

5 装备

6 轮友圈

addModal=true

editModal=true

delModal=true

组件初始化 Page struct

aboutToAppear 生命周期

启动 setInterval 特效定时器

初始化 @State 状态变量

执行 build 方法构建 UI

主内容层 Column

特效层 Column
hitTestBehavior=None

弹框层 Stack
条件渲染

header 头部渲染

mainTab 条件判断

bottomBar 底部导航

subNav 子Tab导航

pageRoute 路线页

pageSkate 装备页

pageMine 我的页

subTab 条件判断

tabPick 精选榜

tabRoute 路线卡

tabMile 里程图

tabCrew 对阵卡

tabTeach 课程卡

tabSkate 宫格

tabFriends 动态流

fxLayer 特效层渲染

霓虹光晕星号 ✦

轨迹光点 ·

弹框状态

发布动态弹框

编辑夜滑弹框

删除确认弹框

用户交互事件

底部Tab点击
mainTab切换

内容Tab点击
subTab切换

发布/编辑/删除按钮点击

定时器触发 tick/glow更新

更新 @State mainTab

更新 @State subTab

调用弹框控制方法
openAdd/openEdit/openDel

更新 tick 和 glow

框架检测状态变化

重新执行 build

Diff 计算最小更新范围

局部 UI 重渲染

界面更新完成

弹框确认操作

doAdd 新增动态
friends.push

doEdit 修改计划
属性赋值

doDel 删除数据
splice

组件销毁
aboutToDisappear

clearInterval 清除定时器

释放资源

整个流程图涵盖了应用运行的主要阶段:组件初始化与生命周期、UI 构建与渲染、用户交互与状态更新、弹框操作与数据变更、组件销毁与资源释放。核心机制在于声明式 UI 的状态驱动——任何状态的变化都会被框架捕获,触发相应的 UI 更新,开发者只需关注状态的管理和业务逻辑的实现。

五、技术对比表格

技术维度 实现方式 设计特点 性能考量
状态管理 @State + @Observed 装饰器 响应式自动追踪,数据变化自动驱动UI更新 细粒度观察,仅更新依赖变更的UI节点,减少无效重绘
组件组织 单组件 + 多 @Builder 函数 轻量模块化,Builder 复用 UI 片段,状态集中管理 减少组件嵌套层级,降低组件实例化开销
列表渲染 ForEach 遍历数组 键值复用机制,数据变化时 Diff 计算最小更新 使用唯一 ID 作为 key,提升列表更新效率
导航模式 状态变量 + 条件渲染(if-else) 声明式导航,状态驱动页面切换 非激活 Tab 不渲染,节省内存和渲染资源
弹框实现 Stack 叠加 + if 条件渲染 遮罩层与内容体分离,zIndex 控制层级 未激活弹框不创建节点,按需渲染
动画效果 setInterval + 状态变量 数学公式驱动,纯 JS 计算位置 仅更新状态变量,框架自动处理渲染,性能友好
颜色体系 接口 + 常量对象 语义化命名,集中管理,便于主题扩展 颜色值直接引用,无需运行时计算
数据模型 @Observed class 面向对象建模,属性级响应式观察 属性变更精确追踪,避免整对象替换的开销
布局方式 Column/Row/Stack + layoutWeight 弹性盒子布局,自适应屏幕尺寸 原生布局引擎,GPU 加速渲染
图标方案 Emoji 字符图标 零资源依赖,系统原生渲染 文字渲染性能好,无需图片加载和缓存
表单处理 受控组件 + onChange 双向绑定 状态实时同步,声明式表单管理 输入即更新,每次击键触发局部重绘
特效穿透 hitTestBehavior(HitTestMode.None) 视觉层不拦截交互,事件穿透到下层 避免无效的事件处理,提升响应速度
滚动列表 Scroll 组件 + ForEach 系统原生滚动,惯性滑动效果 仅渲染可视区域外的有限节点(虚拟滚动需额外实现)
生命周期 aboutToAppear / aboutToDisappear 对称式资源管理,创建销毁配对 及时清理定时器和订阅,防止内存泄漏
渐变效果 linearGradient 属性 声明式渐变配置,多色多位置支持 硬件加速绘制,比多层叠加更高效
文字阴影 textShadow 属性 发光效果模拟霓虹光晕 阴影计算有一定开销,需控制数量和半径
响应式尺寸 百分比 + layoutWeight 自适应不同屏幕宽度 布局计算在框架层完成,开发者无需手动适配

六、详细总结

6.1 架构设计总结

本项目采用单组件多 Builder 的架构模式,将所有功能模块集中在一个 Page 组件中,通过 @Builder 装饰器将不同的 UI 片段封装为可复用的构建函数。这种架构模式在中小规模应用中具有明显的优势:开发效率高、状态管理简单、数据流转清晰。所有状态都集中在组件内部,不需要跨组件的状态传递和同步,降低了状态管理的复杂度。

主布局采用 Stack 三层叠加结构(内容层 + 特效层 + 弹框层),是本项目架构设计的一大亮点。内容层负责主体功能的展示和交互,特效层负责视觉增强(霓虹光晕和轨迹光点),弹框层负责模态交互(发布、编辑、确认)。三层各司其职,通过 Stack 的堆叠特性实现了丰富的叠加效果,同时保持了代码结构的清晰性。

导航系统采用"主 Tab + 子 Tab"的两级结构。主 Tab 由底部导航控制,子 Tab 由首页的内容导航控制。两级导航都使用状态变量 + 条件渲染的声明式方式实现,状态变化时框架自动更新对应的 UI 部分。这种导航模式虽然简单,但对于内容型应用来说已经足够高效,用户可以在不同功能模块之间快速切换。

从可扩展性的角度来看,单组件架构在功能模块较少时优势明显,但随着功能的不断增加,组件文件会变得越来越庞大,维护成本也会逐渐上升。对于未来的演进方向,可以考虑将功能模块进一步拆分为独立的自定义组件,通过 props 和 events 进行父子组件通信,或者引入更完善的状态管理方案(如 AppStorage/LocalStorage 或第三方状态管理库)来支撑更复杂的业务场景。

6.2 状态管理总结

在这里插入图片描述

状态管理是声明式 UI 开发的核心议题,本项目的状态管理方案体现了 ArkTS 框架的特色。项目使用了 @State 和 @Observed 两种主要的状态装饰器,分别用于组件内部的基本类型状态和可观察类的属性追踪。两种装饰器协同工作,构成了完整的响应式状态体系。

@State 装饰器用于标记组件的内部状态变量,包括导航状态(mainTab、subTab)、特效状态(tick、glow)、弹框状态(addModal、editModal、delModal 等)以及列表数据(nights、skates、friends)。这些状态变量的变化都会被框架自动追踪,并触发相关 UI 的更新。由于状态全部集中在组件内部,数据流向非常清晰,便于理解和调试。

@Observed 装饰器用于标记数据模型类,使得类的实例属性具备可观察性。这是一种非常优雅的面向对象状态管理方案——开发者可以使用类来组织和封装数据,同时享受声明式 UI 的响应式特性。与将数据拆分为多个基本类型变量相比,使用 @Observed 类可以更好地保持数据的完整性和内聚性。

弹框相关的状态管理采用了"开关状态 + 表单数据 + 操作目标"的分离模式。三种弹框各有自己的开关状态变量,表单数据独立存储,操作目标通过 editIdx 和 delTarget 记录。这种模式虽然变量数量较多,但每个变量的职责单一,状态之间的关系清晰,避免了复杂状态对象带来的更新判断问题。

从优化的角度来看,项目中的状态管理还可以进一步完善。例如,可以将相关的状态组合成状态对象(如使用 @Observed 类封装弹框状态),减少状态变量的数量;可以引入 @Provide/@Consume 或 AppStorage 实现跨组件的状态共享;对于复杂的业务逻辑,可以抽离为独立的 ViewModel 层,实现视图与业务逻辑的分离。

6.3 性能优化总结

性能是移动端应用的关键指标,特别是对于包含动画效果的应用来说。本项目在性能优化方面有多个值得关注的设计点。首先,特效动画采用了非常轻量的实现方式:仅使用两个状态变量(tick 和 glow)驱动所有特效,通过数学公式计算光点位置,内存占用极低。与逐帧动画或 GIF 动画相比,这种基于状态和数学公式的动画方案在性能上具有显著优势。

其次,列表渲染使用 ForEach 组件配合唯一 key 值,确保列表数据变化时框架能够进行高效的 Diff 计算,只更新真正变化的列表项,而非重新渲染整个列表。对于路线列表、装备列表、轮友动态等长列表来说,这种优化可以显著提升数据更新时的渲染性能。

第三,条件渲染机制确保了非激活状态的 UI 节点不会被创建和渲染。例如,当用户在路线主 Tab 时,首页的七个子 Tab 内容和装备页、我的页都不会被渲染,只渲染当前激活的页面内容。这种按需渲染的策略可以有效减少组件树的节点数量,降低内存占用和渲染开销。

第四,特效层使用 hitTestBehavior(HitTestMode.None) 禁用触摸事件,避免了特效层对触摸事件的拦截和处理。这不仅提升了用户交互的响应速度(事件直接到达目标层),也减少了不必要的事件处理开销。在实现纯视觉装饰效果时,这种事件穿透的设置是必要的性能优化手段。

最后,颜色、图标等视觉资源的选择也考虑了性能因素。使用 emoji 作为图标无需加载图片资源,避免了图片解码和内存占用;使用 CSS 式的渐变和阴影效果(linearGradient、textShadow)由硬件加速绘制,比叠加多层半透明图片更高效。这些看似细微的选择,在整体上构成了应用良好性能的基础。

6.4 交互设计总结

交互设计是用户体验的核心组成部分,本项目在交互设计方面体现了移动端应用的最佳实践。首先,导航交互清晰直观。底部四个主 Tab 配合首页的七个子 Tab,构成了完整的导航体系。Tab 切换使用状态驱动,点击即时响应,没有多余的过渡动画干扰,保证了操作的效率感。

其次,弹框交互符合用户预期。三种弹框都有半透明遮罩、居中内容、取消/确认按钮等标准弹框元素,用户可以凭借已有的使用经验快速理解和操作。弹框的关闭方式多样(点击取消按钮、点击遮罩层、确认操作后自动关闭),满足了不同用户的操作习惯。删除确认弹框使用红色确认按钮和"再想想"的取消文案,在视觉和文案上都起到了防误触的作用。

第三,列表交互信息丰富。每个列表卡片不仅展示核心信息,还提供了多个维度的辅助信息(标签、状态、热度等),用户可以快速获取所需信息。卡片的视觉设计有明确的层次结构,重要信息(名称、价格、热度)使用强调色和粗体突出显示,次要信息使用弱化的颜色和字号,信息层级清晰。

第四,特效交互增强了氛围但不干扰功能。霓虹光晕和轨迹光点特效虽然持续动态,但不会拦截用户的触摸操作(通过 HitTestMode.None 实现),用户可以正常与下方的内容进行交互。特效只覆盖内容区域而不遮挡头部,保持了头部信息的稳定可读。这种"视觉增强但不干扰功能"的设计思路,是动效设计的重要原则。

从交互设计的角度来看,项目还可以进一步提升的空间包括:增加 Tab 切换的过渡动画,使页面切换更加流畅自然;增加列表项的点击反馈(如按压效果),提升操作的感知度;增加下拉刷新和上拉加载等标准列表交互,完善长列表的使用体验。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:



6.5 总结

可扩展性是衡量代码质量的重要维度,特别是对于快速迭代的互联网产品来说。从数据模型的角度来看,项目使用 @Observed 类来定义数据模型,每个类封装了对应业务实体的所有属性。这种面向对象的建模方式具有良好的可扩展性——如果需要增加新的属性,只需在类中添加即可;如果需要增加新的业务实体,只需定义新的 @Observed 类。

从 UI 组件的角度来看,项目使用 @Builder 函数来封装 UI 片段,每个 Builder 函数负责一个独立的功能模块。这种模块化的组织方式使得新增功能模块变得相对简单:只需新增一个 Builder 函数,然后在 build 方法中相应的位置添加调用即可。如果是新增子 Tab,还需要在 subNav 中添加 Tab 项,并在条件渲染中增加对应的分支。

从样式系统的角度来看,项目采用语义化的颜色常量,所有颜色都集中定义在 COLORS 对象中。如果需要调整配色方案或增加新的主题,只需修改或增加颜色常量的定义,无需改动 UI 代码。这种集中式的样式管理大大提升了样式系统的可维护性和可扩展性。

从功能扩展的角度来看,项目的架构预留了多个扩展点。例如,车队阵营功能可以扩展为完整的用户分组和对战系统;轮友圈功能可以扩展为完整的社交 Feed 流,增加评论、点赞、关注等功能;装备铺可以扩展为完整的电商系统,增加商品详情、购物车、订单等模块。这些扩展都可以在现有架构的基础上逐步迭代,不需要大规模的重构。

当然,单组件架构的可扩展性也有其局限性。随着功能模块的不断增加,单个组件文件会变得越来越庞大,代码的可读性和可维护性会逐渐下降。当项目规模达到一定程度时,就需要考虑进行组件拆分,将大组件拆分为多个小组件,每个组件负责单一的功能。同时,状态管理也需要从本地组件状态升级为全局状态管理,以支持跨组件的状态共享和通信。

Logo

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

更多推荐