引言:鸿蒙开发背景与 ArkTS 语言生态

鸿蒙操作系统是华为面向全场景智慧化生活推出的分布式操作系统,其设计理念围绕"一次开发,多端部署"展开,旨在让开发者用同一套代码适配手机、平板、智慧屏、车机等多种终端设备。在鸿蒙的应用开发体系中,ArkTS 是核心的开发语言,它基于 TypeScript 进行扩展,在保留 TypeScript 静态类型检查、面向对象特性、泛型能力的同时,针对声明式 UI 编程范式做了深度定制。ArkTS 引入了诸如 @Component@Entry@State@Builder@Link 等一系列装饰器,让开发者可以用极其简洁的语法描述界面的结构与状态,从而大幅降低跨端 UI 开发的心智负担。

ArkTS 的类型系统继承了 TypeScript 的严谨性,要求开发者在编译期就明确变量的类型与结构。这种强类型约束在大型应用中尤为珍贵——它可以在编译阶段就拦截掉大量因类型不匹配导致的运行时错误,让代码的可维护性大幅提升。在本文所分析的蒸汽波电台应用中,可以看到大量 interface 接口定义,每一个数据实体都被严格建模为带类型的接口,从 ShowItemGoodItem,每个字段都有明确的类型标注,这正是 ArkTS 强类型编程哲学的典型体现。

声明式 UI 范式是 ArkUI 框架的灵魂所在。与传统的命令式 UI 不同,声明式 UI 的核心思想是"描述界面应该是什么样子,而不是告诉系统怎么去构建界面"。开发者在 build() 方法中用链式调用声明组件树的结构与样式,框架则负责在状态变化时自动对比新旧视图树、计算最小差异并高效更新真实界面。这种模式让 UI 永远是状态的函数映射——只要状态正确,界面就一定正确,从根本上消灭了命令式编程中状态与界面不同步的经典难题。在本应用中,每一个 Tab 页面、每一个弹窗的显示与隐藏,都通过 @State 装饰的状态变量驱动,当用户点击按钮修改状态后,框架自动完成界面的局部刷新。

ArkUI 组件体系是鸿蒙应用开发的基石。它提供了从基础容器(ColumnRowStackFlex)到功能组件(TextImageTextInputListScrollProgress)的完整组件库,每个组件都支持丰富的链式属性设置——从尺寸(widthheight)、间距(paddingmargin)、圆角(borderRadius)、边框(border)到动画(animation)、手势(onClick)等。这些组件通过组合与嵌套,可以搭建出极其复杂的界面结构。本文分析的蒸汽波电台应用正是一个典型的多页面、多弹窗、多状态管理的综合案例,涵盖了六大 Tab 页面和十五个弹窗,是学习 ArkUI 完整组件体系与状态管理的绝佳素材。


一、数据建模:七大数据接口的架构设计

1.1 接口定义的工程意义

在任何前端应用中,数据建模都是最基础也是最关键的一环。本应用在文件最顶部定义了七个 interface,分别对应七种核心业务实体。接口在 ArkTS 中是一种纯类型契约,它不产生运行时代码,只用于编译期的类型检查。定义接口的好处在于:第一,让数据结构成为团队共识,任何开发者一看接口就知道某个实体有哪些字段;第二,让 IDE 的智能提示更加精准;第三,让 TypeScript 编译器能够在编译期捕获字段拼写错误和类型不匹配问题。

1.2 ShowItem:节目实体

interface ShowItem {
  id: number
  name: string
  host: string
  time: string
  tag: string
  hot: number
  note: string
}

在这里插入图片描述

ShowItem 是电台节目这一核心业务实体的类型定义。它包含七个字段:id 是唯一标识符,类型为 numbername 是节目名称;host 是主播名;time 是播出时段,用字符串格式如 "00:00-02:00" 存储;tag 是分类标签如"梦幻""点歌"等;hot 是热度值,一个 0-100 的数字;note 是节目描述文案。

技术要点: 在 ArkTS 中,interface 的字段之间使用换行分隔,每个字段都需显式声明类型。与 TypeScript 的 interface 不同,ArkTS 对接口的使用有更严格的约束,比如不允许在接口中定义方法实现,只能定义纯数据契约。这种设计促使开发者将数据与行为分离,有利于代码的解耦与测试。

1.3 TapeItem:磁带实体

interface TapeItem {
  id: number
  name: string
  singer: string
  era: string
  style: string
  hot: number
  note: string
}

在这里插入图片描述

TapeItem 描述的是一张复古磁带的元数据。与 ShowItem 相比,它用 singer 替代了 host(因为磁带关联的是歌手而非主播),用 era 字段记录年代(如 "1987"),用 style 记录风格(如"梦幻"“都市”“故障”)。hotnote 两个字段在多个实体中复用,体现了数据建模中"公共字段统一化"的设计思路——当多个实体都有热度值和备注说明时,统一使用相同名称和类型的字段,可以编写通用的工具函数来处理它们。

1.4 FmItem、RankItem、FeedItem、GoodItem、MyItem

interface FmItem {
  id: number
  name: string
  cat: string
  hot: number
  listen: number
  state: string
  note: string
}

interface RankItem {
  id: number
  name: string
  singer: string
  hot: number
  trend: string
  note: string
}

interface FeedItem {
  id: number
  title: string
  time: string
  tag: string
  text: string
}

interface GoodItem {
  id: number
  name: string
  price: number
  stock: number
  cover: string
  note: string
}

interface MyItem {
  id: number
  name: string
  kind: string
  date: string
  note: string
}

这五个接口分别描述了独立电台、榜单条目、动态资讯、周边商品和用户收藏五种实体。值得注意的几个设计细节:FmItem 中的 listen 字段是 number 类型,用于存储收听人次(可能超过万级),在后续的工具函数中会做"万"单位的格式化转换;FmItemstate 字段用字符串 "live""stand" 表示直播或待机状态;RankItemtrend 字段用 "up""down""keep" 表示榜单趋势方向;GoodItemcover 字段用 emoji 字符串充当商品封面图标;MyItemkind 字段用 "节目""磁带""电台""周边" 区分收藏类型。

技术要点: 使用字符串枚举值(如 "live"/"stand""up"/"down"/"keep")来表示有限状态,是前端开发中的常见做法。虽然 ArkTS 也支持 enum 枚举类型,但在轻量级应用中直接使用字符串字面量更加直观,也方便与后端 API 的字段值直接对接。

1.5 数据建模流程图

业务需求分析

识别核心实体

定义 interface 接口

编写写死数据常量

组件 @State 接收数据

build 方法渲染 UI

用户交互修改状态

框架自动刷新界面

ShowItem 节目

TapeItem 磁带

FmItem 电台

RankItem 榜单

FeedItem 动态

GoodItem 商品

MyItem 收藏


二、写死数据:应用数据层的初始化策略

2.1 常量数组的定义模式

在接口定义之后,应用使用 const 关键字定义了八组写死数据。这些数据是应用的初始数据源,在组件初始化时通过 @State 装饰器注入到组件内部状态中。

const SHOWS: ShowItem[] = [
  { id: 1, name: '午夜棕榈电台', host: 'Vivi 子', time: '00:00-02:00', tag: '梦幻', hot: 96, note: '凌晨档招牌节目,混入海浪白噪与慢速鼓点,营造半梦半醒的泳池畔氛围。' },
  { id: 2, name: '粉色霓虹点歌台', host: '阿澈', time: '20:00-22:00', tag: '点歌', hot: 92, note: '听众点歌互动节目,主打粉紫色系蒸汽波与未来放克,弹幕每秒都在刷屏。' },
  // ... 共 12 条
]

const TAPES: TapeItem[] = [
  { id: 1, name: '粉红海滩 1987', singer: 'VAPOR 合辑', era: '1987', style: '梦幻', hot: 95, note: '封面是棕榈树下的粉色泳池,收录 12 首慢速放克,翻录时保留了磁带的沙沙声。' },
  // ... 共 10 条
]

在这里插入图片描述

这里每一行数据都是一个对象字面量,其结构严格遵循对应的接口定义。SHOWS: ShowItem[] 的类型标注告诉编译器:这个数组中每个元素都必须符合 ShowItem 接口。如果某个对象漏写了 host 字段,或者 hot 写成了字符串而非数字,编译器会立即报错。

技术要点: const 声明的常量数组,其引用不可变(不能重新赋值为另一个数组),但数组内部内容是可变的(可以 pushsplice 等)。在本应用中,这些常量数组被赋值给 @State 装饰的组件状态变量后,组件内部通过 spliceunshift 等方法修改数组内容,框架会感知到变化并触发界面刷新。

2.2 HEAT 热度数据的特殊用途

const HEAT: number[] = [45, 62, 38, 70, 55, 78, 64, 52]

在这里插入图片描述

HEAT 是一个纯数字数组,存储了一周八天的热度数据,用于首页的柱状图渲染。它的类型标注为 number[],即数字数组。与对象数组不同,纯类型数组在 ForEach 渲染时更加轻量,因为不需要访问对象的属性。

2.3 数据层的整体架构

整个数据层的设计遵循了"接口先行,数据后填"的原则。先定义接口约束数据结构,再编写符合接口的常量数据,最后在组件中通过 @State 接收。这种分层设计的好处是:当未来需要将写死数据替换为网络请求获取的远程数据时,只要远程数据返回的 JSON 结构与接口定义一致,组件层的代码完全不需要修改。这是前端工程中数据层与视图层解耦的标准做法。


三、工具函数:纯函数在 UI 渲染中的关键作用

3.1 heatBar:热度值到柱状图高度的转换

function heatBar(v: number): number {
  return Math.floor(28 + v * 0.85)
}

heatBar 是一个纯函数,接收一个热度值(如 45),返回柱状图的高度像素值。其计算逻辑是 28 + v * 0.85,即基础高度 28 像素加上热度值乘以 0.85 的系数。当热度为 78 时,高度为 28 + 78 * 0.85 = 94.3,取整后为 94 像素。Math.floor 用于向下取整,确保高度值是整数像素,避免渲染时出现亚像素模糊。

纯函数在声明式 UI 中非常重要。因为框架会在每次状态变化时重新调用 build() 方法,如果函数有副作用或不纯(比如依赖外部可变状态),可能导致同样的输入产生不同的渲染结果,造成界面闪烁或数据不一致。heatBar 的纯函数特性保证了"输入相同的热度值,永远得到相同的高度"。

3.2 playsText:收听人次的格式化

function playsText(v: number): string {
  if (v >= 10000) {
    return (v / 10000).toFixed(1) + ' 万'
  }
  return v.toString()
}

在这里插入图片描述

playsText 解决了一个常见的数字格式化问题:当收听人次超过一万时,显示为"5.3 万"这种中文惯用格式,否则直接显示原始数字。(v / 10000).toFixed(1) 将数字除以一万后保留一位小数,如 52800 / 10000 = 5.28.toFixed(1) 后变为 "5.3",拼接 " 万" 得到 "5.3 万"。这种格式化方式在中文互联网产品中非常普遍,能让大数字更易读。

3.3 trendText 与 trendColor:榜单趋势的文本与色彩映射

function trendText(t: string): string {
  if (t === 'up') {
    return '↑ 上升'
  }
  if (t === 'down') {
    return '↓ 下滑'
  }
  return '→ 持平'
}

function trendColor(t: string): string {
  if (t === 'up') {
    return '#F472B6'
  }
  if (t === 'down') {
    return '#67E8F9'
  }
  return '#8B7BA8'
}

这两个函数是一对配合使用的工具函数,分别返回趋势的文本描述和颜色值。trendText"up" 映射为 "↑ 上升""down" 映射为 "↓ 下滑""keep" 映射为 "→ 持平"trendColor 则将同样的输入映射为不同的十六进制颜色值:上升用粉色 #F472B6(暖色调表示上升),下滑用青色 #67E8F9(冷色调表示下降),持平用紫灰 #8B7BA8(中性色表示稳定)。

技术要点: 将数据值映射为 UI 属性(文本、颜色)的逻辑提取为独立函数,是前端开发中"关注点分离"的最佳实践。如果这些映射逻辑散落在组件的 build() 方法内部,代码会变得臃肿且难以维护;提取为函数后,既可以在多处复用,又方便单独测试和修改。

3.4 fmStateText 与 fmStateColor:电台状态的映射

function fmStateText(s: string): string {
  if (s === 'live') {
    return '直播中'
  }
  return '待机'
}

function fmStateColor(s: string): string {
  if (s === 'live') {
    return '#F472B6'
  }
  return '#8B7BA8'
}

在这里插入图片描述

与趋势映射函数类似,fmStateTextfmStateColor 将电台状态字符串映射为中文标签和颜色值。直播状态用粉色高亮,待机状态用紫灰弱化。这种"数据值 -> 视觉属性"的映射函数贯穿了整个应用,是状态驱动的声明式 UI 中连接数据层与视觉层的关键桥梁。

3.5 buildFeed、buildMy、buildShow:数据工厂函数

function buildFeed(id: number, title: string): FeedItem {
  return { id: id, title: title, time: '刚刚', tag: '速递', text: '这是一条刚刚发布的电台动态,欢迎关注 VAPORWAVE FM 的最新资讯。' }
}

function buildMy(id: number, name: string, kind: string): MyItem {
  return { id: id, name: name, kind: kind, date: '08-28', note: '刚刚收藏' }
}

function buildShow(id: number, name: string, host: string): ShowItem {
  return { id: id, name: name, host: host, time: '19:00-20:00', tag: '新节目', hot: 70, note: '这是一档刚刚上线的蒸汽波新节目,首播人气正在攀升,欢迎收听。' }
}

这三个是工厂函数,用于在运行时动态创建新的数据对象。buildFeed 接收 idtitle,自动填充 time 为"刚刚"、tag 为"速递";buildMy 接收 idnamekind,自动填充 datenotebuildShow 接收 idnamehost,自动填充 timetaghotnote。工厂函数的价值在于封装了对象创建的默认值逻辑,调用方只需提供核心字段,其余字段自动填充合理默认值。

技术要点: 工厂函数模式在前端开发中极为常用。它比直接写对象字面量有两个优势:一是集中管理默认值,修改时只需改一处;二是类型安全,函数签名明确约束了哪些参数是必需的,编译器可以检查调用方是否传齐了参数。


四、主入口 Index:六 Tab 架构与故障艺术头部

4.1 组件声明与状态定义

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State shows: ShowItem[] = SHOWS
  @State tapes: TapeItem[] = TAPES
  @State fms: FmItem[] = FMS
  @State ranks: RankItem[] = RANKS
  @State feeds: FeedItem[] = FEEDS
  @State goods: GoodItem[] = GOODS
  @State mys: MyItem[] = MYS

在这里插入图片描述

@Entry 装饰器标记此组件为应用的根入口组件,整个应用由此组件开始渲染。@Component 装饰器声明这是一个自定义组件,可以被其他组件引用或在 build() 中渲染。struct Index 定义了组件的结构体,ArkTS 使用 struct 而非 class 来定义组件,这是因为组件本质上是一个不可变的数据结构加上一个 build() 方法,struct 的值语义更符合声明式 UI 的需求。

