一、鸿蒙开发背景与 ArkTS 语言体系

在当今移动应用开发的生态格局中,华为推出的 HarmonyOS(鸿蒙操作系统)凭借其独特的分布式架构理念和多设备协同能力,已经逐渐成为国产移动开发领域不可忽视的重要力量。HarmonyOS 不仅仅是一个手机操作系统,它是一套面向"万物互联"时代的全场景智慧操作系统,其设计理念从底层开始就考虑了跨设备、跨场景的无缝协作。在应用开发层面,HarmonyOS 引入了 ArkUI 声明式开发范式,这一范式借鉴了现代前端框架(如 Flutter、SwiftUI、Jetpack Compose)的声明式 UI 思想,同时深度融合了 TypeScript 的类型安全特性,最终孕育出了 ArkTS 这一专属于鸿蒙生态的应用开发语言。

ArkTS 是在 TypeScript 基础上扩展而来的编程语言,它继承了 TypeScript 的静态类型系统、接口(interface)、泛型(Generics)等核心特性,同时在此基础上引入了一系列专为声明式 UI 开发设计的编译时装饰器(Decorator)。这些装饰器包括但不限于 @Component@Entry@State@Builder@Prop@Link@Watch@Provide@Consume 等,它们构成了 ArkTS 状态管理与组件化架构的基石。与传统的命令式 UI 开发方式不同,声明式 UI 开发要求开发者描述界面"应该是什么样子",而非"应该怎样一步步构建"。这意味着开发者只需关注数据状态与界面之间的映射关系,当状态数据发生变化时,ArkUI 框架会自动触发界面的局部刷新,这种数据驱动视图的机制极大地简化了复杂界面的维护成本。

ArkUI 组件体系是 HarmonyOS 声明式开发的核心资产。它提供了一套丰富的基础组件库,包括线性布局容器 Column(纵向排列子组件)和 Row(横向排列子组件)、层叠布局容器 Stack(子组件可叠加放置)、弹性布局容器 Flex(支持换行与弹性分配)、滚动容器 Scroll(支持纵向和横向滚动)、进度组件 Progress(支持环形和线性两种样式)、开关组件 Toggle(提供开关切换交互)、文本输入框 TextInput(支持单行文本输入与事件回调)、文本展示组件 Text(支持样式设置与溢出处理)等。这些组件通过链式调用的方式配置属性,形成高度可读的声明式代码结构。此外,ArkUI 还提供了 ForEach 列表渲染指令、if/else 条件渲染指令、animateTo 动画 API 等控制能力,使开发者能够构建出交互丰富、动效流畅的现代移动应用界面。

在实际的鸿蒙应用开发中,状态管理是贯穿整个应用生命周期的核心命题。ArkTS 的状态管理模型基于"单一数据源"理念:每个组件维护自己的内部状态(通过 @State 声明),当状态变化时框架自动驱动该组件及其子树的重新渲染。对于跨组件的状态传递,ArkTS 提供了父子组件之间的 @Prop(单向同步)和 @Link(双向同步)机制,以及跨层级的 @Provide/@Consume 上下文注入机制。在本文所剖析的体重代谢管理应用中,开发者大量使用了 @State 来管理当前选中的 Tab 页签、弹窗的显隐状态、表单的临时输入值以及动画驱动参数等,这些状态变量构成了应用运行时的完整数据流。

技术要点:ArkTS 的 @State 装饰器是组件内部状态管理的核心机制。被 @State 修饰的变量一旦发生赋值操作,ArkUI 框架会自动标记依赖该变量的 UI 组件为"脏"状态,并在下一个 Vsync 信号到来时执行增量重渲染。这种细粒度的依赖追踪机制保证了只有真正受影响的 UI 片段才会被刷新,而非整棵组件树重建。


二、数据模型定义:接口与类型系统

在 ArkTS 中,接口(interface)是定义数据结构形状的核心手段。与 TypeScript 的 interface 不同,ArkTS 的 interface 在编译阶段会进行更严格的类型检查,确保运行时数据的完整性。本应用定义了多个接口来描述不同业务领域的数据模型。

2.1 体重记录数据结构

interface WeightT15 {
  id: number
  date: string
  kg: number
  fat: number
  waist: number
  note: string
}

在这里插入图片描述

这段代码定义了体重记录的数据模型。id 字段作为唯一标识符,用于 ForEach 渲染时的键值生成;date 存储日期字符串(如 08-28);kg 是体重值(单位千克);fat 是体脂率百分比;waist 是腰围(厘米);note 是可选的备注文本。在鸿蒙的声明式 UI 中,为列表数据定义清晰的接口类型至关重要,因为 ForEach 的第三个参数(键值生成器)需要依赖稳定且唯一的字段来标识每一项,避免不必要的全量重渲染。

技术要点ForEach 是 ArkUI 中用于列表渲染的核心指令。它接收三个参数:数据源数组、子组件生成函数、键值生成函数。键值生成函数的返回值必须是字符串类型,且在同一数据集中保持唯一性。当数据源发生增删或顺序变化时,框架通过对比键值来决定哪些列表项需要创建、哪些需要销毁、哪些可以复用,这一机制与 React 的 key 概念类似。

2.2 饮食记录数据结构

interface MealT15 {
  id: number
  time: string
  name: string
  kcal: number
  carb: number
  prot: number
  fat: number
  tag: string
}

在这里插入图片描述

这个接口描述了一餐饮食的完整信息。time 字段记录用餐时段(如"早餐 07:40"),name 是食物名称的组合描述,kcal 是该餐总热量(千卡),carbprotfat 分别代表碳水化合物、蛋白质和脂肪的克数,tag 是分类标签(如"均衡"“高蛋白”"超标"等)。这三个宏量营养素字段在后续的食物详情弹窗中被用来计算占比条形图的宽度比例,是数据驱动可视化的典型案例。

值得注意的是,这些字段全部使用了基本类型(number 和 string),而非可选类型(如 string?)。这意味着在创建数据对象时所有字段都必须提供值,否则编译器会报错。在鸿蒙开发实践中,如果某些字段确实可能为空,通常使用空字符串 '' 而非 undefinednull,这是因为 ArkTS 对 nullundefined 的使用有更严格的限制。

2.3 运动记录与用药记录数据结构

interface SportT15 {
  id: number
  name: string
  icon: string
  mins: number
  kcal: number
  heat: number
}

interface DrugT15 {
  id: number
  name: string
  dose: string
  times: string
  remind: boolean
  stock: number
}

在这里插入图片描述

SportT15 接口描述了运动项目信息,其中 icon 字段存储 Emoji 表情符号作为图标(如 '🚶''🏊'),这在轻量级应用中是一种无需引入图片资源即可实现视觉区分的巧妙做法。heat 字段表示运动强度热度值,在数据层提供了额外的维度信息。DrugT15 接口描述了药品/营养品信息,其中 remind 是布尔类型,用于控制是否开启用药提醒,stock 是剩余库存次数。这两个接口分别服务于运动打卡 Tab 和用药管理 Tab 的数据展示。

技术要点:在 ArkTS 中,boolean 类型的状态变量经常被用于控制弹窗(Overlay/Modal)的显隐。通过将布尔值声明为 @State,当其从 false 变为 true 时,框架会自动在 UI 树中插入对应的弹窗组件,反之则移除。这种模式比传统的命令式 showDialog() 调用更加符合声明式 UI 的理念。

2.4 社群动态与订单数据结构

interface PostT15 {
  id: number
  user: string
  avatar: string
  text: string
  likes: number
  comments: number
  topic: string
}

interface OrdT15 {
  id: string
  item: string
  status: string
  amount: number
  date: string
}

在这里插入图片描述

PostT15 接口描述了社区动态的数据结构,avatar 使用 Emoji 代替头像图片,likescomments 分别记录点赞数和评论数,topic 是话题标签(如"#蜕变打卡#")。OrdT15 接口描述了用户订单信息,注意这里的 id 字段类型是 string 而非 number,因为订单号(如 'M20260828001')是包含字母的字符串编码。status 字段在渲染时被用于条件判断,根据不同状态(“已完成”“进行中”“待就诊”)显示不同的颜色样式。

2.5 营养素占比数据结构

interface NutriT15 {
  name: string
  pct: number
  color: string
}

在这里插入图片描述

这是一个用于可视化图表的辅助数据结构。pct 是百分比数值,color 是十六进制颜色字符串。这个接口的数据被用于渲染三大营养素的堆叠条形图,通过 layoutWeight 属性将百分比直接映射为布局权重,实现自适应宽度的比例条。这种将数据模型与视觉属性(颜色)直接绑定的做法,在快速原型开发中非常高效。


三、静态数据初始化与常量定义

3.1 体重趋势数据

const WEIGHTS15: Array<WeightT15> = [
  { id: 1, date: '08-28', kg: 82.4, fat: 28.6, waist: 96, note: '晨起空腹' },
  { id: 2, date: '08-21', kg: 83.1, fat: 29.2, waist: 97, note: '周末聚餐后' },
  { id: 3, date: '08-14', kg: 83.6, fat: 29.8, waist: 98, note: '' },
  { id: 4, date: '08-07', kg: 84.2, fat: 30.4, waist: 99, note: '' },
  { id: 5, date: '07-31', kg: 85.0, fat: 31.0, waist: 100, note: '开营首测' },
  { id: 6, date: '08-25', kg: 82.8, fat: 28.9, waist: 96, note: '' },
  { id: 7, date: '08-18', kg: 83.3, fat: 29.5, waist: 98, note: '' },
  { id: 8, date: '08-11', kg: 83.9, fat: 30.1, waist: 98, note: '' }
]

这段代码定义了体重趋势的静态数据集合,使用 const 声明为不可变常量,类型标注为 Array<WeightT15>。数据按时间降序排列(最新记录在最前),包含了从"开营首测"85.0kg 到最新82.4kg的完整减重轨迹。值得注意的是,数据并非严格按日期排序(id 6-8 插在中间),这模拟了真实场景中用户可能补录或跨日记录的情况。在应用中,这组数据通过 ForEach 渲染为柱状图,每根柱子的高度通过 (w.kg - 78) * 18 计算得出——这种将数据值线性映射为像素高度的技巧是纯代码绘制简易图表的常用手法。

技术要点:ArkTS 中的 const 声明与 TypeScript 中的行为一致,用于声明不可重新赋值的常量引用。但需要注意,const 声明的数组虽然不能被重新赋值(即不能 WEIGHTS15 = [...]),但数组内部的内容仍然可以被修改(如 pushpop)。为了在组件内部维护可变的数据副本,本应用在 @State 初始化时使用了 .slice() 方法创建数组的浅拷贝,确保组件状态与常量数据互不影响。

3.2 饮食与运动数据

const MEALS15: Array<MealT15> = [
  { id: 1, time: '早餐 07:40', name: '燕麦碗 + 水煮蛋 + 无糖豆浆', kcal: 385, carb: 46, prot: 24, fat: 11, tag: '均衡' },
  { id: 2, time: '加餐 10:30', name: '小番茄一盒 + 原味坚果 10g', kcal: 128, carb: 12, prot: 4, fat: 8, tag: '低卡' },
  { id: 3, time: '午餐 12:20', name: '杂粮饭 + 清蒸鲈鱼 + 白灼菜心', kcal: 520, carb: 58, prot: 38, fat: 14, tag: '高蛋白' },
  { id: 4, time: '加餐 15:30', name: '无糖希腊酸奶 100g', kcal: 96, carb: 8, prot: 11, fat: 3, tag: '高蛋白' },
  { id: 5, time: '晚餐 18:50', name: '鸡胸沙拉 + 玉米半根', kcal: 412, carb: 34, prot: 35, fat: 13, tag: '轻食' },
  { id: 6, time: '昨日晚餐', name: '番茄牛腩 + 荞麦面', kcal: 610, carb: 66, prot: 34, fat: 20, tag: '超标' }
]

const SPORTS15: Array<SportT15> = [
  { id: 1, name: '快走 6km/h', icon: '🚶', mins: 45, kcal: 210, heat: 95 },
  { id: 2, name: '游泳 自由式', icon: '🏊', mins: 40, kcal: 340, heat: 88 },
  { id: 3, name: '力量训练 上肢', icon: '🏋️', mins: 50, kcal: 290, heat: 82 },
  { id: 4, name: '骑行 匀速', icon: '🚴', mins: 60, kcal: 380, heat: 90 },
  { id: 5, name: '跳绳 HIIT', icon: '🪢', mins: 20, kcal: 240, heat: 76 },
  { id: 6, name: '瑜伽 恢复', icon: '🧘', mins: 30, kcal: 110, heat: 70 },
  { id: 7, name: '椭圆机', icon: '🏃', mins: 35, kcal: 260, heat: 74 },
  { id: 8, name: '爬楼 12 层 ×3', icon: '🪜', mins: 18, kcal: 190, heat: 68 }
]

