鸿蒙操作系统(HarmonyOS)以其分布式架构和跨设备协同能力,正在重塑移动应用的开发范式。在这套生态中,ArkTS作为主力开发语言,融合了TypeScript的类型安全性与声明式UI编程模型,为开发者提供了一套高效、可维护、可扩展的应用构建体系。本文将以一个完整的播客电台社区应用为例,从类型系统、状态管理、组件化设计、布局引擎、弹窗交互、数据流等多个维度,逐行剖析ArkTS在复杂业务场景下的工程实践。

声明式UI的核心思想在于:开发者只需描述界面在不同状态下的"样子",而框架负责根据状态变化自动计算并应用最小化的DOM差异更新。ArkTS的@Component@State@Builder等装饰器正是这一思想的具体落地。组件是界面构建的基本单元,每个组件拥有独立的状态与生命周期,组件之间通过属性传递和事件回调实现松耦合通信。这种模式不仅提升了代码的复用性,也使得大型应用的架构更加清晰可维护。

一、鸿蒙开发背景与ArkTS语言特性

鸿蒙操作系统是华为推出的面向万物互联时代的分布式操作系统,其设计目标是实现跨设备的无缝协同与能力共享。在应用开发层面,鸿蒙提供了ArkUI框架,这是一套基于声明式编程范式的UI开发框架,支持开发者以极简的代码描述复杂的界面结构与交互逻辑。

ArkTS是在TypeScript基础上扩展而来的语言,它在保留了TypeScript完整类型系统的基础上,针对UI声明式编程做了深度优化。ArkTS引入了一系列装饰器(Decorator)来标注组件、状态、构建器等概念,使编译器能够在编译期进行更严格的类型检查和性能优化。与传统的命令式UI编程相比,ArkTS的声明式模型让开发者专注于"界面应该是什么样",而非"如何一步步把界面变成那样",这大幅降低了状态同步的复杂度。

在ArkTS的类型系统中,interface用于定义数据结构的契约,而class则用于实现这些契约并提供构造函数。这种接口与实现分离的设计模式,使得数据模型可以在不改变消费端代码的前提下灵活替换实现。同时,ArkTS对nullundefined有严格的区分,通过可选链操作符(?.)和空值合并操作符(??)提供了安全的属性访问机制,这在处理可能为空的对象引用时尤为重要。

组件化开发是ArkTS应用架构的核心思想。一个完整的应用由多个@Component装饰的组件构成,每个组件封装了自己的视图逻辑、状态数据和样式属性。组件之间通过属性(Props)进行数据传递,通过回调函数(Events)进行事件通知,形成了清晰的数据流动方向。入口组件使用@Entry装饰,标记为应用的根渲染节点;而@Builder装饰器则用于抽取可复用的UI片段,类似于其他框架中的"渲染函数"或"局部组件"。

在状态管理方面,ArkTS提供了多层次的响应式机制。@State用于管理组件内部状态,当状态值发生变化时,框架会自动触发组件的重新渲染;@Prop用于接收父组件传递的只读数据;@Link用于建立父子组件之间的双向绑定;@Provide@Consume则用于跨层级的数据共享。这些装饰器构成了ArkTS响应式编程的基础设施,使得状态变更能够精确、高效地驱动UI更新。

二、应用整体架构概览

本应用是一个名为"潮汐电台"的播客订阅与电台直播社区平台,采用底部六Tab导航架构,涵盖电台直播、内容发现、订阅管理、睡眠助眠、社区互动和个人中心六大功能模块。整个应用的代码组织遵循了"类型定义—配置常量—数据模型—工具函数—组件实现"的自上而下分层结构,每一层都为上层提供基础能力支撑。

数据层

公共组件

底部导航Tab

应用入口

@Entry TidalApp

Tab1: 电台 RadioContent

Tab2: 发现 DiscoverContent

Tab3: 订阅 SubsContent

Tab4: 睡眠 SleepContent

Tab5: 社区 CommunityContent

Tab6: 我的 MeContent

RadioHeader 公共头部

类型定义 interfaces

风格配置 constants

Mock数据 classes+instances

工具函数 utilities

如上图所示,应用的整体架构以入口组件为枢纽,通过activeTab状态变量控制六个Tab页面的条件渲染。每个Tab页面都是独立的@Component组件,拥有自己的状态管理、弹窗系统和数据消费逻辑。所有Tab共享同一套类型定义、风格配置和Mock数据实例,这保证了数据格式的一致性和视觉风格的统一性。

三、类型定义层详解

3.1 分类元数据接口

interface CatMeta {
  label: string;
  icon: string;
  color: string;
  bg: string;
}

在这里插入图片描述

CatMeta接口定义了播客分类的元数据结构。每个分类包含四个字段:label是分类的显示名称,如"生活"、"音乐"等;icon是分类对应的Emoji图标,用于在卡片和列表中以视觉化方式呈现分类属性;color是分类的主题色值,采用十六进制颜色编码,用于标签文字、图标背景等需要强调分类属性的元素;bg是分类的浅色背景色,用于标签底色、卡片图标背景等大面积色块区域。

这种将分类的视觉属性与逻辑属性统一封装在一个接口中的设计,使得分类的样式管理高度集中化。当需要新增一个分类或调整某个分类的配色方案时,只需修改一处配置即可全局生效,避免了在代码各处散落硬编码颜色值带来的维护成本。

在ArkTS中,interface定义的纯数据结构可以被class直接implements,也可以作为对象字面量的类型约束使用。本应用中,CatMeta主要作为Record的值类型使用,构成分类配置映射表。

3.2 电台节目项接口

interface StationItem {
  id: number;
  name: string;
  freq: string;
  cat: string;
  host: string;
  listeners: number;
  live: boolean;
  desc: string;
}

在这里插入图片描述

StationItem接口定义了电台节目的数据结构。id是唯一标识符,用于在列表渲染和点击事件中精确定位特定电台;name是电台名称,如"潮汐晚间新闻";freq是调频频率,如"FM 96.3",这是模拟传统收音机体验的关键字段;cat是分类标签,与CatMeta中的label对应;host是主播姓名;listeners是当前在线收听人数,用于展示热度;live是布尔值,标记该电台是否正在直播;desc是节目简介。

live字段的设计值得关注。它是一个简单的布尔标志位,但在UI层面却承载了重要的视觉区分功能——直播中的电台会显示醒目的"LIVE"标签,使用粉红色背景突显紧迫感。这种"一个布尔值驱动多重视觉表现"的模式,是声明式UI中状态驱动渲染的典型实践。

3.3 单集节目项接口

interface EpisodeItem {
  id: number;
  title: string;
  station: string;
  host: string;
  date: string;
  duration: number;
  plays: number;
  tag: string;
}

在这里插入图片描述

EpisodeItem定义了播客单集的数据结构。与StationItem不同,单集是电台下的具体内容单元,station字段引用所属电台名称,date是发布时间,duration以分钟为单位的时长,plays是累计播放量,tag是内容标签如"晚安曲"、"AI观察"等。

这里duration使用number类型存储分钟数,而非字符串如"43:50"。这种设计使得后续的时长计算(如总收听时长统计)可以直接进行数学运算,而显示时再做格式化转换。这是数据建模中"存储原始值、显示时格式化"的最佳实践。

3.4 订阅项接口

interface SubItem {
  id: number;
  name: string;
  host: string;
  cat: string;
  total: number;
  unplayed: number;
  updated: string;
  notify: boolean;
  hot: number;
}

在这里插入图片描述

SubItem定义了用户订阅的播客节目数据结构。其中total是该节目的总单集数,unplayed是未播放的单集数,这两个字段共同构成了"追更进度"的量化指标。updated是最近更新时间的描述文本,如"今天更新"、"昨天更新"等,采用自然语言而非时间戳,是为了在列表中直接显示而无需额外格式化。notify是是否开启更新提醒的布尔开关,hot是热度指数(0-100)。

notify字段是该接口中最重要的可变状态之一。在订阅页中,用户可以通过点击铃铛图标切换某个订阅的提醒开关,这个操作会直接修改notify字段的值,并触发列表的重新渲染以反映新的铃铛图标状态。

3.5 社区帖子接口

interface PostItem {
  id: number;
  user: string;
  avatar: string;
  topic: string;
  text: string;
  likes: number;
  comments: number;
  time: string;
  liked: boolean;
}

在这里插入图片描述

PostItem定义了社区帖子的数据结构。avatar字段存储Emoji而非图片URL,这是本应用的一个轻量化设计选择——使用Emoji作为用户头像,既减少了图片资源加载的开销,又营造了轻松活泼的社区氛围。liked字段记录当前用户是否已点赞该帖子,likes是总点赞数,两个字段需要保持联动一致性。

3.6 白噪音项接口

interface SoundItem {
  id: number;
  name: string;
  icon: string;
  minutes: number;
  users: number;
  free: boolean;
}

SoundItem定义了助眠白噪音的数据结构。free字段区分免费和VIP专享内容,在UI中VIP内容会显示金色"VIP"标签。users字段记录当前使用该白噪音的用户数,既展示了热度,也提供了社交证明。

3.7 评论项接口

interface CommentEntry {
  id: number;
  user: string;
  avatar: string;
  text: string;
  time: string;
  likes: number;
}

在这里插入图片描述

CommentEntry定义了评论数据结构,字段相对精简。与PostItem相比,评论没有topic话题标签和comments子评论数,这是因为评论层是社区互动的最末端,不再支持嵌套回复。

3.8 新星榜项与分享占比项接口

interface StarRankEntry {
  rank: number;
  name: string;
  host: string;
  cat: string;
  heat: number;
  delta: string;
}

interface ShareEntry {
  name: string;
  pct: number;
  color: string;
}

StarRankEntry定义了播客热度排行榜的数据结构,heat是0-100的热度指数,delta是排名变动描述如"↑ 2"、“↓ 1”、“— 持平”。ShareEntry定义了分类收听占比的数据结构,pct是百分比数值,color是该分类在图表中对应的柱条颜色。

3.9 勋章项接口

interface BadgeEntry {
  icon: string;
  name: string;
  desc: string;
  got: boolean;
}

在这里插入图片描述

BadgeEntry定义了用户勋章的数据结构。got字段标记该勋章是否已获得,在UI中未获得的勋章会以降低透明度和缩小尺寸的方式呈现,营造出"锁定"的视觉暗示。这种用一个布尔值控制多种视觉属性变化的手法,体现了声明式UI强大的条件渲染能力。

在ArkTS的类型系统中,interface定义的是纯数据契约,不包含行为逻辑。当需要创建可构造的数据实例时,需要用class实现这些接口。这种"接口定义契约、类提供实现"的分离模式,使得数据模型可以在接口不变的前提下灵活演进,例如未来可以将class替换为从网络API反序列化得到的对象,只要其结构满足interface约束即可。

四、风格配置与常量定义层

4.1 分类配置映射表

const CAT_CONFIG: Record<string, CatMeta> = {
  '生活': { label: '生活', icon: '🌿', color: '#0E9384', bg: '#DCEEEA' },
  '音乐': { label: '音乐', icon: '🎧', color: '#B45309', bg: '#FCEFD9' },
  '科技': { label: '科技', icon: '🛰️', color: '#0369A1', bg: '#D8ECF8' },
  '人文': { label: '人文', icon: '📚', color: '#7C3AED', bg: '#EAE4FB' },
  '心理': { label: '心理', icon: '🧠', color: '#DB2777', bg: '#FBE3EE' },
  '商业': { label: '商业', icon: '💼', color: '#CA8A04', bg: '#FBF0CC' },
  '悬疑': { label: '悬疑', icon: '🔎', color: '#475569', bg: '#E3E8EE' }
};

CAT_CONFIG是一个Record<string, CatMeta>类型的常量映射表,将七个分类名称映射到各自的元数据对象。这是整个应用视觉风格的"单一数据源"(Single Source of Truth)。通过将所有分类的颜色、图标、背景色集中管理,应用在任何地方需要获取分类的视觉属性时,都通过这个映射表查询,确保了一致性。

