编程实验:开发 HM Music 音乐播放器
本实验将基于 HM_Music_Base 基础工程,完成一个具备以下能力的音乐播放器应用:
- 首页加载歌单并以两列卡片形式展示
- 点击歌单进入详情页
- 详情页加载歌曲列表并支持搜索
- 点击歌曲后同步播放状态
- 页面底部显示迷你播放栏
- 返回首页后显示上次播放记录
本实验涉及的知识点
| 知识点 | 使用场景 |
|---|---|
@Prop | PlaylistCard、SongItem 接收父组件传入的数据 |
@Link | SearchBar 与详情页双向同步搜索关键词 |
@Provide / @Consume | PlaylistDetailPage 与 MiniPlayer 共享播放状态 |
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 请求的基本流程
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 两列布局的关键写法
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
父组件传给子组件,子组件只读:
@Componentstruct Child { @Prop title: string = ''
build() { Text(this.title) }}@Link
父子组件双向同步:
@Componentstruct Child { @Link keyword: string
build() { TextInput({ text: this.keyword }) .onChange((value: string) => { this.keyword = value }) }}@Provide / @Consume
祖先组件向后代组件共享状态:
@Componentstruct Parent { @Provide playing: boolean = false}
@Componentstruct 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@Componentstruct 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@Componentstruct 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 并填入两条临时数据:
歌单封面链接如下:
import { Playlist } from '../models/MusicModel'
@Entry@Componentstruct 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:
import { Playlist } from '../models/MusicModel'
@Componentexport struct PlaylistCard {
// TODO: 使用 @Prop 接收歌单数据 @Prop playlist: Playlist = new Playlist(0, '', '', '')
build() { ... }}5.3 在首页用 ForEach 接入 PlaylistCard
回到 Index.ets,将之前的临时色块替换为 PlaylistCard,验证两列布局是否正确。
import { PlaylistCard } from '../components/PlaylistCard'
@Entry@Componentstruct 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(正方形) |
| objectFit | ImageFit.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 中:
@Componentexport 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()无论成功还是失败都必须调用,否则会造成资源泄漏。
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() })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:
{ "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 转换为页面类。这样接口格式变化时,只需修改映射逻辑,页面代码无需改动。
PlaylistDTO → Playlist 字段对应关系如下:
PlaylistDTO 字段 | 类型 | 对应 Playlist 字段 | 说明 |
|---|---|---|---|
id | number | id | 歌单唯一标识 |
name | string | name | 歌单名称 |
coverUrl | string | cover | 封面图 URL |
description | string | description | 歌单描述 |
songCount | number | songCount | 歌曲数量 |
实现逻辑
参考代码中的注释,逐步完成 fetchPlaylists() 函数:
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() 释放请求实例
} )}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 状态在请求前后控制加载动画的显示。参考代码中的注释完成实现:
import { fetchPlaylists } from '../services/MusicService'
@Entry@Componentstruct 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 的值分两路显示内容:
isLoading为true时:显示居中的加载动画区域(LoadingProgress+ 提示文字)isLoading为false时:接上步骤 4.2 中搭好的List结构,将PlaylistCard替换掉临时色块
import { PlaylistCard } from '../components/PlaylistCard'
@Entry@Componentstruct 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赋的测试数据清空哦。
import { PlaylistCard } from '../components/PlaylistCard'
@Entry@Componentstruct 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@Componentstruct 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() 用法完成实现:
import router from '@ohos.router'
@Entry(pageStorage)@Componentstruct 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(pageStorage)@Componentstruct 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 字段 | 说明 |
|---|---|---|---|
id | number | id | 歌曲唯一标识 |
title | string | title | 歌曲名称 |
artist | string | artist | 歌手名 |
coverUrl | string | cover | 封面图 URL |
duration(秒数) | number | duration | 需调用 formatDuration() 转为 分:秒 格式 |
url | string | url | 播放地址 |
参考代码中的注释,逐步完成 fetchSongs() 函数:
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() 释放请求实例 ... } )}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 渲染。
import { fetchSongs } from '../services/MusicService'
@Entry(pageStorage)@Componentstruct 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 声明和外层容器,让列表能够先渲染出来:
import { Song } from '../models/MusicModel'
@Componentexport 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.ets 的 build() 中接入歌曲列表区,先让列表能稳定显示,再去完善每个条目的 UI 细节:
import { SongItem } from '../components/SongItem'
@Entry(pageStorage)@Componentstruct 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) 撑满剩余空间,隐藏滚动条 ... } } ... }}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() 内部补齐三个区域:
@Componentexport 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) ... } ... }}@Componentexport 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 放在 PlaylistDetailPage 的 build() 里,是它的直接子组件,理论上用 @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(pageStorage)@Componentstruct 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(pageStorage)@Componentstruct PlaylistDetailPage { ...
playSong(index: number, song: Song) { // TODO: 将 currentIndex 设为传入的 index,标记当前播放位置 // TODO: 将 currentSong 设为传入的 song,触发 MiniPlayer 显示歌曲信息 // TODO: 将 isPlaying 设为 true,触发 MiniPlayer 切换为播放状态
// TODO: 将 lastPlayedTitle 和 lastPlayedArtist 分别赋值 // 这两个变量已通过 @StorageLink 绑定,写入后首页会自动感知 }
...} 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(pageStorage)@Componentstruct 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 共享的播放状态:
import { Song } from '../models/MusicModel'
@Componentexport struct MiniPlayer { // TODO: 用 @Consume 接收 isPlaying,与 PlaylistDetailPage 的 @Provide isPlaying 对应 // TODO: 用 @Consume 接收 currentSong,类型 Song | null,与 PlaylistDetailPage 对应
build() { ... }}✅ 预期效果:MiniPlayer 已经能够接收详情页共享过来的播放状态。
9.5 实现封面、文字和控制按钮
继续在 MiniPlayer.ets 的 build() 函数的根布局容器 Row() 内部补齐三个区域,参考代码注释完成实现:
@Componentexport 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) } ... }} 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():
import { MiniPlayer } from '../components/MiniPlayer'
@Entry(pageStorage)@Componentstruct PlaylistDetailPage { ...
build() { Column() { ... // 导航栏、歌曲列表(已实现)
// TODO: 在此处放置 MiniPlayer(),使其固定在页面底部 } .width('100%') .height('100%') .backgroundColor('#F7F6FD') }}✅ 预期效果:点击歌曲后,页面底部出现联动的迷你播放栏。
目标:返回首页甚至重启应用后,仍能显示上次播放的歌曲。
10.1 在首页前初始化 PersistentStorage
打开 Index.ets,在顶部 import 之后、@Entry 之前,调用 PersistentStorage.persistProp() 完成持久化注册:
import { fetchPlaylists } from '../services/MusicService'
// TODO: 调用 PersistentStorage.persistProp() 持久化 'lastPlayedTitle',默认值为 '暂无'// TODO: 调用 PersistentStorage.persistProp() 持久化 'lastPlayedArtist',默认值为 ''
@Entry@Componentstruct Index { ...}✅ 预期效果:上次播放信息已经开始持久化。
10.2 在首页中声明 @StorageLink
继续在 Index.ets 中声明与持久化数据绑定的变量:
@Entry@Componentstruct Index { @State playlists: Playlist[] = [] @State isLoading: boolean = false
// TODO: 声明 @StorageLink('lastPlayedTitle'),类型 string,默认值 '暂无' // TODO: 声明 @StorageLink('lastPlayedArtist'),类型 string,默认值 ''
...}✅ 预期效果:首页已经能实时感知”上次播放”数据的变化。
10.3 在标题栏下方显示”上次播放”提示
继续完善 Index.ets 首页标题栏,在 Text('HM Music') 下方加入条件渲染的”上次播放”提示行:
@Entry@Componentstruct 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@Componentstruct 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
...}✅ 预期效果:PlayerPage 与 PlayerControlBar 之间建立 @Provide/@Consume 状态链接。
11.2 在 aboutToAppear() 中读取路由参数并初始化播放
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() 旋转动画
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
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,加入路由跳转:
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 的失败回调中将其设为 true;build() 中用 if/else 分支判断显示加载动画、错误提示或正常列表;错误提示可以是一个居中的 Column,包含图标、文字和点击重试的 Button,点击时重置 hasError = false 并重新调用加载方法。
挑战 2:详情页排序按钮实现真实排序
点击详情页右上角排序按钮,按歌曲时长从短到长排序;再次点击恢复默认顺序。
思路:目前 sortOrder 已通过 @LocalStorageLink 双向绑定,但 build() 里展示的始终是 getFilteredSongs() 的结果。在 getFilteredSongs() 末尾根据 sortOrder 的值决定是否调用 .sort((a, b) => a.durationSeconds - b.durationSeconds)(排序时不直接修改原 songs 数组,而是对过滤后的副本排序)。
✅ 完成任意 1 项即可。
请完成以下内容并提交:
- 完成本实验所有必做步骤
- 录制一段不超过 2 分钟的演示视频,按顺序展示以下功能:
| 序号 | 演示功能 | 验收要点 |
|---|---|---|
| 1 | 应用启动,首页加载歌单列表 | Flex 两列布局,歌单卡片正常显示 |
| 2 | 点击歌单卡片进入详情页 | 路由跳转正常,歌单名称显示在标题栏 |
| 3 | 详情页歌曲列表加载完成 | 歌名、歌手、时长均正确显示 |
| 4 | 点击任意歌曲,MiniPlayer 出现 | 封面、歌名、歌手与点击项一致 |
| 5 | 点击 MiniPlayer 播放/暂停按钮 | 按钮图标随状态切换 |
| 6 | 点击 MiniPlayer 展开箭头跳转播放页 | PlayerPage 显示唱片、歌曲信息和控制栏 |
| 7 | 播放页唱片旋转,点击暂停后停转 | 旋转动画与 isPlaying 状态联动 |
| 8 | 返回详情页 → 返回首页 | 首页标题栏出现”上次:歌曲名”提示 |
- 至少完成 1 项拓展挑战(选做)
- 提交工程源码与演示视频