HarmonyOS ArkTS 日晷制作工坊:基于HarmonyOS ArkTS API 24通过 Flex 布局、Stack 层叠、ForEach 列表渲染、animation 动画系统等技术手段
一、日晷制作的历史文化背景

日晷,作为人类最古老的天文计时仪器之一,其历史可追溯至数千年前的古代文明。在中国,日晷的制作与使用不仅仅是一项技术活动,更是一种融合了天文学、数学、工艺美术与哲学思想的综合性文化实践。从先秦时期的粗犷测影,到汉唐时期的精密推算,再到宋元时期的集大成之作,日晷的演变历程映射着中华文明对时间、空间与宇宙秩序的不懈探索。
1.1 日晷的起源与早期形态

日晷的雏形,可以追溯到远古时期的"立竿见影"。先民们在旷野中竖立一根木竿或石柱,通过观察其在阳光下投射的影子方位与长度来判断时间。这种最原始的计时方式,被称为"表"。《周礼》中记载的"正日景,以求地中",便是指周公在阳城(今河南登封)用表测影以确定地理中心的历史事件。这一事件不仅具有天文学意义,更蕴含着"天子居中而治天下"的政治哲学含义。
早期的日晷多为地平式,即晷面水平放置,晷针垂直或倾斜插入。这种日晷结构简单,但精度有限,因为太阳影子在水平面上的投影并非均匀分布,需要进行复杂的数学修正。随着天文观测技术的进步,古人逐渐认识到赤道式日晷的优越性——将晷面平行于赤道面放置,晷针指向天极,这样太阳影子在晷面上的移动便是均匀的,每个时辰对应的晷面角度恰好相等。这一发现标志着日晷制作从经验工艺走向了理论科学。
1.2 汉唐时期的天文盛世

汉代是中国天文学发展的第一个高峰期。汉武帝时期制定的《太初历》,以正月为岁首,确立了沿用两千余年的历法框架。在这一时期,日晷的制作也日趋精密。考古发掘中出土的汉代石晷,晷面上已刻有清晰的时辰刻线和方位标记,表明当时的工匠已经掌握了相当精确的角度分割技术。汉代日晷多采用青石材质,石质坚硬而细腻,适合精细刻线,且历经千年而不风化。
唐代是中国古代天文学的又一个黄金时代。僧人一行主持了人类历史上第一次对地球子午线长度的实测,他编制的《大衍历》在精度上远超前代。唐代的观象台建设也达到了空前规模,长安司天台、洛阳灵台等天文机构遍布全国。日晷在唐代不仅是计时工具,更成为皇家礼器与文人雅玩。宫廷中设有专门的"司辰"官职,负责日晷的维护与校准。文人墨客也将日晷视为高雅之物,白居易诗云"日晷未移三十刻",便以日晷计时入诗,可见其在唐代社会中的普及程度。
1.3 宋元时期的技术巅峰
宋元时期是中国古代科技发展的巅峰阶段。北宋苏颂等人制造的水运仪象台,集天文观测、天文演示与报时功能于一体,堪称中国古代天文仪器的集大成之作。元代郭守敬更是中国天文学史上最伟大的科学家之一,他编制的《授时历》以365.2425日为一个回归年,与现代公历完全一致,比欧洲的格里高利历早了三百余年。
郭守敬在日晷制作方面也有重大贡献。他设计的"仰仪"是一种半球形日晷,通过观察太阳在半球内壁上的投影来确定时间和节气,巧妙地解决了传统日晷无法直接观测太阳位置的难题。他还改进了赤道式日晷的结构,使晷针与晷面的连接更加稳固,刻线更加精确。登封观星台至今保存着郭守敬时期的天文观测设施,这座建于元至元年间的建筑,是中国现存最早的天文台遗址,也是世界文化遗产的重要组成部分。
1.4 明清以来的传承与演变
明清两代,日晷制作进入了一个传承与变革并存的时期。明代北京古观象台的建设,集中了全国最优秀的天文仪器制造工匠。清代康熙、乾隆年间,西方天文学知识传入中国,日晷的制作也受到西方科学的影响,出现了一些中西合璧的设计。故宫中保存的清代赤道日晷,晷面采用汉白玉材质,刻线精度极高,晷针为青铜铸造,通体鎏金,既是精密的天文仪器,也是精美的艺术品。
清代日晷的制作工艺也达到了极高的水平。工匠们在晷面上刻制了十二时辰、二十四节气、七十二候等多种刻度系统,使一枚日晷能够同时显示时间、节气与物候信息。部分日晷还配有修正表,根据不同季节的日影变化进行时间修正,体现了古人对天文规律的深刻理解。这些日晷不仅是实用工具,更是中华文明的物质载体,每一枚都凝聚着工匠的智慧与心血。
1.5 日晷的文化象征意义
日晷在中国文化中具有极为丰富的象征意义。首先,它是时间的具象化表达。"光阴"一词的本义就是日影,"寸金难买寸光阴"的谚语将时间的珍贵与日晷的刻度联系在一起。日晷通过有形的影子来度量无形的时间,体现了中国人"以实见虚"的哲学思维方式。
其次,日晷象征着天人合一的宇宙观。日晷的运行依赖太阳的光照,人通过日晷来感知天体运行规律,这本身就是"观象授时"传统的延续。古代帝王将日晷视为沟通天人的礼器,通过观测日影来确定农事节令、安排国家大事,体现了"天命"与"人事"的统一。
再次,日晷代表着工匠精神与科学精神的融合。一枚精良的日晷,既需要天文学家的理论计算,也需要石匠的精湛雕刻,还需要铸造师的铜针工艺。从选石、打磨、画线、刻字到安装调试,每一步都需要极高的专业技能与耐心细致的态度。这种跨领域的协作精神与精益求精的工匠传统,在今天的数字化时代依然具有深刻的启示意义。
1.6 从实体日晷到数字日晷
进入信息时代,日晷作为计时工具早已被钟表和电子设备取代,但日晷的文化价值与教育意义并未消减。相反,在数字技术飞速发展的今天,日晷所承载的天文知识与人文精神反而焕发出新的生机。通过数字技术复刻日晷制作工艺,不仅可以让更多人了解这一古老智慧,还可以将传统工艺与现代交互设计相结合,创造出既具有文化深度又具有视觉吸引力的数字产品。
本文所分析的 HarmonyOS ArkTS 日晷制作工坊应用,正是这一理念的实践。它以"日晷坊"为主题,通过八个功能模块——日晷款式、晷针工艺、十二时辰、观象台、古历沿革、制晷匠人、传世古晷与订单管理——全面展现了中国日晷文化的方方面面。应用不仅展示了日晷的款式分类、晷针的工艺参数、十二时辰的文化内涵,还涵盖了古代观象台的分布、历代历法的沿革、制晷名匠的生平以及传世古晷的收藏价值,最后通过订单管理模块模拟了日晷的商业流通场景。
这款应用的技术实现同样值得深入探讨。它采用了 HarmonyOS 的 ArkTS 声明式开发范式,运用了 @Entry、@Component、@State、@Builder、@Observed 等核心装饰器,通过 Flex 布局、Stack 层叠、ForEach 列表渲染、animation 动画系统等技术手段,构建了一个信息丰富、交互流畅、视觉精美的移动端应用。接下来,我们将逐段分析源代码,深入剖析每一个技术细节。
二、色彩体系:ColorPalette 接口与 COLORS 常量

