背景
HarmonyOS 为了降低研发人员的学习成本,在Gitee上提供了一些样例代码( gitee.com/harmonyos/c... ),基本覆盖所有基础点内容。对新手学习练习这些内容会有一定帮助,但对于复杂度稍微高一点的应用代码,会给新手带来信心上的打击。
因此本篇文章将从MusicPlay这个应用入手,慢慢解析源码,尽可能达到举一反三作用。
文章比较长,建议收藏,以便后续阅读
学完之后,对MusicPlay还有疑问,可以留言
应用源码解析流程
- 阅读代码库README
- 源代码编译,安装,运行
- 熟悉页面&功能
- 定位页面所映射的布局代码
- 为代码做注释
阅读代码库README
MusicPlay 的README分为5部分。
- "简介":是为了让研发人员在不运行代码情况下,知晓这个库的功能和运行效果(运行效果看看就行,不要过于兴奋或者被炫酷吓到,研发人员一定要看真机运行效果);
- "相关概念":是为了说明这个例子涉及到的一些HarmonyOS基础知识概念(这些相关概念并不代表所有HarmonyOS中基础支持概念,比如:这个例子就不会特意介绍@Entry的概念);
- "相关权限":是为了说明运行此程序,必须满足的权限;
- "使用说明":是关于这个应用的简化版操作手册(如果无法完全理解看懂,可以先真机运行熟悉起来,然后回过头来再做一次比对);
- "约束与限制":是为了说明此程序在研发调试中所需的开发环境配置,为了说明应用可被运行的设备
源代码编译,安装,运行
HarmonyOS 应用开发文档的好处是中文,易于快速学习查看。与开发文档相对应的codelabs代码,托管于Gitee之上,好处自然不用多讲:获取代码快,文字阅读易懂。
准备工作
- DevEco Studio 安装
- 按照"快速入门",看到了运行起来的页面
- 克隆MusicPlay代码
a) 直接从Gitee库中通过浏览器下载
b)通过git下载, 比如:git clone gitee.com/harmonyos/c... - DevEco Studio 导入工程, " File -> Open... -> MusicPlay -> 选择"Open"菜单 "

编译
总结
- 如果编译成功,则会看到 "> hvigor BUILD SUCCESSFUL ......"字样
- 如果发生报错,注意查看一下Dev Eco Studio 下边的 "Run" 标签内的日志
- 如果发生报错,看了日志,依然无法理解,打开HarmonyOS论坛发布帖子
- 如果发生报错,前边两个方法都无法解决,......静静地等待,不要尝试MusicPlay这个例子了
编译操作
工程导入完成后,DevEco Studio 会自动加载所有依赖,等待加载完成,即可编译。
菜单编译方式如下截图:Build -> Build Hap(s)/APP(s) -> Build Hap(s)
注意:如果是真机,直接选择绿色按钮
不幸的错误
你肯定用的是:Build -> Build Hap(s)/APP(s) -> Build APP(s) 为什么呢?因为它是元服务,元服务应用为什么编译不能选择Build APP(s)? 错误本可以先记录下来错误,不需要浪费时间找原因了
安装
HarmonyOS 有两种调试安装方式:a. 如上边所说,编译时点击的是绿色三角按钮 b. 通过hdc命令将hap包推送到手机
hdc命令安装
arduino
//发送本地hap包到手机
$ hdc file send /Users/Harvey/DevEcoStudioProjects/HarmonyLearn/entry/build/default/outputs/default/entry-default-signed.hap /sdcard/e8f72914d3ac481e8f2150264a38a511/entry-default-signed.hap
//安装手机上的hap包
$ hdc shell bm install -p /sdcard/e8f72914d3ac481e8f2150264a38a511/
//删除手机上的原始hap包
$ hdc shell rm -rf /sdcard/e8f72914d3ac481e8f2150264a38a511
发送文件
hdc file send 本地文件的完整路径 手机端的存储路径。
运行
MusicPlay 由于是元服务,所以不会在手机桌面上生成应用入口图标,如何进入呢?从工程README可知,需要通过桌面卡片才能进入程序。
使用说明
- 本篇Codelab为元服务工程,安装到手机后不会在桌面生成桌面图标,通过添加桌面卡片,点击卡> 片即可进入本应用。
- 在桌面双指捏合进入桌面编辑页面,点击服务卡片,下滑至底部点击其他服务卡片,点击 MusicPlay,即可添加卡片。
- 点击界面上播放/暂停、上一首、下一首图标控制音乐播放功能。
- 点击界面上播放控制区空白处或列表歌曲跳转到播放页面。
- 点击界面上评论按钮跳转到对应的评论页面。
- 其他按钮无实际点击事件或功能。
祝你好运,第一步已成功!
熟悉页面&功能
效果浏览
效果交互

