一、引言:移动音乐应用的演进与 ArkTS 技术栈概览

在移动互联网高速发展的今天,音乐播放器早已从单纯的音频解码工具演进为承载用户情感记忆、社交偏好与个性化推荐的综合数字内容平台。从早期的本地文件播放,到在线流媒体订阅,再到如今结合 AI 算法的智能歌单推荐,音乐应用的交互复杂度和数据维度都在持续攀升。一款现代音乐应用不仅要能流畅播放音频,还需要提供精美的视觉呈现、多维度的数据统计、便捷的歌单管理以及沉浸式的个人空间体验。本应用正是一个基于 HarmonyOS(鸿蒙)ArkUI 声明式开发框架打造的音乐歌单播放器示例,它虽为演示性质,却在功能完整度和 UI 精致度上达到了相当高的水准,非常适合作为学习 ArkTS 声明式 UI 开发的范本。

在这里插入图片描述

ArkTS 是华为为 HarmonyOS 应用开发量身打造的编程语言,它在 TypeScript 的基础上进行了扩展与约束,去除了动态特性以获得更好的运行时性能和编译期检查能力。ArkTS 配合 ArkUI 的声明式范式,让开发者可以用接近自然语言的方式描述界面的结构和样式,并通过状态驱动机制实现数据与视图的自动同步。其核心思想是"状态即视图"——开发者只需关注状态数据的变化,框架会自动负责 UI 的刷新与diff,这极大降低了命令式编程中手动操作 DOM 节点的繁琐心智负担。

