基于HarmonyOS API 24与HarmonyOS 6.1.1的宠物烘焙工坊全栈实现:ArkTS声明式UI六Tab架构、粒子动画引擎与弹窗交互模式深度解析

一、技术背景与架构总览

1.1 HarmonyOS技术栈演进

在这里插入图片描述

HarmonyOS 6.1.1 作为鸿蒙操作系统的重要版本迭代,在声明式UI框架、ArkTS语言特性、组件化能力和性能优化等维度均实现了显著提升。基于HarmonyOS ArkTS API 24 的开发范式,开发者可以构建出兼具原生性能和跨设备一致性的分布式应用。ArkTS 作为 TypeScript 的超集,不仅保留了静态类型系统的安全性,还通过装饰器语法糖极大简化了UI组件的声明方式。

在传统的命令式UI开发模式中,开发者需要手动管理视图树的创建、更新和销毁,任何状态变更都需要触发手动的DOM操作。而基于HarmonyOS API 24 的声明式UI范式彻底改变了这一开发模型。开发者只需描述"UI在某种状态下应该是什么样子",框架会自动完成从状态到视图的映射与高效重渲染。这种范式的转变大幅降低了UI开发的认知负担,让开发者能够将更多精力聚焦于业务逻辑本身。

HarmonyOS 6.1.1 在渲染管线层面也进行了深度优化。通过引入懒加载机制、组件复用池和增量渲染算法,框架能够在复杂列表场景下保持60fps的流畅帧率。同时,ArkUI框架提供了丰富的内置组件库,涵盖基础组件、容器组件、媒体组件、绘制组件等多个类别,开发者可以通过链式调用的方式灵活配置组件的外观与行为。

1.2 声明式UI范式的核心机制

在这里插入图片描述

声明式UI的核心在于"状态驱动视图"这一基本理念。在基于HarmonyOS ArkTS API 24 的应用中,每一个被@State装饰器标记的状态变量,都会被框架纳入响应式系统的监控范围。当状态变量发生变更时,框架会自动标记依赖该状态的组件为"脏",并在下一帧渲染时完成增量更新。

这种响应式机制与Vue的响应式系统有异曲同工之妙,但在实现层面更贴近原生渲染。HarmonyOS的UI框架直接操作原生渲染树,而非通过虚拟DOM进行中间层映射,因此在性能上具备天然优势。@State装饰器仅适用于组件内部状态,若需跨组件共享状态,则可以使用@Provide/@Consume@Link或全局状态管理方案。

除了@State之外,基于HarmonyOS API 24 的开发框架还提供了多种状态管理装饰器。@Prop用于父子组件间的单向数据传递,@Link用于双向数据绑定,@StorageLink用于持久化存储的响应式绑定,@Watch则用于监听状态变化并触发回调。这些装饰器共同构成了一个完整的状态管理体系,满足了不同层级、不同粒度的状态管理需求。

1.3 应用业务背景

在这里插入图片描述

本文所分析的应用是一款面向宠物主人的烘焙电商平台,名为"宠物烘焙工坊"(PET BAKERY)。应用提供宠物蛋糕、零食、营养品和烘焙工具的在线浏览与选购服务,同时支持定制蛋糕、烘烤预约等增值功能。应用整体采用暖色调设计,以橙黄色为主色调,营造出温馨、甜美的烘焙氛围。

应用采用六Tab底部导航架构,分别为首页、蛋糕、零食、营养、工具和我的。每个Tab对应一个独立的功能模块,模块间通过底部导航栏进行切换。首页集成了烤箱动画、招牌蛋糕横滑、热销榜和功能宫格等核心入口;蛋糕Tab提供口味筛选和双列商品展示;零食Tab侧重排行榜和营养成分对比;营养Tab以色卡形式展示营养品;工具Tab提供烘焙设备的参数配置;我的Tab则聚合了会员信息、订单、收藏和消费统计。

在视觉特效层面,应用实现了面粉飘落、香气上升、烤箱微光脉动和会员卡光泽扫过等多种动画效果。这些特效并非使用Canvas或WebGL实现,而是完全基于ArkUI的声明式组件和setInterval定时器构建,展现了声明式UI框架在动画表现力上的巨大潜力。

1.4 整体架构层次图

在这里插入图片描述

应用入口 PetBakeryApp

头部导航栏 Header

内容区域 Stack

底部Tab栏 TabBar

首页 Tab

蛋糕 Tab

零食 Tab

营养 Tab

工具 Tab

我的 Tab

烤箱温度仪表盘

招牌蛋糕横滑

热销榜TOP5

烘焙服务宫格

定制蛋糕弹窗

面粉飘落特效

香气上升特效

口味筛选入口

双列蛋糕Grid

克重对比条

口味销量柱状图

口味选择弹窗

零食热销榜

热量对比条

肉类原料分布

营养品色卡Grid

营养占比条

成分分布明细

营养详情弹窗

烘焙工具参数表

功率对比条

参数编辑弹窗

会员卡光泽动画

月消费柱状图

订单列表

收藏列表

删除确认弹窗

数据层 Data Layer

主题层 Theme Layer

工具函数层 Utils

二、类型系统与数据模型深度解析

在这里插入图片描述

2.1 枚举类型定义

在应用的最开始,定义了一个名为PBTab的枚举类型,用于标识六个不同的Tab页面。枚举类型在TypeScript/ArkTS中是一种特殊的数据类型,它允许用符号名称来表示一组常量值,从而提高代码的可读性和可维护性。

enum PBTab { Home, Cake, Snack, Nutri, Tool, Mine }

这行代码定义了六个枚举成员,默认情况下它们的数值分别为0到5。使用枚举而非字符串字面量来表示Tab状态有几个显著优势。首先,枚举成员在IDE中可以获得自动补全支持,减少拼写错误的可能。其次,枚举值在比较时使用严格相等,性能更优。最后,通过枚举名称可以清晰地表达语义,避免魔法字符串散落在代码各处。

在主组件中,activeTab状态变量的类型被声明为PBTab,初始值为PBTab.Home。这种类型注解确保了只有合法的Tab值才能被赋值给该变量,在编译期就能捕获错误,而非等到运行时才发现问题。

2.2 颜色调色板接口

在这里插入图片描述

PBColorPalette接口定义了应用中使用的所有颜色变量。这种将颜色集中管理的做法是一种优秀的设计模式,被称为"设计令牌"(Design Tokens)。

interface PBColorPalette {
  bg: string
  card: string
  soft: string
  primary: string
  accent: string
  text: string
  sub: string
  line: string
  danger: string
  green: string
}

该接口包含10个颜色属性,每个属性都有明确的语义含义。bg代表背景色,card代表卡片底色,soft代表柔和的辅助色,primary代表主色调,accent代表强调色,text代表主文字色,sub代表次要文字色,line代表分割线色,danger代表警示/危险色,green代表成功/正向色。

通过接口定义颜色调色板,开发者可以获得类型安全保障。如果在使用时拼写错误或引用了不存在的颜色属性,TypeScript编译器会立即报错。同时,集中管理颜色也使得主题切换变得容易,只需替换颜色常量对象即可实现整体视觉风格的改变。

2.3 核心业务实体接口

在这里插入图片描述

应用定义了多个业务实体接口,分别对应不同的数据模型。PBCake接口描述蛋糕商品的属性,包括ID、名称、口味、克重、价格、销量、标签和颜色值。

interface PBCake {
  id: number
  name: string
  flavor: string
  weight: number
  price: number
  sales: number
  tag: string
  colorHex: string
}

每个字段都有明确的类型注解。id为数字类型,作为唯一标识符;name为字符串类型,存储商品名称;flavor为字符串类型,表示口味分类;weight为数字类型,单位为克;price为数字类型,表示售价;sales为数字类型,表示月销量;tag为字符串类型,是商品的营销标签;colorHex为字符串类型,存储十六进制颜色值,用于商品卡片的渐变色。

PBSnack接口用于描述零食商品。与蛋糕接口不同,零食接口包含meat(主料)、meatType(肉类分类)、calories(热量)和protein(蛋白质含量)等字段。这些字段反映了零食类商品的核心卖点——原料成分和营养价值。

interface PBSnack {
  id: number
  name: string
  meat: string
  meatType: string
  calories: number
  protein: number
  price: number
  sales: number
}

meatType字段的值包括"禽鱼类"、“红肉类”、"果蔬类"和"乳品类"四个分类。这种分类方式不仅用于展示,还在肉类原料分布统计中发挥作用,通过按类别聚合销量数据来生成可视化图表。

PBNutri接口描述营养品数据模型。除了基本的名称、功能、颜色和价格字段外,还包含了详细的营养成分数据:蛋白质含量、脂肪含量、水分含量,以及建议喂量。这些详细数据在营养详情弹窗中以参数表的形式逐条展示。

interface PBNutri {
  id: number
  name: string
  func: string
  colorHex: string
  weight: number
  protein: number
  fat: number
  moisture: number
  dose: string
  price: number
}

PBTool接口描述烘焙工具的数据模型。工具的核心属性是功率、温度和定时,这三个参数直接关系到烘焙效果。use字段则用一句话描述工具的用途。

interface PBTool {
  id: number
  name: string
  power: number
  temp: number
  timer: number
  price: number
  use: string
}

值得注意的是,功率为0的工具(如硅胶蛋糕模具)属于手动工具,不具备电控功能。在工具参数表的渲染逻辑中,会对功率为0的情况做特殊处理,显示为"手动"而非具体的功率数值。

2.4 订单与收藏数据模型

PBOrder接口描述订单数据模型。每个订单包含蛋糕名称、宠物名称、克重、价格、状态和日期。statusHex字段存储订单状态对应的颜色值,用于状态标签的背景色渲染。

interface PBOrder {
  id: number
  cake: string
  pet: string
  weight: number
  price: number
  status: string
  date: string
  statusHex: string
}

订单状态共有四种:已完成、烘烤中、待付款和配送中。每种状态对应不同的颜色:绿色表示已完成,橙色表示烘烤中,黄色表示待付款,蓝色表示配送中。这种颜色编码方式使用户能够一眼识别订单状态,提升了信息获取效率。

PBFav接口描述收藏项数据模型。每个收藏项包含名称、类型、当前价、原价和标签。type字段标识收藏项的品类(蛋糕/零食/营养/工具),orig字段存储原价,用于与现价对比展示优惠幅度。

interface PBFav {
  id: number
  name: string
  type: string
  price: number
  orig: number
  tag: string
}

收藏列表支持删除操作,用户点击垃圾桶图标后会弹出确认对话框,确认后该收藏项将从列表中移除。这一交互模式遵循了"危险操作二次确认"的设计原则,有效防止了误操作。

2.5 辅助数据结构接口

除了核心业务实体外,应用还定义了多个辅助数据结构接口。PBSpend接口用于月度消费统计,包含月份和金额两个字段。PBFuncItem接口描述功能宫格项,包含图标和标签。PBTabItem接口描述底部Tab栏项,包含标签、图标和对应的枚举值。

PBFlavorStatPBMeatStat接口用于统计数据的封装。前者用于口味销量统计,后者用于肉类分类销量统计。这两个接口都是为了数据可视化而设计的中间数据结构,将原始业务数据经过聚合计算后,以更适合图表渲染的形式呈现。

PBPart接口描述营养成分的占比项,包含标签、比例和颜色值。该接口的数据用于渲染横向堆叠条形图,展示烘焙食品的营养构成。

PBParticle接口是粒子特效的数据结构,包含x坐标、基准位置、尺寸和不透明度四个属性。这个接口同时服务于面粉飘落和香气上升两种粒子效果,通过不同的基准位置和运动函数来区分不同的粒子行为。

2.6 数据流向图

组件层

工具函数

数据源

蛋糕数据 PB_CAKES

零食数据 PB_SNACKS

营养数据 PB_NUTRIS

工具数据 PB_TOOLS

订单数据 PB_ORDERS

收藏数据 PB_FAVS

消费数据 PB_SPENDS

pbHotCakes 热销排序

pbFilterCakes 口味筛选

pbFlavorStats 口味统计

pbSnackRank 零食排行

pbMeatStats 肉类统计

pbRemoveFav 删除收藏

pbCakeRows 双列分组

首页 Tab

蛋糕 Tab

零食 Tab

营养 Tab

工具 Tab

我的 Tab

三、主题系统与常量数据

3.1 颜色主题常量

PB_COLORS常量是应用的颜色主题核心,它实现了PBColorPalette接口并提供了具体的颜色值。整个调色板以暖色调为基调,从米白色背景到橙黄色强调色,营造出温馨甜美的烘焙氛围。

const PB_COLORS: PBColorPalette = {
  bg: '#FFFDF5',
  card: '#FFFFFF',
  soft: '#FFF6E0',
  primary: '#FFE8A3',
  accent: '#FF8A3D',
  text: '#4A3728',
  sub: '#9B8A75',
  line: '#F2E8D5',
  danger: '#E5484D',
  green: '#6FBF73'
}

背景色#FFFDF5是一种略带暖黄的米白色,比纯白色更柔和,长时间浏览也不易产生视觉疲劳。卡片底色使用纯白色,与背景色形成微妙的层次对比。柔和色#FFF6E0用于次要背景和交互区域的底色。

主色调#FFE8A3是一种明亮的淡黄色,常用于标题栏、表头和大面积装饰区域。强调色#FF8A3D是一种鲜明的橙橙色,是整个应用的视觉焦点色,用于按钮、价格、选中状态等需要突出的元素。

文字颜色采用深棕色#4A3728而非纯黑色,与暖色调主题更加协调,同时也减轻了视觉刺激。次要文字使用浅棕色#9B8A75,与主文字形成清晰的层级关系。

3.2 蛋糕商品数据

PB_CAKES数组包含10款蛋糕商品数据。每款蛋糕都有独特的口味、克重、价格和销量数据。这些数据虽然是静态的模拟数据,但在结构上完全符合真实电商应用的数据规范。

const PB_CAKES: PBCake[] = [
  { id: 1, name: '鸡肉慕斯蛋糕', flavor: '鸡肉', weight: 300, price: 88, sales: 1280, tag: '招牌热销', colorHex: '#FFD59E' },
  { id: 2, name: '牛肉芝士蛋糕', flavor: '牛肉', weight: 450, price: 108, sales: 980, tag: '高蛋白', colorHex: '#FFC6A0' },
  // ... 共10款
]

从数据中可以观察到一些有趣的业务规律。鸡肉慕斯蛋糕以1280的月销量位居榜首,是当之无愧的招牌产品。牛肉芝士蛋糕克重最大(450g)、价格较高(108元),但销量依然排名第二,说明高端产品同样有市场。三文鱼系列主打"美毛亮发"的功能诉求,瞄准注重宠物外观健康的用户群体。

口味分布方面,鸡肉和牛肉各有两款,三文鱼两款,红薯两款,南瓜一款,鸭肉一款。这种口味覆盖策略既保证了主流口味的充分供给,又通过低敏、助消化等差异化口味满足了细分需求。

3.3 零食商品数据

PB_SNACKS数组包含8款零食商品。零食的分类维度与蛋糕不同,主要按肉类类型划分:禽鱼类、红肉类、果蔬类和乳品类。

const PB_SNACKS: PBSnack[] = [
  { id: 1, name: '冻干鸡肉脆片', meat: '鸡肉', meatType: '禽鱼类', calories: 320, protein: 68, price: 35, sales: 2150 },
  { id: 2, name: '风干牛肉粒', meat: '牛肉', meatType: '红肉类', calories: 380, protein: 58, price: 45, sales: 1860 },
  // ... 共8款
]

冻干鸡肉脆片以2150的月销量遥遥领先,蛋白质含量高达68%,是高蛋白零食的代表。风干牛肉粒的热量最高(380 kcal),适合运动量较大的宠物。蜂蜜红薯条和南瓜芝麻圈属于果蔬类,热量低、纤维高,适合需要控制体重的宠物。

羊奶磨牙棒是唯一的乳品类零食,兼具补钙和磨牙双重功能。从定价来看,零食的价格区间在22元到48元之间,整体低于蛋糕产品,符合零食类商品的价位预期。

3.4 营养品与工具数据