功能总结
如何总结:所见所得,按照自己语言总结即可
- 桌面上有快捷图标入口(官称: 服务卡片)
- 应用主页面(1. 播放列表 2. 音乐控制 3. 评论入口 4. 快捷入口 5. 歌曲详情页入口)
- 其它功能快捷入口菜单(1. 添加至桌面 2. 分享 3. 设置 4. 内容举报)
- 歌曲详情页
- 评论列表
- 歌曲控制(1. 播放 2. 切换 3. 停止 4. 播放模式)
定位页面所映射的布局代码
桌面快捷图标入口
快捷图标入口,这是我自己所见所得的叫法,官称:服务卡片,如果你发现此网址打开无内容,应该是HarmonyOS文档升级了。为什么升级之后,关于服务卡片的文档就无法打开了,因为它的网址用了数字编码,比如特征词:0000001536226057,V3
服务卡片代码特征
module.json5 【ohos.extension.form
在 extensionAbilities 标签中,寻找 "ohos.extension.form"
form_config.json
这个文件中是所有关于服务卡片的配置内容
在这个配置文件中,可以找到桌面上的服务卡片布局文件。可以查找forms属性值中的src属性值,比如:"src": "./ets/widget/pages/WidgetCard.ets" , WidgetCard.ets 就代表一个服务卡片布局文件。服务卡片时常会出现多种样式,那么应该有多种布局文件,如何区分呢?supportDimensions 属性就可以用来区分,在 MusicPlay 工程代码中,根据 supportDimensions 属性值就可以推断出,这个应用总共有4种服务卡片样式
定位布局样式
四种方式
1)写UI的研发人员都知道布局也是要敲代码的,所以我们可以在布局样式代码中添加日志,从而准确定位出所见所得的页面对应的代码是哪个
2)根据form_config.json文件中对应的服务卡片服务文件,依次打开源文件,然后通过IDE的预览功能查看效果,从而比对出真实的代码映射关系
- 根据@Entry 和 @Component修饰的布局,逐句注释掉语句,然后安装软件看生效的结果
- 目测下断点
页面代码映射方法
既然Dev Eco Studio有布局预览功能,其它页面岂不是参照此种方法也可以快速完成映射关系?答案是可以的,但有一个前提,一个ets文件想要达到预览效果,需要满足其中一个条件:1. 类被@Entry装饰 2. 类被@Preview和@Component装饰。
接下来的事情就非常简单, 全局搜索@Entry 和 @Preview就可以找到相应的页面和组件
Edit -> Find -> File in Files...
输入@Entry, 点击"Open In Find Window"
搜索结果
映射结果
布局文件 | 描述 | 效果 |
---|---|---|
Widget2Card.ets | 服务卡片2 | ![]() |
MainPage.ets | 首页 | ![]() |
MusicComment.ets | 评论 | ![]() |
PlayPage.ets | 歌曲详情 | ![]() |
系统功能属性
事实上,在这个样例中,有一个组件比较特殊,即页面上方的标题栏,这是元服务的特殊功能,任何情况下,它都会自动添加。
小结
MusicPlay除了标题栏之外,其本质由4个服务卡片页面,1个主页面, 1个评论页面 和 1个歌曲详情页组成,共 7 个页面。
为代码做注释
这个听起来有点怪怪的,为什么要给样例代码做注释?因为看了多次看不懂,然后逐步分析后才看懂,所以需要添加注释,添加注释能有意放慢研发人员的节奏,从而达到更好的理解效果。
如果研发人员学习完工程后,感觉已经非常理解,这一步可以直接忽略。
由于前期已经映射过了效果页面与代码,所以这里仅仅是进一步对页面相关的代码做进一步的说明
服务卡片
样式

