ArkUI 深色能量风布局实战:健身运动行业·多场景训练助手·七 Tab 异构布局·Canvas 数据可视化全解析
ArkUI 深色能量风布局实战:健身运动行业·多场景训练助手·七 Tab 异构布局·Canvas 数据可视化全解析
引言:当运动健身遇上声明式 UI 框架
在移动互联网与健康产业深度融合的今天,健身运动类应用已经成为各大应用商店中最活跃的品类之一。从 Keep 到 Nike Training Club,从 Peloton 到 Apple Fitness+,每一款成功的健身应用背后,都离不开一套精心设计的用户界面与交互体系。而在这个赛道中,如何用技术手段构建一个既美观又实用的健身训练助手,是每一位前端/移动端开发者都需要深入思考的课题。
本文将以一个名为「燃力营」的智能健身训练助手为案例,从架构设计、颜色系统、数据模型、布局策略、Canvas 图表绘制、弹窗交互等多个维度,逐段拆解 ArkUI 声明式 UI 框架在复杂行业场景中的实战应用。整个应用采用深色主题搭配能量绿与活力橙的双色点缀,营造出运动场景特有的活力感与专注感。
技术背景概述
ArkUI 是 HarmonyOS/OpenHarmony 生态中的声明式 UI 开发框架,采用 ArkTS 语言(TypeScript 的超集),通过 @Entry、@Component、@State、@Builder、@Observed 等装饰器实现了声明式的状态驱动渲染。与 React 的 JSX 不同,ArkUI 使用链式调用的方式构建组件树,每一个 UI 元素都通过 Text().fontSize().fontColor() 这样的链式 API 进行属性配置,形成了一种独特的"声明式链式编程"范式。
在移动健身应用场景中,ArkUI 的声明式特性带来了显著的开发效率优势:状态变化自动触发 UI 刷新,开发者只需关注数据流与状态管理,无需手动操作 DOM 节点。同时,ArkUI 原生提供的 Canvas 组件支持 2D 绘图上下文,可以绘制进度环、折线图、雷达图等复杂数据可视化图表,为运动数据展示提供了强大的底层能力。
应用整体架构概览
在深入代码之前,我们先用一张架构流程图来展示整个应用的结构设计:
从架构图可以看出,整个应用采用「单页面 + 多 Tab」的架构模式,通过 currentTab 状态变量驱动条件渲染,在同一个 Scroll 容器中切换七个完全不同的布局页面。底部 Tab 栏分为两行(4+3 布局),每个 Tab 对应一种独立的布局风格,避免了千篇一律的列表式展示。
下面,我们将按照代码的自然结构顺序,逐段进行深度解析。
一、颜色系统设计:深色能量主题的视觉基石

1.1 ColorPalette 接口定义
interface ColorPalette {
bg: string;
card: string;
chip: string;
dark: string;
title: string;
sub: string;
text3: string;
green: string;
greenD: string;
orange: string;
orangeD: string;
red: string;
blue: string;
purple: string;
gold: string;
line: string;
tabOn: string;
mask: string;
}
这是整个应用的颜色管理接口。在大型应用开发中,颜色管理是一个常被低估但极其重要的工程环节。这里通过 interface 定义了一个包含 18 个颜色字段的结构体,覆盖了从背景到文字、从主色到辅色、从分割线到遮罩的全场景色彩需求。
逐字段来看:
bg(背景色):应用的全局背景,采用极深的墨绿色调,为深色主题奠定基础。card(卡片色):各功能卡片的背景色,比bg稍浅,通过微妙的色差形成层次感。chip(标签色):用于标签、按钮背景等小面积色块,是 UI 中出现频率最高的中间色。dark(深色):比card更深的色调,用于特殊卡片或渐变背景。title(标题色):主文字颜色,采用接近白色的浅绿色,保证深色背景下的可读性。sub(副标题色):次级文字颜色,饱和度较低的灰绿色。text3(三级文字色):辅助说明文字颜色,用于最不重要的文字信息。green/greenD(能量绿及其深色变体):主题色之一,代表运动活力与能量消耗。orange/orangeD(活力橙及其深色变体):主题色之二,代表热情与目标进度。red(警示红):用于删除、警告等高危操作。blue/purple/gold(蓝/紫/金):功能性辅色,分别用于不同等级标签与排名标识。line(分割线色):极暗的绿色,用于分隔不同区域。tabOn(Tab 激活色):底部 Tab 栏选中状态的颜色,复用能量绿。mask(遮罩色):弹窗背景遮罩,使用 rgba 半透明格式实现毛玻璃效果。
1.2 颜色常量实例