PB_NUTRIS数组包含8款营养品,覆盖了美毛、补钙、肠胃调理、皮肤健康、肌肉增长、关节保健、免疫提升和全营养等多个功能方向。每款产品都有详细的营养成分数据,包括蛋白质、脂肪和水分含量。

const PB_NUTRIS: PBNutri[] = [
  { id: 1, name: '卵磷脂美毛粉', func: '亮泽被毛', colorHex: '#FFB74D', weight: 200, protein: 22, fat: 12, moisture: 8, dose: '每日 5g', price: 68 },
  // ... 共8款
]

营养品的颜色设计颇具匠心,每款产品都有专属的主题色。暖橙色对应美毛粉,棕色对应钙片,绿色对应益生菌,蓝色对应鱼油,红色对应蛋白粉,紫色对应关节素,黄色对应维生素,浅橙色对应羊奶粉。这种色彩编码方式不仅使界面更加生动,也帮助用户快速识别不同功能的产品。

PB_TOOLS数组包含6款烘焙工具,从大功率的智能烤箱到小型的硅胶模具,覆盖了宠物烘焙的全套设备需求。功率范围从0W(手动工具)到1500W,价格从39元到899元不等,形成了完整的产品梯度。

3.5 辅助数据集合

PB_ORDERS数组包含6条订单记录,覆盖了所有四种订单状态。每条订单都关联了具体的蛋糕和宠物名称,使得数据更具真实感。订单日期从8月12日到8月25日,时间跨度约两周。

PB_FAVS数组包含6条收藏记录,涵盖了蛋糕、零食、营养和工具四个品类。每条收藏都标注了原价和现价的对比,直观展示优惠力度。这种设计能够激发用户的购买欲望,是电商应用中常见的转化手段。

PB_SPENDS数组存储了近6个月的消费数据。3月到8月的消费金额分别为236元、415元、180元、520元、358元和466元。6月份的消费达到峰值,数据中的注释解释为"宠物生日季",这是一个很有趣的业务洞察——宠物生日月的烘焙消费会显著增加。

PB_FUNCS数组定义了8个功能入口,包括定制蛋糕、烘烤预约、生日派对、新品试吃、营养配餐、领券中心、收藏夹和在线客服。这些功能以宫格形式展示在首页,是用户快速访问核心服务的入口。

PB_TABS数组定义了底部导航栏的六个Tab项,每个项包含标签文字、图标和对应的枚举值。使用数组驱动Tab栏的渲染,而非硬编码每个Tab,使得Tab栏的配置更加灵活,增减Tab只需修改数组即可。

PB_NUTRI_PARTS数组定义了烘焙食品的标准营养构成:粗蛋白32%、粗脂肪18%、粗纤维9%、水分8%、灰分5%、其他28%。这些数据以堆叠条形图和明细列表两种形式展示,为用户提供专业的营养参考。

3.6 粒子特效数据

PB_FLOURS数组定义了12个面粉粒子,每个粒子有不同的x坐标、基准位置、尺寸和不透明度。这些参数的差异化设计使得面粉飘落效果更加自然,避免了整齐划一的机械感。

const PB_FLOURS: PBParticle[] = [
  { x: 18, base: 60, size: 7, op: 0.85 },
  { x: 62, base: 340, size: 5, op: 0.6 },
  { x: 110, base: 720, size: 8, op: 0.9 },
  // ... 共12个粒子
]

面粉粒子的尺寸在4到8像素之间,不透明度在0.45到0.9之间变化。基准位置的分布范围很广(从60到1180),确保了粒子在整个页面高度上都有分布,不会出现明显的空白区域。

PB_AROMAS数组定义了6个香气粒子。与面粉粒子向下飘落不同,香气粒子是向上飘动的,模拟了烤箱散发出的香气袅袅上升的效果。香气粒子的尺寸相对较小(4-6像素),不透明度也较低(0.5-0.8),营造出轻盈缥缈的视觉感受。

四、工具函数层详解

4.1 蛋糕数据处理函数

pbHotCakes函数用于获取按销量排序的蛋糕列表。它首先通过slice(0)创建原数组的浅拷贝,然后使用sort方法按销量降序排列,最后返回排序后的新数组。

function pbHotCakes(): PBCake[] {
  let r: PBCake[] = PB_CAKES.slice(0)
  r.sort((a: PBCake, b: PBCake) => b.sales - a.sales)
  return r
}

这里有一个值得注意的设计细节:函数没有直接对PB_CAKES数组进行排序,而是先创建了一个副本。这是因为sort方法会修改原数组,如果直接对常量数组排序,会导致原始数据顺序被破坏。通过slice(0)创建副本的方式,既保证了排序功能的正确性,又保护了原始数据的完整性。

pbFilterCakes函数根据口味筛选蛋糕。它接收一个口味分类字符串作为参数,遍历所有蛋糕,将口味匹配的蛋糕收集到结果数组中。如果参数为"全部",则返回所有蛋糕。

function pbFilterCakes(cat: string): PBCake[] {
  let r: PBCake[] = []
  for (let i = 0; i < PB_CAKES.length; i++) {
    if (cat === '全部' || PB_CAKES[i].flavor === cat) {
      r.push(PB_CAKES[i])
    }
  }
  return r
}

该函数使用传统的for循环而非数组的filter方法,这是一个有趣的选择。在性能敏感的场景下,for循环通常比高阶函数更快,因为避免了函数调用的开销。虽然在这个数据量下(只有10款蛋糕)性能差异可以忽略不计,但这种写法体现了对执行效率的关注。

pbTopCakes函数返回销量排名前5的蛋糕。它复用了pbHotCakes函数的排序结果,然后通过slice(0, 5)截取前5项。这种函数组合的方式避免了代码重复,提高了可维护性。

4.2 数据分组与统计函数

pbCakeRows函数将一维蛋糕数组转换为二维数组,每两个蛋糕为一行。这个函数是为双列网格布局服务的,将数据按行分组后,渲染逻辑可以使用嵌套的ForEach来实现双列布局。

function pbCakeRows(cakes: PBCake[]): PBCake[][] {
  let r: PBCake[][] = []
  for (let i = 0; i < cakes.length; i += 2) {
    if (i + 1 < cakes.length) {
      r.push([cakes[i], cakes[i + 1]])
    } else {
      r.push([cakes[i]])
    }
  }
  return r
}

函数的循环步长为2,每次处理两个元素。当数组长度为奇数时,最后一行只有一个元素。这种边界处理确保了数据完整性,不会因为数组长度不是偶数而丢失最后一项。

pbFlavorStats函数统计各口味的总销量。它首先初始化一个包含所有口味的统计数组,每个口味的初始销量为0。然后遍历所有蛋糕,将每款蛋糕的销量累加到对应口味的统计项中。最后按销量降序排序,返回排序后的统计结果。

function pbFlavorStats(): PBFlavorStat[] {
  let r: PBFlavorStat[] = [
    { flavor: '鸡肉', total: 0 },
    { flavor: '牛肉', total: 0 },
    // ... 6种口味
  ]
  for (let i = 0; i < PB_CAKES.length; i++) {
    for (let j = 0; j < r.length; j++) {
      if (r[j].flavor === PB_CAKES[i].flavor) {
        r[j].total = r[j].total + PB_CAKES[i].sales
      }
    }
  }
  r.sort((a: PBFlavorStat, b: PBFlavorStat) => b.total - a.total)
  return r
}

该算法的时间复杂度为O(n*m),其中n是蛋糕数量,m是口味种类数。对于小规模数据来说,这种双重循环的实现方式是完全可以接受的。如果数据量增大,可以考虑使用Map或对象来优化查找效率。

pbFlavorMax函数返回销量最高的蛋糕的销量值。这个值被用作柱状图的最大值参考,用于计算每个口味柱子的高度比例。

pbFlavorH函数计算口味柱子的高度。它接收销量值作为参数,将其映射到0-96像素的高度范围内。计算公式为Math.round(t * 96 / pbFlavorMax()),即销量占最大销量的比例乘以最大高度96像素。

4.3 百分比计算函数

应用中定义了多个百分比计算函数,分别用于不同的场景。pbWeightPct计算克重占比(基准500g),pbSalesPct计算销量占比(基准1280),pbCalPct计算热量占比(基准400kcal),pbPowerPct计算功率占比(基准1500W)。

这些函数的模式非常相似:都是将输入值除以基准值再乘以100,然后四舍五入取整。虽然存在代码重复,但为每个场景单独命名函数可以提高代码的可读性和语义清晰度。调用pbWeightPct(300)比调用pbPct(300, 500)更能清晰地表达"计算300克占500克的百分比"这一含义。

pbPct函数是一个通用的百分比计算函数,接收值和总数两个参数。它被用于肉类分布统计等需要动态计算百分比的场景。

4.4 零食与营养数据处理

pbSnackRank函数返回按销量排序的零食列表。实现方式与pbHotCakes类似,都是先创建副本再排序。这种模式在整个应用中被反复使用,体现了数据处理的一致性。

pbMeatStats函数统计各类肉类的销量总和。它初始化四个肉类分类(禽鱼类、红肉类、果蔬类、乳品类),然后遍历所有零食,按肉类类型累加销量。与口味统计不同的是,肉类统计的结果不需要排序,而是按照预设的分类顺序展示。

pbMeatTotal函数计算所有零食的总销量。这个值被用作肉类分布条形图的100%基准,用于计算各分类的宽度百分比。

pbNutriRows函数将营养品数组按每3个一组进行分组。与蛋糕的双列分组类似,这个函数是为三列网格布局服务的。当最后一行不足3个时,函数会正确处理1个或2个元素的边界情况。

4.5 收藏与通用工具函数

pbRemoveFav函数从收藏列表中移除指定ID的收藏项。它遍历原数组,将ID不匹配的项收集到新数组中,从而实现删除效果。

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

该函数采用函数式的不可变数据处理方式——不直接修改原数组,而是返回一个新数组。这种做法与声明式UI的状态更新模式高度契合:通过替换整个状态值来触发视图更新,而非在原状态上进行修改。

pbCakeName函数根据蛋糕ID查找蛋糕名称。它遍历蛋糕数组,找到ID匹配的蛋糕后返回其名称。如果没有找到匹配项,返回空字符串。这是一个典型的按ID查找函数,在定制蛋糕的成功提示中被用来展示所选蛋糕的名称。

pbFuncRows函数将功能宫格项按每4个一组进行分组。8个功能项被分为两行,每行4个。这种4列宫格的布局在移动端应用中非常常见,是一种高效利用屏幕空间的导航方式。

4.6 粒子动画计算函数

pbFallY函数计算面粉粒子的下落位置。它接收基准位置和偏移量两个参数,通过取模运算实现循环下落效果。当粒子下落到页面底部后,会从顶部重新开始下落,形成无限循环的动画。

function pbFallY(base: number, offset: number): number {
  let v = (base + offset) % 1300
  if (v < 0) {
    v = v + 1300
  }
  return v - 80
}

循环周期为1300像素,这个值略大于页面内容的总高度,确保了粒子在视觉上能够完整地"穿过"整个页面。v - 80的偏移处理使得粒子的起始位置在页面顶部以上80像素处,这样粒子进入视野时是从顶部缓缓飘落,而非突然出现。

pbRiseY函数计算香气粒子的上升位置。与面粉粒子的下落方向相反,香气粒子是向上飘动的。循环周期为240像素,比面粉粒子的周期小得多,这是因为香气效果只出现在烤箱附近的局部区域,不需要覆盖整个页面高度。

function pbRiseY(base: number, offset: number): number {
  let v = (base + offset) % 240
  if (v < 0) {
    v = v + 240
  }
  return 170 - v
}

170 - v的计算方式使得粒子从y坐标170的位置开始向上移动,当v增加到240时,粒子到达y坐标-70的位置(即消失在烤箱上方),然后重新从底部开始上升。

pbGlow函数计算烤箱的微光脉动强度。它使用正弦函数Math.sin(phase)来生成平滑的周期性变化,返回值在0.4到0.7之间波动。这种基于三角函数的动画曲线能够产生自然的呼吸灯效果,比线性变化更加柔和美观。

function pbGlow(phase: number): number {
  return 0.4 + 0.3 * Math.sin(phase)
}

相位值phase随时间递增,正弦函数的值在-1到1之间周期性变化。乘以0.3后振幅为0.3,加上基准值0.4,最终的不透明度就在0.1到0.7之间波动。实际上由于0.4是基准值,0.3是振幅,所以范围是0.1到0.7。乘以0.4之后的最终效果会更加微妙。

五、应用入口组件解析

5.1 组件结构概览

PetBakeryApp是应用的根组件,使用@Entry@Component装饰器标记。@Entry表示这是页面的入口组件,一个页面有且仅有一个入口组件。@Component表示这是一个自定义组件,可以在其他组件中被引用。

@Entry
@Component
struct PetBakeryApp {
  @State activeTab: PBTab = PBTab.Home
  // ...
}

组件只有一个状态变量activeTab,类型为PBTab枚举,初始值为PBTab.Home。这个状态变量控制着当前显示哪个Tab页面,是整个应用导航系统的核心状态。

组件的布局采用经典的三段式结构:头部导航栏、内容区域和底部Tab栏。外层使用Column容器将三个部分垂直排列。头部和底部高度固定,内容区域使用layoutWeight(1)占据剩余空间。

5.2 头部导航栏

头部导航栏使用Row容器水平排列,包含Logo、搜索框和两个功能图标。Logo部分由一个爪印emoji和两行文字组成,主标题为"宠物烘焙工坊",副标题为"PET BAKERY · 爱宠甜品站"。

Row({ space: 10 }) {
  Text('🐾').fontSize(24)
  Column({ space: 1 }) {
    Text('宠物烘焙工坊').fontSize(16).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text)
    Text('PET BAKERY · 爱宠甜品站').fontSize(9).fontColor(PB_COLORS.sub)
  }.alignItems(HorizontalAlign.Start)
  // 搜索框
  Row({ space: 6 }) {
    Text('🔍 搜索蛋糕 / 零食 / 营养品').fontSize(11).fontColor(PB_COLORS.sub)
  }
  .layoutWeight(1)
  .height(34)
  .borderRadius(17)
  .backgroundColor(PB_COLORS.soft)
  .justifyContent(FlexAlign.Center)
  Text('🔔').fontSize(19)
  Text('🛒').fontSize(19)
}

搜索框占据了头部的主要空间,通过layoutWeight(1)实现弹性伸缩。搜索框的高度为34像素,圆角半径为17像素(即高度的一半),形成胶囊形状。背景色使用柔和色PB_COLORS.soft,与整体设计风格保持一致。

搜索框右侧是通知和购物车两个图标按钮,使用emoji字符实现。这种做法简单直接,无需引入图标库即可获得不错的视觉效果。在生产环境中,可能会替换为矢量图标以获得更好的清晰度和一致性。

5.3 内容区域

内容区域使用Stack容器作为根容器,内部通过条件渲染显示当前激活的Tab组件。Stack容器的特点是子组件沿z轴堆叠,后添加的组件覆盖在先添加的组件之上。在这里,由于每次只有一个Tab组件被渲染,Stack的作用更像是一个简单的容器。

Stack() {
  if (this.activeTab === PBTab.Home) {
    PBHomeTab()
  } else if (this.activeTab === PBTab.Cake) {
    PBCakeTab()
  } else if (this.activeTab === PBTab.Snack) {
    PBSnackTab()
  } else if (this.activeTab === PBTab.Nutri) {
    PBNutriTab()
  } else if (this.activeTab === PBTab.Tool) {
    PBToolTab()
  } else {
    PBMineTab()
  }
}

使用条件渲染而非Tabs组件来实现Tab切换,有其独特的优势和劣势。优势在于灵活性高,可以完全自定义Tab栏的样式和切换动画。劣势在于需要手动管理组件的创建和销毁,且无法利用框架内置的Tab切换优化。

每个Tab组件都是独立的自定义组件,拥有自己的状态和生命周期。当切换到新的Tab时,旧的Tab组件会被销毁,新的Tab组件会被创建。这意味着每次切换Tab都会触发组件的aboutToAppearaboutToDisappear生命周期回调。

5.4 底部Tab栏

