HarmonyOS 手把手教你学习codelabs 之 MusicPlay【音乐播放器】

背景

HarmonyOS 为了降低研发人员的学习成本,在Gitee上提供了一些样例代码( gitee.com/harmonyos/c... ),基本覆盖所有基础点内容。对新手学习练习这些内容会有一定帮助,但对于复杂度稍微高一点的应用代码,会给新手带来信心上的打击。

因此本篇文章将从MusicPlay这个应用入手,慢慢解析源码,尽可能达到举一反三作用。

文章比较长,建议收藏,以便后续阅读

学完之后,对MusicPlay还有疑问,可以留言

应用源码解析流程

  1. 阅读代码库README
  2. 源代码编译,安装,运行
  3. 熟悉页面&功能
  4. 定位页面所映射的布局代码
  5. 为代码做注释

阅读代码库README

MusicPlay 的README分为5部分。

  • "简介":是为了让研发人员在不运行代码情况下,知晓这个库的功能和运行效果(运行效果看看就行,不要过于兴奋或者被炫酷吓到,研发人员一定要看真机运行效果);
  • "相关概念":是为了说明这个例子涉及到的一些HarmonyOS基础知识概念(这些相关概念并不代表所有HarmonyOS中基础支持概念,比如:这个例子就不会特意介绍@Entry的概念);
  • "相关权限":是为了说明运行此程序,必须满足的权限;
  • "使用说明":是关于这个应用的简化版操作手册(如果无法完全理解看懂,可以先真机运行熟悉起来,然后回过头来再做一次比对);
  • "约束与限制":是为了说明此程序在研发调试中所需的开发环境配置,为了说明应用可被运行的设备

源代码编译,安装,运行

HarmonyOS 应用开发文档的好处是中文,易于快速学习查看。与开发文档相对应的codelabs代码,托管于Gitee之上,好处自然不用多讲:获取代码快,文字阅读易懂。

准备工作

  1. DevEco Studio 安装
  2. 按照"快速入门",看到了运行起来的页面
  3. 克隆MusicPlay代码
    a) 直接从Gitee库中通过浏览器下载
    b)通过git下载, 比如:git clone gitee.com/harmonyos/c...
  4. DevEco Studio 导入工程, " File -> Open... -> MusicPlay -> 选择"Open"菜单 "

编译

总结

  1. 如果编译成功,则会看到 "> hvigor BUILD SUCCESSFUL ......"字样
  2. 如果发生报错,注意查看一下Dev Eco Studio 下边的 "Run" 标签内的日志
  3. 如果发生报错,看了日志,依然无法理解,打开HarmonyOS论坛发布帖子
  4. 如果发生报错,前边两个方法都无法解决,......静静地等待,不要尝试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 本地文件的完整路径 手机端的存储路径。

配置HDC工具环境变量

运行

MusicPlay 由于是元服务,所以不会在手机桌面上生成应用入口图标,如何进入呢?从工程README可知,需要通过桌面卡片才能进入程序。

使用说明

  1. 本篇Codelab为元服务工程,安装到手机后不会在桌面生成桌面图标,通过添加桌面卡片,点击卡> 片即可进入本应用
  2. 在桌面双指捏合进入桌面编辑页面,点击服务卡片,下滑至底部点击其他服务卡片,点击 MusicPlay,即可添加卡片。
  3. 点击界面上播放/暂停、上一首、下一首图标控制音乐播放功能。
  4. 点击界面上播放控制区空白处或列表歌曲跳转到播放页面。
  5. 点击界面上评论按钮跳转到对应的评论页面。
  6. 其他按钮无实际点击事件或功能。

祝你好运,第一步已成功!

熟悉页面&功能

效果浏览

效果交互

功能总结

如何总结:所见所得,按照自己语言总结即可

  1. 桌面上有快捷图标入口(官称: 服务卡片)
  2. 应用主页面(1. 播放列表 2. 音乐控制 3. 评论入口 4. 快捷入口 5. 歌曲详情页入口)
  3. 其它功能快捷入口菜单(1. 添加至桌面 2. 分享 3. 设置 4. 内容举报)
  4. 歌曲详情页
  5. 评论列表
  6. 歌曲控制(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的预览功能查看效果,从而比对出真实的代码映射关系

  1. 根据@Entry 和 @Component修饰的布局,逐句注释掉语句,然后安装软件看生效的结果
  2. 目测下断点

页面代码映射方法

既然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(),用于响应点击之后的行为处理,比如:打开特定页面,更新自身布局元素信息

  • 页面状态共享,用于事件状态共享,比如:点击播放按钮,音乐开始播放时,卡片上的播放按钮需要变化为暂停图片

代码关键词

  1. @Entry : 页面入口
  2. @Component : 这是一个组件
  3. @LocalStorageProp : 感知LocalStorage与之绑定属性值变化
  4. 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()  //自定义,播放控制器
}

代码关键词

  1. PageTransitionEnter : 打开页面时的动画API
  2. PageTransitionExit : 关闭页面时的动画API
  3. 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 })
  }
 
}

相关概念

源码文件-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() //图片,即歌曲列表入口
  }
  
}

相关概念

  1. @StorageLink : 感知AppStorage属性值变化
  2. @StorageProp : 感知AppStorage属性值变化
  3. @Watch : 观察变量变化,注册监听变量变化之后的函数
  4. @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() //图片,即 绿颜色,箭头朝下的那个返回按钮

}

相关概念

  1. 栅格系统断点
  2. VP,HarmonyOS 中的一种像素单位
  3. if/else:条件渲染
  4. 弹性布局(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()          //最新评论列表
         }
       }
    }

相关概念

  1. @Builder : 通过修饰自定义函数,达到复用布局的目的
  2. ForEach, 循环渲染,对于页面布局的循环逻辑,必须使用ForEach

源文件-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;
    })
  }
}

整体代码页面元素映射关系图

相关推荐
zhanshuo12 小时前
手把手教你用 ArkUI 写出高性能分页列表:List + onScroll 实战解析
harmonyos
zhanshuo12 小时前
深入解析 ArkUI 触摸事件机制:从点击到滑动的开发全流程
harmonyos
i仙银18 小时前
鸿蒙沙箱浏览器 - SandboxFinder
app·harmonyos
Georgewu21 小时前
【HarmonyOS】鸿蒙应用开发中常用的三方库介绍和使用示例
harmonyos
AORO20251 天前
遨游三防平板|国产芯片鸿蒙系统单北斗三防平板,安全高效
5g·安全·电脑·制造·信息与通信·harmonyos
HarmonyOS小助手1 天前
“秒开”时代,HarmonyOS预加载让应用启动快如闪电
harmonyos·鸿蒙·鸿蒙生态
三翼鸟数字化技术团队2 天前
鸿蒙平台运行Lua脚本
lua·harmonyos
万少2 天前
学着学着 我就给这个 HarmonyOS 应用增加了些新技术
前端·后端·harmonyos
zhanshuo2 天前
用 ArkUI 打造媲美小红书的瀑布流布局,原来只需一个 Grid!
harmonyos