一、鸿蒙开发背景与ArkTS语言概述

鸿蒙操作系统(HarmonyOS)是华为面向万物互联时代打造的分布式操作系统,其应用开发框架提供了一套全新的声明式UI开发范式。在这一范式下,开发者使用ArkTS语言来构建用户界面。ArkTS是在TypeScript基础上扩展而来的编程语言,它保留了TypeScript的静态类型检查能力,同时深度集成了鸿蒙系统的UI声明能力。与传统的命令式UI开发不同,ArkTS采用声明式语法,开发者只需描述界面"是什么样子",而不需要关心"怎么渲染"——框架会自动追踪状态变化并驱动UI更新,这种数据驱动的设计理念极大地简化了复杂界面的开发流程。

ArkTS语言的核心优势在于它将UI描述与业务逻辑有机融合在同一个代码文件中。开发者可以在一个.ets文件中同时定义数据接口、状态变量、业务方法和UI布局,这种"高内聚"的代码组织方式使得组件的职责边界清晰,维护成本显著降低。ArkTS通过丰富的装饰器系统(如@Entry@Component@State@Builder等)来标注不同类型的代码块,编译器据此生成高效的渲染逻辑。与React的JSX或Flutter的Widget树不同,ArkTS的声明式语法更加接近自然语言描述,结构化嵌套的组件调用方式让UI层级的可读性极强。

二、声明式UI范式与ArkUI组件体系

鸿蒙的声明式UI范式遵循"状态驱动视图"的核心原则。当状态变量发生变化时,框架会自动比较新旧状态差异,仅更新受影响的UI节点,这一过程称为"差分更新"或"重渲染"。开发者无需手动调用setText()setColor()等方法来更新界面,只需修改被@State等装饰器标注的变量,UI就会自动响应。这种模式不仅减少了样板代码,更重要的是消除了状态与视图不一致的风险——界面始终是状态的忠实映射。

ArkUI是鸿蒙的UI组件框架,提供了从基础容器到高级交互组件的完整组件体系。容器组件如Column(纵向布局)、Row(横向布局)、Stack(层叠布局)、Flex(弹性布局)等负责组织和定位子组件;基础组件如Text(文本)、Image(图片)、TextInput(输入框)、Toggle(开关)、Progress(进度条)等负责内容展示和用户交互;滚动组件如Scroll提供了内容超出屏幕时的滚动能力。每个组件都通过链式调用的方式设置属性,如.fontSize().fontColor().backgroundColor().borderRadius().margin().padding()等,这种链式属性设置方式使得UI描述既简洁又富有表现力。

在ArkUI中,组件的布局采用Flexbox思想,支持主轴和交叉轴的对齐控制。justifyContent控制主轴方向的对齐方式,alignItems控制交叉轴方向的对齐方式。layoutWeight属性允许子组件按比例分配父容器的剩余空间,这在实现等宽列、自适应宽度等布局时非常有用。此外,position属性支持绝对定位,zIndex属性控制组件的层级堆叠顺序,这些能力为实现弹窗、遮罩层等浮动UI提供了基础。配合animateTo动画API,开发者可以让任意属性变化都带有平滑的过渡效果,从而打造出富有动态质感的用户界面。

三、项目概览:营养餐盘应用架构分析

本文将深入剖析一个完整的鸿蒙ArkTS应用——营养餐盘(MEAL PLATE)。这是一个面向健康管理场景的饮食追踪应用,具备五个标签页(餐盘、食物库、记录、营养、我的)和六个弹窗(记一餐、自定义食物、编辑记录、删除确认、食物详情、饮水打卡),涵盖了环形进度展示、营养素横条图、每周柱状图、饮水格子条等多种数据可视化形式,并融入了餐盘旋转动画和光点脉冲特效。通过对这个项目的逐段解析,读者将全面掌握ArkTS从数据建模、状态管理到UI构建、动画实现的完整开发流程。

下面是整个应用的整体架构流程图,展示了从数据层到视图层的完整流转关系:

视图层

业务逻辑层

状态管理层

数据层

MealT 餐记录接口

FoodT 食物接口

WeekT 周热量接口

NutriT 营养素接口

WaterT 饮水知识接口

MEALS0 餐记录数据

FOODS0 食物库数据

WEEKS0 周热量数据

NUTRIS0 营养素数据

WATERS0 饮水知识数据

meals 餐记录列表

foods 食物列表

currentTab 当前标签

waterCups 饮水杯数

弹窗开关组 showAddMeal等

动画状态 plateAngle dotOp dotScale

confirmAddMeal 添加餐记录

confirmAddFood 添加食物

confirmEdit 编辑记录

doDel 删除记录

toggleOften 切换常吃

confirmWater 饮水打卡

headerBar 头部栏

plateTab 餐盘页

foodTab 食物库页

recordTab 记录页

nutriTab 营养页

mineTab 我的页

6个Overlay弹窗

build主入口

四、数据接口定义:TypeScript类型系统在ArkTS中的应用

interface MealT {
  id: number
  meal: string
  icon: string
  food: string
  kcal: number
  portion: number
  time: string
  protein: number
  carb: number
  fat: number
}

interface FoodT {
  id: number
  icon: string
  name: string
  kcal: number
  cat: string
  protein: number
  often: boolean
}

interface WeekT {
  day: string
  kcal: number
}

interface NutriT {
  name: string
  got: number
  target: number
  color: string
  unit: string
}

interface WaterT {
  icon: string
  label: string
  desc: string
}

在这里插入图片描述

应用的第一步是定义数据模型。ArkTS继承了TypeScript的interface关键字来定义数据接口,这些接口充当了整个应用的数据契约。首先看到的是MealT接口,它描述了一条饮食记录的完整结构:id是唯一标识符,meal标识餐次类型(早餐、午餐、晚餐、加餐),icon存储Emoji表情作为视觉图标,food是食物名称描述,kcal记录每份热量值,portion记录份数(支持小数,如0.5份),time记录用餐时间,protein/carb/fat分别记录蛋白质、碳水、脂肪的克数。

技术概念:ArkTS的interface与TypeScript的interface
ArkTS中的interface用法与TypeScript基本一致,用于定义对象的形状(Shape)。但ArkTS对类型安全的要求更加严格——所有变量必须显式声明类型,不支持TypeScript中的隐式any类型。这种严格的类型系统在编译期就能捕获大量潜在错误,是ArkTS区别于普通TypeScript的重要特征之一。

FoodT接口描述的是食物库中的食物条目,与MealT有所不同。FoodT更侧重于食物本身的属性:name是食物名称,cat是分类(主食、蛋白、蔬果、零食、饮品),often是一个布尔值,标记是否为"常吃"食物。值得注意的是,FoodT中没有carbfat字段,只有protein,这是因为食物库主要关注热量和蛋白质这两个核心指标,简化了数据结构。

WeekTNutriTWaterT三个接口则分别服务于不同的展示场景。WeekT只有daykcal两个字段,用于每周柱状图展示;NutriT包含namegot(已摄入量)、target(目标量)、color(展示颜色)、unit(单位),用于营养素横条图展示;WaterT包含iconlabeldesc,用于饮水知识的时间线展示。这些接口的设计体现了"按需定义"的原则——每个接口只包含其展示场景所需的字段,避免数据冗余。

五、静态数据常量:应用初始状态的构建

const TABS0: Array<string> = ['餐盘', '食物库', '记录', '营养', '我的']

const MEALS0: Array<MealT> = [
  { id: 1, meal: '早餐', icon: '🥣', food: '燕麦牛奶碗 + 水煮蛋', kcal: 348, portion: 1, time: '07:40', protein: 18, carb: 46, fat: 9 },
  { id: 2, meal: '加餐', icon: '🍎', food: '红富士苹果一个', kcal: 95, portion: 1, time: '10:20', protein: 1, carb: 24, fat: 0 },
  { id: 3, meal: '午餐', icon: '🍗', food: '黄焖鸡米饭(少汁)', kcal: 612, portion: 1, time: '12:15', protein: 34, carb: 78, fat: 16 },
  { id: 4, meal: '加餐', icon: '☕', food: '燕麦拿铁(无糖)', kcal: 142, portion: 1, time: '15:30', protein: 8, carb: 14, fat: 5 },
  { id: 5, meal: '晚餐', icon: '🥗', food: '鸡胸牛油果沙拉', kcal: 386, portion: 1, time: '18:50', protein: 32, carb: 22, fat: 18 },
  { id: 6, meal: '加餐', icon: '🥛', food: '无糖酸奶半盒', kcal: 68, portion: 0.5, time: '21:10', protein: 6, carb: 8, fat: 2 },
  { id: 7, meal: '早餐', icon: '🥯', food: '全麦贝果 + 奶酪', kcal: 376, portion: 1, time: '昨天 07:55', protein: 16, carb: 52, fat: 11 },
  { id: 8, meal: '午餐', icon: '🍜', food: '番茄牛肉面', kcal: 548, portion: 1, time: '昨天 12:30', protein: 28, carb: 82, fat: 10 },
  { id: 9, meal: '晚餐', icon: '🐟', food: '清蒸鲈鱼 + 杂粮饭', kcal: 428, portion: 1, time: '昨天 19:05', protein: 38, carb: 42, fat: 9 },
  { id: 10, meal: '加餐', icon: '🌰', food: '每日坚果一袋', kcal: 156, portion: 1, time: '前天 15:40', protein: 5, carb: 12, fat: 12 },
  { id: 11, meal: '午餐', icon: '🍱', food: '轻食便当(鸡胸藜麦)', kcal: 468, portion: 1, time: '前天 12:20', protein: 36, carb: 48, fat: 12 },
  { id: 12, meal: '晚餐', icon: '🍲', food: '冬瓜排骨汤 + 米饭', kcal: 495, portion: 1, time: '前天 18:40', protein: 26, carb: 58, fat: 15 },
  { id: 13, meal: '加餐', icon: '🍪', food: '两块曲奇(罪恶)', kcal: 232, portion: 2, time: '周一 16:00', protein: 3, carb: 32, fat: 11 }
]

const FOODS0: Array<FoodT> = [
  { id: 1, icon: '🍚', name: '白米饭', kcal: 116, cat: '主食', protein: 3, often: true },
  { id: 2, icon: '🥔', name: '蒸红薯', kcal: 90, cat: '主食', protein: 2, often: true },
  { id: 3, icon: '🌾', name: '燕麦片', kcal: 367, cat: '主食', protein: 13, often: true },
  { id: 4, icon: '🍗', name: '鸡胸肉', kcal: 133, cat: '蛋白', protein: 24, often: true },
  { id: 5, icon: '🥩', name: '瘦牛肉', kcal: 143, cat: '蛋白', protein: 21, often: false },
  { id: 6, icon: '🐟', name: '鲈鱼', kcal: 105, cat: '蛋白', protein: 19, often: true },
  { id: 7, icon: '🥦', name: '西兰花', kcal: 27, cat: '蔬果', protein: 3, often: true },
  { id: 8, icon: '🍅', name: '番茄', kcal: 15, cat: '蔬果', protein: 1, often: false },
  { id: 9, icon: '🥑', name: '牛油果', kcal: 161, cat: '蔬果', protein: 2, often: false },
  { id: 10, icon: '🍪', name: '曲奇饼干', kcal: 502, cat: '零食', protein: 6, often: false },
  { id: 11, icon: '🧋', name: '珍珠奶茶', kcal: 437, cat: '饮品', protein: 3, often: false },
  { id: 12, icon: '🥛', name: '无糖酸奶', kcal: 62, cat: '饮品', protein: 10, often: true }
]

const WEEKS0: Array<WeekT> = [
  { day: '一', kcal: 1980 },
  { day: '二', kcal: 2260 },
  { day: '三', kcal: 1740 },
  { day: '四', kcal: 2130 },
  { day: '五', kcal: 2480 },
  { day: '六', kcal: 1890 },
  { day: '日', kcal: 1651 }
]

const NUTRIS0: Array<NutriT> = [
  { name: '蛋白质', got: 99, target: 120, color: '#F2762E', unit: 'g' },
  { name: '碳水', got: 201, target: 240, color: '#4E8BC8', unit: 'g' },
  { name: '脂肪', got: 61, target: 65, color: '#9B6BC3', unit: 'g' },
  { name: '膳食纤维', got: 14, target: 25, color: '#5AA469', unit: 'g' }
]

const WATERS0: Array<WaterT> = [
  { icon: '🌅', label: '晨起一杯', desc: '唤醒肠胃,补足夜间流失' },
  { icon: '☕', label: '上午一杯', desc: '搭配加餐,拒绝含糖饮料' },
  { icon: '🍚', label: '饭前一杯', desc: '增加饱腹感,防止吃多' },
  { icon: '🏃', label: '运动前后', desc: '少量多次,别等渴了才喝' },
  { icon: '🌙', label: '睡前小口', desc: '半杯即可,避免起夜' }
]

const MEALKINDS0: Array<string> = ['早餐', '午餐', '晚餐', '加餐']
const CATS0: Array<string> = ['全部', '主食', '蛋白', '蔬果', '零食', '饮品']
const KCAL_TARGET0: number = 2100

