街舞工作室HarmonyOS ArkTS API 24当数组中的元素被 splice 替换或 unshift 插入时,ArkTS 框架能够感知到数组结构的变化并触发 ForEach 的差异化更新
一、技术背景与行业概述
1.1 HarmonyOS 声明式 UI 范式与 ArkTS 语言体系
HarmonyOS 的声明式 UI 范式标志着前端开发从命令式编程向声明式编程的重大转变。在传统的命令式开发模式中,开发者需要手动操控 DOM 节点、管理视图更新时机、处理事件回调链条,这种方式在复杂场景下容易产生状态不一致、渲染性能瓶颈以及代码维护困难等问题。而 ArkTS 作为 HarmonyOS 生态中的核心编程语言,通过引入 @Component、@Entry、@Builder、@State、@Observed 等装饰器,将 UI 结构的描述与状态数据的绑定合为一体,使得开发者只需关注"界面应该长什么样"和"数据是什么状态",框架层面自动负责从状态变化到视图更新的全链路响应式渲染。
ArkTS 在 TypeScript 的基础上进行了深度定制和扩展,保留了静态类型检查、泛型、接口、枚举等语言特性,同时增加了针对 HarmonyOS 运行时优化的编译期处理机制。这种设计既保证了代码的健壮性和可维护性,又实现了接近原生应用的运行性能。在街舞教学管理这样的垂直行业应用场景中,ArkTS 的类型系统能够精确建模舞步、课程、学员、装备等业务实体,使得业务逻辑与 UI 展示之间形成清晰的契约约束。

1.2 @Observed 响应式数据追踪与状态管理
@Observed 装饰器是 ArkTS 生态中实现对象级响应式追踪的关键机制。当类被 @Observed 标注后,其实例的属性变更会被框架自动拦截并触发关联视图的重新渲染。在街舞工作室应用中,舞友动态列表、舞步图鉴数据、课程预约记录以及装备清单都需要在用户操作后实时反映到界面上——比如用户发布一条新的"律语"动态后,列表应立即插入新条目;编辑排练预约后,对应卡片的课程名称和难度等级应即时刷新。这种"数据驱动视图"的模式消除了手动调用 setState 或操作 DOM 的需求,让开发者专注于业务逻辑本身。
需要注意的是,@Observed 的追踪粒度是属性级别的。当数组中的元素被 splice 替换或 unshift 插入时,ArkTS 框架能够感知到数组结构的变化并触发 ForEach 的差异化更新。这一特性在本应用中被大量使用,如 this.postList.unshift(new PostItem(...)) 和 this.classList.splice(this.editIdx, 1, new ClassItem(...)) 等操作,都依赖于 @Observed 的深层追踪能力。
1.3 街舞行业的数字化需求与技术挑战

街舞作为一种融合了音乐律动、身体控制和个人风格的舞蹈艺术形式,在全球范围内拥有庞大的爱好者群体。从 Breaking 到 Hiphop,从 Popping 到 Locking,再到 Vogue 和 Urban,每一种舞种都有其独特的动作体系、训练方法和评判标准。街舞工作室作为连接舞者与教学的桥梁,面临着多维度的管理挑战:课程排期需要协调教练时间和排练厅资源,舞步教学需要按难度等级递进组织,学员进度需要精确跟踪,社交互动需要激励社区活跃度,装备推荐需要匹配舞种特性。
将这样复杂的业务逻辑浓缩到一个移动端应用中,技术架构需要在功能完备性和性能流畅性之间取得平衡。本文分析的代码通过精心设计的色彩系统、数据模型层、纯函数工具集、状态管理机制和组件化构建器体系,实现了一个既功能丰富又性能高效的街舞教学管理平台。特别值得一提的是,应用内置了音符跳动和色块脉冲的视觉特效层,在保证 UI 交互不被干扰的前提下,为整个界面注入了街舞特有的律动感和节奏感。
二、项目整体架构与功能模块概览
2.1 核心功能模块划分

本应用的功能架构可以清晰地划分为以下几个核心模块:色彩配置中心负责全局视觉风格的统一管理;数据模型层定义了舞友动态、舞步图鉴、课程排期和装备清单四大业务实体及其关联关系;静态数据集为各模块提供了初始展示数据;纯函数工具集封装了柱状图高度计算、状态颜色映射、特效粒子位置运算等可复用的逻辑;主组件 PageStreetDance 集中管理所有状态变量和业务操作;构建器体系将界面拆分为头部信息栏、子导航栏、七个内容页面、三个弹窗对话框和底部导航栏等独立模块,通过 build 方法的条件渲染进行统一调度。
2.2 界面布局与导航架构
应用的导航架构采用双层 Tab 设计:底部固定四个主 Tab(首页、舞步、课堂、我的),每个主 Tab 对应一个内容区域;首页内部再嵌套七个内容子 Tab(精选、舞步图鉴、周课表、排练厅、大师课、舞友圈、装备库),通过横向滚动的胶囊式反白导航条进行切换。胶囊式反白的设计意味着选中项使用主色填充背景并使用白色文字,未选中项使用白色背景和灰色文字,形成强烈的视觉对比。在特效层面,应用通过 setInterval 驱动一个 90 毫秒周期的定时器,不断更新 tick 和 glow 两个状态变量,驱动音符图标的随机跳动和色块圆圈的脉冲扩散,这些特效元素位于底层且设置了 hitTestBehavior(HitTestMode.None),确保不会拦截或干扰用户的点击操作。
2.3 弹窗系统与 CRUD 闭环

应用实现了完整的数据增删改查闭环:新增弹窗允许用户输入律语标题和补充内容,提交后通过 unshift 将新动态插入列表头部;编辑弹窗支持修改课程预约的名称、难度和教练备注,通过 splice 替换指定位置的元素实现就地更新;删除弹窗根据操作来源的不同,可以取消课程预约或退出大师课程,同样通过 splice 移除首元素。三种弹窗共享一个半透明黑色遮罩层,通过条件渲染配合 Stack 堆叠和 zIndex(999) 确保弹出时覆盖在所有内容之上。
三、逐段代码深度分析
3.1 色彩配置接口定义

interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
chalk: string;
}
这段代码定义了街舞工作室应用的色彩配置接口 ColorPalette,它包含了 16 个语义化命名的颜色字段。与简单的颜色常量不同,这里使用接口定义颜色体系,确保了类型安全——任何使用 ColorPalette 类型标注的变量都必须提供全部 16 个字段,遗漏任何一个都会在编译期报错。
从字段命名可以看出精心的语义分层:primary/primaryLight/primaryDark 构成主色调的三级层次,用于按钮、选中态、强调文字等不同强度场景;accent 和 accentLight 作为辅助色,与主色形成互补对比;bg 和 cardBg 区分了页面背景和卡片背景,营造层次感;textPrimary/textSecondary/textHint 构成文字的三级灰度,对应标题、正文和辅助说明;success/warning/danger 三个状态色用于表达"空闲可约"“使用中”"取消/退课"等状态语义。特别值得注意的是 chalk 字段——这是为街舞场景特别设计的粉笔色,呼应了街舞文化中教练用粉笔在镜墙上标记动作要点的传统元素。
3.2 色彩常量实例化

const COLORS: ColorPalette = {
primary: '#E84A6F',
primaryLight: '#F491A9',
primaryLight: '#F491A9',
primaryDark: '#C42D52',
accent: '#2EC4B6',
accentLight: '#DDF4F2',
bg: '#F7F2E9',
cardBg: '#FFFFFF',
textPrimary: '#2D2430',
textSecondary: '#6B5F70',
textHint: '#A295A8',
border: '#E5DCCF',
success: '#2EC4B6',
warning: '#FFBF3F',
danger: '#E84A6F',
white: '#FFFFFF',
chalk: '#D9CDB9'
};
这里将色彩配置接口实例化为一个全局常量 COLORS,为整个应用提供统一的颜色取值入口。主色调 #E84A6F 是一种带橙调的玫红色,既传达了街舞的热情与活力,又不会像纯红色那样过于攻击性;辅助色 #2EC4B6 是青绿色,与玫红形成互补色对比,在色相轮上恰好处于对立位置,视觉冲击力强且不刺眼。
背景色 #F7F2E9 是一种暖米白,模拟了街舞工作室常见的木质地板和暖光环境,给人温暖、舒适的心理感受。卡片背景使用纯白 #FFFFFF,与暖米白背景形成微妙的明度差,使卡片"浮"在背景之上。文字色系从深紫灰 #2D2430 到浅紫灰 #A295A8,保持了与主色调的色相一致性。chalk 粉笔色 #D9CDB9 是一种暖灰米色,既可以用于装饰性边框和分割线,也呼应了街舞教学的粉笔标记文化。
3.3 舞友动态数据模型