2.1 ColorPalette 接口定义
interface ColorPalette {
bg: string;
cardBg: string;
header1: string;
header2: string;
bronze: string;
stone: string;
line: string;
sun: string;
gold: string;
shadow: 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 的接口,它是整个应用的色彩管理基础。在 ArkTS 的类型系统中,interface 关键字用于声明一个对象的结构形状,它不包含实现代码,仅定义属性名与属性类型的契约。ColorPalette 接口包含了二十三个字符串类型的属性,每一个属性对应应用中的一个色彩用途。
从设计哲学的角度来看,这种将所有颜色集中到一个接口中管理的做法,体现了"单一色彩来源"的工程思想。在大型应用开发中,色彩管理是一个容易被忽视却又至关重要的环节。如果每个组件各自定义颜色值,当设计稿变更时就需要在数十个文件中逐一查找替换,极易遗漏和出错。而通过统一的色彩接口,所有组件引用同一个色彩常量,修改时只需调整一处,全局生效。
接口中的属性命名采用了语义化的方式,而非直接使用颜色值。例如 bg 代表背景色、cardBg 代表卡片背景色、bronze 代表青铜色、sun 代表太阳色等。这种命名方式使开发者在编写 UI 代码时能够专注于语义而非具体的颜色值,大大提高了代码的可读性与可维护性。当设计师要求将"太阳色"从橙色调整为金黄色时,开发者只需修改 COLORS.sun 的值,所有引用该属性的组件都会自动更新。
值得注意的是,接口中不仅有基础色彩,还有语义化的功能色彩。hot 代表高温或热门或警示色(红色系),cool 代表冷色或信息色(蓝色系),danger 代表危险操作色(红色)。这种将颜色与语义关联的做法,使 UI 代码具有自文档化的特征——看到 COLORS.hot 就知道这里表示的是一个"热门"或"高热度"的视觉元素,而不需要去理解具体的色值含义。
2.2 COLORS 常量实现

const COLORS: ColorPalette = {
bg: '#F1F0EC',
cardBg: '#FFFFFF',
header1: '#23272E',
header2: '#0F1218',
bronze: '#8A6D3B',
stone: '#6E6A5E',
line: '#C9A86A',
sun: '#F5A623',
gold: '#D9A441',
shadow: '#4A4A4A',
title: '#F3EAD6',
sub: '#B9B2A2',
text1: '#23272E',
text2: '#5F635C',
text3: '#94968E',
accent: '#8A6D3B',
hot: '#C0392B',
cool: '#1F5FA8',
danger: '#D9534F',
tabBg: '#23272E',
tabOn: '#D9A441',
mask: 'rgba(0,0,0,0.45)'
};
COLORS 常量是 ColorPalette 接口的具体实现。它使用 const 关键字声明为不可变常量,并显式标注了 ColorPalette 类型。这一类型标注不仅提供了编译时的类型检查,还使 IDE 能够提供智能提示,在开发者输入 COLORS. 时自动列出所有可用属性。
让我们逐一分析这些颜色值的设计意图。bg 是一种温暖的中性灰白色,带有微妙的暖调,模拟宣纸或绢帛的色泽,为整个应用奠定了古朴典雅的基调。与纯白色背景相比,这种暖灰色更加柔和耐看,减少了长时间阅读的视觉疲劳。cardBg 是纯白色,用于卡片背景,在暖灰色的页面背景上形成清爽的层次对比。
header1 和 header2 是两种深色,用于页面头部的渐变背景。header1 是深蓝灰色,header2 是近乎纯黑的深色。两者通过线性渐变(135度角)叠加,营造出深邃的夜空效果,恰好与日晷"测日影"的主题相呼应。这种从深蓝灰到墨黑的渐变,模拟了从黄昏到入夜的色温变化,富有诗意。
bronze 是一种青铜色,模拟了日晷晷针的金属质感。古铜色在中国传统色彩中称为"秋香色"或"古铜色",与日晷的青铜材质天然契合。stone 是一种石灰色,模拟青石或花岗岩的质感,用于日晷晷面的视觉表达。line 是一种金线色,用于日晷上的刻度线,模拟古代日晷上描金刻线的工艺。
sun 是一种明亮的琥珀橙色,代表太阳本身。gold 是一种暗金色,用于标签、数字等强调元素。这两种颜色共同构成了应用的"暖色双核",在深色头部背景上形成鲜明的视觉焦点。shadow 是中性深灰色,用于阴影效果。
文字色彩分为三级:text1 是主文字色(深蓝灰),用于标题与正文;text2 是次要文字色(中灰绿),用于副标题与描述文字;text3 是辅助文字色(浅灰),用于标签与提示信息。三级灰度形成清晰的信息层级,使用户能够快速区分主次内容。标题色 title 是一种暖象牙白色,用于深色背景上的标题文字;sub 是暖灰色,用于深色背景上的副标题。
hot 是一种朱红色,在中国传统中称为"绛红"或"朱砂红",用于表示高热度、高温或重要警示信息。cool 是一种藏蓝色,用于表示冷色调标签或信息提示。danger 是一种珊瑚红色,专用于删除等危险操作按钮。这三种功能色与暖色系的 gold 和 sun 形成了互补,覆盖了应用中所有的语义场景。
tabBg 和 tabOn 分别是底部导航栏的背景色和选中色。mask 是半透明黑色遮罩,用于弹窗背景。值得注意的是,这是色彩体系中唯一使用 rgba 格式的颜色,因为遮罩需要透明度而十六进制色值虽然也支持透明度但可读性不如 rgba 直观。
三、标签系统:TabMeta 接口与 TAB_LIST 常量

3.1 TabMeta 接口定义
interface TabMeta {
label: string;
icon: string;
}
TabMeta 接口定义了底部导航标签的元数据结构。它只有两个属性:label 是标签的文本名称,icon 是标签的图标。这个接口虽然简单,但它将数据与展示分离的设计思想贯穿其中。
在 ArkTS 开发中,标签数据与标签渲染是两个独立的关注点。通过定义 TabMeta 接口,标签数据被抽象为纯数据结构,不包含任何 UI 相关的属性或方法。这种数据与视图分离的架构模式,使得标签数据可以在不同组件之间共享,也使得未来修改标签样式时不需要改动数据定义。
icon 属性使用字符串类型存储 emoji 字符。这是一种巧妙的设计选择——相比于使用图片资源或矢量图标,emoji 字符无需额外的资源文件,跨平台兼容性好,且能够通过 fontSize 属性自由调整大小。当然,emoji 在不同操作系统上的渲染样式可能略有差异,但对于这类文化展示类应用而言,这种差异是可以接受的。
3.2 TAB_LIST 常量实现

const TAB_LIST: TabMeta[] = [
{ label: '日晷', icon: '☀️' },
{ label: '晷针', icon: '📏' },
{ label: '时辰', icon: '🕐' },
{ label: '观象台', icon: '🔭' },
{ label: '历法', icon: '📅' },
{ label: '匠人', icon: '🛠️' },
{ label: '古晷', icon: '🏛️' },
{ label: '订单', icon: '📦' }
];
TAB_LIST 常量是一个 TabMeta 类型的数组,包含八个标签项。这八个标签对应应用的八个功能模块,构成了"日晷坊"应用的完整导航体系。每个标签项包含一个 label 文本和一个 icon emoji 图标,数据结构清晰简洁。
这八个标签的排列顺序并非随意,而是遵循了从"器物"到"人"再到"商业"的逻辑递进。前四个标签——日晷、晷针、时辰、观象台——聚焦于日晷本身的物理属性与天文功能。中间两个标签——历法、匠人——扩展到日晷背后的知识体系与人物传承。最后两个标签——古晷、订单——分别代表文化收藏与商业流通,将日晷置于更广阔的社会经济背景中。
这种从"物"到"人"再到"市"的叙事结构,暗合了中国传统器物学中"格物—致知—经世"的思想脉络。用户在浏览应用时,从具体的日晷款式出发,逐步深入到时辰文化、天文观测、历法沿革、匠人故事,最终落脚于古晷收藏与商业订单,形成了一条完整的知识探索路径。
3.3 索引常量定义

const ROW1_IDX: number[] = [0, 1, 2, 3];
const ROW2_IDX: number[] = [4, 5, 6, 7];
const HOUR_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const DOT_IDX: number[] = [0, 1, 2, 3, 4];
这四组索引常量是用于 ForEach 循环渲染的数字序列。ROW1_IDX 和 ROW2_IDX 分别代表底部导航栏第一行和第二行的标签索引,将八个标签分为两组四个,形成两行四列的网格布局。HOUR_IDX 代表日晷上的十二个时辰刻度,用于在页面头部的日晷图标中渲染十二条刻度线。DOT_IDX 代表五个装饰性金点,用于日晷图标上的点缀效果。
将索引提取为常量而非内联在 ForEach 中,体现了代码复用的思想。ROW1_IDX 和 ROW2_IDX 在 bottomBar 构建器中被使用,HOUR_IDX 和 DOT_IDX 在 pageHeader 构建器中被使用。如果将来需要调整日晷刻度的数量或装饰点的数量,只需修改常量定义即可,无需查找和修改散落在各处的魔法数字。
四、数据模型:@Observed 类的逐个分析
4.1 DialItem 日晷款式数据模型
@Observed
export class DialItem {
name: string;
type: string;
size: string;
stone: string;
price: number;
constructor(name: string, type: string, size: string, stone: string, price: number) {
this.name = name;
this.type = type;
this.size = size;
this.stone = stone;
this.price = price;
}
}
DialItem 是日晷款式的数据模型类,它描述了每一款日晷的基本信息。类声明上方的 @Observed 装饰器是 ArkTS 响应式系统的核心装饰器之一,它使得该类的实例对象在被 @State、@Prop 或 @ObjectLink 装饰的变量引用时,其属性变化能够被 UI 框架自动感知并触发界面更新。
export 关键字表示该类可以被其他模块导入使用。在 ArkTS 项目中,数据模型类通常定义在独立的文件中并通过 export 导出,然后在页面组件文件中通过 import 导入。不过在本例中,所有代码都在同一个文件中,export 的作用更多是为未来可能的模块拆分预留接口。
DialItem 类包含五个属性:name 是日晷名称,如"故宫赤道日晷";type 是款式类型,如"赤道式"、“地平式”;size 是尺寸规格,如"径 120cm";stone 是石材类型,如"汉白玉"、“青石”;price 是售价,以数字类型存储便于价格计算与排序。
构造函数 constructor 接收五个参数并赋值给对应属性。这种通过构造函数初始化所有属性的方式,确保了 DialItem 实例在创建时就拥有完整的数据,避免了"半初始化"对象的存在。在后续的状态数据初始化中,我们可以看到大量 new DialItem(...) 的调用,每一次调用都创建一个完整的日晷数据对象。
从领域建模的角度来看,DialItem 类的设计体现了"值对象"模式的特征。它没有唯一标识符(ID),两个属性完全相同的 DialItem 实例在业务上是等价的。这种设计适用于展示型数据,但在需要编辑、删除等操作的场景中,可能需要额外添加唯一标识字段。在本文的订单管理模块中,删除操作通过 indexOf 来定位对象,这在数据量不大时是可行的,但如果数据量增长,可能需要更高效的查找方式。
4.2 HandItem 晷针工艺数据模型
@Observed
export class HandItem {
name: string;
angle: number;
length: number;
material: string;
precision: number;
constructor(name: string, angle: number, length: number, material: string, precision: number) {
this.name = name;
this.angle = angle;
this.length = length;
this.material = material;
this.precision = precision;
}
}
HandItem 是晷针工艺的数据模型类,描述了日晷晷针的各项技术参数。晷针是日晷最核心的部件,它指向天极方向,在阳光下投射出影子来指示时间。晷针的安装角度直接决定了日晷的计时精度,因此在本应用中,HandItem 类有一个专门的"编辑晷针"弹窗用于调整晷针倾角。
name 属性是晷针名称,如"北极晷针"、“直指晷针"等,反映了晷针的造型特征。angle 是倾角(以度为单位),这是晷针最重要的技术参数——在赤道式日晷中,晷针的倾角应等于当地的地理纬度,才能确保影子在晷面上均匀移动。length 是晷针长度(以厘米为单位),影响日晷的整体尺寸与影子的清晰度。material 是材质,如"青铜”、"紫铜"等,不同材质影响晷针的耐用性与美观度。precision 是精度百分比,综合反映了晷针的安装精度与制造质量。
值得注意的是,angle 和 precision 属性都是 number 类型,且在"编辑晷针"弹窗中可以被修改。正是因为类声明上方有 @Observed 装饰器,这种属性修改才能被 UI 框架感知。如果没有 @Observed,修改对象属性后界面不会自动更新,需要手动调用刷新方法。@Observed 的底层机制是在对象的属性 setter 中注入通知逻辑,当属性被赋新值时自动触发依赖该属性的 UI 组件重新渲染。
4.3 ShichenItem 十二时辰数据模型
@Observed
export class ShichenItem {
name: string;
period: string;
uses: string;
animal: string;
hot: number;
constructor(name: string, period: string, uses: string, animal: string, hot: number) {
this.name = name;
this.period = period;
this.uses = uses;
this.animal = animal;
this.hot = hot;
}
}
ShichenItem 是十二时辰的数据模型类,承载着中国古代时间制度的文化内涵。中国古代将一昼夜分为十二个时辰,每个时辰相当于现代的两个小时,并以十二地支(子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥)命名。每个时辰还对应一种生肖动物,形成了一套富有诗意的时间表达体系。
name 属性是时辰名称,如"子时"、“丑时"等。period 是对应的现代时间段,如"23:00-01:00”,方便当代用户理解。uses 是该时辰的古称或用途描述,如"夜半"、“鸡鸣”、“平旦"等,这些名称来自《左传》等古籍,反映了古人对不同时段的感性认知。animal 是对应的生肖动物,如"鼠”、“牛"等。hot 是"使用热度”,一个0到100的数值,用于在 UI 中以进度条形式展示该时辰在古代社会中的使用频率。
这个数据模型的设计体现了"文化数据化"的思路。十二时辰本身是一个文化概念,通过将其属性结构化为数据字段,使文化知识能够在数字界面中以可视化的方式呈现。特别是 hot 属性,将"午时是日影最短、活动最频繁的时段"这一文化认知量化为一个数值,再通过进度条的长度直观地展示出来,使抽象的文化概念变得可感可知。
4.4 ObservatoryItem 观象台数据模型
@Observed
export class ObservatoryItem {
name: string;
city: string;
age: string;
instruments: number;
level: string;
constructor(name: string, city: string, age: string, instruments: number, level: string) {
this.name = name;
this.city = city;
this.age = age;
this.instruments = instruments;
this.level = level;
}
}
ObservatoryItem 是古代观象台的数据模型类。观象台是古代天文观测的专门场所,也是日晷等天文仪器的主要安置地。这个类记录了中国历史上八座重要的观象台信息,每座观象台都是中国古代天文学发展的重要见证。
name 是观象台名称,如"北京古观象台"、“登封观星台"等。city 是所在城市。age 是建造年代,如"明正统”、“元至元"等,以朝代年号表示。instruments 是现存天文仪器数量,以数字类型存储。level 是保护级别,如"甲级”、"乙级"等,反映了观象台的历史价值与保护状况。
从数据结构设计的角度看,age 属性使用字符串类型而非数字或日期类型,是一个有趣的选择。中国古代的纪年方式以朝代年号为主,直接转换为公元纪年需要查阅历史年表。使用字符串保存原始纪年信息,既保留了历史原貌,又避免了不必要的转换误差。
4.5 CalendarItem 历法沿革数据模型
@Observed
export class CalendarItem {
name: string;
kind: string;
days: number;
era: string;
use: string;
constructor(name: string, kind: string, days: number, era: string, use: string) {
this.name = name;
this.kind = kind;
this.days = days;
this.era = era;
this.use = use;
}
}
CalendarItem 是古代历法的数据模型类。历法与日晷有着密不可分的关系——日晷用于测定太阳时,而历法则是将太阳时系统化、制度化的产物。中国古代历法经历了从夏历到授时历的漫长演变,每一次历法改革都伴随着天文观测精度的提升与数学方法的进步。
name 是历法名称,如"夏历"、“太初历”、“授时历"等。kind 是历法类型,如"阴阳合历”、“四分历"等。days 是一年的天数。era 是所属朝代。use 是该历法的主要特征或贡献,如"二十四节气”、“岁差”、"回归年365.2425"等。
days 属性使用 number 类型,在 UI 中以红色高亮显示。这种数值可视化处理使用户能够直观地比较不同历法的年度天数差异,理解从354天到365天的精度演进过程。特别是授时历的365天与现代公历完全一致这一历史事实,通过数字对比变得一目了然。
4.6 SundialMakerItem 制晷匠人数据模型
@Observed
export class SundialMakerItem {
name: string;
grade: string;
years: number;
works: number;
skill: number;
constructor(name: string, grade: string, years: number, works: number, skill: number) {
this.name = name;
this.grade = grade;
this.years = years;
this.works = works;
this.skill = skill;
}
}
SundialMakerItem 是制晷匠人的数据模型类。日晷的制作不仅是技术活,更是需要天文学知识与工匠技艺完美结合的艺术。这个类记录了中国历史上八位与天文仪器制造密切相关的科学家与工匠信息。
name 是人物姓名,如"郭守敬"、“张衡"等。grade 是称号或等级,如"天文学家”、"浑天大师"等。years 是从事天文工作的年数。works 是传世作品数量。skill 是"天文造诣"评分(0-100)。
grade 属性在 UI 渲染时会通过 gradeColor() 函数映射为不同的颜色——包含"大师"的称号显示为红色,包含"名师"的显示为金色,包含"匠师"的显示为青铜色,其他显示为蓝色。这种基于字符串包含关系进行颜色映射的设计,使不同等级的匠人在列表中一目了然。
4.7 AncientDialItem 传世古晷数据模型
@Observed
export class AncientDialItem {
name: string;
age: string;
origin: string;
value: number;
rank: string;
constructor(name: string, age: string, origin: string, value: number, rank: string) {
this.name = name;
this.age = age;
this.origin = origin;
this.value = value;
this.rank = rank;
}
}
AncientDialItem 是传世古晷的数据模型类。传世古晷是具有历史价值与收藏价值的古代日晷,它们不仅是天文仪器,更是珍贵的文物。
name 是古晷名称。age 是制作年代。origin 是出土地或所在地。value 是估价(以元为单位)。rank 是收藏等级,如"国宝"、"名晷"等。
rank 属性在 UI 中通过 rankColor() 函数映射颜色——"国宝"级显示为红色,"名晷"级显示为青铜色。value 属性在 UI 中以红色高亮显示,突出古晷的经济价值。
4.8 DialOrderItem 订单数据模型
@Observed
export class DialOrderItem {
name: string;
buyer: string;
amount: number;
count: number;
date: string;
constructor(name: string, buyer: string, amount: number, count: number, date: string) {
this.name = name;
this.buyer = buyer;
this.amount = amount;
this.count = count;
this.date = date;
}
}
DialOrderItem 是日晷订单的数据模型类,也是应用中唯一具有完整增删操作的数据类型。这个类记录了日晷的商业交易信息,将日晷从文化展示品延伸到商品流通领域。
name 是订购的日晷名称。buyer 是买方名称。amount 是订单金额。count 是订购数量。date 是订单日期。这五个属性涵盖了订单的核心信息,足以支撑订单管理的基本需求。
在订单列表的 UI 中,amount 属性通过一个高度为8像素、颜色为红色的进度条来可视化展示。每个订单行还有一个红色的"删"按钮,点击后弹出删除确认弹窗。这种将金额可视化为进度条的设计,使订单金额的比较变得直观——金额越高的订单,其进度条越高,在列表中越显眼。
4.9 数据模型架构总览
上图展示了从数据模型到状态变量再到 UI 构建器的完整数据流。八个 @Observed 类各自对应一个 @State 数组和一个 @Builder 行构建器,形成了清晰的一一对应关系。这种结构化的数据流设计,使得添加新的数据模块时可以遵循相同的模式:定义数据模型类、声明状态数组、编写行构建器、在 tabContent 中添加渲染分支,四个步骤即可完成一个新模块的集成。
五、工具函数分析
5.1 日晷刻度计算函数
function hourRotate(i: number): number {
return 90 + i * 30;
}
function hourLen(i: number): number {
return 34 + (i % 4) * 3;
}
这两个函数用于计算页面头部日晷图标中的十二时辰刻度线的旋转角度和长度。hourRotate 函数接收刻度索引 i(0到11),返回旋转角度。计算公式为 90 + i * 30,即从90度(正下方)开始,每增加一个刻度旋转30度。360度除以12等于30度,恰好是每个时辰对应的角度。
hourLen 函数计算刻度线的长度,公式为 34 + (i % 4) * 3。这里使用了取模运算 i % 4,使得刻线长度每四个一组循环变化——第一根34像素、第二根37像素、第三根40像素、第四根43像素,然后第五根回到34像素。这种周期性变化的刻线长度模拟了真实日晷上的时辰刻度设计,在真实日晷中,正午前后的刻度通常比早晚的刻度更长更粗,以突出正午这一重要时间节点。
这两个函数体现了"数据驱动 UI"的设计思想。刻度的角度和长度不是硬编码在 UI 组件中的,而是通过函数动态计算。这意味着如果将来需要调整刻度的排列方式,只需修改函数实现,而不需要改动 UI 代码。此外,函数的纯函数特性(相同的输入总是产生相同的输出)使得调试和测试变得简单。
5.2 装饰点位置计算函数
function dotX(i: number): number {
return 22 + i * 17;
}
function dotY(i: number): number {
return 88 + (i % 2) * 10;
}
function dotR(i: number): number {
return 3 + (i % 3);
}
这三个函数用于计算日晷图标上五个装饰性金点的位置和半径。dotX 计算横坐标,公式 22 + i * 17 使五个点在水平方向上等距排列,间距为17像素。dotY 计算纵坐标,公式 88 + (i % 2) * 10 使奇偶索引的点在垂直方向上交替偏移10像素,形成微妙的波浪状排列。dotR 计算点半径,公式 3 + (i % 3) 使半径在3到5之间循环变化,增加了视觉上的节奏感。
这三个函数配合使用,将五个金点布置成一个不规则但富有韵律的图案。在真实日晷上,这类装饰点通常代表二十八宿或其他天文标记,在视觉上增加了日晷的神秘感与装饰性。在数字日晷中,这些装饰点还承担着"呼吸动画"载体的功能——它们的透明度和大小会随 breath 状态的变化而周期性变化,使整个日晷图标显得活灵活现。
5.3 晷针角度计算函数
function handAngle(breath: boolean): number {
if (breath) {
return -38;
}
return -26;
}
handAngle 函数根据 breath 布尔值返回不同的晷针旋转角度。当 breath 为 true 时返回 -38 度,为 false 时返回 -26 度。这个函数用于页面头部日晷图标中的晷针动画——晷针会在 -38度和 -26度之间来回摆动,模拟"呼吸"效果。
这种用函数封装动画状态切换的设计,将动画逻辑与 UI 渲染解耦。breath 状态每600毫秒翻转一次(由 aboutToAppear 中的 setInterval 控制),所有依赖 breath 的 UI 元素都会在状态变化时自动更新。handAngle 函数作为 breath 状态到晷针角度的映射器,使角度计算的逻辑集中在一处,便于维护和调整。
5.4 进度条宽度计算函数
function precisionBarW(p: number): number {
return 16 + Math.min(p, 100) * 1.35;
}
function hotBarW(hot: number): number {
return 18 + Math.min(hot, 100) * 1.35;
}
function skillBarW(s: number): number {
return 16 + Math.min(s, 100) * 1.35;
}
这三个函数分别计算晷针精度、时辰热度和匠人技能的进度条宽度。它们的计算公式结构相似:基础宽度加上一个与数值成正比的增量。Math.min(p, 100) 确保输入值不超过100,防止进度条宽度超出预期范围。系数 1.35 决定了进度条的最大增量——当数值为100时,增量为135像素,加上基础宽度16到18像素,总宽度约为151到153像素。
三个函数的参数名不同(p、hot、s),基础宽度也略有差异(16和18),但核心逻辑完全一致。这种设计虽然有一定程度的代码重复,但它使每个函数的语义独立,阅读代码时能够立即理解该进度条的用途。如果追求代码精简,可以将三者合并为一个通用函数,但会牺牲一定的可读性。
5.5 颜色映射函数
function precisionColor(p: number): string {
if (p >= 80) {
return COLORS.hot;
}
if (p >= 60) {
return COLORS.gold;
}
return COLORS.cool;
}
function gradeColor(grade: string): string {
if (grade.indexOf('大师') >= 0) {
return COLORS.hot;
}
if (grade.indexOf('名师') >= 0) {
return COLORS.gold;
}
if (grade.indexOf('匠师') >= 0) {
return COLORS.accent;
}
return COLORS.cool;
}
function rankColor(rank: string): string {
if (rank.indexOf('国宝') >= 0) {
return COLORS.hot;
}
if (rank.indexOf('名晷') >= 0) {
return COLORS.gold;
}
return COLORS.accent;
}
这三个函数将数值或字符串映射为颜色值。precisionColor 接收一个0到100的数值,返回对应的颜色——80以上为红色(高精度)、60以上为金色(中等精度)、60以下为蓝色(低精度)。这种颜色梯度设计使用户能够通过颜色快速识别精度等级,无需阅读具体数字。
gradeColor 和 rankColor 使用 indexOf 方法检查字符串是否包含特定关键词,然后返回对应的颜色。gradeColor 检查"大师"、“名师”、“匠师"三个等级关键词,rankColor 检查"国宝”、"名晷"两个等级关键词。这种基于字符串包含的颜色映射方式,适用于等级名称可能包含附加信息的情况。
这三个函数体现了"语义化颜色映射"的设计理念。颜色不是随机分配的,而是根据数据的语义属性有规则地映射。红色代表最高等级(高精度、大师、国宝),金色代表中等等级,青铜色代表基础等级,蓝色代表最低等级。这种一致的色彩语义使用户在不同模块间切换时,能够凭借颜色直觉快速理解信息等级。
5.6 订单条高度计算函数
function orderBarH(amount: number): number {
return 24 + Math.min(amount, 50000) / 500;
}
orderBarH 函数根据订单金额计算进度条的高度。公式为 24 + Math.min(amount, 50000) / 500,其中 Math.min(amount, 50000) 将金额上限设为50000元,超过部分不再增加高度。除以500的系数意味着每500元增加1像素高度。当金额为50000时,高度为 24 + 100 = 124 像素;当金额为0时,高度为24像素。
这个函数的设计巧妙地处理了数据范围与视觉表达的平衡。订单金额从20400元到78000元不等,如果直接按比例计算高度,差异可能过大导致进度条高度参差不齐。通过设置上限和调整系数,将高度范围控制在24到124像素之间,既保留了金额差异的视觉表达,又不会因为个别高金额订单而破坏整体布局的美观性。
六、UI 组件分析:DialPage 主组件
6.1 组件声明与状态定义
@Entry
@Component
struct DialPage {
@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State selHand: HandItem | null = null;
@State selOrder: DialOrderItem | null = null;
@State formName: string = '';
@State formType: string = '';
@State formStone: string = '';
@State formPrice: string = '';
DialPage 是应用的主页面组件,通过 @Entry 装饰器标记为应用的入口页面,通过 @Component 装饰器声明为自定义组件。struct 关键字定义了一个结构体,在 ArkTS 中,自定义组件以 struct 的形式声明,而非传统的 class。
接下来是大量的 @State 状态变量声明。@State 是 ArkTS 最核心的状态管理装饰器,它使变量成为响应式状态——当变量值变化时,所有引用该变量的 UI 组件会自动重新渲染。
curTab 是当前选中的标签索引,初始值为0,对应"日晷"标签。这个变量控制着 tabContent 构建器中哪个内容分支被渲染,是整个应用导航的核心状态。用户点击底部导航栏的某个标签时,curTab 被更新为对应索引,触发内容区域的切换。
breath 是一个布尔型的"呼吸"状态,初始值为 false。它在 aboutToAppear 生命周期中通过 setInterval 每600毫秒翻转一次。这个状态被多处 UI 元素引用——日晷图标的太阳大小、晷针角度、装饰点透明度、进度条透明度等——形成了全局的"呼吸"动画效果。一个状态变量驱动多处视觉变化,是响应式编程的典型应用。
showAdd、showEdit、showDel 三个布尔变量分别控制三个弹窗的显示与隐藏。selHand 和 selOrder 分别存储当前选中的晷针对象和订单对象,类型为联合类型 HandItem | null 和 DialOrderItem | null,初始值为 null 表示未选中任何对象。
formName、formType、formStone、formPrice 是"新增日晷"弹窗中的表单字段。值得注意的是,formPrice 使用 string 类型而非 number,这是因为 TextInput 组件的 onChange 回调返回的是字符串,直接存储字符串避免了类型转换的繁琐。
6.2 状态数据初始化(日晷列表)
@State dials: DialItem[] = [
new DialItem('故宫赤道日晷', '赤道式', '径 120cm', '汉白玉', 180000),
new DialItem('观象台地平日晷', '地平式', '径 90cm', '青石', 96000),
new DialItem('庭院石晷', '赤道式', '径 60cm', '青石', 32000),
new DialItem('铜质便携晷', '折叠式', '径 18cm', '青铜', 8800),
new DialItem('汉白玉园晷', '花园式', '径 80cm', '汉白玉', 56000),
new DialItem('紫铜晷面', '水平式', '径 40cm', '紫铜', 15000),
new DialItem('书院石晷', '赤道式', '径 70cm', '花岗岩', 26000),
new DialItem('罗盘式晷', '方位式', '径 24cm', '黄铜', 6800),
new DialItem('八角石晷', '八角式', '径 66cm', '青白石', 42000),
new DialItem('镀金日晷', '装饰式', '径 30cm', '鎏金铜', 22000),
new DialItem('教堂垂直晷', '垂直式', '径 55cm', '砂岩', 36000),
new DialItem('碑式晷', '碑式', '高 100cm', '汉白玉', 78000)
];
dials 是日晷款式列表的状态数组,初始化时包含了十二款日晷数据。每一行 new DialItem(...) 创建一个 DialItem 实例,参数依次为名称、类型、尺寸、石材、售价。这十二款日晷涵盖了赤道式、地平式、折叠式、花园式、水平式、方位式、八角式、装饰式、垂直式、碑式等多种类型,全面展示了中国日晷的款式多样性。
数据中的命名极具文化韵味——"故宫赤道日晷"让人联想到紫禁城中的皇家日晷,"观象台地平日晷"对应北京古观象台的实物,"庭院石晷"描绘了私家园林中的雅致日晷,"铜质便携晷"则是古代旅行者随身携带的精巧日晷。这些名称不仅是数据标签,更是一幅幅文化图景。
值得注意的是,@State 装饰器修饰的是一个数组类型的变量。在 ArkTS 中,当 @State 修饰数组时,数组的整体替换会触发 UI 更新,但数组的 push、splice 等变异方法是否触发更新取决于框架的具体实现。在本应用中,"新增日晷"操作使用了 this.dials.splice(0, 0, ...) 方法在数组头部插入新元素,HarmonyOS 的 ArkTS 框架通过代理机制实现了对数组变异方法的监听,使 splice、push 等操作能够正确触发 UI 更新。
6.3 状态数据初始化(晷针列表)
@State hands: HandItem[] = [
new HandItem('北极晷针', 32, 28, '青铜', 92),
new HandItem('直指晷针', 26, 24, '紫铜', 88),
new HandItem('折臂晷针', 38, 30, '黄铜', 85),
new HandItem('斜插晷针', 30, 26, '铁木', 80),
new HandItem('双针式', 45, 34, '青铜', 76),
new HandItem('镂空晷针', 24, 22, '鎏金', 90),
new HandItem('石质晷针', 28, 20, '青石', 82),
new HandItem('三棱晷针', 33, 27, '紫铜', 86)
];
hands 是晷针工艺列表,包含八种晷针的数据。每种晷针都有独特的造型与材质——"北极晷针"是最标准的赤道式日晷晷针,指向北极星方向;"折臂晷针"在中间弯折,便于在不同纬度使用;"镂空晷针"在铜针上镂雕花纹,兼具实用与美观;"三棱晷针"截面为三角形,能产生更锐利的影子边缘。
每种晷针的 angle(倾角)值不同,从24度到45度不等。在实际应用中,晷针的倾角应等于使用地点的地理纬度——北京约40度、南京约32度、广州约23度。数据中的倾角值大致对应了中国主要城市的纬度范围。"编辑晷针"弹窗允许用户以正负2度的步长调整倾角,模拟实际校准过程。
6.4 状态数据初始化(十二时辰)
@State shichens: ShichenItem[] = [
new ShichenItem('子时', '23:00-01:00', '夜半', '鼠', 88),
new ShichenItem('丑时', '01:00-03:00', '鸡鸣', '牛', 76),
new ShichenItem('寅时', '03:00-05:00', '平旦', '虎', 82),
new ShichenItem('卯时', '05:00-07:00', '日出', '兔', 92),
new ShichenItem('辰时', '07:00-09:00', '食时', '龙', 90),
new ShichenItem('巳时', '09:00-11:00', '隅中', '蛇', 84),
new ShichenItem('午时', '11:00-13:00', '日中', '马', 96),
new ShichenItem('未时', '13:00-15:00', '日昳', '羊', 80),
new ShichenItem('申时', '15:00-17:00', '晡时', '猴', 78),
new ShichenItem('酉时', '17:00-19:00', '日入', '鸡', 86),
new ShichenItem('戌时', '19:00-21:00', '黄昏', '狗', 72),
new ShichenItem('亥时', '21:00-23:00', '人定', '猪', 70)
];
shichens 是十二时辰的完整数据列表。从子时(23:00-01:00)到亥时(21:00-23:00),每个时辰都包含了丰富的文化信息。古称方面,"夜半"是子时的别称,出自《左传》;"鸡鸣"是丑时的别称,因鸡在此时开始啼鸣;"平旦"是寅时,指天刚亮的时候;"日出"是卯时,太阳初升之时;"食时"是辰时,古人吃早饭的时间;"隅中"是巳时,将近正午;"日中"是午时,太阳最高;"日昳"是未时,太阳偏西;"晡时"是申时,吃晚饭的时间;"日入"是酉时,太阳落山;"黄昏"是戌时,天色将暗;"人定"是亥时,夜深人静。
hot(使用热度)的数值设置体现了古人的生活规律——午时(96)最高,因为正午是活动最频繁的时段;卯时(92)次之,因为日出而作;辰时(90)第三,因为早餐时段社交频繁。而亥时(70)最低,因为夜深人定;戌时(72)次低,因为黄昏将息。这种数据设置将古代社会的作息规律量化为可视化数据,使文化知识变得生动有趣。
6.5 状态数据初始化(观象台、历法、匠人、古晷、订单)
@State observatories: ObservatoryItem[] = [
new ObservatoryItem('北京古观象台', '北京', '明正统', 8, '甲级'),
new ObservatoryItem('登封观星台', '河南登封', '元至元', 6, '甲级'),
new ObservatoryItem('南京紫金山天文台', '南京', '民国', 12, '甲级'),
new ObservatoryItem('周公测景台', '河南登封', '西周', 3, '甲级'),
new ObservatoryItem('阆中观象台', '四川阆中', '汉代', 4, '乙级'),
new ObservatoryItem('洛阳灵台', '河南洛阳', '东汉', 5, '甲级'),
new ObservatoryItem('杭州候潮门观象台', '杭州', '南宋', 3, '乙级'),
new ObservatoryItem('西安司天台', '西安', '唐代', 4, '乙级')
];
observatories 数组包含了八座古代观象台的数据。这些观象台遍布中国各地——从北京到杭州,从西安到阆中,覆盖了中国古代主要朝代的天文观测遗址。"周公测景台"是最早的一座,建于西周,是周公测影定地中的历史遗迹。"登封观星台"是元代郭守敬主持建造的,至今保存完好。"北京古观象台"建于明代正统年间,是中国连续观测时间最长的天文台。
@State calendars: CalendarItem[] = [
new CalendarItem('夏历', '阴阳合历', 354, '夏', '二十四节气'),
new CalendarItem('殷历', '阴阳合历', 355, '商', '置闰法'),
new CalendarItem('周历', '阴阳合历', 354, '周', '岁首建子'),
new CalendarItem('颛顼历', '四分历', 365, '秦', '十月岁首'),
new CalendarItem('太初历', '八十一分历', 365, '汉', '以正月为岁首'),
new CalendarItem('大明历', '祖冲之历', 365, '南朝', '岁差'),
new CalendarItem('大衍历', '一行历', 365, '唐', '定气法'),
new CalendarItem('授时历', '郭守敬历', 365, '元', '回归年 365.2425')
];
calendars 数组展示了中国古代历法从夏历到授时历的演变历程。从354天(阴历)到365天(阳历)的精度提升,反映了天文观测技术的不断进步。"授时历"的回归年长度365.2425天与现代公历完全一致,这一成就在世界天文学史上具有里程碑意义。
@State makers: SundialMakerItem[] = [
new SundialMakerItem('郭守敬', '天文学家', 42, 20, 99),
new SundialMakerItem('张衡', '浑天大师', 38, 16, 96),
new SundialMakerItem('一行', '僧历法家', 35, 14, 95),
new SundialMakerItem('祖冲之', '算术大师', 30, 12, 93),
new SundialMakerItem('落下闳', '太初历家', 28, 10, 90),
new SundialMakerItem('南宫说', '测景名家', 24, 8, 85),
new SundialMakerItem('耶律楚材', '星历家', 22, 7, 84),
new SundialMakerItem('徐光启', '历法家', 20, 6, 88)
];
makers 数组记录了八位与天文仪器制作相关的历史人物。郭守敬以99分的最高天文造诣位列榜首,他编制的授时历和设计的天文仪器在中国天文学史上具有不可替代的地位。张衡以"浑天大师"的称号位列第二,他发明的浑天仪和地动仪是中国古代科技的巅峰之作。一行和尚以"僧历法家"的身份出现,他不仅是高僧,更是杰出的天文学家和数学家。
@State ancients: AncientDialItem[] = [
new AncientDialItem('汉白玉赤道日晷', '清', '北京', 1200000, '国宝'),
new AncientDialItem('青铜刻度晷', '汉', '洛阳', 860000, '国宝'),
new AncientDialItem('观象台地平晷', '明', '北京', 560000, '名晷'),
new AncientDialItem('紫铜晷面', '元', '登封', 320000, '名晷'),
new AncientDialItem('八角石晷', '明', '南京', 240000, '名晷'),
new AncientDialItem('象牙晷仪', '清', '广州', 680000, '国宝'),
new AncientDialItem('鎏金晷针', '明', '西安', 180000, '名晷'),
new AncientDialItem('石质立晷', '宋', '开封', 150000, '名晷')
];
ancients 数组展示了八件传世古晷的收藏信息。估价最高的是"汉白玉赤道日晷"(120万元),属于清代宫廷御用之物,被列为"国宝"级。"青铜刻度晷"出土于洛阳,是汉代天文仪器的珍贵实物,估价86万元。"象牙晷仪"以象牙为材质,工艺极其精湛,估价68万元。这些数据虽然是模拟值,但参照了真实的文物市场价格区间,具有可信的参考价值。
@State orders: DialOrderItem[] = [
new DialOrderItem('庭院石晷', '园林公司', 64000, 2, '2026-08'),
new DialOrderItem('汉白玉园晷', '私人宅邸', 56000, 1, '2026-07'),
new DialOrderItem('铜质便携晷', '文博商店', 44000, 5, '2026-08'),
new DialOrderItem('书院石晷', '高校', 52000, 2, '2026-06'),
new DialOrderItem('紫铜晷面', '收藏家', 30000, 2, '2026-07'),
new DialOrderItem('镀金日晷', '地产公司', 44000, 2, '2026-05'),
new DialOrderItem('罗盘式晷', '户外品牌', 20400, 3, '2026-08'),
new DialOrderItem('八角石晷', '公园管理处', 42000, 1, '2026-06'),
new DialOrderItem('碑式晷', '纪念馆', 78000, 1, '2026-04'),
new DialOrderItem('教堂垂直晷', '文化机构', 36000, 1, '2026-05')
];
orders 数组包含十条日晷订单数据。买方类型多样——有园林公司、私人宅邸、文博商店、高校、收藏家、地产公司、户外品牌、公园管理处、纪念馆、文化机构等——反映了日晷在不同场景中的需求。订单日期从2026年4月到8月,金额从20400元到78000元不等。这些数据使订单管理模块成为应用中最贴近真实商业场景的部分。
6.6 aboutToAppear 生命周期
aboutToAppear(): void {
setInterval(() => {
this.breath = !this.breath;
}, 600);
}
aboutToAppear 是 ArkTS 自定义组件的生命周期回调函数,在组件实例创建后、UI 渲染前被调用。它通常用于初始化数据、启动定时器、注册事件监听等操作。在这个函数中,通过 setInterval 设置了一个每600毫秒执行一次的定时器,定时器回调函数中将 breath 布尔值取反。
这个简单的定时器是整个应用"呼吸"动画效果的引擎。breath 状态每600毫秒在 true 和 false 之间切换,所有引用 breath 的 UI 元素都会随之更新。具体来说,日晷图标的太阳会放大缩小、晷针会摆动、装饰金点会闪烁、进度条会亮暗交替。这种全局的呼吸效果使应用显得生动活泼,仿佛日晷本身也在随着时间流逝而"呼吸"。
需要注意的是,setInterval 返回一个定时器ID,但这里没有保存和清理。在组件销毁时,理想的做法是调用 clearInterval 清理定时器以避免内存泄漏。不过在页面级组件中,组件的生命周期与应用窗口一致,通常不会频繁销毁,因此这个遗漏在实践中影响不大。但在更复杂的组件场景中,定时器的清理是必须注意的工程细节。
七、UI 组件分析:Builder 构建器
7.1 modalOverlay 遮罩构建器
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
modalOverlay 是一个通用的模态遮罩构建器。@Builder 装饰器将一个方法标记为 UI 构建器,使其可以在其他构建器或 build 方法中被调用以复用 UI 结构。这个构建器接收一个 onClose 回调函数作为参数,实现了"参数化 UI"的能力——同一个遮罩构建器可以被多个弹窗复用,每个弹窗传入不同的关闭回调。
构建器内部是一个铺满全屏的 Column,背景色为半透明黑色(COLORS.mask,即 rgba(0,0,0,0.45))。点击遮罩区域时调用 onClose() 回调,通常用于关闭弹窗。这种"点击遮罩关闭弹窗"的交互模式是移动端应用的标准做法,用户可以通过点击弹窗外部区域快速关闭不需要的弹窗,而无需寻找关闭按钮。
从架构设计的角度看,modalOverlay 体现了"组合优于继承"的原则。三个弹窗(新增、编辑、删除)都需要遮罩层,如果不用构建器复用,就需要在三个地方重复编写遮罩代码。通过 @Builder 提取为独立单元,不仅减少了代码重复,还使遮罩的样式修改只需调整一处。
7.2 pageHeader 页面头部构建器
@Builder
pageHeader() {
Column() {
Stack() {
Column()
.width('100%')
.height('100%')
.borderRadius(22)
.linearGradient({
angle: 135,
colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
})
Column()
.width(110)
.height(110)
.borderRadius(55)
.backgroundColor(COLORS.stone)
.border({ width: 2, color: COLORS.line })
.position({ x: 16, y: 12 })
ForEach(HOUR_IDX, (i: number) => {
Column()
.width(1.5)
.height(hourLen(i))
.backgroundColor(COLORS.line)
.rotate({ angle: hourRotate(i) })
.position({ x: 70, y: 67 })
}, (i: number) => 'h' + i)
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor(COLORS.sun)
.scale({ x: this.breath ? 1.5 : 1, y: this.breath ? 1.5 : 1 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
.position({ x: 65, y: 62 })
Column()
.width(4)
.height(40)
.borderRadius(2)
.backgroundColor(COLORS.bronze)
.rotate({ angle: handAngle(this.breath) })
.position({ x: 69, y: 60 })
Column()
.width(3)
.height(34)
.borderRadius(1.5)
.backgroundColor(COLORS.shadow)
.opacity(0.45)
.rotate({ angle: handAngle(this.breath) + 12 })
.position({ x: 74, y: 66 })
Column()
.width(42)
.height(42)
.borderRadius(21)
.backgroundColor(COLORS.sun)
.opacity(this.breath ? 0.18 : 0.06)
.scale({ x: this.breath ? 1.5 : 1.1, y: this.breath ? 1.5 : 1.1 })
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
.position({ x: 49, y: 46 })
ForEach(DOT_IDX, (i: number) => {
Column()
.width(dotR(i) * 2)
.height(dotR(i) * 2)
.borderRadius(dotR(i))
.backgroundColor(COLORS.gold)
.opacity(this.breath ? 0.9 : 0.4)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
.position({ x: dotX(i), y: dotY(i) })
}, (i: number) => 'd' + i)
Column() {
Text('日晷坊')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('晷影流年 · 光阴寸金')
.fontSize(12)
.fontColor(COLORS.sub)
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.position({ x: 158, y: 24 })
Column() {
Text('赤道式 · 地平式 · 垂直式')
.fontSize(12)
.fontColor(COLORS.gold)
Row() {
Text('午时')
.fontSize(11)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.hot)
.borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text('日影最短')
.fontSize(11)
.fontColor(COLORS.sub)
.margin({ left: 8 })
Text('十二时辰')
.fontSize(11)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.cool)
.borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 8 })
}
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.position({ x: 158, y: 82 })
}
.width('100%')
.height(136)
pageHeader 是整个应用最复杂的构建器,它渲染了页面顶部的日晷图标和标题信息。构建器首先用 Column 作为最外层容器,内部嵌套一个 Stack 用于层叠多个视觉元素。
Stack 内部包含了十层子元素——背景渐变层(深蓝灰到近黑的线性渐变)、晷面圆盘层(110像素的圆形石质晷面,带有金线色边框)、十二刻度线层(通过 ForEach 渲染十二条放射状金线,角度由 hourRotate 函数计算)、太阳层(10像素的琥珀橙色圆形,通过 scale 和 opacity 结合 breath 状态实现脉动)、晷针层(4乘40像素的青铜色竖条,通过 handAngle 函数计算摆动角度)、晷针投影层(3乘34像素的深灰色竖条,角度比晷针多12度)、太阳光晕层(42像素的低透明度琥珀橙色圆形,模拟光辉散射)、装饰金点层(五个金色圆点,位置和半径由 dotX、dotY、dotR 函数计算)、标题文字层(“日晷坊"和"晷影流年 · 光阴寸金”)、标签文字层(“赤道式 · 地平式 · 垂直式"和"午时”、"十二时辰"等标签)。
晷针与投影的联动设计非常巧妙——晷针摆动时,投影也随之摆动,但始终保持12度的偏角,模拟了太阳光线方向与晷针影子方向之间的关系。这种物理真实感使数字日晷图标不仅具有装饰性,更具有教育意义——用户可以通过观察晷针与影子的关系,理解日晷"以影计时"的基本原理。
Stack 的高度被设为136像素,这个高度恰好容纳了日晷图标和标题信息,布局紧凑而不拥挤。所有层叠元素通过 position 绝对定位精确放置在各自的位置上,Stack 的层叠特性使得这些元素可以自由重叠而不相互推挤,这是 Row 或 Column 无法实现的。
头部卡片下方是一个统计信息栏,使用 Row 水平排列四个 Column,每个 Column 通过 layoutWeight(1) 等分宽度。每个统计项包含一个金色大数字和灰色小标签——“12 晷制款式”、“12 十二时辰”、“8 观象台”、“8 传世古晷”——概要展示了应用的核心数据规模。统计栏使用白色背景和上方圆角,在日晷图标卡片和下方内容之间形成过渡。
7.3 sectionTitle 区块标题构建器
@Builder
sectionTitle(icon: string, title: string, sub: string) {
Row() {
Text(icon)
.fontSize(15)
Text(title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.margin({ left: 6 })
Column().layoutWeight(1)
Text(sub)
.fontSize(11)
.fontColor(COLORS.text3)
}
.width('100%')
.padding({ left: 4, right: 4, bottom: 10 })
}
sectionTitle 是一个通用的区块标题构建器,接收三个参数:icon(emoji图标)、title(标题文本)、sub(副标题文本)。它被八个内容模块的标题渲染复用,是代码复用的典型示例。
构建器内部使用 Row 水平排列四个元素:emoji图标、标题文本、一个 layoutWeight(1) 的空白 Column(用于占据中间空间将副标题推到右侧)、副标题文本。这种"左标题加右副标题"的布局模式在信息列表类应用中极为常见,通过空白 Column 的弹性权重实现两端对齐效果。
layoutWeight(1) 是 ArkTS Flex 布局中的核心属性,它使元素占据父容器中的剩余空间。当多个元素都设置了 layoutWeight 时,剩余空间按权重比例分配。在这里,只有空白 Column 设置了 layoutWeight(1),因此它占据标题和副标题之间的所有空间,将副标题推到最右侧。
7.4 dialRow 日晷行构建器
@Builder
dialRow(item: DialItem, i: number) {
Row() {
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.text1)
Text(item.type + ' · ' + item.size + ' · ' + item.stone)
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('¥' + item.price)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(item.type)
.fontSize(10)
.fontColor(COLORS.gold)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
.margin({ right: 10 })
Text('编')
.fontSize(12)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.bronze)
.borderRadius(10)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.onClick(() => {
this.showEdit = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
dialRow 是日晷列表的行构建器,接收一个 DialItem 数据对象和行索引作为参数。行内布局分为三部分:左侧信息区、右侧价格区、最右侧操作按钮。
左侧信息区是一个 Column,包含日晷名称(15号中粗体深色)和类型、尺寸、石材的组合描述(11号中灰色)。alignItems(HorizontalAlign.Start) 使文本左对齐,layoutWeight(1) 使该区域占据行内大部分空间。右侧价格区也是一个 Column,包含价格(14号粗体青铜色)和类型标签(10号金色),alignItems(HorizontalAlign.End) 使文本右对齐。
最右侧的"编"按钮使用青铜色背景和白色文字,形成胶囊形状的按钮。点击该按钮会设置 this.showEdit = true,触发编辑弹窗的显示。行的整体样式采用白色卡片背景、14像素圆角、12像素内边距和8像素底部间距,形成了卡片列表的标准视觉效果。
7.5 handRow 晷针行构建器
@Builder
handRow(item: HandItem, i: number) {
Column() {
Row() {
Column()
.width(3)
.height(20)
.borderRadius(1.5)
.backgroundColor(COLORS.bronze)
.rotate({ angle: this.breath ? -30 : -18 })
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.text1)
Text(item.material + ' · 倾角 ' + item.angle + '°')
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(item.precision + '%')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(precisionColor(item.precision))
Text(item.length + 'cm')
.fontSize(10)
.fontColor(COLORS.text3)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
.margin({ right: 10 })
Text('改')
.fontSize(12)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.gold)
.borderRadius(10)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.onClick(() => {
this.selHand = item;
this.showEdit = true;
})
}
.width('100%')
Row() {
Text('精度')
.fontSize(10)
.fontColor(COLORS.text2)
Column()
.width(precisionBarW(item.precision))
.height(8)
.borderRadius(4)
.backgroundColor(precisionColor(item.precision))
.margin({ left: 8 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
handRow 是晷针列表的行构建器。与 dialRow 不同,handRow 的最外层是 Column 而非 Row,因为每行包含两行内容——上方是晷针信息行,下方是精度进度条行。
信息行的最左侧是一个3乘20像素的青铜色竖条,通过 rotate 和 animation 实现摆动动画——角度在 -30度和 -18度之间交替变化,每600毫秒切换一次。这个竖条是晷针的微缩动画表示,与页面头部的日晷图标中的晷针动画相呼应,使每个晷针行都有自己的"呼吸"效果。
竖条右侧是晷针信息区,包含名称和材质、倾角的描述。再右侧是精度数值(百分比形式,颜色由 precisionColor 函数决定)和长度信息。最右侧的"改"按钮使用金色背景,点击后将当前 HandItem 对象赋值给 this.selHand 状态,并设置 this.showEdit = true 打开编辑弹窗。
信息行下方是精度进度条行。"精度"标签后是一个 Column 作为进度条,宽度由 precisionBarW(item.precision) 函数计算,高度固定为8像素,圆角4像素,颜色由 precisionColor 函数决定。进度条还附加了 opacity 和 animation 属性,使其透明度随 breath 状态变化,形成闪烁的"呼吸"效果。
7.6 shichenRow 时辰行构建器
@Builder
shichenRow(item: ShichenItem, i: number) {
Column() {
Row() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.animal)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.cool)
.borderRadius(8)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ left: 8 })
Column().layoutWeight(1)
Text(item.period)
.fontSize(11)
.fontColor(COLORS.text3)
}
.width('100%')
Row() {
Text('使用热度')
.fontSize(10)
.fontColor(COLORS.text2)
Column()
.width(hotBarW(item.hot))
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.gold)
.margin({ left: 8 })
Column().layoutWeight(1)
Text(item.uses)
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
shichenRow 是十二时辰列表的行构建器。每行包含两行内容——时辰信息行和使用热度进度条行。
信息行中,时辰名称(如"子时")以15号粗体显示,其后紧跟一个蓝色胶囊标签显示对应的生肖动物(如"鼠")。这种将时辰与生肖并排展示的设计,直观地呈现了十二地支与十二生肖的对应关系。右侧是现代时间段(如"23:00-01:00"),以浅灰色小字显示,方便用户将古时辰转换为现代时间。
进度条行中,“使用热度"标签后是一个金色进度条,宽度由 hotBarW(item.hot) 函数计算。与晷针进度条不同,时辰热度进度条没有添加 opacity 和 animation 动画效果,保持静态显示。进度条右侧是古称(如"夜半”),以浅灰色小字显示,提供了该时辰的文化含义。
7.7 obsRow 观象台行构建器
@Builder
obsRow(item: ObservatoryItem, i: number) {
Row() {
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.city + ' · 建于' + item.age + ' · ' + item.level)
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text(item.instruments + ' 件')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.cool)
Text('天文仪器')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
obsRow 是观象台列表的行构建器,采用了简单的两列布局——左侧信息区占大部分空间,右侧仪器数量区。信息区包含观象台名称和城市、建于年代、级别的组合描述。右侧显示仪器数量(蓝色中号字)和"天文仪器"标签(灰色小字)。
这是所有行构建器中最简洁的一个,没有进度条、没有动画、没有操作按钮。这种简洁的设计适合信息展示型内容——观象台是历史遗产,用户只需浏览信息,不需要交互操作。布局的简洁也使信息读取更加高效,用户可以快速扫描列表获取关键信息。
7.8 calRow 历法行构建器
@Builder
calRow(item: CalendarItem, i: number) {
Column() {
Row() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.kind)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.accent)
.borderRadius(8)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ left: 8 })
Column().layoutWeight(1)
Text(item.era)
.fontSize(11)
.fontColor(COLORS.text3)
}
.width('100%')
Row() {
Text(item.days + ' 日')
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.hot)
Column().layoutWeight(1)
Text(item.use)
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
calRow 是历法列表的行构建器,每行包含两行内容——历法信息行和年度天数行。
信息行中,历法名称以15号粗体显示,其后紧跟一个青铜色胶囊标签显示历法类型(如"阴阳合历"、“四分历"等)。右侧是所属朝代(如"夏”、“汉”、"元"等),以浅灰色显示。
年度天数行中,天数以红色中粗体显示,突出这一关键数值。右侧是历法特征描述(如"二十四节气"、“岁差”、"回归年365.2425"等),以浅灰色小字显示。这种将关键数值以红色突出的设计,使用户在浏览列表时能够快速比较不同历法的年度天数差异,直观感受从354天到365天的精度演进。
7.9 makerRow 匠人行构建器
@Builder
makerRow(item: SundialMakerItem, i: number) {
Column() {
Row() {
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.grade + ' · ' + item.years + ' 年')
.fontSize(11)
.fontColor(gradeColor(item.grade))
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text(item.works + ' 座')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('传世晷仪')
.fontSize(10)
.fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row() {
Text('天文造诣')
.fontSize(10)
.fontColor(COLORS.text2)
Column()
.width(skillBarW(item.skill))
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.sun)
.margin({ left: 8 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
Text(item.skill + ' 分')
.fontSize(10)
.fontColor(COLORS.hot)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
makerRow 是制晷匠人列表的行构建器,每行包含信息行和天文造诣进度条行。
信息行中,匠人名称以15号粗体显示,下方是称号和从业年数的组合描述,颜色由 gradeColor 函数根据称号中的关键词决定——包含"大师"的显示为红色,包含"名师"的显示为金色,包含"匠师"的显示为青铜色,其他显示为蓝色。这种颜色编码使不同等级的匠人在列表中一目了然。右侧是传世作品数量(青铜色粗体)和"传世晷仪"标签。
天文造诣进度条行中,进度条颜色使用了琥珀橙色而非其他进度条常用的金色或红色,使匠人模块的进度条在视觉上与其他模块区分开来。进度条带有 breath 驱动的透明度动画,右侧显示具体分数(红色小字)。
7.10 ancientRow 古晷行构建器
@Builder
ancientRow(item: AncientDialItem, i: number) {
Row() {
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.age + ' · ' + item.origin)
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('¥' + item.value)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.hot)
Text(item.rank)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(rankColor(item.rank))
.borderRadius(8)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
ancientRow 是传世古晷列表的行构建器,采用两列布局。左侧信息区包含古晷名称和年代、产地的组合描述。右侧价值区包含估价(红色粗体)和等级标签(背景色由 rankColor 函数决定——"国宝"为红色,"名晷"为青铜色)。
古晷行的设计重点突出了价值信息——估价使用红色粗体大号字,等级标签使用醒目的胶囊形状和颜色编码。这种设计使用户在浏览列表时能够快速识别最具价值的藏品,符合"估价榜"这一模块的定位。
7.11 orderRow 订单行构建器
@Builder
orderRow(item: DialOrderItem, i: number) {
Column() {
Row() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.buyer)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.cool)
.borderRadius(8)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ left: 8 })
Column().layoutWeight(1)
Text(item.date)
.fontSize(11)
.fontColor(COLORS.text3)
}
.width('100%')
Row() {
Text('金额 ¥' + item.amount)
.fontSize(11)
.fontColor(COLORS.text2)
Column()
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.hot)
.margin({ left: 8 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
Text(item.count + ' 座')
.fontSize(10)
.fontColor(COLORS.text3)
.margin({ right: 8 })
Text('删')
.fontSize(12)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.onClick(() => {
this.selOrder = item;
this.showDel = true;
})
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
orderRow 是订单列表的行构建器,每行包含信息行和金额进度条行。信息行中,订单名称以15号粗体显示,其后紧跟一个蓝色胶囊标签显示买方名称。右侧是订单日期,以浅灰色显示。
金额进度条行中,金额标签后是一个红色进度条,带有 breath 驱动的透明度动画。右侧显示订购数量和一个红色"删"按钮,点击后弹出删除确认弹窗。
7.12 tabContent 内容区构建器
@Builder
tabContent() {
if (this.curTab === 0) {
Column() {
this.sectionTitle('☀️', '日晷款式', '共 ' + this.dials.length + ' 座')
ForEach(this.dials, (item: DialItem, i: number) => {
this.dialRow(item, i)
}, (item: DialItem) => item.name)
}
.width('100%')
} else if (this.curTab === 1) {
Column() {
this.sectionTitle('📏', '晷针工艺', '影随日移')
ForEach(this.hands, (item: HandItem, i: number) => {
this.handRow(item, i)
}, (item: HandItem) => item.name)
}
.width('100%')
} else if (this.curTab === 2) {
Column() {
this.sectionTitle('🕐', '十二时辰', '古人之钟')
ForEach(this.shichens, (item: ShichenItem, i: number) => {
this.shichenRow(item, i)
}, (item: ShichenItem) => item.name)
}
.width('100%')
} else if (this.curTab === 3) {
Column() {
this.sectionTitle('🔭', '观象高台', '测天之场')
ForEach(this.observatories, (item: ObservatoryItem, i: number) => {
this.obsRow(item, i)
}, (item: ObservatoryItem) => item.name)
}
.width('100%')
} else if (this.curTab === 4) {
Column() {
this.sectionTitle('📅', '古历沿革', '授时之要')
ForEach(this.calendars, (item: CalendarItem, i: number) => {
this.calRow(item, i)
}, (item: CalendarItem) => item.name)
}
.width('100%')
} else if (this.curTab === 5) {
Column() {
this.sectionTitle('🛠️', '制晷匠人', '天文名家')
ForEach(this.makers, (item: SundialMakerItem, i: number) => {
this.makerRow(item, i)
}, (item: SundialMakerItem) => item.name)
}
.width('100%')
} else if (this.curTab === 6) {
Column() {
this.sectionTitle('🏛️', '传世古晷', '估价榜')
ForEach(this.ancients, (item: AncientDialItem, i: number) => {
this.ancientRow(item, i)
}, (item: AncientDialItem) => item.name)
}
.width('100%')
} else {
Column() {
this.sectionTitle('📦', '最新订单', '共 ' + this.orders.length + ' 单')
ForEach(this.orders, (item: DialOrderItem, i: number) => {
this.orderRow(item, i)
}, (item: DialOrderItem) => item.name)
}
.width('100%')
}
}
tabContent 是内容区的核心构建器,通过 if-else if-else 条件分支根据 curTab 的值渲染不同的内容模块。每个分支的结构高度一致——一个 Column 包含 sectionTitle 标题构建器和 ForEach 列表渲染。
ForEach 的三个参数分别是:数据数组、项渲染函数(调用对应的行构建器)、键值生成函数(使用数据对象的 name 属性作为唯一键)。键值生成器是 ForEach 性能优化的关键——当列表数据变化时,框架通过比较键值来确定哪些项需要新增、删除或重用,避免全量重渲染。
这种通过 if-else 链实现的条件渲染在模块数量较少时是清晰高效的。但如果模块数量继续增加,if-else 链会变得冗长。此时可以考虑使用 switch-case 结构或将每个模块的渲染逻辑提取为独立的构建器方法。
每个 sectionTitle 的副标题都经过精心设计——“共 X 座”、“影随日移”、“古人之钟”、“测天之场”、“授时之要”、“天文名家”、“估价榜”、“共 X 单”——既概括了模块内容,又增添了文化韵味。
7.13 bottomItem 与 bottomBar 底部导航构建器
@Builder
bottomItem(i: number) {
Column() {
Text(TAB_LIST[i].icon)
.fontSize(17)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontColor(this.curTab === i ? COLORS.gold : COLORS.sub)
.margin({ top: 2 })
}
.width('25%')
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.curTab === i ? '#33383F' : 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 })
}
bottomItem 是底部导航栏单个标签项的构建器,接收标签索引 i 作为参数。选中状态通过颜色和背景双重区分——选中项的标签文字为金色,背景为浅深色;未选中项的标签文字为灰色,背景为深色。点击导航项时,this.curTab = i 更新当前标签索引,触发 tabContent 的条件分支切换和导航项的选中样式更新。
bottomBar 构建器渲染了整个底部导航栏,由两个 Row 组成——第一行包含前四个标签(索引0-3),第二行包含后四个标签(索引4-7)。这种两行四列的导航栏布局在移动端应用中较为少见,但对于八个标签的情况,两行布局是一个合理的折中方案,避免了标签过于拥挤。
7.14 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 })
// 四个表单字段:晷名、式样、石材、售价
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 })
// ... 其余三个字段结构相同
Row() {
Text('取消')
.fontSize(14).fontColor(COLORS.text2).textAlign(TextAlign.Center)
.layoutWeight(1).padding({ top: 10, bottom: 10 })
.backgroundColor('#EFE9DD').borderRadius(12)
.onClick(() => { this.showAdd = false; })
Text('保存')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.cardBg)
.textAlign(TextAlign.Center).layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.accent).borderRadius(12).margin({ left: 12 })
.onClick(() => {
this.dials.splice(0, 0, new DialItem(this.formName, this.formType, '径 60cm', this.formStone, 26000));
this.showAdd = false;
})
}
.width('100%').margin({ top: 18 })
}
.width('88%').padding(18).backgroundColor(COLORS.cardBg)
.borderRadius(18).constraintSize({ maxHeight: '80%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
}
addModal 是"新增日晷"弹窗的构建器。外层是一个条件判断 if (this.showAdd),只有当 showAdd 状态为 true 时才渲染弹窗内容。这种条件渲染确保了弹窗隐藏时不占用渲染资源。
弹窗内部使用 Stack 层叠遮罩层和内容卡片。遮罩层调用 this.modalOverlay(...) 构建器,传入关闭回调。内容卡片是一个白色背景、圆角18像素、宽度88%的 Column,包含标题和四个表单字段。每个字段的结构相同:一个标签 Text 和一个 TextInput 输入框。TextInput 的 text 属性绑定到对应的表单状态变量,onChange 回调在输入内容变化时更新状态变量。这种双向绑定的模式确保了输入框内容与状态变量始终保持同步。
弹窗底部是"取消"和"保存"两个按钮。"保存"按钮点击后通过 this.dials.splice(0, 0, new DialItem(...)) 在日晷列表头部插入新创建的 DialItem 对象,然后关闭弹窗。constraintSize({ maxHeight: '80%' }) 限制了弹窗的最大高度为视口的80%,zIndex(999) 确保弹窗在所有其他 UI 元素之上。
7.15 editModal 编辑晷针弹窗
@Builder
editModal() {
if (this.showEdit) {
Stack() {
this.modalOverlay(() => { this.showEdit = 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)
Text(this.selHand === null ? '—' : this.selHand.name)
.fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.text1).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).width('100%').margin({ top: 14 })
Column() {
Text('当前倾角').fontSize(12).fontColor(COLORS.text2)
Text(this.selHand === null ? '—' : (this.selHand.angle + '°'))
.fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(precisionColor(this.selHand === null ? 60 : this.selHand.precision))
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).width('100%').margin({ top: 12 })
Row() {
Text('仰角 +2°')
.fontSize(13).fontColor(COLORS.cardBg).textAlign(TextAlign.Center)
.layoutWeight(1).padding({ top: 9, bottom: 9 })
.backgroundColor(COLORS.hot).borderRadius(10)
.onClick(() => {
if (this.selHand !== null) { this.selHand.angle = this.selHand.angle + 2; }
this.showEdit = false;
})
Text('仰角 -2°')
.fontSize(13).fontColor(COLORS.cardBg).textAlign(TextAlign.Center)
.layoutWeight(1).padding({ top: 9, bottom: 9 })
.backgroundColor(COLORS.cool).borderRadius(10).margin({ left: 10 })
.onClick(() => {
if (this.selHand !== null) { this.selHand.angle = this.selHand.angle - 2; }
this.showEdit = false;
})
}
.width('100%').margin({ top: 18 })
Text('关闭')
.fontSize(14).fontColor(COLORS.text2).textAlign(TextAlign.Center)
.width('100%').padding({ top: 10, bottom: 10 })
.backgroundColor('#EFE9DD').borderRadius(12).margin({ top: 12 })
.onClick(() => { this.showEdit = false; })
}
.width('88%').padding(18).backgroundColor(COLORS.cardBg)
.borderRadius(18).constraintSize({ maxHeight: '80%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
}
editModal 是"编辑晷针"弹窗的构建器。弹窗显示当前选中的晷针名称和倾角,并允许用户通过两个按钮调整倾角。this.selHand 的类型是 HandItem | null,因此在访问其属性前需要进行空值检查——this.selHand === null ? '—' : this.selHand.name 是典型的三元运算符空值处理模式。
弹窗的核心交互是两个调整按钮——“仰角 +2°”(红色背景)和"仰角 -2°"(蓝色背景)。点击后分别将 selHand.angle 加2或减2,然后关闭弹窗。由于 HandItem 类被 @Observed 装饰,且 selHand 被 @State 装饰,angle 属性的修改会被框架感知并自动更新对应的 UI。这意味着用户在弹窗中调整倾角后返回列表,对应的晷针行中的倾角显示会自动更新。
7.16 delModal 删除订单弹窗
@Builder
delModal() {
if (this.showDel) {
Stack() {
this.modalOverlay(() => { this.showDel = false; })
Column() {
Text('删除订单').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.text1)
Text('此操作不可恢复,请确认').fontSize(12).fontColor(COLORS.text3).margin({ top: 6 })
Column() {
Text('将删除:').fontSize(12).fontColor(COLORS.text2)
Text(this.selOrder === null ? '—' : (this.selOrder.name + ' · ' + this.selOrder.buyer))
.fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.danger).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).width('100%').padding(12)
.backgroundColor('#FAEDEC').borderRadius(12).margin({ top: 14 })
Row() {
Text('再想想')
.fontSize(14).fontColor(COLORS.text2).textAlign(TextAlign.Center)
.layoutWeight(1).padding({ top: 10, bottom: 10 })
.backgroundColor('#EFE9DD').borderRadius(12)
.onClick(() => { this.showDel = false; })
Text('确认删除')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.cardBg)
.textAlign(TextAlign.Center).layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger).borderRadius(12).margin({ left: 12 })
.onClick(() => {
if (this.selOrder !== null) {
this.orders.splice(this.orders.indexOf(this.selOrder), 1);
}
this.showDel = false;
})
}
.width('100%').margin({ top: 18 })
}
.width('88%').padding(18).backgroundColor(COLORS.cardBg)
.borderRadius(18).constraintSize({ maxHeight: '80%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
}
delModal 是"删除订单"弹窗的构建器。弹窗显示将要删除的订单名称和买方信息,使用红色文字和浅红色背景区域突出删除警示。底部是"再想想"(取消)和"确认删除"两个按钮。
确认删除操作通过 this.orders.splice(this.orders.indexOf(this.selOrder), 1) 实现——先通过 indexOf 找到选中订单在数组中的索引,然后通过 splice 删除该位置的元素。由于 orders 被 @State 装饰,数组变异操作会被框架感知,列表会自动更新,被删除的订单行从列表中移除。
7.17 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() 方法是 @Component 组件的核心入口,它定义了组件的 UI 结构树。在 ArkTS 的声明式 UI 范式中,build 方法不接受参数,返回一个 UI 组件树。框架在组件首次创建时会调用 build 方法构建初始 UI,当组件的状态变量发生变化时,框架会重新调用 build 方法(或其中受影响的部分)来更新 UI。
最外层是一个 Stack 容器,它将主页面内容和三个弹窗层叠在一起。Stack 是 ArkTS 中的层叠布局容器,子元素按声明顺序从下到上层叠排列,后声明的元素覆盖先声明的元素。这种设计使弹窗能够覆盖在主页面之上,同时主页面内容保持不变。
Stack 内部首先是一个 Column 容器,它垂直排列两个部分:可滚动的主体内容和底部导航栏。主体内容使用 Scroll 组件包裹,scrollable(ScrollDirection.Vertical) 指定了垂直滚动方向。Scroll 内部是一个 Column,包含 pageHeader() 头部和 tabContent() 内容区。layoutWeight(1) 让 Scroll 组件占据 Column 中除了底部导航栏之外的所有剩余空间。
三个条件渲染的弹窗——addModal、editModal、delModal——通过 if 语句控制显示。只有当对应的 showXxx 状态为 true 时,弹窗才会被渲染到 Stack 中。这种设计确保了弹窗隐藏时不占用任何渲染资源,也保证了同一时间可以显示多个弹窗(虽然在本应用的业务逻辑中不会同时出现)。
值得注意的一个设计细节是,build 方法中 Scroll 的 padding 设置在内部的 Column 上,而非 Scroll 本身。这是因为 Scroll 组件的 padding 行为可能与普通容器不同,将 padding 设置在内部容器上可以确保内容区域两侧有 14 像素的内边距,同时不影响 Scroll 组件自身的布局计算。
另一个值得关注的技术点是 bottomBar 位于 Scroll 之外,直接作为 Column 的第二个子元素。这意味着底部导航栏是固定的,不会随页面内容滚动。这是移动端应用的常见布局模式——头部内容可滚动,底部导航固定。layoutWeight(1) 应用在 Scroll 上而非 bottomBar 上,确保了 Scroll 自动填充剩余空间,而 bottomBar 保持其内容自适应的高度。
整个 build 方法的结构清晰地体现了"内容优先"的设计理念:先渲染用户最关心的内容区域,再渲染辅助性的导航栏和弹窗。Stack 的层叠机制确保了弹窗总是覆盖在内容之上,而条件渲染确保了弹窗的按需创建与销毁。
八、状态管理深度剖析
8.1 @State 装饰器:响应式状态的核心
@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State 是 ArkTS 中最基础也最核心的状态管理装饰器。它将一个普通变量转变为"响应式状态"——当该变量的值发生变化时,ArkTS 框架会自动检测到变化,并重新渲染所有引用了该变量的 UI 组件部分。这种"数据驱动视图"的模式,是声明式 UI 与传统命令式 UI 的根本区别。
在传统的命令式 UI 开发中(如 Android 的 View 系统),当数据变化时,开发者需要手动调用 setText()、setVisibility() 等方法来更新界面。这种方式的问题在于:随着应用复杂度的增长,数据与界面的对应关系变得错综复杂,手动维护同步关系极易产生遗漏和错误。而 @State 装饰器通过自动化的依赖追踪与变更检测,彻底解决了这一问题。
curTab 是当前选中的标签页索引,它决定了 tabContent 中哪个分支被渲染。当用户点击底部导航栏的某个标签时,bottomItem 的 onClick 回调将 curTab 设置为对应的索引值。框架检测到 curTab 的变化后,会重新执行 tabContent 中的条件判断,渲染新的标签页内容。这种切换是即时的、流畅的,开发者无需关心旧内容的销毁和新内容的创建。
breath 是一个布尔值,用于驱动整个应用的"呼吸"动画效果。它在 aboutToAppear 生命周期中被 setInterval 每 600 毫秒翻转一次。由于大量 UI 元素的样式(如 scale、opacity、rotate)都引用了 this.breath,每次翻转都会触发这些元素的样式更新,配合 animation 属性实现平滑的动画过渡。
showAdd、showEdit、showDel 三个布尔状态分别控制三个弹窗的显示与隐藏。这种"状态驱动弹窗"的模式比传统的"命令式弹窗"(如 AlertDialog.show())更加符合声明式 UI 的理念——弹窗的显示状态是应用状态的一部分,而非一个独立的事件流。
8.2 @State 与引用类型:selHand 与 selOrder
@State selHand: HandItem | null = null;
@State selOrder: DialOrderItem | null = null;
这两个状态变量的类型是联合类型——HandItem | null 和 DialOrderItem | null。这意味着它们可以持有一个 @Observed 类的实例引用,也可以为 null(表示当前没有选中任何项)。
selHand 用于记录当前正在编辑的晷针。当用户在晷针列表中点击某个晷针行的"改"按钮时,onClick 回调将 selHand 设置为该行的 HandItem 实例,然后设置 showEdit = true 打开编辑弹窗。编辑弹窗中显示 selHand 的名称和倾角,用户调整倾角后,selHand.angle 的修改通过 @Observed 机制触发 UI 更新。
selOrder 用于记录当前正在删除的订单。当用户点击某个订单行的"删"按钮时,onClick 回调将 selOrder 设置为该行的 DialOrderItem 实例,然后设置 showDel = true 打开删除确认弹窗。删除弹窗中显示 selOrder 的名称和买方信息,用户确认后,orders.splice 将该订单从数组中移除。
这两个状态变量的设计体现了"选中态"管理的通用模式:用一个可空的状态变量保存当前操作的对象引用,配合一个布尔状态变量控制弹窗的显示。这种模式的优点在于简洁性——一个对象引用加一个布尔标志,就能完整表达"正在对哪个对象执行什么操作"的交互状态。
8.3 @State 与表单数据:formName 等
@State formName: string = '';
@State formType: string = '';
@State formStone: string = '';
@State formPrice: string = '';
这四个状态变量用于"新增日晷"弹窗的表单数据。每个变量对应一个 TextInput 输入框,通过 onChange 回调实现双向绑定——用户在输入框中输入内容时,onChange 被触发,将输入值赋给对应的状态变量;状态变量的变化又会反过来更新 TextInput 的 text 属性(虽然在实际使用中,TextInput 本身已经显示了用户输入的内容,这种反向更新主要用于程序化设置输入框内容的场景)。
值得注意的是,formPrice 的类型是 string 而非 number。这是因为 TextInput 的 onChange 回调返回的值始终是字符串类型。在保存时,代码直接将 26000 作为硬编码价格传入 DialItem 构造函数,而未使用 formPrice 的值。这可能是开发者的简化处理,也提示了一个潜在的功能完善方向——将 formPrice 转换为数字后传入构造函数。
8.4 @State 与数组:dials 等
@State dials: DialItem[] = [
new DialItem('故宫赤道日晷', '赤道式', '径 120cm', '汉白玉', 180000),
new DialItem('观象台地平日晷', '地平式', '径 90cm', '青石', 96000),
// ... 更多日晷数据
];
@State 装饰的数组类型变量具有特殊的响应式特性。当数组本身被重新赋值时(如 this.dials = newArray),框架会触发 UI 更新。更重要的是,当数组元素通过 splice、push、pop 等方法进行增删时,框架同样能够检测到变化并更新 UI。
在本应用中,dials 数组通过 splice 方法在头部插入新元素(新增日晷),orders 数组通过 splice 方法删除指定元素(删除订单)。这些数组变异操作都被 @State 正确追踪,确保了列表 UI 的实时更新。
数组中的元素是 @Observed 类的实例。这意味着不仅数组本身的变化会被追踪,数组元素的属性变化也会被追踪。例如,当 selHand.angle 被修改时,虽然 hands 数组本身没有变化(没有增删元素),但 selHand 是 hands 数组中的某个元素,其 angle 属性的变化通过 @Observed 机制传播到引用了该属性的 UI 组件,触发精确的局部更新。
8.5 @Observed 装饰器:可观察的数据模型
@Observed
export class DialItem {
name: string;
type: string;
size: string;
stone: string;
price: number;
constructor(name: string, type: string, size: string, stone: string, price: number) {
this.name = name;
this.type = type;
this.size = size;
this.stone = stone;
this.price = price;
}
}
@Observed 装饰器将一个普通的 TypeScript 类转变为"可观察类"。被 @Observed 装饰的类,其实例的属性变化可以被 ArkTS 框架追踪。当这些属性被 @State 或 @ObjectLink 装饰的变量引用时,属性的变化会自动触发相关 UI 组件的重新渲染。
@Observed 与 @State 的区别在于作用层次不同。@State 装饰的变量本身是响应式的——变量值的改变触发 UI 更新。但如果 @State 变量持有一个对象引用,对象内部属性的改变默认不会被 @State 追踪。@Observed 正是解决这一问题的——它使对象的属性变化也可被追踪。两者配合使用,才能实现从"变量引用变化"到"对象属性变化"的完整响应式覆盖。
在本应用中,HandItem 被 @Observed 装饰,selHand 被 @State 装饰。当 selHand.angle 被修改时,@Observed 检测到 angle 属性的变化,通过 @State 的引用关系,触发编辑弹窗中显示倾角的 Text 组件重新渲染,显示新的角度值。同时,由于 selHand 是 hands 数组中的元素,hands 数组中对应的 HandItem 实例的 angle 属性也发生了变化,晷针列表中对应行的倾角显示也会自动更新。
8.6 @Prop 与 @ObjectLink:跨组件数据传递
虽然本应用的所有 UI 都在 DialPage 一个组件中实现,没有使用 @Prop 和 @ObjectLink,但理解这两个装饰器对于掌握 ArkTS 的完整状态管理体系至关重要。
@Prop 用于父组件向子组件单向传递数据。父组件传递的数据在子组件中会被深拷贝,子组件对数据的修改不会影响父组件。这种单向数据流确保了组件间的数据隔离,适合传递简单类型的值或需要子组件独立维护副本的场景。
@ObjectLink 用于父子组件间共享 @Observed 类的实例引用。与 @Prop 不同,@ObjectLink 传递的是引用而非副本——子组件对对象属性的修改会直接反映到父组件中的同一对象。这种双向同步机制适合需要父子组件协同操作同一数据对象的场景,如列表项组件修改数据后,父组件的列表也需要同步更新。
在本应用中,如果将每个行构建器(如 dialRow、handRow)抽取为独立的子组件,就需要使用 @Prop 或 @ObjectLink 来传递数据。例如,dialRow 接收的 DialItem 可以用 @ObjectLink 装饰,这样行组件中对 DialItem 属性的修改就能直接同步到父组件的 dials 数组。而 dialRow 接收的索引 i 可以用 @Prop 装饰,因为索引是一个简单的数字,不需要双向同步。
8.7 状态管理架构总览
本应用的状态管理架构可以概括为"一个中心,两层追踪":
"一个中心"指的是 DialPage 组件作为唯一的状态管理中心。所有的 @State 变量都声明在 DialPage 中,所有的 UI 构建器都是 DialPage 的方法,可以直接访问这些状态变量。这种集中式的状态管理适合中小规模的应用,结构清晰、调试方便。
"两层追踪"指的是 @State 追踪变量引用变化,@Observed 追踪对象属性变化。两层追踪机制协同工作,确保了从数组增删到属性修改的所有数据变化都能被框架感知并反映到 UI 上。
这种状态管理架构的局限性在于:当应用规模增长、组件数量增多时,所有状态集中在单一组件中会导致组件过于庞大。此时需要将状态拆分到多个子组件中,通过 @Prop、@ObjectLink、@Provide/@Consume 等装饰器实现跨组件的数据传递与共享。
九、布局样式与排版系统
9.1 Stack 层叠布局
Stack() {
Column()
.width('100%')
.height('100%')
.borderRadius(22)
.linearGradient({
angle: 135,
colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
})
Column()
.width(110)
.height(110)
.borderRadius(55)
.backgroundColor(COLORS.stone)
.border({ width: 2, color: COLORS.line })
.position({ x: 16, y: 12 })
}
.width('100%')
.height(136)
Stack 是 ArkTS 中的层叠布局容器,类似于 CSS 中的 position: relative 容器配合子元素的 position: absolute。Stack 的子元素默认居中对齐,但可以通过 position 属性指定精确的坐标位置。
在页面头部的 Stack 中,多个视觉元素通过 position 精确定位:背景渐变层占据整个区域,日晷石盘圆位于左上角偏移 (16, 12) 的位置,十二时辰刻度线以 (70, 67) 为中心旋转排列,太阳光点位于 (65, 62),晷针位于 (69, 60)。
position 属性接受 { x: number, y: number } 格式的坐标对象,以 Stack 左上角为原点。这种绝对定位方式适合需要在固定位置精确放置元素的场景,如本例中的日晷图标——各个部件的相对位置是固定的,不随屏幕尺寸变化。
Stack 的层叠顺序由声明顺序决定:先声明的元素在底层,后声明的元素在上层。在页面头部的 Stack 中,背景渐变最先声明(最底层),然后是石盘圆、刻度线、太阳光点、晷针、阴影、光晕、金点装饰,最后是文字信息(最上层)。这种声明顺序确保了文字始终可见,不会被装饰性元素遮挡。
9.2 Column 与 Row:线性布局
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.text1)
Text(item.type + ' · ' + item.size + ' · ' + item.stone)
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column 和 Row 是 ArkTS 中最常用的两种线性布局容器。Column 将子元素垂直排列,Row 将子元素水平排列。它们的对齐方式通过 alignItems 属性控制。
Column 的 alignItems 接受 HorizontalAlign 枚举值:Start(左对齐)、Center(居中对齐)、End(右对齐)。Row 的 alignItems 接受 VerticalAlign 枚举值:Top(顶部对齐)、Center(居中对齐)、Bottom(底部对齐)。
在上面的代码中,Column 设置了 alignItems(HorizontalAlign.Start),使其中的文本左对齐。这在列表项中很常见——名称和描述文字需要左对齐以保持视觉一致性。
9.3 layoutWeight:弹性权重分配
Row() {
Column() {
Text('12')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('晷制款式')
.fontSize(10)
.fontColor(COLORS.sub)
}
.layoutWeight(1)
Column() {
// ...
}
.layoutWeight(1)
Column() {
// ...
}
.layoutWeight(1)
Column() {
// ...
}
.layoutWeight(1)
}
.width('100%')
layoutWeight 是 ArkTS 布局系统中最重要的弹性分配属性,类似于 CSS Flexbox 中的 flex-grow。它接受一个数字权重值,容器会根据所有子元素的 layoutWeight 值按比例分配剩余空间。
在页面头部的统计栏中,四个 Column 各设置了 layoutWeight(1),因此它们平分了 Row 的全部宽度——每个 Column 占据 25% 的宽度。这种等权重分配适合需要等宽排列的场景。
在列表行中,layoutWeight 的使用更加灵活。以 dialRow 为例,左侧的名称信息 Column 设置了 layoutWeight(1),占据除右侧价格和按钮之外的所有剩余宽度。右侧的价格 Column 和编辑按钮没有设置 layoutWeight,它们占据内容自适应的宽度。这种"一个弹性元素 + 多个固定宽度元素"的模式是列表行布局的常见做法——左侧的主信息区域弹性填充,右侧的辅助信息和操作按钮保持固定宽度。
layoutWeight 还可以用于垂直方向的 Column 中。在 build 方法中,Scroll 组件设置了 layoutWeight(1),使其占据 Column 中除了 bottomBar 之外的所有剩余高度。这种做法确保了底部导航栏始终固定在屏幕底部,而内容区域可以弹性滚动。
9.4 渐变背景:linearGradient
Column()
.width('100%')
.height('100%')
.borderRadius(22)
.linearGradient({
angle: 135,
colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
})
linearGradient 方法为组件添加线性渐变背景。它接受一个包含 angle 和 colors 两个属性的对象:angle 指定渐变方向的角度(以度为单位),colors 是一个数组,每个元素是 [颜色值, 停止位置] 的二元组。
在页面头部,angle: 135 表示渐变方向从左上角到右下角(0 度为正下方,90 度为正右方,135 度即右下方向)。colors: [[COLORS.header1, 0], [COLORS.header2, 1]] 表示从位置 0(起点)的颜色 #23272E(深灰蓝)渐变到位置 1(终点)的颜色 #0F1218(近黑色)。这种深色渐变背景为头部的日晷图标提供了沉静的视觉基底,增强了古典氛围。
渐变背景在移动端 UI 设计中广泛应用,它比纯色背景更有层次感和深度感。通过调整 angle 和 colors,可以创建从简单到复杂的各种渐变效果。需要注意的是,linearGradient 会覆盖 backgroundColor,如果同时设置了两者,渐变效果优先生效。
9.5 边框与圆角:border 与 borderRadius
Column()
.width(110)
.height(110)
.borderRadius(55)
.backgroundColor(COLORS.stone)
.border({ width: 2, color: COLORS.line })
borderRadius 设置组件的圆角半径。当圆角半径等于组件宽高的一半时(如本例中 110/2 = 55),组件呈现为完美的圆形。这种技巧常用于创建头像、图标背景等圆形元素。
border 方法接受一个包含 width(边框宽度)、color(边框颜色)、radius(圆角半径)等属性的对象。在本例中,石盘圆设置了 2 像素宽的金色边框,增强了日晷石盘的边缘质感。
borderRadius 还可以分别设置四个角的圆角半径,格式为 { topLeft: number, topRight: number, bottomLeft: number, bottomRight: number }。在 bottomBar 中,底部导航栏设置了 { topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 },即顶部两个角为18像素圆角,底部两个角为直角。这种"上圆下方"的圆角设计是底部固定栏的常见样式,使导航栏与内容区域之间形成自然的视觉过渡。
9.6 响应式宽度:百分比与自适应
Column()
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
ArkTS 支持使用百分比字符串(如 '100%')作为宽度或高度值。百分比基于父容器的对应维度计算——width('100%') 意味着组件占据父容器的全部宽度。
在本应用中,几乎所有的容器组件都使用了 width('100%'),确保内容填满屏幕宽度。而子元素的宽度则通过 layoutWeight 或固定像素值来控制。这种"父容器100%宽度 + 子容器弹性/固定宽度"的组合,是一种简洁有效的响应式布局策略。
padding 设置组件的内边距,margin 设置组件的外边距。两者都接受数字(四边相同)或 { left, right, top, bottom } 格式的对象。在列表行中,padding(12) 使内容与卡片边缘之间有12像素的间距,margin({ bottom: 8 }) 使每行之间有8像素的间隔。这种内外边距的配合使用,创造了卡片式列表的视觉节奏感。
9.7 Scroll 滚动容器
Scroll() {
Column() {
this.pageHeader()
this.tabContent()
}
.width('100%')
.padding({ left: 14, right: 14, bottom: 12 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.backgroundColor(COLORS.bg)
Scroll 组件为其子内容提供滚动能力。scrollable(ScrollDirection.Vertical) 指定垂直滚动方向,这是移动端最常见的滚动模式。当子内容的高度超过 Scroll 组件本身的高度时,用户可以通过上下滑动来查看超出部分的内容。
Scroll 组件结合 layoutWeight(1) 使用,使其占据父 Column 中除了 bottomBar 之外的所有剩余空间。这种设计确保了内容区域始终充满屏幕(扣除底部导航栏的高度),而内容超出时可以滚动浏览。
9.8 ForEach 列表渲染
ForEach(this.dials, (item: DialItem, i: number) => {
this.dialRow(item, i)
}, (item: DialItem) => item.name)
ForEach 是 ArkTS 中的列表渲染组件,类似于 React 中的 map 或 Vue 中的 v-for。它接受三个参数:数据源数组、子项渲染函数、键值生成函数。
第一个参数 this.dials 是数据源数组。第二个参数是渲染函数,接收数组元素和索引作为参数,返回一个 UI 组件。第三个参数是键值生成函数,接收数组元素,返回一个唯一的字符串键值。
键值生成函数在 ForEach 的性能优化中起着关键作用。当数据源发生变化(增删元素)时,框架通过键值来判断哪些元素是新增的、哪些被删除了、哪些位置发生了变化。通过键值的 diff 比对,框架可以只更新发生变化的列表项,而非重新渲染整个列表。
在本应用中,dials 数组的键值生成函数返回 item.name(日晷名称),hands 数组返回 item.name(晷针名称),orders 数组也返回 item.name(订单名称)。使用名称作为键值在数据中名称唯一的前提下是合理的。如果存在重名项,可能会导致 diff 错误,此时应考虑使用唯一标识符(如 ID)作为键值。
9.9 布局系统的整体架构
上图展示了从 build 方法开始的完整 UI 树结构。最外层的 Stack 分为主内容区和弹窗层两部分。主内容区是一个 Column,上半部分是可滚动的 Scroll(包含头部和标签内容),下半部分是固定的 bottomBar。标签内容通过 curTab 的条件分支渲染八个模块之一,每个模块使用 ForEach 渲染列表行。弹窗层通过条件判断按需渲染,覆盖在主内容之上。
这种布局架构的设计优势在于层次清晰、职责分明:Stack 负责层叠管理,Column 负责垂直分区,Scroll 负责滚动能力,ForEach 负责列表渲染,条件语句负责内容切换。每一层布局容器都有明确的职责,组合起来构成了完整的页面结构。
十、动画系统深度解析
10.1 定时器驱动的呼吸动画
aboutToAppear(): void {
setInterval(() => {
this.breath = !this.breath;
}, 600);
}
aboutToAppear 是 @Component 的生命周期方法,在组件创建后、build 方法首次执行前被调用。它是执行初始化逻辑的理想位置——此时组件的状态变量已经初始化,但 UI 尚未渲染。
在本应用中,aboutToAppear 中启动了一个 600 毫秒间隔的定时器,不断翻转 breath 状态。这种"定时器 + 状态翻转"的模式是 ArkTS 中实现周期性动画的常用手段。与传统的动画 API(如 Android 的 ValueAnimator)不同,ArkTS 的动画更依赖于状态变化驱动——状态改变后,引用该状态的 UI 组件自动更新样式,配合 animation 属性实现平滑过渡。
10.2 animation 属性:过渡动画
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor(COLORS.sun)
.scale({ x: this.breath ? 1.5 : 1, y: this.breath ? 1.5 : 1 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
animation 属性为组件的样式变化添加过渡动画。它接受一个配置对象,包含以下关键属性:duration(动画时长,毫秒)、iterations(迭代次数,-1 表示无限循环)、playMode(播放模式,Alternate 表示交替往返)、curve(动画曲线,如 Curve.EaseInOut)。
在本例中,太阳光点的 scale(缩放)和 opacity(透明度)都引用了 this.breath 状态。当 breath 从 false 变为 true 时,scale 从 1 变为 1.5,opacity 从 0.6 变为 1。animation 属性使这一变化不是瞬间完成,而是在 600 毫秒内平滑过渡。iterations: -1 使动画无限循环,PlayMode.Alternate 使动画在正向和反向之间交替播放——先放大再缩小,先变亮再变暗。
这种"状态驱动 + animation 过渡"的动画模式,是 ArkTS 声明式动画的核心。开发者只需定义"状态 A 时的样式"和"状态 B 时的样式",animation 属性负责在两个状态之间创建平滑的过渡。无需手动控制动画的开始、暂停、结束——一切都由状态变化自动驱动。
10.3 呼吸动画的视觉编排
上图展示了呼吸动画的完整视觉编排。一个 breath 状态变量驱动了多个视觉元素的同步变化——太阳光点的缩放、晷针的角度、金点装饰的透明度、进度条的透明度、晷针行竖条的旋转角度。所有这些变化都通过各自的 animation 属性在 600 毫秒内同步过渡,形成协调统一的"呼吸"视觉效果。
这种"单状态驱动多元素"的动画编排方式,体现了 ArkTS 动画系统的简洁性。开发者只需维护一个布尔状态,就能控制整个页面的动效节奏。无需创建多个动画对象、无需手动同步动画时序——状态变化自动触发所有引用该状态的 UI 元素同步更新。
10.4 旋转动画:rotate
Column()
.width(3)
.height(40)
.borderRadius(2)
.backgroundColor(COLORS.bronze)
.rotate({ angle: handAngle(this.breath) })
rotate 属性使组件围绕中心点旋转指定角度。angle 以度为单位,正值表示顺时针旋转,负值表示逆时针旋转。handAngle 函数根据 breath 状态返回 -38 或 -26 度,使晷针在两个角度之间交替旋转。
在 handRow 构建器中,每行左侧的装饰竖条也使用了 rotate 动画:rotate({ angle: this.breath ? -30 : -18 }),配合 animation 属性在 600 毫秒内过渡。多个行中的竖条同步旋转,形成波浪般的视觉效果。
10.5 缩放动画:scale
Column()
.width(42)
.height(42)
.borderRadius(21)
.backgroundColor(COLORS.sun)
.opacity(this.breath ? 0.18 : 0.06)
.scale({ x: this.breath ? 1.5 : 1.1, y: this.breath ? 1.5 : 1.1 })
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
scale 属性使组件在 X 和 Y 方向上缩放。scale({ x: 1.5, y: 1.5 }) 表示放大到 1.5 倍,scale({ x: 1.1, y: 1.1 }) 表示放大到 1.1 倍。在呼吸动画中,太阳光晕在 1.1 倍和 1.5 倍之间交替缩放,配合透明度变化(0.06 到 0.18),模拟太阳光芒的呼吸效果。
scale 的 x 和 y 可以分别设置,实现非等比缩放。例如 scale({ x: 1.5, y: 1 }) 只在水平方向放大。但在本应用中,所有 scale 都使用等比缩放,保持元素的宽高比例不变。
十一、应用架构与交互流程
11.1 应用启动流程
上图展示了应用从启动到进入交互状态的完整流程。组件创建后首先执行 aboutToAppear 生命周期方法,启动呼吸动画的定时器。然后执行 build 方法,构建完整的 UI 树。初始状态下 curTab 为 0,显示日晷款式列表。底部导航栏渲染八个标签按钮,等待用户交互。
11.2 用户交互流程
上图展示了所有用户交互场景的处理流程。应用支持四类核心交互:标签切换、编辑晷针、删除订单、新增日晷。每类交互都遵循"事件触发 -> 状态更新 -> UI 自动渲染"的模式,充分体现了声明式 UI 的数据驱动特性。
标签切换是最简单的交互——用户点击底部导航栏的标签按钮,curTab 被更新为对应索引,tabContent 的条件分支自动切换到新模块的渲染逻辑。编辑晷针涉及两个状态变量的协同更新——selHand 记录操作对象,showEdit 控制弹窗显示。删除订单和新增日晷则涉及数组变异操作(splice),通过 @State 的数组追踪机制触发列表更新。
十二、技术对比表格
| 技术维度 | 传统命令式 UI (Android View) | ArkTS 声明式 UI | React 声明式 UI | Vue 声明式 UI |
|---|---|---|---|---|
| 状态管理 | 手动调用 invalidate/refresh | @State 自动追踪依赖并刷新 | useState + 虚拟DOM diff | reactive/ref + 响应式代理 |
| 列表渲染 | RecyclerView + Adapter | ForEach + 键值生成器自动 diff | map + key 属性 | v-for + key 属性 |
| 组件复用 | 继承 BaseView 或工具类 | @Builder 构建器 + 参数化 | 函数组件 + props | 单文件组件 + props |
| 动画系统 | 属性动画 ObjectAnimator | animation 属性 + 状态驱动 | CSS transition / framer-motion | transition / animate.css |
| 条件渲染 | setVisibility VISIBLE/GONE | if/else 条件语句直接渲染 | JSX 中 condition && Comp | v-if / v-show |
| 数据模型 | 普通 Java/Kotlin 类 | @Observed 装饰器自动追踪属性变化 | 普通类 + useState | reactive 包装 |
| 布局系统 | XML 布局 + 代码控制 | 链式调用 .width().height() | JSX + CSS | template + CSS |
| 双向绑定 | 需手动实现 TextWatcher | TextInput text + onChange 回调 | value + onChange | v-model 指令 |
| 样式管理 | styles.xml 资源文件 | 统一 COLORS 常量 + 链式调用 | CSS Modules / styled-components | scoped CSS / CSS variables |
| 生命周期 | onCreate/onStart/onResume | aboutToAppear/aboutToDisappear | useEffect/useLayoutEffect | beforeCreate/created/mounted |
| 跨平台能力 | 仅限 Android | HarmonyOS 多设备 | React Native / Web | Vue Web / Vue Native |
| 类型安全 | Java 强类型 | TypeScript 强类型 | TypeScript 可选 | TypeScript 可选 |
| 弹窗实现 | Dialog/AlertDialog | Stack 层叠 + 条件渲染 | Portal + 条件渲染 | Teleport + v-if |
| 性能优化 | ViewHolder 复用 | ForEach 键值 diff + 条件渲染懒加载 | React.memo + useMemo | v-once / shallowRef |
| 色彩管理 | colors.xml 资源引用 | ColorPalette 接口 + COLORS 常量 | theme 对象或 CSS variables | theme 对象或 CSS variables |
| 数据可视化 | 自定义 View 绘制 | Column 进度条 + 函数计算宽度 | div + inline-style | div + style binding |
| 弹性布局 | LinearLayout weight | layoutWeight 权重分配 | flex-grow | flex-grow |
| 层叠布局 | FrameLayout / RelativeLayout | Stack + position 绝对定位 | position: absolute | position: absolute |
| 渐变背景 | GradientDrawable XML | linearGradient 链式调用 | CSS linear-gradient | CSS linear-gradient |
| 事件处理 | setOnClickListener | .onClick 链式回调 | onClick 属性 | @click 指令 |
十三、结语:从日晷制作看声明式 UI 的文化意蕴
13.1 技术架构的回顾与总结
纵观全文的分析,这款日晷制作工坊应用虽然规模不大,却在技术实现上展现了 ArkTS 声明式 UI 范式的诸多核心特性。从最基础的 ColorPalette 接口与 COLORS 常量定义的色彩管理体系,到 TabMeta 与 TAB_LIST 构成的标签系统,再到八个 @Observed 数据模型类与对应的 @State 状态数组,每一个技术层次都经过精心设计,各司其职、环环相扣。
应用的 UI 构建体系以 @Entry 和 @Component 装饰的 DialPage 结构体为核心,通过十七个 @Builder 构建器方法,将页面拆分为头部图标、统计栏、章节标题、八种列表行、标签内容区、底部导航栏和三个弹窗等独立模块。每个构建器方法负责一个特定的 UI 区域,参数化设计使得列表行构建器能够通过传入不同的数据项实例,复用同一段 UI 模板渲染出整个列表。这种"构建器 + 参数化"的组件复用模式,在保持代码简洁的同时实现了 UI 的高度模块化。
状态管理方面,应用采用了 @State 与 @Observed 双层追踪机制。@State 负责追踪变量引用的变化——包括简单类型值的变更、数组元素的增删、对象引用的切换;@Observed 负责追踪对象属性的变化——使得 selHand.angle 的修改能够被框架感知并触发精确的局部 UI 更新。两层追踪机制协同工作,覆盖了从数组级到属性级的全部数据变化场景,确保了 UI 与数据的始终一致。
布局系统综合运用了 Stack 层叠布局、Column 与 Row 线性布局、layoutWeight 弹性权重分配、position 绝对定位、linearGradient 渐变背景、borderRadius 圆角与 border 边框等多种布局技术。这些技术手段的组合使用,构建了从页面级框架到卡片级细节的完整视觉层次。特别是 Stack 与 position 的配合使用,在页面头部实现了日晷图标的精确视觉构图——石盘、刻度线、太阳、晷针、阴影、光晕、金点等元素各居其位,共同构成了一幅微缩的日晷画面。
动画系统采用"定时器驱动状态翻转 + animation 属性自动过渡"的模式。一个 breath 布尔状态变量,以 600 毫秒为周期不断翻转,驱动了太阳光点的缩放与透明度变化、晷针的角度旋转、金点装饰的明暗交替、进度条的显隐过渡等多个视觉元素的同步动画。这种"单状态驱动多元素"的动画编排方式,既简洁又高效,充分展现了声明式 UI 在动画领域的优势——开发者只需声明"状态 A 时的样式"和"状态 B 时的样式",框架自动完成两个状态之间的平滑过渡。
13.2 声明式 UI 的哲学思考
从日晷制作这一古老工艺到 ArkTS 声明式 UI 这一现代技术,看似跨越了千年时空,实则蕴含着共同的哲学思想——“以简御繁”。
日晷的制作,是用一根晷针、一面石盘这样简单的物理元件,来度量日月运行的复杂规律。工匠不需要理解天体力学的全部数学推导,只需要掌握"影子方位与时间对应"这一核心规律,就能构建出精确的计时仪器。同样,声明式 UI 的核心理念,也是用简洁的状态声明来驱动复杂的界面变化。开发者不需要手动操作每一个 UI 元素的每一个属性,只需要定义"状态与界面"的对应关系,框架自动完成从数据变化到界面更新的全部中间过程。
这种"声明意图而非描述过程"的编程哲学,使得开发者能够将注意力集中在业务逻辑与用户体验上,而非底层实现细节上。正如日晷工匠将精力集中在天文计算的精确性与石盘雕刻的工艺性上,而非影子的物理运动机制上一样。
13.3 文化传承与技术创新的交融
这款日晷制作工坊应用的技术实现,也是文化传承与技术创新交融的一个缩影。应用的八个功能模块——日晷款式、晷针工艺、十二时辰、观象台、古历沿革、制晷匠人、传世古晷与订单管理——涵盖了日晷文化从理论到实践、从历史到当下的多个维度。而承载这些文化内容的,是 ArkTS 这一面向未来的移动应用开发框架。
技术为文化传承提供了新的载体与传播渠道。通过移动应用,日晷这一古老的计时仪器不再局限于博物馆的展柜或古籍的记载,而是以交互式、可视化的方式呈现在每个用户的指尖。用户不仅可以看到日晷的款式分类与工艺参数,还能通过编辑功能调整晷针倾角、通过订单管理模拟日晷的商业流通,在操作中理解日晷的制作工艺与使用方法。
同时,文化内容也为技术应用赋予了更深层的意义。一个纯粹的技术展示应用可能枯燥乏味,但当技术承载着日晷制作这一具有深厚文化底蕴的主题时,每一个 UI 组件、每一段状态逻辑都有了具体的文化指向——色彩体系呼应着青铜与汉白玉的质感,动画系统模拟着日影的呼吸变化,列表布局展现着匠人谱系与古晷传承。技术与文化相互映照,共同构成了一个既有工程深度又有文化温度的数字产品。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
interface ColorPalette {
bg: string;
cardBg: string;
header1: string;
header2: string;
bronze: string;
stone: string;
line: string;
sun: string;
gold: string;
shadow: 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: '#F1F0EC',
cardBg: '#FFFFFF',
header1: '#23272E',
header2: '#0F1218',
bronze: '#8A6D3B',
stone: '#6E6A5E',
line: '#C9A86A',
sun: '#F5A623',
gold: '#D9A441',
shadow: '#4A4A4A',
title: '#F3EAD6',
sub: '#B9B2A2',
text1: '#23272E',
text2: '#5F635C',
text3: '#94968E',
accent: '#8A6D3B',
hot: '#C0392B',
cool: '#1F5FA8',
danger: '#D9534F',
tabBg: '#23272E',
tabOn: '#D9A441',
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: '🛠️' },
{ label: '古晷', icon: '🏛️' },
{ label: '订单', icon: '📦' }
];
const ROW1_IDX: number[] = [0, 1, 2, 3];
const ROW2_IDX: number[] = [4, 5, 6, 7];
const HOUR_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const DOT_IDX: number[] = [0, 1, 2, 3, 4];
@Observed
export class DialItem {
name: string;
type: string;
size: string;
stone: string;
price: number;
constructor(name: string, type: string, size: string, stone: string, price: number) {
this.name = name;
this.type = type;
this.size = size;
this.stone = stone;
this.price = price;
}
}
@Observed
export class HandItem {
name: string;
angle: number;
length: number;
material: string;
precision: number;
constructor(name: string, angle: number, length: number, material: string, precision: number) {
this.name = name;
this.angle = angle;
this.length = length;
this.material = material;
this.precision = precision;
}
}
@Observed
export class ShichenItem {
name: string;
period: string;
uses: string;
animal: string;
hot: number;
constructor(name: string, period: string, uses: string, animal: string, hot: number) {
this.name = name;
this.period = period;
this.uses = uses;
this.animal = animal;
this.hot = hot;
}
}
@Observed
export class ObservatoryItem {
name: string;
city: string;
age: string;
instruments: number;
level: string;
constructor(name: string, city: string, age: string, instruments: number, level: string) {
this.name = name;
this.city = city;
this.age = age;
this.instruments = instruments;
this.level = level;
}
}
@Observed
export class CalendarItem {
name: string;
kind: string;
days: number;
era: string;
use: string;
constructor(name: string, kind: string, days: number, era: string, use: string) {
this.name = name;
this.kind = kind;
this.days = days;
this.era = era;
this.use = use;
}
}
@Observed
export class SundialMakerItem {
name: string;
grade: string;
years: number;
works: number;
skill: number;
constructor(name: string, grade: string, years: number, works: number, skill: number) {
this.name = name;
this.grade = grade;
this.years = years;
this.works = works;
this.skill = skill;
}
}
@Observed
export class AncientDialItem {
name: string;
age: string;
origin: string;
value: number;
rank: string;
constructor(name: string, age: string, origin: string, value: number, rank: string) {
this.name = name;
this.age = age;
this.origin = origin;
this.value = value;
this.rank = rank;
}
}
@Observed
export class DialOrderItem {
name: string;
buyer: string;
amount: number;
count: number;
date: string;
constructor(name: string, buyer: string, amount: number, count: number, date: string) {
this.name = name;
this.buyer = buyer;
this.amount = amount;
this.count = count;
this.date = date;
}
}
function hourRotate(i: number): number {
return 90 + i * 30;
}
function hourLen(i: number): number {
return 34 + (i % 4) * 3;
}
function dotX(i: number): number {
return 22 + i * 17;
}
function dotY(i: number): number {
return 88 + (i % 2) * 10;
}
function dotR(i: number): number {
return 3 + (i % 3);
}
function handAngle(breath: boolean): number {
if (breath) {
return -38;
}
return -26;
}
function precisionBarW(p: number): number {
return 16 + Math.min(p, 100) * 1.35;
}
function precisionColor(p: number): string {
if (p >= 80) {
return COLORS.hot;
}
if (p >= 60) {
return COLORS.gold;
}
return COLORS.cool;
}
function hotBarW(hot: number): number {
return 18 + Math.min(hot, 100) * 1.35;
}
function skillBarW(s: number): number {
return 16 + Math.min(s, 100) * 1.35;
}
function orderBarH(amount: number): number {
return 24 + Math.min(amount, 50000) / 500;
}
function gradeColor(grade: string): string {
if (grade.indexOf('大师') >= 0) {
return COLORS.hot;
}
if (grade.indexOf('名师') >= 0) {
return COLORS.gold;
}
if (grade.indexOf('匠师') >= 0) {
return COLORS.accent;
}
return COLORS.cool;
}
function rankColor(rank: string): string {
if (rank.indexOf('国宝') >= 0) {
return COLORS.hot;
}
if (rank.indexOf('名晷') >= 0) {
return COLORS.gold;
}
return COLORS.accent;
}
@Entry
@Component
struct DialPage {
@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State selHand: HandItem | null = null;
@State selOrder: DialOrderItem | null = null;
@State formName: string = '';
@State formType: string = '';
@State formStone: string = '';
@State formPrice: string = '';
@State dials: DialItem[] = [
new DialItem('故宫赤道日晷', '赤道式', '径 120cm', '汉白玉', 180000),
new DialItem('观象台地平日晷', '地平式', '径 90cm', '青石', 96000),
new DialItem('庭院石晷', '赤道式', '径 60cm', '青石', 32000),
new DialItem('铜质便携晷', '折叠式', '径 18cm', '青铜', 8800),
new DialItem('汉白玉园晷', '花园式', '径 80cm', '汉白玉', 56000),
new DialItem('紫铜晷面', '水平式', '径 40cm', '紫铜', 15000),
new DialItem('书院石晷', '赤道式', '径 70cm', '花岗岩', 26000),
new DialItem('罗盘式晷', '方位式', '径 24cm', '黄铜', 6800),
new DialItem('八角石晷', '八角式', '径 66cm', '青白石', 42000),
new DialItem('镀金日晷', '装饰式', '径 30cm', '鎏金铜', 22000),
new DialItem('教堂垂直晷', '垂直式', '径 55cm', '砂岩', 36000),
new DialItem('碑式晷', '碑式', '高 100cm', '汉白玉', 78000)
];
@State hands: HandItem[] = [
new HandItem('北极晷针', 32, 28, '青铜', 92),
new HandItem('直指晷针', 26, 24, '紫铜', 88),
new HandItem('折臂晷针', 38, 30, '黄铜', 85),
new HandItem('斜插晷针', 30, 26, '铁木', 80),
new HandItem('双针式', 45, 34, '青铜', 76),
new HandItem('镂空晷针', 24, 22, '鎏金', 90),
new HandItem('石质晷针', 28, 20, '青石', 82),
new HandItem('三棱晷针', 33, 27, '紫铜', 86)
];
@State shichens: ShichenItem[] = [
new ShichenItem('子时', '23:00-01:00', '夜半', '鼠', 88),
new ShichenItem('丑时', '01:00-03:00', '鸡鸣', '牛', 76),
new ShichenItem('寅时', '03:00-05:00', '平旦', '虎', 82),
new ShichenItem('卯时', '05:00-07:00', '日出', '兔', 92),
new ShichenItem('辰时', '07:00-09:00', '食时', '龙', 90),
new ShichenItem('巳时', '09:00-11:00', '隅中', '蛇', 84),
new ShichenItem('午时', '11:00-13:00', '日中', '马', 96),
new ShichenItem('未时', '13:00-15:00', '日昳', '羊', 80),
new ShichenItem('申时', '15:00-17:00', '晡时', '猴', 78),
new ShichenItem('酉时', '17:00-19:00', '日入', '鸡', 86),
new ShichenItem('戌时', '19:00-21:00', '黄昏', '狗', 72),
new ShichenItem('亥时', '21:00-23:00', '人定', '猪', 70)
];
@State observatories: ObservatoryItem[] = [
new ObservatoryItem('北京古观象台', '北京', '明正统', 8, '甲级'),
new ObservatoryItem('登封观星台', '河南登封', '元至元', 6, '甲级'),
new ObservatoryItem('南京紫金山天文台', '南京', '民国', 12, '甲级'),
new ObservatoryItem('周公测景台', '河南登封', '西周', 3, '甲级'),
new ObservatoryItem('阆中观象台', '四川阆中', '汉代', 4, '乙级'),
new ObservatoryItem('洛阳灵台', '河南洛阳', '东汉', 5, '甲级'),
new ObservatoryItem('杭州候潮门观象台', '杭州', '南宋', 3, '乙级'),
new ObservatoryItem('西安司天台', '西安', '唐代', 4, '乙级')
];
@State calendars: CalendarItem[] = [
new CalendarItem('夏历', '阴阳合历', 354, '夏', '二十四节气'),
new CalendarItem('殷历', '阴阳合历', 355, '商', '置闰法'),
new CalendarItem('周历', '阴阳合历', 354, '周', '岁首建子'),
new CalendarItem('颛顼历', '四分历', 365, '秦', '十月岁首'),
new CalendarItem('太初历', '八十一分历', 365, '汉', '以正月为岁首'),
new CalendarItem('大明历', '祖冲之历', 365, '南朝', '岁差'),
new CalendarItem('大衍历', '一行历', 365, '唐', '定气法'),
new CalendarItem('授时历', '郭守敬历', 365, '元', '回归年 365.2425')
];
@State makers: SundialMakerItem[] = [
new SundialMakerItem('郭守敬', '天文学家', 42, 20, 99),
new SundialMakerItem('张衡', '浑天大师', 38, 16, 96),
new SundialMakerItem('一行', '僧历法家', 35, 14, 95),
new SundialMakerItem('祖冲之', '算术大师', 30, 12, 93),
new SundialMakerItem('落下闳', '太初历家', 28, 10, 90),
new SundialMakerItem('南宫说', '测景名家', 24, 8, 85),
new SundialMakerItem('耶律楚材', '星历家', 22, 7, 84),
new SundialMakerItem('徐光启', '历法家', 20, 6, 88)
];
@State ancients: AncientDialItem[] = [
new AncientDialItem('汉白玉赤道日晷', '清', '北京', 1200000, '国宝'),
new AncientDialItem('青铜刻度晷', '汉', '洛阳', 860000, '国宝'),
new AncientDialItem('观象台地平晷', '明', '北京', 560000, '名晷'),
new AncientDialItem('紫铜晷面', '元', '登封', 320000, '名晷'),
new AncientDialItem('八角石晷', '明', '南京', 240000, '名晷'),
new AncientDialItem('象牙晷仪', '清', '广州', 680000, '国宝'),
new AncientDialItem('鎏金晷针', '明', '西安', 180000, '名晷'),
new AncientDialItem('石质立晷', '宋', '开封', 150000, '名晷')
];
@State orders: DialOrderItem[] = [
new DialOrderItem('庭院石晷', '园林公司', 64000, 2, '2026-08'),
new DialOrderItem('汉白玉园晷', '私人宅邸', 56000, 1, '2026-07'),
new DialOrderItem('铜质便携晷', '文博商店', 44000, 5, '2026-08'),
new DialOrderItem('书院石晷', '高校', 52000, 2, '2026-06'),
new DialOrderItem('紫铜晷面', '收藏家', 30000, 2, '2026-07'),
new DialOrderItem('镀金日晷', '地产公司', 44000, 2, '2026-05'),
new DialOrderItem('罗盘式晷', '户外品牌', 20400, 3, '2026-08'),
new DialOrderItem('八角石晷', '公园管理处', 42000, 1, '2026-06'),
new DialOrderItem('碑式晷', '纪念馆', 78000, 1, '2026-04'),
new DialOrderItem('教堂垂直晷', '文化机构', 36000, 1, '2026-05')
];
aboutToAppear(): void {
setInterval(() => {
this.breath = !this.breath;
}, 600);
}
@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]]
})
Column()
.width(110)
.height(110)
.borderRadius(55)
.backgroundColor(COLORS.stone)
.border({ width: 2, color: COLORS.line })
.position({ x: 16, y: 12 })
ForEach(HOUR_IDX, (i: number) => {
Column()
.width(1.5)
.height(hourLen(i))
.backgroundColor(COLORS.line)
.rotate({ angle: hourRotate(i) })
.position({ x: 70, y: 67 })
}, (i: number) => 'h' + i)
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor(COLORS.sun)
.scale({ x: this.breath ? 1.5 : 1, y: this.breath ? 1.5 : 1 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
.position({ x: 65, y: 62 })
Column()
.width(4)
.height(40)
.borderRadius(2)
.backgroundColor(COLORS.bronze)
.rotate({ angle: handAngle(this.breath) })
.position({ x: 69, y: 60 })
Column()
.width(3)
.height(34)
.borderRadius(1.5)
.backgroundColor(COLORS.shadow)
.opacity(0.45)
.rotate({ angle: handAngle(this.breath) + 12 })
.position({ x: 74, y: 66 })
Column()
.width(42)
.height(42)
.borderRadius(21)
.backgroundColor(COLORS.sun)
.opacity(this.breath ? 0.18 : 0.06)
.scale({ x: this.breath ? 1.5 : 1.1, y: this.breath ? 1.5 : 1.1 })
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
.position({ x: 49, y: 46 })
ForEach(DOT_IDX, (i: number) => {
Column()
.width(dotR(i) * 2)
.height(dotR(i) * 2)
.borderRadius(dotR(i))
.backgroundColor(COLORS.gold)
.opacity(this.breath ? 0.9 : 0.4)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
.position({ x: dotX(i), y: dotY(i) })
}, (i: number) => 'd' + i)
Column() {
Text('日晷坊')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('晷影流年 · 光阴寸金')
.fontSize(12)
.fontColor(COLORS.sub)
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.position({ x: 158, y: 24 })
Column() {
Text('赤道式 · 地平式 · 垂直式')
.fontSize(12)
.fontColor(COLORS.gold)
Row() {
Text('午时')
.fontSize(11)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.hot)
.borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text('日影最短')
.fontSize(11)
.fontColor(COLORS.sub)
.margin({ left: 8 })
Text('十二时辰')
.fontSize(11)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.cool)
.borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 8 })
}
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.position({ x: 158, y: 82 })
}
.width('100%')
.height(136)
Row() {
Column() {
Text('12')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('晷制款式')
.fontSize(10)
.fontColor(COLORS.sub)
}
.layoutWeight(1)
Column() {
Text('12')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('十二时辰')
.fontSize(10)
.fontColor(COLORS.sub)
}
.layoutWeight(1)
Column() {
Text('8')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('观象台')
.fontSize(10)
.fontColor(COLORS.sub)
}
.layoutWeight(1)
Column() {
Text('8')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('传世古晷')
.fontSize(10)
.fontColor(COLORS.sub)
}
.layoutWeight(1)
}
.width('100%')
.padding({ top: 12, bottom: 12, left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16, bottomLeft: 0, bottomRight: 0 })
.margin({ top: 10 })
}
.width('100%')
}
@Builder
sectionTitle(icon: string, title: string, sub: string) {
Row() {
Text(icon)
.fontSize(15)
Text(title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.margin({ left: 6 })
Column().layoutWeight(1)
Text(sub)
.fontSize(11)
.fontColor(COLORS.text3)
}
.width('100%')
.padding({ left: 4, right: 4, bottom: 10 })
}
@Builder
dialRow(item: DialItem, i: number) {
Row() {
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.text1)
Text(item.type + ' · ' + item.size + ' · ' + item.stone)
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('¥' + item.price)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(item.type)
.fontSize(10)
.fontColor(COLORS.gold)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
.margin({ right: 10 })
Text('编')
.fontSize(12)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.bronze)
.borderRadius(10)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.onClick(() => {
this.showEdit = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
@Builder
handRow(item: HandItem, i: number) {
Column() {
Row() {
Column()
.width(3)
.height(20)
.borderRadius(1.5)
.backgroundColor(COLORS.bronze)
.rotate({ angle: this.breath ? -30 : -18 })
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.text1)
Text(item.material + ' · 倾角 ' + item.angle + '°')
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(item.precision + '%')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(precisionColor(item.precision))
Text(item.length + 'cm')
.fontSize(10)
.fontColor(COLORS.text3)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
.margin({ right: 10 })
Text('改')
.fontSize(12)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.gold)
.borderRadius(10)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.onClick(() => {
this.selHand = item;
this.showEdit = true;
})
}
.width('100%')
Row() {
Text('精度')
.fontSize(10)
.fontColor(COLORS.text2)
Column()
.width(precisionBarW(item.precision))
.height(8)
.borderRadius(4)
.backgroundColor(precisionColor(item.precision))
.margin({ left: 8 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
@Builder
shichenRow(item: ShichenItem, i: number) {
Column() {
Row() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.animal)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.cool)
.borderRadius(8)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ left: 8 })
Column().layoutWeight(1)
Text(item.period)
.fontSize(11)
.fontColor(COLORS.text3)
}
.width('100%')
Row() {
Text('使用热度')
.fontSize(10)
.fontColor(COLORS.text2)
Column()
.width(hotBarW(item.hot))
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.gold)
.margin({ left: 8 })
Column().layoutWeight(1)
Text(item.uses)
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
@Builder
obsRow(item: ObservatoryItem, i: number) {
Row() {
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.city + ' · 建于' + item.age + ' · ' + item.level)
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text(item.instruments + ' 件')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.cool)
Text('天文仪器')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
@Builder
calRow(item: CalendarItem, i: number) {
Column() {
Row() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.kind)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.accent)
.borderRadius(8)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ left: 8 })
Column().layoutWeight(1)
Text(item.era)
.fontSize(11)
.fontColor(COLORS.text3)
}
.width('100%')
Row() {
Text(item.days + ' 日')
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS.hot)
Column().layoutWeight(1)
Text(item.use)
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
@Builder
makerRow(item: SundialMakerItem, i: number) {
Column() {
Row() {
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.grade + ' · ' + item.years + ' 年')
.fontSize(11)
.fontColor(gradeColor(item.grade))
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text(item.works + ' 座')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('传世晷仪')
.fontSize(10)
.fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row() {
Text('天文造诣')
.fontSize(10)
.fontColor(COLORS.text2)
Column()
.width(skillBarW(item.skill))
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.sun)
.margin({ left: 8 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
Text(item.skill + ' 分')
.fontSize(10)
.fontColor(COLORS.hot)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
@Builder
ancientRow(item: AncientDialItem, i: number) {
Row() {
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.age + ' · ' + item.origin)
.fontSize(11)
.fontColor(COLORS.text2)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('¥' + item.value)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.hot)
Text(item.rank)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(rankColor(item.rank))
.borderRadius(8)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
@Builder
orderRow(item: DialOrderItem, i: number) {
Column() {
Row() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.buyer)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.cool)
.borderRadius(8)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ left: 8 })
Column().layoutWeight(1)
Text(item.date)
.fontSize(11)
.fontColor(COLORS.text3)
}
.width('100%')
Row() {
Text('金额 ¥' + item.amount)
.fontSize(11)
.fontColor(COLORS.text2)
Column()
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.hot)
.margin({ left: 8 })
.opacity(this.breath ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
Text(item.count + ' 座')
.fontSize(10)
.fontColor(COLORS.text3)
.margin({ right: 8 })
Text('删')
.fontSize(12)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.onClick(() => {
this.selOrder = item;
this.showDel = true;
})
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
}
@Builder
tabContent() {
if (this.curTab === 0) {
Column() {
this.sectionTitle('☀️', '日晷款式', '共 ' + this.dials.length + ' 座')
ForEach(this.dials, (item: DialItem, i: number) => {
this.dialRow(item, i)
}, (item: DialItem) => item.name)
}
.width('100%')
} else if (this.curTab === 1) {
Column() {
this.sectionTitle('📏', '晷针工艺', '影随日移')
ForEach(this.hands, (item: HandItem, i: number) => {
this.handRow(item, i)
}, (item: HandItem) => item.name)
}
.width('100%')
} else if (this.curTab === 2) {
Column() {
this.sectionTitle('🕐', '十二时辰', '古人之钟')
ForEach(this.shichens, (item: ShichenItem, i: number) => {
this.shichenRow(item, i)
}, (item: ShichenItem) => item.name)
}
.width('100%')
} else if (this.curTab === 3) {
Column() {
this.sectionTitle('🔭', '观象高台', '测天之场')
ForEach(this.observatories, (item: ObservatoryItem, i: number) => {
this.obsRow(item, i)
}, (item: ObservatoryItem) => item.name)
}
.width('100%')
} else if (this.curTab === 4) {
Column() {
this.sectionTitle('📅', '古历沿革', '授时之要')
ForEach(this.calendars, (item: CalendarItem, i: number) => {
this.calRow(item, i)
}, (item: CalendarItem) => item.name)
}
.width('100%')
} else if (this.curTab === 5) {
Column() {
this.sectionTitle('🛠️', '制晷匠人', '天文名家')
ForEach(this.makers, (item: SundialMakerItem, i: number) => {
this.makerRow(item, i)
}, (item: SundialMakerItem) => item.name)
}
.width('100%')
} else if (this.curTab === 6) {
Column() {
this.sectionTitle('🏛️', '传世古晷', '估价榜')
ForEach(this.ancients, (item: AncientDialItem, i: number) => {
this.ancientRow(item, i)
}, (item: AncientDialItem) => item.name)
}
.width('100%')
} else {
Column() {
this.sectionTitle('📦', '最新订单', '共 ' + this.orders.length + ' 单')
ForEach(this.orders, (item: DialOrderItem, i: number) => {
this.orderRow(item, i)
}, (item: DialOrderItem) => item.name)
}
.width('100%')
}
}
@Builder
bottomItem(i: number) {
Column() {
Text(TAB_LIST[i].icon)
.fontSize(17)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontColor(this.curTab === i ? COLORS.gold : COLORS.sub)
.margin({ top: 2 })
}
.width('25%')
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.curTab === i ? '#33383F' : 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.formType, placeholder: '如:赤道式' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formType = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Column() {
Text('石材')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formStone, placeholder: '如:青石' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formStone = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Column() {
Text('售价')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formPrice, placeholder: '如:26000' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formPrice = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Row() {
Text('取消')
.fontSize(14)
.fontColor(COLORS.text2)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#EFE9DD')
.borderRadius(12)
.onClick(() => {
this.showAdd = false;
})
Text('保存')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(12)
.margin({ left: 12 })
.onClick(() => {
this.dials.splice(0, 0, new DialItem(this.formName, this.formType, '径 60cm', this.formStone, 26000));
this.showAdd = false;
})
}
.width('100%')
.margin({ top: 18 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
}
}
@Builder
editModal() {
if (this.showEdit) {
Stack() {
this.modalOverlay(() => {
this.showEdit = 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)
Text(this.selHand === null ? '—' : this.selHand.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 14 })
Column() {
Text('当前倾角')
.fontSize(12)
.fontColor(COLORS.text2)
Text(this.selHand === null ? '—' : (this.selHand.angle + '°'))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(precisionColor(this.selHand === null ? 60 : this.selHand.precision))
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Row() {
Text('仰角 +2°')
.fontSize(13)
.fontColor(COLORS.cardBg)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.padding({ top: 9, bottom: 9 })
.backgroundColor(COLORS.hot)
.borderRadius(10)
.onClick(() => {
if (this.selHand !== null) {
this.selHand.angle = this.selHand.angle + 2;
}
this.showEdit = false;
})
Text('仰角 -2°')
.fontSize(13)
.fontColor(COLORS.cardBg)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.padding({ top: 9, bottom: 9 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selHand !== null) {
this.selHand.angle = this.selHand.angle - 2;
}
this.showEdit = false;
})
}
.width('100%')
.margin({ top: 18 })
Text('关闭')
.fontSize(14)
.fontColor(COLORS.text2)
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 10, bottom: 10 })
.backgroundColor('#EFE9DD')
.borderRadius(12)
.margin({ top: 12 })
.onClick(() => {
this.showEdit = false;
})
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
}
}
@Builder
delModal() {
if (this.showDel) {
Stack() {
this.modalOverlay(() => {
this.showDel = false;
})
Column() {
Text('删除订单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('此操作不可恢复,请确认')
.fontSize(12)
.fontColor(COLORS.text3)
.margin({ top: 6 })
Column() {
Text('将删除:')
.fontSize(12)
.fontColor(COLORS.text2)
Text(this.selOrder === null ? '—' : (this.selOrder.name + ' · ' + this.selOrder.buyer))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(12)
.backgroundColor('#FAEDEC')
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('再想想')
.fontSize(14)
.fontColor(COLORS.text2)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#EFE9DD')
.borderRadius(12)
.onClick(() => {
this.showDel = false;
})
Text('确认删除')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(12)
.margin({ left: 12 })
.onClick(() => {
if (this.selOrder !== null) {
this.orders.splice(this.orders.indexOf(this.selOrder), 1);
}
this.showDel = false;
})
}
.width('100%')
.margin({ top: 18 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
}
}
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)
}
}
13.4 总结

展望未来,ArkTS 声明式 UI 范式还有许多值得探索的方向。在状态管理方面,当应用规模增长到一定程度时,单一组件的集中式状态管理将面临挑战,需要引入 @Provide/@Consume 跨组件状态传递、AppStorage 应用级状态管理、PersistentStorage 持久化存储等更高级的状态管理手段。在性能优化方面,可以利用 LazyForEach 替代 ForEach 实现列表的懒加载,减少首屏渲染压力;可以利用 @Reusable 装饰器实现组件的复用,减少重复创建的开销。
在动画与交互方面,除了当前的状态驱动动画外,ArkTS 还提供了 animateTo 闭包动画、transition 组件转场动画等更丰富的动画能力。结合传感器数据,还可以实现跟随设备重力感应的动态视觉效果,使日晷图标能够根据手机倾斜角度实时调整晷针投影方向,进一步模拟真实日晷的物理行为。
更多推荐




所有评论(0)