本应用采用了 HarmonyOS ArkUI 的经典架构模式:以 @Entry @Component 装饰的主结构作为应用入口,内部通过 @State 管理可变状态,通过 @Builder 抽离可复用的 UI 构建逻辑,通过 @Observed 标记可观察的数据模型类。整个应用围绕一个紫色主题(主色 #7B1FA2、辅色 #C2185B、背景色 #F3E5F5)展开,营造出优雅而富有艺术气息的视觉氛围,契合音乐应用的审美调性。应用包含歌单、歌手、统计、我的四大 Tab 页,覆盖了音乐应用最核心的功能场景,同时在歌单页集成了增删改查的完整 CRUD 流程,并通过自定义弹窗替代系统弹窗以保持视觉一致性。

从技术栈角度看,本应用综合运用了如下 ArkUI 核心能力:接口(interface)定义数据契约、枚举(enum)管理常量集合、@Observed 类实现可观察数据模型、@State 管理组件级响应式状态、@Builder 装饰器抽取声明式 UI 构建函数、ForEach 进行列表渲染、Scroll 实现可滚动区域、Stack/Row/Column 进行弹性布局、positionzIndex 实现弹窗层叠、layoutWeight 进行权重分配等。这些能力的组合使用,构成了一个完整、自洽的声明式 UI 开发范式。接下来,我们将对源码进行逐段深度剖析,揭示每一处设计背后的考量与实现细节。

二、数据契约层:接口定义与类型约束

interface SongItem {
  id: number
  title: string
  artist: string
  album: string
  genre: string
  duration: number
  year: number
  rating: number
  playCount: number
  isFavorite: boolean
  coverEmoji: string
  lyrics: string
  moodTags: string[]
}

interface GenreStat {
  genre: string
  count: number
  percentage: number
  color: string
}

interface MonthlyListen {
  month: string
  minutes: number
}

interface RatingBreakdown {
  stars: number
  count: number
  color: string
}

interface TabConfig {
  index: number
  label: string
  icon: string
}

interface NowPlaying {
  title: string
  artist: string
  emoji: string
  progress: number
}

在这里插入图片描述

应用的开篇并非直接进入 UI 构建,而是首先定义了一组接口(interface)来约束各业务领域的数据结构。这是 TypeScript/ArkTS 类型系统的最佳实践:先定义数据契约,再编写业务逻辑,可以让编译器在开发阶段就捕捉到类型不匹配的错误,同时也能为 IDE 提供智能提示,提升开发效率。这里一共定义了六个接口,分别对应六个不同的业务领域。

SongItem 是整个应用最核心的数据结构,它描述了一首歌曲的完整信息图谱。从基础的 idtitleartistalbum,到分类维度的 genre(流派)和 year(年份),再到统计维度的 duration(时长,以秒为单位)、rating(评分1-5)、playCount(播放次数),以及用户行为维度的 isFavorite(是否收藏),最后到视觉与内容维度的 coverEmoji(用 Emoji 代替封面图片,这是一个巧妙的演示策略,避免了引入网络图片资源)、lyrics(歌词片段)、moodTags(心情标签数组)。这种字段设计覆盖了一首歌曲从元信息到用户交互的全生命周期,体现了数据建模的完整性思维。

GenreStatMonthlyListenRatingBreakdown 三个接口服务于统计 Tab 页,分别描述流派分布、月度听歌时长和评分分布三组统计数据。值得注意的是,GenreStatRatingBreakdown 都包含 color 字段,这说明设计者在数据层就预置了视觉呈现所需的颜色信息,实现了数据与样式的绑定,这样在渲染统计柱状图时可以直接从数据中取色,无需在 UI 层再做映射判断,是一种"数据即配置"的设计哲学。

TabConfig 定义了底部导航栏每个 Tab 的配置项,包含序号 index、文字标签 label 和图标 icon。将 Tab 配置抽象为接口并配合数组使用,比散落的硬编码更具扩展性——未来若要新增 Tab,只需在数组中追加一项即可,无需修改 UI 逻辑。NowPlaying 则定义了迷你播放器展示当前播放歌曲所需的最小信息集,包含标题、歌手、封面 Emoji 和播放进度百分比,体现了"按需定义"的最小化接口原则。

三、可观察数据模型:@Observed 装饰器与 SongModel 类

@Observed
class SongModel {
  id: number
  title: string
  artist: string
  album: string
  genre: string
  duration: number
  year: number
  rating: number
  playCount: number
  isFavorite: boolean
  coverEmoji: string
  lyrics: string
  moodTags: string[]

  constructor(song: SongItem) {
    this.id = song.id
    this.title = song.title
    this.artist = song.artist
    this.album = song.album
    this.genre = song.genre
    this.duration = song.duration
    this.year = song.year
    this.rating = song.rating
    this.playCount = song.playCount
    this.isFavorite = song.isFavorite
    this.coverEmoji = song.coverEmoji
    this.lyrics = song.lyrics
    this.moodTags = song.moodTags
  }
}

在这里插入图片描述

在 ArkUI 的状态管理体系中,@Observed 是一个关键装饰器,它用于标记一个类为"可观察的"。被 @Observed 修饰的类,其实例在配合 @State@Prop@ObjectLink 等状态装饰器使用时,能够实现属性级别的细粒度观察——当对象的某个属性发生变化时,依赖该属性的 UI 组件会自动刷新。这与单纯的 @State 修改整个对象引用才会触发刷新有所不同,它提供了更精细的更新粒度。

这里定义了一个 SongModel 类,它的字段与前面定义的 SongItem 接口完全对应,但二者扮演着不同的角色:SongItem 是一个纯类型契约,用于描述数据的"形状",而 SongModel 是一个带有构造函数的可实例化类,并且被标记为可观察。构造函数接收一个 SongItem 类型的参数,然后逐字段赋值。这种"接口定义形状 + 类承载行为与可观察性"的分离设计,是 ArkTS 中推荐的工程实践,它让数据契约与运行时模型各司其职。

虽然在本应用的实际使用中,SongModel 的可观察能力并未被深度利用(例如没有通过 @ObjectLink 在子组件中实现单首歌曲的细粒度更新),但这一设计为未来的扩展预留了空间。如果后续将歌单项卡片抽取为独立的子组件,并通过 @ObjectLink 接收 SongModel 实例,那么当某首歌的 isFavoriteplayCount 发生变化时,就只有对应的那张卡片会重新渲染,而不是整个列表,从而获得更好的性能表现。这是一个具有前瞻性的架构决策。

四、枚举常量管理:MusicTab 与导航状态

enum MusicTab {
  Playlist,
  Artist,
  Stats,
  Mine
}

在这里插入图片描述

这里使用 enum(枚举)定义了应用的四个主 Tab:Playlist(歌单)、Artist(歌手)、Stats(统计)、Mine(我的)。枚举是一种在编程中管理有限常量集合的经典手段,相比魔法数字(如直接用 0、1、2、3),枚举赋予了常量以语义化的名称,使代码可读性大幅提升。当你在代码中看到 this.currentTab === MusicTab.Playlist 时,立即就能理解这是在判断当前是否处于歌单页,而 this.currentTab === 0 则需要额外的注释或上下文才能解读。

ArkTS 的枚举默认从 0 开始递增,因此 Playlist 对应 0,Artist 对应 1,Stats 对应 2,Mine 对应 3。这些数值会与 @State currentTab: number 配合使用,用于在 build() 方法中通过条件判断决定渲染哪个 Tab 页的内容。使用数值枚举而非字符串枚举,在比较运算上性能略优,且能与 tabConfigs 数组中的 index 字段直接对应,形成索引映射关系,是经过权衡的选择。

五、主组件状态管理:@State 装饰器与响应式数据

@Entry
@Component
struct MusicPlaylistApp {
  @State currentTab: number = MusicTab.Playlist
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State editIndex: number = 0
  @State deleteIndex: number = 0
  @State selectedGenre: string = '全部'
  @State nowPlaying: NowPlaying = {
    title: '夜空中最亮的星',
    artist: '逃跑计划',
    emoji: '🌟',
    progress: 45
  }

在这里插入图片描述

这一段是整个应用的状态中枢。@Entry 装饰器表示这是一个入口组件,即应用的根页面;@Component 表示这是一个自定义组件。在 ArkUI 中,每个组件都可以拥有自己的状态,而 @State 是最基础的状态装饰器,它标记的变量一旦发生变化,框架会自动重新执行 build() 方法中依赖该状态的部分,实现数据驱动的 UI 更新。

这里定义了八个状态变量,可以分为三组来理解。第一组是导航与 Tab 相关状态:currentTab 记录当前激活的 Tab 序号,初始值为 MusicTab.Playlist(即 0),它驱动着主内容区域的条件渲染逻辑。第二组是弹窗控制状态:showAddModalshowEditModalshowDeleteModal 三个布尔值分别控制新增、编辑、删除三个弹窗的显示与隐藏,初始值均为 false。这种"一个布尔值对应一个弹窗"的设计简单直观,便于独立控制。同时配合 editIndexdeleteIndex 两个数值变量,记录当前正在编辑或删除的歌曲索引,使得弹窗能够定位到正确的数据项。第三组是业务筛选与播放状态:selectedGenre 记录歌单页当前选中的流派筛选条件,初始为"全部";nowPlaying 是一个对象类型的状态,封装了迷你播放器展示当前播放歌曲所需的全部信息,初始展示"夜空中最亮的星"这首歌,播放进度为 45%。

将这些变量声明为 @State 而非普通成员变量,意味着它们都纳入了 ArkUI 的响应式系统。例如,当用户点击底部 Tab 切换时,currentTab 被重新赋值,框架检测到变化后会重新评估 build() 中的条件分支,渲染对应的新 Tab 内容;当用户点击歌曲卡片的编辑按钮时,editIndexshowEditModal 依次被赋值,编辑弹窗随即弹出并显示对应歌曲的数据。这种"状态变更→自动刷新"的机制,是声明式 UI 的核心精髓,让开发者从命令式的"先找节点再改属性"中解放出来。

六、私有数据源:静态业务数据集的初始化

  private songs: SongModel[] = [
    new SongModel({ id: 1, title: '夜空中最亮的星', artist: '逃跑计划', album: '世界', genre: '摇滚', duration: 252, year: 2011, rating: 5, playCount: 1280, isFavorite: true, coverEmoji: '🌟', lyrics: '夜空中最亮的星...', moodTags: ['励志', '夜晚'] }),
    new SongModel({ id: 2, title: '晴天', artist: '周杰伦', album: '叶惠美', genre: '流行', duration: 269, year: 2003, rating: 5, playCount: 3560, isFavorite: true, coverEmoji: '☀️', lyrics: '故事的小黄花...', moodTags: ['青春', '回忆'] }),
    // ... 共26首歌曲,涵盖流行、摇滚、民谣、电子、古典、嘻哈六大流派
    new SongModel({ id: 26, title: '好久不见', artist: '陈奕迅', album: '不想放手', genre: '流行', duration: 264, year: 2007, rating: 5, playCount: 3700, isFavorite: true, coverEmoji: '🥀', lyrics: '我来到你的城市...', moodTags: ['伤感', '回忆'] })
  ]

  private genreStats: GenreStat[] = [
    { genre: '流行', count: 9, percentage: 35, color: '#7B1FA2' },
    { genre: '摇滚', count: 3, percentage: 12, color: '#C2185B' },
    { genre: '民谣', count: 4, percentage: 15, color: '#E91E63' },
    { genre: '电子', count: 4, percentage: 15, color: '#9C27B0' },
    { genre: '古典', count: 4, percentage: 15, color: '#AB47BC' },
    { genre: '嘻哈', count: 4, percentage: 15, color: '#CE93D8' },
    { genre: 'R&B', count: 0, percentage: 3, color: '#F8BBD0' }
  ]

  private monthlyListens: MonthlyListen[] = [
    { month: '1月', minutes: 1200 },
    { month: '2月', minutes: 1800 },
    { month: '3月', minutes: 1500 },
    { month: '4月', minutes: 2200 },
    { month: '5月', minutes: 2800 },
    { month: '6月', minutes: 2400 },
    { month: '7月', minutes: 3100 }
  ]

  private ratingBreakdowns: RatingBreakdown[] = [
    { stars: 5, count: 16, color: '#7B1FA2' },
    { stars: 4, count: 8, color: '#C2185B' },
    { stars: 3, count: 2, color: '#E91E63' },
    { stars: 2, count: 0, color: '#F06292' },
    { stars: 1, count: 0, color: '#F8BBD0' }
  ]

  private tabConfigs: TabConfig[] = [
    { index: MusicTab.Playlist, label: '歌单', icon: '🎵' },
    { index: MusicTab.Artist, label: '歌手', icon: '🎤' },
    { index: MusicTab.Stats, label: '统计', icon: '📊' },
    { index: MusicTab.Mine, label: '我的', icon: '👤' }
  ]

  private genreFilters: string[] = ['全部', '流行', '摇滚', '民谣', '电子', '古典', '嘻哈']

在这里插入图片描述

这一段集中初始化了应用所需的全部静态业务数据。注意这些数据都用 private 修饰,意味着它们是组件的私有成员,不对外暴露。与前面 @State 修饰的状态变量不同,这些 private 数组并不直接参与响应式刷新——它们是"数据源",而非"响应式状态"。这种区分很重要:数据源是相对稳定的,只有在用户执行增删改操作时才会变化(本示例中增删改仅切换弹窗显示状态作演示),而响应式状态则频繁驱动 UI 刷新。

songs 数组是应用的主数据集,包含 26 首歌曲,每首都是通过 new SongModel({...}) 实例化而来。歌曲选择颇具匠心,既有华语经典(周杰伦的《晴天》《青花瓷》、邓丽君的《月亮代表我的心》),也有欧美热门(Eminem 的《Rap God》《Lose Yourself》、The Weeknd 的《Blinding Lights》),还有独立民谣(赵雷《成都》、宋冬野《斑马斑马》)和古典名曲(贝多芬《月光奏鸣曲》《第九交响曲》、肖邦《夜曲》),流派覆盖流行、摇滚、民谣、电子、古典、嘻哈六大类别,为统计页的多维数据分析提供了丰富的素材。每首歌的 coverEmoji 都经过精心挑选,用视觉化的 Emoji 暗示歌曲主题,如《夜空中最亮的星》用🌟、《海阔天空》用🌊、《青花瓷》用🏺,既节省了图片资源又增加了趣味性。

genreStatsmonthlyListensratingBreakdowns 三个数组是统计 Tab 的数据源。genreStats 的颜色从深紫到浅粉呈渐变排列,体现了设计者对色彩层次的把控;monthlyListens 记录了 1-7 月的听歌时长,数据呈波动上升趋势,7 月达到峰值 3100 分钟,能够渲染出有起伏的柱状图效果;ratingBreakdowns 显示 5 星歌曲最多(16 首),符合音乐歌单"精选好歌"的定位。

tabConfigs 数组将四个 Tab 的配置数据化,genreFilters 定义了歌单页的流派筛选选项。将这类配置提取为数组而非硬编码在 UI 中,是"数据驱动 UI"思想的体现,提升了可维护性和可扩展性。

七、应用主框架:build() 方法与整体布局架构

  build() {
    Column() {
      Column() {
        // 顶部标题栏
        Row() {
          Text('🎵 音乐歌单').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Row().layoutWeight(1)
          Text('🔍').fontSize(20).fontColor('#FFFFFF').margin({ right: 16 })
          Text('⚙️').fontSize(20).fontColor('#FFFFFF')
        }
        .width('100%').height(56).padding({ left: 20, right: 20 })
        .backgroundColor('#7B1FA2').alignItems(VerticalAlign.Center)

        // Now Playing 迷你播放器
        this.nowPlayingBuilder()

        // 内容区域
        Column() {
          if (this.currentTab === MusicTab.Playlist) {
            this.playlistTab()
          } else if (this.currentTab === MusicTab.Artist) {
            this.artistTab()
          } else if (this.currentTab === MusicTab.Stats) {
            this.statsTab()
          } else {
            this.mineTab()
          }
        }.layoutWeight(1).width('100%')

        // 底部导航栏
        this.bottomTabBar()
      }
      .width('100%').height('100%').backgroundColor('#F3E5F5')

      // 弹窗层
      if (this.showAddModal) {
        this.addModal()
      }
      if (this.showEditModal) {
        this.editModal()
      }
      if (this.showDeleteModal) {
        this.deleteModal()
      }
    }
  }

在这里插入图片描述

build() 方法是每个 ArkUI 组件的心脏,它以声明式的方式描述了组件的 UI 结构。本应用的 build() 方法展现了一个经典的三层布局架构:最外层是一个全屏 Column,其内部第一个子元素是承载主体内容的主容器 Column(占据全部宽高,背景色为淡紫 #F3E5F5),紧随其后的是三个条件渲染的弹窗层。这种"主体内容 + 弹窗层"的分层设计,确保了弹窗能够覆盖在所有内容之上,实现层叠效果。

主容器内部自上而下分为三个区域。顶部标题栏是一个 Row,高度 56,背景色为主题紫 #7B1FA2,采用 alignItems(VerticalAlign.Center) 使内容垂直居中。左侧是应用标题"🎵 音乐歌单",中间用 Row().layoutWeight(1) 作为弹性占位符将右侧两个图标推向右边——这是 ArkUI 中实现两端对齐的常用技巧,layoutWeight(1) 让这个空 Row 占据所有剩余空间,从而把后续元素挤到最右侧。右侧依次是搜索图标🔍和设置图标⚙️,为白色文字色,与紫色背景形成高对比度。

标题栏下方紧接迷你播放器 nowPlayingBuilder(),它横跨在标题栏和内容区之间,形成一种"持续在场"的播放状态提示。再下方是内容区域,使用 Column 包裹并通过 layoutWeight(1) 占据剩余空间,内部通过 if-else if-else 条件分支根据 currentTab 的值渲染对应的 Tab 页内容。这是 ArkUI 中实现页面切换的轻量级方案——相比使用 Tabs 组件或路由跳转,条件渲染更加灵活,便于对各 Tab 页进行独立定制,也便于在不同 Tab 间共享上下文状态。最底部是导航栏 bottomTabBar()

弹窗层是整体架构中一个值得关注的设计。三个弹窗(新增、编辑、删除)分别由三个独立的 @State 布尔值控制,通过 if 条件渲染。当某个布尔值为 true 时,对应弹窗被构建并渲染到界面上,由于它位于最外层 Column 的末尾,且弹窗内部使用了 position 绝对定位和 zIndex(999) 层级提升,因此能够覆盖在所有内容之上。这种将弹窗状态提升到主组件层面统一管理的方式,使得任意子区域(如歌曲卡片)都能通过修改状态来触发弹窗,实现了跨区域的交互协同。

八、迷你播放器:nowPlayingBuilder 与播放进度可视化

  @Builder nowPlayingBuilder() {
    Row() {
      Text(this.nowPlaying.emoji).fontSize(32).margin({ right: 12 })
      Column() {
        Text(this.nowPlaying.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text(this.nowPlaying.artist).fontSize(12).fontColor('#E1BEE7').margin({ top: 2 })
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)

      Row() {
        Text('⏮️').fontSize(18).margin({ right: 16 })
        Text('⏸️').fontSize(22)
        Text('⏭️').fontSize(18).margin({ left: 16 })
      }.alignItems(VerticalAlign.Center)
    }
    .width('100%').height(64).padding({ left: 20, right: 20 })
    .backgroundColor('#6A1B9A').alignItems(VerticalAlign.Center)
    .overlay()
    Row() {
      Column()
        .width(this.nowPlaying.progress + '%')
        .height(3)
        .backgroundColor('#FFB300')
        .borderRadius(2)
    }.width('100%').height(3).backgroundColor('#4A148C').margin({ top: -3 })
  }

在这里插入图片描述

@Builder 是 ArkUI 中用于定义可复用 UI 构建函数的装饰器。被 @Builder 修饰的方法可以在组件内被多次调用,每次调用都会在当前位置展开其声明的 UI 结构。这与子组件(@Component)的区别在于,@Builder 不会创建独立的组件实例,它更像是"UI 片段的宏展开",共享所属组件的状态作用域,适合用于组件内部的结构化拆分。本应用大量使用 @Builder 将各个功能区域拆分为独立的构建方法,使代码结构清晰、职责分明。

nowPlayingBuilder() 构建了横跨在标题栏下方的迷你播放器条,高度 64,背景色为更深的紫色 #6A1B9A(比标题栏的 #7B1FA2 更深),形成视觉上的层次递进。布局采用经典的三段式:左侧是封面 Emoji(32号字体),中间是歌曲信息列(标题白色粗体14号,歌手淡紫 #E1BEE7 12号,左对齐占据弹性空间),右侧是播放控制按钮组(上一曲⏮️、暂停⏸️、下一曲⏭️,中间的暂停按钮字号略大22号以突出主要操作)。这种"封面-信息-控制"的布局是音乐播放器的标准范式,用户能够在一行之内获取当前播放的核心信息并进行基本控制。

播放进度条的实现尤为巧妙。它在 Row 主体之后,通过一个独立的 Row 渲染一条 3 像素高的进度条。外层 Row 的背景色为 #4A148C(极深紫,作为进度条轨道),内部包含一个 Column,其宽度通过 this.nowPlaying.progress + '%' 动态计算(当前为 45%),背景色为金黄 #FFB300(与紫色形成互补色对比,视觉醒目),圆角 2。通过 margin({ top: -3 }) 的负边距,将进度条向上拉 3 像素,使其紧贴在播放器条的底部边缘。.overlay() 修饰符在这里用于确保布局的正确层叠。这种用纯布局组件实现进度条的方式,避免了引入额外图形组件,是 ArkUI 中轻量级可视化技巧的典型应用。

九、底部导航栏:bottomTabBar 与 ForEach 列表渲染

  @Builder bottomTabBar() {
    Row() {
      ForEach(this.tabConfigs, (item: TabConfig) => {
        Column() {
          Text(item.icon).fontSize(22)
          Text(item.label).fontSize(10).fontColor(this.currentTab === item.index ? '#7B1FA2' : '#9E9E9E')
            .margin({ top: 2 })
        }.alignItems(HorizontalAlign.Center).layoutWeight(1)
        .onClick(() => { this.currentTab = item.index })
      }, (item: TabConfig) => item.index.toString())
    }
    .width('100%').height(56).backgroundColor('#FFFFFF')
    .alignItems(VerticalAlign.Center)
    .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
  }

底部导航栏是应用切换 Tab 的核心交互入口,它通过 ForEach 遍历 tabConfigs 数组动态生成四个导航项。ForEach 是 ArkUI 中处理列表渲染的核心组件,它接受三个参数:数据源数组、子项生成函数、键值生成函数。这里的数据源是 tabConfigs,子项生成函数为每个 TabConfig 创建一个包含图标和文字标签的 Column,键值生成函数返回 item.index.toString() 作为唯一标识。

每个导航项是一个垂直排列的 Column:顶部是 22 号字体的 Emoji 图标,下方是 10 号字体的标签文字。标签文字的颜色通过三元运算符动态决定——当 this.currentTab === item.index 时(即当前 Tab 与该项匹配),文字变为主题紫 #7B1FA2,否则为灰色 #9E9E9E。这种选中态的高亮反馈,是 Tab 导航的标配交互。每个 Column 设置了 layoutWeight(1),使得四个导航项在 Row 中平均分配宽度,形成等间距分布。

onClick 事件回调将 this.currentTab 赋值为被点击项的 index,这一赋值会触发响应式系统重新执行 build(),进而重新评估内容区域的 if-else 条件分支,渲染对应的新 Tab 内容,同时导航栏的选中态颜色也会同步刷新。整个导航交互形成了一个闭环:点击→状态变更→UI 刷新。导航栏整体设置了白色背景、56 高度,并通过 shadow 添加了向上偏移 2 像素的轻微阴影(offsetY: -2color: '#1A000000' 表示 10% 不透明度的黑色),营造出导航栏悬浮于内容上方的视觉层次感。

键值生成函数 (item: TabConfig) => item.index.toString()ForEach 性能优化的关键。它为每个列表项提供一个唯一且稳定的键值,框架通过键值进行 diff 比对,当数据源变化时只更新发生变化的项,而非整体重建。虽然本例中 Tab 配置是静态的,但养成提供稳定键值的习惯,在动态列表场景下能显著提升渲染性能。

十、歌单 Tab:playlistTab 与流派筛选交互

  @Builder playlistTab() {
    Column() {
      // 分类筛选
      Scroll() {
        Row() {
          ForEach(this.genreFilters, (genre: string) => {
            Text(genre)
              .fontSize(12).fontColor(this.selectedGenre === genre ? '#FFFFFF' : '#7B1FA2')
              .backgroundColor(this.selectedGenre === genre ? '#7B1FA2' : '#F3E5F5')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(16).margin({ right: 8 })
              .onClick(() => { this.selectedGenre = genre })
          }, (genre: string) => genre)
        }.padding({ left: 16, right: 16 })
      }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(40)

      // 歌曲列表
      Scroll() {
        Column() {
          this.songItemBuilder(this.songs[0])
          this.songItemBuilder(this.songs[1])
          // ... 依次调用26次
          this.songItemBuilder(this.songs[25])
          Row() {
            Text('➕ 添加新歌曲').fontSize(14).fontColor('#7B1FA2').fontWeight(FontWeight.Bold)
          }.width('100%').height(48).justifyContent(FlexAlign.Center)
          .backgroundColor('#E1BEE7').borderRadius(12).margin({ top: 12 })
          .onClick(() => { this.showAddModal = true })
        }.padding({ left: 16, right: 16, top: 4, bottom: 16 })
      }.layoutWeight(1).scrollBar(BarState.Off)
    }.width('100%').height('100%')
  }

歌单 Tab 是应用最核心的功能页面,它由上下两部分组成:顶部的流派筛选条和下方的歌曲列表。这种"筛选+列表"的组合是内容浏览页的经典结构,让用户能够快速按维度过滤内容。

流派筛选条使用 Scroll 组件包裹一个 Row,设置 scrollable(ScrollDirection.Horizontal) 实现水平滚动,scrollBar(BarState.Off) 隐藏滚动条以保持视觉简洁。筛选条高度固定 40。内部通过 ForEach 遍历 genreFilters 数组(包含"全部"、“流行”、“摇滚”、“民谣”、“电子”、“古典”、"嘻哈"七个选项),为每个流派生成一个 Text 标签。标签的样式根据选中状态动态变化:选中时文字为白色、背景为主题紫 #7B1FA2;未选中时文字为主题紫、背景为浅紫 #F3E5F5。这种"反色切换"的视觉设计,让用户能够一眼识别当前选中的筛选条件。每个标签设置了 16 的圆角,形成药丸形(pill shape)标签,搭配 padding 内边距和 margin({ right: 8 }) 间距,视觉上疏密得当。点击事件将 this.selectedGenre 赋值为被点击的流派名称,触发筛选条选中态的刷新。

歌曲列表区域使用 Scroll 包裹一个 Column,占据剩余空间(layoutWeight(1)),同样隐藏滚动条。这里有一个值得注意的实现细节:列表项并非通过 ForEach 动态生成,而是手动逐行调用了 26 次 this.songItemBuilder(this.songs[0])this.songItemBuilder(this.songs[25])。这种写法虽然冗长,但确保了每个歌曲项都被独立渲染,在某些 ArkUI 版本中可以避免 ForEach@Builder 配合时的潜在渲染问题,是一种保守但稳定的策略。列表底部还有一个"添加新歌曲"按钮,点击后将 showAddModal 设为 true,触发新增弹窗。按钮采用浅紫背景 #E1BEE7、紫色文字、12 圆角、居中对齐,视觉上与列表项有所区分但又保持主题一致性。

十一、歌曲卡片:songItemBuilder 与富信息展示

  @Builder songItemBuilder(song: SongModel) {
    Row() {
      // 封面
      Stack() {
        Text(song.coverEmoji).fontSize(28)
      }.width(52).height(52).backgroundColor('#E1BEE7').borderRadius(10)
      .align(Alignment.Center)

      Column() {
        Row() {
          Text(song.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
          if (song.isFavorite) {
            Text('❤️').fontSize(12).margin({ left: 6 })
          }
        }.width('100%').alignItems(VerticalAlign.Center)

        Row() {
          Text(song.artist + ' · ' + song.album).fontSize(12).fontColor('#757575')
        }.width('100%').margin({ top: 3 })

        Row() {
          Text(song.genre).fontSize(10).fontColor('#7B1FA2')
            .backgroundColor('#F3E5F5').padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius(4)
          Text('▶ ' + song.playCount + '次').fontSize(10).fontColor('#9E9E9E').margin({ left: 8 })
          Text('⭐' + song.rating).fontSize(10).fontColor('#FFB300').margin({ left: 8 })
        }.width('100%').margin({ top: 4 }).alignItems(VerticalAlign.Center)
      }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)

      Column() {
        Text(this.formatDuration(song.duration)).fontSize(12).fontColor('#9E9E9E')
        Row() {
          Text('✏️').fontSize(14).margin({ right: 8 })
            .onClick(() => {
              this.editIndex = song.id - 1
              this.showEditModal = true
            })
          Text('🗑️').fontSize(14)
            .onClick(() => {
              this.deleteIndex = song.id - 1
              this.showDeleteModal = true
            })
        }.margin({ top: 6 })
      }.alignItems(HorizontalAlign.End)
    }
    .width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
    .backgroundColor('#FFFFFF').borderRadius(12).margin({ top: 8 })
    .alignItems(VerticalAlign.Center)
  }

songItemBuilder 是整个应用中信息密度最高的 UI 构建方法,它接收一个 SongModel 参数,生成一张包含封面、标题、歌手专辑、流派标签、播放次数、评分、时长、编辑删除操作的歌曲卡片。这张卡片采用了"左封面-中信息-右操作"的三栏布局,是列表项设计的高度成熟范式。

左侧封面区使用 Stack 容器(52x52,浅紫背景 #E1BEE7,10 圆角),居中放置 28 号字体的 Emoji 封面。Stack 在这里的作用是让 Emoji 在方形背景中居中显示,align(Alignment.Center) 确保对齐。中间信息区是一个 Column,占据剩余空间(layoutWeight(1)),包含三行信息:第一行是歌曲标题(15号粗体,深灰 #212121),如果该歌曲被收藏(song.isFavoritetrue),则通过条件渲染在标题右侧添加一个❤️图标——这是条件渲染在列表项中的精巧应用,让收藏状态一目了然;第二行是"歌手·专辑"的拼接文本(12号灰色 #757575),用中点分隔符连接两个信息维度;第三行是一组标签信息,包括流派标签(10号紫色文字配浅紫背景,4 圆角,形成小药丸标签)、播放次数(▶ 图标+数字)、评分(⭐图标+数字),三者通过 margin({ left: 8 }) 间距排列。

右侧操作区同样是一个 Column,右对齐(alignItems(HorizontalAlign.End))。顶部显示格式化后的时长(通过 this.formatDuration(song.duration) 将秒转为"分:秒"格式),下方是编辑✏️和删除🗑️两个图标按钮。编辑按钮的 onClick 回调将 this.editIndex 设为 song.id - 1(利用 id 从 1 开始递增的特性,将其转为从 0 开始的数组索引),然后 this.showEditModal = true 触发编辑弹窗。删除按钮的逻辑类似,设置 deleteIndex 并触发删除确认弹窗。这里 song.id - 1 的索引计算方式虽然巧妙,但也隐含了"id 必须从 1 开始连续递增"的假设,在真实场景中更推荐直接在数据中维护索引或使用 findIndex 查找。

整个卡片设置了白色背景、12 圆角、12 水平内边距和 10 垂直内边距,卡片间距通过 margin({ top: 8 }) 实现。在淡紫色页面背景上,白色卡片形成了清晰的视觉分层,符合 Material Design 的卡片式设计语言。

十二、歌手 Tab:artistTab 与歌手卡片列表

  @Builder artistTab() {
    Scroll() {
      Column() {
        this.artistCardBuilder('周杰伦', '华语流行天王', '🎤', 6, '#7B1FA2')
        this.artistCardBuilder('Eminem', '说唱之神', '👑', 2, '#C2185B')
        this.artistCardBuilder('逃跑计划', '摇滚乐队', '🎸', 1, '#E91E63')
        this.artistCardBuilder('赵雷', '民谣歌手', '🪕', 1, '#9C27B0')
        this.artistCardBuilder('邓紫棋', '实力唱将', '💫', 1, '#AB47BC')
        this.artistCardBuilder('The Weeknd', 'R&B天王', '🌙', 1, '#CE93D8')
        this.artistCardBuilder('贝多芬', '古典大师', '🎼', 2, '#7B1FA2')
        this.artistCardBuilder('朴树', '民谣摇滚', '🍂', 1, '#C2185B')
        this.artistCardBuilder('陈奕迅', '歌神', '🎤', 1, '#E91E63')
        this.artistCardBuilder('Travis Scott', '嘻哈巨星', '🎪', 1, '#9C27B0')
      }.padding({ left: 16, right: 16, top: 12, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

  @Builder artistCardBuilder(name: string, desc: string, emoji: string, songCount: number, color: string) {
    Row() {
      Stack() {
        Text(emoji).fontSize(32)
      }.width(60).height(60).backgroundColor(color).borderRadius(30)
      .align(Alignment.Center)

      Column() {
        Text(name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        Text(desc).fontSize(12).fontColor('#757575').margin({ top: 4 })
        Text(songCount + ' 首歌曲').fontSize(11).fontColor('#7B1FA2').margin({ top: 4 })
      }.layoutWeight(1).margin({ left: 14 }).alignItems(HorizontalAlign.Start)

      Text('▶').fontSize(20).fontColor('#7B1FA2')
    }
    .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 10 })
    .alignItems(VerticalAlign.Center)
  }

歌手 Tab 展示了一个歌手列表,包含 10 位歌手,涵盖了华语、欧美、古典等不同领域。与歌单 Tab 类似,歌手 Tab 也使用 Scroll 包裹 Column 实现可滚动列表,但每张卡片的信息维度与歌曲卡片不同,更侧重于展示歌手的身份标识和作品数量。

artistCardBuilder 是歌手卡片的构建方法,它接收五个参数:歌手名 name、描述 desc、头像 Emoji emoji、歌曲数量 songCount、头像背景色 color。这种多参数的设计让每张卡片都能被独立定制——特别是 color 参数,让 10 位歌手的头像背景色在紫色系内呈现从深到浅的变化(#7B1FA2#C2185B#E91E63#9C27B0#AB47BC#CE93D8 循环),避免了视觉上的单调。

卡片布局同样是三栏式:左侧是 60x60 的圆形头像(borderRadius(30 实现圆形),使用 Stack 居中放置 32 号 Emoji;中间是歌手信息列,包含歌手名(16号粗体)、描述(12号灰色)、歌曲数量(11号紫色,如"6 首歌曲");右侧是一个紫色的播放按钮▶。整个卡片白色背景、14 圆角、上下间距 10,比歌曲卡片的 12 圆角略大,视觉上更加圆润,形成两个 Tab 页之间的微妙差异。

值得注意的是,歌手数据是直接在 artistTab() 中通过参数硬编码传入的,而非从 songs 数组动态聚合。这在演示场景下简化了实现,但在真实应用中,更合理的做法是从歌曲数据中按歌手分组统计,动态生成歌手列表及其歌曲数量。这也反映了示例代码与生产代码在数据流设计上的差异。

十三、统计 Tab:statsTab 与多维度数据可视化

  @Builder statsTab() {
    Scroll() {
      Column() {
        // 总听歌时长
        Row() {
          Column() {
            Text('⏱️ 总听歌时长').fontSize(12).fontColor('#757575')
            Text('18,560 分钟').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#7B1FA2')
              .margin({ top: 4 })
            Text('约 309 小时').fontSize(11).fontColor('#9E9E9E').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)

          Column() {
            Text('🎶 歌曲总数').fontSize(12).fontColor('#757575')
            Text('26').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#C2185B')
              .margin({ top: 4 })
            Text('7种风格').fontSize(11).fontColor('#9E9E9E').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

        // 流派分布
        Row() {
          Text('🏷️ 流派分布').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.genreBarBuilder(this.genreStats[0])
          // ... 7条流派柱状图
          this.genreBarBuilder(this.genreStats[6])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

        // 每月听歌时长
        Row() {
          Text('📈 每月听歌时长').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.monthlyBarBuilder(this.monthlyListens[0])
          // ... 7条月度柱状图
          this.monthlyBarBuilder(this.monthlyListens[6])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

        // 评分分布
        Row() {
          Text('⭐ 评分分布').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.ratingBarBuilder(this.ratingBreakdowns[0])
          // ... 5条评分柱状图
          this.ratingBarBuilder(this.ratingBreakdowns[4])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
      }.padding({ left: 16, right: 16, top: 12, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

统计 Tab 是应用中最具数据可视化特色的页面,它将用户的听歌行为数据以多种图表形式呈现,帮助用户直观了解自己的音乐偏好。整个页面通过 Scroll 包裹,内容自上而下分为四个区块:总览数据卡、流派分布图、月度听歌时长图、评分分布图,每个区块之间通过 margin({ top: 20 }) 形成明显的视觉分隔,标题用 16 号粗体搭配 Emoji 图标增强辨识度。

总览数据卡是一个 Row 内含两个等宽的 Column(各占 layoutWeight(1)),左侧展示总听歌时长(18,560 分钟,约 309 小时,紫色 #7B1FA2),右侧展示歌曲总数(26 首,7 种风格,品红 #C2185B)。每个数据块采用"标签-主数值-辅助说明"的三行结构,主数值用 24 号粗体突出显示,辅助说明用 11 号灰色提供上下文。这种"大数字+小标签"的设计是数据仪表盘的经典视觉语言,让用户在扫视中即可获取关键指标。

三个图表区块都采用白色卡片背景、14 圆角的容器包裹柱状图条目。每个区块内通过循环调用对应的 @Builder 方法逐条渲染柱状图——流派分布 7 条、月度时长 7 条、评分分布 5 条。与歌单列表类似,这里也采用了手动逐条调用的方式而非 ForEach,保持了一致的编码风格。

十四、柱状图构建器:三种数据可视化的统一模式

  @Builder genreBarBuilder(stat: GenreStat) {
    Row() {
      Text(stat.genre).fontSize(12).fontColor('#424242').width(50)
      Row() {
        Column()
          .width(stat.percentage + '%')
          .height(10)
          .backgroundColor(stat.color)
          .borderRadius(5)
      }.layoutWeight(1).height(10).backgroundColor('#F5F5F5').borderRadius(5)
      Text(stat.count + '首').fontSize(11).fontColor('#757575').width(36).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

  @Builder monthlyBarBuilder(item: MonthlyListen) {
    Row() {
      Text(item.month).fontSize(12).fontColor('#424242').width(40)
      Row() {
        Column()
          .width(item.minutes / 35 + '%')
          .height(14)
          .backgroundColor('#7B1FA2')
          .borderRadius(7)
      }.layoutWeight(1).height(14).backgroundColor('#F5F5F5').borderRadius(7)
      Text(item.minutes + '分').fontSize(11).fontColor('#757575').width(56).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

  @Builder ratingBarBuilder(item: RatingBreakdown) {
    Row() {
      Text(item.stars + '星').fontSize(12).fontColor('#424242').width(40)
      Row() {
        Column()
          .width(item.count * 5 + '%')
          .height(12)
          .backgroundColor(item.color)
          .borderRadius(6)
      }.layoutWeight(1).height(12).backgroundColor('#F5F5F5').borderRadius(6)
      Text(item.count + '首').fontSize(11).fontColor('#757575').width(36).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

这三个 @Builder 方法分别用于渲染流派分布、月度听歌时长和评分分布的横向柱状图条目。它们在结构上高度一致,都采用了"标签-进度条-数值"的三段式 Row 布局,但各自在细节参数上有所差异,体现了"统一模式、差异化配置"的设计思想。

三者共同的结构是:左侧固定宽度的文字标签(流派名50宽、月份40宽、星级40宽),中间是占据弹性空间的进度条容器(layoutWeight(1)),右侧是固定宽度的数值文本(居中对齐)。进度条的实现方式完全相同:外层 Row 作为轨道(浅灰 #F5F5F5 背景),内部一个 Column 作为填充条,其宽度通过百分比字符串动态计算。这种"容器+填充"的双层结构是纯 CSS/ArkUI 实现进度条的标准做法。

三者的差异主要体现在三个方面。第一,宽度计算公式不同:流派分布直接用 stat.percentage + '%'(数据中已预设百分比);月度时长用 item.minutes / 35 + '%'(将分钟数除以 35 转为百分比,即 3500 分钟对应满格 100%);评分分布用 item.count * 5 + '%'(每首歌曲占 5% 的宽度,16 首即 80%)。第二,柱条高度不同:流派 10、月度 14、评分 12,月度略高以突出时间序列数据的重要性。第三,颜色策略不同:流派和评分从数据对象的 color 字段取色(每条不同),月度统一使用主题紫 #7B1FA2。这些差异都是根据各自数据的语义特点量身定制的。

这种将柱状图拆分为独立 @Builder 的做法,使得图表的渲染逻辑高度内聚,如果未来需要调整柱条样式(如增加动画、添加点击交互),只需修改对应的方法即可,不会影响其他图表。同时,三者的结构一致性也便于维护者快速理解代码意图。

十五、我的 Tab:mineTab 与个人中心页面

  @Builder mineTab() {
    Scroll() {
      Column() {
        // 用户信息卡片
        Row() {
          Stack() {
            Text('🎧').fontSize(40)
          }.width(80).height(80).backgroundColor('#7B1FA2').borderRadius(40).align(Alignment.Center)

          Column() {
            Text('音乐爱好者').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('ID: MUSIC_2026').fontSize(12).fontColor('#E1BEE7').margin({ top: 4 })
            Row() {
              Text('VIP会员').fontSize(10).fontColor('#FFB300')
                .backgroundColor('#311B92').padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .borderRadius(8)
            }.margin({ top: 6 })
          }.layoutWeight(1).margin({ left: 16 }).alignItems(HorizontalAlign.Start)
        }.width('100%').padding(20).backgroundColor('#7B1FA2').borderRadius(16)

        // 统计数据
        Row() {
          Column() {
            Text('26').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#7B1FA2')
            Text('歌曲').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('10').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#7B1FA2')
            Text('歌手').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('12').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C2185B')
            Text('收藏').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('309h').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C2185B')
            Text('时长').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 12 })

        // 功能列表
        Column() {
          this.menuItemBuilder('❤️', '我的收藏', '12首歌曲')
          this.menuItemBuilder('📅', '最近播放', '查看历史')
          this.menuItemBuilder('📥', '下载管理', '8首已下载')
          this.menuItemBuilder('📤', '分享歌单', '推荐给好友')
          this.menuItemBuilder('⚙️', '设置', '音质·播放')
          this.menuItemBuilder('ℹ️', '关于', '版本 v2.6.0')
        }.width('100%').backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 12 })
      }.padding({ left: 16, right: 16, top: 16, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

  @Builder menuItemBuilder(icon: string, title: string, desc: string) {
    Row() {
      Text(icon).fontSize(20).width(32)
      Text(title).fontSize(14).fontColor('#212121').layoutWeight(1)
      Text(desc).fontSize(12).fontColor('#9E9E9E')
      Text('›').fontSize(18).fontColor('#BDBDBD').margin({ left: 8 })
    }.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .alignItems(VerticalAlign.Center)
  }

我的 Tab 是应用的个人中心页面,它由三个区块组成:用户信息卡片、统计数据栏、功能菜单列表,自上而下依次排列在可滚动的 Scroll 容器中。这个页面的视觉设计从顶部的深紫色沉浸式卡片过渡到下方的白色卡片列表,形成了从"身份标识"到"功能导航"的层次递进。

用户信息卡片是整个页面视觉冲击力最强的元素。它采用主题紫 #7B1FA2 作为背景,16 圆角,内边距 20。左侧是 80x80 的圆形头像(borderRadius(40),使用 Stack 居中放置 40 号的🎧 Emoji。右侧是用户信息列:用户名"音乐爱好者"(18号白色粗体)、用户 ID"MUSIC_2026"(12号淡紫 #E1BEE7)、VIP 会员标签(10号金色 #FFB300 文字配深紫 #311B92 背景,8 圆角)。VIP 标签的色彩搭配——金色文字配深紫底——营造出尊贵感,是会员体系常见的视觉语言。整个卡片因为紫色背景的存在,在淡紫页面背景上形成了强烈的视觉聚焦,成为用户进入"我的"页面的第一视觉落点。

统计数据栏是一个四等分的 Row,展示歌曲数(26)、歌手数(10)、收藏数(12)、听歌时长(309h)四个指标。每个数据块居中对齐,数值用 20 号粗体,前两个为紫色 #7B1FA2,后两个为品红 #C2185B,形成色彩节奏变化;标签用 11 号灰色。这种紧凑的四宫格数据展示,让用户在不滚动的情况下即可了解自己的音乐资产概况。

功能菜单列表是一个白色卡片容器内包含 6 个菜单项,通过 menuItemBuilder 逐项构建。每个菜单项是"图标-标题-描述-箭头"的四段式 Row:左侧 32 宽的 Emoji 图标、中间占据弹性空间的标题(14号深灰)、右侧灰色描述文本、最右侧灰色向右箭头›。layoutWeight(1) 让标题占据中间所有空间,将描述和箭头推向右侧。这种列表项布局是设置页/个人中心的标准范式,用户能够一眼识别每项的功能并知道可以点击进入。

十六、弹窗基础设施:modalBg 与层叠定位

  @Builder modalBg(onClose: () => void) {
    Column().width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.5)').onClick(onClose)
  }

这是一个弹窗基础设施构建器,它创建一个全屏半透明黑色遮罩层,背景色为 rgba(0,0,0,0.5)(50% 透明度的黑色),并绑定了 onClick 事件回调 onClose。接收一个函数类型的参数 onClose: () => void,这是 ArkTS 中将行为(而非数据)作为参数传递的典型用法——调用方传入一个箭头函数,定义遮罩被点击时执行的操作(通常是关闭弹窗)。

这个遮罩层是所有三个弹窗(新增、编辑、删除)的公共组件,被它们各自调用以实现"点击遮罩关闭弹窗"的交互。这种提取公共逻辑的做法,避免了在三个弹窗中重复编写遮罩代码,体现了 DRY(Don’t Repeat Yourself)原则。半透明遮罩的视觉作用是让用户聚焦于弹窗内容,同时暗示弹窗外的内容不可交互,是模态弹窗的标准设计。

十七、新增弹窗:addModal 与表单构建

  @Builder addModal() {
    Column() {
      this.modalBg(() => { this.showAddModal = false })
      Column() {
        Row() {
          Text('➕ 新增歌曲').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Row().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showAddModal = false })
        }.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#F0F0F0')
        Scroll() {
          Column() {
            this.formFieldBuilder('歌曲名称', '请输入歌曲名称')
            this.formFieldBuilder('歌手', '请输入歌手名')
            this.formFieldBuilder('专辑', '请输入专辑名')
            this.formFieldBuilder('流派', '流行/摇滚/民谣/电子/古典/嘻哈')
            this.formFieldBuilder('时长(秒)', '请输入数字')
            this.formFieldBuilder('年份', '如 2024')
            this.formFieldBuilder('评分(1-5)', '请输入1-5')
            this.formFieldBuilder('播放次数', '请输入数字')
            this.formFieldBuilder('封面Emoji', '如 🎵')
            this.formFieldBuilder('歌词', '请输入歌词')
            this.formFieldBuilder('心情标签', '如 励志,夜晚')
          }.padding({ left: 20, right: 20 })
        }.layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .onClick(() => { this.showAddModal = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#7B1FA2').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showAddModal = false })
        }.width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      }
      .width('90%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(18)
      .position({ x: '5%', y: '8%' }).shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

新增弹窗是应用中结构最复杂的 UI 组件之一,它实现了一个完整的歌曲信息录入表单。弹窗的整体结构是一个全屏 ColumnzIndex(999) 确保层叠在最顶层,position({ x: 0, y: 0 }) 绝对定位覆盖全屏),内部分为两层:第一层是 modalBg 遮罩(点击关闭弹窗),第二层是实际的弹窗内容卡片。

弹窗内容卡片宽度 90%(width('90%')),水平居中通过 position({ x: '5%', y: '8%' }) 实现(左边距 5% 使其居中,顶部偏移 8%),最大高度限制为 80%(constraintSize({ maxHeight: '80%' })),白色背景、18 圆角,配以向下偏移 4 像素的大半径阴影(radius: 20, color: '#33000000'),营造出悬浮于页面上方的立体效果。卡片内部自上而下分为三部分:标题栏、可滚动表单区、底部按钮栏。

标题栏是一个 Row,包含标题文字"➕ 新增歌曲"(18号粗体)、一个 layoutWeight(1) 的空 Row 占位符、以及一个关闭按钮✕。关闭按钮的 onClickshowAddModal 设为 false,触发弹窗消失。标题栏下方有一条 Divider 分割线,颜色为极浅灰 #F0F0F0,将标题与表单内容视觉分隔。

表单区使用 Scroll 包裹(layoutWeight(1) 占据卡片中间所有空间),内部是一个 Column,通过 11 次调用 formFieldBuilder 生成 11 个表单字段:歌曲名称、歌手、专辑、流派、时长、年份、评分、播放次数、封面 Emoji、歌词、心情标签。使用 Scroll 的原因是表单字段较多,可能超出弹窗最大高度,需要可滚动查看。constraintSizemaxHeight: '80%' 配合 Scroll,确保弹窗不会占满全屏,同时表单内容可以滚动浏览。

底部按钮栏是一个 RowjustifyContent(FlexAlign.Center) 使两个按钮居中排列。"取消"按钮为浅灰背景灰色文字,"保存"按钮为主题紫背景白色文字,两者都设置了 20 的圆角形成药丸形按钮,通过 margin({ left: 12 }) 间隔。两个按钮的 onClick 目前都将 showAddModal 设为 false(关闭弹窗),在真实应用中"保存"按钮还需要采集表单数据并写入数据源。

十八、编辑弹窗:editModal 与数据回填

  @Builder editModal() {
    Column() {
      this.modalBg(() => { this.showEditModal = false })
      Column() {
        Row() {
          Text('✏️ 编辑歌曲').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Row().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showEditModal = false })
        }.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#F0F0F0')
        Scroll() {
          Column() {
            this.editFieldBuilder('歌曲名称', this.songs[this.editIndex].title)
            this.editFieldBuilder('歌手', this.songs[this.editIndex].artist)
            this.editFieldBuilder('专辑', this.songs[this.editIndex].album)
            this.editFieldBuilder('流派', this.songs[this.editIndex].genre)
            this.editFieldBuilder('时长(秒)', this.songs[this.editIndex].duration.toString())
            this.editFieldBuilder('年份', this.songs[this.editIndex].year.toString())
            this.editFieldBuilder('评分', this.songs[this.editIndex].rating.toString())
            this.editFieldBuilder('播放次数', this.songs[this.editIndex].playCount.toString())
          }.padding({ left: 20, right: 20 })
        }.layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .onClick(() => { this.showEditModal = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#C2185B').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showEditModal = false })
        }.width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      }
      .width('90%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(18)
      .position({ x: '5%', y: '8%' }).shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

编辑弹窗在结构上与新增弹窗几乎完全一致,但有两个关键差异。第一个差异是数据回填:编辑弹窗的每个表单字段都通过 this.songs[this.editIndex] 获取当前编辑歌曲的对应属性作为初始值,传入 editFieldBuildereditIndex 是在歌曲卡片点击编辑按钮时设置的(值为 song.id - 1),因此弹窗打开时即显示被编辑歌曲的现有数据,用户可以在原数据基础上修改。数值类型的字段(如 durationyearratingplayCount)通过 .toString() 转为字符串,因为 TextInput 组件接收的 text 参数是字符串类型。

第二个差异是视觉细节:编辑弹窗的"保存"按钮背景色为品红 #C2185B(而非新增弹窗的主题紫 #7B1FA2),这是一个微妙的色彩区分,暗示"编辑"与"新增"是不同的操作类型。编辑弹窗的表单字段数量为 8 个(比新增的 11 个少),去掉了封面 Emoji、歌词和心情标签三个字段,可能是因为这些字段在编辑场景下修改频率较低。这种根据操作场景裁剪字段的做法,体现了对用户实际使用流程的考虑。

十九、删除确认弹窗:deleteModal 与风险操作保护

  @Builder deleteModal() {
    Column() {
      this.modalBg(() => { this.showDeleteModal = false })
      Column() {
        Text('🗑️').fontSize(40).margin({ top: 24 })
        Text('确认删除?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          .margin({ top: 12 })
        Text('删除后不可恢复,确定要删除「' + this.songs[this.deleteIndex].title + '」吗?')
          .fontSize(13).fontColor('#757575').textAlign(TextAlign.Center)
          .margin({ top: 8, left: 24, right: 24 })

        Row() {
          Text('取消').fontSize(14).fontColor('#666666')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 32, right: 32, top: 10, bottom: 10 })
            .onClick(() => { this.showDeleteModal = false })
          Text('删除').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#C2185B').borderRadius(20)
            .padding({ left: 32, right: 32, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showDeleteModal = false })
        }.margin({ top: 24, bottom: 24 }).justifyContent(FlexAlign.Center)
      }.width('80%').backgroundColor('#FFFFFF').borderRadius(18)
      .alignItems(HorizontalAlign.Center)
      .shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }.width('100%').height('100%').justifyContent(FlexAlign.Center)
    .position({ x: 0, y: 0 }).zIndex(999)
  }

删除确认弹窗与前两个弹窗在风格上有所不同——它是一个居中显示的小型对话框,而非顶部偏移的大表单弹窗。这种差异是有意为之:删除是一个高风险的不可逆操作,需要用户集中注意力确认,居中的小型弹窗比满屏表单更能营造"需要认真对待"的氛围。

弹窗内容卡片宽度 80%,白色背景、18 圆角、居中对齐(alignItems(HorizontalAlign.Center)),内容垂直排列:顶部是 40 号的🗑️垃圾桶图标(margin({ top: 24 }) 留出顶部空间),接着是"确认删除?"标题(18号粗体),然后是包含歌曲名的确认提示文本(13号灰色,居中对齐,通过 this.songs[this.deleteIndex].title 动态插入被删除歌曲的标题),最后是取消和删除两个按钮。确认文本中使用了书名号「」包裹歌曲名,这是中文排版中标注作品名的规范用法,体现了对细节的考究。

按钮组中,"取消"为浅灰背景灰色文字,"删除"为品红 #C2185B 背景白色文字——品红色在这里起到警示作用,暗示这是一个具有破坏性的操作。两个按钮的 padding 水平内边距为 32(比新增/编辑弹窗的 24 更宽),使按钮更醒目。外层 Column 使用 justifyContent(FlexAlign.Center) 使整个弹窗在垂直方向居中,配合 positionzIndex 覆盖全屏。

这种"二次确认"机制是防止用户误删除的标准设计模式。在真实应用中,"删除"按钮的 onClick 还应该执行实际的数据删除操作(从 songs 数组中移除对应项),而不仅仅是关闭弹窗。

二十、表单字段构建器:formFieldBuilder 与 editFieldBuilder

  @Builder formFieldBuilder(label: string, placeholder: string) {
    Column() {
      Text(label).fontSize(12).fontColor('#757575').margin({ bottom: 6 })
      TextInput({ placeholder: placeholder })
        .fontSize(14).height(40).borderRadius(10)
        .backgroundColor('#F5F5F5')
    }.width('100%').margin({ top: 12 }).alignItems(HorizontalAlign.Start)
  }

  @Builder editFieldBuilder(label: string, value: string) {
    Column() {
      Text(label).fontSize(12).fontColor('#757575').margin({ bottom: 6 })
      TextInput({ text: value })
        .fontSize(14).height(40).borderRadius(10)
        .backgroundColor('#F5F5F5')
    }.width('100%').margin({ top: 12 }).alignItems(HorizontalAlign.Start)
  }

这两个 @Builder 方法分别用于构建新增弹窗和编辑弹窗的表单字段,它们在结构上完全一致——都是"标签+输入框"的 Column——唯一的区别在于 TextInput 组件的初始化参数不同。formFieldBuilder 使用 placeholder(占位提示文本),适用于没有初始值的新增场景;editFieldBuilder 使用 text(预填文本),适用于需要回填已有数据的编辑场景。

每个表单字段是一个 Column:顶部是 12 号灰色的字段标签(margin({ bottom: 6 }) 与输入框保持间距),下方是 TextInput 输入框(14号字体、40 高度、10 圆角、浅灰 #F5F5F5 背景)。浅灰背景的输入框比传统的带边框输入框更加现代和简洁,与 Material Design 的扁平化输入风格一致。整个字段块通过 margin({ top: 12 }) 与上方字段保持间距,alignItems(HorizontalAlign.Start) 确保标签和输入框左对齐。

将表单字段抽取为独立的 @Builder,使得新增和编辑弹窗的表单部分可以通过循环调用快速构建,避免了重复编写标签和输入框的样板代码。如果未来需要统一调整输入框样式(如增加边框、修改背景色),只需修改这两个方法即可,所有表单字段会同步更新。

二十一、工具方法:formatDuration 与时间格式化

  formatDuration(seconds: number): string {
    let min: number = Math.floor(seconds / 60)
    let sec: number = seconds % 60
    return min + ':' + (sec < 10 ? '0' + sec : '' + sec)
  }

formatDuration 是应用中唯一的工具方法,它将秒数格式化为"分:秒"的可读时间字符串。方法接收一个 seconds 参数,通过 Math.floor(seconds / 60) 计算分钟数(向下取整),通过 seconds % 60 计算剩余秒数(取模运算),然后用冒号拼接。对于秒数小于 10 的情况,通过 sec < 10 ? '0' + sec : '' + sec 在前面补零(如 3 秒显示为"03"),确保时间格式的一致性(如"4:12"或"3:05")。

这个方法在 songItemBuilder 中被调用,将歌曲的 duration(以秒为单位存储,如 252 秒)转换为"4:12"这样的可读格式展示在卡片右侧。将数据存储为最小单位(秒)而展示为人类可读格式,是数据处理的最佳实践——存储层保持原始精度,展示层按需格式化,便于后续的计算和比较。

值得注意的是,这个方法没有被 @Builder 修饰,因为它不构建 UI,而是一个纯逻辑方法。在 ArkUI 组件中,@Builder 方法用于声明 UI 结构,普通方法用于处理逻辑,二者各司其职。这个方法虽然简短,却体现了"关注点分离"的工程思想——数据格式化逻辑与 UI 渲染逻辑解耦,提升了代码的可测试性和可维护性。

二十二、各模块关键技术点横向对比总结

下面以表格形式对本应用各 Tab 页及核心模块的关键技术点进行横向对比,便于读者快速把握各模块的设计差异与技术选型。

对比维度 歌单 Tab(Playlist) 歌手 Tab(Artist) 统计 Tab(Stats) 我的 Tab(Mine) 弹窗系统(Modals)
核心功能 歌曲列表浏览、流派筛选、增删改入口 歌手列表展示、按歌手浏览作品 多维度听歌数据统计与可视化 用户信息展示、个人数据概览、功能导航 新增/编辑/删除歌曲的模态交互
关键状态 selectedGenre(流派筛选)、showAddModal/showEditModal/showDeleteModal(弹窗触发)、editIndex/deleteIndex(操作索引) 无独立状态(静态数据展示) 无独立状态(静态数据展示) 无独立状态(静态数据展示) showAddModal/showEditModal/showDeleteModal(显示控制)、editIndex/deleteIndex(数据定位)
关键组件 Scroll(水平筛选+垂直列表)、ForEach(筛选标签)、Stack(封面容器) Scroll(垂直滚动)、Stack(圆形头像) Scroll(垂直滚动)、Row+Column(柱状图条目) Scroll(垂直滚动)、Stack(用户头像) Column(全屏遮罩+内容卡片)、Scroll(表单滚动)、TextInput(表单输入)、Divider(分割线)
数据来源 songs 数组(SongModel 实例)、genreFilters 数组 硬编码参数传入 artistCardBuilder genreStats/monthlyListens/ratingBreakdowns 三个静态数组 硬编码用户信息与统计数据 songs 数组(通过 editIndex/deleteIndex 索引取值)
列表渲染方式 手动逐行调用 songItemBuilder 26 次 手动逐行调用 artistCardBuilder 10 次 手动逐行调用 genreBarBuilder/monthlyBarBuilder/ratingBarBuilder 手动逐行调用 menuItemBuilder 6 次 手动逐行调用 formFieldBuilder/editFieldBuilder
设计模式 筛选+列表模式、卡片式设计、条件渲染(收藏图标) 卡片式设计、参数化构建器(多颜色定制) 数据可视化模式、统一柱状图模板、数据即配置(color 字段) 沉浸式头部+数据栏+菜单列表模式、设置页范式 模态弹窗模式、遮罩+内容分层、表单构建器复用、二次确认(删除)
交互特点 点击筛选切换样式、点击编辑/删除触发弹窗 纯展示(无交互回调) 纯展示(无交互回调) 纯展示(无交互回调) 点击遮罩关闭、点击✕关闭、取消/保存按钮、数据回填(编辑)
视觉特征 白色卡片12圆角、药丸筛选标签、Emoji 封面 白色卡片14圆角、圆形头像、多彩背景 白色卡片14圆角、横向柱状图、灰色轨道+彩色填充 紫色沉浸式头部、白色数据栏、白色菜单列表 全屏半透明遮罩、白色内容卡片18圆角、大阴影
主色调 #7B1FA2 + 浅紫背景 #F3E5F5 紫色系循环渐变(6种紫色) 紫色系多色柱状图 + 品红辅助 主题紫 #7B1FA2 + 品红 #C2185B 主题紫(新增)+ 品红(编辑/删除)
复用构建器 songItemBuilder artistCardBuilder genreBarBuilder/monthlyBarBuilder/ratingBarBuilder menuItemBuilder modalBg/formFieldBuilder/editFieldBuilder

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 音乐歌单播放器 — 紫色 #7B1FA2 | 品红 #C2185B | 背景 #F3E5F5

interface SongItem {
  id: number
  title: string
  artist: string
  album: string
  genre: string
  duration: number
  year: number
  rating: number
  playCount: number
  isFavorite: boolean
  coverEmoji: string
  lyrics: string
  moodTags: string[]
}

interface GenreStat {
  genre: string
  count: number
  percentage: number
  color: string
}

interface MonthlyListen {
  month: string
  minutes: number
}

interface RatingBreakdown {
  stars: number
  count: number
  color: string
}

interface TabConfig {
  index: number
  label: string
  icon: string
}

interface NowPlaying {
  title: string
  artist: string
  emoji: string
  progress: number
}

@Observed
class SongModel {
  id: number
  title: string
  artist: string
  album: string
  genre: string
  duration: number
  year: number
  rating: number
  playCount: number
  isFavorite: boolean
  coverEmoji: string
  lyrics: string
  moodTags: string[]

  constructor(song: SongItem) {
    this.id = song.id
    this.title = song.title
    this.artist = song.artist
    this.album = song.album
    this.genre = song.genre
    this.duration = song.duration
    this.year = song.year
    this.rating = song.rating
    this.playCount = song.playCount
    this.isFavorite = song.isFavorite
    this.coverEmoji = song.coverEmoji
    this.lyrics = song.lyrics
    this.moodTags = song.moodTags
  }
}

enum MusicTab {
  Playlist,
  Artist,
  Stats,
  Mine
}

@Entry
@Component
struct MusicPlaylistApp {
  @State currentTab: number = MusicTab.Playlist
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State editIndex: number = 0
  @State deleteIndex: number = 0
  @State selectedGenre: string = '全部'
  @State nowPlaying: NowPlaying = {
    title: '夜空中最亮的星',
    artist: '逃跑计划',
    emoji: '🌟',
    progress: 45
  }

  private songs: SongModel[] = [
    new SongModel({ id: 1, title: '夜空中最亮的星', artist: '逃跑计划', album: '世界', genre: '摇滚', duration: 252, year: 2011, rating: 5, playCount: 1280, isFavorite: true, coverEmoji: '🌟', lyrics: '夜空中最亮的星...', moodTags: ['励志', '夜晚'] }),
    new SongModel({ id: 2, title: '晴天', artist: '周杰伦', album: '叶惠美', genre: '流行', duration: 269, year: 2003, rating: 5, playCount: 3560, isFavorite: true, coverEmoji: '☀️', lyrics: '故事的小黄花...', moodTags: ['青春', '回忆'] }),
    new SongModel({ id: 3, title: '成都', artist: '赵雷', album: '无法长大', genre: '民谣', duration: 325, year: 2016, rating: 5, playCount: 2100, isFavorite: true, coverEmoji: '🏙️', lyrics: '和我在成都的街头走一走...', moodTags: ['旅行', '悠闲'] }),
    new SongModel({ id: 4, title: '海阔天空', artist: 'Beyond', album: '乐与怒', genre: '摇滚', duration: 326, year: 1993, rating: 5, playCount: 4500, isFavorite: true, coverEmoji: '🌊', lyrics: '原谅我这一生不羁放纵爱自由...', moodTags: ['经典', '励志'] }),
    new SongModel({ id: 5, title: '南山南', artist: '马頔', album: '孤岛', genre: '民谣', duration: 358, year: 2014, rating: 4, playCount: 1800, isFavorite: false, coverEmoji: '⛰️', lyrics: '你在南方的艳阳里...', moodTags: ['文艺', '伤感'] }),
    new SongModel({ id: 6, title: '电音之王', artist: 'DJ Alan', album: 'Electric', genre: '电子', duration: 215, year: 2020, rating: 4, playCount: 980, isFavorite: false, coverEmoji: '⚡', lyrics: 'Feel the beat...', moodTags: ['派对', '动感'] }),
    new SongModel({ id: 7, title: '月光奏鸣曲', artist: '贝多芬', album: 'Classical', genre: '古典', duration: 420, year: 1801, rating: 5, playCount: 650, isFavorite: true, coverEmoji: '🌙', lyrics: '(纯音乐)', moodTags: ['安静', '经典'] }),
    new SongModel({ id: 8, title: 'Rap God', artist: 'Eminem', album: 'Marshall', genre: '嘻哈', duration: 363, year: 2013, rating: 4, playCount: 2200, isFavorite: false, coverEmoji: '🎤', lyrics: 'Look, I was gonna go easy...', moodTags: ['激情', '技巧'] }),
    new SongModel({ id: 9, title: '光年之外', artist: '邓紫棋', album: '新的心跳', genre: '流行', duration: 235, year: 2016, rating: 4, playCount: 3100, isFavorite: true, coverEmoji: '🌌', lyrics: '感受停在我发端的指尖...', moodTags: ['浪漫', '太空'] }),
    new SongModel({ id: 10, title: 'Blinding Lights', artist: 'The Weeknd', album: 'After Hours', genre: '电子', duration: 200, year: 2020, rating: 5, playCount: 5600, isFavorite: true, coverEmoji: '🌃', lyrics: 'I said ooh...', moodTags: ['复古', '舞曲'] }),
    new SongModel({ id: 11, title: '平凡之路', artist: '朴树', album: '猎户星座', genre: '流行', duration: 285, year: 2014, rating: 5, playCount: 4100, isFavorite: true, coverEmoji: '🛤️', lyrics: '我曾经跨过山和大海...', moodTags: ['人生', '励志'] }),
    new SongModel({ id: 12, title: 'Nocturne', artist: '肖邦', album: 'Classical', genre: '古典', duration: 280, year: 1830, rating: 5, playCount: 720, isFavorite: true, coverEmoji: '🎻', lyrics: '(纯音乐)', moodTags: ['优雅', '安静'] }),
    new SongModel({ id: 13, title: 'Lose Yourself', artist: 'Eminem', album: '8 Mile', genre: '嘻哈', duration: 326, year: 2002, rating: 5, playCount: 3800, isFavorite: true, coverEmoji: '🏆', lyrics: 'Look, if you had one shot...', moodTags: ['励志', '经典'] }),
    new SongModel({ id: 14, title: '斑马斑马', artist: '宋冬野', album: '安和桥北', genre: '民谣', duration: 318, year: 2013, rating: 4, playCount: 1500, isFavorite: false, coverEmoji: '🦓', lyrics: '斑马斑马你不要睡着啦...', moodTags: ['伤感', '文艺'] }),
    new SongModel({ id: 15, title: 'Shape of You', artist: 'Ed Sheeran', album: 'Divide', genre: '流行', duration: 234, year: 2017, rating: 4, playCount: 6200, isFavorite: true, coverEmoji: '💃', lyrics: "The club isn't the best place...", moodTags: ['舞曲', '浪漫'] }),
    new SongModel({ id: 16, title: 'Defqon.1', artist: 'Headhunterz', album: 'Hardstyle', genre: '电子', duration: 245, year: 2019, rating: 3, playCount: 890, isFavorite: false, coverEmoji: '🔥', lyrics: 'Hard bass...', moodTags: ['派对', '激烈'] }),
    new SongModel({ id: 17, title: '青花瓷', artist: '周杰伦', album: '我很忙', genre: '流行', duration: 239, year: 2007, rating: 5, playCount: 3400, isFavorite: true, coverEmoji: '🏺', lyrics: '素胚勾勒出青花笔锋浓转淡...', moodTags: ['中国风', '文艺'] }),
    new SongModel({ id: 18, title: '月亮代表我的心', artist: '邓丽君', album: '岛国情歌', genre: '古典', duration: 227, year: 1977, rating: 5, playCount: 2800, isFavorite: true, coverEmoji: '🌛', lyrics: '你问我爱你有多深...', moodTags: ['经典', '浪漫'] }),
    new SongModel({ id: 19, title: 'Sicko Mode', artist: 'Travis Scott', album: 'Astroworld', genre: '嘻哈', duration: 312, year: 2018, rating: 4, playCount: 4400, isFavorite: false, coverEmoji: '🎪', lyrics: 'Astro, yeah...', moodTags: ['派对', '动感'] }),
    new SongModel({ id: 20, title: '理想三旬', artist: '陈鸿宇', album: '一如年少模样', genre: '民谣', duration: 280, year: 2016, rating: 4, playCount: 1200, isFavorite: true, coverEmoji: '🍂', lyrics: '雨后有车驶来...', moodTags: ['文艺', '怀旧'] }),
    new SongModel({ id: 21, title: 'Symphony No.9', artist: '贝多芬', album: 'Classical', genre: '古典', duration: 660, year: 1824, rating: 5, playCount: 560, isFavorite: true, coverEmoji: '🎼', lyrics: '(纯音乐)', moodTags: ['宏大', '经典'] }),
    new SongModel({ id: 22, title: '起风了', artist: '买辣椒也用券', album: '起风了', genre: '流行', duration: 325, year: 2017, rating: 5, playCount: 5200, isFavorite: true, coverEmoji: '🍃', lyrics: '这一路上走走停停...', moodTags: ['青春', '感动'] }),
    new SongModel({ id: 23, title: 'HUMBLE.', artist: 'Kendrick Lamar', album: 'DAMN.', genre: '嘻哈', duration: 177, year: 2017, rating: 5, playCount: 3900, isFavorite: true, coverEmoji: '👑', lyrics: 'Sit down, be humble...', moodTags: ['霸气', '经典'] }),
    new SongModel({ id: 24, title: 'Something Just Like This', artist: 'The Chainsmokers', album: 'Memories', genre: '电子', duration: 247, year: 2017, rating: 4, playCount: 4800, isFavorite: false, coverEmoji: '✨', lyrics: "I've been reading books of old...", moodTags: ['浪漫', '梦幻'] }),
    new SongModel({ id: 25, title: '追梦赤子心', artist: 'GALA', album: '追梦痴子心', genre: '摇滚', duration: 316, year: 2011, rating: 5, playCount: 2600, isFavorite: true, coverEmoji: '💪', lyrics: '向前跑 迎着冷眼和嘲笑...', moodTags: ['励志', '热血'] }),
    new SongModel({ id: 26, title: '好久不见', artist: '陈奕迅', album: '不想放手', genre: '流行', duration: 264, year: 2007, rating: 5, playCount: 3700, isFavorite: true, coverEmoji: '🥀', lyrics: '我来到你的城市...', moodTags: ['伤感', '回忆'] })
  ]

  private genreStats: GenreStat[] = [
    { genre: '流行', count: 9, percentage: 35, color: '#7B1FA2' },
    { genre: '摇滚', count: 3, percentage: 12, color: '#C2185B' },
    { genre: '民谣', count: 4, percentage: 15, color: '#E91E63' },
    { genre: '电子', count: 4, percentage: 15, color: '#9C27B0' },
    { genre: '古典', count: 4, percentage: 15, color: '#AB47BC' },
    { genre: '嘻哈', count: 4, percentage: 15, color: '#CE93D8' },
    { genre: 'R&B', count: 0, percentage: 3, color: '#F8BBD0' }
  ]

  private monthlyListens: MonthlyListen[] = [
    { month: '1月', minutes: 1200 },
    { month: '2月', minutes: 1800 },
    { month: '3月', minutes: 1500 },
    { month: '4月', minutes: 2200 },
    { month: '5月', minutes: 2800 },
    { month: '6月', minutes: 2400 },
    { month: '7月', minutes: 3100 }
  ]

  private ratingBreakdowns: RatingBreakdown[] = [
    { stars: 5, count: 16, color: '#7B1FA2' },
    { stars: 4, count: 8, color: '#C2185B' },
    { stars: 3, count: 2, color: '#E91E63' },
    { stars: 2, count: 0, color: '#F06292' },
    { stars: 1, count: 0, color: '#F8BBD0' }
  ]

  private tabConfigs: TabConfig[] = [
    { index: MusicTab.Playlist, label: '歌单', icon: '🎵' },
    { index: MusicTab.Artist, label: '歌手', icon: '🎤' },
    { index: MusicTab.Stats, label: '统计', icon: '📊' },
    { index: MusicTab.Mine, label: '我的', icon: '👤' }
  ]

  private genreFilters: string[] = ['全部', '流行', '摇滚', '民谣', '电子', '古典', '嘻哈']

  build() {
    Column() {
      Column() {
        // 顶部标题栏
        Row() {
          Text('🎵 音乐歌单').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Row().layoutWeight(1)
          Text('🔍').fontSize(20).fontColor('#FFFFFF').margin({ right: 16 })
          Text('⚙️').fontSize(20).fontColor('#FFFFFF')
        }
        .width('100%').height(56).padding({ left: 20, right: 20 })
        .backgroundColor('#7B1FA2').alignItems(VerticalAlign.Center)

        // Now Playing 迷你播放器
        this.nowPlayingBuilder()

        // 内容区域
        Column() {
          if (this.currentTab === MusicTab.Playlist) {
            this.playlistTab()
          } else if (this.currentTab === MusicTab.Artist) {
            this.artistTab()
          } else if (this.currentTab === MusicTab.Stats) {
            this.statsTab()
          } else {
            this.mineTab()
          }
        }.layoutWeight(1).width('100%')

        // 底部导航栏
        this.bottomTabBar()
      }
      .width('100%').height('100%').backgroundColor('#F3E5F5')

      // 弹窗层
      if (this.showAddModal) {
        this.addModal()
      }
      if (this.showEditModal) {
        this.editModal()
      }
      if (this.showDeleteModal) {
        this.deleteModal()
      }
    }
  }

  // Now Playing 迷你播放器
  @Builder nowPlayingBuilder() {
    Row() {
      Text(this.nowPlaying.emoji).fontSize(32).margin({ right: 12 })
      Column() {
        Text(this.nowPlaying.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text(this.nowPlaying.artist).fontSize(12).fontColor('#E1BEE7').margin({ top: 2 })
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)

      Row() {
        Text('⏮️').fontSize(18).margin({ right: 16 })
        Text('⏸️').fontSize(22)
        Text('⏭️').fontSize(18).margin({ left: 16 })
      }.alignItems(VerticalAlign.Center)
    }
    .width('100%').height(64).padding({ left: 20, right: 20 })
    .backgroundColor('#6A1B9A').alignItems(VerticalAlign.Center)
    .overlay()
    Row() {
      Column()
        .width(this.nowPlaying.progress + '%')
        .height(3)
        .backgroundColor('#FFB300')
        .borderRadius(2)
    }.width('100%').height(3).backgroundColor('#4A148C').margin({ top: -3 })
  }

  // 底部导航栏
  @Builder bottomTabBar() {
    Row() {
      ForEach(this.tabConfigs, (item: TabConfig) => {
        Column() {
          Text(item.icon).fontSize(22)
          Text(item.label).fontSize(10).fontColor(this.currentTab === item.index ? '#7B1FA2' : '#9E9E9E')
            .margin({ top: 2 })
        }.alignItems(HorizontalAlign.Center).layoutWeight(1)
        .onClick(() => { this.currentTab = item.index })
      }, (item: TabConfig) => item.index.toString())
    }
    .width('100%').height(56).backgroundColor('#FFFFFF')
    .alignItems(VerticalAlign.Center)
    .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
  }

  // ===== 歌单 Tab =====
  @Builder playlistTab() {
    Column() {
      // 分类筛选
      Scroll() {
        Row() {
          ForEach(this.genreFilters, (genre: string) => {
            Text(genre)
              .fontSize(12).fontColor(this.selectedGenre === genre ? '#FFFFFF' : '#7B1FA2')
              .backgroundColor(this.selectedGenre === genre ? '#7B1FA2' : '#F3E5F5')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(16).margin({ right: 8 })
              .onClick(() => { this.selectedGenre = genre })
          }, (genre: string) => genre)
        }.padding({ left: 16, right: 16 })
      }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(40)

      // 歌曲列表
      Scroll() {
        Column() {
          this.songItemBuilder(this.songs[0])
          this.songItemBuilder(this.songs[1])
          this.songItemBuilder(this.songs[2])
          this.songItemBuilder(this.songs[3])
          this.songItemBuilder(this.songs[4])
          this.songItemBuilder(this.songs[5])
          this.songItemBuilder(this.songs[6])
          this.songItemBuilder(this.songs[7])
          this.songItemBuilder(this.songs[8])
          this.songItemBuilder(this.songs[9])
          this.songItemBuilder(this.songs[10])
          this.songItemBuilder(this.songs[11])
          this.songItemBuilder(this.songs[12])
          this.songItemBuilder(this.songs[13])
          this.songItemBuilder(this.songs[14])
          this.songItemBuilder(this.songs[15])
          this.songItemBuilder(this.songs[16])
          this.songItemBuilder(this.songs[17])
          this.songItemBuilder(this.songs[18])
          this.songItemBuilder(this.songs[19])
          this.songItemBuilder(this.songs[20])
          this.songItemBuilder(this.songs[21])
          this.songItemBuilder(this.songs[22])
          this.songItemBuilder(this.songs[23])
          this.songItemBuilder(this.songs[24])
          this.songItemBuilder(this.songs[25])
          Row() {
            Text('➕ 添加新歌曲').fontSize(14).fontColor('#7B1FA2').fontWeight(FontWeight.Bold)
          }.width('100%').height(48).justifyContent(FlexAlign.Center)
          .backgroundColor('#E1BEE7').borderRadius(12).margin({ top: 12 })
          .onClick(() => { this.showAddModal = true })
        }.padding({ left: 16, right: 16, top: 4, bottom: 16 })
      }.layoutWeight(1).scrollBar(BarState.Off)
    }.width('100%').height('100%')
  }

  @Builder songItemBuilder(song: SongModel) {
    Row() {
      // 封面
      Stack() {
        Text(song.coverEmoji).fontSize(28)
      }.width(52).height(52).backgroundColor('#E1BEE7').borderRadius(10)
      .align(Alignment.Center)

      Column() {
        Row() {
          Text(song.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
          if (song.isFavorite) {
            Text('❤️').fontSize(12).margin({ left: 6 })
          }
        }.width('100%').alignItems(VerticalAlign.Center)

        Row() {
          Text(song.artist + ' · ' + song.album).fontSize(12).fontColor('#757575')
        }.width('100%').margin({ top: 3 })

        Row() {
          Text(song.genre).fontSize(10).fontColor('#7B1FA2')
            .backgroundColor('#F3E5F5').padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius(4)
          Text('▶ ' + song.playCount + '次').fontSize(10).fontColor('#9E9E9E').margin({ left: 8 })
          Text('⭐' + song.rating).fontSize(10).fontColor('#FFB300').margin({ left: 8 })
        }.width('100%').margin({ top: 4 }).alignItems(VerticalAlign.Center)
      }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)

      Column() {
        Text(this.formatDuration(song.duration)).fontSize(12).fontColor('#9E9E9E')
        Row() {
          Text('✏️').fontSize(14).margin({ right: 8 })
            .onClick(() => {
              this.editIndex = song.id - 1
              this.showEditModal = true
            })
          Text('🗑️').fontSize(14)
            .onClick(() => {
              this.deleteIndex = song.id - 1
              this.showDeleteModal = true
            })
        }.margin({ top: 6 })
      }.alignItems(HorizontalAlign.End)
    }
    .width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
    .backgroundColor('#FFFFFF').borderRadius(12).margin({ top: 8 })
    .alignItems(VerticalAlign.Center)
  }

  // ===== 歌手 Tab =====
  @Builder artistTab() {
    Scroll() {
      Column() {
        this.artistCardBuilder('周杰伦', '华语流行天王', '🎤', 6, '#7B1FA2')
        this.artistCardBuilder('Eminem', '说唱之神', '👑', 2, '#C2185B')
        this.artistCardBuilder('逃跑计划', '摇滚乐队', '🎸', 1, '#E91E63')
        this.artistCardBuilder('赵雷', '民谣歌手', '🪕', 1, '#9C27B0')
        this.artistCardBuilder('邓紫棋', '实力唱将', '💫', 1, '#AB47BC')
        this.artistCardBuilder('The Weeknd', 'R&B天王', '🌙', 1, '#CE93D8')
        this.artistCardBuilder('贝多芬', '古典大师', '🎼', 2, '#7B1FA2')
        this.artistCardBuilder('朴树', '民谣摇滚', '🍂', 1, '#C2185B')
        this.artistCardBuilder('陈奕迅', '歌神', '🎤', 1, '#E91E63')
        this.artistCardBuilder('Travis Scott', '嘻哈巨星', '🎪', 1, '#9C27B0')
      }.padding({ left: 16, right: 16, top: 12, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

  @Builder artistCardBuilder(name: string, desc: string, emoji: string, songCount: number, color: string) {
    Row() {
      Stack() {
        Text(emoji).fontSize(32)
      }.width(60).height(60).backgroundColor(color).borderRadius(30)
      .align(Alignment.Center)

      Column() {
        Text(name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        Text(desc).fontSize(12).fontColor('#757575').margin({ top: 4 })
        Text(songCount + ' 首歌曲').fontSize(11).fontColor('#7B1FA2').margin({ top: 4 })
      }.layoutWeight(1).margin({ left: 14 }).alignItems(HorizontalAlign.Start)

      Text('▶').fontSize(20).fontColor('#7B1FA2')
    }
    .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 10 })
    .alignItems(VerticalAlign.Center)
  }

  // ===== 统计 Tab =====
  @Builder statsTab() {
    Scroll() {
      Column() {
        // 总听歌时长
        Row() {
          Column() {
            Text('⏱️ 总听歌时长').fontSize(12).fontColor('#757575')
            Text('18,560 分钟').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#7B1FA2')
              .margin({ top: 4 })
            Text('约 309 小时').fontSize(11).fontColor('#9E9E9E').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)

          Column() {
            Text('🎶 歌曲总数').fontSize(12).fontColor('#757575')
            Text('26').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#C2185B')
              .margin({ top: 4 })
            Text('7种风格').fontSize(11).fontColor('#9E9E9E').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start)
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

        // 流派分布
        Row() {
          Text('🏷️ 流派分布').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.genreBarBuilder(this.genreStats[0])
          this.genreBarBuilder(this.genreStats[1])
          this.genreBarBuilder(this.genreStats[2])
          this.genreBarBuilder(this.genreStats[3])
          this.genreBarBuilder(this.genreStats[4])
          this.genreBarBuilder(this.genreStats[5])
          this.genreBarBuilder(this.genreStats[6])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

        // 每月听歌时长
        Row() {
          Text('📈 每月听歌时长').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.monthlyBarBuilder(this.monthlyListens[0])
          this.monthlyBarBuilder(this.monthlyListens[1])
          this.monthlyBarBuilder(this.monthlyListens[2])
          this.monthlyBarBuilder(this.monthlyListens[3])
          this.monthlyBarBuilder(this.monthlyListens[4])
          this.monthlyBarBuilder(this.monthlyListens[5])
          this.monthlyBarBuilder(this.monthlyListens[6])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

        // 评分分布
        Row() {
          Text('⭐ 评分分布').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.ratingBarBuilder(this.ratingBreakdowns[0])
          this.ratingBarBuilder(this.ratingBreakdowns[1])
          this.ratingBarBuilder(this.ratingBreakdowns[2])
          this.ratingBarBuilder(this.ratingBreakdowns[3])
          this.ratingBarBuilder(this.ratingBreakdowns[4])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
      }.padding({ left: 16, right: 16, top: 12, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

  @Builder genreBarBuilder(stat: GenreStat) {
    Row() {
      Text(stat.genre).fontSize(12).fontColor('#424242').width(50)
      Row() {
        Column()
          .width(stat.percentage + '%')
          .height(10)
          .backgroundColor(stat.color)
          .borderRadius(5)
      }.layoutWeight(1).height(10).backgroundColor('#F5F5F5').borderRadius(5)
      Text(stat.count + '首').fontSize(11).fontColor('#757575').width(36).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

  @Builder monthlyBarBuilder(item: MonthlyListen) {
    Row() {
      Text(item.month).fontSize(12).fontColor('#424242').width(40)
      Row() {
        Column()
          .width(item.minutes / 35 + '%')
          .height(14)
          .backgroundColor('#7B1FA2')
          .borderRadius(7)
      }.layoutWeight(1).height(14).backgroundColor('#F5F5F5').borderRadius(7)
      Text(item.minutes + '分').fontSize(11).fontColor('#757575').width(56).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

  @Builder ratingBarBuilder(item: RatingBreakdown) {
    Row() {
      Text(item.stars + '星').fontSize(12).fontColor('#424242').width(40)
      Row() {
        Column()
          .width(item.count * 5 + '%')
          .height(12)
          .backgroundColor(item.color)
          .borderRadius(6)
      }.layoutWeight(1).height(12).backgroundColor('#F5F5F5').borderRadius(6)
      Text(item.count + '首').fontSize(11).fontColor('#757575').width(36).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

  // ===== 我的 Tab =====
  @Builder mineTab() {
    Scroll() {
      Column() {
        // 用户信息卡片
        Row() {
          Stack() {
            Text('🎧').fontSize(40)
          }.width(80).height(80).backgroundColor('#7B1FA2').borderRadius(40).align(Alignment.Center)

          Column() {
            Text('音乐爱好者').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('ID: MUSIC_2026').fontSize(12).fontColor('#E1BEE7').margin({ top: 4 })
            Row() {
              Text('VIP会员').fontSize(10).fontColor('#FFB300')
                .backgroundColor('#311B92').padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .borderRadius(8)
            }.margin({ top: 6 })
          }.layoutWeight(1).margin({ left: 16 }).alignItems(HorizontalAlign.Start)
        }.width('100%').padding(20).backgroundColor('#7B1FA2').borderRadius(16)

        // 统计数据
        Row() {
          Column() {
            Text('26').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#7B1FA2')
            Text('歌曲').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column() {
            Text('10').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#7B1FA2')
            Text('歌手').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column() {
            Text('12').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C2185B')
            Text('收藏').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column() {
            Text('309h').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C2185B')
            Text('时长').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 12 })

        // 功能列表
        Column() {
          this.menuItemBuilder('❤️', '我的收藏', '12首歌曲')
          this.menuItemBuilder('📅', '最近播放', '查看历史')
          this.menuItemBuilder('📥', '下载管理', '8首已下载')
          this.menuItemBuilder('📤', '分享歌单', '推荐给好友')
          this.menuItemBuilder('⚙️', '设置', '音质·播放')
          this.menuItemBuilder('ℹ️', '关于', '版本 v2.6.0')
        }.width('100%').backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 12 })
      }.padding({ left: 16, right: 16, top: 16, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

  @Builder menuItemBuilder(icon: string, title: string, desc: string) {
    Row() {
      Text(icon).fontSize(20).width(32)
      Text(title).fontSize(14).fontColor('#212121').layoutWeight(1)
      Text(desc).fontSize(12).fontColor('#9E9E9E')
      Text('›').fontSize(18).fontColor('#BDBDBD').margin({ left: 8 })
    }.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .alignItems(VerticalAlign.Center)
  }

  // ===== 新增弹窗 =====
  @Builder modalBg(onClose: () => void) {
    Column().width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.5)').onClick(onClose)
  }

  @Builder addModal() {
    Column() {
      this.modalBg(() => { this.showAddModal = false })
      Column() {
        Row() {
          Text('➕ 新增歌曲').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Row().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showAddModal = false })
        }.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#F0F0F0')
        Scroll() {
          Column() {
            this.formFieldBuilder('歌曲名称', '请输入歌曲名称')
            this.formFieldBuilder('歌手', '请输入歌手名')
            this.formFieldBuilder('专辑', '请输入专辑名')
            this.formFieldBuilder('流派', '流行/摇滚/民谣/电子/古典/嘻哈')
            this.formFieldBuilder('时长(秒)', '请输入数字')
            this.formFieldBuilder('年份', '如 2024')
            this.formFieldBuilder('评分(1-5)', '请输入1-5')
            this.formFieldBuilder('播放次数', '请输入数字')
            this.formFieldBuilder('封面Emoji', '如 🎵')
            this.formFieldBuilder('歌词', '请输入歌词')
            this.formFieldBuilder('心情标签', '如 励志,夜晚')
          }.padding({ left: 20, right: 20 })
        }.layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .onClick(() => { this.showAddModal = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#7B1FA2').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showAddModal = false })
        }.width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      }
      .width('90%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(18)
      .position({ x: '5%', y: '8%' }).shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  // ===== 编辑弹窗 =====
  @Builder editModal() {
    Column() {
      this.modalBg(() => { this.showEditModal = false })
      Column() {
        Row() {
          Text('✏️ 编辑歌曲').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Row().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showEditModal = false })
        }.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#F0F0F0')
        Scroll() {
          Column() {
            this.editFieldBuilder('歌曲名称', this.songs[this.editIndex].title)
            this.editFieldBuilder('歌手', this.songs[this.editIndex].artist)
            this.editFieldBuilder('专辑', this.songs[this.editIndex].album)
            this.editFieldBuilder('流派', this.songs[this.editIndex].genre)
            this.editFieldBuilder('时长(秒)', this.songs[this.editIndex].duration.toString())
            this.editFieldBuilder('年份', this.songs[this.editIndex].year.toString())
            this.editFieldBuilder('评分', this.songs[this.editIndex].rating.toString())
            this.editFieldBuilder('播放次数', this.songs[this.editIndex].playCount.toString())
          }.padding({ left: 20, right: 20 })
        }.layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .onClick(() => { this.showEditModal = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#C2185B').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showEditModal = false })
        }.width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      }
      .width('90%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(18)
      .position({ x: '5%', y: '8%' }).shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  // ===== 删除确认弹窗 =====
  @Builder deleteModal() {
    Column() {
      this.modalBg(() => { this.showDeleteModal = false })
      Column() {
        Text('🗑️').fontSize(40).margin({ top: 24 })
        Text('确认删除?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          .margin({ top: 12 })
        Text('删除后不可恢复,确定要删除「' + this.songs[this.deleteIndex].title + '」吗?')
          .fontSize(13).fontColor('#757575').textAlign(TextAlign.Center)
          .margin({ top: 8, left: 24, right: 24 })

        Row() {
          Text('取消').fontSize(14).fontColor('#666666')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 32, right: 32, top: 10, bottom: 10 })
            .onClick(() => { this.showDeleteModal = false })
          Text('删除').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#C2185B').borderRadius(20)
            .padding({ left: 32, right: 32, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showDeleteModal = false })
        }.margin({ top: 24, bottom: 24 }).justifyContent(FlexAlign.Center)
      }.width('80%').backgroundColor('#FFFFFF').borderRadius(18)
      .alignItems(HorizontalAlign.Center)
      .shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }.width('100%').height('100%').justifyContent(FlexAlign.Center)
    .position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder formFieldBuilder(label: string, placeholder: string) {
    Column() {
      Text(label).fontSize(12).fontColor('#757575').margin({ bottom: 6 })
      TextInput({ placeholder: placeholder })
        .fontSize(14).height(40).borderRadius(10)
        .backgroundColor('#F5F5F5')
    }.width('100%').margin({ top: 12 }).alignItems(HorizontalAlign.Start)
  }

  @Builder editFieldBuilder(label: string, value: string) {
    Column() {
      Text(label).fontSize(12).fontColor('#757575').margin({ bottom: 6 })
      TextInput({ text: value })
        .fontSize(14).height(40).borderRadius(10)
        .backgroundColor('#F5F5F5')
    }.width('100%').margin({ top: 12 }).alignItems(HorizontalAlign.Start)
  }

  formatDuration(seconds: number): string {
    let min: number = Math.floor(seconds / 60)
    let sec: number = seconds % 60
    return min + ':' + (sec < 10 ? '0' + sec : '' + sec)
  }
}


二十三、结语:从示例代码看 ArkTS 声明式 UI 的工程实践

通过对本音乐歌单播放器源码的逐段深度解析,我们可以提炼出几个 ArkTS 声明式 UI 开发的核心工程实践启示。首先是"类型先行"的数据建模思路:通过 interface 定义数据契约、通过 @Observed class 构建可观察模型、通过 enum 管理常量集合,这三者构成了应用的数据基石,让类型安全贯穿开发始终。其次是"状态驱动"的响应式架构:@State 管理的可变状态是整个应用的神经中枢,UI 的每一次变化都源于状态的变更,开发者只需关心"状态应该是什么",框架负责"UI 应该怎么变",这种心智模型的简化是声明式范式相较于命令式的根本优势。

在这里插入图片描述

第三是"@Builder 拆分"的代码组织策略:本应用将各个功能区域拆分为十余个 @Builder 方法,每个方法职责单一、结构清晰,使 build() 主方法保持了高层的结构概览,而细节实现下沉到各构建器中。这种"主方法管架构、构建器管细节"的分层,是管理复杂 UI 的有效手段。第四是"数据即配置"的可扩展设计:Tab 配置、流派筛选、统计颜色等信息都以数组数据的形式存在,UI 通过遍历数据动态生成,新增或修改只需调整数据源而非 UI 代码,体现了数据驱动的设计哲学。

第五是"统一模式、差异化参数"的复用思想:三个柱状图构建器结构一致但参数不同、新增和编辑弹窗结构一致但数据回填方式不同、表单字段构建器结构一致但输入参数不同——这种在统一中求变化的设计,既保证了代码的复用性,又满足了各场景的个性化需求。第六是"视觉一致性"的主题管控:整个应用严格围绕紫色主题展开,通过主色 #7B1FA2、辅色 #C2185B、背景色 #F3E5F5 以及多个中间紫色阶调的搭配,营造出统一而富有层次的视觉体验,即使在不同功能页面之间切换,用户也能感受到一致的审美基调。

当然,作为示例代码,本应用也有一些可以进一步优化的方向。例如,列表渲染采用了手动逐行调用而非 ForEach,在数据量增大时可维护性会下降;歌手页和我的页的数据为硬编码,未与歌曲数据源建立动态关联;增删改弹窗仅切换了显示状态,未实现实际的数据持久化操作。这些都是从示例走向生产需要补齐的环节。但瑕不掩瑜,作为一个 ArkTS 声明式 UI 的教学范本,本应用在功能完整度、视觉精致度和代码结构清晰度上都达到了很高的水准,值得每一位 HarmonyOS 开发者研读借鉴。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