鸿蒙技术栈全景解析

在这里插入图片描述

HarmonyOS 6.1.1 作为华为分布式操作系统的最新版本,在ArkUI声明式开发范式、ArkTS语言特性、状态管理机制以及动画系统等多个维度实现了质的飞跃。基于HarmonyOS ArkTS API 24 的开发模式,开发者可以通过声明式UI语法构建出高性能、高可维护的跨端应用程序。

ArkTS语言在TypeScript的基础上进行了深度扩展,引入了装饰器(Decorator)机制,使得UI组件可以通过@Entry、@Component、@State、@Observed等装饰器实现声明式的状态驱动渲染。这种范式的转变,从根本上改变了传统命令式UI开发中手动操作DOM/控件的繁琐模式。

在HarmonyOS 6.1.1中,ArkUI框架进一步优化了组件复用机制、渲染管线效率以及动画插值算法。特别是animateTo显式动画API的增强,使得开发者可以通过统一的动画上下文来驱动多属性联动动画,实现丝滑的交互动效。

基于HarmonyOS API 24 的另一个重要特性是状态管理体系的完善。从@State组件内状态,到@Observed/@ObjectLink嵌套对象观察,再到AppStorage/LocalStorage的全局状态存储,鸿蒙提供了多层次、细粒度的状态管理方案,满足不同复杂度应用的需求。

项目业务背景

在这里插入图片描述

本文所要深度解析的是一款名为"PARKOUR ZONE 城市跑酷装备馆"的电商类应用。该应用聚焦于城市跑酷运动装备的在线展示与销售,涵盖跑酷鞋、护具、手套、服装四大品类,并提供会员管理、训练打卡、订单追踪、收藏管理等增值服务。

