技术前言:HarmonyOS ArkUI 声明式 UI 在运动健康领域的深度应用

在这里插入图片描述

在 HarmonyOS 生态中,ArkUI 作为官方推荐的声明式 UI 框架,其核心设计理念在于通过数据驱动视图的方式,实现 UI 与业务逻辑的深度解耦。与传统的命令式 UI 编程范式不同,声明式 UI 允许开发者通过描述"界面应该是什么样子"而非"如何一步步构建界面",从而大幅提升开发效率和代码可维护性。在运动健康这一特殊领域,应用界面需要频繁处理实时数据更新——步数计数、卡路里消耗、运动时长计时、心率监测等数据流需要持续驱动 UI 变化,这使得声明式 UI 的优势得以充分体现。

在这里插入图片描述
在鸿蒙系统的分布式架构下,运动健康应用还面临着多设备适配的挑战。同一套代码需要运行在手机、手表、平板等不同形态的设备上,而 ArkUI 提供的弹性布局能力(如 layoutWeightFlex 弹性换行、百分比宽高等)为跨设备适配提供了底层支撑。本文分析的健身运动平台采用深色主题设计——这一选择并非偶然,深色模式在运动场景中具有显著优势:户外强光环境下可减少屏幕反光、夜间运动时降低眩光刺激、同时有效延长可穿戴设备的续航时间。

在这里插入图片描述
从技术架构层面看,该平台采用了典型的分层设计模式:颜色系统层负责统一的视觉语言输出,数据模型层通过 @Observed 装饰器实现可观察对象,业务逻辑层通过工具函数封装颜色映射与状态计算,UI 组件层通过 @Builder 装饰器实现可复用的视图构建器,状态管理层通过 @State 实现响应式数据绑定。这种分层设计使得每个模块都可以独立演进——例如更换配色方案只需修改 COLORS 常量,而无需触碰任何 UI 组件代码;又如新增一个运动类型,只需在 QUICK_TAGS 常量中添加条目并在 typeColor 函数中补充映射逻辑。

颜色系统层 ColorPalette

数据模型层 @Observed

业务逻辑层 辅助函数

UI组件层 @Builder

状态管理层 @State

页面入口 @Entry @Component

弹窗系统 Modal

底部导航 TabBar

在这里插入图片描述
在运动健康行业,用户对数据的实时性和可视化要求极高。运动者希望在大汗淋漓的间隙,只需一瞥屏幕就能获取关键指标——消耗的卡路里是否达标、今日打卡进度如何、心率是否在安全区间内。这种使用场景对信息密度和视觉层次提出了特殊要求:核心数据必须以大字体、高对比度的方式呈现,辅助信息则要适当弱化以避免干扰。本平台通过 fontSize 的层级化设置(28px 主数据 → 13px 标题 → 12px 正文 → 10px 辅助 → 9px 标签 → 8px 微文本)和 fontColor 的色彩区分,构建了清晰的信息优先级体系。


一、颜色系统深度解析:深色运动主题的设计哲学

在这里插入图片描述

1.1 ColorPalette 接口定义

在这里插入图片描述
在 ArkUI 开发中,颜色管理是视觉一致性的基石。本平台并没有简单地使用硬编码的颜色字符串散落在各处,而是通过定义 ColorPalette 接口来建立一套完整的、类型安全的颜色体系。这种做法在工程实践中具有深远意义:当团队需要调整品牌色调或适配深色模式切换时,只需修改一处常量定义,所有引用该颜色的组件都会自动更新。

interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  green: string;
  greenD: string;
  greenL: string;
  orange: string;
  orangeD: string;
  orangeL: string;
  red: string;
  blue: string;
  purple: string;
  gold: string;
  goldL: string;
  line: string;
  tabOn: string;
  mask: string;
}

接口中定义了 20 个颜色字段,每个字段都有明确的语义化命名。bg 代表页面背景色,是整个应用的底色基调;card 代表卡片背景色,在深色主题中通常比背景色略亮一个层级,用于创造视觉上的层次感;chip 用于芯片标签和次级容器背景;titlesubtext3 构成了三级文本颜色梯度,分别对应主要文本、次要文本和辅助文本。

值得注意的是,颜色体系中还包含了三组互补色对:green/greenD/greenL 形成了活力绿的三档梯度,orange/orangeD/orangeL 构成了能量橙的三档梯度,这种"D(Dark)"和"L(Light)"的命名约定使得开发者能够直观地理解颜色在亮度轴上的位置。此外,red 用于警告和删除操作,bluepurple 用于运动类型的差异化标识,goldgoldL 用于成就徽章的荣誉感表达,mask 用于弹窗遮罩层的半透明覆盖效果。

1.2 COLORS 常量实例化

const COLORS: ColorPalette = {
  bg: '#0D1B14',
  card: '#152820',
  chip: '#1E3328',
  title: '#ECFDF5',
  sub: '#6BCB95',
  text3: '#3D6B52',
  green: '#4ADE80',
  greenD: '#22C55E',
  greenL: '#1A3D2A',
  orange: '#FB923C',
  orangeD: '#EA580C',
  orangeL: '#2A1A10',
  red: '#EF4444',
  blue: '#60A5FA',
  purple: '#A78BFA',
  gold: '#FBBF24',
  goldL: '#2A2410',
  line: '#1F3328',
  tabOn: '#4ADE80',
  mask: 'rgba(0,0,0,0.55)'
};

深入分析这些颜色值,我们可以发现一套精心设计的深色主题色彩策略。背景色 #0D1B14 是一种极深的墨绿色,而非纯黑色——这种选择在运动场景中尤为重要:纯黑背景在 OLED 屏幕上虽然省电,但在运动者大量出汗、屏幕沾水时会造成更严重的反光,而略带绿调的深色背景能在保持低亮度的同时提供更好的可读性。

卡片色 #152820 比背景色亮约 10 个亮度级,这种细微的亮度差在视觉上创造了"浮起"的效果,使卡片内容自然地从背景中脱颖而出。芯片色 #1E3328 进一步提亮,用于次级容器和标签背景。三级文本颜色同样遵循亮度递增原则:title 使用接近白色的 #ECFDF5(带极淡绿色调的白色),确保主文本在深色背景上的最大对比度;sub 使用 #6BCB95 中等亮度的绿色,用于次要信息;text3 使用 #3D6B52 较暗的绿色,用于辅助标签和时间戳等低优先级信息。

主色调 green: '#4ADE80' 是一种鲜亮的薄荷绿,在深色背景上具有极强的视觉穿透力,用于强调关键数据和交互元素。greenD: '#22C55E' 是更深一档的绿色,用于渐变和按钮背景。greenL: '#1A3D2A' 是极暗的绿色,用于浅色卡片背景区域。这套绿色梯度不仅服务于视觉层次,还在心理层面传达了"健康、活力、成长"的品牌意象,与运动健身的应用定位高度契合。

橙色系 #FB923C / #EA580C / #2A1A10 与绿色形成互补关系,用于表示卡路里消耗、能量值等需要"热度感"的数据。红色 #EF4444 用于警告和删除等危险操作。蓝色 #60A5FA 和紫色 #A78BFA 用于运动类型的差异化标识——蓝色代表跑步和骑行等有氧运动,紫色代表瑜伽和拉伸等柔韧性训练,这种颜色编码使用户能够在扫视中快速区分运动类型。


二、常量定义与数据架构设计

2.1 Tab 导航常量

interface TabMeta {
  icon: string;
  label: string;
}

const TAB_LIST: TabMeta[] = [
  { icon: '🏠', label: '首页' },
  { icon: '🏋️', label: '课程' },
  { icon: '📊', label: '记录' },
  { icon: '🔥', label: '动态' },
  { icon: '🛒', label: '商城' },
  { icon: '👤', label: '我的' }
];

TabMeta 接口定义了底部导航栏每个标签页的元数据结构,包含图标(emoji 表情符号)和文字标签两个字段。使用 emoji 作为图标是一种轻量级的图标方案,它不需要引入图标字体文件或 SVG 资源,同时天然支持彩色显示和跨平台一致性。六标签的布局覆盖了健身运动平台的核心功能维度:首页提供数据概览、课程提供训练内容、记录展示历史数据、动态构建社区氛围、商城实现商业变现、我的承载用户信息。

2.2 运动类型与图表数据常量

const QUICK_TAGS: string[] = ['跑步', '力量', '瑜伽', '游泳', '骑行', '拉伸'];

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const CAL_VAL: number[] = [420, 680, 550, 920, 730, 610];
const CAL_MAX: number = 1200;

const WEEK_DAY: string[] = ['一', '二', '三', '四', '五', '六', '日'];
const WEEK_CAL: number[] = [320, 0, 580, 450, 0, 720, 380];
const WEEK_TYPE: string[] = ['跑步', '休息', '力量', '瑜伽', '休息', '游泳', '骑行'];

QUICK_TAGS 数组定义了快捷入口的运动类型,涵盖了有氧运动(跑步、游泳、骑行)、无氧运动(力量)和柔韧训练(瑜伽、拉伸)三大类别。这种分类方式与运动科学的经典分类法一致,使用户能够快速找到自己需要的运动类型。

月度卡路里数据 CAL_VALMONTH_NAME 用于绘制六个月的消耗趋势柱状图。CAL_MAX 设为 1200 作为柱状图的缩放基准,确保最高柱(920 卡的六月份)不会超出图表区域。这种通过固定最大值进行归一化处理的方式简单有效,避免了动态计算最大值带来的额外开销。

周历数据 WEEK_DAYWEEK_CALWEEK_TYPE 共同构成了热力图的基础数据。值得注意的是,WEEK_CAL 中有两个 0 值,对应 WEEK_TYPE 中的"休息"日——这种设计不仅展示了数据,还传达了运动计划中"休息日也是计划的一部分"这一健康理念。


三、辅助函数:颜色映射策略的工程化实现

3.1 难度颜色映射函数

/** 难度颜色映射 */
function diffColor(d: string): string {
  if (d === '入门') {
    return COLORS.green;
  }
  if (d === '进阶') {
    return COLORS.orange;
  }
  if (d === '挑战') {
    return COLORS.red;
  }
  return COLORS.text3;
}

