引言:鸿蒙技术栈深度背景

HarmonyOS 6.1.1 作为华为分布式操作系统的最新里程碑版本,在声明式UI范式、ArkTS语言生态、分布式软总线能力以及原子化服务框架等多个维度实现了质的飞跃。基于HarmonyOS API 24的应用开发体系,已经从最初的"可用"阶段全面迈入"高效、优雅、可扩展"的成熟阶段。对于开发者而言,理解 ArkTS 语言特性与声明式UI开发范式的内在逻辑,是构建高质量 HarmonyOS 应用的核心前提。
从技术演进的视角来看,HarmonyOS 的 UI 框架经历了从命令式到声明式的范式迁移。传统的命令式UI开发模式要求开发者手动管理视图树的创建、更新和销毁,每一次状态变化都需要精确控制 DOM 的增删改操作。而声明式UI范式则彻底改变了这一开发心智模型------开发者只需描述"UI应该是什么样子",框架会自动负责"如何达到这个样子"。这一转变不仅大幅降低了UI开发的认知负荷,更从根本上提升了代码的可维护性和可预测性。
ArkTS 作为 HarmonyOS 的主力开发语言,在 TypeScript 的基础上进行了深度扩展和优化。它保留了 TypeScript 强大的类型系统和现代语言特性,同时引入了装饰器(Decorator)、状态管理注解、自定义组件等声明式UI专属能力。基于HarmonyOS API 24,ArkTS 进一步增强了组件化开发能力,提供了更丰富的内置组件、更灵活的布局系统以及更高效的渲染引擎。
在 HarmonyOS 6.1.1 版本中,ArkUI 框架的性能优化成果尤为显著。框架层面引入了更细粒度的组件更新机制,通过精确的状态依赖追踪,实现了"只更新需要更新的组件"这一目标。同时,布局引擎的计算效率也得到了大幅提升,复杂嵌套布局的渲染帧率在低端设备上也能保持稳定流畅。这些底层优化为开发者构建复杂、精致的用户界面提供了坚实的性能保障。
本文将以一个完整的"复古游戏收藏馆"单文件应用为案例,从类型系统、设计令牌、数据层、辅助函数、主架构、六大Tab页面、弹窗交互等多个维度,深度剖析基于HarmonyOS API 24的 ArkTS 声明式UI开发实践。这是一个集商品展示、数据筛选、图表可视化、表单交互于一体的综合性应用,涵盖了移动端应用开发中常见的绝大多数场景。通过对这个应用的逐行拆解,我们将不仅掌握 ArkTS 语法本身,更能深入理解声明式UI的设计哲学和工程化实践方法。
一、整体架构概览

1.1 应用架构层次
复古游戏收藏馆应用采用了经典的单文件架构设计,所有代码集中在一个文件中,这对于中小规模的应用原型开发具有极高的效率优势。虽然是单文件实现,但代码的组织结构却非常清晰,按照"类型定义-枚举常量-设计令牌-业务数据-辅助函数-主组件-各Tab组件"的顺序自上而下排列,形成了良好的可读性和可维护性。
从架构分层的角度来看,整个应用可以划分为以下几个层次:
- 数据模型层:定义了所有业务实体的 TypeScript 接口,包括掌机、卡带、街机、周边、订单、收藏、消费记录等数据结构
- 常量配置层:包含设计令牌(颜色系统)、品牌列表、分类列表、枚举类型等静态配置
- 业务数据层:以常量数组形式存储的模拟业务数据,涵盖各类商品和用户数据
- 工具函数层:提供数据筛选、排序、统计、格式化等纯函数工具
- UI组件层:包含主应用组件和六个Tab页面组件,以及各类弹窗和子组件
- 状态管理层:通过 @State 装饰器实现的组件级状态管理
#mermaid-svg-APG05ZYDqBFK8bBt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-APG05ZYDqBFK8bBt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-APG05ZYDqBFK8bBt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-APG05ZYDqBFK8bBt .error-icon{fill:#552222;}#mermaid-svg-APG05ZYDqBFK8bBt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-APG05ZYDqBFK8bBt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-APG05ZYDqBFK8bBt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-APG05ZYDqBFK8bBt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-APG05ZYDqBFK8bBt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-APG05ZYDqBFK8bBt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-APG05ZYDqBFK8bBt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-APG05ZYDqBFK8bBt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-APG05ZYDqBFK8bBt .marker.cross{stroke:#333333;}#mermaid-svg-APG05ZYDqBFK8bBt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-APG05ZYDqBFK8bBt p{margin:0;}#mermaid-svg-APG05ZYDqBFK8bBt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-APG05ZYDqBFK8bBt .cluster-label text{fill:#333;}#mermaid-svg-APG05ZYDqBFK8bBt .cluster-label span{color:#333;}#mermaid-svg-APG05ZYDqBFK8bBt .cluster-label span p{background-color:transparent;}#mermaid-svg-APG05ZYDqBFK8bBt .label text,#mermaid-svg-APG05ZYDqBFK8bBt span{fill:#333;color:#333;}#mermaid-svg-APG05ZYDqBFK8bBt .node rect,#mermaid-svg-APG05ZYDqBFK8bBt .node circle,#mermaid-svg-APG05ZYDqBFK8bBt .node ellipse,#mermaid-svg-APG05ZYDqBFK8bBt .node polygon,#mermaid-svg-APG05ZYDqBFK8bBt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-APG05ZYDqBFK8bBt .rough-node .label text,#mermaid-svg-APG05ZYDqBFK8bBt .node .label text,#mermaid-svg-APG05ZYDqBFK8bBt .image-shape .label,#mermaid-svg-APG05ZYDqBFK8bBt .icon-shape .label{text-anchor:middle;}#mermaid-svg-APG05ZYDqBFK8bBt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-APG05ZYDqBFK8bBt .rough-node .label,#mermaid-svg-APG05ZYDqBFK8bBt .node .label,#mermaid-svg-APG05ZYDqBFK8bBt .image-shape .label,#mermaid-svg-APG05ZYDqBFK8bBt .icon-shape .label{text-align:center;}#mermaid-svg-APG05ZYDqBFK8bBt .node.clickable{cursor:pointer;}#mermaid-svg-APG05ZYDqBFK8bBt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-APG05ZYDqBFK8bBt .arrowheadPath{fill:#333333;}#mermaid-svg-APG05ZYDqBFK8bBt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-APG05ZYDqBFK8bBt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-APG05ZYDqBFK8bBt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-APG05ZYDqBFK8bBt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-APG05ZYDqBFK8bBt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-APG05ZYDqBFK8bBt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-APG05ZYDqBFK8bBt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-APG05ZYDqBFK8bBt .cluster text{fill:#333;}#mermaid-svg-APG05ZYDqBFK8bBt .cluster span{color:#333;}#mermaid-svg-APG05ZYDqBFK8bBt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-APG05ZYDqBFK8bBt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-APG05ZYDqBFK8bBt rect.text{fill:none;stroke-width:0;}#mermaid-svg-APG05ZYDqBFK8bBt .icon-shape,#mermaid-svg-APG05ZYDqBFK8bBt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-APG05ZYDqBFK8bBt .icon-shape p,#mermaid-svg-APG05ZYDqBFK8bBt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-APG05ZYDqBFK8bBt .icon-shape .label rect,#mermaid-svg-APG05ZYDqBFK8bBt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-APG05ZYDqBFK8bBt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-APG05ZYDqBFK8bBt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-APG05ZYDqBFK8bBt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 应用入口 RetroArcadeApp
数据模型层 Interface
常量配置层 Constants
业务数据层 Data
工具函数层 Utils
UI组件层 Components
主应用组件
首页 Tab
掌机 Tab
卡带 Tab
街机 Tab
周边 Tab
我的 Tab
状态管理层 State
1.2 页面导航结构

应用采用底部Tab栏导航模式,共设有六个一级页面。这种导航结构在移动端应用中非常常见,能够让用户在主要功能模块之间快速切换。每个Tab页面对应一个独立的 @Component 装饰的组件,通过主组件中的 activeTab 状态变量来控制当前显示的页面。
六个Tab页面分别承担不同的业务功能:
- 首页:应用的主入口,展示核心数据概览、经典机型、热销榜单和快捷入口
- 掌机:掌机收藏库,支持品牌筛选、双列网格展示、续航对比和销量柱状图
- 卡带:卡带排行榜,按稀有度排序,支持类型筛选和年份分布统计
- 街机:街机厅展示,支持游戏类型筛选、横向大卡展开和框体尺寸展示
- 周边:周边商店,以三列色卡网格形式展示,支持热度排行和品类分布
- 我的:个人中心,包含会员卡片、消费统计、订单列表和收藏管理
#mermaid-svg-11GnGCgpOFYnntpi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-11GnGCgpOFYnntpi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-11GnGCgpOFYnntpi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-11GnGCgpOFYnntpi .error-icon{fill:#552222;}#mermaid-svg-11GnGCgpOFYnntpi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-11GnGCgpOFYnntpi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-11GnGCgpOFYnntpi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-11GnGCgpOFYnntpi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-11GnGCgpOFYnntpi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-11GnGCgpOFYnntpi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-11GnGCgpOFYnntpi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-11GnGCgpOFYnntpi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-11GnGCgpOFYnntpi .marker.cross{stroke:#333333;}#mermaid-svg-11GnGCgpOFYnntpi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-11GnGCgpOFYnntpi p{margin:0;}#mermaid-svg-11GnGCgpOFYnntpi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-11GnGCgpOFYnntpi .cluster-label text{fill:#333;}#mermaid-svg-11GnGCgpOFYnntpi .cluster-label span{color:#333;}#mermaid-svg-11GnGCgpOFYnntpi .cluster-label span p{background-color:transparent;}#mermaid-svg-11GnGCgpOFYnntpi .label text,#mermaid-svg-11GnGCgpOFYnntpi span{fill:#333;color:#333;}#mermaid-svg-11GnGCgpOFYnntpi .node rect,#mermaid-svg-11GnGCgpOFYnntpi .node circle,#mermaid-svg-11GnGCgpOFYnntpi .node ellipse,#mermaid-svg-11GnGCgpOFYnntpi .node polygon,#mermaid-svg-11GnGCgpOFYnntpi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-11GnGCgpOFYnntpi .rough-node .label text,#mermaid-svg-11GnGCgpOFYnntpi .node .label text,#mermaid-svg-11GnGCgpOFYnntpi .image-shape .label,#mermaid-svg-11GnGCgpOFYnntpi .icon-shape .label{text-anchor:middle;}#mermaid-svg-11GnGCgpOFYnntpi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-11GnGCgpOFYnntpi .rough-node .label,#mermaid-svg-11GnGCgpOFYnntpi .node .label,#mermaid-svg-11GnGCgpOFYnntpi .image-shape .label,#mermaid-svg-11GnGCgpOFYnntpi .icon-shape .label{text-align:center;}#mermaid-svg-11GnGCgpOFYnntpi .node.clickable{cursor:pointer;}#mermaid-svg-11GnGCgpOFYnntpi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-11GnGCgpOFYnntpi .arrowheadPath{fill:#333333;}#mermaid-svg-11GnGCgpOFYnntpi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-11GnGCgpOFYnntpi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-11GnGCgpOFYnntpi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-11GnGCgpOFYnntpi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-11GnGCgpOFYnntpi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-11GnGCgpOFYnntpi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-11GnGCgpOFYnntpi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-11GnGCgpOFYnntpi .cluster text{fill:#333;}#mermaid-svg-11GnGCgpOFYnntpi .cluster span{color:#333;}#mermaid-svg-11GnGCgpOFYnntpi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-11GnGCgpOFYnntpi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-11GnGCgpOFYnntpi rect.text{fill:none;stroke-width:0;}#mermaid-svg-11GnGCgpOFYnntpi .icon-shape,#mermaid-svg-11GnGCgpOFYnntpi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-11GnGCgpOFYnntpi .icon-shape p,#mermaid-svg-11GnGCgpOFYnntpi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-11GnGCgpOFYnntpi .icon-shape .label rect,#mermaid-svg-11GnGCgpOFYnntpi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-11GnGCgpOFYnntpi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-11GnGCgpOFYnntpi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-11GnGCgpOFYnntpi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
应用启动
初始化主组件
渲染底部Tab栏
默认显示首页
点击首页
点击掌机
点击卡带
点击街机
点击周边
点击我的
首页内容
掌机页面
卡带页面
街机页面
周边页面
我的页面
切换Tab?
更新activeTab状态
1.3 技术亮点概览

在深入代码细节之前,我们先快速浏览一下这个应用在技术层面的几个亮点:
声明式UI范式的彻底贯彻:整个应用的UI构建完全采用声明式方式,从最外层的 Column/Row 布局到最内层的 Text 文本组件,全部通过链式调用的方式描述组件的属性和行为。开发者不需要关心组件的创建和更新过程,只需描述状态与UI之间的映射关系。
丰富的状态管理场景:应用中大量使用了 @State 装饰器来管理组件内部状态,包括Tab切换、筛选条件、弹窗显隐、表单数据、动画参数等多种类型的状态。这些状态的变化会自动触发UI的重新渲染,体现了声明式UI的核心优势。
自定义组件与 @Builder:应用灵活运用了 @Builder 装饰器来封装可复用的UI片段,包括弹框遮罩、统计卡片、尺寸条等多种通用UI模式。这种方式既保持了代码的复用性,又避免了过度组件化带来的复杂度。
纯函数数据处理:所有的数据筛选、排序、统计逻辑都封装为纯函数,接收输入参数并返回新的数据副本。这种设计使得数据处理逻辑非常清晰,易于测试和维护,也符合函数式编程的最佳实践。
视觉设计体系:应用拥有完整的设计令牌系统,包括统一的配色方案、圆角规范、间距体系等。深紫色背景配合霓虹粉和像素黄的点缀,营造出浓郁的复古游戏氛围,视觉风格非常统一。
二、类型系统深度解析
2.1 接口定义的设计哲学

在 TypeScript 和 ArkTS 的世界里,接口(Interface)是构建类型安全应用的基石。通过定义清晰的接口,我们可以为数据建模,为组件props建立契约,为整个应用的数据流提供类型保障。复古游戏收藏馆应用共定义了 11 个接口,覆盖了所有业务实体和UI配置项。
让我们来看第一个接口------掌机数据接口:
typescript
interface RACons {
id: number
name: string
brand: string
year: number
price: number
battery: number
sales: number
screen: string
weight: number
cat: string
colorHex: string
}
这个接口定义了掌机实体的完整数据结构,包含 11 个字段。每个字段都有明确的类型注解,从数字类型的 id、year、price、battery、sales、weight,到字符串类型的 name、brand、screen、cat、colorHex。这种精确的类型定义使得开发者在编写业务逻辑时能够获得完整的代码提示和类型检查支持。
特别值得注意的是 colorHex 字段的设计。每个掌机实体都有一个专属的颜色值,这个颜色不仅用于UI装饰,更承担了视觉识别和品牌区分的功能。在界面上,每款掌机都以自己的主题色作为卡片边框和色块背景,形成了强烈的视觉辨识度。这种"数据驱动配色"的设计思路非常巧妙,将视觉样式与业务数据紧密结合在一起。
2.2 各业务实体接口详解

卡带接口 RACart 定义了游戏卡带的数据结构,包含 id、name、platform、genre、rarity、year、price、sales、colorHex 等字段。其中 rarity(稀有度)字段是卡带业务的核心属性,用 0-100 的数值表示卡带的稀有程度,这个数值直接驱动了稀有度排行和进度条展示。platform 字段则标识卡带所属的游戏平台,如 GB、PS、SFC、N64 等,为用户提供了额外的产品信息维度。
街机接口 RAArc 的设计体现了街机业务的特殊性。除了常规的 id、name、genre、sales、year、score、colorHex 字段外,还增加了 cabinet(框体类型)、width、depth、height 等尺寸相关字段。这些字段支撑了街机页面中独特的"框体尺寸"展示功能,用户可以展开卡片查看每台街机的具体长宽高数据,这种细节设计极大地增强了应用的专业感。
周边接口 RAMerch 相对简洁一些,包含 id、name、category、colorHex、price、heat、sales、desc 八个字段。其中 heat(热度)字段是周边业务的特色指标,用数值表示商品的热门程度,驱动了热度排行榜的展示。category 字段将周边分为服饰、挂件、杯具、摆件、抱枕、贴纸六大类,支持了品类分布图表的统计展示。
订单接口 RAOrder 描述了用户订单数据,包含 id、title、count、amount、date、status 字段。status 字段的取值包括"已完成"、"运输中"、"待付款"、"已取消"等多种状态,不同状态对应不同的显示颜色。这个接口虽然字段不多,但涵盖了订单展示所需的全部核心信息。
收藏接口 RAFav 是用户收藏记录的数据结构,包含 id、title、type、colorHex、date 字段。type 字段将收藏分为掌机、卡带、街机、周边四种类型,与应用的四个核心业务模块一一对应。这种设计使得收藏功能可以统一管理不同类型的收藏项,又能通过 type 字段进行区分展示。
消费记录接口 RASpend 用于月度消费统计,包含 month、amount、pct 三个字段。pct 字段表示消费占最高月份的百分比,用于柱状图的高度计算。这个接口虽然简单,却是"我的"页面中月消费图表的数据基础。
经典游戏接口 RAClassic 用于首页的经典机型横滑展示,包含 name、year、colorHex、tag 四个字段。tag 字段是一个简短的评价标签,如"射击鼻祖"、"迷宫之王"、"平台启蒙"等,为经典游戏增添了文化色彩。
首页功能入口接口 RAHomeFunc 定义了首页快捷入口的数据结构,包含 label、icon、colorHex、target 字段。target 字段的类型是 RATab 枚举,直接关联到目标Tab页,这种设计实现了数据驱动的页面跳转逻辑。
类型元数据接口 RAGenreMeta 用于街机游戏类型的元数据配置,包含 label、icon、colorHex 三个字段。每个游戏类型都有自己的图标和主题色,为类型筛选界面提供了丰富的视觉元素。
调色板接口 RAColorPalette 是设计令牌系统的核心,定义了 bg、card、cardDeep、primary、neon、pixel、text、sub、line 等九个颜色角色。这些颜色角色共同构成了应用的完整配色体系,确保了视觉风格的一致性。
2.3 类型系统的工程价值