在这里插入图片描述

这两组数据分别定义了今日饮食记录和可选运动项目列表。饮食数据中每餐的三大营养素比例各不相同,在食物详情弹窗中会根据 carbprotfat 的值动态计算条形图的宽度百分比。运动数据中的 kcal 值在运动打卡 Tab 的卡片中显示,帮助用户选择热量消耗最优的运动方式。tag 字段的"超标"值在渲染时被特殊处理——当标签为"超标"时文字颜色变为红色(#DC2626),否则为绿色(#16A34A),这是数据驱动条件样式的典型应用。

3.3 用药与社群数据

const DRUGS15: Array<DrugT15> = [
  { id: 1, name: '司美格鲁肽 0.5mg', dose: '每周一次 · 皮下注射', times: '周日 09:00', remind: true, stock: 6 },
  { id: 2, name: '复合维生素 B', dose: '1 片/次', times: '每日 08:00', remind: true, stock: 30 },
  { id: 3, name: '鱼油 Omega-3', dose: '2 粒/次', times: '每日 20:00', remind: false, stock: 45 },
  { id: 4, name: '维生素 D3', dose: '1 粒/次', times: '每日 08:00', remind: true, stock: 60 },
  { id: 5, name: '蛋白粉(运动日)', dose: '1 勺/次', times: '运动后 30 分钟', remind: false, stock: 12 }
]

const POSTS15: Array<PostT15> = [
  { id: 1, user: '减脂中的老周', avatar: '🧔', text: '入营 90 天,腰围从 104 到 92,体检脂肪肝没了!坚持记录真的有用。', likes: 328, comments: 46, topic: '#蜕变打卡#' },
  { id: 2, user: 'Momo', avatar: '👩', text: '平台期两周心态崩了,教练让我把碳水后置到运动后,第三周掉 1.4kg。', likes: 192, comments: 33, topic: '#平台期#' },
  { id: 3, user: '大熊', avatar: '🐻', text: '周末食堂红烧肉我炫了三块,来评论区领骂(附运动补打卡)。', likes: 501, comments: 88, topic: '#放纵日#' },
  { id: 4, user: '梨子汽水', avatar: '🥤', text: '食堂党自救:拿水涮一下再吃,一个月少摄入差不多 4000kcal。', likes: 274, comments: 25, topic: '#食堂党#' },
  { id: 5, user: '阿力', avatar: '🧑', text: '第一次完整跟完 HIIT 20 分钟,喘成风箱但很爽,明天继续。', likes: 156, comments: 19, topic: '#运动打卡#' },
  { id: 6, user: '南屿', avatar: '🧑‍🎤', text: '医生说我尿酸偏高,把内脏和浓汤停了,配合体重管理两个月回落正常。', likes: 143, comments: 21, topic: '#指标改善#' }
]

在这里插入图片描述

用药数据涵盖了处方药(司美格鲁肽)、日常补充剂(复合维生素、鱼油、维生素 D3)和运动营养品(蛋白粉)。remind 字段的布尔值在界面中通过 Toggle 组件进行开关切换控制。社群数据模拟了真实减重社区中的用户动态,包含不同的情感色彩和话题标签,在"营友圈"Tab 中通过 ForEach 渲染为信息流卡片。

技术要点:在 ArkTS 中,数组类型的 const 常量通常用于定义应用级的静态数据源(如配置项、初始数据、枚举映射等)。当这些数据需要在组件内部被修改时(如添加新记录、删除条目),应通过 .slice() 方法创建副本后赋值给 @State 变量,避免直接修改常量数据导致的数据污染问题。

3.4 图表辅助数据与营养素常量

const WKT15: Array<string> = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const WKV15: Array<number> = [412, 380, 0, 296, 524, 340, 210]

interface NutriT15 {
  name: string
  pct: number
  color: string
}

const NUTRIS15: Array<NutriT15> = [
  { name: '碳水 158g', pct: 46, color: '#F97316' },
  { name: '蛋白质 112g', pct: 32, color: '#16A34A' },
  { name: '脂肪 49g', pct: 22, color: '#0EA5E9' }
]

在这里插入图片描述

WKT15WKV15 是配对使用的平行数组,前者存储星期标签,后者存储对应的热量消耗值。这种平行数组的方式在简单数据展示场景中比对象数组更轻量。WKV15 中周三的值为 0,在界面中被特殊处理为"休息"并显示灰色柱条。NUTRIS15 定义了三大营养素的占比与颜色映射,pct 值在渲染时直接作为 layoutWeight 的参数,实现按比例分配宽度的堆叠条形图效果。


四、工具函数与主组件入口

4.1 目标差距计算函数

function goalDelta15(kg: number): string {
  if (kg <= 82.5) {
    return '距目标 2.4kg'
  }
  return '本周 -' + (85.0 - kg).toFixed(1).toString() + 'kg'
}

这是一个独立的顶层函数,接收体重值并返回描述性字符串。当体重低于或等于 82.5kg 时,返回固定文案"距目标 2.4kg";否则计算与开营首测值 85.0kg 的差值,格式化为一位小数后拼接成"本周 -X.Xkg"的文案。.toFixed(1) 是 JavaScript/TypeScript 原生的数字格式化方法,在 ArkTS 中同样可用。这种将业务逻辑封装为独立函数的做法,使界面代码更加专注于 UI 描述,提高了代码的可测试性和可维护性。

4.2 主组件声明与状态定义

@Entry
@Component
struct Index {
  @State tab15: number = 0
  @State weights15: Array<WeightT15> = WEIGHTS15.slice()
  @State meals15: Array<MealT15> = MEALS15.slice()
  @State sports15: Array<SportT15> = SPORTS15.slice()
  @State drugs15: Array<DrugT15> = DRUGS15.slice()
  @State posts15: Array<PostT15> = POSTS15.slice()
  @State ords15: Array<OrdT15> = ORDS15.slice()
  @State selMeal15: number = 0
  @State showWeight15: boolean = false
  @State showFood15: boolean = false
  @State showMeal15: boolean = false
  @State showDrug15: boolean = false
  @State showDel15: boolean = false
  @State showGoal15: boolean = false
  @State wKg15: number = 82
  @State wKgDot15: number = 4
  @State wNote15: string = ''
  @State mTime15: number = 0
  @State mName15: string = ''
  @State mKcal15: number = 400
  @State dTimes15: number = 0
  @State dRemind15: boolean = true
  @State selSport15: number = 0
  @State goalKg15: number = 80
  @State goalWeeks15: number = 12
  @State armed15: boolean = false
  @State fireOp15: number = 1
  @State runX15: number = 0

这是整个应用的核心入口。@Entry 装饰器标记此组件为页面的根组件(入口组件),一个页面有且仅有一个 @Entry 组件。@Component 装饰器声明这是一个自定义组件,可以被其他组件引用或在 UI 树中渲染。struct Index 使用 struct 而非 class 来定义组件——这是 ArkTS 的语法特性,组件必须以 struct 形式声明。

@State 装饰器修饰的变量构成了组件的完整状态集合。这些状态变量可以分为几类:第一类是数据集合(weights15meals15 等),通过 .slice() 从常量创建副本,确保组件内部可安全修改;第二类是当前选中的索引值(selMeal15selSport15 等),用于记录用户当前操作的数据项;第三类是弹窗显隐控制(showWeight15showGoal15 共 6 个布尔值),每个对应一个弹窗;第四类是表单临时输入值(wKg15wNote15mName15 等),在弹窗中双向绑定到输入控件;第五类是动画驱动参数(fireOp15runX15),在 aboutToAppear 生命周期中被设置为无限循环动画的目标值。

技术要点@Entry@Component 是 ArkTS 组件化的两个基础装饰器。@Component 声明一个可复用的 UI 组件单元,该组件拥有独立的状态空间和生命周期;@Entry 则进一步标记该组件为页面入口,鸿蒙系统的页面路由系统会识别被 @Entry 标记的组件并将其作为页面加载的根节点。一个 ArkTS 源文件中可以定义多个 @Component 组件,但只能有一个被标记为 @Entry

@Entry @Component struct Index

状态变量声明区

数据集合状态
weights15 / meals15 / sports15 等

选中索引状态
selMeal15 / selSport15

弹窗显隐状态
showWeight15 ~ showGoal15

表单输入状态
wKg15 / wNote15 / mName15 等

动画驱动状态
fireOp15 / runX15

build() 方法渲染

页面 UI 树


五、生命周期与动画系统

5.1 aboutToAppear 与 animateTo 无限动画

aboutToAppear() {
  this.getUIContext().animateTo({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.fireOp15 = 0.3
  })
  this.getUIContext().animateTo({ duration: 2000, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.runX15 = 10
  })
}

aboutToAppear() 是 ArkUI 组件生命周期的核心回调之一,它在组件实例创建后、build() 方法首次执行前被调用。这个时机非常适合进行数据初始化和动画启动。本应用在此处启动了两个无限循环动画。

第一个动画驱动 fireOp15 变量在 1 和 0.3 之间交替变化,时长 1000 毫秒,使用 EaseInOut 缓动曲线,iterations: -1 表示无限循环,PlayMode.Alternate 表示正反向交替播放。这个动画被绑定到标题栏火焰图标的 opacity 属性上,制造出火焰"呼吸"闪烁的效果。

第二个动画驱动 runX15 变量在 0 和 10 之间交替变化,时长 2000 毫秒。这个动画被绑定到搜索栏中跑步图标的 translate({ x: this.runX15 }) 属性上,制造出跑步图标左右平移的效果。

技术要点animateTo 是 ArkUI 提供的显式动画 API。它接收两个参数:动画配置对象和闭包函数。在闭包函数中修改 @State 变量的值,框架不会立即将终值应用到 UI,而是在配置的时长内从当前值平滑过渡到目标值。iterations 设为 -1 表示无限重复,PlayMode.Alternate 使动画在正向和反向之间交替(即先从 1 到 0.3,再从 0.3 回到 1,循环往复),这比 PlayMode.Normal(始终从起点到终点再跳回起点)更加自然。

技术要点getUIContext() 方法返回当前组件所在的 UI 上下文对象,通过该对象调用 animateTo 可以确保动画与当前页面的渲染管线正确关联。在较新的 ArkUI API 版本中,推荐使用 getUIContext().animateTo() 而非全局的 animateTo(),因为前者能更精确地绑定到正确的页面实例,避免在多页面场景下出现动画串扰。


六、头部栏构建

6.1 headerBar15 方法详解

@Builder
headerBar15() {
  Column() {
    Row() {
      Text('🔥 体重代谢营')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Text('METABO CAMP')
        .fontSize(10)
        .fontColor('#C9A88A')
        .letterSpacing(2)
        .margin({ left: 6 })
      Text('🔥')
        .fontSize(17)
        .opacity(this.fireOp15)
        .margin({ left: 6 })
      Column() {
        Text('第 5 周')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
      }
      .padding({ left: 12, right: 12, top: 5, bottom: 5 })
      .borderRadius(13)
      .backgroundColor('#F97316')
      .margin({ left: 10 })
      Text('🔔')
        .fontSize(19)
        .margin({ left: 8 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

@Builder 装饰器是 ArkTS 中用于定义可复用 UI 片段的核心机制。被 @Builder 修饰的方法返回一段声明式 UI 描述,可以在 build() 方法或其他 @Builder 方法中通过 this.methodName() 的方式引用。与 @Component 不同,@Builder 方法不创建独立的组件实例和状态空间,而是直接在调用处展开,因此它更适合用于在同一组件内部拆分复杂 UI 结构。

这段代码构建了头部栏的第一行:标题"🔥 体重代谢营"使用 20 号粗体炭黑色字体,旁边是英文副标题"METABO CAMP"使用 10 号米色字体并设置了 2 的字符间距(letterSpacing),营造品牌感。第二个火焰图标的 opacity 绑定到 this.fireOp15,实现了 aboutToAppear 中设置的呼吸动画效果。右侧的"第 5 周"标签使用橙色背景圆角胶囊样式,通过 Column 包裹 Text 并设置 paddingborderRadius 来实现。

技术要点ColumnRow 是 ArkUI 中最基本的两个线性布局容器。Column 将子组件按垂直方向从上到下排列,Row 将子组件按水平方向从左到右排列。它们都支持 alignItems 属性来控制子组件在交叉轴上的对齐方式:VerticalAlign.Center 表示在 Row 中垂直居中,HorizontalAlign.Start 表示在 Column 中左对齐。此外还支持 justifyContent 属性控制主轴方向上的分布方式。

6.2 搜索栏与快速状态滚动条

    Row() {
      Text('🔍 搜食物热量 / 运动消耗 / 打卡')
        .fontSize(13)
        .fontColor('#C9A88A')
      Text('📷')
        .fontSize(16)
        .margin({ left: 10 })
      Text('🏃')
        .fontSize(16)
        .translate({ x: this.runX15 })
        .margin({ left: 10 })
    }
    .width('100%')
    .height(40)
    .borderRadius(20)
    .backgroundColor('#FFFFFF')
    .padding({ left: 14, right: 12 })
    .alignItems(VerticalAlign.Center)
    .margin({ top: 12 })

    Scroll() {
      Row({ space: 8 }) {
        ForEach(['今日 1541kcal', '消耗 622kcal', '饮水 1.8L', '步数 8420'], (t: string) => {
          Text('✓ ' + t)
            .fontSize(11)
            .fontColor('#16A34A')
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .borderRadius(12)
            .backgroundColor('#F0FDF4')
        }, (t: string) => t)
      }
      .padding({ top: 2, bottom: 2 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ top: 10 })
    .height(30)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 14, bottom: 8 })
  .backgroundColor('#FFF9F0')
}

搜索栏是一个圆角白色背景的 Row,高度 40,圆角半径 20(胶囊形)。其中的跑步图标 🏃 通过 .translate({ x: this.runX15 }) 绑定到动画变量,实现了左右平移的效果——这是一个将 animateTo 驱动的状态变量绑定到 translate 变换属性的典型应用。

下方的快速状态条使用了 Scroll 组件包裹 Row,并设置 .scrollable(ScrollDirection.Horizontal) 实现横向滚动。scrollBar(BarState.Off) 隐藏了滚动条,使界面更加简洁。Row({ space: 8 }) 中的 space 参数设置了子组件之间的间距为 8vp。ForEach 遍历字符串数组,为每个状态项生成一个绿色背景的圆角标签。

技术要点Scroll 是 ArkUI 提供的滚动容器组件,支持纵向(ScrollDirection.Vertical)和横向(ScrollDirection.Horizontal)两种滚动方向。当内容超出容器尺寸时,用户可以通过滑动手势查看溢出的内容。scrollBar(BarState.Off) 用于隐藏系统默认的滚动条指示器,在标签横滑、图片轮播等场景中常用。BarState 枚举有三种值:On(始终显示)、Off(始终隐藏)、Auto(自动显示隐藏)。


七、体重管理 Tab 深度解析

7.1 进度环与关键指标卡

@Builder
tabWeight15() {
  Column() {
    Row() {
      Column() {
        Stack() {
          Progress({ value: 60, total: 100 })
            .style({ strokeWidth: 9 })
            .color('#F97316')
            .backgroundColor('#FFE8D2')
            .width(88)
            .height(88)
          Column() {
            Text('-2.6')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F97316')
            Text('累计kg')
              .fontSize(9)
              .fontColor('#C9A88A')
              .margin({ top: 2 })
          }
          .justifyContent(FlexAlign.Center)
        }
        .width(88)
        .height(88)
      }
      .justifyContent(FlexAlign.Center)

这段代码构建了体重 Tab 的顶部概览卡片。Stack 是 ArkUI 的层叠布局容器,它将子组件按照声明顺序依次叠加(后声明的在上层)。这里将 Progress 环形进度条作为底层,将 Column(包含"-2.6"和"累计kg"文字)叠加在进度条中心,实现了进度环中心显示数值的经典设计。

Progress 组件通过 { value: 60, total: 100 } 配置当前值和总值,默认渲染为环形进度条。.style({ strokeWidth: 9 }) 设置环形线宽为 9vp,.color('#F97316') 设置进度颜色为活力橙,.backgroundColor('#FFE8D2') 设置轨道底色为浅橙色。中心的 Column 通过 .justifyContent(FlexAlign.Center) 实现内容在 Stack 层叠容器中的居中对齐。

技术要点Stack 是 ArkUI 的层叠布局容器,子组件按照声明顺序从下到上叠加。它支持 alignContent 属性控制子组件的对齐方式,默认为 Alignment.Center(居中)。Stack 常用于实现进度环中心数值、图片上叠加文字、按钮上叠加图标等场景。需要注意的是,Stack 不会自动裁剪超出边界的内容,如需裁剪可设置 .clip(true)

技术要点Progress 组件支持两种类型:环形进度条(ProgressType.Ring,默认)和线性进度条(ProgressType.Linear)。通过 type 参数指定类型。.style() 方法用于设置特定类型的样式参数,如环形进度条的 strokeWidth(线宽)和线性进度条的 strokeWidthcolor 属性设置已完成进度的颜色,backgroundColor 设置未完成进度的轨道颜色。

7.2 指标详情与腰臀比可视化

      Column() {
        Row() {
          Text('今晨体重')
            .fontSize(11)
            .fontColor('#C9A88A')
          Text('82.4kg')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D2C1E')
            .margin({ left: 8 })
        }
        .width('100%')
        Row() {
          Text('体脂率')
            .fontSize(11)
            .fontColor('#C9A88A')
          Text('28.6% ↓2.4')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#16A34A')
            .margin({ left: 8 })
        }
        .width('100%')
        .margin({ top: 6 })
        Row() {
          Text('腰围')
            .fontSize(11)
            .fontColor('#C9A88A')
          Text('96cm ↓4cm')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#16A34A')
            .margin({ left: 8 })
        }
        .width('100%')
        .margin({ top: 6 })
        Row() {
          Text('腰臀比')
            .fontSize(11)
            .fontColor('#C9A88A')
          Row() {
            Row()
              .width('62%')
              .height(8)
              .borderRadius(4)
              .backgroundColor('#F97316')
          }
          .width(80)
          .height(8)
          .borderRadius(4)
          .backgroundColor('#FFE8D2')
          .margin({ left: 8 })
          Text('0.91')
            .fontSize(11)
            .fontColor('#3D2C1E')
            .margin({ left: 6 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 6 })

这段代码展示了指标详情列的构建方式。每行都采用 Row 包裹标签 Text 和数值 Text 的模式,通过不同的颜色区分指标性质:体重值使用炭黑色(#3D2C1E),下降指标使用草绿色(#16A34A),辅助标签使用米色(#C9A88A)。

腰臀比行中包含一个手工绘制的进度条:外层 Row 宽 80、高 8、圆角 4、背景色浅橙,内层 Row 宽度设为 '62%'、背景色橙色。这种通过嵌套 Row 组件、设置不同宽度和背景色来模拟进度条的手法,在 ArkUI 开发中非常常见,尤其适合不需要精确数值动画的静态进度展示场景。

7.3 记录体重按钮与弹窗触发

        Text('记录体重')
          .fontSize(12)
          .fontColor('#FFFFFF')
          .padding({ left: 18, right: 18, top: 7, bottom: 7 })
          .borderRadius(15)
          .backgroundColor('#F97316')
          .margin({ top: 10 })
          .onClick(() => {
            this.wKg15 = 82
            this.wKgDot15 = 4
            this.wNote15 = ''
            this.showWeight15 = true
          })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 16 })
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .alignItems(VerticalAlign.Center)
    .margin({ bottom: 12 })

"记录体重"按钮是一个 Text 组件,通过设置 paddingborderRadiusbackgroundColor 将其样式化为圆角按钮。.onClick() 事件处理器中先将表单状态变量重置为默认值(wKg15 = 82wKgDot15 = 4wNote15 = ''),然后将 showWeight15 设为 true 触发体重记录弹窗的显示。这种"先重置表单、再打开弹窗"的模式确保了每次打开弹窗时表单都是干净的初始状态。

技术要点layoutWeight 是 ArkUI 布局系统中非常重要的属性,它用于在 RowColumn 中分配剩余空间。当一个容器有多个子组件时,设置了 layoutWeight 的子组件会按照权重比例瓜分容器中未被固定尺寸子组件占用的剩余空间。在本例中,右侧指标列设置了 .layoutWeight(1),使其占据左侧进度环之后的所有剩余宽度,实现了左小右大的布局比例。

7.4 体重趋势柱状图

    Column() {
      Row() {
        Text('📈 近 8 周体重')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3D2C1E')
        Text(goalDelta15(82.4))
          .fontSize(11)
          .fontColor('#F97316')
          .margin({ left: 8 })
      }
      .width('100%')
      Row({ space: 10 }) {
        ForEach(this.weights15, (w: WeightT15) => {
          Column() {
            Text(w.kg.toString())
              .fontSize(8)
              .fontColor('#C9A88A')
            Row()
              .width(14)
              .height((w.kg - 78) * 18)
              .borderRadius(4)
              .backgroundColor(w.kg <= 83 ? '#16A34A' : '#F97316')
            Text(w.date)
              .fontSize(8)
              .fontColor('#C9A88A')
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Center)
        }, (w: WeightT15) => w.id.toString() + w.kg.toString())
      }
      .alignItems(VerticalAlign.Bottom)
      .height(100)
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .margin({ bottom: 12 })

这段代码实现了一个纯 ArkUI 组件搭建的简易柱状图。Row({ space: 10 }) 作为图表容器,子元素之间保持 10vp 间距。ForEach 遍历体重数据数组,为每条记录生成一个 Column,其中包含三部分:顶部的体重数值文本、中间的柱条(一个空的 Row 组件设置固定宽度和动态高度)、底部的日期标签。

柱条的高度通过 (w.kg - 78) * 18 计算得出——以 78kg 为基准线,每超出 1kg 对应 18vp 的高度。柱条颜色通过条件表达式 w.kg <= 83 ? '#16A34A' : '#F97316' 决定:体重低于或等于 83kg 时显示绿色(达标),否则显示橙色(超标)。Row 容器设置 .alignItems(VerticalAlign.Bottom) 使所有柱条底部对齐,.justifyContent(FlexAlign.Center) 使柱状图整体水平居中。

技术要点ForEach 的键值生成函数 (w: WeightT15) => w.id.toString() + w.kg.toString() 将 id 和体重值拼接为唯一键。这样做的好处是当体重数据更新(如用户记录了新的体重)时,框架能精确识别哪条数据发生了变化,仅更新对应的柱条而非整个图表。如果仅使用 w.id 作为键值,体重值变化但 id 不变时,框架可能无法正确触发该柱条的高度更新。

7.5 阶段目标与线性进度条

    Column() {
      Text('🎯 阶段目标')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Row() {
        Text('80kg / 12 周 · 医学减重方案 · 每周减重不超过 1kg')
          .fontSize(11)
          .fontColor('#8A7A68')
          .layoutWeight(1)
        Text('调整')
          .fontSize(11)
          .fontColor('#FFFFFF')
          .padding({ left: 14, right: 14, top: 5, bottom: 5 })
          .borderRadius(12)
          .backgroundColor('#0EA5E9')
          .onClick(() => {
            this.goalKg15 = 80
            this.goalWeeks15 = 12
            this.showGoal15 = true
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 10 })
      Row() {
        Progress({ value: 42, total: 100, type: ProgressType.Linear })
          .style({ strokeWidth: 10 })
          .color('#F97316')
          .backgroundColor('#FFE8D2')
          .layoutWeight(1)
        Text('42%')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F97316')
          .margin({ left: 10 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .alignItems(HorizontalAlign.Start)
    .margin({ bottom: 12 })

这段代码展示了线性进度条的使用方式。与前面体重概览卡中的环形进度条不同,这里通过 type: ProgressType.Linear 显式指定为线性进度条。.style({ strokeWidth: 10 }) 设置进度条高度为 10vp。进度条通过 .layoutWeight(1) 占据行内剩余空间,右侧紧跟百分比文字标签。

"调整"按钮的 onClick 事件中重置目标相关状态变量并打开目标设置弹窗。这种模式与"记录体重"按钮一致,都是"先重置、后显示"的弹窗触发范式。

技术要点ProgressType.Linear 渲染的线性进度条与 ProgressType.Ring 渲染的环形进度条各有适用场景。线性进度条适合展示完成度、加载进度等线性维度的信息,通常占用整行宽度;环形进度条适合展示百分比、比率等需要在有限空间内突出展示的信息,通常配合中心数值使用。


八、饮食管理 Tab 深度解析

8.1 饮食概览与营养素堆叠条

@Builder
tabMeal15() {
  Column() {
    Row() {
      Text('🍱 今日饮食')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Text('1541 / 1800 kcal')
        .fontSize(11)
        .fontColor('#F97316')
        .margin({ left: 8 })
      Text('+ 记餐')
        .fontSize(11)
        .fontColor('#FFFFFF')
        .padding({ left: 12, right: 12, top: 5, bottom: 5 })
        .borderRadius(12)
        .backgroundColor('#16A34A')
        .margin({ left: 10 })
        .onClick(() => {
          this.mTime15 = 0
          this.mName15 = ''
          this.mKcal15 = 400
          this.showMeal15 = true
        })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .margin({ bottom: 10 })

    Column() {
      Text('三大营养素(点击食物看详情)')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Row() {
        ForEach(NUTRIS15, (n: NutriT15) => {
          Column() {
            Text(n.name)
              .fontSize(10)
              .fontColor('#3D2C1E')
            Text(n.pct.toString() + '%')
              .fontSize(10)
              .fontColor('#8A7A68')
              .margin({ top: 2 })
          }
          .layoutWeight(n.pct)
          .height(44)
          .justifyContent(FlexAlign.Center)
          .backgroundColor(n.color)
        }, (n: NutriT15) => n.name)
      }
      .width('100%')
      .borderRadius(10)
      .clip(true)
      .margin({ top: 10 })
      Text('建议:蛋白充足 ✓ 碳水适中 ✓ 脂肪偏低 ✓')
        .fontSize(10)
        .fontColor('#16A34A')
        .margin({ top: 8 })
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .margin({ bottom: 12 })

这段代码中最精妙的部分是三大营养素堆叠条的实现。ForEach 遍历 NUTRIS15 数组,为每个营养素生成一个 Column,其 layoutWeight 被设为 n.pct(即百分比数值 46、32、22)。由于三个 Column 的权重之和为 100,它们会精确地按照百分比比例瓜分 Row 的宽度,形成一条无缝拼接的彩色堆叠条。每个色块内部显示营养素名称和百分比,通过 .justifyContent(FlexAlign.Center) 居中对齐。

.clip(true) 是关键设置——它裁剪 Row 容器超出圆角边界的内容,使堆叠条的左右两端呈现圆角效果而非直角。如果不设置 clip(true),内部色块的直角会"溢出"外层 Row 的圆角,破坏视觉一致性。

技术要点.clip(true) 方法用于裁剪组件超出其边界的内容,常与 borderRadius 配合使用。当容器设置了圆角但内部子组件有自己的背景色时,子组件的直角会"刺穿"容器的圆角。设置 clip(true) 后,所有超出容器圆角边界的内容都会被裁剪掉,保证视觉上的圆角效果完整。

8.2 餐食列表与食物详情触发

    ForEach(this.meals15, (m: MealT15) => {
      Row() {
        Column() {
          Text(m.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D2C1E')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Row() {
            Text(m.time)
              .fontSize(10)
              .fontColor('#8A7A68')
            Text(m.tag)
              .fontSize(10)
              .fontColor(m.tag === '超标' ? '#DC2626' : '#16A34A')
              .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              .borderRadius(8)
              .backgroundColor('#FFF3E6')
              .margin({ left: 8 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)

        Column() {
          Text(m.kcal.toString() + ' kcal')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F97316')
          Text('详情 >')
            .fontSize(10)
            .fontColor('#0EA5E9')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.End)
        .onClick(() => {
          this.selMeal15 = m.id - 1
          this.showFood15 = true
        })
      }
      .width('100%')
      .padding(12)
      .borderRadius(14)
      .backgroundColor('#FFFFFF')
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 10 })
    }, (m: MealT15) => m.id.toString())
  }
  .width('100%')
}

餐食列表使用 ForEach 遍历 meals15 数组,每条记录渲染为一个 Row 卡片。左侧 Column 通过 layoutWeight(1) 占据大部分宽度,右侧 Column 显示热量值和"详情 >"链接。点击右侧区域触发 onClick 事件,将 selMeal15 设为当前餐食的索引(m.id - 1,因为 id 从 1 开始而数组索引从 0 开始),然后打开食物详情弹窗。

食物名称使用了 .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }),当文本超出可用宽度时截断并显示省略号。标签颜色通过条件表达式根据 tag 值动态变化——"超标"显示红色,其他显示绿色。

技术要点.maxLines() 方法限制 Text 组件的最大行数,配合 .textOverflow() 方法指定溢出处理方式。TextOverflow.Ellipsis 在文本末尾添加省略号(…),TextOverflow.Clip 直接截断不添加标记,TextOverflow.None 不做处理(可能超出容器边界)。在列表项中限制文本行数并使用省略号,是保证列表布局整齐一致的标准做法。


九、运动管理 Tab 深度解析

9.1 本周运动消耗横条图

@Builder
tabSport15() {
  Column() {
    Row() {
      Text('🏃 今日运动')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Text('消耗 622 kcal')
        .fontSize(11)
        .fontColor('#16A34A')
        .margin({ left: 8 })
    }
    .width('100%')
    .margin({ bottom: 10 })

    Column() {
      Text('本周消耗(kcal)')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      ForEach(WKT15, (d: string, i: number) => {
        Row() {
          Text(d)
            .fontSize(11)
            .fontColor('#8A7A68')
            .width(38)
          Row() {
            Row()
              .width((WKV15[i] * 100 / 600).toString() + '%')
              .height(12)
              .borderRadius(6)
              .backgroundColor(WKV15[i] === 0 ? '#E5E7EB' : (WKV15[i] >= 400 ? '#F97316' : '#16A34A'))
          }
          .layoutWeight(1)
          .height(12)
          .borderRadius(6)
          .backgroundColor('#FFF3E6')
          Text(WKV15[i] === 0 ? '休息' : WKV15[i].toString())
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(WKV15[i] === 0 ? '#C9A88A' : '#3D2C1E')
            .width(36)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 8 })
      }, (d: string, i: number) => d + i.toString())
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .margin({ bottom: 12 })

本周运动消耗图表采用了与体重柱状图不同的可视化方式——水平横条图。ForEach 同时遍历 WKT15(星期标签)和通过索引 i 访问 WKV15(热量值),这展示了 ForEach 支持第二个参数索引的能力。

每行包含三部分:固定宽度 38vp 的星期标签、layoutWeight(1) 的进度条容器、固定宽度 36vp 的数值文本。进度条内层的宽度通过 (WKV15[i] * 100 / 600).toString() + '%' 计算——将热量值映射为以 600kcal 为满刻度的百分比宽度。颜色根据热量值分三档:0 为灰色(休息日)、400 以上为橙色(高强度)、其他为绿色(正常)。

数值文本使用 .textAlign(TextAlign.End) 实现右对齐,使数值在固定宽度内靠右排列,与进度条尾部对齐。

技术要点ForEach 的第二个参数(子组件生成函数)可以接收两个参数:当前数据项和索引值。当需要同时访问平行数组(如本例中的 WKT15WKV15)时,可以利用索引值 i 来访问另一个数组中对应位置的元素。这种模式在数据来自多个平行数组且不方便重构为对象数组时非常实用。

9.2 运动项目卡片网格

    Row({ space: 10 }) {
      ForEach(this.sports15, (s: SportT15) => {
        Column() {
          Text(s.icon)
            .fontSize(26)
            .margin({ top: 10 })
          Text(s.name)
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D2C1E')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .margin({ top: 6 })
          Row() {
            Text(s.mins.toString() + ' 分钟')
              .fontSize(10)
              .fontColor('#8A7A68')
            Text(s.kcal.toString() + 'kcal')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F97316')
              .margin({ left: 6 })
          }
          .margin({ top: 4 })
          Text('打卡')
            .fontSize(11)
            .fontColor('#FFFFFF')
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding({ top: 5, bottom: 5 })
            .borderRadius(12)
            .backgroundColor('#16A34A')
            .margin({ top: 8 })
            .onClick(() => {
              this.selSport15 = s.id - 1
            })
        }
        .width('48%')
        .padding(10)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .margin({ bottom: 10 })
        .alignItems(HorizontalAlign.Start)
      }, (s: SportT15) => s.id.toString())
    }
    .width('100%')

这段代码实现了一个两列的运动项目卡片网格。每个卡片宽度设为 '48%',配合 Row({ space: 10 }) 的间距设置,在一行中恰好容纳两个卡片。这是在没有使用 Grid 组件的情况下,通过 Row 和固定百分比宽度模拟两列网格布局的常用技巧。

每个卡片包含运动图标(Emoji)、名称、时长和热量信息,底部是"打卡"按钮。打卡按钮的 .width('100%') 使其占满卡片宽度,.textAlign(TextAlign.Center) 使文字居中,形成完整的底部操作区域。点击后将 selSport15 设为当前运动项目的索引。

技术要点:在 ArkUI 中实现两列网格布局有两种常见方式:一是使用 Grid 组件配合 GridItem,适合大量数据和虚拟滚动场景;二是使用 Row 配合子组件的百分比宽度(如 '48%'),适合数据量较小的静态网格。本应用选择了后者,因为运动项目仅 8 个,无需虚拟滚动,且 Row 方式的代码更简洁直观。

9.3 最近打卡与删除触发

    Column() {
      Text('📋 最近打卡')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Row() {
        Text('🏃 快走 6km/h · 45 分钟 · 210kcal')
          .fontSize(11)
          .fontColor('#8A7A68')
          .layoutWeight(1)
        Text('🗑️')
          .fontSize(14)
          .onClick(() => {
            this.armed15 = false
            this.showDel15 = true
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 10 })
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .margin({ bottom: 12 })
  }
  .width('100%')
}

最近打卡区域展示了一条运动打卡记录,右侧的删除图标通过 onClick 事件触发删除确认弹窗。注意在打开弹窗前先将 armed15 重置为 false——这个状态变量控制删除弹窗中的确认开关,重置确保每次打开弹窗时开关都是关闭状态,用户需要手动打开开关才能执行删除操作,这是一种安全防护设计。


十、用药管理 Tab 深度解析

10.1 用药列表卡片

@Builder
tabDrug15() {
  Column() {
    Row() {
      Text('💊 用药与营养')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Text('处方药需遵医嘱')
        .fontSize(11)
        .fontColor('#DC2626')
        .margin({ left: 8 })
    }
    .width('100%')
    .margin({ bottom: 10 })

    ForEach(this.drugs15, (d: DrugT15) => {
      Column() {
        Row() {
          Column() {
            Text('💊')
              .fontSize(24)
              .margin({ top: 8 })
          }
          .width(52)
          .height(52)
          .borderRadius(14)
          .backgroundColor('#FFF3E6')
          .justifyContent(FlexAlign.Center)

          Column() {
            Text(d.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3D2C1E')
            Text(d.dose)
              .fontSize(11)
              .fontColor('#8A7A68')
              .margin({ top: 3 })
            Text('🕒 ' + d.times + ' · 余 ' + d.stock.toString() + ' 次')
              .fontSize(10)
              .fontColor('#F97316')
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 12 })

          Column() {
            Text(d.remind ? '🔔 已提醒' : '🔕 关闭')
              .fontSize(10)
              .fontColor(d.remind ? '#16A34A' : '#C9A88A')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .borderRadius(9)
              .backgroundColor('#FFF3E6')
            Text('编辑')
              .fontSize(11)
              .fontColor('#FFFFFF')
              .padding({ left: 12, right: 12, top: 4, bottom: 4 })
              .borderRadius(11)
              .backgroundColor('#0EA5E9')
              .margin({ top: 6 })
              .onClick(() => {
                this.dTimes15 = 0
                this.dRemind15 = d.remind
                this.showDrug15 = true
              })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')
      .padding(12)
      .borderRadius(14)
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 10 })
    }, (d: DrugT15) => d.id.toString() + d.remind.toString())

用药列表的每张卡片采用三列布局:左侧是 52x52 的圆角图标容器(浅橙色背景),中间是药品名称、剂量和提醒时间信息(通过 layoutWeight(1) 占据剩余空间),右侧是提醒状态标签和编辑按钮。

提醒状态标签通过条件表达式 d.remind ? '🔔 已提醒' : '🔕 关闭' 显示不同文案和颜色。编辑按钮的 onClick 事件中先将 dRemind15 设为当前药品的提醒状态值(d.remind),确保编辑弹窗中的 Toggle 开关与当前药品状态一致。

技术要点ForEach 的键值生成函数 (d: DrugT15) => d.id.toString() + d.remind.toString() 将 id 和提醒状态拼接为键值。当用户在编辑弹窗中修改药品的提醒状态并保存后,remind 字段的变化会导致键值变化,框架据此识别该列表项需要重新渲染,从而更新提醒状态标签的显示。这是利用键值变化驱动列表项刷新的精妙应用。

10.2 安全提示卡片

    Column() {
      Text('⚠️ 提示:GLP-1 类药物常见恶心、便秘等反应,若持续呕吐或腹痛请立即停药并联系医生。')
        .fontSize(11)
        .fontColor('#DC2626')
        .lineHeight(17)
    }
    .width('100%')
    .padding(12)
    .borderRadius(14)
    .backgroundColor('#FEF2F2')
    .alignItems(HorizontalAlign.Start)
    .margin({ bottom: 12 })
  }
  .width('100%')
}

这是一张安全提示卡片,使用红色文字(#DC2626)和浅红色背景(#FEF2F2)传达警告信息。.lineHeight(17) 设置行高为 17vp,使多行文本有舒适的阅读间距。在医疗健康类应用中,这类安全提示是不可或缺的合规要素。


十一、社群动态 Tab 深度解析

11.1 营友圈信息流

@Builder
tabGroup15() {
  Column() {
    Row() {
      Text('👥 营友圈')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Text('今日 128 条新动态')
        .fontSize(11)
        .fontColor('#8A7A68')
        .margin({ left: 8 })
    }
    .width('100%')
    .margin({ bottom: 10 })

    ForEach(this.posts15, (p: PostT15) => {
      Column() {
        Row() {
          Text(p.avatar)
            .fontSize(24)
          Column() {
            Text(p.user)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3D2C1E')
            Text(p.topic)
              .fontSize(10)
              .fontColor('#F97316')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 10 })

          Text('+ 关注')
            .fontSize(10)
            .fontColor('#FFFFFF')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .borderRadius(11)
            .backgroundColor('#16A34A')
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

        Text(p.text)
          .fontSize(12)
          .fontColor('#5B4A38')
          .lineHeight(18)
          .margin({ top: 8 })

        Row() {
          Text('❤️ ' + p.likes.toString())
            .fontSize(11)
            .fontColor('#DC2626')
          Text('💬 ' + p.comments.toString())
            .fontSize(11)
            .fontColor('#8A7A68')
            .margin({ left: 14 })
          Text('昨天')
            .fontSize(10)
            .fontColor('#C9A88A')
            .layoutWeight(1)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 8 })
      }
      .width('100%')
      .padding(12)
      .borderRadius(14)
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 10 })
    }, (p: PostT15) => p.id.toString())
  }
  .width('100%')
}

社群动态卡片采用自上而下的三段式布局:顶部用户信息行(头像 + 用户名 + 话题标签 + 关注按钮)、中间正文文字、底部互动栏(点赞数 + 评论数 + 时间)。

头像使用 Emoji 表情(p.avatar,如 '🧔''👩'),24 号字体大小,无需加载网络图片。用户名和话题标签在 Column 中纵向排列,话题标签使用橙色字体突出显示。关注按钮是一个绿色圆角胶囊,通过 layoutWeight(1) 推到最右侧。

正文使用 .lineHeight(18) 设置行高,深褐色文字(#5B4A38)提供舒适的阅读体验。底部互动栏中,时间标签通过 .layoutWeight(1).textAlign(TextAlign.End) 推到最右侧并右对齐。

技术要点:在信息流列表中,每张卡片的内部布局通常采用 Column 包裹多个 Row 的结构。Column 负责垂直方向的层次划分(用户信息行、正文、互动栏),每个 Row 负责水平方向的元素排列。这种嵌套结构是 ArkUI 声明式布局的基本范式,通过 ColumnRow 的组合可以表达绝大多数常见的移动端 UI 布局模式。


十二、个人中心 Tab 深度解析

12.1 用户信息与统计概览

@Builder
tabMine15() {
  Column() {
    Row() {
      Column() {
        Text('💪')
          .fontSize(30)
      }
      .width(58)
      .height(58)
      .borderRadius(29)
      .backgroundColor('#FFE8D2')
      .justifyContent(FlexAlign.Center)

      Column() {
        Text('秦朗')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3D2C1E')
        Text('代谢营第 2 期 · 累计减重 2.6kg')
          .fontSize(11)
          .fontColor('#8A7A68')
          .margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 12 })

      Text('体检联动 >')
        .fontSize(12)
        .fontColor('#F97316')
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .alignItems(VerticalAlign.Center)
    .margin({ bottom: 12 })

    Row() {
      Column() {
        Text('35')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F97316')
        Text('打卡天数')
          .fontSize(10)
          .fontColor('#8A7A68')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      Column() {
        Text('96%')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor('#16A34A')
        Text('饮食达标率')
          .fontSize(10)
          .fontColor('#8A7A68')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      Column() {
        Text('18620')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0EA5E9')
        Text('累计步数')
          .fontSize(10)
          .fontColor('#8A7A68')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .margin({ bottom: 12 })

用户信息卡片左侧是 58x58 的圆形头像容器(borderRadius: 29 即宽度的一半,形成正圆),中间是用户名和副标题,右侧是"体检联动 >"链接文字。

统计概览行使用三个等权重的 Column(各 layoutWeight(1)),分别显示打卡天数、饮食达标率和累计步数。三个数值使用不同的颜色(橙色、绿色、蓝色)进行视觉区分,形成色彩丰富的数据仪表盘效果。这种等分三列统计卡片在移动端个人中心页面中极为常见。

技术要点:当多个子组件设置相同的 layoutWeight 值时,它们会均分剩余空间。本例中三个 Column 都设置 layoutWeight(1),因此各占据三分之一的可用宽度,形成等分三列布局。这种模式比使用固定百分比宽度(如 '33.3%')更加精确,不会因为百分比精度问题导致布局偏移。

12.2 订单列表

    Column() {
      Text('🧾 我的订单')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      ForEach(this.ords15, (o: OrdT15) => {
        Row() {
          Column() {
            Text(o.item)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3D2C1E')
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            Text(o.id + ' · ' + o.date)
              .fontSize(10)
              .fontColor('#8A7A68')
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Column() {
            Text(o.status)
              .fontSize(10)
              .fontColor(o.status === '已完成' ? '#16A34A' : (o.status === '进行中' ? '#F97316' : '#0EA5E9'))
              .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              .borderRadius(8)
              .backgroundColor('#FFF3E6')
            Text('¥' + o.amount.toString())
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3D2C1E')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ top: 10, bottom: 10 })
      }, (o: OrdT15) => o.id + o.status)
    }
    .width('100%')
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .margin({ bottom: 12 })
  }
  .width('100%')
}

订单列表中每条记录采用左右布局:左侧通过 layoutWeight(1) 占据主要空间,显示订单名称和编号日期;右侧显示状态标签和金额。状态标签的颜色通过嵌套条件表达式实现三档区分:已完成(绿色)、进行中(橙色)、待就诊(蓝色)。这种多条件嵌套的三元表达式在 ArkTS 中完全合法,但需要注意可读性——当条件分支过多时,考虑抽取为独立的函数会更清晰。


十三、弹窗系统深度解析

13.1 弹窗架构概览

本应用实现了 6 个弹窗,分别服务于不同的交互场景。所有弹窗都通过 @Builder 方法构建,并通过布尔类型的 @State 变量控制显隐。在 build() 方法的 Stack 容器中,通过 if 条件渲染决定是否将弹窗组件插入 UI 树。

true

false

true

false

true

false

true

false

true

false

true

false

用户交互触发

showWeight15?

weightOverlay15()
记录体重抽屉

showFood15?

foodOverlay15()
食物详情居中卡

showMeal15?

mealOverlay15()
新增餐食抽屉

showDrug15?

drugOverlay15()
编辑用药提醒抽屉

showDel15?

delOverlay15()
删除确认窄卡

showGoal15?

goalOverlay15()
目标设置居中卡

无弹窗显示

技术要点:在 ArkUI 中实现弹窗有两种主要方式。第一种是使用系统提供的 CustomDialog 组件和 @CustomDialog 装饰器,框架管理弹窗的生命周期和动画。第二种是使用 Stack 层叠布局 + 布尔状态变量 + if 条件渲染,开发者完全控制弹窗的布局、动画和交互逻辑。本应用选择了第二种方式,通过 zIndex(999) 确保弹窗浮于所有内容之上,通过半透明背景色(#99403311)实现遮罩效果,通过 onClick 实现点击遮罩区域关闭弹窗的交互。

13.2 记录体重抽屉

@Builder
weightOverlay15() {
  Column() {
    Column() {
      Row()
        .width(44)
        .height(5)
        .borderRadius(3)
        .backgroundColor('#FFE8D2')
        .margin({ top: 10 })

      Text('⚖️ 记录体重')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
        .margin({ top: 12 })

      Row() {
        Text('晨起体重')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#8A7A68')
        Row() {
          Text('-')
            .fontSize(16)
            .fontColor('#8A7A68')
            .padding(8)
            .onClick(() => {
              if (this.wKg15 > 50) {
                this.wKg15 = this.wKg15 - 1
              }
            })
          Text(this.wKg15.toString() + '.' + this.wKgDot15.toString() + 'kg')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F97316')
            .padding({ left: 12, right: 12 })
          Text('+')
            .fontSize(16)
            .fontColor('#16A34A')
            .padding(8)
            .onClick(() => {
              if (this.wKg15 < 130) {
                this.wKg15 = this.wKg15 + 1
              }
            })
        }
        .borderRadius(12)
        .backgroundColor('#FFF9F0')
        .margin({ left: 12 })
        Text('小数位')
          .fontSize(10)
          .fontColor('#C9A88A')
          .margin({ left: 10 })
        Row({ space: 6 }) {
          ForEach([0, 2, 4, 6, 8], (d: number) => {
            Text(d.toString())
              .fontSize(11)
              .fontColor(this.wKgDot15 === d ? '#FFFFFF' : '#8A7A68')
              .padding({ left: 8, right: 8, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor(this.wKgDot15 === d ? '#F97316' : '#FFF3E6')
              .onClick(() => {
                this.wKgDot15 = d
              })
          }, (d: number) => d.toString())
        }
        .margin({ left: 6 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 18 })

这是一个从底部滑入的抽屉式弹窗。顶部的拖拽指示条(一个 44x5 的圆角矩形)是移动端抽屉设计的标准元素,向用户暗示"可下拉关闭"。体重输入区域包含整数部分和小数部分两个选择器:整数部分通过"-"和"+"按钮增减,范围限制在 50-130 之间;小数部分通过 ForEach 渲染的标签组选择(0、2、4、6、8),当前选中项以橙色背景白色文字高亮显示。

体重的显示格式 this.wKg15.toString() + '.' + this.wKgDot15.toString() + 'kg' 将整数和小数状态变量拼接为"82.4kg"的格式。这种将一个数值拆分为两个独立状态变量分别控制的方式,在不使用 TextInput 数字键盘的情况下实现了精确的体重选择。

技术要点:弹窗的遮罩层通过外层 Column 设置 .backgroundColor('#99403311') 实现半透明遮罩效果。颜色值 #99403311 是 8 位 ARGB 格式,前两位 99 是 Alpha 通道(约 60% 不透明度),后面 6 位 403311 是 RGB 颜色值(深褐色)。.justifyContent(FlexAlign.End) 使内容卡片贴底排列,配合 .borderRadius({ topLeft: 22, topRight: 22 }) 只设置上方两个圆角,形成标准的底部抽屉外观。

13.3 体重记录备注输入与操作按钮

      Text('备注')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#8A7A68')
        .margin({ top: 16 })
      TextInput({ placeholder: '如:聚餐后 / 空腹 / 运动前…', text: this.wNote15 })
        .fontSize(13)
        .fontColor('#3D2C1E')
        .placeholderColor('#D9C8B5')
        .backgroundColor('#FFF9F0')
        .borderRadius(12)
        .padding({ left: 12, right: 12 })
        .margin({ top: 8 })
        .onChange((v: string) => {
          this.wNote15 = v
        })

      Row({ space: 10 }) {
        Text('取消')
          .fontSize(13)
          .fontColor('#8A7A68')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .borderRadius(19)
          .backgroundColor('#FFF3E6')
          .onClick(() => {
            this.showWeight15 = false
          })
        Text('保存')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .borderRadius(19)
          .backgroundColor('#F97316')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 18, bottom: 18 })
    }
    .width('100%')
    .borderRadius({ topLeft: 22, topRight: 22 })
    .backgroundColor('#FFFFFF')
    .onClick(() => {
    })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#99403311')
  .justifyContent(FlexAlign.End)
  .zIndex(999)
  .onClick(() => {
    this.showWeight15 = false
  })
}

TextInput 组件是 ArkUI 的文本输入控件。通过 { placeholder: '...', text: this.wNote15 } 配置占位提示文本和当前值。.placeholderColor('#D9C8B5') 设置占位文字颜色,.onChange((v: string) => { this.wNote15 = v }) 在用户输入时实时更新状态变量,实现了双向数据绑定。

操作按钮区域包含"取消"和"保存"两个等宽按钮(各 layoutWeight(1)),通过不同的背景色(浅橙色和橙色)区分主次操作。注意内层 Column.onClick(() => {}) 设置了空的点击处理器——这是为了阻止点击事件冒泡到外层遮罩层。当用户点击弹窗内容区域时,空处理器消费了点击事件,不会触发外层遮罩的 onClick(关闭弹窗);只有点击遮罩空白区域时才会执行外层的关闭逻辑。

技术要点TextInput 是 ArkUI 的核心输入组件,支持单行文本输入。它的 onChange 回调在文本内容变化时触发,参数 v 是当前输入框的完整文本值。通过将 v 赋值给 @State 变量,实现了输入框内容与组件状态的实时同步。TextInput 还支持 onSubmit(提交时回调)、onEditChange(编辑状态变化回调)等事件,以及 type 参数指定输入类型(如密码、数字等)。

技术要点:事件冒泡是 ArkUI 事件系统的核心机制。当嵌套的组件都注册了 onClick 事件时,点击内层组件会先触发内层的 onClick,然后事件会向上冒泡触发外层的 onClick。在本应用中,内层 Column 设置空的 onClick(() => {}) 来"消费"点击事件,阻止它继续冒泡到外层遮罩层。这种阻止冒泡的技巧在弹窗开发中非常实用——确保点击弹窗内容时不会意外关闭弹窗。

13.4 食物详情居中弹窗

@Builder
foodOverlay15() {
  Column() {
    Column() {
      Text('🍱 ' + MEALS15[this.selMeal15].name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
      Text(MEALS15[this.selMeal15].time + ' · ' + MEALS15[this.selMeal15].tag)
        .fontSize(11)
        .fontColor('#8A7A68')
        .margin({ top: 6 })

      Row() {
        Text(MEALS15[this.selMeal15].kcal.toString())
          .fontSize(26)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F97316')
        Text(' kcal')
          .fontSize(12)
          .fontColor('#8A7A68')
          .margin({ left: 4 })
      }
      .margin({ top: 12 })

      Row() {
        Column() {
          Text('碳水 ' + MEALS15[this.selMeal15].carb.toString() + 'g')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F97316')
          Row() {
            Row()
              .width((MEALS15[this.selMeal15].carb * 100 / 66).toString() + '%')
              .height(8)
              .borderRadius(4)
              .backgroundColor('#F97316')
          }
          .width('100%')
          .height(8)
          .borderRadius(4)
          .backgroundColor('#FFE8D2')
          .margin({ top: 4 })
        }
        .layoutWeight(1)
        Column() {
          Text('蛋白 ' + MEALS15[this.selMeal15].prot.toString() + 'g')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#16A34A')
          Row() {
            Row()
              .width((MEALS15[this.selMeal15].prot * 100 / 38).toString() + '%')
              .height(8)
              .borderRadius(4)
              .backgroundColor('#16A34A')
          }
          .width('100%')
          .height(8)
          .borderRadius(4)
          .backgroundColor('#DCFCE7')
          .margin({ top: 4 })
        }
        .layoutWeight(1)
        .margin({ left: 10 })
        Column() {
          Text('脂肪 ' + MEALS15[this.selMeal15].fat.toString() + 'g')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0EA5E9')
          Row() {
            Row()
              .width((MEALS15[this.selMeal15].fat * 100 / 20).toString() + '%')
              .height(8)
              .borderRadius(4)
              .backgroundColor('#0EA5E9')
          }
          .width('100%')
          .height(8)
          .borderRadius(4)
          .backgroundColor('#E0F2FE')
          .margin({ top: 4 })
        }
        .layoutWeight(1)
        .margin({ left: 10 })
      }
      .width('100%')
      .margin({ top: 14 })

食物详情弹窗是一个居中显示的卡片(.justifyContent(FlexAlign.Center)),宽度设为 '88%',最大高度限制为 '78%'(通过 .constraintSize({ maxHeight: '78%' }))。弹窗通过 selMeal15 索引从 MEALS15 常量数组中读取对应餐食的详细数据。

营养素占比条形图是此弹窗的核心视觉元素。三个营养素(碳水、蛋白质、脂肪)各占 layoutWeight(1) 的等宽列,每列包含标签文字和进度条。进度条内层宽度通过 (MEALS15[this.selMeal15].carb * 100 / 66).toString() + '%' 计算——以 66g 为满刻度计算碳水的百分比宽度。三个营养素使用不同的满刻度(碳水 66g、蛋白质 38g、脂肪 20g),各自反映在该餐中的相对占比。

技术要点.constraintSize() 方法用于设置组件尺寸的约束条件,包括 minWidthmaxWidthminHeightmaxHeight。在弹窗场景中,设置 maxHeight 可以防止内容过多时弹窗超出屏幕边界。与直接设置 height 不同,constraintSize 只设置上限和下限,组件仍会根据内容自适应尺寸,但不会超出约束范围。

13.5 食物详情的智能建议文本

      Text('该餐结构均衡:优质蛋白占比合适,碳水以低 GI 为主,脂肪控制良好。若晚上有力量训练,可将本餐碳水的 1/3 后移至训练后。')
        .fontSize(11)
        .fontColor('#8A7A68')
        .lineHeight(17)
        .margin({ top: 14 })

      Text('知道了')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(19)
        .backgroundColor('#F97316')
        .margin({ top: 16 })
        .onClick(() => {
          this.showFood15 = false
        })
    }
    .width('88%')
    .padding(18)
    .borderRadius(20)
    .backgroundColor('#FFFFFF')
    .constraintSize({ maxHeight: '78%' })
    .onClick(() => {
    })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#99403311')
  .justifyContent(FlexAlign.Center)
  .zIndex(999)
  .onClick(() => {
    this.showFood15 = false
  })
}

智能建议文本是一段较长的多行文字,通过 .lineHeight(17) 设置行高确保可读性。"知道了"按钮通过 .width('100%') 占满弹窗宽度,是一个全宽的确认操作按钮。居中弹窗与底部抽屉的关键区别在于 .justifyContent(FlexAlign.Center)(居中对齐)而非 FlexAlign.End(底部对齐),以及四个角都设置圆角(borderRadius(20))而非仅上方圆角。

13.6 新增餐食抽屉

@Builder
mealOverlay15() {
  Column() {
    Column() {
      Row()
        .width(44)
        .height(5)
        .borderRadius(3)
        .backgroundColor('#FFE8D2')
        .margin({ top: 10 })

      Text('🍱 新增餐食')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
        .margin({ top: 12 })

      Text('餐段')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#8A7A68')
        .margin({ top: 14 })
      Row({ space: 8 }) {
        ForEach(['早餐', '加餐', '午餐', '晚餐', '夜宵'], (t: string, i: number) => {
          Text(t)
            .fontSize(12)
            .fontColor(this.mTime15 === i ? '#FFFFFF' : '#8A7A68')
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(12)
            .backgroundColor(this.mTime15 === i ? '#16A34A' : '#FFF3E6')
            .onClick(() => {
              this.mTime15 = i
            })
        }, (t: string, i: number) => t + i.toString())
      }
      .width('100%')
      .margin({ top: 8 })

      Text('吃了什么')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#8A7A68')
        .margin({ top: 14 })
      TextInput({ placeholder: '如:玉米一根 + 鸡蛋两个', text: this.mName15 })
        .fontSize(13)
        .fontColor('#3D2C1E')
        .placeholderColor('#D9C8B5')
        .backgroundColor('#FFF9F0')
        .borderRadius(12)
        .padding({ left: 12, right: 12 })
        .margin({ top: 8 })
        .onChange((v: string) => {
          this.mName15 = v
        })

      Row() {
        Text('估算热量')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#8A7A68')
        Row() {
          Text('-')
            .fontSize(15)
            .fontColor('#8A7A68')
            .padding(6)
            .onClick(() => {
              if (this.mKcal15 > 50) {
                this.mKcal15 = this.mKcal15 - 50
              }
            })
          Text(this.mKcal15.toString() + ' kcal')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F97316')
            .padding({ left: 10, right: 10 })
          Text('+')
            .fontSize(15)
            .fontColor('#16A34A')
            .padding(6)
            .onClick(() => {
              if (this.mKcal15 < 1200) {
                this.mKcal15 = this.mKcal15 + 50
              }
            })
        }
        .borderRadius(10)
        .backgroundColor('#FFF3E6')
        .margin({ left: 12 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 16 })

新增餐食抽屉包含了餐段选择器(5 个标签:早餐、加餐、午餐、晚餐、夜宵)、食物名称输入框和热量估算器。餐段选择器通过 ForEach 渲染标签组,当前选中项以绿色背景白色文字高亮,点击切换 mTime15 索引值。热量估算器以 50kcal 为步进增减,范围限制在 50-1200 之间。

技术要点:本应用中多处使用了标签选择器模式(餐段选择、小数位选择、周期选择等),其实现方式完全一致:通过 ForEach 渲染标签列表,当前选中项通过条件表达式设置不同的 fontColorbackgroundColoronClick 中将选中索引赋值给对应的 @State 变量。这种模式在 ArkUI 中可以进一步抽取为通用的 @Builder 函数或自定义组件,实现代码复用。

13.7 编辑用药提醒抽屉

@Builder
drugOverlay15() {
  Column() {
    Column() {
      Row()
        .width(44)
        .height(5)
        .borderRadius(3)
        .backgroundColor('#FFE8D2')
        .margin({ top: 10 })

      Text('💊 编辑用药提醒')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
        .margin({ top: 12 })

      Text('提醒时间')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#8A7A68')
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(['每日 08:00', '每日 12:00', '每日 20:00', '每周日 09:00', '运动后 30 分钟'], (t: string, i: number) => {
          Text(t)
            .fontSize(12)
            .fontColor(this.dTimes15 === i ? '#FFFFFF' : '#8A7A68')
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(12)
            .backgroundColor(this.dTimes15 === i ? '#0EA5E9' : '#FFF3E6')
            .margin({ right: 8, bottom: 8 })
            .onClick(() => {
              this.dTimes15 = i
            })
        }, (t: string, i: number) => t + i.toString())
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Text('开启提醒')
          .fontSize(12)
          .fontColor('#8A7A68')
          .layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: this.dRemind15 })
          .selectedColor('#16A34A')
          .onChange((on: boolean) => {
            this.dRemind15 = on
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 14 })

编辑用药提醒抽屉中使用了 Flex 弹性布局容器来渲染提醒时间标签。Flex({ wrap: FlexWrap.Wrap }) 设置子组件在主轴方向溢出时自动换行,这比 Row(不自动换行)更适合标签数量较多或标签宽度不固定的场景。

Toggle 组件是 ArkUI 的开关控件,通过 { type: ToggleType.Switch, isOn: this.dRemind15 } 配置为 Switch 样式并绑定到 dRemind15 状态变量。.selectedColor('#16A34A') 设置开关打开时的颜色为绿色。.onChange((on: boolean) => { this.dRemind15 = on }) 在开关状态变化时更新状态变量。

技术要点Flex 是 ArkUI 的弹性布局容器,功能比 RowColumn 更加强大灵活。它支持 wrap 参数设置换行模式(FlexWrap.NoWrap 不换行、FlexWrap.Wrap 自动换行)、direction 参数设置主轴方向、justifyContentalignItems 分别控制主轴和交叉轴的对齐方式。Flex 在需要自动换行的标签云、瀑布流等场景中比 Row 更合适。

技术要点Toggle 组件支持两种类型:ToggleType.Switch(开关样式,类似 iOS 的开关按钮)和 ToggleType.Checkbox(复选框样式)。通过 isOn 参数设置初始状态,onChange 回调获取用户操作后的新状态。selectedColor 设置开启状态的颜色,可以与品牌色保持一致。在本应用的删除确认弹窗中,Toggle 被用作安全确认开关,用户必须打开开关才能执行删除操作。

13.8 用药提醒保存逻辑

      Text('调整用药方案请先咨询医生,勿自行增减剂量。')
        .fontSize(10)
        .fontColor('#DC2626')
        .margin({ top: 12 })

      Row({ space: 10 }) {
        Text('取消')
          .fontSize(13)
          .fontColor('#8A7A68')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .borderRadius(19)
          .backgroundColor('#FFF3E6')
          .onClick(() => {
            this.showDrug15 = false
          })
        Text('保存')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .borderRadius(19)
          .backgroundColor('#0EA5E9')
          .onClick(() => {
            this.drugs15 = this.drugs15.map((x: DrugT15) => {
              if (x.id === 1) {
                return { id: x.id, name: x.name, dose: x.dose, times: ['每日 08:00', '每日 12:00', '每日 20:00', '每周日 09:00', '运动后 30 分钟'][this.dTimes15], remind: this.dRemind15, stock: x.stock }
              }
              return x
            })
            this.showDrug15 = false
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 18, bottom: 18 })
    }
    .width('100%')
    .borderRadius({ topLeft: 22, topRight: 22 })
    .backgroundColor('#FFFFFF')
    .onClick(() => {
    })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#99403311')
  .justifyContent(FlexAlign.End)
  .zIndex(999)
  .onClick(() => {
    this.showDrug15 = false
  })
}

保存按钮的 onClick 事件中使用了 JavaScript 数组的 .map() 方法来更新 drugs15 状态数组。.map() 遍历数组中的每个元素,当 x.id === 1(即司美格鲁肽)时返回一个新对象,将 times 替换为用户选择的提醒时间、将 remind 替换为 Toggle 开关的值;其他元素原样返回。这种使用 .map() 生成新数组的做法确保了状态变量的引用发生变化,从而触发框架的重新渲染。

技术要点:在 ArkTS 中更新 @State 修饰的数组类型变量时,直接修改数组元素(如 this.drugs15[0].remind = true)可能无法触发框架的重新渲染,因为数组的引用没有变化。正确做法是使用 .map().filter().slice() 等方法生成新数组后重新赋值,确保引用变化触发依赖追踪。这是 ArkTS 状态管理与 React 类似的地方——需要保持数据的不可变性(Immutability)。

13.9 删除确认弹窗与安全开关

@Builder
delOverlay15() {
  Column() {
    Column() {
      Text('⚠️')
        .fontSize(30)
        .margin({ top: 16 })
      Text('删除这条运动打卡?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
        .margin({ top: 10 })
      Text('🏃 快走 6km/h · 45 分钟 · 210kcal')
        .fontSize(12)
        .fontColor('#8A7A68')
        .margin({ top: 6 })
      Text('删除后当日总消耗将减少 210kcal,周消耗统计同步回退,无法恢复。')
        .fontSize(11)
        .fontColor('#DC2626')
        .lineHeight(17)
        .margin({ top: 8 })
        .padding({ left: 14, right: 14 })

      Row() {
        Text('我确认删除')
          .fontSize(11)
          .fontColor('#8A7A68')
          .layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: this.armed15 })
          .selectedColor('#DC2626')
          .onChange((on: boolean) => {
            this.armed15 = on
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding({ left: 16, right: 16, top: 12 })

      Row({ space: 10 }) {
        Text('再想想')
          .fontSize(13)
          .fontColor('#8A7A68')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .borderRadius(18)
          .backgroundColor('#FFF3E6')
          .onClick(() => {
            this.showDel15 = false
          })
        Text(this.armed15 ? '删除' : '请先打开开关')
          .fontSize(13)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .borderRadius(18)
          .backgroundColor(this.armed15 ? '#DC2626' : '#D9C8B5')
          .onClick(() => {
            if (this.armed15) {
              this.showDel15 = false
            }
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 16 })
    }
    .width('74%')
    .borderRadius(18)
    .backgroundColor('#FFFFFF')
    .border({ width: 1.5, color: '#F5C6BC' })
    .onClick(() => {
    })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#99403008')
  .justifyContent(FlexAlign.Center)
  .zIndex(999)
  .onClick(() => {
    this.showDel15 = false
  })
}

删除确认弹窗是一个居中显示的窄卡(宽度 '74%'),带有红色边框(.border({ width: 1.5, color: '#F5C6BC' }))强化危险操作的视觉警告。弹窗中包含一个安全确认开关 Toggle,其 selectedColor 设为红色(#DC2626)与危险操作的主题色一致。

删除按钮的文案和样式都根据 armed15 状态动态变化:当开关关闭时,按钮文案为"请先打开开关"、背景色为灰色(#D9C8B5),点击无效果;当开关打开时,按钮文案变为"删除"、背景色变为红色,点击后关闭弹窗。这种"双因素确认"机制——即先打开开关再点击按钮——有效防止了误删除操作,在医疗健康类应用中尤为重要。

13.10 目标设置弹窗

@Builder
goalOverlay15() {
  Column() {
    Column() {
      Text('🎯 调整阶段目标')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D2C1E')
        .margin({ top: 14 })

      Row() {
        Text('目标体重')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#8A7A68')
        Row() {
          Text('-')
            .fontSize(15)
            .fontColor('#8A7A68')
            .padding(6)
            .onClick(() => {
              if (this.goalKg15 > 55) {
                this.goalKg15 = this.goalKg15 - 1
              }
            })
          Text(this.goalKg15.toString() + 'kg')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F97316')
            .padding({ left: 10, right: 10 })
          Text('+')
            .fontSize(15)
            .fontColor('#16A34A')
            .padding(6)
            .onClick(() => {
              if (this.goalKg15 < 90) {
                this.goalKg15 = this.goalKg15 + 1
              }
            })
        }
        .borderRadius(10)
        .backgroundColor('#FFF3E6')
        .margin({ left: 12 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 16 })

      Text('周期')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#8A7A68')
        .margin({ top: 14 })
      Row({ space: 8 }) {
        ForEach([8, 12, 16, 24], (w: number) => {
          Text(w.toString() + ' 周')
            .fontSize(12)
            .fontColor(this.goalWeeks15 === w ? '#FFFFFF' : '#8A7A68')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(12)
            .backgroundColor(this.goalWeeks15 === w ? '#F97316' : '#FFF3E6')
            .onClick(() => {
              this.goalWeeks15 = w
            })
        }, (w: number) => w.toString())
      }
      .width('100%')
      .margin({ top: 8 })

      Text('按当前设置,平均每周需减重 ' + ((85 - this.goalKg15) / this.goalWeeks15).toFixed(2).toString() + 'kg' + (((85 - this.goalKg15) / this.goalWeeks15) > 1 ? ',超出医学安全上限(1kg/周),建议延长周期。' : ',处于安全减重区间。'))
        .fontSize(11)
        .fontColor(((85 - this.goalKg15) / this.goalWeeks15) > 1 ? '#DC2626' : '#16A34A')
        .lineHeight(17)
        .margin({ top: 14 })
        .padding({ left: 4, right: 4 })

目标设置弹窗包含目标体重选择器(55-90kg 范围)和周期选择器(8/12/16/24 周四个选项)。最精彩的部分是动态计算的安全提示文本:通过 ((85 - this.goalKg15) / this.goalWeeks15).toFixed(2) 计算平均每周需减重的公斤数,如果超过 1kg/周 则显示红色警告文案"超出医学安全上限,建议延长周期",否则显示绿色安全文案"处于安全减重区间"。

这种将业务规则(医学安全减重上限 1kg/周)与 UI 状态(文案和颜色)实时绑定的做法,展示了声明式 UI 的核心优势——开发者只需描述"当条件满足时界面应该是什么样子",框架自动处理状态变化时的界面更新。

技术要点:在 ArkTS 中,复杂的条件表达式可以嵌套在模板字符串中。本例中 Text 的内容通过多个三元表达式和字符串拼接动态生成,同时 fontColor 也通过条件表达式动态切换。这种模式虽然代码紧凑,但在条件过于复杂时可能影响可读性。在生产代码中,建议将此类逻辑抽取为独立的方法(如 getGoalAdvice(): stringgetGoalAdviceColor(): string),使 UI 描述更加清晰。


十四、底部 Tab 栏深度解析

14.1 TabBar 构建

@Builder
tabBar15() {
  Row() {
    ForEach([['⚖️', '体重'], ['🍱', '饮食'], ['🏃', '运动'], ['💊', '用药'], ['👥', '社群'], ['👤', '我的']], (t: string[], i: number) => {
      Column() {
        Text(t[0])
          .fontSize(20)
          .opacity(this.tab15 === i ? 1 : 0.45)
          .scale(this.tab15 === i ? { x: 1.12, y: 1.12 } : { x: 1, y: 1 })
        Text(t[1])
          .fontSize(10)
          .fontColor(this.tab15 === i ? '#F97316' : '#C9A88A')
          .fontWeight(this.tab15 === i ? FontWeight.Bold : FontWeight.Normal)
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 8, bottom: 8 })
      .onClick(() => {
        this.tab15 = i
      })
    }, (t: string[], i: number) => t[1] + i.toString())
  }
  .width('100%')
  .backgroundColor('#FFFFFF')
}

底部 Tab 栏使用 ForEach 遍历二维数组(每个元素是一个 [icon, label] 对),为每个 Tab 生成一个等宽的 ColumnlayoutWeight(1))。当前选中的 Tab 通过多种视觉手段突出显示:图标不透明度满(1 vs 0.45)、图标放大 1.12 倍(.scale({ x: 1.12, y: 1.12 }))、标签文字橙色加粗。

点击 Tab 项时,this.tab15 = i 更新当前选中索引,触发整个页面的条件渲染切换——build() 方法中的 if/else 链会根据新的 tab15 值渲染对应的 Tab 内容。

技术要点.scale() 方法通过设置 x 和 y 方向的缩放比例来改变组件的视觉大小,不影响布局空间。被缩放组件仍然占据原始尺寸的布局空间,只是渲染时放大或缩小。在 Tab 栏中,选中项放大 1.12 倍是一个常见的微交互设计,通过 .opacity() 配合可以实现选中时的"高亮弹出"效果。

技术要点:ArkUI 的条件渲染使用标准的 if/else if/else 语法。当条件表达式中的状态变量发生变化时,框架会移除旧条件分支的 UI 并创建新条件分支的 UI。需要注意的是,条件分支之间的切换会导致组件的销毁和重建,组件内部的状态(如果有)不会自动保留。如果需要在条件切换间保留状态,应将状态提升到父组件中通过 @State 管理。


十五、build 方法与整体页面架构

15.1 Stack 层叠布局与条件渲染

build() {
  Stack() {
    Column() {
      this.headerBar15()

      Scroll() {
        Column() {
          if (this.tab15 === 0) {
            this.tabWeight15()
          } else if (this.tab15 === 1) {
            this.tabMeal15()
          } else if (this.tab15 === 2) {
            this.tabSport15()
          } else if (this.tab15 === 3) {
            this.tabDrug15()
          } else if (this.tab15 === 4) {
            this.tabGroup15()
          } else {
            this.tabMine15()
          }
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 10, bottom: 20 })
      }
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')
      .backgroundColor('#FFF9F0')
      .edgeEffect(EdgeEffect.Spring)

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

    if (this.showWeight15) {
      this.weightOverlay15()
    }
    if (this.showFood15) {
      this.foodOverlay15()
    }
    if (this.showMeal15) {
      this.mealOverlay15()
    }
    if (this.showDrug15) {
      this.drugOverlay15()
    }
    if (this.showDel15) {
      this.delOverlay15()
    }
    if (this.showGoal15) {
      this.goalOverlay15()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#FFF9F0')
}

build() 方法是每个 ArkUI 组件必须实现的核心方法,它返回组件的 UI 描述。本应用的 build() 方法使用 Stack 作为根容器,其内部包含两大层次:第一层是主页面内容(Column 包裹头部栏、可滚动内容区和底部 Tab 栏),第二层是 6 个条件渲染的弹窗。

主页面内容采用 Column 纵向排列:顶部是固定的头部栏(headerBar15()),中间是可垂直滚动的 Scroll 容器(layoutWeight(1) 占据剩余空间),底部是固定的 Tab 栏。Scroll 容器中通过 if/else if/else 条件渲染根据 tab15 的值切换不同的 Tab 内容。

弹窗层通过 6 个独立的 if 条件语句控制,每个弹窗有自己的布尔状态变量。由于弹窗的 zIndex 设为 999,它们会浮在主页面内容之上。多个弹窗的 if 条件相互独立,理论上可以同时显示多个弹窗(虽然在实际交互中不会出现这种情况)。

技术要点Scroll 组件的 .edgeEffect(EdgeEffect.Spring) 设置滚动到边界时的弹性回弹效果,模拟 iOS 系统的弹性滚动体验。EdgeEffect 枚举有三种值:Spring(弹性回弹)、Fade(渐隐效果)、None(无效果)。在大多数移动端应用中,Spring 是最常用的选择,它能提供接近原生应用的滚动手感。

技术要点Stack 作为根容器在本应用中扮演了关键角色。它的第一层子组件(主页面 Column)占据整个屏幕,第二层及之后的子组件(弹窗)叠加在第一层之上。由于弹窗设置了半透明背景色,主页面内容会被遮罩变暗,形成弹窗浮于页面之上的视觉效果。Stack 的子组件按照声明顺序从下到上叠加,后声明的在上层,这保证了弹窗始终在主页面之上。


十六、核心技术要素对比

以下表格对本应用中涉及的各类 ArkUI 技术要素进行系统性对比归纳:

技术要素 类别 核心作用 本应用中的使用场景 关键属性/参数
@Entry 装饰器 标记页面入口组件 标记 Index 为页面根组件 无参数
@Component 装饰器 声明自定义组件 声明 Index 组件 无参数
@State 装饰器 组件内部状态管理 管理 Tab 索引、弹窗显隐、表单数据等 28 个状态变量 支持基本类型和引用类型
@Builder 装饰器 定义可复用 UI 片段 定义头部栏、6 个 Tab 内容、6 个弹窗、Tab 栏共 14 个 Builder 方法 方法形式,无参数
Column 布局容器 纵向排列子组件 几乎所有 UI 区域的垂直布局 justifyContentalignItems
Row 布局容器 横向排列子组件 卡片内元素水平排列、按钮组、指标行 spacealignItems
Stack 布局容器 层叠排列子组件 进度环中心数值、build 根容器叠加弹窗 默认居中对齐
Flex 布局容器 弹性换行布局 用药提醒时间标签云 wrap: FlexWrap.Wrap
Scroll 滚动容器 内容滚动 Tab 内容纵向滚动、快速状态条横向滚动 scrollablescrollBaredgeEffect
Progress 展示组件 进度展示 体重概览环形进度、目标完成度线性进度 valuetotaltypestyle
Text 展示组件 文字展示 标题、数值、标签、按钮文字等 fontSizefontWeightfontColormaxLines
TextInput 交互组件 文本输入 体重备注、餐食名称输入 placeholdertextonChange
Toggle 交互组件 开关切换 用药提醒开关、删除确认安全开关 typeisOnselectedColoronChange
ForEach 渲染指令 列表渲染 体重柱状图、餐食列表、运动卡片、用药列表、社群动态、订单列表等 数据源、生成函数、键值函数
animateTo 动画 API 显式动画驱动 火焰呼吸动画、跑步图标平移动画 durationiterationsplayModecurve
layoutWeight 布局属性 权重分配剩余空间 三列等分布局、左右分栏比例分配、进度条占据剩余宽度 数值参数
zIndex 层级控制 控制 Z 轴层级 所有弹窗设为 999 浮于主页面之上 数值参数
if/else 条件渲染 条件性插入/移除 UI Tab 内容切换、弹窗显隐控制 标准 if/else 语法
onClick 事件处理 点击事件响应 按钮点击、卡片点击、弹窗遮罩点击 闭包函数
borderRadius 样式属性 圆角设置 卡片圆角、按钮圆角、弹窗圆角、进度条圆角 数值或对象格式
backgroundColor 样式属性 背景色设置 卡片背景、遮罩半透明背景、标签背景色 十六进制颜色或 ARGB
padding 样式属性 内边距设置 卡片内边距、按钮内边距、标签内边距 数值或对象格式
margin 样式属性 外边距设置 元素间距、卡片间距、顶部偏移 数值或对象格式
opacity 样式属性 透明度控制 火焰呼吸动画、Tab 图标选中状态 0-1 数值
scale 变换属性 缩放变换 Tab 选中图标放大效果 { x, y } 对象
translate 变换属性 平移变换 跑步图标左右平移动画 { x, y } 对象
clip 裁剪属性 裁剪溢出内容 营养素堆叠条圆角裁剪 布尔值
constraintSize 约束属性 尺寸约束 食物详情弹窗最大高度限制 maxWidthmaxHeight
aboutToAppear 生命周期 组件创建后初始化 启动两个无限循环动画 无参数方法

十七、总结

通过对这款体重代谢管理应用的完整代码剖析,我们可以清晰地看到 ArkTS 声明式 UI 范式在构建复杂移动应用时的强大表现力。整个应用以一个 @Entry @Component struct Index 为核心,通过 28 个 @State 状态变量管理完整的运行时数据流,通过 14 个 @Builder 方法将复杂的 UI 拆分为可维护的功能模块,通过 build() 方法中的 Stack 层叠布局统一管理主页面和 6 个弹窗的渲染层次。这种架构模式充分体现了声明式 UI 的核心理念——界面是状态的函数,开发者只需描述"界面应该是什么样子"而非"应该怎样一步步构建"。

在数据层面,应用通过 7 个 interface 接口定义了严格的类型约束,通过 9 个 const 常量数组初始化了体重记录、饮食记录、运动项目、用药信息、社群动态、订单数据等完整的业务数据集。这些数据在组件初始化时通过 .slice() 方法创建副本赋值给 @State 变量,确保了组件状态与静态常量的隔离。ForEach 渲染指令配合精心设计的键值生成函数,保证了列表数据更新时的精确 diff 和高效重渲染。在需要修改数组数据的场景中(如保存用药提醒),使用 .map() 方法生成新数组确保引用变化触发框架的依赖追踪。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 活力橙绿浅色:米白底 + 活力橙 + 草绿 + 炭黑;6 tab(体重/饮食/运动/用药/社群/我的)
// 6 个弹框:记录体重抽屉 / 食物详情居中大卡 / 新增餐食抽屉 / 编辑用药提醒抽屉 / 删除打卡窄危险卡 / 目标设置居中卡
// 特效:🔥 fireOp15 呼吸 + 🏃 runX15 平移(aboutToAppear 无限动画)
// 图表:体重趋势柱状 / 体脂环形 / 三大营养素堆叠条 / 周运动消耗横条 / 腰臀比横条

interface WeightT15 {
  id: number
  date: string
  kg: number
  fat: number
  waist: number
  note: string
}

interface MealT15 {
  id: number
  time: string
  name: string
  kcal: number
  carb: number
  prot: number
  fat: number
  tag: string
}

interface SportT15 {
  id: number
  name: string
  icon: string
  mins: number
  kcal: number
  heat: number
}

interface DrugT15 {
  id: number
  name: string
  dose: string
  times: string
  remind: boolean
  stock: number
}

interface PostT15 {
  id: number
  user: string
  avatar: string
  text: string
  likes: number
  comments: number
  topic: string
}

interface OrdT15 {
  id: string
  item: string
  status: string
  amount: number
  date: string
}

const WEIGHTS15: Array<WeightT15> = [
  { id: 1, date: '08-28', kg: 82.4, fat: 28.6, waist: 96, note: '晨起空腹' },
  { id: 2, date: '08-21', kg: 83.1, fat: 29.2, waist: 97, note: '周末聚餐后' },
  { id: 3, date: '08-14', kg: 83.6, fat: 29.8, waist: 98, note: '' },
  { id: 4, date: '08-07', kg: 84.2, fat: 30.4, waist: 99, note: '' },
  { id: 5, date: '07-31', kg: 85.0, fat: 31.0, waist: 100, note: '开营首测' },
  { id: 6, date: '08-25', kg: 82.8, fat: 28.9, waist: 96, note: '' },
  { id: 7, date: '08-18', kg: 83.3, fat: 29.5, waist: 98, note: '' },
  { id: 8, date: '08-11', kg: 83.9, fat: 30.1, waist: 98, note: '' }
]

const MEALS15: Array<MealT15> = [
  { id: 1, time: '早餐 07:40', name: '燕麦碗 + 水煮蛋 + 无糖豆浆', kcal: 385, carb: 46, prot: 24, fat: 11, tag: '均衡' },
  { id: 2, time: '加餐 10:30', name: '小番茄一盒 + 原味坚果 10g', kcal: 128, carb: 12, prot: 4, fat: 8, tag: '低卡' },
  { id: 3, time: '午餐 12:20', name: '杂粮饭 + 清蒸鲈鱼 + 白灼菜心', kcal: 520, carb: 58, prot: 38, fat: 14, tag: '高蛋白' },
  { id: 4, time: '加餐 15:30', name: '无糖希腊酸奶 100g', kcal: 96, carb: 8, prot: 11, fat: 3, tag: '高蛋白' },
  { id: 5, time: '晚餐 18:50', name: '鸡胸沙拉 + 玉米半根', kcal: 412, carb: 34, prot: 35, fat: 13, tag: '轻食' },
  { id: 6, time: '昨日晚餐', name: '番茄牛腩 + 荞麦面', kcal: 610, carb: 66, prot: 34, fat: 20, tag: '超标' }
]

const SPORTS15: Array<SportT15> = [
  { id: 1, name: '快走 6km/h', icon: '🚶', mins: 45, kcal: 210, heat: 95 },
  { id: 2, name: '游泳 自由式', icon: '🏊', mins: 40, kcal: 340, heat: 88 },
  { id: 3, name: '力量训练 上肢', icon: '🏋️', mins: 50, kcal: 290, heat: 82 },
  { id: 4, name: '骑行 匀速', icon: '🚴', mins: 60, kcal: 380, heat: 90 },
  { id: 5, name: '跳绳
    .height('100%')
    .backgroundColor('#FFF9F0')
  }
}


在这里插入图片描述

在 UI 构建层面,应用展示了 ArkUI 组件体系的丰富能力。ColumnRow 的嵌套组合构建了从简单到复杂的各种布局结构;Stack 层叠布局实现了进度环中心数值叠加和弹窗浮层效果;Flex 弹性布局处理了标签云的自动换行需求;Scroll 滚动容器配合 EdgeEffect.Spring 提供了原生级别的弹性滚动体验;Progress 组件以环形和线性两种形式展示了减重进度和目标完成度;Toggle 开关组件在用药提醒和删除确认场景中提供了直观的布尔值交互;TextInput 输入框通过 onChange 回调实现了与状态变量的双向绑定。layoutWeight 权重分配机制贯穿了整个应用的布局体系,从等分三列统计卡到左右分栏卡片,从进度条宽度到按钮组等分,这一属性是 ArkUI 布局系统中最常用的工具之一。

在交互动效层面,aboutToAppear 生命周期中启动的两个 animateTo 无限循环动画为应用注入了活力——火焰图标的呼吸效果和跑步图标的平移效果虽然细微,但显著提升了界面的"活感"。zIndex(999) 确保弹窗始终浮于主内容之上,半透明遮罩背景色(ARGB 格式)实现了专业的弹窗遮罩效果。事件冒泡的控制——通过内层空 onClick 阻止冒泡——确保了点击弹窗内容时不会意外关闭弹窗。Tab 切换时的条件渲染、标签选择器的选中高亮、删除确认弹窗的安全开关机制,这些交互细节共同构成了应用的完整用户体验。

在工程实践层面,这款应用的代码组织提供了几个值得借鉴的模式。第一是"先重置表单、后打开弹窗"的弹窗触发范式,确保了每次打开弹窗时表单都是干净的初始状态。第二是"状态变量拆分"模式——如将体重值拆分为整数和小数两个独立变量分别控制,将弹窗表单的临时值与正式数据分离——这种模式在不引入复杂表单状态管理库的情况下实现了良好的用户体验。第三是"数据驱动可视化"模式——柱状图高度通过 (w.kg - 78) * 18 计算、进度条宽度通过百分比字符串计算、颜色通过条件表达式切换——将业务数据直接映射为视觉属性,是声明式 UI 的精髓所在。这些模式在鸿蒙 ArkTS 开发中具有广泛的适用性,可以作为构建各类移动应用时的参考范式。

Logo

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

更多推荐