diffColor 函数实现了课程难度到颜色的映射逻辑。这种"语义到视觉"的映射在 UI 设计中至关重要——用户不需要阅读文字说明,仅凭颜色就能判断课程难度等级。绿色代表"入门"级课程,传达安全、轻松的信号;橙色代表"进阶"级课程,暗示需要一定基础和付出更多努力;红色代表"挑战"级课程,警示用户需要谨慎评估自身能力。

从工程角度看,该函数使用简单的 if 条件判断而非 switch-case 或查找表,在 TypeScript 的编译优化下,少量分支的 if 链性能与 switch 相当,且可读性更佳。函数最后返回 COLORS.text3 作为默认值,这是一种防御性编程的体现——当传入未知的难度字符串时,不会导致颜色错误,而是回退到中性灰色。

3.2 运动类型颜色映射函数

/** 运动类型颜色映射 */
function typeColor(t: string): string {
  if (t === '跑步' || t === '骑行') {
    return COLORS.green;
  }
  if (t === '力量' || t === '游泳') {
    return COLORS.orange;
  }
  if (t === '瑜伽' || t === '拉伸') {
    return COLORS.purple;
  }
  return COLORS.blue;
}

typeColor 函数将运动类型映射到颜色,其分类逻辑体现了运动生理学的考量。跑步和骑行归为绿色(有氧运动),力量和游泳归为橙色(高强度运动),瑜伽和拉伸归为紫色(柔韧训练),其他类型归为蓝色。这种颜色编码不仅服务于视觉识别,还能帮助用户在浏览大量运动记录时快速筛选自己关注的运动类别。

使用 || 运算符将同类运动合并到同一颜色分支,是一种简洁的代码组织方式。相比为每种运动类型单独写一个 if 分支,这种写法减少了代码行数,同时更清晰地表达了"这些运动类型共享同一种视觉语义"的设计意图。

3.3 热力图颜色映射函数

/** 热力图颜色映射 */
function heatColor(cal: number): string {
  if (cal === 0) {
    return COLORS.chip;
  }
  if (cal < 400) {
    return COLORS.greenL;
  }
  if (cal < 700) {
    return COLORS.greenD;
  }
  return COLORS.green;
}

heatColor 函数是热力图可视化的核心算法。它将卡路里消耗值映射到四档绿色梯度:0 卡路里返回 chip 色(表示休息日)、低于 400 卡返回浅绿 greenL、低于 700 卡返回中绿 greenD、700 卡以上返回亮绿 green。这种热力图映射方式借鉴了 GitHub 贡献热力图的设计理念——通过颜色深浅直观地展示活动强度分布。

卡路里值输入

cal === 0?

chip色 - 休息日

cal < 400?

greenL - 轻度运动

cal < 700?

greenD - 中等强度

green - 高强度

从阈值设计的角度看,400 卡和 700 卡的分界点并非随意设定。根据运动医学研究,一次 30 分钟的中等强度有氧运动大约消耗 300-400 卡路里,而一次高强度的 HIIT 训练可以消耗 600-800 卡路里。因此,400 卡作为"轻度运动"和"中等强度"的分界线,700 卡作为"中等强度"和"高强度"的分界线,具有实际的运动科学依据。


四、数据模型层:@Observed 可观察对象的设计与实践

4.1 WorkoutItem 运动项数据模型

@Observed
export class WorkoutItem {
  name: string;
  duration: string;
  calories: number;
  icon: string;
  done: boolean;

  constructor(name: string, duration: string, calories: number, icon: string, done: boolean) {
    this.name = name;
    this.duration = duration;
    this.calories = calories;
    this.icon = icon;
    this.done = done;
  }
}

const WORKOUT_LIST: WorkoutItem[] = [
  new WorkoutItem('晨跑 5km', '28 分钟', 320, '🏃', true),
  new WorkoutItem('哑铃推举', '20 分钟', 180, '🏋️', true),
  new WorkoutItem('瑜伽流', '35 分钟', 150, '🧘', false),
  new WorkoutItem('HIIT 燃脂', '15 分钟', 220, '🔥', false),
  new WorkoutItem('核心训练', '20 分钟', 140, '💪', false),
  new WorkoutItem('拉伸放松', '10 分钟', 60, '🤸', false)
];

@Observed 装饰器是 ArkUI 状态管理系统的核心之一。被 @Observed 标记的类,其实例的属性变化会被框架自动追踪。当这些属性被 @State@ObjectLink 引用时,任何属性值的修改都会触发依赖该属性的 UI 组件重新渲染。这种细粒度的响应式更新机制确保了只有真正需要更新的 UI 部分才会重新构建,而非整个页面重新渲染。

WorkoutItem 类包含五个属性:运动名称 name、时长 duration(字符串类型,携带单位)、卡路里消耗 calories(数值类型,用于计算)、图标 icon(emoji)、完成状态 done(布尔值)。其中 done 属性是状态管理的重点——当用户点击"开始运动"并确认后,done 的值会从 false 变为 true,触发运动清单项的视觉状态从"待打卡"变为"已完成"。

构造函数采用显式参数传递方式,每个属性在构造函数中被逐一赋值。这种写法虽然看起来冗长,但保证了类型的明确性和可读性。在 TypeScript 严格模式下,所有属性都必须在构造函数中被赋值,否则编译器会报错。

预置的 WORKOUT_LIST 包含六项运动,其中前两项已标记为 done: true,后四项为 false。这种初始状态设计模拟了用户在一天中的运动进展——早晨已完成跑步和力量训练,下午还有瑜伽、HIIT、核心训练和拉伸待完成。

4.2 CourseItem 课程数据模型

@Observed
export class CourseItem {
  name: string;
  difficulty: string;
  duration: string;
  calories: string;
  coach: string;
  icon: string;

  constructor(name: string, difficulty: string, duration: string, calories: string, coach: string, icon: string) {
    this.name = name;
    this.difficulty = difficulty;
    this.duration = duration;
    this.calories = calories;
    this.coach = icon;
    this.icon = icon;
  }
}

const COURSE_LIST: CourseItem[] = [
  new CourseItem('燃脂 HIIT', '挑战', '15 min', '220 卡', '教练 Lisa', '🔥'),
  new CourseItem('普拉提核心', '进阶', '30 min', '180 卡', '教练 Anna', '🧘'),
  new CourseItem('力量塑形', '进阶', '40 min', '320 卡', '教练 Mike', '💪'),
  new CourseItem('晨间瑜伽流', '入门', '20 min', '100 卡', '教练 Lin', '🌅'),
  new CourseItem('拳击有氧', '挑战', '25 min', '280 卡', '教练 Tom', '🥊'),
  new CourseItem('舞蹈燃脂', '入门', '35 min', '200 卡', '教练 Coco', '💃')
];

CourseItem 类描述了健身课程的完整信息。值得注意的是,calories 属性在这里使用 string 类型(如 “220 卡”)而非 number 类型——这是因为课程卡片中卡路里值仅用于展示,不参与计算,使用字符串可以避免额外的类型转换。这种按需选择类型的做法体现了"够用即可"的工程原则。

六门课程覆盖了不同的运动类型和难度等级。"燃脂 HIIT"和"拳击有氧"标记为"挑战"级别,适合有一定基础的用户;"普拉提核心"和"力量塑形"标记为"进阶"级别,需要一定的运动经验;"晨间瑜伽流"和"舞蹈燃脂"标记为"入门"级别,适合初学者。每个课程都关联了一位教练,这为用户选择课程提供了参考维度——有些用户可能更偏好某位教练的教学风格。

4.3 FeedItem 社区动态数据模型

@Observed
export class FeedItem {
  user: string;
  avatar: string;
  content: string;
  workout: string;
  calories: string;
  likes: string;
  time: string;

  constructor(user: string, avatar: string, content: string, workout: string, calories: string, likes: string, time: string) {
    this.user = user;
    this.avatar = avatar;
    this.content = content;
    this.workout = workout;
    this.calories = calories;
    this.likes = likes;
    this.time = time;
  }
}

const FEED_LIST: FeedItem[] = [
  new FeedItem('跑酷达人', '🏃', '今天完成 10km 晨跑,配速 5\'30"!', '跑步 10km', '680 卡', '128', '2小时前'),
  new FeedItem('铁馆女孩', '💪', '硬拉破 PR!120kg 达成,继续努力!', '力量训练', '420 卡', '89', '5小时前'),
  new FeedItem('瑜伽少女', '🧘', '晨间瑜伽 30 分钟,身心舒展,元气满满~', '瑜伽流', '150 卡', '56', '8小时前'),
  new FeedItem('骑行大叔', '🚴', '环城骑行 40km,均速 28,腿部要废了。', '骑行 40km', '920 卡', '215', '12小时前'),
  new FeedItem('燃脂菜鸟', '🔥', 'HIIT 15 分钟打卡,虽然很累但坚持下来了!', 'HIIT', '220 卡', '43', '1天前'),
  new FeedItem('游泳健将', '🏊', '自由泳 2000m 完成,用时 40 分钟。', '游泳 2000m', '550 卡', '72', '2天前')
];

const FEED_LEFT: FeedItem[] = [FEED_LIST[0], FEED_LIST[2], FEED_LIST[4]];
const FEED_RIGHT: FeedItem[] = [FEED_LIST[1], FEED_LIST[3], FEED_LIST[5]];

FeedItem 类是社区动态的数据载体,包含用户名、头像、动态内容、运动项目、卡路里消耗、点赞数和发布时间七个属性。这些属性完整描述了一条社区动态的所有展示信息。

特别值得注意的是 FEED_LEFTFEED_RIGHT 两个数组的定义。它们通过索引从 FEED_LIST 中分别取奇数位和偶数位的元素,实现了瀑布流双列布局的数据准备。这种"一次定义、双列分配"的数据组织方式简洁高效,避免了维护两个独立的数据源可能带来的不一致问题。

六条预置动态覆盖了跑步、力量、瑜伽、骑行、HIIT 和游泳六种运动类型,与 QUICK_TAGS 中的运动类型完全对应。每条动态都包含真实感的数据——配速、重量、距离、时长等具体指标,使社区动态看起来真实可信,能够激发用户的运动动机。

4.4 ShopItem 商品数据模型

@Observed
export class ShopItem {
  icon: string;
  name: string;
  desc: string;
  price: string;
  sales: string;