在基于HarmonyOS API 24的 ArkTS 开发中,完善的类型系统不仅仅是"代码提示"这么简单,它具有多方面的工程价值:
首先,类型系统提供了编译时错误检查能力。在编码阶段,类型不匹配的错误会被即时发现,而不是等到运行时才暴露问题。这大大降低了调试成本,提升了开发效率。对于团队协作项目而言,类型系统更是一种"活文档",让每个函数的输入输出都清晰可查。
其次,接口定义为数据契约提供了正式的表达形式。前后端协作时,接口定义可以作为双方沟通的共同语言;组件开发时,props的接口定义就是组件的使用说明书。这种明确的契约关系减少了沟通成本,降低了集成风险。
第三,良好的类型设计支持重构安全。当需要修改数据结构时,类型系统会自动找出所有受影响的代码位置,确保修改的完整性。在大型项目中,这一点尤为重要------没有类型系统的保护,大规模重构几乎是一场灾难。
最后,类型系统提升了代码的自文档化程度。阅读代码时,通过接口定义就能快速理解数据结构和业务含义,不需要翻阅额外的文档。对于维护人员来说,清晰的类型定义就是最好的入门指南。
三、设计令牌与主题系统
3.1 配色方案的深层设计
复古游戏收藏馆应用采用了一套精心设计的配色方案,整体色调以深紫色为基底,配合霓虹粉和像素黄作为点缀色,营造出浓郁的复古街机厅氛围。配色系统通过 RAColorPalette 接口规范化,并以 RA_COLORS 常量的形式提供全局访问。
typescript
const RA_COLORS: RAColorPalette = {
bg: '#1B1035',
card: '#2A1B4D',
cardDeep: '#241344',
primary: '#1B1035',
neon: '#FF2E88',
pixel: '#FFD52E',
text: '#F5F0FF',
sub: '#9A8BB8',
line: '#3A2A63'
}
让我们逐一解析这些颜色角色的设计考量:
bg(背景色) #1B1035:深紫色背景,RGB值约为27, 16, 53。这个颜色选择非常有讲究------它足够深,能够让前景内容清晰突出;又不是纯黑,带有紫色调,营造出神秘而复古的氛围。在色彩心理学中,深紫色常与创意、想象力和怀旧感联系在一起,非常契合复古游戏的主题。
card(卡片色) #2A1B4D:比背景色稍浅的紫色,用于卡片和面板的背景。这种"深色上叠稍浅深色"的分层手法是暗色模式设计的经典技巧,通过细微的明度差异创造出层次感,又不会破坏整体的深色基调。
cardDeep(深卡片色) #241344:比 card 更深一级的颜色,用于卡片内的次级区域或需要凹陷感的地方。三层紫色的递进关系(bg → cardDeep → card)构建了微妙的视觉层次,让界面在深色基调下依然富有深度。
neon(霓虹粉) #FF2E88:这是整个应用最醒目的强调色,RGB值约为255, 46, 136。这种高饱和度的粉红色在深紫色背景上具有极强的视觉冲击力,如同霓虹灯般闪耀。它主要用于主按钮、选中状态、重要数据的高亮显示,是引导用户视觉焦点的关键色彩。
pixel(像素黄) #FFD52E:第二强调色,一种温暖的金黄色。它的使用频率甚至高于霓虹粉,广泛应用于次级强调、文字高亮、进度条填充等场景。黄色与紫色在色轮上是互补色关系,两者搭配能产生强烈的视觉张力,非常符合复古游戏的像素艺术风格。
text(主文字色) #F5F0FF:接近白色但带有淡紫色调的文字颜色。在深色背景上使用纯白文字可能会产生过于刺眼的效果,而略带紫色调的白色既能保证足够的对比度,又与整体色调更加和谐统一。
sub(次要文字色) #9A8BB8:用于辅助文字和说明文字的灰紫色。它与背景色的对比度适中,既能保证可读性,又不会抢夺主内容的视觉焦点。
line(分割线色) #3A2A63:用于分割线和边框的颜色。在暗色模式中,分割线不宜过于明显,这种低饱和度的紫色既能起到分隔作用,又不会干扰内容的阅读。
3.2 设计令牌的系统化应用
设计令牌(Design Tokens)是现代前端工程中的重要概念,它将设计规范抽象为可复用的变量,确保设计与开发之间的一致性。复古游戏收藏馆应用虽然是单文件实现,但已经体现了设计令牌的核心思想。
除了颜色令牌之外,应用中还隐含了多种设计令牌:
圆角体系:应用中使用了多种圆角尺寸,从小型的 4px、6px 到中型的 8px、10px、12px,再到大型的 14px、16px、18px,以及按钮常用的 20px。不同的圆角尺寸对应不同的组件层级和视觉重要性,形成了一套完整的圆角语言。
间距体系:应用的间距使用也非常规律,从 2px、3px、4px 的微间距,到 6px、8px、10px 的小组件间距,再到 12px、14px、16px 的模块间距,以及 18px、20px 的大间距。这种递增式的间距体系保证了界面的节奏感和秩序感。
字体层级:文字大小从 8px、9px 的超小辅助文字,到 10px、11px 的说明文字,12px、13px 的正文文字,14px、15px 的次级标题,16px、17px、18px、20px 的标题文字,再到 22px、24px、26px、28px 的大数字展示。清晰的字体层级构建了良好的信息架构。
3.3 品牌色与数据色的融合设计
应用中一个非常巧妙的设计是将品牌色(主题色)与数据色(每条数据的专属颜色)融合在一起。每条业务数据(掌机、卡带、街机、周边等)都有自己的 colorHex 字段,这个颜色值不仅用于UI装饰,更成为了该数据项的视觉标识。
这种设计带来了多重好处:
增强视觉辨识度:每个商品都有自己的代表色,用户在浏览列表时可以快速通过颜色识别不同的商品,降低了认知负荷。
丰富视觉层次:多彩的数据色打破了深色界面的沉闷感,让整个应用看起来更加生动活泼,也更符合复古游戏的主题调性。
支撑数据可视化:颜色直接用于柱状图、进度条、对比条等数据可视化组件,无需额外的颜色映射逻辑。每条数据的颜色就是它在图表中的颜色,直观而一致。
体现设计匠心:为每条数据精心挑选配色,体现了开发者对细节的极致追求。这种"每个数据都有自己的颜色"的设计理念,让应用超越了普通的功能性工具,更像是一件精心打磨的作品。
四、数据层设计与业务建模
4.1 掌机数据:十大经典掌机
RA_CONSOLES 数组包含了 10 款经典掌机的数据,从 1989 年的初代 Game Boy 到 2019 年的 Switch Lite,横跨了三十年的掌机发展史。这些掌机来自任天堂、索尼、SNK、万代四大品牌,每一款都曾是各自时代的标杆产品。
typescript
const RA_CONSOLES: RACons[] = [
{ id: 1, name: 'Game Boy', brand: '任天堂', year: 1989, price: 480, battery: 15, sales: 11869, screen: '反射式点阵屏', weight: 220, cat: '任天堂', colorHex: '#8E7CC3' },
// ... 共10条数据
]
每条掌机数据都包含了丰富的属性信息。id 字段作为唯一标识,name 是掌机型号名称,brand 和 cat 分别表示品牌和分类(这里两者的值相同,可能是为了后续扩展预留),year 表示发售年份,price 表示参考价格,battery 表示电池续航小时数,sales 表示销量数据,screen 表示屏幕规格,weight 表示整机重量,colorHex 是掌机的主题色。
这些数据字段并非随意设定,而是紧密对应着界面上的各种展示需求。price 用于价格展示,battery 驱动续航对比图,sales 用于销量排序和柱状图,screen 和 weight 在详情弹窗中展示,colorHex 则贯穿整个视觉设计。这种"数据字段即UI元素"的设计思路,确保了数据模型与展示需求的高度匹配。
4.2 卡带数据:稀有度驱动的收藏模型
RA_CARTS 数组包含 8 款经典游戏卡带,涵盖了冒险、RPG、动作、平台、策略等多种游戏类型。卡带数据的核心特色是 rarity(稀有度)字段,这个 0-100 的数值直接决定了卡带在排行榜中的位置。
卡带数据还包含 platform 字段,表示卡带所属的游戏平台,如 GB、PS、SFC、N64 等。这个字段为用户提供了额外的筛选维度,也丰富了产品的信息维度。year 字段则配合年代分布图表,展示了不同年代的卡带数量分布。
4.3 街机数据:框体尺寸的专业展示
RA_ARCS 数组收录了 8 款经典街机游戏,从 1978 年的太空侵略者到 1997 年的拳皇97,每一款都是街机黄金时代的代表作。街机数据最独特的地方是 cabinet(框体类型)和宽深高尺寸字段。
街机与掌机、卡带最大的不同在于它是实体机器,有具体的物理尺寸。应用通过 width、depth、height 三个字段记录每台街机的尺寸,并在展开卡片时以进度条的形式直观展示。这种细节设计让街机页面显得格外专业,也满足了硬核玩家对街机硬件参数的关注需求。
score(评分)字段是街机数据的另一个亮点,用数值表示游戏的口碑评分,最高分达到了 97 分(街头霸王II 冠军版)。评分与框体类型、尺寸数据共同构成了街机的完整画像。
4.4 周边商品与用户数据
RA_MERCHS 数组包含 6 款复古游戏主题周边商品,涵盖服饰、挂件、杯具、摆件、抱枕、贴纸六大品类。每款周边都有 heat(热度)字段,用于热度排行榜的排序。周边数据相对简洁,但字段设计依然完整,包含价格、销量、描述等核心信息。
RA_ORDERS 数组存储了 6 条用户订单记录,状态涵盖了已完成、运输中、待付款、已取消四种常见的订单状态。订单ID采用了统一的编码格式 "RA-年月-序号",体现了业务数据的规范性。
RA_FAVS 数组包含 6 条用户收藏记录,覆盖了掌机、卡带、街机、周边四种类型。每条收藏记录都有收藏日期,展示了用户的收藏历史轨迹。
RA_SPENDS 数组记录了近 6 个月的消费数据,从 3 月到 8 月,金额从 560 元到 1568 元不等。pct 字段预计算了每月消费占最高月份的百分比,简化了柱状图的渲染逻辑。
RA_CLASSICS 数组收录了 6 款经典游戏,从 1978 年的太空侵略者到 1996 年的超级马里奥64,每款都附带一个简洁的评价标签。这些数据用于首页的经典机型横滑展示,为应用增添了文化底蕴。
RA_HOME_FUNCS 数组定义了首页的四个快捷入口,分别指向掌机库、卡带库、街机厅、周边屋四个Tab页面。target 字段直接关联 RATab 枚举,实现了数据驱动的导航逻辑。
4.5 数据层设计的方法论
从这些数据设计中,我们可以总结出几条实用的方法论:
字段与展示一一对应:每个数据字段都有明确的UI用途,不会出现定义了但从未使用的冗余字段。这要求开发者在设计数据模型时,就要充分考虑界面展示的需求。
预计算提升性能:部分数据(如 RA_SPENDS 中的 pct 字段)进行了预计算,避免了在渲染时的重复计算。对于计算密集型的场景,这种预计算优化能有效提升渲染性能。
数据驱动UI:导航、筛选、排序等交互逻辑都由数据驱动。通过修改状态变量,UI自动响应变化,开发者无需手动操作DOM。这是声明式UI范式的核心优势。
模拟数据的真实性:虽然是模拟数据,但数据的取值范围、数值分布、格式规范都尽量贴近真实业务场景。这不仅让UI展示更加自然,也为后续对接真实API打下了良好基础。
五、辅助函数层:纯函数的数据处理艺术
5.1 函数式编程思想在ArkTS中的应用
复古游戏收藏馆应用的数据处理逻辑全部封装为纯函数,这是函数式编程思想的典型实践。纯函数具有三个核心特征:相同的输入永远产生相同的输出;不修改输入参数;不产生副作用。这些特性使得纯函数非常容易测试、调试和复用。
在基于HarmonyOS API 24的 ArkTS 开发中,纯函数的设计模式尤为重要。由于声明式UI框架会频繁触发重新渲染,如果数据处理函数有副作用或性能问题,会严重影响应用的流畅度。纯函数的引用透明性使得框架可以安全地缓存计算结果,避免重复计算。
5.2 筛选函数详解
raFilterConsoles 函数是掌机品牌筛选的核心逻辑:
typescript
function raFilterConsoles(brand: string): RACons[] {
let r: RACons[] = []
for (let i = 0; i < RA_CONSOLES.length; i++) {
if (brand === '全部' || RA_CONSOLES[i].brand === brand) {
r.push(RA_CONSOLES[i])
}
}
return r
}
这个函数接收一个品牌名称参数,返回符合该品牌的掌机数组。函数内部使用传统的 for 循环遍历源数据数组,通过 if 条件判断是否满足筛选条件。当 brand 参数为"全部"时,所有数据都会被选中,这是一个巧妙的设计,用一个特殊值来表示"不筛选"的状态。
值得注意的是,函数每次都创建并返回一个新数组,而不是修改原数组。这种不可变性的实践是函数式编程的核心原则之一,确保了数据的纯净和可预测性。
同样模式的筛选函数还有 raFilterCarts (卡带类型筛选)和 raFilterArcs(街机类型筛选),它们的结构完全一致,只是操作的数据类型和筛选字段不同。这种高度一致的代码模式体现了良好的编程规范,也便于后续的维护和扩展。
5.3 排序与取优函数
raHotConsoles 函数用于获取销量最高的5款掌机:
typescript
function raHotConsoles(): RACons[] {
let r: RACons[] = RA_CONSOLES.slice()
r.sort((a: RACons, b: RACons) => b.sales - a.sales)
return r.slice(0, 5)
}
这个函数的实现体现了几个重要的编程技巧。首先,使用 slice() 方法创建原数组的浅拷贝,确保排序操作不会修改原始数据。这是不可变数据处理的标准做法。
然后,使用数组的 sort 方法进行排序,排序的比较函数是 b.sales - a.sales,这意味着按照销量从高到低降序排列。如果想升序排列,就写成 a.sales - b.sales。这种简洁的数值排序写法是 JavaScript/TypeScript 中的常用技巧。
最后,使用 slice(0, 5) 截取排序后的前5个元素,返回销量最高的5款掌机。这个函数虽然简短,但完整地演示了"拷贝-排序-截取"的经典数据处理流程。
类似的函数还有 raRareCarts (按稀有度排序卡带)和 raHotMerchs(按热度排序并取前3的周边),它们都遵循相同的处理模式。
5.4 统计与计算函数
应用中包含了多种统计计算函数,用于支撑数据可视化和信息展示。
raArcGenreCount 函数统计指定类型的街机数量:
typescript
function raArcGenreCount(label: string): number {
let c = 0
for (let i = 0; i < RA_ARCS.length; i++) {
if (RA_ARCS[i].genre === label) {
c++
}
}
return c
}
这个函数使用计数器模式,遍历数组并累加符合条件的元素数量。类似的还有 raMerchCatCount (统计某类周边的数量)和 raCartEraCount(统计某个年代的卡带数量)。
raEraOf 函数根据年份判断所属年代:
typescript
function raEraOf(year: number): string {
if (year < 1990) {
return '80年代'
}
if (year < 2000) {
return '90年代'
}
if (year < 2010) {
return '00年代'
}
return '10年代'
}
这是一个经典的区间判断函数,通过多层 if 语句将连续的年份值映射为离散的年代标签。函数的判断顺序很重要------必须从小到大依次判断,利用了"前面的条件不满足才会进入后面的判断"这一特性,使得每个区间都能被正确识别。
最大值计算函数 系列包括 raMaxSales (掌机最大销量)、raMaxArcWidth (街机最大宽度)、raSpendMax (最大月消费)、raMaxHeat(周边最高热度)。这些函数都采用了相同的模式:初始化最大值为0,遍历数组更新最大值。
raArcSalesTotal 函数 和 raSpendTotal 函数分别计算街机总销量和总消费金额,使用的是累加器模式。
5.5 辅助计算与状态映射函数
raBarPct 函数是一个通用的进度条百分比计算工具:
typescript
function raBarPct(v: number, max: number): number {
return Math.round(v / max * 72)
}
这个函数接收当前值和最大值,返回计算后的百分比数值。特别的是,它乘以的是 72 而不是 100,这意味着进度条的最大显示比例是 72% 而不是 100%。这个设计选择可能是为了视觉效果的考虑------让进度条不会顶到最右边,留出一些空间更加美观。Math.round 用于四舍五入取整,确保返回整数像素值。
raStatusColor 函数根据订单状态返回对应的颜色值:
typescript
function raStatusColor(status: string): string {
if (status === '已完成') {
return '#4CD964'
}
if (status === '运输中') {
return '#00E5FF'
}
if (status === '待付款') {
return '#FFD52E'
}
return '#9A8BB8'
}
这是一个典型的状态-颜色映射函数,将业务状态字符串映射为具体的颜色值。已完成使用绿色,运输中使用青色,待付款使用黄色,其他状态(如已取消)使用灰紫色。这种映射函数在UI开发中非常常见,它将业务逻辑与视觉表现解耦,使得状态的展示更加灵活可配置。
5.6 辅助函数层的设计启示
通过分析这些辅助函数,我们可以得到以下设计启示:
保持函数的纯粹性:所有的数据处理函数都是纯函数,不依赖外部状态,不修改输入数据。这使得函数的行为完全可预测,测试成本极低。
单一职责原则:每个函数只做一件事,函数名清晰地描述了它的功能。短小精悍的函数更容易理解、测试和复用。
一致的代码风格:同类函数采用相同的代码结构和命名模式,降低了阅读和维护的认知成本。开发者在阅读了一两个同类函数后,就能快速理解其他类似函数的逻辑。
计算逻辑与UI分离:所有的数据计算都封装在独立的函数中,UI组件只负责调用函数并展示结果。这种分离使得计算逻辑可以独立演进,也方便进行单元测试。
六、主应用组件:架构骨架与导航系统
6.1 入口组件的结构
RetroArcadeApp 是整个应用的入口组件,使用 @Entry 装饰器标记。它承担着应用骨架的构建职责,包括顶部标题栏、中间内容区和底部Tab栏三大部分。
typescript
@Entry
@Component
struct RetroArcadeApp {
@State activeTab: RATab = RATab.Home
// ...
}
@State 装饰的 activeTab 变量是整个应用导航系统的核心状态。它的类型是 RATab 枚举,默认值为 RATab.Home,表示应用启动时默认显示首页。当用户点击底部Tab栏的不同选项时,这个状态变量会被更新,从而触发内容区的重新渲染,显示对应的Tab页面。
这种"单一状态驱动导航"的设计模式非常简洁优雅。整个导航逻辑的核心就是一个枚举变量和一组条件渲染,没有复杂的路由配置,也不需要导航器组件。对于Tab导航这种场景,这种实现方式既高效又易于理解。
6.2 顶部标题栏的构建
appHeader 方法构建了应用的顶部标题栏:
typescript
@Builder appHeader() {
Row() {
Column() {
Text('RA')
.fontSize(15).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.primary)
.backgroundColor(RA_COLORS.pixel).borderRadius(8)
.padding({ left: 7, right: 7, top: 4, bottom: 4 })
}
Column() {
Text('RETRO ARCADE')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text('复古游戏收藏馆 · 1980-2010')
.fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1).padding({ left: 10 })
Text('🔍').fontSize(18).opacity(0.85)
Text('🛒').fontSize(18).opacity(0.85).margin({ left: 14 })
}
.width('100%').alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(RA_COLORS.card)
.shadow({ radius: 8, color: '#552A1B4D', offsetY: 3 })
}
标题栏采用 Row 水平布局,从左到右依次排列了Logo区、标题区和功能图标区。
最左边是 Logo 区域,一个像素黄底色的方块中显示"RA"字样,字体颜色使用深紫色,形成强烈的对比。这种反向对比的Logo设计简洁醒目,在深色背景上非常突出。
中间是标题区域,使用 Column 垂直布局排列主标题和副标题。主标题"RETRO ARCADE"使用大号粗体白字,副标题"复古游戏收藏馆 · 1980-2010"使用小号灰紫色字。.layoutWeight(1) 的设置让这个区域占据剩余的全部空间,实现了左右两端对齐的布局效果。
最右边是两个功能图标------搜索和购物车,使用 emoji 字符实现,简单又直观。opacity 设置为 0.85,让图标稍微柔和一些,不会过于刺眼。
整个标题栏的背景色使用 card 色(比背景稍浅的紫色),底部添加了阴影效果,营造出悬浮感。.alignItems(VerticalAlign.Center) 确保所有元素在垂直方向上居中对齐。
6.3 内容区与条件渲染
contentArea 方法负责渲染当前选中的Tab页面内容:
typescript
@Builder contentArea() {
Column() {
if (this.activeTab === RATab.Home) {
RAHomeTab({ onSwitchTab: (t: RATab) => { this.activeTab = t } })
} else if (this.activeTab === RATab.Handheld) {
RAHandheldTab()
} else if (this.activeTab === RATab.Cart) {
RACartTab()
} else if (this.activeTab === RATab.Arcade) {
RAArcadeTab()
} else if (this.activeTab === RATab.Merch) {
RAMerchTab()
} else {
RAMineTab()
}
}
.layoutWeight(1)
}
这段代码是声明式UI中条件渲染的典型范例。通过 if/else if/else 的链式判断,根据 activeTab 的值决定渲染哪个Tab组件。每次 activeTab 状态变化时,框架会自动重新执行这个构建函数,销毁旧的Tab组件并创建新的Tab组件。
特别值得注意的是首页组件的参数传递:RAHomeTab({ onSwitchTab: (t: RATab) => { this.activeTab = t } })。首页组件接收一个 onSwitchTab 回调函数,当用户点击首页的快捷入口时,通过这个回调函数修改主组件的 activeTab 状态,从而实现页面跳转。
这种"子组件通过回调修改父组件状态"的模式是声明式UI中状态提升(State Lifting)的典型应用。导航状态保存在父组件中,子组件只负责触发状态变更,而不直接管理状态。这种设计保持了状态的单一数据源,避免了状态同步的问题。
.layoutWeight(1) 让内容区占据剩余的全部垂直空间,配合底部Tab栏的固定高度,实现了灵活的自适应布局。
6.4 底部Tab栏的实现
底部Tab栏是应用导航的核心交互入口。bottomTabItem 方法封装了单个Tab项的构建逻辑:
typescript
@Builder bottomTabItem(icon: string, label: string, tab: RATab) {
Column() {
Text(icon)
.fontSize(20)
.opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label)
.fontSize(9)
.fontColor(this.activeTab === tab ? RA_COLORS.pixel : RA_COLORS.sub)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
if (this.activeTab === tab) {
Column()
.width(18).height(3).borderRadius(2)
.backgroundColor(RA_COLORS.neon)
.margin({ top: 3 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.padding({ top: 6, bottom: 6 })
.onClick(() => { this.activeTab = tab })
}
每个Tab项由图标、文字和选中指示器三部分组成。
图标使用 emoji 字符,大小为 20px。通过三元表达式控制透明度:选中状态下不透明(opacity 1.0),未选中状态下半透明(opacity 0.45)。这种简洁的视觉区分方式既美观又高效。
文字标签在选中状态下使用像素黄色并加粗,未选中状态下使用灰紫色常规字重。颜色和字重的双重变化确保了选中状态的清晰可辨。
选中指示器是一个霓虹粉色的小横条,只在当前Tab被选中时显示。这个指示器位于文字下方,为Tab项增添了精致的视觉反馈。
.layoutWeight(1) 让每个Tab项平均分配底部栏的宽度,确保六个Tab等宽排列。.onClick 事件处理器将 activeTab 更新为当前Tab的值,触发UI的重新渲染。
在 build 方法中,六个Tab项被放入一个 Row 容器中:
typescript
Row() {
this.bottomTabItem('🏠', '首页', RATab.Home)
this.bottomTabItem('🎮', '掌机', RATab.Handheld)
this.bottomTabItem('💾', '卡带', RATab.Cart)
this.bottomTabItem('🕹', '街机', RATab.Arcade)
this.bottomTabItem('🧸', '周边', RATab.Merch)
this.bottomTabItem('👤', '我的', RATab.Mine)
}
.width('100%')
.backgroundColor(RA_COLORS.card)
.padding({ top: 4, bottom: 4 })
.shadow({ radius: 10, color: '#552A1B4D', offsetY: -3 })
底部Tab栏使用卡片色作为背景,顶部添加向上的阴影(offsetY: -3),营造出悬浮于内容之上的视觉效果。
6.5 主组件的整体布局结构
主组件的 build 方法将三个部分组合在一起:
typescript
build() {
Column() {
this.appHeader()
this.contentArea()
Row() { /* Tab项 */ }
}
.width('100%').height('100%')
.backgroundColor(RA_COLORS.bg)
}
整体采用 Column 垂直布局,从上到下依次是标题栏、内容区和底部Tab栏。内容区通过 .layoutWeight(1) 占据中间的弹性空间,标题栏和底部栏则保持固定高度。
最外层 Column 设置了宽高为 100%,确保应用充满整个屏幕。背景色使用深紫色的 bg 色,为整个应用奠定了深色基调。
#mermaid-svg-trD0SsIdvN8oRccC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-trD0SsIdvN8oRccC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-trD0SsIdvN8oRccC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-trD0SsIdvN8oRccC .error-icon{fill:#552222;}#mermaid-svg-trD0SsIdvN8oRccC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-trD0SsIdvN8oRccC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-trD0SsIdvN8oRccC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-trD0SsIdvN8oRccC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-trD0SsIdvN8oRccC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-trD0SsIdvN8oRccC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-trD0SsIdvN8oRccC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-trD0SsIdvN8oRccC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-trD0SsIdvN8oRccC .marker.cross{stroke:#333333;}#mermaid-svg-trD0SsIdvN8oRccC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-trD0SsIdvN8oRccC p{margin:0;}#mermaid-svg-trD0SsIdvN8oRccC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-trD0SsIdvN8oRccC .cluster-label text{fill:#333;}#mermaid-svg-trD0SsIdvN8oRccC .cluster-label span{color:#333;}#mermaid-svg-trD0SsIdvN8oRccC .cluster-label span p{background-color:transparent;}#mermaid-svg-trD0SsIdvN8oRccC .label text,#mermaid-svg-trD0SsIdvN8oRccC span{fill:#333;color:#333;}#mermaid-svg-trD0SsIdvN8oRccC .node rect,#mermaid-svg-trD0SsIdvN8oRccC .node circle,#mermaid-svg-trD0SsIdvN8oRccC .node ellipse,#mermaid-svg-trD0SsIdvN8oRccC .node polygon,#mermaid-svg-trD0SsIdvN8oRccC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-trD0SsIdvN8oRccC .rough-node .label text,#mermaid-svg-trD0SsIdvN8oRccC .node .label text,#mermaid-svg-trD0SsIdvN8oRccC .image-shape .label,#mermaid-svg-trD0SsIdvN8oRccC .icon-shape .label{text-anchor:middle;}#mermaid-svg-trD0SsIdvN8oRccC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-trD0SsIdvN8oRccC .rough-node .label,#mermaid-svg-trD0SsIdvN8oRccC .node .label,#mermaid-svg-trD0SsIdvN8oRccC .image-shape .label,#mermaid-svg-trD0SsIdvN8oRccC .icon-shape .label{text-align:center;}#mermaid-svg-trD0SsIdvN8oRccC .node.clickable{cursor:pointer;}#mermaid-svg-trD0SsIdvN8oRccC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-trD0SsIdvN8oRccC .arrowheadPath{fill:#333333;}#mermaid-svg-trD0SsIdvN8oRccC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-trD0SsIdvN8oRccC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-trD0SsIdvN8oRccC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-trD0SsIdvN8oRccC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-trD0SsIdvN8oRccC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-trD0SsIdvN8oRccC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-trD0SsIdvN8oRccC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-trD0SsIdvN8oRccC .cluster text{fill:#333;}#mermaid-svg-trD0SsIdvN8oRccC .cluster span{color:#333;}#mermaid-svg-trD0SsIdvN8oRccC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-trD0SsIdvN8oRccC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-trD0SsIdvN8oRccC rect.text{fill:none;stroke-width:0;}#mermaid-svg-trD0SsIdvN8oRccC .icon-shape,#mermaid-svg-trD0SsIdvN8oRccC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-trD0SsIdvN8oRccC .icon-shape p,#mermaid-svg-trD0SsIdvN8oRccC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-trD0SsIdvN8oRccC .icon-shape .label rect,#mermaid-svg-trD0SsIdvN8oRccC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-trD0SsIdvN8oRccC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-trD0SsIdvN8oRccC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-trD0SsIdvN8oRccC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 主应用布局结构
Home
Handheld
Cart
Arcade
Merch
Mine
Column 最外层容器
appHeader 顶部标题栏
contentArea 内容区域 layoutWeight=1
底部Tab栏 Row
activeTab?
首页组件
掌机组件
卡带组件
街机组件
周边组件
我的组件
首页Tab
掌机Tab
卡带Tab
街机Tab
周边Tab
我的Tab
七、首页:像素仪表盘与复古氛围营造
7.1 首页组件的整体结构
RAHomeTab 组件是应用的首页,也是用户进入应用后看到的第一个页面。它承担着数据概览、内容引导和氛围营造的多重功能。首页的设计非常用心,不仅展示了丰富的信息,还通过多种动画特效营造出浓郁的复古游戏氛围。
首页组件包含三个关键的动画状态变量:raPixelY(像素动画偏移量)、raNeon(霓虹灯闪烁状态)、raScanY(扫描线位置)。这三个状态变量通过定时器不断更新,驱动UI产生动态效果。
typescript
@Component
struct RAHomeTab {
onSwitchTab: (t: RATab) => void = () => {}
@State raPixelY: number = 0
@State raNeon: number = 0
@State raScanY: number = 0
private raTimer: number = -1
private raNeonTimer: number = -1
private raScanTimer: number = -1
// ...
}
onSwitchTab 是从父组件传入的回调函数,用于实现首页快捷入口的Tab切换功能。这种"父传子"的回调模式是 ArkTS 中组件间通信的常用方式。
7.2 生命周期与动画定时器
aboutToAppear 和 aboutToDisappear 是 ArkTS 组件的生命周期钩子函数,分别在组件即将出现和即将消失时被调用。首页组件利用这两个钩子来管理动画定时器:
typescript
aboutToAppear(): void {
this.raTimer = setInterval(() => {
this.raPixelY = (this.raPixelY + 5) % 20
}, 90)
this.raNeonTimer = setInterval(() => {
this.raNeon = this.raNeon === 0 ? 1 : 0
}, 420)
this.raScanTimer = setInterval(() => {
this.raScanY = (this.raScanY + 4) % 101
}, 60)
}
aboutToDisappear(): void {
clearInterval(this.raTimer)
clearInterval(this.raNeonTimer)
clearInterval(this.raScanTimer)
}
aboutToAppear 中启动了三个定时器,分别控制三种动画效果:
-
像素位移动画:每 90 毫秒将 raPixelY 增加 5,取模 20 后形成 0-19 的循环。这个值用于控制统计卡片中数字的上下偏移,模拟像素跳动的效果。
-
霓虹灯闪烁:每 420 毫秒切换 raNeon 的值(0和1之间切换)。这个值用于控制标题文字的颜色切换,在霓虹粉和像素黄之间交替闪烁,模拟霓虹灯的效果。
-
扫描线动画:每 60 毫秒将 raScanY 增加 4,取模 101 后形成 0-100 的循环。这个值控制扫描线的垂直位置,模拟老式显像管显示器的扫描线效果。
aboutToDisappear 中清除了所有定时器,防止内存泄漏。这是一个非常重要的最佳实践------凡是在组件中创建的定时器、事件监听等资源,都必须在组件销毁时及时清理。
7.3 像素仪表盘区域
页面顶部是"像素仪表盘"区域,这是首页最具特色的设计之一:
typescript
Column() {
Row() {
Text('🕹 PIXEL DASHBOARD')
.fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(this.raNeon === 0 ? RA_COLORS.neon : RA_COLORS.pixel)
Column().layoutWeight(1)
Text('8-BIT STATUS')
.fontSize(9).fontColor(RA_COLORS.sub)
}
.width('100%').alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 14 })
Row() {
this.raStatTile('10', '掌机', RA_COLORS.neon, this.raPixelY)
this.raStatTile('8', '卡带', RA_COLORS.pixel, (this.raPixelY + 5) % 20)
this.raStatTile('8', '街机', '#7C4DFF', (this.raPixelY + 10) % 20)
this.raStatTile('6', '周边', '#00E5FF', (this.raPixelY + 15) % 20)
}
.width('100%').padding({ left: 12, right: 4, top: 12 })
}
.width('100%')
.backgroundColor(RA_COLORS.cardDeep).borderRadius(16)
.padding({ bottom: 14 })
.margin({ top: 12 })
仪表盘区域使用深卡片色作为背景,圆角 16px,营造出一个嵌入式仪表盘的视觉效果。
顶部标题行中,"PIXEL DASHBOARD"文字的颜色会随 raNeon 状态变化而在霓虹粉和像素黄之间闪烁,这就是霓虹灯效果的具体应用。右侧的"8-BIT STATUS"副标题使用次要文字颜色,点明仪表盘的像素风格主题。
下方的统计卡片行包含四个统计项,分别展示掌机、卡带、街机、周边的数量。每个卡片都有不同的主题色,数字会根据 raPixelY 的值上下偏移,模拟像素跳动的动画效果。
特别巧妙的是,四个卡片的偏移相位各不相同------分别使用 raPixelY、(raPixelY + 5) % 20、(raPixelY + 10) % 20、(raPixelY + 15) % 20。这意味着四个数字的跳动是错位的,形成了一种"波浪式"的动态效果,比同步跳动更加生动有趣。
7.4 统计卡片组件的构建
raStatTile 方法封装了单个统计卡片的构建逻辑:
typescript
@Builder raStatTile(num: string, label: string, colorHex: string, py: number) {
Column() {
Text(num)
.fontSize(24).fontWeight(FontWeight.Bold).fontColor(colorHex)
.translate({ y: py - 20 })
Text(label)
.fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor(RA_COLORS.card).borderRadius(12)
.padding({ top: 12, bottom: 12 })
.margin({ right: 8 })
.border({ width: 1, color: colorHex })
}
这个 @Builder 方法接收四个参数:num(数字文本)、label(标签文本)、colorHex(主题色)、py(垂直偏移量)。
数字文本使用 24px 的大字号粗体,颜色为传入的主题色。.translate({ y: py - 20 }) 设置了垂直方向的位移变换,py 的取值范围是 0-19,所以 y 的偏移范围是 -20 到 -1。这意味着数字会在向上 1-20 像素的范围内循环移动,模拟像素跳动的效果。
标签文本使用 9px 的小号次要文字颜色,位于数字下方。
卡片本身使用卡片色背景,12px 圆角,带有 1px 的彩色边框(颜色为传入的主题色)。.layoutWeight(1) 让四个卡片平均分配行的宽度。
7.5 经典机型横滑区
经典机型横滑区是首页的第二个主要模块:
typescript
Scroll() {
Row() {
ForEach(RA_CLASSICS, (c: RAClassic) => {
Column() {
Text(c.tag)
.fontSize(9).fontColor(RA_COLORS.pixel)
.backgroundColor('#33FFD52E').borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text(c.name)
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
.margin({ top: 8 })
Text(c.year + ' 年')
.fontSize(10).fontColor(RA_COLORS.sub).margin({ top: 3 })
Text('VIEW ▶')
.fontSize(9).fontColor(RA_COLORS.neon).margin({ top: 8 })
}
.width(136).alignItems(HorizontalAlign.Center)
.backgroundColor(RA_COLORS.card).borderRadius(14)
.padding({ top: 14, bottom: 14 })
.margin({ right: 10 })
.border({ width: 1, color: c.colorHex })
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
.width('100%').margin({ top: 10 })
这个区域使用 Scroll 组件包裹 Row,实现横向滚动效果。.scrollable(ScrollDirection.Horizontal) 设置滚动方向为水平,.scrollBar(BarState.Off) 隐藏滚动条,让界面更加简洁。
ForEach 遍历 RA_CLASSICS 数组,为每款经典游戏生成一张卡片。每张卡片固定宽度 136px,使用卡片色背景和 14px 圆角,带有 1px 的彩色边框(颜色为数据的 colorHex)。
卡片内容从上到下依次是:标签(tag,像素黄文字+半透明黄底)、游戏名称(大号粗体白字)、年份(小号灰字)、VIEW 按钮(霓虹粉文字)。内容居中对齐,整体布局简洁而富有层次。
横向滚动是移动端展示同类内容的常用模式,它的优势在于不占用过多垂直空间,同时又能容纳较多的内容项。用户通过左右滑动来浏览全部内容,交互方式直观自然。
7.6 热销榜单模块
热销榜模块展示销量最高的5款掌机:
typescript
Column() {
ForEach(raHotConsoles(), (c: RACons, i: number) => {
Row() {
Text((i + 1).toString())
.fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(i === 0 ? RA_COLORS.neon : (i === 1 ? RA_COLORS.pixel : (i === 2 ? '#7C4DFF' : RA_COLORS.sub)))
.width(30)
Column() {
Text(c.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text(c.brand + ' · ' + c.year + ' 年').fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1).padding({ left: 6 })
Column() {
Text(c.sales + ' 台').fontSize(11).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel)
Text('¥' + c.price).fontSize(9).fontColor(RA_COLORS.neon).margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
.backgroundColor(RA_COLORS.card).borderRadius(10)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.margin({ top: 8 })
})
}
.width('100%').padding({ left: 16, right: 16 })
热销榜使用列表形式展示,每项都是一个水平排列的 Row 容器。列表数据来自 raHotConsoles() 函数,该函数返回按销量排序的前5款掌机。
每行的布局分为三部分:左侧是排名数字,中间是掌机名称和品牌年份,右侧是销量和价格。
排名数字的颜色设计非常讲究:第一名使用霓虹粉色,第二名使用像素黄色,第三名使用紫色,第四名及以后使用灰紫色。这种"金银铜"式的排名配色方案在榜单设计中非常经典,能够直观地传达排名的等级感。
中间的信息区使用 Column 垂直排列名称和品牌信息,.layoutWeight(1) 让它占据中间的弹性空间,实现左右两端对齐的效果。
右侧的信息区同样使用 Column 垂直排列,销量用像素黄色加粗显示,价格用霓虹粉色显示,两个关键数据一目了然。
7.7 快捷入口宫格
快捷入口宫格是首页的导航枢纽,提供了四个核心模块的快速入口:
typescript
Row() {
ForEach(RA_HOME_FUNCS, (f: RAHomeFunc) => {
Column() {
Text(f.icon).fontSize(22)
Text(f.label).fontSize(11).fontColor(RA_COLORS.text).margin({ top: 6 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor(RA_COLORS.card).borderRadius(12)
.padding({ top: 14, bottom: 14 })
.margin({ left: 4, right: 4 })
.border({ width: 1, color: f.colorHex })
.onClick(() => { this.onSwitchTab(f.target) })
})
}
.width('100%').padding({ left: 12, right: 12, top: 10, bottom: 16 })
四个快捷入口在一行内平均分布,每个入口都是一个带有图标和文字的卡片。图标使用 22px 的 emoji 字符,标签文字使用 11px 的白色字体。
每个入口卡片都有不同颜色的边框,颜色数据来自 RA_HOME_FUNCS 数组的 colorHex 字段。这种多彩边框的设计让宫格区域更加活泼生动。
点击事件处理器调用 this.onSwitchTab(f.target),将目标Tab的枚举值通过回调函数传递给父组件,实现页面跳转。这是一个典型的数据驱动导航------点击哪个入口、跳转到哪个页面,完全由数据决定,代码中没有硬编码的跳转逻辑。
7.8 扫描线特效
扫描线是首页最具复古氛围的视觉特效:
typescript
Stack() {
Column() { /* 主内容 */ }
.width('100%').height('100%')
// ===== 扫描线特效 =====
Column()
.width('100%').height(2)
.linearGradient({ angle: 0, colors: [['#00FF2E88', 0], ['#FFFF2E88', 0.5], ['#00FF2E88', 1]] })
.position({ y: this.raScanY + '%' })
.opacity(0.5)
}
.width('100%').height('100%')
扫描线效果使用 Stack 堆叠布局实现。主内容在底层,扫描线在上层。扫描线本身是一个高度为 2px 的 Column 组件,使用线性渐变创建"两头透明、中间实心"的光效。
.position({ y: this.raScanY + '%' }) 控制扫描线的垂直位置,raScanY 的值从 0 到 100 循环变化,扫描线就会从上到下不断移动,模拟老式CRT显示器的扫描效果。
.opacity(0.5) 设置扫描线的半透明度,让它不会完全遮挡下面的内容,只是增添了一层视觉效果。
这个扫描线特效虽然实现简单,但营造出的复古氛围却非常到位。它是"用最少的代码实现最好的效果"的典范,也是前端开发中"小成本大效果"设计思路的体现。
#mermaid-svg-yxdN5iDcmKscGxvh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yxdN5iDcmKscGxvh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yxdN5iDcmKscGxvh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yxdN5iDcmKscGxvh .error-icon{fill:#552222;}#mermaid-svg-yxdN5iDcmKscGxvh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yxdN5iDcmKscGxvh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yxdN5iDcmKscGxvh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yxdN5iDcmKscGxvh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yxdN5iDcmKscGxvh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yxdN5iDcmKscGxvh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yxdN5iDcmKscGxvh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yxdN5iDcmKscGxvh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yxdN5iDcmKscGxvh .marker.cross{stroke:#333333;}#mermaid-svg-yxdN5iDcmKscGxvh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yxdN5iDcmKscGxvh p{margin:0;}#mermaid-svg-yxdN5iDcmKscGxvh defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-yxdN5iDcmKscGxvh g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-yxdN5iDcmKscGxvh g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-yxdN5iDcmKscGxvh g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-yxdN5iDcmKscGxvh g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-yxdN5iDcmKscGxvh g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-yxdN5iDcmKscGxvh .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-yxdN5iDcmKscGxvh .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-yxdN5iDcmKscGxvh .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-yxdN5iDcmKscGxvh .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-yxdN5iDcmKscGxvh .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-yxdN5iDcmKscGxvh .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-yxdN5iDcmKscGxvh .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-yxdN5iDcmKscGxvh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yxdN5iDcmKscGxvh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yxdN5iDcmKscGxvh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yxdN5iDcmKscGxvh .edgeLabel .label text{fill:#333;}#mermaid-svg-yxdN5iDcmKscGxvh .label div .edgeLabel{color:#333;}#mermaid-svg-yxdN5iDcmKscGxvh .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-yxdN5iDcmKscGxvh .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-yxdN5iDcmKscGxvh .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-yxdN5iDcmKscGxvh .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-yxdN5iDcmKscGxvh .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-yxdN5iDcmKscGxvh .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yxdN5iDcmKscGxvh .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yxdN5iDcmKscGxvh #statediagram-barbEnd{fill:#333333;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yxdN5iDcmKscGxvh .cluster-label,#mermaid-svg-yxdN5iDcmKscGxvh .nodeLabel{color:#131300;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-yxdN5iDcmKscGxvh .note-edge{stroke-dasharray:5;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-note text{fill:black;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram-note .nodeLabel{color:black;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagram .edgeLabel{color:red;}#mermaid-svg-yxdN5iDcmKscGxvh #dependencyStart,#mermaid-svg-yxdN5iDcmKscGxvh #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-yxdN5iDcmKscGxvh .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yxdN5iDcmKscGxvh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 组件初始化
aboutToAppear
像素动画定时器
更新 raPixelY
霓虹灯定时器
切换 raNeon
扫描线定时器
更新 raScanY
aboutToDisappear 清理定时器
Idle
Animating
PixelJump
NeonBlink
ScanMove
八、掌机页面:网格布局与数据可视化
8.1 掌机页面的功能概览
RAHandheldTab 组件是掌机收藏库页面,这是应用中功能最丰富的Tab页面之一。它包含了品牌筛选、双列网格展示、续航对比图、销量柱状图等多个功能模块,还实现了编辑参数和查看详情两个弹窗交互。
掌机页面的状态变量包括:筛选品牌 filterBrand、编辑弹窗显隐 showEdit、详情弹窗显隐 showDetail、选中的掌机数据 selConsole、编辑中的电池续航 editBattery、编辑中的价格 editPrice。这些状态变量共同管理着页面的所有交互逻辑。
typescript
@Component
struct RAHandheldTab {
@State filterBrand: string = '全部'
@State showEdit: boolean = false
@State showDetail: boolean = false
@State selConsole: RACons | null = null
@State editBattery: number = 15
@State editPrice: number = 480
// ...
}
filterBrand 默认值为"全部",表示页面初始状态下显示所有品牌的掌机。showEdit 和 showDetail 是两个布尔型的弹窗控制状态。selConsole 存储当前被选中的掌机数据,类型是 RACons | null,表示它可以是一个掌机对象,也可以是 null(未选中状态)。editBattery 和 editPrice 存储编辑弹窗中的滑块值,初始值分别为 15 和 480。
8.2 头部横幅区域
掌机页面的顶部有一个深色渐变的头部横幅:
typescript
Column() {
Row() {
Column() {
Text('🕹 掌机收藏库').fontSize(20).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text('10 款掌机 · 累计销量 ' + raMaxSales() + ' 台')
.fontSize(10).fontColor(RA_COLORS.sub).margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('+ 新增收藏')
.fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor(RA_COLORS.neon).borderRadius(16)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.onClick(() => {
if (this.selConsole === null) {
this.selConsole = RA_CONSOLES[0]
}
this.editBattery = this.selConsole!.battery
this.editPrice = this.selConsole!.price
this.showEdit = true
})
}
.width('100%').alignItems(VerticalAlign.Center)
Row() {
Text('双击色块查看机种参数表 · 铅笔图标可调参')
.fontSize(9).fontColor(RA_COLORS.sub)
Column().layoutWeight(1)
Text('📊 销量TOP').fontSize(10).fontColor(RA_COLORS.pixel)
}
.width('100%').margin({ top: 10 })
}
.width('100%')
.linearGradient({ angle: 120, colors: [['#2A1B4D', 0], ['#3A1F5C', 1]] })
.borderRadius({ bottomLeft: 18, bottomRight: 18 })
.padding({ left: 16, right: 16, top: 12, bottom: 14 })
头部横幅使用 120 度角的线性渐变背景,从卡片紫色渐变到稍亮的紫色,营造出光影层次感。底部左右两个角设置了 18px 的圆角,顶部保持直角,形成"横幅"的视觉效果。
横幅内容分为上下两行。上行是主要信息区,左侧显示大标题"掌机收藏库"和副标题"10 款掌机 · 累计销量",右侧是一个霓虹粉色的"+ 新增收藏"按钮。
新增收藏按钮的点击事件处理逻辑值得关注。首先检查 selConsole 是否为 null,如果是(表示之前没有选中过),则默认设置为第一款掌机。然后将编辑弹窗的电池和价格初始化为当前选中掌机的对应值,最后显示编辑弹窗。
这里使用了非空断言操作符 !(this.selConsole!.battery),告诉 TypeScript 编译器 selConsole 在此时一定不为 null。因为前面已经做了 null 检查并赋予了默认值,所以这里的断言是安全的。
下行是辅助提示信息,左侧是操作提示文字,右侧是"销量TOP"标签。这行文字虽然小,但起到了功能引导的作用,帮助用户理解页面的交互方式。
8.3 品牌筛选 Chips
品牌筛选是掌机页面的核心交互功能之一:
typescript
Scroll() {
Row() {
ForEach(RA_BRANDS, (b: string) => {
if (this.filterBrand === b) {
Text(b)
.fontSize(11).fontColor(RA_COLORS.primary).fontWeight(FontWeight.Bold)
.backgroundColor(RA_COLORS.pixel).borderRadius(14)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ left: 4, right: 4 })
} else {
Text(b)
.fontSize(11).fontColor(RA_COLORS.text)
.backgroundColor(RA_COLORS.card).borderRadius(14)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ left: 4, right: 4 })
.onClick(() => { this.filterBrand = b })
}
})
}
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
.width('100%').margin({ top: 12 })
筛选功能使用 Scroll + Row + ForEach 的组合实现横向滚动的筛选标签(Chips)。每个标签都是一个 Text 组件,通过 if/else 条件判断来渲染选中状态和未选中状态的不同样式。
选中状态的标签使用像素黄背景、深紫色文字、加粗字重,视觉上非常突出。未选中状态的标签使用卡片色背景、白色文字,点击时会将 filterBrand 更新为当前品牌。
这种"条件渲染不同样式"的模式是声明式UI中实现状态切换的标准做法。相比于命令式地修改元素的 class 或 style,声明式的方式更加直观------你只需要描述"在XX状态下应该是什么样子",框架会自动处理切换的过程。
筛选标签放置在横向滚动的 Scroll 容器中,即使标签数量较多也能通过滑动浏览全部选项。隐藏滚动条的设置让界面更加干净整洁。
8.4 双列网格布局
掌机列表采用双列网格布局展示,这是移动端商品展示的经典模式:
typescript
Grid() {
ForEach(raFilterConsoles(this.filterBrand), (c: RACons) => {
GridItem() {
Column() {
Row() {
Text(c.brand)
.fontSize(8).fontColor('#FFFFFF')
.backgroundColor(c.colorHex).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Column().layoutWeight(1)
Text('✏️').fontSize(11)
.onClick(() => {
this.selConsole = c
this.editBattery = c.battery
this.editPrice = c.price
this.showEdit = true
})
}
.width('100%').alignItems(VerticalAlign.Center)
Column() {
Text(c.name.substring(0, 1))
.fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
.opacity(0.92)
}
.width('100%').height(84).borderRadius(12)
.backgroundColor(c.colorHex)
.margin({ top: 8 })
.onClick(() => {
this.selConsole = c
this.showDetail = true
})
Text(c.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
.margin({ top: 6 })
Text(c.brand + ' · ' + c.year + ' 年').fontSize(9).fontColor(RA_COLORS.sub)
.margin({ top: 2 })
Row() {
Text('🔋 ' + c.battery + 'h').fontSize(9).fontColor(RA_COLORS.pixel)
Column().layoutWeight(1)
Text('¥' + c.price).fontSize(11).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.neon)
}
.width('100%').margin({ top: 6 })
}
.width('100%').backgroundColor(RA_COLORS.card).borderRadius(14)
.padding(10)
.border({ width: 1, color: RA_COLORS.line })
}
})
}
.columnsTemplate('1fr 1fr')
.columnsGap(10).rowsGap(10)
.width('100%').padding({ left: 16, right: 16, top: 12 })
Grid 组件是 ArkUI 中实现网格布局的核心组件。.columnsTemplate('1fr 1fr') 设置了两列等宽的网格布局,1fr 表示一份弹性空间。.columnsGap(10) 和 .rowsGap(10) 分别设置列间距和行间距为 10px。
每个网格项(GridItem)都是一个卡片式的掌机展示单元,内容结构如下:
-
顶部行:左侧是品牌标签(彩色背景+白字),右侧是编辑图标(铅笔 emoji)。点击编辑图标会打开编辑参数弹窗。
-
色块区域:一个 84px 高的彩色方块,显示掌机名称的首字母(大字号白色粗体)。这个色块是整个卡片的视觉焦点,点击色块会打开详情弹窗。
-
名称信息:掌机名称(加粗白字)和品牌年份(小号灰字)。
-
底部信息:左侧是电池续航(像素黄),右侧是价格(霓虹粉加粗),两端对齐排列。
每个卡片都有卡片色背景、14px 圆角和 1px 的边线边框,整体风格统一而精致。
特别值得注意的是色块区域的交互设计------点击色块主体打开详情弹窗,点击铅笔图标打开编辑弹窗。两种不同的点击区域对应两种不同的操作,这种设计在有限的卡片空间内实现了功能的区分,同时又保持了界面的简洁。
8.5 续航对比条形图
续航对比是掌机页面的第一个数据可视化模块:
typescript
Column() {
ForEach(raFilterConsoles(this.filterBrand), (c: RACons) => {
Row() {
Text(c.name.length > 5 ? c.name.substring(0, 5) : c.name)
.fontSize(10).fontColor(RA_COLORS.sub).width(64)
Column() {
Column()
.width((c.battery / 40) * 100 + '%').height(8)
.linearGradient({ angle: 90, colors: [[c.colorHex, 0], [RA_COLORS.neon, 1]] })
.borderRadius(4)
}
.layoutWeight(1).height(8).backgroundColor(RA_COLORS.cardDeep).borderRadius(4)
Text(c.battery + 'h')
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel).width(38)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ bottom: 8 })
})
}
.width('100%').padding({ left: 16, right: 16 })
续航对比图采用横向条形图的形式,每行代表一款掌机的续航数据。
每行布局分为三部分:左侧是掌机名称(固定宽度 64px),中间是进度条(弹性宽度),右侧是续航数值(固定宽度 38px)。
进度条的实现非常巧妙------外层是一个深卡片色背景的 Column(作为进度条的"槽"),内层是一个渐变色彩的 Column(作为进度条的"值")。内层 Column 的宽度使用百分比计算:(c.battery / 40) * 100 + '%',即以 40 小时为最大值计算当前续航的百分比宽度。
进度条的填充色使用 90 度角的线性渐变,从掌机的主题色渐变到霓虹粉色。这种渐变色不仅美观,还让每款掌机的进度条都有自己的色彩特征,增强了视觉辨识度。
名称区域做了长度截断处理------如果名称超过5个字符,只显示前5个字符。这是列表布局中常见的文本处理技巧,防止长名称破坏布局的整齐性。
8.6 销量柱状图
销量柱状图是掌机页面的第二个数据可视化模块,采用垂直柱状图的形式:
typescript
Row() {
ForEach(raFilterConsoles(this.filterBrand), (c: RACons) => {
Column() {
Column().layoutWeight(1)
Text(c.sales + '').fontSize(8).fontColor(RA_COLORS.sub)
Column()
.width(16).height(raBarPct(c.sales, raMaxSales()) + '%')
.backgroundColor(c.colorHex)
.borderRadius(3)
.margin({ top: 3 })
Text(c.name.length > 3 ? c.name.substring(0, 3) : c.name)
.fontSize(8).fontColor(RA_COLORS.sub).margin({ top: 4 })
}
.layoutWeight(1).height(150).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ left: 16, right: 16, bottom: 20 })
柱状图使用 Row 横向排列,每根柱子是一个 Column 组件。.layoutWeight(1) 让所有柱子平均分配行的宽度,确保布局的均匀性。整个图表区域的高度固定为 150px。
每根柱子的内容从上到下依次是:弹性空白(.layoutWeight(1),用于将柱子推到底部)、销量数字、柱体本身、名称缩写。
柱体的宽度固定为 16px,高度则通过 raBarPct 函数计算得出。raBarPct 函数将销量值转换为百分比高度,最大值(最高销量)对应 72% 的高度(不是100%,留出顶部空间显示数字)。每根柱子的颜色使用对应掌机的主题色,底部圆角 3px。
这种柱状图的实现方式非常轻量------完全用基础布局组件搭建,不需要引入专门的图表库。对于简单的数据可视化场景,这种做法既高效又灵活,开发者可以完全控制每一个视觉细节。
名称区域同样做了长度截断处理(最多3个字符),确保在狭窄的柱状图空间内不会出现文字溢出。
#mermaid-svg-4KZSlEERvRldLVN9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4KZSlEERvRldLVN9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4KZSlEERvRldLVN9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4KZSlEERvRldLVN9 .error-icon{fill:#552222;}#mermaid-svg-4KZSlEERvRldLVN9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4KZSlEERvRldLVN9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4KZSlEERvRldLVN9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4KZSlEERvRldLVN9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4KZSlEERvRldLVN9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4KZSlEERvRldLVN9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4KZSlEERvRldLVN9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4KZSlEERvRldLVN9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4KZSlEERvRldLVN9 .marker.cross{stroke:#333333;}#mermaid-svg-4KZSlEERvRldLVN9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4KZSlEERvRldLVN9 p{margin:0;}#mermaid-svg-4KZSlEERvRldLVN9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4KZSlEERvRldLVN9 .cluster-label text{fill:#333;}#mermaid-svg-4KZSlEERvRldLVN9 .cluster-label span{color:#333;}#mermaid-svg-4KZSlEERvRldLVN9 .cluster-label span p{background-color:transparent;}#mermaid-svg-4KZSlEERvRldLVN9 .label text,#mermaid-svg-4KZSlEERvRldLVN9 span{fill:#333;color:#333;}#mermaid-svg-4KZSlEERvRldLVN9 .node rect,#mermaid-svg-4KZSlEERvRldLVN9 .node circle,#mermaid-svg-4KZSlEERvRldLVN9 .node ellipse,#mermaid-svg-4KZSlEERvRldLVN9 .node polygon,#mermaid-svg-4KZSlEERvRldLVN9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4KZSlEERvRldLVN9 .rough-node .label text,#mermaid-svg-4KZSlEERvRldLVN9 .node .label text,#mermaid-svg-4KZSlEERvRldLVN9 .image-shape .label,#mermaid-svg-4KZSlEERvRldLVN9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-4KZSlEERvRldLVN9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4KZSlEERvRldLVN9 .rough-node .label,#mermaid-svg-4KZSlEERvRldLVN9 .node .label,#mermaid-svg-4KZSlEERvRldLVN9 .image-shape .label,#mermaid-svg-4KZSlEERvRldLVN9 .icon-shape .label{text-align:center;}#mermaid-svg-4KZSlEERvRldLVN9 .node.clickable{cursor:pointer;}#mermaid-svg-4KZSlEERvRldLVN9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4KZSlEERvRldLVN9 .arrowheadPath{fill:#333333;}#mermaid-svg-4KZSlEERvRldLVN9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4KZSlEERvRldLVN9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4KZSlEERvRldLVN9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4KZSlEERvRldLVN9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4KZSlEERvRldLVN9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4KZSlEERvRldLVN9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4KZSlEERvRldLVN9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4KZSlEERvRldLVN9 .cluster text{fill:#333;}#mermaid-svg-4KZSlEERvRldLVN9 .cluster span{color:#333;}#mermaid-svg-4KZSlEERvRldLVN9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4KZSlEERvRldLVN9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4KZSlEERvRldLVN9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-4KZSlEERvRldLVN9 .icon-shape,#mermaid-svg-4KZSlEERvRldLVN9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4KZSlEERvRldLVN9 .icon-shape p,#mermaid-svg-4KZSlEERvRldLVN9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4KZSlEERvRldLVN9 .icon-shape .label rect,#mermaid-svg-4KZSlEERvRldLVN9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4KZSlEERvRldLVN9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4KZSlEERvRldLVN9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4KZSlEERvRldLVN9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 掌机页面数据流
是
否
filterBrand 状态
raFilterConsoles 筛选函数
双列 Grid 展示
续航对比条形图
销量柱状图
点击色块
设置 selConsole
showDetail = true
显示详情弹窗
点击铅笔图标
设置 selConsole/editBattery/editPrice
showEdit = true
显示编辑弹窗
点击新增收藏
selConsole 是否 null?
默认选中第一款
使用当前选中
初始化编辑数据
显示编辑弹窗
九、详情弹窗与编辑弹窗:模态交互的实现
9.1 弹窗架构的设计模式
掌机页面包含两个弹窗组件:详情弹窗(consoleDetailModal)和编辑弹窗(editParamModal)。这两个弹窗虽然内容和功能不同,但都遵循了相同的架构模式。
弹窗的实现基于 Stack 堆叠布局------弹窗内容堆叠在主内容之上,通过 zIndex 控制层级,通过状态变量控制显隐。这种实现方式在移动端UI开发中非常常见,也是实现模态对话框的标准做法。
每个弹窗都由两部分组成:半透明遮罩层和弹窗内容层。遮罩层用于遮挡背景内容,同时提供点击关闭的交互。内容层则承载具体的弹窗功能。
9.2 弹框遮罩的实现
modalOverlay 方法封装了遮罩层的构建:
typescript
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(20,10,40,0.6)')
.onClick(onClose)
}
遮罩层是一个充满整个屏幕的 Column 组件,背景色使用半透明的深紫色 rgba(20,10,40,0.6)。alpha 值 0.6 表示 60% 的不透明度,既能让背景内容适当变暗以突出弹窗,又能保持一定的上下文可见性。
遮罩层接收一个 onClose 回调参数,点击遮罩时会调用这个回调。这种设计将"点击遮罩关闭"的行为抽象出来,具体的关闭逻辑由调用方决定,提高了代码的复用性。
9.3 编辑参数弹窗详解
编辑参数弹窗是一个滑条表单样式的弹窗,用于调整掌机的电池续航和参考价格:
typescript
@Builder editParamModal() {
Column() {
this.modalOverlay(() => { this.showEdit = false })
Column() {
// 标题栏
Row() {
Text('🎚 编辑参数').fontSize(16).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Column().layoutWeight(1)
Text('✕').fontSize(15).fontColor(RA_COLORS.sub)
.onClick(() => { this.showEdit = false })
}
// 掌机信息行
Row() {
Column().width(38).height(38).borderRadius(10)
.backgroundColor(this.selConsole!.colorHex)
Column() {
Text(this.selConsole!.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text(this.selConsole!.brand + ' · ' + this.selConsole!.year).fontSize(10).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1).padding({ left: 10 })
}
// 电池续航滑条
Text('电池续航').fontSize(11).fontColor(RA_COLORS.sub)
Slider({ value: this.editBattery, min: 1, max: 40, step: 1 })
.selectedColor(RA_COLORS.neon)
.trackColor(RA_COLORS.line)
.blockColor(RA_COLORS.pixel)
.onChange((v: number) => { this.editBattery = v })
Row() {
Text('🔋 ' + this.editBattery + ' 小时')
.fontSize(22).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel)
Column().layoutWeight(1)
Text('BATTERY').fontSize(9).fontColor(RA_COLORS.sub)
}
// 价格滑条
Text('参考售价').fontSize(11).fontColor(RA_COLORS.sub)
Slider({ value: this.editPrice, min: 100, max: 2500, step: 50 })
.selectedColor(RA_COLORS.pixel)
.trackColor(RA_COLORS.line)
.blockColor(RA_COLORS.neon)
.onChange((v: number) => { this.editPrice = v })
Row() {
Text('¥' + this.editPrice)
.fontSize(22).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.neon)
Column().layoutWeight(1)
Text('PRICE').fontSize(9).fontColor(RA_COLORS.sub)
}
// 操作按钮
Row() {
Text('取消')
.onClick(() => { this.showEdit = false })
Text('保存参数')
.onClick(() => { this.showEdit = false })
}
}
.width('88%').backgroundColor(RA_COLORS.card).borderRadius(18)
.alignItems(HorizontalAlign.Start)
.position({ x: '6%', y: '20%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
编辑弹窗的结构非常清晰,从上到下依次是:标题栏、掌机信息行、电池续航滑条区、价格滑条区、操作按钮区。
标题栏:左侧是弹窗标题"🎚 编辑参数",右侧是关闭按钮"✕"。点击关闭按钮会将 showEdit 设置为 false,关闭弹窗。
掌机信息行:展示当前正在编辑的掌机信息,包括一个 38px 的彩色方块(使用掌机的主题色)和掌机名称/品牌/年份信息。这让用户清楚地知道自己正在编辑哪款掌机的参数。
电池续航滑条:使用 Slider 组件实现,取值范围 1-40 小时,步长 1 小时。滑块的已选中部分使用霓虹粉色,轨道使用分割线色,滑块按钮使用像素黄色。这三个颜色的搭配既符合应用的整体配色,又清晰地表达了滑条的各个组成部分。
滑条下方是当前值的大字号显示,使用 22px 的像素黄粗体字,非常醒目。右侧是"BATTERY"英文标签,增加了设计感。
参考价格滑条:结构与电池续航滑条类似,但取值范围是 100-2500 元,步长 50 元。颜色方案做了对调------已选中部分用像素黄色,滑块按钮用霓虹粉色。这种对调让两个滑条在视觉上有所区分,避免混淆。
操作按钮:底部有"取消"和"保存参数"两个按钮,取消按钮是深色背景浅色文字,保存按钮是霓虹粉背景深色文字(主按钮样式)。两个按钮都是胶囊形(borderRadius 20px),居中排列。
弹窗内容框使用 88% 的宽度,通过 .position({ x: '6%', y: '20%' }) 定位在屏幕水平居中、偏上的位置。.zIndex(999) 确保弹窗显示在最上层。
9.4 详情弹窗:参数表展示
详情弹窗以参数表的形式展示掌机的完整信息:
typescript
@Builder consoleDetailModal() {
Column() {
this.modalOverlay(() => { this.showDetail = false })
Column() {
// 头部信息区
Row() {
Column() {
Text(this.selConsole!.name.substring(0, 1))
.fontSize(26).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
}
.width(64).height(64).borderRadius(16)
.backgroundColor(this.selConsole!.colorHex)
.shadow({ radius: 14, color: this.selConsole!.colorHex })
Column() {
Text(this.selConsole!.name).fontSize(18).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text(this.selConsole!.brand + ' · ' + this.selConsole!.year + ' 年')
.fontSize(11).fontColor(RA_COLORS.sub).margin({ top: 3 })
Text(this.selConsole!.cat + ' · ' + this.selConsole!.id + ' 号机')
.fontSize(10).fontColor(RA_COLORS.pixel).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1).padding({ left: 14 })
Text('✕').fontSize(15).fontColor(RA_COLORS.sub)
.onClick(() => { this.showDetail = false })
}
// 参数表
Row() {
Text('📋 参数表 SPECS').fontSize(11).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.neon)
Column().layoutWeight(1)
Text('STANDARD').fontSize(9).fontColor(RA_COLORS.sub)
}
Column() {
Row() {
Text('发售年份').fontSize(12).fontColor(RA_COLORS.sub).width(88)
Column().layoutWeight(1)
Text(this.selConsole!.year + ' 年').fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
}
Divider().color(RA_COLORS.line)
// ... 更多参数行
}
.backgroundColor(RA_COLORS.cardDeep).borderRadius(12)
// 底部按钮
Row() {
Text('加入收藏')
.borderRadius(20).border({ width: 1, color: RA_COLORS.pixel })
Text('编辑参数')
.backgroundColor(RA_COLORS.neon).borderRadius(20)
.onClick(() => {
this.editBattery = this.selConsole!.battery
this.editPrice = this.selConsole!.price
this.showDetail = false
this.showEdit = true
})
}
}
.width('90%').backgroundColor(RA_COLORS.card).borderRadius(18)
.position({ x: '5%', y: '14%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
详情弹窗的内容更加丰富,分为头部信息区、参数表区和底部按钮区三大部分。
头部信息区:左侧是一个 64px 的大色块,显示掌机名称的首字母,带有同色的发光阴影效果(shadow)。这个发光阴影让色块看起来像是在发光,增强了视觉冲击力。右侧是掌机的名称、品牌年份和分类编号信息,三级文字层次分明。
参数表:以表格形式展示掌机的各项参数,包括发售年份、所属品牌、屏幕规格、整机重量、电池续航、首发参考价等。每个参数行都是一个 Row 布局,左侧是参数名(灰紫色文字,固定宽度 88px),右侧是参数值(白色加粗文字)。行与行之间用 Divider 分割线分隔。整个参数表使用深卡片色背景,形成一个嵌入式的表格区域。
不同类型的参数值使用不同的颜色突出显示:电池续航用像素黄色,价格用霓虹粉色。这种差异化的颜色处理让重要数据更加醒目,用户可以快速找到自己关心的信息。
底部按钮:有"加入收藏"和"编辑参数"两个按钮。"加入收藏"是描边按钮(像素黄色边框+文字),"编辑参数"是填充按钮(霓虹粉色背景)。
编辑参数按钮的点击事件很有意思------它先将 editBattery 和 editPrice 初始化为当前掌机的值,然后关闭详情弹窗(showDetail = false),再打开编辑弹窗(showEdit = true)。这实现了从详情页跳转到编辑页的交互流程。
9.5 弹窗的层级管理与状态同步
在 build 方法中,两个弹窗通过条件渲染的方式添加到 Stack 布局中:
typescript
Stack() {
Column() { /* 主内容 */ }
if (this.showEdit) { this.editParamModal() }
if (this.showDetail) { this.consoleDetailModal() }
}
两个弹窗都是独立的条件渲染,各自控制自己的显隐。由于编辑弹窗和详情弹窗不会同时显示(从详情跳转到编辑时会先关闭详情),所以不存在层级冲突的问题。
弹窗组件的 zIndex 都设置为 999,确保它们始终显示在主内容之上。在 ArkUI 的 Stack 布局中,后渲染的组件会显示在先渲染的组件之上,同时 zIndex 也可以调整堆叠顺序。
从架构的角度看,弹窗的状态管理模式可以总结为:
- 每个弹窗对应一个布尔型的 @State 变量
- 打开弹窗 = 设置变量为 true
- 关闭弹窗 = 设置变量为 false
- 弹窗内容通过条件渲染(if)添加到 Stack 中
- 弹窗所需的数据通过其他 @State 变量传递(如 selConsole)
这种模式简洁直观,易于理解和维护。对于弹窗数量不多的场景,这是一种非常高效的实现方式。
十、卡带页面:稀有度排行与年代分布
10.1 卡带页面的整体设计
RACartTab 组件是卡带排行榜页面,以稀有度为核心指标展示游戏卡带收藏。相比于掌机页面的丰富功能,卡带页面更加聚焦于内容展示和数据统计,交互相对简洁。
卡带页面只有一个状态变量 filterGenre,用于控制游戏类型的筛选。页面内容包括头部横幅、类型筛选标签、稀有度排行榜、年份分布统计和收藏小贴士等模块。
10.2 头部横幅设计
卡带页面的头部横幅采用了与掌机页面类似的结构,但配色有所不同:
typescript
Column() {
Row() {
Column() {
Text('💾 卡带排行榜').fontSize(20).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text('8 款稀有卡带 · 按稀有度降序')
.fontSize(10).fontColor(RA_COLORS.sub).margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('RARITY 榜').fontSize(11).fontColor(RA_COLORS.pixel)
}
.width('100%').alignItems(VerticalAlign.Center)
Row() {
Text('TOP 收藏榜单 · 横竖屏皆可滑')
.fontSize(9).fontColor(RA_COLORS.sub)
Column().layoutWeight(1)
Text('💎 稀有度').fontSize(10).fontColor(RA_COLORS.neon)
}
.width('100%').margin({ top: 10 })
}
.linearGradient({ angle: 120, colors: [['#2A1B4D', 0], ['#4A1D3C', 1]] })
卡带页面的渐变背景使用了偏红的紫色调 #4A1D3C,与掌机页面的偏蓝紫色调形成区分。这种微妙的色彩差异让每个Tab页面都有自己的色彩个性,同时又保持了整体风格的统一。
右上角的标签从"销量TOP"变成了"RARITY 榜",点明了卡带页面的核心主题------稀有度。右下角的标签使用霓虹粉色的"💎 稀有度",与钻石emoji搭配,形象地传达了稀有度的概念。
10.3 稀有度排行榜详解
稀有度排行榜是卡带页面的核心内容:
typescript
Column() {
ForEach(raRareCarts(raFilterCarts(this.filterGenre)), (c: RACart, i: number) => {
Column() {
Row() {
Text((i + 1).toString())
.fontSize(16).fontWeight(FontWeight.Bold).width(30)
.fontColor(i === 0 ? RA_COLORS.neon : (i === 1 ? RA_COLORS.pixel : (i === 2 ? '#7C4DFF' : RA_COLORS.sub)))
Column() {
Text(c.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text(c.platform + ' · ' + c.genre + ' · ' + c.year + ' 年')
.fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1).padding({ left: 6 })
Text('¥' + c.price)
.fontSize(11).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.neon)
}
.width('100%').alignItems(VerticalAlign.Center)
Row() {
Text('稀有度').fontSize(9).fontColor(RA_COLORS.sub).width(42)
Column() {
Column()
.width(c.rarity + '%').height(7)
.linearGradient({ angle: 90, colors: [[c.colorHex, 0], [RA_COLORS.pixel, 1]] })
.borderRadius(4)
}
.layoutWeight(1).height(7).backgroundColor(RA_COLORS.cardDeep).borderRadius(4)
Text(c.rarity + '%')
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel).width(44)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ top: 6 })
Row() {
Text('销量 ' + c.sales + ' 份').fontSize(9).fontColor(RA_COLORS.sub)
Column().layoutWeight(1)
Text('RARITY ' + c.rarity).fontSize(8).fontColor(RA_COLORS.line)
}
.width('100%').margin({ top: 5 })
}
.width('100%').backgroundColor(RA_COLORS.card).borderRadius(12)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.margin({ top: 8 })
.border({ width: 1, color: i === 0 ? RA_COLORS.neon : RA_COLORS.line })
})
}
每个排行项都是一个三层结构的卡片:
第一层:基本信息行:左侧是排名数字(金银铜配色),中间是游戏名称和平台/类型/年份信息,右侧是价格。这一层的布局与热销榜类似,但信息更加丰富。
第二层:稀有度进度条:左侧是"稀有度"标签,中间是进度条,右侧是稀有度百分比数值。进度条的宽度直接使用 c.rarity + '%',因为稀有度本身就是 0-100 的数值,可以直接作为百分比使用。进度条使用渐变色填充,从卡带的主题色渐变到像素黄色。
第三层:辅助信息行:左侧是销量数据,右侧是稀有度的英文标签(用极淡的颜色,几乎与背景融为一体)。这一行虽然信息密度不高,但起到了平衡视觉和丰富细节的作用。
排行卡片的边框颜色也有讲究------第一名的卡片使用霓虹粉色边框突出显示,其他名次使用普通的分割线色边框。这种细节上的差异化处理强化了排名的等级感。
数据来源使用了函数组合:raRareCarts(raFilterCarts(this.filterGenre))。先按类型筛选,再按稀有度排序。这种函数式的组合调用非常优雅,数据处理的逻辑清晰可见。
10.4 年份分布统计
年份分布模块展示了不同年代的卡带数量分布:
typescript
Column() {
ForEach(RA_ERAS, (e: string) => {
Row() {
Text(e).fontSize(10).fontColor(RA_COLORS.sub).width(48)
Column() {
Column()
.width(raBarPct(raCartEraCount(e), 8) + '%').height(10)
.backgroundColor(RA_COLORS.neon)
.borderRadius(5)
}
.layoutWeight(1).height(10).backgroundColor(RA_COLORS.cardDeep).borderRadius(5)
Text(raCartEraCount(e) + ' 款')
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel).width(40)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ bottom: 9 })
})
}
年份分布使用横向条形图展示,四个年代(80年代、90年代、00年代、10年代)从上到下排列。
每个条形图的宽度由 raBarPct 函数计算得出,最大值是总卡带数 8。条形图的填充色统一使用霓虹粉色,与进度条的多彩设计不同,这里使用单色是为了保持统计图表的简洁性。
raCartEraCount 函数用于统计某个年代的卡带数量,它内部调用 raEraOf 函数判断每款卡带所属的年代。这种分层调用的函数设计体现了"单一职责"的原则------每个函数只负责一件事。
10.5 收藏小贴士
收藏小贴士是一个贴心的功能模块:
typescript
Column() {
Row() {
Text('📖 收藏小贴士').fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Column().layoutWeight(1)
Text('TIP').fontSize(9).fontColor(RA_COLORS.sub)
}
.width('100%')
Text('稀有度 ≥ 90 的卡带建议防潮盒保存;GB 系卡带金手指发黑可用橡皮轻擦恢复。')
.fontSize(10).fontColor(RA_COLORS.sub).lineHeight(16)
.width('100%').margin({ top: 6 })
}
.width('100%').backgroundColor(RA_COLORS.card).borderRadius(12)
.padding(14).margin({ left: 16, right: 16, top: 8, bottom: 20 })
这个模块用一个卡片展示了两条收藏保养建议:高稀有度卡带的保存方法和GB卡带金手指的清洁技巧。这些实用的小知识让应用不仅仅是一个商品展示工具,更像是一个专业的收藏指南。
.lineHeight(16) 设置了行高,让多行文字的阅读体验更加舒适。对于说明性文字,适当的行高是提升可读性的关键因素。
这个模块虽然简单,但体现了产品设计中的"内容增值"思维------在功能之外,为用户提供有价值的附加信息,提升用户对产品的认同感和粘性。
#mermaid-svg-VzZI1wiWa1MmXAe8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VzZI1wiWa1MmXAe8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VzZI1wiWa1MmXAe8 .error-icon{fill:#552222;}#mermaid-svg-VzZI1wiWa1MmXAe8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VzZI1wiWa1MmXAe8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VzZI1wiWa1MmXAe8 .marker.cross{stroke:#333333;}#mermaid-svg-VzZI1wiWa1MmXAe8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VzZI1wiWa1MmXAe8 p{margin:0;}#mermaid-svg-VzZI1wiWa1MmXAe8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VzZI1wiWa1MmXAe8 .cluster-label text{fill:#333;}#mermaid-svg-VzZI1wiWa1MmXAe8 .cluster-label span{color:#333;}#mermaid-svg-VzZI1wiWa1MmXAe8 .cluster-label span p{background-color:transparent;}#mermaid-svg-VzZI1wiWa1MmXAe8 .label text,#mermaid-svg-VzZI1wiWa1MmXAe8 span{fill:#333;color:#333;}#mermaid-svg-VzZI1wiWa1MmXAe8 .node rect,#mermaid-svg-VzZI1wiWa1MmXAe8 .node circle,#mermaid-svg-VzZI1wiWa1MmXAe8 .node ellipse,#mermaid-svg-VzZI1wiWa1MmXAe8 .node polygon,#mermaid-svg-VzZI1wiWa1MmXAe8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VzZI1wiWa1MmXAe8 .rough-node .label text,#mermaid-svg-VzZI1wiWa1MmXAe8 .node .label text,#mermaid-svg-VzZI1wiWa1MmXAe8 .image-shape .label,#mermaid-svg-VzZI1wiWa1MmXAe8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-VzZI1wiWa1MmXAe8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VzZI1wiWa1MmXAe8 .rough-node .label,#mermaid-svg-VzZI1wiWa1MmXAe8 .node .label,#mermaid-svg-VzZI1wiWa1MmXAe8 .image-shape .label,#mermaid-svg-VzZI1wiWa1MmXAe8 .icon-shape .label{text-align:center;}#mermaid-svg-VzZI1wiWa1MmXAe8 .node.clickable{cursor:pointer;}#mermaid-svg-VzZI1wiWa1MmXAe8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VzZI1wiWa1MmXAe8 .arrowheadPath{fill:#333333;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VzZI1wiWa1MmXAe8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VzZI1wiWa1MmXAe8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VzZI1wiWa1MmXAe8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VzZI1wiWa1MmXAe8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VzZI1wiWa1MmXAe8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VzZI1wiWa1MmXAe8 .cluster text{fill:#333;}#mermaid-svg-VzZI1wiWa1MmXAe8 .cluster span{color:#333;}#mermaid-svg-VzZI1wiWa1MmXAe8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VzZI1wiWa1MmXAe8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VzZI1wiWa1MmXAe8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-VzZI1wiWa1MmXAe8 .icon-shape,#mermaid-svg-VzZI1wiWa1MmXAe8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VzZI1wiWa1MmXAe8 .icon-shape p,#mermaid-svg-VzZI1wiWa1MmXAe8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VzZI1wiWa1MmXAe8 .icon-shape .label rect,#mermaid-svg-VzZI1wiWa1MmXAe8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VzZI1wiWa1MmXAe8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VzZI1wiWa1MmXAe8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VzZI1wiWa1MmXAe8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 卡带页面加载
显示全部类型的卡带
按稀有度降序排列
渲染排行榜列表
点击类型筛选
更新 filterGenre
重新筛选并排序
年份分布统计
遍历四个年代
计算每年代卡带数量
渲染条形图
十一、街机页面:横向大卡与展开交互
11.1 街机页面的特色功能
RAArcadeTab 组件是街机厅页面,这个页面最具特色的功能是横向大卡的展开交互和游戏类型选择弹窗。街机页面的设计语言与其他页面有所不同,更加突出街机的"大型设备"感和"游戏厅"氛围。
街机页面的状态变量包括:筛选类型 filterGenre、展开的卡片ID expandedId、类型选择弹窗显隐 showGenreModal。其中 expandedId 是一个有趣的设计------它存储当前展开的街机ID,-1 表示没有展开任何卡片。当用户点击一张卡片时,如果这张卡片已经展开(expandedId === a.id),就收起它;否则展开它。这种设计实现了"手风琴"式的展开收起交互。
11.2 游戏类型选择弹窗
游戏类型选择弹窗是街机页面的特色交互组件,采用宫格选择样式:
typescript
@Builder genreSelectModal() {
Column() {
this.modalOverlay(() => { this.showGenreModal = false })
Column() {
Row() {
Text('🎯 选择游戏类型').fontSize(16).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Column().layoutWeight(1)
Text('✕').fontSize(15).fontColor(RA_COLORS.sub)
.onClick(() => { this.showGenreModal = false })
}
.padding({ left: 18, right: 18, top: 16, bottom: 4 })
Grid() {
GridItem() {
Column() {
Text('🗂').fontSize(24)
Text('全部').fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(this.filterGenre === '全部' ? '#FFFFFF' : RA_COLORS.text)
.margin({ top: 6 })
Text('8 款').fontSize(9).fontColor(RA_COLORS.pixel).margin({ top: 2 })
}
.width('100%').alignItems(HorizontalAlign.Center)
.backgroundColor(this.filterGenre === '全部' ? RA_COLORS.neon : RA_COLORS.cardDeep)
.borderRadius(12).padding({ top: 14, bottom: 14 })
.onClick(() => {
this.filterGenre = '全部'
this.showGenreModal = false
})
}
ForEach(RA_ARC_GENRES, (g: RAGenreMeta) => {
GridItem() {
Column() {
Text(g.icon).fontSize(24)
Text(g.label).fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(this.filterGenre === g.label ? '#FFFFFF' : RA_COLORS.text)
.margin({ top: 6 })
Text(raArcGenreCount(g.label) + ' 款').fontSize(9).fontColor(RA_COLORS.pixel)
.margin({ top: 2 })
}
.width('100%').alignItems(HorizontalAlign.Center)
.backgroundColor(this.filterGenre === g.label ? g.colorHex : RA_COLORS.cardDeep)
.borderRadius(12).padding({ top: 14, bottom: 14 })
.onClick(() => {
this.filterGenre = g.label
this.showGenreModal = false
})
}
})
}
.columnsTemplate('1fr 1fr')
.columnsGap(10).rowsGap(10)
.width('100%').height(230)
.padding({ left: 16, right: 16, top: 10 })
Text('查看全部机型')
.fontSize(12).fontColor(RA_COLORS.pixel)
.borderRadius(18).border({ width: 1, color: RA_COLORS.pixel })
.padding({ left: 28, right: 28, top: 9, bottom: 9 })
.margin({ bottom: 18 })
.onClick(() => {
this.filterGenre = '全部'
this.showGenreModal = false
})
}
.width('86%').backgroundColor(RA_COLORS.card).borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '7%', y: '26%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
类型选择弹窗使用两列网格布局展示游戏类型选项,包括一个"全部"选项和四个具体类型选项(格斗、射击、清版、休闲)。
每个选项都是一个卡片式的按钮,包含图标、类型名称和数量统计三个元素。选中状态下,卡片背景变为该类型的主题色,文字变为白色;未选中状态下,卡片使用深卡片色背景,文字使用普通白色。
这种"选中即变色"的设计非常直观,用户一眼就能看出当前选中的是哪个类型。每个类型都有自己的主题色,颜色与图标呼应,形成了强烈的视觉识别性。
"全部"选项使用霓虹粉色作为选中色,与其他类型的各自主题色形成区分。底部还有一个"查看全部机型"的描边按钮,提供了另一种快速切换到"全部"的入口。
点击任意类型选项后,会更新 filterGenre 的值并关闭弹窗。关闭弹窗后,主页面的街机列表会自动根据新的筛选条件重新渲染,这就是声明式UI的响应式特性------数据变化驱动UI自动更新。
11.3 横向大卡与展开交互
街机页面的核心展示形式是横向滚动的大卡片,每张卡片可以展开显示框体尺寸信息:
typescript
Scroll() {
Row() {
ForEach(raFilterArcs(this.filterGenre), (a: RAArc) => {
Column() {
Row() {
Text(a.cabinet)
.fontSize(9).fontColor('#FFFFFF')
.backgroundColor(a.colorHex).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Column().layoutWeight(1)
Text(a.score + ' 分')
.fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel)
}
.width('100%').alignItems(VerticalAlign.Center)
Text(a.name)
.fontSize(17).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
.margin({ top: 10 })
Text(a.genre + ' · ' + a.year + ' 年 · 销量 ' + a.sales + ' 币')
.fontSize(10).fontColor(RA_COLORS.sub).margin({ top: 4 })
Divider().color(RA_COLORS.line).margin({ top: 10 })
if (this.expandedId === a.id) {
// 展开内容:框体尺寸
Column() {
Row() {
Text('📐 框体尺寸').fontSize(11).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.neon)
Column().layoutWeight(1)
Text('CABINET').fontSize(9).fontColor(RA_COLORS.sub)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ top: 10 })
this.raCabSize('宽', a.width, a.colorHex)
this.raCabSize('深', a.depth, a.colorHex)
this.raCabSize('高', a.height, a.colorHex)
Text('▲ 收起')
.fontSize(10).fontColor(RA_COLORS.sub)
.margin({ top: 10 })
}
.width('100%')
} else {
Column() {
Text('▾ 点击展开框体尺寸')
.fontSize(10).fontColor(RA_COLORS.sub)
.margin({ top: 12 })
}
.width('100%')
}
}
.width(280).alignItems(HorizontalAlign.Start)
.backgroundColor(RA_COLORS.card).borderRadius(16)
.padding(14)
.margin({ right: 12 })
.border({ width: 1, color: a.colorHex })
.onClick(() => {
this.expandedId = this.expandedId === a.id ? -1 : a.id
})
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
.width('100%')
每张横向大卡的宽度为 280px,比普通卡片宽很多,为内容展示提供了充足的空间。卡片内容分为几个部分:
顶部信息行:左侧是框体类型标签(彩色背景+白字),右侧是评分(像素黄色加粗)。
游戏名称:使用 17px 的大号粗体字,是卡片的视觉焦点。
副标题信息:游戏类型、年份、销量,使用小号灰字。
分割线:分隔上下两部分内容。
展开/收起区域:这是卡片的交互核心。当 expandedId === a.id 时(当前卡片被展开),显示框体尺寸详情;否则显示"点击展开框体尺寸"的提示文字。
展开区域内显示三条尺寸数据(宽、深、高),每条都使用进度条形式可视化展示。底部有"▲ 收起"提示,引导用户点击收起。
点击事件的处理逻辑非常简洁:this.expandedId = this.expandedId === a.id ? -1 : a.id。这行代码的意思是:如果当前卡片已经是展开状态,就设置为 -1(收起);否则设置为当前卡片的ID(展开)。这是实现"手风琴"效果的经典写法。
由于 expandedId 是单一变量(而不是每个卡片一个布尔值),所以同时只能有一张卡片处于展开状态。点击另一张卡片时,之前展开的卡片会自动收起。这种交互模式符合用户的预期,也让界面保持整洁。
11.4 框体尺寸条组件
raCabSize 方法封装了单条尺寸数据的展示:
typescript
@Builder raCabSize(label: string, v: number, colorHex: string) {
Row() {
Text(label).fontSize(10).fontColor(RA_COLORS.sub).width(30)
Column() {
Column()
.width((v / raMaxArcWidth()) * 100 + '%').height(8)
.backgroundColor(colorHex)
.borderRadius(4)
}
.layoutWeight(1).height(8).backgroundColor(RA_COLORS.cardDeep).borderRadius(4)
Text(v + 'cm')
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text).width(44)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ top: 8 })
}
这是一个典型的进度条组件,结构与续航对比条类似。左侧是尺寸标签(宽/深/高),中间是进度条,右侧是具体数值(带单位cm)。
进度条的最大值由 raMaxArcWidth() 函数返回,即所有街机中的最大宽度值。这样所有尺寸都以最大宽度为基准进行比例展示,用户可以直观地比较各台街机的尺寸差异。
进度条的颜色使用传入的 colorHex 参数,即街机的主题色。这样每台街机的尺寸条都有自己的颜色,与卡片边框颜色一致,保持了视觉的统一性。
11.5 游戏类型分布统计
游戏类型分布模块以条形图形式展示各类型的街机数量:
typescript
Column() {
ForEach(RA_ARC_GENRES, (g: RAGenreMeta) => {
Row() {
Text(g.icon).fontSize(14).width(30)
Text(g.label).fontSize(10).fontColor(RA_COLORS.sub).width(40)
Column() {
Column()
.width(raBarPct(raArcGenreCount(g.label), 4) + '%').height(10)
.backgroundColor(g.colorHex)
.borderRadius(5)
}
.layoutWeight(1).height(10).backgroundColor(RA_COLORS.cardDeep).borderRadius(5)
Text(raArcGenreCount(g.label) + ' 款')
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel).width(40)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ bottom: 9 })
})
}
与年份分布不同的是,这里每个类型前面都有一个图标(emoji),并且进度条使用各类型自己的主题色。这样设计让类型分布图表更加生动有趣,也与类型选择弹窗的视觉风格保持一致。
最大值设置为 4(因为当前数据中最多的类型有 2 款,但可能预留了扩展空间)。raBarPct 函数将数量转换为 0-72% 的进度条宽度。
十二、周边页面:色卡网格与删除确认
12.1 周边页面的设计特点
RAMerchTab 组件是周边商店页面,这个页面的最大特色是三列色卡网格布局------每款周边商品以一个带有主题色的色块卡片形式展示,色彩丰富而有层次感。
周边页面的状态变量包括:删除确认弹窗显隐 showDeleteConfirm、选中的周边商品 selMerch。删除功能是周边页面的特色交互,用户点击色卡上的"×"按钮会弹出删除确认框。
12.2 删除确认弹窗设计
删除确认弹窗是一个红色警告样式的危险操作弹窗:
typescript
@Builder deleteConfirmModal() {
Column() {
this.modalOverlay(() => { this.showDeleteConfirm = false })
Column() {
Text('🗑').fontSize(44).margin({ top: 24 })
Text('确认删除该周边?')
.fontSize(17).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
.margin({ top: 8 })
Text('删除后将从收藏馆永久移除,不可恢复')
.fontSize(11).fontColor('#FF5C5C').margin({ top: 4 })
// 商品信息行
Row() {
Column().width(28).height(28).borderRadius(8)
.backgroundColor(this.selMerch!.colorHex)
Column() {
Text(this.selMerch!.name)
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text(this.selMerch!.category + ' · ¥' + this.selMerch!.price)
.fontSize(10).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1).padding({ left: 10 })
Text('热度 ' + this.selMerch!.heat)
.fontSize(10).fontColor(RA_COLORS.pixel)
}
.width('86%').alignItems(VerticalAlign.Center)
.backgroundColor('#3A1F2E').borderRadius(10)
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.margin({ top: 14 })
.border({ width: 1, color: '#FF5C5C' })
Row() {
Text('再想想')
.onClick(() => { this.showDeleteConfirm = false })
Text('确认删除')
.backgroundColor('#E74C3C')
.onClick(() => { this.showDeleteConfirm = false })
}
.justifyContent(FlexAlign.Center)
.padding({ top: 18, bottom: 22 })
}
.width('80%').backgroundColor(RA_COLORS.card).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '10%', y: '30%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
删除确认弹窗的设计充分体现了"危险操作"的视觉警示原则:
大图标+醒目标题:顶部使用 44px 的垃圾桶 emoji 图标,配合大号加粗的"确认删除该周边?"标题,让用户一眼就明白这是一个删除操作。
红色警告文字:提示文字"删除后将从收藏馆永久移除,不可恢复"使用红色(#FF5C5C),强调操作的危险性和不可逆性。红色在UI设计中是危险、警告的标准用色,用户看到红色会自然地产生警惕心理。
商品信息确认:弹窗中间展示了即将删除的商品信息,包括色块、名称、分类、价格和热度。这让用户在确认删除前能够再次核实,防止误删。商品信息卡片使用深红棕色背景(#3A1F2E)和红色边框,进一步强化了"危险"的视觉暗示。
双按钮设计:底部有两个按钮------"再想想"(取消)和"确认删除"。取消按钮使用深色背景浅色文字的次要按钮样式,确认删除按钮使用鲜红色背景(#E74C3C)的危险按钮样式。两个按钮的视觉权重有明显差异,引导用户谨慎操作。
"再想想"这个文案也很有意思------相比于普通的"取消",它带有更强的情感暗示,像是在劝用户"再考虑一下吧,别着急删除",这种人性化的文案设计能够在一定程度上降低误删率。
12.3 三列色卡网格
周边商品以三列色卡网格的形式展示:
typescript
Grid() {
ForEach(RA_MERCHS, (m: RAMerch) => {
GridItem() {
Column() {
Column() {
Text(m.name.substring(0, 1))
.fontSize(26).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
.opacity(0.92)
Text('✕')
.fontSize(13).fontColor('#FFFFFF')
.backgroundColor('#55FFFFFF').borderRadius(9)
.padding({ left: 7, right: 7, top: 4, bottom: 4 })
.onClick(() => {
this.selMerch = m
this.showDeleteConfirm = true
})
}
.width('100%').height(72).borderRadius(12)
.backgroundColor(m.colorHex)
.padding(8)
Text(m.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
.margin({ top: 6 })
Text(m.category + ' · 热度' + m.heat)
.fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 2 })
Text('¥' + m.price)
.fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel)
.margin({ top: 4 })
}
.width('100%').alignItems(HorizontalAlign.Center)
.backgroundColor(RA_COLORS.card).borderRadius(14)
.padding({ top: 8, bottom: 10 })
.border({ width: 1, color: m.colorHex })
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(10).rowsGap(10)
三列网格通过 .columnsTemplate('1fr 1fr 1fr') 设置,三列等宽分布。每个网格项是一个周边商品卡片,整体结构为:色块区域 + 名称 + 分类热度 + 价格。
色块区域:高度 72px,使用商品的主题色作为背景。色块内左上角是名称首字母(大字号白字),右上角是删除按钮"✕"。删除按钮使用半透明白色背景(#55FFFFFF),形成一个圆形的按钮效果。
删除按钮的点击事件设置了 selMerch 为当前商品,并显示删除确认弹窗。这是"先选中再操作"的经典交互模式------点击操作按钮时先记录操作对象,然后弹出确认框,确认后执行实际操作。
卡片边框:每张卡片都有 1px 的彩色边框,颜色与色块的主题色一致。这种设计让每张卡片都有自己的色彩标识,整体排列在一起时形成了彩虹般的视觉效果,非常吸引眼球。
三列网格布局是移动端展示商品类内容的常用模式,它在信息密度和可读性之间取得了良好的平衡。相比于双列布局,三列布局能在一屏内展示更多的商品;相比于更多列的布局,三列布局的卡片尺寸仍然足够容纳关键信息。
12.4 收藏热度榜
收藏热度榜展示热度最高的前三款周边:
typescript
Column() {
ForEach(raHotMerchs(), (m: RAMerch, i: number) => {
Row() {
Text((i + 1).toString())
.fontSize(15).fontWeight(FontWeight.Bold).width(24)
.fontColor(i === 0 ? RA_COLORS.neon : (i === 1 ? RA_COLORS.pixel : RA_COLORS.sub))
Column().width(12).height(12).borderRadius(6).backgroundColor(m.colorHex)
Text(m.name)
.fontSize(11).fontColor(RA_COLORS.text).width(84)
.margin({ left: 8 })
Column() {
Column()
.width((m.heat / raMaxHeat()) * 100 + '%').height(8)
.backgroundColor(m.colorHex)
.borderRadius(4)
}
.layoutWeight(1).height(8).backgroundColor(RA_COLORS.cardDeep).borderRadius(4)
Text('m.heat')
.fontSize(11).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel).width(30)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ bottom: 9 })
})
}
热度榜每行的元素从左到右依次是:排名数字、颜色小圆点、商品名称、热度进度条、热度数值。
排名数字的颜色遵循"金银铜"原则------第一名霓虹粉,第二名像素黄,第三名灰紫色。颜色小圆点(12px 的圆形色块)让用户可以快速关联到对应的商品色卡。
热度进度条使用商品的主题色作为填充色,最大值由 raMaxHeat() 函数返回(即所有周边中的最高热度值)。进度条的长度直观地反映了热度的相对高低。
12.5 品类分布统计
品类分布模块展示各品类周边的数量分布:
typescript
Column() {
ForEach(RA_MERCH_CATS, (c: string) => {
Row() {
Text(c).fontSize(10).fontColor(RA_COLORS.sub).width(48)
Column() {
Column()
.width(raBarPct(raMerchCatCount(c), 2) + '%').height(10)
.backgroundColor(RA_COLORS.neon)
.borderRadius(5)
}
.layoutWeight(1).height(10).backgroundColor(RA_COLORS.cardDeep).borderRadius(5)
Text(raMerchCatCount(c) + ' 款')
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel).width(40)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ bottom: 9 })
})
}
品类分布的结构与其他分布统计类似,使用横向条形图展示。品类包括服饰、挂件、杯具、摆件、抱枕、贴纸六大类。
由于当前数据中每个品类只有 1 款商品(最大值为 2),所以进度条的长度差异不大。但数据结构已经为品类扩展做好了准备,未来新增更多周边商品时,品类分布图表会自动展示更丰富的分布信息。
十三、我的页面:会员体系与个人中心
13.1 我的页面的功能全景
RAMineTab 组件是个人中心页面,也是应用中功能最综合的Tab页面。它包含了会员卡片、数据统计、月消费图表、订单列表、收藏列表、新增收藏表单等多个功能模块,几乎涵盖了用户账户相关的所有内容。
我的页面的状态变量包括:霓虹灯闪烁状态 raNeon、新增收藏弹窗显隐 showAddModal、表单名称 formName、表单选中机型 formConsole、表单价格 formPrice、最近添加提示 lastAdded。
lastAdded 是一个有趣的状态变量,它存储用户最近一次添加收藏的描述文字。当用户成功添加收藏后,这个变量会被设置,页面上会显示一个绿色的提示条。用户可以手动关闭这个提示条,也会在下次进入页面时自动消失(因为初始值为空字符串)。
13.2 霓虹闪烁效果与生命周期
我的页面也使用了霓虹灯闪烁效果,但与首页不同的是,这里只有一个 raNeon 状态变量,控制会员卡片上 EXP 文字的颜色闪烁:
typescript
aboutToAppear(): void {
this.raNeonTimer = setInterval(() => {
this.raNeon = this.raNeon === 0 ? 1 : 0
}, 480)
}
aboutToDisappear(): void {
clearInterval(this.raNeonTimer)
}
闪烁间隔为 480 毫秒,比首页的 420 毫秒稍慢一些。不同页面的动画速度略有差异,避免了全应用统一节奏的单调感。
生命周期钩子的使用模式与首页完全一致------在 aboutToAppear 中启动定时器,在 aboutToDisappear 中清理定时器。这是 ArkTS 组件管理异步资源的标准最佳实践。
13.3 会员卡片设计
会员卡片是我的页面最醒目的视觉元素:
typescript
Column() {
Row() {
Column() {
Text('RETRO CARD').fontSize(9).fontColor('#FFFFFF').opacity(0.7)
Text('彩虹像素会员').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
.margin({ top: 4 })
Text('LV.8 · 收藏大师').fontSize(10).fontColor('#FFD0E8').margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('RA·2026').fontSize(10).fontColor('#FFFFFF').opacity(0.6)
}
.width('100%').alignItems(VerticalAlign.Top)
Row() {
Text('EXP 8800 / 10000')
.fontSize(10)
.fontColor(this.raNeon === 0 ? RA_COLORS.pixel : '#FFFFFF')
Column().layoutWeight(1)
Text('+1200 本月').fontSize(10).fontColor('#FFFFFF').opacity(0.8)
}
.width('100%').margin({ top: 14 })
Progress({ value: 88, total: 100, type: ProgressType.Linear })
.color(RA_COLORS.pixel).style({ strokeWidth: 6 })
.width('100%').margin({ top: 6 })
}
.width('100%').borderRadius(16)
.linearGradient({ angle: 120, colors: [['#6A1B9A', 0], ['#1B1035', 0.5], ['#FF2E88', 1]] })
.padding(16)
.shadow({ radius: 16, color: '#66FF2E88', offsetY: 6 })
会员卡片采用了三色渐变背景,从深紫色到深紫色再到霓虹粉色,角度为 120 度。这种渐变色彩营造出高端会员卡的质感,霓虹粉的边缘发光效果增强了科技感。
卡片底部添加了霓虹粉色的发光阴影(shadow),让卡片看起来像是在发光。这是一种常用的"发光效果"实现技巧------用同色系的阴影模拟物体的自发光。
卡片内容分为三行:
第一行:左侧是会员信息------卡类型(RETRO CARD)、会员等级名称(彩虹像素会员)、等级头衔(LV.8 · 收藏大师)。右侧是卡号(RA·2026)。文字大小和透明度的变化形成了清晰的视觉层次。
第二行:左侧是 EXP 数值(8800 / 10000),这个文字的颜色会在像素黄和白色之间闪烁(霓虹灯效果)。右侧是本月新增经验(+1200)。
第三行:进度条组件,使用 Progress 组件实现,当前值 88,总值 100,类型为线性进度条。进度条颜色使用像素黄色,线宽 6px。进度条直观地展示了距离下一等级还需要多少经验。
13.4 数据统计三栏
数据统计区用三栏布局展示三个关键指标:
typescript
Row() {
Column() {
Text(RA_FAVS.length + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.pixel)
Text('收藏数').fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column().width(1).height(30).backgroundColor(RA_COLORS.line)
Column() {
Text(RA_ORDERS.length + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.neon)
Text('订单数').fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column().width(1).height(30).backgroundColor(RA_COLORS.line)
Column() {
Text('¥' + raSpendTotal()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#7C4DFF')
Text('总消费').fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').backgroundColor(RA_COLORS.card).borderRadius(14)
.padding({ top: 14, bottom: 14 })
.margin({ top: 14 })
三栏数据分别是:收藏数(像素黄色)、订单数(霓虹粉色)、总消费(紫色)。每个数据项都由大号数字和小号标签组成,数字使用不同的颜色区分。
栏与栏之间用 1px 宽的竖线分隔,竖线高度 30px,颜色为分割线色。这种"竖线分隔"的设计在三栏布局中非常常见,既起到了视觉分隔的作用,又不会过于抢眼。
三个数据的数值来源各不相同:收藏数来自 RA_FAVS 数组的长度,订单数来自 RA_ORDERS 数组的长度,总消费来自 raSpendTotal() 函数的计算结果。数据来源的多样性体现了个人中心作为数据汇聚点的角色。
13.5 月消费柱状图
月消费柱状图展示了近6个月的消费趋势:
typescript
Row() {
ForEach(RA_SPENDS, (s: RASpend) => {
Column() {
Column().layoutWeight(1)
Text('¥' + s.amount).fontSize(8).fontColor(RA_COLORS.sub)
Column()
.width(18).height(raBarPct(s.amount, raSpendMax()) + '%')
.linearGradient({ angle: 180, colors: [[RA_COLORS.neon, 0], [RA_COLORS.pixel, 1]] })
.borderRadius(3)
.margin({ top: 3 })
Text(s.month).fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 4 })
}
.layoutWeight(1).height(150).alignItems(HorizontalAlign.Center)
})
}
月消费柱状图的结构与掌机页面的销量柱状图类似,但有一个重要的区别------柱体使用了 180 度角的线性渐变,从霓虹粉色(顶部)渐变到像素黄色(底部)。
垂直渐变的柱状图比单色柱状图更加精致,色彩的变化增加了视觉层次感。顶部的粉色和底部的黄色都是应用的主题色,渐变的过渡自然而和谐。
柱状图的数值在柱体上方显示,月份标签在柱体下方显示。上方用弹性空白(layoutWeight(1))将柱子推到底部,这是垂直柱状图的标准布局技巧。
13.6 订单列表
订单列表展示用户的历史订单:
typescript
Column() {
ForEach(RA_ORDERS, (o: RAOrder) => {
Row() {
Column() {
Text(o.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text(o.id + ' · ' + o.date).fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text(o.status).fontSize(9).fontColor('#FFFFFF')
.backgroundColor(raStatusColor(o.status)).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text('¥' + o.amount).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.neon)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
.backgroundColor(RA_COLORS.card).borderRadius(10)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.margin({ bottom: 8 })
})
}
每个订单项都是一个左右布局的卡片:左侧是订单标题和订单信息,右侧是订单状态和金额。
订单状态标签的颜色由 raStatusColor 函数动态计算------已完成显示绿色,运输中显示青色,待付款显示黄色,已取消显示灰紫色。状态标签使用彩色背景+白字的胶囊样式,非常醒目。
订单金额使用霓虹粉色加粗显示,突出了金额的重要性。在电商类应用中,金额通常都是最受用户关注的信息之一,用强调色突出显示是标准的设计做法。
13.7 收藏列表与新增收藏
收藏列表展示用户的收藏记录,并提供新增收藏的入口:
typescript
Row() {
Text('❤️ 我的收藏').fontSize(13).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Column().layoutWeight(1)
Text('+ 新增')
.fontSize(11).fontColor(RA_COLORS.pixel).fontWeight(FontWeight.Bold)
.borderRadius(12).border({ width: 1, color: RA_COLORS.pixel })
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.onClick(() => { this.showAddModal = true })
}
Column() {
ForEach(RA_FAVS, (f: RAFav) => {
Row() {
Column().width(14).height(14).borderRadius(7).backgroundColor(f.colorHex)
Column() {
Text(f.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RA_COLORS.text)
Text(f.date + ' 收藏').fontSize(9).fontColor(RA_COLORS.sub).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1).padding({ left: 10 })
Text(f.type).fontSize(9).fontColor(RA_COLORS.pixel)
.backgroundColor('#33FF2E88').borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
}
.width('100%').alignItems(VerticalAlign.Center)
.backgroundColor(RA_COLORS.card).borderRadius(10)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.margin({ bottom: 8 })
})
}
收藏列表的每行布局从左到右依次是颜色小圆点、收藏标题和日期、类型标签。颜色小圆点直径14px,使用收藏项的主题色,直观地标识了不同收藏项的色彩属性。这种小色点设计在列表中非常常见,它用极小的视觉空间提供了丰富的识别信息。
中间是收藏标题和收藏日期,标题使用白色粗体字,日期使用小号灰字。layoutWeight(1) 让中间区域占据弹性空间,实现左右两端对齐的布局效果。
右侧是类型标签,使用霓虹粉色半透明背景加像素黄文字的胶囊样式,标签内容为掌机、卡带、街机、周边等类型名称。类型标签让用户可以快速识别收藏的类别,也为列表增添了色彩层次。
标题栏右侧的加号新增按钮是新增收藏的入口,点击后会打开新增收藏表单弹窗。这个按钮使用像素黄描边样式,既醒目又不会过于抢眼,在视觉权重上介于普通文字和主按钮之间。
13.8 新增收藏表单弹窗
新增收藏弹窗是一个表单样式的弹窗,用于添加新的收藏记录。这是应用中最复杂的弹窗,包含了文本输入、选项选择等多种表单控件。弹窗的整体高度设置为62%,内容较多时支持滚动浏览。
弹窗标题栏的设计很有特色,左侧有一个4px宽、18px高的霓虹粉色竖条,作为标题的视觉装饰。这种小竖条装饰在现代UI设计中很常见,能够快速吸引用户的注意力,同时也强化了品牌色彩。
收藏名称输入框使用 TextInput 组件实现,占位符文字提示用户输入格式。输入框使用深卡片色背景,与弹窗背景形成层次区分。输入内容通过 onChange 事件同步到 formName 状态变量,这是受控组件的标准实现方式。
选择机型部分提供了10款掌机供选择,分为两行排列,每行5个选项。选中的选项使用该掌机的主题色背景加白色文字,未选中的选项使用深卡片色背景加白色文字。点击选项会更新 formConsole 的值。这种多选项的选择器在表单中很常见,它比下拉选择器更加直观易用,用户可以一眼看到所有选项。
参考价格是另一个文本输入框,用于输入收藏的参考价格。输入内容同步到 formPrice 状态变量。价格输入框的设计与名称输入框保持一致,确保了表单视觉风格的统一性。
确认收藏按钮的点击事件处理逻辑比较丰富。它会根据表单填写的内容生成 lastAdded 提示文字:如果填写了收藏名称,就显示名称加机型的格式;如果没有填写名称,就显示机型加价格的格式。然后清空表单数据并关闭弹窗。这种智能提示文案的设计,让用户能够获得更个性化的反馈。
这里虽然没有真正将新收藏添加到 RA_FAVS 数组中,因为是模拟数据,但交互流程已经完整实现。在真实的应用中,这里会调用数据服务的添加接口,然后刷新收藏列表。当前的实现为后续的功能扩展打下了良好的基础。
13.9 已收藏提示条
已收藏提示条是一个轻量级的反馈组件。当 lastAdded 不为空时,也就是用户刚添加了收藏时,显示一个霓虹粉色的提示条,告知用户收藏成功。提示条右侧有一个关闭按钮,点击后将 lastAdded 设置为空字符串,提示条随之消失。
当 lastAdded 为空时,显示一个20px高的空白占位符。这个占位符的作用是保持布局的稳定性,无论提示条是否显示,底部都有相同的空间,避免布局跳动。布局稳定性是UI开发中一个容易被忽视但非常重要的细节。
这种成功提示条的设计模式在移动端非常常见。它不像弹窗那样打断用户的操作流程,而是以非侵入的方式提供反馈,用户可以选择关注或忽略。相比于 Toast 提示,这种内嵌式的提示条持续时间更长,用户有充足的时间阅读和确认,也不会像 Toast 那样可能在用户还没看完时就消失了。
数据层 状态管理 我的页面UI 用户 数据层 状态管理 我的页面UI 用户 #mermaid-svg-ueBU5jSxoxmQw2Hm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ueBU5jSxoxmQw2Hm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ueBU5jSxoxmQw2Hm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ueBU5jSxoxmQw2Hm .error-icon{fill:#552222;}#mermaid-svg-ueBU5jSxoxmQw2Hm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ueBU5jSxoxmQw2Hm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ueBU5jSxoxmQw2Hm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ueBU5jSxoxmQw2Hm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ueBU5jSxoxmQw2Hm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ueBU5jSxoxmQw2Hm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ueBU5jSxoxmQw2Hm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ueBU5jSxoxmQw2Hm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ueBU5jSxoxmQw2Hm .marker.cross{stroke:#333333;}#mermaid-svg-ueBU5jSxoxmQw2Hm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ueBU5jSxoxmQw2Hm p{margin:0;}#mermaid-svg-ueBU5jSxoxmQw2Hm .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ueBU5jSxoxmQw2Hm text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ueBU5jSxoxmQw2Hm .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ueBU5jSxoxmQw2Hm .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ueBU5jSxoxmQw2Hm .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ueBU5jSxoxmQw2Hm .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ueBU5jSxoxmQw2Hm #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ueBU5jSxoxmQw2Hm .sequenceNumber{fill:white;}#mermaid-svg-ueBU5jSxoxmQw2Hm #sequencenumber{fill:#333;}#mermaid-svg-ueBU5jSxoxmQw2Hm #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ueBU5jSxoxmQw2Hm .messageText{fill:#333;stroke:none;}#mermaid-svg-ueBU5jSxoxmQw2Hm .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ueBU5jSxoxmQw2Hm .labelText,#mermaid-svg-ueBU5jSxoxmQw2Hm .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ueBU5jSxoxmQw2Hm .loopText,#mermaid-svg-ueBU5jSxoxmQw2Hm .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ueBU5jSxoxmQw2Hm .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ueBU5jSxoxmQw2Hm .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ueBU5jSxoxmQw2Hm .noteText,#mermaid-svg-ueBU5jSxoxmQw2Hm .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ueBU5jSxoxmQw2Hm .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ueBU5jSxoxmQw2Hm .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ueBU5jSxoxmQw2Hm .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ueBU5jSxoxmQw2Hm .actorPopupMenu{position:absolute;}#mermaid-svg-ueBU5jSxoxmQw2Hm .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ueBU5jSxoxmQw2Hm .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ueBU5jSxoxmQw2Hm .actor-man circle,#mermaid-svg-ueBU5jSxoxmQw2Hm line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ueBU5jSxoxmQw2Hm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击+新增按钮showAddModal = true渲染新增收藏弹窗输入收藏名称formName = 输入值选择机型formConsole = 选中值输入参考价格formPrice = 输入值点击确认收藏lastAdded = 生成提示文字formName = 空字符串formPrice = 空字符串showAddModal = false弹窗消失,显示提示条点击提示条关闭按钮lastAdded = 空字符串提示条消失
十四、状态管理深度解析
14.1 @State 装饰器的工作原理
在基于HarmonyOS API 24的 ArkTS 声明式UI开发中,状态管理是整个框架的核心和灵魂。@State 装饰器是最基础也是最常用的状态管理工具,它用于装饰组件内部的状态变量。当被 @State 装饰的变量发生变化时,框架会自动检测到变化,并触发组件的重新渲染。这是声明式UI能够工作的基础机制。
@State 的工作原理可以概括为以下几个关键步骤。第一步是初始化,组件创建时,@State 装饰的变量被赋予初始值,框架建立变量与组件之间的依赖关系。这种依赖关系是后续变更检测的基础。
第二步是读取追踪。在 build 方法执行过程中,框架会追踪哪些 @State 变量被读取了。这些被读取的变量就是当前渲染的依赖。框架会建立一个依赖图,记录每个UI元素依赖哪些状态变量。这种精确的依赖追踪是实现细粒度更新的关键。
第三步是变化检测。当 @State 变量被修改时,框架会检测到变化,并标记依赖该变量的组件为需要更新。变化检测的效率对整体性能至关重要,框架需要在尽可能短的时间内确定哪些组件需要重新渲染。
第四步是重新渲染。框架在下一个渲染周期中重新执行被标记组件的 build 方法,生成新的UI描述树。这个过程也叫做组件的重建。在重建过程中,框架会再次执行状态读取追踪,更新依赖关系。
第五步是差异更新。框架比较新旧两棵UI描述树的差异,计算出最小的更新操作集。这个阶段也叫做协调或Diff算法,它的效率直接影响渲染性能。好的Diff算法可以将复杂度从O(n³)降低到O(n)级别。
第六步是DOM更新。根据差异比较的结果,框架执行实际的DOM操作,更新真实的UI视图。这个阶段是最耗时的,因为涉及到布局计算和绘制,所以最小化DOM操作是性能优化的关键。
这种状态变化驱动UI更新的模式是声明式UI的核心。开发者不需要关心UI更新的具体过程,只需要关心状态的变化逻辑。UI作为状态的函数,始终与状态保持一致。这种范式转换是声明式UI与命令式UI最本质的区别。
14.2 应用中的状态分类
复古游戏收藏馆应用中使用了多种类型的状态,按照功能和用途可以分为以下几类。理解这些不同类型的状态特点,有助于我们更好地设计和管理应用状态。
导航状态,如主组件的 activeTab,控制当前显示哪个Tab页面。这类状态的特点是取值范围有限,通常是枚举值,变化频率较低,每次变化会导致较大范围的UI重建。导航状态通常需要提升到比较高层的组件中,因为它会影响多个子组件。
筛选状态,如 filterBrand、filterGenre 等,控制列表数据的筛选条件。这类状态的变化会触发数据的重新计算和列表的重新渲染,但UI结构本身不变,只是数据内容变化。筛选状态通常保存在页面组件中,只影响当前页面的内容展示。
弹窗状态,如 showEdit、showDetail、showDeleteConfirm、showAddModal 等,控制弹窗的显示和隐藏。这类状态是布尔型的,变化是二元的,显示或隐藏,变化时会添加或移除整棵弹窗子树。弹窗状态通常与弹窗所需的数据状态配合使用。
数据状态,如 selConsole、selMerch、formName、formConsole、formPrice 等,存储当前选中的数据或表单输入的值。这类状态作为弹窗等交互组件的数据源,变化时会更新弹窗中的数据展示。数据状态通常是临时的,只在特定交互流程中有效。
动画状态,如 raPixelY、raNeon、raScanY 等,由定时器驱动,用于实现动画效果。这类状态的变化频率很高,通常几十毫秒一次,但通常只影响局部UI的视觉属性。动画状态对性能敏感,需要谨慎设计,避免频繁的重渲染影响整体流畅度。
反馈状态,如 lastAdded,用于存储临时反馈信息。这类状态的生命周期较短,通常在用户操作后被设置,之后由用户手动清除或在特定条件下自动消失。反馈状态提供了轻量级的用户反馈机制。
#mermaid-svg-MsK7gWpKxnxHMcDm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MsK7gWpKxnxHMcDm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MsK7gWpKxnxHMcDm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MsK7gWpKxnxHMcDm .error-icon{fill:#552222;}#mermaid-svg-MsK7gWpKxnxHMcDm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MsK7gWpKxnxHMcDm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MsK7gWpKxnxHMcDm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MsK7gWpKxnxHMcDm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MsK7gWpKxnxHMcDm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MsK7gWpKxnxHMcDm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MsK7gWpKxnxHMcDm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MsK7gWpKxnxHMcDm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MsK7gWpKxnxHMcDm .marker.cross{stroke:#333333;}#mermaid-svg-MsK7gWpKxnxHMcDm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MsK7gWpKxnxHMcDm p{margin:0;}#mermaid-svg-MsK7gWpKxnxHMcDm defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-MsK7gWpKxnxHMcDm g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-MsK7gWpKxnxHMcDm g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-MsK7gWpKxnxHMcDm g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-MsK7gWpKxnxHMcDm g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-MsK7gWpKxnxHMcDm g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-MsK7gWpKxnxHMcDm .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-MsK7gWpKxnxHMcDm .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-MsK7gWpKxnxHMcDm .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-MsK7gWpKxnxHMcDm .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-MsK7gWpKxnxHMcDm .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-MsK7gWpKxnxHMcDm .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-MsK7gWpKxnxHMcDm .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-MsK7gWpKxnxHMcDm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MsK7gWpKxnxHMcDm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MsK7gWpKxnxHMcDm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MsK7gWpKxnxHMcDm .edgeLabel .label text{fill:#333;}#mermaid-svg-MsK7gWpKxnxHMcDm .label div .edgeLabel{color:#333;}#mermaid-svg-MsK7gWpKxnxHMcDm .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-MsK7gWpKxnxHMcDm .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-MsK7gWpKxnxHMcDm .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-MsK7gWpKxnxHMcDm .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-MsK7gWpKxnxHMcDm .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-MsK7gWpKxnxHMcDm .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MsK7gWpKxnxHMcDm .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MsK7gWpKxnxHMcDm #statediagram-barbEnd{fill:#333333;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MsK7gWpKxnxHMcDm .cluster-label,#mermaid-svg-MsK7gWpKxnxHMcDm .nodeLabel{color:#131300;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-MsK7gWpKxnxHMcDm .note-edge{stroke-dasharray:5;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-note text{fill:black;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram-note .nodeLabel{color:black;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagram .edgeLabel{color:red;}#mermaid-svg-MsK7gWpKxnxHMcDm #dependencyStart,#mermaid-svg-MsK7gWpKxnxHMcDm #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-MsK7gWpKxnxHMcDm .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MsK7gWpKxnxHMcDm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 首次build执行
渲染完成等待交互
用户点击输入定时器
检测State变量变化
执行build方法
渲染完成
Tab切换页面离开
aboutToDisappear回调
组件完全销毁
初始化
渲染中
稳定
状态变化
标记更新
重新渲染
组件销毁
清理资源
14.3 状态提升与组件通信
在声明式UI中,状态提升State Lifting是一个重要的设计原则和最佳实践。当多个组件需要共享同一个状态时,应该将这个状态提升到它们的最近公共父组件中,然后通过属性props将状态和修改函数传递给子组件。
复古游戏收藏馆应用中有一个典型的状态提升案例,那就是导航状态 activeTab。这个状态保存在主组件 RetroArcadeApp 中,然后通过以下方式传递给子组件。
底部Tab栏通过点击事件修改 activeTab,这是子组件修改父组件状态的典型方式,通过 onClick 回调函数实现。首页组件通过 onSwitchTab 回调修改 activeTab,子组件通过回调函数修改父组件状态,而不是直接操作父组件的状态。内容区通过条件渲染使用 activeTab,父组件将状态应用到子组件的选择上。
这种模式确保了状态的单一数据源Single Source of Truth,整个应用的导航状态只有一份,保存在最顶层的主组件中。所有对导航状态的修改都通过回调函数向上传递,由父组件统一管理。这种单向数据流的模式让状态变化更加可预测和可追踪。
状态提升的好处是显而易见的。首先是避免状态同步问题,如果每个Tab组件都维护自己的导航状态,就需要在多个地方保持状态的同步,很容易出现不一致的问题。其次是便于调试和追踪,状态集中管理,状态变化的路径清晰可追踪,调试时只需要关注一个地方。第三是提高组件复用性,子组件不持有导航状态,就不依赖特定的导航上下文,可以在不同的场景中复用。
当然,状态提升也不是万能的,更不是提升得越高越好。如果状态的使用范围很窄,只在一个组件内部使用,就应该将状态保留在组件内部,避免不必要的提升。判断状态应该放在哪里的原则是:将状态放在需要使用它的所有组件的最近公共祖先中。
14.4 不可变数据与状态更新
在应用的数据处理函数中,我们看到了大量的不可变数据操作。筛选函数返回新数组,排序函数先拷贝再排序,从不直接修改原始数据。这种不可变数据的实践与声明式UI的状态管理模式高度契合,是函数式编程思想在UI开发中的具体体现。
为什么不可变数据在声明式UI中如此重要?原因主要有以下几点。第一是可预测性,当数据是不可变的,每次状态变化都会产生新的数据对象。这意味着我们可以通过比较引用是否相等来快速判断数据是否发生了变化,而不需要深度比较数据内容。这大大提高了变更检测的效率。
第二是调试友好,不可变数据使得状态的历史变化可以被完整记录和回溯。每一次状态变化都是一个新的快照,开发者可以像看录像一样查看状态的变化过程。在Redux等状态管理库中,时间旅行调试就是基于不可变数据实现的。
第三是变更检测效率,框架可以通过引用比较来快速检测状态变化,决定是否需要重新渲染。如果直接修改原对象的属性,框架就需要深度遍历对象才能发现变化,性能开销会大很多。不可变数据让变更检测从O(n)的复杂度降低到了O(1)。
第四是避免副作用,不可变数据确保了数据不会在意外的地方被修改。每个函数都不会修改输入数据,函数的行为更加纯粹,更容易测试和推理。副作用是软件开发中的主要复杂性来源之一,不可变数据是控制副作用的有效手段。
在 ArkTS 中,由于 @State 的变更检测机制依赖于变量的赋值操作,对于对象类型是引用变化,对于基本类型是值变化,保持数据的不可变性尤为重要。如果你直接修改一个 @State 对象的属性,框架可能检测不到变化,从而导致UI不更新。这是初学者经常遇到的陷阱之一。
十五、渲染流程与性能优化
15.1 声明式UI的渲染流水线
理解 ArkUI 的渲染流程对于构建高性能应用至关重要。基于HarmonyOS API 24的 ArkUI 框架采用了一套高效的渲染流水线,确保声明式UI的开发体验不牺牲运行时性能。这套渲染流水线经过了多轮优化,在效率和灵活性之间取得了良好的平衡。
渲染流水线可以大致分为以下几个阶段。第一阶段是状态变化检测。框架监听 @State 等状态变量的变化,当状态发生变化时,标记相关组件为脏,也就是需要更新。这个阶段的关键是精确追踪状态与组件之间的依赖关系,确保只标记真正受影响的组件。依赖追踪的精度直接决定了渲染效率。
第二阶段是组件重建。被标记为脏的组件会重新执行 build 方法,生成新的UI描述树,也就是虚拟DOM。这个阶段是声明式UI的核心,开发者描述UI是什么样子,框架在状态变化时重新生成UI描述。组件重建的开销与组件的复杂度成正比,因此控制组件粒度是性能优化的重要手段。
第三阶段是差异比较。框架比较新旧两棵UI描述树的差异,计算出最小的更新操作集。这个阶段也叫做协调Reconciliation,它的效率直接影响渲染性能。经典的Diff算法复杂度是O(n³),但通过一些优化策略,如同层比较、key标识等,可以将复杂度降低到O(n)级别。
第四阶段是DOM更新。根据差异比较的结果,框架执行实际的DOM操作,更新真实的UI视图。这个阶段是最耗时的,因为涉及到布局计算和绘制,所以最小化DOM操作是性能优化的关键。批量更新、异步更新等技术都是为了减少DOM操作的次数。
第五阶段是布局与绘制。更新后的UI组件重新计算布局位置和尺寸,然后进行绘制渲染。这个阶段由系统的渲染引擎完成,包括测量、布局、绘制等步骤。布局计算的复杂度与视图树的深度和复杂度有关,扁平化的布局结构通常比深层嵌套的布局性能更好。
#mermaid-svg-3MG98o305dgtgPQK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3MG98o305dgtgPQK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3MG98o305dgtgPQK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3MG98o305dgtgPQK .error-icon{fill:#552222;}#mermaid-svg-3MG98o305dgtgPQK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3MG98o305dgtgPQK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3MG98o305dgtgPQK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3MG98o305dgtgPQK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3MG98o305dgtgPQK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3MG98o305dgtgPQK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3MG98o305dgtgPQK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3MG98o305dgtgPQK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3MG98o305dgtgPQK .marker.cross{stroke:#333333;}#mermaid-svg-3MG98o305dgtgPQK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3MG98o305dgtgPQK p{margin:0;}#mermaid-svg-3MG98o305dgtgPQK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3MG98o305dgtgPQK .cluster-label text{fill:#333;}#mermaid-svg-3MG98o305dgtgPQK .cluster-label span{color:#333;}#mermaid-svg-3MG98o305dgtgPQK .cluster-label span p{background-color:transparent;}#mermaid-svg-3MG98o305dgtgPQK .label text,#mermaid-svg-3MG98o305dgtgPQK span{fill:#333;color:#333;}#mermaid-svg-3MG98o305dgtgPQK .node rect,#mermaid-svg-3MG98o305dgtgPQK .node circle,#mermaid-svg-3MG98o305dgtgPQK .node ellipse,#mermaid-svg-3MG98o305dgtgPQK .node polygon,#mermaid-svg-3MG98o305dgtgPQK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3MG98o305dgtgPQK .rough-node .label text,#mermaid-svg-3MG98o305dgtgPQK .node .label text,#mermaid-svg-3MG98o305dgtgPQK .image-shape .label,#mermaid-svg-3MG98o305dgtgPQK .icon-shape .label{text-anchor:middle;}#mermaid-svg-3MG98o305dgtgPQK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3MG98o305dgtgPQK .rough-node .label,#mermaid-svg-3MG98o305dgtgPQK .node .label,#mermaid-svg-3MG98o305dgtgPQK .image-shape .label,#mermaid-svg-3MG98o305dgtgPQK .icon-shape .label{text-align:center;}#mermaid-svg-3MG98o305dgtgPQK .node.clickable{cursor:pointer;}#mermaid-svg-3MG98o305dgtgPQK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3MG98o305dgtgPQK .arrowheadPath{fill:#333333;}#mermaid-svg-3MG98o305dgtgPQK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3MG98o305dgtgPQK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3MG98o305dgtgPQK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3MG98o305dgtgPQK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3MG98o305dgtgPQK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3MG98o305dgtgPQK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3MG98o305dgtgPQK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3MG98o305dgtgPQK .cluster text{fill:#333;}#mermaid-svg-3MG98o305dgtgPQK .cluster span{color:#333;}#mermaid-svg-3MG98o305dgtgPQK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3MG98o305dgtgPQK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3MG98o305dgtgPQK rect.text{fill:none;stroke-width:0;}#mermaid-svg-3MG98o305dgtgPQK .icon-shape,#mermaid-svg-3MG98o305dgtgPQK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3MG98o305dgtgPQK .icon-shape p,#mermaid-svg-3MG98o305dgtgPQK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3MG98o305dgtgPQK .icon-shape .label rect,#mermaid-svg-3MG98o305dgtgPQK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3MG98o305dgtgPQK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3MG98o305dgtgPQK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3MG98o305dgtgPQK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 状态变量发生变化
依赖追踪标记脏组件
重新执行build方法
生成新UI描述树
新旧树差异比较Diff
计算最小更新操作集
执行DOM更新操作
重新计算布局位置
执行绘制渲染
渲染完成显示新界面
15.2 组件粒度与渲染性能
组件粒度的设计对渲染性能有重要影响,这是声明式UI开发中的一个重要设计决策。组件划分得越细,每次状态变化时需要重新渲染的范围就越小,性能也就越好。但组件划分过细也会增加代码的复杂度和开发成本,带来过度设计的问题。
复古游戏收藏馆应用在组件粒度的把握上比较适中。六个Tab页面各自是一个独立的组件,每个组件内部使用 @Builder 封装可复用的UI片段。这种设计在开发效率和运行性能之间取得了良好的平衡。
Tab级别的隔离是一个重要的设计决策。每个Tab页面是一个独立的组件,Tab切换时整页重建。虽然听起来开销很大,但实际上Tab切换的频率很低,用户不会频繁地在Tab之间来回切换,而且整页重建的代码结构简单清晰,维护成本低。相比于复杂的页面缓存机制,简单的整页重建反而更加可靠。
@Builder 的轻量复用是另一个值得关注的设计。对于组件内部的可复用UI片段,使用 @Builder 而不是独立的 @Component。@Builder 本质上是一个构建函数,没有独立的状态和生命周期,比完整组件轻量得多。它提供了代码复用的好处,又避免了组件化的开销,是一种非常实用的中间方案。
细粒度的状态追踪是框架层面的性能保障。在每个组件内部,@State 变量的变化会精确地触发依赖它的UI部分重新渲染。框架会追踪具体哪些UI元素依赖哪些状态变量,实现细粒度的更新。这意味着即使一个组件很大,状态变化也可能只导致很小一部分UI重新渲染。
在实际开发中,我们可以根据以下原则来决定组件的粒度。第一,如果一段UI有独立的状态逻辑,考虑将其抽取为独立组件。第二,如果一段UI在多个地方被复用,考虑将其抽取为独立组件。第三,如果一段UI足够复杂,抽取为独立组件可以提高代码的可读性和可维护性。第四,不要为了性能优化而过度组件化,简单的UI保持内联往往更高效,也更易读。
15.3 列表渲染与 ForEach 的性能
ForEach 是 ArkUI 中渲染列表的核心组件,它的性能对应用的整体流畅度有很大影响。ForEach 的工作原理是遍历数据源,为每个数据项生成对应的UI组件。在移动端应用中,列表是最常见的界面形式之一,因此ForEach的性能优化尤为重要。
ForEach 的性能优化需要注意以下几点。首先是稳定的标识符,ForEach 需要能够识别每个数据项的身份,以便在数据变化时正确地复用和更新组件。如果数据项有唯一的 id 字段,框架可以更高效地进行差异比较。在应用中,大多数数据模型都有 id 字段,这是一个很好的实践。稳定的key可以让框架准确地追踪每个项的变化。
其次是避免不必要的重建。当列表数据发生变化时,比如添加、删除、重排,框架需要确定哪些项是新增的、哪些是删除的、哪些只是移动了位置。稳定的标识符可以让框架准确地追踪每个项的变化,避免不必要的组件销毁和重建。这对于长列表的性能尤为重要。
第三是虚拟滚动。对于长列表,框架会使用虚拟滚动技术,只渲染可见区域内的列表项,不可见的区域用空白占位。这样可以大幅减少DOM节点数量,提升长列表的滚动性能。虽然应用中的列表数据量不大,最多十几项,不需要虚拟滚动,但了解这个机制对于处理大数据量的场景很有帮助。
第四是ForEach与Scroll的配合。在应用中我们经常看到 Scroll 加 ForEach 的组合。Scroll 提供滚动容器,ForEach 负责生成列表项。两者配合可以实现各种方向的滚动列表,是移动端UI开发中最常用的模式之一。Scroll 组件的滚动性能、滚动条控制、滚动事件等特性,与 ForEach 的列表渲染能力相结合,可以构建出丰富多样的滚动界面。
15.4 动画性能考量
应用中使用了多种动画效果,包括像素跳动、霓虹灯闪烁、扫描线移动等。这些动画由定时器驱动,通过修改 @State 变量来触发UI更新。定时器驱动的动画是一种简单直接的动画实现方式,但需要注意性能问题。
定时器驱动的动画需要注意性能问题。频繁的状态变化会导致频繁的重新渲染,如果每次重新渲染的开销很大,动画就会出现卡顿。因此,设计定时器驱动的动画时,需要在动画效果和性能开销之间找到平衡。
应用中的动画设计遵循了以下性能原则。第一,动画状态只影响局部UI。动画状态变量raPixelY、raNeon、raScanY的变化只影响很小范围的UI,几个文字的颜色或位置、一条扫描线的位置。这意味着每次动画帧的重新渲染开销很小,不会影响整体性能。动画影响的范围越小,性能开销就越低。
第二,合理的动画频率。像素动画90毫秒一帧,约11fps,霓虹灯420毫秒一次切换,约2.4fps,扫描线60毫秒一帧,约16fps。这些频率都远低于60fps的满帧标准,因为这些动画只需要看起来在动就行,不需要丝滑流畅。降低动画频率可以有效减少CPU开销。对于装饰性的动画,帧率不需要太高,能感知到变化即可。
第三,及时清理定时器。在 aboutToDisappear 中清理所有定时器,防止组件销毁后动画继续运行,造成内存泄漏和CPU浪费。这是动画开发中必须遵守的最佳实践。如果不清理定时器,即使页面已经不可见,动画仍然在后台运行,白白消耗系统资源。
对于更复杂的动画,比如转场动画、属性动画,ArkUI 提供了专门的动画系统animateTo,它可以在渲染层面直接操作属性,不需要通过状态变化驱动重渲染,性能更加优异。但对于简单的循环动画,比如本应用中的效果,定时器加 @State 的方式更加简单直接,也足够高效。开发者应该根据具体场景选择合适的动画实现方式。
十六、组件依赖与架构设计总结
16.1 组件依赖关系图
整个应用的组件体系呈现出清晰的层次结构。顶层是入口主组件,下面是六个Tab页面组件,每个Tab组件内部又包含若干弹窗组件和子构建函数。这种树状的组件结构与声明式UI的设计理念高度一致,UI就是组件树的渲染结果。
#mermaid-svg-3OcWQdB40cljgCdA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3OcWQdB40cljgCdA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3OcWQdB40cljgCdA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3OcWQdB40cljgCdA .error-icon{fill:#552222;}#mermaid-svg-3OcWQdB40cljgCdA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3OcWQdB40cljgCdA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3OcWQdB40cljgCdA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3OcWQdB40cljgCdA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3OcWQdB40cljgCdA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3OcWQdB40cljgCdA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3OcWQdB40cljgCdA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3OcWQdB40cljgCdA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3OcWQdB40cljgCdA .marker.cross{stroke:#333333;}#mermaid-svg-3OcWQdB40cljgCdA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3OcWQdB40cljgCdA p{margin:0;}#mermaid-svg-3OcWQdB40cljgCdA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3OcWQdB40cljgCdA .cluster-label text{fill:#333;}#mermaid-svg-3OcWQdB40cljgCdA .cluster-label span{color:#333;}#mermaid-svg-3OcWQdB40cljgCdA .cluster-label span p{background-color:transparent;}#mermaid-svg-3OcWQdB40cljgCdA .label text,#mermaid-svg-3OcWQdB40cljgCdA span{fill:#333;color:#333;}#mermaid-svg-3OcWQdB40cljgCdA .node rect,#mermaid-svg-3OcWQdB40cljgCdA .node circle,#mermaid-svg-3OcWQdB40cljgCdA .node ellipse,#mermaid-svg-3OcWQdB40cljgCdA .node polygon,#mermaid-svg-3OcWQdB40cljgCdA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3OcWQdB40cljgCdA .rough-node .label text,#mermaid-svg-3OcWQdB40cljgCdA .node .label text,#mermaid-svg-3OcWQdB40cljgCdA .image-shape .label,#mermaid-svg-3OcWQdB40cljgCdA .icon-shape .label{text-anchor:middle;}#mermaid-svg-3OcWQdB40cljgCdA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3OcWQdB40cljgCdA .rough-node .label,#mermaid-svg-3OcWQdB40cljgCdA .node .label,#mermaid-svg-3OcWQdB40cljgCdA .image-shape .label,#mermaid-svg-3OcWQdB40cljgCdA .icon-shape .label{text-align:center;}#mermaid-svg-3OcWQdB40cljgCdA .node.clickable{cursor:pointer;}#mermaid-svg-3OcWQdB40cljgCdA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3OcWQdB40cljgCdA .arrowheadPath{fill:#333333;}#mermaid-svg-3OcWQdB40cljgCdA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3OcWQdB40cljgCdA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3OcWQdB40cljgCdA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3OcWQdB40cljgCdA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3OcWQdB40cljgCdA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3OcWQdB40cljgCdA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3OcWQdB40cljgCdA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3OcWQdB40cljgCdA .cluster text{fill:#333;}#mermaid-svg-3OcWQdB40cljgCdA .cluster span{color:#333;}#mermaid-svg-3OcWQdB40cljgCdA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3OcWQdB40cljgCdA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3OcWQdB40cljgCdA rect.text{fill:none;stroke-width:0;}#mermaid-svg-3OcWQdB40cljgCdA .icon-shape,#mermaid-svg-3OcWQdB40cljgCdA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3OcWQdB40cljgCdA .icon-shape p,#mermaid-svg-3OcWQdB40cljgCdA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3OcWQdB40cljgCdA .icon-shape .label rect,#mermaid-svg-3OcWQdB40cljgCdA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3OcWQdB40cljgCdA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3OcWQdB40cljgCdA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3OcWQdB40cljgCdA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} RetroArcadeApp 入口组件
首页 RAHomeTab
掌机 RAHandheldTab
卡带 RACartTab
街机 RAArcadeTab
周边 RAMerchTab
我的 RAMineTab
像素仪表盘 raStatTile
经典机型横滑
热销榜列表
快捷入口宫格
扫描线特效
品牌筛选 Chips
双列网格 Grid
续航对比条
销量柱状图
编辑弹窗 editParamModal
详情弹窗 consoleDetailModal
类型筛选 Chips
稀有度排行榜
年份分布统计
收藏小贴士
类型筛选弹窗 genreSelectModal
横向大卡列表
框体尺寸条 raCabSize
游戏类型分布
三列色卡网格
热度排行榜
品类分布统计
删除确认弹窗 deleteConfirmModal
会员卡片
数据统计三栏
月消费柱状图
订单列表
收藏列表
新增收藏弹窗 addFavModal
已收藏提示条
从组件依赖图中我们可以看到,整个应用的组件结构呈现出明显的层级性。顶层的入口组件是根,下面连接六个Tab页面组件,每个Tab页面组件下面又有自己的子组件和子构建函数。这种层级结构与UI的视觉层次是对应的,父组件包含子组件,子组件又包含更小的组件。
每个Tab页面的复杂度各不相同。掌机页面和我的页面是最复杂的,都有六个以上的子模块和多个弹窗。卡带页面相对简单,主要是列表和统计。首页虽然动画效果丰富,但功能模块相对固定。这种复杂度的差异反映了各页面功能定位的不同。
16.2 架构设计的优点
通过对整个应用的深度剖析,我们可以总结出以下几个架构设计上的优点。这些优点使得应用虽然是单文件实现,但依然保持了良好的可维护性和可扩展性。
分层清晰是第一个突出的优点。数据、逻辑、UI三层分离。数据层以常量和接口定义为主,逻辑层封装为纯函数工具,UI层由组件构成。各层职责明确,耦合度低。上层可以依赖下层,但下层不依赖上层。这种分层架构是软件工程中的经典模式,在前端开发中同样适用。
命名规范统一是第二个优点。所有标识符都使用 RA 前缀,避免命名冲突。变量名和函数名都清晰地表达了自身的用途,代码可读性高。统一的命名规范是团队协作的基础,也是代码质量的基本保障。即使是个人项目,良好的命名习惯也能让未来的自己受益。
设计令牌集中管理是第三个优点。颜色、间距、圆角等设计规范通过常量集中管理,确保了UI风格的一致性,也便于后续的设计调整。设计令牌Design Tokens是现代前端工程中的重要概念,它将设计规范抽象为可复用的变量,架起了设计与开发之间的桥梁。
状态管理简洁有效是第四个优点。使用 @State 进行组件级状态管理,通过状态提升处理跨组件的状态共享。状态数量不多,但覆盖了所有交互需求,体现了最少状态的设计原则。过多的状态会增加代码的复杂度和出错的概率,精简状态是优秀架构的重要标志。
纯函数数据处理是第五个优点。所有数据处理逻辑都封装为纯函数,不依赖外部状态,不修改输入数据。这种函数式的设计使得数据逻辑非常清晰,易于测试和维护。纯函数是可测试性最好的代码单元,因为它们的行为完全由输入决定。
弹窗模式统一是第六个优点。所有弹窗都遵循相同的实现模式,Stack布局加遮罩层加内容层加zIndex控制。统一的模式降低了维护成本,也方便后续新增弹窗功能。一致的模式不仅减少了重复代码,更重要的是降低了开发者的认知负担。
16.3 可改进与扩展的方向
虽然当前的架构设计已经相当不错,但从工程化的角度来看,还有一些可以改进和扩展的方向。这些方向代表了从小型项目向中型、大型项目演进时需要关注的重点。
组件拆分是第一个可以改进的方向。对于复杂的Tab页面,比如掌机页面、我的页面,可以进一步拆分为多个子组件,每个子组件负责一个功能模块。这样可以降低单个组件的复杂度,提高代码的复用性。当然,拆分也会增加一些间接成本,需要在复杂度和复用性之间找到平衡点。
状态管理升级是第二个方向。当应用规模扩大、状态变得复杂时,可以考虑引入更强大的状态管理方案,如全局状态管理,类似Redux或Vuex的模式。这样可以更好地处理跨页面、跨层级的状态共享问题。全局状态管理的引入需要慎重,因为它会增加架构的复杂度,只有在确实需要的时候才应该考虑。
数据持久化是第三个方向。目前所有数据都是内存中的常量,刷新页面就会丢失。可以引入本地持久化存储,比如Preferences、Database,让用户数据能够保存下来。数据持久化是大多数真实应用的必备功能,也是从Demo到产品的重要一步。
网络请求是第四个方向。真实应用中的数据通常来自后端API。可以封装网络请求层,将模拟数据替换为真实的接口数据,同时增加加载状态、错误处理等逻辑。网络层的引入会带来异步编程、错误处理、缓存策略等新的挑战。
国际化支持是第五个方向。如果应用需要支持多语言,可以将所有硬编码的文字抽取为语言资源文件,通过国际化框架动态加载对应语言的文案。国际化不仅是文字翻译,还包括日期格式、数字格式、文化习惯等多方面的适配。
单元测试是第六个方向。纯函数的数据处理逻辑非常适合编写单元测试。可以为每个辅助函数添加测试用例,确保数据处理的正确性。单元测试是代码质量的安全网,能够在重构和功能迭代时及时发现问题。
主题系统扩展是第七个方向。目前只有一套深色主题。可以设计多主题系统,支持亮色暗色切换,甚至支持多种配色方案供用户选择。主题系统的设计需要从架构层面考虑,将颜色、字体、间距等设计属性全部参数化。
十七、各模块功能对比总结
17.1 六大Tab页面功能对比
为了更直观地理解各页面的功能特点和技术实现,我们将六个Tab页面的关键特性整理为以下对比表格。通过横向对比,我们可以更清晰地看到各页面的异同,也能更好地理解整个应用的功能全貌。
| 对比维度 | 首页 | 掌机 | 卡带 | 街机 | 周边 | 我的 |
|---|---|---|---|---|---|---|
| 核心功能 | 数据概览、内容导航 | 掌机展示、筛选、详情 | 稀有度排行、年代统计 | 街机展示、类型筛选、尺寸展开 | 周边展示、删除操作 | 个人中心、会员、订单、收藏 |
| 状态变量数 | 6个 | 6个 | 1个 | 3个 | 2个 | 7个 |
| 弹窗数量 | 0个 | 2个 | 0个 | 1个 | 1个 | 1个 |
| 筛选功能 | 无 | 品牌筛选 | 类型筛选 | 类型筛选 | 无 | 无 |
| 数据图表 | 统计卡片 | 续航条形图、销量柱状图 | 稀有度进度条、年代分布图 | 框体尺寸条、类型分布图 | 热度进度条、品类分布图 | 月消费柱状图、进度条 |
| 列表形式 | 热销榜列表 | 双列网格 | 排行列表 | 横向大卡 | 三列网格 | 订单列表、收藏列表 |
| 动画效果 | 像素跳动、霓虹闪烁、扫描线 | 无 | 无 | 无 | 无 | 霓虹闪烁 |
| 表单交互 | 无 | 滑条编辑 | 无 | 宫格选择 | 确认弹窗 | 文本输入、机型选择 |
| 渐变背景 | 无 | 紫蓝色渐变 | 紫红色渐变 | 蓝紫色渐变 | 红紫色渐变 | 三色渐变会员卡 |
| 数据条数 | 经典6、热销5、功能4 | 掌机10款 | 卡带8款 | 街机8台 | 周边6款 | 订单6、收藏6、消费6 |
| Grid布局 | 无 | 双列Grid | 无 | 弹窗双列Grid | 三列Grid | 无 |
| Scroll方向 | 垂直+水平 | 垂直+水平 | 垂直+水平 | 垂直+水平 | 垂直 | 垂直 |
| 特色交互 | 扫描线特效 | 详情加编辑双弹窗 | 稀有度排序 | 手风琴展开 | 删除确认 | 表单加提示条 |
从表格中我们可以看到,我的页面的状态变量最多,有7个,这反映了个人中心功能的复杂性。掌机页面的弹窗最多,有2个,详情弹窗和编辑弹窗,这是因为掌机数据维度最丰富,需要更多的交互方式。首页的动画效果最多,有3种,这是因为首页作为门面,需要更强的视觉吸引力。
17.2 五种弹窗类型对比
应用中共实现了五种不同风格的弹窗,每种弹窗都有特定的使用场景和设计特点。弹窗是移动端应用中非常重要的交互形式,掌握不同类型弹窗的设计和实现,对于构建丰富的交互体验至关重要。
| 弹窗名称 | 所在页面 | 样式特点 | 交互类型 | 关闭方式 | 典型场景 |
|---|---|---|---|---|---|
| 编辑参数弹窗 | 掌机 | 滑条表单样式 | 数值调整 | 取消保存按钮 | 修改掌机参数 |
| 详情弹窗 | 掌机 | 参数表样式 | 信息查看 | 关闭按钮和遮罩点击 | 查看完整参数 |
| 类型选择弹窗 | 街机 | 宫格选择样式 | 类型筛选 | 点击选项或遮罩 | 选择游戏类型 |
| 删除确认弹窗 | 周边 | 红色警告样式 | 危险操作确认 | 取消或确认删除 | 删除商品 |
| 新增收藏弹窗 | 我的 | 表单样式 | 表单填写 | 取消或确认收藏 | 添加收藏记录 |
这五种弹窗分别代表了五种常见的弹窗模式:表单编辑、信息展示、选择器、确认对话框、表单录入。每种弹窗都有自己的视觉风格和交互模式,适用于不同的业务场景。掌握这些弹窗模式,可以应对绝大多数移动端的弹窗需求。
特别值得注意的是删除确认弹窗的红色警告设计。危险操作的弹窗需要在视觉上给用户强烈的警示,红色是国际通用的危险色,配合警告文字和确认按钮的红色背景,能够有效降低误操作的概率。这是UI设计中安全原则的具体体现。
17.3 数据可视化形式对比
应用中大量使用了数据可视化技术,将枯燥的数字转化为直观的图形。数据可视化不仅提升了界面的美观度,更重要的是提高了信息的传达效率,让用户能够快速理解和比较数据。以下是各种可视化形式的对比:
| 可视化形式 | 使用位置 | 展示数据类型 | 实现方式 | 颜色方案 | 交互性 |
|---|---|---|---|---|---|
| 统计卡片 | 首页 | 总量统计数据 | 大数字加标签 | 各色主题色 | 无 |
| 横向条形图 | 掌机续航、卡带稀有度、街机尺寸、周边热度 | 对比类数据 | 嵌套Column加百分比宽度 | 渐变色或主题色 | 无 |
| 垂直柱状图 | 掌机销量、月消费 | 趋势类数据 | Row加弹性布局加百分比高度 | 主题色或垂直渐变 | 无 |
| 进度条组件 | 会员EXP | 完成度数据 | Progress内置组件 | 像素黄 | 无 |
| 分布统计条 | 卡带年代、街机类型、周边品类 | 分布类数据 | 横向条形图加标签 | 霓虹粉或各类型色 | 无 |
| 数据色卡 | 各页面网格 | 商品展示 | 色块加文字 | 数据主题色 | 有点击 |
从对比中我们可以看到,应用中的数据可视化形式非常丰富,涵盖了统计卡片、横向条形图、垂直柱状图、进度条、分布图等多种形式。这些可视化形式各有侧重,分别适用于不同类型的数据展示。
一个有趣的设计特点是,几乎所有的数据可视化都使用了数据本身的颜色作为视觉元素。每款掌机、每盒卡带、每台街机、每件周边都有自己的主题色,这些主题色直接用于图表中的条形、柱子、色块等视觉元素。这种数据驱动配色的设计,让数据可视化与数据本身紧密结合,既美观又直观。
十八、多维度详细总结
18.1 技术实现维度总结
从技术实现的角度来看,这个复古游戏收藏馆应用充分展示了基于HarmonyOS API 24的 ArkTS 声明式UI开发的核心能力和最佳实践。它不是一个简单的Demo,而是一个涵盖了多种常见场景的综合性应用示例。
声明式UI范式的完整体现是首要的技术亮点。整个应用的UI构建完全采用声明式方式,从最外层的布局容器到最内层的文字组件,全部通过链式属性调用来描述。开发者不需要关心组件的生命周期管理和DOM操作细节,只需要关心状态与UI之间的映射关系。这种范式的转变是HarmonyOS开发与传统安卓开发最本质的区别之一。
丰富的内置组件运用是第二个技术亮点。应用灵活运用了 ArkUI 提供的各种内置组件,包括基础组件Text、Column、Row、Stack、Scroll、Grid、GridItem、Divider、Progress、Slider、TextInput,容器组件和功能组件。这些组件通过组合和嵌套,构建出了丰富多样的界面效果。掌握内置组件的特性和使用方法,是高效开发的基础。
状态管理的精准运用是第三个技术亮点。@State 装饰器在应用中得到了广泛而精准的运用,覆盖了导航、筛选、弹窗、表单、动画、反馈等多种状态类型。每种状态都有明确的职责和生命周期,状态之间的依赖关系清晰可追踪。良好的状态管理是应用稳定性和可维护性的基础。
函数式编程思想的融入是第四个技术亮点。数据处理逻辑全部封装为纯函数,遵循不可变数据原则,输入输出清晰可预测。这种函数式的风格与声明式UI相得益彰,共同构成了简洁优雅的代码结构。函数式编程与声明式UI的结合,是现代前端开发的重要趋势。
动画与交互的实现是第五个技术亮点。应用通过定时器加状态变量的方式实现了多种动画效果,包括像素跳动、霓虹闪烁、扫描线等。这些动画虽然实现简单,但营造出了独特的复古氛围,体现了小成本大效果的设计智慧。动画不一定要复杂才能出效果,关键是要有创意和目的性。
18.2 UI与UX设计维度总结
从用户界面和用户体验的角度来看,这个应用在视觉设计和交互设计上都有很多可圈可点之处。它不仅仅是功能的堆砌,而是经过了精心的设计打磨。
完整的设计系统是第一个设计亮点。应用拥有一套完整的设计令牌系统,包括颜色、圆角、间距、字体层级等多个维度的规范。统一的设计语言确保了整个应用视觉风格的一致性和专业感。设计令牌系统的建立,是从做页面到做产品的重要标志。
精心设计的配色方案是第二个设计亮点。深紫色基底加上霓虹粉和像素黄的配色方案非常成功,既营造了浓郁的复古游戏氛围,又保证了良好的信息可读性。每个数据项都有自己的主题色,这种数据驱动配色的设计让界面既多彩又有序。配色是UI设计中最具感染力的元素,好的配色能够在第一眼就抓住用户的注意力。
丰富的数据可视化是第三个设计亮点。应用大量使用了进度条、柱状图、条形图等数据可视化形式,将抽象的数字转化为直观的图形。这种可视化设计不仅美观,更重要的是提升了信息的传达效率。在信息过载的时代,好的数据可视化能够帮助用户快速获取关键信息。
层次分明的信息架构是第四个设计亮点。每个页面的信息都经过了精心的层级设计。从大标题到小标题,从主要数据到辅助说明,字体大小、颜色、字重的变化构建了清晰的信息层级,用户可以快速找到自己需要的信息。信息架构的设计直接影响用户的使用效率和体验感受。
细致入微的交互反馈是第五个设计亮点。应用提供了丰富的交互反馈,包括选中状态的视觉变化、弹窗的过渡效果、操作成功的提示条等。这些反馈虽然微小,但共同构成了流畅自然的交互体验。好的交互设计就像空气,用户感觉不到它的存在,但缺少了就会觉得不舒服。
18.3 代码质量维度总结
从代码质量的角度来看,这个单文件应用展现了相当高的工程素养。虽然代码量不大,但处处都能看到规范和用心。
清晰的组织结构是第一个质量亮点。代码按照类型定义、枚举常量、设计令牌、业务数据、辅助函数、主组件、各Tab组件的顺序自上而下排列,逻辑清晰,层次分明。即使是单文件实现,也保持了良好的可维护性。代码的组织结构就像文章的目录,好的结构让读者能够快速定位和理解。
规范的命名体系是第二个质量亮点。所有标识符都使用 RA 前缀,命名风格统一,含义清晰。变量名、函数名、组件名都能准确表达自身的用途和含义,代码的自文档化程度很高。好的命名是代码可读性的基础,也是团队协作的润滑剂。
良好的代码复用是第三个质量亮点。通过 @Builder 装饰器封装可复用的UI片段,通过纯函数封装可复用的数据处理逻辑。代码复用程度适中,既避免了重复代码,又没有过度抽象。适度的复用是提高开发效率的关键,过度的复用反而会增加复杂度。
一致的代码风格是第四个质量亮点。整个应用的代码风格高度一致,包括缩进、空格、换行、属性顺序等方面的规范。一致的代码风格降低了阅读和维护的认知成本。代码风格的一致性是团队编码规范的核心内容,也是代码审查的重要检查项。
完善的类型注解是第五个质量亮点。所有变量、函数参数、函数返回值都有明确的类型注解,TypeScript 的类型系统得到了充分的利用。类型安全为代码质量提供了基础保障。类型系统是静态检查的第一道防线,能够在编译时发现很多潜在的错误。
18.4 架构设计维度总结
从架构设计的角度来看,这个应用虽然规模不大,但架构设计的思路非常清晰,具备良好的可扩展性。小应用也可以有大架构,好的架构思想不一定要在大型项目中才能体现。
分层架构思想是第一个架构亮点。数据层、逻辑层、UI层三层分离,各层职责明确,耦合度低。数据层不依赖UI,逻辑层不依赖组件,UI层只负责展示和交互。这种分层设计使得各层可以独立演进。分层架构是软件工程中的经典模式,它的核心思想是关注点分离。
单一职责原则是第二个架构亮点。每个函数、每个组件都只负责一件事。辅助函数只做数据处理,@Builder 只做UI构建,组件只管理自己的状态和渲染。单一职责使得代码更加清晰,也更容易测试和维护。单一职责原则是SOLID原则中的第一条,也是最基础的一条。
状态集中管理是第三个架构亮点。跨组件的共享状态通过状态提升集中管理,确保状态的单一数据源。组件内部的状态由组件自己管理,外部无法直接修改。这种谁的状态谁管理的原则避免了状态混乱。状态管理是前端架构的核心问题,好的状态管理方案能够大大降低应用的复杂度。
数据驱动UI是第四个架构亮点。UI的展示完全由数据驱动,从导航到筛选,从列表到图表,一切都是数据的映射。数据驱动的设计使得UI逻辑更加可预测,也更容易调试。数据驱动是声明式UI的核心思想,也是现代前端开发的主流范式。
模块化的扩展潜力是第五个架构亮点。虽然目前是单文件实现,但代码的组织结构已经为模块化拆分做好了准备。如果未来应用规模扩大,可以很容易地将各层代码拆分为独立的文件和模块,而不需要大量改写。良好的架构应该能够平滑演进,而不是到了某个临界点就需要推倒重来。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
js
// ================================================================
// RETRO ARCADE 复古游戏收藏馆 - ArkTS 单文件实现
// 前缀 RA | 深紫 #1B1035 + 霓虹粉 #FF2E88 + 像素黄 #FFD52E
// 6 Tab:首页 / 掌机 / 卡带 / 街机 / 周边 / 我的
// ================================================================
// ============ 类型定义 ============
interface RACons {
id: number
name: string
brand: string
year: number
price: number
battery: number
sales: number
screen: string
weight: number
cat: string
colorHex: string
}
interface RACart {
id: number
name: string
platform: string
genre: string
rarity: number
year: number
price: number
sales: number
colorHex: string
}
interface RAArc {
id: number
name: string
cabinet: string
genre: string
width: number
depth: number
height: number
sales: number
year: number
score: number
colorHex: string
}
---
### 18.5 总结

对于学习 ArkTS 和声明式UI开发的开发者来说,这个应用具有很高的学习价值。它就像一个浓缩的知识库,涵盖了开发中的各个方面。
全面的技术覆盖是第一个学习价值点。应用涵盖了 ArkTS 开发中的大部分核心知识点,包括组件定义、状态管理、布局系统、列表渲染、弹窗实现、表单交互、动画效果等。通过学习这个应用,可以快速建立对 ArkTS 开发的全面认识。对于初学者来说,一个完整的、可运行的示例项目比零散的知识点学习更有效率。
实用的开发模式是第二个学习价值点。应用中使用的很多开发模式,弹窗模式、筛选模式、列表模式、图表模式等,都是实际项目中的常用模式。掌握这些模式,可以直接应用到真实的项目开发中。设计模式是经验的总结,学习模式能够站在前人的肩膀上,避免重复造轮子。
良好的示范作用是第三个学习价值点。代码的组织结构、命名规范、设计模式都体现了良好的工程实践。对于初学者来说,阅读和模仿这样的代码是提升编程能力的有效途径。榜样的力量是无穷的,好的代码示例比空洞的理论说教更有说服力。
设计与开发的结合是第四个学习价值点。应用不仅展示了技术实现,更体现了设计思维。配色方案、布局设计、交互设计都有值得学习的地方。技术与设计的结合,是构建优秀应用的关键。只会写代码不懂设计,做出来的产品会缺乏灵魂;只会设计不懂技术,设计方案可能无法落地。