竹笛制作:基于HarmonyOS ArkTS API 24深度技术解析,BambooFluteItem 的实例通过 @State flutes 数组管理,当用户通过“新增竹笛“弹窗添加新产品时
一、竹笛制作的历史文化背景

竹笛,作为中华民族最古老的吹奏乐器之一,其历史可以追溯至距今约八千年的新石器时代。在河南贾湖遗址中出土的骨笛,用鹤骨制成,能够吹奏出完整的五声音阶乃至七声音阶,震惊了世界音乐考古学界。这支骨笛不仅是迄今为止发现的世界上最早的吹奏乐器,更是中华先民音乐智慧的伟大结晶。从骨笛到竹笛的演变,见证了中华文明从狩猎采集时代走向农耕文明的历史进程,也折射出先民对自然材料的认知和对音律美学的不断追求。
竹笛之所以选择竹材作为主要制作原料,绝非偶然。中国南方广袤的竹林资源为竹笛制作提供了取之不尽的天然材料。竹子中空外直的管状结构天然适合制作吹奏乐器,其管壁的密度和厚度直接影响音色的明亮程度和共鸣效果。不同品种的竹子在密度、纤维走向、壁厚等方面各具特色,因此制作出的竹笛在音色上也呈现出丰富的层次感。紫竹色泽深沉、音色浑厚;苦竹质地坚硬、音色清亮;湘妃竹斑纹如泪、音色幽远;凤眼竹纹理独特、音色温润。这些竹材特性的差异,使得每一支手工竹笛都拥有独一无二的声学品质。
在中国古代音乐文化中,竹笛占据着举足轻重的地位。从先秦时期的雅乐到汉唐的燕乐,从宋元的戏曲伴奏到明清的江南丝竹,竹笛始终是不可或缺的主奏或伴奏乐器。特别是在昆曲、京剧、越剧等戏曲形式中,竹笛承担着引领唱腔、烘托气氛的重要角色。笛声清越悠扬,既能表现婉转柔美的抒情意境,也能展现激昂豪迈的英雄气概。古往今来,无数文人墨客在诗词中吟咏笛声,从李白的"谁家玉笛暗飞声,散入春风满洛城"到李益的"回乐峰前沙似雪,受降城外月如霜。不知何处吹芦管,一夜征人尽望乡",竹笛之声早已深深融入中华民族的情感表达之中。
竹笛制作工艺是一门极其精细的手工技艺,凝聚着历代制笛匠人的智慧与经验。一支上好竹笛的诞生,需要经历选竹、烤竹校直、开吹孔、开膜孔、开音孔、镶骨笛头、调音、上漆等八大工序,每道工序都要求匠人具备丰富的经验和精湛的技艺。选竹环节讲究"三年老竹",即选取生长三年以上的竹子,因为幼竹含水率过高、纤维尚未完全成熟,制成的笛子容易开裂变形且音色发虚。烤竹校直则是利用炭火的热量使竹材中的水分均匀蒸发,同时通过校直架将竹管调至笔直,这一步骤直接影响笛身的外观和音准的稳定性。
开孔是竹笛制作中最考验匠人功底的环节。吹孔的形状和大小决定了气息的操控性和音色的纯净度,通常为椭圆形,需要用小刀和圆锉反复修整。膜孔的位置和大小影响笛膜的振动效果,笛膜一般取自芦苇芯部的薄膜,贴在膜孔上后能在吹奏时产生独特的"笛膜音",这是中国竹笛区别于西方长笛的最显著音色特征。六个音孔的位置和大小则需要借助音准仪逐一定位和校准,任何一个音孔的偏差都会导致整支笛子的音准失准。镶骨笛头是在笛身两端镶嵌牛骨或象牙,既起到加固防裂的作用,也提升了竹笛的整体美观度和档次感。
调音是制笛的最后关键环节,经验丰富的调音师能够凭借敏锐的听觉判断每个音孔的音高是否准确,并通过砂纸打磨微调音孔的大小来修正音准。上漆则使用天然生漆涂覆笛身表面,既能防潮防腐、延长竹笛的使用寿命,又能在反复打磨后呈现出温润如玉的光泽质感。整个制笛过程从选材到成品,短则十余天,长则月余,每一步都需要匠人的耐心与专注,正所谓"慢工出细活"。
在中国不同地域,竹笛制作形成了各具特色的流派和传统。玉屏箫笛产于贵州玉屏,是中国著名的竹笛产地之一,其制作工艺已被列入国家级非物质文化遗产名录。浙江的铜岭桥村素有"中国竹笛之乡"的美誉,全村以竹笛制作和销售为主要产业,年产量达数百万支。此外,上海、苏州、杭州等地的制笛名师也各有所长,形成了南派以曲笛为主、北派以梆笛为主的格局。曲笛较长、音色悠扬,适合演奏江南丝竹等婉约风格的曲目;梆笛较短、音色清脆,适合演奏北方戏曲和民间曲调。
历代竹笛演奏大师更是为竹笛艺术的发展做出了卓越贡献。陆春龄先生被誉为"中国笛王",其演奏风格细腻深沉、韵味悠长,创作并演奏了《鹧鸪飞》《今昔》等经典曲目。赵松庭先生是南派笛艺的代表人物,他在笛子制作、演奏技法和理论教学等方面均有建树,创立了竹笛教学的科学体系。俞逊发先生则以其精湛的演奏技艺和对笛艺创新的执着追求而闻名,其创作的《秋湖月夜》等曲目已成为竹笛曲库中的瑰宝。这些大师不仅提升了竹笛的艺术地位,也通过言传身教培养了一代又一代的竹笛演奏人才。
在当代数字化时代,如何将传统竹笛制作工艺与新兴的移动应用技术相结合,是一个值得探索的方向。本文所分析的 HarmonyOS ArkTS 应用,正是以竹笛制作为主题,通过精心设计的用户界面和丰富的数据模型,将竹笛的笛款管理、笛曲展示、吹奏技法、笛师传承、制作工序和订单管理等功能整合于一体,为传统竹笛文化的传播与推广提供了一个现代化的数字平台。接下来,我们将从代码层面逐段深入分析这个应用的技术实现细节。
二、色彩体系:ColorPalette 与 COLORS 的设计哲学

2.1 ColorPalette 接口定义
interface ColorPalette {
bg: string;
cardBg: string;
header1: string;
header2: string;
bambooA: string;
bambooB: string;
jade: string;
gold: string;
title: string;
sub: string;
text1: string;
text2: string;
text3: string;
accent: string;
hot: string;
cool: string;
danger: string;
tabBg: string;
tabOn: string;
mask: string;
}
这段代码定义了一个名为 ColorPalette 的接口,它是整个应用色彩体系的核心骨架。在 HarmonyOS ArkTS 的开发范式中,接口用于约束数据结构的形状,确保色彩常量在定义和使用时都遵循统一的类型规范。ColorPalette 接口中声明了二十一个字符串类型的字段,每一个字段都对应应用界面中的特定色彩角色。
从命名语义来看,这些色彩字段可以大致分为四组。第一组是基础背景色,包括 bg(页面主背景)和 cardBg(卡片背景),它们构成了界面的底色层。第二组是竹笛主题色,包括 header1、header2(头部渐变色)、bambooA、bambooB(竹子色)、jade(玉石色)、gold(金色),这些颜色直接服务于竹笛制作的主题视觉表达。第三组是文本色阶,包括 title(标题色)、sub(副标题色)、text1、text2、text3(三级文本色),形成了清晰的文本视觉层次。第四组是语义色,包括 accent(强调色)、hot(热色)、cool(冷色)、danger(危险色)、tabBg(标签栏背景)、tabOn(选中标签色)和 mask(遮罩色),这些颜色承担着交互状态和功能语义的传达。
使用接口而非普通对象来定义色彩规范,其优势在于类型安全和可维护性。当其他代码引用 ColorPalette 类型的变量时,编译器会自动检查字段名和类型的正确性,避免因拼写错误或类型不匹配导致的运行时错误。同时,如果未来需要扩展色彩体系——例如增加夜间模式或主题切换功能——只需创建新的 ColorPalette 实例即可,不会影响已有代码的类型安全。
2.2 COLORS 常量实例