  constructor(icon: string, name: string, desc: string, price: string, sales: string) {
    this.icon = icon;
    this.name = name;
    this.desc = desc;
    this.price = price;
    this.sales = sales;
  }
}

const SHOP_LIST: ShopItem[] = [
  new ShopItem('🥤', '乳清蛋白粉', '巧克力味 · 1kg 装', '¥299', '月销 2.3 万'),
  new ShopItem('🧘', 'TPE 瑜伽垫', '加厚防滑 · 含体位线', '¥89', '月销 1.8 万'),
  new ShopItem('⌚', '运动智能手表', '心率血氧 · GPS 路径', '¥1299', '月销 5600'),
  new ShopItem('⛓️', '弹力阻力带', '三档阻力 · 含门扣', '¥49', '月销 3.1 万'),
  new ShopItem('🏋️', '可调哑铃', '2.5~24kg · 六边手柄', '¥599', '月销 8900'),
  new ShopItem('👟', '碳板跑鞋', '回弹中底 · 透气鞋面', '¥899', '月销 1.2 万')
];

ShopItem 类描述了运动商城中的商品信息。商品涵盖了营养补充剂(蛋白粉)、训练器材(瑜伽垫、阻力带、哑铃)、智能设备(运动手表)和运动装备(跑鞋)四大品类,覆盖了健身用户从入门到进阶的装备需求。

pricesales 属性都使用字符串类型,因为它们携带了货币符号和单位信息(如 “¥299”、“月销 2.3 万”)。在实际电商系统中,价格通常以分为单位的整数存储,但在展示层使用格式化后的字符串更为便捷。这里的做法是合理的——因为该平台仅展示商品信息,不涉及价格计算逻辑。

4.5 Achievement 成就徽章数据模型

@Observed
export class Achievement {
  icon: string;
  name: string;
  desc: string;
  got: boolean;

  constructor(icon: string, name: string, desc: string, got: boolean) {
    this.icon = icon;
    this.name = name;
    this.desc = desc;
    this.got = got;
  }
}

const ACHIEVE_LIST: Achievement[] = [
  new Achievement('🔥', '连续打卡', '连续运动 7 天', true),
  new Achievement('💯', '百分卡路里', '单日消耗 500+ 卡', true),
  new Achievement('🏃', '跑者初成', '累计跑步 50km', true),
  new Achievement('💪', '力量入门', '完成 10 次力量训练', false),
  new Achievement('🧘', '瑜伽达人', '完成 20 次瑜伽课', false),
  new Achievement('🏊', '全能选手', '尝试 5 种运动类型', false),
  new Achievement('⚡', '速力双全', '跑步+力量同日完成', false),
  new Achievement('🏆', '月度冠军', '单月运动 30 天', false)
];

const ACHIEVE_GOT: number = 3;

Achievement 类是游戏化设计的核心载体。got 布尔属性表示用户是否已获得该成就,在 UI 渲染时控制徽章的亮度和透明度——已获得的徽章完全显示,未获得的徽章以 30% 的透明度呈现,既展示了成就系统的全貌,又激发了用户的解锁欲望。

八项成就覆盖了不同的行为维度:连续性(连续打卡 7 天)、强度(单日 500+ 卡)、累积量(跑步 50km)、训练频次(力量 10 次、瑜伽 20 次)、多样性(5 种运动类型)、组合性(跑步+力量同日)、坚持度(月度 30 天)。这种多维度的成就设计能够满足不同类型用户的动机需求——有些用户追求连续打卡,有些用户追求数值突破,有些用户追求多样化体验。

ACHIEVE_GOT 常量记录了已获得成就的数量(3 个),用于在成就标题中显示进度(如 “3/8”)。这个值在实际应用中应该从后端获取或通过本地存储计算,这里使用常量简化了演示场景。


五、组件主体:@Entry 与 @Component 装饰器的深度解析

5.1 状态变量定义

@Entry
@Component
struct Page801 {
  @State currentTab: number = 0;
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delIdx: number = -1;
  @State breath: boolean = false;
  timer: number = -1;

  @State workoutList: WorkoutItem[] = WORKOUT_LIST;
  @State courseList: CourseItem[] = COURSE_LIST;
  @State feedList: FeedItem[] = FEED_LIST;
  @State shopList: ShopItem[] = SHOP_LIST;
  @State achieveList: Achievement[] = ACHIEVE_LIST;
}

@Entry 装饰器标记该组件为页面入口组件,鸿蒙系统的路由系统通过该标记识别可导航的页面。@Component 装饰器声明该结构体为自定义组件,使其可以在其他组件中被引用和使用。

状态变量的定义是组件最核心的部分。@State 装饰器为每个变量创建了响应式监听——当变量的值发生变化时,引用该变量的 UI 部分会自动重新渲染。这里定义了 12 个状态变量,分为三类:

交互状态变量currentTab(当前激活的标签页索引)、addModal/editModal/delModal(三个弹窗的显示状态)、editIdx/delIdx(正在编辑或删除的项索引)。这些变量直接控制 UI 的行为和表现。

动画状态变量breath(呼吸效果的翻转布尔值),配合定时器实现 1 秒间隔的透明度变化。timer 是定时器 ID,不使用 @State 装饰——因为它不直接驱动 UI 变化,仅在组件销毁时用于清理定时器。

数据状态变量workoutListcourseListfeedListshopListachieveList,分别引用预置的常量数据数组。将这些数据声明为 @State 而非直接使用常量,是因为运行时可能需要修改这些数据(如标记运动为已完成、添加新动态等),修改后的数据变化需要触发 UI 更新。

5.2 生命周期函数

aboutToAppear() {
  this.timer = setInterval(() => {
    this.breath = !this.breath;
  }, 1000);
}

aboutToDisappear() {
  clearInterval(this.timer);
}

aboutToAppear 是组件的生命周期钩子,在组件即将出现在屏幕上时被调用。这里使用 setInterval 创建了一个每秒执行一次的定时器,用于翻转 breath 布尔值。由于 breath@State 变量,每次翻转都会触发 UI 中引用该变量的部分重新渲染——具体表现为头部打卡提醒条的肌肉表情符号和"我的"页面等级卡片中跑步表情符号的透明度交替变化,形成呼吸效果。

aboutToDisappear 在组件即将从屏幕上移除时被调用。这里通过 clearInterval 清除了定时器。这种资源清理是防止内存泄漏的关键——如果不在组件销毁时清除定时器,定时器会在后台继续执行,不仅浪费系统资源,还可能引用已销毁组件的状态变量导致未定义行为。

组件创建

aboutToAppear

启动定时器
每秒翻转breath

breath变化触发
UI重新渲染

组件是否销毁?

aboutToDisappear

clearInterval
清除定时器

资源释放完成


六、build 方法:页面整体布局架构

6.1 顶层布局结构

build() {
  Column() {
    this.headerFit()
    Divider().strokeWidth(1).color(COLORS.line)
    Scroll() {
      Column() {
        if (this.currentTab === 0) {
          this.tabHome()
        } else if (this.currentTab === 1) {
          this.tabCourse()
        } else if (this.currentTab === 2) {
          this.tabRecord()
        } else if (this.currentTab === 3) {
          this.tabFeed()
        } else if (this.currentTab === 4) {
          this.tabShop()
        } else if (this.currentTab === 5) {
          this.tabMine()
        }
        this.chartCard()
      }
      .padding({ left: 14, right: 14, top: 12, bottom: 12 })
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)

    this.tabBar()

    if (this.addModal) {
      this.panelAdd(() => {
        this.addModal = false;
      })
    }
    if (this.editModal) {
      this.panelEdit(() => {
        this.editModal = false;
      })
    }
    if (this.delModal) {
      this.panelDel(() => {
        this.delModal = false;
      })
    }
  }
  .backgroundColor(COLORS.bg)
  .height('100%')
}

build 方法是每个 ArkUI 组件的核心——它描述了组件的 UI 结构。该方法的整体架构可以分解为四个层次:

头部层:调用 this.headerFit() 构建固定头部区域,包含定位信息、数据大数字、搜索栏、快捷入口和打卡提醒条。头部之后紧跟一条 Divider 分割线,在视觉上将头部与内容区域分隔开来。

内容滚动层:使用 Scroll 组件包裹内容区域,支持上下滚动浏览。内部使用 Column 作为内容容器,通过 if-else 条件分支根据 currentTab 的值渲染对应的标签页内容。每个标签页结束后都会调用 this.chartCard() 渲染通用的月度柱状图卡片。scrollBar(BarState.Off) 隐藏了滚动条,在运动场景中,隐藏滚动条可以减少视觉干扰,使用户更专注于数据本身。

底部导航层:调用 this.tabBar() 构建底部六标签导航栏,固定在页面底部不随内容滚动。

弹窗层:三个条件渲染的弹窗组件,分别对应开始运动、设置目标和删除记录三种操作。每个弹窗的显示由对应的 @State 布尔变量控制,当变量为 true 时弹窗渲染,为 false 时不渲染。弹窗组件接收一个回调函数 onClose,用于在用户点击关闭或确认后重置弹窗状态。

0

1

2

3

4

5

addModal=true

editModal=true

delModal=true

build 方法入口

headerFit 头部区域

Divider 分割线

Scroll 滚动内容区

currentTab 值判断

tabHome 首页

tabCourse 课程

tabRecord 记录

tabFeed 动态

tabShop 商城

tabMine 我的

chartCard 图表卡片

tabBar 底部导航

弹窗状态检测

panelAdd 开始运动弹窗

panelEdit 设置目标弹窗

panelDel 删除确认弹窗

这种布局架构的设计优势在于内容区域的隔离性——每个标签页的内容完全独立,互不干扰。切换标签页时,旧的标签页内容会被完全卸载(因为 if 条件不满足),新的标签页内容会被重新构建。这种"即用即建"的策略虽然会在切换时产生轻微的构建开销,但保证了内存占用的最小化,不会因为同时保持六个标签页的渲染树而导致内存压力。


七、头部区域:运动数据可视化设计

7.1 headerFit 头部构建器