在这里插入图片描述

在定义了数据接口之后,应用通过一系列const常量来初始化静态数据。这些常量名都以0结尾,这是一种命名约定,暗示它们是"第0版"的初始数据,后续可能会被状态变量覆盖或修改。TABS0定义了底部导航栏的五个标签名称;MEALS0是核心的餐记录数组,包含13条记录,覆盖了今天、昨天、前天和周一的用餐数据,每条记录都完整填充了MealT接口定义的所有字段。

FOODS0是食物库数据,包含12种常见食物,按主食、蛋白、蔬果、零食、饮品五个分类组织。每种食物都标注了每100g的热量值和蛋白质含量,以及是否为常吃食物。WEEKS0是本周七天的热量摄入数据,用于柱状图展示。NUTRIS0定义了四大营养素的当日摄入量和目标值,每种营养素都关联了一个展示颜色——蛋白质用橙色、碳水用蓝色、脂肪用紫色、膳食纤维用绿色,这种颜色编码在整个应用的UI中保持一致。WATERS0是饮水知识卡片数据,按时间线排列了五个饮水建议。

技术概念:const常量与@State状态变量的区别
const声明的常量在编译期就确定了值,运行时不可修改。而@State装饰的状态变量虽然也可以赋予初始值,但它的值在运行时可以被修改,且修改后会自动触发UI重渲染。在本应用中,静态数据常量作为初始值赋给@State变量后,所有的数据变更操作(增删改)都作用于@State变量,而原始常量保持不变。

MEALKINDS0CATS0是两个字符串数组,分别定义了餐次类型和食物分类。MEALKINDS0有四个元素(早餐、午餐、晚餐、加餐),在添加餐记录和编辑餐记录的弹窗中用作选择项。CATS0有六个元素,第一个是"全部",用于食物库的分类筛选——当用户选择"全部"时显示所有食物,选择其他分类时只显示对应类别的食物。KCAL_TARGET0是每日热量目标值2100 kcal,这个值在多处被引用用于计算热量百分比和剩余可用热量。

六、辅助函数:餐次颜色映射

function mealColor0(m: string): string {
  if (m === '早餐') {
    return '#F2B23E'
  }
  if (m === '午餐') {
    return '#F2762E'
  }
  if (m === '晚餐') {
    return '#9B6BC3'
  }
  return '#5AA469'
}

在这里插入图片描述

mealColor0是一个全局辅助函数,接收餐次名称字符串,返回对应的十六进制颜色值。早餐映射为金黄色#F2B23E,午餐映射为橙色#F2762E,晚餐映射为紫色#9B6BC3,加餐则默认返回绿色#5AA469。这个函数在多处被调用——餐记录列表中的餐次标签背景色、记录页中的餐次图标背景色、热量数字颜色等都使用它来获取对应餐次的主题色。

技术概念:全局函数与组件方法的区别
在ArkTS中,使用function关键字定义在组件外部的函数是全局函数,它不依赖任何组件实例,可以在文件的任何位置调用。而定义在struct内部的方法则属于组件实例,需要通过this来调用。全局函数适合那些纯逻辑计算、不涉及组件状态的工具函数,如颜色映射、格式化等。

这种颜色映射设计体现了"视觉一致性"原则——同一种餐次在整个应用中始终使用同一种颜色,帮助用户快速识别。函数内部使用简单的if条件判断而非switch或对象映射,这是因为餐次类型只有四种,条件判断的可读性已经足够好,没有必要过度设计。

七、组件入口与状态变量声明

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State meals: Array<MealT> = MEALS0
  @State foods: Array<FoodT> = FOODS0
  @State waterCups: number = 5
  @State catIdx: number = 0

  // 弹框开关
  @State showAddMeal: boolean = false
  @State showAddFood: boolean = false
  @State showEdit: boolean = false
  @State showDel: boolean = false
  @State showFood: boolean = false
  @State showWater: boolean = false

  // 记一餐
  @State amKind: number = 0
  @State amFood: number = 0
  @State amPortionTen: number = 2
  @State amSync: boolean = true

  // 添加食物
  @State afName: string = ''
  @State afCat: number = 0
  @State afKcal: number = 150
  @State afOften: boolean = false

  // 编辑餐记录
  @State editIdx: number = 0
  @State editKind: number = 0
  @State editPortionTen: number = 2

  // 删除
  @State delIdx: number = 0
  @State dangerDel: boolean = false

  // 食物详情
  @State foodIdx: number = 0

  // 饮水打卡
  @State waterSlot: number = 0

  // 特效
  @State plateAngle: number = 0
  @State dotOp: number = 0.3
  @State dotScale: number = 0.9

在这里插入图片描述

这是整个应用的核心组件定义。@Entry装饰器标记Index结构体为应用的入口组件——每个ArkTS页面有且仅有一个@Entry组件,它是页面组件树的根节点。@Component装饰器标记Index为一个自定义组件,使其可以被复用和组合。这两个装饰器搭配使用是鸿蒙页面组件的标准写法。

@State是ArkTS中最核心的状态管理装饰器。被@State标注的变量成为"可观察的状态变量",当其值发生变化时,框架会自动重新渲染引用了该变量的UI部分。这里声明了大量状态变量,可以按功能分为几组:currentTab控制当前激活的标签页;mealsfoods是两个核心数据源,分别存储餐记录列表和食物列表;waterCups记录今日饮水杯数;catIdx控制食物库的分类筛选索引。

技术概念:@State装饰器的工作原理
@State装饰器在底层通过观察者模式实现状态追踪。当组件初次渲染时,框架会记录每个@State变量在UI树中的引用关系。当变量值被修改时,框架会通知所有引用了该变量的UI节点进行差分更新。需要注意的是,对于数组类型的状态变量,必须赋值一个新数组才能触发更新——直接通过push()splice()修改原数组不会触发重渲染。这就是为什么后续代码中所有的数组操作都采用了concat()map()filter()等返回新数组的方法。

六组弹窗开关变量(showAddMealshowAddFoodshowEditshowDelshowFoodshowWater)都是布尔类型,初始值为false。每个开关控制一个弹窗的显示与隐藏,在build()方法中通过if条件判断来决定是否渲染对应的弹窗组件。这种"布尔开关+条件渲染"的模式是ArkTS实现弹窗的最简方案。

接下来是各弹窗内部的表单状态变量。"记一餐"弹窗使用am前缀(Add Meal):amKind记录选中的餐次索引,amFood记录选中的食物索引,amPortionTen记录份量值(以0.5为步进,用整数存储再除以2来避免浮点精度问题),amSync控制是否同步给营养师。"添加食物"弹窗使用af前缀(Add Food):afName是食物名称输入框的值,afCat是分类索引,afKcal是热量值,afOften是是否加入常吃清单。"编辑餐记录"弹窗使用edit前缀:editIdx是编辑的记录索引,editKind是修改后的餐次,editPortionTen是修改后的份量。"删除"弹窗使用del前缀:delIdx是删除索引,dangerDel是二次确认开关。

技术概念:份量值的整数化存储技巧
amPortionTeneditPortionTen的命名中带有"Ten"后缀,实际存储的是份量乘以2后的整数值(如1.0份存为2,1.5份存为3,2.0份存为4)。这种设计避免了浮点数运算的精度问题——在UI上显示时除以2即可得到实际份量值。这是一个实用的工程技巧,特别适用于需要0.5步进的数值选择器场景。

最后是动画特效相关的状态变量:plateAngle控制餐盘图标的旋转角度,dotOp控制营养素指示点的透明度,dotScale控制指示点的缩放比例。这三个变量在aboutToAppear生命周期中被设置为无限循环动画的目标值,驱动餐盘页的持续动效。

八、生命周期与动画初始化:aboutToAppear

aboutToAppear(): void {
  this.getUIContext().animateTo({ duration: 9000, iterations: -1, curve: Curve.Linear }, () => {
    this.plateAngle = 360
  })
  this.getUIContext().animateTo({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.dotOp = 0.85
    this.dotScale = 1.15
  })
}

在这里插入图片描述

aboutToAppear是ArkTS组件的生命周期回调方法,在组件创建后、UI渲染前被调用。它是初始化状态、启动动画、发起网络请求等操作的理想位置。在这个方法中,通过this.getUIContext().animateTo()启动了两个无限循环动画。

第一个动画驱动餐盘旋转:duration: 9000表示动画时长9秒,iterations: -1表示无限循环,curve: Curve.Linear表示匀速线性动画。动画闭包中将this.plateAngle从0设置到360,这意味着餐盘图标会以9秒一周的速度持续匀速旋转。Linear曲线确保旋转速度均匀,不会出现加速或减速的"呼吸感",模拟了餐盘匀速转动的物理效果。

第二个动画驱动营养素指示点的脉冲效果:duration: 1200表示动画时长1.2秒,iterations: -1表示无限循环,playMode: PlayMode.Alternate表示交替播放——即正向播放完毕后反向播放,形成"呼吸"效果,curve: Curve.EaseInOut表示缓入缓出曲线。动画闭包中将this.dotOp从0.3设置到0.85(透明度变化),this.dotScale从0.9设置到1.15(缩放变化)。由于Alternate模式,指示点会在0.3到0.85透明度之间、0.9到1.15缩放之间来回过渡,形成脉冲呼吸的视觉效果。

技术概念:animateTo动画API详解
animateTo是ArkUI提供的命令式动画API,接收两个参数:第一个是动画选项对象(包含duration时长、iterations循环次数、curve动画曲线、playMode播放模式等),第二个是闭包函数——闭包内对状态变量的修改会以动画形式过渡,而非瞬间跳变。iterations设为-1表示无限循环;PlayMode.Alternate表示正向播放完毕后自动反向播放,适合做"呼吸"效果;Curve.Linear是匀速曲线,Curve.EaseInOut是缓入缓出曲线。与CSS动画不同,animateTo直接操作状态变量,动画过程中的每一帧都会触发UI重渲染。

技术概念:getUIContext()的作用
this.getUIContext()返回当前组件的UI上下文对象,通过它可以访问一些与UI相关的系统能力,如动画、弹窗、页面路由等。在较新的ArkUI版本中,animateTo需要通过getUIContext()来调用,这是为了确保动画在正确的UI上下文中执行。这种方式比全局直接调用animateTo更加安全,避免了多线程或多实例场景下的上下文混乱。

九、计算方法:热量与营养素的实时统计

todayKcal(): number {
  let sum: number = 0
  this.meals.forEach((m: MealT) => {
    sum += Math.round(m.kcal * m.portion)
  })
  return sum
}

todayProtein(): number {
  let sum: number = 0
  this.meals.forEach((m: MealT) => {
    sum += Math.round(m.protein * m.portion)
  })
  return sum
}

kcalPct(): number {
  return Math.min(Math.round(this.todayKcal() / KCAL_TARGET0 * 100), 100)
}

在这里插入图片描述

这三个方法是应用的核心计算逻辑。todayKcal()遍历所有餐记录,将每条记录的kcal(每份热量)乘以portion(份数),用Math.round()四舍五入后累加,返回今日总摄入热量。todayProtein()的逻辑完全类似,只是累加的是蛋白质值。kcalPct()计算今日热量摄入占目标的百分比,使用Math.min()确保返回值不超过100——即使实际摄入超过了目标值,环形进度条也最多显示满格。

这些方法没有使用@State或任何缓存机制,每次调用都会重新计算。这在数据量不大时是完全可接受的——遍历十几条记录的计算开销可以忽略不计。但由于它们在UI中被多次调用(如头部栏、餐盘页、记录页都调用了todayKcal()),当数据量增大时可能需要考虑缓存优化。

技术概念:ArkTS中的方法调用与UI刷新
在ArkTS的build()方法和@Builder方法中调用组件方法时,方法返回值会被自动纳入状态依赖追踪。也就是说,如果todayKcal()内部引用了@State变量this.meals,那么当meals发生变化时,所有调用了todayKcal()的UI部分都会自动重渲染。这种"方法即派生状态"的设计让开发者无需手动管理计算属性的依赖关系。

十、数据变更逻辑:增删改查操作

openAddMeal(): void {
  this.amKind = 0
  this.amFood = 0
  this.amPortionTen = 2
  this.amSync = true
  this.showAddMeal = true
}

confirmAddMeal(): void {
  const f: FoodT = this.foods[this.amFood]
  const portion: number = this.amPortionTen / 2
  this.meals = [{ id: this.meals.length + 20, meal: MEALKINDS0[this.amKind], icon: f.icon, food: f.name + (portion === 1 ? '' : ' ×' + portion.toString()), kcal: f.kcal, portion: portion, time: '21:45', protein: f.protein, carb: 24, fat: 6 } as MealT].concat(this.meals)
  this.showAddMeal = false
}

在这里插入图片描述

openAddMeal()是打开"记一餐"弹窗的初始化方法。它将表单状态变量重置为默认值:餐次选第一项(早餐)、食物选第一项、份量设为2(即1.0份)、同步开关设为true,然后将showAddMeal设为true触发弹窗显示。这种"先重置再显示"的模式确保每次打开弹窗时表单都是干净的初始状态。