const COLORS: ColorPalette = {
bg: '#0B1512',
card: '#12211B',
chip: '#1A2E26',
dark: '#0E1A15',
title: '#E9FFF5',
sub: '#9CC9B5',
text3: '#63857A',
green: '#34E39B',
greenD: '#1E9E6C',
orange: '#FF9E45',
orangeD: '#C9711E',
red: '#FF6B6B',
blue: '#4FC3F7',
purple: '#B388FF',
gold: '#FFD54F',
line: '#1E362D',
tabOn: '#34E39B',
mask: 'rgba(4,10,8,0.66)',
};
这段代码将颜色接口实例化为全局常量 COLORS。值得注意的是,颜色值的选择并非随意而为,而是遵循了一套精心设计的色彩学逻辑。
背景色层次分析:从 bg(#0B1512)到 dark(#0E1A15)再到 card(#12211B)再到 chip(#1A2E26),这四个色值在 RGB 空间中呈现阶梯式递增的关系。以绿色通道为例,分别为 0x15、0x1A、0x21、0x2E,形成了从深到浅的四级灰阶。这种设计确保了不同层级之间的视觉区分度足够清晰,同时又不会因为色差过大而产生割裂感。
主题色搭配策略:能量绿 #34E39B 和活力橙 #FF9E45 是一对典型的互补色组合。在 HSL 色彩空间中,绿色位于 150° 附近,橙色位于 30° 附近,两者色相差约 120°,属于"分裂互补色"关系。这种搭配既能保证视觉冲击力,又不会像正互补色(180°)那样产生强烈的视觉对抗。在健身场景中,绿色代表"健康"和"能量",橙色代表"热情"和"目标",两者结合完美呼应了运动健身的核心理念。
文字可读性保障:title 色值 #E9FFF5 是带有极浅绿色调的近白色,其亮度值(Lightness)约为 95%,而背景 bg 的亮度约为 7%,对比度远超 WCAG 2.1 AAA 标准要求的 7:1。副标题 sub 的亮度约为 68%,三级文字 text3 约为 47%,形成清晰的视觉层级。
二、常量定义与导航配置

2.1 Tab 元数据接口与数据
interface TabMeta {
icon: string;
label: string;
}
const TAB_ROW1: TabMeta[] = [
{ icon: '🏋️', label: '训练' },
{ icon: '📀', label: '课程' },
{ icon: '🥗', label: '饮食' },
{ icon: '📅', label: '计划' },
];
const TAB_ROW2: TabMeta[] = [
{ icon: '📊', label: '数据' },
{ icon: '👥', label: '社群' },
{ icon: '👤', label: '我的' },
];
这里定义了底部导航栏的数据结构。TabMeta 接口包含 icon(图标)和 label(标签文本)两个字段,采用 Emoji 作为图标,这是一种轻量级的图标方案。
Tab 栏被分为两行:第一行四个 Tab(训练、课程、饮食、计划),第二行三个 Tab(数据、社群、我的)。这种 4+3 的布局设计在移动端并不常见,但在这个健身应用中却有其合理性——七个功能模块如果排成一行,每个 Tab 的可点击区域将不足 50px,严重影响触控体验;分成两行后,每个 Tab 的宽度可达 90px 以上,显著提升了可用性。
2.2 快捷训练入口配置

interface EntryMeta {
icon: string;
label: string;
}
const TRAIN_ENTRY: EntryMeta[] = [
{ icon: '🔥', label: '今日训练' },
{ icon: '🏃', label: '自由跑' },
{ icon: '📏', label: '体测' },
{ icon: '🛒', label: '装备' },
];
这组常量定义了头部区域的四个快捷入口。EntryMeta 与 TabMeta 结构相同,但语义不同——这里定义的是"快捷操作入口"而非"导航 Tab"。复用相同的数据结构既减少了类型定义的冗余,又保持了代码的简洁性。四个入口分别对应:今日训练(跳转到训练详情)、自由跑(开启自由跑步模式)、体测(进入体测记录页面)、装备(查看装备列表),覆盖了用户最常用的四项操作。
2.3 图表数据常量

// 月度消耗柱状图(6 个月)
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const KCAL_VAL: number[] = [8600, 9200, 11300, 12800, 10400, 13600];
// 心率折线(一节课 12 个采样点)
const LINE_LABELS: string[] = ['0', '5', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
const LINE_DATA: number[] = [88, 102, 118, 132, 145, 138, 126, 134, 142, 128, 112, 96];
// 体能雷达(6 维)
const RADAR_LABELS: string[] = ['心肺', '力量', '柔韧', '耐力', '爆发', '恢复'];
const RADAR_VALUES: number[] = [0.82, 0.74, 0.58, 0.88, 0.7, 0.66];
// 今日目标进度环
const RING_PROGRESS: number = 0.72;
const RING_TARGET: string = '900 kcal';
这段代码定义了所有 Canvas 图表所需的静态数据。分为四组:
月度消耗数据:包含 6 个月的热量消耗值,用于底部柱状图展示。数据从 8600 kcal 逐步增长到 13600 kcal,呈现整体上升趋势,其中 08 月为峰值(13600 kcal),将用橙色高亮显示。
心率折线数据:模拟了一节课 55 分钟内的 12 个心率采样点,从 88 bpm 起步,在 20 分钟达到峰值 145 bpm,随后逐渐回落。这组数据典型地反映了有氧训练中的心率变化曲线——热身阶段平稳上升、高强度阶段接近最大心率、放松阶段逐步恢复。
体能雷达数据:六个维度分别为心肺、力量、柔韧、耐力、爆发、恢复,数值归一化为 0-1 之间。从数据可以看出,该用户的耐力最强(0.88),柔韧最弱(0.58),这是一个典型的力量型训练者的体能特征。
进度环数据:今日目标完成率 72%,目标 900 kcal,已消耗 648 kcal。
三、辅助函数:状态色彩映射的集中管理
3.1 计划状态颜色函数
function planStatusColor(s: string): string {
if (s === '已完成') return COLORS.green;
if (s === '进行中') return COLORS.orange;
return COLORS.text3;
}
这是一个典型的"状态到颜色"映射函数。在健身应用的计划模块中,训练计划有三种状态:已完成(绿色)、进行中(橙色)、待开始(灰色)。通过集中管理颜色映射逻辑,避免了在各处硬编码颜色值,当需要调整配色方案时只需修改一处。
函数采用简洁的 if-return 链式判断,而非 switch-case 或对象查找表,在分支数量较少(3 个)时这是最优写法——既保持了可读性,又不会引入不必要的复杂度。
3.2 难度等级颜色函数
function levelColor(l: string): string {
if (l === '入门') return COLORS.blue;
if (l === '进阶') return COLORS.orange;
if (l === '高级') return COLORS.red;
return COLORS.text3;
}
与上一个函数类似,这里将训练难度映射为颜色:入门(蓝色,温和)、进阶(橙色,渐强)、高级(红色,警示)。颜色的选择暗含了心理学层面的考量——蓝色给人安全感,适合初学者;橙色代表挑战,适合进阶用户;红色则传递出危险信号,提醒高级训练需要谨慎。
这两个辅助函数虽然简单,但体现了"单一职责原则"(SRP)的工程实践——每个函数只做一件事,将颜色映射逻辑从 UI 渲染中解耦出来。
四、数据模型设计:@Observed 装饰器与响应式数据
4.1 训练课程模型
@Observed export class TrainCourse {
name: string;
type: string;
kcal: number;
mins: number;
level: string;
constructor(name: string, type: string, kcal: number, mins: number, level: string) {
this.name = name;
this.type = type;
this.kcal = kcal;
this.mins = number;
this.level = level;
}
}
const COURSE_LIST: TrainCourse[] = [
new TrainCourse('HIIT 极速燃脂', '有氧燃脂', 480, 30, '高级'),
new TrainCourse('动感单车夜光课', '有氧耐力', 380, 45, '进阶'),
new TrainCourse('普拉提核心塑形', '塑形拉伸', 210, 40, '入门'),
new TrainCourse('杠铃力量推举', '力量增肌', 320, 50, '高级'),
new TrainCourse('战绳爆发训练', '爆发冲刺', 400, 25, '进阶'),
new TrainCourse('阴瑜伽深度放松', '拉伸恢复', 120, 60, '入门'),
new TrainCourse('拳击格斗体验', '格斗体能', 450, 40, '进阶'),
];
@Observed 是 ArkUI 的可观察对象装饰器,被它修饰的类实例在属性变化时可以触发绑定该实例的 UI 组件刷新。export 关键字使得该类可以在其他文件中被引用。
TrainCourse 模型包含五个字段:课程名称、课程类型、消耗热量、训练时长、难度等级。构造函数采用传统的参数赋值方式,每个字段在构造时被初始化。
COURSE_LIST 常量包含了 7 个课程实例,覆盖了有氧燃脂、有氧耐力、塑形拉伸、力量增肌、爆发冲刺、拉伸恢复、格斗体能等多种训练类型。这些数据不仅用于训练 Tab 的横滑卡片展示,也是整个应用训练推荐的核心数据源。
4.2 课程排名模型
@Observed export class CourseItem {
name: string;
coach: string;
hot: string;
tag: string;
constructor(name: string, coach: string, hot: string, tag: string) {
this.name = name;
this.coach = coach;
this.hot = hot;
this.tag = tag;
}
}
const RANK_LIST: CourseItem[] = [
new CourseItem('帕梅拉全身燃脂', '帕梅拉', '286万', '免跟练'),
new CourseItem('晨间唤醒瑜伽', '林岚', '192万', '新人友好'),
new CourseItem('腹肌撕裂者X', '陈锋', '165万', '高强度'),
new CourseItem('蜜桃臀养成计划', '苏晴', '138万', '塑形'),
new CourseItem('跑者力量训练', '阿凯', '96万', '备赛'),
new CourseItem('久坐族肩颈放松', '林岚', '88万', '办公室'),
];
CourseItem 模型用于课程排行榜展示,包含课程名称、教练姓名、热度(人气值)和标签。六条数据按照热度从高到低排列,排名第一的"帕梅拉全身燃脂"拥有 286 万练习人次。
值得注意的是 hot 字段使用 string 类型而非 number,这是因为热度值包含了"万"这个单位,直接以字符串形式存储避免了在前端进行数字到带单位字符串的格式化转换。这种设计在数据量不大、不需要数值计算的场景下是合理的选择。
4.3 饮食餐品模型
@Observed export class MealItem {
thumb: string;
name: string;
kcal: string;
protein: string;
time: string;
constructor(thumb: string, name: string, kcal: string, protein: string, time: string) {
this.thumb = thumb;
this.name = name;
this.kcal = kcal;
this.protein = protein;
this.time = time;
}
}
const MEAL_LIST: MealItem[] = [
new MealItem('🥗', '鸡胸肉牛油果沙拉', '386 kcal', '蛋白质 32g', '午餐'),
new MealItem('🥣', '燕麦蓝莓酸奶杯', '295 kcal', '蛋白质 14g', '早餐'),
new MealItem('🍚', '牛肉杂粮能量碗', '512 kcal', '蛋白质 38g', '晚餐'),
new MealItem('🥛', '蛋白粉香蕉奶昔', '240 kcal', '蛋白质 26g', '练后'),
new MealItem('🐟', '三文鱼藜麦便当', '468 kcal', '蛋白质 34g', '午餐'),
new MealItem('🍞', '水煮蛋全麦吐司', '320 kcal', '蛋白质 18g', '早餐'),
];
MealItem 模型用于饮食 Tab 的双列卡片展示。thumb 字段使用 Emoji 作为食物缩略图(如 🥗 代表沙拉、🍚 代表米饭),这是一种在资源受限场景下的轻量级替代方案。六条数据覆盖了早餐、午餐、晚餐和练后加餐四个用餐时段,每条数据包含热量和蛋白质含量两项关键营养指标。
4.4 训练计划模型
@Observed export class PlanItem {
date: string;
title: string;
status: string;
note: string;
constructor(date: string, title: string, status: string, note: string) {
this.date = date;
this.title = title;
this.status = status;
this.note = note;
}
}
const PLAN_LIST: PlanItem[] = [
new PlanItem('周一 07:00', '胸背超级组', '已完成', '卧推 4x12 · 划船 4x10'),
new PlanItem('周一 19:30', '夜跑 5km', '已完成', '配速 5\'42" · 心率 148'),
new PlanItem('周二 07:30', '腿臀力量日', '进行中', '深蹲 5x8 · 硬拉 4x6'),
new PlanItem('周三 12:20', '午间肩颈拉伸', '待开始', '15 分钟办公室跟练'),
new PlanItem('周五 18:00', 'HIIT 燃脂课', '待开始', '提前 10 分钟到场热身'),
new PlanItem('周日 09:00', '长距离慢跑 10km', '待开始', '心率控制在 150 以下'),
];
PlanItem 模型用于训练计划 Tab 的时间轴展示,包含日期时间、标题、状态和备注四个字段。六条数据构成了一周的训练安排,涵盖了力量训练、有氧跑步、拉伸放松和 HIIT 等多种训练形式。
note 字段的备注信息非常详细,例如"卧推 4x12 · 划船 4x10"表示卧推 4 组每组 12 次、划船 4 组每组 10 次,“配速 5’42” · 心率 148"则记录了跑步的配速和平均心率。这些信息为训练者提供了完整的训练日志,也是健身追踪类应用的核心价值所在。
4.5 体重记录与社区帖子模型
@Observed export class WeightItem {
date: string;
weight: string;
bodyFat: string;
constructor(date: string, weight: string, bodyFat: string) {
this.date = date;
this.weight = weight;
this.bodyFat = bodyFat;
}
}
const WEIGHT_LIST: WeightItem[] = [
new WeightItem('08-01', '74.2 kg', '18.6%'),
new WeightItem('08-08', '73.6 kg', '18.1%'),
new WeightItem('08-15', '73.1 kg', '17.7%'),
new WeightItem('08-20', '72.8 kg', '17.4%'),
new WeightItem('08-23', '72.5 kg', '17.2%'),
];
WeightItem 模型用于体重与体脂数据的记录展示。五条数据跨越了 8 月份的 23 天,体重从 74.2 kg 降至 72.5 kg(减少 1.7 kg),体脂率从 18.6% 降至 17.2%(降低 1.4 个百分点)。这种渐进式的变化趋势能够给予训练者持续的正向反馈。
@Observed export class PostItem {
user: string;
content: string;
likes: string;
tag: string;
time: string;
constructor(user: string, content: string, likes: string, tag: string, time: string) {
this.user = user;
this.content = content;
this.likes = likes;
this.tag = tag;
this.time = time;
}
}
const POST_LIST: PostItem[] = [
new PostItem('铁腿小王', '深蹲 PR 120kg 达成!三个月从 60kg 练上来,腿是真的酸爽', '328', '打卡', '12分钟前'),
new PostItem('跑者阿岚', '晨跑 8km 配速 5\'30",今天状态意外地好,体感很轻', '156', '跑步', '1小时前'),
new PostItem('减脂中的琳', '体重秤终于下 60 了,三个月 -7.6kg,感谢坚持的自己', '512', '变化', '3小时前'),
new PostItem('力量派老张', '卧推 100kg x5 完成,腰背全程紧绷无不适,姿势对了真不一样', '240', '力量', '昨天'),
new PostItem('瑜伽喵喵', '30 天瑜伽挑战 Day21,下犬式终于不抖了,肩膀也打开了', '187', '挑战', '昨天'),
new PostItem('燃力营官方', '本周训练榜已更新,快看看你排在第几名 →', '893', '官方', '2天前'),
];
PostItem 模型用于社群 Tab 的动态流展示,包含用户名、内容、点赞数、标签和发布时间。六条帖子覆盖了打卡、跑步、减脂变化、力量训练、瑜伽挑战和官方公告等多种内容类型,模拟了一个活跃的健身社区氛围。
4.6 个人资料模型
@Observed export class ProfileItem {
icon: string;
label: string;
value: string;
constructor(icon: string, label: string, value: string) {
this.icon = icon;
this.label = label;
this.value = value;
}
}
const PROFILE_LIST: ProfileItem[] = [
new ProfileItem('🏅', '运动等级', '健身达人 Lv.6'),
new ProfileItem('🔥', '连续打卡', '128 天'),
new ProfileItem('💳', '会员权益', '至 2027-03-15'),
new ProfileItem('⚙️', '训练偏好', '增肌 · 每周 4 练'),
new ProfileItem('📦', '我的装备', '6 件'),
];
ProfileItem 模型用于"我的" Tab 的清单行展示。五条数据分别对应用户的运动等级、打卡天数、会员权益、训练偏好和装备数量,构成了一个完整的用户画像。
上图展示了整个数据模型体系的结构——七个模型各自独立,又通过 @Observed 装饰器统一纳入 ArkUI 的响应式系统,当数据变化时自动驱动对应的 UI 组件刷新。
五、组件主体:状态管理与生命周期
5.1 状态变量声明
@Entry
@Component
struct Page1001 {
// --- Tab 状态 ---
@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 addName: string = '';
@State addMins: string = '';
@State addLevel: string = '入门';
// --- 动画状态 ---
@State breath: boolean = false;
timer: number = -1;
// --- Canvas 抗锯齿开关 ---
@State antialiasOn: boolean = true;
@Entry 装饰器标记 Page1001 为应用的入口组件,@Component 声明它是一个 ArkUI 组件。组件内部通过 @State 声明了多组状态变量:
Tab 状态:currentTab 是整个应用的核心状态,它的值(0-6)决定了当前展示哪个 Tab 页面。初始值为 0,即默认展示训练页。
弹窗状态:三个布尔变量(addModal、editModal、delModal)分别控制新增、编辑、删除三个弹窗的显示与隐藏。两个索引变量(editIdx、delIdx)记录当前操作的数据项索引,初始值 -1 表示未选中任何项。这种"状态驱动弹窗"的设计模式确保了弹窗的显示完全由数据控制,避免了命令式 UI 中常见的"状态不一致"问题。
弹窗表单:addName、addMins、addLevel 分别存储新增计划表单中的训练名称、时长和难度。addLevel 默认值为"入门",体现了对初学者的友好设计。
动画状态:breath 是一个布尔型开关变量,配合定时器实现"呼吸动画"效果——每秒切换一次 true/false,通过绑定到 UI 组件的 opacity 属性实现透明度的周期变化。timer 变量存储定时器 ID,用于在组件销毁时清除定时器,防止内存泄漏。
Canvas 抗锯齿开关:antialiasOn 控制三个 Canvas 图表的抗锯齿效果,这是一个独特的设计——允许用户实时切换抗锯齿开关来对比渲染效果,这在教学和演示场景中非常实用。
5.2 数据数组状态
// --- 数据数组 ---
@State courseList: TrainCourse[] = COURSE_LIST;
@State rankList: CourseItem[] = RANK_LIST;
@State mealList: MealItem[] = MEAL_LIST;
@State planList: PlanItem[] = PLAN_LIST;
@State postList: PostItem[] = POST_LIST;
这里将之前定义的常量数组赋值给 @State 变量,使其成为响应式数据。当这些数组的内容发生变化(如新增、删除、修改元素)时,绑定的 UI 组件会自动刷新。
值得注意的设计决策是:WEIGHT_LIST 和 PROFILE_LIST 没有被声明为 @State,因为它们在运行时不需要修改——体重记录和个人资料是只读展示数据。这种"按需响应式"的设计避免了不必要的状态追踪开销。
5.3 Canvas 上下文初始化
// --- Canvas 上下文(private,Canvas 自管理渲染)---
private ringCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
private lineCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
private radarCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
三个 CanvasRenderingContext2D 实例分别用于进度环、折线图和雷达图的绘制。RenderingContextSettings(true) 中的 true 参数表示启用抗锯齿。
这些上下文被声明为 private(非 @State),因为它们不需要触发 UI 刷新——Canvas 组件的渲染由自身的 onReady 回调和手动调用的绘制方法驱动,与 ArkUI 的声明式状态系统是解耦的。这是 ArkUI 中 Canvas 使用的一个关键设计原则:Canvas 的绘制逻辑是命令式的,通过直接调用 ctx.fillRect()、ctx.arc() 等方法进行渲染,而非通过声明式状态驱动。
5.4 生命周期方法
aboutToAppear() {
this.timer = setInterval(() => {
this.breath = !this.breath;
this.drawRing();
}, 1000);
}
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear() 是 ArkUI 组件的生命周期钩子,在组件即将出现在屏幕上时调用。这里设置了一个每秒触发一次的定时器,在回调中做两件事:切换 breath 状态(驱动呼吸动画)和调用 drawRing() 重绘进度环。
aboutToDisappear() 在组件即将从屏幕上消失时调用,这里清除定时器。这是一个至关重要的操作——如果不在组件销毁时清除定时器,定时器会继续在后台运行,不仅浪费 CPU 资源,还可能因为尝试访问已销毁组件的状态而引发错误。
5.5 抗锯齿切换方法
toggleAntialias() {
this.antialiasOn = !this.antialiasOn;
this.ringCtx.antialias = this.antialiasOn;
this.lineCtx.antialias = this.antialiasOn;
this.radarCtx.antialias = this.antialiasOn;
this.drawRing();
this.drawLine();
this.drawRadar();
}
toggleAntialias() 方法实现了抗锯齿开关的切换逻辑:先翻转 antialiasOn 布尔值,然后同步更新三个 Canvas 上下文的 antialias 属性,最后调用三个绘制方法重绘所有图表。这个方法展示了 ArkUI 中状态与命令式绘图的协作模式——状态变化后,需要手动触发绘制函数来更新 Canvas 内容。
六、build 方法:根布局的架构设计
6.1 整体布局结构
build() {
Stack() {
Column() {
this.headerGym()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabTrain()
} else if (this.currentTab === 1) {
this.tabCourse()
} else if (this.currentTab === 2) {
this.tabMeal()
} else if (this.currentTab === 3) {
this.tabPlan()
} else if (this.currentTab === 4) {
this.tabData()
} else if (this.currentTab === 5) {
this.tabCommunity()
} else {
this.tabMine()
}
this.chartCard()
}
.padding({ left: 14, right: 14, top: 14, bottom: 18 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
this.tabBar()
}
.width('100%')
.height('100%')
if (this.addModal) {
this.panelAdd(() => {
this.addModal = false;
})
}
if (this.editModal) {
this.panelEdit(() => {
this.editModal = false;
})
}
if (this.delModal) {
this.panelDel(() => {
this.delModal = false;
})
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
.alignContent(Alignment.Center)
}
这是整个应用的根布局方法。从外到内分为三层:
第一层:Stack——最外层使用 Stack 堆叠容器,将主内容与弹窗层叠在一起。alignContent(Alignment.Center) 使得弹窗(如删除确认弹窗)居中显示。背景色设为 COLORS.bg,确保整个屏幕被深色背景覆盖。
第二层:Column——主内容列容器,从上到下依次排列:头部区域(headerGym)、分割线(Divider)、可滚动内容区(Scroll)和底部 Tab 栏(tabBar)。
第三层:Scroll——滚动内容区使用 Scroll 组件包裹一个 Column,内部通过 if-else 条件渲染当前 Tab 对应的内容。layoutWeight(1) 让滚动区域占据头部和 Tab 栏之间的所有剩余空间。scrollBar(BarState.Off) 隐藏了滚动条,保持界面的简洁感。
条件渲染策略:currentTab 的值决定了渲染哪个 @Builder 方法的内容。这种 if-else 链式条件渲染是 ArkUI 中实现 Tab 切换的标准方式——每次 currentTab 变化时,ArkUI 会自动移除旧 Tab 的 UI 树并构建新 Tab 的 UI 树。由于每次只渲染一个 Tab 的内容,这种方式在性能上优于同时渲染所有 Tab 并通过 visibility 控制显隐的方案。
弹窗渲染:三个弹窗(新增、编辑、删除)通过独立的 if 条件渲染在 Stack 的顶层,确保它们覆盖在主内容之上。每个弹窗接收一个回调函数 onClose,在弹窗内部需要关闭时调用。这种"回调函数注入"的设计模式将弹窗的关闭逻辑交给了调用方,保持了弹窗组件的通用性。
下面是整个布局渲染的流程图:
七、头部区域:数据展示与快捷入口
7.1 头部布局分析
@Builder
headerGym() {
Column({ space: 12 }) {
Row() {
Column({ space: 2 }) {
Text('燃力营').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('今日已燃 · keep burning').fontSize(10).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Row({ space: 6 }) {
Circle().width(8).height(8).fill(COLORS.orange)
Text('128 天').fontSize(12).fontColor(COLORS.orange)
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.chip)
.borderRadius(12)
}
.width('100%')
@Builder 装饰器声明了一个可复用的 UI 构建方法。headerGym 构建了应用的头部区域,分为三个层次:
第一层:品牌信息行——左侧是应用名称"燃力营"和副标题"今日已燃 · keep burning",右侧是打卡天数标签。打卡标签使用 Circle 组件绘制了一个 8px 的橙色圆点作为指示灯,搭配"128 天"文字,封装在 chip 背景色的圆角胶囊中。中间的 Column().layoutWeight(1) 是一个弹性占位符,将左右内容推到两端。
Row({ space: 14 }) {
Column({ space: 2 }) {
Text('648').fontSize(34).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
.opacity(this.breath ? 1 : 0.72)
Text('今日消耗 kcal').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column().width(1).height(38).backgroundColor(COLORS.line)
Column({ space: 2 }) {
Text('46 分').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('训练时长').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column().width(1).height(38).backgroundColor(COLORS.line)
Column({ space: 2 }) {
Text('72%').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
Text('目标完成').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('+').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
.width(34).height(34).textAlign(TextAlign.Center)
.backgroundColor(COLORS.chip).borderRadius(17)
.onClick(() => {
this.addModal = true;
})
}
.width('100%')
第二层:核心数据行——这是头部最重要的区域,展示了三组核心运动数据:
- 今日消耗 648 kcal:使用 34px 超大字号和能量绿色,是整个头部最醒目的元素。
opacity(this.breath ? 1 : 0.72)绑定了呼吸动画状态,使数字在 100% 和 72% 透明度之间周期切换,营造出"能量跳动"的视觉效果。 - 训练时长 46 分:使用 20px 字号和标题色,作为次要数据展示。
- 目标完成 72%:使用 20px 字号和活力橙色,与能量绿形成对比,提示目标完成进度。
三组数据之间用 1px 宽、38px 高的竖线(Column().width(1).height(38).backgroundColor(COLORS.line))分隔,形成了清晰的视觉边界。
最右侧是一个 34x34 的圆形"+“按钮,点击后设置 addModal = true 打开新增计划弹窗。这个按钮的设计简洁而高效——用 borderRadius(17) 将正方形变为圆形(宽度的一半即为半径),用 textAlign(TextAlign.Center) 使”+"号居中。
Row() {
ForEach(TRAIN_ENTRY, (e: EntryMeta) => {
Column({ space: 6 }) {
Text(e.icon).fontSize(20)
Text(e.label).fontSize(10).fontColor(COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.chip)
.borderRadius(12)
}, (e: EntryMeta) => e.label)
}
.width('100%')
}
.width('100%')
.padding({ left: 14, right: 14, top: 14, bottom: 14 })
.backgroundColor(COLORS.card)
}
第三层:快捷入口宫格——使用 ForEach 遍历 TRAIN_ENTRY 数组,生成四个等宽的快捷入口按钮。每个入口由 Emoji 图标(20px)和标签文字(10px)组成,垂直排列在 chip 背景色的圆角卡片中。layoutWeight(1) 确保四个入口均匀分布,间距由 Row 容器的默认布局决定。
整个头部区域使用 COLORS.card 作为背景色,与全局 COLORS.bg 形成微妙的色差层次。内边距统一为 14px,保证了内容与屏幕边缘的舒适间距。
八、训练 Tab:横向滑动卡片与预约入口
8.1 训练推荐标题区
@Builder
tabTrain() {
Column({ space: 12 }) {
Row() {
Text('🔥 为你推荐').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('换一批').fontSize(11).fontColor(COLORS.text3)
}
.width('100%')
训练 Tab 的顶部是标题行,左侧"🔥 为你推荐"用 15px 加粗字体,右侧"换一批"用 11px 三级文字色,暗示这是一个可刷新的推荐列表。中间的弹性占位符将两端内容推开。
8.2 横滑训练卡片
Scroll() {
Row({ space: 12 }) {
ForEach(this.courseList, (c: TrainCourse) => {
Column({ space: 8 }) {
Text(c.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${c.type} · ${c.mins} 分钟`).fontSize(11).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text(`${c.kcal} kcal`).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
Text(c.level).fontSize(10).fontColor(levelColor(c.level))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(COLORS.chip).borderRadius(8)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width(210)
.alignItems(HorizontalAlign.Start)
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(14)
}, (c: TrainCourse) => c.name)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
这是训练 Tab 的核心区域——横向滑动的训练课程卡片列表。Scroll 组件设置了 scrollable(ScrollDirection.Horizontal) 实现水平滚动,scrollBar(BarState.Off) 隐藏滚动条。
每张卡片固定宽度 210px,内部从上到下依次展示:
- 课程名称:15px 加粗白色,
maxLines(1)限制为单行,textOverflow({ overflow: TextOverflow.Ellipsis })在超长时显示省略号。 - 课程类型与时长:11px 副标题色,使用模板字符串拼接。
- 热量与难度:底部使用
Row横向排列,热量值用橙色加粗,难度标签使用levelColor()函数动态着色。justifyContent(FlexAlign.SpaceBetween)将两者分别推到左右两端。
ForEach 的第三个参数 (c: TrainCourse) => c.name 是键值生成函数,使用课程名称作为列表项的唯一标识。这在 ArkUI 中是必要的——它帮助框架高效地识别列表项的增删改操作,避免不必要的全量重绘。
8.3 今日课程预约卡
Row({ space: 8 }) {
Text('🏋️').fontSize(24)
Column({ space: 2 }) {
Text('今日课程:腿臀力量日 07:30').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('教练 苏晴 · 剩余 3 个名额').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('去预约').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.dark)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(COLORS.green).borderRadius(14)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(14)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
训练 Tab 底部是一个固定的"今日课程预约"卡片,左侧是 24px 的健身 Emoji 图标,中间是课程信息(标题 + 教练/名额),右侧是"去预约"按钮。
这个按钮的设计有一个巧妙之处——文字颜色使用 COLORS.dark(深色),背景使用 COLORS.green(能量绿),与卡片中其他元素的颜色方案形成了反转。这种"反转色"按钮在 UI 设计中常用于强调主要操作,引导用户点击。
九、课程 Tab:排行榜式布局
@Builder
tabCourse() {
Column({ space: 12 }) {
Row() {
Text('🏆 热门课程榜').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('本周更新').fontSize(11).fontColor(COLORS.text3)
}
.width('100%')
ForEach(this.rankList, (r: CourseItem, i: number) => {
Row({ space: 12 }) {
Text(i < 9 ? `0${i + 1}` : `${i + 1}`)
.fontSize(i < 3 ? 26 : 20)
.fontWeight(FontWeight.Bold)
.fontColor(i === 0 ? COLORS.gold : (i === 1 ? COLORS.blue : (i === 2 ? COLORS.purple : COLORS.text3)))
.width(44)
.textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(r.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`教练 ${r.coach} · ${r.hot} 人练过`).fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(r.tag).fontSize(10).fontColor(COLORS.green)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(8)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
}, (r: CourseItem) => r.name)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
课程 Tab 采用"大编号排行榜"布局,这是健身应用中展示热门课程的经典模式。ForEach 遍历 rankList,第二个参数 (r: CourseItem, i: number) 中的 i 是当前项的索引,用于生成排名序号。
排名序号的渲染逻辑有三层设计:
- 格式化:
i < 9 ?0i+1‘:‘{i + 1}` : `i+1‘:‘{i + 1}``——前 9 名使用"01"-"09"的两位数格式,第 10 名及以后使用原始数字,保持视觉对齐。 - 字号差异:
fontSize(i < 3 ? 26 : 20)——前三名使用 26px 大字号,第四名及以后使用 20px 标准字号,通过字号差异突出前三名的特殊地位。 - 颜色差异:第一名金色、第二名蓝色、第三名紫色、其余灰色——这是经典的"金银铜"配色方案的变体,用金/蓝/紫替代了传统的金/银/铜,与整体深色主题更加协调。
每条数据的右侧是一个标签(如"免跟练"、“新人友好”),使用能量绿文字配 chip 背景。这些标签为用户提供了快速的课程筛选参考。
十、饮食 Tab:双列网格卡片
@Builder
tabMeal() {
Column({ space: 12 }) {
Row() {
Text('🥗 今日食谱').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('已摄入 2216 kcal').fontSize(11).fontColor(COLORS.sub)
}
.width('100%')
Grid() {
ForEach(this.mealList, (m: MealItem) => {
GridItem() {
Column({ space: 6 }) {
Text(m.thumb).fontSize(26)
Text(m.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(m.kcal).fontSize(11).fontColor(COLORS.orange)
Text(`${m.protein} · ${m.time}`).fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
}, (m: MealItem) => m.name)
}
.columnsTemplate('1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.width('100%')
.height(372)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
饮食 Tab 使用 Grid 组件实现双列网格布局。columnsTemplate('1fr 1fr') 定义了两列等宽的网格模板,columnsGap(10) 和 rowsGap(10) 分别设置列间距和行间距为 10px。height(372) 固定了网格区域的高度,确保三行双列的 6 个卡片能够完整展示。
每个 GridItem 内部的卡片包含四层信息:
- 食物缩略图:26px 的 Emoji 图标。
- 食物名称:12px 加粗白色,单行省略。
- 热量值:11px 橙色,如"386 kcal"。
- 蛋白质与餐次:9px 三级文字色,如"蛋白质 32g · 午餐"。
头部右侧显示"已摄入 2216 kcal",这是一个实时统计值,为用户提供了当日总热量摄入的快速参考。
十一、计划 Tab:状态时间轴布局
11.1 时间轴结构与交互
@Builder
tabPlan() {
Column({ space: 10 }) {
Row() {
Text('📅 本周计划').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('+ 新增').fontSize(12).fontColor(COLORS.green)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.chip).borderRadius(12)
.onClick(() => {
this.addModal = true;
})
}
.width('100%')
计划 Tab 的头部包含一个"+ 新增"按钮,点击后打开新增计划弹窗。这个按钮的设计与头部区域的"+"按钮功能相同,提供了多个入口点进入新增流程,符合"用户随时可能需要新增计划"的使用场景。
ForEach(this.planList, (p: PlanItem, i: number) => {
Row({ space: 10 }) {
Column() {
Circle().width(10).height(10).fill(planStatusColor(p.status))
if (i < this.planList.length - 1) {
Column().width(2).layoutWeight(1).backgroundColor(COLORS.line).margin({ top: 4, bottom: 4 })
}
}
.width(14)
.alignItems(HorizontalAlign.Center)
这是时间轴的核心结构——左侧的"节点+连线"列。每个计划项的左侧是一个 Column,包含:
- 状态圆点:10px 的
Circle,颜色由planStatusColor()函数根据状态动态返回(绿色/橙色/灰色)。 - 连接线:2px 宽的
Column,使用layoutWeight(1)填充剩余高度。最后一个项(i < this.planList.length - 1)不渲染连接线,形成时间轴的"终点"效果。
Column({ space: 6 }) {
Row() {
Text(p.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(p.status).fontSize(10).fontColor(planStatusColor(p.status))
}
.width('100%')
Text(p.date).fontSize(10).fontColor(COLORS.sub)
Text(p.note).fontSize(10).fontColor(COLORS.text3)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text('编辑').fontSize(10).fontColor(COLORS.blue)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.chip).borderRadius(10)
.onClick(() => {
this.editIdx = i;
this.editModal = true;
})
Text('删除').fontSize(10).fontColor(COLORS.red)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.chip).borderRadius(10)
.onClick(() => {
this.delIdx = i;
this.delModal = true;
})
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
}, (p: PlanItem) => `${p.date}-${p.title}`)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
右侧是计划详情卡片,包含四层信息:
- 标题行:标题(13px 加粗白色,单行省略)和状态标签(10px,颜色随状态变化)横向排列。
- 日期时间:10px 副标题色。
- 备注:10px 三级文字色,单行省略。
- 操作按钮:“编辑”(蓝色)和"删除"(红色)两个小按钮,点击分别设置
editIdx/delIdx为当前索引并打开对应的弹窗。
时间轴布局是计划管理场景的最佳实践——它将时间序列信息以视觉化的方式呈现,用户可以直观地看到计划的先后顺序和完成进度。状态圆点的颜色编码让用户一眼就能识别哪些计划已完成、哪些正在进行、哪些还未开始。
十二、数据 Tab:Canvas 图表三连击
12.1 数据 Tab 概览
@Builder
tabData() {
Column({ space: 12 }) {
Row() {
Text('📊 训练数据').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(this.antialiasOn ? '抗锯齿 开' : '抗锯齿 关').fontSize(10).fontColor(COLORS.green)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.chip).borderRadius(12)
.onClick(() => {
this.toggleAntialias();
})
}
.width('100%')
数据 Tab 的头部右侧有一个独特的"抗锯齿开关"按钮,文字根据 antialiasOn 状态动态切换"开"/“关”,点击调用 toggleAntialias() 方法。这个功能允许用户实时对比抗锯齿开启和关闭时的 Canvas 渲染效果差异。
12.2 进度环 Canvas
// 进度环:今日目标
Column({ space: 8 }) {
Row() {
Text('⭕ 今日目标完成率').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(`目标 ${RING_TARGET}`).fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Canvas(this.ringCtx)
.width('100%')
.height(200)
.onReady(() => {
this.drawRing();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
这是进度环的容器结构。Canvas(this.ringCtx) 将之前创建的 ringCtx 上下文绑定到 Canvas 组件,.onReady() 回调在 Canvas 准备就绪后调用 this.drawRing() 进行首次绘制。容器高度 200px,背景色为 card,圆角 12px。
12.3 折线图 Canvas
// 折线:单课心率曲线
Column({ space: 8 }) {
Row() {
Text('📈 单课心率曲线').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('峰值 145 bpm').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Canvas(this.lineCtx)
.width('100%')
.height(180)
.onReady(() => {
this.drawLine();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
心率折线图的容器结构与进度环类似,高度 180px。头部右侧标注了"峰值 145 bpm",这是从 LINE_DATA 数组中提取的最大值。
12.4 雷达图 Canvas
// 雷达:体能六维评估
Column({ space: 8 }) {
Row() {
Text('🕸️ 体能六维评估').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('综合 B+').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Canvas(this.radarCtx)
.width('100%')
.height(220)
.onReady(() => {
this.drawRadar();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
雷达图容器高度 220px,是三个 Canvas 中最高的,因为六维雷达图需要更多的垂直空间来展示六个维度的数据。
12.5 体重记录清单
// 体重记录清单
Row() {
Text('⚖️ 体重与体脂').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('近 4 周 -1.7kg').fontSize(9).fontColor(COLORS.green)
}
.width('100%')
ForEach(WEIGHT_LIST, (w: WeightItem) => {
Row() {
Text(w.date).fontSize(11).fontColor(COLORS.sub).width(56)
Text(w.weight).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title).layoutWeight(1)
Text(w.bodyFat).fontSize(11).fontColor(COLORS.orange)
}
.width('100%')
.padding({ top: 10, bottom: 10, left: 12, right: 12 })
.backgroundColor(COLORS.card)
.borderRadius(10)
}, (w: WeightItem) => w.date)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
数据 Tab 底部是体重与体脂的记录清单,每行三列:日期(56px 固定宽度)、体重(弹性宽度,13px 加粗白色)、体脂率(11px 橙色)。头部右侧标注了"近 4 周 -1.7kg",提供了体重变化的趋势总结。
下图展示了数据 Tab 中三种 Canvas 图表的数据渲染流程:
十三、社群 Tab:左色条列表布局
@Builder
tabCommunity() {
Column({ space: 10 }) {
Row() {
Text('👥 燃力社群').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('12.8万人在练').fontSize(11).fontColor(COLORS.text3)
}
.width('100%')
ForEach(this.postList, (p: PostItem, i: number) => {
Row({ space: 0 }) {
Column()
.width(4)
.height(64)
.backgroundColor(i % 3 === 0 ? COLORS.green : (i % 3 === 1 ? COLORS.orange : COLORS.purple))
.borderRadius(2)
Column({ space: 5 }) {
Row() {
Text(p.user).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(p.tag).fontSize(9).fontColor(COLORS.blue)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.chip).borderRadius(6)
Column().layoutWeight(1)
Text(p.time).fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Text(p.content).fontSize(11).fontColor(COLORS.sub)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`❤ ${p.likes}`).fontSize(10).fontColor(COLORS.red)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10, right: 12, top: 10, bottom: 10 })
.backgroundColor(COLORS.card)
.borderRadius({ topLeft: 0, topRight: 12, bottomLeft: 0, bottomRight: 12 })
}
.width('100%')
}, (p: PostItem) => `${p.user}-${p.time}`)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
社群 Tab 采用"左色条列表"布局,这是社交动态流的一种变体设计。每条动态左侧有一个 4px 宽、64px 高的色条,颜色按照 i % 3 的结果循环切换:绿色、橙色、紫色。这种设计有两个作用:
- 视觉节奏感:三种颜色的循环交替打破了单色列表的单调感,为信息流注入了活力。
- 内容分类暗示:虽然这里的颜色是按位置循环的,但在实际应用中可以扩展为按内容类型着色——绿色代表打卡、橙色代表成就、紫色代表挑战等。
每条动态的卡片右侧采用非对称圆角设计:borderRadius({ topLeft: 0, topRight: 12, bottomLeft: 0, bottomRight: 12 })——左上和左下圆角为 0(与色条衔接),右上和右下圆角为 12px。这种设计使色条与卡片在视觉上融为一体,形成了一个统一的"L 形"组件。
动态内容包含三层信息:
- 用户信息行:用户名(12px 加粗)、标签(9px 蓝色胶囊)、弹性占位、时间(9px 三级文字)。
- 内容正文:11px 副标题色,最多 2 行,超出省略。
- 点赞数:10px 红色,前缀"❤"图标。
十四、我的 Tab:渐变大数字卡与清单行
14.1 渐变大数字卡片
@Builder
tabMine() {
Column({ space: 12 }) {
// 渐变大数字卡
Column({ space: 8 }) {
Text('连续打卡').fontSize(11).fontColor(COLORS.sub)
Text('128').fontSize(52).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
.opacity(this.breath ? 1 : 0.8)
Text('天 · 健身达人 Lv.6').fontSize(12).fontColor(COLORS.title)
Row({ space: 18 }) {
Column({ space: 2 }) {
Text('412h').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
Text('累计时长').fontSize(9).fontColor(COLORS.text3)
}
Column({ space: 2 }) {
Text('86240').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
Text('累计 kcal').fontSize(9).fontColor(COLORS.text3)
}
Column({ space: 2 }) {
Text('36').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
Text('勋章').fontSize(9).fontColor(COLORS.text3)
}
}
.margin({ top: 6 })
}
.width('100%')
.padding(20)
.backgroundColor(COLORS.dark)
.borderRadius(16)
.linearGradient({
angle: 140,
colors: [[COLORS.greenD, 0.0], [COLORS.dark, 0.55]]
})
“我的” Tab 的顶部是一个渐变大数字卡片,这是整个应用中最具视觉冲击力的设计元素。
渐变背景:linearGradient 属性设置了一个 140° 角的线性渐变,从 COLORS.greenD(深绿色,0% 位置)渐变到 COLORS.dark(深暗色,55% 位置)。140° 的角度使渐变方向从左下到右上,营造出"能量从底部升起"的视觉效果。
大数字:"128"使用 52px 超大字号和活力橙色,是整个应用中最大的文字元素。opacity(this.breath ? 1 : 0.8) 绑定了呼吸动画,使数字在 100% 和 80% 透明度之间缓慢变化,配合 52px 的大字号,形成了强烈的"心跳"视觉效果。
统计数据行:大数字下方是三组统计数据——累计时长 412h、累计热量 86240 kcal、勋章 36 个。每组数据使用 14px 加粗能量绿色,配以 9px 三级文字色的标签,与大数字形成大小对比。
14.2 清单行
// 清单行
ForEach(PROFILE_LIST, (p: ProfileItem) => {
Row() {
Text(p.icon).fontSize(16)
Text(p.label).fontSize(12).fontColor(COLORS.sub).margin({ left: 10 }).layoutWeight(1)
Text(p.value).fontSize(12).fontColor(COLORS.title)
Text('›').fontSize(16).fontColor(COLORS.text3).margin({ left: 8 })
}
.width('100%')
.padding({ top: 13, bottom: 13, left: 14, right: 14 })
.backgroundColor(COLORS.card)
.borderRadius(12)
}, (p: ProfileItem) => p.label)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
清单行是设置页面的经典布局模式——左侧图标 + 标签名 + 右侧值 + 箭头指示符。每行包含四部分:Emoji 图标(16px)、标签名(12px 副标题色,弹性宽度)、值(12px 标题色)、右箭头"›"(16px 三级文字色)。行高通过 padding({ top: 13, bottom: 13 }) 控制为约 42px,保证了足够的触控区域。
十五、月度趋势图:柱状图卡片
@Builder
chartCard() {
Column({ space: 12 }) {
Row() {
Text('📊 月度消耗趋势').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('单位 kcal · 近 6 个月').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Row({ space: 10 }) {
ForEach(MONTH_NAME, (m: string, i: number) => {
Column({ space: 6 }) {
Column()
.width(26)
.height(40 + KCAL_VAL[i] / 13600 * 68 + (this.breath ? 3 : 0))
.backgroundColor(KCAL_VAL[i] === 13600 ? COLORS.orange : COLORS.greenD)
.borderRadius({ topLeft: 6, topRight: 6 })
.opacity(this.breath ? 1 : 0.82)
Text(m).fontSize(9).fontColor(COLORS.text3)
}
.layoutWeight(1)
}, (m: string) => m)
}
.width('100%')
Row() {
Row({ space: 6 }) {
Column().width(10).height(10).backgroundColor(COLORS.orange).borderRadius(3)
Text('峰值月').fontSize(10).fontColor(COLORS.sub)
}
Row({ space: 6 }).margin({ left: 16 }) {
Column().width(10).height(10).backgroundColor(COLORS.greenD).borderRadius(3)
Text('常规月').fontSize(10).fontColor(COLORS.sub)
}
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ top: 12 })
}
chartCard 是一个独立于 Tab 切换的通用组件,它在所有 Tab 页面的底部都可见。这个柱状图使用 ArkUI 的原生组件(而非 Canvas)实现,展示了 6 个月的热量消耗趋势。
柱子高度计算:height(40 + KCAL_VAL[i] / 13600 * 68 + (this.breath ? 3 : 0))。这个公式将数据值映射为像素高度:基础高度 40px + 数据占比 × 68px(最大增量),加上呼吸动画的 3px 偏移。最大值 13600 kcal 对应 108px 高度,最小值 8600 kcal 对应约 83px 高度。
柱子颜色区分:KCAL_VAL[i] === 13600 ? COLORS.orange : COLORS.greenD——只有最大值(峰值月)使用橙色,其余月份使用深绿色。这种"单色高亮"策略让用户一眼就能识别出表现最好的月份。
图例:底部居中显示了两色图例——橙色"峰值月"和深绿色"常规月",帮助用户理解柱子颜色的含义。justifyContent(FlexAlign.Center) 使图例水平居中。
十六、底部 Tab 栏:双行导航设计
16.1 Tab 项构建
@Builder
tabItem(t: TabMeta, idx: number) {
Column({ space: 3 }) {
Text(t.icon).fontSize(19).opacity(idx === this.currentTab ? 1 : 0.5)
Text(t.label).fontSize(9)
.fontColor(idx === this.currentTab ? COLORS.tabOn : COLORS.text3)
.fontWeight(idx === this.currentTab ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.currentTab = idx;
})
}
tabItem 是单个 Tab 项的构建方法,接收两个参数:Tab 元数据 t 和索引 idx。每个 Tab 项由 Emoji 图标(19px)和标签文字(9px)组成,垂直排列。
选中状态视觉差异通过三重编码实现:
- 透明度:选中时图标 opacity 为 1,未选中为 0.5——这是最弱的视觉区分。
- 颜色:选中时文字使用
COLORS.tabOn(能量绿),未选中使用COLORS.text3(灰色)——这是最主要的区分。 - 字重:选中时加粗,未选中正常——这是最精细的区分。
三重编码确保了即使在高亮环境下,用户也能准确识别当前所在的 Tab。
点击事件设置 this.currentTab = idx,触发 ArkUI 的状态更新和条件渲染,自动切换到对应的 Tab 页面。
16.2 Tab 栏容器
@Builder
tabBar() {
Column({ space: 2 }) {
Row() {
ForEach(TAB_ROW1, (t: TabMeta, i: number) => {
this.tabItem(t, i)
}, (t: TabMeta) => t.label)
}
.width('100%')
Row() {
ForEach(TAB_ROW2, (t: TabMeta, i: number) => {
this.tabItem(t, i + 4)
}, (t: TabMeta) => t.label)
}
.width('100%')
}
.width('100%')
.padding({ top: 4, bottom: 6 })
.backgroundColor(COLORS.card)
}
Tab 栏容器分为两行,分别遍历 TAB_ROW1(前 4 个 Tab)和 TAB_ROW2(后 3 个 Tab)。第二行的索引从 4 开始(i + 4),确保与 currentTab 状态值的映射正确。整个 Tab 栏使用 COLORS.card 作为背景色,与主内容区形成微妙的层次分离。
十七、弹窗系统:遮罩层与三种面板
17.1 遮罩层
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => onClose())
}
modalOverlay 是所有弹窗的通用遮罩层。它是一个全屏的 Column,背景色为 COLORS.mask(半透明深色),点击时调用 onClose 回调关闭弹窗。这种"点击外部关闭"的交互模式是移动端弹窗的标准设计,提供了便捷的关闭方式。
17.2 新增计划弹窗
@Builder
panelAdd(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('+ 新增训练计划').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column({ space: 6 }) {
Text('训练名称').fontSize(11).fontColor(COLORS.sub)
TextInput({ placeholder: '如:核心平板支撑' })
.fontSize(13)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.borderRadius(10)
.onChange((v: string) => {
this.addName = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
新增弹窗从底部滑出(alignContent(Alignment.Bottom)),包含三个表单字段:
- 训练名称:
TextInput文本输入框,placeholder 为"如:核心平板支撑",onChange回调将输入值同步到addName状态。 - 时长(分钟):同样使用
TextInput,输入值同步到addMins状态。 - 难度:使用三个
Text标签作为单选按钮组,点击切换addLevel状态。选中项使用绿色背景配深色文字,未选中项使用chip背景配副标题色文字。
Row({ space: 10 }) {
Button('取消')
.fontSize(13)
.fontColor(COLORS.sub)
.backgroundColor(COLORS.chip)
.borderRadius(14)
.layoutWeight(1)
.onClick(() => onClose())
Button('保存')
.fontSize(13)
.fontColor(COLORS.dark)
.backgroundColor(COLORS.green)
.borderRadius(14)
.layoutWeight(1)
.onClick(() => {
const name: string = this.addName === '' ? '自由训练' : this.addName;
const mins: string = this.addMins === '' ? '30' : this.addMins;
this.planList.push(new PlanItem('今天', name, '待开始', `${mins} 分钟 · ${this.addLevel}`));
this.addName = '';
this.addMins = '';
this.addLevel = '入门';
this.addModal = false;
})
}
.width('100%')
.margin({ top: 4 })
}
.width('100%')
.padding(18)
.backgroundColor(COLORS.card)
.borderRadius({ topLeft: 18, topRight: 18 })
}
.width('100%')
.height('100%')
.alignContent(Alignment.Bottom)
}
保存按钮的点击逻辑展示了表单提交的完整流程:
- 默认值处理:名称为空时默认"自由训练",时长为空时默认"30"分钟。
- 数据追加:创建新的
PlanItem实例并push到planList数组。 - 表单重置:清空所有输入字段,难度重置为"入门"。
- 关闭弹窗:设置
addModal = false。
17.3 编辑状态弹窗
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('编辑计划状态').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(this.editIdx >= 0 ? this.planList[this.editIdx].title : '')
.fontSize(12)
.fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
ForEach(['已完成', '进行中', '待开始'], (s: string) => {
Text(s).fontSize(12).fontColor(planStatusColor(s))
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(COLORS.chip)
.borderRadius(14)
.onClick(() => {
if (this.editIdx >= 0) {
const p = this.planList[this.editIdx];
this.planList[this.editIdx] = new PlanItem(p.date, p.title, s, p.note);
}
this.editModal = false;
})
}, (s: string) => s)
}
Button('关闭')
.fontSize(13)
.fontColor(COLORS.sub)
.backgroundColor(COLORS.chip)
.borderRadius(14)
.width('100%')
.onClick(() => onClose())
}
.width('100%')
.padding(18)
.backgroundColor(COLORS.card)
.borderRadius({ topLeft: 18, topRight: 18 })
}
.width('100%')
.height('100%')
.alignContent(Alignment.Bottom)
}
编辑弹窗从底部滑出,显示当前编辑项的标题,并提供三个状态选项(已完成、进行中、待开始)。每个选项的颜色由 planStatusColor() 动态着色,点击后创建一个新的 PlanItem 实例替换原数组中的对应项,然后关闭弹窗。
这里采用了"创建新实例替换旧实例"的更新方式,而非直接修改对象属性。这是因为 @Observed 装饰器在检测数组变化时,对于"替换整个元素"的识别比"修改元素属性"更可靠。这是一个 ArkUI 响应式系统的重要实现细节。
17.4 删除确认弹窗
@Builder
panelDel(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 14 }) {
Text('⚠️ 删除计划').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.red)
Text(this.delIdx >= 0 ? `确定删除「${this.planList[this.delIdx].title}」吗?` : '')
.fontSize(12)
.fontColor(COLORS.sub)
Row({ space: 10 }) {
Button('再想想')
.fontSize(13)
.fontColor(COLORS.sub)
.backgroundColor(COLORS.chip)
.borderRadius(14)
.layoutWeight(1)
.onClick(() => onClose())
Button('确认删除')
.fontSize(13)
.fontColor(COLORS.title)
.backgroundColor(COLORS.red)
.borderRadius(14)
.layoutWeight(1)
.onClick(() => {
if (this.delIdx >= 0) {
this.planList.splice(this.delIdx, 1);
}
this.delModal = false;
})
}
.width('100%')
}
.width('72%')
.padding(18)
.backgroundColor(COLORS.card)
.borderRadius(16)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
删除弹窗与新增、编辑弹窗不同——它居中显示(alignContent(Alignment.Center)),宽度为 72%,形成一个居中的确认对话框。这种设计差异是有意为之:新增和编辑是常规操作,底部滑出更符合移动端的操作习惯;删除是高危操作,居中显示更能引起用户注意。
删除确认按钮使用红色背景,与标题的红色警告图标呼应。点击"确认删除"后,调用 this.planList.splice(this.delIdx, 1) 从数组中移除对应项,然后关闭弹窗。
十八、Canvas 绘制方法:命令式绘图的深度实践
18.1 进度环绘制
drawRing() {
const ctx = this.ringCtx;
ctx.antialias = this.antialiasOn;
const cx = ctx.width / 2;
const cy = 100;
const r = 66;
const breathVal = this.breath ? 1 : 0.92;
ctx.clearRect(0, 0, ctx.width, 200);
// 背景环
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = COLORS.chip;
ctx.lineWidth = 14;
ctx.stroke();
// 进度弧
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * RING_PROGRESS * breathVal);
ctx.strokeStyle = COLORS.green;
ctx.lineWidth = 14;
ctx.lineCap = 'round';
ctx.stroke();
// 刻度点缀
ctx.beginPath();
ctx.arc(cx, cy - r, 5, 0, Math.PI * 2);
ctx.fillStyle = COLORS.orange;
ctx.fill();
// 中心文字
ctx.fillStyle = COLORS.title;
ctx.font = 'bold 26px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${Math.round(RING_PROGRESS * 100)}%`, cx, cy + 4);
ctx.font = '10px sans-serif';
ctx.fillStyle = COLORS.text3;
ctx.fillText(`目标 ${RING_TARGET}`, cx, cy + 24);
}
drawRing() 方法绘制今日目标完成率的进度环。绘制过程分为五个步骤:
步骤 1:清空画布。ctx.clearRect(0, 0, ctx.width, 200) 清除整个 Canvas 区域,为后续绘制准备干净的画布。每次绘制前清空是 Canvas 编程的基本准则,避免新旧帧叠加。
步骤 2:绘制背景环。使用 ctx.arc(cx, cy, r, 0, Math.PI * 2) 绘制一个完整的圆环(0 到 2π),线条宽度 14px,颜色为 COLORS.chip。这个背景环是进度弧的"轨道"。
步骤 3:绘制进度弧。关键在于角度计算——-Math.PI / 2 是起始角度(12 点钟方向),-Math.PI / 2 + Math.PI * 2 * RING_PROGRESS * breathVal 是结束角度。RING_PROGRESS 为 0.72,breathVal 在 0.92-1.0 之间变化(呼吸动画),因此进度弧的角度范围约为 230°-250°。lineCap = 'round' 使弧线两端呈圆头,与背景环的平头形成对比。
步骤 4:绘制刻度点缀。在圆环顶部(12 点钟位置,即 cy - r)绘制一个半径 5px 的橙色实心圆点。这个点标记了进度的"起点",在视觉上形成了一个醒目的"起点标记"。
步骤 5:绘制中心文字。在圆心位置绘制百分比文字(26px 加粗)和目标值(10px),textAlign = 'center' 确保文字水平居中。文字的 y 坐标经过微调——百分比在 cy + 4(略偏下以补偿基线偏差),目标值在 cy + 24(百分比下方 20px)。
18.2 心率折线图绘制
drawLine() {
const ctx = this.lineCtx;
ctx.antialias = this.antialiasOn;
const w = ctx.width;
const h = 180;
const pad = 28;
const max = 160;
const stepX = (w - pad * 2) / (LINE_DATA.length - 1);
ctx.clearRect(0, 0, w, h);
drawLine() 方法绘制心率折线图。首先计算布局参数:w 和 h 是画布宽高,pad 是内边距(28px),max 是心率最大值参考(160 bpm,略高于数据中的最大值 145),stepX 是每个数据点之间的水平间距。
// 背景网格
ctx.strokeStyle = COLORS.line;
ctx.lineWidth = 1;
for (let i = 0; i <= 3; i++) {
const y = pad + (h - pad * 2 - 20) * i / 3;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(w - pad, y);
ctx.stroke();
}
绘制 4 条水平网格线,均匀分布在画布的绘图区域内。网格线颜色为极暗的 COLORS.line,不干扰数据展示但提供刻度参考。
// 渐变填充区域
const grad = ctx.createLinearGradient(0, pad, 0, h - pad - 20);
grad.addColorStop(0, COLORS.orange);
grad.addColorStop(1, COLORS.bg);
ctx.beginPath();
ctx.moveTo(pad, h - pad - 20);
for (let i = 0; i < LINE_DATA.length; i++) {
const x = pad + i * stepX;
const y = h - pad - 20 - (LINE_DATA[i] / max) * (h - pad * 2 - 20);
ctx.lineTo(x, y);
}
ctx.lineTo(w - pad, h - pad - 20);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
绘制折线下方的渐变填充区域。使用 createLinearGradient 创建从上到下的线性渐变——顶部为橙色(COLORS.orange),底部为背景色(COLORS.bg),形成从数据线向背景过渡的"光晕"效果。填充区域的路径从左下角开始,沿数据点连线到右下角,再闭合回到起点。
// 折线
ctx.beginPath();
for (let i = 0; i < LINE_DATA.length; i++) {
const x = pad + i * stepX;
const y = h - pad - 20 - (LINE_DATA[i] / max) * (h - pad * 2 - 20);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = COLORS.orange;
ctx.lineWidth = 2;
ctx.stroke();
绘制折线本身。y 坐标的计算公式 h - pad - 20 - (LINE_DATA[i] / max) * (h - pad * 2 - 20) 将心率值映射到画布坐标——心率越高,y 值越小(位置越靠上),符合"数值越大位置越高"的直觉。第一个点使用 moveTo,后续点使用 lineTo,形成连续的折线。
// 数据点
for (let i = 0; i < LINE_DATA.length; i++) {
const x = pad + i * stepX;
const y = h - pad - 20 - (LINE_DATA[i] / max) * (h - pad * 2 - 20);
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = COLORS.card;
ctx.fill();
ctx.strokeStyle = COLORS.orange;
ctx.lineWidth = 1.5;
ctx.stroke();
}
在每个数据点位置绘制一个 3px 半径的圆形标记。填充色为 COLORS.card(与卡片背景一致),边框为橙色 1.5px——这种"空心圆"设计使数据点在折线上清晰可见,同时不会过于突兀。
// 横轴标签
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = COLORS.text3;
for (let i = 0; i < LINE_LABELS.length; i += 2) {
const x = pad + i * stepX;
ctx.fillText(`${LINE_LABELS[i]}′`, x, h - 6);
}
}
绘制横轴时间标签。i += 2 每隔一个数据点绘制一个标签(0’、10’、20’、30’、40’、50’),避免标签过密。标签后缀"′"表示分钟。
18.3 雷达图绘制
drawRadar() {
const ctx = this.radarCtx;
ctx.antialias = this.antialiasOn;
const cx = ctx.width / 2;
const cy = 110;
const r = 68;
const n = RADAR_LABELS.length;
ctx.clearRect(0, 0, ctx.width, 220);
drawRadar() 方法绘制体能六维评估雷达图。圆心位于 (cx, 110),半径 68px,维度数 n 为 6。
// 背景多边形(3 层)
for (let layer = 1; layer <= 3; layer++) {
const lr = r * layer / 3;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
const x = cx + Math.cos(angle) * lr;
const y = cy + Math.sin(angle) * lr;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.strokeStyle = COLORS.line;
ctx.lineWidth = 1;
ctx.stroke();
}
绘制三层背景多边形,半径分别为 r/3、2r/3、r,形成从内到外的三级刻度。每个顶点的坐标通过三角函数计算:angle = -Math.PI / 2 + (i / n) * Math.PI * 2——起始角度为 -π/2(正上方),每个维度的角度增量为 2π/n = 60°。
// 轴线
for (let i = 0; i < n; i++) {
const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(angle) * r, cy + Math.sin(angle) * r);
ctx.strokeStyle = COLORS.line;
ctx.stroke();
}
绘制六条从圆心到外层多边形顶点的轴线,将雷达图分成六个均等的扇区。
// 数据多边形
ctx.beginPath();
for (let i = 0; i < n; i++) {
const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
const x = cx + Math.cos(angle) * r * RADAR_VALUES[i];
const y = cy + Math.sin(angle) * r * RADAR_VALUES[i];
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fillStyle = COLORS.green;
ctx.globalAlpha = 0.28;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = COLORS.green;
ctx.lineWidth = 2;
ctx.stroke();
绘制数据多边形——这是雷达图的核心。每个顶点的坐标在轴线方向上按 RADAR_VALUES[i] 的比例缩放,数值越大的维度顶点越远离圆心。先填充半透明绿色(globalAlpha = 0.28),再绘制 2px 绿色边框,形成"半透明填充 + 实线边框"的双层效果。绘制后将 globalAlpha 重置为 1,避免影响后续绘制。
// 数据点
for (let i = 0; i < n; i++) {
const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
const x = cx + Math.cos(angle) * r * RADAR_VALUES[i];
const y = cy + Math.sin(angle) * r * RADAR_VALUES[i];
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = COLORS.green;
ctx.fill();
}
在数据多边形的每个顶点绘制 4px 半径的实心绿色圆点,突出每个维度的具体数值位置。
// 标签
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = COLORS.sub;
for (let i = 0; i < n; i++) {
const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
const x = cx + Math.cos(angle) * (r + 16);
const y = cy + Math.sin(angle) * (r + 16) + 3;
ctx.fillText(RADAR_LABELS[i], x, y);
}
}
在最外层多边形之外(半径 r + 16)绘制维度标签。y + 3 是对文字基线的微调,使标签在视觉上与顶点对齐。六个标签分别对应心肺、力量、柔韧、耐力、爆发、恢复六个维度。
十九、七种 Tab 布局风格对比总结
经过上述逐段分析,我们可以对七种 Tab 的布局风格进行系统性对比:
| Tab 名称 | 布局类型 | 核心组件 | 数据展示方式 | 交互特色 | 视觉亮点 |
|---|---|---|---|---|---|
| 训练 | 横滑大卡 | Scroll + Row + ForEach | 课程卡片横向滚动 | 水平滑动浏览 | 热量橙色高亮 + 难度标签 |
| 课程 | 大编号榜 | ForEach + Row | 排行榜列表 | 无特殊交互 | 前三名金/蓝/紫色编码 |
| 饮食 | 双列网格 | Grid + GridItem | 双列卡片网格 | 无特殊交互 | Emoji 缩略图 + 营养信息 |
| 计划 | 状态时间轴 | ForEach + Circle + Column | 时间轴列表 | 编辑/删除弹窗 | 彩色状态圆点 + 连接线 |
| 数据 | Canvas 图表 | Canvas × 3 + ForEach | 进度环+折线+雷达 | 抗锯齿开关 | 三种 Canvas 图表 |
| 社群 | 左色条列表 | ForEach + Column(色条) | 动态信息流 | 无特殊交互 | 三色循环色条 + 非对称圆角 |
| 我的 | 渐变卡+清单 | linearGradient + ForEach | 大数字+清单行 | 无特殊交互 | 52px 大数字 + 线性渐变 |
二十、技术要点总结与工程启示
20.1 架构设计总结
本文详细解析了一个基于 ArkUI 框架的智能健身训练助手应用的完整代码实现。从架构层面来看,该应用采用了以下几个关键设计策略:
单页面多 Tab 架构:整个应用只有一个入口组件 Page1001,通过 currentTab 状态变量驱动条件渲染,在同一个 Scroll 容器中切换七个不同布局的页面。这种架构的优点是页面切换无需导航跳转,响应速度快;缺点是所有代码集中在一个文件中,随着功能增长需要考虑拆分。
状态分层管理:状态变量分为四层——Tab 切换状态、弹窗状态、表单状态、数据数组。每层状态各司其职,互不干扰。@State 装饰器确保了状态变化自动触发 UI 刷新,开发者无需手动操作 DOM。
声明式与命令式混合:ArkUI 的声明式 UI 构建(@Builder 方法链式调用)与 Canvas 的命令式绘图(ctx.arc()、ctx.fill() 等方法调用)共存于同一组件中。声明式负责结构化 UI 的构建和状态绑定,命令式负责复杂数据可视化的精确绘制,两者各展所长。
20.2 布局设计总结
七种 Tab 布局的差异化设计是本应用最大的亮点。每个 Tab 都采用了完全不同的布局策略——从横向滑动卡片到双列网格,从时间轴到 Canvas 图表,从色条列表到渐变大数字卡——避免了"千篇一律的列表"这种移动应用中最常见的审美疲劳。
这种"一 Tab 一风格"的设计理念值得借鉴:它不仅提升了视觉丰富度,更重要的是让每种数据类型找到了最适合的展示方式——课程推荐适合横滑浏览、排行榜适合大编号展示、计划安排适合时间轴呈现、运动数据适合图表可视化、社区动态适合色条列表、个人成就适合大数字渐变卡片。
20.3 Canvas 绘图总结
三种 Canvas 图表(进度环、折线图、雷达图)的实现展示了 ArkUI 中 2D 绘图的核心能力。关键的工程实践包括:
- 清空画布:每次绘制前调用
clearRect清除旧内容。 - 坐标计算:使用三角函数(
Math.cos、Math.sin)计算圆弧和多边形顶点坐标。 - 渐变填充:使用
createLinearGradient创建渐变色填充区域。 - 呼吸动画:通过定时器切换状态变量,在绘制方法中读取状态实现透明度变化。
- 抗锯齿控制:通过
RenderingContextSettings和ctx.antialias属性控制渲染质量。
20.4 弹窗交互总结
三种弹窗(新增、编辑、删除)采用了"遮罩层 + 面板"的组合模式。新增和编辑弹窗从底部滑出,删除弹窗居中显示——通过弹出位置的不同暗示操作的严重程度。每个弹窗接收 onClose 回调函数,将关闭逻辑的控制在调用方,保持了弹窗组件的通用性。
20.5 工程启示
通过对这段代码的深度解析,我们可以提炼出以下几条工程启示:
- 颜色系统集中管理:通过
interface+const定义全局颜色系统,确保整个应用配色的一致性和可维护性。 - 数据模型与 UI 解耦:使用
@Observed装饰器将数据模型独立于 UI 逻辑,数据变化自动驱动 UI 刷新。 - 辅助函数提取公共逻辑:将颜色映射等重复逻辑提取为独立函数,减少代码重复。
ForEach键值生成:为每个列表项提供唯一的键值生成函数,优化虚拟列表的渲染性能。- Canvas 与声明式 UI 互补:在需要精确控制渲染的场景使用 Canvas,在需要状态驱动的场景使用声明式组件。
- 弹窗状态驱动:通过布尔状态变量控制弹窗的显示与隐藏,避免命令式的 show/hide 调用。
- 生命周期管理:在
aboutToAppear中初始化资源(定时器),在aboutToDisappear中释放资源,防止内存泄漏。 - 响应式更新策略:对于数组元素的修改,采用"替换整个元素"而非"修改属性"的方式,确保
@Observed的变化检测可靠触发。
总之,这个健身训练助手的代码实现不仅是一个 ArkUI 的技术示范,更是一份关于"如何在移动端构建多场景、多布局、数据驱动的复杂应用"的工程实践指南。从颜色系统的精心设计到 Canvas 图表的精确绘制,从七种 Tab 布局的差异化呈现到三种弹窗的交互模式设计,每一个细节都体现了对用户体验的深入思考和对工程质量的高标准追求。希望通过本文的逐段解析,读者能够不仅理解每行代码的功能,更能掌握其背后的设计理念和工程方法论,在自己的项目中灵活运用这些技术手段。
附录: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 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 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.1 | Release | ✅ 已安装 |
界面顶部提示:“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 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐



所有评论(0)