const COLORS: ColorPalette = {
bg: '#F2F6E8',
cardBg: '#FFFFFF',
header1: '#1E4D3B',
header2: '#0F2B20',
bambooA: '#3E7C54',
bambooB: '#9ACD8E',
jade: '#7FB069',
gold: '#E3B23C',
title: '#EAF6E2',
sub: '#B9D8AE',
text1: '#1F3D2E',
text2: '#4A6B57',
text3: '#8FA893',
accent: '#C0392B',
hot: '#E3B23C',
cool: '#3E7C54',
danger: '#D9534F',
tabBg: '#1E4D3B',
tabOn: '#E3B23C',
mask: 'rgba(0,0,0,0.45)'
};
这段代码创建了一个 COLORS 常量,它是 ColorPalette 接口的具体实现。通过 const 关键字声明,确保这个色彩对象在应用整个生命周期中不会被重新赋值,从而保证色彩主题的一致性。每个颜色值都经过精心调配,形成了一套以竹绿色和暖金色为主调、兼具深沉与明快的色彩体系。
从色值分析来看,bg 为 #F2F6E8,这是一种极浅的青绿色,带有微弱的植物色调,营造出如同竹纸般的温润质感。cardBg 使用纯白色 #FFFFFF,确保卡片内容在浅色背景上拥有足够的对比度和清晰度。header1 为 #1E4D3B,这是一种深竹绿色,header2 为 #0F2B20,更深的墨绿色,两者通过线性渐变形成头部区域的深邃视觉效果,如同茂密竹林深处的光影。
bambooA(#3E7C54)和 bambooB(#9ACD8E)分别代表竹子的深色和浅色,在页面头部的竹笛图形中交替使用,模拟真实竹管的色彩变化。jade(#7FB069)是一种柔和的玉绿色,用于音域进度条等数据可视化元素。gold(#E3B23C)是一种温暖的琥珀金色,象征着竹笛上的金属镶件和传统文化中金玉交辉的审美意象。
文本色阶的设计遵循了从深到浅的渐进原则。text1(#1F3D2E)是最深的主文本色,接近深绿色,确保正文内容的可读性。text2(#4A6B57)是次要文本色,用于副标题和辅助说明。text3(#8FA893)是最浅的辅助文本色,用于标注和元数据信息。title(#EAF6E2)和 sub(#B9D8AE)则是专门为深色头部背景设计的浅色文本,在深绿渐变背景上保持清晰可读。
语义色方面,accent(#C0392B)是一种暗红色,用于价格金额和强调性文本,形成视觉焦点。hot 与 gold 共用 #E3B23C,用于"欢快"情绪标签和进度条。cool 与 bambooA 共用 #3E7C54,用于"悠扬"情绪标签和冷色调元素。danger(#D9534F)是标准的警示红色,用于删除按钮和危险提示。tabBg 和 tabOn 分别控制底部标签栏的背景色和选中色,形成深绿底色配金色高亮的导航交互效果。最后的 mask 使用 rgba(0,0,0,0.45) 半透明黑色,用于模态弹窗的背景遮罩。
2.3 色彩体系的整体设计理念

这套色彩体系的整体设计理念可以概括为"自然取色、层次分明、语义清晰"。所有颜色都从竹笛制作工艺的自然环境中提取——竹绿、玉翠、琥珀金、牛骨白,让用户在打开应用的第一时间就能感受到竹笛文化的自然之美。色彩层次从背景到前景、从主文本到辅助文本、从默认状态到交互状态,都经过精心编排,确保每个界面元素都有明确的视觉优先级。
值得一提的是,部分颜色字段存在复用关系。例如 hot 和 gold 指向同一色值,cool 和 bambooA 也完全相同。这种设计虽然从字面上看似乎冗余,但从语义角度考虑却是合理的——同一个颜色在不同语境中可能承载不同的含义。通过为每种用途定义独立的字段名,即使日后需要调整某一语义对应的颜色,也不会意外影响到其他语义。这是一种典型的"语义优先于实现"的设计策略,在大型应用的色彩管理中尤为重要。
三、标签系统:TabMeta 与 TAB_LIST 的结构设计

3.1 TabMeta 接口定义
interface TabMeta {
label: string;
icon: string;
}
TabMeta 接口定义了底部导航标签的元数据结构。它非常简洁,只包含两个字段:label 是标签的文字描述,icon 是标签的 Emoji 图标。虽然结构简单,但这个接口将标签的显示信息和数据结构解耦,使得标签内容的修改不会影响到标签的渲染逻辑。
在 HarmonyOS ArkTS 中,底部导航栏是移动应用中最常见的页面切换模式之一。通过将标签元数据抽象为接口,开发者可以灵活地增减标签数量、修改标签内容,甚至从远程配置动态加载标签列表,而无需修改组件代码中的渲染逻辑。这种数据驱动 UI 的设计思想是现代声明式 UI 框架的核心原则之一。
3.2 TAB_LIST 常量与标签内容

const TAB_LIST: TabMeta[] = [
{ label: '竹笛', icon: '🎋' },
{ label: '笛曲', icon: '🎵' },
{ label: '吹奏', icon: '🎶' },
{ label: '笛师', icon: '🧑🎤' },
{ label: '工序', icon: '📜' },
{ label: '订单', icon: '📦' }
];
TAB_LIST 常量定义了六个导航标签,覆盖了竹笛制作应用的全部功能模块。每个标签由一个 Emoji 图标和一个中文标签组成,直观地表达了对应模块的功能内容。"竹笛"标签对应竹笛产品展示,使用竹子图标;"笛曲"标签对应经典曲目展示,使用音符图标;"吹奏"标签对应吹奏技法介绍,使用多个音符的图标;"笛师"标签对应历代笛师介绍,使用歌唱者图标;"工序"标签对应制笛工艺流程,使用卷轴图标;"订单"标签对应订单管理,使用包裹图标。
使用 Emoji 作为标签图标是一个有趣的设计选择。相比传统的矢量图标或位图资源,Emoji 具有无需额外资源文件、跨平台一致性高、色彩丰富等优点。在 HarmonyOS 系统中,Emoji 字符能够被正常渲染并保持较好的视觉效果。不过需要注意,不同操作系统版本和设备厂商可能对 Emoji 的渲染样式存在细微差异,因此在生产环境中使用 Emoji 图标时需要做充分的兼容性测试。
3.3 标签索引常量

const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const FLUTE_IDX: number[] = [0, 1, 2];
const HOLE_IDX: number[] = [0, 1, 2, 3, 4];
const TASSEL_IDX: number[] = [0, 1, 2, 3];
const NOTE_IDX: number[] = [0, 1, 2, 3, 4];
这段代码定义了六个索引数组常量,它们在页面的不同视觉区域中控制循环迭代的次数和范围。ROW1_IDX 和 ROW2_IDX 分别对应底部导航栏第一行和第二行的标签索引,将六个标签平均分成两行三列的布局。这种将索引列表单独定义为常量的做法,使得布局结构的调整变得非常方便——如果需要改为三行两列或其他排列方式,只需修改这两个数组即可。
FLUTE_IDX、HOLE_IDX、TASSEL_IDX 和 NOTE_IDX 这四个索引数组则服务于页面头部装饰图形的绘制。FLUTE_IDX 控制绘制三支竹笛的管身,HOLE_IDX 控制每支竹笛上五个音孔的位置,TASSEL_IDX 控制四根流苏的摆动,NOTE_IDX 控制五个音符的飘浮动画。通过这些索引常量,头部装饰区域的复杂图形被分解为多个可管理的循环渲染单元,每个单元负责一种装饰元素,代码结构清晰、易于维护。
四、辅助函数:位置计算与视觉映射
4.1 竹笛位置计算函数
function fluteY(i: number): number {
return 28 + i * 31;
}
function fluteLen(i: number): number {
return 204 - i * 30;
}
function fluteX(i: number): number {
return (340 - fluteLen(i)) / 2;
}
这三个函数共同构成了页面头部竹笛图形的位置计算系统。fluteY(i) 根据竹笛索引 i 计算其垂直位置,第一支竹笛在 y=28 处,每支之间间隔 31 像素,形成了自上而下依次排列的三支竹笛。fluteLen(i) 计算竹笛的长度,第一支 204 像素,每支递减 30 像素,使得三支竹笛呈现出由长到短的视觉层次,模拟了不同调性竹笛的长度差异——实际上曲笛(较长)和梆笛(较短)的长度确实存在显著差异。
fluteX(i) 则根据竹笛长度计算其水平居中位置,通过 (340 - fluteLen(i)) / 2 的公式确保每支竹笛在 340 像素宽的头部区域内水平居中对齐。这种基于参数化函数的位置计算方式具有极大的灵活性,如果需要调整竹笛的数量、间距或大小,只需修改函数中的常数即可,所有相关元素的位置会自动重新计算,无需逐个手动调整。
4.2 音孔与流苏位置函数
function holeX(i: number, j: number): number {
return fluteX(i) + 26 + j * ((fluteLen(i) - 52) / 5);
}
function holeY(i: number): number {
return fluteY(i) + 15;
}
function tasselX(j: number): number {
return 318 + j * 9;
}
function tasselY(j: number): number {
return 106 + j * 22;
}
holeX(i, j) 函数计算第 i 支竹笛上第 j 个音孔的水平位置。这个函数的参数有两个维度:竹笛索引 i 和音孔索引 j。计算公式 fluteX(i) + 26 + j * ((fluteLen(i) - 52) / 5) 中,fluteX(i) + 26 确定第一个音孔距离竹笛左端 26 像素的起始位置,(fluteLen(i) - 52) / 5 计算五个音孔之间的等间距——从竹笛总长度中减去两端的 52 像素(每端 26 像素),然后均分为五段。这种等距分布的音孔排列虽然不完全符合真实竹笛的音孔间距规律,但在视觉上达到了整齐美观的效果。
holeY(i) 简单地将音孔的垂直位置设定为竹笛管身下方 15 像素处,使音孔位于管身的中央偏下位置。tasselX(j) 和 tasselY(j) 则分别计算四根流苏的水平和垂直位置,流苏从坐标 (318, 106) 开始,每根间隔 9 像素水平、22 像素垂直,形成斜向下垂的排列方式,模拟竹笛末端悬挂的中国结流苏装饰。
4.3 音符与数据条函数
function noteX(i: number): number {
return 44 + i * 58;
}
function noteY(i: number): number {
return 14 + (i % 2) * 15;
}
function noteSize(i: number): number {
return 13 + (i % 3) * 2;
}
这三个函数控制页面头部飘浮音符的位置和大小。noteX(i) 以 58 像素的间距水平排列五个音符。noteY(i) 使用 i % 2 实现高低交替的排列效果——偶数索引的音符在 y=14 处,奇数索引的在 y=29 处,形成波浪状的飘浮轨迹。noteSize(i) 使用 i % 3 实现大小渐变——13、15、17 三种字号循环出现,增加了视觉层次感。
这种利用取模运算(%)生成周期性变化模式的手法,在 UI 装饰元素的排列中非常实用。它能够在少量代码中实现丰富的视觉变化,避免所有元素完全一致导致的呆板感。
function pitchBarW(p: number): number {
return 18 + p * 2.4;
}
function levelBarW(v: number): number {
return 16 + v * 1.1;
}
function tempoBarW(t: number): number {
return 16 + t * 1.3;
}
function orderBarH(a: number): number {
return 24 + Math.min(a, 50000) / 500;
}
这四个函数将数据值映射为可视化条的宽度或高度。pitchBarW(p) 将音域值(6-24)映射为 32.4-75.6 像素的进度条宽度。levelBarW(v) 将掌握度百分比(0-100)映射为 16-126 像素的宽度。tempoBarW(t) 将节奏值(40-160)映射为 68-224 像素的宽度。orderBarH(a) 将订单金额映射为柱状图高度,使用 Math.min(a, 50000) 限制最大值不超过 50000,避免过大的数值导致柱状图溢出。
这些映射函数体现了数据可视化中的一个重要原则:将抽象的数据值转换为直观的视觉量度。通过线性映射的方式,用户可以通过观察进度条的长度或柱状图的高度,快速感知数据的大小关系,而无需仔细阅读具体数字。orderBarH 中的上限保护机制也值得注意,它防止了极端值对整体图表比例的破坏。
4.4 颜色映射函数
function gradeColor(g: string): string {
if (g === '入门') {
return COLORS.jade;
}
if (g === '进阶') {
return COLORS.hot;
}
if (g === '高级') {
return COLORS.accent;
}
return COLORS.cool;
}
function moodColor(m: string): string {
if (m === '欢快') {
return COLORS.hot;
}
if (m === '悠扬') {
return COLORS.cool;
}
if (m === '激昂') {
return COLORS.accent;
}
return COLORS.jade;
}
function titleColor(t: string): string {
if (t === '大师') {
return COLORS.accent;
}
if (t === '名家') {
return COLORS.hot;
}
return COLORS.cool;
}
function stepColor(s: number): string {
if (s <= 2) {
return COLORS.jade;
}
if (s <= 5) {
return COLORS.hot;
}
return COLORS.accent;
}
这四个函数实现了从语义标签到视觉颜色的映射逻辑。gradeColor 将笛曲的难度等级(入门、进阶、高级)分别映射为玉绿色、金色和暗红色,颜色从冷到暖、从淡到浓,暗示难度的递增。moodColor 将曲目情绪(欢快、悠扬、激昂)映射为相应的色彩——欢快用暖金色、悠扬用竹绿色、激昂用暗红色,颜色与情绪的心理感受相匹配。
titleColor 将笛师的头衔(大师、名家)映射为暗红色和金色,大师使用更强烈的 accent 红色以突出其至高的地位。stepColor 则根据工序序号返回不同颜色——前三道工序用玉绿色(初期阶段)、中间工序用金色(中期阶段)、后三道工序用暗红色(收尾阶段),通过颜色的渐变暗示制笛工序的推进节奏。
这些颜色映射函数的设计体现了一个重要的 UI 设计原则:语义化的色彩编码。通过将颜色与特定的含义建立稳定的对应关系,用户在浏览不同条目时能够通过颜色快速识别其属性,降低了认知负荷。同时,将映射逻辑封装在独立函数中,也避免了在 UI 代码中散布大量的条件判断语句,提升了代码的可读性和可维护性。
五、数据模型:@Observed 类的深度解析
5.1 BambooFluteItem 竹笛产品模型
@Observed
export class BambooFluteItem {
name: string;
material: string;
len: string;
pitch: number;
price: number;
constructor(name: string, material: string, len: string, pitch: number, price: number) {
this.name = name;
this.material = material;
this.len = len;
this.pitch = pitch;
this.price = price;
}
}
BambooFluteItem 是竹笛产品的数据模型,使用 @Observed 装饰器标记。在 HarmonyOS ArkTS 的状态管理框架中,@Observed 装饰器的作用是使类的实例成为可观察对象——当实例的属性值发生变化时,框架能够自动检测到变化并触发与之绑定的 UI 组件重新渲染。这对于实现数据驱动的响应式界面至关重要。
这个类包含五个属性:name 是竹笛名称,如"紫竹笛"“湘妃竹笛"等;material 是制作材料,如"紫竹”“苦竹"等;len 是笛身长度,以字符串形式存储(如"65cm”)而非纯数字,这是因为长度信息在 UI 中主要作为文本展示,不需要参与数值计算;pitch 是音域值,用数字表示,用于驱动音域进度条的宽度计算;price 是价格,同样用数字存储,用于价格显示和排序。
构造函数采用了经典的参数注入方式,在实例化时将所有属性一次性赋值。这种设计确保了对象的不可变性倾向——虽然 @Observed 允许属性被修改(这正是观察机制生效的前提),但在创建对象时提供完整的数据集合,使得对象的初始状态明确且可预测。在实际应用中,BambooFluteItem 的实例通过 @State flutes 数组管理,当用户通过"新增竹笛"弹窗添加新产品时,新的 BambooFluteItem 实例会被 push 到数组中,触发列表的重新渲染。
5.2 TuneItem 笛曲模型
@Observed
export class TuneItem {
name: string;
style: string;
tempo: number;
mood: string;
grade: string;
constructor(name: string, style: string, tempo: number, mood: string, grade: string) {
this.name = name;
this.style = style;
this.tempo = tempo;
this.mood = mood;
this.grade = grade;
}
}
TuneItem 是笛曲的数据模型,同样使用 @Observed 装饰。它包含五个属性:name 是曲目名称,如"姑苏行"“鹧鸪飞"等经典笛曲;style 是演奏风格流派,如"南派”“北派”“民间"等,反映了中国竹笛艺术的流派分野;tempo 是节奏速度(BPM),数值范围通常在 40-160 之间,用于驱动节奏进度条的可视化;mood 是曲目的情绪特征,如"欢快”“悠扬”“激昂"等,通过 moodColor 函数映射为不同的标签颜色;grade 是难度等级,取值为"入门”“进阶”"高级"之一,通过 gradeColor 函数映射颜色。
这个模型的一个特别之处在于 tempo 属性是可变的。在应用的"编辑笛曲"弹窗中,用户可以通过"加快节奏 +10"和"放缓节奏 -10"按钮调整选中曲目的节奏值。由于 TuneItem 使用了 @Observed 装饰器,当 tempo 属性被修改时(如 this.selTune.tempo = Math.min(160, this.selTune.tempo + 10)),框架会自动检测到变化并更新与之绑定的节奏进度条宽度,实现了即时的视觉反馈。这种响应式数据绑定是 @Observed 装饰器的核心价值所在。
5.3 SkillItem 吹奏技法模型
@Observed
export class SkillItem {
name: string;
tech: string;
level: number;
years: number;
desc: string;
constructor(name: string, tech: string, level: number, years: number, desc: string) {
this.name = name;
this.tech = tech;
this.level = level;
this.years = years;
this.desc = desc;
}
}
SkillItem 是吹奏技法的数据模型。name 是技法名称,如"气振音"“颤音”“滑音"等,这些技法名称直接来自竹笛演奏的专业术语体系。tech 是技法分类,取值为"气息”“指法”"舌法"之一,反映了竹笛演奏中气息控制、手指技巧和舌头技巧三大技术体系。level 是掌握度百分比,数值范围 0-100,通过 levelBarW 函数映射为进度条宽度,同时根据是否达到 85 分阈值决定使用红色还是金色进度条。
years 是练习年限,以年为单位,反映了技法修炼需要长期坚持的特性。desc 是技法的简要描述,如"长音震颤""快速打指"等,用简练的文字概括了每种技法的核心要领。这些描述信息不仅为用户提供了技法认知的参考,也让整个技法列表更加生动具体。
5.4 MasterItem 笛师模型
@Observed
export class MasterItem {
name: string;
title: string;
age: number;
pieces: number;
skill: number;
constructor(name: string, title: string, age: number, pieces: number, skill: number) {
this.name = name;
this.title = title;
this.age = age;
this.pieces = pieces;
this.skill = skill;
}
}
MasterItem 是笛师信息的数据模型。name 是笛师姓名,如"陆春龄"“赵松庭"等中国竹笛界的泰斗级人物。title 是头衔,取值为"大师"或"名家”,通过 titleColor 函数映射不同的标签颜色——大师用暗红色,名家用金色。age 是年龄,这些数据大多取自笛师生前的长寿年龄或当前年龄,体现了大师们毕生奉献于笛艺的精神。pieces 是代表作品数量,反映笛师的艺术创作丰富度。skill 是技艺评分,数值范围 0-100,通过 levelBarW 函数映射为进度条宽度,让用户直观感知各位笛师的技艺水平高低。
5.5 StepItem 制笛工序模型
@Observed
export class StepItem {
name: string;
days: number;
tool: string;
note: string;
seq: number;
constructor(name: string, days: number, tool: string, note: string, seq: number) {
this.name = name;
this.days = days;
this.tool = tool;
this.note = note;
this.seq = seq;
}
}
StepItem 是制笛工序的数据模型。name 是工序名称,如"选竹"“烤竹校直”“开吹孔"等,完整覆盖了竹笛制作的八大工序。days 是该工序所需天数,反映了制笛工艺中不同环节的时间投入差异。tool 是使用的工具,如"目测·敲击”“炭火·校直架"等,让用户了解每道工序的工具配置。note 是工序要点说明,如"三年老竹”"去弯定型"等,用简练的文字传达工序的关键要求。seq 是工序序号(1-8),通过 stepColor 函数映射不同颜色,形成从浅到深的视觉递进效果。
5.6 OrderItem 订单模型
@Observed
export class OrderItem {
name: string;
buyer: string;
amount: number;
count: number;
month: string;
constructor(name: string, buyer: string, amount: number, count: number, month: string) {
this.name = name;
this.buyer = buyer;
this.amount = amount;
this.count = count;
this.month = month;
}
}
OrderItem 是订单数据模型,也是应用中唯一支持删除操作的数据实体。name 是订单对应的竹笛产品名称,buyer 是买方名称(如"民乐团"“国乐工作室"等),amount 是订单总金额(以元为单位),count 是订购数量(以支为单位),month 是订单月份(格式如"2026-08”)。amount 属性同时被用于柱状图高度计算(通过 orderBarH 函数)和金额文本展示,是数据可视化和文本展示相结合的典型案例。
由于 OrderItem 使用了 @Observed 装饰器,当用户在删除弹窗中确认删除某个订单时,通过 this.orders.splice(this.orders.indexOf(this.selOrder), 1) 操作数组后,框架能够自动检测到数组变化并移除对应的列表项和柱状图柱条,实现了数据与视图的自动同步。这种响应式机制大大简化了删除操作的实现复杂度——开发者只需修改数据源,无需手动操作 DOM 或组件树。
六、状态管理:@State 变量的深度解析
6.1 页面状态变量声明
@Entry
@Component
struct FlutePage {
@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State selTune: TuneItem | null = null;
@State selOrder: OrderItem | null = null;
@State formName: string = '';
@State formMaterial: string = '';
@State formPrice: string = '';
这段代码声明了 FlutePage 组件的入口标记和前十个状态变量。@Entry 装饰器标记该组件为应用的入口页面,@Component 装饰器声明这是一个 ArkUI 声明式组件。struct FlutePage 使用结构体语法定义组件,这是 HarmonyOS ArkTS 特有的组件声明方式——与传统的 class 组件不同,struct 组件更加轻量,专门为声明式 UI 优化。
@State 装饰器是 ArkTS 状态管理体系中最基础也最重要的装饰器之一。被 @State 修饰的变量成为组件的响应式状态——当其值发生变化时,框架会自动触发组件的重新渲染,更新所有引用该状态的 UI 元素。curTab 是当前选中的标签页索引,初始值为 0(即默认显示"竹笛"页),它的变化驱动着整个内容区域的切换。breath 是一个布尔型"呼吸"状态,通过定时器每 480 毫秒翻转一次,驱动页面头部音符飘浮、流苏摆动、金光闪烁等动画效果。
showAdd、showEdit、showDel 三个布尔变量分别控制三个模态弹窗(新增竹笛、编辑笛曲、删除订单)的显示与隐藏。这种使用独立布尔变量管理弹窗可见性的方式简单直观,每次只有一个弹窗处于显示状态。selTune 和 selOrder 使用了联合类型 TuneItem | null 和 OrderItem | null,初始值为 null,表示尚未选中任何条目;当用户点击编辑或删除操作时,被选中的数据对象会被赋值给这些变量,供模态弹窗中读取和操作。formName、formMaterial、formPrice 是新增竹笛表单中的三个输入字段,以字符串形式存储用户输入的文本。
6.2 数据数组状态
@State flutes: BambooFluteItem[] = [
new BambooFluteItem('紫竹笛', '紫竹', '65cm', 22, 680),
new BambooFluteItem('湘妃竹笛', '湘妃竹', '58cm', 19, 1280),
new BambooFluteItem('苦竹笛', '苦竹', '52cm', 16, 460),
new BambooFluteItem('凤眼竹笛', '凤眼竹', '60cm', 20, 880),
new BambooFluteItem('白竹笛', '白竹', '55cm', 18, 520),
new BambooFluteItem('斑竹笛', '斑竹', '63cm', 21, 760),
new BambooFluteItem('玉屏箫笛', '玉屏竹', '49cm', 15, 1580),
new BambooFluteItem('曲笛', '苦竹', '60cm', 20, 980),
new BambooFluteItem('梆笛', '紫竹', '46cm', 14, 640),
new BambooFluteItem('长笛', '紫竹', '72cm', 24, 1380),
new BambooFluteItem('骨笛', '鹤骨', '21cm', 6, 29800),
new BambooFluteItem('排箫笛', '黄竹', '40cm', 12, 860)
];
flutes 数组是竹笛产品的数据源,初始包含十二条竹笛记录。每条记录通过 BambooFluteItem 构造函数创建,传入名称、材质、长度、音域和价格五个参数。这些数据涵盖了紫竹、湘妃竹、苦竹、凤眼竹、白竹、斑竹、玉屏竹、黄竹等多种竹材,以及鹤骨这一特殊材质(骨笛),还包含了曲笛、梆笛、长笛、排箫笛等不同形制的竹笛品种。
价格数据的设计颇有深意。普通竹笛价格在 460-1580 元之间,而骨笛高达 29800 元——这反映了真实市场中骨笛作为远古乐器的复制品或收藏品,其价格远超普通竹笛。音域值从 6(骨笛)到 24(长笛)不等,数值越大表示音域越广,在 UI 中对应的进度条也越长。这些精心设计的数据不仅使应用看起来内容丰富、真实可信,也为用户了解不同竹笛品种的特性提供了有价值的参考信息。
@State tunes: TuneItem[] = [
new TuneItem('姑苏行', '南派', 88, '悠扬', '高级'),
new TuneItem('鹧鸪飞', '南派', 76, '悠扬', '高级'),
new TuneItem('扬鞭催马运粮忙', '北派', 120, '欢快', '进阶'),
new TuneItem('牧民新歌', '北派', 132, '欢快', '进阶'),
new TuneItem('喜相逢', '北派', 96, '欢快', '入门'),
new TuneItem('荫中鸟', '北派', 140, '激昂', '高级'),
new TuneItem('小放牛', '民间', 72, '悠扬', '入门'),
new TuneItem('三五七', '浙江', 108, '欢快', '进阶'),
new TuneItem('早晨', '南派', 84, '悠扬', '进阶'),
new TuneItem('欢乐歌', '江南丝竹', 100, '欢快', '入门')
];
tunes 数组包含十首经典笛曲,涵盖了南派、北派、民间、浙江、江南丝竹等多种风格流派。曲目名称如"姑苏行"“鹧鸪飞”“牧民新歌”"喜相逢"等都是中国竹笛曲目中的经典之作,在现实中广为流传。节奏值从 72(小放牛)到 140(荫中鸟)不等,反映了不同曲目的速度特征——慢板曲目的悠扬意境与快板曲目的欢快气氛形成鲜明对比。
@State skills: SkillItem[] = [
new SkillItem('气振音', '气息', 92, 6, '长音震颤'),
new SkillItem('颤音', '指法', 88, 4, '快速打指'),
new SkillItem('滑音', '指法', 85, 3, '音阶滑动'),
new SkillItem('花舌', '舌法', 80, 5, '舌尖快速颤动'),
new SkillItem('垛音', '气息', 76, 4, '急促短音'),
new SkillItem('历音', '指法', 90, 5, '全孔扫掠'),
new SkillItem('吐音', '舌法', 84, 3, '断奏颗粒'),
new SkillItem('泛音', '气息', 72, 4, '超吹高音')
];
skills 数组包含八种竹笛吹奏技法,按气息、指法、舌法三大类别分布。气振音的掌握度最高(92%),泛音最低(72%),反映了不同技法的学习难度差异。描述文字如"长音震颤"“快速打指”"全孔扫掠"等,用极简的语言概括了每种技法的动作要领。
@State masters: MasterItem[] = [
new MasterItem('陆春龄', '大师', 96, 320, 98),
new MasterItem('赵松庭', '大师', 92, 280, 97),
new MasterItem('俞逊发', '大师', 84, 210, 96),
new MasterItem('刘管乐', '名家', 88, 180, 93),
new MasterItem('曾永清', '名家', 78, 150, 91),
new MasterItem('王铁锤', '名家', 82, 140, 90),
new MasterItem('詹永明', '名家', 64, 110, 88),
new MasterItem('张维良', '名家', 60, 120, 89)
];
masters 数组收录了八位竹笛名家,其中陆春龄、赵松庭、俞逊发被标记为"大师",其余为"名家"。这些人物都是中国竹笛艺术史上真实存在的杰出代表,他们的技艺评分从 98 分(陆春龄)到 88 分(詹永明)不等。作品数量最高的是陆春龄的 320 首,体现了他作为"中国笛王"的丰富艺术创作。
@State steps: StepItem[] = [
new StepItem('选竹', 3, '目测 · 敲击', '三年老竹', 1),
new StepItem('烤竹校直', 1, '炭火 · 校直架', '去弯定型', 2),
new StepItem('开吹孔', 1, '小刀 · 圆锉', '椭圆吹孔', 3),
new StepItem('开膜孔', 1, '钻头', '贴笛膜', 4),
new StepItem('开音孔', 2, '钻头 · 音准仪', '六音孔', 5),
new StepItem('镶骨笛头', 1, '牛骨 · 胶', '头尾加固', 6),
new StepItem('调音', 2, '音准仪 · 砂纸', '逐孔校准', 7),
new StepItem('上漆', 1, '生漆 · 绒布', '防潮抛光', 8)
];
steps 数组定义了制笛的八大工序,从选竹到上漆完整覆盖了竹笛制作的全流程。工序天数总和为 12 天,体现了手工制笛虽然工序繁多但每道工序都有其不可替代的作用。工具和要点信息为用户提供了制笛工艺的概览,让他们了解到一支好竹笛的诞生需要经过多少道精细的手工步骤。
@State orders: OrderItem[] = [
new OrderItem('紫竹笛', '民乐团', 20400, 30, '2026-08'),
new OrderItem('湘妃竹笛', '国乐工作室', 25600, 20, '2026-07'),
new OrderItem('苦竹笛', '琴行', 13800, 30, '2026-08'),
new OrderItem('凤眼竹笛', '文创店', 17600, 20, '2026-06'),
new OrderItem('梆笛', '戏曲团', 12800, 20, '2026-07'),
new OrderItem('曲笛', '音乐学院', 19600, 20, '2026-05'),
new OrderItem('玉屏箫笛', '非遗展馆', 15800, 10, '2026-08'),
new OrderItem('长笛', '乐团定制', 27600, 20, '2026-06'),
new OrderItem('排箫笛', '研学基地', 8600, 10, '2026-04'),
new OrderItem('斑竹笛', '直播电商', 15200, 20, '2026-05')
];
orders 数组包含十条订单记录,买方涵盖了民乐团、国乐工作室、琴行、文创店、戏曲团、音乐学院、非遗展馆、乐团定制、研学基地、直播电商等多种客户类型,反映了竹笛市场的多元化需求。订单金额从 8600 元到 27600 元不等,月份跨越 2026 年 4 月至 8 月,为柱状图提供了丰富的数据基础。这些订单数据支持删除操作,用户可以通过点击每条订单右侧的"删除"按钮将其移除。
6.3 生命周期函数
aboutToAppear(): void {
setInterval(() => {
this.breath = !this.breath;
}, 480);
}
aboutToAppear 是 ArkUI 组件的生命周期回调函数,在组件创建后、UI 渲染前被调用。这里使用 setInterval 设置了一个每 480 毫秒执行一次的定时器,在每次回调中将 breath 状态值取反。由于 breath 被 @State 装饰器修饰,其值变化会触发所有引用该状态的 UI 元素重新渲染——包括音符的透明度和旋转角度、流苏的摆动方向、金色圆球的缩放和透明度、柱状图的颜色切换等。
这种通过定时器驱动的"呼吸"动画机制是一种轻量级的实现方式。相比使用 ArkUI 的 animateTo 或 animation 属性,setInterval 方式虽然需要在 JavaScript 层面频繁切换状态值,但由于间隔设置为 480 毫秒,频率并不算高,对性能的影响可以忽略不计。同时,配合各元素上设置的 .animation({ duration: ..., iterations: -1, playMode: PlayMode.Alternate }) 属性,能够在状态切换之间产生平滑的过渡动画效果。
七、UI 组件:@Builder 方法的逐段深度解析
7.1 modalOverlay 遮罩层构建器
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
modalOverlay 是一个通用的模态遮罩层构建器,它接收一个 onClose 回调函数作为参数。构建器内部创建了一个占满全屏的 Column 容器,背景色设为 COLORS.mask(即 rgba(0,0,0,0.45) 半透明黑色),并绑定了点击事件——当用户点击遮罩区域时,调用传入的 onClose 回调关闭弹窗。
这个构建器体现了 ArkUI 中 @Builder 的参数化设计模式。通过将关闭逻辑作为回调函数传入,modalOverlay 可以被多个模态弹窗复用——新增弹窗传入关闭新增的回调、编辑弹窗传入关闭编辑的回调、删除弹窗传入关闭删除的回调。这种设计遵循了"开放-封闭"原则:遮罩层本身对扩展开放(可以被任何弹窗复用),对修改封闭(遮罩层的渲染逻辑不需要因调用方的不同而改变)。点击遮罩关闭弹窗是移动端常见的交互模式,它为用户提供了一种直觉化的弹窗关闭方式,降低了操作门槛。
7.2 pageHeader 页面头部构建器
@Builder
pageHeader() {
Column() {
Stack() {
Column()
.width('100%')
.height('100%')
.borderRadius(22)
.linearGradient({
angle: 135,
colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
})
pageHeader 是整个应用中最复杂、最精美的构建器,它负责渲染页面顶部的装饰性头部区域。构建器的外层是一个 Column 容器,宽度占满父容器。内部嵌套一个 Stack 堆叠容器,Stack 的特性是所有子元素以左上角为原点堆叠在一起,后面的元素覆盖在前面的元素之上。
Stack 内部的第一个子元素是一个填满整个区域的 Column,它设置了 22 像素的圆角和 135 度角度的线性渐变。渐变从 COLORS.header1(#1E4D3B 深竹绿)到 COLORS.header2(#0F2B20 墨绿),135 度的角度意味着渐变方向从左上角到右下角,营造出如同从竹叶间隙透过的光线效果。这个渐变背景层是头部视觉的基础,后续所有装饰元素都将堆叠在其之上。
ForEach(NOTE_IDX, (i: number) => {
Text(i % 2 === 0 ? '♪' : '♫')
.fontSize(noteSize(i))
.fontColor(COLORS.bambooB)
.opacity(this.breath ? 0.5 : 1)
.position({ x: noteX(i), y: noteY(i) })
.rotate({ angle: this.breath ? 8 : -8 })
.animation({ duration: 900, iterations: -1, playMode: PlayMode.Alternate })
}, (i: number) => 'n' + i)
紧接着渐变背景层的是音符飘浮层。通过 ForEach 遍历 NOTE_IDX 数组(即 [0, 1, 2, 3, 4]),为每个索引渲染一个音乐符号 Text 组件。偶数索引显示 ♪(单音符),奇数索引显示 ♫(双音符),通过 noteSize(i) 函数设置不同的字号(13、15、17 像素交替),通过 noteX(i) 和 noteY(i) 函数设置高低波浪状的位置排列。
音符的动画效果由 breath 状态驱动:透明度在 0.5 和 1 之间切换,旋转角度在 8 度和 -8 度之间交替。.animation 属性配置了 900 毫秒的过渡时长、无限次重复(iterations: -1)和交替播放模式(PlayMode.Alternate),使得音符在每次 breath 状态翻转时产生平滑的摆动和闪烁效果,如同音符在空气中飘浮舞动。ForEach 的第三个参数 'n' + i 是键值生成函数,确保每个音符在列表更新时拥有稳定的唯一标识,避免不必要的重渲染。
ForEach(FLUTE_IDX, (i: number) => {
Stack() {
Column()
.width(fluteLen(i))
.height(12)
.borderRadius(6)
.backgroundColor(i === 1 ? COLORS.bambooB : COLORS.bambooA)
.position({ x: fluteX(i), y: fluteY(i) })
ForEach(HOLE_IDX, (j: number) => {
Column()
.width(j === 0 ? 4 : 3)
.height(j === 0 ? 4 : 3)
.borderRadius(2)
.backgroundColor(COLORS.header2)
.position({ x: holeX(i, j), y: holeY(i) })
}, (j: number) => 'h' + i + j)
Column()
.width(6)
.height(12)
.borderRadius(3)
.backgroundColor(COLORS.gold)
.position({ x: fluteX(i) - 3, y: fluteY(i) })
Column()
.width(6)
.height(12)
.borderRadius(3)
.backgroundColor(COLORS.gold)
.position({ x: fluteX(i) + fluteLen(i) - 3, y: fluteY(i) })
}
}, (i: number) => 'f' + i)
这是竹笛图形的渲染层。外层 ForEach 遍历 FLUTE_IDX(即 [0, 1, 2]),为每支竹笛创建一个 Stack 容器。Stack 内部首先渲染竹笛管身——一个 Column,宽度由 fluteLen(i) 计算(204、174、144 像素递减),高度 12 像素,6 像素圆角,背景色在 bambooA(深竹绿)和 bambooB(浅竹绿)之间交替,通过 fluteX(i) 和 fluteY(i) 定位。
管身之上叠加音孔层——内层 ForEach 遍历 HOLE_IDX(即 [0, 1, 2, 3, 4]),为每个音孔创建一个小圆点 Column。第一个音孔(j === 0)稍大(4x4 像素),其余为 3x3 像素,模拟吹孔大于音孔的真实形态。音孔使用 COLORS.header2 深绿色,通过 holeX(i, j) 和 holeY(i) 定位在管身上。
管身两端还各有一个金色 Column(6x12 像素,3 像素圆角),分别定位在竹笛管身的左端和右端,模拟竹笛两端镶骨或金属箍的装饰效果。这种多层 ForEach 嵌套加上精确定位的渲染方式,虽然代码量较大,但实现了高度还原的竹笛图形效果。
ForEach(TASSEL_IDX, (j: number) => {
Column()
.width(2)
.height(18)
.backgroundColor(COLORS.accent)
.borderRadius(1)
.position({ x: tasselX(j), y: tasselY(j) })
.rotate({ angle: this.breath ? 6 : -6, centerX: 1, centerY: 0 })
.animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
}, (j: number) => 't' + j)
流苏摆动层渲染四根红色细线(2x18 像素),通过 tasselX(j) 和 tasselY(j) 定位在竹笛右下方区域。每根流苏的旋转角度在 6 度和 -6 度之间交替,旋转中心设在顶部(centerX: 1, centerY: 0),模拟流苏从固定点自然摆动的物理效果。动画时长 700 毫秒,与音符的 900 毫秒形成差异,避免了所有动画完全同步的呆板感。
Column()
.width(46)
.height(46)
.borderRadius(23)
.backgroundColor(COLORS.gold)
.opacity(this.breath ? 0.65 : 1)
.scale({ x: this.breath ? 1.12 : 1, y: this.breath ? 1.12 : 1 })
.position({ x: 296, y: 10 })
.animation({ duration: 620, iterations: -1, playMode: PlayMode.Alternate })
Text('笛')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.header2)
.position({ x: 307, y: 22 })
金色光球层是一个 46x46 像素的圆形(borderRadius: 23),金色背景,通过 breath 状态驱动透明度(0.65 到 1)和缩放(1 到 1.12 倍)的双重动画,配合 620 毫秒的过渡时长,呈现出如同月光般柔和脉动的光球效果。光球上方叠放一个"笛"字(20 像素粗体,深绿色),位于光球的中心偏上位置,形成了应用的品牌标识——一个金色光晕中"笛"字的视觉效果,既美观又点明了应用主题。
Column() {
Text('竹笛坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('笛韵悠扬 · 一管清音传千年')
.fontSize(11)
.fontColor(COLORS.sub)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.position({ x: 18, y: 12 })
标题信息层位于头部左上角(x=18, y=12),包含两行文字:主标题"竹笛坊"(20 像素粗体,浅绿色 COLORS.title)和副标题"笛韵悠扬·一管清音传千年"(11 像素,COLORS.sub 副标题色)。副标题中的"一管清音传千年"既是对竹笛音色的诗意描述,也暗含了竹笛作为千年传承乐器的文化底蕴。alignItems(HorizontalAlign.Start) 确保两行文字左对齐。
Row() {
Text('12')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('笛款')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 18, y: 58 })
Row() {
Text('10')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('名曲')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 18, y: 80 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('技法')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 118, y: 58 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('工序')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 118, y: 80 })
}
.width('100%')
.height(128)
}
.width('100%')
}
统计信息层由四个 Row 组成,分别显示"12 笛款"“10 名曲”“8 技法”"8 工序"的统计数据。这些数字与 flutes(12条)、tunes(10条)、skills(8条)、steps(8条)数组的实际长度一致。四个统计信息以 2x2 网格排列在头部下半部分,左列在 x=18 处,右列在 x=118 处,上行 y=58,下行 y=80。每个统计信息由一个大号粗体数字(15 像素,COLORS.title)和一个小号标签(9 像素,COLORS.sub)组成,形成了简洁明了的数据看板效果。
整个 pageHeader 构建器通过 Stack 堆叠了七层视觉元素——渐变背景、音符飘浮、竹笛图形、流苏摆动、金色光球、标题信息、统计信息——在 128 像素高的区域内构建了一个信息密集、动画丰富、层次分明的头部区域,充分展示了 ArkUI 声明式 UI 的表现力。
7.3 fluteRow 竹笛列表行构建器
@Builder
fluteRow(item: BambooFluteItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Column() {
Text(item.material + ' · ' + item.len)
.fontSize(10)
.fontColor(COLORS.text3)
Row() {
Text('音域')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(pitchBarW(item.pitch))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.jade)
.margin({ left: 6 })
Text(item.pitch + '音')
.fontSize(9)
.fontColor(COLORS.cool)
.margin({ left: 6 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('¥' + item.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
fluteRow 构建器负责渲染竹笛列表中的每一行。它接收一个 BambooFluteItem 参数,将竹笛的名称、材质、长度、音域和价格信息组织在一个卡片式的 Row 容器中。
行内布局从左到右分为三个部分。第一部分是竹笛名称(item.name),14 像素粗体,使用主文本色 COLORS.text1,作为行内最醒目的信息。第二部分是一个 Column 容器,通过 layoutWeight(1) 占据剩余空间,内部包含两行信息:上方是材质和长度的组合文本(如"紫竹·65cm"),10 像素,使用辅助文本色 COLORS.text3;下方是一个 Row,包含"音域"标签、音域进度条和音域数值。进度条的宽度由 pitchBarW(item.pitch) 函数计算,高度 6 像素,使用玉石色 COLORS.jade,左右各留 6 像素间距。第三部分是价格信息,12 像素粗体,使用强调色 COLORS.accent(暗红色),以"¥"符号开头,形成视觉焦点。
卡片整体使用白色背景(COLORS.cardBg)、12 像素圆角、12 像素内边距和 8 像素顶部间距,在浅绿色页面背景上形成了清晰可辨的卡片层次。这种卡片式布局是移动端列表展示的标准模式,通过白色卡片与浅色背景的对比,让每条数据都拥有明确的视觉边界。
7.4 tuneRow 笛曲列表行构建器
@Builder
tuneRow(item: TuneItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.layoutWeight(1)
Text(item.grade)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(gradeColor(item.grade))
.borderRadius(8)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text(item.style)
.fontSize(10)
.fontColor(COLORS.cool)
Text(item.mood)
.fontSize(10)
.fontColor(moodColor(item.mood))
.margin({ left: 10 })
Column()
.width(tempoBarW(item.tempo))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.hot)
.margin({ left: 10 })
Text('节奏 ' + item.tempo)
.fontSize(9)
.fontColor(COLORS.text3)
.margin({ left: 6 })
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
tuneRow 构建器的特殊之处在于它由两个 Row 组成,视觉上形成一个上下两部分组合的卡片。第一个 Row 是曲目标题行,左侧是曲目名称(layoutWeight(1) 占据剩余空间),右侧是难度等级标签。难度标签使用了内联背景色——通过 gradeColor(item.grade) 函数获取对应颜色,配以 8 像素圆角和左右各 8 像素的水平内边距,形成了一个药丸状的彩色标签。标签文字使用白色(COLORS.cardBg),在彩色背景上保持高对比度。
第二个 Row 是曲目详情行,包含流派、情绪、节奏进度条和节奏数值四个元素。流派使用 COLORS.cool 竹绿色,情绪通过 moodColor(item.mood) 映射颜色,节奏进度条使用 COLORS.hot 金色,宽度由 tempoBarW(item.tempo) 计算。两行之间通过共享 COLORS.cardBg 白色背景和 borderRadius(12) 圆角,在视觉上合并为一张完整的卡片,同时第一个 Row 的 margin({ top: 8 }) 保证了卡片之间的间距。
7.5 skillRow 技法列表行构建器
@Builder
skillRow(item: SkillItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.tech)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.cool)
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text('掌握度 ' + item.level + '%')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(levelBarW(item.level))
.height(6)
.borderRadius(3)
.backgroundColor(item.level >= 85 ? COLORS.accent : COLORS.hot)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text(item.years + '年')
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
skillRow 构建器渲染吹奏技法列表行。行内从左到右依次排列:技法名称(14 像素粗体)、技法分类标签(如"气息"“指法”“舌法”,使用 COLORS.cool 竹绿色背景的药丸标签)、掌握度进度条区域、练习年限。
掌握度进度条区域是一个右对齐的 Column,包含"掌握度 XX%“的文字标签和一条进度条。进度条颜色使用了条件表达式 item.level >= 85 ? COLORS.accent : COLORS.hot——掌握度达到 85 分及以上的用暗红色(accent)标记为"优秀”,低于 85 分的用金色(hot)标记为"良好"。这种基于阈值的颜色切换,让用户能够通过颜色快速区分高掌握度和一般掌握度的技法,是一种有效的视觉编码策略。
7.6 masterRow 笛师列表行构建器
@Builder
masterRow(item: MasterItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.title)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(titleColor(item.title))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text('技艺 ' + item.skill + ' · ' + item.pieces + ' 首作品')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(levelBarW(item.skill))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.jade)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text(item.age + '岁')
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
masterRow 构建器渲染笛师列表行,结构上与 skillRow 类似但内容不同。头衔标签通过 titleColor(item.title) 函数获取颜色——大师用暗红色,名家用金色。技艺信息区显示"技艺 XX·XX 首作品"的复合文本和一条技艺进度条,进度条统一使用玉石色 COLORS.jade。最右侧显示笛师年龄。整体布局清晰,让用户能够在每个卡片中快速获取笛师的关键信息:姓名、头衔、技艺水平、作品数量和年龄。
7.7 stepRow 工序列表行构建器
@Builder
stepRow(item: StepItem) {
Row() {
Text(item.seq + '')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.backgroundColor(stepColor(item.seq))
.borderRadius(12)
Column() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.tool + ' · ' + item.note)
.fontSize(10)
.fontColor(COLORS.text3)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(item.days + '天')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.hot)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
stepRow 构建器渲染制笛工序列表行。行的最左侧是一个 24x24 像素的圆形序号徽章,显示工序序号(item.seq),背景色由 stepColor(item.seq) 函数返回——前三道工序玉绿色、中间三道金色、后两道暗红色,通过颜色渐变暗示工序从初期到收尾的推进。序号居中显示(textAlign: TextAlign.Center),白色文字在彩色背景上清晰可辨。
序号右侧是工序详情 Column,包含工序名称(14 像素粗体)和工具要点信息(10 像素,如"目测·敲击·三年老竹")。最右侧是工序天数,11 像素粗体金色,用"天"作为单位,让用户直观了解每道工序的时间投入。这个构建器的设计强调了工序的"序"——通过序号、颜色和排列顺序,让用户感受到制笛工艺是一个有序的、循序渐进的过程。
7.8 orderChart 订单柱状图构建器
@Builder
orderChart() {
Row() {
ForEach(this.orders, (item: OrderItem) => {
Column() {
Column()
.width(12)
.height(orderBarH(item.amount))
.borderRadius(3)
.backgroundColor(this.breath ? COLORS.gold : COLORS.cool)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Text(item.amount / 1000 + 'k')
.fontSize(8)
.fontColor(COLORS.text3)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (item: OrderItem) => item.name)
}
.width('100%')
.height(92)
.alignItems(VerticalAlign.Bottom)
.padding({ left: 6, right: 6, top: 6, bottom: 6 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
orderChart 构建器渲染订单金额的柱状图。外层 Row 容器宽度 100%、高度 92 像素,子元素底部对齐(alignItems(VerticalAlign.Bottom)),确保所有柱条从底部向上生长。ForEach 遍历 orders 数组,为每个订单创建一个 Column,通过 layoutWeight(1) 等分宽度,内容水平居中。
每个柱条由两部分组成:上方的柱体(12 像素宽,高度由 orderBarH(item.amount) 计算,3 像素圆角)和下方的金额标签("Xk"格式,8 像素)。柱体的背景色由 breath 状态驱动——在金色(COLORS.gold)和竹绿色(COLORS.cool)之间切换,配合 600 毫秒的交替动画,产生柱条颜色在金绿之间脉动的动态效果。
金额标签使用 item.amount / 1000 + 'k' 的格式将金额转换为千单位(如 20400 显示为"20.4k"),这种缩写方式节省了显示空间,使标签不会超出柱条的宽度。柱状图整体使用白色卡片背景和圆角,与列表中其他卡片保持视觉一致性。
7.9 orderRow 订单列表行构建器
@Builder
orderRow(item: OrderItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.layoutWeight(1)
Column() {
Text('¥' + item.amount)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(item.count + ' 支 · ' + item.month)
.fontSize(9)
.fontColor(COLORS.text3)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
Text('删除')
.fontSize(11)
.fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#FDE8E8')
.borderRadius(8)
.onClick(() => {
this.selOrder = item;
this.showDel = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
orderRow 构建器渲染订单列表行,是应用中唯一包含交互操作按钮的列表行。行内从左到右依次排列:竹笛名称(layoutWeight(1) 占据剩余空间)、订单金额和数量月份信息(右对齐的 Column)、删除按钮。
删除按钮使用了淡红色背景(#FDE8E8)和危险红色文字(COLORS.danger),通过 onClick 事件处理器将当前订单赋值给 selOrder 状态变量,并将 showDel 设为 true 以弹出删除确认弹窗。这种"先选中、再确认"的两步式删除交互模式,有效防止了用户的误操作——用户需要先点击删除按钮,再在弹出的确认弹窗中点击"确认删除",才能最终执行删除操作。
7.10 tabContent 标签内容构建器
@Builder
tabContent() {
if (this.curTab === 0) {
Column() {
Text('笛款总览 · 音域越广越难得')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.flutes, (item: BambooFluteItem) => {
this.fluteRow(item)
}, (item: BambooFluteItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 1) {
Column() {
Text('经典笛曲 · 曲目与节奏')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.tunes, (item: TuneItem) => {
this.tuneRow(item)
}, (item: TuneItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
tabContent 构建器是内容区域的核心调度器,通过一系列 if 条件判断 curTab 的值来决定渲染哪个标签页的内容。这种基于条件渲染的标签页切换方式是 ArkUI 中最直接的模式——每次 curTab 变化时,之前的内容块被销毁,新的内容块被创建。
第一个标签页(curTab === 0)显示竹笛列表,包含一个区域标题"笛款总览·音域越广越难得"和通过 ForEach 遍历 flutes 数组渲染的竹笛卡片列表。第二个标签页(curTab === 1)显示笛曲列表,区域标题为"经典笛曲·曲目与节奏"。每个标签页的结构模式一致:一个标题 Text 加一个 ForEach 列表,这种一致性使得添加新标签页变得非常简单——只需复制相同的结构模式,替换数据源和行构建器即可。
if (this.curTab === 2) {
Column() {
Text('吹奏技法 · 修炼等级')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.skills, (item: SkillItem) => {
this.skillRow(item)
}, (item: SkillItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 3) {
Column() {
Text('一代笛师 · 技艺传承')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.masters, (item: MasterItem) => {
this.masterRow(item)
}, (item: MasterItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
第三个标签页显示吹奏技法列表,第四个标签页显示笛师列表。两者的结构完全一致,区别仅在于数据源和行构建器的不同。这种高度模式化的代码结构虽然看起来重复,但它的可读性极高——每个标签页的内容一目了然,开发者可以快速定位到需要修改的部分。如果追求代码精简,可以考虑使用映射表来动态选择数据源和构建器,但会增加间接性、降低可读性,在中小规模应用中并不一定值得。
if (this.curTab === 4) {
Column() {
Text('制笛八序 · 慢工出细活')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.steps, (item: StepItem) => {
this.stepRow(item)
}, (item: StepItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 5) {
Column() {
Text('订单金额 · 月度走势')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
this.orderChart()
Text('全部订单 · 点击可删除')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
.margin({ top: 12 })
ForEach(this.orders, (item: OrderItem) => {
this.orderRow(item)
}, (item: OrderItem) => item.name + item.month)
}
.width('100%')
.margin({ top: 10 })
}
}
第五个标签页显示制笛工序列表。第六个标签页(订单页)的内容最为丰富——它不仅包含一个标题和订单列表,还在两者之间插入了 orderChart 柱状图。柱状图上方的标题是"订单金额·月度走势",柱状图下方的标题是"全部订单·点击可删除",形成了一个"图表+列表"的组合视图。ForEach 的键值函数使用了 item.name + item.month 的组合键,确保即使存在同名但不同月份的订单,每个项也有唯一的标识。
7.11 bottomItem 底部标签项构建器
@Builder
bottomItem(i: number) {
Row() {
Text(TAB_LIST[i].icon)
.fontSize(16)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.curTab === i ? COLORS.tabOn : COLORS.sub)
.margin({ left: 4 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.curTab === i ? '#2A6349' : COLORS.tabBg)
.borderRadius(10)
.onClick(() => {
this.curTab = i;
})
}
bottomItem 构建器渲染底部导航栏中的单个标签按钮。它接收标签索引 i 作为参数,从 TAB_LIST 数组中获取对应的 Emoji 图标和标签文字。图标 16 像素,标签 11 像素,两者水平排列在 Row 中,通过 justifyContent(FlexAlign.Center) 居中对齐。
标签的视觉状态根据是否为当前选中标签(this.curTab === i)而动态变化:选中时文字加粗、使用金色(COLORS.tabOn),背景色为较亮的绿色(#2A6349);未选中时文字常规粗细、使用副标题色(COLORS.sub),背景色为深绿色(COLORS.tabBg)。onClick 事件处理器将 curTab 设为当前索引 i,触发标签页切换。通过 layoutWeight(1) 使每个标签在行内等分宽度,六个标签均匀分布。
7.12 bottomBar 底部导航栏构建器
@Builder
bottomBar() {
Column() {
Row() {
ForEach(ROW1_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => 'r1' + i)
}
.width('100%')
Row() {
ForEach(ROW2_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => 'r2' + i)
}
.width('100%')
.margin({ top: 6 })
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.tabBg)
.borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
}
bottomBar 构建器将六个标签按钮组织成两行三列的导航栏。外层 Column 使用深绿色背景(COLORS.tabBg),并通过 borderRadius 设置了顶部 18 像素圆角、底部无圆角,形成从页面底部"升起"的视觉效果。内层两个 Row 分别遍历 ROW1_IDX([0, 1, 2])和 ROW2_IDX([3, 4, 5])渲染六个标签按钮,两行之间留 6 像素间距。
将六个标签分为两行而非一行,是考虑到六个标签如果排在一行中,每个标签的宽度会被压缩到很小,图标和文字可能显示不全或过于拥挤。两行三列的布局为每个标签提供了足够的显示空间,同时也形成了一种紧凑的网格导航模式。这种布局选择在屏幕较窄的移动设备上尤为实用。
7.13 addModal 新增竹笛弹窗构建器
@Builder
addModal() {
if (this.showAdd) {
Stack() {
this.modalOverlay(() => {
this.showAdd = false;
})
Column() {
Text('新增竹笛')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('登记一款新制竹笛')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 3 })
addModal 构建器渲染新增竹笛的模态弹窗。最外层使用 if (this.showAdd) 条件判断控制弹窗的显示与隐藏——当 showAdd 为 true 时渲染弹窗内容,为 false 时不渲染任何内容。这种条件渲染方式比通过 visibility 属性控制显隐更加彻底,因为在条件为 false 时组件完全不会被创建,节省了内存和渲染开销。
弹窗内部使用 Stack 堆叠两层内容:底层是 modalOverlay 遮罩层(传入关闭弹窗的回调),上层是弹窗主体 Column。弹窗主体的标题区域包含"新增竹笛"主标题(17 像素粗体)和"登记一款新制竹笛"副标题(11 像素,灰色),让用户明确弹窗的功能和用途。
Column() {
Text('笛名')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formName, placeholder: '如:梅鹿竹笛' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formName = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 14 })
Column() {
Text('材质')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formMaterial, placeholder: '如:梅鹿竹' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formMaterial = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Column() {
Text('价格(元)')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formPrice, placeholder: '如:560' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formPrice = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
表单区域包含三个输入字段:笛名、材质和价格。每个字段由一个标签 Text 和一个 TextInput 输入框组成,通过 onChange 回调将输入值实时同步到对应的 @State 变量(formName、formMaterial、formPrice)。TextInput 的 text 参数绑定了状态变量,确保输入框的显示内容与状态保持同步。每个字段之间留 12 像素的垂直间距(价格字段为 14 像素),形成了清晰的表单节奏。
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.onClick(() => {
this.showAdd = false;
})
Text('确认登记')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
this.flutes.push(new BambooFluteItem(this.formName, this.formMaterial, '60cm', 18, Number(this.formPrice)));
this.showAdd = false;
})
}
.width('100%')
.margin({ top: 16 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
按钮区域包含"取消"和"确认登记"两个按钮,通过 layoutWeight(1) 等分宽度。"取消"按钮使用浅灰色背景,点击后直接关闭弹窗。"确认登记"按钮使用竹绿色背景(COLORS.cool)和白色粗体文字,点击后执行 this.flutes.push(new BambooFluteItem(this.formName, this.formMaterial, '60cm', 18, Number(this.formPrice))) 将新竹笛添加到列表中,然后关闭弹窗。
注意新创建的 BambooFluteItem 中,长度被固定为 '60cm',音域被固定为 18,这两个值不是用户输入的而是硬编码的默认值。这种设计简化了表单——用户只需输入三个核心字段(名称、材质、价格),长度和音域使用合理的默认值,降低了用户的输入负担。Number(this.formPrice) 将字符串形式的用户输入转换为数字。
弹窗主体通过 zIndex(999) 确保显示在所有其他元素之上,constraintSize({ maxHeight: '80%' }) 限制最大高度不超过屏幕的 80%,防止在内容过多时溢出屏幕。position({ x: 0, y: 0 }) 将弹窗定位在 Stack 的左上角,配合 width('88%') 使弹窗宽度略小于屏幕宽度,两侧留出遮罩可见区域,暗示用户可以通过点击两侧遮罩区域关闭弹窗。
7.14 editModal 编辑笛曲弹窗构建器
@Builder
editModal() {
if (this.showEdit) {
Stack() {
this.modalOverlay(() => {
this.showEdit = false;
})
Column() {
Text('编辑笛曲')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
if (this.selTune) {
Text(this.selTune.name + ' · 节奏 ' + this.selTune.tempo)
.fontSize(12)
.fontColor(COLORS.text2)
.margin({ top: 6 })
}
Text('调整节奏快慢,影响演奏情绪')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 3 })
editModal 构建器渲染编辑笛曲的模态弹窗,结构上与 addModal 类似但功能不同。弹窗标题为"编辑笛曲",副标题区域使用了 if (this.selTune) 条件判断——只有当 selTune 不为 null 时才显示选中曲目的名称和当前节奏值(如"姑苏行·节奏 88")。这种空值检查是必要的,因为 selTune 的类型是 TuneItem | null,直接访问其属性在值为 null 时会引发运行时错误。
Row() {
Text('加快节奏 +10')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(10)
.onClick(() => {
if (this.selTune) {
this.selTune.tempo = Math.min(160, this.selTune.tempo + 10);
}
this.showEdit = false;
})
Text('放缓节奏 -10')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selTune) {
this.selTune.tempo = Math.max(40, this.selTune.tempo - 10);
}
this.showEdit = false;
})
}
.width('100%')
.margin({ top: 16 })
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.margin({ top: 10 })
.onClick(() => {
this.showEdit = false;
})
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
弹窗的核心操作区域包含两个按钮:“加快节奏 +10"和"放缓节奏 -10”。"加快节奏"按钮使用暗红色背景(COLORS.accent),点击后将选中曲目的节奏值加 10,但通过 Math.min(160, ...) 限制最大不超过 160。"放缓节奏"按钮使用竹绿色背景(COLORS.cool),点击后将节奏值减 10,通过 Math.max(40, ...) 限制最小不低于 40。两个边界值 160 和 40 分别对应竹笛曲目中可能的最高和最低节奏速度,防止用户将节奏调整到不合理的范围。
由于 TuneItem 使用了 @Observed 装饰器,当 this.selTune.tempo 被修改后,列表中对应条目的节奏进度条和节奏数值会自动更新,实现了即时的视觉反馈。弹窗底部还有一个独立的"取消"按钮,占据整行宽度,使用浅灰色背景,点击后关闭弹窗但不修改任何数据。
7.15 delModal 删除确认弹窗构建器
@Builder
delModal() {
if (this.showDel) {
Stack() {
this.modalOverlay(() => {
this.showDel = false;
})
Column() {
Text('删除订单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
if (this.selOrder) {
Text('确认删除「' + this.selOrder.name + '」订单?')
.fontSize(12)
.fontColor(COLORS.text2)
.margin({ top: 8 })
}
Text('删除后不可恢复')
.fontSize(10)
.fontColor(COLORS.danger)
.margin({ top: 4 })
delModal 构建器渲染删除订单的确认弹窗。弹窗标题为"删除订单",当 selOrder 不为空时显示确认提示文本(如"确认删除「紫竹笛」订单?“),下方还有一行红色的警告文字"删除后不可恢复”(10 像素,COLORS.danger),提醒用户此操作的不可逆性。
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.onClick(() => {
this.showDel = false;
})
Text('确认删除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selOrder) {
this.orders.splice(this.orders.indexOf(this.selOrder), 1);
}
this.showDel = false;
})
}
.width('100%')
.margin({ top: 16 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
按钮区域包含"取消"和"确认删除"两个按钮。"取消"按钮使用浅灰色背景,点击后直接关闭弹窗。"确认删除"按钮使用危险红色背景(COLORS.danger)和白色粗体文字,点击后执行 this.orders.splice(this.orders.indexOf(this.selOrder), 1) 从订单数组中移除被选中的订单项,然后关闭弹窗。
splice 方法是 JavaScript/TypeScript 中数组操作的核心方法之一,它通过 indexOf 获取被删除元素在数组中的索引位置,然后从该位置删除一个元素。由于 orders 数组被 @State 装饰,数组的变化会触发 ForEach 的重新渲染,被删除的订单项会自动从列表和柱状图中消失。这种数据驱动的删除模式简洁高效,开发者只需操作数据源,UI 的更新由框架自动完成。
7.16 build 主构建方法
build() {
Stack() {
Column() {
Scroll() {
Column() {
this.pageHeader()
this.tabContent()
}
.width('100%')
.padding({ left: 14, right: 14, bottom: 12 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.backgroundColor(COLORS.bg)
this.bottomBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
if (this.showAdd) {
this.addModal()
}
if (this.showEdit) {
this.editModal()
}
if (this.showDel) {
this.delModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
build 方法是组件的入口构建方法,它定义了整个页面的最终结构。最外层是一个 Stack 容器,它堆叠了两层内容:底层是页面的主体内容,上层是三个模态弹窗。
页面主体是一个 Column 容器,包含两个部分。第一部分是 Scroll 可滚动容器,内部包裹一个 Column,其中依次调用 pageHeader() 渲染头部装饰区域和 tabContent() 渲染标签页内容。Scroll 设置了垂直滚动方向(ScrollDirection.Vertical),通过 layoutWeight(1) 占据底部导航栏之外的所有剩余高度。内层 Column 设置了左右各 14 像素和底部 12 像素的内边距,确保内容不会贴边显示。
第二部分是 bottomBar() 底部导航栏,固定在页面底部不随内容滚动。这种"头部+可滚动内容+底部导航"的三段式布局是移动应用中最经典的页面结构,兼顾了内容展示和导航功能。
三个模态弹窗通过 if 条件判断控制显示,分别对应 showAdd、showEdit 和 showDel 三个状态变量。由于它们被放在 Stack 的最上层,当任一弹窗显示时,它会覆盖在整个页面内容之上,配合 modalOverlay 的半透明遮罩,形成标准的模态交互效果。使用 Stack 堆叠而非将弹窗放在 Column 中的设计,确保了弹窗不会影响页面主体的布局,实现了真正的"覆盖层"效果。
八、状态管理深度分析
8.1 @State 装饰器的响应式机制
在本应用中,@State 装饰器被广泛应用于组件内部的状态管理。@State 修饰的变量在值发生变化时,会自动触发组件的重新渲染,使 UI 始终与数据保持同步。这种响应式机制是 ArkUI 声明式 UI 范式的核心。
具体而言,当用户点击底部导航标签时,curTab 的值发生变化,触发 tabContent 构建器的重新执行,根据新的 curTab 值渲染对应标签页的内容。当 breath 定时器翻转状态时,所有引用 this.breath 的 UI 元素——音符、流苏、光球、柱状图——都会重新渲染,产生动画效果。当用户在弹窗中输入文本时,formName、formMaterial、formPrice 的变化会实时反映在输入框中。
@State 对数组的操作也具有良好的响应式支持。当通过 push 方法向 flutes 数组添加新元素,或通过 splice 方法从 orders 数组删除元素时,框架能够检测到数组的变化并触发 ForEach 的重新渲染。这种对数组变异方法的拦截和响应,是 ArkTS 状态管理框架的重要特性。
8.2 @Observed 与可观察对象
@Observed 装饰器作用于类级别,使类的实例成为可观察对象。当被 @Observed 修饰的类的实例属性发生变化时,框架会通知所有引用该实例的 UI 组件进行更新。在本应用中,六个数据模型类都使用了 @Observed 装饰器。
@Observed 的价值在 editModal 中得到了充分体现。当用户点击"加快节奏 +10"按钮时,this.selTune.tempo 被直接修改。由于 TuneItem 被 @Observed 修饰,且 selTune 指向 tunes 数组中的同一个对象引用,框架能够检测到属性变化并更新列表中对应条目的节奏进度条和节奏数值。这种对象级别的响应式追踪,比数组级别的追踪更加精细,能够实现更高效的局部更新。
8.3 @Entry 与 @Component 的协同
@Entry 和 @Component 两个装饰器协同工作,定义了应用的入口组件。@Component 将 FlutePage struct 标记为一个 ArkUI 组件,使其具备声明式 UI 渲染能力。@Entry 进一步将 FlutePage 标记为页面的入口组件,即应用的根组件。
在 HarmonyOS 应用的加载流程中,框架会查找带有 @Entry 装饰器的组件,将其作为页面的渲染起点。build 方法的返回值构成了组件的 UI 树,框架负责将这棵 UI 树渲染到屏幕上,并在状态变化时执行高效的差分更新。
8.4 联合类型与空值安全
selTune 和 selOrder 两个状态变量使用了联合类型 TuneItem | null 和 OrderItem | null。这种类型设计反映了这两个变量的生命周期:初始状态为 null(未选中任何条目),当用户执行操作时被赋值为具体的对象引用,操作完成后可以再次置为 null。
在使用这两个变量时,代码中通过 if (this.selTune) 和 if (this.selOrder) 的空值检查确保安全访问。ArkTS 作为 TypeScript 的超集,继承了 TypeScript 的类型系统,包括对联合类型的空值安全检查。这种设计在编译时就能捕获潜在的空指针访问错误,提升了代码的健壮性。
九、布局样式深度分析
9.1 Flex 布局与 layoutWeight
ArkUI 的布局系统基于 Flexbox 模型,Row 和 Column 组件本质上是 Flex 容器。在本应用中,layoutWeight 属性被大量使用,它是 Flex 布局中 flex-grow 属性的 ArkUI 实现。
当一个 Column 或 Text 设置了 layoutWeight(1) 时,它会在父容器中占据所有剩余的空间。例如,在 fluteRow 中,竹笛名称没有设置 layoutWeight,价格也没有设置 layoutWeight,它们按照内容大小占据空间;而中间的 Column(包含材质和音域信息)设置了 layoutWeight(1),占据了名称和价格之间的所有剩余空间。这种布局确保了价格信息始终右对齐,不受中间内容长度的影响。
在 bottomItem 中,每个标签都设置了 layoutWeight(1),使六个标签在导航行内等分宽度。在 orderChart 中,每个柱条也设置了 layoutWeight(1),确保十条柱条均匀分布。
9.2 Stack 堆叠布局
Stack 组件是 ArkUI 中的堆叠布局容器,所有子元素以左上角为原点堆叠在一起,后添加的元素覆盖在前面的元素之上。在本应用中,Stack 被用于两个关键场景:页面头部的装饰图形层叠和模态弹窗的遮罩层叠。
在 pageHeader 中,Stack 堆叠了七层视觉元素,从底到顶依次是:渐变背景层、音符层、竹笛图形层、流苏层、金色光球层、标题信息层、统计信息层。通过 position 属性精确定位每个元素在 Stack 内的位置,实现了复杂的装饰图形组合。这种基于绝对定位的堆叠方式虽然需要手动计算坐标,但能够实现 Flex 布局难以完成的复杂视觉效果。
在模态弹窗中,Stack 用于将遮罩层和弹窗主体堆叠在一起,遮罩层在下、弹窗主体在上,通过 zIndex(999) 确保弹窗主体在视觉层级上位于遮罩之上。
9.3 位置定位与 position 属性
position 属性在头部装饰区域的渲染中扮演了关键角色。通过 { x: number, y: number } 的参数格式,每个装饰元素都被精确地定位在头部区域内的指定坐标。这种绝对定位方式与 Flex 布局的自动排列不同——它不参与父容器的流式布局,而是相对于父容器的左上角进行偏移定位。
在 pageHeader 的 Stack 中,所有使用 position 的子元素都以 Stack 的左上角为原点进行定位。竹笛图形通过 fluteX(i) 和 fluteY(i) 定位,音孔通过 holeX(i, j) 和 holeY(i) 定位,流苏通过 tasselX(j) 和 tasselY(j) 定位,音符通过 noteX(i) 和 noteY(i) 定位。这些位置计算函数的返回值确保了各元素之间的相对位置关系正确,形成了协调统一的装饰画面。
9.4 渐变与圆角
linearGradient 属性用于创建线性渐变背景。在 pageHeader 中,头部背景使用了从 COLORS.header1 到 COLORS.header2 的 135 度线性渐变,角度参数指定了渐变的方向——135 度意味着从左上到右下的对角线方向。colors 数组中的每个元素是一个 [color, position] 元组,position 是 0-1 之间的浮点数,表示渐变停止点在渐变线上的位置。
borderRadius 属性在本应用中被广泛使用,从卡片圆角(12 像素)到头部圆角(22 像素)到按钮圆角(10 像素)到圆形徽章(12 像素半径实现圆形)到底部导航栏的顶部圆角(18 像素)。合理的圆角设计使界面看起来柔和现代,避免了直角带来的尖锐感。底部导航栏的 borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 }) 只设置顶部圆角而底部保持直角,形成了从页面底部"升起"的视觉效果。
9.5 动画系统
本应用的动画系统由两个层面协同工作:breath 状态的定时切换和 .animation 属性的过渡配置。
aboutToAppear 中的 setInterval 每 480 毫秒翻转 breath 布尔值,这是动画的"驱动源"。当 breath 变化时,所有引用 this.breath 的属性(如 opacity、rotate、scale、backgroundColor)都会改变目标值,而 .animation 属性则负责在这些目标值之间产生平滑的过渡。
.animation 属性接受一个配置对象,包含 duration(过渡时长)、iterations(重复次数,-1 为无限重复)和 playMode(播放模式)。PlayMode.Alternate 表示交替播放——动画在正向和反向之间来回播放,形成"呼吸"般的往复效果。不同元素设置了不同的 duration 值(音符 900 毫秒、流苏 700 毫秒、光球 620 毫秒、柱状图 600 毫秒),避免了所有动画完全同步的机械感,营造出自然活泼的动态氛围。
十、Mermaid 流程图
10.1 应用整体架构流程图
10.2 数据流与状态管理流程图
10.3 模态弹窗交互流程图
十一、技术对比表格
11.1 ArkTS 状态管理装饰器对比
| 装饰器 | 作用范围 | 响应式粒度 | 典型用例 | 本应用使用情况 |
|---|---|---|---|---|
| @State | 组件内部变量 | 变量级别 | 组件内部状态管理 | curTab, breath, showAdd 等全部状态变量 |
| @Observed | 类级别 | 对象属性级别 | 可观察数据模型 | BambooFluteItem, TuneItem 等六个数据模型 |
| @Prop | 父到子单向 | 变量级别 | 父组件向子组件传值 | 本应用未使用(单组件架构) |
| @Link | 父到子双向 | 变量级别 | 父子组件双向同步 | 本应用未使用(单组件架构) |
| @ObjectLink | 对象级别 | 对象属性级别 | 接收@Observed对象 | 本应用未使用(单组件架构) |
| @Entry | 组件级别 | N/A | 标记入口组件 | FlutePage 组件 |
| @Component | 组件级别 | N/A | 声明UI组件 | FlutePage 组件 |
| @Builder | 方法级别 | N/A | 定义可复用UI片段 | 16个@Builder方法 |
11.2 布局组件对比
| 布局组件 | 排列方向 | 子元素定位方式 | 典型用例 | 本应用使用场景 |
|---|---|---|---|---|
| Column | 垂直 | 从上到下流式排列 | 垂直内容堆叠 | 页面主体、卡片内容、表单字段 |
| Row | 水平 | 从左到右流式排列 | 水平内容排列 | 列表行内布局、按钮组、统计信息 |
| Stack | 堆叠 | 绝对定位(position) | 元素层叠覆盖 | 头部装饰图形、模态弹窗遮罩 |
| Scroll | 可滚动 | 子元素流式排列 | 内容超出屏幕时滚动 | 页面内容区域 |
11.3 动画实现方式对比
| 动画方式 | 实现原理 | 适用场景 | 优点 | 缺点 | 本应用使用情况 |
|---|---|---|---|---|---|
| setInterval + @State | 定时器翻转状态值驱动 | 周期性状态变化动画 | 实现简单、可控性强 | 频繁触发重渲染 | breath呼吸动画 |
| .animation属性 | 属性值变化时自动过渡 | 单次或连续过渡动画 | 声明式、性能好 | 需要触发条件 | 配合breath的过渡效果 |
| PlayMode.Alternate | 动画交替播放模式 | 往复式动画 | 自然循环效果 | 仅适用于有限场景 | 音符飘浮、流苏摆动 |
| iterations:-1 | 无限循环动画 | 持续性动画 | 无需手动循环 | 需注意性能影响 | 所有装饰动画 |
11.4 数据操作方式对比
| 操作类型 | 方法 | 响应式触发 | 适用数据类型 | 本应用使用场景 |
|---|---|---|---|---|
| 新增元素 | array.push() | @State自动检测 | @State数组 | 添加新竹笛到flutes数组 |
| 删除元素 | array.splice() | @State自动检测 | @State数组 | 删除orders中的订单 |
| 修改属性 | 直接赋值 | @Observed自动检测 | @Observed对象属性 | 修改selTune.tempo节奏值 |
| 条件渲染 | if条件判断 | @State变化触发 | 任意@State变量 | 标签页切换、弹窗显隐 |
| 列表渲染 | ForEach | 数组变化触发 | @State数组 | 所有列表和装饰图形 |
11.5 颜色管理策略对比
| 策略 | 实现方式 | 优点 | 缺点 | 本应用使用情况 |
|---|---|---|---|---|
| 接口约束 | interface ColorPalette | 类型安全、结构清晰 | 需要额外定义 | COLORS常量的类型约束 |
| 语义化命名 | bg/cardBg/accent/danger | 语义明确、易于理解 | 字段数量较多 | 21个语义化颜色字段 |
| 映射函数 | gradeColor/moodColor等 | 逻辑集中、易于维护 | 需要额外函数调用 | 4个颜色映射函数 |
| 直接引用 | COLORS.jade等 | 简单直接 | 缺乏语义抽象 | 进度条、文本等基础颜色 |
| 条件表达式 | level>=85?accent:hot | 灵活、内联 | 逻辑分散、难以复用 | 技法掌握度颜色切换 |
十二、技术总结与深度思考
12.1 架构设计的整体评价
纵观整个竹笛制作应用的技术实现,我们可以看到一套结构清晰、层次分明的 ArkTS 应用架构。从最底层的色彩体系定义,到数据模型的 @Observed 封装,再到位置计算和颜色映射的辅助函数,最后到 @Builder 方法的 UI 渲染和 build 方法的整体组装,每一层都有明确的职责和边界,形成了自下而上的数据流动和自上而下的渲染调用。
色彩体系的设计是整个应用的美学基础。通过 ColorPalette 接口的类型约束和 COLORS 常量的具体实现,应用建立了一套以竹绿、墨绿、琥珀金、暗红为核心的自然色彩体系,所有 UI 元素的颜色都从这套体系中选取,确保了视觉风格的一致性。语义化的字段命名让每个颜色都有明确的用途定义,即使部分颜色存在复用(如 hot 和 gold、cool 和 bambooA),也通过独立的字段名保持了语义的清晰性。颜色映射函数(gradeColor、moodColor、titleColor、stepColor)进一步将语义标签与颜色编码建立了稳定的对应关系,使用户能够通过颜色快速识别数据的属性和状态。
数据模型的设计体现了领域驱动的设计思想。六个 @Observed 类分别对应竹笛产品、笛曲、吹奏技法、笛师、制笛工序和订单六个业务领域,每个类的属性都准确地描述了该领域实体的关键特征。@Observed 装饰器的使用使得这些数据对象具备了对属性变化的自动追踪能力,为响应式 UI 提供了数据层面的基础支撑。构造函数的参数注入方式确保了对象在创建时就拥有完整的初始数据,虽然 @Observed 允许后续修改属性,但初始状态的明确性有助于减少运行时的不确定性。
12.2 状态管理的精妙之处
状态管理是本应用技术实现中最值得深入探讨的部分。应用采用了单组件架构——所有 UI 和逻辑都集中在 FlutePage 这一个 @Component 中,没有拆分为子组件。这种架构选择带来了一些有趣的技术特点。
一方面,单组件架构使得所有状态都通过 @State 在组件内部管理,无需使用 @Prop、@Link、@ObjectLink 等跨组件通信装饰器,简化了状态管理的复杂度。@State 变量包括简单的基本类型(curTab: number、breath: boolean)、联合类型(selTune: TuneItem | null)和复杂的数据数组(flutes: BambooFluteItem[] 等),覆盖了移动应用中常见的状态类型。状态变化的触发来源包括用户交互(点击导航标签、填写表单、确认操作)和定时器(breath 呼吸动画),形成了完整的响应式状态闭环。
另一方面,@Observed 与 @State 的配合使用展现了 ArkTS 状态管理的精细粒度。当 selTune.tempo 被修改时,由于 TuneItem 被 @Observed 修饰,框架能够追踪到对象属性级别的变化,并精确地更新引用了该属性的 UI 元素(节奏进度条和数值),而不需要重新渲染整个列表。这种对象级别的响应式追踪,比仅依赖 @State 的数组级别追踪更加高效,特别适用于列表中单个条目属性需要频繁更新的场景。
breath 呼吸状态的设计也值得称道。一个简单的布尔型状态变量,通过 480 毫秒的定时翻转,驱动了页面头部多达四种不同节奏的动画效果——音符飘浮(900ms)、流苏摆动(700ms)、光球脉动(620ms)、柱状图变色(600ms)。不同元素使用不同的动画时长,避免了所有动画完全同步的机械感,营造出自然活泼的动态氛围。这种"一个状态驱动多种动画"的设计模式,以极低的状态管理成本实现了丰富的视觉效果。
12.3 声明式 UI 的表现力
本应用充分展现了 ArkUI 声明式 UI 的强大表现力。pageHeader 构建器是这方面的集大成者——它在 128 像素高的区域内,通过 Stack 堆叠了七层视觉元素,包括渐变背景、飘浮音符、竹笛图形(含管身、音孔、镶骨)、摆动流苏、脉动光球、标题信息和统计看板。这些元素通过参数化的位置计算函数(fluteX、fluteY、holeX、holeY 等)精确定位,通过 ForEach 循环批量渲染,通过 .animation 属性配置独立的动画参数,最终形成了一个信息密集、动画丰富、层次分明的头部装饰区域。
声明式 UI 的核心优势在于"描述结果而非过程"——开发者只需声明 UI 的目标状态(如"在坐标(100, 50)处渲染一个20像素的音符,透明度0.5,旋转8度"),框架负责计算如何从当前状态过渡到目标状态。这种范式在本应用的动画系统中表现得尤为明显:开发者只需在 .animation 属性中配置 duration、iterations 和 playMode,框架就能自动在 breath 状态变化时生成平滑的过渡动画,无需手动编写动画帧或插值逻辑。
@Builder 方法的参数化设计也是声明式 UI 的重要特性。modalOverlay(onClose) 接收回调函数实现复用,fluteRow(item) 接收数据对象实现列表渲染,bottomItem(i) 接收索引实现标签渲染。这种将 UI 片段封装为可参数化方法的设计,使得代码结构清晰、复用性强,同时保持了良好的可读性。
12.4 交互设计的用户体验考量
从用户体验的角度来看,本应用的交互设计体现了多个值得借鉴的原则。首先是"两步式删除确认"模式——用户点击删除按钮后不会直接删除数据,而是弹出确认弹窗要求二次确认。这种设计有效防止了误操作导致的数据丢失,在涉及不可逆操作的场景中尤为重要。弹窗中"删除后不可恢复"的红色警告文字进一步强化了操作的严肃性,让用户在确认前充分了解操作的后果。
其次是"表单简化"策略。在新增竹笛弹窗中,用户只需输入三个核心字段(名称、材质、价格),长度和音域使用合理的默认值(60cm、18音)。这种设计降低了用户的输入负担,同时通过默认值保证了新数据的基本完整性。如果用户需要修改默认值,可以在后续迭代中增加更多输入字段。
第三是"数据可视化"的运用。音域进度条、掌握度进度条、节奏进度条、技艺进度条、订单柱状图等多种可视化元素,将抽象的数值数据转换为直观的视觉量度。用户可以通过观察进度条的长度或柱状图的高度快速感知数据的大小关系,而无需仔细阅读具体数字。颜色编码(如掌握度85分以上用红色标记)进一步增强了数据的可读性。
第四是"动画增强体验"的设计理念。页面头部的多种动画效果——音符飘浮、流苏摆动、光球脉动——不仅美观,更重要的是为应用注入了"生命感",让用户在每次打开应用时都感受到一种动态的、活泼的氛围。柱状图的颜色脉动也使得原本静态的数据图表变得生动有趣。这些动画虽然不直接提升应用的功能性,但显著提升了用户的情感体验和对应用品质的感知。
12.5 性能优化的思考
虽然本应用在功能实现上已经相当完善,但从性能优化的角度仍有值得思考的空间。首先是 breath 定时器的频率问题——480 毫秒的间隔虽然不算高频,但每次翻转都会触发页面头部所有装饰元素的重渲染。如果这些元素的渲染开销较大(特别是包含多个 ForEach 循环的竹笛图形和音孔),可能会对低端设备的帧率产生影响。一种优化方案是将动画效果改为纯 CSS/属性动画,减少 JavaScript 层面的状态切换频率。
其次是 ForEach 的键值函数设计。在本应用中,列表项的键值使用了数据对象的 name 属性(如 item.name),这在名称唯一的情况下是合理的。但如果存在同名的数据项(如两个"紫竹笛"订单),键值冲突可能导致渲染异常。更稳健的做法是使用唯一标识符(如自增 ID 或时间戳)作为键值,但这要求数据模型中增加 id 字段。
第三是条件渲染与 visibility 的选择。本应用在弹窗显示控制上使用了 if 条件渲染,这种方式在弹窗不可见时完全不会创建组件,节省了内存和初始渲染开销。但代价是每次显示弹窗时需要重新创建组件,可能在弹窗内容复杂时产生可感知的延迟。对于频繁切换的场景,使用 visibility 属性控制显隐可能更合适,因为组件始终保持创建状态,只是不可见时跳过渲染。
12.6 可扩展性的展望
本应用的代码结构具有良好的可扩展性。添加新的标签页只需三个步骤:在 TAB_LIST 中添加标签元数据、在 ROW1_IDX 或 ROW2_IDX 中调整索引分配、在 tabContent 中添加对应的条件分支。这种"数据+配置+渲染"的三层分离模式,使得功能扩展的代码改动集中且可控。
添加新的数据模型也遵循类似的模式:定义 @Observed 类、在组件中声明 @State 数组、创建 @Builder 行渲染方法、在 tabContent 中添加渲染分支。这种模式化的开发流程不仅降低了开发难度,也使得代码风格保持一致,有利于团队协作和后期维护。
色彩体系的接口化设计也为主题切换功能预留了扩展空间。如果需要实现深色模式或多主题切换,只需创建多个 ColorPalette 实例(如 COLORS_DARK、COLORS_LIGHT),通过状态变量动态切换即可,所有引用 COLORS 的 UI 代码无需修改。这种"面向接口编程"的设计原则在大型应用中尤为重要。
12.7 文化与技术的融合
最后,从更高的层面来看,本应用展现了一种将传统文化与现代技术相融合的尝试。竹笛作为拥有八千年历史的中华传统乐器,其制作工艺、演奏技法、名家传承和曲目体系构成了一个丰富的文化知识体系。通过 HarmonyOS ArkTS 的技术手段,将这些文化知识以数据可视化和交互式 UI 的形式呈现给用户,不仅为传统文化的传播提供了新的载体,也为技术应用的场景拓展了新的方向。
应用中的数据设计尤为用心——竹笛品种涵盖了紫竹、湘妃竹、苦竹、凤眼竹、白竹、斑竹、玉屏竹、黄竹等多种竹材以及鹤骨特殊材质;笛曲收录了"姑苏行"“鹧鸪飞”"牧民新歌"等十首经典曲目,涵盖南派、北派、民间、浙江、江南丝竹等多种风格流派;笛师名录包含了陆春龄、赵松庭、俞逊发等真实的历史人物;制笛工序完整呈现了从选竹到上漆的八大步骤。这些精心设计的数据让应用不仅是一个技术展示项目,更是一个竹笛文化的小型知识库,用户在浏览应用的过程中能够自然而然地了解竹笛的相关知识。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
interface ColorPalette {
bg: string;
cardBg: string;
header1: string;
header2: string;
bambooA: string;
bambooB: string;
jade: string;
gold: string;
title: string;
sub: string;
text1: string;
text2: string;
text3: string;
accent: string;
hot: string;
cool: string;
danger: string;
tabBg: string;
tabOn: string;
mask: string;
}
const COLORS: ColorPalette = {
bg: '#F2F6E8',
cardBg: '#FFFFFF',
header1: '#1E4D3B',
header2: '#0F2B20',
bambooA: '#3E7C54',
bambooB: '#9ACD8E',
jade: '#7FB069',
gold: '#E3B23C',
title: '#EAF6E2',
sub: '#B9D8AE',
text1: '#1F3D2E',
text2: '#4A6B57',
text3: '#8FA893',
accent: '#C0392B',
hot: '#E3B23C',
cool: '#3E7C54',
danger: '#D9534F',
tabBg: '#1E4D3B',
tabOn: '#E3B23C',
mask: 'rgba(0,0,0,0.45)'
};
interface TabMeta {
label: string;
icon: string;
}
const TAB_LIST: TabMeta[] = [
{ label: '竹笛', icon: '🎋' },
{ label: '笛曲', icon: '🎵' },
{ label: '吹奏', icon: '🎶' },
{ label: '笛师', icon: '🧑🎤' },
{ label: '工序', icon: '📜' },
{ label: '订单', icon: '📦' }
];
const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const FLUTE_IDX: number[] = [0, 1, 2];
const HOLE_IDX: number[] = [0, 1, 2, 3, 4];
const TASSEL_IDX: number[] = [0, 1, 2, 3];
const NOTE_IDX: number[] = [0, 1, 2, 3, 4];
function fluteY(i: number): number {
return 28 + i * 31;
}
function fluteLen(i: number): number {
return 204 - i * 30;
}
function fluteX(i: number): number {
return (340 - fluteLen(i)) / 2;
}
function holeX(i: number, j: number): number {
return fluteX(i) + 26 + j * ((fluteLen(i) - 52) / 5);
}
function holeY(i: number): number {
return fluteY(i) + 15;
}
function tasselX(j: number): number {
return 318 + j * 9;
}
function tasselY(j: number): number {
return 106 + j * 22;
}
function noteX(i: number): number {
return 44 + i * 58;
}
function noteY(i: number): number {
return 14 + (i % 2) * 15;
}
function noteSize(i: number): number {
return 13 + (i % 3) * 2;
}
function pitchBarW(p: number): number {
return 18 + p * 2.4;
}
function levelBarW(v: number): number {
return 16 + v * 1.1;
}
function tempoBarW(t: number): number {
return 16 + t * 1.3;
}
function orderBarH(a: number): number {
return 24 + Math.min(a, 50000) / 500;
}
function gradeColor(g: string): string {
if (g === '入门') {
return COLORS.jade;
}
if (g === '进阶') {
return COLORS.hot;
}
if (g === '高级') {
return COLORS.accent;
}
return COLORS.cool;
}
function moodColor(m: string): string {
if (m === '欢快') {
return COLORS.hot;
}
if (m === '悠扬') {
return COLORS.cool;
}
if (m === '激昂') {
return COLORS.accent;
}
return COLORS.jade;
}
function titleColor(t: string): string {
if (t === '大师') {
return COLORS.accent;
}
if (t === '名家') {
return COLORS.hot;
}
return COLORS.cool;
}
function stepColor(s: number): string {
if (s <= 2) {
return COLORS.jade;
}
if (s <= 5) {
return COLORS.hot;
}
return COLORS.accent;
}
@Observed
export class BambooFluteItem {
name: string;
material: string;
len: string;
pitch: number;
price: number;
constructor(name: string, material: string, len: string, pitch: number, price: number) {
this.name = name;
this.material = material;
this.len = len;
this.pitch = pitch;
this.price = price;
}
}
@Observed
export class TuneItem {
name: string;
style: string;
tempo: number;
mood: string;
grade: string;
constructor(name: string, style: string, tempo: number, mood: string, grade: string) {
this.name = name;
this.style = style;
this.tempo = tempo;
this.mood = mood;
this.grade = grade;
}
}
@Observed
export class SkillItem {
name: string;
tech: string;
level: number;
years: number;
desc: string;
constructor(name: string, tech: string, level: number, years: number, desc: string) {
this.name = name;
this.tech = tech;
this.level = level;
this.years = years;
this.desc = desc;
}
}
@Observed
export class MasterItem {
name: string;
title: string;
age: number;
pieces: number;
skill: number;
constructor(name: string, title: string, age: number, pieces: number, skill: number) {
this.name = name;
this.title = title;
this.age = age;
this.pieces = pieces;
this.skill = skill;
}
}
@Observed
export class StepItem {
name: string;
days: number;
tool: string;
note: string;
seq: number;
constructor(name: string, days: number, tool: string, note: string, seq: number) {
this.name = name;
this.days = days;
this.tool = tool;
this.note = note;
this.seq = seq;
}
}
@Observed
export class OrderItem {
name: string;
buyer: string;
amount: number;
count: number;
month: string;
constructor(name: string, buyer: string, amount: number, count: number, month: string) {
this.name = name;
this.buyer = buyer;
this.amount = amount;
this.count = count;
this.month = month;
}
}
@Entry
@Component
struct FlutePage {
@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State selTune: TuneItem | null = null;
@State selOrder: OrderItem | null = null;
@State formName: string = '';
@State formMaterial: string = '';
@State formPrice: string = '';
@State flutes: BambooFluteItem[] = [
new BambooFluteItem('紫竹笛', '紫竹', '65cm', 22, 680),
new BambooFluteItem('湘妃竹笛', '湘妃竹', '58cm', 19, 1280),
new BambooFluteItem('苦竹笛', '苦竹', '52cm', 16, 460),
new BambooFluteItem('凤眼竹笛', '凤眼竹', '60cm', 20, 880),
new BambooFluteItem('白竹笛', '白竹', '55cm', 18, 520),
new BambooFluteItem('斑竹笛', '斑竹', '63cm', 21, 760),
new BambooFluteItem('玉屏箫笛', '玉屏竹', '49cm', 15, 1580),
new BambooFluteItem('曲笛', '苦竹', '60cm', 20, 980),
new BambooFluteItem('梆笛', '紫竹', '46cm', 14, 640),
new BambooFluteItem('长笛', '紫竹', '72cm', 24, 1380),
new BambooFluteItem('骨笛', '鹤骨', '21cm', 6, 29800),
new BambooFluteItem('排箫笛', '黄竹', '40cm', 12, 860)
];
@State tunes: TuneItem[] = [
new TuneItem('姑苏行', '南派', 88, '悠扬', '高级'),
new TuneItem('鹧鸪飞', '南派', 76, '悠扬', '高级'),
new TuneItem('扬鞭催马运粮忙', '北派', 120, '欢快', '进阶'),
new TuneItem('牧民新歌', '北派', 132, '欢快', '进阶'),
new TuneItem('喜相逢', '北派', 96, '欢快', '入门'),
new TuneItem('荫中鸟', '北派', 140, '激昂', '高级'),
new TuneItem('小放牛', '民间', 72, '悠扬', '入门'),
new TuneItem('三五七', '浙江', 108, '欢快', '进阶'),
new TuneItem('早晨', '南派', 84, '悠扬', '进阶'),
new TuneItem('欢乐歌', '江南丝竹', 100, '欢快', '入门')
];
@State skills: SkillItem[] = [
new SkillItem('气振音', '气息', 92, 6, '长音震颤'),
new SkillItem('颤音', '指法', 88, 4, '快速打指'),
new SkillItem('滑音', '指法', 85, 3, '音阶滑动'),
new SkillItem('花舌', '舌法', 80, 5, '舌尖快速颤动'),
new SkillItem('垛音', '气息', 76, 4, '急促短音'),
new SkillItem('历音', '指法', 90, 5, '全孔扫掠'),
new SkillItem('吐音', '舌法', 84, 3, '断奏颗粒'),
new SkillItem('泛音', '气息', 72, 4, '超吹高音')
];
@State masters: MasterItem[] = [
new MasterItem('陆春龄', '大师', 96, 320, 98),
new MasterItem('赵松庭', '大师', 92, 280, 97),
new MasterItem('俞逊发', '大师', 84, 210, 96),
new MasterItem('刘管乐', '名家', 88, 180, 93),
new MasterItem('曾永清', '名家', 78, 150, 91),
new MasterItem('王铁锤', '名家', 82, 140, 90),
new MasterItem('詹永明', '名家', 64, 110, 88),
new MasterItem('张维良', '名家', 60, 120, 89)
];
@State steps: StepItem[] = [
new StepItem('选竹', 3, '目测 · 敲击', '三年老竹', 1),
new StepItem('烤竹校直', 1, '炭火 · 校直架', '去弯定型', 2),
new StepItem('开吹孔', 1, '小刀 · 圆锉', '椭圆吹孔', 3),
new StepItem('开膜孔', 1, '钻头', '贴笛膜', 4),
new StepItem('开音孔', 2, '钻头 · 音准仪', '六音孔', 5),
new StepItem('镶骨笛头', 1, '牛骨 · 胶', '头尾加固', 6),
new StepItem('调音', 2, '音准仪 · 砂纸', '逐孔校准', 7),
new StepItem('上漆', 1, '生漆 · 绒布', '防潮抛光', 8)
];
@State orders: OrderItem[] = [
new OrderItem('紫竹笛', '民乐团', 20400, 30, '2026-08'),
new OrderItem('湘妃竹笛', '国乐工作室', 25600, 20, '2026-07'),
new OrderItem('苦竹笛', '琴行', 13800, 30, '2026-08'),
new OrderItem('凤眼竹笛', '文创店', 17600, 20, '2026-06'),
new OrderItem('梆笛', '戏曲团', 12800, 20, '2026-07'),
new OrderItem('曲笛', '音乐学院', 19600, 20, '2026-05'),
new OrderItem('玉屏箫笛', '非遗展馆', 15800, 10, '2026-08'),
new OrderItem('长笛', '乐团定制', 27600, 20, '2026-06'),
new OrderItem('排箫笛', '研学基地', 8600, 10, '2026-04'),
new OrderItem('斑竹笛', '直播电商', 15200, 20, '2026-05')
];
aboutToAppear(): void {
setInterval(() => {
this.breath = !this.breath;
}, 480);
}
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
@Builder
pageHeader() {
Column() {
Stack() {
Column()
.width('100%')
.height('100%')
.borderRadius(22)
.linearGradient({
angle: 135,
colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
})
ForEach(NOTE_IDX, (i: number) => {
Text(i % 2 === 0 ? '♪' : '♫')
.fontSize(noteSize(i))
.fontColor(COLORS.bambooB)
.opacity(this.breath ? 0.5 : 1)
.position({ x: noteX(i), y: noteY(i) })
.rotate({ angle: this.breath ? 8 : -8 })
.animation({ duration: 900, iterations: -1, playMode: PlayMode.Alternate })
}, (i: number) => 'n' + i)
ForEach(FLUTE_IDX, (i: number) => {
Stack() {
Column()
.width(fluteLen(i))
.height(12)
.borderRadius(6)
.backgroundColor(i === 1 ? COLORS.bambooB : COLORS.bambooA)
.position({ x: fluteX(i), y: fluteY(i) })
ForEach(HOLE_IDX, (j: number) => {
Column()
.width(j === 0 ? 4 : 3)
.height(j === 0 ? 4 : 3)
.borderRadius(2)
.backgroundColor(COLORS.header2)
.position({ x: holeX(i, j), y: holeY(i) })
}, (j: number) => 'h' + i + j)
Column()
.width(6)
.height(12)
.borderRadius(3)
.backgroundColor(COLORS.gold)
.position({ x: fluteX(i) - 3, y: fluteY(i) })
Column()
.width(6)
.height(12)
.borderRadius(3)
.backgroundColor(COLORS.gold)
.position({ x: fluteX(i) + fluteLen(i) - 3, y: fluteY(i) })
}
}, (i: number) => 'f' + i)
ForEach(TASSEL_IDX, (j: number) => {
Column()
.width(2)
.height(18)
.backgroundColor(COLORS.accent)
.borderRadius(1)
.position({ x: tasselX(j), y: tasselY(j) })
.rotate({ angle: this.breath ? 6 : -6, centerX: 1, centerY: 0 })
.animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
}, (j: number) => 't' + j)
Column()
.width(46)
.height(46)
.borderRadius(23)
.backgroundColor(COLORS.gold)
.opacity(this.breath ? 0.65 : 1)
.scale({ x: this.breath ? 1.12 : 1, y: this.breath ? 1.12 : 1 })
.position({ x: 296, y: 10 })
.animation({ duration: 620, iterations: -1, playMode: PlayMode.Alternate })
Text('笛')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.header2)
.position({ x: 307, y: 22 })
Column() {
Text('竹笛坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('笛韵悠扬 · 一管清音传千年')
.fontSize(11)
.fontColor(COLORS.sub)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.position({ x: 18, y: 12 })
Row() {
Text('12')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('笛款')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 18, y: 58 })
Row() {
Text('10')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('名曲')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 18, y: 80 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('技法')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 118, y: 58 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('工序')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 118, y: 80 })
}
.width('100%')
.height(128)
}
.width('100%')
}
@Builder
fluteRow(item: BambooFluteItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Column() {
Text(item.material + ' · ' + item.len)
.fontSize(10)
.fontColor(COLORS.text3)
Row() {
Text('音域')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(pitchBarW(item.pitch))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.jade)
.margin({ left: 6 })
Text(item.pitch + '音')
.fontSize(9)
.fontColor(COLORS.cool)
.margin({ left: 6 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('¥' + item.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
tuneRow(item: TuneItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.layoutWeight(1)
Text(item.grade)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(gradeColor(item.grade))
.borderRadius(8)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text(item.style)
.fontSize(10)
.fontColor(COLORS.cool)
Text(item.mood)
.fontSize(10)
.fontColor(moodColor(item.mood))
.margin({ left: 10 })
Column()
.width(tempoBarW(item.tempo))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.hot)
.margin({ left: 10 })
Text('节奏 ' + item.tempo)
.fontSize(9)
.fontColor(COLORS.text3)
.margin({ left: 6 })
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
@Builder
skillRow(item: SkillItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.tech)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.cool)
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text('掌握度 ' + item.level + '%')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(levelBarW(item.level))
.height(6)
.borderRadius(3)
.backgroundColor(item.level >= 85 ? COLORS.accent : COLORS.hot)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text(item.years + '年')
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
masterRow(item: MasterItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.title)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(titleColor(item.title))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text('技艺 ' + item.skill + ' · ' + item.pieces + ' 首作品')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(levelBarW(item.skill))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.jade)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text(item.age + '岁')
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
stepRow(item: StepItem) {
Row() {
Text(item.seq + '')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.backgroundColor(stepColor(item.seq))
.borderRadius(12)
Column() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.tool + ' · ' + item.note)
.fontSize(10)
.fontColor(COLORS.text3)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(item.days + '天')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.hot)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
orderChart() {
Row() {
ForEach(this.orders, (item: OrderItem) => {
Column() {
Column()
.width(12)
.height(orderBarH(item.amount))
.borderRadius(3)
.backgroundColor(this.breath ? COLORS.gold : COLORS.cool)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Text(item.amount / 1000 + 'k')
.fontSize(8)
.fontColor(COLORS.text3)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (item: OrderItem) => item.name)
}
.width('100%')
.height(92)
.alignItems(VerticalAlign.Bottom)
.padding({ left: 6, right: 6, top: 6, bottom: 6 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
@Builder
orderRow(item: OrderItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.layoutWeight(1)
Column() {
Text('¥' + item.amount)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(item.count + ' 支 · ' + item.month)
.fontSize(9)
.fontColor(COLORS.text3)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
Text('删除')
.fontSize(11)
.fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#FDE8E8')
.borderRadius(8)
.margin({ left: 10 })
.onClick(() => {
this.selOrder = item;
this.showDel = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
tabContent() {
if (this.curTab === 0) {
Column() {
Text('笛款总览 · 音域越广越难得')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.flutes, (item: BambooFluteItem) => {
this.fluteRow(item)
}, (item: BambooFluteItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 1) {
Column() {
Text('经典笛曲 · 曲目与节奏')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.tunes, (item: TuneItem) => {
this.tuneRow(item)
}, (item: TuneItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 2) {
Column() {
Text('吹奏技法 · 修炼等级')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.skills, (item: SkillItem) => {
this.skillRow(item)
}, (item: SkillItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 3) {
Column() {
Text('一代笛师 · 技艺传承')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.masters, (item: MasterItem) => {
this.masterRow(item)
}, (item: MasterItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 4) {
Column() {
Text('制笛八序 · 慢工出细活')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.steps, (item: StepItem) => {
this.stepRow(item)
}, (item: StepItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 5) {
Column() {
Text('订单金额 · 月度走势')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
this.orderChart()
Text('全部订单 · 点击可删除')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
.margin({ top: 12 })
ForEach(this.orders, (item: OrderItem) => {
this.orderRow(item)
}, (item: OrderItem) => item.name + item.month)
}
.width('100%')
.margin({ top: 10 })
}
}
@Builder
bottomItem(i: number) {
Row() {
Text(TAB_LIST[i].icon)
.fontSize(16)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.curTab === i ? COLORS.tabOn : COLORS.sub)
.margin({ left: 4 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.curTab === i ? '#2A6349' : COLORS.tabBg)
.borderRadius(10)
.onClick(() => {
this.curTab = i;
})
}
@Builder
bottomBar() {
Column() {
Row() {
ForEach(ROW1_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => 'r1' + i)
}
.width('100%')
Row() {
ForEach(ROW2_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => 'r2' + i)
}
.width('100%')
.margin({ top: 6 })
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.tabBg)
.borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
}
@Builder
addModal() {
if (this.showAdd) {
Stack() {
this.modalOverlay(() => {
this.showAdd = false;
})
Column() {
Text('新增竹笛')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('登记一款新制竹笛')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 3 })
Column() {
Text('笛名')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formName, placeholder: '如:梅鹿竹笛' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formName = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 14 })
Column() {
Text('材质')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formMaterial, placeholder: '如:梅鹿竹' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formMaterial = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Column() {
Text('价格(元)')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formPrice, placeholder: '如:560' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formPrice = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.onClick(() => {
this.showAdd = false;
})
Text('确认登记')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
this.flutes.push(new BambooFluteItem(this.formName, this.formMaterial, '60cm', 18, Number(this.formPrice)));
this.showAdd = false;
})
}
.width('100%')
.margin({ top: 16 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
@Builder
editModal() {
if (this.showEdit) {
Stack() {
this.modalOverlay(() => {
this.showEdit = false;
})
Column() {
Text('编辑笛曲')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
if (this.selTune) {
Text(this.selTune.name + ' · 节奏 ' + this.selTune.tempo)
.fontSize(12)
.fontColor(COLORS.text2)
.margin({ top: 6 })
}
Text('调整节奏快慢,影响演奏情绪')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 3 })
Row() {
Text('加快节奏 +10')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(10)
.onClick(() => {
if (this.selTune) {
this.selTune.tempo = Math.min(160, this.selTune.tempo + 10);
}
this.showEdit = false;
})
Text('放缓节奏 -10')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selTune) {
this.selTune.tempo = Math.max(40, this.selTune.tempo - 10);
}
this.showEdit = false;
})
}
.width('100%')
.margin({ top: 16 })
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.margin({ top: 10 })
.onClick(() => {
this.showEdit = false;
})
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
@Builder
delModal() {
if (this.showDel) {
Stack() {
this.modalOverlay(() => {
this.showDel = false;
})
Column() {
Text('删除订单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
if (this.selOrder) {
Text('确认删除「' + this.selOrder.name + '」订单?')
.fontSize(12)
.fontColor(COLORS.text2)
.margin({ top: 8 })
}
Text('删除后不可恢复')
.fontSize(10)
.fontColor(COLORS.danger)
.margin({ top: 4 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.onClick(() => {
this.showDel = false;
})
Text('确认删除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selOrder) {
this.orders.splice(this.orders.indexOf(this.selOrder), 1);
}
this.showDel = false;
})
}
.width('100%')
.margin({ top: 16 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
build() {
Stack() {
Column() {
Scroll() {
Column() {
this.pageHeader()
this.tabContent()
}
.width('100%')
.padding({ left: 14, right: 14, bottom: 12 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.backgroundColor(COLORS.bg)
this.bottomBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
if (this.showAdd) {
this.addModal()
}
if (this.showEdit) {
this.editModal()
}
if (this.showDel) {
this.delModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
12.8 总结

综上所述,这个竹笛制作应用是一个结构完整、设计精良、内容丰富的 HarmonyOS ArkTS 应用。从色彩体系的自然取色到数据模型的领域建模,从位置计算的参数化函数到颜色映射的语义编码,从页面头部的多层动画到列表卡片的统一风格,从模态弹窗的交互设计到状态管理的响应式机制,每一个技术层面都体现了开发者的精心设计和深入思考。
应用的核心技术亮点可以归纳为以下几点:第一,@Observed 与 @State 的配合使用实现了对象属性级别的精细响应式追踪,使得列表中单个条目的属性修改能够触发精确的局部更新。第二,breath 呼吸状态驱动的多节奏动画系统,以极低的状态管理成本实现了丰富的视觉效果。第三,pageHeader 通过 Stack 堆叠和参数化定位实现了高度还原的竹笛装饰图形。第四,颜色映射函数将语义标签与视觉颜色建立了稳定的对应关系,形成了有效的视觉编码体系。第五,两步式删除确认和表单简化等交互设计,在保证数据安全的同时降低了用户的操作负担。
更多推荐




所有评论(0)