confirmAddMeal()是确认添加餐记录的核心方法。首先从食物列表中取出当前选中的食物f,计算实际份量portion(整数除以2)。然后使用[{...} as MealT].concat(this.meals)创建一个新数组——新记录被放在数组头部(concatthis.meals拼接到新记录后面),这样最新的记录会显示在列表顶部。新记录的id使用this.meals.length + 20来生成一个不太可能重复的ID(加20是为了避免与初始数据的ID冲突)。食物名称后面会根据份量添加" ×1.5"这样的标注(当份量不等于1时)。最后关闭弹窗。

技术概念:不可变数据更新模式
注意this.meals = [...].concat(this.meals)这行代码——它没有使用this.meals.unshift()来在原数组头部插入元素,而是创建了一个全新的数组再赋值给this.meals。这就是ArkTS状态更新的"不可变数据"模式:必须赋值新数组才能触发@State的变更检测。如果使用push()unshift()splice()等原地修改方法,虽然数组内容确实变了,但框架的依赖追踪系统不会感知到变化,UI不会刷新。这与React中的setState要求返回新数组是同样的道理。

openAddFood(): void {
  this.afName = ''
  this.afCat = 0
  this.afKcal = 150
  this.afOften = false
  this.showAddFood = true
}

confirmAddFood(): void {
  const icon: string = this.afCat === 0 ? '🍚' : (this.afCat === 1 ? '🍗' : (this.afCat === 2 ? '🥦' : (this.afCat === 3 ? '🍪' : '🥤')))
  this.foods = [{ id: this.foods.length + 20, icon: icon, name: this.afName.length > 0 ? this.afName : '自定义食物', kcal: this.afKcal, cat: CATS0[this.afCat + 1], protein: 5, often: this.afOften } as FoodT].concat(this.foods)
  this.showAddFood = false
}

在这里插入图片描述

confirmAddFood()处理自定义食物的添加。有趣的是图标的选择逻辑——通过嵌套的三元运算符根据分类索引afCat来匹配对应的Emoji图标:主食用🍚、蛋白用🍗、蔬果用🥦、零食用🍪、饮品用🥤。食物名称如果用户留空则默认填入"自定义食物"。分类通过CATS0[this.afCat + 1]获取——因为CATS0的第一个元素是"全部",实际分类从索引1开始,所以需要+1偏移。蛋白质含量固定设为5g(因为自定义食物没有精确的营养数据,使用一个保守的默认值)。

openEdit(idx: number): void {
  this.editIdx = idx
  this.editKind = MEALKINDS0.indexOf(this.meals[idx].meal)
  if (this.editKind < 0) {
    this.editKind = 0
  }
  this.editPortionTen = Math.round(this.meals[idx].portion * 2)
  this.showEdit = true
}

confirmEdit(): void {
  this.meals = this.meals.map((m: MealT, i: number) => {
    if (i === this.editIdx) {
      return { id: m.id, meal: MEALKINDS0[this.editKind], icon: m.icon, food: m.food, kcal: m.kcal, portion: this.editPortionTen / 2, time: m.time, protein: m.protein, carb: m.carb, fat: m.fat } as MealT
    }
    return m
  })
  this.showEdit = false
}

编辑功能的实现采用了map()遍历模式。openEdit(idx)接收要编辑的记录索引,从现有记录中读取餐次和份量填充到编辑表单中。MEALKINDS0.indexOf()用于将餐次名称反查为索引值,如果没找到则默认设为0。confirmEdit()使用this.meals.map()遍历所有记录,当索引匹配editIdx时返回一个修改后的新对象(更新餐次和份量),其他记录原样返回。这种写法确保了只替换目标记录,其余记录的引用不变,最大化了差分更新的效率。

openDel(idx: number): void {
  this.delIdx = idx
  this.dangerDel = false
  this.showDel = true
}

doDel(): void {
  this.meals = this.meals.filter((m: MealT, i: number) => i !== this.delIdx)
  this.showDel = false
}

删除功能使用filter()方法过滤掉目标索引的记录。openDel(idx)保存删除索引并重置二次确认开关,doDel()执行实际删除——filter会返回一个不包含目标索引的新数组。这里需要注意,使用索引作为过滤条件在并发删除场景下可能有问题(删除一条后其他记录的索引会变化),但在单用户操作的应用中这是可以接受的。

openFood(f: FoodT): void {
  const idx: number = this.foods.indexOf(f)
  if (idx >= 0) {
    this.foodIdx = idx
  }
  this.showFood = true
}

eatFood(): void {
  const f: FoodT = this.foods[this.foodIdx]
  this.meals = [{ id: this.meals.length + 30, meal: '加餐', icon: f.icon, food: f.name, kcal: f.kcal, portion: 1, time: '21:46', protein: f.protein, carb: 24, fat: 6 } as MealT].concat(this.meals)
  this.showFood = false
}

toggleOften(f: FoodT): void {
  this.foods = this.foods.map((x: FoodT) => {
    if (x.id === f.id) {
      return { id: x.id, icon: x.icon, name: x.name, kcal: x.kcal, cat: x.cat, protein: x.protein, often: !x.often } as FoodT
    }
    return x
  })
}

openFood(f)接收一个FoodT对象,通过indexOf()找到其在数组中的索引并保存,然后显示食物详情弹窗。eatFood()从食物详情弹窗中将当前食物直接加入今日餐盘——创建一条新的餐记录,餐次默认为"加餐",份数为1。toggleOften(f)切换食物的"常吃"状态,同样使用map()遍历返回新数组,通过id匹配目标食物后取反often字段。

技术概念:对象引用与indexOf查找
openFood方法中使用this.foods.indexOf(f)来查找食物索引,这依赖于对象引用相等性——传入的f必须是foods数组中的同一个对象引用。在ForEach渲染中,每个迭代项都是数组中的原始对象引用,因此indexOf能正确找到。但如果在传递过程中创建了对象的副本,indexOf将返回-1。这就是为什么代码中加了if (idx >= 0)的保护判断。

openWater(): void {
  this.waterSlot = 0
  this.showWater = true
}

confirmWater(): void {
  this.waterCups += 1
  this.showWater = false
}

饮水打卡的逻辑最为简单——openWater()重置时间段选择并打开弹窗,confirmWater()将饮水杯数加1并关闭弹窗。每次打卡增加一杯水(250ml),在UI上会实时反映到饮水格子条和饮水统计文字中。

下面是应用中数据变更操作的完整流程图:

记一餐

添加食物

编辑记录

删除记录

饮水打卡

用户操作

操作类型

openAddMeal

填充表单默认值

showAddMeal = true

用户选择餐次/食物/份量

confirmAddMeal

新记录插入meals头部

showAddMeal = false

openAddFood

填充表单默认值

showAddFood = true

用户输入名称/分类/热量

confirmAddFood

新食物插入foods头部

showAddFood = false

openEdit idx

读取现有数据填充表单

showEdit = true

用户修改餐次/份量

confirmEdit

map遍历替换目标记录

showEdit = false

openDel idx

保存索引 showDel = true

用户勾选确认

doDel

filter过滤目标记录

showDel = false

openWater

showWater = true

用户选择时间段

confirmWater

waterCups += 1

showWater = false

十一、头部栏构建:电商风格的横向布局

@Builder
headerBar() {
  Column() {
    Row() {
      Column() {
        Text('营养餐盘')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('MEAL PLATE')
          .fontSize(8)
          .fontColor('#FFE0C7')
          .fontWeight(FontWeight.Bold)
          .letterSpacing(2)
          .margin({ top: 1 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 14 })

      Row() {
        Text('🔍')
          .fontSize(13)
        Text('查食物热量,一键记餐')
          .fontSize(11)
          .fontColor('#C98A5E')
          .margin({ left: 6 })
      }
      .layoutWeight(1)
      .height(34)
      .borderRadius(17)
      .backgroundColor('#FFFFFF')
      .margin({ left: 12, right: 8 })
      .justifyContent(FlexAlign.Center)

      Text('🧺')
        .fontSize(16)
        .margin({ right: 10 })

      Text('碗')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#C9551B')
        .width(30)
        .height(30)
        .textAlign(TextAlign.Center)
        .borderRadius(15)
        .backgroundColor('#FFFFFF')
        .margin({ right: 12 })
    }
    .width('100%')
    .height(54)
    .alignItems(VerticalAlign.Center)

    Row() {
      Text('🥗')
        .fontSize(13)
      Text('今日已摄入 ' + this.todayKcal().toString() + ' / ' + KCAL_TARGET0.toString() + ' kcal · 剩余 ' + (KCAL_TARGET0 - this.todayKcal()).toString())
        .fontSize(10)
        .fontColor('#FFFFFF')
        .margin({ left: 6 })
      Column()
        .layoutWeight(1)
        .height(1)
      Text('记一餐')
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#C9551B')
        .padding({ left: 10, right: 10, top: 3, bottom: 3 })
        .borderRadius(10)
        .backgroundColor('#FFFFFF')
        .onClick(() => {
          this.openAddMeal()
        })
    }
    .width('100%')
    .height(30)
    .alignItems(VerticalAlign.Center)
    .padding({ left: 14, right: 14 })
    .backgroundColor('#E0641F')
  }
  .width('100%')
  .backgroundColor('#F2762E')
}

headerBar()是使用@Builder装饰器标注的UI构建方法。@Builder是ArkTS中用于封装可复用UI片段的装饰器——被@Builder标注的方法返回一段组件树,可以在build()或其他@Builder中通过this.xxx()调用。与独立的@Component不同,@Builder方法不需要额外的组件实例化开销,它更像是UI片段的"宏展开"。

头部栏采用Column容器嵌套两个Row的布局结构。外层Column设置背景色为橙色#F2762E,这是应用的主题色。第一个Row是主标题栏,高度54像素,包含三部分内容:左侧是应用名称(中文"营养餐盘"加英文"MEAL PLATE"的竖排排列,英文使用letterSpacing(2)增加字符间距增强设计感);中间是一个白色圆角搜索框样式的Row,使用layoutWeight(1)占据剩余空间,高度34像素、圆角17像素(高度的一半形成胶囊形),内含搜索图标和提示文字;右侧是购物篮图标和用户头像(用"碗"字代替)。

技术概念:layoutWeight权重分配
layoutWeight是ArkUI中Flex布局的核心属性之一。它指定组件在主轴方向上占据剩余空间的比例权重。当多个子组件设置了layoutWeight时,它们按权重比例分配父容器的剩余空间(扣除固定宽度的子组件后)。在本例中,搜索框Row设置了layoutWeight(1),它会占据父Row中除了标题列和右侧图标以外的所有空间。如果两个组件都设置layoutWeight(1),它们会平分剩余空间;如果一个设为1、另一个设为2,则按1:2分配。

第二个Row是热量状态条,高度30像素,背景色为更深的橙色#E0641F,展示今日已摄入热量和剩余可用热量。这里调用了this.todayKcal()方法来获取实时热量值,字符串拼接将数值嵌入到文案中。右侧是一个白色胶囊形的"记一餐"按钮,点击后调用this.openAddMeal()打开弹窗。值得注意的是两个Column().layoutWeight(1).height(1)的用法——这是一个常见的"弹性分隔符"技巧,用一个空Column占据剩余空间,将右侧按钮推到行尾。

技术概念:@Builder方法的特点与适用场景
@Builder方法与直接在build()中写UI的区别在于:@Builder可以将复杂的UI拆分为逻辑独立的模块,提高代码可读性和复用性。@Builder方法可以接收参数,但参数不会触发UI更新(只有@State变量才能触发更新)。当@Builder内部引用了@State变量时,该变量变化会自动触发@Builder内的UI刷新。需要注意@Builder方法不能有返回值,它的组件树通过调用this.xxx()隐式插入到调用位置。

十二、标签栏与标签项构建

@Builder
tabItem(title: string, icon: string, idx: number) {
  Column() {
    Text(icon)
      .fontSize(17)
    Text(title)
      .fontSize(9)
      .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
      .fontColor(this.currentTab === idx ? '#F2762E' : '#9A8577')
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .padding({ top: 7, bottom: 7 })
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.currentTab = idx
  })
}

@Builder
tabBar() {
  Row() {
    this.tabItem('餐盘', '🍽️', 0)
    this.tabItem('食物库', '📖', 1)
    this.tabItem('记录', '📓', 2)
    this.tabItem('营养', '🧪', 3)
    this.tabItem('我的', '👤', 4)
  }
  .width('94%')
  .borderRadius(24)
  .backgroundColor('#FFFFFF')
  .border({ width: 1, color: '#F0DFD1' })
  .margin({ bottom: 10 })
}

标签栏的实现分为两个@Builder方法:tabItem负责构建单个标签项,tabBar负责构建整个标签栏容器。tabItem接收三个参数:title(标签文字)、icon(Emoji图标)、idx(标签索引)。内部使用Column纵向排列图标和文字,通过三元运算符this.currentTab === idx ? ... : ...来区分选中态和未选中态的样式——选中时文字加粗且颜色为主题橙色,未选中时文字常规且颜色为灰色。