@Builder
headerFit() {
  Column({ space: 10 }) {
    Row() {
      Column({ space: 2 }) {
        Text('📍 成都·高新区').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('今日步数 8,432 步').fontSize(9).fontColor(COLORS.text3)
      }
      Column().layoutWeight(1)
      Row({ space: 8 }) {
        Text('🔔').fontSize(16)
        Text('🏃').fontSize(18)
      }
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12, bottom: 8 })

头部区域的第一行分为左右两部分。左侧使用 Column 垂直排列定位信息(“成都·高新区”)和步数统计(“今日步数 8,432 步”),分别使用 12px 和 9px 的字号——定位信息作为主要信息使用较大的字号和粗体,步数作为辅助信息使用小字号和暗色。中间的 Column().layoutWeight(1) 充当弹性间隔,将左右内容推到两端。右侧是通知和跑步图标,间距 8px。

这种"左信息-右图标"的头部布局是移动应用的经典模式。在运动场景中,用户最常需要的信息是当前位置和今日运动数据,因此将它们放在头部最显眼的位置。通知图标和跑步图标位于右侧,便于用户快速访问。

7.2 数据大数字展示

    Row({ space: 0 }) {
      Column({ space: 2 }) {
        Text('500').fontSize(28).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
        Text('卡路里').fontSize(9).fontColor(COLORS.text3)
      }
      .layoutWeight(1)
      Column({ space: 2 }) {
        Text('68').fontSize(28).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
        Text('运动分钟').fontSize(9).fontColor(COLORS.text3)
      }
      .layoutWeight(1)
      Column({ space: 2 }) {
        Text('6/8').fontSize(28).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
        Text('打卡任务').fontSize(9).fontColor(COLORS.text3)
      }
      .layoutWeight(1)
    }
    .width('90%')

这一行是头部的视觉焦点——三个大数字横向排列,分别展示卡路里消耗(500,绿色)、运动分钟(68,橙色)和打卡任务进度(6/8,金色)。28px 的字号在深色背景上具有极强的视觉冲击力,使用不同颜色区分三项指标,使用户在扫视中就能快速获取关键数据。

每个数据项都采用了"大数字+小标签"的双层结构:大数字在上方,使用粗体和主题色;小标签在下方,使用 9px 字号和暗色文本。这种结构既突出了数值本身,又通过标签提供了语义上下文。三列使用 layoutWeight(1) 等分宽度,保证视觉上的均匀分布。

7.3 搜索栏设计

    Row({ space: 8 }) {
      Text('🔍').fontSize(14)
      Text('搜索课程·教练·运动装备…').fontSize(11).fontColor(COLORS.text3)
      Column().layoutWeight(1)
      Text('搜索').fontSize(11).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
    }
    .width('90%')
    .height(38)
    .backgroundColor(COLORS.card)
    .borderRadius(19)
    .padding({ left: 14, right: 14 })
    .alignItems(VerticalAlign.Center)

搜索栏采用了胶囊形设计——borderRadius(19) 配合 height(38) 形成了完全圆角的胶囊外观。搜索图标、占位文本、弹性间隔和搜索按钮从左到右排列。占位文本列出了三种搜索维度(课程、教练、运动装备),帮助用户了解搜索功能的覆盖范围。

搜索按钮文本使用绿色加粗,暗示这是一个可交互的入口。虽然在当前实现中搜索功能仅是视觉展示,但搜索栏的布局结构已经为后续接入真实搜索功能做好了准备——只需为"搜索"文本添加 onClick 事件处理器即可。

7.4 快捷入口图标行

    Row({ space: 0 }) {
      ForEach(QUICK_TAGS, (tag: string, idx: number) => {
        Column({ space: 4 }) {
          Text(idx === 0 ? '🏃' : idx === 1 ? '🏋️' : idx === 2 ? '🧘' : idx === 3 ? '🏊' : idx === 4 ? '🚴' : '🤸').fontSize(20)
          Text(tag).fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
      })
    }
    .width('90%')
    .padding({ top: 6, bottom: 6 })

快捷入口行使用 ForEach 遍历 QUICK_TAGS 数组,生成六个运动类型快捷入口。每个入口由图标和标签组成,使用 Column 垂直排列。图标使用条件表达式根据索引选择对应的 emoji,这种写法虽然简单直接,但在运动类型较多时会变得冗长——更优雅的做法是定义一个包含图标和标签的结构数组。

六个入口使用 layoutWeight(1) 等分宽度,保证视觉上的均匀分布。图标 20px 字号、标签 9px 字号,在紧凑的空间内提供了足够的可识别性。这种快捷入口设计使用户无需进入课程页面就能快速开始特定类型的运动。

7.5 打卡提醒条

    Row({ space: 8 }) {
      Stack() {
        Text('💪').fontSize(14)
          .opacity(this.breath ? 1 : 0.4)
      }
      Text('今日还差 200 卡达成目标,加油!').fontSize(10).fontColor(COLORS.orangeD)
      Column().layoutWeight(1)
      Text('去运动 ›').fontSize(9).fontColor(COLORS.green)
    }
    .width('90%')
    .height(32)
    .backgroundColor(COLORS.greenL)
    .borderRadius(8)
    .padding({ left: 10, right: 10 })
    .alignItems(VerticalAlign.Center)
  }
  .width('100%')
  .backgroundColor(COLORS.card)
}

打卡提醒条是头部最底部的元素,使用浅绿色背景 greenL 区分于其他头部内容。提醒文本"今日还差 200 卡达成目标,加油!"使用了 orangeD 深橙色——这是一种激励性色彩,在深色主题中具有警示但不刺眼的视觉效果,传达了"需要行动"的信号。

左侧的肌肉表情符号通过 this.breath 状态变量控制透明度,实现了 1 秒间隔的呼吸效果。Stack 容器包裹表情符号是为了给呼吸动画提供一个独立的定位上下文。右侧的"去运动 ›"文本使用绿色,暗示这是一个可点击的交互入口,引导用户前往运动页面。

整个头部使用 Column({ space: 10 }) 包裹,各行之间保持 10px 的间距,backgroundColor(COLORS.card) 使头部区域与页面背景产生层次区分。头部的所有内容都在不滚动区域内,确保用户在任何标签页都能看到关键运动数据。


八、首页标签页:数据概览大卡设计

8.1 tabHome 首页构建器

@Builder
tabHome() {
  Column({ space: 14 }) {
    // 今日数据概览大卡
    Column({ space: 12 }) {
      Row() {
        Text('📊 今日运动概览').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('08-24').fontSize(10).fontColor(COLORS.text3)
      }

首页标签页由两个主要卡片组成:今日运动概览大卡和今日运动清单卡。概览大卡的标题行使用"📊"图标加文本的形式,右侧显示当前日期"08-24"。这种标题设计在保持简洁的同时提供了时间上下文。

8.2 三列数据展示

      Row({ space: 0 }) {
        Column({ space: 4 }) {
          Text('500').fontSize(24).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
          Text('消耗卡路里').fontSize(9).fontColor(COLORS.text3)
        }
        .layoutWeight(1)
        Column({ space: 4 }) {
          Text('68').fontSize(24).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
          Text('运动分钟').fontSize(9).fontColor(COLORS.text3)
        }
        .layoutWeight(1)
        Column({ space: 4 }) {
          Text('8432').fontSize(24).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
          Text('步数').fontSize(9).fontColor(COLORS.text3)
        }
        .layoutWeight(1)
      }

三列数据展示与头部的大数字行类似,但使用了 24px 的字号(头部使用 28px),形成视觉层级差异——头部数据是"全局概览",首页数据是"详细分解"。三项数据分别使用绿色、橙色和蓝色,与头部配色保持一致。步数使用蓝色是一个巧妙的设计——蓝色在运动心理学中与"稳定、持续"相关联,适合表示步数这种持续性积累的数据。

8.3 打卡进度条

      // 打卡进度条
      Column({ space: 4 }) {
        Row() {
          Text('今日打卡').fontSize(10).fontColor(COLORS.sub)
          Column().layoutWeight(1)
          Text('2/6').fontSize(10).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
        }
        Row() {
          Column()
            .width('33%')
            .height(6)
            .backgroundColor(COLORS.green)
            .borderRadius(3)
          Column().layoutWeight(1)
        }
        .width('100%')
        .height(6)
        .backgroundColor(COLORS.chip)
        .borderRadius(3)
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.card)
    .borderRadius(16)

打卡进度条是一个经典的双层结构:底层是一个 chip 色的圆角条作为轨道,上层是一个 green 色的圆角条作为进度填充。进度条的宽度为 ‘33%’(2/6 ≈ 0.333),精确反映了今日打卡进度。进度条高度 6px、圆角 3px,形成了完全圆角的胶囊外观。

这种进度条的实现方式利用了 ArkUI 的层叠特性——两个 Column 在同一 Row 中水平排列,进度填充在前、弹性间隔在后,填充的固定百分比宽度决定了进度条的可视长度。这种写法比使用绝对定位或 Stack 叠加更简洁,且天然支持从左到右的进度方向。

8.4 今日运动清单

    // 今日运动清单
    Column({ space: 8 }) {
      Row() {
        Text('✅ 今日运动清单').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('+ 添加').fontSize(10).fontColor(COLORS.green)
          .onClick(() => {
            this.addModal = true;
          })
      }

      ForEach(this.workoutList, (item: WorkoutItem, idx: number) => {
        Row({ space: 10 }) {
          Text(item.icon).fontSize(20)
          Column({ space: 2 }) {
            Text(item.name).fontSize(12).fontColor(item.done ? COLORS.text3 : COLORS.title)
            Row({ space: 6 }) {
              Text('⏱ ' + item.duration).fontSize(9).fontColor(COLORS.text3)
              Text('🔥 ' + item.calories.toString() + ' 卡').fontSize(9).fontColor(COLORS.orange)
            }
          }
          Column().layoutWeight(1)
          Text(item.done ? '✓ 已完成' : '待打卡').fontSize(9)
            .fontColor(item.done ? COLORS.green : COLORS.orangeD)
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })
        .alignItems(VerticalAlign.Center)
        .onClick(() => {
          if (!item.done) {
            this.editIdx = idx;
            this.addModal = true;
          }
        })
      })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.card)
    .borderRadius(12)
  }
}

