玻璃吹制工坊基于HarmonyOS ArkTS API 24 FurnaceItem 则封装了窑炉信息,包含区域名称、温度、图标和是否高温的标志,FurnaceItem 的 hot 字段是布尔类型
在移动端应用开发领域,声明式 UI 框架已经逐渐成为主流范式。不同于命令式框架需要开发者手动操作 DOM 或视图树节点,声明式 UI 允许开发者通过描述界面的最终状态,由框架的渲染引擎自动推导出从当前状态到目标状态所需的视图更新操作。这种范式转变极大地降低了复杂界面的维护成本,使得状态管理与视图渲染之间的耦合度被有效控制。本文将围绕一个完整的玻璃吹制工坊主题应用展开深度剖析,从数据模型设计、纯函数抽取、状态驱动、生命周期管理、动画特效实现到弹框交互模式,全方位解析一个具备复杂业务逻辑与沉浸式视觉体验的应用是如何被构建出来的。
该应用以"焰语·玻璃吹制工坊"为主题,融合了玻璃吹制工艺、窑炉温控、技法图鉴、匠友社交、课程管理等多个业务域。在视觉层上,应用采用了深色暖调的窑炉火光配色方案,底层叠加了"火星上浮"与"炉光呼吸"两套动态特效,营造出沉浸式的工坊氛围。在交互层上,应用采用了底部四主 Tab 加首页七内容 Tab 的双层导航结构,配合新增、编辑、删除三类弹框操作,覆盖了内容发布、进度更新、报废退课等核心业务场景。在架构层上,应用严格遵循了数据模型与视图渲染分离的设计原则,将颜色配置、静态数据、纯函数、可观察对象、状态变量、构建器方法分层组织,形成清晰的关注点边界。
从技术栈角度看,该应用使用了基于 TypeScript 超集的声明式 UI 描述语言,支持装饰器驱动的状态观测、组件化构建器、布局组件容器、链式属性设置等特性。应用中的所有可变数据均通过状态装饰器标记,框架会在状态变化时自动触发依赖该状态的视图片段重新渲染。而那些与动画帧相关的高频更新(如火星粒子坐标、炉光呼吸透明度),则通过定时器驱动的 Tick 计数器配合纯函数坐标计算来实现,避免了对昂贵动画 API 的依赖,同时也保证了特效层不会拦截用户点击事件。这种"轻量级自定义动画引擎"的设计思路,在资源受限的移动设备上具有显著的性能优势。
本文将按照源码的组织顺序,逐段拆解每一个接口定义、常量配置、数据模型、纯函数、状态变量、生命周期方法、业务方法、构建器方法和主构建入口。每个代码段之后都会跟随若干段详细的解释文案,涵盖设计意图、实现原理、性能考量、可维护性评估和可扩展建议。文章最后会给出整体数据流图、组件调用关系图、技术对比表格以及详尽的总结,力求让读者不仅理解"怎么写的",更能理解"为什么这么写"以及"还能怎么改进"。

一、文件头部注释与场景定义

// 场景:玻璃吹制 / 窑炉 / 技法 / 匠友圈
// 底部4主tab(首页/作品/课堂/我的)+ 首页7内容tab(图标+文字双行式)
// 特效:火星上浮 + 炉光呼吸(特效层位于底层,不遮挡点击)
// 弹框:新增(发焰语) / 编辑(编辑作品单) / 删除(退炉报废·退课)
文件头部的注释以简洁的方式勾勒出了整个应用的业务轮廓和技术骨架。注释的第一行明确了应用所覆盖的四大业务场景:玻璃吹制、窑炉、技法、匠友圈,这四个场景分别对应内容创作、设备管理、技能学习、社交互动四个产品域。通过在文件起始处就声明业务边界,开发者可以在维护代码时迅速定位到与某条业务线相关的逻辑片段,降低了代码的认知负荷。
注释的第二行揭示了应用的双层导航结构。底部四个主 Tab 是应用的一级导航,分别为首页、作品、课堂和我的;而首页内部又嵌套了七个内容 Tab,采用"图标加文字双行式"的呈现方式。这种双层导航的设计在内容密度较高的应用中非常常见,一级 Tab 负责业务域切换,二级 Tab 负责在同一业务域内做内容细分,二者通过状态变量联动,形成灵活的内容分发机制。
注释的第三行和第四行分别点出了视觉特效层和交互弹框层两大特色。特效层的"位于底层不遮挡点击"是一个关键设计决策,它通过命中测试模式设置为 None 来实现事件透传,确保特效不会干扰用户对上层内容的正常操作。弹框层则细分为新增、编辑、删除三类,分别对应"发焰语"、“编辑作品单”、"退炉报废与退课"三个业务动作,体现了 CRUD 操作在该应用中的语义化包装。
二、ColorPalette 颜色接口定义

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;
ember: string;
}
ColorPalette 接口定义了应用所使用的完整颜色体系。这里采用接口而非类来定义颜色结构,体现了 TypeScript 中"结构型类型"的设计哲学:接口只声明形状而不创建运行时对象,编译后不会产生额外代码,因此非常适合用于约束常量对象的形状。通过接口约束,后续的 COLORS 常量对象必须严格包含这十六个字段,任何遗漏都会在编译期被捕获,从而避免了运行时因颜色未定义而产生的渲染异常。
颜色字段按照语义用途进行了分组命名,包含了主色系(primary、primaryLight、primaryDark)、强调色系(accent、accentLight)、背景色系(bg、cardBg)、文本色系(textPrimary、textSecondary、textHint)、边界色(border)、状态色系(success、warning、danger)、纯白色(white)以及主题特色色(ember 火光色)。这种语义化命名比直接使用十六进制颜色值在代码中散布要优秀得多,它使得后续维护者一眼就能看出某个颜色用于何种用途,也方便了主题切换的实现。
特别值得一提的是 ember 字段,这是专门为"火星"和"炉光"特效预留的火光色,其命名直接取自英文"余烬"之意,与玻璃工坊的窑炉意象高度契合。这种将主题特色色单独命名的设计,使得特效层的视觉调性可以独立于业务组件进行调参,而不会因为修改主色而意外影响特效表现,体现了视觉系统设计的解耦思维。
三、COLORS 常量配置对象