tabBar使用Row横向排列五个tabItem,每个标签项设置了layoutWeight(1)实现等宽分配。容器宽度设为94%(留出两侧3%的边距),圆角24像素,白色背景配以浅色边框。这种悬浮式圆角标签栏是移动端常见的导航设计,比传统的底部固定标签栏更加精致。

技术概念:三元运算符在声明式UI中的条件渲染
在ArkTS的UI描述中,三元运算符condition ? valueA : valueB是实现条件样式的最简方式。它可以用于任何属性值——.fontColor().fontWeight().backgroundColor()等。当条件依赖于@State变量时(如本例中的this.currentTab),条件变化会自动触发UI更新。对于更复杂的条件渲染(如显示/隐藏整个组件块),则使用if...else语句。

十三、餐盘标签页:环形进度与动画特效

@Builder
plateTab() {
  Column() {
    Column() {
      Stack() {
        Progress({ value: this.kcalPct(), total: 100 })
          .width(150)
          .height(150)
          .style({ strokeWidth: 14 })
        Column() {
          Text('🍽️')
            .fontSize(26)
            .rotate({ x: 0, y: 0, z: 1, angle: this.plateAngle })
          Text(this.todayKcal().toString())
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor('#33261C')
            .margin({ top: 2 })
          Text('已摄入 / ' + KCAL_TARGET0.toString() + ' kcal')
            .fontSize(9)
            .fontColor('#9A8577')
            .margin({ top: 2 })
        }
        .justifyContent(FlexAlign.Center)
      }
      .width(150)
      .height(150)
      .margin({ top: 4 })

餐盘页是应用的主页面,也是最复杂的标签页。其核心视觉元素是一个环形进度条,由Stack层叠布局容器包裹Progress进度组件和Column信息列实现。Stack是ArkUI的层叠布局容器,子组件按声明顺序从底到顶堆叠,后声明的子组件覆盖在前面的之上。这里Progress作为底层环形进度条,Column作为上层信息展示——叠放在一起就形成了"环中有内容"的经典设计。

Progress组件通过{ value: this.kcalPct(), total: 100 }参数配置进度值和总量,.style({ strokeWidth: 14 })设置环形线条宽度为14像素。kcalPct()返回的值在0到100之间,环形进度条会按比例填充。Column中先是一个餐盘Emoji图标🍽️,通过.rotate({ x: 0, y: 0, z: 1, angle: this.plateAngle })实现绕Z轴旋转——this.plateAngleaboutToAppear中被设置为360度无限循环动画,因此餐盘图标会持续旋转。图标下方是今日摄入热量的大号数字和目标热量的小号说明文字。

技术概念:Stack层叠布局
Stack是ArkUI中的层叠布局容器,所有子组件默认居中堆叠。可以通过.align(Alignment.Top)等属性调整对齐方式。Stack的子组件按代码声明顺序从底层到顶层排列——第一个子组件在最底层,最后一个在最顶层。这种特性非常适合实现"背景+前景"的叠加效果,如头像上的角标、图片上的蒙层文字、本例中的环形进度条+中心信息等。

技术概念:rotate旋转变换
.rotate()是ArkUI的图形变换属性,接收一个对象参数指定旋转轴和角度。{ x: 0, y: 0, z: 1, angle: this.plateAngle }表示绕Z轴(垂直于屏幕的轴)旋转plateAngle度。当plateAngle从0动画到360时,组件会完成一周完整的旋转。xyz三个值定义旋转轴向量的方向,通常只需要Z轴旋转(平面内旋转),X轴和Y轴旋转会产生3D翻转效果。

      Row() {
        Column() {
          Column()
            .width(8)
            .height(8)
            .borderRadius(4)
            .backgroundColor('#F2762E')
            .opacity(this.dotOp)
            .scale({ x: this.dotScale, y: this.dotScale })
          Text('蛋白质 ' + this.todayProtein().toString() + 'g')
            .fontSize(9)
            .fontColor('#9A8577')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Center)
        .layoutWeight(1)

        Column() {
          Column()
            .width(8)
            .height(8)
            .borderRadius(4)
            .backgroundColor('#4E8BC8')
            .opacity(this.dotOp)
            .scale({ x: this.dotScale, y: this.dotScale })
            Text('碳水 ' + (this.todayKcal() > 1600 ? '201' : '168') + 'g')
            .fontSize(9)
            .fontColor('#9A8577')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Center)
        .layoutWeight(1)

        Column() {
          Column()
            .width(8)
            .height(8)
            .borderRadius(4)
            .backgroundColor('#9B6BC3')
            .opacity(this.dotOp)
            .scale({ x: this.dotScale, y: this.dotScale })
          Text('脂肪 61g')
            .fontSize(9)
            .fontColor('#9A8577')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Center)
        .layoutWeight(1)
      }
      .width('100%')
      .margin({ top: 12 })

环形进度条下方是三个营养素指示点,使用Row横向排列,每个指示点是一个Column包含一个8x8像素的圆点(通过borderRadius(4)实现圆形)和一行说明文字。三个圆点分别使用橙色(蛋白质)、蓝色(碳水)、紫色(脂肪)对应NUTRIS0中的颜色编码。每个圆点都设置了.opacity(this.dotOp).scale({ x: this.dotScale, y: this.dotScale })——这两个属性绑定到在aboutToAppear中初始化的脉冲动画状态变量,使三个圆点同步呼吸闪烁。

技术概念:opacity与scale图形变换
.opacity()控制组件的透明度(0完全透明,1完全不透明),.scale()控制组件的缩放比例(1为原始大小,大于1放大,小于1缩小)。这两个属性与.rotate()一样属于图形变换属性,它们不改变组件的布局空间占用,只是在渲染层做视觉变换。这意味着即使组件被放大到1.15倍,它仍只占据原始大小的布局空间,不会挤压周围组件。这种特性使脉冲动画不会导致周围元素的位置抖动。

碳水值的展示使用了一个内联条件判断:this.todayKcal() > 1600 ? '201' : '168'——当今日热量超过1600 kcal时显示201g碳水,否则显示168g。这是一个简化的动态数据展示逻辑,实际应用中应该从真实数据计算得出。

      Row() {
        Text('今日还剩 ' + (KCAL_TARGET0 - this.todayKcal()).toString() + ' kcal 可用,约等于一个汉堡 🍔')
          .fontSize(9)
          .fontColor('#C9551B')
          .layoutWeight(1)
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Text('记一餐')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .height(36)
          .textAlign(TextAlign.Center)
          .borderRadius(18)
          .backgroundColor('#F2762E')
          .onClick(() => {
            this.openAddMeal()
          })
        Text('喝水打卡')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#4E8BC8')
          .layoutWeight(1)
          .height(36)
          .textAlign(TextAlign.Center)
          .borderRadius(18)
          .border({ width: 1, color: '#4E8BC8' })
          .margin({ left: 8 })
          .onClick(() => {
            this.openWater()
          })
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(16)
    .borderRadius(18)
    .backgroundColor('#FFFFFF')
    .border({ width: 1, color: '#F0DFD1' })

餐盘页主卡片底部是两个操作按钮:左侧"记一餐"按钮使用实心橙色填充,右侧"喝水打卡"按钮使用蓝色描边的空心样式。两个按钮都设置了layoutWeight(1)等宽分配,高度36像素,圆角18像素(高度的一半形成胶囊形)。textAlign(TextAlign.Center)确保文字在按钮中水平居中。两个按钮通过.margin({ left: 8 })保持间距。这种"一主一辅"的按钮设计是移动端常见的操作组合模式——主操作用实心强调,辅操作用描边弱化。

技术概念:borderRadius与胶囊形按钮
borderRadius的值等于组件高度的一半时,矩形会变成完全的胶囊形(两侧半圆)。这是移动端按钮设计的主流圆角方案。ArkUI的borderRadius可以接收一个数字(四角统一)或一个对象{ topLeft, topRight, bottomLeft, bottomRight }来分别设置四个角的圆角,后者可以实现只圆顶部或只圆某一侧的特殊效果。

    Column() {
      Row() {
        Text('💧 饮水打卡')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#33261C')
        Column()
          .layoutWeight(1)
          .height(1)
        Text(this.waterCups.toString() + '/8 杯')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#4E8BC8')
      }
      .width('100%')

      Row() {
        ForEach([0, 1, 2, 3, 4, 5, 6, 7], (n: number) => {
          Column() {
            Text(n < this.waterCups ? '💧' : '🥛')
              .fontSize(16)
              .opacity(n < this.waterCups ? 1 : 0.35)
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
        }, (n: number) => n.toString())
      }
      .width('100%')
      .margin({ top: 10 })

      Text(this.waterCups >= 8 ? '🎉 今日饮水目标达成,约 ' + (this.waterCups * 250).toString() + 'ml' : '还差 ' + (8 - this.waterCups).toString() + ' 杯达成目标,每杯约 250ml')
        .fontSize(9)
        .fontColor('#9A8577')
        .margin({ top: 8 })
        .width('100%')
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .border({ width: 1, color: '#F0DFD1' })
    .margin({ top: 12 })
    .alignItems(HorizontalAlign.Start)

饮水打卡卡片展示了ForEach列表渲染的典型用法。ForEach接收三个参数:第一个是数据源数组[0, 1, 2, 3, 4, 5, 6, 7](代表8个水杯位置),第二个是项渲染函数(根据索引nwaterCups的比较结果决定显示满水杯💧还是空水杯🥛,并设置不同的透明度),第三个是键值生成函数(n: number) => n.toString()(为每个项生成唯一键值,帮助框架高效做差分更新)。

技术概念:ForEach列表渲染详解
ForEach是ArkUI的列表渲染组件,类似于Vue的v-for或React的map。它接收三个参数:arr(数据源数组)、itemGenerator(项渲染闭包,接收项值和索引两个参数)、keyGenerator(键值生成闭包,为每项生成唯一标识)。keyGenerator的作用至关重要——当数据源发生变化时,框架通过键值来判断哪些项是新增的、哪些被删除、哪些需要更新,从而实现高效的差分渲染。如果键值不唯一或不稳定,可能导致渲染异常或性能下降。

卡片底部是一行动态文案:当waterCups >= 8时显示"今日饮水目标达成",否则显示"还差X杯达成目标"。这种条件文案通过三元运算符实现,使UI状态与数据保持同步。

      ForEach(this.meals.slice(0, 6), (m: MealT, i: number) => {
        Row() {
          Column() {
            Text(m.icon)
              .fontSize(18)
          }
          .width(42)
          .height(42)
          .borderRadius(12)
          .backgroundColor('#FFF7EF')
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          .margin({ right: 12 })

          Column() {
            Row() {
              Text(m.food)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#33261C')
              Text(m.meal)
                .fontSize(8)
                .fontColor('#FFFFFF')
                .padding({ left: 6, right: 6, top: 1, bottom: 1 })
                .borderRadius(6)
                .backgroundColor(mealColor0(m.meal))
                .margin({ left: 6 })
            }
            Text(m.time + ' · ' + (m.portion === 1 ? '标准份' : m.portion.toString() + ' 份') + ' · 蛋白 ' + m.protein.toString() + 'g')
              .fontSize(9)
              .fontColor('#9A8577')
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Column() {
            Text('+' + Math.round(m.kcal * m.portion).toString())
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F2762E')
            Text('kcal')
              .fontSize(8)
              .fontColor('#9A8577')
            Text('✎')
              .fontSize(13)
              .fontColor('#4E8BC8')
              .margin({ top: 4 })
              .onClick(() => {
                this.openEdit(i)
              })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .border({ width: 1, color: '#F0DFD1' })
        .margin({ bottom: 8 })
        .alignItems(VerticalAlign.Center)
      }, (m: MealT) => m.id.toString() + 'm' + m.portion.toString())

餐盘页底部的餐记录列表使用ForEach渲染this.meals.slice(0, 6)——只取前6条记录在首页展示。每条记录是一个Row卡片,包含三列:左侧是食物图标的圆角方块(42x42像素,浅橙背景),中间是食物名称(带餐次标签)、时间、份量和蛋白质信息,右侧是热量值和编辑按钮。餐次标签使用mealColor0(m.meal)获取对应颜色作为背景色,白色文字,形成彩色小标签。

键值生成函数(m: MealT) => m.id.toString() + 'm' + m.portion.toString()将记录ID和份量拼接为唯一键值。加入portion是为了在编辑份量时也能触发对应项的重渲染——如果只用id作为键值,修改份量后框架可能认为键值没变而跳过更新。

技术概念:ForEach的slice与键值设计
this.meals.slice(0, 6)创建了一个新数组传递给ForEach,这只是截取了前6条数据用于首页展示。slice不会修改原数组,它返回一个浅拷贝。键值函数中加入portion字段是一个精妙的设计——当用户编辑某条记录的份量时,该记录的键值会发生变化,迫使ForEach重新渲染该项,确保UI数据与状态完全同步。

十四、食物库标签页:分类筛选与卡片网格

@Builder
foodTab() {
  Column() {
    Row() {
      ForEach(CATS0, (c: string, i: number) => {
        Text(c)
          .fontSize(11)
          .fontWeight(this.catIdx === i ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.catIdx === i ? '#FFFFFF' : '#9A8577')
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .borderRadius(14)
          .backgroundColor(this.catIdx === i ? '#F2762E' : '#FFFFFF')
          .border({ width: 1, color: this.catIdx === i ? '#F2762E' : '#F0DFD1' })
          .margin({ right: 8 })
          .onClick(() => {
            this.catIdx = i
          })
      }, (c: string) => c)
    }
    .width('100%')
    .margin({ bottom: 10 })

    Row() {
      Text('共 ' + this.foods.length.toString() + ' 种食物 · 每 100g 热量')
        .fontSize(10)
        .fontColor('#9A8577')
        .layoutWeight(1)
      Text('+ 自定义食物')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .borderRadius(10)
        .backgroundColor('#F2762E')
        .onClick(() => {
          this.openAddFood()
        })
    }
    .width('100%')
    .margin({ bottom: 10 })

食物库页顶部是分类筛选栏,使用ForEach渲染CATS0数组(全部、主食、蛋白、蔬果、零食、饮品)。每个分类是一个胶囊形标签,选中态为橙色实心填充白字,未选中态为白色背景浅色边框灰字。点击标签时更新this.catIdx,由于ForEach内部引用了@State变量catIdx,样式变化会自动触发重渲染。

分类栏下方是统计信息和"自定义食物"按钮。this.foods.length显示当前食物总数,按钮点击后调用this.openAddFood()打开添加食物弹窗。

    Row() {
      ForEach(this.foods.filter((f: FoodT) => this.catIdx === 0 || f.cat === CATS0[this.catIdx]), (f: FoodT) => {
        Column() {
          Column() {
            Row() {
              Text(f.often ? '⭐' : '☆')
                .fontSize(13)
                .onClick(() => {
                  this.toggleOften(f)
                })
            }
            .width('100%')
            .justifyContent(FlexAlign.End)

            Text(f.icon)
              .fontSize(28)
              .margin({ top: -20 })

            Text(f.name)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#33261C')
              .margin({ top: 6 })

            Text(f.kcal.toString() + ' kcal/100g')
              .fontSize(9)
              .fontColor('#F2762E')
              .fontWeight(FontWeight.Bold)
              .margin({ top: 3 })

            Text(f.cat + ' · 蛋白 ' + f.protein.toString() + 'g')
              .fontSize(8)
              .fontColor('#9A8577')
              .margin({ top: 2 })

            Row() {
              Text('看详情')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 4, bottom: 4 })
                .borderRadius(10)
                .backgroundColor('#F2762E')
                .margin({ top: 8 })
            }
            .onClick(() => {
              this.openFood(f)
            })
          }
          .width('100%')
          .padding(10)
          .borderRadius(14)
          .backgroundColor('#FFF7EF')
          .border({ width: 1, color: '#F0DFD1' })
          .alignItems(HorizontalAlign.Center)
        }
        .layoutWeight(1)
        .margin({ bottom: 10 })
      }, (f: FoodT) => f.id.toString() + (f.often ? 'o' : 'n'))
    }
    .width('100%')
  }
  .width('100%')
}

食物卡片网格使用ForEach渲染经过筛选的食物列表。筛选逻辑是this.catIdx === 0 || f.cat === CATS0[this.catIdx]——当catIdx为0(“全部”)时显示所有食物,否则只显示分类匹配的食物。filter()返回一个新数组,这符合ArkTS不可变数据的要求。

每个食物卡片是一个Column,设置了layoutWeight(1)使所有卡片等宽排列。卡片内部从上到下依次是:常吃星标(点击切换toggleOften)、食物图标(28号字大小,margin({ top: -20 })向上偏移与星标重叠)、食物名称、热量值(橙色加粗)、分类和蛋白质信息、"看详情"按钮。卡片使用浅橙背景#FFF7EF和圆角边框,视觉上与页面整体风格统一。

键值函数(f: FoodT) => f.id.toString() + (f.often ? 'o' : 'n')将ID和常吃状态拼接为键值。当用户点击星标切换常吃状态时,键值从1o变为1n(或反过来),迫使ForEach重新渲染该项,更新星标图标。这又是一个利用键值变化驱动重渲染的技巧。

技术概念:filter筛选与ForEach的配合
this.foods.filter(...)在每次重渲染时都会执行,根据当前的catIdx筛选出符合条件的食物。由于filter返回新数组,ForEach会将其视为新数据源进行差分比较。配合精心设计的键值函数,只有真正变化的项(如常吃状态切换的项)才会被重新渲染,其余项保持不动。这种"计算属性+ForEach"的模式是ArkTS处理筛选列表的标准做法。

十五、记录标签页:完整列表与操作入口

@Builder
recordTab() {
  Column() {
    Column() {
      Text('全部饮食记录')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#33261C')
      Text('累计 ' + this.meals.length.toString() + ' 笔 · 今日 ' + this.todayKcal().toString() + ' kcal')
        .fontSize(9)
        .fontColor('#9A8577')
        .margin({ top: 3 })
    }
    .width('100%')
    .margin({ bottom: 10 })

    ForEach(this.meals, (m: MealT, i: number) => {
      Column() {
        Row() {
          Column() {
            Text(m.meal.slice(0, 1))
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
          }
          .width(40)
          .height(40)
          .borderRadius(12)
          .backgroundColor(mealColor0(m.meal))
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          .margin({ right: 12 })

          Column() {
            Text(m.food)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#33261C')
            Text(m.meal + ' · ' + m.time + ' · ' + (m.portion === 1 ? '标准份' : m.portion.toString() + ' 份'))
              .fontSize(9)
              .fontColor('#9A8577')
              .margin({ top: 3 })
            Text('蛋白 ' + m.protein.toString() + 'g · 碳水 ' + m.carb.toString() + 'g · 脂肪 ' + m.fat.toString() + 'g')
              .fontSize(8)
              .fontColor('#9A8577')
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Column() {
            Text(Math.round(m.kcal * m.portion).toString())
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(mealColor0(m.meal))
            Text('kcal')
              .fontSize(8)
              .fontColor('#9A8577')
            Row() {
              Text('✎')
                .fontSize(12)
                .fontColor('#4E8BC8')
                .onClick(() => {
                  this.openEdit(i)
                })
              Text('🗑')
                .fontSize(11)
                .fontColor('#D9534F')
                .margin({ left: 10 })
                .onClick(() => {
                  this.openDel(i)
                })
            }
            .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')
      .padding(12)
      .borderRadius(14)
      .backgroundColor('#FFFFFF')
      .border({ width: 1, color: '#F0DFD1' })
      .margin({ bottom: 8 })
    }, (m: MealT) => m.id.toString() + 'r' + m.portion.toString())
  }
  .width('100%')
}

记录页与餐盘页的餐记录列表类似,但展示了全部记录(不使用slice截取),且每条记录的信息更加详细。左侧图标改为餐次首字的圆形色块(如"早"字配橙色背景),使用m.meal.slice(0, 1)截取餐次名称的第一个字。中间列增加了碳水m.carb和脂肪m.fat的数值展示。右侧除了热量值和编辑按钮外,还增加了删除按钮🗑,点击后调用this.openDel(i)打开删除确认弹窗。

热量数字的颜色使用mealColor0(m.meal)——不同餐次的热量数字显示不同颜色,这种视觉编码帮助用户快速区分记录类型。编辑和删除按钮水平排列,编辑用蓝色,删除用红色🗑,颜色语义清晰。

技术概念:slice字符串截取
m.meal.slice(0, 1)是JavaScript/TypeScript的字符串方法,截取索引0到1(不含1)的子字符串,即第一个字符。ArkTS完全支持标准的字符串方法。在UI中使用首字代替图标是一种常见的简约设计手法,尤其在已有颜色区分的场景下,单个汉字比Emoji更加简洁清晰。

十六、营养标签页:横条图、柱状图与时间线

@Builder
nutriTab() {
  Column() {
    Column() {
      Text('三大营养素达成')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#33261C')
        .width('100%')
      Text('TODAY MACROS TARGET')
        .fontSize(8)
        .fontColor('#C98A5E')
        .letterSpacing(1)
        .margin({ top: 2 })
        .width('100%')

      ForEach(NUTRIS0, (n: NutriT) => {
        Row() {
          Text(n.name)
            .fontSize(10)
            .fontColor('#33261C')
            .width(56)
          Column() {
            Column()
              .width(Math.min(Math.round(n.got / n.target * 100), 100) + '%')
              .height(12)
              .borderRadius(6)
              .backgroundColor(n.color)
          }
          .layoutWeight(1)
          .height(12)
          .borderRadius(6)
          .backgroundColor('#FFF7EF')
          Text(n.got.toString() + '/' + n.target.toString() + n.unit)
            .fontSize(9)
            .fontColor(n.color)
            .width(64)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .margin({ top: 12 })
      }, (n: NutriT) => n.name)
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .border({ width: 1, color: '#F0DFD1' })
    .alignItems(HorizontalAlign.Start)

营养页的第一个卡片是营养素横条图,使用ForEach渲染NUTRIS0数组。每个营养素是一行Row:左侧是名称(固定宽度56像素),中间是横条进度条,右侧是数值文字。横条进度条的实现方式是嵌套两个Column——外层Column是灰色轨道(背景色#FFF7EF,高度12像素,圆角6像素,layoutWeight(1)占据剩余空间),内层Column是彩色填充(宽度按百分比设置,背景色为营养素对应颜色)。

填充宽度的计算方式是Math.min(Math.round(n.got / n.target * 100), 100) + '%'——将已摄入量除以目标量得到百分比,四舍五入后用Math.min限制不超过100,最后拼接百分号作为字符串宽度值。这种"外层轨道+内层填充"的横条图实现是ArkTS中纯代码绘制进度条的常用模式,无需引入图表库。

技术概念:用嵌套Column实现横条进度条
ArkUI没有内置的横向进度条组件(Progress组件主要支持环形和线性两种样式),因此开发者常用"外层容器做轨道+内层容器做填充"的方式手动实现。外层设置固定高度和背景色作为轨道,内层通过百分比宽度和不同背景色作为填充。这种方式的灵活性远高于组件封装——可以自由设置圆角、渐变色、动画等效果。

    Column() {
      Text('本周摄入 vs 目标')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#33261C')
      Text('目标 2100 kcal · 绿色为达标日')
        .fontSize(8)
        .fontColor('#9A8577')
        .margin({ top: 2 })

      Row() {
        ForEach(WEEKS0, (w: WeekT) => {
          Column() {
            Text(w.kcal.toString())
              .fontSize(8)
              .fontColor('#9A8577')
              .margin({ bottom: 3 })
            Column() {
              Column()
                .width('62%')
                .height(w.kcal / 16)
                .borderRadius({ topLeft: 4, topRight: 4 })
                .backgroundColor(w.kcal <= KCAL_TARGET0 ? '#5AA469' : '#F2762E')
            }
            .layoutWeight(1)
            .height('100%')
            .justifyContent(FlexAlign.End)
            .alignItems(HorizontalAlign.Center)

            Text(w.day)
              .fontSize(9)
              .fontColor('#9A8577')
              .margin({ top: 5 })
          }
          .layoutWeight(1)
          .height(150)
          .justifyContent(FlexAlign.End)
          .alignItems(HorizontalAlign.Center)
        }, (w: WeekT) => w.day)
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .border({ width: 1, color: '#F0DFD1' })
    .margin({ top: 12 })

本周柱状图使用ForEach渲染WEEKS0的七天数据。每个柱子是一个Column,固定高度150像素,layoutWeight(1)等宽分配。柱子内部从上到下是:热量数值文字、柱体(高度为w.kcal / 16——将热量值除以16得到像素高度,如1980/16≈124像素)、星期文字。柱体颜色根据是否达标选择绿色或橙色:w.kcal <= KCAL_TARGET0 ? '#5AA469' : '#F2762E'

柱体使用了.borderRadius({ topLeft: 4, topRight: 4 })只圆顶部两个角,模拟传统柱状图的视觉风格。外层Column设置了.justifyContent(FlexAlign.End)使柱体和文字从底部对齐,确保所有柱子都"站"在同一条基线上。

技术概念:borderRadius的分区设置
.borderRadius()可以接收一个对象来分别设置四个角的圆角半径:{ topLeft, topRight, bottomLeft, bottomRight }。这种分区设置在实现"只圆顶部"的卡片或柱状图中非常实用。配合StackColumnjustifyContent(FlexAlign.End),可以实现标准的柱状图底部对齐效果。

    Column() {
      Text('💧 饮水小知识')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#4E8BC8')
        .width('100%')
      ForEach(WATERS0, (w: WaterT) => {
        Row() {
          Column() {
            Text(w.icon)
              .fontSize(14)
            Column()
              .width(2)
              .layoutWeight(1)
              .backgroundColor('#D8E8F5')
              .margin({ top: 4 })
          }
          .width(30)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(w.label)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor('#33261C')
            Text(w.desc)
              .fontSize(9)
              .fontColor('#9A8577')
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .padding({ bottom: 14 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Top)
      }, (w: WaterT) => w.label)
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .border({ width: 1, color: '#F0DFD1' })
    .margin({ top: 12 })
    .alignItems(HorizontalAlign.Start)

饮水小知识卡片使用ForEach渲染WATERS0数组,实现了时间线效果。每个条目是一个Row:左侧是一个30像素宽的Column,包含图标和一条2像素宽的竖线(用Column设置宽2像素、layoutWeight(1)占据剩余高度、浅蓝色背景实现),竖线连接各个时间节点形成时间轴效果;右侧是标签和描述文字。alignItems(VerticalAlign.Top)确保左右两列顶部对齐,竖线从图标下方延伸。

技术概念:用Column模拟时间线竖线
时间线(Timeline)是常见的UI模式,在ArkUI中没有内置组件,但可以通过Column设置固定宽度(如2像素)和layoutWeight(1)来模拟竖线。layoutWeight(1)使竖线Column占据图标下方的所有剩余空间,配合backgroundColor形成视觉上的连接线。这种"用容器模拟线条"的技巧在ArkTS开发中非常普遍。

营养页最后还有一个"营养师点评"卡片,使用绿色背景#EEF7F0区分于其他白色卡片,内含三条文字建议。这些文字是静态的,实际应用中应该由后端营养师生成。

十七、我的标签页:用户信息与统计面板

@Builder
mineTab() {
  Column() {
    Row() {
      Column() {
        Text('盘')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
      }
      .width(56)
      .height(56)
      .borderRadius(28)
      .backgroundColor('#F2762E')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .margin({ right: 12 })

      Column() {
        Text('陈小盘')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#33261C')
        Text('女 · 27 岁 · 减脂期第 6 周 · 目标 2100 kcal/日')
          .fontSize(10)
          .fontColor('#9A8577')
          .margin({ top: 3 })
        Text('⭐ 已坚持记录 68 天')
          .fontSize(10)
          .fontColor('#5AA469')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(14)
    .borderRadius(18)
    .backgroundColor('#FFFFFF')
    .border({ width: 1, color: '#F0DFD1' })

    Row() {
      Column() {
        Text('68')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F2762E')
        Text('记录天数')
          .fontSize(9)
          .fontColor('#9A8577')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)

      Column() {
        Text('41')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5AA469')
        Text('达标天数')
          .fontSize(9)
          .fontColor('#9A8577')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)

      Column() {
        Text('1.6kg')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#4E8BC8')
        Text('已减重')
          .fontSize(9)
          .fontColor('#9A8577')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)

      Column() {
        Text('12')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#9B6BC3')
        Text('自定义食物')
          .fontSize(9)
          .fontColor('#9A8577')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .padding({ top: 12, bottom: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .border({ width: 1, color: '#F0DFD1' })
    .margin({ top: 10 })

"我的"页顶部是用户信息卡片,左侧是56x56像素的圆形头像(用"盘"字代替,橙色背景,borderRadius(28)实现圆形),右侧是用户名、基本信息和坚持天数。下方是四列统计面板,每列使用layoutWeight(1)等宽分配,分别展示记录天数(68天,橙色)、达标天数(41天,绿色)、已减重(1.6kg,蓝色)、自定义食物数(12个,紫色)。四个数字使用四种不同颜色,与营养素颜色编码体系保持一致。

技术概念:Flex布局的等宽分配
当一个Row中的所有子组件都设置了layoutWeight(1)时,它们会平分父容器的全部宽度(因为没有任何子组件有固定宽度,全部剩余空间按1:1:1:1分配)。这是实现等宽列布局的最简方式。如果需要不等宽分配,只需改变权重值,如layoutWeight(1)layoutWeight(2)的搭配可以实现1:2的宽度比例。

"我的"页还包含饮食偏好卡片(忌口、饮食模式、营养师信息)和记录小贴士卡片,内容以静态文字为主,布局模式与前面介绍的卡片一致,此处不再赘述。

十八、弹窗实现:遮罩层与zIndex层级控制

@Builder
addMealOverlay() {
  Column() {
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#66331F0F')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .onClick(() => {
    this.showAddMeal = false
  })

  Column() {
    Row() {
      Text('🍱 记一餐')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#33261C')
      Text('ADD MEAL')
        .fontSize(8)
        .fontColor('#C98A5E')
        .letterSpacing(1)
        .margin({ left: 8 })
      Column()
        .layoutWeight(1)
        .height(1)
      Text('✕')
        .fontSize(14)
        .fontColor('#9A8577')
        .onClick(() => {
          this.showAddMeal = false
        })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

"记一餐"弹窗的实现展示了ArkTS中弹窗的标准模式。每个弹窗由两个Column组成:第一个是遮罩层,第二个是弹窗内容。遮罩层是一个空的Column,设置width('100%')height('100%')铺满全屏,背景色#66331F0F#66是40%透明度的十六进制表示,331F0F是深褐色),position({ x: 0, y: 0 })绝对定位到屏幕左上角,zIndex(999)设置层级为999。点击遮罩层关闭弹窗(this.showAddMeal = false)。

弹窗内容Column设置zIndex(1000)——比遮罩层高1级,确保内容显示在遮罩层之上。position({ x: '6%', y: '8%' })将弹窗定位到屏幕偏上位置。宽度88%(左右各留6%边距),白色背景,圆角边框。

技术概念:zIndex层级控制
zIndex属性控制同一父容器中子组件的堆叠顺序。数值越大,组件越在上层。在本例中,遮罩层zIndex(999),弹窗内容zIndex(1000),确保弹窗内容覆盖在遮罩层之上。需要注意的是,zIndex只在同一父容器的子组件之间生效——如果两个组件分属不同的父容器,其zIndex不会互相比较。这就是为什么所有弹窗都放在build()方法的同一个Stack根容器中。

技术概念:position绝对定位
.position({ x, y })将组件从正常文档流中脱出,相对于父容器进行绝对定位。xy可以是像素值(如0)或百分比字符串(如'6%')。弹窗通过position精确控制在屏幕上的位置——居中弹窗使用百分比定位,底部抽屉使用y: '50%'y: '52%'定位到屏幕下半部分。与Flex布局不同,position定位的组件不参与父容器的空间分配。

弹窗内容区包含了完整的表单:餐次选择器(ForEach渲染MEALKINDS0)、食物选择器(横向Scroll滚动选择)、份量步进器(减号-数字-加号)、热量预览区、同步开关(Toggle组件)、取消和确认按钮。每个表单控件都与对应的@State变量双向绑定。

      Scroll() {
        Row() {
          ForEach(this.foods, (f: FoodT, i: number) => {
            Row() {
              Text(f.icon)
                .fontSize(13)
              Text(f.name)
                .fontSize(10)
                .fontWeight(this.amFood === i ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.amFood === i ? '#FFFFFF' : '#33261C')
                .margin({ left: 4 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(this.amFood === i ? '#F2762E' : '#FFF7EF')
            .border({ width: 1, color: this.amFood === i ? '#F2762E' : '#F0DFD1' })
            .margin({ right: 6 })
            .onClick(() => {
              this.amFood = i
            })
          }, (f: FoodT) => f.id.toString() + this.amFood.toString())
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ top: 6 })

食物选择器使用Scroll容器包裹横向排列的ForEach.scrollable(ScrollDirection.Horizontal)设置滚动方向为水平,.scrollBar(BarState.Off)隐藏滚动条以保持界面整洁。每个食物选项是一个胶囊形标签,选中态为橙色实心白字,未选中态为浅色背景深色文字。点击选项更新this.amFood索引。

技术概念:Scroll滚动容器
Scroll是ArkUI的滚动容器组件,当内容超出容器尺寸时提供滚动能力。通过.scrollable()设置滚动方向(Horizontal水平、Vertical垂直),通过.scrollBar()控制滚动条显示状态(On显示、Off隐藏、Auto自动)。Scroll只能有一个直接子组件,因此需要用RowColumn包裹多个子项。在本例中,横向Scroll+Row实现了水平滚动的食物选择器。

技术概念:Toggle开关组件
弹窗中的同步开关使用了Toggle组件:Toggle({ type: ToggleType.Switch, isOn: this.amSync })ToggleType.Switch是iOS风格的滑动开关,isOn绑定状态变量。.selectedColor('#F2762E')设置开启状态的主题色。.onChange((on: boolean) => { this.amSync = on })监听开关变化并更新状态变量。Toggle还支持ToggleType.Checkbox(复选框)和ToggleType.Button(按钮式开关)两种类型。

十九、编辑弹窗与底部抽屉模式

@Builder
editOverlay() {
  Column() {
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#66331F0F')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .onClick(() => {
    this.showEdit = false
  })

  Column() {
    Column()
      .width(40)
      .height(4)
      .borderRadius(2)
      .backgroundColor('#F0DFD1')
      .margin({ top: 10 })

    Row() {
      Text('✎ 修改这份记录')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#33261C')
      Column()
        .layoutWeight(1)
        .height(1)
      Text('✕')
        .fontSize(14)
        .fontColor('#9A8577')
        .onClick(() => {
          this.showEdit = false
        })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .padding({ left: 16, right: 16, top: 12 })

编辑弹窗采用了底部抽屉的交互模式,与"记一餐"弹窗的居中模式不同。弹窗内容Column设置了.borderRadius({ topLeft: 24, topRight: 24 })只圆顶部两个角,.position({ x: 0, y: '50%' })定位到屏幕中下方,宽度100%铺满屏幕宽度。顶部有一个40x4像素的灰色拖拽指示条(borderRadius(2)圆角),这是底部抽屉的标志性视觉元素。

技术概念:底部抽屉的设计规范
底部抽屉(Bottom Sheet)是移动端常见的交互模式,特别适合编辑、筛选等操作。其设计特征包括:从底部滑入、顶部圆角、顶部有拖拽指示条、内容区可滚动。与居中弹窗相比,底部抽屉更方便单手操作(手指自然位于屏幕下方),且不遮挡上半部分的内容。在本应用中,编辑弹窗和饮水打卡弹窗都使用底部抽屉模式。

编辑弹窗的内容与"记一餐"弹窗类似——餐次选择器和份量步进器,但少了食物选择器(因为编辑时不改变食物本身,只调整餐次和份量)。份量步进器的减号按钮有下限保护if (this.editPortionTen > 1)(最小0.5份),加号按钮有上限保护if (this.editPortionTen < 20)(最大10份)。底部显示修改后的热量预览值,通过Math.round(this.meals[this.editIdx].kcal * this.editPortionTen / 2)实时计算。

二十、删除确认弹窗:二次确认与危险操作保护

@Builder
delOverlay() {
  Column() {
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#66331F0F')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .onClick(() => {
    this.showDel = false
  })

  Column() {
    Text('🗑️')
      .fontSize(30)

    Text('删掉这笔饮食记录?')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor('#33261C')
      .margin({ top: 8 })

    Text(this.meals.length > this.delIdx ? this.meals[this.delIdx].food + ' · ' + Math.round(this.meals[this.delIdx].kcal * this.meals[this.delIdx].portion).toString() + ' kcal' : '')
      .fontSize(10)
      .fontColor('#9A8577')
      .margin({ top: 4 })

    Text('删除后今日热量与营养素统计将同步扣减。')
      .fontSize(10)
      .fontColor('#D9534F')
      .textAlign(TextAlign.Center)
      .margin({ top: 8 })

    Row() {
      Text('我已确认')
        .fontSize(10)
        .fontColor('#9A8577')
      Toggle({ type: ToggleType.Switch, isOn: this.dangerDel })
        .selectedColor('#D9534F')
        .margin({ left: 8 })
        .onChange((on: boolean) => {
          this.dangerDel = on
        })
    }
    .margin({ top: 12 })

    Row() {
      Text('保留')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#9A8577')
        .layoutWeight(1)
        .height(38)
        .textAlign(TextAlign.Center)
        .borderRadius(19)
        .backgroundColor('#FFF7EF')
        .onClick(() => {
          this.showDel = false
        })
      Text(this.dangerDel ? '确认删除' : '先勾选')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .layoutWeight(1.4)
        .height(38)
        .textAlign(TextAlign.Center)
        .borderRadius(19)
        .backgroundColor(this.dangerDel ? '#D9534F' : '#E7D3C4')
        .margin({ left: 10 })
        .onClick(() => {
          if (this.dangerDel) {
            this.doDel()
          }
        })
    }
    .width('100%')
    .margin({ top: 14 })
  }
  .width('74%')
  .padding(18)
  .borderRadius(16)
  .backgroundColor('#FFFFFF')
  .border({ width: 1, color: '#F2D6CE' })
  .alignItems(HorizontalAlign.Center)
  .position({ x: '13%', y: '28%' })
  .zIndex(1000)
}

删除确认弹窗是一个居中的窄卡片(宽度74%),设计上强调"危险操作"的警示感。弹窗顶部是删除图标🗑️(30号字大小),下方是确认标题和待删除记录的信息。红色警告文字"删除后今日热量与营养素统计将同步扣减"使用#D9534F红色,提醒用户操作的不可逆性。

弹窗的核心安全机制是Toggle二次确认开关。用户必须先打开"我已确认"开关(dangerDel变为true),底部的"确认删除"按钮才会从灰色禁用态变为红色激活态。按钮文字也会根据状态变化——未确认时显示"先勾选"(灰色背景),已确认时显示"确认删除"(红色背景)。这种"二次确认"模式可以有效防止误删除。

技术概念:危险操作的二次确认设计
对于不可逆的删除操作,UI设计通常采用"渐进式确认"策略:首先弹出确认弹窗,要求用户明确点击确认;对于更危险的操作,还要求用户勾选复选框或输入确认文字。本应用使用Toggle开关作为二次确认——用户需要先拨动开关再点击按钮,两步操作显著降低了误删风险。按钮的backgroundColor和文字根据dangerDel状态动态变化,给用户明确的视觉反馈。

二十一、食物详情弹窗与饮水打卡弹窗

@Builder
foodOverlay() {
  Column() {
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#66331F0F')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .onClick(() => {
    this.showFood = false
  })

  Column() {
    Row() {
      Text('🍎 食物详情')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#33261C')
      Text('FOOD CARD')
        .fontSize(8)
        .fontColor('#C98A5E')
        .letterSpacing(1)
        .margin({ left: 8 })
      Column()
        .layoutWeight(1)
        .height(1)
      Text('✕')
        .fontSize(14)
        .fontColor('#9A8577')
        .onClick(() => {
          this.showFood = false
        })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Row() {
      Column() {
        Text(this.foods[this.foodIdx].icon)
          .fontSize(40)
      }
      .width(72)
      .height(72)
      .borderRadius(20)
      .backgroundColor('#FFF7EF')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .margin({ right: 14 })

      Column() {
        Text(this.foods[this.foodIdx].name)
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#33261C')
        Text(this.foods[this.foodIdx].cat + ' · 每 100g 参考值')
          .fontSize(10)
          .fontColor('#9A8577')
          .margin({ top: 4 })
        Row() {
          Text(this.foods[this.foodIdx].kcal.toString() + ' kcal')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F2762E')
          Text(this.foods[this.foodIdx].often ? ' · 常吃' : '')
            .fontSize(9)
            .fontColor('#F2B23E')
            .margin({ left: 4 })
        }
        .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .margin({ top: 10 })

食物详情弹窗是一个居中弹窗,展示选中食物的详细信息。顶部是食物大图标(72x72像素圆角方块,40号字Emoji)和食物名称、分类、热量值、常吃标签。下方是一个三列对照区:蛋白质含量、相当于多少个鸡蛋(Math.max(1, Math.round(this.foods[this.foodIdx].kcal / 90))——将热量除以90kcal/个鸡蛋得到对照数量),以及一个智能提示——根据食物热量是否超过今日剩余可用热量,显示"会超出目标,注意份量"或"还有富余,放心吃"。

技术概念:TextInput输入框组件
在添加食物弹窗中使用了TextInput组件接收用户输入:TextInput({ text: this.afName, placeholder: '如:妈妈牌韭菜盒子' })text参数绑定状态变量(注意TextInput是单向绑定,需要通过.onChange()手动更新状态),placeholder是占位提示文字。.placeholderColor().placeholderFont()可以自定义占位符样式。.onChange((v: string) => { this.afName = v })监听输入变化并更新状态变量。与React或Vue的双向绑定不同,ArkTS的TextInput需要手动处理状态同步。

饮水打卡弹窗采用底部抽屉模式,内容包括今日饮水进度、时间段选择器(ForEach渲染WATERS0的五个时段)和打卡按钮。时间段选择器是一个横向排列的标签组,选中态为蓝色实心白字,未选中态为浅色背景灰字。打卡按钮点击后调用this.confirmWater()waterCups加1并关闭弹窗。

二十二、build主入口:Stack根容器与条件渲染

build() {
  Stack() {
    Column() {
      this.headerBar()
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            this.plateTab()
          } else if (this.currentTab === 1) {
            this.foodTab()
          } else if (this.currentTab === 2) {
            this.recordTab()
          } else if (this.currentTab === 3) {
            this.nutriTab()
          } else {
            this.mineTab()
          }
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 10, bottom: 16 })
      }
      .layoutWeight(1)
      .width('100%')
      .scrollBar(BarState.Off)
      .align(Alignment.Top)

      this.tabBar()
    }
    .width('100%')
    .height('100%')

    if (this.showAddMeal) {
      this.addMealOverlay()
    }
    if (this.showAddFood) {
      this.addFoodOverlay()
    }
    if (this.showEdit) {
      this.editOverlay()
    }
    if (this.showDel) {
      this.delOverlay()
    }
    if (this.showFood) {
      this.foodOverlay()
    }
    if (this.showWater) {
      this.waterOverlay()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#FFF7EF')
}

build()方法是每个@Component组件必须实现的方法,它返回组件的UI树。整个页面的build()方法使用Stack作为根容器——这是实现弹窗层叠的关键。Stack中第一个子组件是主内容区Column(包含头部栏、可滚动内容区、标签栏),后续的六个if条件块控制六个弹窗的显示。

主内容区的结构是:headerBar()固定在顶部不滚动,Scroll容器包裹可滚动的内容区(layoutWeight(1)占据头部和标签栏之间的所有空间),tabBar()固定在底部。内容区内部通过if...else if...else条件判断根据currentTab的值渲染对应的标签页@Builder方法。.align(Alignment.Top)确保滚动内容从顶部开始对齐。

技术概念:Stack作为弹窗容器的原理
使用Stack作为根容器是实现弹窗层叠的标准方案。Stack的子组件按声明顺序堆叠——主内容区在底层,弹窗在上层。当弹窗的if条件为true时,弹窗组件被插入到Stack中覆盖在主内容区之上;当条件变为false时,弹窗组件被移除。这种"条件渲染+Stack堆叠"的模式比visibility属性控制显隐更加高效——未显示的弹窗完全不参与渲染,不占用任何资源。

技术概念:if条件渲染与ForEach的区别
if...elseForEach都是ArkTS中的控制流语句,但工作原理不同。if条件渲染是"全有或全无"的——条件为true时整个UI块被创建,为false时被销毁。每次条件翻转都会重新创建/销毁组件,有一定的性能开销。ForEach则是"差分更新"——根据键值函数复用已有组件,只增删变化的项。因此,对于频繁切换的场景应优先使用ForEachvisibility属性,对于低频切换的场景(如弹窗开关)使用if完全没问题。

六个弹窗的if条件都是独立的@State布尔变量,互不排斥。理论上可以同时显示多个弹窗(如先打开"记一餐"再打开"食物详情"),但在实际交互中,弹窗的打开路径设计使得同时弹出多个弹窗的概率很低。每个弹窗的遮罩层都能接收点击事件关闭自身,因此即使多个弹窗叠加,用户点击遮罩层也会从最上层开始逐个关闭。

二十三、核心技术要素对比表

以下表格对应用中涉及的核心数据结构、组件、状态变量、装饰器、动画API等技术要素进行了系统性对比:

技术要素类别作用描述使用位置关键特征
MealT接口餐记录数据结构MEALS0、meals状态变量包含id/meal/icon/food/kcal/portion/time/protein/carb/fat十个字段
FoodT接口食物库数据结构FOODS0、foods状态变量包含id/icon/name/kcal/cat/protein/often七个字段
WeekT接口周热量数据结构WEEKS0常量仅含day和kcal两个字段,用于柱状图
NutriT接口营养素数据结构NUTRIS0常量含name/got/target/color/unit,用于横条图
WaterT接口饮水知识数据结构WATERS0常量含icon/label/desc,用于时间线展示
@Entry装饰器标记页面入口组件Index结构体每个页面仅一个,是组件树根节点
@Component装饰器标记自定义组件Index结构体使结构体成为可复用的ArkUI组件
@State装饰器声明可观察状态变量currentTab/meals/foods等值变化时自动触发引用该变量的UI重渲染
@Builder装饰器封装可复用UI片段headerBar/tabBar/plateTab等无额外实例化开销,通过this.xxx()调用
Column容器组件纵向布局容器几乎所有UI区块子组件从上到下排列
Row容器组件横向布局容器头部栏/标签栏/列表项等子组件从左到右排列
Stack容器组件层叠布局容器build根容器/环形进度区子组件按声明顺序从底到顶堆叠
Progress基础组件环形进度条餐盘页中心通过value/total配置进度,style设置线宽
Scroll滚动容器内容滚动主内容区/食物选择器支持水平和垂直滚动,可隐藏滚动条
ForEach列表渲染遍历数组生成UI餐记录列表/食物网格/饮水格子等三参数:数据源/项渲染/键值生成
Toggle交互组件开关选择器同步开关/常吃开关/危险确认支持Switch/Checkbox/Button三种类型
TextInput交互组件文本输入框添加食物弹窗需通过onChange手动同步状态
layoutWeight布局属性权重分配剩余空间标签栏等宽列/搜索框弹性宽度数值表示比例权重,如1和1.6的搭配
position布局属性绝对定位所有弹窗的遮罩层和内容区脱离文档流,相对父容器定位
zIndex布局属性层级控制弹窗遮罩(999)和内容(1000)数值越大越在上层,仅同父级下有效
animateTo动画API命令式动画aboutToAppear生命周期通过闭包修改状态变量实现动画过渡
borderRadius样式属性圆角设置卡片/按钮/头像等支持四角统一或分区设置
rotate图形变换旋转餐盘图标旋转绕指定轴旋转指定角度
opacity图形变换透明度营养素脉冲指示点0到1之间,不改变布局空间
scale图形变换缩放营养素脉冲指示点大于1放大,不改变布局空间
mealColor0全局函数餐次颜色映射餐次标签/记录图标等早餐金/午餐橙/晚餐紫/加餐绿
aboutToAppear生命周期组件创建后渲染前Index组件初始化动画和状态

二十四、总结与回顾

通过对这个营养餐盘应用的完整剖析,我们可以清晰地看到ArkTS声明式UI开发范式的全貌。从数据建模开始,应用通过五个interface定义了严格类型的数据契约,这些接口不仅约束了数据的形状,更在整个开发过程中提供了编译期的类型安全保障。静态数据常量(MEALS0FOODS0等)以const声明作为初始数据源,随后被赋值给@State状态变量,完成了从"静态常量"到"动态状态"的转换——这一转换是应用具备数据变更能力的基础。

状态管理是本应用的核心架构设计。全部20余个@State变量按功能分组:核心数据源(mealsfoodswaterCups)、导航控制(currentTabcatIdx)、弹窗开关(六个show布尔变量)、表单临时状态(各弹窗的am*af*edit*变量)、动画状态(plateAngledotOpdotScale)。这种分组管理使状态的可追踪性极强——当某个UI出现问题时,开发者可以快速定位到对应的状态变量组进行排查。所有数据变更操作都遵循"不可变更新"原则——使用concat()map()filter()返回新数组,确保@State的变更检测能正常工作。

UI构建方面,@Builder装饰器将复杂的界面拆分为14个逻辑独立的构建方法,每个方法负责一个UI区块(5个标签页+1个头部栏+1个标签栏+6个弹窗+1个标签项)。这种拆分方式使build()主方法极为简洁——只需组合@Builder调用和if条件判断即可构建出完整的页面结构。每个@Builder方法内部的组件嵌套清晰地反映了UI的层级关系,配合链式属性调用,代码的可读性接近自然语言描述。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 场景:爱康体检风营养饮食管理 · 5 tab · 6 弹框 · 摄入环形 / 营养素横条 / 每周柱状 / 饮水格子条 · 餐盘旋转 / 光点脉冲特效

interface MealT {
  id: number
  meal: string
  icon: string
  food: string
  kcal: number
  portion: number
  time: string
  protein: number
  carb: number
  fat: number
}

interface FoodT {
  id: number
  icon: string
  name: string
  kcal: number
  cat: string
  protein: number
  often: boolean
}

interface WeekT {
  day: string
  kcal: number
}

interface NutriT {
  name: string
  got: number
  target: number
  color: string
  unit: string
}

interface WaterT {
  icon: string
  label: string
  desc: string
}

const TABS0: Array<string> = ['餐盘', '食物库', '记录', '营养', '我的']

const MEALS0: Array<MealT> = [
  { id: 1, meal: '早餐', icon: '🥣', food: '燕麦牛奶碗 + 水煮蛋', kcal: 348, portion: 1, time: '07:40', protein: 18, carb: 46, fat: 9 },
  { id: 2, meal: '加餐', icon: '🍎', food: '红富士苹果一个', kcal: 95, portion: 1, time: '10:20', protein: 1, carb: 24, fat: 0 },
  { id: 3, meal: '午餐', icon: '🍗', food: '黄焖鸡米饭(少汁)', kcal: 612, portion: 1, time: '12:15', protein: 34, carb: 78, fat: 16 },
  { id: 4, meal: '加餐', icon: '☕', food: '燕麦拿铁(无糖)', kcal: 142, portion: 1, time: '15:30', protein: 8, carb: 14, fat: 5 },
  { id: 5, meal: '晚餐', icon: '🥗', food: '鸡胸牛油果沙拉', kcal: 386, portion: 1, time: '18:50', protein: 32, carb: 22, fat: 18 },
  { id: 6, meal: '加餐', icon: '🥛', food: '无糖酸奶半盒', kcal: 68, portion: 0.5, time: '21:10', protein: 6, carb: 8, fat: 2 },
  { id: 7, meal: '早餐', icon: '🥯', food: '全麦贝果 + 奶酪', kcal: 376, portion: 1, time: '昨天 07:55', protein: 16, carb: 52, fat: 11 },
  { id: 8, meal: '午餐', icon: '🍜', food: '番茄牛肉面', kcal: 548, portion: 1, time: '昨天 12:30', protein: 28, carb: 82, fat: 10 },
  { id: 9, meal: '晚餐', icon: '🐟', food: '清蒸鲈鱼 + 杂粮饭', kcal: 428, portion: 1, time: '昨天 19:05', protein: 38, carb: 42, fat: 9 },
  { id: 10, meal: '加餐', icon: '🌰', food: '每日坚果一袋', kcal: 156, portion: 1, time: '前天 15:40', protein: 5, carb: 12, fat: 12 },
  { id: 11, meal: '午餐', icon: '🍱', food: '轻食便当(鸡胸藜麦)', kcal: 468, portion: 1, time: '前天 12:20', protein: 36, carb: 48, fat: 12 },
  { id: 12, meal: '晚餐', icon: '🍲', food: '冬瓜排骨汤 + 米饭', kcal: 495, portion: 1, time: '前天 18:40', protein: 26, carb: 58, fat: 15 },
  { id: 13, meal: '加餐', icon: '🍪', food: '两块曲奇(罪恶)', kcal: 232, portion: 2, time: '周一 16:00', protein: 3, carb: 32, fat: 11 }
]

const FOODS0: Array<FoodT> = [
  { id: 1, icon: '🍚', name: '白米饭', kcal: 116, cat: '主食', protein: 3, often: true },
  { id: 2, icon: '🥔', name: '蒸红薯', kcal: 90, cat: '主食', protein: 2, often: true },
  { id: 3, icon: '🌾', name: '燕麦片', kcal: 367, cat: '主食', protein: 13, often: true },
  { id: 4, icon: '🍗', name: '鸡胸肉', kcal: 133, cat: '蛋白', protein: 24, often: true },
  { id: 5, icon: '🥩', name: '瘦牛肉', kcal: 143, cat: '蛋白', protein: 21, often: false },
  { id: 6, icon: '🐟', name: '鲈鱼', kcal: 105, cat: '蛋白', protein: 19, often: true },
  { id: 7, icon: '🥦', name: '西兰花', kcal: 27, cat: '蔬果', protein: 3, often: true },
  { id: 8, icon: '🍅', name: '番茄', kcal: 15, cat: '蔬果', protein: 1, often: false },
  { id: 9, icon: '🥑', name: '牛油果', kcal: 161, cat: '蔬果', protein: 2, often: false },
  { id: 10, icon: '🍪', name: '曲奇饼干', kcal: 502, cat: '零食', protein: 6, often: false },
  { id: 11, icon: '🧋', name: '珍珠奶茶', kcal: 437, cat: '饮品', protein: 3, often: false },
  { id: 12, icon: '🥛', name: '无糖酸奶', kcal: 62, cat: '饮品', protein: 10, often: true }
]

const WEEKS0: Array<WeekT> = [
  { day: '一', kcal: 1980 },
  { day: '二', kcal: 2260 },
  { day: '三', kcal: 1740 },
  { day: '四', kcal: 2130 },
  { day: '五', kcal: 2480 },
  { day: '六', kcal: 1890 },
  { day: '日', kcal: 1651 }
]

const NUTRIS0: Array<NutriT> = [
  { name: '蛋白质', got: 99, target: 120, color: '#F2762E', unit: 'g' },
  { name: '碳水', got: 201, target: 240, color: '#4E8BC8', unit: 'g' },
  { name: '脂肪', got: 61, target: 65, color: '#9B6BC3', unit: 'g' },
  { name: '膳食纤维', got: 14, target: 25, color: '#5AA469', unit: 'g' }
]

const WATERS0: Array<WaterT> = [
  { icon: '🌅', label: '晨起一杯', desc: '唤醒肠胃,补足夜间流失' },
  { icon: '☕', label: '上午一杯', desc: '搭配加餐,拒绝含糖饮料' },
  { icon: '🍚', label: '饭前一杯', desc: '增加饱腹感,防止吃多' },
  { icon: '🏃', label: '运动前后', desc: '少量多次,别等渴了才喝' },
  { icon: '🌙', label: '睡前小口', desc: '半杯即可,避免起夜' }
]

const MEALKINDS0: Array<string> = ['早餐', '午餐', '晚餐', '加餐']
const CATS0: Array<string> = ['全部', '主食', '蛋白', '蔬果', '零食', '饮品']
const KCAL_TARGET0: number = 2100

function mealColor0(m: string): string {
  if (m === '早餐') {
    return '#F2B23E'
  }
  if (m === '午餐') {
    return '#F2762E'
  }
  if (m === '晚餐') {
    return '#9B6BC3'
  }
  return '#5AA469'
}

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State meals: Array<MealT> = MEALS0
  @State foods: Array<FoodT> = FOODS0
  @State waterCups: number = 5
  @State catIdx: number = 0

  // 弹框开关
  @State showAddMeal: boolean = false
  @State showAddFood: boolean = false
  @State showEdit: boolean = false
  @State showDel: boolean = false
  @State showFood: boolean = false
  @State showWater: boolean = false

  // 记一餐
  @State amKind: number = 0
  @State amFood: number = 0
  @State amPortionTen: number = 2
  @State amSync: boolean = true

  // 添加食物
  @State afName: string = ''
  @State afCat: number = 0
  @State afKcal: number = 150
  @State afOften: boolean = false

  // 编辑餐记录
  @State editIdx: number = 0
  @State editKind: number = 0
  @State editPortionTen: number = 2

  // 删除
  @State delIdx: number = 0
  @State dangerDel: boolean = false

  // 食物详情
  @State foodIdx: number = 0

  // 饮水打卡
  @State waterSlot: number = 0

  // 特效
  @State plateAngle: number = 0
  @State dotOp: number = 0.3
  @State dotScale: number = 0.9

  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 9000, iterations: -1, curve: Curve.Linear }, () => {
      this.plateAngle = 360
    })
    this.getUIContext().animateTo({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.dotOp = 0.85
      this.dotScale = 1.15
    })
  }

  todayKcal(): number {
    let sum: number = 0
    this.meals.forEach((m: MealT) => {
      sum += Math.round(m.kcal * m.portion)
    })
    return sum
  }

  todayProtein(): number {
    let sum: number = 0
    this.meals.forEach((m: MealT) => {
      sum += Math.round(m.protein * m.portion)
    })
    return sum
  }

  kcalPct(): number {
    return Math.min(Math.round(this.todayKcal() / KCAL_TARGET0 * 100), 100)
  }

  // ================= 数据变更逻辑 =================
  openAddMeal(): void {
    this.amKind = 0
    this.amFood = 0
    this.amPortionTen = 2
    this.amSync = true
    this.showAddMeal = true
  }

  confirmAddMeal(): void {
    const f: FoodT = this.foods[this.amFood]
    const portion: number = this.amPortionTen / 2
    this.meals = [{ id: this.meals.length + 20, meal: MEALKINDS0[this.amKind], icon: f.icon, food: f.name + (portion === 1 ? '' : ' ×' + portion.toString()), kcal: f.kcal, portion: portion, time: '21:45', protein: f.protein, carb: 24, fat: 6 } as MealT].concat(this.meals)
    this.showAddMeal = false
  }

  openAddFood(): void {
    this.afName = ''
    this.afCat = 0
    this.afKcal = 150
    this.afOften = false
    this.showAddFood = true
  }

  confirmAddFood(): void {
    const icon: string = this.afCat === 0 ? '🍚' : (this.afCat === 1 ? '🍗' : (this.afCat === 2 ? '🥦' : (this.afCat === 3 ? '🍪' : '🥤')))
    this.foods = [{ id: this.foods.length + 20, icon: icon, name: this.afName.length > 0 ? this.afName : '自定义食物', kcal: this.afKcal, cat: CATS0[this.afCat + 1], protein: 5, often: this.afOften } as FoodT].concat(this.foods)
    this.showAddFood = false
  }

  openEdit(idx: number): void {
    this.editIdx = idx
    this.editKind = MEALKINDS0.indexOf(this.meals[idx].meal)
    if (this.editKind < 0) {
      this.editKind = 0
    }
    this.editPortionTen = Math.round(this.meals[idx].portion * 2)
    this.showEdit = true
  }

  confirmEdit(): void {
    this.meals = this.meals.map((m: MealT, i: number) => {
      if (i === this.editIdx) {
        return { id: m.id, meal: MEALKINDS0[this.editKind], icon: m.icon, food: m.food, kcal: m.kcal, portion: this.editPortionTen / 2, time: m.time, protein: m.protein, carb: m.carb, fat: m.fat } as MealT
      }
      return m
    })
    this.showEdit = false
  }

  openDel(idx: number): void {
    this.delIdx = idx
    this.dangerDel = false
    this.showDel = true
  }

  doDel(): void {
    this.meals = this.meals.filter((m: MealT, i: number) => i !== this.delIdx)
    this.showDel = false
  }

  openFood(f: FoodT): void {
    const idx: number = this.foods.indexOf(f)
    if (idx >= 0) {
      this.foodIdx = idx
    }
    this.showFood = true
  }

  eatFood(): void {
    const f: FoodT = this.foods[this.foodIdx]
    this.meals = [{ id: this.meals.length + 30, meal: '加餐', icon: f.icon, food: f.name, kcal: f.kcal, portion: 1, time: '21:46', protein: f.protein, carb: 24, fat: 6 } as MealT].concat(this.meals)
    this.showFood = false
  }

  toggleOften(f: FoodT): void {
    this.foods = this.foods.map((x: FoodT) => {
      if (x.id === f.id) {
        return { id: x.id, icon: x.icon, name: x.name, kcal: x.kcal, cat: x.cat, protein: x.protein, often: !x.often } as FoodT
      }
      return x
    })
  }

  openWater(): void {
    this.waterSlot = 0
    this.showWater = true
  }

  confirmWater(): void {
    this.waterCups += 1
    this.showWater = false
  }

  // ================= 头部(无动画,电商风) =================
  @Builder
  headerBar() {
    Column() {
      Row() {
        Column() {
          Text('营养餐盘')
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('MEAL PLATE')
            .fontSize(8)
            .fontColor('#FFE0C7')
            .fontWeight(FontWeight.Bold)
            .letterSpacing(2)
            .margin({ top: 1 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 14 })

        Row() {
          Text('🔍')
            .fontSize(13)
          Text('查食物热量,一键记餐')
            .fontSize(11)
            .fontColor('#C98A5E')
            .margin({ left: 6 })
        }
        .layoutWeight(1)
        .height(34)
        .borderRadius(17)
        .backgroundColor('#FFFFFF')
        .margin({ left: 12, right: 8 })
        .justifyContent(FlexAlign.Center)

        Text('🧺')
          .fontSize(16)
          .margin({ right: 10 })

        Text('碗')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#C9551B')
          .width(30)
          .height

      Text(this.meals.length > this.delIdx ? this.meals[this.delIdx].food + ' · ' + Math.round(th
    .height('100%')
    .backgroundColor('#FFF7EF')
  }
}


在这里插入图片描述

动画与特效的实现展示了animateTo命令式动画API的强大能力。通过在aboutToAppear中启动两个无限循环动画,应用实现了餐盘匀速旋转和营养素指示点脉冲呼吸两个持续动效。动画的核心机制是"在闭包中修改状态变量"——plateAngle从0到360的动画驱动了.rotate()属性的变化,dotOpdotScale的交替动画驱动了.opacity().scale()属性的变化。这种"状态驱动动画"的设计使得动画与UI渲染天然集成,无需额外的动画框架或手动刷新逻辑。

弹窗系统是本应用交互设计的一大亮点。六个弹窗覆盖了增(记一餐、添加食物)、删(删除确认)、改(编辑记录)、查(食物详情)和辅助功能(饮水打卡)五大场景,采用了居中弹窗和底部抽屉两种交互模式。所有弹窗都通过"遮罩层zIndex(999)+内容区zIndex(1000)+position绝对定位"的三件套实现,配合if条件渲染控制显隐。删除弹窗的Toggle二次确认机制体现了对危险操作的安全设计意识。整个弹窗系统不依赖任何第三方弹窗库,纯用ArkTS原生组件实现,代码可维护性极高。

从工程实践的角度看,这个应用虽然功能丰富,但代码结构清晰、职责分明。数据接口层定义契约,常量层提供初始数据,状态管理层驱动UI更新,业务逻辑层处理增删改查,UI构建层声明界面结构,动画层提供视觉增强——每一层都有明确的边界和约定。ForEach的键值函数设计尤为精妙——通过在键值中拼接portionoften等可变字段,确保了编辑操作能正确触发对应项的重渲染。这种对ArkTS差分更新机制的深入理解,是编写高效声明式UI的关键。整个应用无需任何第三方依赖,完全基于ArkUI原生组件和ArkTS语言能力构建,充分展现了鸿蒙声明式UI开发范式的完备性和表现力。

Logo

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

更多推荐