@State 装饰器是 ArkUI 状态管理体系中最基础的一环。被 @State 装饰的变量成为"可观察的状态变量",当它的值发生变化时,框架会自动重新执行 build() 方法中依赖该变量的部分,实现精准的局部刷新。这里有七个 @State 变量:currentTab 记录当前激活的 Tab 索引(0-5),其余六个分别存储六个页面的数据数组。

技术要点: @State 装饰的变量在值变化时触发 UI 刷新,但"值变化"的判定因类型而异。对于基本类型(numberstringboolean),直接赋新值即可触发刷新;对于数组和对象,需要通过框架感知的方法(如 pushspliceunshift)修改,或直接赋值一个新数组/对象,才能触发刷新。这是声明式 UI 状态管理的核心机制。

4.2 tabItem:Tab 项的 Builder 构建

@Builder
tabItem(icon: string, label: string, tab: number) {
  Column({ space: 3 }) {
    Text(icon)
      .fontSize(22)
    Text(label)
      .fontSize(11)
      .fontColor(tab === this.currentTab ? '#F472B6' : '#8B7BA8')
      .fontWeight(tab === this.currentTab ? 900 : 400)
  }
  .width('100%')
  .scale(tab === this.currentTab ? { x: 1.1, y: 1.1 } : { x: 1, y: 1 })
  .animation({ duration: 200, curve: Curve.EaseOut })
  .onClick(() => {
    this.currentTab = tab
  })
}

在这里插入图片描述

@Builder 装饰器用于定义一个可复用的 UI 构建片段。与 @Component 不同,@Builder 方法不产生独立的组件实例,而是在调用处内联展开,更适合简单 UI 片段的复用。这里 tabItem 接收三个参数:icon(图标 emoji)、label(文字标签)、tab(对应的 Tab 索引)。

Column({ space: 3 }) 创建一个垂直布局容器,子元素之间间距 3 像素。Column 是 ArkUI 中最常用的容器组件之一,它将子元素按垂直方向从上到下依次排列。内部的两个 Text 组件分别渲染图标和文字。fontColorfontWeight 使用三元运算符根据当前 Tab 是否激活来决定颜色和字重——激活时用粉色 #F472B6 和字重 900,未激活时用紫灰 #8B7BA8 和字重 400。

.scale() 属性控制组件的缩放比例。激活的 Tab 放大 1.1 倍,未激活的正常大小。.animation({ duration: 200, curve: Curve.EaseOut }) 为缩放变化添加了 200 毫秒的缓出动画,让切换 Tab 时有平滑的放大/缩小过渡效果。Curve.EaseOut 是一种先快后慢的缓动曲线,适合"出现"类动画。

技术要点: @Builder 方法中通过参数 tabthis.currentTab 比较来决定样式,这是声明式 UI 中典型的"状态驱动样式"模式。当 this.currentTab 变化时,@Builder 所在的组件会重新渲染,所有 tabItem 调用都会重新计算样式,实现 Tab 切换时的视觉反馈。Column 容器的 space 参数等价于 CSS 的 gap,但它是 ArkUI 原生属性,性能更优。

4.3 故障艺术头部:蒸汽波视觉风格的代码实现