观察配色方案可以发现,这是一个精心设计的"清新海洋风"色系:生活类使用水鸭绿(#0E9384),呼应应用名"潮汐"的海洋主题;音乐类使用琥珀棕(#B45309),营造温暖听感;科技类使用天空蓝(#0369A1),传递理性与未来感;心理类使用玫红(#DB2777),暗示情感深度。每种颜色的浅色背景版本都是主色的高明度衍生,保证了在白色卡片上的可读性。

4.2 分类列表与数据常量

const CAT_LIST: string[] = ['全部', '生活', '音乐', '科技', '人文', '心理', '商业', '悬疑'];

const WEEK_LISTEN: number[] = [86, 132, 74, 158, 96, 63, 110];
const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
const SLEEP_WEEK: number[] = [6.5, 7.2, 6.8, 7.9, 7.4, 8.2, 7.6];
const SUB_UPDATE_WEEK: number[] = [3, 5, 2, 7, 4, 6, 1];

在这里插入图片描述

CAT_LIST是分类筛选的有序数组,首项"全部"代表不过滤。这个数组在多个Tab页面的分类筛选条中被ForEach渲染为水平滚动的标签按钮。

WEEK_LISTEN是周一到周日的收听时长数据(分钟),其中周四(158分钟)是峰值,这会在柱状图中以琥珀色突出显示。SLEEP_WEEK是每晚睡眠时长数据(小时),用于睡眠页的柱状图渲染。SUB_UPDATE_WEEK是订阅节目在一周中每天的更新数量,周四(7集)最多。这些数据数组虽然目前是Mock数据,但它们的结构已经为接入真实API数据做好了准备。

4.3 分类收听占比配置

const CAT_SHARE: ShareEntry[] = [
  { name: '生活', pct: 28, color: '#0E9384' },
  { name: '音乐', pct: 22, color: '#B45309' },
  { name: '科技', pct: 18, color: '#0369A1' },
  { name: '人文', pct: 12, color: '#7C3AED' },
  { name: '心理', pct: 10, color: '#DB2777' },
  { name: '商业', pct: 6, color: '#CA8A04' },
  { name: '悬疑', pct: 4, color: '#475569' }
];

CAT_SHARE定义了分类收听占比数据,七个分类的百分比之和恰好为100。这组数据在发现页的"分类收听占比"图表中被渲染为水平条形图,每条柱子的宽度与pct值成正比,颜色取自CAT_CONFIG中对应分类的主色,实现了数据配置与视觉呈现的统一。

4.4 定时与倍速选项配置

const TIMER_OPTIONS: number[] = [5, 15, 30, 45, 60, 90];
const SPEED_OPTIONS: string[] = ['0.75x', '1.0x', '1.25x', '1.5x'];

TIMER_OPTIONS定义了定时关闭的可选时长(分钟),涵盖从5分钟到90分钟的六个档位。SPEED_OPTIONS定义了播放倍速的四个选项。这两个常量在电台页和睡眠页的弹窗中被渲染为选择网格,用户点击后更新对应的timerIndexspeedIndex状态变量。

4.5 话题标签与勋章配置

const TOPIC_CHIPS: string[] = ['#深夜书单#', '#通勤搭子#', '#播客安利#', '#电台回忆#', '#助眠神器#', '#主播快回#'];

const BADGE_LIST: BadgeEntry[] = [
  { icon: '🌅', name: '早起鸟', desc: '连续7天6点收听', got: true },
  { icon: '🌙', name: '夜猫子', desc: '深夜档收听50小时', got: true },
  { icon: '🌊', desc: '订阅满20档节目', name: '深海听友', got: true },
  { icon: '🐚', name: '拾贝人', desc: '收藏100条单集', got: true },
  { icon: '⚡', name: '连播狂', desc: '单日收听8小时', got: false },
  { icon: '🎧', name: '全勤生', desc: '连续30天签到', got: false }
];

TOPIC_CHIPS定义了社区话题标签,在社区页的横滑话题栏和发帖弹窗的话题选择器中使用。BADGE_LIST定义了六个勋章条目,其中四个已获得(got: true),两个未获得。勋章墙使用Flex弹性布局以网格方式呈现,已获得和未获得的勋章通过透明度和缩放比例的差异化呈现,形成视觉对比。

五、Mock数据类与数据实例

5.1 数据类的实现模式

class StationData implements StationItem {
  id: number = 0;
  name: string = '';
  freq: string = '';
  cat: string = '';
  host: string = '';
  listeners: number = 0;
  live: boolean = false;
  desc: string = '';

  constructor(id: number, name: string, freq: string, cat: string, host: string, listeners: number, live: boolean, desc: string) {
    this.id = id;
    this.name = name;
    this.freq = freq;
    this.cat = cat;
    this.host = host;
    this.listeners = listeners;
    this.live = live;
    this.desc = desc;
  }
}

StationData类通过implements StationItem实现了电台数据接口。值得注意的是,类的每个属性都有默认初始值(id默认为0,name默认为空字符串等),这是ArkTS对类属性的强制要求——所有实例属性必须在声明时或构造函数中完成初始化。

构造函数接收所有字段作为参数,并逐一赋值给this上的对应属性。这种"全参数构造函数"的模式虽然写法较为冗长,但保证了数据实例的完整性——调用者必须在创建时提供所有字段的值,不会遗漏任何必要属性。

应用中还有EpisodeDataSubDataPostDataSoundDataCommentDataStarRankData等类似的类,它们都遵循相同的实现模式:实现对应接口、声明带默认值的属性、提供全参数构造函数。这种一致的编码风格使得整个数据层具有高度的可预测性。

StationItem

+id: number

+name: string

+freq: string

+cat: string

+host: string

+listeners: number

+live: boolean

+desc: string

StationData

+constructor()

EpisodeData

+constructor()

SubData

+constructor()

PostData

+constructor()

SoundData

+constructor()

CommentData

+constructor()

StarRankData

+constructor()

EpisodeItem

SubItem

PostItem

SoundItem

CommentEntry

StarRankEntry

5.2 Mock数据实例的构造

const mockStations: StationData[] = [
  new StationData(1, '潮汐晚间新闻', 'FM 96.3', '生活', '林晚', 48210, true, '每晚八点,用 20 分钟听完今天的世界'),
  new StationData(2, '蓝调午夜场', 'FM 88.1', '音乐', '老周', 36540, true, '午夜十二点,只剩爵士和城市呼吸声'),
  new StationData(3, '未来科技观察', 'FM 101.7', '科技', 'Kiko', 52980, true, '一周三次,追踪全球科技圈新动向'),
  // ... 共16条
];

mockStations是电台数据的Mock实例数组,包含16条电台记录,覆盖了全部七个分类。每条数据都通过new StationData(...)构造,所有字段值在构造时一次性传入。这些数据在电台页的"正在直播"横滑区和发现页的网格中被ForEach渲染。

类似地,mockEpisodes包含24条单集数据,mockSubs包含10条订阅数据,mockPosts包含14条社区帖子,mockSounds包含12条白噪音数据,mockComments包含8条评论数据,mockStarRank包含8条排行榜数据。这些Mock数据虽然内容是虚构的,但其结构、数量级和分布特征都与真实场景接近,为UI开发和测试提供了充分的覆盖。

Mock数据的使用是前后端并行开发的关键实践。通过在应用内内置结构完整、语义真实的数据实例,前端开发者可以在后端API就绪之前完成全部UI开发和交互调试。当API可用时,只需将Mock数据源替换为网络请求返回的数据,由于两者都遵循相同的interface类型约束,替换过程不会引入任何类型错误。

六、工具函数层

6.1 分类属性查询函数

function getCatColor(c: string): string {
  if (CAT_CONFIG[c]) {
    return CAT_CONFIG[c].color;
  }
  return '#0E9384';
}

function getCatBg(c: string): string {
  if (CAT_CONFIG[c]) {
    return CAT_CONFIG[c].bg;
  }
  return '#DCEEEA';
}

function getCatIcon(c: string): string {
  if (CAT_CONFIG[c]) {
    return CAT_CONFIG[c].icon;
  }
  return '📻';
}

这三个工具函数提供了分类属性的安全查询能力。它们的实现逻辑完全一致:先检查CAT_CONFIG映射表中是否存在传入的分类名c,如果存在则返回对应的属性值,否则返回一个默认值。这种"查询-降级"模式保证了即使传入了未知的分类名,UI也能正常渲染而不崩溃。

三个函数的默认值各不相同:getCatColor默认返回水鸭绿主色,getCatBg默认返回浅绿背景色,getCatIcon默认返回收音机图标。这些默认值都与应用的整体海洋主题保持一致。

6.2 播放量格式化函数

function fmtPlay(n: number): string {
  if (n >= 10000) {
    return (n / 10000).toFixed(1) + '万';
  }
  return '' + n;
}

fmtPlay函数将播放量数值格式化为人类友好的显示文本。当数值达到或超过一万时,除以一万并保留一位小数,附加"万"字单位,如48210格式化为4.8万;否则将数值转换为字符串直接返回。这是中文语境下数字格式化的标准做法,既避免了长数字的视觉冗余,又保留了足够的精度信息。

6.3 分类过滤函数

function filterByCat(list: StationData[], cat: string): StationData[] {
  if (cat === '全部') {
    return list;
  }
  const r: StationData[] = [];
  for (let i = 0; i < list.length; i++) {
    if (list[i].cat === cat) {
      r.push(list[i]);
    }
  }
  return r;
}

filterByCat函数根据分类名过滤电台列表。当分类为"全部"时直接返回原始列表,否则遍历列表将匹配分类的项收集到新数组中返回。这个函数在发现页的分类筛选中被调用——用户点击不同分类标签时,catFilter状态变化触发build重新执行,filterByCat根据新的筛选条件返回过滤后的列表,ForEach据此重新渲染网格。

值得注意的是,该函数使用传统的for循环而非数组方法filter,这可能是出于ArkTS编译器对传统循环语法优化更好的考虑。同时,函数返回的是新数组而非修改原数组,符合不可变数据的函数式编程原则。

七、Tab枚举与入口组件

7.1 Tab枚举定义

enum TidalTab {
  RADIO = 0,
  DISCOVER = 1,
  SUBS = 2,
  SLEEP = 3,
  COMMUNITY = 4,
  ME = 5
}

TidalTab枚举定义了六个Tab页面的标识符,每个枚举成员对应一个整数值。使用枚举而非魔法数字(如直接用0-5)的好处在于:代码可读性大幅提升——TidalTab.RADIO0更能表达语义;IDE的自动补全和类型检查能防止输入错误;未来如果需要调整Tab顺序或新增Tab,只需修改枚举定义即可。

7.2 入口组件状态与内容区

@Entry
@Component
struct TidalApp {
  @State activeTab: TidalTab = TidalTab.RADIO

  @Builder contentArea() {
    Column() {
      if (this.activeTab === TidalTab.RADIO) {
        RadioContent()
      } else if (this.activeTab === TidalTab.DISCOVER) {
        DiscoverContent()
      } else if (this.activeTab === TidalTab.SUBS) {
        SubsContent()
      } else if (this.activeTab === TidalTab.SLEEP) {
        SleepContent()
      } else if (this.activeTab === TidalTab.COMMUNITY) {
        CommunityContent()
      } else {
        MeContent()
      }
    }
    .layoutWeight(1)
  }

TidalApp是整个应用的入口组件,使用@Entry@Component双装饰器标记。@Entry表示这是一个可被页面路由直接加载的入口组件,@Component声明它是一个拥有独立状态和build方法的UI组件。

@State activeTab是入口组件的核心状态变量,初始值为TidalTab.RADIO,即应用启动时默认展示电台页。当用户点击底部Tab栏时,activeTab的值被修改,触发contentArea构建器重新执行,通过if-else条件判断渲染对应的Tab内容组件。

contentArea是一个@Builder装饰的构建器函数,它封装了"根据当前Tab渲染对应内容"的逻辑。@Builder的作用类似于其他框架中的"渲染函数"或"局部组件模板",它不是独立的组件,而是宿主组件的一部分,可以直接访问宿主的this上下文。

Column是ArkTS中最基础的垂直线性布局容器,它将子组件按从上到下的顺序排列。这里Column包裹了六个条件分支的内容组件,并通过.layoutWeight(1)占据除底部Tab栏之外的所有剩余空间。layoutWeight是ArkTS弹性布局的关键属性,值为1表示"占据父容器中所有剩余的弹性空间"。

Column组件是ArkTS三大基础布局容器之一(另外两个是RowStack)。Column以垂直方向排列子元素,子元素默认在水平方向上居中对齐(可通过alignItems修改)。在移动应用开发中,Column是最常用的布局容器,因为手机屏幕的垂直方向空间通常比水平方向更充裕。Column配合Scroll组件可以实现任意长度的垂直滚动列表。

7.3 底部Tab栏构建器

  @Builder bottomTabItem(icon: string, label: string, tab: TidalTab) {
    Column() {
      Text(icon)
        .fontSize(20)
        .opacity(this.activeTab === tab ? 1.0 : 0.42)
        .scale({ x: this.activeTab === tab ? 1.16 : 1.0, y: this.activeTab === tab ? 1.16 : 1.0 })
        .animation({ duration: 200, curve: Curve.EaseOut })
      Text(label)
        .fontSize(9)
        .fontColor(this.activeTab === tab ? '#0E9384' : '#8AA8A3')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 6, bottom: 7 })
    .onClick(() => { this.activeTab = tab })
  }

bottomTabItem是一个参数化的@Builder,接收图标、标签文字和Tab枚举三个参数,构建一个底部Tab按钮。这个构建器的设计体现了ArkTS中"参数化UI片段复用"的核心能力——六个Tab按钮的结构完全一致,只是图标、文字和对应的Tab不同,通过参数化避免了六段重复代码。

在这个构建器中,Text(icon)渲染Emoji图标,其opacity(不透明度)和scale(缩放比例)都根据this.activeTab === tab的条件判断取不同值:选中状态下不透明度为1.0、缩放1.16倍,未选中状态下不透明度降至0.42、缩放为1.0。.animation属性为这些视觉变化添加了200毫秒的缓出动画,使Tab切换时的视觉过渡平滑自然。

Text(label)渲染Tab文字标签,选中时使用主色#0E9384和粗体,未选中时使用灰色#8AA8A3和常规字重。.onClick事件处理器将this.activeTab设置为当前Tab的值,触发状态变更和界面重新渲染。

Column容器设置了.layoutWeight(1)使六个Tab按钮均分底部栏的宽度,.alignItems(HorizontalAlign.Center)使图标和文字水平居中对齐。

7.4 入口组件的build方法

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.bottomTabItem('📻', '电台', TidalTab.RADIO)
        this.bottomTabItem('🧭', '发现', TidalTab.DISCOVER)
        this.bottomTabItem('📌', '订阅', TidalTab.SUBS)
        this.bottomTabItem('🌙', '睡眠', TidalTab.SLEEP)
        this.bottomTabItem('💬', '社区', TidalTab.COMMUNITY)
        this.bottomTabItem('👤', '我的', TidalTab.ME)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ top: 5, bottom: 7 })
      .shadow({ radius: 16, color: '#140B3B36', offsetY: -4 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#EEF6F4')
  }
}

入口组件的build方法是整个应用的渲染入口。它构建了一个Column容器,包含两部分:上方是contentArea()构建器调用渲染的当前Tab内容(占据弹性空间),下方是Row容器包裹的六个底部Tab按钮。

Row是ArkTS的水平线性布局容器,将六个bottomTabItem从左到右排列。它设置了白色背景、上下内边距,以及一个向上偏移4vp的阴影效果(shadow属性),营造出底部导航栏悬浮于内容之上的层次感。阴影颜色#140B3B36中的14是Alpha通道值(十六进制的20,即约12.5%不透明度),0B3B36是深绿色基底。

最外层的Column设置了width('100%')height('100%')以填满整个屏幕,背景色#EEF6F4是一种极浅的水鸭绿,作为应用的全局底色,营造清新海洋氛围。

RADIO

DISCOVER

SUBS

SLEEP

COMMUNITY

ME

用户点击底部Tab

onClick: this.activeTab = tab

@State activeTab 状态变更

框架检测到状态变化

重新执行 contentArea @Builder

if-else 条件判断

渲染 RadioContent

渲染 DiscoverContent

渲染 SubsContent

渲染 SleepContent

渲染 CommunityContent

渲染 MeContent

bottomTabItem 重新渲染
选中态视觉更新

动画过渡 200ms EaseOut

Row组件是ArkTS中与Column对应的水平线性布局容器。它将子组件按从左到右的顺序排列,子元素默认在垂直方向上居中对齐。Row常用于卡片内部的横向信息排列(如头像+文字+操作按钮的组合),以及水平滚动的标签栏等场景。当Row中的子元素总宽度超过容器宽度时,需要配合Scroll组件实现水平滚动。

八、公共头部组件

8.1 RadioHeader组件定义

@Component
struct RadioHeader {
  title: string = '潮汐电台';
  subtitle: string = '';
  freq: string = 'FM 87.6';
  onSearchTap: () => void = () => {};
  onBellTap: () => void = () => {};

  build() {
    Column() {

RadioHeader是一个可复用的公共头部组件,使用@Component装饰(注意没有@Entry,因为它不是入口组件,而是被其他组件引用的子组件)。它定义了五个属性:title是应用标题,subtitle是副标题信息,freq是频率显示,onSearchTaponBellTap是两个回调函数属性,分别用于搜索按钮和铃铛按钮的点击事件通知。

属性都有默认值,这意味着使用RadioHeader时可以不传任何参数,它会以默认配置渲染。onSearchTaponBellTap的默认值是空箭头函数() => {},这是一种防御性编程实践——即使调用者没有提供回调,点击事件也不会抛出"函数未定义"的异常。

在ArkTS的组件化开发中,组件通过声明公开的属性来定义其"接口契约"。父组件在创建子组件实例时通过参数传递数据(Props)和回调(Events),子组件内部通过this.访问这些值。这种模式实现了父子之间的松耦合通信:数据自上而下流动,事件自下而上通知。

8.2 头部主体布局

      Row() {
        Column() {
          Text('📻 ' + this.freq)
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
          Text(this.subtitle)
            .fontSize(8)
            .fontColor('#9BD4CC')
            .margin({ top: 1 })
        }
        .alignItems(HorizontalAlign.Start)
        .onClick(() => { this.onBellTap() })

头部主体使用Row容器进行水平布局。左侧是一个Column,包含两行文字:上方是频率信息(如"📻 FM 87.6"),白色粗体13号字;下方是副标题(如"正在直播 8 档节目 · 今日更新 24 集"),浅青色8号字。

Column设置了.alignItems(HorizontalAlign.Start)使两行文字左对齐(默认是居中对齐)。整个左侧区域绑定了onClick事件,点击时调用this.onBellTap()——这是一个有意的设计,使用户点击头部左上角区域也能触发铃铛回调,增大了可点击区域。

8.3 搜索栏与铃铛按钮

        Row() {
          Text('🔍')
            .fontSize(13)
          Text('搜索电台 / 播客 / 单集')
            .fontSize(11)
            .fontColor('#7FAAA4')
            .margin({ left: 6 })
        }
        .layoutWeight(1)
        .height(34)
        .backgroundColor('#0B4E46')
        .borderRadius(17)
        .margin({ left: 12 })
        .onClick(() => { this.onSearchTap() })

        Text('🔔')
          .fontSize(18)
          .margin({ left: 12 })
          .onClick(() => { this.onBellTap() })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10 })

搜索栏是一个嵌套的Row,包含搜索图标和占位提示文字,深绿色背景#0B4E46,圆角17vp(正好是高度34vp的一半,形成完全的胶囊形)。它使用.layoutWeight(1)占据左侧频率信息和右侧铃铛之间的所有空间。

铃铛按钮是一个简单的Text,18号字,左侧外边距12vp。三个元素(频率信息、搜索栏、铃铛)通过Row水平排列,形成经典的"左-中-右"三段式头部布局。

8.4 快捷分类条

      Row() {
        ForEach(CAT_LIST, (c: string) => {
          Text(c)
            .fontSize(10)
            .fontColor(c === '全部' ? '#0B3B36' : '#BFEBE4')
            .backgroundColor(c === '全部' ? '#7BE0D2' : '#12564E')
            .borderRadius(10)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .margin({ right: 6 })
        }, (c: string) => 'hd' + c)
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10, bottom: 12 })
    }
    .width('100%')
    .linearGradient({
      angle: 135,
      colors: [['#0F766E', 0], ['#0B4E46', 1]]
    })
    .alignItems(HorizontalAlign.Start)
  }
}

头部底部是快捷分类条,使用Row配合ForEach渲染CAT_LIST中的八个分类标签。每个标签是一个Text组件,"全部"标签使用亮色背景#7BE0D2和深色文字#0B3B36以突出显示,其他分类使用深色背景#12564E和浅色文字#BFEBE4

ForEach的第三个参数是键值生成器(c: string) => 'hd' + c,它为每个渲染项生成唯一标识符。ArkTS的ForEach通过这个键值来进行diff优化——当数据源变化时,框架通过比较键值来决定哪些项需要新增、删除或更新,而非简单地进行全量重渲染。键值的唯一性是性能优化的关键,这里使用'hd'前缀加上分类名保证了全局唯一性。

整个头部组件最外层的Column使用了linearGradient线性渐变背景,135度角从#0F766E(深青绿)过渡到#0B4E46(更深的水鸭绿),营造出深邃的海洋质感。

ForEach是ArkTS中用于循环渲染列表数据的核心组件。它的签名是ForEach(arr, itemGenerator, keyGenerator):第一个参数是数据数组,第二个是单项渲染函数,第三个是键值生成函数。ForEach在ArkTS中的地位类似于React中的Array.map()或Vue中的v-for,但它内置了diff算法,能够根据键值高效地更新DOM。正确使用ForEach的键值生成器,是保证长列表渲染性能的关键。

linearGradient是ArkTS提供的线性渐变背景属性。它接收一个对象参数,包含angle(渐变角度)和colors(颜色断点数组)。每个颜色断点是一个[color, position]元组,position是0到1之间的浮点数,表示该颜色在渐变路径上的位置。通过组合多个颜色断点,可以创建复杂的渐变效果。

九、Tab1:电台页深度解析

9.1 电台页状态定义

@Component
struct RadioContent {
  @State showStationModal: boolean = false
  @State showEpisodeModal: boolean = false
  @State showTimerModal: boolean = false
  @State showShareModal: boolean = false
  @State selectedStation: StationData | null = null
  @State selectedEpisode: EpisodeData | null = null
  @State discAngle: number = 0
  @State playing: boolean = true
  @State playProgress: number = 0.42
  @State timerIndex: number = 2
  @State speedIndex: number = 1

RadioContent是电台Tab的内容组件,拥有丰富的状态变量。前四个@State变量(showStationModalshowEpisodeModalshowTimerModalshowShareModal)分别控制四个弹窗的显示与隐藏,这种"一个布尔值控制一个弹窗"的模式简洁直观。

selectedStationselectedEpisode是联合类型(Union Type)的状态变量,类型为StationData | nullEpisodeData | null,初始值为null。当用户点击某个电台卡片或单集行时,对应的数据实例被赋值到这些变量中,弹窗通过读取这些变量渲染详情。使用null作为初始值表示"未选中任何项",这是一个语义清晰的可空状态设计。

discAngle控制迷你播放条中唱片图标的旋转角度,每次点击增加45度。playing是播放/暂停状态。playProgress是播放进度(0-1之间的浮点数),初始值0.42表示42%进度。timerIndexspeedIndex是定时和倍速选项的当前选中索引。

@State是ArkTS中最核心的状态管理装饰器。被@State标注的变量成为"可观察状态"——当其值发生变化时,ArkTS框架会自动检测哪些UI片段依赖了这个变量,并精确地重新渲染这些片段。这种"状态驱动渲染"的模式是声明式UI的精髓。需要注意的是,@State变量只能在组件内部声明和使用,如果需要在父子组件间共享状态,需要使用@Prop@Link@Provide/@Consume等装饰器。

9.2 弹窗遮罩构建器

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(9,45,40,0.68)')
      .onClick(onClose)
  }