今日运动清单卡使用 ForEach 遍历 workoutList 数组,为每个运动项渲染一行。每行包含图标、运动名称、时长、卡路里和完成状态五个信息元素。已完成的运动项名称使用 text3 暗色(表示已过时),未完成的运动项名称使用 title 亮色(表示当前关注)。完成状态标签使用条件表达式切换文本和颜色——"✓ 已完成"显示绿色,"待打卡"显示橙色,这种视觉差异使用户能够快速区分已完成和待完成的运动。

清单标题右侧的"+ 添加"文本使用绿色,绑定了 onClick 事件——点击后设置 this.addModal = true,弹出"开始运动"弹窗。同样,每个运动项行也绑定了 onClick 事件,但仅在 !item.done(未完成)时响应——点击后设置 this.editIdx = idx 记录当前操作的索引,然后打开弹窗。这种"设置索引→打开弹窗→弹窗内读取索引"的模式是该平台弹窗系统的核心交互模式,使得一个弹窗可以服务于列表中的任意项。


九、课程标签页:横滑大卡与难度色带

9.1 tabCourse 课程构建器

@Builder
tabCourse() {
  Column({ space: 12 }) {
    Row() {
      Text('🏋️ 推荐课程').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('全部 ›').fontSize(9).fontColor(COLORS.green)
    }

    Scroll() {
      Row({ space: 12 }) {
        ForEach(this.courseList, (item: CourseItem, idx: number) => {
          Column({ space: 10 }) {
            // 顶部图标区 + 难度色带
            Stack() {
              Column()
                .width('100%')
                .height(80)
                .backgroundColor(idx % 2 === 0 ? COLORS.greenL : COLORS.orangeL)
                .borderRadius(12)

              Column({ space: 4 }) {
                Text(item.icon).fontSize(36)
                Text(item.difficulty).fontSize(9)
                  .fontColor(COLORS.card)
                  .backgroundColor(diffColor(item.difficulty))
                  .borderRadius(4)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              }
              .height(80)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
            }
            .width('100%')
            .height(80)

课程标签页的核心是横向滚动的课程卡片列表。使用 Scroll 组件配合 Row 容器实现水平滚动布局,scrollable(ScrollDirection.Horizontal) 设置滚动方向为水平,scrollBar(BarState.Off) 隐藏滚动条,使横滑体验更加沉浸。

每张课程卡片宽度固定为 140px,包含三个区域:顶部图标区、难度标签和课程信息。顶部图标区使用 Stack 叠加了两层——底层是一个带圆角的彩色背景(偶数索引用绿色 greenL,奇数索引用橙色 orangeL,形成交替色彩节奏),上层居中显示课程图标和难度标签。难度标签的背景色通过 diffColor 函数动态计算,确保"入门"显示绿色、"进阶"显示橙色、"挑战"显示红色。

9.2 课程信息区

            // 课程信息
            Column({ space: 4 }) {
              Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Row({ space: 8 }) {
                Text('⏱ ' + item.duration).fontSize(9).fontColor(COLORS.text3)
                Text('🔥 ' + item.calories).fontSize(9).fontColor(COLORS.orange)
              }
              Row({ space: 4 }) {
                Text('👤').fontSize(9)
                Text(item.coach).fontSize(9).fontColor(COLORS.sub)
              }
            }
            .width('100%')
            .padding({ left: 4, right: 4, bottom: 4 })
          }
          .width(140)
          .padding(10)
          .backgroundColor(COLORS.card)
          .borderRadius(14)
          .onClick(() => {
            this.editIdx = idx;
            this.addModal = true;
          })
        })
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
  }
}

课程信息区展示课程名称、时长、卡路里消耗和教练信息。课程名称使用 12px 粗体,确保在 140px 宽度的卡片中清晰可读。时长使用时钟图标前缀,卡路里使用火焰图标前缀,教练使用人物图标前缀——这些图标前缀不仅增强了信息的可读性,还通过视觉一致性帮助用户快速定位信息。

每张课程卡片都绑定了 onClick 事件,点击后设置 this.editIdx = idx 记录课程索引,然后打开弹窗。弹窗会根据 this.currentTabthis.editIdx 的值判断当前操作来源——如果 currentTab === 1(课程标签页),则从 courseList 中读取对应课程信息展示在弹窗中。


十、记录标签页:周历热力网格

10.1 tabRecord 记录构建器

@Builder
tabRecord() {
  Column({ space: 14 }) {
    Row() {
      Text('📊 本周运动记录').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('查看全部 ›').fontSize(9).fontColor(COLORS.green)
    }

    // 周历热力网格
    Column({ space: 8 }) {
      Row({ space: 0 }) {
        ForEach(WEEK_DAY, (day: string, idx: number) => {
          Column({ space: 6 }) {
            Text(day).fontSize(9).fontColor(COLORS.text3)
            Column()
              .width(28)
              .height(28)
              .backgroundColor(heatColor(WEEK_CAL[idx]))
              .borderRadius(8)
            Text(WEEK_CAL[idx] > 0 ? WEEK_CAL[idx].toString() : '—').fontSize(8)
              .fontColor(WEEK_CAL[idx] > 0 ? COLORS.green : COLORS.text3)
          }
          .layoutWeight(1)
        })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.card)
    .borderRadius(12)

记录标签页的第一个卡片是周历热力网格。使用 ForEach 遍历 WEEK_DAY 数组,为每天生成一个热力格子。每个格子包含三部分:日期标签(一/二/…/日)、热力方块和卡路里数值。

热力方块的核心是 heatColor(WEEK_CAL[idx]) 函数调用——它根据当天的卡路里消耗值返回对应的颜色。休息日(0 卡)显示 chip 色,低强度运动显示 greenL,中等强度显示 greenD,高强度显示 green。这种通过颜色深浅直观展示运动强度分布的设计,使用户能够一眼看出本周的运动节奏是否均衡。

卡路里数值在 0 时显示"—“而非数字,这是一个细节优化——”—"传达了"无数据/休息"的含义,比显示"0"更加直观。非零值使用绿色文字,零值使用暗色文字,进一步强化了视觉区分。

10.2 运动类型图例

    // 运动类型图例
    Column({ space: 8 }) {
      Text('🏷️ 运动类型分布').fontSize(11).fontColor(COLORS.sub).fontWeight(FontWeight.Bold)
      ForEach(QUICK_TAGS, (tag: string, idx: number) => {
        Row({ space: 8 }) {
          Column()
            .width(12)
            .height(12)
            .backgroundColor(typeColor(tag))
            .borderRadius(3)
          Text(tag).fontSize(10).fontColor(COLORS.sub)
          Column().layoutWeight(1)
          Text((idx + 1).toString() + ' 次').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')
        .padding({ top: 4, bottom: 4 })
        .alignItems(VerticalAlign.Center)
      })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.card)
    .borderRadius(12)
  }
}

运动类型图例卡片使用 ForEach 遍历 QUICK_TAGS 数组,为每种运动类型生成一行图例。每行包含一个 12×12px 的色块(颜色由 typeColor 函数计算)、运动类型名称、弹性间隔和次数统计。色块使用 borderRadius(3) 形成微圆角,在视觉上比直角方块更加柔和。

次数统计使用 (idx + 1).toString() + ' 次' 的表达式——这里利用索引值生成了递增的次数数据(1 次、2 次、3 次…),但在实际应用中应该从真实的运动记录中统计每种类型的出现次数。这种基于索引的模拟数据适用于演示场景,但在生产环境中需要替换为真实的统计逻辑。


十一、动态标签页:双列瀑布流布局

11.1 tabFeed 动态构建器

@Builder
tabFeed() {
  Column({ space: 12 }) {
    Row() {
      Text('🔥 运动动态').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('发布 +').fontSize(10).fontColor(COLORS.green)
    }

    Row({ space: 10 }) {
      // 左列
      Column({ space: 10 }) {
        ForEach(FEED_LEFT, (item: FeedItem, idx: number) => {
          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Text(item.avatar).fontSize(24)
              Column({ space: 2 }) {
                Text(item.user).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(item.time).fontSize(8).fontColor(COLORS.text3)
              }
            }
            Text(item.content).fontSize(10).fontColor(COLORS.sub)
              .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
            Row({ space: 6 }) {
              Text('🏆 ' + item.workout).fontSize(8).fontColor(typeColor(item.workout))
              Text('🔥 ' + item.calories).fontSize(8).fontColor(COLORS.orange)
            }
            Row() {
              Text('❤️ ' + item.likes).fontSize(9).fontColor(COLORS.red)
              Column().layoutWeight(1)
              Text('💬').fontSize(10)
            }
            .width('100%')
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
        })
      }
      .layoutWeight(1)

      // 右列
      Column({ space: 10 }) {
        ForEach(FEED_RIGHT, (item: FeedItem, idx: number) => {
          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Text(item.avatar).fontSize(24)
              Column({ space: 2 }) {
                Text(item.user).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(item.time).fontSize(8).fontColor(COLORS.text3)
              }
            }
            Text(item.content).fontSize(10).fontColor(COLORS.sub)
              .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
            Row({ space: 6 }) {
              Text('🏆 ' + item.workout).fontSize(8).fontColor(typeColor(item.workout))
              Text('🔥 ' + item.calories).fontSize(8).fontColor(COLORS.orange)
            }
            Row() {
              Text('❤️ ' + item.likes).fontSize(9).fontColor(COLORS.red)
              Column().layoutWeight(1)
              Text('💬').fontSize(10)
            }
            .width('100%')
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
        })
      }
      .layoutWeight(1)
    }
  }
}

动态标签页采用了双列瀑布流布局。外层 Row 包含两个 Column,每个 Column 使用 layoutWeight(1) 等分宽度。左列遍历 FEED_LEFT 数组,右列遍历 FEED_RIGHT 数组,两列各自独立排列,形成错落有致的瀑布流效果。

每张动态卡片包含四个区域:用户信息行(头像+用户名+时间)、动态内容文本(最多两行,超出省略)、运动信息行(运动项目+卡路里)和互动行(点赞数+评论图标)。动态内容使用 maxLines(2)textOverflow({ overflow: TextOverflow.Ellipsis }) 限制为两行并显示省略号,防止单条动态过长导致瀑布流列高严重不均。

运动项目标签的颜色通过 typeColor(item.workout) 动态计算——由于 item.workout 的值可能包含额外的描述信息(如"跑步 10km"、“骑行 40km”),而 typeColor 函数在匹配时会检查字符串是否完全等于"跑步"、"骑行"等。这意味着如果运动项目文本不是精确匹配,将回退到默认的蓝色。这是一个潜在的改进点——可以通过提取运动项目关键词来增强匹配的鲁棒性。

FEED_LIST 六条动态

按奇偶索引分配

FEED_LEFT 左列
索引 0,2,4

FEED_RIGHT 右列
索引 1,3,5

左列 Column
layoutWeight=1

右列 Column
layoutWeight=1

动态卡片1
跑酷达人

动态卡片2
瑜伽少女

动态卡片3
燃脂菜鸟

动态卡片4
铁馆女孩

动态卡片5
骑行大叔

动态卡片6
游泳健将


十二、商城标签页:图标清单行布局

12.1 tabShop 商城构建器

@Builder
tabShop() {
  Column({ space: 10 }) {
    Row() {
      Text('🛒 运动商城').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('购物车 ›').fontSize(9).fontColor(COLORS.green)
    }

    ForEach(this.shopList, (item: ShopItem, idx: number) => {
      Row({ space: 12 }) {
        Column() {
          Text(item.icon).fontSize(28)
        }
        .width(48)
        .height(48)
        .backgroundColor(idx % 2 === 0 ? COLORS.greenL : COLORS.orangeL)
        .borderRadius(12)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)

        Column({ space: 4 }) {
          Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text(item.desc).fontSize(9).fontColor(COLORS.text3)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(item.sales).fontSize(8).fontColor(COLORS.text3)
        }
        Column().layoutWeight(1)

        Column({ space: 2 }) {
          Text(item.price).fontSize(14).fontColor(COLORS.orangeD).fontWeight(FontWeight.Bold)
          Text('购买 ›').fontSize(9).fontColor(COLORS.green)
            .onClick(() => {
              this.editIdx = idx;
              this.addModal = true;
            })
        }
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(10)
      .alignItems(VerticalAlign.Center)
    })
  }
}

商城标签页采用简洁的垂直列表布局,每行展示一个商品。每行分为三部分:左侧的图标容器、中间的商品信息和右侧的价格与购买按钮。

图标容器是一个 48×48px 的圆角方块,背景色交替使用绿色和橙色浅色——这种交替色彩不仅增加了视觉趣味性,还隐含了商品的分类信息(偶数索引可能是营养类/装备类,奇数索引可能是器材类/设备类)。图标使用 28px 字号居中显示。

商品信息区包含商品名称、描述和销量三行文本。描述使用 maxLines(1) 和省略号截断,确保长描述不会撑破行宽。销量使用 8px 字号——这是整个应用中最小的字号,因为销量是用户决策的参考信息而非核心信息。

价格使用 14px 深橙色粗体,是商品行中字号最大的文本元素——这种字号差异使价格成为视觉焦点,符合电商场景中"价格优先"的信息架构原则。"购买 ›"文本使用绿色并绑定了 onClick 事件,点击后设置索引并打开弹窗。


十三、我的标签页:等级大卡与成就四宫格

13.1 等级进度大卡

@Builder
tabMine() {
  Column({ space: 14 }) {
    // 等级进度大卡
    Column({ space: 10 }) {
      Row() {
        Text('🏃').fontSize(28)
          .opacity(this.breath ? 1 : 0.5)
        Column().layoutWeight(1)
        Column({ space: 2 }) {
          Text('运动等级').fontSize(10).fontColor('rgba(255,255,255,0.8)')
          Row({ space: 4 }) {
            Text('Lv.8').fontSize(28).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('活力达人').fontSize(10).fontColor('rgba(255,255,255,0.8)')
          }
        }
      }
      Row() {
        Text('距 Lv.9 还需 320 经验').fontSize(9).fontColor('rgba(255,255,255,0.8)')
        Column().layoutWeight(1)
        Text('68%').fontSize(9).fontColor('rgba(255,255,255,0.9)')
      }
      Row() {
        Column()
          .width('68%')
          .height(4)
          .backgroundColor(COLORS.green)
          .borderRadius(2)
        Column().layoutWeight(1)
      }
      .width('100%')
      .height(4)
      .backgroundColor('rgba(255,255,255,0.2)')
      .borderRadius(2)
    }
    .width('100%')
    .padding(20)
    .borderRadius(16)
    .linearGradient({
      angle: 135,
      colors: [[COLORS.greenD, 0], [COLORS.greenL, 1]]
    })

"我的"标签页的第一张卡片是等级进度大卡,它使用了 linearGradient 线性渐变背景——从 135 度方向的 greenD(深绿)渐变到 greenL(暗绿),创造出运动场般的绿色光影效果。这种渐变背景是整个应用中唯一使用渐变的地方,通过视觉特殊性强调了"等级"这一用户成就的核心地位。

卡片内容分为三层:顶部的等级展示行、中部的进度描述行和底部的进度条。等级展示行中,跑步表情符号使用 this.breath 控制透明度实现呼吸效果,与头部打卡提醒条的呼吸动画形成呼应。“Lv.8” 使用 28px 粗体白色,“活力达人” 使用 10px 半透明白色,形成了强烈的字号对比。

进度条采用了与首页打卡进度条相同的双层结构,但使用 rgba(255,255,255,0.2) 半透明白色作为轨道色——这种半透明设计使进度条在渐变背景上更加自然地融合。进度填充宽度为 ‘68%’,与文本中的"68%"保持一致。

13.2 成就徽章四宫格

    // 成就徽章四宫格
    Column({ space: 10 }) {
      Row() {
        Text('🏆 我的成就 · ' + ACHIEVE_GOT.toString() + '/' + this.achieveList.length.toString()).fontSize(13)
          .fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('全部 ›').fontSize(9).fontColor(COLORS.green)
      }

      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(this.achieveList, (item: Achievement, idx: number) => {
          Column({ space: 6 }) {
            Text(item.icon).fontSize(28)
              .opacity(item.got ? 1 : 0.3)
            Text(item.name).fontSize(10).fontColor(item.got ? COLORS.title : COLORS.text3)
            Text(item.desc).fontSize(8).fontColor(COLORS.text3)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .width('23%')
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.card)
          .borderRadius(10)
          .alignItems(HorizontalAlign.Center)
          .margin({ bottom: 8 })
        })
      }
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.chip)
    .borderRadius(12)
  }
}

成就徽章区使用 Flex 容器配合 FlexWrap.Wrap 实现自动换行布局。每个徽章宽度为 ‘23%’,四列布局(4 × 23% = 92%,剩余 8% 由 SpaceBetween 分配为三个间距)。这种基于百分比的弹性布局能够自动适应不同屏幕宽度。

每个徽章包含图标、名称和描述三行内容。图标的透明度由 item.got 控制——已获得的徽章完全显示(opacity: 1),未获得的徽章以 30% 透明度显示。这种"灰化"处理是游戏化设计中常见的"解锁预览"模式——用户可以看到所有可获得的成就,但未获得的会以暗淡形式呈现,激发解锁欲望。

成就标题中的进度显示 “3/8” 使用了字符串拼接:ACHIEVE_GOT.toString() + '/' + this.achieveList.length.toString()。这种动态计算总数的方式确保了当成就列表增减时,总数会自动更新。

tabMine 我的标签页

等级进度大卡
linearGradient渐变背景

成就徽章四宫格
Flex弹性换行

🏃 呼吸动画图标

Lv.8 活力达人
大字号等级展示

距Lv.9还需320经验
68%进度条

已获得徽章
opacity=1
绿色高亮

未获得徽章
opacity=0.3
灰化预览

🔥连续打卡

💯百分卡路里

🏃跑者初成

💪力量入门

🧘瑜伽达人

🏊全能选手

⚡速力双全

🏆月度冠军


十四、图表卡片:月度卡路里柱状图

14.1 chartCard 图表构建器

@Builder
chartCard() {
  Column({ space: 12 }) {
    Row() {
      Text('📊 近 6 月消耗').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('合计 3,910 卡').fontSize(11).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
    }

    Row({ space: 0 }) {
      ForEach(MONTH_IDX, (mi: number, idx: number) => {
        Column({ space: 4 }) {
          Column()
            .width(16)
            .height(30 + CAL_VAL[idx] / CAL_MAX * 100)
            .backgroundColor(idx === 3 ? COLORS.orange : COLORS.greenL)
            .borderRadius(4)

          Text(CAL_VAL[idx].toString()).fontSize(7)
            .fontColor(idx === 3 ? COLORS.orangeD : COLORS.text3)

          Text(MONTH_NAME[idx]).fontSize(8).fontColor(COLORS.text3)
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.End)
        .alignItems(HorizontalAlign.Center)
      })
    }
    .width('100%')
    .height(110)
    .padding({ top: 8, bottom: 8 })
    .backgroundColor(COLORS.chip)
    .borderRadius(10)
    .alignItems(VerticalAlign.Bottom)
  }
  .width('100%')
  .padding(14)
  .backgroundColor(COLORS.card)
  .borderRadius(12)
  .margin({ top: 12 })
}

图表卡片是所有标签页共享的底部组件——无论用户在哪个标签页,月度柱状图都会显示在内容区域的底部。这种设计确保了用户随时都能看到长期运动趋势。

柱状图使用 ForEach 遍历六个月的数据,每个月份生成一个柱子。柱子的高度通过公式 30 + CAL_VAL[idx] / CAL_MAX * 100 计算——30px 是基础高度(确保零值也有可见的柱子),CAL_VAL[idx] / CAL_MAX * 100 是按比例缩放的高度增量。最大值 920 卡的柱子高度为 30 + 920/1200100 ≈ 106px,最小值 420 卡的柱子高度为 30 + 420/1200100 ≈ 65px。

当前月份(索引 3,即六月份)的柱子使用橙色 COLORS.orange,其他月份使用浅绿 COLORS.greenL。这种颜色差异使当前月份在视觉上脱颖而出,便于用户快速定位当前位置。柱子下方的数值标签和月份标签也使用了对应的颜色——当前月份使用 orangeD,其他月份使用 text3

柱状图容器使用 alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,这是柱状图的标准视觉规范。容器高度 110px,包含柱子、数值标签和月份标签三层信息,布局紧凑而不拥挤。


十五、底部导航栏:六标签单排设计

15.1 tabBar 导航构建器

@Builder
tabBar() {
  Row({ space: 0 }) {
    ForEach(TAB_LIST, (tab: TabMeta, idx: number) => {
      Column({ space: 2 }) {
        Text(tab.icon).fontSize(this.currentTab === idx ? 21 : 19)
          .opacity(this.currentTab === idx ? 1 : 0.6)
        Text(tab.label).fontSize(9)
          .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
          .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
      }
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.currentTab = idx;
      })
    })
  }
  .width('100%')
  .height(58)
  .backgroundColor(COLORS.card)
  .padding({ top: 6, bottom: 6 })
}