底部Tab栏使用Row容器水平排列,通过ForEach遍历PB_TABS数组生成每个Tab项。这种数据驱动的渲染方式使得Tab栏的配置更加灵活。

Row() {
  ForEach(PB_TABS, (t: PBTabItem) => {
    Column({ space: 3 }) {
      Text(t.icon).fontSize(this.activeTab === t.tab ? 22 : 19)
      Text(t.label)
        .fontSize(10)
        .fontWeight(this.activeTab === t.tab ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.activeTab === t.tab ? PB_COLORS.accent : PB_COLORS.sub)
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.activeTab = t.tab
    })
  }, (t: PBTabItem) => t.label)
}

每个Tab项使用Column垂直排列图标和文字。layoutWeight(1)确保所有Tab项平均分配宽度。选中状态和未选中状态通过三元表达式来区分:选中时图标更大、文字加粗、颜色为强调色。

点击事件处理函数非常简洁,只需将activeTab设置为对应的Tab枚举值即可。由于activeTab@State状态变量,赋值操作会触发框架的响应式更新机制,自动重新渲染内容区域和Tab栏的选中状态。

ForEach的第二个参数是键生成函数,返回t.label作为每项的唯一标识。在数据稳定的情况下,使用标签作为键是可行的。但如果标签可能重复或动态变化,更好的做法是使用ID等唯一标识符。

5.5 页面导航流程图

点击营养Tab

点击工具Tab

点击我的Tab

点击零食Tab

点击工具Tab

点击我的Tab

点击零食Tab

点击营养Tab

点击我的Tab

点击零食Tab

点击营养Tab

点击工具Tab

应用启动

点击蛋糕Tab

点击零食Tab

点击营养Tab

点击工具Tab

点击我的Tab

点击首页Tab

点击零食Tab

点击营养Tab

点击工具Tab

点击我的Tab

点击首页Tab

点击蛋糕Tab

点击首页Tab

点击蛋糕Tab

点击首页Tab

点击蛋糕Tab

点击首页Tab

点击蛋糕Tab

Home

烤箱温度仪表盘

招牌蛋糕横滑

热销榜TOP5

烘焙服务宫格

Cake

口味筛选

双列蛋糕Grid

克重对比条

口味柱状图

Snack

Nutri

Tool

Mine

六、首页Tab深度解析

6.1 组件状态与生命周期

PBHomeTab是首页Tab对应的组件。它包含了丰富的状态变量,用于控制动画、弹窗和表单数据。

@Component
struct PBHomeTab {
  @State pbFlourY: number = 0
  @State pbAromaY: number = 0
  @State pbPhase: number = 0
  @State pbOvenTemp: number = 25
  @State pbOvenDir: boolean = true
  @State showCustom: boolean = false
  @State customOk: boolean = false
  @State inputPet: string = ''
  @State inputWeight: string = ''
  @State selCakeId: number = 1
  pbFlourTimer: number = -1
  pbAromaTimer: number = -1
  pbOvenTimer: number = -1
  // ...
}

前5个状态变量与动画相关:pbFlourY控制面粉粒子的偏移量,pbAromaY控制香气粒子的偏移量,pbPhase控制烤箱微光的相位,pbOvenTemp控制烤箱温度,pbOvenDir控制温度变化方向(升温或降温)。

接下来的3个状态变量与弹窗和交互相关:showCustom控制定制蛋糕弹窗的显示与隐藏,customOk控制提交成功提示的显示,selCakeId记录用户选择的蛋糕ID。

表单输入相关的状态变量有两个:inputPet存储宠物昵称输入值,inputWeight存储定制克重输入值。

最后三个变量是定时器ID,类型为number,初始值为-1。这些变量不需要响应式更新,因此没有使用@State装饰器。它们在aboutToAppear中被赋值,在aboutToDisappear中被清除。

6.2 动画定时器的启动与清理

aboutToAppear是组件的生命周期回调,在组件即将出现时被调用。首页组件在这个回调中启动了三个定时器,分别驱动面粉飘落、香气上升和烤箱温度动画。

aboutToAppear() {
  this.pbFlourTimer = setInterval(() => {
    this.pbFlourY = this.pbFlourY - 9
  }, 50)
  this.pbAromaTimer = setInterval(() => {
    this.pbAromaY = this.pbAromaY + 6
  }, 60)
  this.pbOvenTimer = setInterval(() => {
    this.pbPhase = this.pbPhase + 0.18
    if (this.pbOvenDir) {
      this.pbOvenTemp = this.pbOvenTemp + 4
      if (this.pbOvenTemp >= 218) {
        this.pbOvenDir = false
      }
    } else {
      this.pbOvenTemp = this.pbOvenTemp - 4
      if (this.pbOvenTemp <= 25) {
        this.pbOvenDir = true
      }
    }
  }, 60)
}

面粉定时器每50毫秒触发一次,每次将pbFlourY减少9个像素。由于面粉是向下飘落的,而y坐标的正值方向是向下的,所以减少y值意味着粒子向上移动。但配合pbFallY函数的计算逻辑,最终呈现的效果是粒子向下飘落。

香气定时器每60毫秒触发一次,每次将pbAromaY增加6个像素。香气是向上飘动的,通过pbRiseY函数将偏移量转换为上升的位置。

烤箱定时器每60毫秒触发一次,每次将相位增加0.18弧度(约10.3度)。温度变化逻辑更复杂:当pbOvenDir为true时温度上升,每次增加4度,达到218度后方向反转;当方向为false时温度下降,每次减少4度,降到25度后方向再次反转。这样就形成了温度在25度到218度之间循环变化的效果。

aboutToDisappear生命周期回调在组件即将消失时被调用,用于清理定时器,防止内存泄漏。

aboutToDisappear() {
  clearInterval(this.pbFlourTimer)
  clearInterval(this.pbAromaTimer)
  clearInterval(this.pbOvenTimer)
}

及时清理定时器是非常重要的性能优化措施。如果组件销毁后定时器仍在运行,不仅会造成CPU资源的浪费,还可能因为访问已销毁组件的状态而引发错误。

6.3 定制蛋糕弹窗

modalOverlay是一个@Builder装饰的方法,用于构建定制蛋糕的弹窗内容。@Builder是ArkUI提供的一种特殊装饰器,它可以将UI代码封装为可复用的构建函数,在build方法中通过this.modalOverlay()的方式调用。

弹窗的整体结构是一个半透明遮罩层居中包裹一个白色内容卡片。遮罩层使用rgba(74,55,40,0.5)的半透明深棕色,既能够弱化背景内容,又与整体暖色调主题保持一致。

@Builder modalOverlay() {
  Column() {
    Column({ space: 14 }) {
      // 标题栏
      Row({ space: 10 }) {
        Column()
          .width(10)
          .height(24)
          .borderRadius(5)
          .backgroundColor(PB_COLORS.accent)
        Text('新增定制蛋糕').fontSize(17).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text)
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor(PB_COLORS.sub)
          .onClick(() => {
            this.showCustom = false
          })
      }.width('100%')
      // ... 表单项
    }
    .width('88%')
    .padding(20)
    .borderRadius(18)
    .backgroundColor(PB_COLORS.card)
    .constraintSize({ maxHeight: '80%' })
  }
  .width('100%')
  .height('100%')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .backgroundColor('rgba(74,55,40,0.5)')
  .justifyContent(FlexAlign.Center)
}

标题栏左侧有一个装饰性的橙色小柱子,这是一种常见的UI设计手法,通过小面积的强调色来增强视觉层次感。标题右侧是关闭按钮,点击后将showCustom设为false,弹窗随之消失。

弹窗包含三个表单项:宠物昵称、蛋糕款式和定制克重。宠物昵称使用普通文本输入框,占位提示给出了几个示例名称,帮助用户理解输入格式。

蛋糕款式使用横向滚动的选择列表,用户可以在10款蛋糕中滑动选择。选中的款式会以橙色背景白色文字高亮显示,未选中的则使用柔和背景深色文字。这种选中态的视觉反馈清晰明确。

定制克重使用数字输入框,通过type(InputType.Number)指定输入类型为数字,系统会自动弹出数字键盘。

底部是操作按钮区域,包含"取消"和"提交定制"两个按钮。取消按钮使用柔和背景,提交按钮使用橙色强调色背景。点击提交按钮后,弹窗关闭,同时显示提交成功提示,提示会在3.2秒后自动消失。

6.4 烤箱温度仪表盘

烤箱温度仪表盘是首页最具特色的视觉元素,集成了温度环、微光脉动、香气粒子和状态信息四种视觉效果。

Stack() {
  Circle({ width: 196, height: 196 })
    .fill(PB_COLORS.primary)
    .opacity(pbGlow(this.pbPhase) * 0.4)
  Progress({ value: this.pbOvenTemp, total: 250, type: ProgressType.Ring })
    .style({ strokeWidth: 13 })
    .width(158)
    .height(158)
    .color(PB_COLORS.accent)
  Column({ space: 2 }) {
    Text(this.pbOvenTemp + '℃')
      .fontSize(30)
      .fontWeight(FontWeight.Bold)
      .fontColor(PB_COLORS.text)
    Text(this.pbOvenDir ? '预热升温中' : '回温保温中')
      .fontSize(10)
      .fontColor(PB_COLORS.sub)
  }
  ForEach(PB_AROMAS, (a: PBParticle) => {
    Circle({ width: a.size, height: a.size })
      .fill('#FFFFFF')
      .opacity(a.op)
      .position({ x: a.x, y: pbRiseY(a.base, this.pbAromaY) })
  })
}

最底层是一个直径196像素的圆形,填充主色调淡黄色,不透明度随pbGlow函数的返回值变化,产生微光脉动效果。由于不透明度还要乘以0.4,实际的不透明度范围在0.16到0.28之间,效果非常柔和。

中间层是一个环形进度条,使用Progress组件的Ring类型。进度条的总范围是250度,当前值为烤箱温度。环形的线宽为13像素,颜色为强调色橙色。

最上层的中心位置显示温度数值和状态文字。温度数字使用30像素的粗体字,非常醒目。状态文字根据温度变化方向动态切换:升温时显示"预热升温中",降温时显示"回温保温中"。

香气粒子通过ForEach遍历PB_AROMAS数组生成,每个粒子是一个白色小圆点,位置通过pbRiseY函数计算,随时间向上飘动。这些粒子只分布在烤箱区域内,模拟了烤箱散发出的热气和香气。

6.5 招牌蛋糕横滑

招牌蛋糕横滑区域使用Scroll组件实现水平滚动。内部使用Row容器水平排列多个蛋糕卡片,每个卡片包含渐变背景、蛋糕图标、名称、口味克重和价格信息。