modalOverlay是一个可复用的弹窗遮罩构建器,接收一个onClose回调函数参数。它渲染一个占满全屏的Column,背景色为半透明深绿色rgba(9,45,40,0.68)(68%不透明度),点击时调用onClose回调关闭弹窗。

这个构建器在四个弹窗中被复用,避免了重复编写遮罩代码。参数化的onClose回调使得每个弹窗可以传入自己的关闭逻辑(将对应的show*Modal状态设为false),实现了"同一遮罩、不同关闭行为"的灵活复用。

9.3 电台详情弹窗

  @Builder stationDetailModal() {
    Column() {
      this.modalOverlay(() => { this.showStationModal = false })
      Column() {
        Row() {
          Column() {
            Text(getCatIcon(this.selectedStation?.cat ?? '生活'))
              .fontSize(36)
          }
          .width(70)
          .height(70)
          .backgroundColor(getCatBg(this.selectedStation?.cat ?? '生活'))
          .borderRadius(18)
          .alignItems(HorizontalAlign.Center)
          .justifyContent(FlexAlign.Center)

stationDetailModal是电台详情弹窗的构建器。它首先调用modalOverlay渲染遮罩层,然后在遮罩层之上渲染一个白色卡片的Column

卡片顶部是一个Row,左侧是一个70x70vp的圆角图标容器,使用getCatIcongetCatBg函数根据选中电台的分类获取对应的图标和背景色。这里使用了可选链操作符?.和空值合并操作符??this.selectedStation?.cat ?? '生活'的含义是——如果selectedStation不为null,则取其cat属性;如果为null(虽然在实际使用中弹窗打开时必定不为null),则降级为默认分类’生活’。

图标容器使用了.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)双重居中设置。alignItems控制子元素在交叉轴(水平方向)上的对齐方式,justifyContent在ArkTS的Column中控制子元素在主轴(垂直方向)上的对齐方式,两者结合实现了图标的完全居中。

Stack是ArkTS的第三大基础布局容器,它以层叠方式排列子组件——后声明的子组件覆盖在先声明的子组件之上。Stack在弹窗系统中极为重要:弹窗的遮罩层和内容卡片就是典型的层叠关系——遮罩在下,卡片在上。本应用中每个Tab页的build方法都使用了Stack作为根容器,使得弹窗可以覆盖在页面内容之上。

9.4 电台详情信息行

          Column() {
            Text(this.selectedStation?.name ?? '')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#10312D')
            Text(this.selectedStation?.freq ?? '' + ' · ' + (this.selectedStation?.cat ?? ''))
              .fontSize(10)
              .fontColor('#6B8B86')
              .margin({ top: 4 })
            Row() {
              Text('🎙️ ' + (this.selectedStation?.host ?? ''))
                .fontSize(10)
                .fontColor('#0E9384')
              Text('▸ ' + fmtPlay(this.selectedStation?.listeners ?? 0) + '人在线')
                .fontSize(10)
                .fontColor('#CA8A04')
                .margin({ left: 10 })
            }
            .margin({ top: 6 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

图标右侧是一个Column信息区,包含三行内容:电台名称(17号粗体深色字)、频率与分类信息(10号灰色字)、主播名与在线人数(10号,主播名用主色、人数用琥珀色)。

这个Column使用.layoutWeight(1)占据图标右侧的所有剩余空间,.alignItems(HorizontalAlign.Start)使文字左对齐。外层的Row使用.alignItems(VerticalAlign.Center)使图标和信息区在垂直方向上居中对齐。

fmtPlay函数在这里被调用,将listeners数值(如48210)格式化为"4.8万"的显示文本。字符串拼接中使用了??操作符提供默认值,确保即使selectedStationnull也不会导致运行时错误。

9.5 电台统计三卡

        Row() {
          Column() {
            Text('' + Math.floor((this.selectedStation?.listeners ?? 0) / 40))
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0E9384')
            Text('单集总数')
              .fontSize(9)
              .fontColor('#6B8B86')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

电台详情弹窗中有一个三列统计区,展示"单集总数"、"今日新增"和"当前状态"三个指标。第一个指标通过Math.floor(listeners / 40)计算单集总数——这是一个Mock计算逻辑,用收听人数除以40取整作为单集数量的估算值。Math.floor是JavaScript/TypeScript标准的向下取整函数。

三个Column统计卡都使用.layoutWeight(1)均分宽度,.alignItems(HorizontalAlign.Center)使数值和标签居中对齐。数值使用16号粗体字,标签使用9号灰色字。三个卡片的数值颜色不同:单集总数用水鸭绿、今日新增用琥珀色、当前状态根据live字段动态选择粉红(直播中)或灰色(轮播)。

弹窗交互流程

用户点击电台卡片

selectedStation = s
showStationModal = true

Stack检测到条件渲染

stationDetailModal 构建

modalOverlay 渲染遮罩

详情卡片渲染
图标+信息+统计+按钮

用户点击'立即收听'

showStationModal = false
弹窗关闭

用户点击'分享'

showStationModal = false
showShareModal = true

切换到分享弹窗

用户点击遮罩

9.6 弹窗操作按钮行

        Row() {
          Text('立即收听')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .backgroundColor('#0E9384')
            .borderRadius(20)
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .onClick(() => { this.showStationModal = false })

          Text('订阅更新')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0E9384')
            .backgroundColor('#DCEEEA')
            .borderRadius(20)
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showStationModal = false })

          Text('分享')
            .fontSize(13)
            .fontColor('#6B8B86')
            .backgroundColor('#F0F5F3')
            .borderRadius(20)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => {
              this.showStationModal = false
              this.showShareModal = true
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .margin({ top: 18 })

操作按钮行包含三个按钮:“立即收听”(主色填充,白色文字)、“订阅更新”(浅色背景,主色文字)、“分享”(更浅的背景,灰色文字)。这种"主-次-辅"的按钮视觉层次设计是移动端UI的常见模式:主操作使用高对比度的填充色,次操作使用低对比度的描边或浅色填充,辅助操作则进一步弱化。

"分享"按钮的onClick事件中连续设置了两个状态变量——先关闭当前弹窗(showStationModal = false),再打开分享弹窗(showShareModal = true)。这种"关一开一"的弹窗切换模式在应用中被广泛使用,实现了弹窗之间的级联导航。

Row设置了.justifyContent(FlexAlign.Center)使三个按钮在水平方向上居中排列。FlexAlign是ArkTS弹性布局的对齐枚举,Center表示主轴方向居中。

9.7 弹窗定位与层级

      }
      .width('86%')
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
      .padding(18)
      .alignItems(HorizontalAlign.Start)
      .position({ x: '7%', y: '18%' })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
  }

详情卡片的Column设置了width('86%')(占屏幕宽度的86%),白色背景,20vp圆角,18vp内边距。.position({ x: '7%', y: '18%' })使用绝对定位将卡片放置在屏幕水平7%、垂直18%的位置,使卡片在屏幕中偏上居中。

最外层的Column设置了width('100%')height('100%')占满全屏,.position({ x: 0, y: 0 })定位到屏幕左上角,.zIndex(999)设置极高的层级确保弹窗覆盖在所有内容之上。

zIndex是ArkTS中控制组件层叠顺序的属性,值越大越靠上。这里使用999确保弹窗不会被后续渲染的其他元素遮挡。这种通过zIndexposition实现弹窗层叠的方式,是ArkTS在没有专用Dialog API时的常见做法。

9.8 单集详情底部抽屉弹窗

  @Builder episodeSheetModal() {
    Column() {
      this.modalOverlay(() => { this.showEpisodeModal = false })
      Column() {
        Row() {
          Column() {
            Text('—')
              .fontSize(4)
              .fontColor('#D5E8E4')
          }
          .width(44)
          .height(4)
          .borderRadius(2)
          .backgroundColor('#D5E8E4')
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .margin({ top: 10 })
        .onClick(() => { this.showEpisodeModal = false })

episodeSheetModal是单集详情弹窗,采用底部抽屉式布局。弹窗顶部是一个44x4vp的圆角拖拽指示条(俗称"小辫子"),点击可关闭弹窗。这是移动端底部弹窗的标准交互模式,用户可以通过点击指示条或遮罩来关闭弹窗。

9.9 静态波形条与进度条

        Row() {
          ForEach(WEEK_LISTEN, (v: number, i: number) => {
            Column()
              .width(5)
              .height((v / 160 * 34).toFixed(0) + 'vp')
              .borderRadius(3)
              .backgroundColor(i < 4 ? '#0E9384' : '#CBE5DF')
              .margin({ left: 3, right: 3 })
          }, (v: number, i: number) => 'wv' + i)
        }
        .justifyContent(FlexAlign.Center)
        .margin({ top: 20 })

波形条使用ForEach遍历WEEK_LISTEN数组渲染七个垂直柱条。每个柱条的宽度固定为5vp,高度通过(v / 160 * 34).toFixed(0) + 'vp'计算——将收听时长数值映射到0-34vp的像素高度范围。toFixed(0)将浮点结果取整并转为字符串,再拼接'vp'单位后缀。

柱条颜色根据索引i决定:前四个(周一至周四)使用主色#0E9384,后三个(周五至日)使用浅色#CBE5DF,形成视觉分组效果。ForEach的键值生成器(v, i) => 'wv' + i使用索引作为键值,因为这里的数据是常量不会变化,用索引做键值是安全的。

vp(virtual pixel)是ArkTS中的虚拟像素单位,类似于Web开发中的dp(density-independent pixel)。vp会根据屏幕的像素密度自动缩放,确保在不同分辨率设备上呈现一致的物理尺寸。在ArkTS中,vp是默认的长度单位,大多数尺寸属性可以直接使用数字(隐式vp)或字符串'数字vp'

9.10 播放控制与倍速选择

        Row() {
          Text('⏮')
            .fontSize(22)
            .fontColor('#3E5C57')
          Text(this.playing ? '⏸' : '▶️')
            .fontSize(30)
            .margin({ left: 34, right: 34 })
            .scale({ x: this.playing ? 1.0 : 0.9, y: this.playing ? 1.0 : 0.9 })
            .animation({ duration: 180, curve: Curve.EaseOut })
            .onClick(() => { this.playing = !this.playing })
          Text('⏭')
            .fontSize(22)
            .fontColor('#3E5C57')
        }
        .justifyContent(FlexAlign.Center)
        .margin({ top: 22 })

播放控制行包含三个按钮:上一曲、播放/暂停、下一曲。中间的播放/暂停按钮根据this.playing状态显示不同图标(暂停或播放),并配合0.9倍的缩放动画——播放时正常大小,暂停时略微缩小,通过.animation添加180毫秒的缓出动画过渡。

        Row() {
          ForEach(SPEED_OPTIONS, (s: string, i: number) => {
            Text(s)
              .fontSize(11)
              .fontColor(this.speedIndex === i ? '#0B3B36' : '#6B8B86')
              .backgroundColor(this.speedIndex === i ? '#7BE0D2' : '#F0F5F3')
              .borderRadius(12)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .margin({ left: 4, right: 4 })
              .onClick(() => { this.speedIndex = i })
          }, (s: string, i: number) => 'sp' + i)
        }
        .justifyContent(FlexAlign.Center)
        .margin({ top: 18 })

倍速选择行使用ForEach渲染SPEED_OPTIONS数组中的四个倍速选项。选中的选项使用亮色背景和深色文字,未选中使用浅灰背景和灰色文字。点击时更新speedIndex状态,触发选中态的视觉切换。

9.11 定时关闭弹窗

  @Builder timerModal() {
    Column() {
      this.modalOverlay(() => { this.showTimerModal = false })
      Column() {
        Row() {
          Text('⏲ 定时关闭')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#10312D')
          Text('✕')
            .fontSize(15)
            .fontColor('#8AA8A3')
            .margin({ left: 12 })
            .onClick(() => { this.showTimerModal = false })
        }
        .width('100%')
        .padding({ left: 18, right: 18, top: 16 })

timerModal是定时关闭弹窗,采用居中小卡片布局。标题行使用Row水平排列标题文字和关闭按钮,关闭按钮位于右侧,点击时将showTimerModal设为false关闭弹窗。

        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(TIMER_OPTIONS, (t: number, i: number) => {
            Column() {
              Text('' + t)
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.timerIndex === i ? '#0E9384' : '#3E5C57')
              Text('分钟')
                .fontSize(9)
                .fontColor('#8AA8A3')
                .margin({ top: 2 })
            }
            .width('28%')
            .padding({ top: 12, bottom: 12 })
            .backgroundColor(this.timerIndex === i ? '#DCEEEA' : '#F5FAF8')
            .borderRadius(14)
            .margin({ left: '2.5%', top: 8 })
            .scale({ x: this.timerIndex === i ? 1.04 : 1.0, y: this.timerIndex === i ? 1.04 : 1.0 })
            .animation({ duration: 160, curve: Curve.EaseOut })
            .onClick(() => { this.timerIndex = i })
          }, (t: number, i: number) => 'tm' + i)
        }

定时选项使用Flex弹性布局配合FlexWrap.Wrap实现自动换行的网格。Flex是ArkTS中比RowColumn更强大的弹性布局容器,它支持wrap参数控制子元素是否自动换行。这里六个定时选项每个宽度28%,加上2.5%的左外边距,每行可以排列三个,共两行。

每个选项卡显示分钟数值和"分钟"单位,选中时使用浅绿背景#DCEEEA和主色文字,并配合1.04倍的微缩放动画增强选中反馈。

Flex是ArkTS中最灵活的布局容器,它综合了RowColumn的能力,并支持子元素换行。Flexwrap参数取FlexWrap.Wrap时启用自动换行,取FlexWrap.NoWrap时不换行。在需要网格布局(如勋章墙、白噪音宫格、定时选项网格)的场景中,Flex配合百分比宽度是最常用的实现方式。

9.12 分享弹窗

  @Builder shareModal() {
    Column() {
      this.modalOverlay(() => { this.showShareModal = false })
      Column() {
        Text('分享到')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#10312D')
          .margin({ top: 18 })

        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(['微信好友', '朋友圈', 'QQ', '微博', '复制链接', '生成海报'], (s: string, i: number) => {
            Column() {
              Text('🔗')
                .fontSize(22)
              Text(s)
                .fontSize(10)
                .fontColor('#3E5C57')
                .margin({ top: 5 })
            }
            .width('26%')
            .padding({ top: 14, bottom: 14 })
            .backgroundColor('#F5FAF8')
            .borderRadius(14)
            .margin({ left: '2.3%', top: 10 })
            .onClick(() => { this.showShareModal = false })
          }, (s: string, i: number) => 'sh' + i)
        }

shareModal是分享弹窗,采用底部宫格式布局。六个分享渠道通过ForEach渲染为3x2的网格(每个宽度26%),每个渠道显示一个链接图标和渠道名称。点击任意渠道后关闭弹窗。

9.13 电台卡片构建器

  @Builder stationCard(s: StationData) {
    Column() {
      Row() {
        Text(getCatIcon(s.cat))
          .fontSize(26)
        if (s.live) {
          Text('LIVE')
            .fontSize(7)
            .fontColor('#FFFFFF')
            .backgroundColor('#DB2777')
            .borderRadius(6)
            .padding({ left: 5, right: 5, top: 2, bottom: 2 })
            .margin({ left: 6 })
        }
      }
      .width('100%')

stationCard是电台卡片的构建器,接收一个StationData参数。卡片顶部是一个Row,包含分类图标和条件渲染的"LIVE"标签。if (s.live)是ArkTS中的条件渲染语法,当livetrue时渲染粉红色的"LIVE"标签,为false时不渲染任何内容。

这种条件渲染能力是声明式UI的核心特性——开发者只需声明"在什么条件下显示什么",框架负责在条件变化时自动添加或移除对应的DOM节点。

      Text(s.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#10312D')
        .margin({ top: 8 })
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

电台名称设置了.maxLines(1)限制为单行显示,.textOverflow({ overflow: TextOverflow.Ellipsis })指定超出部分以省略号截断。这是移动端文本显示的标准处理——卡片宽度有限,过长的名称需要优雅地截断而非溢出。

      Row() {
        Text('▸ ' + fmtPlay(s.listeners))
          .fontSize(9)
          .fontColor('#0E9384')
        Text('收听')
          .fontSize(9)
          .fontColor('#FFFFFF')
          .backgroundColor('#0E9384')
          .borderRadius(10)
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .margin({ left: 6 })
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width(136)
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Start)
    .margin({ left: 6, right: 6 })
    .onClick(() => {
      this.selectedStation = s
      this.showStationModal = true
    })
  }

卡片底部是一个Row,包含收听人数(使用fmtPlay格式化)和"收听"按钮。整个卡片宽度固定为136vp,白色背景,16vp圆角,内容左对齐。

卡片的onClick事件设置了两个状态变量——将点击的电台数据赋值给selectedStation,并将showStationModal设为true,这两个状态变更会触发弹窗的渲染。这种"点击卡片→设置选中数据→打开弹窗"是列表交互的标准模式。

9.14 单集行构建器

  @Builder episodeRow(e: EpisodeData) {
    Row() {
      Column() {
        Text(getCatIcon('生活'))
          .fontSize(20)
      }
      .width(44)
      .height(44)
      .backgroundColor('#DCEEEA')
      .borderRadius(12)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)

      Column() {
        Text(e.title)
          .fontSize(13)
          .fontWeight(FontWeight.Medium)
          .fontColor('#10312D')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(e.station + ' · ' + e.host + ' · ' + e.date)
          .fontSize(9)
          .fontColor('#6B8B86')
          .margin({ top: 4 })

episodeRow是单集列表项的构建器,采用横向布局:左侧44x44vp的圆角图标容器,右侧信息区。信息区包含单集标题(13号中等字重,单行省略截断)、电台与主播与日期信息(9号灰色字,用·分隔)。

        Row() {
          Text(e.tag)
            .fontSize(8)
            .fontColor(getCatColor('音乐'))
            .backgroundColor(getCatBg('音乐'))
            .borderRadius(8)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
          Text('▶ ' + fmtPlay(e.plays) + ' · ' + e.duration + '分钟')
            .fontSize(8)
            .fontColor('#8AA8A3')
            .margin({ left: 8 })
        }
        .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 12 })

      Text('▶')
        .fontSize(16)
        .fontColor('#FFFFFF')
        .backgroundColor('#0E9384')
        .width(34)
        .height(34)
        .borderRadius(17)
        .textAlign(TextAlign.Center)
        .onClick(() => {
          this.selectedEpisode = e
          this.showEpisodeModal = true
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .margin({ top: 10 })
    .onClick(() => {
      this.selectedEpisode = e
      this.showEpisodeModal = true
    })
  }

信息区底部是一个Row,包含内容标签(使用音乐分类的配色)和播放量与时长信息。右侧是一个34x34vp的圆形播放按钮,绿色背景,白色播放图标。整个列表项的onClick和播放按钮的onClick都执行相同的操作——设置选中单集数据并打开单集详情弹窗。

9.15 电台页主体布局

  build() {
    Stack() {
      Column() {
        RadioHeader({
          title: '潮汐电台',
          subtitle: '正在直播 8 档节目 · 今日更新 24 集',
          freq: 'FM 87.6',
          onSearchTap: () => { this.showShareModal = true },
          onBellTap: () => { this.showTimerModal = true }
        })

RadioContentbuild方法以Stack为根容器。Stack内首先是一个Column承载页面主体内容,然后是四个条件渲染的弹窗。

页面主体顶部是RadioHeader公共头部组件,通过参数传递了标题、副标题、频率,以及两个回调函数。这里有一个有趣的设计选择:搜索按钮的回调onSearchTap实际打开的是分享弹窗而非搜索界面,铃铛按钮的回调onBellTap打开的是定时关闭弹窗。这种"复用已有弹窗"的做法在原型阶段是常见的权宜之计。

Stack布局容器的层叠特性是弹窗系统的基础。在Stack中,后声明的子组件覆盖在先声明的子组件之上。本应用中,页面主体内容最先声明(在最底层),弹窗根据条件在后声明(在上层),遮罩层又在弹窗内容之上。通过zIndex属性可以进一步精确控制层叠顺序。

9.16 直播横滑区

        Scroll() {
          Column() {
            Row() {
              Text('🎙️ 正在直播')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#10312D')
              Text('全部 8 档 >')
                .fontSize(10)
                .fontColor('#8AA8A3')
                .margin({ left: 12 })
                .onClick(() => { this.showStationModal = true })
            }
            .width('100%')
            .padding({ left: 14, right: 14, top: 14 })

            Scroll() {
              Row() {
                ForEach(mockStations, (s: StationData) => {
                  this.stationCard(s)
                }, (s: StationData) => 'sc' + s.id)
              }
              .padding({ left: 8, right: 8 })
            }
            .scrollable(ScrollDirection.Horizontal)
            .scrollBar(BarState.Off)
            .width('100%')
            .margin({ top: 10 })

直播横滑区包含标题行和水平滚动的电台卡片列表。标题行使用Row排列"正在直播"标题和"全部"链接。卡片列表使用嵌套的Scroll组件实现水平滚动——.scrollable(ScrollDirection.Horizontal)指定水平滚动方向,.scrollBar(BarState.Off)隐藏滚动条。

ForEach遍历mockStations数组渲染电台卡片,键值生成器(s) => 'sc' + s.id使用电台ID作为唯一标识。16张卡片横向排列,用户可以左右滑动浏览。

Scroll是ArkTS的滚动容器组件,它为其子内容提供滚动能力。Scrollscrollable属性指定滚动方向(Horizontal水平、Vertical垂直),scrollBar属性控制滚动条的显示状态(On显示、Off隐藏、Auto自动)。在本应用中,外层垂直Scroll配合内层水平Scroll实现了经典的"纵可滚、横也可滚"的内容浏览体验。

9.17 本周节目单列表

            Column() {
              ForEach(mockEpisodes, (e: EpisodeData) => {
                this.episodeRow(e)
              }, (e: EpisodeData) => 'ep' + e.id)
            }
            .width('100%')
            .padding({ left: 14, right: 14 })

            Text('— 已经到底啦,明天见 —')
              .fontSize(10)
              .fontColor('#A7C2BD')
              .margin({ top: 20, bottom: 90 })
          }
          .width('100%')
        }
        .scrollBar(BarState.Off)
        .layoutWeight(1)

本周节目单使用ForEach遍历mockEpisodes数组渲染24个单集行。列表底部显示"已经到底啦,明天见"的结束提示文字,底部外边距90vp为迷你播放条留出空间。

整个内容区域包裹在一个垂直Scroll中,.layoutWeight(1)占据头部和播放条之间的所有空间。滚动条被隐藏以保持界面整洁。

9.18 迷你播放条

        Row() {
          Text('💿')
            .fontSize(24)
            .rotate({ angle: this.discAngle })
            .animation({ duration: 700, curve: Curve.Linear })
            .onClick(() => { this.discAngle += 45 })

          Column() {
            Text('蓝调午夜场 · Vol.128 深夜的便利店')
              .fontSize(11)
              .fontWeight(FontWeight.Medium)
              .fontColor('#10312D')
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })

迷你播放条固定在页面底部(在Column中位于Scroll之后),包含旋转的唱片图标、节目信息和播放控制。唱片图标使用.rotate({ angle: this.discAngle })实现旋转效果,每次点击增加45度,配合700毫秒的线性动画。Curve.Linear表示匀速旋转,模拟唱片的物理旋转效果。

            Row() {
              Column() {
                Column()
                  .height(3)
                  .backgroundColor('#7BE0D2')
                  .borderRadius(2)
                  .width((this.playProgress * 100).toFixed(0) + '%')
              }
              .width('82%')
              .height(3)
              .backgroundColor('#DCEEEA')
              .borderRadius(2)
              .margin({ top: 6 })
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })

          Text('⏲')
            .fontSize(20)
            .onClick(() => { this.showTimerModal = true })

          Text(this.playing ? '⏸' : '▶')
            .fontSize(22)
            .margin({ left: 16 })
            .scale({ x: this.playing ? 1.0 : 0.86, y: this.playing ? 1.0 : 0.86 })
            .animation({ duration: 160, curve: Curve.EaseOut })
            .onClick(() => { this.playing = !this.playing })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10, bottom: 12 })
        .backgroundColor('#FFFFFF')
        .shadow({ radius: 14, color: '#140B3B36', offsetY: -3 })

播放进度条使用嵌套的Column实现——外层Column宽度82%、高度3vp、浅绿背景作为轨道,内层Column宽度为playProgress * 100的百分比、主色背景作为已播放部分。这种"轨道+填充"的双层结构是进度条的经典实现方式。

播放条右侧有定时和播放/暂停两个按钮。播放按钮的图标根据playing状态切换,并配合0.86倍的缩放动画。整个播放条设置了白色背景和向上偏移的阴影,与上方内容形成视觉分隔。

9.19 弹窗的条件渲染

      if (this.showStationModal) {
        this.stationDetailModal()
      }
      if (this.showEpisodeModal) {
        this.episodeSheetModal()
      }
      if (this.showTimerModal) {
        this.timerModal()
      }
      if (this.showShareModal) {
        this.shareModal()
      }
    }
    .width('100%')
    .height('100%')
  }
}

Stack的末尾,四个弹窗通过if条件语句根据各自的show*Modal状态决定是否渲染。当某个状态变量为true时,对应的弹窗构建器被调用并渲染在Stack的最上层;当变为false时,弹窗从Stack中移除。

这种条件渲染弹窗的模式简洁有效,但也有局限——同一时间只能显示一个弹窗(虽然四个if是独立的,但视觉上后渲染的弹窗会覆盖先渲染的)。在实际使用中,应用通过"关一开一"的弹窗切换逻辑确保了同一时间只有一个弹窗可见。

弹窗状态联动

电台页Stack层级

false→true

false→true

false→true

false→true

Layer 1: 页面主体 Column

Layer 2: 电台详情弹窗 if showStationModal

Layer 3: 单集详情弹窗 if showEpisodeModal

Layer 4: 定时关闭弹窗 if showTimerModal

Layer 5: 分享弹窗 if showShareModal

showStationModal

showEpisodeModal

showTimerModal

showShareModal

十、Tab2:发现页深度解析

10.1 发现页状态定义

@Component
struct DiscoverContent {
  @State catFilter: string = '全部'
  @State showCreateModal: boolean = false
  @State showStarModal: boolean = false
  @State selectedStar: StarRankData | null = null
  @State playlistName: string = ''
  @State playlistDesc: string = ''
  @State playlistCat: number = 0

DiscoverContent是发现Tab的内容组件。catFilter是当前分类筛选条件,初始为"全部"(显示所有分类)。showCreateModalshowStarModal分别控制新建播单弹窗和新星榜详情弹窗。selectedStar存储当前选中的排行榜条目。playlistNameplaylistDescplaylistCat是新建播单表单的三个输入状态。

10.2 紧凑头部与分类筛选

        Row() {
          Text('🧭 发现')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#10312D')
          Text('+ 新建播单')
            .fontSize(11)
            .fontColor('#FFFFFF')
            .backgroundColor('#0E9384')
            .borderRadius(16)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .margin({ left: 12 })
            .onClick(() => { this.showCreateModal = true })
          Text('🔔 消息')
            .fontSize(11)
            .fontColor('#6B8B86')
            .margin({ left: 12 })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 14, bottom: 10 })

发现页的头部与电台页不同——它使用紧凑的Row布局而非渐变背景的RadioHeader,包含"发现"标题、"新建播单"按钮和"消息"链接。这种"不同页面使用不同头部设计"的选择,体现了内容优先级驱动的UI差异化策略。

            Scroll() {
              Row() {
                ForEach(CAT_LIST, (c: string) => {
                  Text(c)
                    .fontSize(11)
                    .fontColor(this.catFilter === c ? '#FFFFFF' : '#3E5C57')
                    .backgroundColor(this.catFilter === c ? '#0E9384' : '#FFFFFF')
                    .borderRadius(15)
                    .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                    .margin({ left: 6 })
                    .onClick(() => { this.catFilter = c })
                }, (c: string) => 'cf' + c)
              }
              .padding({ left: 8, right: 8 })
            }
            .scrollable(ScrollDirection.Horizontal)
            .scrollBar(BarState.Off)

分类筛选条使用水平Scroll配合ForEach渲染八个分类标签。选中的分类使用主色填充背景和白色文字,未选中使用白色背景和深色文字。点击时更新catFilter状态,触发下方播客网格的重新过滤和渲染。

10.3 播客双列网格

            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(filterByCat(mockStations, this.catFilter), (s: StationData) => {
                Column() {
                  Row() {
                    Text(getCatIcon(s.cat))
                      .fontSize(28)
                    if (s.live) {
                      Text('直播中')
                        .fontSize(7)
                        .fontColor('#FFFFFF')
                        .backgroundColor('#DB2777')
                        .borderRadius(6)
                        .padding({ left: 5, right: 5, top: 2, bottom: 2 })
                        .margin({ left: 6 })
                    }
                  }
                  .width('100%')

播客网格使用Flex弹性布局配合FlexWrap.Wrap实现双列网格——每个卡片宽度46%,加上2.5%的左外边距,每行排列两个。ForEach的数据源是filterByCat(mockStations, this.catFilter)的返回值——当catFilter变化时,过滤结果变化,网格内容自动更新。

每个卡片包含分类图标、直播标签(条件渲染)、电台名称、简介、主播名和收听人数。卡片点击时将catFilter设为该电台的分类,实现"点击卡片即筛选同分类"的快捷操作。

10.4 分类收听占比图

            Column() {
              ForEach(CAT_SHARE, (e: ShareEntry) => {
                Row() {
                  Text(e.name)
                    .fontSize(11)
                    .fontColor('#3E5C57')
                    .width(40)
                  Column() {
                    Column()
                      .height(8)
                      .borderRadius(4)
                      .backgroundColor(e.color)
                      .width((e.pct * 3.2).toFixed(0) + '%')
                  }
                  .layoutWeight(1)
                  .height(8)
                  .backgroundColor('#EAF4F1')
                  .borderRadius(4)
                  .margin({ left: 10, right: 10 })

                  Text(e.pct + '%')
                    .fontSize(10)
                    .fontColor(e.color)
                    .fontWeight(FontWeight.Medium)
                }
                .width('100%')
                .margin({ top: 10 })
              }, (e: ShareEntry) => 'cs' + e.name)
            }

分类收听占比图使用ForEach遍历CAT_SHARE数组渲染水平条形图。每行包含分类名称(固定40vp宽)、进度条(layoutWeight(1)弹性宽度)和百分比文字。进度条使用嵌套Column结构——外层是浅色轨道,内层宽度为e.pct * 3.2的百分比、使用分类对应颜色的填充条。

由于最大百分比值是28(生活类),pct * 3.2将28映射为89.6%,使最大条接近轨道满宽,视觉效果合理。这种"数据值映射到像素宽度"的编码方式,是纯代码实现简单图表的通用手法。

10.5 新星榜列表

            Column() {
              ForEach(mockStarRank, (r: StarRankData) => {
                Row() {
                  Text('' + r.rank)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(r.rank <= 3 ? '#CA8A04' : '#8AA8A3')
                    .width(28)

新星榜使用ForEach渲染mockStarRank中的八条排行数据。每行包含排名数字(前三名使用琥珀色突出)、播客名称与主播信息、热度进度条和排名变动标记。

排名变动标记r.delta使用字符串包含"↓"的判断来决定颜色——包含下降箭头使用灰色,否则(上升或持平)使用粉红色。这是一种简单的文本内容驱动的条件样式逻辑。

10.6 新建播单弹窗

  @Builder createPlaylistModal() {
    Column() {
      this.modalOverlay(() => { this.showCreateModal = false })
      Column() {
        Text('🎵 新建播单')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#10312D')
          .width('100%')
          .padding({ top: 18, left: 18 })

        Text('给它起个名字')
          .fontSize(10)
          .fontColor('#6B8B86')
          .width('100%')
          .padding({ left: 18, top: 16 })

        TextInput({ placeholder: '例如:深夜书房专用歌单' })
          .fontSize(12)
          .height(40)
          .backgroundColor('#F0F7F5')
          .borderRadius(12)
          .padding({ left: 12 })
          .margin({ left: 18, right: 18, top: 8 })
          .onChange((v: string) => { this.playlistName = v })

新建播单弹窗是一个底部表单,包含播单名称输入框、备注输入框和分类选择器。TextInput是ArkTS的文本输入组件,通过onChange事件回调实时将输入值同步到playlistName状态变量。

        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(CAT_LIST, (c: string, i: number) => {
            Text(c)
              .fontSize(11)
              .fontColor(this.playlistCat === i ? '#0B3B36' : '#6B8B86')
              .backgroundColor(this.playlistCat === i ? '#7BE0D2' : '#F0F5F3')
              .borderRadius(14)
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .margin({ left: 4, right: 4, top: 6 })
              .scale({ x: this.playlistCat === i ? 1.05 : 1.0, y: this.playlistCat === i ? 1.05 : 1.0 })
              .animation({ duration: 150, curve: Curve.EaseOut })
              .onClick(() => { this.playlistCat = i })
          }, (c: string, i: number) => 'pc' + i)
        }

分类选择器使用Flex配合ForEach渲染八个可选分类标签,选中的分类使用亮色背景并配合1.05倍微缩放动画。playlistCat存储的是选中分类在CAT_LIST中的索引值,而非分类名字符串,这是一种"用索引代替值"的常见优化——索引比较比字符串比较更高效。

10.7 新星榜详情弹窗

  @Builder starModal() {
    Column() {
      this.modalOverlay(() => { this.showStarModal = false })
      Column() {
        Row() {
          Text('🚀 新星播客')
            .fontSize(13)
            .fontColor('#0E9384')
            .backgroundColor('#DCEEEA')
            .borderRadius(10)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          Text('✕')
            .fontSize(15)
            .fontColor('#8AA8A3')
            .margin({ left: 12 })
            .onClick(() => { this.showStarModal = false })
        }
        .width('100%')

新星榜详情弹窗展示选中播客的详细排行信息,包括当前排名、热度指数和周变动。弹窗顶部是一个标签胶囊和关闭按钮,使用Row水平排列。

        Column() {
          Column()
            .height(8)
            .borderRadius(4)
            .backgroundColor('#0E9384')
            .width(((this.selectedStar?.heat ?? 0)) + '%')
        }
        .width('100%')
        .height(8)
        .backgroundColor('#DCEEEA')
        .borderRadius(4)
        .margin({ top: 14 })

热度进度条将heat值(0-100)直接作为百分比宽度使用——width(((this.selectedStar?.heat ?? 0)) + '%')。由于heat已经是0-100的数值,它天然适合作为百分比使用,无需额外映射计算。

十一、Tab3:订阅页深度解析

11.1 订阅页状态与通知切换

@Component
struct SubsContent {
  @State subs: SubData[] = mockSubs
  @State showEditModal: boolean = false
  @State showCancelModal: boolean = false
  @State selectedSub: SubData | null = null
  @State notifyTime: number = 0
  @State autoDownload: boolean = true
  @State updateFreq: number = 1

  toggleNotify(id: number) {
    for (let i = 0; i < this.subs.length; i++) {
      if (this.subs[i].id === id) {
        this.subs[i].notify = !this.subs[i].notify;
      }
    }
    this.subs = this.subs.slice();
  }

SubsContentsubs状态变量直接初始化为mockSubs数组引用。toggleNotify方法是该组件的核心业务逻辑——根据传入的id查找对应的订阅项,切换其notify布尔值。

方法最后执行了this.subs = this.subs.slice()——这是一个关键操作。slice()方法创建数组的浅拷贝,将新数组的引用赋值给subs状态变量。在ArkTS中,@State变量需要引用变化才能触发重新渲染(对于数组和对象类型),直接修改数组内部元素的属性不会自动触发渲染。通过slice()创建新数组引用,确保框架检测到状态变化并触发ForEach的重新渲染。

这是ArkTS状态管理中一个重要的细节:对于@State标注的数组类型变量,直接修改数组元素的属性(如this.subs[i].notify = !this.subs[i].notify)不会触发UI更新,因为数组引用没有变化。需要通过创建新数组引用(如slice()concat()或展开运算符)来通知框架状态已变化。这是"引用相等性检测"机制的体现——框架通过比较变量引用是否改变来决定是否重新渲染。

11.2 统计三卡

        Row() {
          Column() {
            Text('10')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0E9384')
            Text('订阅节目')
              .fontSize(9)
              .fontColor('#6B8B86')
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#DCEEEA')
          .borderRadius(14)
          .margin({ right: 6 })

订阅页头部有三张统计卡片,分别显示订阅节目数(10,水鸭绿)、待听单集数(49,琥珀色)、累计更新数(365,紫色)。三张卡片使用layoutWeight(1)均分宽度,各自使用对应分类的浅色背景色。

11.3 更新日历柱状图

            Column() {
              Text('📅 本周更新日历')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#10312D')
                .width('100%')
              Row() {
                ForEach(SUB_UPDATE_WEEK, (v: number, i: number) => {
                  Column() {
                    Column()
                      .width(16)
                      .height((v / 7 * 64).toFixed(0) + 'vp')
                      .borderRadius(6)
                      .backgroundColor(i === 3 ? '#B45309' : '#0E9384')
                    Text('周' + WEEK_LABELS[i])
                      .fontSize(8)
                      .fontColor('#6B8B86')
                      .margin({ top: 6 })
                  }
                  .alignItems(HorizontalAlign.Center)
                  .margin({ left: 10, right: 10 })
                }, (v: number, i: number) => 'su' + i)
              }
              .justifyContent(FlexAlign.Center)

更新日历柱状图使用ForEach遍历SUB_UPDATE_WEEK数组渲染七个柱条。柱条高度通过(v / 7 * 64).toFixed(0) + 'vp'计算——将更新数量(最大值7)映射到0-64vp的像素高度。周四(索引3)的柱条使用琥珀色突出,因为其更新量最大(7集)。

11.4 订阅列表与键值优化

            Column() {
              ForEach(this.subs, (s: SubData) => {
                this.subRow(s)
              }, (s: SubData) => 'sb' + s.id + (s.notify ? 'y' : 'n'))
            }

订阅列表的ForEach键值生成器值得特别关注:(s) => 'sb' + s.id + (s.notify ? 'y' : 'n')。键值不仅包含订阅ID,还包含notify状态的标识(‘y’或’n’)。这意味着当某个订阅的notify状态变化时,该项的键值也会变化,框架会将其视为"新项"进行重新渲染。

这是一种精妙的键值优化策略——通过将状态信息编码到键值中,确保状态变化时框架能精确地重新渲染对应项,而非依赖整个列表的全量重渲染。虽然前面已经通过slice()创建了新数组引用来触发重渲染,但精细的键值设计能进一步优化diff性能。

11.5 订阅行构建器

  @Builder subRow(s: SubData) {
    Row() {
      Column() {
        Text(getCatIcon(s.cat))
          .fontSize(24)
      }
      .width(48)
      .height(48)
      .backgroundColor(getCatBg(s.cat))
      .borderRadius(14)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)

subRow构建器渲染单个订阅项,横向布局:左侧48x48vp的圆角图标容器,右侧信息区,最右侧操作区。图标使用分类对应的Emoji和背景色。

      Column() {
        Row() {
          Text(s.name)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#10312D')
            .maxLines(1)
          Text('+' + s.unplayed)
            .fontSize(8)
            .fontColor('#FFFFFF')
            .backgroundColor('#DB2777')
            .borderRadius(9)
            .padding({ left: 6, right: 6, top: 1, bottom: 1 })
            .margin({ left: 6 })
        }
        .alignItems(VerticalAlign.Center)

信息区顶部是一个Row,包含节目名称和未播放数量徽章。未播放数量使用粉红色背景的小徽章展示,醒目地提示用户有待听内容。

      Column() {
        Text(s.notify ? '🔔' : '🔕')
          .fontSize(20)
          .scale({ x: s.notify ? 1.0 : 0.88, y: s.notify ? 1.0 : 0.88 })
          .animation({ duration: 160, curve: Curve.EaseOut })
          .onClick(() => { this.toggleNotify(s.id) })
        Text('⚙️')
          .fontSize(16)
          .margin({ top: 8 })
          .onClick(() => {
            this.selectedSub = s
            this.showEditModal = true
          })
      }
      .alignItems(HorizontalAlign.Center)

操作区包含通知切换按钮和设置按钮。通知按钮根据notify状态显示不同图标(铃铛或静音),并配合0.88倍的缩放动画。点击时调用toggleNotify(s.id)切换通知状态。设置按钮点击时设置选中订阅数据并打开编辑弹窗。

11.6 订阅设置编辑弹窗

  @Builder editSubModal() {
    Column() {
      this.modalOverlay(() => { this.showEditModal = false })
      Column() {
        Row() {
          Column() {
            Text('⚙️ 订阅设置')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#10312D')
            Text(this.selectedSub?.name ?? '')
              .fontSize(10)
              .fontColor('#6B8B86')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('✕')
            .fontSize(15)
            .fontColor('#8AA8A3')
            .onClick(() => { this.showEditModal = false })
        }

订阅设置弹窗提供三个设置项:更新提醒时间、自动下载和更新频率。每个设置项都有标签文字和选项组,选项使用ForEach渲染为可点击的标签。

        Row() {
          Text(this.autoDownload ? '已开启 · 仅Wi-Fi环境' : '已关闭')
            .fontSize(10)
            .fontColor(this.autoDownload ? '#0E9384' : '#8AA8A3')
            .layoutWeight(1)

          Text(this.autoDownload ? '✅' : '⬜')
            .fontSize(20)
            .scale({ x: this.autoDownload ? 1.0 : 0.9, y: this.autoDownload ? 1.0 : 0.9 })
            .animation({ duration: 150, curve: Curve.EaseOut })
            .onClick(() => { this.autoDownload = !this.autoDownload })
        }

自动下载开关使用Emoji(✅/⬜)作为开关视觉,配合缩放动画。这是在ArkTS没有专用Toggle/Switch组件时的常见替代方案——使用Text配合条件渲染和动画模拟开关效果。

11.7 取消订阅确认弹窗

  @Builder cancelSubModal() {
    Column() {
      this.modalOverlay(() => { this.showCancelModal = false })
      Column() {
        Text('🗑️')
          .fontSize(38)
          .margin({ top: 24 })

        Text('确认取消订阅?')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#10312D')
          .margin({ top: 14 })

        Text('取消后将不再接收《' + (this.selectedSub?.name ?? '') + '》的更新提醒,收听记录仍会保留。')
          .fontSize(11)
          .fontColor('#6B8B86')
          .textAlign(TextAlign.Center)
          .lineHeight(18)
          .margin({ top: 12, left: 24, right: 24 })

取消订阅确认弹窗是一个居中的危险操作确认对话框。它使用垃圾桶图标和红色确认按钮(#DC2626背景)传达破坏性操作的视觉警示。描述文字中动态插入了选中订阅的名称,提供上下文信息。

双按钮使用"再想想"(灰色背景)和"确认取消"(红色背景)的搭配,其中"再想想"按钮使用轻松的口语化措辞,降低用户误操作后的焦虑感。这种"柔和取消+醒目确认"的按钮设计是危险操作弹窗的最佳实践。

订阅页 build

头部统计三卡

Scroll 可滚动内容区

更新日历柱状图

订阅列表 ForEach

subRow 构建器

通知按钮 onClick

toggleNotify id

遍历 subs 查找

切换 notify 布尔值

subs = subs.slice()

@State 变化触发重渲染

设置按钮 onClick

selectedSub = s
showEditModal = true

editSubModal 弹窗

用户点击'取消订阅'

showEditModal = false
showCancelModal = true

cancelSubModal 确认弹窗

十二、Tab4:睡眠页深度解析

12.1 睡眠页状态与深色主题

@Component
struct SleepContent {
  @State playingId: number = 1
  @State showReportModal: boolean = false
  @State showTimerModal: boolean = false
  @State timerIndex: number = 1

SleepContentplayingId记录当前播放的白噪音ID,初始为1(雨打芭蕉)。睡眠页是唯一使用深色主题的Tab页,通过深蓝绿色渐变背景#0E2A38#0A1E28营造夜晚助眠氛围。

12.2 今晚状态卡与波形动画

          Row() {
            Column() {
              Text('💤')
                .fontSize(30)
              Text('正在播放')
                .fontSize(8)
                .fontColor('#8FB8B1')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Center)
            .width(64)

            Column() {
              Text('雨打芭蕉 · 单曲循环')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#F0FDFA')
              Row() {
                Column()
                  .width(6)
                  .height(10)
                  .borderRadius(2)
                  .backgroundColor('#7BE0D2')
                Column()
                  .width(6)
                  .height(16)
                  .borderRadius(2)
                  .backgroundColor('#7BE0D2')
                  .margin({ left: 3 })
                Column()
                  .width(6)
                  .height(8)
                  .borderRadius(2)
                  .backgroundColor('#7BE0D2')
                  .margin({ left: 3 })
                Column()
                  .width(6)
                  .height(13)
                  .borderRadius(2)
                  .backgroundColor('#7BE0D2')
                  .margin({ left: 3 })
              }
              .margin({ top: 8 })

今晚状态卡使用四个不同高度的Column柱条模拟音频波形动画效果。四个柱条的宽度都是6vp,高度分别是10、16、8、13vp,形成参差不齐的波形视觉效果。虽然这里柱条高度是静态的(没有实际动画驱动),但通过参差的高度排列,已经营造出音频可视化的视觉暗示。

12.3 白噪音宫格

            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(mockSounds, (s: SoundData) => {
                Column() {
                  Row() {
                    Text(s.icon)
                      .fontSize(24)
                    if (!s.free) {
                      Text('VIP')
                        .fontSize(7)
                        .fontColor('#F5C06A')
                        .backgroundColor('#3A2E14')
                        .borderRadius(6)
                        .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                        .margin({ left: 5 })
                    }
                  }
                  .width('100%')

白噪音宫格使用Flex配合ForEach渲染12个白噪音卡片。每个卡片宽度30%,每行排列三个。非免费内容(!s.free)显示金色"VIP"标签。

                  Text(this.playingId === s.id ? '◉ 播放中' : '播放')
                    .fontSize(9)
                    .fontColor(this.playingId === s.id ? '#FFFFFF' : '#0E9384')
                    .backgroundColor(this.playingId === s.id ? '#0E9384' : '#DCEEEA')
                    .borderRadius(12)
                    .padding({ left: 14, right: 14, top: 4, bottom: 4 })
                    .margin({ top: 8 })
                }
                .width('30%')
                .padding(10)
                .backgroundColor(this.playingId === s.id ? '#E8F7F3' : '#FFFFFF')
                .borderRadius(14)
                .alignItems(HorizontalAlign.Start)
                .margin({ left: '1.6%', top: 10 })
                .scale({ x: this.playingId === s.id ? 1.03 : 1.0, y: this.playingId === s.id ? 1.03 : 1.0 })
                .animation({ duration: 180, curve: Curve.EaseOut })
                .onClick(() => { this.playingId = s.id })
              }, (s: SoundData) => 'so' + s.id + (this.playingId === s.id ? 'p' : ''))

白噪音卡片的视觉状态根据playingId是否等于当前项ID而变化:播放中的卡片使用浅绿背景#E8F7F3和1.03倍缩放,播放按钮变为"◉ 播放中"和主色填充;未播放的卡片使用白色背景和正常缩放。

ForEach的键值生成器同样包含了播放状态信息:'so' + s.id + (this.playingId === s.id ? 'p' : '')。当playingId变化时,新旧播放项的键值都会变化,确保两者都被正确重新渲染。

12.4 睡眠报告弹窗

  @Builder sleepReportModal() {
    Column() {
      this.modalOverlay(() => { this.showReportModal = false })
      Column() {
        Text('🌙 本周睡眠报告')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F0FDFA')
          .width('100%')

        Row() {
          Column() {
            Text('7.4h')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor('#7BE0D2')
            Text('平均时长')
              .fontSize(9)
              .fontColor('#8FB8B1')
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

睡眠报告弹窗是唯一使用深色背景的弹窗(#0E2A38),与睡眠页的深色主题保持一致。弹窗包含三个统计指标(平均时长7.4h、平均分86、对比上周+0.6h)和一周睡眠柱状图。

柱状图中睡眠时长达到或超过7.5小时的柱条使用亮色#7BE0D2,低于7.5小时的使用暗色#3E6B72,通过颜色深浅直观传达睡眠质量好坏。

12.5 助眠小指南

            Column() {
              ForEach(mockEpisodes, (e: EpisodeData, i: number) => {
                if (i < 5) {
                  Row() {
                    Text('' + (i + 1))
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#0E9384')
                      .width(22)
                    Text(e.title)
                      .fontSize(11)
                      .fontColor('#3E5C57')
                      .layoutWeight(1)
                      .maxLines(1)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                    Text(e.duration + '分钟')
                      .fontSize(9)
                      .fontColor('#8AA8A3')
                  }
                  .width('100%')
                  .padding({ top: 12, bottom: 12 })
                  .backgroundColor('#FFFFFF')
                  .borderRadius(12)
                  .margin({ top: 8 })
                }
              }, (e: EpisodeData, i: number) => 'gd' + e.id)
            }

助眠小指南使用ForEach遍历mockEpisodes数组,但通过if (i < 5)条件只渲染前五项。ForEach的项生成函数中可以使用任意条件语句控制是否渲染该项,这是ArkTS ForEach的灵活之处。

每条指南包含序号、标题(单行省略截断)和时长,形成一个简洁的推荐列表。

十三、Tab5:社区页深度解析

13.1 社区页状态与点赞逻辑

@Component
struct CommunityContent {
  @State posts: PostData[] = mockPosts
  @State showNewPostModal: boolean = false
  @State showCommentsModal: boolean = false
  @State showShareModal: boolean = false
  @State selectedPost: PostData | null = null
  @State postText: string = ''
  @State postTopic: number = 0

  toggleLike(id: number) {
    for (let i = 0; i < this.posts.length; i++) {
      if (this.posts[i].id === id) {
        if (this.posts[i].liked) {
          this.posts[i].likes -= 1;
          this.posts[i].liked = false;
        } else {
          this.posts[i].likes += 1;
                   this.posts[i].liked = true;
        }
      }
    }
    this.posts = this.posts.slice();
  }

CommunityContenttoggleLike方法实现了点赞/取消点赞的完整逻辑。与订阅页的toggleNotify类似,它遍历posts数组查找对应ID的帖子,切换liked状态并增减likes计数。已点赞时取消(likes减1、liked设false),未点赞时点赞(likes加1、liked设true)。

方法末尾的this.posts = this.posts.slice()与订阅页的模式完全一致——通过创建新数组引用触发@State的变更检测和UI重新渲染。likesliked两个字段的联动修改保证了数据一致性。

13.2 帖子卡片构建器

  @Builder postCard(p: PostData) {
    Column() {
      Row() {
        Text(p.avatar)
          .fontSize(26)
          .width(42)
          .height(42)
          .backgroundColor('#DCEEEA')
          .borderRadius(21)
          .textAlign(TextAlign.Center)

        Column() {
          Text(p.user)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#10312D')
          Text(p.time)
            .fontSize(8)
            .fontColor('#A7C2BD')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Text(p.topic)
          .fontSize(9)
          .fontColor('#0E9384')
          .backgroundColor('#DCEEEA')
          .borderRadius(9)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

postCard构建器渲染单个社区帖子。头部Row包含Emoji头像(42x42vp圆形)、用户名与时间信息区、以及话题标签胶囊。头像使用textAlign(TextAlign.Center)使Emoji在圆形容器中居中。

      Text(p.text)
        .fontSize(12)
        .fontColor('#3E5C57')
        .width('100%')
        .lineHeight(19)
        .margin({ top: 10 })

      Row() {
        Text(p.liked ? '❤️ ' + p.likes : '🤍 ' + p.likes)
          .fontSize(11)
          .fontColor(p.liked ? '#DB2777' : '#6B8B86')
          .scale({ x: p.liked ? 1.18 : 1.0, y: p.liked ? 1.18 : 1.0 })
          .animation({ duration: 220, curve: Curve.EaseOut })
          .onClick(() => { this.toggleLike(p.id) })

        Text('💬 ' + p.comments)
          .fontSize(11)
          .fontColor('#6B8B86')
          .margin({ left: 24 })
          .onClick(() => {
            this.selectedPost = p
            this.showCommentsModal = true
          })

        Text('↗️')
          .fontSize(13)
          .fontColor('#6B8B86')
          .margin({ left: 24 })
          .onClick(() => { this.showShareModal = true })
      }
      .width('100%')
      .margin({ top: 12 })

帖子正文使用12号字、19vp行高,保证多行文字的可读性。底部操作行包含点赞、评论和分享三个按钮。点赞按钮根据liked状态显示不同图标(红心或空心)和颜色(粉红或灰色),并配合1.18倍的放大动画——点赞时心形放大跳动,取消时缩回,这种"弹跳"效果是点赞交互的经典设计。

帖子列表的ForEach键值生成器同样包含了点赞状态:(p) => 'po' + p.id + (p.liked ? 'y' : 'n'),确保点赞状态变化时对应项被正确重新渲染。

13.3 发布新帖弹窗

  @Builder newPostModal() {
    Column() {
      this.modalOverlay(() => { this.showNewPostModal = false })
      Column() {
        Row() {
          Text('✍️ 发布收听瞬间')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#10312D')
          Text('✕')
            .fontSize(15)
            .fontColor('#8AA8A3')
            .margin({ left: 12 })
            .onClick(() => { this.showNewPostModal = false })
        }

发布新帖弹窗包含话题选择器、多行文本输入和关联单集展示。话题选择器使用Flex配合ForEach渲染TOPIC_CHIPS中的六个话题标签,选中的话题使用亮色背景。

        TextArea({ placeholder: '分享此刻正在听的单集、感受或安利……' })
          .fontSize(12)
          .height(90)
          .backgroundColor('#F0F7F5')
          .borderRadius(12)
          .padding(10)
          .margin({ left: 18, right: 18, top: 8 })
          .onChange((v: string) => { this.postText = v })

TextArea是ArkTS的多行文本输入组件(与单行TextInput区分),高度90vp,通过onChange回调实时将输入内容同步到postText状态变量。

13.4 评论列表弹窗

  @Builder commentsModal() {
    Column() {
      this.modalOverlay(() => { this.showCommentsModal = false })
      Column() {
        Row() {
          Text('💬 评论 ' + (this.selectedPost?.comments ?? 0) + '条')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#10312D')
            .layoutWeight(1)
          Text('✕')
            .fontSize(15)
            .fontColor('#8AA8A3')
            .onClick(() => { this.showCommentsModal = false })
        }

评论列表弹窗展示选中帖子的评论。弹窗标题动态显示评论总数。评论列表使用可滚动的Scroll容器,并通过.constraintSize({ maxHeight: '46%' })限制最大高度为屏幕的46%,防止评论过多时弹窗占据全屏。

        Scroll() {
          Column() {
            ForEach(mockComments, (c: CommentData) => {
              Row() {
                Text(c.avatar)
                  .fontSize(26)
                  .width(40)
                  .height(40)
                  .backgroundColor('#DCEEEA')
                  .borderRadius(20)
                  .textAlign(TextAlign.Center)

                Column() {
                  Text(c.user)
                    .fontSize(12)
                    .fontWeight(FontWeight.Medium)
                    .fontColor('#0E9384')
                  Text(c.text)
                    .fontSize(11)
                    .fontColor('#3E5C57')
                    .margin({ top: 4 })
                    .lineHeight(16)
                  Row() {
                    Text(c.time)
                      .fontSize(8)
                      .fontColor('#A7C2BD')
                    Text('👍 ' + c.likes)
                      .fontSize(8)
                      .fontColor('#6B8B86')
                      .margin({ left: 16 })
                  }
                  .margin({ top: 6 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })
              }
              .width('100%')
              .alignItems(VerticalAlign.Top)
              .padding({ top: 10, bottom: 10 })
            }, (c: CommentData) => 'cm' + c.id)
          }
          .width('100%')
          .padding({ left: 18, right: 18 })
        }
        .constraintSize({ maxHeight: '46%' })
        .margin({ top: 10 })

评论列表使用ForEach渲染mockComments中的八条评论。每条评论横向排列Emoji头像和文字信息区(用户名、评论内容、时间与点赞数)。Scroll容器的.constraintSize({ maxHeight: '46%' })是一个重要的布局约束——它限制了评论列表的最大高度,当评论内容超过此高度时自动启用滚动。

constraintSize是ArkTS中用于设置组件尺寸约束的属性,它可以指定minWidthmaxWidthminHeightmaxHeight四个约束值。在弹窗内部使用constraintSize限制可滚动区域的最大高度,是防止内容过多导致弹窗溢出屏幕的有效手段。这种"约束最大高度+内部滚动"的组合是弹窗列表的标准实现模式。

13.5 评论输入框

        Divider()
          .color('#EAF4F1')
          .margin({ left: 18, right: 18 })

        Row() {
          Text('说说你的看法…')
            .fontSize(11)
            .fontColor('#A7C2BD')
            .layoutWeight(1)
            .height(36)
            .backgroundColor('#F0F7F5')
            .borderRadius(18)
            .padding({ left: 14, right: 14 })
            .textAlign(TextAlign.Start)

          Text('发送')
            .fontSize(11)
            .fontColor('#FFFFFF')
            .backgroundColor('#0E9384')
            .borderRadius(18)
            .padding({ left: 16, right: 16, top: 9, bottom: 9 })
            .margin({ left: 10 })
        }
        .width('100%')
        .padding({ left: 18, right: 18, top: 12, bottom: 22 })
        .alignItems(VerticalAlign.Center)

评论输入区域位于评论列表下方,使用Divider分隔线与列表分隔。输入区域是一个Row,包含占位提示文字(使用Text模拟输入框外观)和"发送"按钮。Divider是ArkTS的专用分隔线组件,用于在垂直或水平方向上创建视觉分隔。

十四、Tab6:我的页深度解析

14.1 我的页状态定义

@Component
struct MeContent {
  @State signed: boolean = false
  @State showLevelModal: boolean = false
  @State showAboutModal: boolean = false

MeContent的状态较为简单:signed记录今日是否已签到,showLevelModalshowAboutModal分别控制等级详情弹窗和关于弹窗。

14.2 资料卡与签到

            Column() {
              Row() {
                Text('🦭')
                  .fontSize(36)
                  .width(64)
                  .height(64)
                  .backgroundColor('#DCEEEA')
                  .borderRadius(32)
                  .textAlign(TextAlign.Center)

                Column() {
                  Text('海盐芝士')
                    .fontSize(17)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#10312D')
                  Text('Lv.8 深海听友 · 已连续打卡 186 天')
                    .fontSize(9)
                    .fontColor('#6B8B86')
                    .margin({ top: 5 })
                  Text(this.signed ? '今日已签到 ✓' : '今日未签到')
                    .fontSize(9)
                    .fontColor(this.signed ? '#8AA8A3' : '#CA8A04')
                    .backgroundColor(this.signed ? '#F0F5F3' : '#FBF0CC')
                    .borderRadius(10)
                    .padding({ left: 10, right: 10, top: 3, bottom: 3 })
                    .margin({ top: 6 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 14 })

                Text(this.signed ? '✓' : '签到')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                  .backgroundColor(this.signed ? '#A7C2BD' : '#0E9384')
                  .borderRadius(18)
                  .padding({ left: 18, right: 18, top: 9, bottom: 9 })
                  .scale({ x: this.signed ? 0.95 : 1.0, y: this.signed ? 0.95 : 1.0 })
                  .animation({ duration: 180, curve: Curve.EaseOut })
                  .onClick(() => { this.signed = true })
              }
              .width('100%')
              .alignItems(VerticalAlign.Center)

资料卡是"我的"页的核心区域,使用渐变背景的Column容器。左侧是64x64vp的圆形头像,右侧是用户信息区(用户名、等级与打卡天数、签到状态标签),最右侧是签到按钮。

签到状态标签根据signed值显示不同文字和颜色:已签到显示灰色"今日已签到 ✓",未签到显示琥珀色"今日未签到"。签到按钮在已签到状态下变为灰色背景并缩小至0.95倍,视觉上呈现"已完成"的弱化状态。点击签到按钮将signed设为true,触发整个资料卡的重新渲染——签到状态标签和签到按钮同时更新。

这种"一个布尔状态驱动多个UI元素联动变化"的模式,充分展示了声明式UI状态驱动渲染的威力:开发者无需手动操作DOM来更新各个元素的状态,只需修改一个状态变量,框架自动完成所有依赖该变量的UI片段的更新。

14.3 统计行与等级入口

              Row() {
                Column() {
                  Text('328h')
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#0E9384')
                  Text('收听时长')
                    .fontSize(9)
                    .fontColor('#6B8B86')
                    .margin({ top: 2 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)
                // ... 其他三列
                Column() {
                  Text('升级')
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#CA8A04')
                  Text('等级')
                    .fontSize(9)
                    .fontColor('#6B8B86')
                    .margin({ top: 2 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)
                .onClick(() => { this.showLevelModal = true })
              }
              .width('100%')
              .margin({ top: 18 })
              .padding({ top: 12, bottom: 12 })
              .backgroundColor('rgba(255,255,255,0.12)')
              .borderRadius(14)

统计行包含四列:收听时长(328h)、收藏单集(102)、自建播单(4)和等级(升级)。前三列是纯展示型,第四列"等级"是可点击的,点击后打开等级详情弹窗。

统计行容器使用了半透明白色背景rgba(255,255,255,0.12)(12%不透明度的白色),在渐变背景上形成微妙的玻璃质感效果。这种半透明叠加是模仿"毛玻璃"(Glassmorphism)效果的常见手法。

14.4 本周收听柱状图

            Column() {
              Text('📊 本周收听时长(分钟)')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#10312D')
                .width('100%')

              Row() {
                ForEach(WEEK_LISTEN, (v: number, i: number) => {
                  Column() {
                    Text('' + v)
                      .fontSize(8)
                      .fontColor(i === 3 ? '#B45309' : '#0E9384')
                    Column()
                      .width(18)
                      .height((v / 160 * 88).toFixed(0) + 'vp')
                      .borderRadius(6)
                      .backgroundColor(i === 3 ? '#B45309' : '#0E9384')
                      .margin({ top: 3 })
                    Text('周' + WEEK_LABELS[i])
                      .fontSize(8)
                      .fontColor('#6B8B86')
                      .margin({ top: 5 })
                  }
                  .alignItems(HorizontalAlign.Center)
                  .margin({ left: 12, right: 12 })
                }, (v: number, i: number) => 'ml' + i)
              }
              .justifyContent(FlexAlign.Center)

本周收听柱状图与订阅页的更新日历柱状图结构类似,但柱条更高(映射到0-88vp)、更宽(18vp),并在柱条上方显示具体数值。周四(索引3)的柱条使用琥珀色突出,因为158分钟是本周新高。柱条下方的周标签使用WEEK_LABELS数组的中文字符。

14.5 勋章墙

            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(BADGE_LIST, (b: BadgeEntry) => {
                Column() {
                  Text(b.icon)
                    .fontSize(26)
                    .opacity(b.got ? 1.0 : 0.3)
                    .scale({ x: b.got ? 1.0 : 0.9, y: b.got ? 1.0 : 0.9 })
                  Text(b.name)
                    .fontSize(10)
                    .fontWeight(FontWeight.Medium)
                    .fontColor(b.got ? '#10312D' : '#A7C2BD')
                    .margin({ top: 6 })
                  Text(b.got ? b.desc : '未解锁')
                    .fontSize(8)
                    .fontColor('#8AA8A3')
                    .margin({ top: 3 })
                }
                .width('30%')
                .padding(10)
                .backgroundColor('#FFFFFF')
                .borderRadius(14)
                .alignItems(HorizontalAlign.Center)
                .margin({ left: '1.6%', top: 10 })
              }, (b: BadgeEntry) => 'bd' + b.name)
            }

勋章墙使用Flex配合ForEach渲染六个勋章条目,每行三个(宽度30%)。已获得和未获得的勋章通过三个维度的视觉差异区分:不透明度(1.0 vs 0.3)、缩放比例(1.0 vs 0.9)、描述文字(具体描述 vs “未解锁”)。这种多维度的状态视觉化,使得用户一眼就能区分已获得和未获得的勋章。

14.6 设置行构建器

  @Builder settingRow(icon: string, label: string, extra: string, extraColor: string) {
    Row() {
      Text(icon)
        .fontSize(16)
      Text(label)
        .fontSize(13)
        .fontColor('#10312D')
        .margin({ left: 12 })
        .layoutWeight(1)
      Text(extra)
        .fontSize(11)
        .fontColor(extraColor)
    }
    .width('100%')
    .padding({ top: 14, bottom: 14 })
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .margin({ top: 8 })
    .padding({ left: 14, right: 14 })
  }

settingRow是一个四参数的设置行构建器,接收图标、标签、额外文字和额外文字颜色。它渲染一个横向Row,左侧是图标,中间是标签(layoutWeight(1)占据空间),右侧是附加信息文字。

这个构建器在设置列表中被调用五次,渲染五个设置项:音质选择、缓存管理、推送通知、深色模式、关于。每个设置项的附加信息使用不同颜色,以语义化方式区分状态(绿色表示已开启、琥珀色表示警告、灰色表示中性)。

@Builder装饰器在ArkTS中扮演着"UI函数"的角色。与@Component不同,@Builder定义的不是一个独立组件,而是宿主组件内部的一个可复用UI片段。@Builder可以直接访问宿主组件的this上下文(包括状态变量和方法),这使得它非常适合封装那些需要在同一组件内多次使用的UI结构。@Builder支持参数传递,使得同一构建器可以通过不同参数渲染不同内容,极大地减少了代码重复。

14.7 等级详情弹窗

  @Builder levelModal() {
    Column() {
      this.modalOverlay(() => { this.showLevelModal = false })
      Column() {
        Text('🏅')
          .fontSize(40)
          .margin({ top: 22 })

        Text('Lv.8 深海听友')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor('#10312D')
          .margin({ top: 12 })

        Text('再收听 72 小时即可升级 Lv.9 远洋船长')
          .fontSize(10)
          .fontColor('#6B8B86')
          .margin({ top: 8 })

        Column() {
          Column()
            .height(8)
            .borderRadius(4)
            .backgroundColor('#0E9384')
            .width('82%')
        }
        .width('100%')
        .height(8)
        .backgroundColor('#DCEEEA')
        .borderRadius(4)
        .margin({ top: 18, left: 24, right: 24 })

等级详情弹窗展示用户等级信息和升级进度。进度条宽度固定为82%,表示当前进度328h/400h(328/400 = 82%)。弹窗还包含三个统计指标:累计收听时长328h、连续打卡186天、订阅节目10档。

14.8 关于弹窗

  @Builder aboutModal() {
    Column() {
      this.modalOverlay(() => { this.showAboutModal = false })
      Column() {
        Text('📻')
          .fontSize(40)
          .margin({ top: 22 })
        Text('潮汐电台 TIDAL FM')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#10312D')
          .margin({ top: 12 })
        Text('v3.8.2 · 让每一次收听都有回声')
          .fontSize(10)
          .fontColor('#6B8B86')
          .margin({ top: 8 })

关于弹窗展示应用信息,包含应用图标、名称、版本号和slogan。弹窗中还包含当前电台信息、"给我们评分"按钮和"知道了"关闭按钮。

签到状态联动

我的数据页组件树

if showLevelModal

if showAboutModal

onClick 签到

标签变为 已签到

按钮变为 ✓ 灰色

MeContent @Component

Scroll 滚动容器

资料卡 Column 渐变背景

统计行 Row 半透明白色

本周收听柱状图

勋章墙 Flex 网格

设置列表

levelModal 等级弹窗

aboutModal 关于弹窗

signed = false

signed = true

十五、数据流与状态管理总览

整个应用的状态管理架构可以分为三个层次:入口组件的全局状态、各Tab组件的局部状态、以及通过Mock数据共享的全局数据源。

共享Mock数据

全局状态

条件渲染

条件渲染

条件渲染

条件渲染

条件渲染

条件渲染

Tab5 社区页状态

posts: PostData[]

showNewPostModal

showCommentsModal

selectedPost

Tab3 订阅页状态

subs: SubData[]

showEditModal

showCancelModal

autoDownload

Tab2 发现页状态

catFilter

showCreateModal

selectedStar

playlistName/Desc/Cat

Tab1 电台页状态

showStationModal

showEpisodeModal

showTimerModal

showShareModal

selectedStation

selectedEpisode

playing

discAngle

playProgress

activeTab: TidalTab
入口组件当前Tab

mockStations

mockEpisodes

mockSubs

mockPosts

mockSounds

mockComments

mockStarRank

RadioContent

DiscoverContent

SubsContent

SleepContent

CommunityContent

MeContent

入口组件TidalApp通过activeTab状态控制六个Tab的条件渲染,这是最顶层的状态。每个Tab组件拥有自己独立的状态变量集,互不干扰。Tab组件内部通过@State管理的状态变化只影响本Tab的UI重新渲染,不会波及其他Tab。

共享的Mock数据(mockStationsmockEpisodes等)作为模块级常量,被多个Tab组件引用。目前这些数据是只读的(社区页的posts和订阅页的subs虽然可以修改,但修改的是组件内部的@State副本引用),未来接入真实API时,这些常量可以替换为从网络请求获取的数据。

ArkTS的状态管理遵循"单向数据流"原则:数据自上而下通过Props传递,事件自下而上通过回调通知。@State是组件内部的可变状态,@Prop是父组件传递的只读数据,@Link是父子双向绑定。在本应用中,由于所有Tab组件都是平级的(由入口组件的条件渲染控制),没有深层的组件嵌套,因此主要使用@State进行状态管理,通过@Builder参数化实现UI复用,这已经足以满足复杂的交互需求。

十六、弹窗系统架构分析

本应用的弹窗系统是其交互设计的核心,六个Tab页面共实现了15个弹窗。所有弹窗都遵循统一的架构模式:遮罩层+内容卡片+绝对定位+条件渲染。

我的页弹窗

社区页弹窗

睡眠页弹窗

订阅页弹窗

发现页弹窗

电台页弹窗

弹窗分类

居中大卡片

底部抽屉

居中小卡片

底部表单

底部宫格

stationDetailModal 居中大卡

episodeSheetModal 底部抽屉

timerModal 居中小卡

shareModal 底部宫格

createPlaylistModal 底部表单

starModal 居中卡

editSubModal 底部表单

cancelSubModal 居中确认

sleepReportModal 居中深色卡

sleepTimerModal 底部网格

newPostModal 底部表单

commentsModal 底部抽屉

shareModal 底部宫格

levelModal 居中卡

aboutModal 居中卡

所有弹窗都使用相同的modalOverlay构建器渲染遮罩层,遮罩层的半透明背景色根据页面主题有所不同——大多数页面使用rgba(9,45,40,0.68)(深绿色68%不透明度),睡眠页使用rgba(4,20,30,0.78)(更深的蓝绿色78%不透明度,配合深色主题)。

弹窗内容卡片的定位策略有三种:居中定位(position的x和y都为百分比)、底部定位(y为较大百分比如52%或56%)、偏上定位(y为18%-30%)。居中弹窗适合详情展示和确认操作,底部弹窗适合表单输入和操作选择,偏上弹窗则适合需要留出底部空间查看背景内容的场景。

每个弹窗都通过zIndex(999)确保层级最高,遮罩层位于弹窗内容之下但在页面内容之上。点击遮罩层会触发onClose回调关闭弹窗,这是移动端弹窗的标准交互——用户可以通过点击遮罩区域的空白处快速关闭弹窗,无需精确点击关闭按钮。

弹窗之间的切换通过"关一开一"模式实现——在关闭当前弹窗的onClick中同时设置下一个弹窗的show状态为true。例如电台详情弹窗的"分享"按钮先设置showStationModal = false再设置showShareModal = true,实现了从详情弹窗到分享弹窗的平滑过渡。

弹窗系统的设计是移动应用交互架构的核心挑战之一。本应用采用的条件渲染+Stack层叠方案虽然简洁,但在弹窗数量较多时会导致build方法末尾堆积大量if语句。在更复杂的场景中,可以考虑使用ArkTS的CustomDialog组件或@CustomDialog装饰器来实现更规范的弹窗管理。不过,当前方案的优点是完全可控——弹窗的样式、位置、动画都可以自由定制,不受Dialog组件默认样式的约束。

十七、布局技术要点总结

本应用大量使用了ArkTS的多种布局容器和布局属性。以下对核心布局技术进行归纳。

**Column(垂直线性布局)**是应用中使用频率最高的布局容器。从入口组件的页面骨架到卡片内部的信息排列,Column承担了主要的垂直方向布局职责。Column的alignItems属性控制子元素在水平方向的对齐方式,默认值为HorizontalAlign.Center(居中),本应用中大量使用HorizontalAlign.Start(左对齐)来满足信息排列需求。

**Row(水平线性布局)**与Column互为补充,用于水平方向排列子元素。Row的justifyContent属性控制子元素在水平方向(主轴)上的分布方式,FlexAlign.Center(居中)、FlexAlign.SpaceBetween(两端对齐)和FlexAlign.Center是本应用中最常用的值。

**Stack(层叠布局)**是弹窗系统的基石。Stack的子元素按声明顺序层叠排列,后声明的覆盖在先声明的之上。通过zIndex属性可以进一步控制层叠顺序。本应用中每个Tab页的根容器都是Stack,页面内容在最底层,弹窗条件渲染在上层。

**Flex(弹性布局)**是最灵活的布局容器,支持子元素自动换行。本应用中的网格布局(播客双列、白噪音三列、勋章三列、定时选项三列、分享渠道三列)都使用Flex配合百分比宽度实现。Flex的wrap参数设置为FlexWrap.Wrap启用自动换行。

**Scroll(滚动容器)**为超出屏幕尺寸的内容提供滚动能力。本应用中外层垂直Scroll配合内层水平Scroll实现了双向滚动。scrollable属性指定滚动方向,scrollBar属性控制滚动条显示。

**layoutWeight(弹性权重)**是ArkTS弹性布局的核心属性。设置layoutWeight(1)的子元素会占据父容器中所有未被固定尺寸子元素占用的剩余空间。多个layoutWeight(1)的子元素均分剩余空间。本应用中统计卡片均分宽度、底部Tab均分宽度、内容区占据头部和播放条之间的空间,都依赖layoutWeight实现。

**position(绝对定位)**将组件从正常文档流中脱离,相对于父容器进行精确定位。本应用中所有弹窗内容卡片都使用position进行定位,通过百分比坐标实现居中、底部等不同位置效果。

**constraintSize(尺寸约束)**设置组件的最小/最大宽高限制。本应用中评论列表弹窗使用constraintSize({ maxHeight: '46%' })限制评论列表的最大高度,防止单个弹窗内容过多时占据全屏。

十八、动画与交互效果分析

本应用虽然以静态布局为主,但在关键交互点融入了精细的动画效果,提升了用户体验的流畅度和反馈感。

**缩放动画(scale)**是最常用的交互反馈。Tab按钮选中时放大1.16倍、播放按钮暂停时缩小0.86倍、点赞按钮点赞时放大1.18倍、勋章未获得时缩小0.9倍——这些缩放变化都配合.animation({ duration: 150-220, curve: Curve.EaseOut })实现了平滑过渡。Curve.EaseOut是缓出曲线,动画开始快、结束慢,适合"出现"和"放大"类的动画。

**旋转动画(rotate)**用于迷你播放条的唱片图标,每次点击增加45度旋转角度,配合700毫秒的Curve.Linear(匀速)动画,模拟黑胶唱片的物理旋转。线性曲线适合持续的旋转运动,因为它没有加速和减速阶段,旋转速度恒定。

**不透明度动画(opacity)**用于Tab按钮的选中/未选中状态切换,选中时不透明度为1.0,未选中降至0.42。配合动画属性实现了淡入淡出效果。

ArkTS的动画系统分为"属性动画"和"显式动画"两类。属性动画通过在样式方法链上附加.animation()实现——当被动画修饰的属性值发生变化时,框架自动在指定时长内以指定曲线进行过渡。这种方式简单直观,适合大多数交互反馈场景。显式动画通过animateTo()函数实现,可以在代码中精确控制动画的开始时机和参数,适合更复杂的动画编排。本应用全部使用属性动画,这已经足够满足需求。

十九、核心技术点对比

以下表格对应用中涉及的ArkTS核心技术点进行系统性对比:

技术点 类别 核心作用 使用场景示例 关键属性/参数 优点 局限性
Column 布局容器 垂直方向排列子元素 页面骨架、卡片信息区 alignItems, justifyContent, layoutWeight 简单直观,适合纵向内容 不支持子元素自动换行
Row 布局容器 水平方向排列子元素 头部按钮行、操作按钮行 alignItems, justifyContent, layoutWeight 简单直观,适合横向排列 不支持子元素自动换行
Stack 布局容器 层叠排列子元素 弹窗系统、页面根容器 zIndex, alignContent 支持层叠覆盖,弹窗基础 子元素定位需手动控制
Flex 弹性布局 可换行的弹性排列 网格布局、标签云 wrap, direction, justifyContent 支持自动换行,最灵活 性能略低于Column/Row
Scroll 滚动容器 为内容提供滚动能力 长列表、横滑区 scrollable, scrollBar 支持双向滚动 单子组件限制
ForEach 循环渲染 遍历数组渲染列表 电台列表、单集列表、帖子列表 arr, itemGenerator, keyGenerator 内置diff优化,高效更新 键值需保证唯一性
@Component 组件装饰器 声明UI组件 六个Tab组件、RadioHeader struct, build() 封装独立状态和视图 需配合@Entry作为入口
@Entry 入口装饰器 标记页面入口组件 TidalApp入口组件 搭配@Component 路由可直接加载 每个页面仅一个@Entry
@State 状态管理 组件内部可变状态 activeTab, showModal, playing 变量声明+初始值 自动触发UI更新 数组需引用变更才触发
@Builder 构建器装饰器 可复用UI片段 stationCard, episodeRow, postCard 参数化函数 减少代码重复,访问this 非独立组件,依赖宿主
linearGradient 背景属性 线性渐变背景 RadioHeader, 资料卡 angle, colors 丰富视觉层次 仅支持线性渐变
borderRadius 样式属性 圆角效果 所有卡片、按钮 number或string 柔化视觉边缘 对非矩形元素无效
shadow 样式属性 阴影效果 底部Tab栏、迷你播放条 radius, color, offsetY 增加层次感 性能开销需控制
position 定位属性 绝对定位 弹窗内容卡片 x, y 精确控制位置 脱离文档流
zIndex 层级属性 控制层叠顺序 弹窗(999) number 确保弹窗在最上层 需合理规划层级
scale 变换属性 缩放变换 Tab按钮、播放按钮、勋章 x, y 交互反馈自然 需配合animation使用
rotate 变换属性 旋转变换 迷你播放条唱片图标 angle 模拟物理旋转 需手动累加角度值
opacity 样式属性 不透明度 Tab按钮选中态、未获得勋章 0.0-1.0 淡入淡出效果 过度使用影响可读性
animation 动画属性 属性变化过渡动画 几乎所有交互反馈 duration, curve 自动平滑过渡 仅对属性变化生效
maxLines 文本属性 最大行数限制 电台名称、单集标题 number 防止文本溢出 需配合textOverflow
textOverflow 文本属性 溢出处理方式 配合maxLines使用 Ellipsis 优雅截断长文本 仅在maxLines生效
constraintSize 尺寸约束 最小/最大尺寸限制 评论列表弹窗 minWidth/maxHeight 防止内容溢出 需与Scroll配合
Divider 分隔组件 视觉分隔线 评论弹窗输入区上方 color, margin 简洁的分隔效果 样式较为有限
TextInput 输入组件 单行文本输入 新建播单名称输入 placeholder, onChange 轻量级文本输入 仅支持单行
TextArea 输入组件 多行文本输入 发帖内容输入 placeholder, onChange 支持多行文本 高度需手动设置
enum 类型定义 枚举类型 TidalTab导航枚举 成员=数值 避免魔法数字 值不可变更
interface 类型定义 数据结构契约 所有数据接口 属性签名 类型安全约束 不含实现逻辑
implements 类接口实现 类实现接口 所有Mock数据类 class implements 强制实现契约 需手动实现所有属性
可选链?. 安全访问 空值安全属性访问 弹窗中的selectedStation?.name 防止空引用崩溃 语法稍显冗长
空值合并?? 默认值 提供降级默认值 selectedStation?.cat ?? ‘生活’ 保证非空输出 需合理选择默认值
slice() 数组方法 创建数组浅拷贝 toggleNotify/toggleLike末尾 触发@State变更检测 浅拷贝不复制嵌套对象

二十、全文总结

本文对一款基于鸿蒙ArkTS开发的播客电台社区应用进行了逐行级别的深度技术剖析。从类型系统定义到Mock数据建模,从工具函数实现到组件化架构,从入口组件状态管理到六个Tab页面的完整实现,从弹窗系统设计到布局与动画技术,覆盖了ArkTS应用开发的全部核心知识领域。

在类型系统层面,应用通过九个interface定义了完整的数据契约,涵盖分类元数据、电台节目、播客单集、订阅信息、社区帖子、白噪音、评论、排行榜和勋章等业务实体。每个接口都遵循"字段语义化命名、类型精确标注"的原则,例如live用布尔值标记直播状态、duration用数值存储分钟数而非格式化字符串,体现了数据建模的最佳实践。七个class通过implements关键字实现了这些接口,提供了带构造函数的可实例化数据类,使得Mock数据的构造既类型安全又结构清晰。

在状态管理层面,应用的核心架构是入口组件的@State activeTab状态驱动的条件渲染系统。六个Tab页面通过if-else条件判断按需渲染,既保证了内存效率(未激活的Tab不渲染),又实现了即时的页面切换体验。每个Tab组件内部拥有独立的状态变量集,通过@State装饰器管理。特别值得关注的是两个涉及数组修改的操作——订阅页的toggleNotify和社区页的toggleLike——它们都通过slice()创建新数组引用来触发@State的变更检测,并通过在ForEach键值中编码状态信息来优化diff性能。这种"引用变更+键值编码"的双重优化策略,是ArkTS数组状态管理的精髓。

在组件化设计层面,应用通过@Component装饰器定义了八个独立组件(入口组件、公共头部和六个Tab内容组件),通过@Builder装饰器定义了十余个可复用UI片段(电台卡片、单集行、订阅行、帖子卡片、设置行、底部Tab按钮等)。@Builder的参数化能力使得同一构建器可以通过不同参数渲染不同内容,极大地减少了代码重复。每个@Builder都可以直接访问宿主组件的this上下文,包括状态变量和方法,这使得构建器内的交互逻辑(如点击事件)可以直接修改宿主状态,无需额外的事件传递机制。

在弹窗系统层面,应用实现了十五个弹窗,涵盖居中大卡、底部抽屉、居中小卡、底部表单和底部宫格五种类型。所有弹窗都遵循统一的架构模式:modalOverlay遮罩层+内容卡片+绝对定位+条件渲染+zIndex(999)层级控制。弹窗之间的切换通过"关一开一"模式实现,在关闭当前弹窗的回调中同时打开下一个弹窗。每个弹窗都通过position进行精确定位,居中弹窗的x和y都为百分比坐标,底部弹窗的y为较大百分比(52%-56%)。这种完全自定义的弹窗方案虽然需要手动管理多个show*Modal状态变量,但换来了完全可控的样式和交互自由度。

在布局技术层面,应用综合运用了Column、Row、Stack、Flex、Scroll五大布局容器。Column和Row承担基础的线性行列排列,Stack提供弹窗层叠能力,Flex通过FlexWrap.Wrap实现自动换行的网格布局,Scroll提供垂直和水平滚动能力。layoutWeight弹性权重属性用于均分空间和占据剩余空间,position绝对定位用于弹窗精确定位,constraintSize尺寸约束用于限制弹窗内可滚动区域的最大高度。这些布局技术的组合使用,使得应用能够在各种屏幕尺寸上实现合理的自适应布局。

在数据可视化层面,应用纯代码实现了多种简单图表:水平条形图(分类收听占比)、柱状图(本周收听时长、更新日历、睡眠质量)、进度条(播放进度、热度指数、升级进度)和波形条(单集详情)。这些图表通过ForEach渲染数据驱动的柱条元素,柱条高度通过数据值与最大值的比例映射到像素高度,颜色根据数据特征(如是否为峰值、是否达标)动态选择。这种纯代码图表实现方式无需引入第三方图表库,适合数据量小、样式要求高的场景。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 清新海洋风:水鸭绿主色 + 琥珀点缀 + 电商式静态头部
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

// ============ 类型定义 ============
interface CatMeta {
  label: string;
  icon: string;
  color: string;
  bg: string;
}

interface StationItem {
  id: number;
  name: string;
  freq: string;
  cat: string;
  host: string;
  listeners: number;
  live: boolean;
  desc: string;
}

interface EpisodeItem {
  id: number;
  title: string;
  station: string;
  host: string;
  date: string;
  duration: number;
  plays: number;
  tag: string;
}

interface SubItem {
  id: number;
  name: string;
  host: string;
  cat: string;
  total: number;
  unplayed: number;
  updated: string;
  notify: boolean;
  hot: number;
}

interface PostItem {
  id: number;
  user: string;
  avatar: string;
  topic: string;
  text: string;
  likes: number;
  comments: number;
  time: string;
  liked: boolean;
}

interface SoundItem {
  id: number;
  name: string;
  icon: string;
  minutes: number;
  users: number;
  free: boolean;
}

interface CommentEntry {
  id: number;
  user: string;
  avatar: string;
  text: string;
  time: string;
  likes: number;
}

interface StarRankEntry {
  rank: number;
  name: string;
  host: string;
  cat: string;
  heat: number;
  delta: string;
}

interface ShareEntry {
  name: string;
  pct: number;
  color: string;
}

interface BadgeEntry {
  icon: string;
  name: string;
  desc: string;
  got: boolean;
}

// ============ 风格配置 ============
const CAT_CONFIG: Record<string, CatMeta> = {
  '生活': { label: '生活', icon: '🌿', color: '#0E9384', bg: '#DCEEEA' },
  '音乐': { label: '音乐', icon: '🎧', color: '#B45309', bg: '#FCEFD9' },
  '科技': { label: '科技', icon: '🛰️', color: '#0369A1', bg: '#D8ECF8' },
  '人文': { label: '人文', icon: '📚', color: '#7C3AED', bg: '#EAE4FB' },
  '心理': { label: '心理', icon: '🧠', color: '#DB2777', bg: '#FBE3EE' },
  '商业': { label: '商业', icon: '💼', color: '#CA8A04', bg: '#FBF0CC' },
  '悬疑': { label: '悬疑', icon: '🔎', color: '#475569', bg: '#E3E8EE' }
};

const CAT_LIST: string[] = ['全部', '生活', '音乐', '科技', '人文', '心理', '商业', '悬疑'];

const WEEK_LISTEN: number[] = [86, 132, 74, 158, 96, 63, 110];
const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
const SLEEP_WEEK: number[] = [6.5, 7.2, 6.8, 7.9, 7.4, 8.2, 7.6];
const SUB_UPDATE_WEEK: number[] = [3, 5, 2, 7, 4, 6, 1];

const CAT_SHARE: ShareEntry[] = [
  { name: '生活', pct: 28, color: '#0E9384' },
  { name: '音乐', pct: 22, color: '#B45309' },
  { name: '科技', pct: 18, color: '#0369A1' },
  { name: '人文', pct: 12, color: '#7C3AED' },
  { name: '心理', pct: 10, color: '#DB2777' },
  { name: '商业', pct: 6, color: '#CA8A04' },
  { name: '悬疑', pct: 4, color: '#475569' }
];

const TIMER_OPTIONS: number[] = [5, 15, 30, 45, 60, 90];
const SPEED_OPTIONS: string[] = ['0.75x', '1.0x', '1.25x', '1.5x'];

const TOPIC_CHIPS: string[] = ['#深夜书单#', '#通勤搭子#', '#播客安利#', '#电台回忆#', '#助眠神器#', '#主播快回#'];

const BADGE_LIST: BadgeEntry[] = [
  { icon: '🌅', name: '早起鸟', desc: '连续7天6点收听', got: true },
  { icon: '🌙', name: '夜猫子', desc: '深夜档收听50小时', got: true },
  { icon: '🌊', desc: '订阅满20档节目', name: '深海听友', got: true },
  { icon: '🐚', name: '拾贝人', desc: '收藏100条单集', got: true },
  { icon: '⚡', name: '连播狂', desc: '单日收听8小时', got: false },
  { icon: '🎧', name: '全勤生', desc: '连续30天签到', got: false }
];

// ============ Mock 数据类 ============
class StationData implements StationItem {
  id: number = 0;
  name: string = '';
  freq: string = '';
  cat: string = '';
  host: string = '';
  listeners: number = 0;
  live: boolean = false;
  desc: string = '';

  constructor(id: number, name: string, freq: string, cat: string, host: string, listeners: number, live: boolean, desc: string) {
    this.id = id;
    this.name = name;
    this.freq = freq;
    this.cat = cat;
    this.host = host;
    this.listeners = listeners;
    this.live = live;
    this.desc = desc;
  }
}

class EpisodeData implements EpisodeItem {
  id: number = 0;
  title: string = '';
  station: string = '';
  host: string = '';
  date: string = '';
  duration: number = 0;
  plays: number = 0;
  tag: string = '';

  constructor(id: number, title: string, station: string, host: string, date: string, duration: number, plays: number, tag: string) {
    this.id = id;
    this.title = title;
    this.station = station;
    this.host = host;
    this.date = date;
    this.duration = duration;
    this.plays = plays;
    this.tag = tag;
  }
}

class SubData implements SubItem {
  id: number = 0;
  name: string = '';
  host: string = '';
  cat: string = '';
  total: number = 0;
  unplayed: number = 0;
  updated: string = '';
  notify: boolean = true;
  hot: number = 0;

  constructor(id: number, name: string, host: string, cat: string, total: number, unplayed: number, updated: string, notify: boolean, hot: number) {
    this.id = id;
    this.name = name;
    this.host = host;
    this.cat = cat;
    this.total = total;
    this.unplayed = unplayed;
    this.updated = updated;
    this.notify = notify;
    this.hot = hot;
  }
}

class PostData implements PostItem {
  id: number = 0;
  user: string = '';
  avatar: string = '';
  topic: string = '';
  text: string = '';
  likes: number = 0;
  comments: number = 0;
  time: string = '';
  liked: boolean = false;

  constructor(id: number, user: string, avatar: string, topic: string, text: string, likes: number, comments: number, time: string, liked: boolean) {
    this.id = id;
    this.user = user;
    this.avatar = avatar;
    this.topic = topic;
    this.text = text;
    this.likes = likes;
    this.comments = comments;
    this.time = time;
    this.liked = liked;
  }
}

class SoundData implements SoundItem {
  id: number = 0;
  name: string = '';
  icon: string = '';
  minutes: number = 0;
  users: number = 0;
  free: boolean = true;

  constructor(id: number, name: string, icon: string, minutes: number, users: number, free: boolean) {
    this.id = id;
    this.name = name;
    this.icon = icon;
    this.minutes = minutes;
    this.users = users;
    this.free = free;
  }
}

class CommentData implements CommentEntry {
  id: number = 0;
  user: string = '';
  avatar: string = '';
  text: string = '';
  time: string = '';
  likes: number = 0;

  constructor(id: number, user: string, avatar: string, text: string, time: string, likes: number) {
    this.id = id;
    this.user = user;
    this.avatar = avatar;
    this.text = text;
    this.time = time;
    this.likes = likes;
  }
}

class StarRankData implements StarRankEntry {
  rank: number = 0;
  name: string = '';
  host: string = '';
  cat: string = '';
  heat: number = 0;
  delta: string = '';

  constructor(rank: number, name: string, host: string, cat: string, heat: number, delta: string) {
    this.rank = rank;
    this.name = name;
    this.host = host;
    this.cat = cat;
    this.heat = heat;
    this.delta = delta;
  }
}

// ============ Mock 数据 ============
const mockStations: StationData[] = [
  new StationData(1, '潮汐晚间新闻', 'FM 96.3', '生活', '林晚', 48210, true, '每晚八点,用 20 分钟听完今天的世界'),
  new StationData(2, '蓝调午夜场', 'FM 88.1', '音乐', '老周', 36540, true, '午夜十二点,只剩爵士和城市呼吸声'),
  new StationData(3, '未来科技观察', 'FM 101.7', '科技', 'Kiko', 52980, true, '一周三次,追踪全球科技圈新动向'),
  new StationData(4, '深夜书房', 'FM 92.5', '人文', '沈砚', 28760, false, '把好书读给你听,也聊聊写书的人'),
  new StationData(5, '资本快问快答', 'FM 105.3', '商业', '钱多多', 41320, true, '财经热点快节奏拆解,三分钟一个知识点'),
  new StationData(6, '心流研究所', 'FM 97.9', '心理', '阿岚', 39870, false, '专注、焦虑、拖延……都能聊明白'),
  new StationData(7, '迷雾档案', 'FM 99.4', '悬疑', '陈拾', 61240, true, '真实案件慢速重演,胆小慎入'),
  new StationData(8, '绿洲生活家', 'FM 94.2', '生活', '小满', 22310, false, '收纳、做饭、养花,把日子过细'),
  new StationData(9, '环球音乐速递', 'FM 90.6', '音乐', 'DJ Mango', 34180, true, '每周新歌盘点 + 冷门宝藏挖掘'),
  new StationData(10, '硅基漫谈', 'FM 103.8', '科技', '大雄', 47520, false, 'AI 圈的瓜和干货,一个不落'),
  new StationData(11, '城市折叠', 'FM 89.9', '人文', '苏格', 19870, false, '每期一座城,聊聊街道背后的故事'),
  new StationData(12, '财经早高峰', 'FM 107.2', '商业', '晨露', 55630, true, '通勤路上 15 分钟,盘前必听'),
  new StationData(13, '情绪急救站', 'FM 98.5', '心理', '栗子', 31250, false, '坏情绪来了别硬扛,先坐下聊聊'),
  new StationData(14, '真相调查组', 'FM 100.1', '悬疑', '老白', 44890, false, '谣言粉碎机,只讲证据链'),
  new StationData(15, '周末市集', 'FM 95.8', '生活', '阿福', 17340, false, '周末去哪儿、吃什么,全都安排'),
  new StationData(16, '星海电台', 'FM 87.6', '音乐', '夏夜', 26730, false, '睡前一小时的温柔歌单与晚安信')
];

const mockEpisodes: EpisodeData[] = [
  new EpisodeData(1, 'Vol.128 深夜的便利店,亮着一盏灯', '蓝调午夜场', '老周', '今天 23:00', 62, 38210, '晚安曲'),
  new EpisodeData(2, 'AI 会不会先取代产品经理?', '硅基漫谈', '大雄', '今天 20:30', 48, 29840, 'AI 观察'),
  new EpisodeData(3, '早间快报:三大指数高开', '财经早高峰', '晨露', '今天 07:30', 15, 51230, '盘前必听'),
  new EpisodeData(4, '第 41 案:雨夜消失的出租车', '迷雾档案', '陈拾', '昨天 22:00', 76, 68450, '真实案件'),
  new EpisodeData(5, '今晚的书是《城南旧事》', '深夜书房', '沈砚', '昨天 21:00', 54, 19870, '读书'),
  new EpisodeData(6, '拖延症不是病,是信号', '心流研究所', '阿岚', '昨天 18:00', 42, 25310, '自我成长'),
  new EpisodeData(7, '本周新歌 TOP10 与遗珠三首', '环球音乐速递', 'DJ Mango', '昨天 16:00', 58, 21450, '歌单'),
  new EpisodeData(8, '晚八点:今天的世界发生了什么', '潮汐晚间新闻', '林晚', '昨天 20:00', 20, 47120, '新闻'),
  new EpisodeData(9, '整理收纳的五个反直觉原则', '绿洲生活家', '小满', '2天前', 36, 15320, '生活方式'),
  new EpisodeData(10, '融资寒冬里,谁还在花钱?', '资本快问快答', '钱多多', '2天前', 33, 27640, '财经'),
  new EpisodeData(11, '巴黎:一条河把城市分成两种性格', '城市折叠', '苏格', '3天前', 68, 17240, '城市'),
  new EpisodeData(12, '焦虑发作时,身体在想什么', '情绪急救站', '栗子', '3天前', 45, 23870, '心理'),
  new EpisodeData(13, '深夜读者来信:关于转行', '深夜书房', '沈砚', '3天前', 39, 16420, '来信'),
  new EpisodeData(14, '谣言粉碎:隔夜水到底能不能喝', '真相调查组', '老白', '4天前', 28, 31520, '辟谣'),
  new EpisodeData(15, '周末计划:城市骑行路线推荐', '周末市集', '阿福', '4天前', 31, 12980, '出行'),
  new EpisodeData(16, '晚安信 Vol.9:慢慢来', '星海电台', '夏夜', '4天前', 52, 18450, '晚安'),
  new EpisodeData(17, '大模型落地这一年', '未来科技观察', 'Kiko', '5天前', 64, 36420, 'AI'),
  new EpisodeData(18, '便利店咖啡测评大翻车', '潮汐晚间新闻', '林晚', '5天前', 18, 28340, '闲聊'),
  new EpisodeData(19, '自由职业第一年账本公开', '资本快问快答', '钱多多', '5天前', 41, 22310, '搞钱'),
  new EpisodeData(20, '老黑胶与新爵士', '蓝调午夜场', '老周', '6天前', 59, 27650, '乐史'),
  new EpisodeData(21, '可穿戴设备十年回顾', '未来科技观察', 'Kiko', '6天前', 47, 24180, '硬件'),
  new EpisodeData(22, '你的睡眠质量及格了吗', '心流研究所', '阿岚', '6天前', 38, 30250, '睡眠'),
  new EpisodeData(23, '夜市小吃地图(南方篇)', '周末市集', '阿福', '7天前', 44, 19320, '美食'),
  new EpisodeData(24, '第 40 案:消失的储蓄罐', '迷雾档案', '陈拾', '7天前', 71, 59780, '真实案件')
];

const mockSubs: SubData[] = [
  new SubData(1, '迷雾档案', '陈拾', '悬疑', 102, 7, '今天更新', true, 98),
  new SubData(2, '硅基漫谈', '大雄', '科技', 88, 3, '昨天更新', true, 92),
  new SubData(3, '潮汐晚间新闻', '林晚', '生活', 365, 12, '每天 20:00', true, 96),
  new SubData(4, '深夜书房', '沈砚', '人文', 76, 2, '昨天更新', false, 81),
  new SubData(5, '心流研究所', '阿岚', '心理', 64, 5, '3天前更新', true, 88),
  new SubData(6, '蓝调午夜场', '老周', '音乐', 128, 0, '今天更新', false, 90),
  new SubData(7, '财经早高峰', '晨露', '商业', 412, 9, '每天 07:30', true, 94),
  new SubData(8, '环球音乐速递', 'DJ Mango', '音乐', 96, 4, '昨天更新', false, 76),
  new SubData(9, '城市折叠', '苏格', '人文', 52, 1, '4天前更新', false, 70),
  new SubData(10, '真相调查组', '老白', '悬疑', 45, 6, '3天前更新', true, 84)
];

const mockPosts: PostData[] = [
  new PostData(1, '海边的曼彻斯特', '🦭', '#深夜书单#', '《城南旧事》这期听到最后哭了,沈砚的声音太适合读这个了。', 1284, 96, '12分钟前', false),
  new PostData(2, '早八人永不认输', '☕️', '#通勤搭子#', '早高峰 15 分钟刚好一站地铁,已经连续打卡 66 天。', 842, 37, '34分钟前', true),
  new PostData(3, '铁皮青蛙', '🐸', '#播客安利#', '安利《迷雾档案》!第 41 案全程高能,建议白天听。', 2310, 187, '1小时前', false),
  new PostData(4, '夜航星', '🌙', '#助眠神器#', '雨声 + 星海电台 = 我的入睡公式,实测两周入睡时间缩短一半。', 1563, 92, '2小时前', false),
  new PostData(5, '栗子家的猫', '🐈', '#主播快回#', '阿岚什么时候更新焦虑系列啊,等一个下期!', 675, 45, '3小时前', false),
  new PostData(6, '南方以南', '🌴', '#电台回忆#', '小时候守着收音机等点歌节目,现在换成了潮汐电台,感觉又回来了。', 1092, 78, '5小时前', false),
  new PostData(7, '打工人小吴', '🧱', '#通勤搭子#', '把财经早高峰调成 1.5 倍速,地铁上听完还能买杯咖啡。', 733, 51, '7小时前', false),
  new PostData(8, '阿茶', '🍵', '#深夜书单#', '深夜书房的听众来信系列太治愈了,第 13 期转行那段听完立刻投了简历。', 1456, 83, '9小时前', true),
  new PostData(9, '风滚草', '🌵', '#播客安利#', '城市折叠讲巴黎那期,配着歌单听完了,像免费旅行。', 918, 62, '11小时前', false),
  new PostData(10, '布丁三分甜', '🍮', '#助眠神器#', '猫咪呼噜声居然真的有用……睡了这个月最沉的一觉。', 1892, 134, '13小时前', false),
  new PostData(11, '赛博道士', '🤖', '#深夜书单#', '硅基漫谈讲大模型落地那期信息量爆炸,二刷做笔记中。', 1046, 69, '昨天', false),
  new PostData(12, '慢速快门', '📷', '#电台回忆#', '用磁带机的颜值听数字电台,仪式感这块拿捏了。', 671, 28, '昨天', false),
  new PostData(13, '薄荷绿', '🌿', '#主播快回#', '求老周多放几张老黑胶!乐史系列是宝藏。', 528, 33, '2天前', false),
  new PostData(14, '深夜厨房', '🍳', '#通勤搭子#', '绿洲生活家的收纳五原则真的有用,衣柜终于合得上了。', 815, 56, '2天前', false)
      if (this.showAboutModal) {
        this.aboutModal()
      }
    }
    .width('100%')
    .height('100%')
  }
}


在这里插入图片描述

在视觉设计层面,应用采用"清新海洋风"色彩体系,以水鸭绿(#0E9384)为主色,琥珀棕(#B45309)为点缀色,配合七个分类各自的主题色,形成了层次丰富但风格统一的视觉语言。linearGradient线性渐变背景用于头部和资料卡,营造深邃的海洋质感。半透明背景色(如rgba(255,255,255,0.12))用于叠加在渐变背景上的统计行,实现微妙的玻璃质感。阴影效果通过shadow属性实现,底部Tab栏和迷你播放条使用向上偏移的阴影,营造悬浮于内容之上的层次感。

在交互反馈层面,应用在关键交互点融入了缩放、旋转、不透明度和颜色变化等多种动画效果。缩放动画用于选中态反馈(Tab按钮放大1.16倍、勋章未获得缩小0.9倍)和操作反馈(点赞放大1.18倍、暂停缩小0.86倍);旋转动画用于唱片图标的物理旋转模拟;不透明度动画用于Tab选中态的淡入淡出;颜色变化配合动画实现状态切换的平滑过渡。所有动画都使用Curve.EaseOut缓出曲线(适合出现类动画)或Curve.Linear匀速曲线(适合持续旋转),时长在150-700毫秒之间,符合移动端动画的时长规范。

通过对这款播客电台社区应用的完整剖析,我们可以看到ArkTS在构建复杂移动应用时的全貌:类型系统提供数据安全、装饰器体系提供架构骨架、声明式UI提供高效渲染、状态管理提供响应式更新、布局容器提供灵活排版、动画系统提供交互反馈。这些能力的组合,使得开发者可以用一套语言、一套框架,完成从数据建模到界面渲染到交互反馈的全栈开发。这种"一体化"的开发体验,正是鸿蒙ArkTS的核心价值所在。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