底部导航栏使用 Row 容器配合 ForEach 渲染六个标签。每个标签使用 layoutWeight(1) 等分宽度,确保六个标签在底部均匀排列。标签栏高度 58px,背景使用 card 色,与页面背景产生层次区分。

标签的激活状态通过三个维度的视觉变化来体现:图标字号(激活 21px / 非激活 19px)、图标透明度(激活 1.0 / 非激活 0.6)和文字颜色/粗细(激活 tabOn 色加粗 / 非激活 text3 色常规)。这种多维度差异确保了激活状态的识别性,即使用户在快速扫视中也能准确判断当前所在的标签页。

点击标签时,onClick 回调将 this.currentTab 设置为对应的索引值。由于 currentTab@State 变量,值的变化会触发 build 方法中的 if-else 条件分支重新求值,卸载旧的标签页内容并加载新的标签页内容。


十六、弹窗系统:遮罩层与交互逻辑

16.1 通用遮罩层

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

modalOverlay 是弹窗系统的通用遮罩层构建器。它接收一个 onClose 回调函数作为参数,在用户点击遮罩区域时调用该回调关闭弹窗。遮罩使用 COLORS.maskrgba(0,0,0,0.55))半透明黑色背景,覆盖整个屏幕,使背景内容变暗但仍然可见——这种视觉效果将用户的注意力聚焦到弹窗内容上。