Scroll() {
  Row({ space: 12 }) {
    ForEach(pbHotCakes(), (c: PBCake) => {
      Column({ space: 7 }) {
        Column() {
          Text('🍰').fontSize(26)
        }
        .width(118)
        .height(74)
        .borderRadius(12)
        .justifyContent(FlexAlign.Center)
        .linearGradient({ angle: 135, colors: [[c.colorHex, 0], ['#FFFDF5', 1]] })
        Text(c.name).fontSize(12).fontWeight(FontWeight.Medium).fontColor(PB_COLORS.text).maxLines(1)
        Text(c.flavor + ' · ' + c.weight + 'g').fontSize(9).fontColor(PB_COLORS.sub)
        Row({ space: 6 }) {
          Text('¥' + c.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.accent)
          Text('¥' + Math.round(c.price * 1.25))
            .fontSize(9)
            .fontColor(PB_COLORS.sub)
            .decoration({ type: TextDecorationType.LineThrough })
        }
      }
      .width(118)
      .padding(10)
      .borderRadius(14)
      .backgroundColor(PB_COLORS.card)
      .alignItems(HorizontalAlign.Start)
    }, (c: PBCake) => c.id.toString())
  }
  .padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')

蛋糕卡片的上半部分是一个118x74像素的渐变背景区域,使用linearGradient创建从左上角到右下角的线性渐变。渐变的起始色是每款蛋糕的主题色,结束色是背景色米白色。每款蛋糕的主题色都不相同,使得卡片色彩丰富,视觉效果生动。

价格区域显示了两个价格:当前价格和原价。原价通过c.price * 1.25计算得出,即原价是现价的1.25倍。原价使用删除线样式,颜色为次要文字色,与现价形成对比,突出优惠力度。

6.6 热销榜TOP5

热销榜展示销量排名前5的蛋糕。每个排行项包含排名徽章、蛋糕信息和价格信息三部分。

ForEach(pbTopCakes(), (c: PBCake, idx: number) => {
  Row({ space: 10 }) {
    // 排名徽章
    Row() {
      Text(String(idx + 1))
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(idx < 3 ? Color.White : PB_COLORS.sub)
    }
    .width(24)
    .height(24)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(idx === 0 ? '#FF6B3D' : (idx === 1 ? '#FF8A3D' : (idx === 2 ? '#FFB13D' : PB_COLORS.soft)))
    // 蛋糕信息 + 销量条
    Column({ space: 4 }) {
      Text(c.name).fontSize(13).fontWeight(FontWeight.Medium).fontColor(PB_COLORS.text).maxLines(1)
      Row() {
        Row()
          .height(6)
          .borderRadius(3)
          .width(pbSalesPct(c.sales) + '%')
          .linearGradient({ angle: 90, colors: [[PB_COLORS.accent, 0], ['#FFC24D', 1]] })
      }
      .width('100%')
      .height(6)
      .borderRadius(3)
      .backgroundColor(PB_COLORS.soft)
    }.layoutWeight(1).alignItems(HorizontalAlign.Start)
    // 价格信息
    Column({ space: 2 }) {
      Text('¥' + c.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.accent)
      Text('月售 ' + c.sales).fontSize(9).fontColor(PB_COLORS.sub)
    }.alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(8)
  .borderRadius(12)
  .backgroundColor(PB_COLORS.soft)
}, (c: PBCake) => c.id.toString())

排名徽章是一个24x24像素的圆形。前三名的徽章使用不同深浅的橙黄色,且文字为白色;第四名及以后的徽章使用柔和色背景,文字为次要文字色。这种视觉区分方式突出了前三名的荣誉感,是排行榜设计的经典手法。

中间部分的销量进度条是一个很巧妙的设计。进度条的宽度与销量成正比,通过pbSalesPct函数计算百分比。进度条使用从橙色到黄色的渐变色,在柔和色的背景衬托下非常醒目。

右侧显示价格和月销量。价格使用强调色加粗显示,销量使用次要文字色显示。整个排行项的背景色为柔和色,与卡片的白色背景形成对比,营造出列表项的层次感。

6.7 烘焙服务宫格

烘焙服务宫格以4列网格布局展示8个功能入口。pbFuncRows函数将8个功能项分为两行,每行4个。

ForEach(pbFuncRows(), (row: PBFuncItem[]) => {
  Row() {
    ForEach(row, (f: PBFuncItem) => {
      Column({ space: 7 }) {
        Column() {
          Text(f.icon).fontSize(22)
        }
        .width(44)
        .height(44)
        .borderRadius(22)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(PB_COLORS.soft)
        Text(f.label).fontSize(10).fontColor(PB_COLORS.text)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        if (f.label === '定制蛋糕') {
          this.showCustom = true
        }
      })
    }, (f: PBFuncItem) => f.label)
  }.width('100%')
}, (row: PBFuncItem[]) => row[0].label)

每个功能项由一个圆形图标和下方的文字标签组成。图标区域是44x44像素的圆形,背景为柔和色,图标为22像素的emoji。文字标签使用10像素的主文字色。

点击事件处理函数中只有"定制蛋糕"功能实现了交互——点击后弹出定制蛋糕弹窗。其他功能目前只做了UI展示,没有绑定具体逻辑。这种部分功能先行实现的方式在迭代开发中很常见。

6.8 面粉飘落特效

面粉飘落特效通过遍历PB_FLOURS数组生成多个白色圆形粒子,每个粒子的y坐标由pbFallY函数计算得出。粒子设置了hitTestBehavior(HitTestMode.None),这意味着它们不会响应触摸事件,点击事件会穿透到下层的内容。

ForEach(PB_FLOURS, (f: PBParticle) => {
  Circle({ width: f.size, height: f.size })
    .fill('#FFFFFF')
    .opacity(f.op)
    .position({ x: f.x, y: pbFallY(f.base, this.pbFlourY) })
    .hitTestBehavior(HitTestMode.None)
})

这是一个非常重要的性能和交互优化。如果粒子组件能够响应点击事件,那么用户在点击页面内容时可能会误触到面粉粒子,导致预期的点击操作失效。通过设置HitTestMode.None,粒子完全不参与命中测试,确保了下层内容的正常交互。

面粉特效的实现原理是利用setInterval定时更新偏移量pbFlourY,每次更新后框架会自动重新计算每个粒子的位置,从而产生动画效果。虽然每一帧都需要重新计算和渲染12个粒子,但由于粒子数量较少且计算简单,性能开销可以忽略不计。

6.9 状态管理状态图

进入首页

点击定制蛋糕

点击取消 / 关闭

提交定制

3.2秒后消失

切换Tab / 离开页面

正常浏览

定时器驱动

面粉飘落 + 香气上升 + 温度脉动

定制弹窗

输入克重

点击提交定制

3.2秒后自动消失

编辑中

提交成功

成功提示

展示定制详情

七、蛋糕Tab深度解析

7.1 组件结构与状态

PBCakeTab组件是蛋糕Tab的实现。它包含两个状态变量:cat用于记录当前选中的口味分类,初始值为"全部";showFlavor用于控制口味选择弹窗的显示与隐藏。

@Component
struct PBCakeTab {
  @State cat: string = '全部'
  @State showFlavor: boolean = false
  // ...
}

组件的内容区域分为四个模块:口味筛选入口、双列蛋糕Grid、克重对比条和口味销量柱状图。每个模块都有独立的功能和视觉表现,共同构成了完整的蛋糕浏览体验。

7.2 口味筛选入口

口味筛选入口是一个可点击的行,点击后弹出口味选择弹窗。它展示了当前选中的口味和符合条件的蛋糕数量。

Row({ space: 10 }) {
  Column({ space: 3 }) {
    Text('口味筛选').fontSize(10).fontColor(PB_COLORS.sub)
    Text(this.cat === '全部' ? '全部口味' : this.cat).fontSize(16).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text)
  }.alignItems(HorizontalAlign.Start)
  Column().layoutWeight(1)
  Text('共 ' + pbFilterCakes(this.cat).length + ' 款').fontSize(11).fontColor(PB_COLORS.sub)
  Text('▾').fontSize(18).fontColor(PB_COLORS.accent)
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor(PB_COLORS.card)
.onClick(() => {
  this.showFlavor = true
})

左侧显示"口味筛选"标签和当前选中的口味名称。当选中"全部"时,显示"全部口味";选中具体口味时,显示口味名称。口味名称使用16像素的粗体字,比标签大且更醒目。

右侧显示符合条件的蛋糕数量和一个下拉箭头。数量通过pbFilterCakes(this.cat).length动态计算,即调用筛选函数后获取结果数组的长度。下拉箭头是一个向下的三角形字符,颜色为强调色,提示用户这是一个可展开的选择器。

整个区域是一个可点击的卡片,点击后将showFlavor设为true,弹出口味选择弹窗。

7.3 口味选择弹窗

口味选择弹窗采用宫格布局展示7个口味选项(全部、鸡肉、牛肉、三文鱼、红薯、南瓜、鸭肉)。选项排列为3行,前两行各3个,第三行1个加2个占位。

Row({ space: 10 }) {
  Row() {
    Text('全部')
      .fontSize(13)
      .fontColor(this.cat === '全部' ? Color.White : PB_COLORS.text)
  }
  .layoutWeight(1)
  .height(40)
  .borderRadius(12)
  .justifyContent(FlexAlign.Center)
  .backgroundColor(this.cat === '全部' ? PB_COLORS.accent : PB_COLORS.soft)
  .onClick(() => {
    this.cat = '全部'
  })
  // ... 其他口味
}

每个口味选项是一个高度40像素的圆角矩形。选中状态使用橙色背景白色文字,未选中状态使用柔和色背景深色文字。点击选项时,立即更新cat状态变量,选中样式随之改变。

与一般的弹窗选择器不同,这里的口味选择是即时生效的——用户点击某个口味后,筛选结果立即更新,不需要等待点击"确定"按钮。"确定"按钮的作用仅仅是关闭弹窗。这种设计提升了操作效率,用户可以快速尝试不同的筛选条件。

弹窗底部有一个醒目的"确定"按钮,宽度占满弹窗内容区,高度44像素,圆角22像素,背景为橙色强调色。点击确定按钮后关闭弹窗。

7.4 双列蛋糕Grid

双列蛋糕Grid是蛋糕Tab的核心内容区域。它使用嵌套的ForEach实现双列布局:外层遍历行,内层遍历每行中的蛋糕。

Column({ space: 12 }) {
  ForEach(pbCakeRows(pbFilterCakes(this.cat)), (row: PBCake[]) => {
    Row({ space: 12 }) {
      ForEach(row, (c: PBCake) => {
        Column({ space: 7 }) {
          // 蛋糕卡片内容
        }
        .layoutWeight(1)
        .padding(10)
        .borderRadius(14)
        .backgroundColor(PB_COLORS.card)
        .alignItems(HorizontalAlign.Start)
        .shadow({ radius: 6, color: 'rgba(74,55,40,0.06)', offsetY: 2 })
      }, (c: PBCake) => c.id.toString())
    }.width('100%')
  }, (row: PBCake[]) => row[0].id.toString())
}

数据来源是pbCakeRows(pbFilterCakes(this.cat)),即先按口味筛选,再按双列分组。这种函数链式调用的方式非常优雅,将数据处理逻辑与UI渲染逻辑完全分离。

每个蛋糕卡片的内容与首页横滑区域的卡片类似,但增加了标签展示。标签位于卡片底部,显示商品的营销标签(如"招牌热销"、"高蛋白"等),使用橙色文字和柔和色背景,是一种常见的标签样式。

卡片添加了淡淡的阴影效果,阴影半径6像素,颜色为半透明的深棕色,y方向偏移2像素。这种微妙的阴影为卡片增添了立体感,使其从背景中"浮"了起来。

7.5 克重对比条

克重对比条模块以横向条形图的形式直观展示了每款蛋糕的克重对比。满格为500克,条形的长度与克重成正比。

ForEach(PB_CAKES, (c: PBCake) => {
  Row({ space: 8 }) {
    Text(c.name).fontSize(10).fontColor(PB_COLORS.text).width(92).maxLines(1)
    Row() {
      Row()
        .height(8)
        .borderRadius(4)
        .width(pbWeightPct(c.weight) + '%')
        .linearGradient({ angle: 90, colors: [[PB_COLORS.accent, 0], ['#FFD59E', 1]] })
    }
    .layoutWeight(1)
    .height(8)
    .borderRadius(4)
    .backgroundColor(PB_COLORS.soft)
    Text(c.weight + 'g').fontSize(9).fontColor(PB_COLORS.sub).width(38)
  }
  .width('100%')
  .height(30)
  .padding({ left: 10, right: 10 })
}, (c: PBCake) => c.id.toString())

每行由三部分组成:蛋糕名称(固定宽度92像素)、进度条(弹性宽度)和克重数值(固定宽度38像素)。名称和数值的宽度固定,中间的进度条占据剩余空间。

进度条的实现是一个典型的"嵌套Row"技巧:外层Row作为背景轨道,内层Row作为进度指示。内层Row的宽度通过百分比设置,百分比值由pbWeightPct函数计算得出。进度条使用从橙色到浅橙色的渐变色,视觉效果更加丰富。

每行高度为30像素,左右内边距各10像素。这种紧凑的布局能够在有限的空间内展示更多数据项。

7.6 口味销量柱状图

口味销量柱状图模块以柱状图的形式展示了各口味的总销量对比。柱子从下往上排列,高度与销量成正比。

Row({ space: 8 }) {
  ForEach(pbFlavorStats(), (s: PBFlavorStat) => {
    Column({ space: 5 }) {
      Text(String(s.total)).fontSize(9).fontColor(PB_COLORS.sub)
      Column()
        .width(24)
        .height(pbFlavorH(s.total))
        .borderRadius(6)
        .linearGradient({ angle: 180, colors: [[PB_COLORS.accent, 0], [PB_COLORS.primary, 1]] })
      Text(s.flavor).fontSize(10).fontColor(PB_COLORS.text).maxLines(1)
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.End)
  }, (s: PBFlavorStat) => s.flavor)
}
.width('100%')
.height(150)
.alignItems(VerticalAlign.Bottom)

每个柱子是一个Column组件,从上到下依次为销量数值、柱状条和口味标签。柱状条的宽度固定为24像素,高度由pbFlavorH函数计算,最高为96像素。

柱状图的渐变方向是从上到下(角度180度),顶部为橙色,底部为淡黄色。这种渐变方向与柱状图"从底部生长"的视觉效果相呼应,底部颜色较深、顶部颜色较浅,符合视觉直觉。

整个图表区域高度为150像素,内容垂直底部对齐(alignItems(VerticalAlign.Bottom)),确保所有柱子的底部在同一水平线上。

7.7 渲染流程图

PBCakeTab build

cat状态变量

pbFilterCakes 筛选

pbCakeRows 双列分组

ForEach 渲染每行

ForEach 渲染每列蛋糕卡片

口味筛选入口渲染

显示当前口味

计算筛选结果数量

pbFlavorStats 口味统计

排序

pbFlavorH 计算柱高

柱状图渲染

PB_CAKES 原始数据

pbWeightPct 计算克重比

克重对比条渲染

点击筛选入口

showFlavor = true

显示口味选择弹窗

点击口味选项

更新 cat 状态

实时筛选刷新

点击确定

showFlavor = false

关闭弹窗

八、零食Tab深度解析

8.1 组件结构

PBSnackTab组件是零食Tab的实现。与其他Tab组件不同,它没有任何状态变量,是一个纯展示型组件。这是因为零食Tab的所有数据都是静态的,不需要用户交互来改变状态。

@Component
struct PBSnackTab {
  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 热销排行榜
        // 热量条
        // 肉类分布
      }
    }
  }
}

组件包含三个模块:零食热销榜、热量对比条和肉类原料分布。这三个模块从不同维度展示零食商品的信息,帮助用户全面了解产品特性。

8.2 零食热销榜

零食热销榜展示所有零食按月销量排序的结果。与首页的热销榜TOP5不同,这里展示了全部8款零食的完整排名。