@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.time = time; this.likes = likes
}
}
PostItem 类被 @Observed 装饰器标注,表示这是一个可被框架追踪属性变化的响应式数据模型。它建模了舞友社区中的动态信息,包含 6 个属性字段:id 用于唯一标识和 ForEach 的 key 生成;nick 和 avatar 分别存储发布者的昵称和 emoji 头像(如 ‘🌀’、‘🤳’ 等);text 存储动态正文内容;time 记录发布时间(如 ‘8分钟前’、‘昨天’);likes 记录点赞数。
构造函数采用参数列表赋值的简洁写法,在一行内完成多个属性的初始化。这种写法虽然紧凑,但在 ArkTS 编译器的优化下性能与多行赋值完全一致。使用 @Observed 的关键意义在于:当 postList 数组中的某个 PostItem 实例的属性被修改时(如 likes 增加),或者数组本身通过 unshift/splice 发生结构变化时,框架会自动感知并触发对应的 ForEach 差异化渲染,无需手动调用任何刷新方法。
3.4 舞步数据模型
@Observed
export class MoveItem {
id: number = 0
name: string = ''
style: string = ''
level: string = ''
status: string = ''
tag: string = ''
constructor(id: number, name: string, style: string, level: string, status: string, tag: string) {
this.id = id; this.name = name; this.style = style; this.level = level
this.status = status; this.tag = tag
}
}
MoveItem 类建模了舞步图鉴中的每一个动作条目。与 PostItem 类似,它同样使用 @Observed 实现响应式追踪,但其字段设计更加贴合街舞教学的专业需求:name 存储动作名称(如 ‘Toprock 基础步’、‘Windmill 风车’);style 标注所属舞种(如 ‘Breaking’、‘Hiphop’、‘Popping’);level 表示难度等级(‘入门’、‘初级’、‘中级’、‘高级’);status 表示学员的掌握状态(‘已掌握’、‘练习中’、‘未尝试’);tag 是简短的功能标签(‘律动’、‘地板’、‘大招’、‘卡点’、‘波浪’、‘震动’、‘造型’、‘几何’)。
这种多维度建模使得 UI 层能够根据不同维度进行灵活的视觉表达。例如,statusColor 函数根据 status 字段返回不同的颜色——"已掌握"使用青绿色 #2EC4B6、"练习中"使用主色 #E84A6F、其他使用灰色 #A295A8;levelColor 函数根据 level 字段返回颜色——入门和初级使用青绿、中级使用黄色、高级使用玫红。这种数据驱动的颜色映射确保了视觉表达与业务语义的一致性。
3.5 课程数据模型
@Observed
export class ClassItem {
id: number = 0
name: string = ''
coach: string = ''
level: string = ''
progress: number = 0
constructor(id: number, name: string, coach: string, level: string, progress: number) {
this.id = id; this.name = name; this.coach = coach; this.level = level
this.progress = progress
}
}
ClassItem 类建模了课程排期和预约信息。name 存储课程名称(如 ‘周一 Hiphop 基础’、‘周二 Breaking 入门’);coach 记录教练姓名(如 ‘Kiki 教练’、‘Bboy 阿岳’);level 标注课程难度定位(‘基础’、‘入门’、‘初级’、‘中级’、‘高级’、‘自由’、‘定制’);progress 是一个 0-100 的整数,表示满员进度——100 表示已满员,较低值表示仍有名额。
progress 字段在 UI 层被用于绘制进度条和判断是否可预约:当 progress === 100 时显示"已满"标签和灰色文字,否则显示"可约"标签和绿色文字。这个字段也是编辑弹窗操作的对象——doEdit 方法在替换课程条目时将 progress 设为固定值 40,模拟编辑后重置满员进度的业务场景。
3.6 装备数据模型
@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
constructor(id: number, name: string, desc: string, icon: string) {
this.id = id; this.name = name; this.desc = desc; this.icon = icon
}
}
GearItem 类建模了舞蹈装备库中的物品信息。字段设计简洁明了:name 是装备名称(如 ‘舞鞋’、‘护膝’、‘护腕’、‘宽松卫衣’、‘棒球帽’);desc 是简短功能描述(如 ‘平底防滑’、‘大招必备’、‘支撑保护’);icon 存储 emoji 图标用于视觉展示。装备数据是静态的,不会在运行时被修改,因此虽然类被 @Observed 标注,实际上主要利用的是其类型约束和构造函数的便捷初始化能力。
3.7 图表与课程接口定义
interface WeekChartItem {
label: string;
value: number;
}
interface StyleChartItem {
label: string;
value: number;
color: string;
}
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface GridItem {
day: string;
cnt: string;
hot: boolean;
}
这里定义了四个用于图表和课程展示的轻量级数据接口。与 @Observed 类不同,这些接口仅用于类型约束,不需要响应式追踪,因为它们对应的数据在运行时不会发生变化。
WeekChartItem 用于周课表柱状图,label 是星期几,value 是当天课程数量;StyleChartItem 用于舞种热度分布,额外包含 color 字段使得每个柱子可以独立着色;CourseItem 用于大师课程进度展示,week 标注教学周次,color 为每门课程指定独立色彩;GridItem 用于周课表网格展示,hot 布尔值标记高峰日(周六日),驱动网格背景色的条件渲染。
3.8 顶层静态数据——舞友动态列表
const POST_LIST: PostItem[] = [
new PostItem(1, '地板旋风', '🌀', '终于把 windmill 连满八圈了,膝盖的淤青都是勋章。', '8分钟前', 94),
new PostItem(2, 'Locking老炮', '🤳', '锁点要锁在鼓点上,今晚卡点终于被教练夸了。', '23分钟前', 81),
new PostItem(3, 'Wave女孩', '🌊', '身体波浪过了胸那关,整整练了三个星期。', '39分钟前', 72),
new PostItem(4, 'Breaking新手', '🤸', '第一次 free 跟音乐 jam,紧张到忘动作但超爽。', '1小时前', 64),
new PostItem(5, 'Hiphop通勤族', '🎧', '下班跳一小时,比健身房的快乐多十倍。', '2小时前', 57),
new PostItem(6, 'Popping肌', '⚡', '肌肉震动练到腿抖,popping 的 pop 真的是体力活。', '3小时前', 52),
new PostItem(7, '编舞师阿K', '🎬', '新编的urban小段发出来了,副歌那八个八拍我很满意。', '5小时前', 45),
new PostItem(8, 'Vogue猫', '💅', 'Vogue 的 framing 练出感觉了,镜子前自己都看入迷。', '昨天', 70)
];
这是舞友圈动态的初始数据集,包含 8 条预填充的社区动态。每条动态都经过精心设计,内容真实反映了街舞练习中的各种场景:从 Breaking 的风车连圈练习,到 Locking 的鼓点卡点训练;从 Body Wave 的身体波浪突破,到 Popping 的肌肉震动耐力挑战;从编舞师的创作分享,到 Vogue 的造型练习心得。
这些动态数据在应用启动时被赋值给 this.postList 状态变量,后续用户通过"发律语"弹窗新增的动态会通过 unshift 插入到列表头部。动态内容设计上兼顾了不同舞种、不同水平层次和不同练习阶段的覆盖,为应用提供了丰富的初始展示内容,也让用户能够立即感受到社区的活跃氛围。
3.9 顶层静态数据——舞步图鉴列表
const MOVE_LIST: MoveItem[] = [
new MoveItem(1, 'Toprock 基础步', 'Breaking', '入门', '已掌握', '律动'),
new MoveItem(2, 'Six Step', 'Breaking', '初级', '练习中', '地板'),
new MoveItem(3, 'Windmill 风车', 'Breaking', '高级', '练习中', '大招'),
new MoveItem(4, 'Locking 锁点', 'Locking', '初级', '已掌握', '卡点'),
new MoveItem(5, 'Body Wave', 'Hiphop', '入门', '已掌握', '波浪'),
new MoveItem(6, 'Muscle Pop', 'Popping', '中级', '练习中', '震动'),
new MoveItem(7, 'Vogue Framing', 'Vogue', '中级', '未尝试', '造型'),
new MoveItem(8, 'Tutting 手掌框', 'Popping', '高级', '未尝试', '几何')
];
舞步图鉴数据集涵盖了 Breaking、Locking、Hiphop、Popping、Vogue 五大主流舞种的 8 个代表性动作。每个动作的难度等级从"入门"到"高级"递进,掌握状态覆盖了"已掌握"“练习中”"未尝试"三种情况,功能标签涵盖了律动、地板、大招、卡点、波浪、震动、造型、几何等不同维度。
这种数据设计不仅为 UI 层提供了丰富的展示内容,更重要的是为各种条件渲染逻辑提供了测试覆盖——例如 statusColor 函数的三种分支、levelColor 函数的三种分支、workProgress 函数的三种分支都能在这 8 条数据中得到完整的覆盖测试。
3.10 顶层静态数据——课程排期列表
const CLASS_LIST: ClassItem[] = [
new ClassItem(1, '周一 Hiphop 基础', 'Kiki 教练', '基础', 100),
new ClassItem(2, '周二 Breaking 入门', 'Bboy 阿岳', '入门', 88),
new ClassItem(3, '周三 Locking 卡点', '老炮 Leo', '初级', 72),
new ClassItem(4, '周四 Popping 震感', '肌霸小康', '中级', 55),
new ClassItem(5, '周五 Urban 编舞', '阿K 编舞师', '中级', 46),
new ClassItem(6, '周六 Breaking 大招', 'Bboy 阿岳', '高级', 30),
new ClassItem(7, '周日 Cypher 实战', '全员轮值', '自由', 20),
new ClassItem(8, '预约 私教一对一', 'Kiki 教练', '定制', 10)
];
课程排期数据集按照周一到周日的顺序组织,每天安排不同舞种和难度等级的课程。教练分配也体现了专业化分工:Kiki 教练负责 Hiphop 基础和私教定制课程,Bboy 阿岳专攻 Breaking 从入门到大招的递进教学,老炮 Leo 传授 Locking 卡点技巧,肌霸小康负责 Popping 震感训练,阿K 编舞师主导 Urban 编舞创作。
满员进度 progress 从 100(已满)递减到 10(私教),模拟了不同课程的报名热度差异。这个字段在 UI 层被用于绘制进度条和判断是否可预约,是编辑和删除操作的直接目标数据。
3.11 导航配置与子导航配置
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🕺', label: '首页' },
{ icon: '🤸', label: '舞步' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['精选', '舞步图鉴', '周课表', '排练厅', '大师课', '舞友圈', '装备库'];
导航配置定义了应用的双层 Tab 结构。NAV_LIST 定义了底部 4 个主 Tab 的图标和文字标签,每个图标都经过精心选择以语义化地对应其功能:‘🕺’ 舞蹈人物对应首页,‘🤸’ 翻滚人物对应舞步,‘📚’ 书本对应课堂,‘👤’ 人物轮廓对应个人中心。
SUB_NAV_LIST 定义了首页内部的 7 个内容子 Tab,按照"精选推荐→分类浏览→社交互动→工具购物"的逻辑递进排列。这种从内容发现到社区互动再到工具使用的排列顺序,符合用户在内容型应用中的典型浏览路径。子 Tab 采用纯字符串数组形式,在 subNav 构建器中通过 ForEach 的 idx 索引与 subTab 状态变量比较来决定选中态。
3.12 纯函数工具集——柱状图高度与颜色映射
function barH(v: number, max: number): number {
return Math.round(108 * v / max);
}
function styleColor(v: number): string {
if (v > 60) {
return '#E84A6F';
} else if (v > 30) {
return '#2EC4B6';
}
return '#FFBF3F';
}
barH 函数将数值映射为柱状图的像素高度,接受当前值 v 和最大值 max 两个参数,返回 0-108 之间的整数像素值。Math.round 确保返回值为整数,避免亚像素渲染导致的模糊。108 这个上限值经过计算,与图表容器的 140 像素高度(含标签文字 32 像素)相匹配,确保柱子不会溢出容器边界。
styleColor 函数实现了舞种热度的三档颜色映射:大于 60 返回主色玫红,大于 30 返回青绿,其余返回黄色。这种分级映射使得热度值在视觉上形成"热-温-凉"的直观区分,用户扫一眼就能判断各舞种的热度分布。两个函数都是纯函数——相同输入永远产生相同输出,不依赖外部状态,没有副作用,这使得它们可以被安全地缓存和并行调用。
3.13 纯函数工具集——音符跳动特效位置计算
function noteX(tick: number, i: number): number {
return 30 + ((tick * 6 + i * 87) % 580);
}
function noteY(tick: number, i: number): number {
return 700 - ((tick * 11 + i * 53) % 680);
}
function noteA(tick: number, i: number): number {
return (i + tick) % 3 === 0 ? 0.5 : 0.18;
}
这三个函数共同驱动了特效层中 12 个音符图标的跳动动画。noteX 和 noteY 分别计算每个音符在 X 轴和 Y 轴的位置,noteA 计算透明度。关键设计在于使用了模运算 (%) 来产生周期性但看似随机的运动轨迹:tick 是每 90 毫秒递增 1 的计时器,i 是音符的索引(0-11)。
noteX 的公式 30 + ((tick * 6 + i * 87) % 580) 中,系数 6 和 87 是经过调优的互质数——6 控制 tick 对位置的影响速度,87 控制不同音符之间的位置偏移量。模 580 限制 X 坐标在 30-610 之间。noteY 的公式类似但方向相反(700 减去结果),使得音符从底部向上跳动,模 680 限制 Y 坐标在 20-700 之间。noteA 使用 (i + tick) % 3 === 0 的条件判断,使得每三个 tick 中有一个 tick 让特定音符以 0.5 的高透明度显示,其余时刻以 0.18 的低透明度显示,形成闪烁效果。
3.14 纯函数工具集——色块脉冲特效参数
function pulseA(tick: number, i: number): number {
return 0.1 + ((tick + i) % 4) * 0.06;
}
function pulseX(tick: number, i: number): number {
return 50 + ((tick * 2 + i * 121) % 530);
}
function pulseY(tick: number, i: number): number {
return 100 + i * 145;
}
function pulseR(tick: number, i: number): number {
return 14 + ((tick + i * 3) % 5) * 4;
}
这组函数驱动特效层中 5 个色块圆圈的脉冲扩散效果。与音符跳动不同,色块脉冲更强调"呼吸感"——半径会周期性增大缩小,透明度同步变化,营造出能量波纹扩散的视觉效果。
pulseR 的公式 14 + ((tick + i * 3) % 5) * 4 产生 14-30 之间的半径值,% 5 确保了 5 步一个周期的呼吸节奏,每步增大 4 像素。pulseA 的透明度范围 0.1-0.28 较低,确保色块不会过于抢眼而干扰内容阅读。pulseY 使用 i * 145 的线性间距,5 个色块均匀分布在 100-680 的 Y 轴区间。pulseX 使用与 noteX 类似的模运算模式但系数不同(2 和 121),确保色块与音符的运动轨迹互不干扰。
3.15 纯函数工具集——状态颜色与进度映射
function statusColor(s: string): string {
if (s === '已掌握') {
return '#2EC4B6';
} else if (s === '练习中') {
return '#E84A6F';
}
return '#A295A8';
}
function workProgress(s: string): number {
if (s === '已掌握') {
return 100;
} else if (s === '练习中') {
return 56;
}
return 12;
}
这两个函数实现了从舞步状态字符串到视觉表达参数的映射。statusColor 将三种状态映射为三种颜色:"已掌握"使用青绿色传达成就感和完成度;"练习中"使用主色玫红表示活跃度和进行中;未尝试状态使用灰色表示待开发。workProgress 将同样的三种状态映射为进度百分比:100%、56% 和 12%,用于驱动进度条的宽度。
这种将业务语义到视觉参数的映射逻辑封装为纯函数的设计,带来了三个好处:一是消除了 UI 构建器中的条件分支,使构建器代码更加简洁;二是映射规则集中管理,修改时只需改一处;三是函数可以独立测试,确保映射逻辑的正确性。
3.16 纯函数工具集——难度颜色与网格背景
function levelColor(g: string): string {
if (g === '入门' || g === '初级') {
return '#2EC4B6';
} else if (g === '中级') {
return '#FFBF3F';
}
return '#E84A6F';
}
function gridBg(hot: boolean): string {
if (hot) {
return '#E84A6F';
}
return '#FFFFFF';
}
levelColor 函数将难度等级映射为三档颜色,与 statusColor 的映射逻辑平行但语义不同:入门和初级使用青绿表示低门槛和友好度;中级使用黄色表示中等挑战;高级使用玫红表示高难度和高风险。这种颜色递进与交通信号灯系统类似,利用了用户对颜色编码的本能理解。
gridBg 函数根据 hot 布尔值返回网格背景色——高峰日使用主色玫红填充,非高峰日使用白色。这使得周六和周日的网格在视觉上突出显示,提醒用户高峰时段需要提前预约。虽然函数体极其简单,但将其封装为独立函数的好处是:如果未来高峰日的视觉表达需要调整(如添加边框或渐变背景),只需修改这一个函数即可全局生效。
3.17 主组件状态变量定义
@Entry
@Component
struct PageStreetDance {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'works'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State postList: PostItem[] = POST_LIST
@State classList: ClassItem[] = CLASS_LIST
@State fxTimer: number = -1
主组件 PageStreetDance 被 @Entry 和 @Component 双重装饰器标注,表示这是应用的入口组件。@Entry 使得该组件成为页面的根节点,@Component 声明这是一个可复用的 UI 组件。所有状态变量都使用 @State 装饰,确保任何状态变化都能自动触发关联视图的重新渲染。
状态变量可以按功能分为四组:导航状态(mainTab、subTab)控制当前显示的 Tab 页面;特效状态(tick、glow)驱动动画层的持续更新;弹窗状态(addOpen、editOpen、delOpen)控制三种弹窗的显隐;编辑上下文状态(editIdx、delTarget、editName、editStyle、editNote、addTitle、addContent)临时存储弹窗中的用户输入。数据状态(postList、classList)持有可变的数据列表,fxTimer 存储定时器 ID 用于生命周期清理。
3.18 生命周期管理
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToAppear 在组件即将挂载到视图树时被调用,这里启动了一个 90 毫秒周期的定时器。定时器回调中同时更新两个状态变量:tick 每次递增 1,用于驱动音符跳动和色块脉冲的纯函数计算;glow 在 0 和 1 之间切换(% 2),可用于额外的二态视觉切换。90 毫秒的周期意味着大约每秒更新 11 次,在视觉流畅度和性能消耗之间取得了平衡。
aboutToDisappear 在组件即将从视图树卸载时被调用,这里清理了定时器。if (this.fxTimer > 0) 的守卫检查确保只有在定时器确实存在时才执行清理,避免对未初始化的 -1 调用 clearInterval。清理后将 fxTimer 重置为 -1,防止重复清理。这种生命周期管理是防止内存泄漏的关键——如果组件卸载后定时器仍在运行,回调会尝试更新已销毁组件的状态,导致不可预测的行为。
3.19 弹窗打开操作
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.classList.length) {
this.editIdx = index;
this.editName = this.classList[index].name;
this.editStyle = this.classList[index].level;
this.editNote = this.classList[index].coach;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'works';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
这四个方法负责打开不同类型的弹窗,并初始化弹窗的上下文状态。openAdd 在打开新增弹窗前清空标题和内容输入框,确保每次打开都是空白状态。openEdit 接受一个索引参数,先通过边界检查(index >= 0 && index < this.classList.length)确保索引有效,然后将对应课程条目的 name、level、coach 分别赋值给编辑状态变量,实现"回显"效果——弹窗打开时输入框中预填充当前值。
openDelA 和 openDelB 分别设置 delTarget 为 ‘works’ 和 ‘course’,然后打开删除弹窗。delTarget 的值决定了删除弹窗的标题文案和实际删除操作的目标列表,这种通过状态变量区分删除来源的设计,使得同一个删除弹窗可以复用于不同场景。
3.20 数据操作方法——新增与编辑
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的律语', '🕺', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.classList.length) {
this.classList.splice(this.editIdx, 1, new ClassItem(this.classList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doAdd 方法执行新增操作:首先检查 addTitle 是否非空(length > 0),避免插入空标题的动态。通过后,使用 unshift 将新的 PostItem 插入到 postList 数组头部——新动态的 id 固定为 999(模拟临时 ID),nick 为 ‘我的律语’,avatar 为 ‘🕺’,time 为 ‘刚刚’,likes 为 0。最后关闭新增弹窗。
doEdit 方法执行编辑操作:先通过边界检查确保 editIdx 有效,然后使用 splice(editIdx, 1, new ClassItem(...)) 替换指定位置的课程条目。splice 的第二个参数 1 表示删除 1 个元素,第三个参数是替换插入的新元素。新元素的 id 保留原条目的 id(this.classList[this.editIdx].id),其余字段使用编辑弹窗中的输入值,progress 固定为 40。最后关闭编辑弹窗。
3.21 数据操作方法——删除
doDel(): void {
if (this.delTarget === 'works' && this.classList.length > 0) {
this.classList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
doDel 方法根据 delTarget 的值决定删除目标:当 delTarget === 'works' 时,删除 classList 的首元素(模拟取消首节课程预约);当 delTarget === 'course' 时,删除 postList 的首元素(模拟退出首门课程)。两种情况都先检查列表非空(length > 0),避免在空列表上调用 splice 导致的潜在问题。
使用 splice(0, 1) 而非 shift() 的原因可能是保持代码风格的一致性——编辑操作也使用了 splice。虽然 shift() 在语义上更清晰,但 splice(0, 1) 的意图同样明确,且与编辑操作的 splice 调用形式一致,降低了认知负担。删除完成后关闭弹窗,ForEach 会自动感知数组变化并重新渲染列表。
3.22 特效层构建器——音符跳动与色块脉冲
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Column()
.width(pulseR(this.tick, i) * 2)
.height(pulseR(this.tick, i) * 2)
.borderRadius(pulseR(this.tick, i))
.backgroundColor(COLORS.accentLight)
.opacity(pulseA(this.tick, i))
.translate({ x: pulseX(this.tick, i), y: pulseY(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (i: number) => {
Text('🎵')
.fontSize(12)
.opacity(noteA(this.tick, i))
.translate({ x: noteX(this.tick, i), y: noteY(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
特效层是应用视觉设计的点睛之笔。它由两个 ForEach 组成:第一个渲染 5 个色块圆圈(使用 Column 组件配合 borderRadius 实现圆形效果),半径、透明度和位置都由 pulse* 系列纯函数根据当前 tick 值动态计算;第二个渲染 12 个 ‘🎵’ 音符 emoji,位置和透明度由 note* 系列函数计算。
整个特效层的关键设计是 .hitTestBehavior(HitTestMode.None)——这个属性设置使得特效层不参与命中测试,即所有触摸和点击事件都会穿透特效层,直接传递到下方的内容层。这意味着即使用户的点击位置恰好落在某个音符 emoji 上,点击事件仍然会正确传递到下方的按钮或列表项,特效层的存在完全不影响交互体验。这种"视觉效果不干扰功能交互"的设计是高性能动画应用的基本要求。
3.23 头部信息栏构建器
@Builder
header() {
Column({ space: 8 }) {
Row() {
Column({ space: 2 }) {
Text('律语街舞工作室')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('Breaking · Hiphop · Popping · Vogue')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🔥')
.fontSize(18)
Text('约课')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
.margin({ left: 10 })
}
.width('100%')
Row({ space: 8 }) {
// 四个数据卡片:剩余课时、本周已上、连击天数、会员等级
Column({ space: 2 }) {
Text('剩余课时').fontSize(9).fontColor(COLORS.textHint)
Text('16 节').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
}
.layoutWeight(1).padding(8).backgroundColor(COLORS.cardBg).borderRadius(10)
.alignItems(HorizontalAlign.Center)
// ... 其他三个卡片
}
.width('100%')
}
.width('100%')
.padding({ top: 10, bottom: 8 })
}
头部信息栏分为上下两层:上层是工作室名称、副标题和约课按钮的横向排列;下层是四个等宽数据卡片的横向排列,分别展示剩余课时(16 节·主色)、本周已上(5 节·辅助色)、连击天数(23 天·警告色)和会员等级(金卡·危险色)。每个卡片使用不同的强调色,形成了多色拼盘的视觉效果,同时通过 layoutWeight(1) 确保四个卡片等宽分布。
约课按钮使用主色玫红背景配合白色文字和 borderRadius(14) 的圆角,形成胶囊形按钮,视觉上醒目且具有点击诱导力。‘🔥’ 火焰 emoji 位于按钮左侧,传达"热门""紧迫"的心理暗示。副标题 ‘Breaking · Hiphop · Popping · Vogue’ 列出了工作室涵盖的四大舞种,让用户一目了然地了解业务范围。
3.24 子导航栏构建器——胶囊式反白设计
@Builder
subNav() {
Scroll() {
Row({ space: 8 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Text(item)
.fontSize(13)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
.backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.borderRadius(16)
.border({ width: this.subTab === idx ? 0 : 1, color: COLORS.border })
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 4, bottom: 10 })
}
子导航栏采用了"胶囊式反白"设计风格——这是其名称中"反白"的含义所在。每个导航项的视觉状态由 this.subTab === idx 条件决定:选中时使用主色填充背景(COLORS.primary)、白色文字(COLORS.white)、粗体字重(FontWeight.Bold)、无边框(width: 0);未选中时使用白色背景(COLORS.cardBg)、灰色文字(COLORS.textSecondary)、常规字重(FontWeight.Normal)、1 像素边框(color: COLORS.border)。
所有导航项都使用 borderRadius(16) 形成胶囊形状。横向滚动通过 Scroll 组件的 scrollable(ScrollDirection.Horizontal) 实现,滚动条被隐藏(BarState.Off)以保持视觉整洁。onClick 回调直接设置 this.subTab = idx,触发条件渲染切换内容页面。ForEach 的 key 生成使用 item 字符串本身,确保导航项在数据不变时不会重新创建。
3.25 精选页面构建器——本周主打课横幅
@Builder
pageFeatured() {
Column({ space: 12 }) {
Row({ space: 14 }) {
Text('🕺').fontSize(44)
Column({ space: 6 }) {
Text('周六大招课 · Windmill 专题')
.fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
Text('Bboy 阿岳执教 · 限额 16 人 · 含保护垫')
.fontSize(12).fontColor('#FFE0E8')
Text('去预约')
.fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.white).borderRadius(10)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').padding(16)
.backgroundColor(COLORS.primary).borderRadius(16)
// ... 后续内容
精选页面的第一块是本周主打课横幅——一个使用主色玫红背景填充的大卡片,内部包含一个 44 像素的舞蹈 emoji 和课程详情文本。"去预约"按钮使用反色设计(白色背景+主色文字),在玫红背景上形成强烈的视觉焦点。副标题颜色 #FFE0E8 是一种浅粉色,是主色 #E84A6F 的高明度变体,确保在深色背景上的可读性同时保持色相一致性。
这个横幅的设计遵循了"首屏即吸引"的原则——用户进入精选页面时,第一眼看到的就是本周最值得关注的课程信息,配合醒目的色彩和明确的 CTA(Call To Action)按钮,引导用户进行预约操作。
3.26 精选页面——周课表七格与舞种热度柱状图
// 周课表七格
Column() {
Text('本周课表').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).width('100%').margin({ bottom: 10 })
Row({ space: 6 }) {
ForEach(GRID_LIST, (it: GridItem) => {
Column({ space: 3 }) {
Text(it.day).fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(it.hot ? COLORS.white : COLORS.textSecondary)
Text(it.cnt).fontSize(10)
.fontColor(it.hot ? COLORS.white : COLORS.textHint)
}
.layoutWeight(1).padding({ top: 10, bottom: 10 })
.backgroundColor(gridBg(it.hot)).borderRadius(10)
.alignItems(HorizontalAlign.Center)
}, (it: GridItem) => it.day)
}
.width('100%')
Text('周六日为高峰 · 建议提前 2 天预约')
.fontSize(10).fontColor(COLORS.textHint).margin({ top: 8 })
}
.width('100%').padding(14)
.backgroundColor(COLORS.cardBg).borderRadius(14)
周课表七格使用 GRID_LIST 数据渲染了周一到周日的课程数量网格。每个格子通过 gridBg(it.hot) 函数获取背景色——高峰日(周六周日)使用主色玫红填充并显示白色文字,非高峰日使用白色背景和灰色文字。layoutWeight(1) 确保七个格子等宽分布,space: 6 提供适当的间距。
网格下方有一条提示文字"周六日为高峰 · 建议提前 2 天预约",使用 textHint 灰色和小字号 10,作为辅助信息引导用户行为。这种在数据可视化旁边提供行动建议的设计,将信息展示与用户引导无缝结合。
舞种热度柱状图使用 STYLE_CHART 数据,通过 ForEach 渲染 5 个柱子,每个柱子的高度由 barH(it.value, 88) 计算(最大值 88 对应 108 像素高度),颜色由数据自带的 color 字段决定。柱子底部显示舞种名称,整体布局使用 alignItems(VerticalAlign.Bottom) 确保所有柱子底部对齐。柱状图下方还展示了"本店最热 Breaking"的文字提示,使用主色和粗体强调。
3.27 舞步图鉴页面构建器
@Builder
pageMoves() {
Column({ space: 10 }) {
Row() {
Text('舞步图鉴').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text('打卡练习').fontSize(12).fontColor(COLORS.primary)
.onClick(() => { this.openAdd(); })
}
.width('100%')
ForEach(MOVE_LIST, (it: MoveItem, idx: number) => {
Column({ space: 6 }) {
Row() {
Column().width(4).height(38).borderRadius(2)
.backgroundColor(statusColor(it.status))
Column({ space: 4 }) {
Row() {
Text(it.name).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text(it.status).fontSize(10).fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.bg).borderRadius(6)
}
.width('100%')
Row({ space: 8 }) {
Text(`🎧 ${it.style}`).fontSize(11).fontColor(COLORS.textSecondary)
Text(`⚡ ${it.level}`).fontSize(11).fontColor(COLORS.textSecondary)
}
.width('100%')
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row().width('100%').height(6).borderRadius(3).backgroundColor(COLORS.border)
Row().width(`${workProgress(it.status)}%`).height(6)
.borderRadius(3).backgroundColor(statusColor(it.status))
}
.width('100%')
Row({ space: 10 }) {
Text(`熟练度 ${workProgress(it.status)}%`).fontSize(10)
.fontColor(COLORS.textHint).layoutWeight(1)
Text(it.tag).fontSize(10).fontWeight(FontWeight.Bold)
.fontColor(levelColor(it.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight).borderRadius(6)
}
.width('100%')
}
.width('100%').padding(12)
.backgroundColor(COLORS.cardBg).borderRadius(12)
}, (it: MoveItem) => it.id.toString())
// 删除按钮
Button().width('100%').height(42)
.backgroundColor(COLORS.danger).borderRadius(10).opacity(0.9)
.onClick(() => { this.openDelA(); })
Text('取消课程预约').fontSize(14).fontColor(COLORS.white)
.margin({ left: -98, top: -32 })
}
.width('100%')
}
舞步图鉴页面是应用的核心功能页之一,展示了 8 个舞步动作的详细信息。每个动作条目采用卡片式布局,包含以下视觉元素:左侧 4 像素宽的彩色竖条(颜色由 statusColor(it.status) 决定,用于快速区分掌握状态);动作名称和状态标签(状态标签背景使用 COLORS.bg,文字颜色与竖条一致);舞种和难度信息行(使用 emoji 前缀增强语义);进度条(背景灰色,前景由 workProgress(it.status) 计算宽度百分比,颜色与状态一致);底部熟练度百分比文字和功能标签。
页面底部有一个"取消课程预约"的危险操作按钮,使用 COLORS.danger 背景色和 0.9 透明度,点击后调用 openDelA 打开删除弹窗。按钮的文字标签使用了一个巧妙的技巧:Text 组件通过负 margin(margin({ left: -98, top: -32 }))叠加在 Button 组件上方,实现按钮内文字的居中显示。这种做法虽然不是最优雅的方案(更标准的做法是使用 Button('取消课程预约') 的构造函数形式),但在当前代码结构中实现了预期效果。
3.28 周课表页面构建器
@Builder
pageWeek() {
Column({ space: 10 }) {
Text('周课表').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).width('100%')
ForEach(this.classList, (it: ClassItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('📅').fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text(it.level).fontSize(10).fontWeight(FontWeight.Bold)
.fontColor(levelColor(it.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight).borderRadius(6)
}
.width('100%')
Text(`👤 ${it.coach}`).fontSize(11).fontColor(COLORS.textSecondary)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row().width('100%').height(6).borderRadius(3).backgroundColor(COLORS.border)
Row().width(`${it.progress}%`).height(6).borderRadius(3)
.backgroundColor(levelColor(it.level))
}
.width('100%')
Row() {
Text(`满员进度 ${it.progress}%`).fontSize(10)
.fontColor(COLORS.textHint).layoutWeight(1)
Text('编辑预约').fontSize(11).fontColor(COLORS.primary)
.onClick(() => { this.openEdit(idx); })
}
.width('100%')
}
.width('100%').padding(12)
.backgroundColor(COLORS.cardBg).borderRadius(12)
}, (it: ClassItem) => it.id.toString())
}
.width('100%')
}
周课表页面遍历 this.classList 数据列表,为每个课程条目渲染一个详细信息卡片。卡片包含:日历 emoji 和课程名称、难度标签;教练姓名(使用 👤 前缀);满员进度条(使用 levelColor(it.level) 作为前景色,与难度等级颜色保持一致);"编辑预约"文字按钮,点击后调用 openEdit(idx) 打开编辑弹窗。
这里的进度条颜色使用 levelColor(it.level) 而非 statusColor(it.status),因为 ClassItem 没有 status 字段,只有 level 字段。这种设计将难度等级的颜色映射复用于进度条,使得不同难度的课程在视觉上有所区分——入门课程用青绿进度条,中级课程用黄色进度条,高级课程用玫红进度条。"编辑预约"按钮使用主色文字,没有背景和边框,呈现出轻量级的可点击文字风格。
3.29 排练厅页面构建器
@Builder
pageStudio() {
Column({ space: 10 }) {
Text('排练厅').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).width('100%')
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🪞').fontSize(28)
Text('镜墙 A 厅').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Text('空闲 · 可约').fontSize(10).fontColor(COLORS.success)
}
.layoutWeight(1).padding(12).backgroundColor(COLORS.cardBg).borderRadius(12)
.alignItems(HorizontalAlign.Center)
// ... B厅、自由角斗场
}
.width('100%')
ForEach(this.classList.slice(0, 5), (it: ClassItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.primary : COLORS.textHint).width(20)
Column({ space: 3 }) {
Text(it.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Text(`${it.coach} · ${it.level}`).fontSize(10).fontColor(COLORS.textSecondary)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(it.progress === 100 ? '已满' : '可约').fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(it.progress === 100 ? COLORS.textHint : COLORS.success)
}
.width('100%').padding(10).backgroundColor(COLORS.cardBg).borderRadius(10)
}, (it: ClassItem) => it.id.toString())
}
.width('100%')
}
排练厅页面分为上下两部分:上半部分展示三个排练厅的实时状态——镜墙 A 厅(空闲·可约,绿色)、音响 B 厅(使用中·18:00止,黄色)、自由角斗场(Cypher 进行中,主色),使用不同的状态色和文字描述传达各厅的可用性。下半部分使用 this.classList.slice(0, 5) 取前 5 个课程条目,渲染为排行榜样式的列表,序号 1-3 使用主色,4-5 使用灰色,模拟热门课程排名。
每个课程条目的右侧显示"已满"或"可约"标签,颜色根据 it.progress === 100 判断——100% 满员显示灰色"已满",否则显示绿色"可约"。这种简洁的二态展示让用户快速扫描可预约的课程。slice(0, 5) 确保排行榜只展示前 5 条,避免列表过长影响浏览体验。
3.30 大师课页面构建器
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('大师课').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).width('100%')
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text(it.week).fontSize(11).fontColor(COLORS.textHint)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row().width('100%').height(8).borderRadius(4).backgroundColor(COLORS.border)
Row().width(`${it.progress}%`).height(8).borderRadius(4).backgroundColor(it.color)
}
.width('100%')
Row() {
Text('已学').fontSize(11).fontColor(COLORS.textSecondary).layoutWeight(1)
Text(`${it.progress}%`).fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
}
.width('100%')
}
.width('100%').padding(12).backgroundColor(COLORS.cardBg).borderRadius(12)
}, (it: CourseItem) => it.title)
Button().width('100%').height(42)
.backgroundColor(COLORS.danger).borderRadius(10).opacity(0.9)
.onClick(() => { this.openDelB(); })
Text('退课').fontSize(14).fontColor(COLORS.white)
.margin({ left: -28, top: -32 })
}
.width('100%')
}
大师课页面使用 COURSE_LIST 静态数据,展示 5 门系统化教学课程的进度信息。每门课程包含:课程标题和教学周次(如"第 1 周"、“第 2-3 周”);学习进度条(使用课程数据自带的 color 字段着色,每门课程有独立的进度条颜色);学习进度百分比文字。
页面底部的"退课"按钮使用 COLORS.danger 背景色,点击后调用 openDelB 打开删除弹窗,delTarget 被设为 ‘course’。注意这里与舞步图鉴页面的删除按钮不同——舞步图鉴的删除按钮调用 openDelA(delTarget = 'works'),大师课的删除按钮调用 openDelB(delTarget = 'course'),两个按钮通过不同的 delTarget 值区分删除目标。ForEach 的 key 使用 it.title(课程标题字符串),确保每门课程有唯一的稳定标识。
3.31 舞友圈页面构建器
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('舞友圈').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text('发律语').fontSize(12).fontColor(COLORS.primary)
.onClick(() => { this.openAdd(); })
}
.width('100%')
ForEach(this.postList, (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar).fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text(it.time).fontSize(10).fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text).fontSize(13).fontColor(COLORS.textSecondary)
Text(`👍 ${it.likes} · 💬 回复`).fontSize(11).fontColor(COLORS.textHint)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').padding(12).backgroundColor(COLORS.cardBg).borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
舞友圈页面是社区的完整动态流,遍历 this.postList 渲染所有动态条目(与精选页面只展示前 3 条不同)。每条动态包含:emoji 头像(26 像素);发布者昵称和发布时间的横向排列;动态正文(13 像素,使用 textSecondary 灰色);点赞数和回复提示(👍 ${it.likes} · 💬 回复)。
"发律语"按钮位于标题右侧,使用主色文字和 12 像素字号,点击后调用 openAdd 打开新增弹窗。这里的"律语"是应用为舞友动态起的专属名称,呼应了应用名"律语街舞工作室"的品牌定位——用"律"字串联了音乐律动、舞蹈节奏和社区动态三层含义。ForEach 的 key 使用 it.id.toString(),确保每条动态有稳定的唯一标识,当 unshift 新增动态时只有新条目需要创建,已有条目不会重新渲染。
3.32 装备库页面构建器
@Builder
pageGear() {
Column({ space: 10 }) {
Text('装备库').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).width('100%')
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx].icon).fontSize(26)
Text(GEAR_LIST[idx].name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx].desc).fontSize(11).fontColor(COLORS.textSecondary)
Text('去购买').fontSize(11).fontColor(COLORS.primary).margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding(12)
.backgroundColor(COLORS.cardBg).borderRadius(12)
}
if (idx + 1 < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx + 1].icon).fontSize(26)
Text(GEAR_LIST[idx + 1].name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx + 1].desc).fontSize(11).fontColor(COLORS.textSecondary)
Text('去购买').fontSize(11).fontColor(COLORS.primary).margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding(12)
.backgroundColor(COLORS.cardBg).borderRadius(12)
}
}
.width('100%')
}
}, (it: GearItem) => it.id.toString())
}
.width('100%')
}
装备库页面使用双列网格布局展示 8 件舞蹈装备。双列布局的实现方式是:ForEach 遍历装备列表,但只在偶数索引(idx % 2 === 0)时创建一个 Row 容器,容器内同时渲染当前索引 idx 和 idx + 1 的两个装备卡片。通过 if (idx < GEAR_LIST.length) 和 if (idx + 1 < GEAR_LIST.length) 两个守卫检查,确保不会越界访问。
每个装备卡片包含:emoji 图标(26 像素)、装备名称(14 像素粗体)、功能描述(11 像素灰色)和"去购买"链接(11 像素主色)。layoutWeight(1) 确保两个卡片等宽分布,space: 10 提供卡片间的间距。这种双列网格布局在移动端是常见的商品展示模式,能够在有限的屏幕宽度内高效地展示更多商品。
3.33 个人中心页面构建器
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🦊').fontSize(40)
Column({ space: 4 }) {
Text('律语舞者').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Text('金卡 · 剩余 16 节 · 连击 23 天').fontSize(11).fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').padding(14).backgroundColor(COLORS.cardBg).borderRadius(14)
// 我的预约卡片
Column({ space: 10 }) {
Row() {
Text('我的预约').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary).layoutWeight(1)
Text(`${this.classList.length} 节`).fontSize(12).fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.classList.slice(0, 3), (it: ClassItem) => {
Row() {
Text(it.name).fontSize(13).fontColor(COLORS.textSecondary).layoutWeight(1)
Text(it.level).fontSize(11).fontColor(levelColor(it.level))
}
.width('100%')
}, (it: ClassItem) => it.id.toString())
}
.width('100%').padding(14).backgroundColor(COLORS.cardBg).borderRadius(14)
// 我的律语卡片
Column({ space: 10 }) {
Row() {
Text('我的律语').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary).layoutWeight(1)
Text(`${this.postList.length} 条`).fontSize(12).fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row() {
Text(it.nick).fontSize(13).fontColor(COLORS.textSecondary).layoutWeight(1)
Text(it.time).fontSize(11).fontColor(COLORS.textHint)
}
.width('100%')
}, (it: PostItem) => it.id.toString())
}
.width('100%').padding(14).backgroundColor(COLORS.cardBg).borderRadius(14)
}
.width('100%')
}
个人中心页面由三个卡片组成:用户信息卡片(头像 🦊、昵称"律语舞者"、会员等级和课时信息摘要);我的预约卡片(展示前 3 条课程预约,右侧显示总数 ${this.classList.length} 节);我的律语卡片(展示前 3 条发布的动态,右侧显示总数 ${this.postList.length} 条)。
注意两个摘要卡片中的数量显示使用了模板字符串动态计算——${this.classList.length} 和 ${this.postList.length},这意味着当用户通过新增或删除操作改变了列表长度后,个人中心页面的数量统计会自动更新。这种"数据驱动统计"的设计确保了界面显示与实际数据的一致性,无需手动同步。两个列表都使用 slice(0, 3) 只展示前 3 条,避免个人中心页面过长。
3.34 弹窗遮罩层构建器
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.6)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
})
}
.width('100%')
.height('100%')
}
弹窗遮罩层是一个全屏的半透明黑色背景,透明度为 0.6(60% 不透明度),用于在弹窗显示时遮蔽底层内容,引导用户注意力集中在弹窗内容上。遮罩层的 onClick 回调同时将三个弹窗状态变量设为 false,实现"点击遮罩层关闭任意弹窗"的交互行为。
这种共享遮罩层的设计意味着三种弹窗(新增、编辑、删除)共用同一个遮罩背景,在 build 方法中通过条件渲染分别叠加各自的内容体。由于同一时间只能有一种弹窗处于打开状态(addOpen、editOpen、delOpen 互斥),所以共用遮罩层不会产生冲突。这种设计减少了代码重复,也确保了遮罩层行为的一致性。
3.35 新增弹窗内容体构建器
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布律语').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
TextInput({ placeholder: '一句话练舞心得…', text: this.addTitle })
.fontSize(13).fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg).borderRadius(8).height(40)
.onChange((v: string) => { this.addTitle = v; })
TextArea({ placeholder: '补充细节:舞种、动作、课程、感受…', text: this.addContent })
.fontSize(13).fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg).borderRadius(8).height(90)
.onChange((v: string) => { this.addContent = v; })
Row({ space: 10 }) {
Button().layoutWeight(1).height(38).backgroundColor(COLORS.bg).borderRadius(8)
.onClick(() => { this.addOpen = false; })
Text('取消').fontSize(13).fontColor(COLORS.textSecondary).margin({ left: -52 })
Button().layoutWeight(1).height(38).backgroundColor(COLORS.primary).borderRadius(8)
.onClick(() => { this.doAdd(); })
Text('发布').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.white).margin({ left: -44 })
}
.width('100%')
}
.width('100%').padding(16).backgroundColor(COLORS.cardBg)
}
新增弹窗内容体包含一个标题、一个单行输入框、一个多行文本域和两个按钮。TextInput 的 text 属性绑定到 this.addTitle,onChange 回调将用户输入实时写入状态变量。TextArea 的 text 属性绑定到 this.addContent,同样通过 onChange 更新状态。这种"输入即更新状态"的模式是 ArkTS 声明式 UI 的核心特征——输入框的显示值与状态变量双向绑定,用户输入直接反映到状态中,提交时直接读取状态即可。
底部按钮行使用"Button + Text 叠加"的模式实现按钮内文字——Button 组件提供背景色和点击区域,Text 组件通过负 margin 叠加在按钮中心位置显示文字。"取消"按钮使用灰色背景,点击后关闭弹窗;"发布"按钮使用主色背景,点击后调用 doAdd 执行新增操作。
3.36 编辑弹窗内容体构建器
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('编辑排练预约').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
TextInput({ placeholder: '课程名称', text: this.editName })
.fontSize(13).fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg).borderRadius(8).height(40)
.onChange((v: string) => { this.editName = v; })
TextInput({ placeholder: '难度(如 中级)', text: this.editStyle })
.fontSize(13).fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg).borderRadius(8).height(40)
.onChange((v: string) => { this.editStyle = v; })
TextArea({ placeholder: '教练与时间段说明', text: this.editNote })
.fontSize(13).fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg).borderRadius(8).height(70)
.onChange((v: string) => { this.editNote = v; })
Row({ space: 10 }) {
Button().layoutWeight(1).height(38).backgroundColor(COLORS.bg).borderRadius(8)
.onClick(() => { this.editOpen = false; })
Text('取消').fontSize(13).fontColor(COLORS.textSecondary).margin({ left: -52 })
Button().layoutWeight(1).height(38).backgroundColor(COLORS.primary).borderRadius(8)
.onClick(() => { this.doEdit(); })
Text('保存').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.white).margin({ left: -44 })
}
.width('100%')
}
.width('100%').padding(16).backgroundColor(COLORS.cardBg)
}
编辑弹窗与新增弹窗的结构高度一致,但输入字段不同——编辑弹窗包含课程名称、难度等级和教练备注三个输入框,分别绑定到 editName、editStyle 和 editNote 状态变量。当 openEdit(index) 被调用时,这些变量会被预填充为对应课程条目的当前值,实现"回显"效果。用户修改后点击"保存"按钮,doEdit 方法使用这些状态变量的最新值创建新的 ClassItem 并替换原条目。
编辑弹窗的"保存"按钮使用主色背景,与新增弹窗的"发布"按钮形成视觉一致性——两者都是"确认提交"操作,使用相同的颜色编码。这种一致性降低了用户的学习成本,用户在不同弹窗中能快速识别确认按钮的位置和外观。
3.37 删除弹窗内容体构建器
@Builder
delModalBody() {
Column({ space: 12 }) {
Text(this.delTarget === 'works' ? '取消课程预约' : '退出大师课')
.fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Text(this.delTarget === 'works' ? '将取消首节课程预约并返还课时,确认执行?' : '将退出首门进行中的课程,确认执行?')
.fontSize(13).fontColor(COLORS.textSecondary)
Row({ space: 10 }) {
Button().layoutWeight(1).height(38).backgroundColor(COLORS.bg).borderRadius(8)
.onClick(() => { this.delOpen = false; })
Text('取消').fontSize(13).fontColor(COLORS.textSecondary).margin({ left: -52 })
Button().layoutWeight(1).height(38).backgroundColor(COLORS.danger).borderRadius(8)
.onClick(() => { this.doDel(); })
Text('确认').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.white).margin({ left: -44 })
}
.width('100%')
}
.width('100%').padding(16).backgroundColor(COLORS.cardBg)
}
删除弹窗是一个复用性极强的组件——通过 this.delTarget 的值动态切换标题和描述文案。当 delTarget === 'works' 时,标题为"取消课程预约",描述为"将取消首节课程预约并返还课时,确认执行?“;当 delTarget === 'course' 时,标题为"退出大师课”,描述为"将退出首门进行中的课程,确认执行?"。
"确认"按钮使用 COLORS.danger 背景色,与"取消"按钮的灰色背景形成鲜明对比,传达"危险操作"的视觉警告。这种颜色编码在删除类操作中是通用且有效的设计模式——红色/玫红色的确认按钮让用户在点击前自然地产生"再想想"的心理停顿,降低了误操作的风险。
3.38 底部导航栏构建器
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon).fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label).fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.onClick(() => { this.mainTab = idx; })
}, (it: NavItem) => it.label)
}
.width('100%').height(56).backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
底部导航栏使用 NAV_LIST 数据渲染 4 个主 Tab 按钮。每个按钮的选中态通过三个维度的视觉变化来传达:图标透明度(选中 1.0 vs 未选中 0.55)、文字字重(选中 Bold vs 未选中 Normal)、文字颜色(选中主色 vs 未选中灰色)。这种多维度的选中态设计确保了即使在不同光线条件和视角下,用户都能清晰地识别当前所在的 Tab。
导航栏容器使用白色背景和 borderRadius({ topLeft: 16, topRight: 16 }) 的顶部圆角,营造出"浮"在内容上方的卡片效果。56 像素的固定高度为手指点击提供了足够的触控区域。layoutWeight(1) 确保 4 个按钮等宽分布。ForEach 的 key 使用 it.label,确保导航项的稳定标识。
3.39 主内容区构建器——条件渲染调度
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
this.subNav()
Scroll() {
Column() {
if (this.subTab === 0) {
this.pageFeatured()
} else if (this.subTab === 1) {
this.pageMoves()
} else if (this.subTab === 2) {
this.pageWeek()
} else if (this.subTab === 3) {
this.pageStudio()
} else if (this.subTab === 4) {
this.pageCourse()
} else if (this.subTab === 5) {
this.pageCircle()
} else {
this.pageGear()
}
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 1) {
Scroll() {
Column() { this.pageMoves() }
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 2) {
Scroll() {
Column() { this.pageCourse() }
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else {
Scroll() {
Column() { this.pageMine() }
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
}
}
.width('100%').height('100%')
}
主内容区构建器是整个应用的渲染调度核心。它首先渲染头部信息栏(this.header()),然后根据 mainTab 的值进行条件分支:当 mainTab === 0(首页)时,额外渲染子导航栏并嵌套一层 subTab 条件渲染;其他 Tab(舞步、课堂、我的)直接渲染对应的内容页面构建器。
首页的双层条件渲染结构值得注意:外层 if-else if-else 链根据 mainTab 选择主 Tab 内容,内层 if-else if-else 链根据 subTab 选择子 Tab 内容。这种嵌套的条件渲染确保了只有当前页面的构建器被调用,其他页面的构建器不会被执行,实现了"按需渲染"的性能优化。每个内容区域都被包裹在 Scroll 组件中,支持垂直滚动,滚动条被隐藏(BarState.Off),layoutWeight(1) 让内容区占据头部和底部导航栏之间的所有可用空间。
3.40 build 主入口与三层堆叠
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() { this.addModalBody() }
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Stack() {
this.modalOverlay()
Column() { this.editModalBody() }
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.delOpen) {
Stack() {
this.modalOverlay()
Column() { this.delModalBody() }
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
}
build 方法是组件的渲染入口,使用 Stack 实现了三层堆叠架构:最底层是纯色背景(COLORS.bg 暖米白),中间层是特效层(this.fxLayer()),第三层是主要内容层(mainContent + bottomBar)。弹窗层通过条件渲染叠加在最上层。
三层堆叠的顺序至关重要:背景在最底,特效在中间,内容在最上。由于特效层设置了 hitTestBehavior(HitTestMode.None),虽然它位于内容层下方,但不会拦截点击事件。内容层在特效层之上,确保所有 UI 元素都清晰可见,特效只是作为背景的动态装饰。弹窗层使用 zIndex(999) 确保在最顶层显示,width('88%') 让弹窗宽度占屏幕的 88%,constraintSize({ maxHeight: '80%' }) 限制弹窗最大高度不超过屏幕的 80%,borderRadius(16) 提供圆角效果。每个弹窗都由 modalOverlay(遮罩层)和对应的 *ModalBody(内容体)组成,通过 Stack 堆叠在一起。
四、技术对比总览
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 色彩系统 | ColorPalette 接口 + COLORS 常量 | 16 字段语义化命名,含 chalk 粉笔色 | 编译期类型检查,运行时零开销 |
| 主色调 | #E84A6F 玫红 + #2EC4B6 青绿 | 互补色对比,暖米白背景 | 固定常量,无运行时计算 |
| 数据模型 | 4 个 @Observed 类 | PostItem/MoveItem/ClassItem/GearItem | 属性级响应式追踪 |
| 图表接口 | 4 个 interface 定义 | WeekChart/StyleChart/Course/Grid | 轻量级类型约束,无追踪开销 |
| 状态管理 | 17 个 @State 变量 | 分为导航/特效/弹窗/编辑/数据五组 | 按需触发重渲染 |
| 定时器周期 | 90ms setInterval | 驱动 tick 和 glow 双状态更新 | ~11fps,流畅度与性能平衡 |
| 特效层 | hitTestBehavior(None) | 5 色块脉冲 + 12 音符跳动 | 不拦截点击,零交互干扰 |
| 音符位置 | noteX/noteY/noteA 纯函数 | 模运算 + 互质系数产生伪随机轨迹 | O(1) 计算,无内存分配 |
| 色块脉冲 | pulseA/X/Y/R 纯函数 | 5 步呼吸周期,半径 14-30px | O(1) 计算,无状态依赖 |
| 导航架构 | 4 主 Tab + 7 子 Tab | 胶囊式反白子导航 | 条件渲染按需调度 |
| 选中态表达 | 三维度视觉变化 | 透明度+字重+颜色同时变化 | 纯属性绑定,无额外计算 |
| 列表渲染 | ForEach + key 生成 | id.toString() / label / title 作为 key | 差异化更新,最小化重渲染 |
| 进度条 | Stack + 双 Row 叠加 | 背景+前景百分比宽度 | 纯布局计算,无动画开销 |
| 柱状图 | ForEach + barH 函数 | 108px 上限,Math.round 取整 | O(1) 计算,整数像素 |
| 弹窗系统 | 3 种弹窗共享遮罩 | 条件渲染 + zIndex(999) | 互斥显示,无并发冲突 |
| 新增操作 | unshift 插入头部 | 固定 ID 999,空标题守卫 | O(n) 移动,小列表无感知 |
| 编辑操作 | splice 就地替换 | 保留原 ID,边界检查 | O(n) 移动,精确更新 |
| 删除操作 | splice(0,1) 删首 | delTarget 区分目标 | O(n) 移动,守卫空列表 |
| 双列网格 | idx%2===0 + 双重守卫 | 偶数索引创建 Row 容器 | 减半 Row 创建数量 |
| 生命周期 | aboutToAppear/Disappear | 定时器启动+清理配对 | 防止内存泄漏 |
五、深度总结
5.1 架构设计:分层清晰与职责单一
本应用的架构设计体现了"关注点分离"的核心原则。色彩配置、数据模型、静态数据、纯函数工具集和组件构建器各自位于不同的代码层级,层与层之间的依赖关系清晰单向——色彩配置被所有层级引用,数据模型被静态数据和状态变量引用,纯函数被构建器调用,构建器被主组件调度。这种分层设计使得任何一层的变化都不会扩散到其他层:修改颜色只需改 COLORS 常量,修改特效参数只需改纯函数,修改页面布局只需改对应构建器。17 个状态变量按功能分组(导航、特效、弹窗、编辑上下文、数据),每组状态的变化范围和影响面可预测,降低了状态管理的复杂度。
5.2 状态管理:响应式追踪与数据驱动
应用的状态管理完全基于 ArkTS 的 @State 和 @Observed 机制,实现了真正的"数据驱动视图"。所有 UI 的变化——Tab 切换、列表更新、弹窗显隐、进度条变化——都由状态变量的变化自动触发,无需手动调用任何渲染方法。@Observed 的属性级追踪使得 splice 替换数组元素时,只有被替换的条目需要重新渲染,其他条目保持不变。ForEach 的 key 生成机制(id.toString()、label、title)确保了列表更新时的差异化渲染效率最大化。编辑弹窗的"回显"设计——打开弹窗时从数据列表预填充状态变量——实现了数据到 UI 的单向流,而输入框的 onChange 实现了 UI 到数据的反向流,两者构成了完整的双向数据绑定。
5.3 性能优化:纯函数与按需渲染
应用在性能优化方面采取了多项措施。首先,所有特效计算(音符位置、色块参数、颜色映射、进度计算)都封装为纯函数,无副作用、无外部状态依赖,编译器可以进行内联优化和缓存优化。其次,条件渲染确保了只有当前页面的构建器被执行,其他 6 个内容页面的构建器不会被调用,大幅减少了不必要的 UI 创建。第三,定时器周期设为 90ms(约 11fps),在视觉流畅度和 CPU 消耗之间取得了平衡——比 60fps(16ms)节省了约 5.6 倍的定时器回调开销,比 1fps(1000ms)在视觉上更加平滑。第四,hitTestBehavior(HitTestMode.None) 使得特效层不参与命中测试,消除了触摸事件在特效层的拦截和分发开销。第五,双列网格布局通过 idx % 2 === 0 的条件判断减半了 Row 容器的创建数量。
5.4 交互设计:一致性反馈与防误操作
应用的交互设计遵循了"一致性"和"安全性"两大原则。一致性体现在:所有"确认提交"按钮使用主色背景(新增的"发布"、编辑的"保存"),所有"取消"按钮使用灰色背景,所有"危险确认"按钮使用 danger 色背景(删除的"确认");所有列表项的选中态通过多维度视觉变化传达(透明度+字重+颜色);所有弹窗的遮罩层行为一致(点击关闭)。安全性体现在:新增操作有空标题守卫(addTitle.length > 0);编辑操作有索引边界检查(editIdx >= 0 && editIdx < this.classList.length);删除操作有列表非空检查(length > 0);删除弹窗有明确的文案提示和二次确认。这些措施共同确保了用户操作的可预测性和安全性。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
5.5 总结:

应用的可扩展性体现在多个方面。新增一种舞种只需在 MOVE_LIST 中添加 MoveItem 条目,UI 层会自动渲染新条目;新增一种课程只需在 CLASS_LIST 中添加 ClassItem 条目;新增一个装备只需在 GEAR_LIST 中添加 GearItem 条目。颜色映射规则集中在纯函数中,修改难度等级的颜色只需改 levelColor 一处。弹窗系统通过 delTarget 状态变量实现了复用——同一个删除弹窗可以服务于不同的删除场景,只需在调用前设置 delTarget 的值。特效层通过修改纯函数的系数即可调整动画的轨迹和节奏,无需重构组件结构。
更多推荐



所有评论(0)