整体布局说明
scss
Row(){ //行布局
Stack(){ //堆叠布局
Image() //第一层 图片,即 音乐封面
Image() //第二层 图片,即 播放/暂停 按钮
}
Text() // 文字,即 音乐名称
Blank() // 空白填充,在不同分辨率屏幕下,可自动伸缩
Image() // 图片,即 切换下一首歌曲按钮
}
相关概念
-
卡片事件传递 postCardAction(),用于响应点击之后的行为处理,比如:打开特定页面,更新自身布局元素信息
-
-
页面状态共享,用于事件状态共享,比如:点击播放按钮,音乐开始播放时,卡片上的播放按钮需要变化为暂停图片
-
代码关键词
- @Entry : 页面入口
- @Component : 这是一个组件
- @LocalStorageProp : 感知LocalStorage与之绑定属性值变化
- postCardAction : 系统API,发送消息至UIAbility
源码文件-WidgetCard.ets
WidgetCard.ets
const MSG_SEND_ACTION_CALL: string = 'call';
const MSG_SEND_ACTION_ROUTER: string = 'router';
const MSG_SEND_METHOD: string = 'CallSendMsgForm';
const ABILITY_NAME = 'EntryAbility';
const MSG_SEND_PLAY = 'play';
const MSG_SEND_PAUSE = 'pause';
const MSG_SEND_NEXT = 'next';
//实例化一个LocalStorage
let storageCard = new LocalStorage();
//将LocalStorage实例化对象传入@Entry装饰器中,即可实现一个UIAbility所有页面状态的共享
//服务卡片的实际情况是,只要在这个布局文件中将接收信息的变量添加上@LocalStorageProp装饰器就可以完成交互
@Entry(storageCard)
@Component
struct WidgetCard {
/**
* The cover image height.
*/
readonly COVER_IMAGE_HEIGHT: string = '76%';
/**
* The play icon height.
*/
readonly PLAY_IMAGE_HEIGHT: string = '44%';
/**
* The stack image left.
*/
readonly STACK_MARGIN_LEFT: string = '4%';
/**
* The text margin left.
*/
readonly TEXT_MARGIN_LEFT: string = '4%';
/**
* The text width.
*/
readonly TEXT_WIDTH: string = '50%';
/**
* The next image width.
*/
readonly NEXT_IMAGE_WIDTH: string = '13%';
/**
* The next image margin right.
*/
readonly NEXT_IMAGE_MARGIN_RIGHT: string = '9%';
/**
* The full percent.
*/
readonly FULL_PERCENT: string = '100%';
@LocalStorageProp('isPlay') isPlay: boolean = false;
@LocalStorageProp('musicName') musicName: string = '独立民谣';
@LocalStorageProp('musicCover') musicCover: Resource = $r('app.media.ic_avatar1');
@LocalStorageProp('musicSinger') musicSinger: string = 'Katy Perry';
//整体行布局的点击响应事件,最终进入首页
//这个跳转采用的是router机制:https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-ui-widget-event-router-0000001502352142-V3
//如果当前桌面上的服务卡片不是播放状态,则进入应用首页后也依然不是播放状态
routerToMainPage() {
postCardAction(this, {
'action': MSG_SEND_ACTION_ROUTER,
'abilityName': ABILITY_NAME,
'params': {
'message': MSG_SEND_PLAY
}
});
}
//歌曲切换按钮的响应事件, 最终进入首页,流转 sendMessage, 本例中有3种消息,分别为:MSG_SEND_PLAY(播放歌曲), MSG_SEND_PAUSE(暂停歌曲), MSG_SEND_NEXT(切换下一首歌曲)
//这个跳转采用的是call机制:https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-ui-widget-event-call-0000001594314361-V3
onCallOperation(sendMessage: string) {
postCardAction(this, {
'action': MSG_SEND_ACTION_CALL,
'abilityName': ABILITY_NAME,
'params': {
'method': MSG_SEND_METHOD,
'message': sendMessage
}
});
}
build() {
Row() { //整体为行布局
Stack() { //封面 + 播放按钮
//封面
Image(this.musicCover)
.height(this.COVER_IMAGE_HEIGHT)
.aspectRatio(1)
.borderRadius($r('app.float.image_border_radius'))
//播放音乐按钮,具备暂停功能
Image(this.isPlay ? $r('app.media.ic_play_normal') : $r('app.media.ic_Pause_normal'))
.height(this.PLAY_IMAGE_HEIGHT)
.height('44%')
.aspectRatio(1)
.onClick(() => {
let sendMessage = this.isPlay ? MSG_SEND_PAUSE : MSG_SEND_PLAY;
this.onCallOperation(sendMessage);
})
}
// 歌曲名字
Text(this.musicName)
.width(this.TEXT_WIDTH)
.fontSize($r('app.float.text_font_size'))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.item_title_font'))
.margin({ left: this.TEXT_MARGIN_LEFT })
.textOverflow({ overflow: TextOverflow.Ellipsis })
.maxLines(1)
Blank()
// 切换下一首歌按钮
Image($r('app.media.ic_next_grey'))
.width($r('app.float.image_option'))
.aspectRatio(1)
.onClick(() => {
this.onCallOperation(MSG_SEND_NEXT);
})
}
.padding({
left: this.STACK_MARGIN_LEFT,
right: this.STACK_MARGIN_LEFT
})
.width(this.FULL_PERCENT)
.height(this.FULL_PERCENT)
.backgroundColor($r('app.color.card1_background'))
.onClick(() => {
this.routerToMainPage();
})
}
}
主页
样式