ForEach(pbSnackRank(), (s: PBSnack, idx: number) => {
  Row({ space: 10 }) {
    // 排名徽章
    Row() {
      Text(String(idx + 1))
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(idx < 3 ? Color.White : PB_COLORS.sub)
    }
    .width(24)
    .height(24)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(idx === 0 ? '#FF6B3D' : (idx === 1 ? '#FF8A3D' : (idx === 2 ? '#FFB13D' : PB_COLORS.soft)))
    // 商品信息
    Column({ space: 4 }) {
      Row({ space: 6 }) {
        Text(s.name).fontSize(13).fontWeight(FontWeight.Medium).fontColor(PB_COLORS.text).maxLines(1)
        Text(s.meat)
          .fontSize(9)
          .fontColor(PB_COLORS.accent)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .borderRadius(8)
          .backgroundColor(PB_COLORS.soft)
      }
      Text('月售 ' + s.sales + ' · 蛋白质 ' + s.protein + '%').fontSize(10).fontColor(PB_COLORS.sub)
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
    // 价格与热量
    Column({ space: 2 }) {
      Text('¥' + s.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.accent)
      Text(s.calories + ' kcal').fontSize(9).fontColor(PB_COLORS.sub)
    }.alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(10)
  .borderRadius(12)
  .backgroundColor(idx % 2 === 0 ? PB_COLORS.soft : PB_COLORS.card)
}, (s: PBSnack) => s.id.toString())

排名徽章的设计与蛋糕热销榜类似,前三名使用橙黄色渐变的徽章样式。商品信息区域增加了主料标签,使用橙色文字和柔和背景的小标签形式展示,帮助用户快速识别产品的主要原料。

副标题显示月销量和蛋白质含量,用一个中间点分隔。蛋白质含量是零食类商品的重要指标,将其与销量并列展示,体现了产品的营养价值卖点。

右侧显示价格和热量。价格醒目,热量使用次要文字色。整个列表项使用斑马纹背景——偶数项使用柔和色,奇数项使用白色。斑马纹不仅增加了视觉节奏感,也提高了长列表的可读性,帮助用户追踪每一行。

8.3 热量对比条

热量对比条模块以横向条形图的形式展示每款零食的热量值对比。满格为400 kcal,条形长度与热量成正比。

ForEach(PB_SNACKS, (s: PBSnack) => {
  Column({ space: 5 }) {
    Row() {
      Text(s.name).fontSize(11).fontColor(PB_COLORS.text)
      Column().layoutWeight(1)
      Text(s.calories + ' kcal').fontSize(10).fontColor(PB_COLORS.sub)
    }.width('100%')
    Row() {
      Row()
        .height(9)
        .borderRadius(5)
        .width(pbCalPct(s.calories) + '%')
        .linearGradient({ angle: 90, colors: [['#FF6B3D', 0], ['#FFD59E', 1]] })
    }
    .width('100%')
    .height(9)
    .borderRadius(5)
    .backgroundColor(PB_COLORS.soft)
  }.width('100%')
}, (s: PBSnack) => s.id.toString())

与克重对比条不同,热量对比条的每行分为上下两部分:上方是名称和热量数值,下方是进度条。这种布局使得数值标签和进度条在纵向上对齐,阅读路径更加自然。

进度条使用从红色到浅橙色的渐变色,暗示热量的"热度"。进度条高度为9像素,比克重对比条的8像素略高,视觉上更加饱满。

8.4 肉类原料分布

肉类原料分布模块以堆叠条形图的形式展示各类肉类零食的销量占比。这是一种简单但直观的数据可视化方式,能够一眼看出各分类的比例关系。

Row() {
  ForEach(pbMeatStats(), (m: PBMeatStat) => {
    Column()
      .height(24)
      .width(pbPct(m.total, pbMeatTotal()) + '%')
      .backgroundColor(m.colorHex)
  }, (m: PBMeatStat) => m.type)
}
.width('100%')
.borderRadius(12)
.clip(true)

堆叠条形图的实现非常巧妙:使用一个Row容器,内部放置多个Column组件,每个Column的宽度通过百分比设置。所有Column的宽度加起来正好是100%,因此它们会紧密排列,形成一个完整的条形。

每个分类对应不同的颜色:禽鱼类为橙色、红肉类为红色、果蔬类为绿色、乳品类为黄色。这些颜色的选择具有一定的语义关联性——橙色对应禽类羽毛的颜色,红色对应红肉的颜色,绿色对应蔬菜水果的颜色,黄色对应乳制品的颜色。

条形图的外层设置了borderRadius(12)clip(true),使得堆叠条形的两端都是圆角。如果没有clip(true),内部的Column会超出圆角边界,破坏圆角效果。

条形图下方是图例区域,使用一行四列的布局展示每个分类的颜色方块、名称和百分比。底部还有一行说明文字,简要介绍了各类零食的营养价值。

九、营养Tab深度解析

9.1 组件状态与结构

PBNutriTab组件是营养Tab的实现。它包含两个状态变量:showDetail控制营养详情弹窗的显示与隐藏,selNutri存储当前选中的营养品数据。

@Component
struct PBNutriTab {
  @State showDetail: boolean = false
  @State selNutri: PBNutri | null = null
  // ...
}

selNutri的类型是PBNutri | null,这是一个联合类型,表示该变量可以是PBNutri对象,也可以是null。初始值为null,表示没有选中任何营养品。当用户点击某个营养品时,selNutri被赋值为对应的数据对象,同时弹窗显示。

组件包含三个模块:营养品色卡Grid、营养占比条和成分分布明细。其中色卡Grid是可交互的,点击后会弹出详情弹窗。

9.2 营养品色卡Grid

营养品色卡Grid使用三列布局展示8款营养品。每款营养品以一张色卡的形式呈现,包含渐变背景、图标、名称、功能和价格。

ForEach(pbNutriRows(), (row: PBNutri[]) => {
  Row({ space: 10 }) {
    ForEach(row, (n: PBNutri) => {
      Column({ space: 6 }) {
        Column() {
          Text('💊').fontSize(20)
        }
        .width('100%')
        .height(50)
        .borderRadius(10)
        .justifyContent(FlexAlign.Center)
        .linearGradient({ angle: 135, colors: [[n.colorHex, 0], ['#FFFFFF', 1]] })
        Text(n.name).fontSize(11).fontWeight(FontWeight.Medium).fontColor(PB_COLORS.text).maxLines(1).width('100%')
        Text(n.func).fontSize(9).fontColor(PB_COLORS.sub).maxLines(1).width('100%')
        Text('¥' + n.price).fontSize(11).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.accent)
      }
      .layoutWeight(1)
      .padding(9)
      .borderRadius(14)
      .backgroundColor(PB_COLORS.card)
      .alignItems(HorizontalAlign.Start)
      .onClick(() => {
        this.selNutri = n
        this.showDetail = true
      })
    }, (n: PBNutri) => n.id.toString())
  }.width('100%')
}, (row: PBNutri[]) => row[0].id.toString())

色卡的顶部是一个渐变色块,高度为50像素,使用从产品主题色到白色的135度线性渐变。色块中央是一个胶囊emoji图标,大小为20像素。

色块下方依次是产品名称、功能描述和价格。名称为11像素的中等字重,功能描述为9像素的次要文字色,价格为11像素的粗体强调色。价格信息位于最底部,视觉权重最高,符合电商产品卡片的设计惯例。

点击色卡时,将当前营养品对象赋值给selNutri,并将showDetail设为true。由于ArkTS的响应式机制,这两个状态变量的更新会立即触发详情弹窗的渲染。

9.3 营养详情弹窗

营养详情弹窗以参数表的形式展示营养品的详细信息。弹窗顶部是产品概要,中间是参数列表,底部是确认按钮。

@Builder modalOverlay() {
  Column() {
    Column() {
      // 产品概要
      Row({ space: 12 }) {
        Column()
          .width(46)
          .height(46)
          .borderRadius(12)
          .linearGradient({ angle: 135, colors: [[this.selNutri!.colorHex, 0], ['#FFFFFF', 1]] })
        Column({ space: 3 }) {
          Text(this.selNutri!.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text)
          Text(this.selNutri!.func + ' · 净含量 ' + this.selNutri!.weight + 'g')
            .fontSize(10)
            .fontColor(PB_COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(PB_COLORS.sub)
          .onClick(() => {
            this.showDetail = false
          })
      }
      .width('100%')
      .padding(16)
      // 参数列表
      Column() {
        Row() {
          Text('粗蛋白含量').fontSize(12).fontColor(PB_COLORS.sub)
          Column().layoutWeight(1)
          Text(this.selNutri!.protein + '%').fontSize(12).fontWeight(FontWeight.Medium).fontColor(PB_COLORS.text)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 11, bottom: 11 })
        .backgroundColor(PB_COLORS.card)
        // ... 更多参数项
      }
      .width('100%')
      // 确认按钮
      Text('知道了')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .width('100%')
        .height(44)
        .textAlign(TextAlign.Center)
        .borderRadius(22)
        .backgroundColor(PB_COLORS.accent)
        .margin({ top: 16, bottom: 18 })
        .onClick(() => {
          this.showDetail = false
        })
    }
    .width('88%')
    .borderRadius(18)
    .backgroundColor('#FFFDF5')
    .constraintSize({ maxHeight: '78%' })
  }
  // ... 遮罩层
}

注意代码中大量使用了this.selNutri!.xxx的语法。!.是非空断言操作符,告诉TypeScript编译器"我确定这个值不为null,请不要报错"。由于弹窗只在showDetail为true时显示,而此时selNutri一定已经被赋值,因此使用非空断言是安全的。

参数列表采用交替背景色的设计:奇数行使用白色背景,偶数行使用柔和色背景。这种斑马纹设计提高了参数表的可读性,使用户能够轻松追踪每一行。

参数项包括粗蛋白含量、粗脂肪含量、水分含量、建议喂量和参考价格。前四个是营养相关参数,最后一个是价格信息。参考价格使用强调色加粗显示,与其他参数形成视觉区分。

9.4 营养占比条

营养占比条模块以横向堆叠条形图的形式展示烘焙食品的标准营养构成。数据来源于PB_NUTRI_PARTS常量,包含粗蛋白、粗脂肪、粗纤维、水分、灰分和其他六个成分。

Row() {
  ForEach(PB_NUTRI_PARTS, (p: PBPart) => {
    Column()
      .height(26)
      .width(p.ratio + '%')
      .backgroundColor(p.colorHex)
  }, (p: PBPart) => p.label)
}
.width('100%')
.borderRadius(13)
.clip(true)

实现方式与肉类分布条形图类似,都是使用Row容器包裹多个不同宽度的Column组件。但这里的条形高度为26像素,比肉类分布的24像素略高,更加醒目。

条形图下方是图例区域,每个图例项包含一个颜色小方块和百分比数值。图例同样使用ForEach遍历生成,布局为一行六列,每列平均分配宽度。

9.5 成分分布明细

成分分布明细模块以列表形式详细展示每种营养成分的名称、比例和进度条。这是一种更加详细的数据展示方式,用户可以精确看到每种成分的具体百分比。

ForEach(PB_NUTRI_PARTS, (p: PBPart) => {
  Row({ space: 8 }) {
    Column().width(10).height(10).borderRadius(3).backgroundColor(p.colorHex)
    Text(p.label).fontSize(11).fontColor(PB_COLORS.text).width(52)
    Row() {
      Row()
        .height(8)
        .borderRadius(4)
        .width(p.ratio * 2 + '%')
        .backgroundColor(p.colorHex)
    }
    .layoutWeight(1)
    .height(8)
    .borderRadius(4)
    .backgroundColor(PB_COLORS.soft)
    Text(p.ratio + '%').fontSize(9).fontColor(PB_COLORS.sub).width(28)
  }.width('100%')
}, (p: PBPart) => p.label)

每行从左到右依次为:颜色标识方块(10x10像素)、成分名称(固定宽度52像素)、进度条(弹性宽度)、百分比数值(固定宽度28像素)。

进度条的宽度计算公式是p.ratio * 2 + '%',即将比例值乘以2作为百分比宽度。为什么乘以2呢?因为最大的比例值是32%(粗蛋白),如果直接使用比例值作为宽度,进度条会显得很短。乘以2之后,最大宽度为64%,视觉效果更加饱满。

列表底部有一行说明文字,提示用户以上数据为标准配方模型,实际以每批次检测报告为准。这种免责声明是食品相关应用中常见的合规设计。

9.6 组件依赖图

渲染错误: Mermaid 渲染失败: Parse error on line 8: ...2[selNutri: PBNutri | null] end -----------------------^ Expecting 'SQE', 'TAGEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PIPE'

十、工具Tab深度解析

10.1 组件状态与结构

PBToolTab组件是工具Tab的实现。它包含四个状态变量:showEdit控制参数编辑弹窗的显示与隐藏,editTemp记录编辑中的温度值,editTime记录编辑中的时间值,selTool存储当前选中的工具数据。

@Component
struct PBToolTab {
  @State showEdit: boolean = false
  @State editTemp: number = 180
  @State editTime: number = 35
  @State selTool: PBTool | null = null
  // ...
}

组件包含两个模块:烘焙工具参数表和功率对比条。参数表是可交互的,点击任意一行可以弹出编辑对话框,通过滑块调整温度和时间参数。

10.2 烘焙工具参数表

烘焙工具参数表以表格形式展示所有烘焙工具的参数。表格支持横向滚动,确保在窄屏设备上也能完整显示所有列。

Scroll() {
  Column() {
    // 表头
    Row() {
      Text('工具名称').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text).width(112)
      Text('功率').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text).width(58)
      Text('温度').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text).width(58)
      Text('定时').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text).width(58)
      Text('价格').fontSize(11).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text).width(58)
    }
    .width(370)
    .padding({ left: 14, right: 14, top: 9, bottom: 9 })
    .backgroundColor(PB_COLORS.primary)
    .borderRadius({ topLeft: 10, topRight: 10 })
    // 数据行
    ForEach(PB_TOOLS, (t: PBTool, idx: number) => {
      Row() {
        Text(t.name).fontSize(11).fontColor(PB_COLORS.text).width(112).maxLines(1)
        Text(t.power > 0 ? t.power + 'W' : '手动').fontSize(11).fontColor(PB_COLORS.sub).width(58)
        Text(t.temp > 0 ? t.temp + '℃' : '-').fontSize(11).fontColor(PB_COLORS.sub).width(58)
        Text(t.timer > 0 ? t.timer + 'm' : '-').fontSize(11).fontColor(PB_COLORS.sub).width(58)
        Text('¥' + t.price).fontSize(11).fontWeight(FontWeight.Medium).fontColor(PB_COLORS.accent).width(58)
      }
      .width(370)
      .padding({ left: 14, right: 14, top: 11, bottom: 11 })
      .backgroundColor(idx % 2 === 0 ? PB_COLORS.card : PB_COLORS.soft)
      .onClick(() => {
        this.selTool = t
        this.editTemp = t.temp > 0 ? t.temp : 180
        this.editTime = t.timer > 0 ? t.timer : 30
        this.showEdit = true
      })
    }, (t: PBTool) => t.id.toString())
  }.width(370)
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')

表格的总宽度固定为370像素,这个宽度超出了大多数手机屏幕的宽度,因此需要横向滚动。每列的宽度也是固定的:工具名称列112像素,功率、温度、定时、价格列各58像素。

表头使用主色调淡黄色背景,文字为粗体深棕色。表头只有顶部左右两个角有圆角,底部是直角,与下方的数据行紧密衔接。

数据行使用斑马纹背景,偶数行为白色,奇数行为柔和色。每行都可以点击,点击后将当前工具数据赋值给selTool,并将温度和时间初始值设为该工具的默认参数(如果默认值为0,则使用备用值180度和30分钟),然后弹出编辑弹窗。

对于功率、温度、定时为0的手动工具,显示内容会做特殊处理:功率显示为"手动",温度和定时显示为"-"。这种条件渲染增强了数据表达的准确性。

10.3 参数编辑弹窗

参数编辑弹窗使用两个滑块组件分别控制烘烤温度和烘烤时长。滑块是一种直观的交互控件,用户可以通过拖拽来快速调整数值。

@Builder modalOverlay() {
  Column() {
    Column({ space: 16 }) {
      // 标题
      Row({ space: 10 }) {
        Column()
          .width(10)
          .height(24)
          .borderRadius(5)
          .backgroundColor(PB_COLORS.accent)
        Text('编辑烘烤参数').fontSize(17).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.text)
        Column().layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(PB_COLORS.sub)
          .onClick(() => {
            this.showEdit = false
          })
      }.width('100%')
      // 工具信息
      Row({ space: 10 }) {
        Column()
          .width(34)
          .height(34)
          .borderRadius(10)
          .backgroundColor(PB_COLORS.soft)
          .justifyContent(FlexAlign.Center)
        Text(this.selTool!.name)
          .fontSize(13)
          .fontWeight(FontWeight.Medium)
          .fontColor(PB_COLORS.text)
          .maxLines(1)
          .layoutWeight(1)
      }.width('100%')
      Text(this.selTool!.use + ' · 原功率 ' + this.selTool!.power + 'W')
        .fontSize(10)
        .fontColor(PB_COLORS.sub)
        .width('100%')
      // 温度滑块
      Column({ space: 4 }) {
        Row() {
          Text('烘烤温度').fontSize(12).fontColor(PB_COLORS.sub)
          Column().layoutWeight(1)
          Text(this.editTemp + '℃').fontSize(13).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.accent)
        }.width('100%')
        Slider({ value: this.editTemp, min: 60, max: 250, step: 5, style: SliderStyle.OutSet })
          .blockColor(PB_COLORS.accent)
          .trackColor(PB_COLORS.line)
          .selectedColor(PB_COLORS.accent)
          .width('100%')
          .onChange((v: number, mode: SliderChangeMode) => {
            this.editTemp = v
          })
      }.width('100%')
      // 时间滑块
      Column({ space: 4 }) {
        Row() {
          Text('烘烤时长').fontSize(12).fontColor(PB_COLORS.sub)
          Column().layoutWeight(1)
          Text(this.editTime + ' min').fontSize(13).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.accent)
        }.width('100%')
        Slider({ value: this.editTime, min: 5, max: 120, step: 5, style: SliderStyle.OutSet })
          .blockColor(PB_COLORS.accent)
          .trackColor(PB_COLORS.line)
          .selectedColor(PB_COLORS.accent)
          .width('100%')
          .onChange((v: number, mode: SliderChangeMode) => {
            this.editTime = v
          })
      }.width('100%')
      // 保存按钮
      Text('保存参数')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .width('100%')
        .height(44)
        .textAlign(TextAlign.Center)
        .borderRadius(22)
        .backgroundColor(PB_COLORS.accent)
        .onClick(() => {
          this.showEdit = false
        })
    }
    // ...
  }
  // ...
}

Slider组件是基于HarmonyOS ArkTS API 24 提供的内置组件,支持多种配置选项。value属性设置当前值,minmax设置取值范围,step设置步长,style设置滑块样式(OutSet表示滑块按钮在轨道外侧)。

温度滑块的范围是60到250度,步长为5度。时间滑块的范围是5到120分钟,步长为5分钟。滑块的按钮颜色和选中轨道颜色都使用强调色橙色,未选中的轨道颜色使用分割线色。

onChange回调接收两个参数:新值v和变化模式mode。变化模式可以区分是点击、拖拽还是松手等不同的交互方式。在这个实现中,每次值变化都立即更新状态变量,实现实时预览效果。

10.4 功率对比条

功率对比条模块以横向条形图的形式展示各工具的功率对比。满格为1500W,即智能烘焙烤箱的功率。

ForEach(PB_TOOLS, (t: PBTool) => {
  Column({ space: 5 }) {
    Row() {
      Text(t.name).fontSize(11).fontColor(PB_COLORS.text)
      Column().layoutWeight(1)
      Text(t.power > 0 ? t.power + 'W' : '手动工具').fontSize(10).fontColor(PB_COLORS.sub)
    }.width('100%')
    Row() {
      Row()
        .height(9)
        .borderRadius(5)
        .width(pbPowerPct(t.power) + '%')
        .linearGradient({ angle: 90, colors: [[PB_COLORS.accent, 0], [PB_COLORS.primary, 1]] })
    }
    .width('100%')
    .height(9)
    .borderRadius(5)
    .backgroundColor(PB_COLORS.soft)
  }.width('100%')
}, (t: PBTool) => t.id.toString())

功率为0的手动工具,右侧数值显示为"手动工具"而非"0W",进度条宽度也为0(即不显示)。这种处理方式避免了出现"0W"这样可能引起用户困惑的表述。

进度条使用从橙色到淡黄色的渐变色,从左到右由深变浅。这种渐变效果比纯色进度条更具视觉吸引力,也符合整体的暖色调设计风格。

10.5 弹窗交互流程图

点击某一行

拖拽温度滑块

拖拽时间滑块

点击保存参数

点击关闭按钮

工具参数表展示

初始化编辑状态

showEdit = true

显示编辑弹窗

展示工具名称和用途

温度滑块显示当前温度

时间滑块显示当前时长

editTemp 实时更新

温度数值实时变化

editTime 实时更新

时间数值实时变化

showEdit = false

关闭弹窗

返回参数表

十一、我的Tab深度解析

11.1 组件状态与生命周期

PBMineTab组件是"我的"Tab的实现。它包含四个状态变量和一个定时器变量。

@Component
struct PBMineTab {
  @State pbShineX: number = -60
  @State favList: PBFav[] = PB_FAVS
  @State showDel: boolean = false
  @State selFavId: number = 1
  pbShineTimer: number = -1
  // ...
}

pbShineX控制会员卡光泽效果的x坐标偏移,初始值为-60,表示光泽条从会员卡左侧外开始。favList存储收藏列表数据,初始值为PB_FAVS常量数组。showDel控制删除确认弹窗的显示与隐藏。selFavId记录当前待删除的收藏项ID。