// 头部:粉紫故障错位条(蒸汽波风,无动画)
Column() {
  Row({ space: 10 }) {
    Column({ space: 1 }) {
      Row({ space: 6 }) {
        Text('🌴')
          .fontSize(14)
        Text('VAPORWAVE FM')
          .fontSize(19)
          .fontWeight(900)
          .fontColor('#F472B6')
          .letterSpacing(2)
      }
      Text('蒸汽波电台 · 始于 1984')
        .fontSize(10)
        .fontColor('#8B7BA8')
        .letterSpacing(1)
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Row({ space: 14 }) {
      Text('🔍')
        .fontSize(18)
        .onClick(() => {
          this.currentTab = 0
        })
      Text('📼')
        .fontSize(18)
        .onClick(() => {
          this.currentTab = 2
        })
    }
  }
  .width('100%')
  .padding({ left: 18, right: 18, top: 12, bottom: 10 })

头部区域是整个应用视觉风格的核心展示区。最外层 Column 包含三行内容:标题行、故障分隔条行、导航标签行。

标题行用 Row({ space: 10 }) 创建水平布局容器,内部分为左右两部分。左侧是一个 Column,包含品牌名称"VAPORWAVE FM"和副标题"蒸汽波电台 · 始于 1984"。.alignItems(HorizontalAlign.Start) 让子元素左对齐,.layoutWeight(1) 让左侧占据剩余空间。letterSpacing 属性控制字符间距,增加品牌名的呼吸感。

右侧是两个图标快捷入口,分别点击跳转到首页(Tab 0)和磁带页(Tab 2)。onClick 回调直接修改 this.currentTab,通过状态变化驱动 Stack 内容区的切换。

技术要点: Row 是 ArkUI 的水平布局容器,与 Column 互为镜像。layoutWeight 是 ArkUI 弹性布局的核心属性,类似于 CSS 的 flex: 1,它让组件在父容器中占据剩余空间的权重比例。HorizontalAlign.Start 是水平对齐枚举值,在 Column 容器中控制子元素的水平对齐方式。

4.4 三色渐变故障分隔条

// 粉紫青三色渐变分隔条 + 故障错位方块
Row() {
  Text('')
    .width(12)
    .height(12)
    .backgroundColor('#F472B6')
  Text('')
    .layoutWeight(1)
    .height(3)
    .backgroundColor('#A855F7')
  Text('')
    .width(8)
    .height(8)
    .backgroundColor('#67E8F9')
    .margin({ left: 4, right: 4 })
  Text('')
    .layoutWeight(1)
    .height(3)
    .backgroundColor('#F472B6')
  Text('')
    .width(8)
    .height(8)
    .backgroundColor('#67E8F9')
    .margin({ left: 4, right: 4 })
  Text('')
    .layoutWeight(1)
    .height(3)
    .backgroundColor('#A855F7')
  Text('')
    .width(12)
    .height(12)
    .backgroundColor('#F472B6')
}
.width('100%')
.padding({ left: 18, right: 18 })

这一段是蒸汽波"故障艺术"(Glitch Art)风格的分隔条实现。它由七个 Text('') 空文本组件组成,通过不同的 widthheightbackgroundColormargin 组合,模拟出故障艺术中色块错位、条带断裂的视觉效果。

两端是 12x12 的粉色方块,中间交替排列着三条 layoutWeight(1) 的细线(高度 3 像素,颜色交替为紫、粉、紫)和两个 8x8 的青色小方块。layoutWeight(1) 让三条细线均分剩余空间,形成渐变带的效果。margin({ left: 4, right: 4 }) 给青色方块添加左右间距,形成"方块漂浮在色带上"的错位感。

技术要点: 用空 Text 组件充当色块是 ArkUI 中一种巧妙的"图形绘制"技巧。当需要简单的矩形色块时,无需引入 Image 或 Canvas,直接用 Text('') 设置 backgroundColorwidthheight 即可。margin 属性控制组件外边距,与 padding(内边距)的区别在于:padding 是组件内部内容与边框的间距,margin 是组件与其他元素之间的间距。

4.5 Stack 内容区与 Tab 切换

// 内容区
Stack() {
  if (this.currentTab === 0) {
    HomeContent({ feeds: $feeds, shows: $shows })
  }
  if (this.currentTab === 1) {
    ShowContent({ shows: $shows, mys: $mys })
  }
  if (this.currentTab === 2) {
    TapeContent({ tapes: $tapes, mys: $mys })
  }
  if (this.currentTab === 3) {
    FmContent({ fms: $fms })
  }
  if (this.currentTab === 4) {
    RankContent({ ranks: $ranks, goods: $goods })
  }
  if (this.currentTab === 5) {
    MeContent({ mys: $mys })
  }
}
.layoutWeight(1)
.width('100%')

Stack 是 ArkUI 的层叠布局容器,它将子元素叠放在一起,后面的元素覆盖前面的。但这里利用了六个 if 条件分支——在任意时刻只有一个条件为 true,因此实际上只有一个子组件被渲染。Stack 在这里的作用更像是一个"占位容器",通过条件渲染实现页面切换。

$feeds$shows 等带 $ 前缀的语法是 ArkUI 的双向绑定引用。$feeds 表示将 feeds 状态变量的引用传递给子组件,子组件内部对该数组的修改会反映回父组件。这是 @Link 装饰器的工作机制:子组件通过 @Link 接收父组件的 $ 引用,实现父子组件之间的双向数据同步。

技术要点: Stack 层叠布局在 ArkUI 中有三个典型用途:一是真正的层叠(如背景+前景),二是通过条件渲染实现页面切换(如本例),三是结合 zIndexposition 实现绝对定位浮层。$ 引用语法是 ArkUI 状态管理中实现父子通信的关键机制,类似于 Vue 的 v-model 但更加显式。

4.6 底部 Tab 栏

// 底部 Tab 栏
Row() {
  this.tabItem('🏠', '首页', 0)
  this.tabItem('📻', '节目', 1)
  this.tabItem('📼', '磁带', 2)
  this.tabItem('📡', '电台', 3)
  this.tabItem('🏆', '榜单', 4)
  this.tabItem('👤', '我的', 5)
}
.width('100%')
.height(62)
.padding({ top: 6, bottom: 6 })
.backgroundColor('#1A1230')
.border({ width: { top: 1 }, color: '#33255A' })

底部 Tab 栏用 Row 水平排列六个 tabItem 调用。由于每个 tabItem 内部的 Column 设置了 .width('100%'),六个 Tab 项均分底部宽度。height(62) 固定底部栏高度为 62 像素,padding 上下各 6 像素留白。border({ width: { top: 1 }, color: '#33255A' }) 只在顶部添加 1 像素的紫灰边框,与上方内容区形成视觉分隔。

4.7 Index 组件整体结构流程图

Index 根入口组件

0

1

2

3

4

5

onClick

onClick

onClick

onClick

onClick

onClick

@State currentTab
当前 Tab 索引

build 方法

头部区域 Column

Stack 内容区

底部 Tab 栏 Row

品牌标题

故障分隔条

导航标签行

currentTab ==?

HomeContent 首页

ShowContent 节目

TapeContent 磁带

FmContent 电台

RankContent 榜单

MeContent 我的

tabItem 首页

tabItem 节目

tabItem 磁带

tabItem 电台

tabItem 榜单

tabItem 我的


五、首页 HomeContent:横幅、柱状图与动态列表

5.1 组件声明与 @Link 双向绑定

@Component
struct HomeContent {
  @Link feeds: FeedItem[]
  @Link shows: ShowItem[]
  @State showDetail: boolean = false
  @State picked: FeedItem = FEEDS[0]
  @State showPlay: boolean = false
  @State playSong: string = ''
  @State playWho: string = ''
  @State tip: string = ''

HomeContent 是首页内容组件。它通过 @Link 接收父组件 Index 传入的 feedsshows 数组的引用,这意味着在 HomeContent 内部对这两个数组的修改(如 unshift 新增数据)会同步反映到父组件的 @State 变量中,进而影响其他使用相同数据的 Tab 页面。@Link 装饰器要求父组件必须以 $ 语法传入引用(如 { feeds: $feeds }),这是 ArkUI 实现父子双向数据同步的标准模式。

@State 装饰的 showDetailshowPlay 是布尔型状态变量,控制弹窗的显示与隐藏。picked 存储当前选中的动态条目,初始值为 FEEDS[0]playSongplayWhotip 分别存储点播表单的输入值和操作提示文案。

技术要点: @Link@State 的区别在于数据流向。@State 是组件内部管理的私有状态,变化时触发本组件刷新;@Link 是从父组件接收的引用,变化时既触发本组件刷新,又同步回父组件。@Link 装饰的变量不能在子组件内直接初始化,必须由父组件通过 $ 引用传入。

5.2 feedModalOverlay:动态详情弹窗

@Builder
feedModalOverlay(onClose: () => void) {
  Column({ space: 0 }) {
    Row({ space: 8 }) {
      Text('📡')
        .fontSize(20)
      Text('电台动态')
        .fontSize(17)
        .fontWeight(900)
        .fontColor('#E8DFF5')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#8B7BA8')
        .padding(6)
        .onClick(() => {
          onClose()
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 12 })
    .border({ width: { bottom: 1 }, color: '#33255A' })

    Column({ space: 10 }) {
      Text(this.picked.title)
        .fontSize(17)
        .fontWeight(900)
        .fontColor('#F472B6')
      Row({ space: 8 }) {
        Text(this.picked.tag)
          .fontSize(10)
          .fontColor('#67E8F9')
          .backgroundColor('#14263A')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(10)
        Text(this.picked.time)
          .fontSize(10)
          .fontColor('#8B7BA8')
      }
      Text(this.picked.text)
        .fontSize(13)
        .fontColor('#C9BDE8')
        .lineHeight(22)
    }
    .width('100%')
    .padding(16)
    .alignItems(HorizontalAlign.Start)

    Text('已助力 ' + (this.picked.id * 137).toString() + ' 次收听')
      .fontSize(11)
      .fontColor('#8B7BA8')
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 12 })
  }
  .width('86%')
  .constraintSize({ maxHeight: '80%' })
  .backgroundColor('#1E1538')
  .borderRadius(14)
  .border({ width: 1, color: '#33255A' })
}

这是一个典型的弹窗 @Builder 方法。它接收一个 onClose 回调函数作为参数,在关闭按钮点击时调用。弹窗的结构分为三个部分:标题栏(带图标、标题文字、关闭按钮)、内容区(展示 picked 动态的标题、标签、时间、正文)、底部统计信息。

标题栏用 Row 水平排列,Text('').layoutWeight(1) 作为弹性占位把关闭按钮推到最右侧。.border({ width: { bottom: 1 }, color: '#33255A' }) 只在底部添加边框,形成标题栏与内容区的分隔线。内容区的标签用 backgroundColor + padding + borderRadius 组合实现了"药丸标签"(pill tag)效果——青色文字配深青色背景,圆角 10 像素。

弹窗最外层设置了 .width('86%') 占屏幕宽度的 86%,.constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%,.borderRadius(14) 设置 14 像素圆角,.border({ width: 1, color: '#33255A' }) 添加 1 像素紫灰边框。

技术要点: constraintSize 用于设置组件的尺寸约束,包括 maxWidthmaxHeightminWidthminHeight。在弹窗设计中,限制最大高度非常重要——当内容过多时防止弹窗超出屏幕,配合外层 Scroll 可以实现可滚动弹窗。borderRadius 设置圆角半径,值越大越圆润,14 像素是移动端卡片常用的圆角值。

5.3 playModalOverlay:点播弹窗与 TextInput

@Builder
playModalOverlay(onClose: () => void) {
  Column({ space: 0 }) {
    Row({ space: 8 }) {
      Text('🎧')
        .fontSize(20)
      Text('点播新曲')
        .fontSize(17)
        .fontWeight(900)
        .fontColor('#E8DFF5')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#8B7BA8')
        .padding(6)
        .onClick(() => {
          onClose()
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 12 })
    .border({ width: { bottom: 1 }, color: '#33255A' })

    Column({ space: 12 }) {
      Text('想听什么?把歌名告诉主播')
        .fontSize(13)
        .fontColor('#8B7BA8')
      TextInput({ placeholder: '输入歌曲名', text: this.playSong })
        .height(40)
        .backgroundColor('#241A42')
        .fontColor('#E8DFF5')
        .placeholderColor('#8B7BA8')
        .borderRadius(6)
        .onChange((v: string) => {
          this.playSong = v
        })
      TextInput({ placeholder: '点给谁(可留空)', text: this.playWho })
        .height(40)
        .backgroundColor('#241A42')
        .fontColor('#E8DFF5')
        .placeholderColor('#8B7BA8')
        .borderRadius(6)
        .onChange((v: string) => {
          this.playWho = v
        })

点播弹窗包含两个 TextInput 输入框组件。TextInput 是 ArkUI 的文本输入组件,支持 placeholder(占位提示文字)、text(当前值)、placeholderColor(占位文字颜色)等属性。.onChange((v: string) => { this.playSong = v }) 监听输入变化,将输入值同步到状态变量。.height(40) 设置输入框高度 40 像素,backgroundColorborderRadius 统一了深紫色圆角输入框的视觉风格。

技术要点: TextInput 组件的 text 属性用于设置初始值,但真正的双向绑定需要通过 onChange 回调手动实现——在回调中将输入值赋给 @State 变量。这种"初始值 + onChange 赋值"的模式是 ArkUI 中实现受控输入框的标准做法。placeholderColor 控制占位文字的颜色,在深色主题中尤其重要,避免占位文字与实际输入文字混淆。

5.4 点播提交逻辑与表单验证

      Row({ space: 10 }) {
        Text('取消')
          .textAlign(TextAlign.Center)
          .width('45%')
          .height(40)
          .fontSize(13)
          .fontColor('#8B7BA8')
          .backgroundColor('#241A42')
          .borderRadius(6)
          .onClick(() => {
            this.playSong = ''
            this.playWho = ''
            this.tip = ''
            onClose()
          })
        Text('提交点播')
          .textAlign(TextAlign.Center)
          .width('45%')
          .height(40)
          .fontSize(13)
          .fontWeight(800)
          .fontColor('#0F0A1A')
          .backgroundColor('#F472B6')
          .borderRadius(6)
          .onClick(() => {
            if (this.playSong.length === 0) {
              this.tip = '请先输入歌名'
              return
            }
            this.shows.unshift(buildShow(100 + this.shows.length, this.playSong + '(点播)', this.playWho.length > 0 ? this.playWho : '听众'))
            this.tip = '✅ 点播成功,已加入播放队列'
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)

      Text(this.tip)
        .fontSize(11)
        .fontColor('#67E8F9')
    }

提交按钮的 onClick 回调实现了表单验证逻辑:先检查 this.playSong.length === 0,如果歌名为空则设置提示信息"请先输入歌名"并 return 中断执行。验证通过后,调用 this.shows.unshift(buildShow(...)) 在节目列表头部插入新节目。unshift 是 JavaScript/TypeScript 数组的原生方法,在数组头部添加元素并返回新长度。由于 shows@Link 装饰的引用数组,修改后父组件 Indexshows 状态也会同步更新。

buildShow(100 + this.shows.length, ...) 使用 100 + length 作为新节目的 id,避免与已有数据的 id(1-12)冲突。点播的节目名后缀"(点播)“,主播名如果用户填写了点给谁则用填写值,否则默认"听众”。

技术要点: justifyContent(FlexAlign.Center) 控制弹性布局中子元素在主轴(水平方向)上的对齐方式。FlexAlign.Center 让两个按钮在行内居中排列。FlexAlign 枚举还包括 StartEndSpaceBetween(两端对齐,首尾贴边)、SpaceAround(等间距,两侧有半间距)、SpaceEvenly(完全等间距)等值。

5.5 电台横幅与柱状图

// 电台横幅
Column({ space: 8 }) {
  Row({ space: 8 }) {
    Text('🔴')
      .fontSize(10)
    Text('ON AIR · 直播中')
      .fontSize(11)
      .fontWeight(900)
      .fontColor('#F472B6')
      .letterSpacing(1)
  }
  Text('VAPORWAVE FM 96.9')
    .fontSize(24)
    .fontWeight(900)
    .fontColor('#E8DFF5')
    .letterSpacing(2)
  Text('午夜棕榈电台 · 凌晨档 · 海浪白噪混慢速放克')
    .fontSize(12)
    .fontColor('#C9BDE8')
  Row({ space: 14 }) {
    Text('📻 2.3 万人在线')
      .fontSize(11)
      .fontColor('#67E8F9')
    Text('💾 收听峰值 5.2 万')
      .fontSize(11)
      .fontColor('#8B7BA8')
  }
}
.width('100%')
.padding(18)
.alignItems(HorizontalAlign.Start)
.backgroundColor('#241A42')
.borderRadius(12)
.border({ width: 1, color: '#F472B6' })

电台横幅是首页最顶部的卡片,展示当前直播状态。.border({ width: 1, color: '#F472B6' }) 给整个卡片添加粉色边框,强调直播中的视觉突出感。alignItems(HorizontalAlign.Start)Column 内所有子元素左对齐,符合阅读习惯。

柱状图是首页的核心数据可视化组件:

// 周热度柱状图
Row({ space: 8 }) {
  ForEach(HEAT, (v: number, i: number) => {
    Column({ space: 4 }) {
      Text(v.toString())
        .fontSize(9)
        .fontColor('#F472B6')
      Text('')
        .width(24)
        .height(heatBar(v))
        .backgroundColor(i % 2 === 0 ? '#F472B6' : '#A855F7')
        .borderRadius(4)
        .animation({ duration: 700, curve: Curve.EaseOut })
      Text('周' + (i + 1).toString())
        .fontSize(9)
        .fontColor('#8B7BA8')
    }
  }, (v: number) => v.toString())
}
.width('100%')
.height(120)
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.SpaceBetween)

ForEach 是 ArkUI 的列表渲染组件,它遍历 HEAT 数组,为每个元素生成一个 Column。第一个参数是数据源数组,第二个是渲染函数(接收值和索引),第三个是键值生成函数(用于 Diff 算法的元素标识)。每个柱子是一个 Column,从上到下包含:热度数字、色块柱体、周标签。柱体用 Text('') 空文本加 height(heatBar(v)) 实现,高度由工具函数计算。i % 2 === 0 让奇偶柱子交替使用粉色和紫色,增加视觉层次。

.alignItems(VerticalAlign.Bottom)Row 内的子元素底部对齐,这是柱状图的关键——所有柱子底部对齐,高度不同形成高低起伏的效果。.justifyContent(FlexAlign.SpaceBetween) 让柱子均匀分布,首尾贴边。.animation({ duration: 700, curve: Curve.EaseOut }) 给柱体高度变化添加 700 毫秒的缓出动画。

技术要点: ForEach 的第三个参数(键值生成函数)非常重要。它返回一个字符串作为列表项的唯一标识,框架的 Diff 算法通过比较新旧键值来判断哪些项需要增删改。如果省略或返回重复值,可能导致渲染异常。(v: number) => v.toString() 将热度值转为字符串作为键值,在本例中因为 HEAT 值有重复(如两个 64),实际应使用索引作为键更合适,但本应用因数据固定不影响功能。

5.6 电台动态列表与弹层渲染

ForEach(this.feeds, (f: FeedItem) => {
  Row({ space: 10 }) {
    Text(f.tag)
      .fontSize(10)
      .fontColor('#67E8F9')
      .backgroundColor('#14263A')
      .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      .borderRadius(10)
    Column({ space: 4 }) {
      Text(f.title)
        .fontSize(13)
        .fontWeight(700)
        .fontColor('#E8DFF5')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(f.time)
        .fontSize(10)
        .fontColor('#8B7BA8')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Text('查看')
      .fontSize(11)
      .fontColor('#F472B6')
  }
  .width('100%')
  .padding(10)
  .backgroundColor('#1A1230')
  .borderRadius(10)
  .onClick(() => {
    this.picked = f
    this.showDetail = true
  })
}, (f: FeedItem) => f.id.toString())

每条动态是一个 Row,左侧是药丸标签,中间是标题和时间,右侧是"查看"链接。.maxLines(1) 限制标题最多一行,.textOverflow({ overflow: TextOverflow.Ellipsis }) 让超长文字以省略号结尾,防止文字溢出破坏布局。.onClick 回调设置 this.picked = fthis.showDetail = true,同时修改选中的数据和弹窗显示状态,触发弹窗渲染。

弹窗的渲染通过条件 if 实现:

// 弹层
if (this.showDetail) {
  Column() {
    this.feedModalOverlay(() => {
      this.showDetail = false
    })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#66000000')
  .justifyContent(FlexAlign.Center)
}

showDetailtrue 时,渲染一个全屏覆盖的 Column,背景色 #66000000(半透明黑色,66 是十六进制透明度,约 40% 不透明度),居中显示弹窗内容。feedModalOverlay 接收一个箭头函数作为 onClose 回调,点击关闭按钮时调用 this.showDetail = false,条件变为 false 后弹窗从 DOM 中移除。

技术要点: #66000000 是 8 位十六进制颜色值,前两位 66 是 alpha 通道(透明度),后六位 000000 是 RGB。透明度从 00(完全透明)到 FF(完全不透明),66 约等于 40% 不透明度,是移动端遮罩层的常用值。.justifyContent(FlexAlign.Center) 让弹窗在遮罩层中垂直水平居中,形成经典的"弹窗居中"效果。这种"状态变量 + 条件渲染"的弹窗模式,是 ArkUI 中实现弹窗的标准方案,无需引入额外的弹窗管理组件。


六、节目页 ShowContent:时间轴列表与收听打卡

6.1 时间轴列表的布局设计

List() {
  ForEach(this.shows, (s: ShowItem) => {
    ListItem() {
      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text(s.time.split('-')[0])
            .fontSize(12)
            .fontWeight(900)
            .fontColor('#F472B6')
          Text(s.time.split('-')[1])
            .fontSize(10)
            .fontColor('#8B7BA8')
        }
        .width(46)
        .alignItems(HorizontalAlign.Center)

        Text('')
          .width(3)
          .height(48)
          .backgroundColor(s.id % 2 === 0 ? '#A855F7' : '#67E8F9')
          .borderRadius(2)

        Column({ space: 4 }) {
          Text(s.name)
            .fontSize(14)
            .fontWeight(800)
            .fontColor('#E8DFF5')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Text('🎙️ ' + s.host + ' · ' + s.tag + ' · 热度 ' + s.hot.toString())
            .fontSize(10)
            .fontColor('#8B7BA8')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text('详情')
          .fontSize(11)
          .fontColor('#67E8F9')
      }
      .width('100%')
      .padding(10)
      .backgroundColor('#1A1230')
      .borderRadius(10)
      .onClick(() => {
        this.picked = s
        this.showDetail = true
      })
    }
  }, (s: ShowItem) => s.id.toString())
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)

节目列表使用 List + ListItem 组件实现。List 是 ArkUI 的高性能列表容器,相比 ForEach + Column 的普通列表,List 支持懒加载(只渲染可见区域的项),在数据量大时性能优势明显。ListItemList 的直接子组件,每个列表项必须包裹在 ListItem 中。

时间轴的视觉效果通过三列布局实现:左侧是时间段(用 s.time.split('-')[0] 提取起始时间,split('-')[1] 提取结束时间),中间是 3 像素宽的彩色竖线(模拟时间轴线),右侧是节目信息。竖线颜色用 s.id % 2 === 0 交替使用紫色和青色,形成时间轴的节奏感。.scrollBar(BarState.Off) 隐藏滚动条,让界面更干净。

技术要点: List 组件与 Scroll + Column + ForEach 的区别在于性能。List 使用懒加载策略,只有进入可见区域的列表项才会被创建和渲染,移出可见区域后会被回收,这使得它可以高效渲染上千条数据。而 ForEachScroll 中会一次性创建所有子组件,数据量大时会导致卡顿。在数据量不超过几十条时两者差异不大,但 List 是更专业的列表方案。

6.2 节目详情弹窗与热度进度条

Row() {
  Text('')
    .layoutWeight(this.picked.hot)
    .height(6)
    .backgroundColor('#F472B6')
    .borderRadius(3)
  Text('')
    .layoutWeight(100 - this.picked.hot)
    .height(6)
    .backgroundColor('#33255A')
    .borderRadius(3)
}
.width('100%')

热度进度条用两个 layoutWeight 的空 Text 组合实现。左侧 layoutWeight(this.picked.hot) 占据热度值比例的宽度,右侧 layoutWeight(100 - this.picked.hot) 占据剩余比例。当热度为 96 时,左侧占 96/100,右侧占 4/100,视觉上几乎填满。height(6) 设置 6 像素高度,borderRadius(3) 设置半高圆角实现胶囊形。

技术要点: layoutWeight 接收一个数字作为权重值,多个同级的 layoutWeight 组件按权重比例分配父容器空间。这里用"hot : 100-hot"的比例实现进度条效果,是一种简洁而优雅的方案,无需引入 Progress 组件。当 hot 值变化时,两侧的 layoutWeight 自动重新计算,进度条实时更新。

6.3 收听打卡弹窗

@Builder
checkModalOverlay(onClose: () => void) {
  Column({ space: 0 }) {
    Row({ space: 8 }) {
      Text('⏱️')
        .fontSize(20)
      Text('收听打卡')
        .fontSize(17)
        .fontWeight(900)
        .fontColor('#E8DFF5')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#8B7BA8')
        .padding(6)
        .onClick(() => {
          onClose()
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 12 })
    .border({ width: { bottom: 1 }, color: '#33255A' })

    Column({ space: 12 }) {
      Text('《' + this.picked.name + '》收听满 30 分钟即可打卡')
        .fontSize(13)
        .fontColor('#C9BDE8')
      TextInput({ placeholder: '本次收听了多少分钟', text: this.checkMin })
        .height(40)
        .backgroundColor('#241A42')
        .fontColor('#E8DFF5')
        .placeholderColor('#8B7BA8')
        .borderRadius(6)
        .onChange((v: string) => {
          this.checkMin = v
        })
      Row({ space: 10 }) {
        Text('取消')
          .textAlign(TextAlign.Center)
          .width('45%')
          .height(40)
          .fontSize(13)
          .fontColor('#8B7BA8')
          .backgroundColor('#241A42')
          .borderRadius(6)
          .onClick(() => {
            this.checkMin = ''
            this.tip = ''
            onClose()
          })
        Text('完成打卡')
          .textAlign(TextAlign.Center)
          .width('45%')
          .height(40)
          .fontSize(13)
          .fontWeight(800)
          .fontColor('#0F0A1A')
          .backgroundColor('#F472B6')
          .borderRadius(6)
          .onClick(() => {
            if (this.checkMin.length === 0) {
              this.tip = '请填写收听时长'
              return
            }
            this.tip = '✅ 打卡成功 +' + this.checkMin + ' 分钟积分'
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)

收听打卡弹窗的交互逻辑与点播弹窗类似:用 TextInput 收集用户输入,用 @State 管理输入值,提交时做表单验证。打卡成功后设置 tip 为成功提示,通过 Text(this.tip) 显示。这里的 tip 变量在多个弹窗间复用,因为同一时间只有一个弹窗打开,不会冲突。

技术要点: textAlign(TextAlign.Center) 设置文本在组件内的水平对齐方式。TextAlign 枚举有 Start(左对齐)、Center(居中)、End(右对齐)。在按钮设计中,将 Textwidthheight 固定后配合 textAlign(TextAlign.Center) 可以实现文字在按钮内的居中,模拟按钮效果。


七、磁带页 TapeContent:卡片列表与翻录编辑

7.1 横滑筛选条与 Scroll 水平滚动

// 风格横滑筛选(仅高亮)
Scroll() {
  Row({ space: 8 }) {
    ForEach(['全部', '梦幻', '都市', '复古', '游戏', '异域', '故障'], (t: string, i: number) => {
      Text(t)
        .fontSize(11)
        .fontColor(i === 0 ? '#0F0A1A' : '#8B7BA8')
        .backgroundColor(i === 0 ? '#F472B6' : '#1A1230')
        .padding({ left: 12, right: 12, top: 5, bottom: 5 })
        .borderRadius(14)
    })
  }
  .padding({ left: 1, right: 1 })
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)

Scroll 是 ArkUI 的滚动容器组件,.scrollable(ScrollDirection.Horizontal) 将滚动方向设为水平。内部的 Row 包含七个筛选项,当总宽度超过 Scroll 宽度时可以横向滑动。筛选项通过 i === 0 判断是否高亮("全部"为激活状态),激活时用深底+粉字,未激活时用暗底+灰字。.borderRadius(14) 配合 padding 形成药丸标签效果。

技术要点: Scroll 组件默认垂直滚动,通过 scrollable(ScrollDirection.Horizontal) 切换为水平。ScrollDirection 枚举有 Vertical(垂直)、Horizontal(水平)、Free(自由方向,仅部分场景支持)。横滑筛选条是移动端常见的 UI 模式,当分类数量超过屏幕宽度时,横向滚动比缩放或换行更加友好。

7.2 磁带卡片与动态边框配色

List() {
  ForEach(this.tapes, (t: TapeItem) => {
    ListItem() {
      Row({ space: 12 }) {
        Text('📼')
          .fontSize(34)
          .width(56)
          .height(56)
          .textAlign(TextAlign.Center)
          .backgroundColor(t.id % 3 === 0 ? '#14263A' : (t.id % 3 === 1 ? '#2A1440' : '#331F4A'))
          .borderRadius(10)
          .border({ width: 1, color: t.id % 2 === 0 ? '#F472B6' : '#A855F7' })

        Column({ space: 4 }) {
          Text(t.name)
            .fontSize(14)
            .fontWeight(800)
            .fontColor('#E8DFF5')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(t.singer + ' · ' + t.era + ' · ' + t.style)
            .fontSize(10)
            .fontColor('#8B7BA8')
          Row() {
            Text('')
              .layoutWeight(t.hot)
              .height(5)
              .backgroundColor('#F472B6')
              .borderRadius(3)
            Text('')
              .layoutWeight(100 - t.hot)
              .height(5)
              .backgroundColor('#33255A')
              .borderRadius(3)
          }
          .width('100%')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text('详情')
          .fontSize(11)
          .fontColor('#67E8F9')
      }
      .width('100%')
      .padding(10)
      .backgroundColor('#1A1230')
      .borderRadius(10)
      .onClick(() => {
        this.picked = t
        this.showDetail = true
      })
    }
  }, (t: TapeItem) => t.id.toString())
}

磁带卡片的视觉亮点在于磁带图标区域的动态配色:t.id % 3 === 0 将磁带分为三组,分别使用深青色 #14263A、深紫色 #2A1440、暗紫色 #331F4A 三种背景色;t.id % 2 === 0 将边框分为两组,交替使用粉色 #F472B6 和紫色 #A855F7。这种用取模运算(%)实现交替配色的技巧在列表渲染中非常实用,让每张卡片都有视觉差异,避免单调感。

卡片内部嵌套了热度进度条(用 layoutWeight 实现的胶囊条),与节目详情弹窗中的进度条实现方式一致,体现了代码复用的理念。

7.3 翻录编辑弹窗与 splice 替换

Text('保存翻录')
  .textAlign(TextAlign.Center)
  .width('45%')
  .height(40)
  .fontSize(13)
  .fontWeight(800)
  .fontColor('#0F0A1A')
  .backgroundColor('#67E8F9')
  .borderRadius(6)
  .onClick(() => {
    const idx = this.tapes.indexOf(this.picked)
    if (idx >= 0) {
      this.tapes.splice(idx, 1, { id: this.picked.id, name: this.picked.name, singer: this.picked.singer, era: this.editEra.length > 0 ? this.editEra : this.picked.era, style: this.editStyle.length > 0 ? this.editStyle : this.picked.style, hot: this.picked.hot, note: '已重新翻录,音质焕然一新。' })
    }
    this.tip = '✅ 翻录完成,磁带信息已更新'
  })

翻录编辑的核心逻辑是用 splice 方法替换数组中的指定元素。indexOf(this.picked) 找到当前选中磁带在数组中的索引位置,splice(idx, 1, newItem) 从索引 idx 处删除 1 个元素并插入 newItem,实现"原地替换"。新对象的 erastyle 字段优先使用用户在 TextInput 中输入的值(this.editErathis.editStyle),如果用户未输入则保留原值(this.picked.erathis.picked.style)。note 固定更新为"已重新翻录,音质焕然一新。"

技术要点: splice 是 JavaScript/TypeScript 数组最强大的修改方法。它的签名是 splice(start, deleteCount, ...items):从 start 索引开始,删除 deleteCount 个元素,然后在 start 位置插入 items。当 deleteCount 为 1 且传入一个新元素时,实现"原地替换"。在 ArkUI 中,对 @Link@State 数组调用 splice 会触发框架感知变化并刷新界面。这是声明式 UI 中修改列表数据的标准方式之一。


八、电台页 FmContent:双列卡片与关注/屏蔽

8.1 双列布局的实现策略

List() {
  ForEach([0, 2, 4, 6], (n: number) => {
    ListItem() {
      Row({ space: 10 }) {
        Column({ space: 6 }) {
          // 第 n 个电台卡片
          Column({ space: 5 }) { ... }
          // 第 n+1 个电台卡片
          Column({ space: 5 }) { ... }
        }
        .layoutWeight(1)

        Column({ space: 6 }) {
          // 第 n+2 个电台卡片
          Column({ space: 5 }) { ... }
          // 第 n+3 个电台卡片
          Column({ space: 5 }) { ... }
        }
        .layoutWeight(1)
      }
      .width('100%')
    }
  }, (n: number) => n.toString())
}

双列布局的实现方式很有创意:ForEach 遍历 [0, 2, 4, 6] 这个步长为 2 的索引数组,每次迭代渲染 4 个电台卡片(n、n+1、n+2、n+3),分为左右两列各两个。这种"手动分组"的方式避免了使用 Grid 组件的复杂性,用 Row + Column 的嵌套实现了 2x2 网格效果。

每个 Column 设置 layoutWeight(1),左右两列均分宽度。列内的两个卡片用 Column({ space: 6 }) 垂直排列,间距 6 像素。虽然这种方式在数据量大时不如 Grid 优雅,但对于固定 8 条数据的场景,代码更加直观可控。

技术要点: ArkUI 提供了 Grid 组件用于网格布局,但 Grid 需要配合 GridItem 使用且配置项较多。在数据量小且固定时,用 Row + Column + layoutWeight 手动实现网格布局更加灵活直接。这种"务实优先"的选择体现了前端开发中"用最简单的工具完成任务"的工程哲学。

8.2 电台详情弹窗与状态展示

Row({ space: 8 }) {
  Text('📻')
    .fontSize(16)
  Text(this.picked.name)
    .fontSize(18)
    .fontWeight(900)
    .fontColor('#F472B6')
  Text('')
    .layoutWeight(1)
  Text(fmStateText(this.picked.state))
    .fontSize(10)
    .fontWeight(800)
    .fontColor(fmStateColor(this.picked.state))
    .backgroundColor('#241A42')
    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
    .borderRadius(10)
}
.width('100%')

电台详情弹窗标题栏展示了电台状态。fmStateTextfmStateColor 两个工具函数在此处调用,将 "live""stand" 转换为中文标签和对应颜色。状态标签用药丸样式渲染:backgroundColor 深色背景、padding 内边距、borderRadius(10) 圆角。直播状态用粉色高亮,待机状态用紫灰弱化,一眼可辨。

8.3 屏蔽弹窗与删除操作

Text('确认屏蔽')
  .textAlign(TextAlign.Center)
  .width('45%')
  .height(40)
  .fontSize(13)
  .fontWeight(800)
  .fontColor('#0F0A1A')
  .backgroundColor('#F472B6')
  .borderRadius(6)
  .onClick(() => {
    const idx = this.fms.indexOf(this.picked)
    if (idx >= 0) {
      this.fms.splice(idx, 1)
    }
    onClose()
  })

屏蔽操作的实现是 this.fms.splice(idx, 1)——从数组中删除指定电台。与翻录编辑的 splice(idx, 1, newItem) 不同,这里 splice 只传两个参数(起始索引和删除数量),不传入替换元素,纯删除。删除后调用 onClose() 关闭弹窗,列表自动刷新移除该项。

技术要点: 删除操作配合警告弹窗是移动端的最佳实践。直接删除可能让用户误操作,先用弹窗展示"屏蔽后将不再推荐"的警告文案,用户确认后才执行删除。这种"二次确认"模式在涉及不可逆操作(删除、清空、退出)时尤为重要。本应用中所有删除类操作(屏蔽电台、清空购物车、移除收藏)都遵循了这一模式。


九、榜单页 RankContent:领奖台、榜单列表与周边商店

9.1 前三领奖台的错落布局

Row({ space: 8 }) {
  Column({ space: 4 }) {
    Text('🥈')
      .fontSize(26)
    Text(this.ranks[1].name)
      .fontSize(11)
      .fontWeight(800)
      .fontColor('#E8DFF5')
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
    Text(this.ranks[1].hot.toString())
      .fontSize(10)
      .fontColor('#67E8F9')
    Text('')
      .width(76)
      .height(34)
      .backgroundColor('#A855F7')
      .borderRadius(6)
  }
  .width('28%')
  .alignItems(HorizontalAlign.Center)

  Column({ space: 4 }) {
    Text('🥇')
      .fontSize(32)
    Text(this.ranks[0].name)
      .fontSize(12)
      .fontWeight(900)
      .fontColor('#F472B6')
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
    Text(this.ranks[0].hot.toString())
      .fontSize(10)
      .fontColor('#67E8F9')
    Text('')
      .width(92)
      .height(46)
      .backgroundColor('#F472B6')
      .borderRadius(6)
  }
  .width('44%')
  .alignItems(HorizontalAlign.Center)

  Column({ space: 4 }) {
    Text('🥉')
      .fontSize(24)
    Text(this.ranks[2].name)
      .fontSize(11)
      .fontWeight(800)
      .fontColor('#E8DFF5')
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
    Text(this.ranks[2].hot.toString())
      .fontSize(10)
      .fontColor('#67E8F9')
    Text('')
      .width(76)
      .height(26)
      .backgroundColor('#14263A')
      .borderRadius(6)
  }
  .width('28%')
  .alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.Center)

领奖台的视觉设计非常精妙:三个 Column 分别代表第二、第一、第三名,宽度比例为 28%:44%:28%(第一名最宽最突出)。底部对齐 .alignItems(VerticalAlign.Bottom) 让三者底部处于同一水平线,但柱体高度不同——第一名最高(46 像素)、第二名次之(34 像素)、第三名最矮(26 像素),形成经典的"领奖台"高低错落效果。

奖牌图标大小也递减:第一名 32 像素、第二名 26 像素、第三名 24 像素。柱体颜色各异:第一名粉色(最高荣誉)、第二名紫色(银牌感)、第三名深青色(铜牌感)。这种通过尺寸、颜色、位置的多维度差异来传达排名信息的设计,是数据可视化的经典手法。

技术要点: .alignItems(VerticalAlign.Bottom)Row 容器的垂直对齐设置。VerticalAlign 枚举有 Top(顶部对齐)、Center(垂直居中)、Bottom(底部对齐)。在领奖台场景中,底部对齐是关键——只有底部对齐,不同高度的柱体才能形成"高低错落"的领奖台视觉。如果用居中对齐,柱体会在垂直中心对齐,视觉上变成"漂浮"效果,失去领奖台的仪式感。

9.2 榜单列表与趋势展示

ForEach(this.ranks, (r: RankItem, i: number) => {
  Row({ space: 10 }) {
    Text((i + 1).toString())
      .fontSize(16)
      .fontWeight(900)
      .fontColor(i < 3 ? '#F472B6' : '#8B7BA8')
      .width(28)
    Column({ space: 3 }) {
      Text(r.name)
        .fontSize(13)
        .fontWeight(700)
        .fontColor('#E8DFF5')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(r.singer)
        .fontSize(10)
        .fontColor('#8B7BA8')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Column({ space: 3 }) {
      Text('热度 ' + r.hot.toString())
        .fontSize(10)
        .fontColor('#C9BDE8')
      Text(trendText(r.trend))
        .fontSize(10)
        .fontWeight(800)
        .fontColor(trendColor(r.trend))
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(10)
  .backgroundColor(i % 2 === 0 ? '#1A1230' : '#1E1538')
  .borderRadius(8)
}, (r: RankItem) => r.id.toString())

榜单列表的 ForEach 回调接收第二个参数 i(索引),用于显示排名序号 i + 1。前三名(i < 3)的序号用粉色高亮,其余用紫灰。背景色用 i % 2 === 0 交替使用两种深紫色,实现斑马纹效果,让长列表更易读。

右侧展示热度值和趋势。trendTexttrendColor 工具函数将趋势方向(up/down/keep)转换为箭头文本和对应颜色。.alignItems(HorizontalAlign.End) 让右侧信息右对齐,与左侧的排名和名称形成"左信息+右数据"的平衡布局。

技术要点: 斑马纹(Zebra Striping)是列表 UI 的经典设计模式。通过交替背景色,帮助用户在长列表中定位行。在 ArkUI 中,用 i % 2 === 0 判断奇偶行并交替设置 backgroundColor 是最简洁的实现方式。HorizontalAlign.EndColumn 中让子元素右对齐,常用于展示数值型数据(如价格、热度、百分比)。

9.3 周边商店与步进器

Row({ space: 10 }) {
  Text('数量')
    .fontSize(13)
    .fontColor('#C9BDE8')
  Text('')
    .layoutWeight(1)
  Text('−')
    .fontSize(18)
    .fontColor('#8B7BA8')
    .width(36)
    .height(32)
    .textAlign(TextAlign.Center)
    .backgroundColor('#241A42')
    .borderRadius(6)
    .onClick(() => {
      if (this.buyNum > 1) {
        this.buyNum = this.buyNum - 1
      }
    })
  Text(this.buyNum.toString())
    .fontSize(14)
    .fontWeight(800)
    .fontColor('#E8DFF5')
    .width(40)
    .textAlign(TextAlign.Center)
  Text('+')
    .fontSize(18)
    .fontColor('#67E8F9')
    .width(36)
    .height(32)
    .textAlign(TextAlign.Center)
    .backgroundColor('#241A42')
    .borderRadius(6)
    .onClick(() => {
      this.buyNum = this.buyNum + 1
    })
}
.width('100%')

数量步进器由"减号按钮 + 数值显示 + 加号按钮"三部分组成。减号按钮的 onClick 先检查 this.buyNum > 1(不能小于 1),满足条件才减 1。加号按钮直接 this.buyNum + 1。中间的 Text 展示当前数量。Text('').layoutWeight(1) 作为弹性占位把步进器推到右侧。

技术要点: 步进器(Stepper)是电商场景的常见组件。ArkUI 虽然提供了 Stepper 组件,但在简单场景下用 Text + onClick 手动实现更加灵活可控。减号按钮的边界检查(> 1)是防呆设计——防止用户将数量减到 0 或负数,这种输入验证应该在 UI 层就拦截,而不是等到提交时才报错。

9.4 下单逻辑与库存检查

Text('确认下单')
  .onClick(() => {
    if (this.picked.stock < this.buyNum) {
      this.tip = '⚠️ 库存不足,请减少数量'
      return
    }
    const idx = this.goods.indexOf(this.picked)
    if (idx >= 0) {
      this.goods.splice(idx, 1, { id: this.picked.id, name: this.picked.name, price: this.picked.price, stock: this.picked.stock - this.buyNum, cover: this.picked.cover, note: this.picked.note })
    }
    this.tip = '✅ 下单成功,共 ¥' + (this.picked.price * this.buyNum).toString()
  })

下单逻辑先做库存检查 this.picked.stock < this.buyNum,库存不足时设置提示并 return。库存充足时用 splice 替换商品数据,新对象的 stock 字段为 this.picked.stock - this.buyNum(原库存减去购买数量),模拟库存扣减。成功后设置包含总价 this.picked.price * this.buyNum 的提示文案。


十、我的页 MeContent:档案卡、功能入口与收藏管理

10.1 档案卡与等级进度条

Row({ space: 12 }) {
  Text('🌴')
    .fontSize(40)
    .width(60)
    .height(60)
    .textAlign(TextAlign.Center)
    .backgroundColor('#241A42')
    .borderRadius(30)
    .border({ width: 2, color: '#F472B6' })
  Column({ space: 5 }) {
    Text(this.nameVal)
      .fontSize(17)
      .fontWeight(900)
      .fontColor('#E8DFF5')
    Text('LV.12 蒸汽波骨灰粉 · 收听 1280 分钟')
      .fontSize(10)
      .fontColor('#8B7BA8')
    Row() {
      Text('')
        .layoutWeight(72)
        .height(6)
        .backgroundColor('#F472B6')
        .borderRadius(3)
      Text('')
        .layoutWeight(28)
        .height(6)
        .backgroundColor('#33255A')
        .borderRadius(3)
    }
    .width('100%')
  }
  .alignItems(HorizontalAlign.Start)
  .layoutWeight(1)
}

档案卡左侧是圆形头像(borderRadius(30) 配合 60x60 尺寸形成圆形),右侧是昵称、等级文案和等级进度条。进度条用 layoutWeight(72)layoutWeight(28) 实现 72% 的进度效果。this.nameVal 是可变的昵称状态变量,修改昵称后档案卡实时更新。

10.2 四宫格功能入口

Row({ space: 8 }) {
  Column({ space: 4 }) {
    Text('✏️')
      .fontSize(22)
    Text('改昵称')
      .fontSize(10)
      .fontColor('#C9BDE8')
  }
  .width('24%')
  .padding({ top: 12, bottom: 12 })
  .backgroundColor('#1A1230')
  .borderRadius(10)
  .onClick(() => {
    this.tip = ''
    this.showName = true
  })

  Column({ space: 4 }) {
    Text('🧹')
      .fontSize(22)
    Text('清缓存')
      .fontSize(10)
      .fontColor('#C9BDE8')
  }
  .width('24%')
  .padding({ top: 12, bottom: 12 })
  .backgroundColor('#1A1230')
  .borderRadius(10)
  .onClick(() => {
    this.cacheProg = 0
    this.showCache = true
  })

  // 数据报告、退出入口类似...
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)

四个功能入口用 Row 水平排列,每个 Column 宽度 24%,justifyContent(FlexAlign.SpaceBetween) 让四个入口两端对齐均匀分布。每个入口包含一个 emoji 图标和文字标签,点击后打开对应的弹窗或设置提示。.padding({ top: 12, bottom: 12 }) 给入口上下留白,增加点击区域面积。

技术要点: FlexAlign.SpaceBetween 是弹性布局的对齐方式之一,它让子元素在主轴上两端贴边、中间等间距分布。四个 24% 宽的入口加起来 96%,剩余 4% 被 SpaceBetween 均匀分配为三个间距(每个约 1.3%),形成"四等分+小间距"的视觉效果。移动端的底部功能入口栏普遍采用这种布局模式。

10.3 修改昵称弹窗与快捷建议

Row({ space: 8 }) {
  ForEach(['粉雾', '霓虹', '棕榈'], (t: string) => {
    Text(t)
      .fontSize(11)
      .fontColor('#67E8F9')
      .backgroundColor('#14263A')
      .padding({ left: 10, right: 10, top: 5, bottom: 5 })
      .borderRadius(12)
      .onClick(() => {
        this.nameVal = t + '少年'
      })
  })
}
.width('100%')

修改昵称弹窗中有一组快捷建议标签。点击标签后 this.nameVal = t + '少年',将建议词与"少年"拼接后填入输入框。这种"快捷填词"的交互模式在表单中很实用——用户可以直接选择预设值,也可以手动输入自定义值。

技术要点: 将交互逻辑直接写在 onClick 中修改 @State 变量,是 ArkUI 中最常见的交互处理方式。由于 this.nameVal 同时绑定了 TextInputtext 属性,修改后会立即反映到输入框中,实现"点击标签 -> 输入框自动填充"的联动效果。这就是声明式 UI 的核心优势——状态是唯一真相来源,UI 只是状态的投影。

10.4 清理缓存弹窗与进度模拟

Row() {
  Text('')
    .layoutWeight(this.cacheProg)
    .height(8)
    .backgroundColor('#F472B6')
    .borderRadius(4)
  Text('')
    .layoutWeight(100 - this.cacheProg)
    .height(8)
    .backgroundColor('#33255A')
    .borderRadius(4)
}
.width('100%')

Text('清理进度 ' + this.cacheProg.toString() + '%')
  .fontSize(12)
  .fontWeight(800)
  .fontColor(this.cacheProg === 100 ? '#67E8F9' : '#F472B6')

清理缓存弹窗展示了一个进度条和百分比文案。进度条用 layoutWeight(this.cacheProg)layoutWeight(100 - this.cacheProg) 实现,与热度进度条的原理一致。点击"开始清理"按钮时 this.cacheProg = 100,进度条和百分比立即跳到满值。fontColor 用三元运算符根据进度是否完成来切换颜色——未完成时粉色(进行中),完成时青色(已完成)。

技术要点: 这里用状态变化直接跳到 100% 模拟清理完成,没有真正的异步进度递增。在实际应用中,可以用 setIntervalsetTimeout 实现进度递增的动画效果。但本应用作为演示项目,用即时跳变简化了逻辑。即便如此,fontColor 根据状态切换颜色的设计仍然值得学习——它让用户通过颜色变化感知操作结果,是"色彩反馈"的典型应用。

10.5 收藏列表与移除操作

ForEach(this.mys, (m: MyItem) => {
  Row({ space: 10 }) {
    Text(m.kind === '节目' ? '📻' : (m.kind === '磁带' ? '📼' : '🛍️'))
      .fontSize(20)
      .width(40)
      .height(40)
      .textAlign(TextAlign.Center)
      .backgroundColor('#241A42')
      .borderRadius(8)
    Column({ space: 3 }) {
      Text(m.name)
        .fontSize(13)
        .fontWeight(800)
        .fontColor('#E8DFF5')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(m.kind + ' · ' + m.date + ' · ' + m.note)
        .fontSize(10)
        .fontColor('#8B7BA8')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Text('移除')
      .fontSize(11)
      .fontColor('#F472B6')
      .onClick(() => {
        this.picked = m
        this.showRemove = true
      })
  }
  .width('100%')
  .padding(10)
  .backgroundColor('#1A1230')
  .borderRadius(10)
}, (m: MyItem) => m.id.toString())

收藏列表的图标根据 m.kind 动态选择:节目用收音机 emoji、磁带用录像带 emoji、其余用购物袋 emoji。嵌套的三元运算符 m.kind === '节目' ? '📻' : (m.kind === '磁带' ? '📼' : '🛍️') 实现了三分支判断。点击"移除"后弹出确认弹窗,确认后用 splice 从收藏数组中删除。

技术要点: 嵌套三元运算符在简单枚举映射中很实用,但当分支超过 3 个时可读性会下降。更优雅的做法是使用映射对象(如 { '节目': '📻', '磁带': '📼', '电台': '📡', '周边': '🛍️' }),通过键值查找替代条件判断。但在本例中三元运算符足够简洁,且代码行数更少,是合理的权衡选择。


十一、弹窗体系的设计模式总结

11.1 统一的弹窗结构

纵观十五个弹窗,它们都遵循统一的结构模式:

  1. 遮罩层:全屏 Column,背景色 #66000000(半透明黑色),justifyContent(FlexAlign.Center) 居中弹窗
  2. 弹窗容器width('86%') + constraintSize({ maxHeight: '80%' }) + borderRadius(14) + border + 深色背景
  3. 标题栏:图标 + 标题 + 弹性占位 + 关闭按钮,底部 1 像素边框分隔
  4. 内容区Column 垂直排列内容,padding(16) 内边距
  5. 操作栏:取消 + 确认两个按钮,width('45%') + height(40) + borderRadius(6)
  6. 提示区Text(this.tip) 显示操作结果

这种高度统一的模式让代码可维护性极高——修改弹窗的视觉风格只需调整统一的颜色值和尺寸参数,所有弹窗同步生效。

11.2 onClose 回调模式

所有弹窗 @Builder 方法都接收一个 onClose: () => void 回调参数。这种"将关闭逻辑外提"的设计让弹窗组件不关心自己何时被关闭——关闭的决策权在调用方(父组件)。父组件在调用 @Builder 时传入箭头函数,箭头函数内修改 @State 变量(如 this.showDetail = false),实现条件渲染的关闭。

技术要点: onClose 回调模式是 ArkUI 中组件通信的常见方式。@Builder 方法虽然可以通过 this 访问组件状态,但将关闭逻辑参数化让弹窗更加通用——同一个弹窗 @Builder 可以被不同的调用方使用,每个调用方可以传入不同的关闭逻辑。这种"控制反转"的设计思想让组件复用性大幅提升。

11.3 弹窗体系流程图

弹窗类型

详情弹窗

表单弹窗

确认弹窗

进度弹窗

弹窗管理

用户点击操作按钮

设置 showXxx = true

showXxx 是否为 true?

渲染遮罩层 Column

调用 @Builder 方法

渲染弹窗内容

用户点击关闭/确认

设置 showXxx = false

showXxx 是否为 true?

移除遮罩与弹窗


十二、核心技术点深度剖析

12.1 @State 状态管理详解

@State 是 ArkUI 状态管理体系中最核心的装饰器。它将一个普通变量转变为"可观察的状态变量",框架会追踪该变量在 build() 方法中的使用位置,当变量值变化时,精准刷新依赖该变量的 UI 片段。

在本应用中,@State 的使用场景包括:

  • 布尔型状态showDetailshowPlay 等控制弹窗显隐
  • 对象型状态picked 存储当前选中的数据项
  • 字符串状态playSongtip 存储表单输入和提示文案
  • 数字状态buyNumcacheProgcartNum 存储计数和进度
  • 数组状态:通过 @Link 从父组件接收的数组引用

技术要点: @State 的观察精度是"变量级"的,即整个变量的值变化才会触发刷新。对于对象类型,直接修改对象属性(如 this.picked.name = '新名称')不会触发刷新,必须赋值一个新对象(如 this.picked = { ...this.picked, name: '新名称' })或通过 @Link 引用的数组方法修改。这是 ArkTS 与传统前端框架的一个重要差异。

12.2 @Link 父子双向绑定

@Link 装饰器用于接收父组件通过 $ 语法传入的状态引用。与 @State 的单向观察不同,@Link 实现的是双向同步——子组件对 @Link 变量的修改会反映回父组件的 @State

本应用中,Index 组件将七个数据数组的 $ 引用分别传入六个子 Tab 组件:

HomeContent({ feeds: $feeds, shows: $shows })
ShowContent({ shows: $shows, mys: $mys })
TapeContent({ tapes: $tapes, mys: $mys })
FmContent({ fms: $fms })
RankContent({ ranks: $ranks, goods: $goods })
MeContent({ mys: $mys })

注意 mys 数组同时传给了 ShowContentTapeContentMeContent 三个子组件。这意味着在任何一个子组件中通过 unshift 收藏新节目/磁带,或在 MeContent 中通过 splice 移除收藏,变化都会同步到 Indexmys 状态,进而同步到其他两个子组件。这是"跨组件状态共享"的典型实现。

技术要点: @Link 实现的双向绑定是"引用共享"机制——子组件的 @Link 变量与父组件的 @State 变量指向同一份数据。当数据通过引用修改方法(pushspliceunshift 等)变化时,框架会感知到变化并通知所有引用了该数据的组件刷新。这种机制避免了"数据拷贝"的性能开销,是 ArkUI 状态管理高效的原因之一。

12.3 @Builder 与组件复用

@Builder 装饰器定义的是一个"UI 构建片段"而非独立组件。它与 @Component 的区别在于:

特性 @Component @Builder
产生独立组件实例
可以拥有 @State
可以被 export
在调用处内联展开
适合复杂独立逻辑
适合简单 UI 复用

本应用中 @Builder 的使用场景包括:

  • tabItem:Tab 项的复用(同一结构重复 6 次)
  • 各种 xxxModalOverlay:弹窗内容的构建

技术要点: @Builder 方法虽然不能拥有独立的 @State,但可以访问所在 @Componentthis(包括其 @State@Link 变量)。这使得 @Builder 可以直接读取和修改组件状态,非常适合弹窗这类需要展示组件数据的 UI 片段。@Builder 方法可以接收参数(包括回调函数),这让它在简单复用场景下比 @Component 更加轻量。

12.4 Column 与 Row 容器组件

ColumnRow 是 ArkUI 中最基础的两个容器组件,分别实现垂直和水平线性布局。它们的核心参数和属性包括:

  • space:子元素之间的间距(构造参数)
  • alignItems:子元素在交叉轴方向的对齐方式
  • justifyContent:子元素在主轴方向的对齐方式(需配合 widthheight 使用)
  • layoutWeight:弹性布局权重

技术要点: Column 的主轴是垂直方向,交叉轴是水平方向;Row 的主轴是水平方向,交叉轴是垂直方向。alignItems 控制交叉轴对齐(Column 中是水平对齐,Row 中是垂直对齐),justifyContent 控制主轴对齐(Column 中是垂直分布,Row 中是水平分布)。理解主轴和交叉轴的概念是掌握 Flex 布局的关键。

12.5 layoutWeight 权重分配

layoutWeight 是 ArkUI 弹性布局的核心属性。它接收一个数字作为权重值,多个同级组件的 layoutWeight 值的比例决定了它们占据父容器剩余空间的比例。

本应用中 layoutWeight 的三种用法:

  1. 占位推挤Text('').layoutWeight(1) 用空组件占据剩余空间,把后续组件推到另一端
  2. 等分:多个组件设置相同的 layoutWeight(1),均分剩余空间
  3. 比例分配:进度条用 layoutWeight(this.picked.hot)layoutWeight(100 - this.picked.hot) 按比例分配

12.6 animation 动画 API

.animation({ duration: 200, curve: Curve.EaseOut }) 是 ArkUI 的属性动画 API。当被装饰的组件的某些属性(如 scalewidthheightbackgroundColor 等)发生变化时,框架会自动用指定的动画曲线在 duration 毫秒内完成过渡。

本应用中动画的使用场景:

  • Tab 切换的 scale 缩放动画(200ms EaseOut)
  • 柱状图柱体高度的过渡动画(700ms EaseOut)

技术要点: ArkUI 的 animation 属性是"隐式动画"——开发者只需声明动画参数和目标属性值,框架自动计算并播放过渡帧。与"显式动画"(如 animateTo)不同,隐式动画无需手动调用动画 API,只要属性值变化就会自动播放。Curve.EaseOut 是先快后慢的缓动曲线,适合"出现"和"放大"类动画;Curve.EaseIn 是先慢后快,适合"消失"和"缩小"类动画;Curve.EaseInOut 是两端慢中间快,适合"往返"类动画。

12.7 ForEach 列表渲染

ForEach 是 ArkUI 的列表渲染组件,语法为 ForEach(array, itemGenerator, keyGenerator)

  • array:数据源数组
  • itemGenerator:渲染函数,接收 (item, index) 返回 UI 结构
  • keyGenerator:键值函数,接收 (item) 返回唯一字符串标识

本应用中 ForEach 的使用场景涵盖了所有列表渲染需求:动态列表、节目列表、磁带列表、电台列表、榜单列表、商品列表、收藏列表、周热度柱状图、筛选项标签等。

技术要点: keyGenerator 的作用是帮助框架的 Diff 算法高效识别列表项的增删改。当数据源变化时,框架通过比较新旧键值集合,判断哪些项是新增的、哪些是删除的、哪些是位置变化的,然后只对变化的项进行 DOM 操作,避免全量重新渲染。键值应该选择稳定且唯一的值(如 id),避免使用索引(因为索引会随数据变化而错位)。

12.8 Scroll 滚动容器

Scroll 是 ArkUI 的滚动容器组件,用于包裹内容超出屏幕尺寸的区域。本应用中 Scroll 的使用场景:

  • 首页内容纵向滚动
  • 榜单页内容纵向滚动
  • 我的页内容纵向滚动
  • 磁带页筛选项横向滚动
  • 电台页筛选项横向滚动

核心属性:

  • scrollable:滚动方向(Vertical / Horizontal
  • scrollBar:滚动条显隐(BarState.Off 隐藏 / BarState.Auto 自动 / BarState.On 始终显示)

技术要点: ScrollList 的区别在于:Scroll 包裹的是任意内容(包括 ColumnRowForEach 等),一次性创建所有子元素;List 是专门的列表容器,支持懒加载。当内容固定且数量少时用 Scroll 更简单,当内容动态且数量多时用 List 更高效。本应用中首页、榜单页、我的页用 Scroll(内容模块化且固定),节目页、磁带页、电台页用 List(列表项可能动态增删)。

12.9 maxLines 与 textOverflow 文本截断

Text(s.name)
  .fontSize(14)
  .fontWeight(800)
  .fontColor('#E8DFF5')
  .maxLines(1)
  .textOverflow({ overflow: TextOverflow.Ellipsis })

maxLines(1) 限制文本最多显示 1 行,textOverflow({ overflow: TextOverflow.Ellipsis }) 设置超出部分以省略号(…)截断。这两个属性配合使用是处理长文本的标准方案,防止文字溢出破坏布局。

技术要点: TextOverflow 枚举有 Clip(直接截断)、Ellipsis(省略号截断)、None(不截断,默认值)。在列表项的标题中,Ellipsis 是最合理的选择——它告诉用户文字被截断了,可以点击查看完整内容。如果不设置 maxLines,长文本会自动换行占据多行空间,可能导致列表项高度不统一。

12.10 constraintSize 尺寸约束

.constraintSize({ maxHeight: '80%' })

constraintSize 用于设置组件的尺寸约束边界,包括 maxWidthmaxHeightminWidthminHeight 四个属性。在弹窗设计中,maxHeight: '80%' 限制弹窗最大高度不超过屏幕的 80%,防止内容过多时弹窗超出屏幕边界。配合外层的 Scroll 组件,可以实现弹窗内容超出时的滚动浏览。


十三、综合对比表

以下是本应用中涉及的所有核心数据结构、组件、状态变量、装饰器、工具函数的对比汇总:

序号 名称 类别 作用描述 使用场景 典型属性/参数
1 ShowItem 接口 电台节目数据实体 节目列表、详情弹窗 id, name, host, time, tag, hot, note
2 TapeItem 接口 磁带数据实体 磁带列表、详情、编辑弹窗 id, name, singer, era, style, hot, note
3 FmItem 接口 独立电台数据实体 电台列表、详情弹窗 id, name, cat, hot, listen, state, note
4 RankItem 接口 榜单条目数据实体 领奖台、榜单列表 id, name, singer, hot, trend, note
5 FeedItem 接口 动态资讯数据实体 首页动态列表、详情弹窗 id, title, time, tag, text
6 GoodItem 接口 周边商品数据实体 周边商店、下单弹窗 id, name, price, stock, cover, note
7 MyItem 接口 用户收藏数据实体 我的收藏列表、移除弹窗 id, name, kind, date, note
8 @Entry 装饰器 标记应用根入口组件 Index 组件 无参数
9 @Component 装饰器 声明自定义组件 所有 struct 组件 无参数
10 @State 装饰器 组件内部可观察状态 所有组件的状态变量 变量初始化必填
11 @Link 装饰器 父子双向绑定引用 子组件接收父组件数据 父组件用 $ 语法传入
12 @Builder 装饰器 定义可复用 UI 片段 tabItem、弹窗 Overlay 方法 可接收参数和回调
13 Column 容器组件 垂直线性布局 几乎所有布局结构 space, alignItems, justifyContent
14 Row 容器组件 水平线性布局 标题栏、按钮行、列表项 space, alignItems, justifyContent
15 Stack 容器组件 层叠布局/条件渲染容器 内容区页面切换 layoutWeight
16 List 容器组件 高性能列表(懒加载) 节目列表、磁带列表、电台列表 scrollBar, layoutWeight
17 ListItem 子组件 List 的直接子项 List 内的每一项 无特殊属性
18 Scroll 容器组件 滚动容器 首页、榜单页、我的页、横滑筛选 scrollable, scrollBar
19 Text 基础组件 文本展示 全应用最广泛使用的组件 fontSize, fontColor, fontWeight, borderRadius
20 TextInput 基础组件 文本输入框 点播弹窗、打卡弹窗、编辑弹窗 placeholder, text, onChange
21 ForEach 渲染组件 列表循环渲染 所有列表和柱状图 数据源, 渲染函数, 键值函数
22 heatBar 工具函数 热度值转柱状图高度 首页周热度柱状图 接收 number 返回 number
23 playsText 工具函数 收听人次格式化 电台列表收听人数显示 接收 number 返回 string
24 trendText 工具函数 趋势方向转文本 榜单列表趋势展示 up/down/keep -> 文本
25 trendColor 工具函数 趋势方向转颜色 榜单列表趋势着色 up/down/keep -> 颜色值
26 fmStateText 工具函数 电台状态转文本 电台卡片状态标签 live/stand -> 文本
27 fmStateColor 工具函数 电台状态转颜色 电台卡片状态着色 live/stand -> 颜色值
28 buildFeed 工厂函数 创建动态数据对象 动态列表新增 id, title -> FeedItem
29 buildMy 工厂函数 创建收藏数据对象 收藏列表新增 id, name, kind -> MyItem
30 buildShow 工厂函数 创建节目数据对象 点播新增节目 id, name, host -> ShowItem

十四、总结

架构设计的整体评价

本文详细分析了一个蒸汽波电台风格的鸿蒙 ArkTS 应用,从数据建模、工具函数、主入口架构到六个 Tab 页面和十五个弹窗的完整实现。这个应用虽然使用的是写死数据而非网络请求,但其架构设计完整地展示了一个真实移动端应用应有的所有核心要素:多页面导航、状态管理、表单交互、列表渲染、弹窗系统、数据可视化等。它不仅是一个 UI 模板,更是一个可运行的工程范式。

数据驱动 UI 的范式体现

整个应用最核心的设计思想是"数据驱动 UI"。七个 interface 接口严格定义了数据结构,八组写死数据填充初始值,工具函数处理数据与视觉属性之间的映射,@State@Link 管理数据的流动与同步,build() 方法将数据映射为组件树。用户的每一次交互——点击 Tab、打开弹窗、提交表单、删除数据——本质上都是在修改状态变量,而界面的更新完全由框架自动完成。开发者只需关心"状态是什么",不需要操心"怎么渲染",这是声明式 UI 范式的最大价值。

状态管理体系的层次分明

应用的状态管理体系呈现出清晰的层次结构:最顶层是 Index 组件的七个 @State 数组,作为全局数据源;通过 $ 引用和 @Link 向下传递给六个 Tab 子组件,实现跨页面的数据共享(如 mys 数组在三个 Tab 间同步);子组件内部的 @State 管理局部状态(弹窗显隐、表单输入、选中项等),不向上冒泡。这种"全局数据向下流动、局部状态自我管理"的分层设计,让组件间的耦合度降到最低,每个组件可以独立理解和测试。

弹窗系统的工程化思维

十五个弹窗覆盖了增(新增点播、关注电台)、删(屏蔽电台、清空购物车、移除收藏)、改(翻录编辑、修改昵称)、查(各种详情)四种基本操作,形成了完整的 CRUD 体系。所有弹窗遵循统一的 @Builder + onClose 回调 + 条件渲染模式,代码结构高度一致。这种统一性使得新增弹窗的成本极低——复制一个现有弹窗的结构,修改标题和内容即可。删除操作前一律弹窗确认的防呆设计,体现了对用户体验的尊重。

视觉设计的蒸汽波美学

应用在视觉上忠实地再现了蒸汽波美学:深紫底色 #0F0A1A 营造赛博夜空氛围,粉色 #F472B6 作为主强调色用于标题和热点数据,紫色 #A855F7 作为次强调色用于辅助元素,青色 #67E8F9 作为第三色用于冷色调点缀,紫灰 #8B7BA8 用于弱化文案。故障艺术风格的头部三色分隔条、领奖台的高低错落、柱状图的交替配色、斑马纹列表的背景交替——这些视觉细节共同构建了一个完整的蒸汽波视觉语言体系。颜色不是随意选择的,而是有着明确的语义分工:粉色=热点/主操作,青色=辅助信息/完成状态,紫灰=弱化/未激活,紫色=次级/装饰。

ArkUI 组件体系的实战检验

这个应用几乎涵盖了 ArkUI 日常开发所需的所有核心组件和概念:Column/Row/Stack/Flex 四大布局容器,List/ListItem/Scroll 三大滚动容器,Text/TextInput 两大基础组件,ForEach 列表渲染,@Entry/@Component/@State/@Link/@Builder 五大装饰器,layoutWeight 弹性权重,animation 属性动画,borderRadius/border/padding/margin 四大样式属性,maxLines/textOverflow 文本截断,constraintSize 尺寸约束,textAlign/alignItems/justifyContent 三大对齐属性。掌握这些组件和概念,就掌握了鸿蒙 ArkTS 应用开发的 80% 以上能力。这个应用是一份优秀的 ArkUI 学习教材,值得逐行研读、反复实践。

工具函数与纯函数的设计哲学

本应用在工具函数的设计上体现了"纯函数优先"的工程哲学。八个工具函数(heatBarplaysTexttrendTexttrendColorfmStateTextfmStateColorbuildFeedbuildMybuildShow)全部是纯函数——相同的输入永远产生相同的输出,不依赖外部可变状态,不产生副作用。这种设计在声明式 UI 框架中尤为重要,因为框架会在每次状态变化时重新执行 build() 方法,如果工具函数不纯,同样的状态可能产生不同的渲染结果,导致界面闪烁或数据不一致。将数据到视觉属性的映射逻辑(如热度值到颜色、状态到文本)提取为独立函数,还带来了代码复用的好处——trendTexttrendColor 在榜单列表中被同时调用,fmStateTextfmStateColor 在电台卡片和详情弹窗中被重复使用,避免了在多处写重复的条件判断逻辑。

色彩体系的语义化设计

应用建立了一套完整的色彩语义体系,每种颜色都有明确的用途分工。深紫底色 #0F0A1A 作为最底层背景色,营造赛博夜空的沉浸氛围;稍浅的深紫 #1A1230 用于卡片背景,与底色形成层次区分;更浅的紫蓝 #241A42 用于输入框和次级卡片背景,在深色环境中提供可辨识的层次递进。粉色 #F472B6 是整个应用的视觉灵魂色,用于品牌名称、热点数据、主操作按钮、进度条填充等最需要吸引注意力的元素。紫色 #A855F7 作为粉色的互补色,用于次级强调元素如分隔条、柱状图交替柱、边框装饰等。青色 #67E8F9 作为冷色调点缀,用于辅助信息、完成状态、次要操作按钮。紫灰 #8B7BA8 专门用于弱化文案——时间、描述、未激活的标签等不需要突出显示的文字。浅紫 #E8DFF5 用于正文文字色,在深色背景上保证可读性。这套色彩体系不是随意搭配的,而是遵循了"明度递进+色相分工"的设计原则:底色最深、卡片次之、输入框最浅形成空间层次,粉色暖、青色冷、紫色中性形成情绪分工。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// =====================================================================
// 应用名: VAPORWAVE FM 蒸汽波电台 · 赛博复古电台与磁带美学
// 场景: 电台节目、磁带回放、独立电台、蒸汽波榜单、周边商店、我的电台
// 风格: 粉紫故障艺术 —— 梦幻粉、霓虹紫、海水青、棕榈剪影、深紫底
// Tab: 首页 / 节目 / 磁带 / 电台 / 榜单 / 我的(6 Tab 单排)
// 弹窗: 节目详情、点播新增、收听打卡(新增)、磁带详情、翻录(编辑)、
//       电台详情、关注(新增)、屏蔽(删除警告)、榜单规则、周边下单(步进)、
//       购物车清空(删除警告)、收藏移除(删除)、改昵称(编辑)、清缓存、退出
//       (共 15 个)
// =====================================================================

// ---------------------------- 数据结构 ----------------------------

interface ShowItem {
  id: number
  name: string
  host: string
  time: string
  tag: string
  hot: number
  note: string
}

interface TapeItem {
  id: number
  name: string
  singer: string
  era: string
  style: string
  hot: number
  note: string
}

interface FmItem {
  id: number
  name: string
  cat: string
  hot: number
  listen: number
  state: string
  note: string
}

interface RankItem {
  id: number
  name: string
  singer: string
  hot: number
  trend: string
  note: string
}

interface FeedItem {
  id: number
  title: string
  time: string
  tag: string
  text: string
}

interface GoodItem {
  id: number
  name: string
  price: number
  stock: number
  cover: string
  note: string
}

interface MyItem {
  id: number
  name: string
  kind: string
  date: string
  note: string
}

// ---------------------------- 写死数据 ----------------------------

const SHOWS: ShowItem[] = [
  { id: 1, name: '午夜棕榈电台', host: 'Vivi 子', time: '00:00-02:00', tag: '梦幻', hot: 96, note: '凌晨档招牌节目,混入海浪白噪与慢速鼓点,营造半梦半醒的泳池畔氛围。' },
  { id: 2, name: '粉色霓虹点歌台', host: '阿澈', time: '20:00-22:00', tag: '点歌', hot: 92, note: '听众点歌互动节目,主打粉紫色系蒸汽波与未来放克,弹幕每秒都在刷屏。' },
  { id: 3, name: '磁带倒带夜', host: '老磁带', time: '22:00-24:00', tag: '复古', hot: 89, note: '专放 80 年代华语翻录磁带音质的老歌,把倒带声当作节目片头。' },
  { id: 4, name: '罗马假日剪影', host: 'Momo', time: '16:00-18:00', tag: '异域', hot: 85, note: '地中海风蒸汽波专场,复古雕像与喷泉采样混搭,下午档收听率第一。' },
  { id: 5, name: '游戏厅开机音', host: '手柄君', time: '18:00-20:00', tag: '游戏', hot: 88, note: '街机厅开机动效音与 8-bit 旋律的赛博拼贴,游戏宅的快乐源泉。' },
  { id: 6, name: '都市黄昏车流', host: '夕烧', time: '17:00-19:00', tag: '氛围', hot: 87, note: '晚高峰通勤伴侣,城市夜景采样垫底,放慢倍速的迪斯科让你堵车不烦躁。' },
  { id: 7, name: 'AI 少女主理人', host: 'Echo-2077', time: '08:00-10:00', tag: 'AI', hot: 90, note: '虚拟主播主持的晨间节目,用 AI 音色翻唱经典老歌,弹幕问得最多的是她用什么声卡。' },
  { id: 8, name: '泳池派对混音', host: '浪里白条', time: '14:00-16:00', tag: '混音', hot: 82, note: '午后泳池派对混音,充气玩具音效与气泡声采样,夏天气息拉满。' },
  { id: 9, name: '废土购物车', host: '赛博老王', time: '12:00-14:00', tag: '闲谈', hot: 80, note: '用蒸汽波 BGM 聊赛博朋克购物车清单,偶尔测评复古电子产品。' },
  { id: 10, name: '星空商场打烊', host: '打烊员', time: '22:30-23:30', tag: '氛围', hot: 84, note: '商场打烊广播与自动扶梯声采样,空旷又治愈的深夜氛围音乐。' },
  { id: 11, name: '胶片相机快门', host: '菲林', time: '15:00-17:00', tag: '复古', hot: 79, note: '胶片过片与快门声为底色的轻蒸汽波,复古摄影爱好者聚集地。' },
  { id: 12, name: '落日飞车返场', host: '湾区仔', time: '19:00-21:00', tag: '推荐', hot: 93, note: '本周最火节目,落日飞车风格返场歌单,连播三小时不重样。' }
]

const TAPES: TapeItem[] = [
  { id: 1, name: '粉红海滩 1987', singer: 'VAPOR 合辑', era: '1987', style: '梦幻', hot: 95, note: '封面是棕榈树下的粉色泳池,收录 12 首慢速放克,翻录时保留了磁带的沙沙声。' },
  { id: 2, name: '东京霓虹雨夜', singer: 'City Pop 组', era: '1989', style: '都市', hot: 92, note: '雨夜出租车电台采样开场,City Pop 黄金年代的致敬之作。' },
  { id: 3, name: '罗马雕塑园', singer: 'Momo 精选', era: '1991', style: '异域', hot: 88, note: '大理石雕像与喷泉的水声采样,地中海阳光味十足。' },
  { id: 4, name: '游戏厅不夜城', singer: '手柄君', era: '1993', style: '游戏', hot: 90, note: '街机厅氛围音与 8-bit 旋律拼贴,开机音的仪式感拉满。' },
  { id: 5, name: '日落高速路', singer: '夕烧', era: '1985', style: '氛围', hot: 86, note: '黄昏车流采样垫底,放慢的迪斯科节拍,通勤路上的必听磁带。' },
  { id: 6, name: '泳池气泡酒', singer: '浪里白条', era: '1990', style: '混音', hot: 84, note: '气泡音效与清凉旋律,夏日午后解暑首选。' },
  { id: 7, name: '百货商场打烊', singer: '打烊员', era: '1988', style: '氛围', hot: 83, note: '空荡商场广播与扶梯声,孤独感与治愈感并存。' },
  { id: 8, name: '胶片快门一刻', singer: '菲林', era: '1984', style: '复古', hot: 81, note: '快门与过片声采样,复古摄影与蒸汽波的跨界之作。' },
  { id: 9, name: '午夜电台情书', singer: 'Vivi 子', era: '1986', style: '梦幻', hot: 89, note: '深夜电台读信环节混音版,海风与钢琴的温柔碰撞。' },
  { id: 10, name: '粉紫故障艺术', singer: '阿澈', era: '2021', style: '故障', hot: 94, note: '新派故障艺术蒸汽波,画面撕裂与信号丢失音效做成节奏。' }
]

const FMS: FmItem[] = [
  { id: 1, name: '粉雾电台 FM', cat: '梦幻', hot: 96, listen: 52800, state: 'live', note: '专注梦幻系蒸汽波的 24 小时电台,主打慢速放克与海浪采样。' },
  { id: 2, name: '未来放克基地', cat: '放克', hot: 92, listen: 41600, state: 'live', note: '未来放克与合成器浪潮的大本营,每周五上新混音。' },
  { id: 3, name: '老磁带档案馆', cat: '复古', hot: 89, listen: 35400, state: 'stand', note: '翻录 80-90 年代华语磁带的怀旧电台,音质刻意做旧。' },
  { id: 4, name: '像素都市电台', cat: '游戏', hot: 90, listen: 38900, state: 'live', note: '游戏配乐与 8-bit 旋律专线,主机党熬夜必备。' },
  { id: 5, name: '地中海剪影 FM', cat: '异域', hot: 85, listen: 27300, state: 'stand', note: '罗马与圣托里尼的采样之声,下午茶时间人气最高。' },
  { id: 6, name: '都市夜行电台', cat: '氛围', hot: 88, listen: 34200, state: 'live', note: '深夜城市影像配乐台,公交站台的寂寞与浪漫。' },
  { id: 7, name: 'AI 声浪实验台', cat: 'AI', hot: 87, listen: 31500, state: 'live', note: 'AI 音色翻唱与合成人声的实验场,每期一个虚拟主理人。' },
  { id: 8, name: '泳池俱乐部 FM', cat: '混音', hot: 83, listen: 22800, state: 'stand', note: '泳池派对混音专线,充气玩具音效是它的灵魂。' }
]

const RANKS: RankItem[] = [
  { id: 1, name: '落日飞车返场', singer: '湾区仔', hot: 98, trend: 'up', note: '连续两周霸榜,三小时连播不重样,弹幕梗最多的节目。' },
  { id: 2, name: '粉红海滩 1987', singer: 'VAPOR 合辑', hot: 95, trend: 'up', note: '翻录磁带之王,沙沙声成为一代人的共同记忆。' },
  { id: 3, name: '午夜棕榈电台', singer: 'Vivi 子', hot: 93, trend: 'keep', note: '凌晨档常青树,海浪白噪让人上瘾。' },
  { id: 4, name: '粉紫故障艺术', singer: '阿澈', hot: 92, trend: 'up', note: '新派故障美学,画面撕裂音效做成节奏的先锋之作。' },
  { id: 5, name: '东京霓虹雨夜', singer: 'City Pop 组', hot: 90, trend: 'down', note: '雨夜出租车电台采样,City Pop 黄金年代致敬。' },
  { id: 6, name: '游戏厅不夜城', singer: '手柄君', hot: 89, trend: 'up', note: '街机厅开机音的仪式感,游戏宅的快乐源泉。' },
  { id: 7, name: 'AI 少女主理人', singer: 'Echo-2077', hot: 88, trend: 'up', note: 'AI 音色翻唱经典,虚拟主播人气飙升中。' },
  { id: 8, name: '罗马雕塑园', singer: 'Momo 精选', hot: 86, trend: 'keep', note: '大理石与喷泉采样,地中海阳光味十足。' },
  { id: 9, name: '日落高速路', singer: '夕烧', hot: 85, trend: 'down', note: '黄昏车流采样,晚高峰通勤伴侣。' },
  { id: 10, name: '泳池气泡酒', singer: '浪里白条', hot: 84, trend: 'keep', note: '气泡音效与清凉旋律,夏日解暑神曲。' },
  { id: 11, name: '百货商场打烊', singer: '打烊员', hot: 82, trend: 'down', note: '空荡商场广播,孤独又治愈。' },
  { id: 12, name: '胶片快门一刻', singer: '菲林', hot: 80, trend: 'up', note: '快门采样与蒸汽波跨界,复古摄影爱好者聚集地。' }
]

const FEEDS: FeedItem[] = [
  { id: 1, title: '粉紫故障艺术磁带 24 小时售罄', time: '今天 10:00', tag: '热卖', text: '限量 300 盒的粉紫故障艺术磁带上线 24 小时即告售罄,二手价已炒至三倍。' },
  { id: 2, title: '午夜棕榈电台收听破五万', time: '今天 09:00', tag: '破纪录', text: 'Vivi 子的午夜棕榈电台累计收听突破五万人次,成为蒸汽波圈现象级节目。' },
  { id: 3, title: 'AI 声浪实验台新企划上线', time: '昨天 22:00', tag: 'AI', text: 'AI 声浪实验台推出「虚拟合唱团」企划,合成人声合唱经典蒸汽波曲目。' },
  { id: 4, title: '夏日泳池派对混音赛开启', time: '昨天 18:00', tag: '活动', text: '泳池俱乐部 FM 开启混音赛,投稿你的泳池派对混音,冠军可获限定磁带套装。' },
  { id: 5, title: '老磁带档案馆扩充三千首', time: '前天 15:00', tag: '馆藏', text: '老磁带档案馆新翻录三千首 80 年代华语金曲,全部保留磁带沙沙原声。' },
  { id: 6, title: '蒸汽波美学展周末开幕', time: '前天 11:00', tag: '展览', text: '「粉紫故障」蒸汽波视觉艺术展本周末开幕,棕榈剪影与霓虹装置等你打卡。' },
  { id: 7, title: '城市电台联播企划征集', time: '3 天前', tag: '征集', text: '面向独立电台主理人征集联播企划,入选电台将获得流量扶持与混音设备赞助。' },
  { id: 8, title: '复古磁带转盘硬件开售', time: '4 天前', tag: '周边', text: '官方复古磁带转盘硬件开售,支持蓝牙连接,带倒带声效开关。' }
]

const GOODS: GoodItem[] = [
  { id: 1, name: '粉紫故障磁带', price: 39, stock: 300, cover: '📼', note: '限量磁带,封面为棕榈剪影与粉紫故障条纹,附数字兑换码。' },
  { id: 2, name: '霓虹棕榈 T 恤', price: 89, stock: 200, cover: '👕', note: '棕榈树霓虹印花短袖,渐变粉紫,纯棉透气。' },
  { id: 3, name: '复古磁带转盘', price: 199, stock: 80, cover: '📀', note: '蓝牙磁带转盘播放器,模拟倒带声与沙沙底噪,情怀拉满。' },
  { id: 4, name: '像素游戏厅贴纸', price: 15, stock: 500, cover: '🕹️', note: '八张一套的游戏厅像素贴纸,防水材质。' },
  { id: 5, name: '泳池气泡杯', price: 49, stock: 150, cover: '🥤', note: '渐变粉紫双层玻璃杯,倒水时呈现气泡上浮效果。' },
  { id: 6, name: '落日高速海报', price: 29, stock: 260, cover: '🌇', note: 'A3 微喷海报,黄昏高速路与粉色天空,可裱框。' },
  { id: 7, name: 'AI 声浪徽章', price: 19, stock: 400, cover: '🎙️', note: '虚拟主理人 Echo-2077 联名徽章,夜光材质。' },
  { id: 8, name: '打烊广播抱枕', price: 69, stock: 120, cover: '🛋️', note: '印有「商场打烊」广播词的抱枕,深夜氛围感神器。' }
]

const MYS: MyItem[] = [
  { id: 1, name: '午夜棕榈电台', kind: '节目', date: '08-27', note: '每晚必听' },
  { id: 2, name: '粉红海滩 1987', kind: '磁带', date: '08-25', note: '翻录收藏' },
  { id: 3, name: '落日飞车返场', kind: '节目', date: '08-23', note: '单曲循环' },
  { id: 4, name: '粉紫故障艺术', kind: '磁带', date: '08-20', note: '先锋之作' },
  { id: 5, name: '粉雾电台 FM', kind: '电台', date: '08-18', note: '24h 常驻' },
  { id: 6, name: '游戏厅不夜城', kind: '磁带', date: '08-15', note: '开机仪式' },
  { id: 7, name: '复古磁带转盘', kind: '周边', date: '08-12', note: '情怀硬件' },
  { id: 8, name: '东京霓虹雨夜', kind: '磁带', date: '08-10', note: 'City Pop' }
]

const HEAT: number[] = [45, 62, 38, 70, 55, 78, 64, 52]

// ---------------------------- 工具函数 ----------------------------

function heatBar(v: number): number {
  return Math.floor(28 + v * 0.85)
}

function playsText(v: number): string {
  if (v >= 10000) {
    return (v / 10000).toFixed(1) + ' 万'
  }
  return v.toString()
}

function trendText(t: string): string {
  if (t === 'up') {
    return '↑ 上升'
  }
  if (t === 'down') {
    return '↓ 下滑'
  }
  return '→ 持平'
}

function trendColor(t: string): string {
  if (t === 'up') {
    return '#F472B6'
  }
  if (t === 'down') {
    return '#67E8F9'
  }
  return '#8B7BA8'
}

function fmStateText(s: string): string {
  if (s === 'live') {
    return '直播中'
  }
  return '待机'
}

function fmStateColor(s: string): string {
  if (s === 'live') {
    return '#F472B6'
  }
  return '#8B7BA8'
}

function buildFeed(id: number, title: string): FeedItem {
  return { id: id, title: title, time: '刚刚', tag: '速递', text: '这是一条刚刚发布的电台动态,欢迎关注 VAPORWAVE FM 的最新资讯。' }
}

function buildMy(id: number, name: string, kind: string): MyItem {
  return { id: id, name: name, kind: kind, date: '08-28', note: '刚刚收藏' }
}

function buildShow(id: number, name: string, host: string): ShowItem {
  return { id: id, name: name, host: host, time: '19:00-20:00', tag: '新节目', hot: 70, note: '这是一档刚刚上线的蒸汽波新节目,首播人气正在攀升,欢迎收听。' }
}

// =====================================================================
// Index 主入口:粉紫故障错位头部(蒸汽波风,无动画)+ 6 Tab + 底部单排
// =====================================================================

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State shows: ShowItem[] = SHOWS
  @State tapes: TapeItem[] = TAPES
  @State fms: FmItem[] = FMS
  @State ranks: RankItem[] = RANKS
  @State feeds: FeedItem[] = FEEDS
  @State goods: GoodItem[] = GOODS
  @State mys: MyItem[] = MYS

  @Builder
  tabItem(icon: string, label: string, tab: number) {
    Column({ space: 3 }) {
      Text(icon)
        .fontSize(22)
      Text(label)
        .fontSize(11)
        .fontColor(tab === this.currentTab ? '#F472B6' : '#8B7BA8')
        .fontWeight(tab === this.currentTab ? 900 : 400)
    }
    .width('100%')
    .scale(tab === this.currentTab ? { x: 1.1, y: 1.1 } : { x: 1, y: 1 })
    .animation({ duration: 200, curve: Curve.EaseOut })
    .onClick(() => {
      this.currentTab = tab
    })
  }

  build() {
    Column() {
      // ---- 头部:粉紫故障错位条(蒸汽波风,无动画) ----
      Column() {
        Row({ space: 10 }) {
          Column({ space: 1 }) {
            Row({ space: 6 }) {
              Text('🌴')
                .fontSize(14)
              Text('VAPORWAVE FM')
                .fontSize(19)
                .fontWeight(900)
                .fontColor('#F472B6')
                .letterSpacing(2)
            }
            Text('蒸汽波电台 · 始于 1984')
              .fontSize(10)
              .fontColor('#8B7BA8')
              .letterSpacing(1)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Row({ space: 14 }) {
            Text('🔍')
              .fontSize(18)
              .onClick(() => {
                this.currentTab = 0
              })
            Text('📼')
              .fontSize(18)
              .onClick(() => {
                this.currentTab = 2
              })
          }
        }
        .width('100%')
        .padding({ left: 18, right: 18, top: 12, bottom: 10 })

        // 粉紫青三色渐变分隔条 + 故障错位方块
        Row() {
          Text('')
            .width(12)
            .height(12)
            .backgroundColor('#F472B6')
          Text('')
            .layoutWeight(1)
            .height(3)
            .backgroundColor('#A855F7')
          Text('')
            .width(8)
            .height(8)
            .backgroundColor('#67E8F9')
            .margin({ left: 4, right: 4 })
          Text('')
            .layoutWeight(1)
            .height(3)
            .backgroundColor('#F472B6')
          Text('')
            .width(8)
            .height(8)
            .backgroundColor('#67E8F9')
            .margin({ left: 4, right: 4 })
          Text('')
            .layoutWeight(1)
            .height(3)
            .backgroundColor('#A855F7')
          Text('')
            .width(12)
            .height(12)
            .backgroundColor('#F472B6')
        }
        .width('100%')
        .padding({ left: 18, right: 18 })

        Row({ space: 10 }) {
          ForEach(['电台节目', '翻录磁带', '独立电台', '蒸汽榜单'], (t: string, i: number) => {
            Text(t)
              .fontSize(10)
              .fontColor(i === 0 ? '#F472B6' : '#E8DFF5')
              .fontWeight(i === 0 ? 900 : 500)
          })
          Text('')
            .layoutWeight(1)
          Text('FM 96.9 MHz')
            .fontSize(9)
            .fontColor('#8B7BA8')
        }
        .width('100%')
        .padding({ left: 18, right: 18, top: 8, bottom: 10 })
      }
      .width('100%')
      .backgroundColor('#1A1230')
      .border({ width: { bottom: 2 }, color: '#F472B6' })

      // ---- 内容区 ----
      Stack() {
        if (this.currentTab === 0) {
          HomeContent({ feeds: $feeds, shows: $shows })
        }
        if (this.currentTab === 1) {
          ShowContent({ shows: $shows, mys: $mys })
        }
        if (this.currentTab === 2) {
          TapeContent({ tapes: $tapes, mys: $mys })
        }
        if (this.currentTab === 3) {
          FmContent({ fms: $fms })
        }
        if (this.currentTab === 4) {
          RankContent({ ranks: $ranks, goods: $goods })
        }
        if (this.currentTab === 5) {
          MeContent({ mys: $mys })
        }
      }
      .layoutWeight(1)
      .width('100%')

      // ---- 底部 Tab 栏 ----
      Row() {
        this.tabItem('🏠', '首页', 0)
        this.tabItem('📻', '节目', 1)
        this.tabItem('📼', '磁带', 2)
        this.tabItem('📡', '电台', 3)
        this.tabItem('🏆', '榜单', 4)
        this.tabItem('👤', '我的', 5)
      }
      .width('100%')
      .height(62)
      .padding({ top: 6, bottom: 6 })
      .backgroundColor('#1A1230')
      .border({ width: { top: 1 }, color: '#33255A' })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0F0A1A')
  }
}

// =====================================================================
// Tab 1 首页:电台横幅 + 周热度柱状图 + 电台动态列表 + 详情 + 点播新增
// =====================================================================

@Component
struct HomeContent {
  @Link feeds: FeedItem[]
  @Link shows: ShowItem[]
  @State showDetail: boolean = false
  @State picked: FeedItem = FEEDS[0]
  @State showPlay: boolean = false
  @State playSong: string = ''
  @State playWho: string = ''
  @State tip: string = ''

  @Builder
  feedModalOverlay(onClose: () => void) {
    Column({ space: 0 }) {
      Row({ space: 8 }) {
        Text('📡')
          .fontSize(20)
        Text('电台动态')
          .fontSize(17)
          .fontWeight(900)
          .fontColor('#E8DFF5')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#8B7BA8')
          .padding(6)
          .onClick(() => {
            onClose()
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 12 })
      .border({ width: { bottom: 1 }, color: '#33255A' })

      Column({ space: 10 }) {
        Text(this.picked.title)
          .fontSize(17)
          .fontWeight(900)
          .fontColor('#F472B6')
        Row({ space: 8 }) {
          Text(this.picked.tag)
            .fontSize(10)
            .fontColor('#67E8F9')
            .backgroundColor('#14263A')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(10)
          Text(this.picked.time)
            .fontSize(10)
            .fontColor('#8B7BA8')
        }
        Text(this.picked.text)
          .fontSize(13)
          .fontColor('#C9BDE8')
          .lineHeight(22)
      }
      .width('100%')
      .padding(16)
      .alignItems(HorizontalAlign.Start)

      Text('已助力 ' + (this.picked.id * 137).toString() + ' 次收听')
        .fontSize(11)
        .fontColor('#8B7BA8')
        .width('100%')
        .padding({ left: 16, right: 16, bottom: 12 })
    }
    .width('86%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor('#1E1538')
    .borderRadius(14)
    .border({ width: 1, color: '#33255A' })
  }

  @Builder
  playModalOverlay(onClose: () => void) {
    Column({ space: 0 }) {
      Row({ space: 8 }) {
        Text('🎧')
          .fontSize(20)
        Text('点播新曲')
          .fontSize(17)
          .fontWeight(900)
          .fontColor('#E8DFF5')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#8B7BA8')
          .padding(6)
          .onClick(() => {
            onClose()
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 12 })
      .border({ width: { bottom: 1 }, color: '#33255A' })

      Column({ space: 12 }) {
        Text('想听什么?把歌名告诉主播')
          .fontSize(13)
          .fontColor('#8B7BA8')
        TextInput({ placeholder: '输入歌曲名', text: this.playSong })
          .height(40)
          .backgroundColor('#241A42')
          .fontColor('#E8DFF5')
          .placeholderColor('#8B7BA8')
          .borderRadius(6)
          .onChange((v: string) => {
            this.playSong = v
          })
        TextInput({ placeholder: '点给谁(可留空)', text: this.playWho })
          .height(40)
          .backgroundColor('#241A42')
          .fontColor('#E8DFF5')
          .placeholderColor('#8B7BA8')
          .borderRadius(6)
          .onChange((v: string) => {
            this.playWho = v
          })
        Row({ space: 10 }) {
          Text('取消')
            .textAlign(TextAlign.Center)
            .width('45%')
            .height(40)
            .fontSize(13)
            .fontColor('#8B7BA8')
            .backgroundColor('#241A42')
            .borderRadius(6)
            .onClick(() => {
              this.playSong = ''
              this.playWho = ''
              this.tip = ''
              onClose()
            })
          Text('提交点播')
            .textAlign(TextAlign.Center)
            .width('45%')
            .height(40)
            .fontSize(13)
            .fontWeight(800)
            .fontColor('#0F0A1A')
            .backgroundColor('#F472B6')
            .borderRadius(6)
            .onClick(() => {
              if (this.playSong.length === 0) {
                this.tip = '请先输入歌名'
                return
              }
              this.shows.unshift(buildShow(100 + this.shows.length, this.playSong + '(点播)', this.playWho.length > 0 ? this.playWho : '听众'))
              this.tip = '✅ 点播成功,已加入播放队列'
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)

        Text(this.tip)
          .fontSize(11)
          .fontColor('#67E8F9')
      }
      .width('100%')
      .padding(16)
      .alignItems(HorizontalAlign.Start)
    }
    .width('86%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor('#1E1538')
    .borderRadius(14)
    .border({ width: 1, color: '#33255A' })
  }

  build() {
    Stack() {
      Scroll() {
        Column({ space: 12 }) {
          // ---- 电台横幅 ----
          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Text('🔴')
                .fontSize(10)
              Text('ON AIR · 直播中')
                .fontSize(11)
                .fontWeight(900)
                .fontColor('#F472B6')
                .letterSpacing(1)
            }
            Text('VAPORWAVE FM 96.9')
              .fontSize(24)
              .fontWeight(900)
              .fontColor('#E8DFF5')
              .letterSpacing(2)
            Text('午夜棕榈电台 · 凌晨档 · 海浪白噪混慢速放克')
              .fontSize(12)
              .fontColor('#C9BDE8')
            Row({ space: 14 }) {
              Text('📻 2.3 万人在线')
                .fontSize(11)
                .fontColor('#67E8F9')
              Text('💾 收听峰值 5.2 万')
                .fontSize(11)
                .fontColor('#8B7BA8')
            }
          }
          .width('100%')
          .padding(18)
          .alignItems(HorizontalAlign.Start)
          .backgroundColor('#241A42')
          .borderRadius(12)
          .border({ width: 1, color: '#F472B6' })

          // ---- 周热度柱状图 ----
          Column({ space: 8 }) {
            Row() {
              Text('📈 本周电台热度')
                .fontSize(15)
                .fontWeight(900)
                .fontColor('#E8DFF5')
              Text('')
                .layoutWeight(1)
              Text('单位:万次')
                .fontSize(10)
                .fontColor('#8B7BA8')
            }
            .width('100%')

            Row({ space: 8 }) {
              ForEach(HEAT, (v: number, i: number) => {
                Column({ space: 4 }) {
                  Text(v.toString())
                    .fontSize(9)
                    .fontColor('#F472B6')
                  Text('')
                    .width(24)
                    .height(heatBar(v))
                    .backgroundColor(i % 2 === 0 ? '#F472B6' : '#A855F7')
                    .borderRadius(4)
                    .animation({ duration: 700, curve: Curve.EaseOut })
                  Text('周' + (i + 1).toString())
                    .fontSize(9)
                    .fontColor('#8B7BA8')
                }
              }, (v: number) => v.toString())
            }
            .width('100%')
            .height(120)
            .alignItems(VerticalAlign.Bottom)
            .justifyContent(FlexAlign.SpaceBetween)
          }
          .width('100%')
          .padding(14)
          .backgroundColor('#1A1230')
          .borderRadius(12)
          .border({ width: 1, color: '#33255A' })

          // ---- 点播入口 ----
          Row({ space: 10 }) {
            Column({ space: 4 }) {
              Text('🎧 电台点播')
                .fontSize(15)
                .fontWeight(900)
                .fontColor('#E8DFF5')
              Text('把想听的歌告诉主播')
                .fontSize(10)
                .fontColor('#8B7BA8')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('去点播')
              .textAlign(TextAlign.Center)
              .width(72)
              .height(34)
              .fontSize(12)
              .fontWeight(800)
              .fontColor('#0F0A1A')
              .backgroundColor('#67E8F9')
              .borderRadius(17)
              .onClick(() => {
                this.showPlay = true
              })
          }
          .width('100%')
          .padding(14)
          .backgroundColor('#241A42')
          .borderRadius(12)

          // ---- 电台动态列表 ----
          Column({ space: 8 }) {
            Row() {
              Text('📡 电台动态')
                .fontSize(15)
                .fontWeight(900)
                .fontColor('#E8DFF5')
              Text('')
        .backgroundColor('#66000000')
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
  }
}

// =====================================================================
// 2169.ets END
// =====================================================================


在这里插入图片描述

交互反馈的多层次实现

应用在交互反馈的设计上展现了多层次的考量。视觉层面,Tab 切换时的缩放动画(scale 1.1 + animation 200ms)提供了即时的触觉式反馈;柱状图的 700 毫秒缓出动画让数据可视化有了"生长"的仪式感;按钮的颜色对比(深底浅字 vs 浅底深字)区分了主次操作的优先级。文案层面,每个操作完成后都有 tip 状态变量驱动的提示文案(“点播成功”“打卡成功”"翻录完成"等),用青色 #67E8F9 文字在弹窗底部展示;删除操作前展示警告文案(“屏蔽后将不再推荐”“清空后无法恢复”),用粉色 #F472B6 高亮风险提示。状态层面,电台卡片通过 fmStateTextfmStateColor 实时反映直播/待机状态,榜单列表通过 trendTexttrendColor 展示上升/下滑/持平趋势,缓存清理弹窗通过进度条颜色切换(粉色->青色)表示完成状态。这三个层次的反馈机制共同构建了一个"用户永远知道发生了什么"的交互体验。

代码复用与模式提取

纵观整个应用,最值得称道的工程实践是高度的代码复用和模式提取。弹窗的 @Builder 方法结构完全统一——标题栏+内容区+操作栏+提示区,十五个弹窗只是内容不同,骨架一致。热度进度条在节目详情、磁带详情、电台详情、档案卡四个位置复用,实现方式完全相同(两个 layoutWeight 的空 Text)。药丸标签(backgroundColor + padding + borderRadius(10))在动态列表、节目详情、关注弹窗、昵称建议等七八处复用。列表项的 Row + Column + layoutWeight(1) + Text('详情/查看') 的三段式布局在六个列表中反复出现。这种"提取模式、多处复用"的做法,不仅减少了代码量,更重要的是保证了视觉一致性——所有进度条长得一样,所有标签风格统一,所有列表项结构相似,用户的学习成本大幅降低。如果未来需要调整进度条的样式,只需修改一处即可全局生效,这是 DRY 原则在 UI 开发中的完美体现。

Logo

一站式 AI 云服务平台

更多推荐