整体布局说明
注意:这里的整体布局不包含"状态栏"和"标题栏",因为应用为元服务,所以标题栏属于元服务自身的一部份
scss
Stack(){ //跟布局为堆叠布局
Content() //自定义,歌曲内容
Player() //自定义,播放控制器
}
代码关键词
- PageTransitionEnter : 打开页面时的动画API
- PageTransitionExit : 关闭页面时的动画API
- r : 引用资源的API,如果之前你没接触过以开头的函数名称,不要奇怪,仅仅是一个符号而已
源码文件-MainPage.ets
scss
......
@Entry
@Component
struct MainPage {
private breakpointSystem: BreakpointSystem = new BreakpointSystem();
//监听屏幕分辨率,根据不同分辨率来调节自定义组件的布局(Content 和 Player)
@State currentBreakpoint: string = BreakpointConstants.BREAKPOINT_SM;
//页面即将展示 API,系统提供
aboutToAppear() {
//初始化音乐播放器
MediaService.getInstance();
//注册监听屏幕分辨率
this.breakpointSystem.register();
}
//页面即将销毁 API,系统提供
aboutToDisappear() {
//解除监听屏幕分辨率
this.breakpointSystem.unregister();
}
//布局渲染 API,系统提供
build() {
Stack({ alignContent: Alignment.Top }) {
Content({ currentBreakpoint: $currentBreakpoint })
Player({ currentBreakpoint: $currentBreakpoint })
}
.width(StyleConstants.FULL_WIDTH)
.backgroundColor(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ?
$r('app.color.page_background_sm') : $r('app.color.page_background_other'))
}
//页面切换动画 API, 系统提供
pageTransition() {
PageTransitionEnter({ duration: SongConstants.TRANSITION_DURATION, curve: Curve.Smooth })
PageTransitionExit({ duration: SongConstants.TRANSITION_DURATION, curve: Curve.Smooth })
}
}
歌曲内容
样式