pbShineTimer是光泽动画的定时器ID,在aboutToAppear中启动,在aboutToDisappear中清除。

11.2 会员卡光泽动画

会员卡是"我的"页面最醒目的视觉元素。卡片使用从橙色到黄色的渐变色背景,上面展示会员等级、会员卡号、积分、优惠券和已省金额等信息。

Stack() {
  Column({ space: 12 }) {
    Row({ space: 10 }) {
      Text('🐾').fontSize(24)
      Column({ space: 2 }) {
        Text('金牌会员 · PET PLUS').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text('PET BAKERY 会员卡 NO.0826').fontSize(9).fontColor('rgba(255,255,255,0.85)')
      }.alignItems(HorizontalAlign.Start)
      Column().layoutWeight(1)
      Text('🍞').fontSize(20)
    }.width('100%')
    Row() {
      Text('积分 1280').fontSize(11).fontColor('#FFFFFF')
      Text('生日 9 折券 ×2').fontSize(11).fontColor('rgba(255,255,255,0.92)')
      Text('本月已省 ¥86').fontSize(11).fontColor('rgba(255,255,255,0.92)')
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
  }
  .padding(18)
  .borderRadius(18)
  .width('100%')
  .height(118)
  .justifyContent(FlexAlign.Center)
  .linearGradient({ angle: 135, colors: [[PB_COLORS.accent, 0], ['#FFB13D', 0.55], [PB_COLORS.primary, 1]] })

  Column()
    .width(42)
    .height(230)
    .borderRadius(21)
    .backgroundColor('rgba(255,255,255,0.38)')
    .rotate({ angle: 22 })
    .position({ x: this.pbShineX, y: -56 })
}
.width('100%')
.height(118)
.clip(true)

会员卡使用Stack容器实现两层结构:底层是卡片内容,上层是光泽条。卡片内容使用135度角的三色渐变,从左上方的橙色过渡到中间的金黄色,再到右下方的淡黄色。

光泽条是一个42像素宽、230像素高的半透明白色矩形,旋转22度后放置在卡片上。通过不断改变光泽条的x坐标,产生光泽从左向右扫过的动画效果。

外层容器设置了clip(true),确保光泽条在移动过程中不会超出卡片边界。光泽条的高度远大于卡片高度(230像素 vs 118像素),这是因为旋转后需要足够的长度才能完全覆盖卡片的对角线方向。

光泽动画的定时器每32毫秒触发一次,每次将pbShineX增加14个像素。当x坐标超过430时,重置为-70,重新开始下一轮扫描。

aboutToAppear() {
  this.pbShineTimer = setInterval(() => {
    this.pbShineX = this.pbShineX + 14
    if (this.pbShineX > 430) {
      this.pbShineX = -70
    }
  }, 32)
}

430像素的重置阈值大约相当于手机屏幕的宽度,确保光泽条完全移出卡片右侧后才重置。-70像素的起始位置确保光泽条从左侧完全进入视野,而非突然出现。

11.3 月消费柱状图

月消费柱状图展示近6个月的消费金额变化趋势。数据来源于PB_SPENDS常量,包含3月到8月共6个月的数据。

Row({ space: 6 }) {
  ForEach(PB_SPENDS, (s: PBSpend) => {
    Column({ space: 5 }) {
      Text(String(s.amount)).fontSize(9).fontColor(PB_COLORS.sub)
      Column()
        .width(22)
        .height(pbSpendH(s.amount))
        .borderRadius(8)
        .linearGradient({ angle: 180, colors: [['#FF8A3D', 0], ['#FFD59E', 1]] })
      Text(s.month).fontSize(10).fontColor(PB_COLORS.sub)
    }.layoutWeight(1)
  }, (s: PBSpend) => s.month)
}
.width('100%')
.height(150)
.alignItems(VerticalAlign.Bottom)

每个柱子由三部分组成:顶部的金额数值、中间的柱状条和底部的月份标签。柱状条宽度为22像素,高度由pbSpendH函数计算,最大高度为96像素。

柱状图使用从上到下的渐变色,顶部为橙色,底部为浅橙色。整个图表区域高度为150像素,内容底部对齐,确保所有柱子的底部在同一水平线上。

图表下方有一行说明文字,指出6月消费最高(520元),原因是"宠物生日季"。这种数据注解不仅解释了数据波动的原因,也增添了应用的趣味性和温度感。

11.4 订单列表

订单列表展示用户的所有订单,每个订单项包含蛋糕图标、订单信息和价格状态三部分。

ForEach(PB_ORDERS, (o: PBOrder) => {
  Row({ space: 12 }) {
    Column() {
      Text('🍰').fontSize(18)
    }
    .width(40)
    .height(40)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(PB_COLORS.soft)

    Column({ space: 3 }) {
      Text(o.cake).fontSize(13).fontWeight(FontWeight.Medium).fontColor(PB_COLORS.text).maxLines(1)
      Text(o.pet + ' · ' + o.weight + 'g · ' + o.date).fontSize(10).fontColor(PB_COLORS.sub)
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)

    Column({ space: 3 }) {
      Text('¥' + o.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.accent)
      Text(o.status)
        .fontSize(9)
        .fontColor(Color.White)
        .padding({ left: 7, right: 7, top: 2, bottom: 2 })
        .borderRadius(8)
        .backgroundColor(o.statusHex)
    }.alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(10)
  .borderRadius(12)
  .backgroundColor(PB_COLORS.card)
}, (o: PBOrder) => o.id.toString())

左侧是一个40x40像素的圆形图标容器,背景为柔和色,里面是一个蛋糕emoji。中间是订单的主要信息:蛋糕名称和详细信息(宠物名、克重、日期)。右侧是价格和状态信息。

状态标签是一个彩色的小标签,背景色由statusHex字段决定,文字为白色。不同的订单状态使用不同的颜色:已完成为绿色、烘烤中为橙色、待付款为黄色、配送中为蓝色。这种颜色编码方式使得订单状态一目了然。

11.5 收藏列表与删除功能

收藏列表展示用户收藏的商品,每个收藏项包含商品信息、价格信息和删除按钮。

ForEach(this.favList, (f: PBFav) => {
  Row({ space: 12 }) {
    Column({ space: 3 }) {
      Row({ space: 6 }) {
        Text(f.name).fontSize(13).fontWeight(FontWeight.Medium).fontColor(PB_COLORS.text).maxLines(1)
        Text(f.type)
          .fontSize(9)
          .fontColor(PB_COLORS.accent)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .borderRadius(8)
          .backgroundColor(PB_COLORS.soft)
      }
      Text(f.tag).fontSize(10).fontColor(PB_COLORS.sub)
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)

    Column({ space: 2 }) {
      Row({ space: 5 }) {
        Text('¥' + f.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(PB_COLORS.accent)
        Text('¥' + f.orig)
          .fontSize(9)
          .fontColor(PB_COLORS.sub)
          .decoration({ type: TextDecorationType.LineThrough })
      }
      Text('已省 ¥' + (f.orig - f.price)).fontSize(9).fontColor(PB_COLORS.green)
    }.alignItems(HorizontalAlign.End)

    Text('🗑')
      .fontSize(16)
      .padding(6)
      .onClick(() => {
        this.selFavId = f.id
        this.showDel = true
      })
  }
  .width('100%')
  .padding(10)
  .borderRadius(12)
  .backgroundColor(PB_COLORS.card)
}, (f: PBFav) => f.id.toString())

左侧是商品信息,包括商品名称和类型标签,以及标签文字。类型标签使用橙色文字和柔和背景,与其他页面的标签样式保持一致。

中间是价格信息,显示当前价和原价(带删除线),以及已省金额。已省金额使用绿色文字,给用户积极的心理暗示,强化"省钱了"的获得感。

右侧是删除按钮,使用垃圾桶emoji。点击后将当前收藏项的ID存入selFavId,并将showDel设为true,弹出删除确认弹窗。

11.6 删除确认弹窗

删除确认弹窗采用红色警告样式,与其他弹窗的橙色主题形成鲜明对比,突出"危险操作"的视觉警示。

@Builder modalOverlay() {
  Column() {
    Column({ space: 14 }) {
      Row() {
        Text('!').fontSize(28).fontWeight(FontWeight.Bold).fontColor(Color.White)
      }
      .width(58)
      .height(58)
      .borderRadius(29)
      .justifyContent(FlexAlign.Center)
      .setColor(PB_COLORS.danger)
      .shadow({ radius: 14, color: 'rgba(229,72,77,0.4)', offsetY: 0 })

      Column({ space: 6 }) {
        Text('确认删除该收藏?')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(PB_COLORS.danger)
        Text('删除后将从收藏列表移除,且无法恢复')
          .fontSize(11)
          .fontColor(PB_COLORS.sub)
      }.alignItems(HorizontalAlign.Center).width('100%')

      Row({ space: 12 }) {
        Text('再想想')
          .fontSize(14)
          .fontColor(PB_COLORS.text)
          .layoutWeight(1)
          .height(42)
          .textAlign(TextAlign.Center)
          .borderRadius(21)
          .backgroundColor(PB_COLORS.soft)
          .onClick(() => {
            this.showDel = false
          })
        Text('确认删除')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .layoutWeight(1)
          .height(42)
          .textAlign(TextAlign.Center)
          .borderRadius(21)
          .backgroundColor(PB_COLORS.danger)
          .onClick(() => {
            this.favList = pbRemoveFav(this.favList, this.selFavId)
            this.showDel = false
          })
      }.width('100%').margin({ top: 6 })
    }
    .width('80%')
    .padding(24)
    .borderRadius(18)
    .backgroundColor(PB_COLORS.card)
    .alignItems(HorizontalAlign.Center)
  }
  .width('100%')
  .height('100%')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .backgroundColor('rgba(74,55,40,0.55)')
  .justifyContent(FlexAlign.Center)
}

弹窗顶部是一个红色的圆形警告图标,内部有一个白色的感叹号。圆形带有红色的发光阴影,增强了警示效果。阴影半径14像素,颜色为半透明红色,y方向偏移为0,形成均匀的发光效果。

警告文字使用红色粗体,提示语使用次要文字色。两个操作按钮分别为"再想想"和"确认删除"。"再想想"使用柔和色背景和深色文字,"确认删除"使用红色背景和白色文字。

点击"确认删除"后,调用pbRemoveFav函数生成新的收藏列表(不包含被删除项),赋值给favList状态变量,同时关闭弹窗。由于favList@State状态变量,赋值操作会触发框架重新渲染收藏列表,被删除的项会立即消失。

11.7 架构层次图

逻辑层

数据层

组件层

页面层

应用层

PetBakeryApp 根组件

首页 PBHomeTab

蛋糕 PBCakeTab

零食 PBSnackTab

营养 PBNutriTab

工具 PBToolTab

我的 PBMineTab

烤箱仪表盘

蛋糕横滑卡片

热销榜

功能宫格

定制弹窗

粒子特效

筛选入口

双列Grid

克重对比

口味柱状图

口味弹窗

零食排行

热量对比

肉类分布

营养色卡

营养占比

成分明细

详情弹窗

参数表

功率对比

编辑弹窗

会员卡

消费柱状图

订单列表

收藏列表

删除弹窗

PB_CAKES 蛋糕数据

PB_SNACKS 零食数据

PB_NUTRIS 营养数据

PB_TOOLS 工具数据

PB_ORDERS 订单数据

PB_FAVS 收藏数据

PB_SPENDS 消费数据

PB_COLORS 颜色主题

排序函数

筛选函数

统计函数

百分比计算

动画计算

数据分组

十二、动画系统深度剖析

12.1 基于setInterval的声明式动画

应用中的所有动画效果(面粉飘落、香气上升、烤箱脉动、温度变化、会员卡光泽)都是基于setInterval定时器实现的。这种动画实现方式与传统的命令式动画有本质区别。

在命令式UI框架中,动画通常通过不断修改DOM元素的样式属性来实现。开发者需要手动获取元素引用,然后在每一帧更新元素的位置、大小或透明度。这种方式不仅代码冗长,还容易引发性能问题。

而在基于HarmonyOS ArkTS API 24 的声明式UI框架中,动画的实现方式发生了根本性的变化。开发者不再直接操作UI元素,而是通过更新状态变量来间接驱动UI变化。框架会自动检测状态变化,计算需要更新的组件,并高效地完成重渲染。

以面粉飘落动画为例,动画的核心逻辑只有一行代码:

this.pbFlourY = this.pbFlourY - 9

这行代码只是简单地修改了一个数字变量的值。但由于pbFlourY@State装饰器标记为状态变量,框架会自动追踪所有依赖这个变量的UI元素。当变量变化时,所有使用pbFallY(f.base, this.pbFlourY)计算位置的粒子组件都会被标记为需要更新,框架会在下一帧重新计算并渲染它们的位置。

这种"状态驱动视图"的动画模式大大简化了动画代码的编写。开发者只需关注状态如何随时间变化,而无需关心UI如何更新。框架承担了从状态到视图的映射和优化工作。

12.2 循环动画的数学原理

应用中的粒子动画都采用了循环设计,即粒子运动到边界后会重新开始,形成无限循环的效果。这种循环效果是通过取模运算(%)实现的。

以下落动画为例,位置计算公式为:

function pbFallY(base: number, offset: number): number {
  let v = (base + offset) % 1300
  if (v < 0) {
    v = v + 1300
  }
  return v - 80
}

base是粒子的基准位置,每个粒子不同;offset是随时间变化的偏移量,所有粒子共用。两者相加后对1300取模,得到0到1300之间的循环值。

由于offset是不断减小的(下落动画中y值减小意味着向上移动,但配合负偏移的设计实现了下落效果),当base + offset为负数时,取模运算的结果也为负数。因此需要一个判断:如果结果小于0,就加上1300,将其转换为正数。

最后减去80像素,使得粒子的起始位置在可视区域上方,产生"从天而降"的视觉效果。

上升动画的原理类似,只是循环周期更小(240像素),且最终位置的计算方式不同:

function pbRiseY(base: number, offset: number): number {
  let v = (base + offset) % 240
  if (v < 0) {
    v = v + 240
  }
  return 170 - v
}

170 - v的计算方式使得粒子从y=170的位置开始,随着v的增大,y值逐渐减小,粒子向上移动。当v达到240时,y值为-70,粒子消失在顶部,然后v重置为0,粒子重新从底部开始上升。

12.3 正弦函数与呼吸灯效果

烤箱的微光脉动效果使用了正弦函数来实现平滑的周期性变化。正弦函数是动画设计中非常常用的数学工具,它能够产生自然、平滑的过渡效果。

function pbGlow(phase: number): number {
  return 0.4 + 0.3 * Math.sin(phase)
}

Math.sin(phase)的返回值在-1到1之间周期性变化。乘以0.3后,振幅变为0.3。加上基准值0.4后,最终的返回值就在0.1到0.7之间波动。

与线性变化相比,正弦变化有以下优势:首先,变化速率不是恒定的,在极值处变化最慢,在中间位置变化最快,这种非线性变化更符合自然界中的脉动现象;其次,正弦曲线是连续光滑的,没有突兀的拐点,视觉效果更加柔和。

相位值phase随时间匀速增加,每帧增加0.18弧度。完整的正弦周期为2π弧度(约6.28弧度),因此完成一个完整的脉动周期需要约35帧。以60fps计算,周期约为0.58秒。

12.4 光泽扫过动画的实现

会员卡的光泽扫过动画是另一种经典的动画效果。它通过一个倾斜的半透明矩形从左向右匀速移动,模拟光线在金属或塑料表面扫过的效果。

实现原理非常简单:一个倾斜的矩形条,x坐标随时间匀速增加,当移动到一定位置后重置到左侧重新开始。

this.pbShineTimer = setInterval(() => {
  this.pbShineX = this.pbShineX + 14
  if (this.pbShineX > 430) {
    this.pbShineX = -70
  }
}, 32)

光泽条的宽度为42像素,倾斜角度为22度。卡片宽度约为手机屏幕宽度(约375像素)。430像素的重置阈值确保光泽条完全移出卡片右侧后才重置,-70像素的起始位置确保光泽条从左侧完全进入。

定时器间隔为32毫秒,约合每秒31帧。这个帧率对于光泽扫过这类慢速移动的动画来说已经足够流畅,不需要更高的帧率。每帧移动14像素,移动完整个卡片宽度(约400像素)需要约28帧,即约0.9秒。

12.5 动画性能考量

虽然应用中使用了多个定时器和多个动画元素,但整体性能开销仍然在可接受范围内。这主要得益于以下几个因素:

首先,动画元素的数量有限。面粉粒子有12个,香气粒子有6个,光泽条1个,温度环1个,微光圆1个。加起来不到20个动画元素,对于现代移动设备的GPU来说完全不在话下。

其次,动画的计算量很小。每个粒子的位置计算只涉及简单的算术运算(加法、取模),不需要复杂的数学计算。即使有20个粒子,每帧的计算量也微乎其微。

第三,声明式UI框架的增量渲染机制确保只有真正变化的部分才会被重新渲染。当pbFlourY变化时,只有使用了这个状态的粒子组件会被更新,页面其他部分(如烤箱信息、热销榜等)完全不受影响。

最后,粒子组件设置了hitTestBehavior(HitTestMode.None),跳过了命中测试,减少了触摸事件处理的开销。

当然,如果要进一步优化性能,还可以考虑以下方向:使用框架内置的动画系统(如animateTo)替代手动定时器,可以获得更好的性能优化;将多个粒子合并到一个Canvas组件中绘制,减少组件数量;使用@StorageLink或全局状态管理减少状态传递的开销。

十三、弹窗交互模式分析

13.1 Stack + 条件渲染的弹窗模式

应用中的所有弹窗都采用了相同的实现模式:使用Stack容器作为根布局,通过条件渲染控制弹窗的显示与隐藏。

build() {
  Stack() {
    // 主要内容
    Scroll() {
      // ...
    }
    
    if (this.showCustom) {
      this.modalOverlay()
    }
  }
}

Stack容器的子组件沿z轴堆叠,后添加的组件覆盖在先添加的组件之上。弹窗组件通过条件渲染添加到Stack的最上层,实现了"浮在"内容之上的效果。

弹窗组件本身也是一个两层结构:外层是半透明的遮罩层,占满整个屏幕,背景色为半透明深色;内层是居中显示的白色内容卡片。

@Builder modalOverlay() {
  Column() {
    Column({ space: 14 }) {
      // 弹窗内容
    }
    .width('88%')
    .padding(20)
    .borderRadius(18)
    .backgroundColor(PB_COLORS.card)
  }
  .width('100%')
  .height('100%')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .backgroundColor('rgba(74,55,40,0.5)')
  .justifyContent(FlexAlign.Center)
}

遮罩层设置了position({ x: 0, y: 0 })zIndex(999),确保它覆盖整个屏幕并且位于最上层。justifyContent(FlexAlign.Center)使内部的内容卡片在垂直和水平方向上都居中显示。

这种弹窗实现模式的优点是简单直观、易于理解和调试。但它也有一些局限性:缺少入场和退场动画,弹窗的出现和消失都是瞬间的;遮罩层点击不关闭,必须点击特定的关闭按钮或操作按钮;弹窗的状态管理分散在各个组件中,缺乏统一的管理。

13.2 四种弹窗类型对比

应用中共有四种不同类型的弹窗,分别出现在不同的Tab页面中:

弹窗类型 所在页面 触发方式 主要内容 操作按钮
定制蛋糕弹窗 首页 点击"定制蛋糕"功能入口 宠物昵称输入、蛋糕款式选择、克重输入 取消 / 提交定制
口味选择弹窗 蛋糕页 点击口味筛选入口 7个口味选项的宫格选择 确定
营养详情弹窗 营养页 点击营养品色卡 产品概要、5项营养参数表 知道了
参数编辑弹窗 工具页 点击工具参数表行 工具信息、温度滑块、时间滑块 保存参数
删除确认弹窗 我的页 点击收藏项的删除按钮 警告图标、删除确认提示 再想想 / 确认删除

每种弹窗的设计都与其功能场景高度匹配。定制蛋糕弹窗是表单类型,包含多个输入控件,操作按钮为双按钮(取消+确认)。口味选择弹窗是选择类型,使用宫格布局,选择即时生效,只有一个确认按钮。

营养详情弹窗是信息展示类型,以参数表的形式展示详细信息,只有一个关闭按钮。参数编辑弹窗是设置类型,使用滑块控件进行参数调整,操作按钮为单按钮(保存)。

删除确认弹窗是警告类型,使用红色警示主题,双按钮设计,强调操作的危险性,引导用户谨慎决策。

13.3 弹窗状态管理模式

每个弹窗都由一个布尔类型的状态变量控制显示与隐藏。弹窗的打开操作通常伴随着数据的准备:

  • 定制蛋糕弹窗:打开时不需要额外准备数据,因为表单初始值已设好
  • 口味选择弹窗:打开时cat状态已经存在,选择即时更新
  • 营养详情弹窗:打开前需要设置selNutri为选中的营养品数据
  • 参数编辑弹窗:打开前需要设置selTooleditTempeditTime
  • 删除确认弹窗:打开前需要设置selFavId为待删除的收藏项ID

弹窗的关闭操作则相对简单,通常只需将控制变量设为false。但有些弹窗的关闭操作还会伴随副作用:

  • 定制蛋糕弹窗的"提交定制":关闭弹窗并显示成功提示
  • 参数编辑弹窗的"保存参数":目前只关闭弹窗,未实现数据持久化
  • 删除确认弹窗的"确认删除":执行删除操作并关闭弹窗

这种"一个状态变量控制一个弹窗"的模式在弹窗数量较少时是简单有效的。但如果弹窗数量增多,状态变量会变得越来越多,管理成本也会随之上升。在更复杂的应用中,可以考虑使用弹窗管理器或全局状态管理方案来统一管理弹窗。

13.4 弹窗交互流程图

结果层

交互操作

弹窗类型

触发层

点击定制蛋糕入口

点击口味筛选入口

点击营养品色卡

点击工具参数行

点击收藏删除按钮

定制蛋糕弹窗

口味选择弹窗

营养详情弹窗

参数编辑弹窗

删除确认弹窗

输入宠物名

选择蛋糕款式

输入克重

点击提交定制

点击取消

点击口味选项

实时筛选更新

点击确定

查看营养参数

点击知道了

拖拽温度滑块

拖拽时间滑块

点击保存参数

点击再想想

点击确认删除

显示成功提示 3.2秒

筛选结果刷新

返回列表浏览

参数更新生效

收藏项被删除

十四、各模块特性对比总结

14.1 六Tab模块全面对比表

对比维度 首页 Tab 蛋糕 Tab 零食 Tab 营养 Tab 工具 Tab 我的 Tab
核心功能 综合首页、功能入口 蛋糕商品浏览 零食商品浏览 营养品浏览 烘焙工具展示 个人中心
状态变量数 10个(含3个定时器) 2个 0个 2个 4个 4个(含1个定时器)
动画效果 面粉飘落、香气上升、烤箱微光、温度脉动 会员卡光泽扫过
弹窗类型 表单型(定制蛋糕) 选择型(口味筛选) 详情型(营养参数) 编辑型(滑块调参) 确认型(删除警告)
弹窗数量 1个 1个 0个 1个 1个 1个
数据可视化 温度环、销量条 克重条、口味柱状图 热量条、肉类分布条 营养占比条、成分明细条 功率条 消费柱状图
图表类型 环形进度 + 水平条 水平条 + 垂直柱状图 水平条 + 堆叠条 堆叠条 + 水平条 水平条 垂直柱状图
列表布局 横滑卡片 + 排行列表 双列Grid + 列表 排行列表 三列Grid + 列表 横向表格 订单列表 + 收藏列表
交互复杂度 高(动画+表单+导航) 中(筛选+浏览) 低(纯展示) 中(点击查看详情) 中(点击编辑参数) 中(删除交互)
商品数量 5款(TOP5) 10款 8款 8款 6款 6单 + 6收藏
数据来源 PB_CAKES / PB_FUNCS / PB_FLOURS / PB_AROMAS PB_CAKES PB_SNACKS PB_NUTRIS / PB_NUTRI_PARTS PB_TOOLS PB_ORDERS / PB_FAVS / PB_SPENDS
工具函数依赖 pbHotCakes / pbTopCakes / pbFuncRows / pbFallY / pbRiseY / pbGlow / pbSalesPct / pbCakeName pbFilterCakes / pbCakeRows / pbFlavorStats / pbFlavorH / pbWeightPct / pbFlavorMax pbSnackRank / pbCalPct / pbMeatStats / pbMeatTotal / pbPct pbNutriRows pbPowerPct pbRemoveFav / pbSpendH
生命周期钩子 aboutToAppear / aboutToDisappear aboutToAppear / aboutToDisappear
主题色调 暖橙 + 面粉白 暖橙 + 多彩渐变 暖橙 + 分类色 多彩色卡 暖橙 + 表格黄 金色渐变 + 暖橙
设计模式 仪表盘 + 卡片 + 宫格 筛选 + Grid + 图表 排行 + 对比 + 分布 色卡 + 参数表 表格 + 滑块 会员卡 + 列表

14.2 弹窗类型详细对比表

对比项 定制蛋糕弹窗 口味选择弹窗 营养详情弹窗 参数编辑弹窗 删除确认弹窗
所属页面 首页 蛋糕页 营养页 工具页 我的页
弹窗用途 收集定制信息 筛选口味分类 展示产品详情 调整烘烤参数 确认危险操作
弹窗宽度 88% 86% 88% 88% 80%
背景色 白色卡片 白色卡片 #FFFDF5 白色卡片 白色卡片
遮罩透明度 0.5 0.5 0.5 0.5 0.55
标题样式 橙色装饰柱 + 粗体标题 居中粗体标题 + 副标题 产品概要 + 关闭按钮 橙色装饰柱 + 粗体标题 红色警告图标 + 红色标题
主色调 橙色 橙色 多彩(随产品变化) 橙色 红色
表单项数 3项(昵称+款式+克重) 7个口味选项 5项参数 2项(温度+时间) 0项(纯确认)
输入控件 TextInput + 横向选择 + TextInput 宫格选择按钮 无(纯展示) Slider滑块 ×2
按钮数量 2个(取消+提交) 1个(确定) 1个(知道了) 1个(保存参数) 2个(再想想+确认删除)
即时反馈 否(提交后才生效) 是(点击即筛选) 不适用 是(滑块实时变化) 否(确认后执行)
zIndex值 999 999 999 999 999
最大高度限制 80% 78% 80%

14.3 数据可视化组件对比表

图表类型 所在模块 数据来源 计算函数 方向 渐变方向 基准值
温度环形进度 首页烤箱 pbOvenTemp状态 内置Progress 环形 纯色 250℃
销量进度条 首页热销榜 c.sales pbSalesPct 水平 90度(左→右) 1280
克重对比条 蛋糕页 c.weight pbWeightPct 水平 90度(左→右) 500g
口味柱状图 蛋糕页 pbFlavorStats pbFlavorH 垂直 180度(上→下) pbFlavorMax
热量对比条 零食页 s.calories pbCalPct 水平 90度(左→右) 400kcal
肉类分布条 零食页 pbMeatStats pbPct 水平堆叠 纯色 pbMeatTotal
营养占比条 营养页 PB_NUTRI_PARTS p.ratio 水平堆叠 纯色 100%
成分明细条 营养页 PB_NUTRI_PARTS p.ratio * 2 水平 纯色 64%(视觉)
功率对比条 工具页 t.power pbPowerPct 水平 90度(左→右) 1500W
消费柱状图 我的页 PB_SPENDS pbSpendH 垂直 180度(上→下) 520元

十五、技术亮点与设计模式总结

15.1 声明式UI范式的深度应用

整个应用的构建完全遵循基于HarmonyOS ArkTS API 24 的声明式UI开发范式。每一个UI元素都通过链式调用的方式配置属性,每一个动态效果都通过状态变量的变化来驱动。

这种开发模式带来的最大好处是代码的可读性和可维护性大幅提升。开发者在阅读代码时,可以清晰地看到组件的结构、属性和交互逻辑,而不需要在不同的函数之间跳来跳去。状态变量与UI渲染之间的关系是直接的、声明式的,不需要追踪复杂的调用链。

以温度仪表盘为例,只需要看一眼代码就能理解:温度值来自pbOvenTemp状态变量,它被同时用于进度环的数值、中心文字的显示和状态文字的判断。当温度变化时,所有相关的UI元素都会自动更新,不需要手动编写更新逻辑。

15.2 数据驱动的组件设计

应用中大量使用了"数据驱动视图"的设计模式。无论是底部Tab栏、功能宫格、商品列表还是图表数据,都是通过数组遍历生成的,而非硬编码每一个元素。

这种模式的优势非常明显。首先,代码量大幅减少。以底部Tab栏为例,如果硬编码六个Tab项,每个项都需要重复几乎相同的代码结构。而使用ForEach遍历PB_TABS数组,只需写一次模板,就能生成所有Tab项。

其次,维护成本大幅降低。如果需要增加、删除或修改一个Tab项,只需要修改PB_TABS数组即可,无需改动渲染逻辑。数据和视图的分离使得两者可以独立演进。

最后,可扩展性更强。当数据来自网络请求或数据库时,同样的渲染逻辑可以直接复用,不需要为动态数据做特殊处理。

15.3 函数式数据处理风格

应用中的数据处理函数普遍采用函数式编程风格:不修改输入参数,而是返回新的数据对象或数组。pbHotCakespbFilterCakespbRemoveFav等函数都是如此。

pbRemoveFav为例,它接收原数组和要删除的ID,然后遍历原数组,将不匹配的元素收集到新数组中返回。整个过程没有修改原数组,符合不可变数据的原则。

这种函数式的处理方式与声明式UI的状态更新模式高度契合。在声明式UI中,状态更新应该是不可变的——通过替换整个状态值来触发更新,而非在原状态上修改。函数式的数据处理函数天然支持这种模式。

15.4 模块化的组件拆分

虽然整个应用写在一个文件中,但在逻辑层面已经做了清晰的模块化拆分。六个Tab页面对应六个独立的组件,每个组件负责一个功能模块的完整实现。

每个组件内部又包含了多个子模块,通过注释进行分隔。例如首页组件包含了烤箱仪表盘、招牌蛋糕横滑、热销榜、功能宫格等子模块。这种分层结构使得代码组织清晰,易于理解和维护。

弹窗的实现也采用了模块化的方式,每个弹窗都对应一个@Builder方法。@Builder装饰器允许将UI代码封装为可复用的构建函数,这是基于HarmonyOS API 24 提供的一种优雅的代码复用机制。

15.5 主题系统的集中管理

应用使用PB_COLORS常量集中管理所有颜色,通过PBColorPalette接口确保类型安全。这种设计令牌(Design Tokens)的管理方式是现代UI开发的最佳实践之一。

集中管理颜色的好处是多方面的。首先,确保了颜色的一致性,不会出现同一语义的颜色在不同地方有细微差异的情况。其次,便于主题切换,如果未来需要支持深色模式或其他主题,只需替换颜色常量即可。第三,提高了代码的可维护性,修改颜色只需改一处,无需全局搜索替换。

除了颜色,应用中的数据也都是集中管理的。所有的模拟数据都定义为顶层常量,各组件按需引用。这种数据集中管理的方式同样带来了一致性和可维护性的提升。

15.6 渐进式的信息展示

应用在信息展示方面遵循了"渐进式披露"的设计原则。用户首先看到的是概览信息,如果需要了解更多细节,可以通过点击等交互方式查看详细内容。

营养Tab是一个典型的例子。色卡Grid只展示产品名称、功能和价格这些最核心的信息。如果用户想了解详细的营养成分,可以点击色卡弹出详情弹窗,查看粗蛋白、粗脂肪、水分、喂量等完整参数。

工具Tab也采用了类似的模式。参数表只展示关键参数的数值,如果用户想调整参数,点击行后弹出编辑弹窗,通过滑块进行精细调整。

渐进式披露的设计避免了信息过载,让界面保持简洁,同时又满足了深度用户的需求。用户可以根据自己的需要决定探索的深度。

15.7 动画性能与视觉效果的平衡

应用在动画设计上找到了性能与视觉效果之间的良好平衡。虽然有面粉飘落、香气上升、烤箱脉动、光泽扫过等多种动画效果,但由于动画元素数量控制得当、计算逻辑简单高效,整体性能开销并不大。

特别是粒子效果的实现,没有使用重量级的Canvas或WebGL方案,而是完全基于声明式组件和状态驱动。12个面粉粒子加6个香气粒子,每帧只需做简单的算术运算,对于现代移动设备来说完全是小菜一碟。

同时,动画的视觉效果并不打折扣。循环下落的面粉、袅袅上升的香气、微微脉动的烤箱光芒、缓缓扫过的会员卡光泽,这些细节共同营造出了温馨而有活力的烘焙工坊氛围。

这种"轻量实现、足量表达"的动画设计理念值得借鉴。动画的目的是增强用户体验、传达情感氛围,而不是炫技。在满足视觉效果的前提下,尽可能采用简单高效的实现方式,是成熟开发者的标志。

十六、基于HarmonyOS 6.1.1的开发实践总结

16.1 声明式开发的思维转变

从命令式UI开发转向基于HarmonyOS ArkTS API 24 的声明式UI开发,最重要的不是学习新的API,而是转变思维方式。

在命令式思维中,开发者关注的是"如何做":如何创建元素、如何设置属性、如何响应用户操作、如何更新界面。每一步都需要开发者手动指定。

在声明式思维中,开发者关注的是"是什么":UI在某种状态下应该呈现什么样子。至于如何从当前状态过渡到目标状态,框架会自动处理。

这种思维转变带来的直接影响是代码量的减少和代码质量的提升。开发者不再需要编写大量的模板代码来手动管理UI状态,而是将精力集中在业务逻辑和状态设计上。

以弹窗的显示与隐藏为例。在命令式开发中,可能需要手动创建弹窗元素、添加到DOM、设置动画、监听关闭事件、移除元素等一系列操作。而在声明式开发中,只需要一个布尔状态变量和一个条件渲染表达式,框架会处理好一切。

16.2 状态管理的最佳实践

基于HarmonyOS API 24 提供了丰富的状态管理装饰器,选择合适的装饰器对于应用的可维护性和性能都有重要影响。

对于组件内部的局部状态,使用@State是最基本也是最常用的选择。@State装饰的变量会被框架纳入响应式系统,变化时自动触发UI更新。

对于父子组件之间的数据传递,@Prop@Link提供了单向和双向两种绑定方式。@Prop是单向的,父组件的数据变化会传递给子组件,但子组件的修改不会影响父组件。@Link是双向的,父子组件的数据保持同步。

对于跨层级的状态共享,@Provide@Consume提供了类似依赖注入的机制。祖先组件通过@Provide提供状态,后代组件通过@Consume消费状态,无需逐层传递。

在本应用中,由于组件之间相对独立,状态共享的需求不多,因此主要使用@State来管理组件内部状态。这也是大多数中小型应用的常见模式。

状态管理的一个重要原则是"状态最小化"——只把真正需要触发UI更新的数据声明为状态。不需要响应式更新的数据(如定时器ID)应该作为普通属性存在,避免不必要的渲染开销。

16.3 组件化与代码复用

基于HarmonyOS ArkTS API 24 的组件化机制非常灵活。@Component装饰器可以将任何一个struct标记为可复用的组件。组件可以嵌套组合,形成复杂的UI结构。

本应用将六个Tab页分别实现为独立的组件,这是一种合理的组件拆分方式。每个Tab页都是一个独立的功能模块,有自己的状态和生命周期。组件之间通过父组件的状态(activeTab)来协调。

@Builder装饰器则提供了更细粒度的代码复用能力。对于组件内部重复出现的UI模式(如弹窗),可以封装为@Builder方法,在需要的地方调用。

在更复杂的应用中,还可以进一步提炼通用组件,如商品卡片、列表项、按钮等,将它们抽离为独立的组件文件,在整个应用中复用。本应用由于代码量集中在一个文件中,组件拆分的粒度相对较粗,但在架构层面已经具备了扩展的基础。

16.4 性能优化策略

虽然应用的数据量和复杂度都不高,但其中体现的一些性能优化思路仍然值得总结。

首先,合理使用@State,避免不必要的响应式更新。只有真正需要驱动UI变化的数据才应该被@State装饰。定时器ID、常量引用等不需要响应式的数据,应该作为普通属性。

其次,利用ForEach的键生成函数优化列表渲染。ForEach的第二个参数返回每项的唯一标识,框架可以根据这个标识来判断哪些项是新增的、哪些是删除的、哪些是移动的,从而实现高效的列表更新。

第三,设置hitTestBehavior(HitTestMode.None)跳过不必要的命中测试。对于纯装饰性的UI元素(如粒子特效),跳过命中测试可以减少触摸事件处理的开销。

第四,使用layoutWeight实现弹性布局,避免频繁的尺寸计算。layoutWeight可以让组件按比例分配空间,比手动计算尺寸更高效。

最后,及时清理定时器和事件监听。在aboutToDisappear生命周期回调中清理所有定时器,防止内存泄漏和不必要的CPU占用。

16.5 代码组织与可维护性

虽然整个应用的代码都在一个文件中,但代码的组织结构非常清晰。从类型定义到主题常量,再到模拟数据、工具函数,最后是各个组件,层层递进,逻辑分明。

这种按功能分区的代码组织方式使得开发者可以快速定位到需要修改的部分。如果要改颜色,就去主题常量区;如果要加数据,就去模拟数据区;如果要改逻辑,就去工具函数区;如果要调UI,就去对应组件区。

注释的使用也很恰当。每个大的功能区块之前都有明确的分隔注释(如// ============ 首页 Tab ============),起到了视觉锚点的作用。开发者在滚动代码时可以通过这些注释快速定位。

在更大规模的项目中,当然应该将不同模块拆分到不同的文件中。但即使是单文件应用,良好的代码组织结构也是提升可维护性的关键。

16.6 用户体验设计考量

应用在用户体验设计上有很多可圈可点之处。首先是视觉风格的统一性。整个应用从颜色、字体、间距到圆角、阴影、渐变,都遵循统一的设计语言。暖橙色调贯穿始终,营造出温馨甜美的品牌氛围。

其次是交互反馈的及时性。口味筛选即时生效、滑块拖动实时变化、状态文字随温度动态切换,这些细节都让用户感受到应用的"活"的生命力,而非冷冰冰的静态界面。

第三是信息层级的清晰性。通过字体大小、字重、颜色的变化,构建了清晰的视觉层级。重要信息(如价格)使用大字号、粗体、强调色;次要信息(如标签、说明)使用小字号、常规字重、次要色。用户可以快速扫描页面,找到自己关心的信息。

第四是情感化设计的运用。emoji图标(🍰、🥛、🔨、🐾等)增添了界面的趣味性和亲和力。宠物名称(豆豆、旺财、咪咪)让数据更有温度。"宠物生日季消费最高"这样的注解让数据不再枯燥,而是有了故事感。

最后,危险操作的二次确认设计体现了对用户的关怀。删除收藏时弹出确认对话框,用醒目的红色警告用户这是一个不可逆的操作,有效防止了误触带来的不良体验。

十七、总结与展望

17.1 技术维度总结

从技术实现的角度来看,这款基于HarmonyOS ArkTS API 24 构建的宠物烘焙应用充分展现了声明式UI开发范式的优势和魅力。

在架构层面,应用采用了清晰的分层结构:数据层、工具函数层、组件层和入口层。各层职责分明,数据流向清晰。六个Tab组件各自独立,通过父组件的状态进行协调,耦合度低,内聚度高。

在状态管理层面,应用合理使用了@State装饰器管理组件内部状态,状态变量的数量和粒度都控制得恰到好处。动画驱动、弹窗控制、表单数据、列表数据等不同类型的状态各司其职,没有出现状态冗余或状态爆炸的问题。

在动画实现层面,应用展示了基于setInterval + 状态驱动的声明式动画方案。虽然不如框架内置的animateTo那样优化,但胜在简单直观、易于理解和调试。对于中小规模的动画需求,这种方案完全够用。

在数据可视化层面,应用使用基础的容器组件(Row、Column)和百分比宽度,巧妙地实现了条形图、柱状图、堆叠图等多种图表效果。不需要引入第三方图表库,也能做出美观实用的数据可视化效果。

在代码质量层面,应用代码结构清晰、命名规范、注释恰当。类型定义完善,充分利用了TypeScript的类型系统提供类型安全保障。工具函数职责单一、可测试性好。组件代码简洁明了,可读性强。

17.2 产品维度总结

从产品设计的角度来看,这款宠物烘焙应用在有限的页面空间内融入了丰富的功能和内容,信息密度高但不显拥挤。

首页作为应用的门户,集成了烤箱动画(品牌氛围营造)、招牌蛋糕横滑(核心产品展示)、热销榜(社交证明)、功能宫格(服务入口)等多个模块,信息层次丰富,功能入口清晰。

蛋糕Tab和零食Tab作为商品浏览页面,不仅提供了商品列表,还通过克重对比、热量对比、口味统计、肉类分布等数据可视化手段,帮助用户从多个维度了解产品特点,做出更明智的购买决策。

营养Tab和工具Tab作为辅助页面,前者以色卡形式展示营养品并提供详细参数,后者以表格形式展示工具参数并支持自定义调整。两个页面都采用了"概览 + 详情"的渐进式信息展示模式。

我的Tab作为个人中心,聚合了会员卡、消费统计、订单和收藏等功能。会员卡的光泽动画增添了尊贵感,消费柱状图帮助用户了解消费趋势,订单和收藏是电商应用的标配功能。

整体来看,六个Tab覆盖了"逛-选-比-看-用-我"的完整用户旅程,功能完整,逻辑顺畅。

17.3 可优化方向与未来展望

尽管应用已经相当完善,但仍然有一些可以优化和拓展的方向。

在状态管理方面,可以引入更系统化的状态管理方案。当前状态分散在各个组件中,如果应用规模继续增长,状态共享和通信会变得复杂。可以考虑使用基于HarmonyOS 6.1.1 的全局状态管理方案,如AppStoragePersistentStorage或第三方状态管理库。

在组件复用方面,可以进一步提炼通用组件。目前商品卡片、列表项、弹窗等UI模式在不同页面中有重复实现,可以将它们抽离为独立的通用组件,通过参数配置实现差异化。

在动画方面,可以尝试使用框架内置的animateTo方法替代手动定时器。animateTo能够提供更流畅的动画效果和更好的性能优化,同时支持更丰富的动画曲线和插值器。

在数据方面,可以将模拟数据替换为真实的网络请求。引入HTTP请求库,实现数据的动态加载。同时可以加入下拉刷新、上拉加载更多等列表交互,提升应用的真实感。

在交互方面,可以增加更多的微交互细节。如按钮的点击反馈、列表项的按压效果、页面切换的转场动画、下拉刷新的动效等。这些细节虽然微小,但积累起来会显著提升应用的品质感。

在功能方面,可以拓展更多的电商功能。如购物车、下单流程、支付集成、物流追踪、用户评价、客服系统等。将目前的"展示型"应用升级为真正的"交易型"应用。

在适配方面,可以基于HarmonyOS 6.1.1 的分布式能力,实现多设备协同。如在手机上浏览商品,在平板上查看详情,在智慧屏上展示烘焙教程等。充分发挥鸿蒙操作系统的分布式优势。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 宠物烘焙工坊 PET BAKERY — ArkTS 单文件应用
// 6 Tab:首页 / 蛋糕 / 零食 / 营养 / 工具 / 我的
// 特效:面粉飘落 + 香气上升 + 烤箱微光脉动
// ============================================================

// ============ 类型定义 ============
enum PBTab { Home, Cake, Snack, Nutri, Tool, Mine }

interface PBColorPalette {
  bg: string
  card: string
  soft: string
  primary: string
  accent: string
  text: string
  sub: string
  line: string
  danger: string
  green: string
}

interface PBCake {
  id: number
  name: string
  flavor: string
  weight: number
  price: number
  sales: number
  tag: string
  colorHex: string
}

interface PBSnack {
  id: number
  name: string
  meat: string
  meatType: string
  calories: number
  protein: number
  price: number
  sales: number
}

interface PBNutri {
  id: number
  name: string
  func: string
  colorHex: string
  weight: number
  protein: number
  fat: number
  moisture: number
  dose: string
  price: number
}

interface PBTool {
  id: number
  name: string
  power: number
  temp: number
  timer: number
  price: number
  use: string
}

interface PBOrder {
  id: number
  cake: string
  pet: string
  weight: number
  price: number
  status: string
  date: string
  statusHex: string
}

interface PBFav {
  id: number
  name: string
  type: string
  price: number
  orig: number
  tag: string
}

interface PBSpend {
  month: string
  amount: number
}

interface PBFuncItem {
  icon: string
  label: string
}

interface PBTabItem {
  label: string
  icon: string
  tab: PBTab
}

interface PBFlavorStat {
  flavor: string
  total: number
}

interface PBMeatStat {
  type: string
  total: number
  colorHex: string
}

interface PBPart {
  label: string
  ratio: number
  colorHex: string
}

interface PBParticle {
  x: number
  base: number
  size: number
  op: number
}

// ============ 主题 ============
const PB_COLORS: PBColorPalette = {
  bg: '#FFFDF5',
  card: '#FFFFFF',
  soft: '#FFF6E0',
  primary: '#FFE8A3',
  accent: '#FF8A3D',
  text: '#4A3728',
  sub: '#9B8A75',
  line: '#F2E8D5',
  danger: '#E5484D',
  green: '#6FBF73'
}

// ============ 数据 ============
const PB_CAKES: PBCake[] = [
  { id: 1, name: '鸡肉慕斯蛋糕', flavor: '鸡肉', weight: 300, price: 88, sales: 1280, tag: '招牌热销', colorHex: '#FFD59E' },
  { id: 2, name: '牛肉芝士蛋糕', flavor: '牛肉', weight: 450, price: 108, sales: 980, tag: '高蛋白', colorHex: '#FFC6A0' },
  { id: 3, name: '三文鱼奶油蛋糕', flavor: '三文鱼', weight: 350, price: 98, sales: 890, tag: '美毛亮发', colorHex: '#FFADC8' },
  { id: 4, name: '红薯千层蛋糕', flavor: '红薯', weight: 400, price: 78, sales: 762, tag: '低敏养胃', colorHex: '#E8C8A0' },
  { id: 5, name: '南瓜酸奶蛋糕', flavor: '南瓜', weight: 320, price: 82, sales: 704, tag: '助消化', colorHex: '#FFE0B2' },
  { id: 6, name: '鸭肉低敏蛋糕', flavor: '鸭肉', weight: 380, price: 118, sales: 560, tag: '低敏配方', colorHex: '#D9B8A8' },
  { id: 7, name: '紫薯软心蛋糕', flavor: '红薯', weight: 420, price: 92, sales: 512, tag: '流心爆浆', colorHex: '#D8B4D8' },
  { id: 8, name: '三文鱼莓果蛋糕', flavor: '三文鱼', weight: 360, price: 105, sales: 470, tag: '莓果抗氧化', colorHex: '#F8B8C8' },
  { id: 9, name: '鸡肉蔬菜蛋糕', flavor: '鸡肉', weight: 300, price: 86, sales: 398, tag: '均衡营养', colorHex: '#CDE8B5' },
  { id: 10, name: '牛肝能量蛋糕', flavor: '牛肉', weight: 500, price: 128, sales: 349, tag: '补血能量', colorHex: '#E0A899' }
]



17.4 总结:

在这里插入图片描述

通过对这款宠物烘焙工坊应用的深度剖析,我们可以清晰地看到基于HarmonyOS ArkTS API 24 的声明式UI开发范式在实际项目中的应用效果。简洁的语法、强大的组件库、高效的渲染引擎,让开发者能够用更少的代码构建出体验更好的应用。

HarmonyOS 6.1.1 带来的不仅是版本号的提升,更是整个开发生态的成熟和完善。从 ArkTS 语言特性到 ArkUI 框架能力,从开发工具到应用市场,鸿蒙生态正在以惊人的速度成长和进化。

Logo

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

更多推荐