遮罩层的 onClick 事件绑定了 onClose 回调,使用户可以通过点击遮罩区域来关闭弹窗——这是移动应用中广泛采用的交互模式,提供了除关闭按钮外的另一种关闭途径。

16.2 开始运动弹窗

@Builder
panelAdd(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)

    Column({ space: 14 }) {
      Text('🏋️ 开始运动').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      if (this.editIdx >= 0 && this.currentTab === 0 && this.editIdx < this.workoutList.length) {
        Row({ space: 12 }) {
          Text(this.workoutList[this.editIdx].icon).fontSize(32)
          Column({ space: 4 }) {
            Text(this.workoutList[this.editIdx].name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('⏱ ' + this.workoutList[this.editIdx].duration + ' · 🔥 ' + this.workoutList[this.editIdx].calories.toString() + ' 卡').fontSize(10).fontColor(COLORS.orange)
          }
          Column().layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.greenL)
        .borderRadius(10)
      } else if (this.editIdx >= 0 && this.currentTab === 1 && this.editIdx < this.courseList.length) {
        Row({ space: 12 }) {
          Text(this.courseList[this.editIdx].icon).fontSize(32)
          Column({ space: 4 }) {
            Text(this.courseList[this.editIdx].name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('⏱ ' + this.courseList[this.editIdx].duration + ' · 🔥 ' + this.courseList[this.editIdx].calories).fontSize(10).fontColor(COLORS.orange)
          }
          Column().layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.greenL)
        .borderRadius(10)
      } else if (this.editIdx >= 0 && this.currentTab === 4 && this.editIdx < this.shopList.length) {
        Row({ space: 12 }) {
          Text(this.shopList[this.editIdx].icon).fontSize(32)
          Column({ space: 4 }) {
            Text(this.shopList[this.editIdx].name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(this.shopList[this.editIdx].price).fontSize(12).fontColor(COLORS.orangeD)
          }
          Column().layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.greenL)
        .borderRadius(10)
      }

      Text('准备好开始运动了吗?点击确认后开始计时。').fontSize(11).fontColor(COLORS.sub).textAlign(TextAlign.Center)

      Row({ space: 10 }) {
        Text('取消').fontSize(12).fontColor(COLORS.sub)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.chip)
          .borderRadius(10)
          .onClick(() => { onClose(); })

        Text('开始运动').fontSize(12).fontColor(COLORS.bg)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.green)
          .borderRadius(10)
          .onClick(() => {
            if (this.editIdx >= 0 && this.currentTab === 0 && this.editIdx < this.workoutList.length) {
              this.workoutList[this.editIdx].done = true;
            }
            this.editIdx = -1;
            onClose();
          })
      }
      .width('100%')
    }
    .width('80%')
    .padding(20)
    .backgroundColor(COLORS.card)
    .borderRadius(16)
  }
  .width('100%')
  .height('100%')
  .alignContent(Alignment.Center)
}

"开始运动"弹窗是整个平台最复杂的弹窗——它需要根据 this.currentTabthis.editIdx 的值判断当前操作的来源(首页运动清单、课程列表或商城商品),并展示对应的信息卡片。

弹窗使用 Stack 叠加遮罩层和内容卡片,内容卡片宽度 80%、圆角 16px、内边距 20px。信息卡片区域使用三段 if-else if 条件分支:当 currentTab === 0 时从 workoutList 读取运动项信息;当 currentTab === 1 时从 courseList 读取课程信息;当 currentTab === 4 时从 shopList 读取商品信息。每个分支都进行了索引边界检查(this.editIdx < this.xxxList.length),防止数组越界。

确认按钮的 onClick 回调中包含了一段状态修改逻辑:当操作来源是首页运动清单(currentTab === 0)时,将对应运动项的 done 属性设置为 true。由于 WorkoutItem@Observed 类且 workoutList@State 数组,这个修改会触发首页运动清单的 UI 更新——该运动项的名称会变为暗色,状态标签会从"待打卡"变为"✓ 已完成"。修改完成后,重置 this.editIdx = -1 并关闭弹窗。

16.3 设置运动目标弹窗

@Builder
panelEdit(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)

    Column({ space: 14 }) {
      Text('🎯 设置运动目标').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Column({ space: 6 }) {
        Text('每日卡路里目标').fontSize(11).fontColor(COLORS.sub)
        Row({ space: 8 }) {
          Text('300').fontSize(10).fontColor(COLORS.text3)
            .padding({ left: 10, right: 10, top: 8, bottom: 8 })
            .backgroundColor(COLORS.chip).borderRadius(8)
          Text('500').fontSize(10).fontColor(COLORS.bg)
            .backgroundColor(COLORS.green).borderRadius(8)
            .padding({ left: 10, right: 10, top: 8, bottom: 8 })
          Text('800').fontSize(10).fontColor(COLORS.text3)
            .padding({ left: 10, right: 10, top: 8, bottom: 8 })
            .backgroundColor(COLORS.chip).borderRadius(8)
        }
      }

      Column({ space: 6 }) {
        Text('每周运动天数').fontSize(11).fontColor(COLORS.sub)
        Row({ space: 8 }) {
          Text('3 天').fontSize(10).fontColor(COLORS.text3)
            .padding({ left: 10, right: 10, top: 8, bottom: 8 })
            .backgroundColor(COLORS.chip).borderRadius(8)
          Text('5 天').fontSize(10).fontColor(COLORS.bg)
            .backgroundColor(COLORS.orange).borderRadius(8)
            .padding({ left: 10, right: 10, top: 8, bottom: 8 })
          Text('7 天').fontSize(10).fontColor(COLORS.text3)
            .padding({ left: 10, right: 10, top: 8, bottom: 8 })
            .backgroundColor(COLORS.chip).borderRadius(8)
        }
      }

      Row({ space: 10 }) {
        Text('取消').fontSize(12).fontColor(COLORS.sub)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.chip)
          .borderRadius(10)
          .onClick(() => { onClose(); })

        Text('保存目标').fontSize(12).fontColor(COLORS.bg)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.green)
          .borderRadius(10)
          .onClick(() => { onClose(); })
      }
      .width('100%')
    }
    .width('85%')
    .padding(20)
    .backgroundColor(COLORS.card)
    .borderRadius(16)
  }
  .width('100%')
  .height('100%')
  .alignContent(Alignment.Center)
}

“设置运动目标"弹窗提供了两组选择器:每日卡路里目标(300/500/800 卡)和每周运动天数(3/5/7 天)。每个选项使用胶囊样式——选中项使用主题色背景加深色文字,未选中项使用 chip 色背景加暗色文字。当前默认选中"500 卡"和"5 天”,分别使用绿色和橙色背景表示选中状态。

虽然当前实现中选项的点击事件仅关闭弹窗,但选择器的视觉设计已经为后续接入真实的目标设置功能做好了准备。在实际应用中,每个选项应绑定 onClick 事件来更新选中的目标值,并在"保存目标"按钮的回调中将设置持久化到本地存储或后端。

16.4 删除记录确认弹窗

@Builder
panelDel(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)

    Column({ space: 14 }) {
      Text('⚠️ 删除记录').fontSize(16).fontColor(COLORS.red).fontWeight(FontWeight.Bold)

      Text('确定要删除这条运动记录吗?删除后无法恢复。').fontSize(11).fontColor(COLORS.sub).textAlign(TextAlign.Center)

      Row({ space: 10 }) {
        Text('再想想').fontSize(12).fontColor(COLORS.sub)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.chip)
          .borderRadius(10)
          .onClick(() => { onClose(); })

        Text('确认删除').fontSize(12).fontColor(COLORS.card)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.red)
          .borderRadius(10)
          .onClick(() => {
            this.delIdx = -1;
            onClose();
          })
      }
      .width('100%')
    }
    .width('75%')
    .padding(20)
    .backgroundColor(COLORS.card)
    .borderRadius(16)
  }
  .width('100%')
  .height('100%')
  .alignContent(Alignment.Center)
}

"删除记录"弹窗是三段式确认对话框的经典实现。标题使用红色(COLORS.red)和警告图标(⚠️),明确传达了操作的严重性。描述文本"确定要删除这条运动记录吗?删除后无法恢复"进一步强调不可逆性。