const COLORS: ColorPalette = {
primary: '#E8734A',
primaryLight: '#F5A57F',
primaryDark: '#B44E2E',
accent: '#4AA8A0',
accentLight: '#E0F0EE',
bg: '#1C1210',
cardBg: '#2A1B16',
textPrimary: '#F5E9DF',
textSecondary: '#C9B2A4',
textHint: '#8F7A6D',
border: '#3E2A22',
success: '#7FBF8F',
warning: '#E9B44C',
danger: '#D95757',
white: '#FFFFFF',
ember: '#FF9A3C'
};
COLORS 常量对象是整个应用视觉调性的根基所在。它被声明为 const 且实现了 ColorPalette 接口,意味着这是一个不可重新赋值的只读对象,其形状在编译期就被严格约束。所有的颜色值统一采用六位十六进制格式,这种格式一致性有助于后续的颜色处理函数(如解析、转换、插值)统一处理,避免出现三位简写与六位全写混用导致的解析分支。
从配色策略看,主色系采用了暖橙色调(#E8734A 是一种偏向窑炉火焰的橙色),强调色则选择了与之互补的青绿色(#4AA8A0),形成了"火与冰"的视觉张力。背景色采用了极深的暖褐色(#1C1210 近乎黑色但带有微弱的红色调),这种深色背景能有效凸显前景内容的火光特效,同时也降低了对 OLED 屏幕的功耗。卡片背景色(#2A1B16)比页面背景略亮,形成层次感。
文本色系采用了三档暖白色调,从 textPrimary 的近乎米白到 textHint 的暗灰褐,形成了清晰的视觉层级。状态色系中 success 使用柔和的绿色、warning 使用琥珀黄、danger 使用偏粉的红,这三种状态色都在饱和度上做了克制处理,避免在深色背景上过于刺眼。整体配色方案体现了"暖色为主、冷色点缀、低饱和克制"的窑炉工坊美学。
四、PostItem 匠友动态数据模型

@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 装饰器标记为可观察对象。@Observed 的作用是在类的实例属性被赋值时,自动通知所有依赖该实例的观察者进行视图更新。这意味着当应用在运行时修改了某个 PostItem 实例的 likes 字段时,绑定到该实例的视图片段会自动刷新,无需开发者手动调用更新方法。这种响应式机制是声明式 UI 框架的核心能力之一。
该类被 export 修饰,表明它是一个对外可复用的模型,可以在其他文件中被导入使用。这种设计使得数据模型可以被多个页面或组件共享,而不必在每个使用点重复定义,符合 DRY(Don’t Repeat Yourself)原则。类的六个字段涵盖了动态所需的核心信息:唯一标识 id、用户昵称 nick、头像 emoji avatar、动态正文 text、发布时间 time 和点赞数 likes。
构造函数采用了简洁的单行赋值风格,将六个参数依次赋给对应字段。虽然这种写法在可读性上略逊于多行写法,但在数据模型类这种"纯数据容器"的场景下是可接受的,因为构造函数的逻辑极其简单,不存在复杂的初始化运算。字段都给出了默认值(如 id 默认 0、nick 默认空字符串),这保证了即便通过无参方式创建实例,对象也处于一个安全的初始状态,避免 undefined 在后续逻辑中传播。
五、GlassItem 玻璃作品数据模型

@Observed
export class GlassItem {
id: number = 0
name: string = ''
craft: string = ''
series: string = ''
status: string = ''
heat: number = 0
constructor(id: number, name: string, craft: string, series: string, status: string, heat: number) {
this.id = id; this.name = name; this.craft = craft; this.series = series
this.status = status; this.heat = heat
}
}
GlassItem 类封装了玻璃作品的核心元数据。与 PostItem 类似,它同样被 @Observed 装饰以支持响应式更新,并使用 export 暴露给外部使用。六个字段分别承载了作品的标识、名称、工艺、系列、状态和热度。其中 craft 字段记录了该作品所采用的技法(如吹制、套料、喷砂、灯工、铸造等),series 字段则将作品归类到不同的展示系列(如日用器、艺术器、雕塑件、小品件),这两个字段共同构成了作品分类的两个维度。
status 字段是一个字符串类型的状态描述,可能的取值包括"已完工"、“退火中”、“冷加工”、“在制”、"规划中"等。值得注意的是,这里没有使用枚举类型而是使用字符串字面量,这在小型应用中是合理的折衷——枚举虽然类型安全更好,但会引入额外的类型定义开销,而字符串字面量配合后续的 statusColor 和 statusProgress 等纯函数,已经能覆盖状态到视觉表现的映射需求。在大型应用中,建议将状态收敛为联合字面量类型(如 status: ‘已完工’ | ‘退火中’ | …)以获得编译期检查。
heat 字段记录了作品的"热度",这是一个介于业务数据与社交数据之间的指标,可能代表了作品在匠友圈中的关注度或工艺难度系数。将热度作为数字而非字符串存储,使得后续可以进行数值比较、排序、图表展示等操作,体现了数据类型选择的审慎考量。整个 GlassItem 的设计体现了"扁平化数据结构"的思想,所有信息都在同一层级,便于序列化、传输和渲染。
六、TechniqueItem 技法数据模型与 GearItem 工具数据模型

@Observed
export class TechniqueItem {
id: number = 0
name: string = ''
step: string = ''
level: string = ''
progress: number = 0
constructor(id: number, name: string, step: string, level: string, progress: number) {
this.id = id; this.name = name; this.step = step; this.level = level
this.progress = progress
}
}
@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
}
}
TechniqueItem 类代表了玻璃吹制技法的掌握情况。它记录了技法名称 name、当前练习步骤 step、难度等级 level 和掌握进度 progress。其中 level 字段的取值为"基础"、“进阶”、"高级"三档,progress 为 0 到 100 的百分比数值。该模型在应用中承担了双重角色:既是技法图鉴页面的展示数据,也是创作工坊页面的列表数据,还被"我的"页面用于展示用户的技法概览。一个数据模型服务于多个视图,正是声明式 UI 中"单一数据源"原则的体现。
GearItem 类则代表了玻璃吹制工具的数据。四个字段分别记录了工具的唯一标识、名称、描述和 emoji 图标。与 GlassItem 不同,GearItem 没有状态字段,因为工具本身不涉及生命周期状态变化,它是一个相对静态的实体。icon 字段使用 emoji 字符串而非图片资源引用,这是一个轻量化的图标方案——emoji 字符内置在系统字体中,无需额外的图片资源加载,渲染开销极低,且天然支持跨平台显示。
这两个数据模型同样被 @Observed 装饰,意味着当技法进度被更新(通过编辑弹框)时,所有引用了 techList 的视图片段都会自动刷新。这种响应式能力在该应用中被大量使用,特别是在 doEdit 方法执行 splice 替换技法项之后,技法图鉴页、创作工坊页、"我的"页的相关列表都会同步更新,无需开发者手动触发各页面的重新渲染。这正是声明式 UI 相对命令式 UI 的核心优势所在。
七、图表与课程接口定义
interface FireChartItem {
label: string;
value: number;
}
interface CraftChartItem {
label: string;
value: number;
color: string;
}
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface FurnaceItem {
zone: string;
temp: string;
icon: string;
hot: boolean;
}
这一组接口定义了应用中四种辅助性的数据结构,它们与前面的 @Observed 类不同,使用 interface 而非 class 来声明。这是因为这四种数据主要用于静态数据列表或图表数据,不需要被独立观察,只需要在列表层面随父级状态变量变化即可。interface 在编译后不会产生运行时代码,更适合这种"纯数据形状约束"的场景。
FireChartItem 和 CraftChartItem 都包含 label 和 value 字段,用于柱状图和进度条的数据驱动。不同之处在于 CraftChartItem 多了一个 color 字段,允许每条数据携带自己的颜色,这使得"技法构成"图表可以为不同工艺分配不同的颜色,增强了图表的信息密度。这种"数据自带视觉属性"的设计,在数据条目数量较少且颜色映射稳定的场景下是高效的,避免了在渲染层维护一张额外的颜色映射表。
CourseItem 封装了课程信息,包含标题、周次、进度和颜色四个字段。FurnaceItem 则封装了窑炉信息,包含区域名称、温度、图标和是否高温的标志。值得注意的是 FurnaceItem 的 hot 字段是布尔类型,用于在渲染时区分高温炉和低温炉的颜色显示(高温用 ember 火光色,低温用 accent 青色),这种二元状态字段在视觉切换场景中非常常见,比使用字符串状态更直接。
八、静态数据列表之 POST_LIST 与 GLASS_LIST
const POST_LIST: PostItem[] = [
new PostItem(1, '炉前老周', '🔥', '1180 度开炉,一支湖蓝海棠杯收工,杯口那道弧线值了。', '4分钟前', 96),
new PostItem(2, '吹管姑娘', '🫧', '第一次独立完成双耳瓶,膨胀率控制住了没炸裂,激动。', '21分钟前', 88),
// ...
];
const GLASS_LIST: GlassItem[] = [
new GlassItem(1, '湖蓝海棠杯', '吹制', '日用器', '已完工', 82),
new GlassItem(2, '落日套料瓶', '套料', '艺术器', '退火中', 66),
// ...
];
POST_LIST 和 GLASS_LIST 是两份顶层静态数据列表,它们在应用启动时即被构造完成,作为初始数据源供组件使用。POST_LIST 包含八条匠友动态,内容涵盖了开炉心得、独立创作、退火守夜、套料技法、冷加工、配色实验、失蜡铸造、安全提醒等玻璃吹制工坊的方方面面,每条动态都带有真实的工艺细节(如"1180 度开炉"、“膨胀率控制”、“0.3% 和 0.5% 差距”),体现了数据与业务的高度贴合。
GLASS_LIST 包含八件玻璃作品,覆盖了吹制、套料、喷砂、灯工、铸造、威尼斯技法等多种工艺,以及日用器、艺术器、雕塑件、小品件等不同系列,状态则分布在已完工、退火中、冷加工、在制、规划中五个阶段。这种数据覆盖的广度保证了应用在初始状态下就能展示丰富的内容,便于开发者在原型阶段就评估视觉表现和交互流程。
值得注意的是,这两份数据都使用 new 实例化,而不是直接使用对象字面量。这意味着每条数据都是对应类的实例,可以被 @Observed 装饰器的响应式系统正确追踪。如果改为对象字面量,虽然 TypeScript 的结构类型允许它通过编译,但运行时响应式系统可能无法正确识别这些对象为可观察实例,从而导致修改属性后视图不刷新。这是一个容易被忽略的实现细节。
九、TECH_LIST、GEAR_LIST 与图表数据
const TECH_LIST: TechniqueItem[] = [
new TechniqueItem(1, '蘸料与旋转', '匀速定形', '基础', 100),
new TechniqueItem(2, '吹小泡控形', '气压练习', '基础', 86),
// ...
];
const GEAR_LIST: GearItem[] = [
new GearItem(1, '吹管', '蘸料吹制', '🥢'),
new GearItem(2, '坩埚钳', '转移夹持', '🔧'),
// ...
];
const FIRE_CHART: FireChartItem[] = [
{ label: '一', value: 2 },
{ label: '二', value: 3 },
// ...
];
TECH_LIST 包含了八项玻璃吹制技法,从基础的"蘸料与旋转"到高级的"威尼斯千花"和"Pontil 转接",进度从 100 递减到 16,形成了一条由熟练到初学的技能曲线。这种数据分布使得技法图鉴页面呈现出"金字塔"式的视觉结构,已经掌握的技法在顶部,正在练习的在底部,符合学习曲线的认知规律。GEAR_LIST 则列出了八件常用工具,每件都配有 emoji 图标和简短描述,便于匠友快速识别。
FIRE_CHART 是本周炉次的柱状图数据,包含周一到周日七天的炉次数量。这里的 value 范围在 1 到 5 之间,对应了后续 barH 函数中的 max 参数为 5。CRAFT_CHART 则是技法构成占比数据,每条数据都自带 color 字段,用于在进度条中以不同颜色区分吹制、套料、灯工、铸造、喷砂五种工艺。这种"颜色随数据走"的设计,使得图表的视觉表现完全由数据驱动,新增或修改数据时无需改动渲染代码。
这四份数据虽然都是 const 声明的顶层常量,但它们在应用中被组件以两种方式引用:一是直接引用(如 GLASS_LIST 被首页的双列展示直接 ForEach 遍历),二是赋值给 @State 状态变量后再引用(如 postList 被赋值为 POST_LIST,techList 被赋值为 TECH_LIST)。后者是为了支持运行时对数据的修改(如新增动态、编辑技法、删除作品),而前者则是只读展示场景。这种区分体现了"可变数据"与"不可变数据"在使用方式上的差异。
十、FURNACE_LIST 与 COURSE_LIST 数据
const FURNACE_LIST: FurnaceItem[] = [
{ zone: '熔料炉', temp: '1180℃', icon: '🔥', hot: true },
{ zone: '退火炉', temp: '520℃', icon: '🌡️', hot: false },
{ zone: '预热炉', temp: '400℃', icon: '♨️', hot: false }
];
const COURSE_LIST: CourseItem[] = [
{ title: '安全与炉前规范', week: '第 1 周', progress: 100, color: '#E8734A' },
{ title: '蘸料与基本旋转', week: '第 2 周', progress: 92, color: '#4AA8A0' },
// ...
];
FURNACE_LIST 提供了三个窑炉区域的数据:熔料炉处于 1180 度的高温状态(hot 为 true),退火炉和预热炉则分别处于 520 度和 400 度的相对低温状态。这三组数据在头部构建器中被渲染为三个并排的温度卡片,高温炉的数值显示为火光色 ember,低温炉的数值显示为青色 accent,形成视觉上的热度对比。温度使用字符串而非数字存储,是因为这里只需要展示,不涉及数值运算,字符串携带的℃符号也便于直接渲染。
COURSE_LIST 列出了五门课程,按周次排列,进度从 100 递减到 12,反映了学员从入门到进阶的学习路径。每门课程自带 color 字段,使得进度条可以使用不同的颜色区分不同阶段,增强了视觉识别度。第一门课程"安全与炉前规范"使用主色 primary,强调了安全规范的重要性;第二门课程使用 accent 青色,标识基础技能;后续课程则交替使用暖色调,形成节奏感。
这两份数据的共性在于它们都是"展示型数据",在应用运行期间不会被修改。因此它们被声明为顶层 const 常量,而不是赋值给 @State 变量。这种区分使得框架不必为这些数据维护响应式追踪,降低了内存和 CPU 开销。在实际工程中,这种"可变状态与不可变常量分层管理"的模式是性能优化的重要手段之一。
十一、NavItem 导航接口与 NAV_LIST 主导航
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🔥', label: '首页' },
{ icon: '🫙', label: '作品' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
NavItem 接口定义了导航项的数据结构,仅包含 icon 和 label 两个字段,极为简洁。这种"最小化接口"的设计反映了导航项的本质需求:一个图标用于视觉识别,一个文字标签用于语义说明。没有多余的状态字段(如"是否选中"、“是否禁用”),是因为选中状态由组件内部的 mainTab 索引变量动态决定,而非数据自身携带。这种"数据与状态分离"的模式,使得同一份数据可以被不同的状态上下文复用。
NAV_LIST 定义了底部四个主 Tab:首页、作品、课堂、我的。图标使用 emoji 而非图片资源,延续了该应用轻量化的视觉策略。四个 Tab 的语义覆盖了"内容浏览(首页)"、“作品管理(作品)”、“学习课程(课堂)”、"个人中心(我的)"四个核心功能域,是内容型应用的经典导航布局。底部栏的设计采用图标在上、文字在下的双行式布局,这种布局在移动端底部导航中被广泛采用,因为它既能通过图标快速识别,又能通过文字消除歧义。
主 Tab 的选中状态在 bottomBar 构建器中通过 mainTab 索引与当前遍历索引的比较来决定:选中时图标不透明度为 1、文字加粗且为主色,未选中时图标不透明度为 0.55、文字常规且为提示色。这种基于索引比较的选中态计算,避免了为每个 Tab 维护独立的选中状态,简化了状态管理。当用户点击某个 Tab 时,onClick 回调只需更新 mainTab 一个变量,整个底部栏就会自动重新渲染为新的选中态。
十二、SUB_NAV_LIST 副导航配置
const SUB_NAV_LIST: NavItem[] = [
{ icon: '✨', label: '精选' },
{ icon: '🫙', label: '玻璃作品' },
{ icon: '📖', label: '技法图鉴' },
{ icon: '⚒️', label: '创作工坊' },
{ icon: '🎓', label: '吹制课堂' },
{ icon: '💬', label: '匠友圈' },
{ icon: '🧰', label: '工具坊' }
];
SUB_NAV_LIST 定义了首页内部的七个内容 Tab,复用了与 NAV_LIST 相同的 NavItem 接口。这种接口复用体现了类型系统的抽象能力——只要数据形状一致,就可以共用类型定义,无需为每个使用场景单独定义接口。七个 Tab 分别对应七种内容视图:精选聚合页、玻璃作品列表、技法图鉴、创作工坊、吹制课堂、匠友圈动态、工具坊。这种细粒度的内容划分,使得首页成为一个"内容枢纽",用户可以在不离开首页的情况下浏览到几乎所有业务内容。
副导航被包裹在一个横向滚动的 Scroll 容器中,这是因为七个 Tab 的总宽度可能超过屏幕宽度,需要支持横向滑动。Scroll 组件被设置为 scrollable(ScrollDirection.Horizontal) 并关闭了滚动条(scrollBar(BarState.Off)),提供了流畅的横向滑动体验。每个 Tab 项是一个 Column,图标在上文字在下,选中时整体背景变为主色,未选中时为卡片背景色,形成清晰的选中态视觉反馈。
副导航的选中状态通过 subTab 索引变量管理,与主 Tab 的 mainTab 变量相互独立。这意味着用户在首页内部切换内容 Tab 时,底部主 Tab 始终保持在"首页"位置,两个层级的导航状态不会相互干扰。这种"双层导航独立状态"的设计,使得导航逻辑清晰可追踪,便于调试和扩展。
十三、barH 与 craftColor 纯函数
function barH(v: number, max: number): number {
return Math.round(112 * v / max);
}
function craftColor(v: number): string {
if (v > 60) {
return '#E8734A';
} else if (v > 35) {
return '#4AA8A0';
}
return '#E9B44C';
}
barH 函数是柱状图高度的计算工具,它接收一个数值 v 和最大值 max,返回按比例缩放到 112 像素高度的结果。这里使用 Math.round 进行四舍五入,避免了小数像素在渲染时产生的锯齿。112 这个魔法数字代表了柱状图区域的最大高度,它与后续 Row 容器的 height(140) 相呼应——140 是整个柱状图容器的高度,112 是柱子本身的最大高度,剩余的空间用于标签文字和间距。这种将布局尺寸硬编码到纯函数中的做法,在小型应用中是可接受的,但在大型应用中建议提取为布局常量。
craftColor 函数根据数值大小返回不同的颜色:大于 60 返回主色橙、大于 35 返回青色、其余返回琥珀黄。这是一个典型的"分段映射"函数,将数值区间映射到颜色区间。该函数在首页的周炉次柱状图中被使用,为每根柱子根据炉次数量分配颜色,使得炉次较高的日期在视觉上更突出。这种"数据驱动颜色"的设计,使得图表的颜色不再是静态的,而是随着数据变化而动态调整。
这两个函数都是纯函数——给定相同的输入,总是返回相同的输出,且不产生副作用。纯函数的优势在于可测试性高、可缓存、可并行执行,且在响应式系统中行为可预测。该应用将所有与计算逻辑相关的代码都提取为顶层纯函数,使得组件方法中只保留状态管理和视图构建逻辑,职责分离清晰。这种设计在函数式编程范式中被广泛推崇,也适合声明式 UI 框架的开发模式。
十四、火星粒子坐标函数族
function sparkX(tick: number, i: number): number {
return 30 + ((tick * 6 + i * 107) % 600);
}
function sparkY(tick: number, i: number): number {
return 620 - ((tick * 9 + i * 53) % 560);
}
function sparkA(tick: number, i: number): number {
return 0.2 + ((tick + i) % 4) * 0.18;
}
function sparkR(tick: number, i: number): number {
return 1.5 + (i % 3);
}
这一组函数共同构成了"火星上浮"特效的核心计算逻辑。sparkX 和 sparkY 分别计算第 i 颗火星在当前 tick 下的横纵坐标,sparkA 计算透明度,sparkR 计算半径。这四个函数都接收两个参数:tick(时间步进值,由定时器每 90 毫秒递增一次)和 i(火星粒子的索引),通过取模运算将线性递增的 tick 转换为周期性变化的坐标值,形成火星不断上浮、循环往复的视觉效果。
sparkX 的计算逻辑是 30 加上 (tick 乘以 6 加 i 乘以 107) 对 600 取模的结果。tick 每次增加 1,乘以 6 后每次步进 6 个像素,600 取模使得火星在水平方向上每 100 步循环一次。i 乘以 107 用于为不同的火星粒子赋予不同的初始位置,107 这个质数的选择保证了不同粒子的位置分布相对均匀,避免多颗火星在同一位置重叠。sparkY 的逻辑类似,但使用了 620 减去取模结果,使得火星从底部向上移动,符合"上浮"的语义。
sparkA 函数通过 (tick 加 i) 对 4 取模再乘以 0.18,使得透明度在 0.2 到 0.74 之间周期性变化,形成火星闪烁的效果。sparkR 函数通过 i 对 3 取模加 1.5,使得不同火星的半径在 1.5 到 3.5 之间分布,形成大小不一的粒子层次。这组函数的设计精髓在于:通过简单的取模运算,将一个单调递增的 tick 变量转换为多维度(位置、透明度、大小)的周期性变化,无需维护复杂的粒子状态数组,极大地降低了内存和计算开销。
十五、炉光呼吸函数族与状态映射函数
function glowA(tick: number, i: number): number {
return 0.07 + ((tick + i * 2) % 5) * 0.04;
}
function glowX(tick: number, i: number): number {
return 60 + ((tick * 4 + i * 151) % 540);
}
function glowY(tick: number, i: number): number {
return 120 + i * 180;
}
function statusColor(s: string): string {
if (s === '已完工') {
return '#7FBF8F';
} else if (s === '在制' || s === '退火中' || s === '冷加工') {
return '#E8734A';
}
return '#8F7A6D';
}
glowA、glowX、glowY 三个函数构成了"炉光呼吸"特效的计算逻辑。与火星函数类似,它们也接收 tick 和 i 两个参数,但产生的视觉效果截然不同。glowA 计算的透明度范围在 0.07 到 0.23 之间,远低于火星的透明度,这是因为炉光作为背景光晕,需要保持柔和而不喧宾夺主。glowX 的计算使用了 151 这个质数作为乘数,与火星函数的 107 不同,确保了炉光与火星的位置分布不会同步,形成更自然的随机感。
glowY 的计算逻辑与火星不同,它使用 120 加 i 乘以 180,即炉光的垂直位置只与索引 i 有关,与 tick 无关。这意味着炉光在垂直方向上是静止的,只有水平位置和透明度随 tick 变化。这种设计使得炉光表现为"水平漂移、明暗呼吸"的效果,与火星的"斜向上浮"形成互补的视觉层次。炉光被绘制为 70 乘以 70 的圆形,半径远大于火星的 3 到 7 像素,配合极低的透明度,形成了大范围柔和的光晕效果。
statusColor 函数则属于完全不同的功能域——它是作品状态到颜色的映射函数。"已完工"映射为绿色 success、“在制/退火中/冷加工"映射为主色橙、其余(如"规划中”)映射为提示色灰。这种映射函数将业务语义与视觉表现解耦,使得状态字符串的变化不会影响渲染代码,颜色的调整也只需修改一处。后续还有 statusProgress、seriesTagColor、levelColor 等同类映射函数,共同构成了该应用的"语义到视觉"映射层。
十六、状态进度与分类颜色映射函数
function statusProgress(s: string): number {
if (s === '已完工') {
return 100;
} else if (s === '冷加工') {
return 78;
} else if (s === '退火中') {
return 64;
} else if (s === '在制') {
return 46;
}
return 10;
}
function seriesTagColor(g: string): string {
if (g === '艺术器' || g === '雕塑件') {
return '#4AA8A0';
} else if (g === '日用器') {
return '#E8734A';
}
return '#E9B44C';
}
function levelColor(s: string): string {
if (s === '基础') {
return '#7FBF8F';
} else if (s === '进阶') {
return '#4AA8A0';
}
return '#E8734A';
}
statusProgress 函数将作品状态映射为完成度百分比。这种映射使得进度条的宽度可以完全由状态驱动,无需为每件作品单独存储 progress 字段。函数使用了 if-else if 的链式判断,将五种状态映射到四个进度值(100、78、64、46),其余状态默认为 10。这种"状态到进度的推导"设计,减少了数据冗余,但也引入了"修改状态映射需要同步修改函数"的耦合,在工程实践中需要权衡。
seriesTagColor 函数将作品系列映射为标签颜色:艺术器和与雕塑件映射为青色、日用器映射为橙色、其余映射为琥珀黄。levelColor 函数则将技法难度映射为颜色:基础映射为绿色、进阶映射为青色、高级映射为橙色。这两个函数的设计逻辑一致——都是将离散的字符串分类映射到预定义的颜色,形成"分类色编码"的视觉系统。这种色编码在信息密集的列表中非常有效,用户可以通过颜色快速识别分类,而不必逐个阅读文字。
三个函数共同构成了该应用的"语义映射层",它们被分散在各个构建器方法中被调用,将业务数据转换为视觉属性。这种集中式映射函数的设计,使得颜色的调整可以集中在一处进行,而不必在各个构建器中散布颜色判断逻辑。如果未来需要支持主题切换,只需修改这些函数的返回值即可,无需改动视图代码。这是"关注点分离"原则在视觉映射层的具体体现。
十七、PageGlassBlowingStudio 组件入口与状态变量
@Entry
@Component
struct PageGlassBlowingStudio {
@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 = 'glass'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State postList: PostItem[] = POST_LIST
@State techList: TechniqueItem[] = TECH_LIST
@State fxTimer: number = -1
}
PageGlassBlowingStudio 是整个应用的根组件,被 @Entry 和 @Component 双重装饰。@Entry 标记它为应用的入口组件,@Component 声明它为一个自定义组件。struct 关键字定义了一个结构体,这是该声明式 UI 框架中组件的基本组织形式。struct 内部可以包含状态变量、生命周期方法、业务方法和构建器方法,形成了一个自包含的组件单元。
组件内部声明了十六个 @State 状态变量,涵盖了导航状态(mainTab、subTab)、动画状态(tick、glow)、弹框状态(addOpen、editOpen、delOpen)、编辑上下文(editIdx、editName、editStyle、editNote)、新增上下文(addTitle、addContent)、删除上下文(delTarget)、数据列表(postList、techList)和定时器句柄(fxTimer)。每一个 @State 变量都被框架追踪,当其值发生变化时,依赖该变量的视图片段会自动重新渲染。
值得注意的是 postList 和 techList 被初始化为 POST_LIST 和 TECH_LIST 的引用。这意味着初始状态下,postList 指向的就是顶层静态数组。但由于它们被声明为 @State,框架会在首次渲染时为它们建立响应式追踪。当后续通过 splice、unshift 等方法修改这两个数组时,框架能够检测到变化并触发视图更新。这里有一个细节:由于 POST_LIST 和 TECH_LIST 本身是 const 声明的数组引用,修改 postList 实际上是在修改 POST_LIST 的内容,这在小型应用中可以接受,但在严格模式下建议先进行拷贝再赋值,避免静态数据被意外污染。
十八、aboutToAppear 生命周期与定时器初始化
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToAppear 是组件的生命周期回调,在组件即将出现于屏幕之前被调用。该应用在此回调中初始化了一个定时器,每 90 毫秒执行一次回调函数。回调函数做两件事:将 tick 递增 1,将 glow 在 0 和 1 之间切换。这两个状态变量是整个动画特效系统的时间驱动源——所有的火星坐标和炉光透明度都是 tick 的函数,每 90 毫秒 tick 变化一次,特效层就会重新计算所有粒子的位置和透明度,形成动画效果。
90 毫秒的间隔对应约每秒 11 帧的刷新率,这低于标准的 60 帧每秒,但对于这种"柔和粒子漂移"的特效来说是足够的。较低的刷新率可以有效降低 CPU 和 GPU 的负载,对于背景特效而言是合理的性能折衷。如果将刷新率提高到 60 帧,特效会更加流畅,但功耗也会显著增加,在移动设备上可能影响续航。这种"特效流畅度与功耗"的权衡,是移动端动效设计的常见决策点。
定时器的句柄被保存到 fxTimer 状态变量中,这看似将一个非视图相关的值存储到了 @State 中,但实际上这里使用 @State 是为了在组件范围内方便访问。更严格的做法是使用普通成员变量(不加 @State),因为定时器句柄的变化不应触发视图重渲染。不过在该应用中,fxTimer 只在 aboutToAppear 中赋值一次、在 aboutToDisappear 中读取一次,不会频繁变化,因此使用 @State 的额外开销可以忽略。
十九、aboutToDisappear 生命周期与定时器清理
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToDisappear 是组件即将从屏幕消失时被调用的生命周期回调。该应用在此回调中清理了 aboutToAppear 中创建的定时器。清理逻辑先检查 fxTimer 是否大于 0(即是否持有一个有效的定时器句柄),如果是则调用 clearInterval 清除定时器,并将 fxTimer 重置为 -1。这种"先检查再清理"的模式,避免了在定时器未启动时调用 clearInterval 的无效操作,也防止了句柄被重复清理。
定时器清理是前端开发中一个容易被忽视但极为重要的问题。如果组件销毁时没有清理定时器,定时器回调会继续执行,尝试访问已经销毁的组件实例,可能导致内存泄漏或运行时异常。在声明式 UI 框架中,组件的销毁和重建可能频繁发生(如在 Tab 切换、页面导航时),因此 aboutToAppear 与 aboutToDisappear 的配对使用是保证资源生命周期正确性的基础实践。
将 fxTimer 重置为 -1 而非 0,是因为 setInterval 返回的句柄通常是一个正整数,使用 -1 作为"未持有"的标志可以避免与有效句柄混淆。这是一种常见的"哨兵值"用法,虽然不如使用可选类型(如 number | null)类型安全,但在该框架的 TypeScript 子集中是可接受的实践。
二十、弹框打开方法 openAdd、openEdit、openDelA、openDelB
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.techList.length) {
this.editIdx = index;
this.editName = this.techList[index].name;
this.editStyle = this.techList[index].level;
this.editNote = this.techList[index].step;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'glass';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
这一组方法负责打开三种弹框。openAdd 方法在打开新增弹框之前,先将 addTitle 和 addContent 重置为空字符串,确保每次打开弹框时输入框都是干净的。这种"打开前重置"的模式,避免了上一次输入的内容残留在弹框中,提升了用户体验。openEdit 方法则相反,它在打开编辑弹框之前,先将目标技法项的数据填充到编辑字段中,使得弹框打开时输入框已经预填了当前值,便于用户在原有基础上修改。
openEdit 方法接收一个 index 参数,并先进行边界检查(index 大于等于 0 且小于 techList 长度),只有检查通过才填充数据。这种防御性编程的实践,避免了数组越界访问导致的运行时异常。即便边界检查失败,方法仍然会设置 editOpen 为 true 打开弹框,只是输入框可能为空。这种设计在严格度上有所妥协,但在小型应用中可以接受。
openDelA 和 openDelB 两个方法分别用于打开"退炉报废"和"退课"两种删除场景的弹框。它们都通过设置 delTarget 变量来区分删除目标,然后打开同一个删除弹框。这种"一个弹框服务多个删除场景"的设计,减少了弹框数量,也使得删除逻辑可以统一管理。delTarget 的取值为 ‘glass’ 和 ‘course’ 两个字符串字面量,对应了 doDel 方法中的分支判断。
二十一、业务操作方法 doAdd、doEdit、doDel
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.techList.length) {
this.techList.splice(this.editIdx, 1, new TechniqueItem(this.techList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'glass' && this.techList.length > 0) {
this.techList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
doAdd 方法执行新增动态的操作。它先检查 addTitle 是否非空,如果是则通过 unshift 方法在 postList 数组头部插入一条新的 PostItem。unshift 会将新元素添加到数组开头,使得新动态在列表中显示在最前面,符合"最新内容优先"的社交信息流惯例。新动态的 id 被硬编码为 999,这在原型阶段是可接受的,但在生产环境中应该使用自增 ID 或 UUID 生成器。插入完成后,无论是否实际插入了数据,都会将 addOpen 设为 false 关闭弹框。
doEdit 方法执行技法进度的更新。它通过 splice 方法的三参数形式(splice(index, 1, newItem))实现了"替换"操作——删除指定索引的一项并插入一个新项。这种"用新对象替换旧对象"的模式在响应式系统中非常重要,因为直接修改旧对象的属性可能不会被框架检测到,而 splice 替换则会触发数组的变化追踪。新技法项的 id 复用了原项的 id,progress 被硬编码为 40,这在演示场景下是可接受的简化。
doDel 方法根据 delTarget 的值执行不同的删除操作。当 delTarget 为 ‘glass’ 时,删除 techList 的首项;当 delTarget 为 ‘course’ 时,删除 postList 的首项。两种场景都先检查数组长度大于 0,避免在空数组上执行 splice。这里有一个语义上的有趣设计:界面上"退炉报废"按钮对应的是删除技法记录(techList),"退课"按钮对应的是删除动态(postList),这种映射虽然在数据层面对应了 techList 和 postList,但在业务语义上存在交叉,体现了原型阶段数据映射的灵活性。
二十二、fxLayer 特效层构建器
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Column()
.width(70)
.height(70)
.borderRadius(35)
.backgroundColor(COLORS.ember)
.opacity(glowA(this.tick, i))
.translate({ x: glowX(this.tick, i), y: glowY(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], (i: number) => {
Column()
.width(sparkR(this.tick, i) * 2)
.height(sparkR(this.tick, i) * 2)
.borderRadius(sparkR(this.tick, i))
.backgroundColor(COLORS.primaryLight)
.opacity(sparkA(this.tick, i))
.translate({ x: sparkX(this.tick, i), y: sparkY(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
fxLayer 构建器是整个应用视觉特效的核心。它使用 Stack 容器叠加了两层粒子:第一层是五个"炉光"圆,尺寸为 70 乘以 70 像素,颜色为 ember 火光色,透明度和位置由 glowA、glowX、glowY 函数计算;第二层是十四颗"火星"圆,尺寸和位置由 sparkR、sparkX、sparkY、sparkA 函数计算,颜色为 primaryLight 浅橙色。两层粒子叠加在同一个 Stack 中,形成了"大范围柔和光晕 + 小颗粒明亮火星"的双层视觉层次。
每个粒子的位置通过 translate 属性设置,translate 接收一个包含 x 和 y 的对象,将元素从其原始位置平移指定距离。由于粒子的原始位置都是 Stack 的左上角(alignContent 为 TopStart),translate 的值实际上就是粒子在屏幕上的绝对坐标。这种"原始位置统一、通过 translate 定位"的模式,使得粒子坐标的计算可以完全在纯函数中完成,无需考虑布局复杂度。
最为关键的一行是 hitTestBehavior(HitTestMode.None),它将整个特效层的命中测试模式设置为 None,意味着特效层不会拦截任何触摸事件,所有点击都会穿透到下层的业务内容。这是该应用"特效不遮挡点击"承诺的技术实现。如果没有这一行,特效层的 Stack 会覆盖在整个业务内容之上,拦截所有的点击事件,导致用户无法操作。这是动效设计中"视觉与交互分离"的典型实践。
二十三、header 头部构建器
@Builder
header() {
Column({ space: 10 }) {
Row() {
Text('🔥')
.fontSize(26)
Column() {
Text('焰语玻璃工坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('熔料 · 吹制 · 套料 · 退火')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
.layoutWeight(1)
Text('预约炉位')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
}
.width('100%')
Row({ space: 8 }) {
ForEach(FURNACE_LIST, (it: FurnaceItem) => {
// 窑炉温度卡片
}, (it: FurnaceItem) => it.zone)
}
.width('100%')
}
.width('100%')
.padding({ top: 10, bottom: 8 })
}
header 构建器渲染应用的顶部头部区域,包含品牌信息和窑炉温度卡片两部分。品牌信息部分采用 Row 容器横向排列:左侧是一个大火焰 emoji,中间是工坊名称和工艺标语(使用 Column 纵向排列,layoutWeight 为 1 占据剩余空间),右侧是"预约炉位"按钮(使用主色背景、圆角、白色文字)。这种"图标 + 标题副标题 + 操作按钮"的头部布局是移动端应用的经典模式,兼顾了品牌展示和快捷操作。
窑炉温度卡片部分使用 ForEach 遍历 FURNACE_LIST 数据,为每个窑炉渲染一个温度卡片。卡片内部是 Row 横向排列:左侧是窑炉图标 emoji,右侧是 Column 纵向排列的窑炉名称和温度。温度的颜色根据 hot 字段动态选择——hot 为 true 时使用 ember 火光色,hot 为 false 时使用 accent 青色。这种基于数据字段的动态着色,使得高温窑炉在视觉上更加醒目,符合"危险/热点优先"的视觉层级原则。
header 构建器没有接收任何参数,所有数据都来自组件的状态变量和顶层常量。它通过 @Builder 装饰器声明为一个可复用的视图片段构建方法,在 mainContent 构建器中被调用。@Builder 方法的好处在于它可以将复杂的视图结构封装为一个命名单元,使得主构建方法的代码更加简洁,也便于在多个地方复用同一视图片段。
二十四、subNav 副导航构建器
@Builder
subNav() {
Scroll() {
Row({ space: 8 }) {
ForEach(SUB_NAV_LIST, (item: NavItem, idx: number) => {
Column({ space: 3 }) {
Text(item.icon)
.fontSize(16)
.opacity(this.subTab === idx ? 1 : 0.6)
Text(item.label)
.fontSize(10)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
.borderRadius(12)
.onClick(() => {
this.subTab = idx;
})
}, (item: NavItem) => item.label)
}
.width('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 4, bottom: 10 })
}
subNav 构建器渲染首页内部的七个内容 Tab。外层是一个横向滚动的 Scroll 容器,内层是一个 Row 横向排列所有 Tab 项。每个 Tab 项是一个 Column,上方是图标 Text,下方是标签 Text,两者之间有 3 的间距。Tab 项的视觉状态通过 subTab 索引与当前遍历索引 idx 的比较来决定:选中时图标不透明度为 1、标签加粗、文字为白色、背景为主色;未选中时图标不透明度为 0.6、标签常规、文字为次级色、背景为卡片色。
每个 Tab 项都绑定了 onClick 回调,点击时将 subTab 设置为当前索引 idx。由于 subTab 是 @State 变量,赋值后会触发整个 subNav 构建器重新渲染,所有 Tab 项的视觉状态会根据新的 subTab 值重新计算。这种"点击改变状态、状态驱动渲染"的模式,是声明式 UI 的核心交互逻辑。开发者无需手动操作 DOM 来切换 Tab 的样式,只需修改状态变量,框架会自动完成视图更新。
Scroll 容器被设置为横向滚动(scrollable(ScrollDirection.Horizontal))并关闭了滚动条(scrollBar(BarState.Off))。关闭滚动条是因为七个 Tab 的图标加文字形式已经足够清晰,滚动条反而会占用宝贵的屏幕空间并影响视觉整洁。横向滚动的设计使得即使未来增加更多 Tab,导航也能正常工作而不溢出屏幕。padding 的上下间距为 4 和 10,为 Tab 项提供了呼吸空间。
二十五、pageFeatured 精选聚合页构建器
@Builder
pageFeatured() {
Column({ space: 12 }) {
// 窑炉横幅
Row({ space: 14 }) {
Text('🫧').fontSize(44)
Column({ space: 6 }) {
Text('今日开炉 · 双人协吹专场').fontSize(17).fontWeight(FontWeight.Bold)
Text('1180℃ 熔料就绪 · 大件套料瓶体验 · 限 4 组').fontSize(12)
Text('去预约').fontSize(12).fontWeight(FontWeight.Bold).backgroundColor(COLORS.primaryLight)
}
}
// 周炉次柱状图
// 技法占比
// 作品双列
// 匠友动态
}
}
pageFeatured 是首页"精选"Tab 的内容构建器,它将多个内容板块聚合在一个 Column 中,形成了首页的"信息流"布局。板块包括:窑炉横幅(推广今日开炉活动)、周炉次柱状图(展示本周每日炉次)、技法构成占比(展示用户的技法分布)、工坊展架(双列展示玻璃作品)、匠友动态(展示最近三条匠友动态)。这种"横幅 + 图表 + 列表"的混合布局,使得首页在有限的屏幕空间内呈现了丰富的信息维度。
窑炉横幅采用了主色深(primaryDark)作为背景,与下方的卡片背景(cardBg)形成对比,视觉上突出。横幅内部是 Row 横向排列:左侧大 emoji、右侧 Column 纵向排列标题、描述和"去预约"按钮。这种"图文 + 行动按钮"的横幅设计,既能传递信息又能引导用户行动,是营销型组件的常见模式。
周炉次柱状图通过 ForEach 遍历 FIRE_CHART 数据,为每条数据渲染一个柱子。柱子的高度由 barH 函数计算(根据 value 和 max 5 的比例缩放到 112 像素),颜色由 craftColor 函数根据 value 大小决定。柱子下方是对应的日期标签。整个柱状图区域的高度为 140,底部对齐(alignItems(VerticalAlign.Bottom)),使得不同高度的柱子都从同一基线向上延伸。技法构成占比则使用 Stack 叠加背景条和前景进度条,为每种工艺渲染一条横向进度条。
二十六、pageWorks 作品列表构建器
@Builder
pageWorks() {
Column({ space: 10 }) {
Row() {
Text('玻璃作品').fontSize(16).fontWeight(FontWeight.Bold).layoutWeight(1)
Text('上架新作品').fontSize(12).fontColor(COLORS.primary)
.onClick(() => { this.openAdd(); })
}
ForEach(GLASS_LIST, (it: GlassItem) => {
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).layoutWeight(1)
Text(it.status).fontSize(10).fontColor(statusColor(it.status)).backgroundColor(COLORS.bg)
}
Row({ space: 8 }) {
Text(`⚒️ ${it.craft}`).fontSize(11)
Text(`🔥 热度 ${it.heat}`).fontSize(11)
}
}.layoutWeight(1)
}
Stack({ alignContent: Alignment.Start }) {
Row().width('100%').height(6).backgroundColor(COLORS.border)
Row().width(`${statusProgress(it.status)}%`).height(6).backgroundColor(statusColor(it.status))
}
Row() {
Text(`完成度 ${statusProgress(it.status)}%`).fontSize(10)
Text(it.series).fontSize(10).fontColor(seriesTagColor(it.series))
}
}
})
Button().width('100%').height(42).backgroundColor(COLORS.danger)
.onClick(() => { this.openDelA(); })
Text('退炉报废').fontColor(COLORS.white).margin({ left: -70, top: -32 })
}
}
pageWorks 构建器渲染玻璃作品列表。每个作品卡片采用左侧色条加右侧内容的布局:左侧是一个 4 像素宽的竖条,颜色由 statusColor 函数根据作品状态决定,起到了视觉分类标识的作用;右侧是 Column 纵向排列的作品名称、状态标签、工艺和热度信息。这种"色条 + 内容"的卡片设计,既通过色条提供了快速的状态识别,又通过文字提供了详细信息,兼顾了视觉效率和语义完整性。
每个作品卡片底部有一条进度条,宽度由 statusProgress 函数根据状态计算。进度条采用 Stack 叠加的方式实现:底层是 100% 宽的灰色背景条,上层是按进度百分比宽的彩色前景条。这种"背景 + 前景"的进度条实现方式,是该声明式 UI 框架中最常见的进度条模式,因为它无需额外的进度条组件,仅用基础布局组件即可实现。
列表底部有一个"退炉报废"按钮,使用 danger 红色背景以示警告。按钮的实现采用了一个空的 Button 组件加上一个 Text 组件叠加的方式——Button 没有设置内容,而是通过 margin 负值将 Text 文字定位到 Button 上方。这种实现方式略显 unconventional,更规范的做法是直接在 Button 的构造函数中传入文字内容。不过这种叠加实现在该框架中是可行的,且能更灵活地控制文字样式。
二十七、pageTechnique 技法图鉴构建器
@Builder
pageTechnique() {
Column({ space: 10 }) {
Text('技法图鉴').fontSize(16).fontWeight(FontWeight.Bold)
ForEach(this.techList, (it: TechniqueItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('📖').fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name).fontSize(14).fontWeight(FontWeight.Bold).layoutWeight(1)
Text(it.level).fontSize(10).fontColor(levelColor(it.level)).backgroundColor(COLORS.accentLight)
}
Text(`🔧 ${it.step}`).fontSize(11)
}.layoutWeight(1)
}
Stack({ alignContent: Alignment.Start }) {
Row().width('100%').height(6).backgroundColor(COLORS.border)
Row().width(`${it.progress}%`).height(6).backgroundColor(levelColor(it.level))
}
Row() {
Text(`掌握度 ${it.progress}%`).fontSize(10).layoutWeight(1)
Text('更新进度').fontSize(11).fontColor(COLORS.primary)
.onClick(() => { this.openEdit(idx); })
}
}
})
}
}
pageTechnique 构建器渲染技法图鉴列表,遍历的是组件的 @State 变量 techList 而非顶层常量 TECH_LIST。这一点非常关键——因为 techList 是可变状态,当用户通过编辑弹框更新技法进度后,techList 的变化会触发 pageTechnique 重新渲染,显示更新后的数据。如果这里遍历的是 TECH_LIST 常量,则编辑后的变化不会反映到视图上。这是"可变数据 vs 不可变数据"在使用上的核心区别。
每个技法卡片包含:技法图标(📖)、技法名称、难度标签(颜色由 levelColor 函数决定)、当前步骤说明、掌握度进度条和"更新进度"操作链接。难度标签使用 accentLight 浅青色作为背景,文字颜色由 levelColor 函数根据难度级别决定——基础为绿色、进阶为青色、高级为橙色。这种"标签背景统一、文字颜色随分类变化"的设计,保持了标签的视觉一致性,同时通过文字颜色传递了分类信息。
"更新进度"链接绑定了 onClick 回调,调用 openEdit 方法并传入当前技法的索引 idx。点击后,openEdit 方法会将该技法的数据填充到编辑弹框的字段中,然后打开编辑弹框。用户在弹框中修改数据并点击保存后,doEdit 方法会通过 splice 替换 techList 中对应索引的技法项,触发视图自动更新。这种"列表项 -> 编辑弹框 -> 保存更新列表"的交互闭环,是 CRUD 应用的典型操作流程。
二十八、pageStudio 创作工坊与工具坊构建器
@Builder
pageStudio() {
Column({ space: 10 }) {
Text('创作工坊').fontSize(16).fontWeight(FontWeight.Bold)
ForEach(this.techList.slice(0, 5), (it: TechniqueItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`).fontSize(14).fontColor(idx < 3 ? COLORS.primary : COLORS.textHint).width(20)
Column({ space: 3 }) {
Text(it.name).fontSize(13).fontWeight(FontWeight.Bold)
Text(`${it.step} · ${it.level}`).fontSize(10)
}.layoutWeight(1)
Text(`${it.progress}%`).fontSize(11).fontColor(levelColor(it.level))
}
})
Text('炉位预约').fontSize(14).fontWeight(FontWeight.Bold)
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
// 双列工具卡片
}
}
})
}
}
pageStudio 构建器渲染创作工坊页面,分为两部分:技法排行榜和工具预约区。技法排行榜取 techList 的前五项(slice(0, 5)),以列表形式展示。每项左侧是排名数字,前三名使用主色 primary 显示,后两名使用提示色 textHint,形成"前三名突出、其余弱化"的排名视觉。这种设计在竞技、榜单类场景中非常常见,能够快速引导用户关注头部内容。
工具预约区遍历 GEAR_LIST 数据,采用双列布局展示工具卡片。双列布局的实现逻辑是:ForEach 遍历时,只在偶数索引(idx % 2 === 0)时渲染一个 Row,该 Row 内部包含当前索引和下一个索引的两个工具卡片。这种"按行分组"的双列实现方式,是该框架中常见的多列列表模式,虽然代码略显冗长(需要重复写两个卡片的构建逻辑),但灵活性高,可以精确控制每行的内容。
工具卡片内部包含图标、名称、描述和"去登记"链接。与技法图鉴不同,工具卡片没有进度条和编辑操作,只有"去登记"的预约入口。这反映了工具与技法在业务语义上的差异——工具是被使用的资源,技法是被掌握的技能,二者的交互模式自然不同。pageTools 构建器(工具坊 Tab)则进一步将工具按热端区、退火区、冷加工区三个功能区分类展示,并提供了"补货"操作入口,形成了更完整的工具管理视图。
二十九、pageCourse 课堂与 pageCircle 匠友圈构建器
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('吹制课堂').fontSize(16).fontWeight(FontWeight.Bold)
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title).fontSize(14).fontWeight(FontWeight.Bold).layoutWeight(1)
Text(it.week).fontSize(11).fontColor(COLORS.textHint)
}
Stack({ alignContent: Alignment.Start }) {
Row().width('100%').height(8).backgroundColor(COLORS.border)
Row().width(`${it.progress}%`).height(8).backgroundColor(it.color)
}
Row() {
Text('已学').fontSize(11)
Text(`${it.progress}%`).fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
}
}
})
Button().backgroundColor(COLORS.danger).onClick(() => { this.openDelB(); })
Text('退课').fontColor(COLORS.white).margin({ left: -28, top: -32 })
}
}
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('匠友圈').fontSize(16).fontWeight(FontWeight.Bold).layoutWeight(1)
Text('发焰语').fontSize(12).fontColor(COLORS.primary)
.onClick(() => { this.openAdd(); })
}
ForEach(this.postList, (it: PostItem) => {
// 匠友动态卡片
})
}
}
pageCourse 构建器渲染吹制课堂页面,遍历 COURSE_LIST 数据展示五门课程。每门课程卡片包含标题、周次、进度条(颜色由数据自带的 color 字段决定)和"已学"百分比。与技法图鉴和作品列表不同,课堂页面的进度条颜色直接来自数据,而非通过映射函数计算,这是因为 COURSE_LIST 的每条数据在定义时就指定了颜色。这种"数据自带颜色"和"函数映射颜色"两种模式在该应用中并存,前者适用于颜色与数据强绑定的场景,后者适用于颜色由业务状态推导的场景。
课堂页面底部有一个"退课"按钮,使用 danger 红色背景,绑定了 openDelB 方法。openDelB 方法将 delTarget 设为 ‘course’ 并打开删除弹框。用户确认后,doDel 方法会删除 postList 的首项(注意这里是 postList 而非课程列表,这是该原型的简化映射)。这种按钮配色为红色、操作为删除的视觉与交互对应,是危险操作的标准设计模式,能够有效提醒用户谨慎操作。
pageCircle 构建器渲染匠友圈页面,遍历 @State 变量 postList 展示所有匠友动态。每条动态卡片包含头像 emoji、昵称、发布时间、正文和点赞回复数。页面顶部有"发焰语"链接,点击后调用 openAdd 方法打开新增弹框。用户在弹框中输入内容并发布后,doAdd 方法会通过 unshift 在 postList 头部插入新动态,匠友圈页面会自动刷新显示新动态。这种"发布 -> 列表更新"的实时反馈,是社交型应用的核心体验。
三十、pageMine 个人中心构建器
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🦊').fontSize(40)
Column({ space: 4 }) {
Text('焰语吹制匠').fontSize(18).fontWeight(FontWeight.Bold)
Text('Lv.4 · 完工 22 件 · 炉时 96h').fontSize(11).fontColor(COLORS.textSecondary)
}.layoutWeight(1)
}
Column({ space: 10 }) {
Row() {
Text('我的技法').fontSize(14).fontWeight(FontWeight.Bold).layoutWeight(1)
Text(`${this.techList.length} 项`).fontSize(12).fontColor(COLORS.primary)
}
ForEach(this.techList.slice(0, 3), (it: TechniqueItem) => {
Row() {
Text(it.name).fontSize(13).layoutWeight(1)
Text(it.step).fontSize(11).fontColor(levelColor(it.level))
}
})
}
Column({ space: 10 }) {
Row() {
Text('我的焰语').fontSize(14).fontWeight(FontWeight.Bold).layoutWeight(1)
Text(`${this.postList.length} 条`).fontSize(12).fontColor(COLORS.primary)
}
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row() {
Text(it.nick).fontSize(13).layoutWeight(1)
Text(it.time).fontSize(11).fontColor(COLORS.textHint)
}
})
}
}
}
pageMine 构建器渲染"我的"个人中心页面,分为三个板块:用户信息卡、我的技法摘要、我的焰语摘要。用户信息卡展示了头像 emoji、昵称"焰语吹制匠"和等级统计信息(Lv.4、完工 22 件、炉时 96h)。这些统计信息目前是硬编码的字符串,在实际应用中应该来自后端用户数据接口。头像使用狐狸 emoji 而非真实图片,延续了该应用的 emoji 视觉风格。
"我的技法"板块取 techList 的前三项(slice(0, 3))展示,每项显示技法名称和当前步骤,步骤文字颜色由 levelColor 函数决定。板块标题右侧显示技法总数(techList.length 项),这个数字会随着用户编辑或删除技法而动态变化——当 doDel 删除了 techList 的首项后,techList.length 减小,"我的技法"板块的总数会自动更新。这是响应式系统在跨视图数据一致性上的体现。
“我的焰语"板块取 postList 的前三项展示,每项显示昵称和发布时间。板块标题右侧显示动态总数(postList.length 条),这个数字会随着用户新增或删除动态而变化。整个"我的"页面的设计理念是"数据摘要”——从用户已有的技法记录和动态记录中提取前三项展示,作为用户个人活动的概览。如果用户想查看完整列表,可以切换到技法图鉴或匠友圈 Tab。
三十一、modalOverlay 弹框蒙层构建器
@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%')
}
modalOverlay 构建器渲染弹框的蒙层,它是三个弹框(新增、编辑、删除)共用的背景遮罩。蒙层是一个铺满整个屏幕的黑色半透明 Column(背景色为纯黑、透明度为 0.6),用于将用户的视觉焦点集中到弹框内容上,同时降低背景内容的视觉干扰。蒙层的 onClick 回调会将三个弹框状态变量(addOpen、editOpen、delOpen)全部设为 false,实现了"点击蒙层关闭弹框"的交互模式。
这种"点击蒙层关闭"的模式是移动端弹框的标准交互之一,它为用户提供了一种快速退出弹框的方式,无需特意寻找关闭按钮。由于三个弹框状态变量在 onClick 中同时被设为 false,即便用户在某个弹框打开时点击了蒙层,所有弹框都会被关闭。这种设计在该应用中是安全的,因为同一时间最多只有一个弹框打开,同时重置三个变量不会产生副作用。
蒙层使用 Stack 容器包裹 Column,虽然这里 Stack 只有一个子元素,但使用 Stack 是为了后续可能的扩展(如在蒙层上添加动画效果)。蒙层的宽高都是 100%,使其铺满父容器。由于弹框在 build 方法中是通过 Stack 叠加在主内容之上的,蒙层作为 Stack 的第一个子元素,会位于弹框内容的下方,形成"蒙层在底、内容在上"的视觉层级。
三十二、addModalBody 新增弹框构建器
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布焰语').fontSize(17).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '一句话炉前心得…', text: this.addTitle })
.fontSize(13).backgroundColor(COLORS.bg).borderRadius(8).height(40)
.onChange((v: string) => { this.addTitle = v; })
TextArea({ placeholder: '补充细节:炉温、技法、颜色配比…', text: this.addContent })
.fontSize(13).backgroundColor(COLORS.bg).borderRadius(8).height(90)
.onChange((v: string) => { this.addContent = v; })
Row({ space: 10 }) {
Button().layoutWeight(1).height(38).backgroundColor(COLORS.bg)
.onClick(() => { this.addOpen = false; })
Text('取消').fontSize(13).fontColor(COLORS.textSecondary).margin({ left: -52 })
Button().layoutWeight(1).height(38).backgroundColor(COLORS.primary)
.onClick(() => { this.doAdd(); })
Text('发布').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.white).margin({ left: -44 })
}
}
.padding(16).backgroundColor(COLORS.cardBg)
}
addModalBody 构建器渲染"发布焰语"新增弹框的内容区域。弹框包含标题、一个单行输入框 TextInput(用于输入一句话心得)、一个多行文本域 TextArea(用于补充细节)和取消/发布两个按钮。TextInput 和 TextArea 都绑定了 onChange 回调,将用户输入的值实时同步到 addTitle 和 addContent 状态变量。这种"输入即状态"的双向绑定模式,使得弹框关闭后状态变量中保存的就是用户最新输入的值。
按钮区域的实现采用了 Button 加 Text 叠加的方式——空的 Button 组件作为可点击区域,Text 组件通过 margin 负值定位到 Button 上方显示文字。取消按钮的背景色为 bg 深色,发布按钮的背景色为 primary 主色,形成"次要操作 + 主要操作"的视觉层级。发布按钮的 onClick 调用 doAdd 方法执行新增操作,取消按钮的 onClick 直接设置 addOpen 为 false 关闭弹框。
弹框内容的容器使用 cardBg 卡片背景色和 16 的 padding,与页面其他卡片保持视觉一致性。整个弹框在 build 方法中被包裹在一个 88% 宽度的 Column 中,并设置了 maxHeight 为 80%,防止内容过多时超出屏幕。这种"限定最大高度 + 滚动"的弹框设计,保证了弹框在各种屏幕尺寸和内容长度下都能正常显示。
三十三、editModalBody 编辑弹框构建器
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('编辑技法进度').fontSize(17).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '技法名称', text: this.editName })
.onChange((v: string) => { this.editName = v; })
TextInput({ placeholder: '难度(如 进阶)', text: this.editStyle })
.onChange((v: string) => { this.editStyle = v; })
TextArea({ placeholder: '当前步骤与练习说明', text: this.editNote })
.onChange((v: string) => { this.editNote = v; })
Row({ space: 10 }) {
Button().layoutWeight(1).backgroundColor(COLORS.bg)
.onClick(() => { this.editOpen = false; })
Text('取消')
Button().layoutWeight(1).backgroundColor(COLORS.primary)
.onClick(() => { this.doEdit(); })
Text('保存')
}
}
}
editModalBody 构建器渲染"编辑技法进度"弹框,结构与新增弹框类似,但包含三个输入字段:技法名称、难度、步骤说明。这些字段的初始值来自 openEdit 方法中从目标技法项填充的数据(editName、editStyle、editNote)。当用户修改输入时,onChange 回调会实时更新对应的状态变量。保存按钮调用 doEdit 方法,该方法会通过 splice 替换 techList 中指定索引的技法项。
编辑弹框与新增弹框的代码结构高度相似,这在工程实践中是一个"重复代码"的信号。在大型应用中,可以考虑将弹框抽象为一个通用组件,通过配置参数(标题、字段列表、按钮文案、回调函数)来复用,减少代码重复。但在该原型应用中,两个弹框的字段和逻辑略有不同,分开实现的可读性更高,是可以接受的折衷。
编辑弹框的三个字段对应了 TechniqueItem 的 name、level、step 三个属性。值得注意的是,doEdit 方法在创建新的 TechniqueItem 时,progress 被硬编码为 40,而非保留原项的 progress 值。这意味着每次编辑后,技法的掌握度都会被重置为 40。这是一个原型的简化处理,在实际应用中应该保留原 progress 或根据编辑内容动态计算。
三十四、delModalBody 删除弹框构建器
@Builder
delModalBody() {
Column({ space: 12 }) {
Text(this.delTarget === 'glass' ? '退炉报废' : '退出吹制课堂').fontSize(17).fontWeight(FontWeight.Bold)
Text(this.delTarget === 'glass' ? '将移除首项在制技法记录,确认执行?' : '将退出首门进行中的课程,确认执行?')
.fontSize(13).fontColor(COLORS.textSecondary)
Row({ space: 10 }) {
Button().layoutWeight(1).backgroundColor(COLORS.bg)
.onClick(() => { this.delOpen = false; })
Text('取消')
Button().layoutWeight(1).backgroundColor(COLORS.danger)
.onClick(() => { this.doDel(); })
Text('确认')
}
}
}
delModalBody 构建器渲染删除确认弹框,它的内容根据 delTarget 的值动态变化。当 delTarget 为 ‘glass’ 时,标题显示"退炉报废",提示文字显示"将移除首项在制技法记录";当 delTarget 为 ‘course’ 时,标题显示"退出吹制课堂",提示文字显示"将退出首门进行中的课程"。这种"一个弹框模板服务多个删除场景"的设计,通过 delTarget 变量驱动内容的动态切换,减少了弹框数量。
删除弹框的按钮设计与新增、编辑弹框有所不同——确认按钮使用 danger 红色背景而非 primary 主色,以视觉上警示用户这是一个不可逆的删除操作。这种"危险操作使用红色"的视觉约定,是该应用状态色系的延伸应用,与 success 绿色、warning 黄色形成完整的状态色体系。确认按钮调用 doDel 方法执行删除,取消按钮直接关闭弹框。
删除弹框的提示文字明确说明了删除的范围——“首项在制技法记录"或"首门进行中的课程”,即总是删除列表的第一项。这种简化逻辑在原型阶段是可接受的,但在实际应用中,应该支持用户选择要删除的具体项目,而非总是删除首项。弹框的文案使用了"确认执行?"的疑问句式,引导用户二次确认,是危险操作防误触的标准实践。
三十五、bottomBar 底部导航栏构建器
@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 })
}
bottomBar 构建器渲染应用的底部导航栏,包含四个主 Tab。底部栏使用 Row 容器横向排列,每个 Tab 项通过 layoutWeight(1) 等分宽度。Tab 项内部是 Column 纵向排列图标和标签,间距为 2。选中状态通过 mainTab 索引与当前遍历索引 idx 的比较决定:选中时图标不透明度为 1、标签加粗且为主色;未选中时图标不透明度为 0.55、标签常规且为提示色。
底部栏的整体高度为 56 像素,背景色为 cardBg 卡片色,顶部圆角为 16。这种"顶部圆角 + 卡片背景"的底部栏样式,使得导航栏在视觉上与页面内容分离,形成漂浮在底部的卡片感。圆角的运用是该应用视觉设计的一个特色——几乎所有卡片和容器都使用了不同大小的圆角(6、8、10、12、14、16),通过圆角尺寸的变化形成视觉层级。
每个 Tab 项的 onClick 回调将 mainTab 设为当前索引 idx。由于 mainTab 是 @State 变量,赋值后会触发 mainContent 构建器重新渲染,根据新的 mainTab 值显示对应的主 Tab 内容。同时,bottomBar 自身也会重新渲染,更新各 Tab 的选中态视觉。这种"一处状态变化、多处视图同步更新"的能力,是声明式 UI 相对命令式 UI 的核心优势。
三十六、mainContent 主内容构建器
@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.pageWorks()
} else if (this.subTab === 2) {
this.pageTechnique()
} else if (this.subTab === 3) {
this.pageStudio()
} else if (this.subTab === 4) {
this.pageCourse()
} else if (this.subTab === 5) {
this.pageCircle()
} else {
this.pageTools()
}
}
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
} else if (this.mainTab === 1) {
Scroll() { Column() { this.pageWorks() } }.layoutWeight(1)
} else if (this.mainTab === 2) {
Scroll() { Column() { this.pageCourse() } }.layoutWeight(1)
} else {
Scroll() { Column() { this.pageMine() } }.layoutWeight(1)
}
}
}
mainContent 构建器是整个应用内容区域的组织中枢,它通过 mainTab 和 subTab 两个状态变量的条件判断,决定渲染哪个页面构建器。当 mainTab 为 0(首页)时,先渲染 subNav 副导航,再根据 subTab 的值(0 到 6)选择渲染七个内容页面之一;当 mainTab 为 1(作品)时,直接渲染 pageWorks;当 mainTab 为 2(课堂)时,渲染 pageCourse;当 mainTab 为 3(我的)时,渲染 pageMine。
这种基于 if-else 的条件渲染是该声明式 UI 框架的标准内容分发模式。当 mainTab 或 subTab 的值发生变化时,框架会重新执行 mainContent 构建器,根据新的条件分支选择渲染对应的页面。未选中的页面不会被渲染到视图树中,节省了渲染资源。这种"按需渲染"的模式,在内容页面较多且每个页面较重的情况下,能够有效控制视图树的规模。
每个内容页面都被包裹在一个纵向滚动的 Scroll 容器中,Scroll 使用 layoutWeight(1) 占据 header 下方到底部栏之间的所有空间。scrollBar 被设为 Off 隐藏滚动条,padding 提供了左右和底部的间距。这种"每个 Tab 独立滚动"的设计,使得每个内容页面都有自己的滚动位置状态,切换 Tab 再切回时,滚动位置会保持上次的位置(如果框架支持滚动位置记忆),提升了浏览连续性。
三十七、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)
}
}
if (this.editOpen) { /* 编辑弹框 */ }
if (this.delOpen) { /* 删除弹框 */ }
}
.width('100%')
.height('100%')
}
build 方法是组件的根构建方法,它使用 Stack 容器叠加了四层内容:背景层、特效层、主内容层和弹框层。背景层是一个铺满屏幕的 Column,背景色为 bg 深色,为整个应用提供了基础底色。特效层是 fxLayer 构建器渲染的火星和炉光粒子,位于背景层之上、主内容层之下,通过 hitTestBehavior(None) 不拦截点击。主内容层是一个 Column,包含 mainContent 和 bottomBar 两个构建器。
弹框层使用条件渲染——只有当 addOpen、editOpen 或 delOpen 为 true 时,对应的弹框 Stack 才会被渲染到视图树中。每个弹框 Stack 内部包含 modalOverlay 蒙层和弹框内容 Column,内容 Column 的宽度为 88%,最大高度为 80%,圆角为 16,zIndex 为 999。zIndex 999 确保弹框始终位于所有内容之上,不会被其他元素遮挡。这种"条件渲染弹框"的模式,使得弹框未打开时不占用渲染资源。
整个 build 方法的层级结构清晰:背景 -> 特效 -> 内容 -> 弹框,自下而上叠加。这种层级设计保证了视觉层级和交互层级的正确性——特效在背景之上但不遮挡内容,内容在特效之上接收正常点击,弹框在所有内容之上独占焦点。Stack 容器是该应用整体布局的基础,通过 z 轴叠加实现了多层内容的和谐共存。
整体数据流与组件调用关系
以下流程图展示了该应用从用户交互到视图更新的完整数据流:
以下流程图展示了定时器驱动的特效动画更新机制:
技术方案对比表格
以下表格从多个维度对比了该应用中所采用的技术方案与替代方案的优劣:
| 技术维度 | 当前方案 | 替代方案一 | 替代方案二 | 优势分析 | 适用场景 |
|---|---|---|---|---|---|
| 颜色管理 | 接口约束常量对象 | 枚举类型 | 主题类动态切换 | 编译期类型安全、零运行时开销 | 主题固定的应用 |
| 数据模型 | @Observed class | interface 字面量 | 函数式 Record | 实例级响应式追踪、支持 new 构造 | 需要修改属性的应用 |
| 静态数据 | const 数组 + new 实例 | 对象字面量数组 | 异步加载 JSON | 实例可观察、启动即就绪 | 原型演示与小数据量 |
| 动画驱动 | setInterval + 纯函数 | requestAnimationFrame | 属性动画 API | 轻量可控、不依赖动画框架 | 自定义粒子特效 |
| 特效透传 | hitTestBehavior None | z-index 分层 | 绝对定位避开 | 一行配置实现事件透传 | 背景装饰层 |
| 导航结构 | 双层 Tab 状态变量 | 路由栈管理 | 状态机模式 | 简单直接、无需路由库 | 内容型 Tab 应用 |
| 弹框管理 | 三布尔状态变量 | 状态枚举 | 弹框管理器类 | 独立控制、条件渲染轻量 | 弹框数量少 |
| 弹框蒙层 | 共用 modalOverlay | 每个弹框独立蒙层 | 全局蒙层组件 | 代码复用、行为统一 | 多弹框共用蒙层 |
| 列表渲染 | ForEach + if-else 分组 | LazyForEach 懒加载 | 虚拟列表组件 | 实现简单、数据量小即可 | 少量列表项 |
| 双列布局 | idx % 2 按行分组 | Grid 网格组件 | CSS Grid 布局 | 精确控制每行内容 | 不规则双列卡片 |
| 进度条 | Stack 叠加背景前景 | 专用 Progress 组件 | Canvas 绘制 | 纯基础组件实现、样式灵活 | 简单进度展示 |
| 状态映射 | 纯函数 if-else 链 | 查找表 Map | 枚举映射对象 | 逻辑清晰、支持区间判断 | 离散状态映射 |
| 图表实现 | ForEach + 基础布局组件 | 第三方图表库 | Canvas 自绘 | 零依赖、风格统一 | 简单柱状图进度条 |
| 图标方案 | emoji 字符 | SVG 图标 | 图片资源 | 零资源加载、跨平台 | 轻量化原型 |
| 按钮文字 | Button + Text 负 margin 叠加 | Button 构造函数传文字 | 自定义按钮组件 | 灵活控制文字样式 | 需要自定义文字 |
| 定时器清理 | aboutToDisappear 配对清理 | WeakRef 自动清理 | 路由离开清理 | 生命周期配对、资源不泄漏 | 定时器动画 |
| 数据修改 | splice 替换触发响应 | 直接修改属性 | 不可变数据展开 | 确保框架检测到变化 | 响应式数组更新 |
| 布局容器 | Stack/Row/Column 组合 | Flex 布局 | 绝对定位 | 语义清晰、自适应 | 声明式 UI 标准布局 |
| 权重分配 | layoutWeight 等分 | 固定像素宽度 | 百分比宽度 | 自适应屏幕宽度 | 等分导航栏 |
| 条件渲染 | if-else 分支选择 | 策略模式映射 | 状态机驱动 | 直观易读、适合少量分支 | 内容页面切换 |
| 输入双向绑定 | onChange 回调赋值 | 双向绑定语法糖 | 受控组件模式 | 显式数据流、便于追踪 | 表单输入 |
| 文本溢出 | maxLines + textOverflow | 截断字符串 | 滚动文本 | 框架原生支持、视觉友好 | 动态文本展示 |
| 数据分层 | @State 可变 + const 不可变 | 全部 @State | 全部不可变 + Redux | 按需追踪、性能与灵活性平衡 | 混合数据场景 |
| 生命周期 | aboutToAppear/Disappear | onPageShow/Hide | 组件监听模式 | 资源初始化与清理配对 | 资源管理 |
详细总结
综上所述,该"焰语·玻璃吹制工坊"应用是一个结构完整、视觉沉浸、交互闭环的声明式 UI 应用。从架构层面看,它严格遵循了"数据模型 -> 静态数据 -> 纯函数 -> 状态变量 -> 构建器方法 -> 主构建入口"的分层组织模式,每一层都有清晰的职责边界:数据模型负责定义业务实体的形状,静态数据提供初始内容,纯函数封装计算逻辑,状态变量驱动视图更新,构建器方法描述视图结构。这种分层使得代码的可维护性和可测试性都达到了较高水平。
在数据模型设计上,应用使用了 @Observed 装饰器将 PostItem、GlassItem、TechniqueItem、GearItem 四个类标记为可观察对象,使得这些对象的属性变化能够被框架自动追踪并触发视图更新。同时,FireChartItem、CraftChartItem、CourseItem、FurnaceItem、NavItem 等纯展示型数据结构则使用 interface 定义,避免了 class 的运行时开销。这种"class 用于可变数据、interface 用于不可变形状"的区分,体现了对类型系统特性的深入理解。
在视觉特效实现上,应用采用了"定时器驱动 Tick + 纯函数计算坐标"的自定义动画方案,通过 90 毫秒间隔的 setInterval 递增 tick 变量,再由 sparkX/sparkY/sparkA/sparkR 和 glowA/glowX/glowY 两组纯函数将 tick 转换为粒子的位置、透明度和大小。这种方案避免了重量级动画库的引入,运行时开销极低,同时通过 hitTestBehavior(None) 实现了特效层的事件透传,保证了特效不干扰用户对业务内容的正常操作。火星上浮与炉光呼吸两套特效叠加,营造出了窑炉火光的沉浸式氛围。
在导航与内容分发上,应用采用了"底部四主 Tab + 首页七内容 Tab"的双层导航结构,通过 mainTab 和 subTab 两个 @State 变量驱动 mainContent 构建器中的 if-else 条件渲染。每个主 Tab 对应一个独立的内容页面,首页内部又通过 subTab 细分为精选、作品、技法、工坊、课堂、匠友圈、工具坊七个子页面。这种双层导航既保证了内容覆盖的广度,又通过副导航的横向滚动支持了未来 Tab 的扩展。所有页面都被包裹在独立的 Scroll 容器中,拥有各自的滚动状态。
在交互模式上,应用实现了新增、编辑、删除三类弹框,分别对应"发焰语"、“更新技法进度”、"退炉报废与退课"三个业务动作。弹框通过三个布尔状态变量(addOpen、editOpen、delOpen)控制显隐,打开前先重置或填充输入字段,确认时调用 doAdd、doEdit、doDel 方法执行数据修改。删除弹框通过 delTarget 变量区分"退炉报废"和"退课"两种场景,实现了"一个弹框模板服务多个删除场景"的复用设计。弹框的蒙层点击关闭、危险操作红色按钮、输入实时双向绑定等细节,都体现了较为成熟的交互设计实践。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
—
在性能与资源管理上,应用通过多个维度的设计优化了运行时表现:纯函数计算粒子坐标避免了状态数组的维护开销;hitTestBehavior(None) 保证了特效层不参与事件分发,降低了命中测试开销;条件渲染弹框确保了未打开的弹框不占用视图树节点;const 静态数据不参与响应式追踪,减少了框架的观测开销;aboutToDisappear 中清理定时器避免了组件销毁后的内存泄漏。这些优化虽然每一项都是"小优化",但叠加起来对移动设备的续航和流畅度有显著正面影响。
从可扩展性角度看,该应用也留下了清晰的扩展空间。颜色系统通过 ColorPalette 接口和 COLORS 常量集中管理,未来支持主题切换只需替换 COLORS 对象即可。纯函数层封装了状态映射和粒子计算,修改特效参数或状态颜色只需调整函数实现。构建器方法将各页面封装为独立单元,新增页面只需编写新的 @Builder 方法并在 mainContent 中添加条件分支。数据模型使用了 @Observed class,未来接入后端接口时只需将静态数据替换为异步加载即可,视图层无需改动。这种"分层解耦"的架构设计,使得应用在功能扩展和数据源切换时都具备良好的适应性。
更多推荐



所有评论(0)