应用的视觉设计采用了极具运动感的深色主题,以沥青灰(#2C2C2C)为基底,电光蓝(#1E90FF)与警示橙(#FF6B00)作为主色调点缀,营造出专业、动感的品牌氛围。整体交互风格强调运动速度感,通过仪表盘动画、速度线特效、粒子系统等视觉元素强化跑酷运动的极限精神。

从技术架构角度看,该应用是一个典型的单文件多组件ArkTS项目,所有页面组件、数据模型、工具函数均组织在同一模块中。这种架构方式虽然在大型项目中需要进一步拆分,但对于理解鸿蒙声明式UI的完整开发流程具有极高的教学价值。

整体架构概览

在这里插入图片描述

应用采用了经典的Tab导航架构,主入口页面通过底部Tab栏切换六个功能页面:首页、跑酷鞋、护具、手套、服装、我的。每个页面均为独立的@Component结构体,拥有各自的状态管理和交互逻辑。

数据层采用静态配置数组的方式集中管理,配合一系列纯函数工具实现数据的排序、过滤、统计和格式化。状态管理以@State装饰器为主,结合@Observed类实现可观察对象的细粒度更新。

下面是应用的整体架构层次图:

在这里插入图片描述

类型定义层深度解析

接口类型体系设计

类型定义是整个应用的基石。在ArkTS中,接口(interface)不仅提供了编译期的类型检查保障,更是数据契约设计的核心手段。应用共定义了十六个接口类型,覆盖了Tab元数据、商品数据、订单数据、特效配置等多个维度。

第一个核心接口是PKTabMeta,用于描述底部Tab栏的每一项配置。该接口包含三个属性:label(标签文本)、icon(图标字符)、color(主题色)。这种数据驱动的Tab配置方式,使得Tab栏的渲染完全由PK_TABS常量数组控制,新增或修改Tab项只需调整数据即可。

interface PKTabMeta {
  label: string
  icon: string
  color: string
}

第1-3行定义了标签、图标和颜色三个字符串属性。这种简洁的接口设计体现了ArkTS类型系统的轻量特性。与TypeScript不同的是,ArkTS接口中不支持可选属性的问号语法,所有属性均为必选,这在编译期就能提前发现数据缺失问题。

接下来是PKSkillMeta接口,用于描述技能等级数据。它包含name(技能名称)、level(等级数值)、color(进度条颜色)三个属性。该接口配合PK_SKILLS常量数组,在首页以技能进度条的形式展示用户的跑酷能力等级。

interface PKSkillMeta {
  name: string
  level: number
  color: string
}

第12行的level字段为number类型,取值范围0-100,用于控制进度条的宽度百分比。第13行的color字段为字符串类型,存储十六进制颜色值,实现每项技能独立配色的视觉效果。

PKHotItem接口定义了热销商品的数据结构。它包含id(唯一标识)、name(商品名称)、sales(销量)、color(主题色)四个属性。销量数据用于排序,color用于视觉区分。

interface PKHotItem {
  id: number
  name: string
  sales: number
  color: string
}

第19行的id字段是重要的唯一标识符,它不仅用于ForEach的键值生成,还通过pkHotPrice函数映射到商品价格。这种设计虽然在数据冗余方面有所取舍,但保证了热销榜数据的独立性。

PKGridFunc接口描述功能宫格项的数据结构,包含label、icon、color三个属性。该接口与PKTabMeta结构高度相似,但语义上完全不同——一个是导航Tab,一个是功能入口。这种看似重复的类型定义,实际上体现了领域驱动设计中"不同上下文应有独立类型"的原则。

PKMemberStat接口用于会员统计数据,包含label和value两个字符串属性。这种通用的键值对结构在展示型数据场景中非常实用,可以灵活适配多种统计项的展示需求。

@Observed装饰器与可观察类

在这里插入图片描述

在鸿蒙状态管理体系中,@Observed装饰器扮演着至关重要的角色。它用于装饰类,使得该类的实例属性变化可以被UI框架观察到,从而触发相应的UI更新。

应用中定义了两个@Observed类:PKShoe和PKGlove。这两个类代表了核心商品实体,它们需要支持运行时的属性修改(如编辑手套参数、新增鞋子等),因此需要被框架观察。

@Observed
class PKShoe {
  id: number = 0
  name: string = ''
  grip: number = 0
  weight: number = 0
  price: number = 0
  colorHex: string = ''
  tag: string = ''
  constructor(id: number, name: string, grip: number, weight: number, price: number, colorHex: string, tag: string) {
    this.id = id; this.name = name; this.grip = grip; this.weight = weight
    this.price = price; this.colorHex = colorHex; this.tag = tag
  }
}

第36行的@Observed装饰器是整个类的关键。被@Observed装饰的类,其实例的属性变化会被ArkUI框架追踪。当属性值发生改变时,所有依赖该属性的UI节点都会进行增量更新。

第37-43行定义了七个实例属性,并都提供了默认值。这是ArkTS类定义的规范要求——所有属性必须在声明时初始化,或在构造函数中赋值。这种严格的初始化要求,从语言层面避免了空引用异常的问题。

第44-47行的构造函数采用了紧凑的一行多赋值写法。虽然这种写法在可读性上略有牺牲,但对于数据模型类而言,它减少了代码行数,使得类定义更加凝练。

PKGlove类的结构与PKShoe类似,同样使用@Observed装饰器。它包含id、name、friction(磨阻系数)、finger(指型)、price、colorHex六个属性。friction属性是手套品类的特有参数,用于描述手套的摩擦力等级。

值得注意的是,并非所有数据类都使用了@Observed装饰器。PKGuard和PKCloth仍然使用普通接口定义。这种差异的背后,是应用对状态更新粒度的精细考量——只有需要运行时修改属性的对象才需要@Observed能力,而纯展示型数据使用接口即可满足需求。

业务数据接口详解

在这里插入图片描述

PKGuard接口定义了护具商品的数据结构。它包含id、name、level(防护等级)、price、parts(覆盖部位)、material(材质)、colorHex七个属性。其中parts字段以字符串形式描述覆盖的身体部位,如"膝部+小腿",这种设计方便了后续的部位分布统计。

PKCloth接口定义了服装商品的数据结构。它的特有属性是breath(透气性)和season(适用季节)。透气性是一个0-100的数值,季节字段则是一个字符串,可能包含"夏"、“春秋”、"冬"等取值,这种多季节的设计使得同一件服装可以属于多个季节分类。

PKOrder接口描述订单数据,包含id、name、time(下单时间)、amount(金额)、status(状态)五个属性。订单状态字段是一个字符串类型,取值包括"已完成"、“运输中”、"待付款"等,通过pkStatusColor函数映射为不同的颜色。

PKFav接口定义了收藏项的数据结构,包含id、name、type(类型)、colorHex四个属性。收藏项的type字段用于标识商品所属的品类,如"跑酷鞋"、"护具"等,方便在收藏列表中进行分类展示。

统计与特效接口

在这里插入图片描述

PKSpend接口用于月消费统计数据,包含month(月份)和amount(金额)两个属性。该数据用于在"我的"页面绘制月消费柱状图,直观展示用户近六个月的消费趋势。

PKPartDist、PKFingerDist、PKSeasonDist三个接口结构完全一致,都包含label和count两个属性。它们分别用于描述护具部位分布、手套指型分布和服装季节分布的统计结果。这种数据结构是统计图表的标准输入格式。

PKDayItem接口定义了日历日的数据结构,包含day(日期)和checked(是否打卡)两个属性。day为0时表示空白占位格子,用于填充日历第一周之前的空白位置。

PKTick、PKParticle、PKSpeedLine三个接口用于首页跳跃仪表盘的特效数据。PKTick描述刻度信息(角度和大小),PKParticle描述粒子参数(位移和大小),PKSpeedLine描述速度线参数(位置和长度)。这些特效数据配合动画状态变量,实现了丰富的视觉动效。

PKSizeOption接口用于尺码选择,包含size(尺码名称)和stock(库存)两个属性。该数据配合尺码选择弹框,为用户提供直观的尺码选择体验。

PKTabMeta

+string label

+string icon

+string color

PKSkillMeta

+string name

+number level

+string color

PKHotItem

+number id

+string name

+number sales

+string color

«@Observed»

PKShoe

+number id

+string name

+number grip

+number weight

+number price

+string colorHex

+string tag

PKGuard

+number id

+string name

+number level

+number price

+string parts

+string material

+string colorHex

«@Observed»

PKGlove

+number id

+string name

+number friction

+string finger

+number price

+string colorHex

PKCloth

+number id

+string name

+number breath

+string season

+string colorHex

+number price

PKOrder

+number id

+string name

+string time

+number amount

+string status

PKFav

+number id

+string name

+string type

+string colorHex

PKTick

+number angle

+boolean big

PKParticle

+number dx

+number dy

+number size

PKSpeedLine

+number x

+number y

+number len

全局静态配置与纯函数层

配置数据的设计思想

全局静态配置是应用的数据基石。所有的商品数据、Tab配置、特效参数等都以const常量的形式集中定义,形成了一个统一的数据配置中心。这种设计方式具有多方面的优势。

首先,数据集中管理便于维护和修改。所有商品信息都在同一个位置定义,新增、修改或删除商品数据时不需要在多个文件之间跳转。对于中小型应用而言,这种模式能够显著提升开发效率。

其次,const常量保证了数据的不可变性。在编译期,ArkTS就能够检测到对常量的意外修改,避免了运行时数据被意外篡改的风险。虽然@State变量会拷贝这些数据用于状态管理,但原始数据源始终保持纯净。

第三,数据驱动UI的模式使得UI代码更加通用。ForEach循环可以直接遍历这些配置数组生成UI元素,无需硬编码每个列表项。这种模式下,UI结构与数据内容解耦,代码的可复用性和可维护性都得到了提升。

核心配置数组详解

PK_TABS数组定义了底部Tab栏的六个导航项。每项包含label、icon、color三个属性,分别对应标签文字、emoji图标和主题色。数组的顺序决定了Tab栏从左到右的排列顺序,而数组索引则对应currentTab状态变量的取值。

const PK_TABS: PKTabMeta[] = [
  { label: '首页', icon: '🏠', color: '#1E90FF' },
  { label: '跑酷鞋', icon: '👟', color: '#1E90FF' },
  { label: '护具', icon: '🛡️', color: '#FF6B00' },
  { label: '手套', icon: '🧤', color: '#1E90FF' },
  { label: '服装', icon: '👕', color: '#FF6B00' },
  { label: '我的', icon: '👤', color: '#1E90FF' }
]

第147-153行的六个Tab项交替使用电光蓝和警示橙作为主题色,形成了富有节奏感的视觉层次。首页、跑酷鞋、手套、我的使用电光蓝,护具、服装使用警示橙,这种配色方案与各品类的品牌调性相呼应。

PK_SKILLS数组定义了三项技能等级数据:跑酷等级(72级)、翻越技巧(58级)、落地控制(86级)。每项技能都有独立的颜色,电光蓝和警示橙交替出现,与整体视觉风格保持一致。

PK_HOT数组包含六个热销商品,销量从428到205不等。这些数据通过pkSortHot函数按销量降序排列后展示在首页热销榜中。值得注意的是,热销榜的价格信息并不直接存储在PK_HOT中,而是通过pkHotPrice函数根据id进行映射,这种设计减少了数据冗余。

PK_FUNCS数组定义了八个功能宫格项,包括训练课程、装备保养、动作教学、城市地图、极限赛事、跑友圈子、医疗急救、装备租赁。这些功能入口以2x4的网格形式展示在首页,为用户提供了丰富的增值服务入口。

PK_MEMBER_STATS数组定义了四项会员统计数据:训练天数(96天)、累计里程(320公里)、收藏装备(6件)、本月打卡(12天)。这些数据展示在"我的"页面的会员卡区域,直观呈现用户的运动数据和会员权益。

商品数据配置详解

PK_SHOES数组包含十款跑酷鞋的数据,每款鞋都有独特的抓地力、重量、价格和标签。抓地力最高的是"壁虎 墙面抓附"(95分),最轻的是"轻羽 零感训练"(188g),最贵的是"影锋 极限跳跃"(949元)。这些数据为跑酷鞋页面的双列Grid和重量对比图提供了丰富的数据支撑。

PK_GUARDS数组包含八款护具产品,涵盖了膝部、肘部、腕部、背部、脚踝等多个身体部位。防护等级最高的是"钛甲护膝"(92级),价格最高的是"龟壳护背"(359元)。材质信息包括TPU凯夫拉、碳纤维复合、硅胶+ABS等专业术语,增强了产品的专业感。

PK_GLOVES数组包含八款手套产品,分为全指、半指、露指三种指型。磨阻系数最高的是"岩壁 攀爬专用"(94),价格最高的也是同款(259元)。每款手套都有独特的定位和适用场景,满足不同跑酷爱好者的需求。

PK_CLOTHS数组包含六款服装产品,覆盖了春夏秋冬四个季节。透气性最高的是"霓虹训练背心"(95分),价格最高的是"冲锋防风外套"(429元)。服装数据还包括晨雾连帽卫衣和暖芯抓绒内层等春秋冬季产品,形成了完整的产品线。

订单、收藏与消费数据

PK_ORDERS数组包含六笔订单记录,涵盖已完成、运输中、待付款三种状态。每笔订单都有详细的下单时间和金额信息。这些数据展示在"我的"页面的订单列表中,让用户可以清晰追踪自己的购买记录。

PK_FAVS数组包含六个收藏项,涵盖了跑酷鞋、护具、手套、服装四个品类。收藏数据支持删除操作,用户可以通过删除确认弹框移除不再需要的收藏项。

PK_SPEND数组包含近六个月的消费数据,从3月到8月,金额从1280元到5180元不等。整体呈现上升趋势,8月达到最高点。这些数据用于绘制月消费柱状图,直观展示用户的消费趋势。

特效配置数据

PK_TICKS数组定义了仪表盘的十一个刻度点,角度从-90度到90度,每隔18度一个刻度。其中-90度、0度、90度三个位置为大刻度(big: true),其余为小刻度。这些刻度通过三角函数计算位置,绘制出半圆形的仪表盘刻度。

PK_PARTICLES数组定义了五个跳跃粒子的参数,每个粒子有不同的位移和大小。粒子效果在落地时触发,通过缩放和透明度动画模拟尘土飞扬的视觉效果。

PK_SPEED_LINES数组定义了六条速度线的位置和长度。速度线在跳跃过程中显示,通过水平位移动画模拟高速运动的视觉效果,增强了跳跃动作的速度感。

PK_SIZES数组定义了五个尺码选项(S、M、L、XL、XXL)及其对应的库存数量。L码库存最多(34件),XXL码最少(3件)。这些数据用于服装页面的尺码选择弹框。

纯函数工具层设计

纯函数是函数式编程的核心概念,它们不依赖外部状态,不产生副作用,相同的输入永远产生相同的输出。应用中定义了大量的纯函数工具,涵盖了排序、统计、过滤、格式化等多个方面。

pkPct函数是最简单的格式化函数,它将数值转换为百分比字符串。这个函数虽然简单,但在进度条宽度计算中被频繁使用,体现了"一次定义,多处复用"的工程化思想。

function pkPct(v: number): string {
  return v.toString() + '%'
}

第309-311行的pkPct函数接收一个number类型的参数v,返回拼接了百分号的字符串。在ArkUI中,Column组件的width属性可以接受百分比字符串值,因此这个函数成为了数值到宽度的桥梁。

pkSortHot函数实现了热销商品的排序功能。它首先创建一个原数组的拷贝,然后使用Array.sort方法按销量降序排列。创建拷贝而不是直接修改原数组,是纯函数的典型特征——保证输入数据不被修改。

function pkSortHot(list: PKHotItem[]): PKHotItem[] {
  let r: PKHotItem[] = []
  for (let i = 0; i < list.length; i++) {
    r.push(list[i])
  }
  return r.sort((a: PKHotItem, b: PKHotItem) => b.sales - a.sales)
}

第314-317行通过for循环手动拷贝数组。这种写法虽然比展开运算符([…list])更冗长,但在ArkTS的早期版本中兼容性更好,也更贴近底层实现。第318行的sort回调函数使用b.sales - a.sales实现降序排列,数值越大排名越靠前。

pkSortShoeWeight函数实现了跑鞋按重量升序排列的功能。与pkSortHot类似,它也是先拷贝数组再排序,只是排序依据从销量变成了重量,且采用升序排列(a.weight - b.weight)。

pkSortGuard函数实现了护具按防护等级降序排列的功能。排序逻辑与热销榜类似,只是数据类型和排序字段不同。这种高度一致的排序函数模式,体现了代码设计的规律性和可预测性。

pkTopLight函数返回最轻的六款跑鞋。它先按重量升序排序,再通过slice(0, 6)截取前六项。这个函数是组合式函数设计的典范——先排序再截取,通过基础操作的组合实现复杂需求。

分布统计算法

pkCountPart函数统计护具列表中覆盖特定部位的产品数量。它通过for循环遍历所有护具,使用indexOf方法检查parts字符串中是否包含目标部位名称。如果包含则计数器加一。

function pkCountPart(list: PKGuard[], part: string): number {
  let c: number = 0
  for (let i = 0; i < list.length; i++) {
    if (list[i].parts.indexOf(part) >= 0) {
      c = c + 1
    }
  }
  return c
}

第349行的for循环使用传统的索引遍历方式,而不是forEach或for…of。这是因为在性能敏感的场景中,传统for循环的执行效率更高,尤其在ArkUI的渲染上下文中,每一点性能优化都可能影响页面流畅度。

第350行使用indexOf判断字符串包含关系。由于parts字段可能包含多个部位(如"膝部+小腿"),使用indexOf可以正确匹配到包含目标部位的记录。这种基于字符串包含的统计方式,实现简单但功能有效。

pkPartDist函数生成完整的部位分布数据。它遍历PK_PARTS常量数组,对每个部位调用pkCountPart函数获取数量,最终组装成PKPartDist数组返回。这个函数是部位分布柱状图的数据来源。

类似地,pkCountFinger和pkFingerDist实现了手套指型分布的统计,pkCountSeason和pkSeasonDist实现了服装季节分布的统计。虽然统计的维度不同,但算法结构高度一致,体现了代码模式的复用性。

过滤与工具函数

pkFilterFav函数实现了收藏项的过滤删除功能。它通过for循环遍历收藏列表,将id不等于目标id的项放入新数组返回。这种"创建新数组而非修改原数组"的方式,是不可变数据操作的标准实践。

pkBuildCalendar函数生成日历数据。它首先添加6个空白占位项(day: 0),然后添加31天的日期数据。每天的checked状态通过(d % 3 === 1 || d % 7 === 0)的规则计算,模拟出一个伪随机的打卡记录。

function pkBuildCalendar(year: number, month: number): PKDayItem[] {
  let r: PKDayItem[] = []
  for (let i = 0; i < 6; i++) {
    r.push({ day: 0, checked: false })
  }
  for (let d = 1; d <= 31; d++) {
    r.push({ day: d, checked: (d % 3 === 1 || d % 7 === 0) })
  }
  return r
}

第413-415行添加6个空白占位项。这些占位项的作用是填充日历第一行之前的空白位置,使得日历的第一列对应星期日。虽然这个简化的日历没有根据实际月份计算首日偏移,但固定6个占位的设计确保了UI布局的稳定性。

第416-418行生成1到31号的日期数据。打卡规则是日期模3余1或模7余0时标记为已打卡。这种规则生成的打卡分布看起来自然且有一定的规律性,适合作为演示数据。

三角函数与计算工具

pkTickX和pkTickY函数通过三角函数计算刻度的坐标位置。它们以(130, 200)为圆心,116为半径,根据角度计算出刻度在圆弧上的坐标点。这两个函数是仪表盘刻度渲染的数学基础。

function pkTickX(angle: number): number {
  return Math.round(130 + 116 * Math.sin(angle * Math.PI / 180))
}

function pkTickY(angle: number): number {
  return Math.round(200 - 116 * Math.cos(angle * Math.PI / 180))
}

第423行使用sin函数计算x坐标。由于角度以度为单位,需要先乘以Math.PI/180转换为弧度。130是圆心的x坐标,116是半径。Math.round函数将结果取整,确保坐标为整数值,避免亚像素渲染带来的模糊问题。

第427行使用cos函数计算y坐标。y坐标的计算使用减法(200 - 116 * cos),这是因为在屏幕坐标系中,y轴向下为正方向,而数学坐标系中y轴向上为正方向。通过减法可以将数学坐标转换为屏幕坐标。

pkTickColor函数根据当前跳跃高度计算刻度的颜色。它将高度值映射到-90到90度的角度范围内,如果刻度角度小于等于映射后的角度,则显示电光蓝(激活状态),否则显示深灰(未激活状态)。这个函数实现了仪表盘指针扫过区域的高亮效果。

pkSpendBar函数将消费金额转换为柱状图的高度百分比。它以5200为基准最大值,将金额映射为百分比后再加6个像素的最小高度。这样即使金额为0,柱子也有6像素的高度,视觉上更加完整。

pkWeightBar函数将鞋子重量转换为柱状图的高度。重量越轻,柱子越高(因为(410 - w) * 0.4 + 16,w越小结果越大)。这种反向映射的设计符合"越轻越好"的语义——轻量鞋的柱子更高,视觉上更突出。

pkRankColor函数根据排名索引返回对应的颜色。第一名使用警示橙色,第二名使用电光蓝色,第三名使用银灰色,其余使用深灰色。这种配色方案模拟了金银铜牌的颁奖视觉,增强了排行榜的竞技感。

pkStatusColor函数根据订单状态返回对应的颜色。已完成使用绿色(#4CAF50),运输中使用电光蓝,待付款使用警示橙。颜色编码让用户可以快速识别订单状态,提升了信息获取效率。

pkHotPrice函数根据热销商品的id返回价格。这是一个简单的映射函数,通过一系列if判断实现id到价格的转换。虽然可以用对象字面量或Map替代,但连续if的写法在ArkTS中执行效率更高。

静态配置数据

排序函数

统计函数

过滤函数

计算函数

pkSortHot 热销排序

pkSortShoeWeight 重量排序

pkSortGuard 等级排序

pkTopLight TOP6轻量

pkPartDist 部位分布

pkFingerDist 指型分布

pkSeasonDist 季节分布

pkFilterFav 收藏过滤

pkTickX/Y 刻度坐标

pkTickColor 刻度颜色

pkSpendBar 消费柱高

pkWeightBar 重量柱高

UI渲染层

主入口页面架构解析

@Entry装饰器与组件生命周期

主入口页面是整个应用的根组件,使用@Entry装饰器标记。@Entry装饰器告诉ArkUI框架,这个组件是页面的入口点,框架会从这里开始构建UI树。每个页面有且仅有一个@Entry组件。

@Entry
@Component
struct PKMainPage {
  @State currentTab: number = 0

第498行的@Entry装饰器标记了PKMainPage为页面入口。第499行的@Component装饰器声明这是一个自定义组件。struct关键字定义了组件结构体,这是ArkTS声明式UI的基本单元。

第500行的@State装饰器声明了currentTab状态变量,初始值为0。@State是组件级别的状态管理装饰器,当被装饰的变量发生变化时,框架会自动重新执行build方法中依赖该变量的部分,实现UI的增量更新。

@Builder自定义构建函数

@Builder装饰器用于定义自定义构建函数,它可以将可复用的UI片段封装成函数,在build方法中通过this.函数名()的方式调用。这种机制类似于React中的组件组合,但更加轻量。

@Builder contentArea() {
  Column() {
    if (this.currentTab === 0) {
      PKHomePage()
    } else if (this.currentTab === 1) {
      PKShoesPage()
    } else if (this.currentTab === 2) {
      PKGuardPage()
    } else if (this.currentTab === 3) {
      PKGlovePage()
    } else if (this.currentTab === 4) {
      PKClothPage()
    } else {
      PKMinePage()
    }
  }
  .width('100%').height('100%')
}

第502行的@Builder装饰器定义了contentArea构建函数。第503行使用Column容器包裹内容区域,设置宽高为100%以充满父容器。

第504-516行通过一系列if/else判断,根据currentTab的值渲染不同的页面组件。这种条件渲染方式是声明式UI的核心特性之一——UI描述与状态直接关联,状态变化时框架自动处理组件的挂载和卸载。

值得注意的是,每次切换Tab时,对应的页面组件会被重新创建(mount),而离开的页面组件会被销毁(unmount)。这意味着页面的内部状态(如表单输入、滚动位置等)在切换后会丢失。对于需要保持状态的场景,可以使用@Keep装饰器或页面级状态持久化方案。

底部Tab栏实现机制

bottomTabItem构建函数定义了单个Tab项的UI结构和交互逻辑。它接收四个参数:icon(图标)、label(标签)、idx(索引)、color(颜色),这些参数由ForEach循环从PK_TABS数组中传入。

@Builder bottomTabItem(icon: string, label: string, idx: number, color: string) {
  Column() {
    Text(icon).fontSize(18).opacity(this.currentTab === idx ? 1.0 : 0.5)
    Text(label).fontSize(9)
      .fontColor(this.currentTab === idx ? '#F0F0F0' : '#6E6E6E')
      .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
    if (this.currentTab === idx) {
      Column().width(20).height(3)
        .backgroundColor(color).borderRadius(2).margin({ top: 3 })
    }
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 6, bottom: 6 })
  .onClick(() => { this.currentTab = idx })
}

第523行的图标Text组件使用了条件透明度——选中时不透明度为1.0,未选中时为0.5。这种微妙的视觉差异既区分了选中状态,又保持了整体视觉的统一性。

第524-527行的标签Text组件根据选中状态切换颜色和字重。选中时使用亮白色和粗体,未选中时使用灰色和常规字重。这种多层次的视觉反馈(颜色+字重+透明度)比单一维度的变化更加醒目。

第528-531行使用条件渲染在选中的Tab下方显示一个彩色指示条。指示条宽20px,高3px,使用Tab对应的主题色,圆角为2px。这个小指示器是移动端Tab导航的经典设计元素,能够清晰地指示当前所在位置。

第533行的layoutWeight(1)是Tab等分排列的关键。每个Tab项设置相同的layoutWeight,Row容器会自动将它们平均分配宽度,实现灵活的自适应布局。

第536行的onClick事件处理函数将currentTab设置为当前Tab的索引。这是一个典型的声明式状态更新——只需修改状态变量的值,框架会自动处理UI的更新。

品牌头部设计

品牌头部位于页面顶部,展示应用Logo和会员标识。它采用深色背景(#1B1B1B),与内容区域的稍浅背景形成层次区分。

Column() {
  Row() {
    Text('PARKOUR').fontSize(21).fontWeight(FontWeight.Bold).fontColor('#1E90FF')
    Text('ZONE').fontSize(21).fontWeight(FontWeight.Bold).fontColor('#F5F5F5')
    Column().layoutWeight(1)
    Text('PK·001').fontSize(10).fontColor('#FF6B00')
      .border({ width: 1, color: '#FF6B00', radius: 8 })
      .padding({ left: 8, right: 8, top: 3, bottom: 3 })
  }
  .width('100%').padding({ left: 16, right: 16, top: 12 })
  Text('城市跑酷装备馆 · 暗夜行动指南')
    .fontSize(11).fontColor('#8A8A8A')
    .width('100%').padding({ left: 16, top: 3 })
}
.width('100%').padding({ bottom: 10 })
.backgroundColor('#1B1B1B')

第544-545行的品牌名称使用了双色设计——"PARKOUR"为电光蓝,"ZONE"为白色。这种色彩分割的Logo设计增强了品牌的视觉冲击力,也与应用的运动主题相契合。

第546行的Column().layoutWeight(1)是一个经典的弹性布局技巧。通过在两个元素之间插入一个设置了layoutWeight的空白Column,可以实现左右两端对齐的效果。这类似于Web开发中的flex: 1或space-between布局。

第547-549行的"PK·001"会员徽章采用了描边设计——橙色的文字和边框,半透明的背景。圆角矩形的形状加上小号字体,营造出精致的标签感。

第552-554行的副标题"城市跑酷装备馆 · 暗夜行动指南"使用了灰色小字,定位在Logo下方。副标题进一步阐述了应用的定位和主题,"暗夜行动指南"的说法增添了几分神秘感和冒险感。

整体布局结构

主页面的整体布局采用了经典的三段式结构:顶部品牌头部、中间内容区域、底部Tab栏。外层使用Column容器垂直排列三个区域,内容区域通过layoutWeight(1)充满剩余空间。

build() {
  Column() {
    // 品牌头部
    Column() { ... }
    .width('100%').padding({ bottom: 10 })
    .backgroundColor('#1B1B1B')

    this.contentArea()

    // 底部 Tab 栏
    Row() {
      ForEach(PK_TABS, (tab: PKTabMeta, idx: number) => {
        this.bottomTabItem(tab.icon, tab.label, idx, tab.color)
      }, (tab: PKTabMeta) => tab.label)
    }
    .width('100%')
    .backgroundColor('#1B1B1B')
    .padding({ top: 2, bottom: 6 })
  }
  .width('100%').height('100%')
  .backgroundColor('#141414')
}

第562-566行的底部Tab栏使用Row容器配合ForEach循环渲染。ForEach的第二个参数是一个函数,返回每项的唯一键值(这里使用tab.label)。键值对于框架的diff算法至关重要,它能帮助框架高效地识别列表项的增删和移动。

第572行的背景色设置为#141414,这是整个应用的最深色,作为底层背景。品牌头部和Tab栏使用稍浅的#1B1B1B,形成微妙的层次差异。这种多层次的深色设计,既保持了整体的暗色调,又通过细微的色差营造了空间感。

currentTab = 0

点击Tab 1

点击Tab 2

点击Tab 3

点击Tab 4

点击Tab 5

点击Tab 0

点击Tab 2

点击Tab 0

点击Tab 1

点击Tab 0

点击Tab 0

点击Tab 0

首页

跑酷鞋页

护具页

手套页

服装页

我的页

首页:跳跃仪表盘深度解析

页面结构总览

首页是应用的核心展示页面,也是视觉效果最丰富的页面。它以"跳跃仪表盘"为核心亮点,配合技能等级条、热销榜和功能宫格,构成了一个信息丰富且动感十足的首页。

页面的状态变量数量众多,共包含八个@State变量和一个私有定时器变量。这些状态变量共同控制着跳跃动画的各个视觉元素,包括跳跃位置、指针角度、跳跃高度、冲击波效果、粒子效果、速度线位移等。

@Component
struct PKHomePage {
  @State pkJumpY: number = 0
  @State pkNeedleAngle: number = -72
  @State pkJumpHeight: number = 0
  @State pkShockScale: number = 0.4
  @State pkShockOp: number = 0
  @State pkParticleScale: number = 0.4
  @State pkParticleOp: number = 0
  @State pkSpeedOffset: number = 0
  @State pkRunning: boolean = false
  private pkTimer: number = -1

第579行的pkJumpY控制跳跃球的垂直位移,初始值为0,表示在初始位置。第580行的pkNeedleAngle控制仪表盘指针的旋转角度,初始值为-72度,指向较低的位置。

第581行的pkJumpHeight记录当前跳跃高度,用于实时高度显示和刻度颜色计算。第582-583行的pkShockScale和pkShockOp控制落地冲击波的缩放和透明度。

第584-585行的pkParticleScale和pkParticleOp控制跳跃粒子的缩放和透明度。第586行的pkSpeedOffset控制速度线的水平位移。第587行的pkRunning标记是否处于跳跃运动状态。

第588行的pkTimer是私有定时器引用,用于存储setInterval的返回值,以便在组件销毁时清除定时器。它不使用@State装饰,因为它不需要触发UI更新。

生命周期与动画控制

aboutToAppear和aboutToDisappear是ArkUI组件的两个重要生命周期钩子。aboutToAppear在组件即将显示时调用,适合执行初始化操作;aboutToDisappear在组件即将销毁时调用,适合执行清理操作。

aboutToAppear(): void {
  this.pkTimer = setInterval(() => {
    this.pkDoJump()
  }, 2600)
}

aboutToDisappear(): void {
  clearInterval(this.pkTimer)
}

第590-594行的aboutToAppear方法设置了一个2600毫秒(2.6秒)间隔的定时器,周期性地触发跳跃动画。这个自动播放的设计使得仪表盘始终处于动态展示状态,吸引用户注意力。

第596-598行的aboutToDisappear方法清除定时器,防止内存泄漏和不必要的性能消耗。这是使用定时器的标准实践——必须在组件销毁时清理资源。

pkDoJump方法是跳跃动画的核心控制函数。它使用getUIContext().animateTo()方法执行显式动画,将多个属性同时过渡到目标状态。

private pkDoJump(): void {
  this.getUIContext().animateTo({ duration: 460, curve: Curve.EaseOut }, () => {
    this.pkJumpY = -128
    this.pkNeedleAngle = 84
    this.pkJumpHeight = 88
    this.pkSpeedOffset = 42
    this.pkRunning = true
  })
  setTimeout(() => {
    this.pkLand()
    this.getUIContext().animateTo({ duration: 520, curve: Curve.EaseIn }, () => {
      this.pkJumpY = 0
      this.pkNeedleAngle = -72
      this.pkJumpHeight = 0
      this.pkSpeedOffset = 0
      this.pkRunning = false
    })
  }, 560)
}

第601行的getUIContext().animateTo()是HarmonyOS ArkTS API 24 中显式动画的标准写法。第一个参数是动画配置对象,包含duration(时长)和curve(缓动曲线)。第二个参数是闭包函数,在其中修改状态变量的值,框架会自动对这些变化应用动画过渡。

第602-606行设置了起跳阶段的目标状态:跳跃球向上移动128像素,指针旋转到84度(接近最高点),跳跃高度达到88cm,速度线偏移42像素,运动状态设为true。

动画时长460毫秒,使用EaseOut缓动曲线——开始快、结束慢。这种曲线模拟了物体向上抛射时受重力影响逐渐减速的物理规律,让跳跃动作更加自然。

第608行的setTimeout设置了560毫秒后的回调。这个延迟时间包括了460毫秒的上升动画,加上约100毫秒的"滞空"时间,然后触发落地动作。

第609行调用pkLand()方法触发落地特效(冲击波和粒子)。第610-616行执行下落阶段的动画,将所有属性恢复到初始状态。下落动画时长520毫秒,使用EaseIn缓动曲线——开始慢、结束快,模拟物体加速下落的物理效果。

落地特效实现

pkLand方法处理落地瞬间的视觉特效,包括冲击波和粒子两个效果。这两个效果都使用了"从小变大、从有到无"的动画模式,模拟了冲击扩散的视觉效果。

private pkLand(): void {
  this.pkShockScale = 0.4
  this.pkShockOp = 1
  this.pkParticleScale = 0.4
  this.pkParticleOp = 1
  this.getUIContext().animateTo({ duration: 560, curve: Curve.EaseOut }, () => {
    this.pkShockScale = 2
    this.pkShockOp = 0
    this.pkParticleScale = 1.7
    this.pkParticleOp = 0
  })
}

第621-624行首先重置特效状态到初始值:缩放0.4,透明度1。这个"先重置再动画"的技巧非常重要——如果不重置,当动画连续触发时可能会从上一次的中间状态开始过渡,导致视觉不连贯。

第625-630行执行特效扩散动画。在560毫秒内,冲击波的缩放从0.4增加到2(放大2倍),透明度从1降到0(完全消失)。粒子的缩放从0.4增加到1.7,透明度同样从1降到0。

冲击波的缩放比例(2倍)大于粒子的缩放比例(1.7倍),这模拟了真实世界中冲击波传播速度快于碎片飞溅的物理现象。两个效果使用相同的动画时长和缓动曲线,保持了视觉的协调性。

仪表盘视觉构成

仪表盘是首页最复杂的视觉组件,由多个图层叠加而成。使用Stack容器作为基础,通过position定位将各个元素精确放置在指定位置。

Stack 仪表盘容器

速度线层 SpeedLines

刻度弧层 Ticks

指针层 Needle

中心轴层 CenterAxis

粒子层 Particles

冲击波层 ShockWave

跳跃球层 JumpBall

6条速度线 水平位移

11个刻度 颜色动态

指针 旋转动画

5个粒子 缩放+透明

冲击波圆环 缩放+透明

跳跃球 垂直位移

速度线是最底层的装饰元素,共六条,分布在不同位置。它们在跳跃过程中向右平移并增加透明度,模拟高速运动时周围景物向后飞逝的视觉效果。

刻度弧层由11个刻度条组成,排列成半圆形。刻度的颜色根据当前跳跃高度动态变化——指针扫过的区域显示电光蓝,未到达的区域显示深灰色。这种动态着色让仪表盘看起来像是一个实时的仪表。

指针是一个橙色的长条,绕底部中心点旋转。指针的角度与跳跃高度同步变化,形成了"跳跃越高、指针越偏右"的视觉关联。中心轴是一个小圆点,作为指针的旋转轴心,也起到视觉锚点的作用。

粒子层由五个小圆点组成,在落地时触发扩散动画。粒子从中心点向不同方向飞散,同时逐渐消失,模拟落地时尘土飞溅的效果。

冲击波是一个蓝色圆环,落地时从中心向外扩散并逐渐消失。圆环的边框设计让它看起来像是能量波动的波纹,增强了落地的视觉冲击力。

跳跃球是整个仪表盘的核心主角——一个橙色的发光圆球。它通过translate的y轴位移实现上下跳跃的动作。shadow属性为球体添加了橙色光晕,让它看起来更加立体和有能量感。

刻度坐标计算详解

刻度的位置计算使用了三角函数,这是仪表盘类组件的核心技术点。每个刻度都有一个角度值,通过sin和cos函数计算出在圆弧上的x、y坐标。

ForEach(PK_TICKS, (t: PKTick) => {
  Column().width(2).height(t.big ? 16 : 9)
    .backgroundColor(pkTickColor(t.angle, this.pkJumpHeight))
    .borderRadius(2)
    .position({ x: pkTickX(t.angle), y: pkTickY(t.angle) })
}, (t: PKTick) => t.angle.toString())

第659行的刻度高度根据big属性区分——大刻度高16px,小刻度高9px。这种大小刻度交替的设计是真实仪表的常见样式,提升了视觉的专业感。

第660行的背景色通过pkTickColor函数动态计算。该函数比较刻度角度与当前高度对应的角度,如果刻度在已达到的范围内则显示电光蓝,否则显示深灰色。

第662行的position属性设置刻度的绝对位置,x和y坐标分别通过pkTickX和pkTickY函数计算得出。这两个函数封装了三角函数计算,让UI代码更加简洁易读。

指针旋转机制

指针的旋转动画是仪表盘的关键交互反馈。它使用rotate变换实现,旋转中心设置在底部中心点。

Column().width(4).height(82)
  .backgroundColor('#FF6B00')
  .borderRadius(2)
  .rotate({ angle: this.pkNeedleAngle, centerX: '50%', centerY: '100%' })
  .position({ x: 128, y: 118 })

第665-667行定义了指针的外观:宽4px、高82px的橙色矩形,圆角2px。这个细长的形状模拟了真实仪表指针的形态。

第668行的rotate属性设置了旋转变换。angle参数绑定到pkNeedleAngle状态变量,实现角度的动画过渡。centerX: '50%'和centerY: '100%'将旋转中心设置在指针底部的中点,这样指针就像钟表指针一样绕底部旋转。

第669行的position将指针定位在(128, 118)的位置。这个坐标是经过精确计算的——指针底部中心对齐仪表盘的圆心位置(130, 200),确保旋转轴心与刻度圆弧的圆心重合。

跳跃球与发光效果

跳跃球是整个仪表盘的视觉焦点,它的上下运动直观地表达了"跳跃"的主题。发光效果的运用让球体更具科技感和能量感。

Column().width(26).height(26)
  .backgroundColor('#FF6B00').borderRadius(13)
  .shadow({ radius: 10, color: '#66FF6B00' })
  .position({ x: 117, y: 164 })
  .translate({ y: this.pkJumpY })

第695-697行定义了球体的外观:26x26的橙色圆形,通过borderRadius: 13实现完美圆形。shadow属性添加了柔和的发光效果——radius: 10设置模糊半径为10px,color: '#66FF6B00’使用半透明的橙色(66是alpha通道的十六进制值,约40%不透明度)。

第699行的translate属性通过y轴位移实现跳跃动作。pkJumpY的负值表示向上移动(因为屏幕坐标系y轴向下为正)。在起跳动画中,pkJumpY从0变为-128,球体向上移动128像素。

使用translate而不是直接修改position的y值有两个优势。首先,translate是合成层属性,不会触发布局重新计算,性能更好。其次,position定义的是基准位置,translate定义的是偏移量,这种分离让逻辑更加清晰。

手动试跳交互

除了自动播放的跳跃动画,首页还提供了"手动试跳"按钮,让用户可以主动触发跳跃效果。这个交互设计让用户可以与仪表盘互动,增强了页面的趣味性和参与感。

Row() {
  Text('🏁 手动试跳').fontSize(11).fontColor('#B8B8B8')
  Column().layoutWeight(1)
  Text('跳跃').fontSize(12).fontColor('#FFFFFF')
    .backgroundColor('#FF6B00').borderRadius(14)
    .padding({ left: 22, right: 22, top: 7, bottom: 7 })
    .onClick(() => { this.pkDoJump() })
}

第714行的"手动试跳"文字使用了赛车旗帜emoji和浅灰色字体,作为功能说明。第716-719行的"跳跃"按钮使用了醒目的警示橙色,圆角胶囊形状,点击后调用pkDoJump()方法触发一次完整的跳跃动画。

手动触发与自动定时器共享同一个pkDoJump方法,这种代码复用设计保证了两种触发方式的动画效果完全一致。用户点击按钮后,会看到与自动播放相同的跳跃过程,包括起跳、滞空、落地、冲击波等完整效果。

技能等级条模块

技能等级条是首页的第二个模块,以进度条的形式展示三项跑酷技能的等级。每项技能有独立的名称、等级数值和颜色,形成了清晰的技能面板。

Column() {
  Row() {
    Text('🎯 技能等级').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text('Lv.7').fontSize(11).fontColor('#1E90FF')
  }
  .width('100%')
  ForEach(PK_SKILLS, (s: PKSkillMeta) => {
    Column() {
      Row() {
        Text(s.name).fontSize(12).fontColor('#B8B8B8')
        Column().layoutWeight(1)
        Text(s.level.toString() + '/100').fontSize(11).fontColor(s.color)
      }
      .width('100%').margin({ top: 8 })
      Stack({ alignContent: Alignment.Start }) {
        Column().width(pkPct(s.level)).height(8)
          .backgroundColor(s.color).borderRadius(4)
      }
      .width('100%').height(8).backgroundColor('#3A3A3A').borderRadius(4).margin({ top: 5 })
    }
    .width('100%')
  }, (s: PKSkillMeta) => s.name)
}

第728行的模块标题使用了靶心emoji和"技能等级"文字,右上角显示总等级"Lv.7"。总等级使用电光蓝色,与第一项技能的颜色一致,暗示了等级体系的关联性。

第733行的ForEach遍历PK_SKILLS数组,为每项技能生成一个进度条项。每个进度条项包含两部分:上方的文字信息行和下方的进度条图形。

第735-739行的文字信息行左侧显示技能名称(浅灰色),右侧显示等级数值(技能主题色)。左右两端对齐的布局让信息层次清晰,用户可以快速获取每项技能的名称和数值。

第741-745行的进度条使用Stack容器实现。底层是深灰色的背景条(#3A3A3A),上层是彩色的进度条,宽度通过pkPct(s.level)计算为百分比值。Stack的alignContent: Alignment.Start确保进度条从左侧开始填充。

这种"Stack嵌套Column"的进度条实现方式,是ArkUI中没有原生Progress组件时的经典替代方案。它虽然需要手动编写两个叠加的Column,但灵活度很高,可以轻松定制颜色、圆角、高度等样式。

热销榜模块

热销榜模块展示销量最高的六款商品,按销量降序排列。每个列表项包含排名、商品名称、销量、价格等信息,并使用不同颜色区分排名等级。

ForEach(pkSortHot(PK_HOT), (h: PKHotItem, idx: number) => {
  Column() {
    Row() {
      Text('#' + (idx + 1).toString()).fontSize(14).fontWeight(FontWeight.Bold)
        .fontColor(pkRankColor(idx)).width(30).textAlign(TextAlign.Center)
      Column() {
        Text(h.name).fontSize(13).fontColor('#E8E8E8')
        Text(h.sales.toString() + ' 人入手').fontSize(10).fontColor('#8A8A8A').margin({ top: 2 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
      Column().width(40).height(4).backgroundColor(h.color).borderRadius(2)
      Text('¥' + pkHotPrice(h.id).toString()).fontSize(12).fontColor('#FF6B00')
        .margin({ left: 10 }).width(58).textAlign(TextAlign.End)
    }
    .width('100%').padding({ top: 9, bottom: 9 })
    Divider().color('#2E2E2E').strokeWidth(0.5)
  }
  .width('100%')
}, (h: PKHotItem) => h.id.toString())

第761行的ForEach直接调用pkSortHot(PK_HOT)获取排序后的数据。这种"在ForEach中直接调用函数"的写法是声明式UI的典型模式——数据的获取和转换与UI描述在同一处,逻辑更加内聚。

第764行的排名序号通过pkRankColor(idx)获取颜色。第一名橙色、第二名蓝色、第三名灰色、其余深灰色。这种配色方案借鉴了体育赛事的奖牌设计,让排名信息一目了然。

第766-770行的商品信息列包含名称和销量两行文字。名称使用较大的字号和较亮的颜色,销量使用较小的字号和较暗的颜色,形成了清晰的视觉层次。

第771行的彩色小条(宽40px、高4px)是一个视觉装饰元素,它使用商品的主题色填充,为列表项增添了一抹亮色,也起到了视觉锚点的作用。

第772-773行的价格右对齐显示,使用醒目的警示橙色。价格信息是电商应用中最重要的信息之一,使用高对比度的颜色确保用户能够快速注意到。

功能宫格模块

功能宫格模块以2x4的网格形式展示八个功能入口。每个宫格包含一个emoji图标和一个文字标签,提供了丰富的增值服务入口。

Column() {
  ForEach(PK_FUNCS, (f: PKGridFunc) => {
    Column() {
      Text(f.icon).fontSize(20)
      Text(f.label).fontSize(10).fontColor('#D0D0D0').margin({ top: 6 })
    }
    .width('100%')
    .padding({ top: 13, bottom: 13 })
    .backgroundColor('#242424').borderRadius(12)
    .border({ width: 1, color: '#2E2E2E' })
  }, (f: PKGridFunc) => f.label)
}

第793行的ForEach遍历PK_FUNCS数组生成宫格项。但仔细观察会发现,这里使用的是Column而非Grid组件,且每个宫格设置了width(‘100%’)。这意味着所有宫格会垂直排列成一列,而不是预期的2x4网格。

这实际上是代码中的一个设计选择——虽然视觉上可以理解为"宫格",但实现上使用了单列布局。每个宫格项都有独立的背景、边框和圆角,形成了卡片式的列表效果。这种实现方式比Grid更简单,也更容易控制间距和样式。

第794-797行的宫格内容包含一个大图标(20号字体)和一个小标签(10号字体)。图标使用emoji字符,无需额外的图片资源,既简化了开发又保证了跨设备的一致性。

第800-801行的卡片样式使用了深灰色背景(#242424)、12px圆角和1px边框。边框颜色(#2E2E2E)比背景色稍亮,在深色主题下形成了微妙的轮廓感,增强了卡片的立体感。

pkDoJump 触发

animateTo EaseOut 460ms

pkJumpY = -128 球上移

pkNeedleAngle = 84 指针旋转

pkJumpHeight = 88 高度更新

pkSpeedOffset = 42 速度线移动

pkRunning = true 运动状态

setTimeout 560ms

pkLand 落地特效

pkShockScale 0.4→2

pkShockOp 1→0

pkParticleScale 0.4→1.7

pkParticleOp 1→0

animateTo EaseIn 520ms

所有状态恢复初始值

动画结束

跑酷鞋页面深度解析

页面状态与数据管理

跑酷鞋页面是商品展示类页面的典型代表。它展示了十款跑酷鞋的详细信息,支持动态新增商品,并提供了重量对比的可视化图表。

页面的状态变量包括鞋子列表、弹框显示状态和表单数据。鞋子列表使用@State装饰,初始值从PK_SHOES常量拷贝。这种"常量为源、状态为流"的设计模式,保证了原始数据的纯净性,同时允许页面动态修改数据。

@Component
struct PKShoesPage {
  @State shoes: PKShoe[] = PK_SHOES
  @State showAddShoe: boolean = false
  @State formName: string = ''
  @State formGrip: number = 85
  @State formWeight: number = 280
  @State formPrice: string = '799'

第821行的shoes状态变量初始化为PK_SHOES。由于PK_SHOES中的元素是PKShoe类的实例(@Observed),而@State装饰的是数组本身,因此数组的增删操作会触发UI更新,但单个数组元素的属性变化需要依赖@Observed机制。

第822行的showAddShoe控制新增弹框的显示与隐藏。第823-826行的四个表单变量分别存储用户输入的名称、抓地力、重量和价格。这些变量在弹框打开时被重置为默认值,在用户输入时实时更新。

新增装备弹框(底部抽屉样式)

新增装备弹框采用底部抽屉样式,从屏幕底部滑入。这种弹框样式通常用于包含较多表单元素的场景,因为底部抽屉有更大的操作空间,且符合移动端"从底部弹出操作面板"的交互习惯。

@Builder addShoeModal() {
  Column() {
    this.modalOverlay(() => { this.showAddShoe = false })
    Column() {
      Row() {
        Text('🆕 新增装备').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor('#8A8A8A')
          .onClick(() => { this.showAddShoe = false })
      }
      .width('100%')
      Divider().color('#333333').margin({ top: 10, bottom: 4 })
      // 表单内容...
      Row() {
        Text('取消').fontSize(14).fontColor('#B8B8B8')
          .backgroundColor('#2E2E2E').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .onClick(() => { this.showAddShoe = false })
        Text('添加装备').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#1E90FF').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .margin({ left: 14 })
          .onClick(() => { this.pkAddShoe() })
      }
      .width('100%').justifyContent(FlexAlign.Center).margin({ top: 14, bottom: 4 })
    }
    .width('100%')
    .backgroundColor('#262626')
    .borderRadius({ topLeft: 20, topRight: 20 })
    .padding({ left: 20, right: 20, top: 18, bottom: 18 })
    .position({ x: 0, y: '52%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

第829行的modalOverlay是半透明遮罩层,覆盖整个屏幕,点击遮罩可以关闭弹框。这是弹框组件的标准配置,既提供了视觉聚焦效果,又提供了便捷的关闭方式。

第839-899行是弹框的主体内容容器。它使用position: { x: 0, y: ‘52%’ }定位在屏幕偏下的位置(52%处),形成底部抽屉的视觉效果。顶部两个角设置了20px的圆角,增强了抽屉的形态感。

第841-845行的标题栏包含标题文字和关闭按钮。标题使用emoji图标增强辨识度,关闭按钮使用"✕"符号,简洁明了。

第847行的分割线使用Divider组件,颜色为深灰色(#333333),将标题与内容区域清晰分隔。

第882-893行的操作按钮区域包含"取消"和"添加装备"两个按钮。取消按钮使用深灰背景和浅灰文字,添加按钮使用电光蓝背景和白色文字。主次按钮的视觉差异清晰地传达了操作的优先级。

表单控件详解

弹框内包含三种类型的表单控件:TextInput文本输入、Slider滑块选择、以及另一个TextInput数字输入。这些控件覆盖了文本、数值等不同类型的数据录入场景。

TextInput({ placeholder: '输入跑酷鞋名称...' })
  .placeholderColor('#6E6E6E').fontSize(14).height(38)
  .backgroundColor('#1E1E1E').borderRadius(8).width('100%')
  .fontColor('#F0F0F0')
  .onChange((v: string) => { this.formName = v })

第849-853行的TextInput组件用于输入装备名称。placeholder设置提示文字,placeholderColor设置提示文字颜色(深灰色)。背景色使用比弹框背景更深的颜色(#1E1E1E),形成输入框的凹陷感。

onChange回调在输入内容变化时触发,将输入值同步到formName状态变量。这是受控组件的典型实现——输入框的值由状态变量控制,用户输入通过回调更新状态。

Row() {
  Text('抓地力').fontSize(11).fontColor('#8A8A8A')
  Column().layoutWeight(1)
  Text(Math.round(this.formGrip).toString() + '/100').fontSize(11).fontColor('#1E90FF')
}
.width('100%').margin({ top: 10 })
Slider({ value: this.formGrip, min: 0, max: 100, style: SliderStyle.OutSet })
  .width('100%').selectedColor('#1E90FF').trackColor('#3A3A3A')
  .onChange((v: number) => { this.formGrip = v })

第854-862行是抓地力滑块控件。上方的标签行左侧显示"抓地力"标签,右侧显示当前数值(电光蓝色)。数值使用Math.round取整,确保显示整数。

Slider组件的value绑定到formGrip状态变量,min和max设置取值范围(0-100)。style: SliderStyle.OutSet表示滑块样式为凸起式,这是鸿蒙Slider组件的默认样式。

selectedColor设置滑块已选中部分的颜色(电光蓝),trackColor设置轨道背景色(深灰)。onChange回调在滑块值变化时触发,实时更新formGrip状态。

新增商品逻辑

pkAddShoe方法处理新增装备的提交逻辑。它将表单数据组装成新的PKShoe实例,添加到鞋子列表中,然后关闭弹框并重置表单。

private pkAddShoe(): void {
  this.shoes = this.shoes.concat([new PKShoe(this.shoes.length + 1, this.formName === '' ? '新锐跑酷鞋' : this.formName, Math.round(this.formGrip), Math.round(this.formWeight), this.formPrice === '' ? 699 : Number(this.formPrice), '#1E90FF', '新到')])
  this.showAddShoe = false
  this.formName = ''
  this.formGrip = 85
  this.formWeight = 280
  this.formPrice = '799'
}

第905行是核心的添加逻辑。它使用concat方法创建一个新数组,将新的PKShoe实例添加到数组末尾。id设置为当前数组长度+1,确保唯一性。

名称字段有默认值处理——如果用户没有输入名称,使用"新锐跑酷鞋"作为默认名称。价格字段同样有默认值处理——如果输入为空,使用699作为默认价格。颜色固定为电光蓝(#1E90FF),标签固定为"新到"。

使用concat而不是push的原因是@State装饰器对数组的检测机制。直接修改数组(如push)可能不会触发状态更新,而替换整个数组引用(concat返回新数组)则一定能触发更新。这是ArkUI状态管理中的一个重要注意事项。

第906-910行在添加完成后关闭弹框并重置表单数据。表单重置确保下次打开弹框时显示的是默认值而不是上次输入的值。

双列商品卡片

跑酷鞋列表使用双列卡片布局展示每款鞋子的详细信息。每张卡片包含名称、标签、抓地力进度条、重量和价格等信息。

Column() {
  ForEach(this.shoes, (s: PKShoe) => {
    Column() {
      Row() {
        Text(s.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#E8E8E8')
        Column().layoutWeight(1)
        Text(s.tag).fontSize(9).fontColor(s.colorHex)
          .backgroundColor('#1E1E1E').borderRadius(6)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      }
      .width('100%')
      Column().width('100%').height(4).backgroundColor(s.colorHex).borderRadius(2).margin({ top: 8 })
      Row() {
        Text('抓地力').fontSize(10).fontColor('#9A9A9A')
        Column().layoutWeight(1)
        Text(s.grip.toString() + '/100').fontSize(10).fontColor(s.colorHex)
      }
      .width('100%').margin({ top: 8 })
      Stack({ alignContent: Alignment.Start }) {
        Column().width(pkPct(s.grip)).height(6)
          .backgroundColor(s.colorHex).borderRadius(3)
      }
      .width('100%').height(6).backgroundColor('#3A3A3A').borderRadius(3).margin({ top: 4 })
      Row() {
        Column() {
          Text('重量').fontSize(9).fontColor('#8A8A8A')
          Text(s.weight.toString() + 'g').fontSize(12).fontColor('#E8E8E8').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Column() {
          Text('价格').fontSize(9).fontColor('#8A8A8A')
          Text('¥' + s.price.toString()).fontSize(12).fontColor('#FF6B00').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%').margin({ top: 10 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#242424')
    .borderRadius(14)
    .border({ width: 1, color: '#2E2E2E' })
  }, (s: PKShoe) => s.id.toString())
}

第931-936行的卡片头部显示鞋名和标签。鞋名使用粗体白色,标签使用小号彩色文字,带有深色背景和圆角。标签的颜色与鞋子的主题色一致,形成了视觉上的呼应。

第938行的彩色横条是卡片的视觉装饰元素,使用鞋子的主题色填充,宽度100%,高度4px。这个小设计让每张卡片都有独特的色彩标识,增强了视觉辨识度。

第939-949行的抓地力进度条与首页的技能进度条设计风格一致。标签行+进度条的组合,使用Stack实现填充效果。进度条颜色同样使用鞋子的主题色,保持了设计语言的统一性。

第950-963行的底部信息行左右分布展示重量和价格。重量在左、价格在右,每项都是"标签+数值"的上下结构。价格使用醒目的警示橙色,突出电商属性。

每张卡片都有独立的背景(#242424)、圆角(14px)和边框(1px #2E2E2E),形成了清晰的卡片视觉。卡片之间通过Column容器的间距自然分隔。

重量对比柱状图

重量对比模块以柱状图的形式展示最轻的六款跑鞋。柱子高度与重量成反比——越轻的鞋子柱子越高,直观地突出了轻量属性。

Column() {
  Row() {
    Text('⚖️ 重量对比').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text('最轻 TOP 6').fontSize(11).fontColor('#8A8A8A')
  }
  .width('100%')
  Row() {
    ForEach(pkTopLight(this.shoes), (s: PKShoe) => {
      Column() {
        Text(s.weight.toString()).fontSize(9).fontColor('#1E90FF')
        Column().width(26).height(pkWeightBar(s.weight))
          .backgroundColor(s.colorHex).borderRadius(4).margin({ top: 4 })
        Text(s.name.substring(0, 2)).fontSize(9).fontColor('#9A9A9A').margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
    }, (s: PKShoe) => 'w' + s.id.toString())
  }
  .width('100%').alignItems(VerticalAlign.Bottom).height(150).margin({ top: 10 })
}

第982行的ForEach调用pkTopLight(this.shoes)获取最轻的六款鞋子。这个函数内部先按重量升序排序再截取前六项,保证了数据的正确性。

第984行的顶部显示具体重量数值(电光蓝色)。第985行的柱子宽度固定为26px,高度通过pkWeightBar函数动态计算。第987行的底部显示鞋名的前两个字作为标签。

第993行的alignItems: VerticalAlign.Bottom是柱状图的关键设置——它让所有柱子底部对齐,这样柱子的高度差就能直观地反映数值差异。Row容器设置了固定高度150px,为柱子提供了足够的展示空间。

每个柱子使用layoutWeight(1)平均分配宽度,确保六根柱子在水平方向上等距排列。这种"Row + VerticalAlign.Bottom + layoutWeight"的组合,是ArkUI中实现柱状图的经典模式。

列表 状态 表单 弹框 新增按钮 用户 列表 状态 表单 弹框 新增按钮 用户 点击+新增装备 showAddShoe = true 显示弹框 输入名称 formName = v 滑动抓地力滑块 formGrip = v 滑动重量滑块 formWeight = v 输入价格 formPrice = v 点击添加装备 pkAddShoe() shoes.concat([new PKShoe(...)]) showAddShoe = false 重置表单数据 显示新增的商品卡片

护具页面深度解析

防护排行榜设计

护具页面以防护排行榜为核心内容,按防护等级从高到低展示八款护具产品。每个列表项包含排名、名称、材质、价格、防护等级进度条和覆盖部位等信息。

ForEach(pkSortGuard(this.guards), (g: PKGuard, idx: number) => {
  Column() {
    Row() {
      Text('#' + (idx + 1).toString()).fontSize(15).fontWeight(FontWeight.Bold)
        .fontColor(pkRankColor(idx)).width(34)
      Column() {
        Text(g.name).fontSize(13).fontColor('#E8E8E8')
        Text(g.material).fontSize(10).fontColor('#8A8A8A').margin({ top: 2 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 8 })
      Text('¥' + g.price.toString()).fontSize(12).fontColor('#FF6B00')
    }
    .width('100%').padding({ top: 8, bottom: 8 })
    Stack({ alignContent: Alignment.Start }) {
      Column().width(pkPct(g.level)).height(7)
        .backgroundColor(g.colorHex).borderRadius(3)
    }
    .width('100%').height(7).backgroundColor('#3A3A3A').borderRadius(3)
    Row() {
      Text('覆盖:' + g.parts).fontSize(10).fontColor('#9A9A9A')
      Column().layoutWeight(1)
      Text('详情').fontSize(11).fontColor('#1E90FF')
        .onClick(() => { this.selectedGuard = g; this.showGuardDetail = true })
    }
    .width('100%').margin({ top: 6 })
    Divider().color('#2E2E2E').strokeWidth(0.5).margin({ top: 8 })
  }
  .width('100%')
}, (g: PKGuard) => g.id.toString())

第1108行的ForEach调用pkSortGuard函数获取按等级降序排列的护具列表。排序后的数据直接用于渲染,保证了排行榜的正确性。

第1111-1112行的排名序号使用了pkRankColor函数获取颜色,与热销榜的排名配色方案一致。宽度固定34px,确保所有排名数字对齐。

第1113-1116行的商品信息列包含名称和材质两行。名称使用较亮的颜色和较大的字号,材质使用较暗的颜色和较小的字号,形成了清晰的信息层次。

第1121-1125行的防护等级进度条与其他页面的进度条设计风格保持一致。进度条的颜色使用护具自身的colorHex属性,每件护具都有独特的进度条颜色。

第1126-1131行的底部信息行左侧显示覆盖部位,右侧显示"详情"链接。点击详情按钮会设置selectedGuard为当前护具,并打开详情弹框。这种"列表+详情弹框"的模式是移动端常用的详情展示方式。

覆盖部位分布图

覆盖部位分布图以柱状图的形式展示护具对各个身体部位的覆盖数量统计。这是一个数据可视化模块,帮助用户直观了解护具的品类分布。

Column() {
  Row() {
    Text('📍 覆盖部位分布').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text('护具覆盖面').fontSize(11).fontColor('#8A8A8A')
  }
  .width('100%')
  Row() {
    ForEach(pkPartDist(this.guards), (d: PKPartDist) => {
      Column() {
        Text(d.count.toString()).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1E90FF')
        Column().width(18).height(d.count * 16).backgroundColor('#1E90FF')
          .borderRadius(4).margin({ top: 6 })
        Text(d.label).fontSize(10).fontColor('#9A9A9A').margin({ top: 6 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
    }, (d: PKPartDist) => d.label)
  }
  .width('100%').alignItems(VerticalAlign.Bottom).height(120).margin({ top: 10 })
}

第1149行的ForEach调用pkPartDist函数生成部位分布数据。这个函数内部遍历五个部位(膝部、肘部、腕部、背部、脚踝),分别统计覆盖该部位的护具数量。

第1151-1154行的每个柱状项包含三部分:顶部的数量文字(电光蓝粗体)、中间的柱子(电光蓝填充)、底部的部位标签(灰色)。柱子的高度为d.count * 16,即数量越多柱子越高,每增加一件护具柱子增高16像素。

第1159行的alignItems: VerticalAlign.Bottom确保所有柱子底部对齐,形成标准的柱状图形态。容器高度固定为120px,根据最大数量(膝部可能有5-6件,高度约80-96px)计算,留有足够的顶部空间。

防护详情弹框(居中卡片样式)

防护详情弹框采用居中卡片样式,在屏幕中央显示一个圆角卡片。这种弹框样式适合展示详情信息,因为居中的位置和固定的尺寸能够提供良好的阅读体验。

@Builder guardDetailModal() {
  Column() {
    this.modalOverlay(() => { this.showGuardDetail = false })
    Column() {
      Row() {
        Text('🛡️ 防护详情').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor('#8A8A8A')
          .onClick(() => { this.showGuardDetail = false })
      }
      .width('100%')
      Column().width('100%').height(6)
        .backgroundColor(this.selectedGuard!.colorHex)
        .borderRadius(3).margin({ top: 10 })
      Text(this.selectedGuard!.name).fontSize(18).fontWeight(FontWeight.Bold)
        .fontColor('#F0F0F0').width('100%').margin({ top: 12 })
      // 详细信息...
      Column() {
        Text('知道了').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF6B00').borderRadius(20)
          .padding({ left: 34, right: 34, top: 10, bottom: 10 })
          .onClick(() => { this.showGuardDetail = false })
      }
      .width('100%').alignItems(HorizontalAlign.Center).margin({ top: 16 })
    }
    .width('88%').backgroundColor('#262626').borderRadius(18).padding(20)
    .position({ x: '6%', y: '24%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

第1045-1047行的彩色横条是详情弹框的特色设计。它使用护具的主题色填充,宽度100%,高度6px,位于标题下方。这个彩色横条起到了视觉锚点的作用,也与护具列表中的色彩设计相呼应。

第1048-1049行的护具名称使用了更大的字号(18px)和粗体,作为详情页的主标题。名称下方依次排列防护等级、覆盖部位、材质工艺、参考价格等详细信息。

第1082-1085行的"知道了"按钮使用警示橙色,位于弹框底部中央。按钮使用圆角胶囊形状,内边距充足,确保点击区域足够大。点击后关闭弹框,返回列表页面。

第1089-1090行的卡片样式设置宽度为88%,使用position定位在水平居中(x: '6%'即左右各6%边距)、垂直偏上(y: ‘24%’)的位置。这种偏上的定位让弹框避开了键盘区域,也更符合人眼的视觉焦点位置。

selectedGuard的非空断言

在详情弹框中,selectedGuard使用了非空断言操作符(!),如this.selectedGuard!.name。这是因为selectedGuard的类型是PKGuard | null,可能为null。但在弹框显示的上下文中,selectedGuard一定是被赋值过的(因为只有点击详情按钮才会打开弹框,而点击时会设置selectedGuard),因此可以安全地使用非空断言。

非空断言是TypeScript/ArkTS中的类型断言语法,告诉编译器"我确定这个值不为null/undefined"。它可以简化代码,避免重复的null检查。但使用时需要谨慎,确保运行时上下文的正确性。

在弹框的渲染条件中,showGuardDetail和selectedGuard之间存在隐含的关联——只有当selectedGuard被设置后,showGuardDetail才会变为true。这种状态间的依赖关系是UI开发中常见的模式,虽然类型系统无法直接表达,但开发者需要在逻辑层面保证一致性。

手套页面深度解析

横向大卡布局设计

手套页面采用横向大卡的布局形式,每款手套占据一整行宽度。这种布局与跑酷鞋的双列卡片形成对比,因为手套卡片包含更多的信息和操作元素(如编辑按钮),需要更宽的展示空间。

ForEach(this.gloves, (g: PKGlove) => {
  Row() {
    Column() {
      Text(g.finger).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
    }
    .width(84).height(112)
    .backgroundColor(g.colorHex)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    Column() {
      Row() {
        Text(g.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
        Column().layoutWeight(1)
        Text('¥' + g.price.toString()).fontSize(13).fontColor('#FF6B00')
      }
      .width('100%')
      Row() {
        Text('磨阻').fontSize(10).fontColor('#9A9A9A')
        Column().layoutWeight(1)
        Text(g.friction.toString() + '/100').fontSize(10).fontColor('#FF6B00')
      }
      .width('100%').margin({ top: 8 })
      Stack({ alignContent: Alignment.Start }) {
        Column().width(pkPct(g.friction)).height(6)
          .backgroundColor('#FF6B00').borderRadius(3)
      }
      .width('100%').height(6).backgroundColor('#3A3A3A').borderRadius(3).margin({ top: 4 })
      Row() {
        Text(g.finger + '指型').fontSize(10).fontColor('#8A8A8A')
          .backgroundColor('#1E1E1E').borderRadius(6)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        Column().layoutWeight(1)
        Text('编辑').fontSize(11).fontColor('#1E90FF')
          .onClick(() => {
            this.editGlove = g
            this.formFriction = g.friction
            this.formName = g.name
            this.showEditGlove = true
          })
      }
      .width('100%').margin({ top: 10 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#242424').borderRadius(16)
  .border({ width: 1, color: '#2E2E2E' })
  .margin({ top: 10 })
}, (g: PKGlove) => g.id.toString())

第1284-1291行的左侧色块是卡片的视觉焦点。它是一个84x112的矩形区域,使用手套的主题色填充,居中显示指型文字(全指/半指/露指)。这个色块设计既展示了颜色信息,又突出了指型分类,视觉冲击力强。

第1292-1324行的右侧内容区域占据剩余宽度(layoutWeight: 1)。内容分为四行:名称价格行、磨阻标签行、磨阻进度条、指型标签+编辑按钮。信息层次分明,布局紧凑有序。

第1314-1320行的"编辑"按钮是手套页面特有的交互元素。点击编辑按钮会执行三个操作:将editGlove设置为当前手套、将表单数据初始化为当前手套的属性值、打开编辑弹框。这种"先赋值再打开"的顺序确保了弹框显示时数据已经准备就绪。

编辑参数弹框(双滑块设计)

编辑参数弹框采用居中卡片样式,包含名称输入和磨阻系数滑块两个主要编辑项。此外还显示了指型和价格的只读信息,让用户在编辑时能够看到完整的产品信息。

@Builder editGloveModal() {
  Column() {
    this.modalOverlay(() => { this.showEditGlove = false })
    Column() {
      Row() {
        Text('✏️ 编辑参数').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor('#8A8A8A')
          .onClick(() => { this.showEditGlove = false })
      }
      .width('100%')
      Text('手套名称').fontSize(11).fontColor('#8A8A8A').width('100%').margin({ top: 12 })
      TextInput({ placeholder: this.editGlove?.name ?? '' })
        .placeholderColor('#6E6E6E').fontSize(14).height(38)
        .backgroundColor('#1E1E1E').borderRadius(8).width('100%')
        .fontColor('#F0F0F0')
        .onChange((v: string) => { this.formName = v })
      Row() {
        Text('磨阻系数').fontSize(11).fontColor('#8A8A8A')
        Column().layoutWeight(1)
        Text(Math.round(this.formFriction).toString() + '/100').fontSize(11).fontColor('#FF6B00')
      }
      .width('100%').margin({ top: 12 })
      Slider({ value: this.formFriction, min: 0, max: 100, style: SliderStyle.OutSet })
        .width('100%').selectedColor('#FF6B00').trackColor('#3A3A3A')
        .onChange((v: number) => { this.formFriction = v })
      Row() {
        Text('指型').fontSize(11).fontColor('#8A8A8A')
        Column().layoutWeight(1)
        Text(this.editGlove?.finger ?? '').fontSize(11).fontColor('#1E90FF')
      }
      .width('100%').margin({ top: 10 })
      Row() {
        Text('价格').fontSize(11).fontColor('#8A8A8A')
        Column().layoutWeight(1)
        Text('¥' + (this.editGlove?.price ?? 0).toString()).fontSize(11).fontColor('#FF6B00')
      }
      .width('100%').margin({ top: 8 })
      Row() {
        Text('取消').fontSize(14).fontColor('#B8B8B8')
          .backgroundColor('#2E2E2E').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .onClick(() => { this.showEditGlove = false })
        Text('保存修改').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF6B00').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .margin({ left: 14 })
          .onClick(() => { this.pkSaveGloveEdit() })
      }
      .width('100%').justifyContent(FlexAlign.Center).margin({ top: 16 })
    }
    .width('88%').backgroundColor('#262626').borderRadius(18).padding(20)
    .position({ x: '6%', y: '28%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

第1215行的TextInput使用了可选链操作符(?.)和空值合并操作符(??)设置placeholder。如果editGlove为null,则显示空字符串。这种写法比if/else判断更加简洁。

第1220-1228行的磨阻滑块是弹框的核心编辑控件。滑块使用警示橙色(与手套的磨阻属性颜色一致),取值范围0-100。滑块上方显示当前数值,实时反映用户的调整。

第1229-1234行的指型信息是只读的,仅用于展示。它使用电光蓝色,与其他可编辑项的颜色形成区分,暗示这是不可修改的属性。

第1235-1240行的价格信息同样是只读的。价格使用警示橙色,与电商应用中价格的标准配色一致。

保存编辑逻辑

pkSaveGloveEdit方法处理编辑结果的保存。由于PKGlove是@Observed类,直接修改其实例属性就能触发UI更新,无需替换整个数组。

private pkSaveGloveEdit(): void {
  if (this.editGlove !== null) {
    this.editGlove!.name = this.formName === '' ? this.editGlove!.name : this.formName
    this.editGlove!.friction = Math.round(this.formFriction)
    this.showEditGlove = false
  }
}

第1261行首先检查editGlove不为null,确保后续操作的安全性。第1262行更新手套名称——如果表单输入为空,则保留原名;否则使用新名称。这种"空值保留原值"的处理方式避免了用户误操作导致名称被清空。

第1263行更新磨阻系数,使用Math.round取整确保数值为整数。第1264行关闭弹框。

由于PKGlove类使用了@Observed装饰器,修改name和friction属性会自动触发UI更新。列表中对应的手套卡片会立即显示新的名称和磨阻数值,进度条也会相应调整长度。这种细粒度的状态更新是@Observed的核心优势——只更新变化的属性,而不是重新渲染整个列表。

指型分布图

指型分布模块以横向进度条的形式展示三种指型(全指、半指、露指)的款数统计。这是另一种数据可视化形式——横向条形图,适合展示分类数据的对比。

Column() {
  Row() {
    Text('🖐️ 指型分布').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text('款数统计').fontSize(11).fontColor('#8A8A8A')
  }
  .width('100%')
  ForEach(pkFingerDist(this.gloves), (d: PKFingerDist) => {
    Row() {
      Text(d.label).fontSize(11).fontColor('#9A9A9A').width(44)
      Stack({ alignContent: Alignment.Start }) {
        Column().width(pkPct(d.count * 10)).height(8)
          .backgroundColor('#1E90FF').borderRadius(4)
      }
      .layoutWeight(1).height(8).backgroundColor('#3A3A3A').borderRadius(4)
      Text(d.count.toString() + ' 款').fontSize(11).fontColor('#1E90FF')
        .width(44).textAlign(TextAlign.End)
    }
    .width('100%').margin({ top: 10 })
  }, (d: PKFingerDist) => d.label)
}

第1341行的ForEach调用pkFingerDist函数生成指型分布数据。该函数统计每种指型的手套数量,返回PKFingerDist数组。

第1342-1351行的每行包含三部分:左侧的指型标签(固定宽度44px)、中间的进度条(占据剩余空间)、右侧的数量文字(固定宽度44px,右对齐)。这种"标签-图形-数值"的三段式布局是数据可视化的经典结构。

进度条的宽度通过pkPct(d.count * 10)计算。乘以10是一个缩放系数,让数量较少的分类也能有可见的进度条长度。例如,数量为4的分类进度条宽度为40%,在视觉上比较醒目。

服装页面深度解析

三列色彩Grid布局

服装页面采用三列网格布局,每款服装以色彩卡片的形式展示。卡片的顶部是彩色区域(使用服装的colorHex填充),底部是文字信息区域。这种以色彩为主要视觉元素的设计,非常适合服装类商品的展示。

Column() {
  ForEach(this.cloths, (c: PKCloth) => {
    Column() {
      Column() {
        Text(c.season).fontSize(10).fontColor('#FFFFFF')
          .backgroundColor('#33FFFFFF').borderRadius(6)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      }
      .width('100%').height(76)
      .backgroundColor(c.colorHex)
      .borderRadius(10)
      .justifyContent(FlexAlign.End)
      .alignItems(HorizontalAlign.End)
      .padding(6)
      Text(c.name).fontSize(12).fontWeight(FontWeight.Medium)
        .fontColor('#E8E8E8').width('100%').margin({ top: 8 })
      Row() {
        Text('透气').fontSize(9).fontColor('#8A8A8A')
        Column().layoutWeight(1)
        Text(c.breath.toString() + '/100').fontSize(9).fontColor('#1E90FF')
      }
      .width('100%').margin({ top: 6 })
      Stack({ alignContent: Alignment.Start }) {
        Column().width(pkPct(c.breath)).height(5)
          .backgroundColor('#1E90FF').borderRadius(2)
      }
      .width('100%').height(5).backgroundColor('#3A3A3A').borderRadius(2).margin({ top: 4 })
      Row() {
        Text('¥' + c.price.toString()).fontSize(12).fontColor('#FF6B00')
        Column().layoutWeight(1)
        Text('尺码').fontSize(10).fontColor('#1E90FF')
          .onClick(() => {
            this.selectedCloth = c
            this.selSize = 'M'
            this.showSizeDialog = true
          })
      }
      .width('100%').margin({ top: 8 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#242424').borderRadius(14)
  }, (c: PKCloth) => c.id.toString())
}

第1460-1470行的彩色区域是卡片的视觉主体。它高76px,使用服装的colorHex作为背景色。右下角显示季节标签,标签使用白色文字和半透明白色背景(#33FFFFFF,约20%不透明度),在彩色背景上保持了良好的可读性。

justifyContent(FlexAlign.End)和alignItems(HorizontalAlign.End)将季节标签定位在彩色区域的右下角。这种定位方式让标签不会遮挡主要的色彩展示区域,同时保持了视觉的平衡感。

第1471-1494行的文字信息区域包含服装名称、透气进度条和价格尺码行。透气进度条使用电光蓝色,与其他页面的进度条设计保持一致。

第1487-1492行的"尺码"链接是服装页面特有的交互入口。点击后会设置selectedCloth为当前服装、重置选中尺码为M、打开尺码选择弹框。

尺码选择弹框(尺码宫格)

尺码选择弹框是服装品类特有的交互组件,它以宫格形式展示所有可选尺码,用户可以点击选择合适的尺码。每个尺码项显示尺码名称和库存数量,选中状态有明显的视觉反馈。

@Builder sizeSelectModal() {
  Column() {
    this.modalOverlay(() => { this.showSizeDialog = false })
    Column() {
      Row() {
        Text('📏 选择尺码').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor('#8A8A8A')
          .onClick(() => { this.showSizeDialog = false })
      }
      .width('100%')
      Text(this.selectedCloth?.name ?? '').fontSize(13).fontColor('#FF6B00')
        .width('100%').margin({ top: 10 })
      Text('当前已选:' + this.selSize + ' 码').fontSize(11).fontColor('#8A8A8A')
        .width('100%').margin({ top: 4 })
      Column() {
        ForEach(PK_SIZES, (o: PKSizeOption) => {
          Column() {
            Text(o.size).fontSize(16).fontWeight(FontWeight.Bold)
              .fontColor(this.selSize === o.size ? '#FFFFFF' : '#D0D0D0')
            Text('库存 ' + o.stock.toString()).fontSize(9)
              .fontColor(this.selSize === o.size ? '#B0D6FF' : '#6E6E6E')
          }
          .width('100%')
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(this.selSize === o.size ? '#1E90FF' : '#1E1E1E')
          .borderRadius(12)
          .onClick(() => { this.selSize = o.size })
        }, (o: PKSizeOption) => o.size)
      }
      Row() {
        Text('透气 ' + (this.selectedCloth?.breath ?? 0).toString() + '/100')
          .fontSize(11).fontColor('#9A9A9A')
        Column().layoutWeight(1)
        Text('确认尺码').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF6B00').borderRadius(20)
          .padding({ left: 30, right: 30, top: 10, bottom: 10 })
          .onClick(() => { this.showSizeDialog = false })
      }
      .width('100%').margin({ top: 16 })
    }
    .width('88%').backgroundColor('#262626').borderRadius(18).padding(20)
    .position({ x: '6%', y: '30%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

第1407行的服装名称使用警示橙色显示在标题下方,让用户确认当前选择的商品。第1409行显示当前选中的尺码,使用灰色文字,提供实时的选择反馈。

第1412-1424行的尺码宫格是弹框的核心内容。每个尺码项是一个独立的卡片,包含尺码名称(大字号粗体)和库存数量(小字号)。选中状态使用电光蓝背景和白色文字,未选中状态使用深灰背景和浅灰文字。

第1421行的背景色通过条件表达式动态切换。当selSize等于当前尺码时,背景为电光蓝(#1E90FF);否则为深灰(#1E1E1E)。文字颜色也随之切换,形成完整的选中状态反馈。

第1423行的onClick事件将selSize设置为当前尺码。由于selSize是@State变量,修改后所有依赖它的UI元素都会自动更新,包括尺码卡片的选中状态和"当前已选"文字。

第1427-1433行的底部操作栏左侧显示透气性信息(辅助参考),右侧是"确认尺码"按钮。按钮使用警示橙色,点击后关闭弹框。当前的实现中,确认操作仅关闭弹框而没有保存选择——这是演示版本的简化处理,在实际项目中可以扩展为加入购物车等操作。

季节分布图

季节分布模块以横向条形图的形式展示各季节的服装款数。由于服装可能属于多个季节(如"春秋"同时属于春和秋),统计时使用indexOf进行包含匹配,确保多季节服装被正确计入。

Column() {
  Row() {
    Text('🌦️ 季节分布').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text('按季节').fontSize(11).fontColor('#8A8A8A')
  }
  .width('100%')
  ForEach(pkSeasonDist(this.cloths), (d: PKSeasonDist) => {
    Row() {
      Text(d.label).fontSize(11).fontColor('#9A9A9A').width(34)
      Stack({ alignContent: Alignment.Start }) {
        Column().width(pkPct(d.count * 14)).height(8)
          .backgroundColor('#FF6B00').borderRadius(4)
      }
      .layoutWeight(1).height(8).backgroundColor('#3A3A3A').borderRadius(4)
      Text(d.count.toString() + ' 款').fontSize(11).fontColor('#FF6B00')
        .width(44).textAlign(TextAlign.End)
    }
    .width('100%').margin({ top: 10 })
  }, (d: PKSeasonDist) => d.label)
}

第1510行的ForEach调用pkSeasonDist函数生成季节分布数据。该函数内部遍历春、夏、秋、冬四个季节,分别统计包含该季节的服装数量。

进度条的颜色使用警示橙色,与手套页面的指型分布图(电光蓝)形成颜色区分。这种颜色编码方式让用户可以快速识别不同的数据维度。

进度条宽度的缩放系数为14(d.count * 14),比指型分布的缩放系数(10)更大。这是因为服装数量较少(6款),需要更大的缩放系数才能让进度条有明显的长度差异。

五种弹框模式对比

底部抽屉式 新增装备

居中卡片式 防护详情

居中卡片式 编辑参数

居中卡片式 尺码选择

居中紧凑式 删除确认

位置: 底部 y:52%

宽度: 100%

内容: 表单输入

按钮: 取消+添加

位置: 居中偏上 y:24%

宽度: 88%

内容: 详情展示

按钮: 知道了

位置: 居中偏上 y:28%

宽度: 88%

内容: 编辑表单

按钮: 取消+保存

位置: 居中偏上 y:30%

宽度: 88%

内容: 尺码选择

按钮: 确认尺码

位置: 居中 y:34%

宽度: 82%

内容: 警示确认

按钮: 取消+删除

我的页面深度解析

会员卡渐变设计

"我的"页面以会员卡为视觉起点,采用渐变色彩设计,营造出高级感和会员专属感。会员卡是个人中心页面的经典设计元素,它集中展示了会员等级、积分和关键统计数据。

Column() {
  Row() {
    Column() {
      Text('PARKOUR ZONE').fontSize(10).fontColor('#FFFFFF').opacity(0.8)
      Text('PK 金卡会员').fontSize(20).fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF').margin({ top: 4 })
      Text('会员号 NO.8888 0826').fontSize(10).fontColor('#E0E0E0').margin({ top: 4 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start)
    Column() {
      Text('2480').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text('积分').fontSize(10).fontColor('#E0E0E0').margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Center)
  }
  .width('100%')
  Row() {
    ForEach(PK_MEMBER_STATS, (st: PKMemberStat) => {
      Column() {
        Text(st.value).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text(st.label).fontSize(9).fontColor('#D8E6FF').margin({ top: 3 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
    }, (st: PKMemberStat) => st.label)
  }
  .width('100%').margin({ top: 16 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [['#1E90FF', 0.0], ['#7A4DFF', 0.55], ['#FF6B00', 1.0]] })
.borderRadius(18).padding(18)

第1609-1620行的卡片上半部分左右分布——左侧显示会员品牌和等级信息,右侧显示积分数值。品牌名"PARKOUR ZONE"使用小号白色文字加0.8的透明度,作为装饰性文字。

第1611行的"PK 金卡会员"使用20号粗体白色文字,是卡片上最醒目的文字元素。第1613行的会员号使用小号浅灰色文字,提供身份识别信息。

第1617-1619行的积分信息垂直排列,数值大而醒目,标签小而低调。积分是会员体系的核心价值指标,因此使用了较大的字号和粗体。

第1623-1631行的统计数据行展示四项会员统计:训练天数、累计里程、收藏装备、本月打卡。每项都是"数值+标签"的上下结构,使用layoutWeight(1)平均分配宽度。

第1635行的linearGradient属性定义了三色渐变背景。渐变角度为135度(从左上到右下),起始色为电光蓝(#1E90FF),中间色为紫色(#7A4DFF),结束色为警示橙(#FF6B00)。这种蓝-紫-橙的渐变方案既包含了品牌的两个主色调,又通过紫色过渡实现了平滑的色彩融合。

训练打卡日历

训练打卡日历是个人中心的特色功能模块,它以月历的形式展示用户的训练打卡记录。已打卡的日期用橙色圆形标记,未打卡的日期显示灰色数字。

Column() {
  Row() {
    Text('📅 训练打卡').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text('2026.08 · 12天').fontSize(11).fontColor('#8A8A8A')
  }
  .width('100%')
  Row() {
    ForEach(['日', '一', '二', '三', '四', '五', '六'], (w: string) => {
      Text(w).fontSize(10).fontColor('#6E6E6E')
        .layoutWeight(1).textAlign(TextAlign.Center)
    }, (w: string) => w)
  }
  .width('100%').margin({ top: 10 })
  Column() {
    ForEach(this.calendarDays, (d: PKDayItem) => {
      if (d.day === 0) {
        Column().width('100%').height(34)
      } else {
        Column() {
          if (d.checked) {
            Text(d.day.toString()).fontSize(11).fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF').width(26).height(26).textAlign(TextAlign.Center)
              .backgroundColor('#FF6B00').borderRadius(13)
          } else {
            Text(d.day.toString()).fontSize(11).fontColor('#9A9A9A')
              .width(26).height(26).textAlign(TextAlign.Center)
          }
        }
        .width('100%').height(34)
        .justifyContent(FlexAlign.Center)
      }
    }, (d: PKDayItem) => d.day.toString())
  }
}

第1641行的标题栏显示模块名称和月份信息。右上角的"2026.08 · 12天"标明了当前月份和累计打卡天数,让用户一目了然地了解自己的训练频率。

第1647-1651行的星期标题行显示日、一、二、三、四、五、六七个星期缩写。每个标题使用layoutWeight(1)平均分配宽度,textAlign(TextAlign.Center)居中对齐。

第1653-1672行的日历主体由ForEach循环生成。calendarDays数组包含6个空白占位项和31个日期项,总共37项。这些项通过Column容器垂直排列,再配合外层的七列布局(通过每个项的width(‘100%’)和父容器的宽度约束间接实现),形成日历网格。

第1655-1656行处理空白占位项(day === 0),显示一个空的Column以保持布局对齐。第1657-1669行处理真实日期,根据checked状态显示不同的样式——已打卡显示橙色圆形背景+白色粗体文字,未打卡显示灰色文字。

值得注意的是,这个日历实现使用了单列Column配合width(‘100%’)的方式,而不是Grid组件或Row/Column嵌套。这种实现方式在视觉上可能呈现为单列而不是七列网格,但代码逻辑上表达了日历数据的结构。实际的多列布局效果需要结合父容器的宽度约束和CSS flex-wrap特性来实现。

月消费柱状图

月消费柱状图展示近六个月的消费趋势。与首页的重量对比图类似,它也是一个底部对齐的柱状图,但使用了不同的颜色和数据计算方式。

Column() {
  Row() {
    Text('💰 月消费').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text('近6个月').fontSize(11).fontColor('#8A8A8A')
  }
  .width('100%')
  Row() {
    ForEach(PK_SPEND, (sp: PKSpend) => {
      Column() {
        Text(sp.amount.toString()).fontSize(9).fontColor('#FF6B00')
        Column().width(24).height(pkSpendBar(sp.amount))
          .backgroundColor('#1E90FF').borderRadius(4).margin({ top: 4 })
        Text(sp.month).fontSize(9).fontColor('#9A9A9A').margin({ top: 4 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
    }, (sp: PKSpend) => sp.month)
  }
  .width('100%').alignItems(VerticalAlign.Bottom).height(150).margin({ top: 10 })
}

第1686行的ForEach遍历PK_SPEND数组,为每个月生成一根柱子。每根柱子包含三部分:顶部的金额数值(警示橙色)、中间的蓝色柱子、底部的月份标签(灰色)。

第1689行的柱子高度通过pkSpendBar函数计算。该函数以5200为基准最大值,将金额映射为百分比高度,再加6像素的最小高度。柱子使用电光蓝色填充,与金额的警示橙色形成冷暖对比。

第1696行的alignItems: VerticalAlign.Bottom确保所有柱子底部对齐。容器高度150px,为最高的柱子(8月5180元,约100%+6=106px高度)留有充足的顶部空间。

订单列表

订单列表展示用户的历史订单记录,每笔订单包含商品名称、下单时间、金额和状态。订单状态使用不同颜色标识,让用户可以快速识别订单进度。

Column() {
  Row() {
    Text('🧾 我的订单').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text('全部').fontSize(11).fontColor('#8A8A8A')
  }
  .width('100%')
  ForEach(PK_ORDERS, (o: PKOrder) => {
    Column() {
      Row() {
        Column() {
          Text(o.name).fontSize(13).fontColor('#E8E8E8')
          Text(o.time).fontSize(10).fontColor('#8A8A8A').margin({ top: 3 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start)
        Column() {
          Text('¥' + o.amount.toString()).fontSize(13).fontColor('#F0F0F0')
          Text(o.status).fontSize(10).fontColor(pkStatusColor(o.status)).margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%').padding({ top: 10, bottom: 10 })
      Divider().color('#2E2E2E').strokeWidth(0.5)
    }
    .width('100%')
  }, (o: PKOrder) => o.id.toString())
}

第1709行的ForEach遍历PK_ORDERS数组生成订单列表。每个订单项左右分布——左侧是商品名称和下单时间,右侧是金额和订单状态。

第1719行的订单状态颜色通过pkStatusColor函数动态获取。已完成为绿色(#4CAF50),运输中为电光蓝(#1E90FF),待付款为警示橙(#FF6B00)。这种颜色编码方案与交通信号灯的语义类似——绿色表示完成,蓝色表示进行中,橙色表示待处理。

每个订单项底部有一条细分割线(0.5px #2E2E2E),分隔不同的订单记录。最后一项的分割线也会显示,这是一个小细节问题,但在实际应用中通常影响不大。

收藏管理与删除确认

收藏列表展示用户收藏的商品,支持删除操作。点击删除按钮会弹出删除确认弹框,用户需要二次确认才能删除收藏项。这种二次确认机制可以防止误操作,提升用户体验。

Column() {
  Row() {
    Text('⭐ 我的收藏').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
    Column().layoutWeight(1)
    Text(this.favs.length.toString() + ' 件').fontSize(11).fontColor('#8A8A8A')
  }
  .width('100%')
  ForEach(this.favs, (f: PKFav) => {
    Column() {
      Row() {
        Column().width(34).height(34).backgroundColor(f.colorHex).borderRadius(8)
        Text(f.name).fontSize(13).fontColor('#E8E8E8')
          .margin({ left: 10 }).layoutWeight(1)
        Text(f.type).fontSize(10).fontColor('#8A8A8A')
          .backgroundColor('#1E1E1E').borderRadius(6)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        Text('删除').fontSize(11).fontColor('#FF3B30').margin({ left: 10 })
          .onClick(() => { this.deleteTarget = f; this.showDeleteConfirm = true })
      }
      .width('100%').padding({ top: 9, bottom: 9 })
      Divider().color('#2E2E2E').strokeWidth(0.5)
    }
    .width('100%')
  }, (f: PKFav) => f.id.toString())
}

第1737行的收藏数量使用this.favs.length动态计算。当用户删除收藏项后,数量会自动更新,因为favs是@State变量,数组长度变化会触发UI重新渲染。

第1743行的彩色方块(34x34px)使用收藏商品的主题色填充,作为商品的视觉缩略图。虽然没有真实的商品图片,但彩色方块配合文字名称已经能提供足够的识别度。

第1746-1748行的品类标签使用深灰背景和灰色文字,与整体深色主题融为一体。第1749-1750行的删除按钮使用红色(#FF3B30),这是系统标准的删除/警告色,明确传达了"删除"的语义。

点击删除按钮后,执行两个操作:设置deleteTarget为当前收藏项、设置showDeleteConfirm为true。这两步操作的顺序很重要——必须先设置目标数据,再显示弹框,否则弹框可能显示错误的数据。

删除确认弹框(警示样式)

删除确认弹框是五种弹框中最紧凑的一种,它采用居中卡片样式,以警示图标和醒目的红色删除按钮为特征。这种设计旨在引起用户注意,确保删除操作是经过深思熟虑的。

@Builder deleteConfirmModal() {
  Column() {
    this.modalOverlay(() => { this.showDeleteConfirm = false })
    Column() {
      Text('⚠️').fontSize(34).margin({ top: 6 })
      Text('删除收藏').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#F0F0F0').margin({ top: 8 })
      Text('确认删除「' + (this.deleteTarget?.name ?? '') + '」吗?')
        .fontSize(12).fontColor('#9A9A9A').margin({ top: 6 })
      Text('删除后不可恢复').fontSize(10).fontColor('#FF6B00').margin({ top: 2 })
      Row() {
        Text('取消').fontSize(14).fontColor('#B8B8B8')
          .backgroundColor('#2E2E2E').borderRadius(20)
          .padding({ left: 30, right: 30, top: 10, bottom: 10 })
          .onClick(() => { this.showDeleteConfirm = false })
        Text('确认删除').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF3B30').borderRadius(20)
          .padding({ left: 30, right: 30, top: 10, bottom: 10 })
          .margin({ left: 14 })
          .onClick(() => { this.pkDeleteFav() })
      }
      .width('100%').justifyContent(FlexAlign.Center).margin({ top: 16, bottom: 4 })
    }
    .width('82%').backgroundColor('#262626').borderRadius(18).padding(20)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '9%', y: '34%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

第1569行的警告emoji(⚠️)使用34号大字体,是弹框中最醒目的视觉元素。它在视觉上起到了"警示"的作用,让用户意识到这是一个需要谨慎操作的弹框。

第1571行使用字符串拼接显示确认消息,其中商品名称通过可选链和空值合并安全获取。第1573行的"删除后不可恢复"使用警示橙色,进一步强调删除操作的不可逆性。

第1574-1583行的两个按钮——取消和确认删除,形成鲜明对比。取消按钮使用深灰背景和浅灰文字,视觉上较弱;确认删除按钮使用红色背景和白色文字,视觉上非常强烈。这种"弱取消、强确认"的设计虽然在视觉上突出了删除按钮,但结合上方的警示文案,整体上还是传达了"谨慎操作"的语义。

第1587行的弹框宽度为82%,比其他详情弹框(88%)更窄,位置也更靠近垂直中心(y: ‘34%’)。这种紧凑的尺寸适合内容较少的确认对话框,也符合移动端确认弹框的设计规范。

删除逻辑与不可变数据

pkDeleteFav方法执行实际的删除操作。它调用pkFilterFav函数过滤掉目标收藏项,将返回的新数组赋值给favs状态变量。

private pkDeleteFav(): void {
  if (this.deleteTarget !== null) {
    this.favs = pkFilterFav(this.favs, this.deleteTarget!.id)
    this.showDeleteConfirm = false
  }
}

第1596行的删除操作使用了函数式的不可变数据处理方式。pkFilterFav函数不修改原数组,而是创建并返回一个新数组,新数组中不包含被删除的项。将新数组赋值给favs后,@State机制会检测到引用变化并触发UI更新。

这种不可变数据的处理方式是现代前端框架(包括ArkUI)的推荐实践。它有几个优势:首先,状态变化可追踪,每次更新都是新的状态对象;其次,避免了意外的副作用,原数据不会被修改;第三,配合时间旅行调试等工具可以实现状态回溯。

页面加载

点击删除按钮

showDeleteConfirm=true

点击取消/遮罩

点击确认删除

favs = pkFilterFav()

弹框关闭

点击编辑按钮

showEditGlove=true

点击取消

点击保存

editGlove.name/friction变化

@Observed触发UI更新

正常浏览

选中删除目标

显示确认弹框

执行删除

更新列表

编辑状态

显示编辑弹框

保存修改

更新属性

五种弹框模式深度对比

弹框架构的统一模式

应用中的五个页面共使用了五种不同的弹框组件,它们虽然在内容和样式上各有差异,但都遵循了相同的架构模式。这种统一的模式体现在弹框的结构、遮罩层、定位方式等多个方面。

每种弹框都由三层结构组成:最外层是全屏的Stack容器,设置zIndex: 999确保弹框在最上层;中间层是半透明的遮罩层(modalOverlay),点击遮罩可以关闭弹框;最内层是弹框的主体内容,使用position属性定位在屏幕的特定位置。

这种三层结构的设计具有很好的通用性。无论是底部抽屉还是居中卡片,都可以通过调整内容层的position和样式来实现。遮罩层的统一处理也保证了交互的一致性——点击遮罩关闭弹框是移动端的标准交互模式。

底部抽屉式弹框(新增装备)

底部抽屉式弹框用于跑酷鞋页面的新增装备功能。它的特点是弹框内容从屏幕底部延伸出来,顶部有圆角,与底部Tab栏的形态相呼应。

这种弹框模式适合包含较多表单元素的场景。底部位置有几个优势:首先,用户的拇指在手机屏幕底部区域更容易操作,符合移动端的交互习惯;其次,底部抽屉可以容纳更多的内容,即使内容较多也可以在抽屉内部滚动;第三,从底部滑出的动画效果更加自然流畅。

底部抽屉式弹框通常包含较多的操作控件,如文本输入框、滑块等。弹框宽度为100%,充分利用屏幕宽度。操作按钮位于弹框底部,同样处于拇指易操作的区域。

居中卡片式弹框(防护详情)

居中卡片式弹框用于护具页面的防护详情展示。它的特点是弹框内容位于屏幕中央偏上位置,宽度为88%,四周留有均匀的边距。

这种弹框模式适合信息展示类场景。居中位置有几个优势:首先,居中布局视觉上更加正式和聚焦,适合展示重要的详情信息;其次,卡片式设计与背景形成明显的层次感,用户可以清晰区分弹框内容和页面内容;第三,偏上的位置避开了虚拟键盘区域,适合可能弹出键盘的表单弹框。

居中卡片式弹框的内容组织更加灵活,可以包含标题、详情信息、操作按钮等多种元素。卡片的圆角设计(18px)让弹框看起来更加柔和友好,减少了弹窗的突兀感。

居中卡片式弹框(编辑参数)

编辑参数弹框也是居中卡片样式,与防护详情弹框在布局上类似,但内容侧重于表单编辑。它包含了文本输入和滑块控件,是一个功能性较强的弹框。

与详情弹框相比,编辑弹框在内容组织上更加紧凑。表单标签和输入控件交替排列,信息密度较高。操作按钮(取消+保存)位于弹框底部,提供了明确的操作选择。

编辑弹框的一个重要设计细节是数据的初始化时机。在打开弹框之前,需要先将表单数据设置为当前编辑对象的属性值。这个顺序确保了弹框显示时数据已经准备就绪,避免了显示默认值后再跳转为真实值的闪烁问题。

尺码选择弹框(宫格选择)

尺码选择弹框是一种特殊的居中卡片式弹框,它的核心内容是尺码宫格选择器。这种弹框模式适合选项较少(5-10个)且每个选项有明确标识的选择场景。

尺码宫格的每个选项都是一个独立的可点击区域,选中状态有明确的视觉反馈(蓝色背景+白色文字)。库存信息的展示让用户可以了解每个尺码的备货情况,辅助购买决策。

尺码选择弹框的交互流程是:点击尺码链接 → 设置选中商品 → 重置选中尺码 → 打开弹框 → 用户选择尺码 → 点击确认 → 关闭弹框。整个流程清晰流畅,每一步都有明确的状态变化和视觉反馈。

删除确认弹框(警示样式)

删除确认弹框是五种弹框中最紧凑、最具警示性的一种。它的内容最少,但视觉冲击力最强。大尺寸的警告图标和红色的确认按钮共同构成了强烈的警示效果。

这种弹框模式适用于不可逆的危险操作,如删除、清空、取消等。二次确认机制是防止误操作的重要手段,尤其是对于删除后不可恢复的数据,必须让用户明确知晓操作的后果。

删除确认弹框的设计要点包括:醒目的警示图标、明确的操作描述(“确认删除XX吗?”)、后果提示(“删除后不可恢复”)、以及对比鲜明的操作按钮(灰色取消+红色确认)。这些设计元素共同确保用户在执行危险操作前有充分的认知。

弹框交互模式对比

五种弹框虽然形态各异,但交互模式有很多共通之处。它们都支持点击遮罩关闭、点击关闭按钮关闭、以及通过状态变量控制显示隐藏。这种一致性的交互设计降低了用户的学习成本。

在数据传递方面,五种弹框都采用了"先设置数据再打开弹框"的模式。通过selectedXXX或editXXX等中间状态变量传递数据,确保弹框显示时数据已经准备就绪。这种模式简单可靠,适合中小型应用的弹框数据传递。

在状态管理方面,每个弹框对应一个boolean类型的@State变量(showXXX),通过条件渲染(if (this.showXXX))控制弹框的显示与隐藏。这种一对一的状态管理方式直观易懂,但当弹框数量较多时可能会导致状态变量泛滥。

渲染错误: Mermaid 渲染失败: Parse error on line 3: ... B[状态初始化] B --> B1[@State变量赋初值] ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

渲染流程与状态管理机制深度剖析

ArkUI声明式渲染原理

基于HarmonyOS ArkTS API 24 的声明式UI框架,其渲染机制与传统命令式UI有着本质的区别。在声明式范式中,开发者只需要描述"UI应该是什么样子",而不需要关心"如何一步步绘制UI"。框架会自动处理状态到UI的映射和更新。

渲染流程的起点是组件的build方法。当组件首次挂载时,框架调用build方法,执行其中的声明式UI描述,生成一棵虚拟的UI节点树。这棵树记录了所有组件的类型、属性、子节点等信息,是后续渲染和更新的基础。

当状态变量发生变化时,框架会重新执行build方法,生成新的UI节点树。然后通过diff算法对比新旧两棵树的差异,找出需要更新的最小单元。最后只对发生变化的部分进行实际的DOM更新,而不是重新渲染整个页面。

这种"状态驱动视图"的模式极大地简化了UI开发的复杂度。开发者无需手动管理UI更新的时机和方式,只需关注业务逻辑和状态管理即可。框架承担了底层的渲染调度和优化工作。

@State装饰器的工作机制

@State是ArkUI中最基础也是最常用的状态管理装饰器。被@State装饰的变量成为组件的内部状态,变量值的变化会触发组件的重新渲染。

@State的工作原理可以概括为三个步骤:观察、通知、更新。首先,框架在初始化时会为@State变量添加观察者,监控其值的变化。其次,当变量值发生改变时,观察者会通知框架状态已变更。最后,框架调度一次重新渲染,执行build方法并对比差异,更新UI。

需要注意的是,@State只观察变量本身的值变化。对于基本类型(number、string、boolean等),赋值操作会触发更新。对于引用类型(数组、对象等),只有替换整个引用(如数组重新赋值)才会触发更新。修改数组的单个元素或对象的属性不会触发@State级别的更新。

这也是为什么应用中新增鞋子时使用concat而不是push的原因。push方法修改原数组,引用不变,@State可能检测不到变化。而concat方法返回新数组,引用发生了变化,@State能够检测到并触发更新。

@Observed与嵌套对象观察

@Observed装饰器解决了@State无法观察嵌套对象属性变化的问题。当一个类被@Observed装饰后,其实例的每个属性变化都会被框架追踪。配合@ObjectLink(在父组件中使用),可以实现跨组件的细粒度状态同步。

在本应用中,PKGlove类使用了@Observed装饰器。当用户编辑手套的名称或磨阻系数时,直接修改editGlove实例的属性值就能触发UI更新。由于editGlove指向gloves数组中的某个元素,修改实例属性会同时更新列表中的显示。

这种细粒度的更新机制比替换整个数组更加高效。框架只需要重新渲染依赖于被修改属性的UI节点,而不需要重新渲染整个列表。在数据量较大的场景下,这种性能差异会非常明显。

@Observed的使用也有一些限制。首先,它只能装饰类,不能装饰接口。其次,被观察的属性必须是类的实例属性,且需要在声明时初始化。第三,@Observed类的实例需要配合@ObjectLink或@State使用才能发挥完整功效。

状态管理层次对比

HarmonyOS 6.1.1 提供了多层次的状态管理方案,从组件内到跨组件,从页面级到应用级,形成了完整的状态管理体系。理解不同层次的适用场景,对于构建高质量的鸿蒙应用至关重要。

@State是最基础的层次,适用于组件内部的状态管理。它的特点是简单直接,一个变量控制一部分UI的显示。但@State的作用域仅限于当前组件,无法在组件间共享状态。

@Observed/@ObjectLink是第二层次,适用于需要观察对象属性变化的场景。它的优势在于细粒度的更新和跨组件的状态同步。但它需要对数据类进行改造,添加@Observed装饰器。

AppStorage/LocalStorage是第三层次,适用于页面间或全局的状态共享。AppStorage是应用级的全局状态存储,LocalStorage是页面级的状态存储。它们提供了键值对的存取方式,可以在任意组件中读写。

对于本应用这样的中小型项目,@State配合@Observed已经能够满足大部分需求。页面间的状态传递可以通过路由参数或全局常量实现。当应用规模进一步扩大时,可以考虑引入更完善的状态管理方案。

动画系统的底层原理

HarmonyOS ArkTS API 24 的动画系统基于属性动画和显式动画两种机制。属性动画通过animation属性直接绑定到组件属性,显式动画通过animateTo方法在闭包中修改状态变量。

本应用主要使用显式动画(animateTo)。它的工作原理是:在animateTo的闭包中修改的所有状态变量,框架都会自动为对应的UI属性添加动画过渡效果。动画的时长、缓动曲线等参数由animateTo的第一个参数统一配置。

显式动画的优势在于可以一次触发多个属性的联动动画。例如在跳跃动画中,同时修改了位移、旋转、透明度、缩放等多个属性,这些属性的动画会同步进行,形成协调一致的视觉效果。

动画的实现底层依赖于属性插值器。框架在动画的每一帧计算属性的中间值,并更新到UI上。插值的计算方式由缓动曲线(curve)决定,不同的曲线会产生不同的运动感觉。EaseOut模拟减速运动,EaseIn模拟加速运动,Linear模拟匀速运动。

声明式UI的性能优化策略

声明式UI虽然简化了开发,但也带来了一些性能挑战。如果状态管理不当,可能会导致不必要的重新渲染,影响应用的流畅度。因此,理解并应用性能优化策略非常重要。

首先是合理控制状态变量的粒度。将大的状态对象拆分为多个小的@State变量,可以减少每次状态变化时的重新渲染范围。只有依赖于变化状态的UI部分才会更新,其他部分保持不变。

其次是使用@Observed实现细粒度更新。对于复杂对象,使用@Observed装饰类可以让属性级别的变化只影响依赖该属性的UI节点,而不会触发整个对象的重新渲染。

第三是合理使用ForEach的键值生成函数。ForEach的第三个参数(键值函数)对于列表的diff性能至关重要。使用稳定且唯一的键值(如id)可以帮助框架准确识别列表项的增删和移动,减少不必要的重建。

第四是避免在build方法中执行复杂计算。build方法会在每次状态变化时重新执行,如果其中包含复杂的计算逻辑,会影响渲染性能。可以将计算结果缓存到@State变量中,或者使用@Memo装饰器(如果可用)进行记忆化。

第五是合理使用@Builder和@Extracted等封装机制。将可复用的UI片段抽取为@Builder函数,可以提高代码的可维护性,同时也有助于框架进行更精细的更新优化。

渲染错误: Mermaid 渲染失败: Parse error on line 2: ...R A[状态管理层次] --> B[@State 组件内] A ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

数据可视化组件实现详解

柱状图的通用实现模式

应用中出现了多种柱状图/条形图组件,包括重量对比图、部位分布图、月消费图、指型分布图、季节分布图等。它们虽然形态各异(纵向、横向),但都遵循相似的实现模式。

纵向柱状图的核心实现是"Row + VerticalAlign.Bottom"。Row容器设置alignItems(VerticalAlign.Bottom),让所有子元素底部对齐。每个柱子使用Column容器,高度通过数据计算得出。柱子之间通过layoutWeight(1)平均分配宽度。

横向条形图的核心实现是"Stack + 百分比宽度"。使用Stack容器作为背景条,内部嵌套一个Column作为前景条,前景条的宽度通过百分比值控制。Stack的alignContent: Alignment.Start确保前景条从左侧开始填充。

这两种模式都是利用ArkUI的基础组件(Column、Row、Stack)组合实现的,不需要依赖专门的图表库。对于简单的数据可视化需求,这种方案足够灵活,且性能优异。

数据计算与映射

图表数据的计算和映射是数据可视化的关键环节。应用中使用了多种数据映射函数,将原始数据转换为UI可用的尺寸、颜色等属性。

线性映射是最常用的映射方式。例如pkWeightBar函数将鞋子重量映射为柱子高度:weight越小,高度越大。映射公式为Math.round((410 - w) * 0.4) + 16,其中410是重量基准最大值,0.4是缩放系数,16是最小高度偏移。

百分比映射也是常用的方式。例如pkSpendBar函数将消费金额映射为百分比高度:Math.round(amount / 5200 * 100) + 6。除以5200得到归一化值,乘以100转换为百分比,再加6个像素的最小高度。

颜色映射用于根据数据值返回不同的颜色。例如pkRankColor函数根据排名索引返回奖牌色,pkStatusColor函数根据订单状态返回状态色。这些映射函数将数据语义与视觉表现关联起来。

图表设计的视觉原则

应用中的图表设计遵循了一些通用的视觉设计原则,确保数据展示既美观又易读。

首先是数据-墨水比原则。图表中的每一笔墨水(像素)都应该用于传达数据信息,减少不必要的装饰元素。应用中的图表都非常简洁——只有柱子和标签,没有多余的边框、背景和装饰线。

其次是颜色编码原则。不同的数据维度使用不同的颜色编码,帮助用户快速识别。例如电光蓝用于等级/进度类数据,警示橙用于价格/金额类数据,绿色用于完成/成功状态,红色用于警告/删除操作。

第三是对齐原则。所有图表元素都遵循严格的对齐规则——柱状图底部对齐,条形图左侧对齐,文字标签基线对齐。整齐的对齐让图表看起来专业且易读。

第四是层次原则。图表中的信息按照重要程度分为不同层次。主要数据(柱子高度、数值)使用较大的字号和较亮的颜色,次要信息(标签、单位)使用较小的字号和较暗的颜色。这种层次区分引导用户的视线,快速获取关键信息。

各模块关键特性对比总结

模块/页面 布局模式 核心交互 弹框类型 数据可视化 状态变量数 特色技术
主入口页 三段式Column Tab切换 1 底部Tab导航、@Builder封装
首页 Scroll滚动列表 手动试跳 仪表盘+进度条+热销榜 9 animateTo显式动画、粒子特效、速度线、三角函数坐标
跑酷鞋页 单列卡片列表 新增装备 底部抽屉式 重量对比柱状图 6 表单输入、Slider滑块、concat新增数组
护具页 排行榜列表 查看详情 居中卡片式 部位分布柱状图 3 详情弹框、非空断言、防护等级进度
手套页 横向大卡列表 编辑参数 居中卡片+双滑块 指型分布条形图 5 @Observed属性更新、双滑块编辑
服装页 三列色彩Grid 选择尺码 居中卡片+尺码宫格 季节分布条形图 3 尺码选择器、色彩卡片、渐变背景
我的页 Scroll滚动列表 删除收藏 居中紧凑警示式 会员卡+日历+月消费+订单列表 3 三色渐变、日历布局、删除确认、不可变数据

布局模式对比

主入口页采用经典的三段式布局(头部+内容+底部Tab),这是移动端应用最常见的布局结构。它的优势是结构清晰、导航便捷,用户可以快速在不同功能模块间切换。

首页采用滚动列表布局,将多个功能模块垂直堆叠。这种布局适合信息密度较高的首页,可以通过滚动浏览全部内容。每个模块使用独立的卡片容器,视觉上层次分明。

跑酷鞋页、护具页、手套页都采用了列表式布局,但卡片的设计各不相同。跑酷鞋使用双列卡片,护具使用排行榜行,手套使用横向大卡。不同的卡片设计适应了不同品类的信息展示需求。

服装页采用三列网格布局,以色彩展示为核心。这种布局适合服装、配饰等视觉驱动的商品,让用户可以快速浏览和比较不同款式。

我的页采用滚动列表布局,从上到下依次展示会员卡、打卡日历、月消费、订单列表、收藏列表。这种布局符合个人中心页面的用户习惯——重要信息在顶部,详细列表在下方。

弹框类型对比

应用中共使用了五种不同的弹框类型,每种弹框都有其特定的适用场景和设计特点。

底部抽屉式弹框适合表单类操作,如新增装备。它的优势是操作区域大,适合拇指操作,且可以容纳较多的表单控件。底部弹出的动画也符合移动端的交互习惯。

居中卡片式弹框(详情型)适合信息展示,如防护详情。它的优势是视觉聚焦,内容层次清晰,适合阅读较长的详情信息。居中的位置也给人一种"模态对话"的感觉。

居中卡片式弹框(编辑型)适合参数编辑,如编辑手套参数。它与详情型弹框布局类似,但内容侧重于表单控件。编辑型弹框需要处理数据初始化和保存逻辑,交互流程更复杂。

尺码选择弹框是一种特殊的居中卡片弹框,核心内容是选择器。它适合选项数量适中(5-10个)的选择场景,每个选项都有明确的视觉标识和选中状态反馈。

删除确认弹框是最紧凑的弹框类型,用于危险操作的二次确认。它的特点是警示性强、操作简单,通过醒目的图标和颜色提醒用户谨慎操作。

数据可视化对比

应用中包含多种数据可视化组件,从简单的进度条到复杂的仪表盘,展现了声明式UI在数据展示方面的强大能力。

仪表盘是最复杂的可视化组件,使用三角函数计算刻度位置,通过旋转、位移、缩放等变换实现动画效果。它综合运用了多种视觉元素(刻度、指针、粒子、速度线、冲击波),营造出丰富的视觉层次。

进度条是最简单也最常用的可视化组件,使用Stack叠加两个Column实现。虽然简单,但应用非常广泛——技能等级、抓地力、防护等级、透气性等都使用了进度条展示。

柱状图有纵向和横向两种形态。纵向柱状图用于重量对比、部位分布、月消费等场景,通过Row容器的底部对齐实现。横向条形图用于指型分布、季节分布等场景,通过Stack的百分比宽度实现。

日历是一种特殊的网格可视化,用于展示打卡记录。它通过条件渲染区分已打卡和未打卡状态,使用圆形背景标记已打卡日期,视觉上非常直观。

技术总结与深度思考

声明式UI开发范式的价值

通过对这个城市跑酷装备馆应用的深度解析,我们可以清晰地看到声明式UI开发范式带来的价值。基于HarmonyOS ArkTS API 24 的开发模式,在开发效率、代码质量、可维护性等多个方面都展现出显著优势。

首先,声明式UI大幅提升了开发效率。开发者只需描述UI的目标状态,框架会自动处理状态变化到UI更新的映射。这种模式消除了大量手动操作UI的样板代码,让开发者可以更专注于业务逻辑的实现。

其次,声明式UI提高了代码的可读性和可维护性。UI描述与状态变量直接关联,代码的意图非常清晰。当需要修改UI时,只需找到对应的状态变量和UI描述,理解成本低,修改风险小。

第三,声明式UI促进了组件化和复用。@Builder装饰器、@Component组件等机制让UI片段的封装和复用变得简单自然。良好的组件化是大型项目可维护性的基础。

第四,声明式UI与状态管理天然契合。@State、@Observed等装饰器提供了多层次的状态管理能力,让状态变化的追踪和UI更新的触发变得自动化和可预测。

ArkTS语言特性的优势

ArkTS作为鸿蒙应用的开发语言,在TypeScript的基础上进行了针对性的扩展和优化,使其更适合声明式UI开发。

装饰器机制是ArkTS最重要的语言扩展。@Entry、@Component、@State、@Observed、@Builder等装饰器提供了声明式UI、状态管理、UI封装等核心能力。装饰器让代码的意图更加明确,也让框架能够在编译期进行更多的优化。

结构体组件(struct)是ArkTS的另一个特色。与类组件不同,结构体组件更轻量,生命周期更简单,性能更优。对于纯展示型的UI组件,使用struct是最佳选择。

严格的类型系统是ArkTS的重要保障。所有变量必须声明类型,所有属性必须初始化,接口属性不可选。这些严格的规则虽然增加了一些代码量,但在编译期就能发现大量潜在问题,提高了代码的健壮性。

链式调用的属性设置方式是ArkTS的语法亮点。通过.属性名(值)的链式调用方式设置组件属性,代码结构清晰,可读性强。属性的顺序不影响结果,但约定俗成的顺序(布局-外观-交互)让代码更易理解。

HarmonyOS 6.1.1 动画系统的亮点

HarmonyOS 6.1.1 的动画系统在流畅度和易用性之间取得了很好的平衡。animateTo显式动画API的设计非常优雅,让复杂的多属性联动动画变得简单直观。

显式动画的最大优势是"一次触发、多属性同步"。在一个animateTo闭包中修改的所有状态变量,对应的UI属性都会自动应用动画过渡,且动画的时序完全同步。这种方式比为每个属性单独设置动画要简洁得多。

缓动曲线的支持让动画效果更加自然。Curve.EaseOut、Curve.EaseIn、Curve.Linear等不同的缓动曲线模拟了不同的物理运动规律,开发者可以根据场景选择合适的曲线,让动画更符合用户的心理预期。

动画的性能表现也是HarmonyOS的强项。框架对动画进行了深度优化,包括硬件加速、属性动画合并、无效帧跳过等技术。即使同时运行多个动画,界面依然能够保持流畅。

状态管理的最佳实践

从本应用的实践中,我们可以总结出一些状态管理的最佳实践。这些经验对于构建高质量的鸿蒙应用具有参考价值。

第一,合理选择状态管理层次。不要过度使用全局状态,能用@State解决的就不要用AppStorage。状态的作用域越小,调试和维护越容易。

第二,保持状态的不可变性。对于数组和对象,尽量使用替换引用的方式更新(如concat、filter、map返回新数组),而不是直接修改原数据。不可变数据让状态变化可追踪,也更容易排查问题。

第三,拆分大状态为小状态。如果一个@State变量控制了太多的UI逻辑,考虑将其拆分为多个小的状态变量。这样每次状态变化触发的重新渲染范围更小,性能更好。

第四,对于复杂数据模型,使用@Observed。当需要修改对象的单个属性并触发UI更新时,@Observed是最佳选择。它提供了属性级别的细粒度更新,性能优于整体替换。

第五,弹框状态与数据分离。弹框的显示状态(showXXX)和弹框的数据(selectedXXX)应该分开管理。这样逻辑更清晰,也更容易处理弹框打开前的数据准备。

数据可视化的实现思路

应用中丰富的数据可视化组件展示了声明式UI在数据展示方面的强大潜力。通过基础组件的组合和状态驱动的动画,可以实现各种复杂的可视化效果。

对于简单的进度条和柱状图,使用Stack和Column的组合就足够了。这种方案轻量、灵活,不需要引入额外的依赖库。对于大多数业务场景,这种轻量方案完全能够满足需求。

对于复杂的仪表盘类组件,需要结合三角函数计算和变换属性(rotate、translate、scale)来实现。虽然实现复杂度较高,但效果也更加惊艳,能够显著提升应用的视觉品质。

数据映射函数是连接数据与视觉的桥梁。将数据计算和映射逻辑抽取为纯函数,让UI代码更加简洁,也方便单元测试。良好的数据映射设计可以让数据可视化的代码更加优雅。

架构演进方向

虽然当前的单文件架构对于学习和演示非常友好,但在实际项目中,随着功能的增加,需要考虑架构的演进和优化。

首先是模块拆分。将不同页面的组件拆分到不同的文件中,每个页面一个文件。公共的类型定义、工具函数、常量配置抽取到独立的公共模块中。这样可以提高代码的可维护性,也方便团队协作。

其次是状态管理的升级。当应用状态变得复杂时,可以引入更完善的状态管理方案,如基于AppStorage的全局状态管理,或使用第三方状态管理库。统一的状态管理可以避免状态分散在各个组件中导致的数据同步问题。

第三是组件库的建设。将可复用的UI组件(如进度条、柱状图、弹框等)抽取为独立的组件库,在项目中统一使用。组件库可以提高开发效率,也能保证UI风格的一致性。

第四是性能优化。随着页面复杂度的增加,需要关注渲染性能。包括列表虚拟化、图片懒加载、动画优化、状态粒度优化等。良好的性能是用户体验的基础保障。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============ PARKOUR ZONE 城市跑酷装备馆 ============
// 前缀 PK | 主题:沥青灰 #2C2C2C + 电光蓝 #1E90FF + 警示橙 #FF6B00

// ============ 类型定义 ============
interface PKTabMeta {
  label: string
  icon: string
  color: string
}

interface PKSkillMeta {
  name: string
  level: number
  color: string
}

interface PKHotItem {
  id: number
  name: string
  sales: number
  color: string
}

interface PKGridFunc {
  label: string
  icon: string
  color: string
}

interface PKMemberStat {
  label: string
  value: string
}

@Observed
class PKShoe {
  id: number = 0
  name: string = ''
  grip: number = 0
  weight: number = 0
  price: number = 0
  colorHex: string = ''
  tag: string = ''
  constructor(id: number, name: string, grip: number, weight: number, price: number, colorHex: string, tag: string) {
    this.id = id; this.name = name; this.grip = grip; this.weight = weight
    this.price = price; this.colorHex = colorHex; this.tag = tag
  }
}

interface PKGuard {
  id: number
  name: string
  level: number
  price: number
  parts: string
  material: string
  colorHex: string
}

@Observed
class PKGlove {
  id: number = 0
  name: string = ''
  friction: number = 0
  finger: string = ''
  price: number = 0
  colorHex: string = ''
  constructor(id: number, name: string, friction: number, finger: string, price: number, colorHex: string) {
    this.id = id; this.name = name; this.friction = friction; this.finger = finger
    this.price = price; this.colorHex = colorHex
  }
}

interface PKCloth {
  id: number
  name: string
  breath: number
  season: string
  colorHex: string
  price: number
}

interface PKOrder {
  id: number
  name: string
  time: string
  amount: number
  status: string
}

interface PKFav {
  id: number
  name: string
  type: string
  colorHex: string
}

interface PKSpend {
  month: string
  amount: number
}

interface PKPartDist {
  label: string
  count: number
}

interface PKFingerDist {
  label: string
  count: number
}

interface PKSeasonDist {
  label: string
  count: number
}

interface PKDayItem {
  day: number
  checked: boolean
}

interface PKTick {
  angle: number
  big: boolean
}

interface PKParticle {
  dx: number
  dy: number
  size: number
}

interface PKSpeedLine {
  x: number
  y: number
  len: number
}

interface PKSizeOption {
  size: string
  stock: number
}

// ============ 全局静态配置 ============
const PK_TABS: PKTabMeta[] = [
  { label: '首页', icon: '🏠', color: '#1E90FF' },
  { label: '跑酷鞋', icon: '👟', color: '#1E90FF' },
  { label: '护具', icon: '🛡️', color: '#FF6B00' },
  { label: '手套', icon: '🧤', color: '#1E90FF' },
  { label: '服装', icon: '👕', color: '#FF6B00' },
  { label: '我的', icon: '👤', color: '#1E90FF' }
]

const PK_SKILLS: PKSkillMeta[] = [
  { name: '跑酷等级', level: 72, color: '#1E90FF' },
  { name: '翻越技巧', level: 58, color: '#FF6B00' },
  { name: '落地控制', level: 86, color: '#1E90FF' }
]

const PK_HOT: PKHotItem[] = [
  { id: 1, name: '夜枭V3 全地形鞋', sales: 428, color: '#1E90FF' },
  { id: 2, name: '疾风 轻量竞速鞋', sales: 396, color: '#FF6B00' },
  { id: 3, name: '钛甲护膝套装', sales: 312, color: '#1E90FF' },
  { id: 4, name: '壁虎掌 全指手套', sales: 268, color: '#FF6B00' },
  { id: 5, name: '速干夜幕短袖', sales: 241, color: '#1E90FF' },
  { id: 6, name: '影锋 极限跳跃鞋', sales: 205, color: '#FF6B00' }
]

const PK_FUNCS: PKGridFunc[] = [
  { label: '训练课程', icon: '🏃', color: '#1E90FF' },
  { label: '装备保养', icon: '🧼', color: '#FF6B00' },
  { label: '动作教学', icon: '🎬', color: '#1E90FF' },
  { label: '城市地图', icon: '🗺️', color: '#FF6B00' },
  { label: '极限赛事', icon: '🏆', color: '#1E90FF' },
  { label: '跑友圈子', icon: '👥', color: '#FF6B00' },
  { label: '医疗急救', icon: '🚑', color: '#1E90FF' },
  { label: '装备租赁', icon: '🔧', color: '#FF6B00' }
]

const PK_MEMBER_STATS: PKMemberStat[] = [
  { label: '训练天数', value: '96' },
  { label: '累计里程', value: '320' },
  { label: '收藏装备', value: '6' },
  { label: '本月打卡', value: '12' }
]

// ============ 跑酷鞋数据(10款)============
const PK_SHOES: PKShoe[] = [
  new PKShoe(1, '夜枭V3 全地形', 92, 312, 899, '#1E90FF', '热卖'),
  new PKShoe(2, '疾风 轻量竞速', 78, 205, 699, '#FF6B00', '轻量'),
  new PKShoe(3, '壁虎 墙面抓附', 95, 288, 799, '#1E90FF', '抓地'),
  new PKShoe(4, '铁壁 城市防护', 72, 398, 749, '#2C2C2C', '耐磨'),
  new PKShoe(5, '影锋 极限跳跃', 88, 256, 949, '#FF6B00', '弹跳'),
  new PKShoe(6, '霜刃 雨天防滑', 84, 302, 689, '#1E90FF', '防滑'),
  new PKShoe(7, '轻羽 零感训练', 66, 188, 559, '#2C2C2C', '轻量'),
  new PKShoe(8, '狂沙 越野强抓', 90, 342, 829, '#FF6B00', '抓地'),
  new PKShoe(9, '隐刺 夜跑反光', 76, 275, 759, '#1E90FF', '夜行'),
  new PKShoe(10, '磐石 大底耐磨', 82, 388, 649, '#2C2C2C', '耐磨')
]

// ============ 护具数据(8款)============
const PK_GUARDS: PKGuard[] = [
  { id: 1, name: '钛甲护膝', level: 92, price: 299, parts: '膝部+小腿', material: 'TPU凯夫拉', colorHex: '#1E90FF' },
  { id: 2, name: '刃锋护肘', level: 85, price: 219, parts: '肘部+前臂', material: '碳纤维复合', colorHex: '#FF6B00' },
  { id: 3, name: '铁腕护掌', level: 78, price: 149, parts: '腕部+手掌', material: '硅胶+ABS', colorHex: '#1E90FF' },
  { id: 4, name: '龟壳护背', level: 90, price: 359, parts: '背部+肩部', material: '高密EVA', colorHex: '#FF6B00' },
  { id: 5, name: '鹰爪护踝', level: 74, price: 129, parts: '脚踝+足弓', material: '织物+TPU', colorHex: '#1E90FF' },
  { id: 6, name: '蛇鳞护腰', level: 68, price: 179, parts: '腰部+髋部', material: '尼龙网布', colorHex: '#2C2C2C' },
  { id: 7, name: '猿臂护腕', level: 82, price: 169, parts: '腕部+手背', material: '皮质减震垫', colorHex: '#FF6B00' },
  { id: 8, name: '岩层护胫', level: 88, price: 249, parts: '小腿+胫骨', material: '铝板+泡棉', colorHex: '#1E90FF' }
]

// ============ 手套数据(8款)============
const PK_GLOVES: PKGlove[] = [
  new PKGlove(1, '壁虎掌 全指', 88, '全指', 199, '#1E90FF'),
  new PKGlove(2, '烈风 半指', 72, '半指', 129, '#FF6B00'),
  new PKGlove(3, '蜘蛛丝 露指', 65, '露指', 109, '#1E90FF'),
  new PKGlove(4, '岩壁 攀爬专用', 94, '全指', 259, '#FF6B00'),
  new PKGlove(5, '黑曜 耐磨战术', 81, '全指', 219, '#2C2C2C'),
  new PKGlove(6, '气垫 减震半指', 59, '半指', 139, '#1E90FF'),
  new PKGlove(7, '冰棱 湿滑防滑', 90, '全指', 229, '#FF6B00'),
  new PKGlove(8, '云触 轻量训练', 70, '露指', 99, '#1E90FF')
]

// ============ 服装数据(6款)============
const PK_CLOTHS: PKCloth[] = [
  { id: 1, name: '速干夜幕短袖', breath: 92, season: '夏', colorHex: '#1E90FF', price: 159 },
  { id: 2, name: '疾风弹力长裤', breath: 78, season: '春秋', colorHex: '#FF6B00', price: 229 },
  { id: 3, name: '冲锋防风外套', breath: 42, season: '冬', colorHex: '#2C2C2C', price: 429 },
  { id: 4, name: '霓虹训练背心', breath: 95, season: '夏', colorHex: '#FF6B00', price: 99 },
  { id: 5, name: '晨雾连帽卫衣', breath: 60, season: '春秋', colorHex: '#8A8A8A', price: 289 },
  { id: 6, name: '暖芯抓绒内层', breath: 50, season: '冬', colorHex: '#1E90FF', price: 199 }
]

// ============ 订单 / 收藏 / 月消费 ============
const PK_ORDERS: PKOrder[] = [
  { id: 1, name: '夜枭V3 全地形鞋', time: '2026-08-20 14:32', amount: 899, status: '已完成' },
  { id: 2, name: '钛甲护膝套装', time: '2026-08-18 09:15', amount: 299, status: '运输中' },
  { id: 3, name: '壁虎掌 全指手套', time: '2026-08-15 20:41', amount: 199, status: '已完成' },
  { id: 4, name: '速干夜幕短袖', time: '2026-08-12 11:08', amount: 159, status: '待付款' },
  { id: 5, name: '影锋 极限跳跃鞋', time: '2026-08-08 22:47', amount: 949, status: '已完成' },
  { id: 6, name: '隐刺 夜跑反光鞋', time: '2026-08-03 16:20', amount: 759, status: '运输中' }
]

const PK_FAVS: PKFav[] = [
  { id: 1, name: '夜枭V3 全地形鞋', type: '跑酷鞋', colorHex: '#1E90FF' },
  { id: 2, name: '钛甲护膝', type: '护具', colorHex: '#FF6B00' },
  { id: 3, name: '壁虎掌 全指手套', type: '手套', colorHex: '#1E90FF' },
  { id: 4, name: '速干夜幕短袖', type: '服装', colorHex: '#FF6B00' },
  { id: 5, name: '霜刃 雨天防滑鞋', type: '跑酷鞋', colorHex: '#1E90FF' },
  { id: 6, name: '蛇鳞护腰', type: '护具', colorHex: '#2C2C2C' }
]

const PK_SPEND: PKSpend[] = [
  { month: '3月', amount: 1280 },
  { month: '4月', amount: 2460 },
  { month: '5月', amount: 3680 },
  { month: '6月', amount: 1900 },
  { month: '7月', amount: 4320 },
  { month: '8月', amount: 5180 }
]

// ============ 特效配置 ============
const PK_TICKS: PKTick[] = [
  { angle: -90, big: true },
  { angle: -72, big: false },
  { angle: -54, big: false },
  { angle: -36, big: false },
  { angle: -18, big: false },
  { angle: 0, big: true },
  { angle: 18, big: false },
  { angle: 36, big: false },
  { angle: 54, big: false },
  { angle: 72, big: false },
  { angle: 90, big: true }
]

const PK_PARTICLES: PKParticle[] = [
  { dx: -6, dy: -10, size: 6 },
  { dx: -20, dy: 2, size: 5 },
  { dx: 14, dy: -12, size: 7 },
  { dx: 22, dy: 4, size: 4 },
  { dx: 2, dy: 12, size: 5 }
]

const PK_SPEED_LINES: PKSpeedLine[] = [
  { x: 22, y: 34, len: 30 },
  { x: 58, y: 66, len: 20 },
  { x: 190, y: 40, len: 34 },
  { x: 226, y: 80, len: 22 },
  { x: 96, y: 24, len: 26 },
  { x: 168, y: 96, len: 18 }
]

const PK_SIZES: PKSizeOption[] = [
  { size: 'S', stock: 12 },
  { size: 'M', stock: 20 },
  { size: 'L', stock: 34 },
  { size: 'XL', stock: 8 },
  { size: 'XXL', stock: 3 }
]

// ============ 全局纯函数 ============
const PK_PARTS: string[] = ['膝部', '肘部', '腕部', '背部', '脚踝']
const PK_FINGER_TYPES: string[] = ['全指', '半指', '露指']
const PK_SEASONS: string[] = ['春', '夏', '秋', '冬']

function pkPct(v: number): string {
  return v.toString() + '%'
}

function pkSortHot(list: PKHotItem[]): PKHotItem[] {
  let r: PKHotItem[] = []
  for (let i = 0; i < list.length; i++) {
    r.push(list[i])
  }
  return r.sort((a: PKHotItem, b: PKHotItem) => b.sales - a.sales)
}

function pkSortShoeWeight(list: PKShoe[]): PKShoe[] {
  let r: PKShoe[] = []
  for (let i = 0; i < list.length; i++) {
    r.push(list[i])
  }
  return r.sort((a: PKShoe, b: PKShoe) => a.weight - b.weight)
}

function pkSortGuard(list: PKGuard[]): PKGuard[] {
  let r: PKGuard[] = []
  for (let i = 0; i < list.length; i++) {
    r.push(list[i])
  }
  return r.sort((a: PKGuard, b: PKGuard) => b.level - a.level)
}

function pkTopLight(list: PKShoe[]): PKShoe[] {
  let r: PKShoe[] = []
  for (let i = 0; i < list.length; i++) {
    r.push(list[i])
  }
  r = r.sort((a: PKShoe, b: PKShoe) => a.weight - b.weight)
  r = r.slice(0, 6)
  return r
}

function pkCountPart(list: PKGuard[], part: string): number {
  let c: number = 0
  for (let i = 0; i < list.length; i++) {
    if (list[i].parts.indexOf(part) >= 0) {
      c = c + 1
    }
  }
  return c
}

function pkPartDist(list: PKGuard[]): PKPartDist[] {
  let r: PKPartDist[] = []
  for (let i = 0; i < PK_PARTS.length; i++) {
    r.push({ label: PK_PARTS[i], count: pkCountPart(list, PK_PARTS[i]) })
  }
  return r
}

function pkCountFinger(list: PKGlove[], type: string): number {
  let c: number = 0
  for (let i = 0; i < list.length; i++) {
    if (list[i].finger === type) {
      c = c + 1
    }
  }
  return c
}

function pkFingerDist(list: PKGlove[]): PKFingerDist[] {
  let r: PKFingerDist[] = []
  for (let i = 0; i < PK_FINGER_TYPES.length; i++) {
    r.push({ label: PK_FINGER_TYPES[i], count: pkCountFinger(list, PK_FINGER_TYPES[i]) })
  }
  return r
}

function pkCountSeason(list: PKCloth[], s: string): number {
  let c: number = 0
  for (let i = 0; i < list.length; i++) {
    if (list[i].season.indexOf(s) >= 0) {
      c = c + 1
    }
  }
  return c
}

function pkSeasonDist(list: PKCloth[]): PKSeasonDist[] {
  let r: PKSeasonDist[] = []
  for (let i = 0; i < PK_SEASONS.length; i++) {
    r.push({ label: PK_SEASONS[i], count: pkCountSeason(list, PK_SEASONS[i]) })
  }
  return r
}

function pkFilterFav(list: PKFav[], id: number): PKFav[] {
  let r: PKFav[] = []
  for (let i = 0; i < list.length; i++) {
    if (list[i].id !== id) {
      r.push(list[i])
    }
  }
  return r
}

function pkBuildCalendar(year: number, month: number): PKDayItem[] {
  let r: PKDayItem[] = []
  for (let i = 0; i < 6; i++) {
    r.push({ day: 0, checked: false })
  }
  for (let d = 1; d <= 31; d++) {
    r.push({ day: d, checked: (d % 3 === 1 || d % 7 === 0) })
  }
  return r
}

function pkTickX(angle: number): number {
  return Math.round(130 + 116 * Math.sin(angle * Math.PI / 180))
}

function pkTickY(angle: number): number {
  return Math.round(200 - 116 * Math.cos(angle * Math.PI / 180))
}

function pkTickColor(angle: number, height: number): string {
  const t: number = -90 + height / 100 * 180
  if (angle <= t) {
    return '#1E90FF'
  }
  return '#3A3A3A'
}

function pkSpendBar(amount: number): number {
  return Math.round(amount / 5200 * 100) + 6
}

function pkWeightBar(w: number): number {
  return Math.round((410 - w) * 0.4) + 16
}

function pkShoeGridHeight(n: number): number {
  return Math.ceil(n / 2) * 176 + 8
}

function pkClothGridHeight(n: number): number {
  return Math.ceil(n / 3) * 192 + 8
}

function pkRankColor(idx: number): string {
  if (idx === 0) {
    return '#FF6B00'
  }
  if (idx === 1) {
    return '#1E90FF'
  }
  if (idx === 2) {
    return '#8A8A8A'
  }
  return '#555555'
}

function pkStatusColor(s: string): string {
  if (s === '已完成') {
    return '#4CAF50'
  }
  if (s === '运输中') {
    return '#1E90FF'
  }
  return '#FF6B00'
}

function pkHotPrice(id: number): number {
  if (id === 1) {
    return 899
  }
  if (id === 2) {
    return 699
  }
  if (id === 3) {
    return 299
  }
  if (id === 4) {
    return 199
  }
  if (id === 5) {
    return 159
  }
  return 949
}

// ============ 主入口:PARKOUR ZONE ============
@Entry
@Component
struct PKMainPage {
  @State currentTab: number = 0

  @Builder contentArea() {
    Column() {
      if (this.currentTab === 0) {
        PKHomePage()
      } else if (this.currentTab === 1) {
        PKShoesPage()
      } else if (this.currentTab === 2) {
        PKGuardPage()
      } else if (this.currentTab === 3) {
        PKGlovePage()
      } else if (this.currentTab === 4) {
        PKClothPage()
      } else {
        PKMinePage()
      }
    }
    .width('100%').height('100%')
  }

  @Builder bottomTabItem(icon: string, label: string, idx: number, color: string) {
    Column() {
      Text(icon).fontSize(18).opacity(this.currentTab === idx ? 1.0 : 0.5)
      Text(label).fontSize(9)
        .fontColor(this.currentTab === idx ? '#F0F0F0' : '#6E6E6E')
        .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
      if (this.currentTab === idx) {
        Column().width(20).height(3)
          .backgroundColor(color).borderRadius(2).margin({ top: 3 })
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 6, bottom: 6 })
    .onClick(() => { this.currentTab = idx })
  }

  build() {
    Column() {
      // 静态品牌头部(无动画,运动品牌风格)
      Column() {
        Row() {
          Text('PARKOUR').fontSize(21).fontWeight(FontWeight.Bold).fontColor('#1E90FF')
          Text('ZONE').fontSize(21).fontWeight(FontWeight.Bold).fontColor('#F5F5F5')
          Column().layoutWeight(1)
          Text('PK·001').fontSize(10).fontColor('#FF6B00')
            .border({ width: 1, color: '#FF6B00', radius: 8 })
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        }
        .width('100%').padding({ left: 16, right: 16, top: 12 })
        Text('城市跑酷装备馆 · 暗夜行动指南')
          .fontSize(11).fontColor('#8A8A8A')
          .width('100%').padding({ left: 16, top: 3 })
      }
      .width('100%').padding({ bottom: 10 })
      .backgroundColor('#1B1B1B')

      this.contentArea()

      // 底部 Tab 栏
      Row() {
        ForEach(PK_TABS, (tab: PKTabMeta, idx: number) => {
          this.bottomTabItem(tab.icon, tab.label, idx, tab.color)
        }, (tab: PKTabMeta) => tab.label)
      }
      .width('100%')
      .backgroundColor('#1B1B1B')
      .padding({ top: 2, bottom: 6 })
    }
    .width('100%').height('100%')
    .backgroundColor('#141414')
  }
}

// ============ 首页:跳跃仪表盘 ============
@Component
struct PKHomePage {
  @State pkJumpY: number = 0
  @State pkNeedleAngle: number = -72
  @State pkJumpHeight: number = 0
  @State pkShockScale: number = 0.4
  @State pkShockOp: number = 0
  @State pkParticleScale: number = 0.4
  @State pkParticleOp: number = 0
  @State pkSpeedOffset: number = 0
  @State pkRunning: boolean = false
  private pkTimer: number = -1

  aboutToAppear(): void {
    this.pkTimer = setInterval(() => {
      this.pkDoJump()
    }, 2600)
  }

  aboutToDisappear(): void {
    clearInterval(this.pkTimer)
  }

  private pkDoJump(): void {
    this.getUIContext().animateTo({ duration: 460, curve: Curve.EaseOut }, () => {
      this.pkJumpY = -128
      this.pkNeedleAngle = 84
      this.pkJumpHeight = 88
      this.pkSpeedOffset = 42
      this.pkRunning = true
    })
    setTimeout(() => {
      this.pkLand()
      this.getUIContext().animateTo({ duration: 520, curve: Curve.EaseIn }, () => {
        this.pkJumpY = 0
        this.pkNeedleAngle = -72
        this.pkJumpHeight = 0
        this.pkSpeedOffset = 0
        this.pkRunning = false
      })
    }, 560)
  }

  private pkLand(): void {
    this.pkShockScale = 0.4
    this.pkShockOp = 1
    this.pkParticleScale = 0.4
    this.pkParticleOp = 1
    this.getUIContext().animateTo({ duration: 560, curve: Curve.EaseOut }, () => {
      this.pkShockScale = 2
      this.pkShockOp = 0
      this.pkParticleScale = 1.7
      this.pkParticleOp = 0
    })
  }

  build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            // 跳跃仪表盘
            Column() {
              Row() {
                Text('⚡ 跳跃仪表盘').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
                Column().layoutWeight(1)
                Text('实时 ' + Math.round(this.pkJumpHeight).toString() + ' cm')
                  .fontSize(11).fontColor('#FF6B00')
              }
              .width('100%')

              Stack() {
                // 速度线
                ForEach(PK_SPEED_LINES, (l: PKSpeedLine) => {
                  Column().width(2).height(l.len)
                    .backgroundColor('#1E90FF')
                    .opacity(this.pkRunning ? 0.7 : 0.1)
                    .position({ x: l.x, y: l.y })
                    .translate({ x: this.pkSpeedOffset })
                }, (l: PKSpeedLine) => l.x.toString() + l.y.toString())
                // 刻度弧
                ForEach(PK_TICKS, (t: PKTick) => {
                  Column().width(2).height(t.big ? 16 : 9)
                    .backgroundColor(pkTickColor(t.angle, this.pkJumpHeight))
                    .borderRadius(2)
                    .position({ x: pkTickX(t.angle), y: pkTickY(t.angle) })
                }, (t: PKTick) => t.angle.toString())
                // 指针
                Column().width(4).height(82)
                  .backgroundColor('#FF6B00')
                  .borderRadius(2)
                  .rotate({ angle: this.pkNeedleAngle, centerX: '50%', centerY: '100%' })
                  .position({ x: 128, y: 118 })
                // 中心轴
                Column().width(14).height(14)
                  .backgroundColor('#FF6B00').borderRadius(7)
                  .position({ x: 123, y: 191 })
                // 跳跃粒子
                Stack() {
                  ForEach(PK_PARTICLES, (p: PKParticle) => {
                    Column().width(p.size).height(p.size)
                      .backgroundColor('#FF6B00')
                      .borderRadius(p.size / 2)
                      .position({ x: 60 + p.dx, y: 60 + p.dy })
                  }, (p: PKParticle) => p.dx.toString() + p.dy.toString())
                }
                .width(120).height(120)
                .position({ x: 70, y: 120 })
                .opacity(this.pkParticleOp)
                .scale({ x: this.pkParticleScale, y: this.pkParticleScale })
                // 落地冲击波
                Column().width(44).height(44)
                  .border({ width: 3, color: '#1E90FF', radius: 22 })
                  .borderRadius(22)
                  .opacity(this.pkShockOp)
                  .scale({ x: this.pkShockScale, y: this.pkShockScale })
                  .position({ x: 108, y: 148 })
                // 跳跃球
                Column().width(26).height(26)
                  .backgroundColor('#FF6B00').borderRadius(13)
                  .shadow({ radius: 10, color: '#66FF6B00' })
                  .position({ x: 117, y: 164 })
                  .translate({ y: this.pkJumpY })
              }
              .width(260).height(210)
              .margin({ top: 8 })

              Row() {
                Text('0').fontSize(10).fontColor('#6E6E6E')
                Column().layoutWeight(1)
                Text('跳跃高度').fontSize(10).fontColor('#9A9A9A')
                Column().layoutWeight(1)
                Text('100').fontSize(10).fontColor('#6E6E6E')
              }
              .width('100%').margin({ top: 2 })

              Row() {
                Text('🏁 手动试跳').fontSize(11).fontColor('#B8B8B8')
                Column().layoutWeight(1)
                Text('跳跃').fontSize(12).fontColor('#FFFFFF')
                  .backgroundColor('#FF6B00').borderRadius(14)
                  .padding({ left: 22, right: 22, top: 7, bottom: 7 })
                  .onClick(() => { this.pkDoJump() })
              }
              .width('100%').margin({ top: 10 })
            }
            .width('100%').backgroundColor('#242424').borderRadius(16).padding(14)

            // 技能等级条
            Column() {
              Row() {
                Text('🎯 技能等级').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
                Column().layoutWeight(1)
                Text('Lv.7').fontSize(11).fontColor('#1E90FF')
              }
              .width('100%')
              ForEach(PK_SKILLS, (s: PKSkillMeta) => {
                Column() {
                  Row() {
                    Text(s.name).fontSize(12).fontColor('#B8B8B8')
                    Column().layoutWeight(1)
                    Text(s.level.toString() + '/100').fontSize(11).fontColor(s.color)
                  }
                  .width('100%').margin({ top: 8 })
                  Stack({ alignContent: Alignment.Start }) {
                    Column().width(pkPct(s.level)).height(8)
                      .backgroundColor(s.color).borderRadius(4)
                  }
                  .width('100%').height(8).backgroundColor('#3A3A3A').borderRadius(4).margin({ top: 5 })
                }
                .width('100%')
              }, (s: PKSkillMeta) => s.name)
            }
            .width('100%').backgroundColor('#242424').borderRadius(16).padding(14)
            .margin({ top: 12 })

            // 热销榜
            Column() {
              Row() {
                Text('🔥 热销榜').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
                Column().layoutWeight(1)
                Text('近30天').fontSize(11).fontColor('#8A8A8A')
              }
              .width('100%')
              ForEach(pkSortHot(PK_HOT), (h: PKHotItem, idx: number) => {
                Column() {
                  Row() {
                    Text('#' + (idx + 1).toString()).fontSize(14).fontWeight(FontWeight.Bold)
                      .fontColor(pkRankColor(idx)).width(30).textAlign(TextAlign.Center)
                    Column() {
                      Text(h.name).fontSize(13).fontColor('#E8E8E8')
                      Text(h.sales.toString() + ' 人入手').fontSize(10).fontColor('#8A8A8A').margin({ top: 2 })
                    }
                    .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
                    Column().width(40).height(4).backgroundColor(h.color).borderRadius(2)
                    Text('¥' + pkHotPrice(h.id).toString()).fontSize(12).fontColor('#FF6B00')
                      .margin({ left: 10 }).width(58).textAlign(TextAlign.End)
                  }
                  .width('100%').padding({ top: 9, bottom: 9 })
                  Divider().color('#2E2E2E').strokeWidth(0.5)
                }
                .width('100%')
              }, (h: PKHotItem) => h.id.toString())
            }
            .width('100%').backgroundColor('#242424').borderRadius(16).padding(14)
            .margin({ top: 12 })

            // 功能宫格
            Column() {
              Row() {
                Text('🗂️ 装备服务').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
                Column().layoutWeight(1)
                Text('8 项').fontSize(11).fontColor('#8A8A8A')
              }
              .width('100%')
              Column() {
                ForEach(PK_FUNCS, (f: PKGridFunc) => {
                  Column() {
                    Text(f.icon).fontSize(20)
                    Text(f.label).fontSize(10).fontColor('#D0D0D0').margin({ top: 6 })
                  }
                  .width('100%')
                  .padding({ top: 13, bottom: 13 })
                  .backgroundColor('#242424').borderRadius(12)
                  .border({ width: 1, color: '#2E2E2E' })
                }, (f: PKGridFunc) => f.label)
              }
            }
            .width('100%').backgroundColor('#242424').borderRadius(16).padding(14)
            .margin({ top: 12 })
          }
          .width('100%').padding(14)
        }
        .layoutWeight(1).width('100%').scrollBar(BarState.Off)
      }
      .width('100%').height('100%')
    }
    .width('100%').height('100%')
  }
}

// ============ 跑酷鞋页 ============
@Component
struct PKShoesPage {
  @State shoes: PKShoe[] = PK_SHOES
  @State showAddShoe: boolean = false
  @State formName: string = ''
  @State formGrip: number = 85
  @State formWeight: number = 280
  @State formPrice: string = '799'

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.62)')
      .onClick(onClose)
  }

  // 弹框1:新增装备(底部抽屉样式)
  @Builder addShoeModal() {
    Column() {
      this.modalOverlay(() => { this.showAddShoe = false })
      Column() {
        Row() {
          Text('🆕 新增装备').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#F0F0F0')
          Column().layoutWeight(1)
          Text('✕').fontSize(16).fontColor('#8A8A8A')
            .onClick(() => { this.showAddShoe = false })
        }
        .width('100%')
        Divider().color('#333333').margin({ top: 10, bottom: 4 })
        Text('装备名称').fontSize(11).fontColor('#8A8A8A').width('100%').margin({ top: 8 })
        TextInput({ placeholder: '输入跑酷鞋名称...' })
          .placeholderColor('#6E6E6E').fontSize(14).height(38)
          .backgroundColor('#1E1E1E').borderRadius(8).width('100%')
          .fontColor('#F0F0F0')
          .onChange((v: string) => { this.formName = v })
        Row() {
          Text('抓地力').fontSize(11).fontColor('#8A8A8A')
          Column().layoutWeight(1)
          Text(Math.round(this.formGrip).toString() + '/100').fontSize(11).fontColor('#1E90FF')
        }
        .width('100%').margin({ top: 10 })
        Slider({ value: this.formGrip, min: 0, max: 100, style: SliderStyle.OutSet })
          .width('100%').selectedColor('#1E90FF').trackColor('#3A3A3A')
          .onChange((v: number) => { this.formGrip = v })
        Row() {
          Text('重量').fontSize(11).fontColor('#8A8A8A')
          Column().layoutWeight(1)
          Text(Math.round(this.formWeight).toString() + ' g').fontSize(11).fontColor('#FF6B00')
        }
        .width('100%').margin({ top: 8 })
        Slider({ value: this.formWeight, min: 160, max: 420, style: SliderStyle.OutSet })
          .width('100%').selectedColor('#FF6B00').trackColor('#3A3A3A')
          .onChange((v: number) => { this.formWeight = v })
        Row() {
          Text('价格(¥)').fontSize(11).fontColor('#8A8A8A')
          Column().layoutWeight(1)
          TextInput({ placeholder: '输入价格...' })
            .placeholderColor('#6E6E6E').fontSize(14).height(34).width(120)
            .backgroundColor('#1E1E1E').borderRadius(8)
            .fontColor('#F0F0F0')
            .onChange((v: string) => { this.formPrice = v })
        }
        .width('100%').margin({ top: 10 })
        Row() {
          Text('取消').fontSize(14).fontColor('#B8B8B8')
            .backgroundColor('#2E2E2E').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .onClick(() => { this.showAddShoe = false })
          Text('添加装备').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#1E90FF').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .margin({ left: 14 })
            .onClick(() => { this.pkAddShoe() })
        }
        .width('100%').justifyContent(FlexAlign.Center).margin({ top: 14, bottom: 4 })
      }
      .width('100%')
      .backgroundColor('#262626')
      .borderRadius({ topLeft: 20, topRight: 20 })
      .padding({ left: 20, right: 20, top: 18, bottom: 18 })
      .position({ x: 0, y: '52%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  private pkAddShoe(): void {
    this.shoes = this.shoes.concat([new PKShoe(this.shoes.length + 1, this.formName === '' ? '新锐跑酷鞋' : this.formName, Math.round(this.formGrip), Math.round(this.formWeight), this.formPrice === '' ? 699 : Number(this.formPrice), '#1E90FF', '新到')])
    this.showAddShoe = false
    this.formName = ''
    this.formGrip = 85
  
  }
}


总结

在这里插入图片描述

基于HarmonyOS API 24 的城市跑酷装备馆应用,全面展示了鸿蒙声明式UI开发的技术魅力。从类型定义到状态管理,从动画系统到数据可视化,从弹框交互到页面布局,每一个技术点都有其独特的设计考量和实现方式。

通过深入解析这个应用,我们不仅掌握了ArkTS声明式UI的具体用法,更理解了背后的设计思想和技术原理。这些知识和经验,可以直接应用到实际的鸿蒙应用开发中,帮助我们构建更高质量、更优体验的应用程序。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