跳转到内容

编程实验:开发 HM Music 音乐播放器

本实验将基于 HM_Music_Base 基础工程,完成一个具备以下能力的音乐播放器应用:

  • 首页加载歌单并以两列卡片形式展示
  • 点击歌单进入详情页
  • 详情页加载歌曲列表并支持搜索
  • 点击歌曲后同步播放状态
  • 页面底部显示迷你播放栏
  • 返回首页后显示上次播放记录
APP效果预览

本实验涉及的知识点

知识点使用场景
@PropPlaylistCardSongItem 接收父组件传入的数据
@LinkSearchBar 与详情页双向同步搜索关键词
@Provide / @ConsumePlaylistDetailPageMiniPlayer 共享播放状态
AppStorage / @StorageLink保存全局播放状态与上次播放信息
PersistentStorage应用重启后仍能保留上次播放记录
http.createHttp()获取歌单与歌曲 JSON 数据
FlexWrap.Wrap首页两列歌单卡片布局
List / ForEach渲染歌曲列表

2.1 获取模板工程

📦 模板工程仓库https://cnb.cool/sziit-coding/harmony-coding/hm-music-base

请将以上工程导入到DevEco Studio中,并运行此项目。

2.2 认识需要修改的文件

本实验主要会修改以下文件:

文件路径作用
entry/src/main/ets/pages/Index.ets首页:歌单列表
entry/src/main/ets/pages/PlaylistDetailPage.ets歌单详情页
entry/src/main/ets/services/MusicService.ets网络请求与数据转换
entry/src/main/ets/components/PlaylistCard.ets歌单卡片
entry/src/main/ets/components/SongItem.ets歌曲列表项
entry/src/main/ets/components/SearchBar.ets搜索栏
entry/src/main/ets/components/MiniPlayer.ets底部迷你播放栏

2.3 确认初始效果

运行基础工程后,当前页面应表现为:

  • 首页顶部显示 HM Music
  • 页面主体尚未完成
  • 歌曲详情页和播放状态联动功能尚未完成

✅ 预期效果:工程能够正常启动,便于后续逐步完善。

模板工程初始效果

3.1 HTTP 请求的基本流程

entry/src/main/ets/services/DemoHttp.ets
import http from '@ohos.net.http'
export function requestDemo(): void {
const req = http.createHttp()
req.request(
'https://example.com/data.json',
{
method: http.RequestMethod.GET,
connectTimeout: 6000,
readTimeout: 6000
},
(err, data) => {
if (!err && data.responseCode === 200) {
console.info(String(data.result))
}
req.destroy()
}
)
}

本实验中的歌单数据和歌曲数据都将按这个流程获取。

3.2 两列布局的关键写法

entry/src/main/ets/pages/DemoFlex.ets
Flex({
direction: FlexDirection.Row,
wrap: FlexWrap.Wrap,
justifyContent: FlexAlign.SpaceBetween
}) {
Column().width('49%').height(120).backgroundColor('#FFD7D7')
Column().width('49%').height(120).backgroundColor('#D7E8FF')
}

歌单卡片将使用这种方式排列。这里直接使用 width('49%'),不要使用百分比形式的 flexBasis

3.3 三种常用状态传递方式

@Prop

父组件传给子组件,子组件只读:

entry/src/main/ets/components/DemoProp.ets
@Component
struct Child {
@Prop title: string = ''
build() {
Text(this.title)
}
}

父子组件双向同步:

entry/src/main/ets/components/DemoLink.ets
@Component
struct Child {
@Link keyword: string
build() {
TextInput({ text: this.keyword })
.onChange((value: string) => {
this.keyword = value
})
}
}

@Provide / @Consume

祖先组件向后代组件共享状态:

entry/src/main/ets/pages/DemoProvide.ets
@Component
struct Parent {
@Provide playing: boolean = false
}
@Component
struct Child {
@Consume playing: boolean
}

✅ 预期效果:理解后续代码中状态是如何在多个组件之间流动的。

目标:先完成首页整体结构,确认标题栏位置和列表区域布局正确。

4.1 将标题改造成标题栏 Row

打开 Index.ets,把原来的单个标题文字改为标题栏布局。

实现标题栏布局

要求:

组件结构尺寸与间距颜色与字体设定其它细节属性
外部容器 (Row)宽度 100%
内边距: top:24, bottom:16, left:20, right:16
--
左侧标题 (Text)-字号 24, 粗体 (Bold)
颜色 #15143A
文本内容:HM Music
中间间距 (Blank)自动撑开两侧多余空间--
右侧刷新 (Image)宽高各 36,内边距 8填充色 #AAAABB加载 $r('app.media.ic_refresh')
entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
...
build() {
Column() {
// TODO: 实现标题栏
Text('HM Music')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#15143A')
.margin({ left: 20, top: 24 })
// 在此处编码实现标题栏布局
...
}
...
}
}
点击查看参考实现
Row() {
Text('HM Music')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#15143A')
Blank()
Image($r('app.media.ic_refresh'))
.width(36)
.height(36)
.fillColor('#AAAABB')
.padding(8)
}
.width('100%')
.padding({ left: 20, right: 16, top: 24, bottom: 16 })

✅ 预期效果:页面顶部出现一个左右分布的标题栏。

4.2 实现歌单列表区域的骨架结构,先用临时色块占位。

歌单列表占位效果

继续在 build() 中增加 List 结构,先用临时色块观察布局是否正确。

要求:

组件结构属性设置说明
列表容器 (List)lanes(2)width('100%')layoutWeight(1)以两列方式排列列表项,高度撑满剩余空间
列表项 (ListItem)padding(4)为每个色块增加间距,避免相互贴边
临时占位色块 (Row)width('100%')height(100)使用 Row 作为占位块
背景色 #dbdbdb临时用灰色填充,便于观察布局位置
entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
...
build() {
Column() {
...
// TODO: 实现歌单列表区域
...
}
...
}
}
点击查看参考实现
List() {
ForEach(Array.from({length: 20}), () => {
ListItem() {
Row()
.backgroundColor('#dbdbdb')
.size({width: '100%', height: 100})
}
.padding(4)
})
}
.lanes(2)
.width('100%')
.height('100%')
.layoutWeight(1)

✅ 预期效果:首页标题栏下方出现一个临时占位块,说明歌单区域位置正确。

目标:完成 PlaylistCard组件,并先用临时数据验证两列布局。

5.1 在首页声明歌单状态并填入测试数据

在实现卡片前,先在 Index.ets 里准备好测试用的歌单数据。这样后续一旦 PlaylistCard 组件创建好,可以立即在首页中看到渲染效果。

打开 Index.ets,在组件中声明 @State playlists 并填入两条临时数据:

歌单封面链接如下:

entry/src/main/ets/pages/Index.ets
import { Playlist } from '../models/MusicModel'
@Entry
@Component
struct Index {
// TODO: 声明歌单列表状态并填入测试数据
@State playlists: Playlist[] = [
new Playlist(1, '华语流行', 'https://picsum.photos/seed/cpop/300/300', '适合通勤时收听'),
new Playlist(2, '夜跑歌单', 'https://picsum.photos/seed/pophits/300/300', '适合夜间跑步')
]
...
}

5.2 在 PlaylistCard 中声明 @Prop playlist

PlaylistCard 是一个展示型子组件,它的职责是接收一条歌单数据并负责渲染。由于它不需要修改数据,只需要父组件”传进来给它看”,因此使用 @Prop 装饰器是最合适的选择。

思路:父组件(首页)持有歌单列表,在 ForEach 中遍历时把每一条 Playlist 数据传给 PlaylistCard;卡片拿到后只负责显示,不对数据做任何修改。

打开 PlaylistCard.ets,先完成 @Prop playlist

entry/src/main/ets/components/PlaylistCard.ets
import { Playlist } from '../models/MusicModel'
@Component
export struct PlaylistCard {
// TODO: 使用 @Prop 接收歌单数据
@Prop playlist: Playlist = new Playlist(0, '', '', '')
build() {
...
}
}

5.3 在首页用 ForEach 接入 PlaylistCard

回到 Index.ets,将之前的临时色块替换为 PlaylistCard,验证两列布局是否正确。

entry/src/main/ets/pages/Index.ets
import { PlaylistCard } from '../components/PlaylistCard'
@Entry
@Component
struct Index {
...
build() {
Column() {
...
List() {
ForEach(Array.from({length: 20}), () => {
ListItem() {
Row()
.backgroundColor('#dbdbdb')
.size({width: '100%', height: 100})
}
.padding(4)
})
ForEach(this.playlists, (playlist: Playlist) => {
ListItem() {
PlaylistCard({ playlist: playlist })
}
.padding(4)
}, (playlist: Playlist) => playlist.id.toString())
}
.lanes(2)
.width('100%')
.layoutWeight(1)
}
...
}
}

5.4 实现歌单卡片完整结构

打开 PlaylistCard.ets,完整实现卡片的三个区域:外层容器、封面图和文字信息区。

歌单卡片效果预览
Column(外层卡片容器)
├── Image(封面图,正方形,由外层 clip 裁剪圆角)
└── Column(文字信息区)
├── Text(歌单名称)
├── Text(歌单描述)
└── Text(歌曲数量标签,pill 样式)

各区域样式要求

① 外层容器 (Column)

属性
宽度100%
底部外边距8
圆角 + 裁剪borderRadius(8) 配合 .clip(true)
背景色#FFFFFF
阴影radius:12,颜色 #1A6C63FF,偏移 offsetX:0, offsetY:4

② 封面图 (Image)

属性
宽度100%
高宽比1(正方形)
objectFitImageFit.Cover
占位背景色#EAE8FF

封面图无需单独设置圆角,.clip(true) 已由外层容器统一裁剪。

③ 文字信息区 (Column)

子元素属性
容器内边距left:10, right:10, top:8, bottom:10,左对齐
歌单名称 (Text)字号 14,粗体颜色 #15143A,最多 1 行,超出省略
歌单描述 (Text)字号 11,上外边距 3颜色 #706F9A,最多 1 行,超出省略
数量标签 (Text)字号 10,上外边距 8,内边距 left:8, right:8, top:3, bottom:3颜色 #6C63FF,背景 #F0EEFF,圆角 10

请将以上结构和样式要求完整实现在 PlaylistCard.ets 中:

entry/src/main/ets/components/PlaylistCard.ets
@Component
export struct PlaylistCard {
build() {
// TODO: 实现歌单卡片UI布局效果
...
}
}
点击查看参考实现
Column() {
// 封面图(铺满顶部,仅裁上方两角)
Image(this.playlist.cover)
.width('100%')
.aspectRatio(1)
.objectFit(ImageFit.Cover)
.backgroundColor('#EAE8FF') // 图片加载中的占位背景
// 文字信息区
Column() {
// 歌单名称
Text(this.playlist.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#15143A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// 歌单描述
Text(this.playlist.description)
.fontSize(11)
.fontColor('#706F9A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.margin({ top: 3 })
// 歌曲数量 - pill 风格标签
Text(`${this.playlist.songCount} 首`)
.fontSize(10)
.fontColor('#6C63FF')
.fontWeight(FontWeight.Medium)
.margin({ top: 8 })
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#F0EEFF')
.borderRadius(10)
}
.alignItems(HorizontalAlign.Start)
.padding({ left: 10, right: 10, top: 8, bottom: 10 })
}
.clip(true)
.borderRadius(8)
.backgroundColor('#FFFFFF')
.shadow({ radius: 12, color: '#1A6C63FF', offsetX: 0, offsetY: 4 })

✅ 预期效果:首页歌单卡片呈现白色卡片样式,包含封面、名称、描述和歌曲数量标签。

歌单卡片效果

目标:实现 fetchPlaylists(),让首页显示接口返回的真实歌单数据。

知识回顾:ArkUI 中 HTTP 请求实例

在鸿蒙 ArkUI 中,发起 HTTP 请求需要使用 @ohos.net.http 模块。每次请求前必须调用 http.createHttp() 创建一个独立的请求实例,请求完成后需调用 req.destroy() 释放资源。

⚠️ 注意req.destroy() 无论成功还是失败都必须调用,否则会造成资源泄漏。

GET 请求示例
import http from '@ohos.net.http'
// 1. 创建请求实例
const req = http.createHttp()
// 2. 发起 GET 请求
req.request(
'https://example.com/api/data',
{
method: http.RequestMethod.GET,
connectTimeout: 6000,
readTimeout: 6000
},
(err, data) => {
if (!err && data.responseCode === 200) {
// 3. 解析响应 JSON
const result = JSON.parse(String(data.result))
console.info('请求成功', JSON.stringify(result))
} else {
console.error('请求失败', JSON.stringify(err))
}
// 4. 释放实例
req.destroy()
}
)
POST 请求示例
import http from '@ohos.net.http'
// 1. 创建请求实例
const req = http.createHttp()
// 2. 发起 POST 请求
req.request(
'https://example.com/api/login',
{
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/json' },
extraData: JSON.stringify({
username: 'admin',
password: '123456'
}),
connectTimeout: 6000,
readTimeout: 6000
},
(err, data) => {
if (!err && data.responseCode === 200) {
// 3. 解析响应 JSON
const result = JSON.parse(String(data.result))
console.info('登录成功', JSON.stringify(result))
} else {
console.error('登录失败', JSON.stringify(err))
}
// 4. 释放实例
req.destroy()
}
)

6.1 实现歌单请求与数据转换

打开 MusicService.ets,完整实现 fetchPlaylists() 函数:发起 HTTP 请求、解析响应 JSON,并将接口返回的 DTO 数据转换为页面可直接使用的 Playlist[]

接口说明

项目内容
请求地址API_PLAYLISTS(已在文件顶部定义,指向 JSON 静态资源)
请求方法GET
响应格式JSON

请求成功后,接口将返回如下结构的 JSON 数据,解析后的 TypeScript 类型为 PlaylistsResp,其 data 数组中每一项对应一个 PlaylistDTO

接口返回示例(playlists.json)
{
"code": 200,
"data": [
{
"id": 1,
"name": "华语流行",
"coverUrl": "https://picsum.photos/seed/cpop/300/300",
"description": "适合通勤时收听",
"songCount": 12
},
{
"id": 2,
"name": "夜跑歌单",
"coverUrl": "https://picsum.photos/seed/pophits/300/300",
"description": "适合夜间跑步",
"songCount": 8
}
]
}

DTO 与页面模型的映射

什么是 DTO?
DTO(Data Transfer Object,数据传输对象)是专门用于承载接口返回数据的临时对象,字段与后端 JSON 严格对应。
后端字段(如 coverUrl)通常与前端页面模型(如 cover)命名不同,因此需要一个映射步骤将 DTO 转换为页面类。这样接口格式变化时,只需修改映射逻辑,页面代码无需改动。

PlaylistDTOPlaylist 字段对应关系如下:

PlaylistDTO 字段类型对应 Playlist 字段说明
idnumberid歌单唯一标识
namestringname歌单名称
coverUrlstringcover封面图 URL
descriptionstringdescription歌单描述
songCountnumbersongCount歌曲数量

实现逻辑

参考代码中的注释,逐步完成 fetchPlaylists() 函数:

entry/src/main/ets/services/MusicService.ets
export function fetchPlaylists(
onSuccess: (list: Playlist[]) => void,
onError: () => void
): void {
// TODO: 1. 调用 http.createHttp() 创建请求实例,赋给常量 req
// TODO: 2. 调用 req.request() 发起请求,传入三个参数:
// - 第一个参数:请求地址常量 API_PLAYLISTS
// - 第二个参数:配置对象(method: GET,connectTimeout/readTimeout: 6000)
// - 第三个参数:回调函数 (err, data) => { ... }
req.request(
/* 请求地址 */,
{
/* method, connectTimeout, readTimeout */
},
(err, rsp) => {
// TODO: 3. 判断是否成功:!err && rsp.responseCode === 200
if ( /* 成功条件 */ ) {
// 3a. 将 rsp.result 转字符串,用 JSON.parse() 解析,断言为 PlaylistsResp
// 3b. 用 resp.data.map() 将每个 PlaylistDTO 构造为 Playlist 对象
// 构造函数参数:(id, name, coverUrl, description)
// 构造完成后把 item.songCount 赋值给对象的 songCount 字段
// 3c. 调用 onSuccess(list) 将结果回传给调用方
} else {
// 失败:调用 onError()
}
// TODO: 4. 调用 req.destroy() 释放请求实例
}
)
}
点击查看参考实现
entry/src/main/ets/services/MusicService.ets
export function fetchPlaylists(
onSuccess: (list: Playlist[]) => void,
onError: () => void
): void {
const req = http.createHttp()
req.request(
API_PLAYLISTS,
{
method: http.RequestMethod.GET,
connectTimeout: 6000,
readTimeout: 6000
},
(err, data) => {
if (!err && data.responseCode === 200) {
const resp = JSON.parse(String(data.result)) as PlaylistsResp
const list: Playlist[] = resp.data.map((item: PlaylistDTO) => {
const playlist = new Playlist(item.id, item.name, item.coverUrl, item.description)
playlist.songCount = item.songCount
return playlist
})
onSuccess(list)
} else {
onError()
}
req.destroy()
}
)
}

✅ 预期效果:服务层已经能够发起网络请求并返回页面可直接使用的歌单数组。

6.2 在首页实现 loadPlaylists()

回到 Index.ets,在 loadPlaylists() 方法中调用刚刚实现的 fetchPlaylists(),并通过 isLoading 状态在请求前后控制加载动画的显示。参考代码中的注释完成实现:

entry/src/main/ets/pages/Index.ets
import { fetchPlaylists } from '../services/MusicService'
@Entry
@Component
struct Index {
@State playlists: Playlist[] = []
@State isLoading: boolean = false
loadPlaylists() {
// TODO: 1. 将 this.isLoading 设为 true,让 UI 进入加载状态
// TODO: 2. 调用 fetchPlaylists(),传入成功和失败两个回调
fetchPlaylists(
(list: Playlist[]) => {
// 成功:将 list 赋给 this.playlists
// 将 this.isLoading 设为 false
},
() => {
// 失败:将 this.playlists 置为空数组
// 将 this.isLoading 设为 false
}
)
}
...
}

✅ 预期效果:首页已经具备真实加载歌单数据的能力。

6.3 在页面出现时加载,并显示加载中状态

继续在 Index.ets 中完成以下两部分,参考代码注释完成实现:

aboutToAppear() 生命周期方法:在页面首次显示时自动触发,在此处调用 loadPlaylists() 即可。

build() 中的条件渲染:根据 isLoading 的值分两路显示内容:

  • isLoadingtrue 时:显示居中的加载动画区域(LoadingProgress + 提示文字)
  • isLoadingfalse 时:接上步骤 4.2 中搭好的 List 结构,将 PlaylistCard 替换掉临时色块
entry/src/main/ets/pages/Index.ets
import { PlaylistCard } from '../components/PlaylistCard'
@Entry
@Component
struct Index {
...
// 在 aboutToAppear() 生命周期方法,调用 loadPlaylists()
aboutToAppear() {
loadPlaylists()
}
build() {
Column() {
// 标题栏(已实现)
...
// TODO: 根据 isLoading 显示不同内容
if (this.isLoading) {
// 加载中区域:垂直居中,宽度 100%,高度用 layoutWeight(1) 撑满剩余空间
Column() {
// LoadingProgress:宽高 40,颜色 #6C63FF
// Text '加载中...':字号 14,颜色 #8888AA,上外边距 12
}
...
} else {
// 歌单列表:步骤 4.2 已实现
List() {
...
}
...
}
}
.width('100%')
.height('100%')
.backgroundColor('#F7F6FD')
}
}

别忘了将playlists 赋的测试数据清空哦。

点击查看参考实现
entry/src/main/ets/pages/Index.ets
import { PlaylistCard } from '../components/PlaylistCard'
@Entry
@Component
struct Index {
...
aboutToAppear() {
this.loadPlaylists()
}
build() {
Column() {
...
if (this.isLoading) {
Column() {
LoadingProgress()
.width(40)
.color('#6C63FF')
Text('加载中...')
.fontSize(14)
.fontColor('#8888AA')
.margin({ top: 12 })
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
} else {
List() {
ForEach(this.playlists, (playlist: Playlist) => {
ListItem() {
PlaylistCard({ playlist: playlist })
}
.padding(4)
}, (playlist: Playlist) => playlist.id.toString())
}
.lanes(2)
.width('100%')
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
.backgroundColor('#F7F6FD')
}
}

✅ 预期效果:首页先显示加载中动画,数据加载完成后以两列卡片布局展示真实歌单。

加载中效果
加载完成效果

目标:点击歌单卡片后跳转到详情页,传递歌单信息;并完成详情页顶部导航栏的基础布局。

7.1 路由导航与参数传递

HarmonyOS ArkUI 使用 @ohos.router 模块进行多页面跳转。本实验中点击歌单卡片后需要跳转到详情页并携带歌单数据,这要用到两个核心 API:

发起跳转(传递数据)

来源页:发起跳转
import router from '@ohos.router'
// 跳转并传入参数
router.pushUrl({
url: 'pages/DetailPage',
params: {
id: 1,
name: '华语流行',
desc: '适合通勤时收听'
}
})

读取参数(接收数据)

目标页:读取参数
import router from '@ohos.router'
aboutToAppear() {
// getParams() 返回 object,需要断言类型
const params = router.getParams()
as Record<string, object>
const id = Number(params?.['id'] ?? 0)
const name = String(params?.['name'] ?? '')
const desc = String(params?.['desc'] ?? '')
}

注意router.getParams() 返回的是 object 类型,必须先断言为 Record<string, object> 才能按 key 取值,取出后再用 Number() / String() 强转为具体类型。

了解以上两个 API 的用法后,接下来在步骤 7.2 中将它们应用到歌单点击跳转功能上。

7.2 为歌单卡片增加跳转事件

打开 Index.ets,在步骤 5.3 / 6.3 已搭建好的 List 结构中,为每个 PlaylistCard 增加 .onClick() 事件,点击后跳转到 PlaylistDetailPage 并传递当前歌单的信息。参考代码注释完成实现:

entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
...
build() {
Column() {
...
if (this.isLoading) {
// 加载中 UI(已实现)
...
} else {
List() {
ForEach(this.playlists, (playlist: Playlist) => {
ListItem() {
PlaylistCard({ playlist: playlist })
// TODO: 为 PlaylistCard 增加 .onClick() 事件
// 在点击时调用 router.pushUrl(),url 为 'pages/PlaylistDetailPage'
// params 传入:
// playlistId: playlist.id
// playlistName: playlist.name
// playlistDesc: playlist.description
.onClick(() => {
...
})
}
.padding(4)
}, (playlist: Playlist) => playlist.id.toString())
}
}
}
}
}

✅ 预期效果:点击首页歌单卡片能够跳转进入详情页。

7.3 在详情页读取路由参数

跳转完成后,需要在详情页的 aboutToAppear() 生命周期中读取路由传来的参数,结合步骤 7.1 中学到的 router.getParams() 用法完成实现:

entry/src/main/ets/pages/PlaylistDetailPage.ets
import router from '@ohos.router'
@Entry(pageStorage)
@Component
struct PlaylistDetailPage {
@State playlist: Playlist | null = null
...
aboutToAppear() {
// TODO: 调用 router.getParams() 获取参数,并断言为 Record<string, object>
...
// TODO: 从 params 中分别读取以下三个字段,注意类型强转:
// playlistId → Number,默认值 0
// playlistName → String,默认值 '歌单'
// playlistDesc → String,默认值 ''
...
// TODO: 用 playlistId、playlistName、''、playlistDesc 构造 Playlist 对象,赋给 this.playlist
...
}
...
}

✅ 预期效果:详情页已能从路由参数中读取到歌单名称和 ID,并触发歌曲加载。

7.4 实现详情页导航栏

参数读取完成后,继续在 build() 中实现详情页顶部导航栏。导航栏包含三个子元素:左侧返回按钮、中间标题、右侧排序按钮(本步骤先完成样式,后续再接入排序逻辑)。参考代码注释完成实现:

entry/src/main/ets/pages/PlaylistDetailPage.ets
@Entry(pageStorage)
@Component
struct PlaylistDetailPage {
...
build() {
Column() {
// TODO: 实现导航栏 Row,宽度 100%,左右内边距 16,上内边距 16,下内边距 8
Row() {
// 左侧返回按钮(Image)
// 加载 $r('app.media.ic_back'),宽高 36,内边距 8
// 填充色 #706F9A,圆角 18,背景色 #F0EEFF,右外边距 12
// onClick: 调用 router.back()
...
// 中间标题(Text)
// 显示 this.playlist?.name ?? '歌单详情'
// 字号 18,粗体,颜色 #15143A
// layoutWeight(1) 占满剩余空间
...
// 右侧排序按钮(Text)
// 文字 '默认',字号 13,颜色 #6C63FF
// 内边距 left/right:12,top/bottom:5
// 背景色 #F0EEFF,圆角 14
...
}
...
...
}
...
}
}

✅ 预期效果:进入详情页后可以看到顶部导航栏。

跳转到详情页

目标:请求歌曲数据,并使用 SongItem 显示列表。

8.1 实现 fetchSongs() 请求与数据转换

打开 MusicService.ets,完整实现 fetchSongs() 函数:发起 HTTP 请求、解析响应 JSON,并将 SongDTO 转换为页面可用的 Song[]

DTO 与页面模型的映射

SongDTO 字段类型对应 Song 字段说明
idnumberid歌曲唯一标识
titlestringtitle歌曲名称
artiststringartist歌手名
coverUrlstringcover封面图 URL
duration(秒数)numberduration需调用 formatDuration() 转为 分:秒 格式
urlstringurl播放地址

参考代码中的注释,逐步完成 fetchSongs() 函数:

entry/src/main/ets/services/MusicService.ets
export function fetchSongs(
playlistId: number,
onSuccess: (songs: Song[]) => void,
onError: () => void
): void {
// TODO: 1. 调用 http.createHttp() 创建请求实例,赋给常量 req
...
// TODO: 2. 调用 req.request(),传入三个参数:
// - 第一个参数:请求地址 getSongsUrl(playlistId)
// - 第二个参数:配置对象 { method: GET,connectTimeout/readTimeout: 6000 }
// - 第三个参数:回调函数 (err, rsp) => { ... }
req.request(
/* 请求地址 */,
{
/* method, connectTimeout, readTimeout */
...
},
(err, rsp) => {
// TODO: 3. 判断是否成功:!err && rsp.responseCode === 200
if ( /* 成功条件 */ ) {
// 3a. 将 rsp.result 转字符串,用 JSON.parse() 解析,断言为 SongsResp
...
// 3b. 用 resp.data.map() 将每个 SongDTO 构造为 Song 对象
// Song 构造函数参数:(id, title, artist, coverUrl, formatDuration(duration), url)
...
// 3c. 调用 onSuccess(list) 将结果回传
...
} else {
// 失败:调用 onError()
...
}
// TODO: 4. 调用 req.destroy() 释放请求实例
...
}
)
}
点击查看参考实现
entry/src/main/ets/services/MusicService.ets
export function fetchSongs(
playlistId: number,
onSuccess: (songs: Song[]) => void,
onError: () => void
): void {
const req = http.createHttp()
req.request(
getSongsUrl(playlistId),
{
method: http.RequestMethod.GET,
connectTimeout: 6000,
readTimeout: 6000
},
(err, data) => {
if (!err && data.responseCode === 200) {
const resp = JSON.parse(String(data.result)) as SongsResp
const list: Song[] = resp.data.map((item: SongDTO) => {
return new Song(
item.id, item.title, item.artist,
item.coverUrl, formatDuration(item.duration), item.url
)
})
onSuccess(list)
} else {
onError()
}
req.destroy()
}
)
}

✅ 预期效果:歌曲请求函数已能发起网络请求并返回页面可用的歌曲数组。

8.2 在详情页中触发歌曲加载并用日志验证

回到 PlaylistDetailPage.ets,实现 loadSongs() 方法,并在 aboutToAppear() 末尾调用它触发加载。先通过 console.info 打印歌曲数量,确认数据是否正确返回,再进行后续 UI 渲染。

entry/src/main/ets/pages/PlaylistDetailPage.ets
import { fetchSongs } from '../services/MusicService'
@Entry(pageStorage)
@Component
struct PlaylistDetailPage {
@State songs: Song[] = []
@State isLoadingSongs: boolean = false
@State currentIndex: number = -1
...
// TODO: 实现 loadSongs() 方法
loadSongs(playlistId: number) {
// 1. 将 this.isLoadingSongs 设为 true
...
// 2. 调用 fetchSongs(),传入 playlistId 和两个回调
fetchSongs(
playlistId,
(songs: Song[]) => {
// 成功:将 songs 赋给 this.songs,isLoadingSongs 设为 false
// 打印日志验证:console.info(`获取到 ${songs.length} 首歌曲`)
...
},
() => {
// 失败:将 this.songs 置为空数组,isLoadingSongs 设为 false
...
}
)
}
aboutToAppear() {
const params = router.getParams() as Record<string, object>
const playlistId = Number(params?.['playlistId'] ?? 0)
const playlistName = String(params?.['playlistName'] ?? '歌单')
const playlistDesc = String(params?.['playlistDesc'] ?? '')
this.playlist = new Playlist(playlistId, playlistName, '', playlistDesc)
// TODO: 调用 this.loadSongs(playlistId) 触发歌曲加载
...
}
...
}

✅ 预期效果:进入详情页后,DevEco Studio 的日志面板中能看到类似 获取到 10 首歌曲 的输出,说明网络请求和数据转换均已正常工作。

获取歌曲日志输出

8.3 实现 SongItem 基础结构

SongItem 是展示型子组件,只需接收父组件传入的歌曲数据和激活状态,因此使用 @Prop。打开 SongItem.ets,先完成 @Prop 声明和外层容器,让列表能够先渲染出来:

entry/src/main/ets/components/SongItem.ets
import { Song } from '../models/MusicModel'
@Component
export struct SongItem {
// TODO: 使用 @Prop 接收歌曲数据, 变量名为 song,类型 Song,默认值为 new Song(0, '', '', '', '', '')
...
// TODO: 使用 @Prop 接收激活状态, 变量名为 isActive,类型 boolean,默认值为 false
...
onTap: () => void = () => {}
build() {
// TODO: 外层容器使用 Row,内部暂时留空
Row() { ...
}
// TODO: 宽度 100%,高度 68,左右内边距 16
// TODO: 背景色:isActive 为 true 时 #F5F3FF,否则透明
// TODO: 圆角 10
// TODO: onClick:触发 this.onTap()
...
}
}

8.4 在详情页中展示歌曲列表

SongItem 骨架完成后,在 PlaylistDetailPage.etsbuild() 中接入歌曲列表区,先让列表能稳定显示,再去完善每个条目的 UI 细节:

entry/src/main/ets/pages/PlaylistDetailPage.ets
import { SongItem } from '../components/SongItem'
@Entry(pageStorage)
@Component
struct PlaylistDetailPage {
...
build() {
Column() {
... // 导航栏(已实现)
// TODO: 根据 isLoadingSongs 显示不同内容
if (this.isLoadingSongs) {
// 加载中区域:Column,宽度 100%,layoutWeight(1) 撑满,垂直居中
Column() {
// TODO: LoadingProgress,宽高 48,颜色 #6C63FF
// TODO: Text '加载歌曲中...',字号 13,颜色 #706F9A,上外边距 12
...
}
...
} else {
// 歌曲列表:使用 List 展示,内部用 ForEach + ListItem 渲染每首歌
List() {
// TODO: ForEach 遍历 this.songs,用 ListItem 包裹每个 SongItem
// song: 当前歌曲对象
// isActive: index === this.currentIndex
// onTap: 将 this.currentIndex 设为当前 index
ForEach(this.songs, (song: Song, index: number) => {
ListItem() {
SongItem({
// TODO: 传入 song、isActive、onTap 三个属性
...
})
}
}, (song: Song) => song.id.toString())
}
// TODO: 宽度 100%,layoutWeight(1) 撑满剩余空间,隐藏滚动条
...
}
}
...
}
}
点击查看参考实现
entry/src/main/ets/pages/PlaylistDetailPage.ets(build 片段)
if (this.isLoadingSongs) {
Column() {
LoadingProgress()
.size({width: 48, height: 48})
.color('#6c63ff')
Text('加载中')
.fontSize(13)
.fontColor('#706f9a')
.margin({top: 12})
}
.width('100%')
.layoutWeight(1)
} else {
List() {
ForEach(this.songs, (song: Song, index: number) => {
ListItem() {
SongItem({
song: song,
isActive: index == this.currentIndex,
onTap: () => {
this.currentIndex = index
}
})
}
}, (song: Song) => song.id.toString())
}
.width('100%')
.height('100%')
.layoutWeight(1)
}

✅ 预期效果:详情页先显示加载动画,歌曲数据到达后展示出一行行条目(此时内容尚为空白,后续步骤再完善)。

8.5 完善 SongItem 的 UI 细节

列表能渲染后,回到 SongItem.ets,依次在 Row() 内部补齐三个区域:

entry/src/main/ets/components/SongItem.ets
@Component
export struct SongItem {
...
build() {
Row() {
// TODO: ① 选中指示条,仅 isActive 为 true 时显示(Row)
// 宽 3,高 28,颜色 #6C63FF,圆角 2,右边距 10
if (this.isActive) {
Row()
...
}
// TODO: ② 封面图(Image),加载 this.song.cover
// 宽高 42,圆角 4,objectFit: Cover,占位背景色 #EAE8FF,右边距 12
Image(this.song.cover)
...
// TODO: ③ 中间文字区(Column),layoutWeight(1) 撑满,左对齐
Column() {
// 歌曲名称(Text),字号 14,maxLines 1,超出省略
// 激活时颜色 #6C63FF,默认颜色 #1A1A2E
...
// 歌手名(Text),字号 12,颜色 #8888AA,上外边距 3
...
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
// TODO: ④ 时长(Text),显示 this.song.duration
// 字号 12,颜色 #AAAABB
Text(this.song.duration)
...
}
...
}
}
点击查看 SongItem 完整参考实现
entry/src/main/ets/components/SongItem.ets
@Component
export struct SongItem {
@Prop song: Song = new Song(0, '', '', '', '', '')
@Prop isActive: boolean = false
onTap: () => void = () => {}
build() {
Row() {
if (this.isActive) {
Row()
.width(3)
.height(28)
.backgroundColor('#6C63FF')
.borderRadius(2)
.margin({ right: 10 })
}
Image(this.song.cover)
.width(42)
.height(42)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.backgroundColor('#EAE8FF')
.margin({ right: 12 })
Column() {
Text(this.song.title)
.fontSize(14)
.fontColor(this.isActive ? '#6C63FF' : '#1A1A2E')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(this.song.artist)
.fontSize(12)
.fontColor('#8888AA')
.margin({ top: 3 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(this.song.duration)
.fontSize(12)
.fontColor('#AAAABB')
}
.width('100%')
.height(68)
.padding({ left: 16, right: 16 })
.backgroundColor(this.isActive ? '#F5F3FF' : Color.Transparent)
.borderRadius(10)
.onClick(() => {
this.onTap()
})
}
}

✅ 预期效果:歌曲列表项可完整显示封面、歌名、歌手和时长,点击后对应条目高亮显示。

歌曲列表效果

目标:点击歌曲后同步播放状态,并在页面底部显示 MiniPlayer。

先想清楚:哪些数据需要共享,共享给谁?

想象一下这个应用的使用场景:用户在歌单详情页点击了一首歌,页面底部的 MiniPlayer 要立刻显示这首歌的封面和名字;当用户按返回键回到首页后,首页顶部要出现一行”上次:XXX”的提示。

这两个需求背后,有两个不同的”数据共享问题”需要解决:

问题一:详情页点击歌曲后,同一个页面内的 MiniPlayer 怎么知道要显示哪首歌?

MiniPlayer 示意图

MiniPlayer 放在 PlaylistDetailPagebuild() 里,是它的直接子组件,理论上用 @Link 向下传参也能实现。但 MiniPlayer 是一个独立封装的可复用组件——将来如果其他页面(如全屏播放页)也需要嵌入它,用 @Link 的话每个父页面都要显式声明并传入同名变量,耦合度较高。使用 @Provide / @Consume 则不同:父组件用 @Provide 声明,MiniPlayer 内部用 @Consume 订阅,无论它被放在哪个页面下,只要祖先有对应的 @Provide 就能自动接收,组件本身不依赖具体的父级结构。

共享的数据谁提供谁使用用途
currentSong(当前歌曲)PlaylistDetailPage (@Provide)MiniPlayer (@Consume)显示封面、歌名、歌手
isPlaying(是否播放中)PlaylistDetailPage (@Provide)MiniPlayer (@Consume)控制播放/暂停按钮图标

问题二:用户返回首页后,首页怎么知道详情页刚才播放了哪首歌?

首页播放历史示意图

详情页和首页是两个完全独立的页面,它们之间没有父子关系,无法用 @Prop@Link。ArkUI 提供了 AppStorage 作为应用级的全局状态容器,任何页面都可以读写它。用 @StorageLink 把组件变量绑定到 AppStorage 的某个 key 上,一旦详情页更新了这个 key,首页就能自动感知并刷新界面。

AppStorage key详情页做什么首页用来做什么
lastPlayedTitle点击歌曲时写入歌曲名返回后显示”上次:歌名”
lastPlayedArtist点击歌曲时写入歌手名(配合歌曲名一起展示)
isPlaying点击歌曲时写入 true判断当前是否有歌曲在播放

理解了上面的场景和设计思路后,接下来逐步完成声明和实现。

9.1 在详情页中声明共享状态

打开 PlaylistDetailPage.ets,参考代码注释完成状态声明:

entry/src/main/ets/pages/PlaylistDetailPage.ets
@Entry(pageStorage)
@Component
struct PlaylistDetailPage {
...
// TODO: 用 @Provide 声明 isPlaying,初始值 false
// MiniPlayer 将通过 @Consume 接收此变量,用于控制播放/暂停按钮图标
// TODO: 用 @Provide 声明 currentSong,类型 Song | null,初始值 null
// MiniPlayer 将通过 @Consume 接收此变量,用于显示封面、歌名、歌手
// TODO: 用 @StorageLink('lastPlayedTitle') 声明 lastPlayedTitle
// 与 AppStorage 双向绑定,点击歌曲时写入歌名,首页返回时读取展示
// TODO: 用 @StorageLink('lastPlayedArtist') 声明 lastPlayedArtist
...
}

✅ 预期效果:详情页已经具备向后代组件共享播放状态,以及跨页面同步播放记录的能力。

9.2 实现 playSong() 方法

PlaylistDetailPage.ets 中添加 playSong() 方法,参考代码中的注释完成实现:

entry/src/main/ets/pages/PlaylistDetailPage.ets
@Entry(pageStorage)
@Component
struct PlaylistDetailPage {
...
playSong(index: number, song: Song) {
// TODO: 将 currentIndex 设为传入的 index,标记当前播放位置
// TODO: 将 currentSong 设为传入的 song,触发 MiniPlayer 显示歌曲信息
// TODO: 将 isPlaying 设为 true,触发 MiniPlayer 切换为播放状态
// TODO: 将 lastPlayedTitle 和 lastPlayedArtist 分别赋值
// 这两个变量已通过 @StorageLink 绑定,写入后首页会自动感知
}
...
}
点击查看参考实现
entry/src/main/ets/pages/PlaylistDetailPage.ets
playSong(index: number, song: Song) {
this.currentIndex = index
this.currentSong = song
this.isPlaying = true
this.lastPlayedTitle = song.title
this.lastPlayedArtist = song.artist
}

✅ 预期效果:点击歌曲后,当前播放信息和上次播放记录都会被同步。

9.3 让歌曲点击事件调用 playSong()

回到歌曲列表渲染位置,将 onTap 改为调用 playSong(),参考代码注释完成修改:

entry/src/main/ets/pages/PlaylistDetailPage.ets
@Entry(pageStorage)
@Component
struct PlaylistDetailPage {
...
build() {
Column() {
...
if (this.isLoadingSongs) {
...
} else {
List() {
ForEach(this.songs, (song: Song, index: number) => {
ListItem() {
SongItem({
song: song,
isActive: index == this.currentIndex,
onTap: () => {
this.currentIndex = index
// TODO: onTap:调用 this.playSong(index, song)
...
}
})
}
}, (song: Song) => song.id.toString())
}
...
}
}
...
}
}

9.4 在 MiniPlayer 中声明 @Consume

打开 MiniPlayer.ets,用 @Consume 订阅详情页通过 @Provide 共享的播放状态:

entry/src/main/ets/components/MiniPlayer.ets
import { Song } from '../models/MusicModel'
@Component
export struct MiniPlayer {
// TODO: 用 @Consume 接收 isPlaying,与 PlaylistDetailPage 的 @Provide isPlaying 对应
// TODO: 用 @Consume 接收 currentSong,类型 Song | null,与 PlaylistDetailPage 对应
build() {
...
}
}

✅ 预期效果:MiniPlayer 已经能够接收详情页共享过来的播放状态。

9.5 实现封面、文字和控制按钮

继续在 MiniPlayer.etsbuild() 函数的根布局容器 Row() 内部补齐三个区域,参考代码注释完成实现:

entry/src/main/ets/components/MiniPlayer.ets
@Component
export struct MiniPlayer {
@Consume isPlaying: boolean
@Consume currentSong: Song | null
build() {
Row() {
// TODO: ① 左侧封面图(Image)
// 有歌曲时加载 currentSong.cover,无歌曲时传空字符串
// 宽高 42,圆角 4,objectFit: Cover,右边距 12
// 背景色:无歌曲时 #E0DFF8,有歌曲时透明
Image(...)
...
// TODO: ② 中间文字区(Column),layoutWeight(1) 撑满,左对齐
Column() {
// 歌曲标题(Text),字号 14,粗体
// 有歌曲时显示 currentSong.title,无歌曲时显示「未在播放」
...
// 歌手名(Text),字号 12,颜色 #8888AA,上外边距 2
// 有歌曲时显示 currentSong.artist,无歌曲时显示「点击歌曲开始播放」
...
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// TODO: ③ 右侧控制区(Row),宽 80,均匀分布,垂直居中
Row() {
// 播放/暂停按钮(Image),始终显示
// 根据 isPlaying 加载 ic_pause 或 ic_play
// 宽高 32,内边距 4
// fillColor:无歌曲时 #CCCCDD,有歌曲时 #6C63FF
...
// 展开按钮(Image),仅当 currentSong !== null 时显示
// 加载 $r('app.media.ic_chevron_up'),宽高 32,内边距 6,fillColor #706F9A
...
}
.justifyContent(FlexAlign.SpaceEvenly)
.alignItems(VerticalAlign.Center)
.width(80)
}
...
}
}
点击查看参考实现
entry/src/main/ets/components/MiniPlayer.ets
build() {
Row() {
Image(this.currentSong !== null ? (this.currentSong as Song).cover : '')
.width(42)
.height(42)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.backgroundColor(this.currentSong !== null ? 'transparent' : '#E0DFF8')
.margin({ right: 12 })
Column() {
Text(this.currentSong !== null ? (this.currentSong as Song).title : '未在播放')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#1A1A2E')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.currentSong !== null ? (this.currentSong as Song).artist : '点击歌曲开始播放')
.fontSize(12)
.fontColor('#8888AA')
.margin({ top: 2 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Image(this.isPlaying ? $r('app.media.ic_pause') : $r('app.media.ic_play'))
.width(32)
.height(32)
.padding(4)
.fillColor(this.currentSong !== null ? '#6C63FF' : '#CCCCDD')
if (this.currentSong !== null) {
Image($r('app.media.ic_chevron_up'))
.width(32)
.height(32)
.padding(6)
.fillColor('#706F9A')
}
}
.justifyContent(FlexAlign.SpaceEvenly)
.alignItems(VerticalAlign.Center)
.width(80)
}
.width('100%')
.height(64)
.padding({ left: 16, right: 8, top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderWidth({ top: 1 })
.borderColor('#E0DFF8')
.shadow({ radius: 8, color: '#1A000000', offsetX: 0, offsetY: -2 })
}

✅ 预期效果:底部播放栏会根据当前播放状态显示不同内容。

9.6 在详情页底部放置 MiniPlayer

回到 PlaylistDetailPage.ets,在 build()Column() 最底部放置 MiniPlayer()

entry/src/main/ets/pages/PlaylistDetailPage.ets
import { MiniPlayer } from '../components/MiniPlayer'
@Entry(pageStorage)
@Component
struct PlaylistDetailPage {
...
build() {
Column() {
... // 导航栏、歌曲列表(已实现)
// TODO: 在此处放置 MiniPlayer(),使其固定在页面底部
}
.width('100%')
.height('100%')
.backgroundColor('#F7F6FD')
}
}

✅ 预期效果:点击歌曲后,页面底部出现联动的迷你播放栏。

MiniPlayer 效果

目标:返回首页甚至重启应用后,仍能显示上次播放的歌曲。

10.1 在首页前初始化 PersistentStorage

打开 Index.ets,在顶部 import 之后、@Entry 之前,调用 PersistentStorage.persistProp() 完成持久化注册:

entry/src/main/ets/pages/Index.ets
import { fetchPlaylists } from '../services/MusicService'
// TODO: 调用 PersistentStorage.persistProp() 持久化 'lastPlayedTitle',默认值为 '暂无'
// TODO: 调用 PersistentStorage.persistProp() 持久化 'lastPlayedArtist',默认值为 ''
@Entry
@Component
struct Index {
...
}

✅ 预期效果:上次播放信息已经开始持久化。

继续在 Index.ets 中声明与持久化数据绑定的变量:

entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
@State playlists: Playlist[] = []
@State isLoading: boolean = false
// TODO: 声明 @StorageLink('lastPlayedTitle'),类型 string,默认值 '暂无'
// TODO: 声明 @StorageLink('lastPlayedArtist'),类型 string,默认值 ''
...
}

✅ 预期效果:首页已经能实时感知”上次播放”数据的变化。

10.3 在标题栏下方显示”上次播放”提示

继续完善 Index.ets 首页标题栏,在 Text('HM Music') 下方加入条件渲染的”上次播放”提示行:

entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
...
build() {
Column() {
Row() {
Column() {
Text('HM Music')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#15143A')
// TODO: 仅当 lastPlayedTitle !== '暂无' 时,渲染一个 Row()
// Row 内:左侧播放图标(10x10,颜色 #6C63FF),右侧文字 `上次:${this.lastPlayedTitle}`(字号 11,颜色 #706F9A)
// Row 样式:背景色 #F0EEFF,圆角 10,内边距左右 8 上下 3,顶部 margin 6
}
.alignItems(HorizontalAlign.Start)
...
}
...
}
...
}
}

✅ 预期效果:当详情页点过歌曲后,返回首页可以看到”上次:歌曲名”的提示。

目标:完成 PlayerPage 的状态声明、初始化逻辑、旋转动画和完整 UI 布局,并将 MiniPlayer 的展开按钮与播放页连接。

11.1 声明 @Provide isPlaying

打开 PlayerPage.ets,补全播放状态声明:

entry/src/main/ets/pages/PlayerPage.ets
@Entry
@Component
struct PlayerPage {
@State songTitle: string = ''
@State songArtist: string = ''
@State songCover: string = ''
@State songDuration: string = ''
// TODO: 声明 @Provide isPlaying: boolean,初始值 false
// PlayerControlBar 中的 @Consume isPlaying 将消费此变量
@State rotateAngle: number = 0
private rotateTimer: number = -1
...
}

✅ 预期效果:PlayerPagePlayerControlBar 之间建立 @Provide/@Consume 状态链接。

11.2 在 aboutToAppear() 中读取路由参数并初始化播放

entry/src/main/ets/pages/PlayerPage.ets
aboutToAppear() {
const params = router.getParams() as Record<string, string>
// TODO: 从 params 中读取并赋值以下四个字段:
// songTitle ← params['songTitle']
// songArtist ← params['songArtist']
// songCover ← params['songCover']
// songDuration ← params['songDuration']
// TODO: 将 isPlaying 设为 true,并调用 this.startRotate()
}

✅ 预期效果:进入播放页后,歌曲信息正确展示,唱片开始旋转。

11.3 实现 startRotate() 旋转动画

entry/src/main/ets/pages/PlayerPage.ets
startRotate() {
this.rotateTimer = setInterval(() => {
// TODO: 当 isPlaying 为 true 时,让 rotateAngle 每帧 +1(对 360 取余)
// @State rotateAngle 驱动下方 Image 的 .rotate({ angle: this.rotateAngle })
}, 16) as number
}

✅ 预期效果:播放时唱片持续旋转,暂停时停止。

11.4 实现 PlayerPage.build() UI

entry/src/main/ets/pages/PlayerPage.ets
build() {
Column() {
// TODO: 实现导航栏 Row(width '100%',padding: left/right 4, top 12)
// - 左侧 ic_chevron_down(28×28,fillColor '#1A1A2E',padding 4)
// onClick: router.back()
// - 中间 Text '正在播放'(fontSize 16,fontWeight Medium,fontColor '#1A1A2E',layoutWeight 1,textAlign Center)
// - 右侧 ic_more(28×28,fillColor '#8888AA',padding 4)
// TODO: 实现唱片区域 Column(layoutWeight 1,justifyContent Center,alignItems Center)
// 内部 Stack(padding 20)包含:
// 1. 唱片图片 Image(使用 songCover,为空则回退 $r('app.media.startIcon'))
// - 尺寸 260×260,borderRadius 130,objectFit Cover
// - .rotate({ angle: this.rotateAngle }) ← 旋转动画绑定
// - shadow: radius 30,color '#66000000',offsetX 0,offsetY 10
// 2. 中心圆孔 Row()
// - 尺寸 20×20,borderRadius 10,backgroundColor '#F5F5FA'
// - border: width 3,color '#E0DFF8'
// TODO: 实现歌曲信息区域 Column(padding: left/right 32,bottom 16)
// - 歌曲名 Text(this.songTitle 或 '未知歌曲';fontSize 22,fontWeight Bold,fontColor '#1A1A2E',maxLines 1,textOverflow Ellipsis,textAlign Center)
// - 歌手名 Text(this.songArtist 或 '未知歌手';fontSize 15,fontColor '#8888AA',margin top 6,textAlign Center)
// - 操作按钮行 Flex(SpaceBetween,ItemAlign Center,width 100,margin top 8):
// ic_heart(28×28,padding 4)
// ic_more(28×28,fillColor '#8888AA',padding 4)
// TODO: 放置 PlayerControlBar 组件
// 传入 onPrev / onNext 回调(目前可打印 console.info 占位即可)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5FA')
}

✅ 预期效果:播放页完整展示,点击播放/暂停按钮状态切换,唱片根据播放状态旋转或停止。

11.5 在 MiniPlayer 展开按钮中跳转到 PlayerPage

打开 MiniPlayer.ets,找到展开按钮(ic_chevron_up)的 onClick,加入路由跳转:

entry/src/main/ets/components/MiniPlayer.ets
Image($r('app.media.ic_chevron_up'))
.width(24)
.height(24)
.fillColor('#8888AA')
.padding(4)
.onClick(() => {
// TODO: 调用 router.pushUrl() 跳转到 'pages/PlayerPage'
// params 中传入:
// songTitle → (this.currentSong as Song).title
// songArtist → (this.currentSong as Song).artist
// songCover → (this.currentSong as Song).cover
// songDuration → (this.currentSong as Song).duration
})

✅ 预期效果:点击 MiniPlayer 的展开箭头,进入播放页并显示当前歌曲信息。

播放页效果

以下挑战不提供完整代码,仅给出思路,自行设计实现方案。


挑战 1:加载失败后显示重试提示

在首页和详情页增加错误状态处理,当网络请求失败时显示”加载失败,点击重试”按钮。

思路:在组件中增加一个 @State hasError: boolean 变量,在 fetchPlaylists / fetchSongs 的失败回调中将其设为 truebuild() 中用 if/else 分支判断显示加载动画、错误提示或正常列表;错误提示可以是一个居中的 Column,包含图标、文字和点击重试的 Button,点击时重置 hasError = false 并重新调用加载方法。


挑战 2:详情页排序按钮实现真实排序

点击详情页右上角排序按钮,按歌曲时长从短到长排序;再次点击恢复默认顺序。

思路:目前 sortOrder 已通过 @LocalStorageLink 双向绑定,但 build() 里展示的始终是 getFilteredSongs() 的结果。在 getFilteredSongs() 末尾根据 sortOrder 的值决定是否调用 .sort((a, b) => a.durationSeconds - b.durationSeconds)(排序时不直接修改原 songs 数组,而是对过滤后的副本排序)。

✅ 完成任意 1 项即可。

请完成以下内容并提交:

  1. 完成本实验所有必做步骤
  2. 录制一段不超过 2 分钟的演示视频,按顺序展示以下功能:
序号演示功能验收要点
1应用启动,首页加载歌单列表Flex 两列布局,歌单卡片正常显示
2点击歌单卡片进入详情页路由跳转正常,歌单名称显示在标题栏
3详情页歌曲列表加载完成歌名、歌手、时长均正确显示
4点击任意歌曲,MiniPlayer 出现封面、歌名、歌手与点击项一致
5点击 MiniPlayer 播放/暂停按钮按钮图标随状态切换
6点击 MiniPlayer 展开箭头跳转播放页PlayerPage 显示唱片、歌曲信息和控制栏
7播放页唱片旋转,点击暂停后停转旋转动画与 isPlaying 状态联动
8返回详情页 → 返回首页首页标题栏出现”上次:歌曲名”提示
  1. 至少完成 1 项拓展挑战(选做)
  2. 提交工程源码与演示视频