整体布局说明
scss
GridRow() { //栅格布局:行, 默认12列
GridCol() { //栅格布局: 列,歌曲封面
AlbumCover({ currentBreakpoint: $currentBreakpoint })
}
GridCol() { //栅格布局: 列, 歌曲列表
PlayList({ currentBreakpoint: $currentBreakpoint })
}
}
相关概念
- GridRow/GridCol : developer.harmonyos.com/cn/docs/doc... 注意,看完这篇官方文档,再联想MainPage.ets文件中监听分辨率的代码,就可以想的通其用意了
源码文件-Content.ets
Content.ets
......
@Component
export struct Content {
//设备断点,采用@Link 接收父布局监听到分辨率
@Link currentBreakpoint: string;
build() {
GridRow() { //栅栏布局 行
//栅栏布局 列
GridCol({ span: { sm: GridConstants.SPAN_TWELVE, md: GridConstants.SPAN_SIX, lg: GridConstants.SPAN_FOUR } }) {
//歌曲封面
AlbumCover({ currentBreakpoint: $currentBreakpoint })
}
.backgroundColor($r('app.color.album_background'))//设置背景颜色
//栅栏布局 列
GridCol({ span: { sm: GridConstants.SPAN_TWELVE, md: GridConstants.SPAN_SIX, lg: GridConstants.SPAN_EIGHT } }) {
//歌曲列表
PlayList({ currentBreakpoint: $currentBreakpoint })
}
.borderRadius($r('app.float.playlist_border_radius'))//设置背景圆角
}
.height(StyleConstants.FULL_HEIGHT)
.onBreakpointChange((breakpoints: string) => {
this.currentBreakpoint = breakpoints;
})
}
}
播放控制器
样式
### 整体布局说明
scss
Row() { //行布局
Row() {
Image() //图片,即歌曲封面
Column(){ //列布局
Text() //文字,即 歌曲名称
Row() {
Image() //图片,即 VIP标识
Text() //文字,即 歌手名字
}
}
}
Blank() //空白填充
Row(){
Image() //图片,即切换上一首歌曲按钮
Image() //图片,即播放/暂停
Image() //图片,即切换下一首歌曲按钮
Image() //图片,即歌曲列表入口
}
}
相关概念
- @StorageLink : 感知AppStorage属性值变化
- @StorageProp : 感知AppStorage属性值变化
- @Watch : 观察变量变化,注册监听变量变化之后的函数
- @State : 感知变量变化,刷新元素
源文件-Player.ets
less
......
@Preview //这个装饰用于预览@Component修饰的类,不能和@Entry搭配使用
@Component //代表这个类是一个组件
export struct Player {
//当前正在播放的歌曲列表下标
@StorageProp('selectIndex') selectIndex: number = 0;
//播放状态,这里的'isPlay'会在MediaService类中进行修改,从而因为@StorageLink的修饰,会被感知到
@StorageLink('isPlay') @Watch('animationFun') isPlay: boolean = false;
//歌曲列表,MusicList 为数组常量
songList: SongItem[] = MusicList;
//栅栏布局"断点", 使用@Link的原因:必须通过父布局对本组件进行初始化
@Link currentBreakpoint: string;
//歌曲封面旋转角度,@State 修饰的变量在值发生变化时,页面元素会自动刷新
@State imageRotate: number = 0;
//歌曲封面旋转角度控制函数
animationFun() {
this.imageRotate = 0;
//如果当前处于播放状态,则封面的旋转角度为360度,否则封面不旋转
this.imageRotate = this.isPlay ? PlayerConstants.ROTATE : 0;
}
build() {
Row() {
Row() {
//歌曲封面
Image(this.songList[this.selectIndex]?.label)
.height($r('app.float.cover_height'))
.width($r('app.float.cover_width'))
.borderRadius($r('app.float.label_border_radius'))
.margin({ right: $r('app.float.cover_margin') })
.rotate({ angle: this.imageRotate })
.animation({ //播放歌曲时,封面转动的动画
duration: PlayerConstants.ANIMATION_DURATION,
iterations: PlayerConstants.ITERATIONS,
curve: Curve.Linear
})
.onAppear(() => {
//当前Image可见时,设置动画旋转角度
this.animationFun();
})
Column() {
//歌曲名称
Text(this.songList[this.selectIndex].title)
.fontColor($r('app.color.song_name'))
.fontSize(new BreakpointType({ //根据栅栏布局 断点计算字体尺寸
sm: $r('app.float.song_title_sm'),
md: $r('app.float.song_title_md'),
lg: $r('app.float.song_title_lg')
}).getValue(this.currentBreakpoint))
Row() {
//VIP状态图片
Image($r('app.media.ic_vip'))
.height($r('app.float.vip_icon_height'))
.width($r('app.float.vip_icon_width'))
.margin({ right: $r('app.float.vip_icon_margin') })
//歌手名字
Text(this.songList[this.selectIndex].singer)
.fontColor($r('app.color.singer'))
.fontSize(new BreakpointType({ //根据栅栏布局 断点计算字体尺寸
sm: $r('app.float.singer_title_sm'),
md: $r('app.float.singer_title_md'),
lg: $r('app.float.singer_title_lg')
}).getValue(this.currentBreakpoint))
.opacity($r('app.float.singer_opacity')) //歌手名字透明度
}
}
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(PlayerConstants.LAYOUT_WEIGHT_PLAYER_CONTROL)
.onClick(() => {
//被点击之后,打开歌曲详情页 PlayPage.ets
//以单例模式打开,见参数:router.RouterMode.Single
router.pushUrl({
url: RouterUrlConstants.MUSIC_PLAY
}, router.RouterMode.Single);
})
Blank()
.onClick(() => {
//空白部分被点击之后,打开歌曲详情页 PlayPage.ets
//以单例模式打开,见参数:router.RouterMode.Single
router.pushUrl({
url: RouterUrlConstants.MUSIC_PLAY
}, router.RouterMode.Single);
})
Row() {
//切换上一首歌曲
Image($r('app.media.ic_previous'))
.height($r('app.float.control_icon_height'))
.width($r('app.float.control_icon_width'))
.margin({ right: $r('app.float.control_icon_margin') })
.displayPriority(PlayerConstants.DISPLAY_PRIORITY_TWO)
.onClick(() =>
//通过MediaService单例对象播放上一首歌曲
MediaService.getInstance().playPrevious()
)
//播放/暂停歌曲
Image(this.isPlay ? $r('app.media.ic_play') : $r('app.media.ic_pause'))
.height($r('app.float.control_icon_height'))
.width($r('app.float.control_icon_width'))
.displayPriority(PlayerConstants.DISPLAY_PRIORITY_THREE)
.onClick(() => {
//第一次播放
if (MediaService.getInstance().getFirst()) {
MediaService.getInstance().loadAssent(0);
} else {
//如果正在播放,则本次需要执行暂停;如果正在暂停,则本次需要执行播放
this.isPlay ? MediaService.getInstance().pause() : MediaService.getInstance().play();
}
})
//切换下一首歌曲
Image($r('app.media.ic_list_next'))
.height($r('app.float.control_icon_height'))
.width($r('app.float.control_icon_width'))
.margin({
right: $r('app.float.control_icon_margin'),
left: $r('app.float.control_icon_margin')
})
.displayPriority(PlayerConstants.DISPLAY_PRIORITY_TWO)
.onClick(() =>
//通过MediaService单例对象播放下一首歌曲
MediaService.getInstance().playNextAuto(true)
)
//歌曲列表,此处没有实现点击事件
Image($r('app.media.ic_music_list'))
.height($r('app.float.control_icon_height'))
.width($r('app.float.control_icon_width'))
.displayPriority(PlayerConstants.DISPLAY_PRIORITY_ONE)
}
.width(new BreakpointType({
sm: $r('app.float.play_width_sm'),
md: $r('app.float.play_width_sm'),
lg: $r('app.float.play_width_lg')
}).getValue(this.currentBreakpoint))
.justifyContent(FlexAlign.End)
}
.width(StyleConstants.FULL_WIDTH)
.height($r('app.float.player_area_height'))
.backgroundColor($r('app.color.player_background'))
.padding({
left: $r('app.float.player_padding'),
right: $r('app.float.player_padding')
})
.position({
x: 0,
y: StyleConstants.FULL_HEIGHT
})
.translate({
x: 0,
y: StyleConstants.TRANSLATE_PLAYER_Y
})
}
}
歌曲详情页
样式

整体布局说明
scss
Stack(){
//大宽度布局,即断点为lg,参见:https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-layout-development-grid-layout-0000001454765270-V3#section11932105415580
//[840vp, +∞)
//VP像素单位,参见:https://developer.harmonyos.com/cn/docs/documentation/doc-references-V3/ts-pixel-units-0000001478341189-V3
if(this.currentBreakpoint === PlayConstants.LG){
Stack(){ //堆叠布局
LyricsComponent() //歌词
Flex({ direction: FlexDirection.Column }) { //列布局
MusicInfoComponent() //歌曲信息
ControlComponent() //播放控制器
}
}
} else {
Flex({ direction: FlexDirection.Column }) { //列布局
Tabs(){ //歌曲&歌词容器
TabContent(){ //歌曲
MusicInfoComponent() //歌曲信息
}
TabContent(){ //歌词
LyricsComponent() //歌词
}
}
ControlComponent() //播放控制器
}
}
Image() //图片,即 绿颜色,箭头朝下的那个返回按钮
}
相关概念
- 栅格系统断点
- VP,HarmonyOS 中的一种像素单位
- if/else:条件渲染
- 弹性布局(Flex)
源码文件-PlayPage.ets
scss
......
@Entry
@Component
struct PlayPage {
@State currentTabIndex: number = 0;
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'sm';
songList: SongItem[] = MusicList;
@StorageProp('selectIndex') selectIndex: number = 0;
build() {
Stack({ alignContent: Alignment.TopStart }) {
//大宽度布局
if (this.currentBreakpoint === PlayConstants.LG) {
Stack({ alignContent: Alignment.Top }) {
LyricsComponent() //歌词
Flex({ direction: FlexDirection.Column }) {
MusicInfoComponent() //歌曲
ControlComponent() //音乐播放控制器
}
}
} else {
//其它宽度布局
Flex({ direction: FlexDirection.Column }) { //列布局, 原因:FlexDirection.Column
Tabs({ barPosition: BarPosition.Start, index: this.currentTabIndex }) {
TabContent() { //歌曲Tab
MusicInfoComponent()
}
.tabBar(this.TabTitle(PlayConstants.TAB_SONG, 0))
TabContent() { //歌词Tab
LyricsComponent()
}
.tabBar(this.TabTitle(PlayConstants.TAB_LYRICS, 1))
}
.onChange(index => this.currentTabIndex = index)
.vertical(false)
.barHeight($r('app.float.fifty_six'))
.barWidth(PlayConstants.TAB_WIDTH)
//音乐播放控制器
ControlComponent()
}
}
Image($r('app.media.ic_back_down'))
.width($r('app.float.image_back_size'))
.height($r('app.float.image_back_size'))
.margin({ left: $r('app.float.twenty_four'), top: $r('app.float.image_back_margin_top') })
.onClick(() => router.back())
}
.backgroundImage(this.songList[this.selectIndex].label)
.backgroundImageSize(ImageSize.Cover)
.backdropBlur(PlayConstants.BLUR)
.linearGradient({
direction: GradientDirection.Bottom,
colors: [
[PlayConstants.EIGHTY_WHITE_COLOR, PlayConstants.EIGHTY_WHITE],
[PlayConstants.NINETY_WHITE_COLOR, PlayConstants.NINETY_WHITE]
]
})
.height(StyleConstants.FULL_HEIGHT)
.width(StyleConstants.FULL_WIDTH)
}
@Builder TabTitle(title: string, index: number) {
Text(title)
.fontColor(this.currentTabIndex === index ? $r('app.color.text_color') : $r('app.color.text_forty_color'))
.fontWeight(this.currentTabIndex === index ? PlayConstants.FIVE_HUNDRED : PlayConstants.FOUR_HUNDRED)
.fontSize(new BreakpointType({
sm: $r('app.float.font_sixteen'),
md: $r('app.float.font_twenty')
}).getValue(this.currentBreakpoint))
.border({
width: { bottom: this.currentTabIndex === index ? $r('app.float.tab_border_width') : 0 },
color: $r('app.color.text_color')
})
.padding({ bottom: $r('app.float.tab_text_padding_bottom') })
}
pageTransition() {
PageTransitionEnter({ duration: PlayConstants.FIVE_HUNDRED, curve: Curve.Smooth }).slide(SlideEffect.Bottom);
PageTransitionExit({ duration: PlayConstants.FIVE_HUNDRED, curve: Curve.Smooth }).slide(SlideEffect.Bottom);
}
}
评论页
样式

整体布局说明
scss
GridRow(){
GridCol(){
Column(){
CommentMusicComponent() //歌曲信息
this.ShowTile() //精彩评论
List() //精彩评论列表
this.ShowTile() //最新评论
List() //最新评论列表
}
}
}
相关概念
源文件-MusicComment.ets
MusicComment.ets
......
@Entry
@Component
struct MusicComment {
@State currentBp: string = BreakpointConstants.CURRENT_BREAKPOINT;
@State wonderfulComment: Comment[] = CommentViewModel.getWonderfulReview();
@State newComment: Comment[] = CommentViewModel.getNewComment();
//评论类型标题栏:1. 精选评论 2.最新评论
@Builder ShowTitle(title: ResourceStr) {
Row() {
Text(title)
.fontSize($r('app.float.comment_title_size'))
.fontColor($r('app.color.comment_title_color'))
.lineHeight($r('app.float.title_line_height'))
.fontWeight(FontWeight.Medium)
.margin({
top: $r('app.float.title_margin_top'),
bottom: $r('app.float.title_margin_bottom'),
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_left_sm') : $r('app.float.margin_left'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_right_sm') : $r('app.float.margin_right')
})
}
.justifyContent(FlexAlign.Start)
.width(StyleConstants.FULL_WIDTH)
}
build() {
GridRow({
breakpoints: {
value: BreakpointConstants.BREAKPOINT_VALUE,
reference: BreakpointsReference.WindowSize
},
columns: {
sm: BreakpointConstants.COLUMN_SM,
md: BreakpointConstants.COLUMN_MD,
lg: BreakpointConstants.COLUMN_LG
},
gutter: { x: BreakpointConstants.GUTTER_X }
}) {
GridCol({
span: {
sm: BreakpointConstants.COLUMN_SM,
md: BreakpointConstants.COLUMN_MD,
lg: BreakpointConstants.COLUMN_LG
}
}) {
Column() {
//被评论的音乐信息组件
CommentMusicComponent()
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_left_sm') : $r('app.float.margin_left'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_right_sm') : $r('app.float.margin_right')
})
//精选评论标题,实际场景中,这里需要再添加一层if else 判断,因为精彩评论可能为空
this.ShowTitle($r('app.string.wonderful_comment'))
//精彩评论列表
List() {
//页面渲染循环,必须使用ForEach
ForEach(this.wonderfulComment, (comment: Comment, index?: number) => {
if (this.currentBp === BreakpointConstants.BREAKPOINT_SM ||
this.currentBp === BreakpointConstants.BREAKPOINT_MD) {
if (index && index < CommonConstants.LIST_COUNT) {
ListItem() { //List必须包含ListItem, 以达到对每条信息的统一封装
//自定义的评论组件
ListItemComponent({ item: comment })
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.margin_left_list'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.margin_right_list')
})
}
.width(StyleConstants.FULL_WIDTH)
.padding({
bottom: $r('app.float.padding_bottom')
})
}
} else {
ListItem() { //List必须包含ListItem, 以达到对每条信息的统一封装
//自定义的评论组件
ListItemComponent({ item: comment })
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.margin_left_list'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.margin_right_list')
})
}
.width(StyleConstants.FULL_WIDTH)
.padding({
bottom: $r('app.float.padding_bottom')
})
}
}, (item: Comment, index?: number) => index + JSON.stringify(item))
}
.lanes(this.currentBp === BreakpointConstants.BREAKPOINT_LG ? 2 : 1)
.scrollBar(BarState.Off)
.divider({
color: $r('app.color.list_divider'),
strokeWidth: $r('app.float.stroke_width'),
startMargin: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.start_margin') : $r('app.float.start_margin_lg'),
endMargin: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.divider_margin_left')
})
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_left_sm') : $r('app.float.margin_left_list'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_right_sm') : $r('app.float.margin_right_list')
})
//最新评论标题,实际场景中,这里需要再添加一层if else 判断,因为精彩评论可能为空
this.ShowTitle($r('app.string.new_comment'))
List() {
//页面渲染循环,必须使用ForEach
ForEach(this.newComment, (comment: Comment) => {
ListItem() {//List必须包含ListItem, 以达到对每条信息的统一封装
//自定义的评论组件
ListItemComponent({ item: comment })
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.margin_left_list'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.margin_right_list')
})
}
.width(StyleConstants.FULL_WIDTH)
.padding({
bottom: $r('app.float.padding_bottom')
})
}, (item: Comment, index?: number) => index + JSON.stringify(item))
}
.layoutWeight(1)
.lanes(this.currentBp === BreakpointConstants.BREAKPOINT_LG ? 2 : 1)
.scrollBar(BarState.Off)
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_left_sm') : $r('app.float.margin_left_list'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_right_sm') : $r('app.float.margin_right_list')
})
.divider({
color: $r('app.color.list_divider'),
strokeWidth: $r('app.float.stroke_width'),
startMargin: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.start_margin') : $r('app.float.start_margin_lg'),
endMargin: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.divider_margin_left')
})
}
.height(StyleConstants.FULL_HEIGHT)
}
}
.backgroundColor(Color.White)
.onBreakpointChange((breakpoint) => {
this.currentBp = breakpoint;
})
}
}
整体代码页面元素映射关系图