两个按钮采用了对比色设计:取消按钮"再想想"使用 chip 色背景和 sub 色文字——这种弱视觉表现降低了用户的误点击概率;确认按钮"确认删除"使用红色背景和白色文字——强烈的视觉警告确保用户在点击前充分意识到后果。按钮的文本也经过精心设计——"再想想"比"取消"更口语化,传达了系统对用户决定的尊重;"确认删除"比"确定"更明确,减少了操作歧义。

addModal=true

editModal=true

delModal=true

Tab 0 首页

Tab 1 课程

Tab 4 商城

弹窗系统触发

弹窗类型判断

panelAdd 开始运动弹窗

panelEdit 设置目标弹窗

panelDel 删除确认弹窗

currentTab判断

展示运动项信息

展示课程信息

展示商品信息

确认 → workoutList.done=true

editIdx重置为-1
关闭弹窗

选择卡路里目标
300/500/800

选择运动天数
3/5/7天

保存目标 → 关闭弹窗

再想想 → 关闭弹窗

确认删除 → delIdx重置
关闭弹窗


十七、架构总结与技术对比

17.1 整体架构流程

弹窗系统

页面组件

辅助函数

数据模型

颜色系统

ColorPalette 接口

COLORS 常量实例

WorkoutItem 运动项

CourseItem 课程

FeedItem 社区动态

ShopItem 商品

Achievement 成就

diffColor 难度映射

typeColor 类型映射

heatColor 热力映射

headerFit 头部

tabHome 首页

tabCourse 课程

tabRecord 记录

tabFeed 动态

tabShop 商城

tabMine 我的

chartCard 图表

tabBar 导航

modalOverlay 遮罩

panelAdd 开始运动

panelEdit 设置目标

panelDel 删除确认

17.2 功能模块对比表

模块布局方式核心数据模型颜色策略交互特点状态管理视觉重点
头部区域Column 垂直堆叠静态常量绿/橙/金三色大数字呼吸动画+搜索入口breath 布尔翻转大数字数据展示
首页 TabColumn 卡片堆叠WorkoutItem[]绿色为主+进度条点击运动项弹窗workoutList.done概览+清单双卡
课程 TabScroll 横向滚动CourseItem[]交替绿/橙底色点击卡片弹窗editIdx 索引横滑大卡+难度色带
记录 TabRow 七列网格静态常量数组heatColor 四档梯度仅展示无交互无额外状态热力图可视化
动态 TabRow 双列瀑布流FeedItem[] 左右分配typeColor 类型着色点赞/评论入口feedList 数据绑定瀑布流社区卡片
商城 TabColumn 垂直列表ShopItem[]交替绿/橙图标底点击购买弹窗editIdx 索引清单行+价格突出
我的 TabColumn 卡片+FlexAchievement[]渐变背景+灰化成就解锁预览achieveList.got等级渐变大卡
图表卡片Row 六列柱状静态常量数组当前月橙色高亮仅展示无交互无额外状态柱状图趋势
底部导航Row 六列等分TAB_LIST[]激活态 tabOn 色点击切换标签页currentTab图标+文字双态
开始运动弹窗Stack 遮罩+居中多源数据读取greenL 信息底确认后标记完成editIdx+addModal多条件分支展示
设置目标弹窗Stack 遮罩+居中静态选项选中态主题色选项切换+保存editModal胶囊选择器
删除确认弹窗Stack 遮罩+居中无数据红色警告双按钮确认delModal+delIdx危险操作确认

17.3 技术亮点对比

技术维度实现方式设计优势适用场景潜在优化
颜色管理ColorPalette 接口 + COLORS 常量类型安全、一处修改全局生效所有需要颜色统一的项目可扩展为运行时主题切换
数据响应@Observed + @State细粒度更新、自动追踪属性变化需要实时数据驱动的应用大数据量时需注意性能
条件渲染if-else 分支切换 Tab内存占用最小、切换时卸载旧内容Tab 页面较少的应用Tab 较多时可考虑懒加载
弹窗系统Stack 叠加+回调函数复用遮罩层、灵活的关闭策略需要多种弹窗的应用可抽象为通用 Modal 组件
热力图heatColor 函数四档映射直观展示运动强度分布运动记录、活动统计可扩展为动态阈值
瀑布流左右双列 Column+layoutWeight简单高效、无需第三方库社区动态、图片展示不支持不等高自动排列
呼吸动画setInterval + opacity实现简单、无需动画框架轻量级视觉反馈可替换为 animateTo 更流畅
进度条双 Column 叠加百分比宽度纯 CSS 实现、无额外组件打卡进度、等级进度可添加动画过渡效果

十八、深度总结

从整体架构来看,该健身运动平台的代码实现体现了 HarmonyOS ArkUI 声明式 UI 范式的核心优势。通过 @Entry@Component@Builder@State@Observed 等装饰器的组合使用,实现了数据与视图的深度绑定——开发者只需关注数据的变化逻辑,UI 的更新由框架自动完成。这种设计哲学在运动健康领域尤为重要,因为运动数据(步数、卡路里、心率等)天然具有高频更新的特性,声明式 UI 能够优雅地处理这种频繁的数据流变化。

深色主题的色彩设计是该平台的另一个显著特色。背景色 #0D1B14 选用了极深的墨绿色而非纯黑色,这在运动场景中具有实际的生理学考量——纯黑背景在户外强光环境下会造成更严重的屏幕反光,而略带绿色调的深色背景能够在保持低亮度的同时提供更好的对比度和可读性。主色调活力绿 #4ADE80 在深色背景上具有极强的视觉穿透力,能够有效吸引用户注意力到关键数据上。辅助色能量橙 #FB923C 与绿色形成互补关系,用于强调卡路里等需要"热度感"的数据,这种色彩心理学上的互补设计使界面在视觉上既统一又有层次。

六个标签页的差异化布局设计是该平台最突出的交互特色。首页采用数据概览大卡+运动清单的双卡布局,强调数据聚合和任务管理;课程页采用横向滚动大卡,突出课程内容的丰富性和可选择性;记录页采用七列热力网格,直观展示一周运动强度分布;动态页采用双列瀑布流,营造社区氛围和社交感;商城页采用垂直清单行,突出商品信息和购买引导;我的页采用渐变大卡+成就四宫格,强化用户成就感和等级标识。这种"每页不同"的设计策略避免了界面同质化,使每个标签页都有独特的视觉记忆点。

弹窗系统的多源数据读取机制是一个值得深入分析的技术实现。单个 panelAdd 弹窗通过检查 this.currentTabthis.editIdx 两个状态变量,能够从三个不同的数据源(workoutListcourseListshopList)中读取对应项的信息并展示。这种"一个弹窗服务多个数据源"的设计减少了代码重复,但也增加了条件分支的复杂度。在更复杂的场景中,可以考虑使用策略模式或映射表来替代多段 if-else 分支,提升代码的可维护性。

呼吸动画的实现方式体现了 ArkUI 在轻量级动画方面的灵活性。通过 setInterval 每秒翻转 breath 布尔值,配合 opacity 属性的条件绑定,实现了一种简单的脉冲效果。虽然这种方式不如 animateToanimation 装饰器那样平滑,但它足够简单、可靠,且不需要额外的动画 API 知识。在实际项目中,可以将这种 setInterval 方式替换为 ArkUI 的属性动画 API,以获得更流畅的过渡效果和更好的性能表现。

热力图的可视化设计借鉴了 GitHub 贡献热力图的成功经验——通过颜色深浅直观展示活动强度分布。heatColor 函数的四档映射(休息/轻度/中等/高强度)基于运动科学的卡路里消耗标准设定阈值,使颜色编码不仅具有视觉意义,还具有实际的运动指导价值。这种将数据可视化与领域知识结合的设计思路,值得在其他数据密集型应用中借鉴。

成就系统的游戏化设计是该平台用户留存策略的重要组成部分。八项成就覆盖了连续性、强度、累积量、频次、多样性和组合性六个维度,满足了不同类型用户的成就动机。未获得徽章的灰化处理(opacity: 0.3)是游戏化设计中经典的"解锁预览"模式——用户可以看到所有可获得的成就但无法完全感知,这种"可见但未获得"的心理张力是驱动用户持续使用应用的有效动力。

从工程实践的角度来看,该代码的结构化程度较高——颜色系统、数据模型、辅助函数、UI 组件和弹窗系统各有明确的职责边界,修改某一部分通常不会影响其他部分。但也存在一些可改进之处:例如 typeColor 函数在处理包含额外描述的运动项目文本(如"跑步 10km")时无法精确匹配,可以考虑使用 includes 方法进行子串匹配;又如瀑布流布局使用固定数组分配(FEED_LEFTFEED_RIGHT),无法支持动态添加的动态内容,可以考虑在运行时根据内容高度动态分配左右列。

总体而言,该健身运动平台的代码实现展示了 ArkUI 声明式 UI 在运动健康领域的深度应用——从深色主题的色彩策略到六种差异化布局,从热力图可视化到游戏化成就系统,每个模块都体现了对运动场景和用户心理的深入理解。这种将技术实现与领域知识紧密结合的开发方式,为 HarmonyOS 生态中的行业应用开发提供了有价值的参考范例。

附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型说明
应用(Application)开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service)开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项示例值说明
项目名称(Project name)rollboat应用的项目名称,建议使用英文命名
包名(Bundle name)com.rollboat.myapplication应用唯一标识,采用反向域名格式
保存路径(Save location)D:\CodeFactory\rollboat项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK)6.1.1(24)目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name)entry主模块名称,默认 entry 为应用入口模块
设备类型(Device types)☑ Phone勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法作用
@Entry标记为页面入口,可用于路由跳转
@Component声明为自定义组件
@State状态变量,数据变更时自动触发 UI 刷新
RelativeContainer相对布局容器,替代传统线性布局
.onClick()点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称阶段状态
HarmonyOS 6.1.1Release✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本SDK 版本号阶段状态
API Version 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤操作关键点
创建项目欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成使用 Stage 模型 + ArkTS 语言
查看 SDK设置 → HarmonyOS SDKSDK 已内置,无需手动安装
跨平台扩展设置 → ArkUI-X根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

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

更多推荐