手把手带你实现 鸿蒙应用-键盘音乐

手把手带你实现 鸿蒙应用-键盘音乐

先看结果

image-20240724165146560

关键技术

  1. 基本布局技巧
  2. AVPlayer
  3. 面向对象
  4. 全部采用 V2版本 状态管理技术

新建一个项目

  1. 创建项目

    image-20240724165725058

  2. 新建项目

    image-20240724165809469

  3. 目录结构 - 可以后期用到再去新建

    image-20240724170053521

设置全局沉浸式

设置和不设置全局沉浸式的区别是这样的

image-20240724172446641

  1. src/main/ets/entryability/EntryAbility.ets 文件内进行编辑

  2. loadContent 中进行设置

image-20240724172808954

  //   1 设置应用全屏
  let windowClass: window.Window = windowStage.getMainWindowSync(); // 获取应用主窗口

  // 2   设置沉浸式
  windowClass.setWindowLayoutFullScreen(true)
  1. 此时效果是这样的 , 文字也会直接在状态栏上显示

    image-20240724172944978

    1. 此时,考虑到不同设备的状态栏高度可能不同,所以我们需要

      1. 动态获取状态栏高度,存到全局状态中 AppStorageV2
      2. 页面读取全局状态中的状态栏高度,单独给页面进行设置
         //   1 获取应用窗口对象
        const windowClass: window.Window = windowStage.getMainWindowSync(); // 获取应用主窗口
        //  2  设置全屏
        windowClass.setWindowLayoutFullScreen(true)
        // 3 获取布局避让遮挡的区域
        const type = window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR;
      
      
        const avoidArea = windowClass.getWindowAvoidArea(type);
        const bottomRectHeight = avoidArea.bottomRect.height; // 获取到导航条区域的高度
        const vpHeight = px2vp(bottomRectHeight) //  转换成 vp单位的数值
        //   4 把导航栏高度数据 存在全局
        const appStatu = AppStorageV2.connect(AppStatu, "AppStatu", () => new AppStatu())
        appStatu!.vpHeight = vpHeight
      
    2. AppStatu 是自定义类,用来存储数据 状态栏高度数据的

      src/main/ets/types/index.ets

      @ObservedV2
      export class AppStatu {
        @Trace vpHeight: number =0
      }
      

搭建背景

image-20240724171823959

  build() {

    Column({ space: 30 }) {

    }
    .width("100%")
    .height("100%")
    .backgroundImage($r("app.media.startIcon"))
    .backgroundImageSize(ImageSize.FILL)
    .backdropBlur(1000) // 对背景进行模糊

  }

搭建琴谱

image-20240725131919196

琴谱背景区域

使用背景图片+模糊搭建琴谱区域,高度由内容撑开

image-20240725131740491

  @Builder
  MusicScore() {
    // 琴谱
    Column({ space: 3 }) {
      Text("琴谱")
    }
    .width("100%")
    .backgroundImage($r("app.media.startIcon"))
    .backgroundImageSize(ImageSize.FILL)
    .backdropBlur(500) // 对背景进行模糊
    .padding({
      top: this.appStatu!.vpHeight + 20
    })
  }

  build() {

    Column({ space: 30 }) {
      //   1 琴谱
      this.MusicScore()
    }
    .width("100%")
    .height("100%")

    .backgroundImage($r("app.media.startIcon"))
    .backgroundImageSize(ImageSize.FILL)
    .backdropBlur(1000) // 对背景进行模糊

  }

定义琴谱数据类型

琴谱只需要两个字段

  1. 琴谱对应歌曲的标题 title
  2. 琴谱 对应的英文字母 content

src/main/ets/types/index.ets

@ObservedV2
export class Lyric {
  @Trace title: string = ""
  @Trace content: string[] = []
}

定义字母的正确和不正确的状态类型

  1. 如图所示,绿色为正确
  2. 黄色为未输入或者不正确

image-20240725151553224

@ObservedV2
export class LyricStatu {
  @Trace title: string = ""
  @Trace isCorrect: boolean = false
}

处理要渲染的数据

为了方便页面的效果处理,我们需要将手上的数据,简单处理下,方便页面渲染

  1. 手上的数据

    src/main/ets/mock/index.ets

    import { Lyric } from '../types'
    
    export const tonghua: Lyric = {
      title: "童话",
      content: ["LONOL", "LONOL", "OOMML", "LONOL", "LQPPO", "LONOM", "MMOTS", "PPRRQQ", "QQNPOONO", "ONOR", "LSRQPPPRRQQ",
        "QQVUTUV", "VPOT", "TTSSSLSRQQRQ", "QRQ", "RQPOOQST", "TTSPPRQ", "OQST", "TTSPPRQRQPO", "PQMMOONO",]
    }
    
  2. 处理后的数据结构

    image-20240725162815736

  3. 为什么要这样处理,因为让它方便渲染

    image-20240725163103371

  4. 如何处理呢 在页面打开的时候在aboutToAppear中处理即可 lyricList

    import { tonghua } from '../mock'
    import {   LyricStatu } from '../types'
    @Entry
    @ComponentV2
    struct Index {
      // 琴谱列表
     @Local lyricList: LyricStatu[][] = []
      aboutToAppear() {
        this.lyricList = tonghua.content.map(row => {
          const list = row.split('').map(v => {
            const o = new LyricStatu()
            o.title = v
            return o
          })
          return list
        })
      }
    }
    
   
### 渲染琴谱
   
   ```typescript
   // 状态栏的高度
   @Local appStatu: AppStatu | undefined = AppStorageV2.connect(AppStatu, "AppStatu", () => new AppStatu())
   
   @Builder
     MusicScore() {
       // 琴谱
       Column({ space: 3 }) {
         //   标题
         Text(tonghua.title)
           .fontSize(30)
           .fontColor("#fff")
         ForEach(this.lyricList, (item1: LyricStatu[]) => {
           Row({ space: 5 }) {
             ForEach(item1, (item2: LyricStatu) => {
               Text(item2.title)
                 .fontColor(item2.isCorrect ? "#23d96e" : "#ffcf49")
                 .fontSize(20)
             })
           }
         })
       }
       .width("100%")
       .backgroundImage($r("app.media.startIcon"))
       .backgroundImageSize(ImageSize.FILL)
       .backdropBlur(500) // 对背景进行模糊
       .padding({ // 设置文字下移,否则被屏幕摄像头给挡住
         top: this.appStatu!.vpHeight + 20
       })
     }
   
   
   
     build() {
   
       Column({ space: 30 }) {
         //   1 琴谱
         this.MusicScore()
   
         // this.KeyBoard()
       }
       .width("100%")
       .height("100%")
   
       .backgroundImage($r("app.media.startIcon"))
       .backgroundImageSize(ImageSize.FILL)
       .backdropBlur(1000) // 对背景进行模糊
   
     }
   

得到结果

image-20240725180612568

搭建键盘

image-20240725181308829

准备音频资源

键盘一个26个字母,对应边有26个声音。一一相对应

其中,我们的静态资源存放在 rawFile中,鸿蒙应用在打包时不会对里面的文件做任何的编译处理,然后在使用的时候需要搭配AVPlayer使用。如

 const res = await getContext().resourceManager.getRawFd("paino1.mp3")
AVPlayer实例.fdSrc = res

image-20240725181410268

定义字母和音频映射数据

src/main/ets/mock/index.ets

export const letters: LettemMusic[][] = [
  [
    { name: "Q", src: "paino17.mp3" },
    { name: "W", src: "paino23.mp3" },
    { name: "E", src: "paino5.mp3" },
    { name: "R", src: "paino18.mp3" },
    { name: "T", src: "paino20.mp3" },
    { name: "Y", src: "paino25.mp3" },
    { name: "U", src: "paino21.mp3" },
    { name: "I", src: "paino9.mp3" },
    { name: "O", src: "paino15.mp3" },
    { name: "P", src: "paino16.mp3" },
  ],
  [
    { name: "A", src: "paino1.mp3" },
    { name: "S", src: "paino19.mp3" },
    { name: "D", src: "paino4.mp3" },
    { name: "F", src: "paino6.mp3" },
    { name: "G", src: "paino7.mp3" },
    { name: "H", src: "paino8.mp3" },
    { name: "J", src: "paino10.mp3" },
    { name: "K", src: "paino11.mp3" },
    { name: "L", src: "paino12.mp3" },
  ],
  [
    { name: "Z", src: "paino26.mp3" },
    { name: "X", src: "paino24.mp3" },
    { name: "C", src: "paino3.mp3" },
    { name: "V", src: "paino22.mp3" },
    { name: "B", src: "paino2.mp3" },
    { name: "N", src: "paino14.mp3" },
    { name: "M", src: "paino13.mp3" },
  ]
]

页面关联数据

import { letters, tonghua } from '../mock'

...
// 键盘 和 对应的音乐按键
@Local letters: LettemMusic[][] = letters

构建键盘布局结构

  // 键盘
  @Builder
  KeyBoard() {
    Column({ space: 10 }) {
      ForEach(this.letters, (items: LettemMusic[]) => {
        Row({ space: 8 }) {
          ForEach(items, (item: LettemMusic) => {
            Text(item.name)
              .backgroundColor("rgba(255,255,255,0.9)")
              .padding(10)
              .borderRadius(10)
              .fontWeight(400)
              .stateStyles({
                clicked: {
                  .backgroundColor("#fff")
                }
              })
          })
        }
        .width("100%")
        .padding(2)
        .justifyContent(FlexAlign.Center)
      })

    }
    .layoutWeight(1)
  }

使用键盘布局结构

  build() {

    Column({ space: 30 }) {
      //   1 琴谱
      this.MusicScore()

      //   2 键盘
      this.KeyBoard()
    }
    .width("100%")
    .height("100%")
    .backgroundImage($r("app.media.startIcon"))
    .backgroundImageSize(ImageSize.FILL)
    .backdropBlur(1000) // 对背景进行模糊

  }

按下键盘,播放音乐功能

关键流程

  1. 封装AVPlayer管理类,每一个按键对应一个单独声音,因为上一个声音没有播放完毕,我们是可以同时播放第二个、第三个声音的,所以可以通过实例化多个 AVPlayer来使其一一对应
  2. 点击键盘 获取键盘对应的音乐路径
  3. 将音乐路径传递给AVPlayer,使其播放声音

了解AVPlayer

使用AVPlayer可以实现端到端播放原始媒体资源,本开发指导将以完整地播放一首音乐作为示例,向开发者讲解AVPlayer音频播放相关功能。

播放的全流程包含:创建AVPlayer,设置播放资源,设置播放参数(音量/倍速/焦点模式),播放控制(播放/暂停/跳转/停止),重置,销毁资源。

在进行应用开发的过程中,开发者可以通过AVPlayer的state属性主动获取当前状态或使用on(‘stateChange’)方法监听状态变化。如果应用在音频播放器处于错误状态时执行操作,系统可能会抛出异常或生成其他未定义的行为。

使用流程基本围绕这一张图即可

image-20240726171415420

AVPlayer基本使用流程

  1. 创建 AVPlayer 实例 此时,avPlayer进入空闲状态 idle

    const avPlayer = await media.createAVPlayer()
    
  2. 监听状态的改变 我们对播放器的每一个操作,都会影响到它状态发生改变

    avPlayer.on("stateChange", (state) => {
          switch (state) {
            // 如果播放器初始化完毕,那么就让它开始状态
            case "initialized":
              avPlayer.prepare()
              break;
            case "prepared":
             // 如果播放器准备完毕,就让它变成开始播放     
              avPlayer.play()
              break;
            default:
              break;
          }
        })
    
  3. 设置播放音乐的URL

    	const res = await getContext().resourceManager.getRawFd(this.url)
        avPlayer.fdSrc = res  // 设置完播放器后,播放器会进入 initialized 状态
    
  4. 开始播放

    我们已经在 prepared 状态中,设置了自动播放了 avPlayer.play()

核心思路讲解

  1. 我们思考一下弹钢琴的逻辑,我们是不是可以同时按下多个按键,同时播放声音的? 所以我们需要 new 多个 AVPlayer播放器实例
  2. 如果你重复按下两个相同的琴键,终止上一个琴键的播放,马上开启新的一个琴键的播放
  3. 最后,当这个琴键播放完毕时,我们要销毁掉这个实例,释放内存

AVPlayerManager

src/main/ets/utils/AvPlayerManager.ets

实现了对 AVPlayer功能的基本封装

import { media } from '@kit.MediaKit'

class AVPlayerManager {
  // 播放器实例
  avPlayer: media.AVPlayer | null = null;
  url: string = ""
  // 播放完毕的回调事件
  playComplete: () => void = () => {
  }

  // 构造函数
  constructor(url: string) {
    this.init()
    this.url = url
  }

  // 初始化
  async init() {
    this.avPlayer = await media.createAVPlayer()
    this.avPlayer.on("stateChange", (state) => {
      switch (state) {
        case "initialized":
          this.avPlayer?.prepare()
          break;
        case "prepared":
          this.avPlayer?.play()
          break;
        case "completed":
          // 播放完毕,销毁实例
          this.avPlayer?.release()
          this.playComplete()
          break;
        default:
          break;
      }
    })
    this.avPlayer.on("error", (err) => {
      console.log("err", err)
    })
    // 设置URL
    const res = await getContext().resourceManager.getRawFd(this.url)
    this.avPlayer!.fdSrc = res
  }
}


export default AVPlayerManager

对琴谱数据进行扁平化处理

方便判断按下的键盘是否正确和播放正确的按键音乐

image-20240728155427829

// 用来判断按下的按键和琴谱是否对应的
letterFlat: LettemMusic[] = []

aboutToAppear() {
    this.letterFlat = this.letters.flat()
    // ...
  }

给键盘添加点击事件

 .onClick(() => this.playLetter(item))

image-20240728154002013

实现点击播放音乐

  // 用来管理正在播放的声音对应的AVPlayer实例 如按下了 Q W ,那么就会出生两个 AVPlayer实例
  avPlayManagerList: AVPlayerManager[] = []
  
  // 点击键盘播放音乐
  playLetter(letter: LettemMusic) {
    // 根据点击的键盘 找到琴谱音乐对象 如 { name :"A" ,src :"paino1.mp1"}
    const item = this.letterFlat.find(v => v.name === letter.name)

    // 根据播放的歌曲路径 判断当前音乐是否正在播放
    const avIndex = this.avPlayManagerList.findIndex(v => v.url === item!.src)

    if (avIndex !== -1) {
      // 如果正在播放 马上销毁
      this.avPlayManagerList[avIndex].avPlayer?.release()
      // 并且从数组中移除
      this.avPlayManagerList.splice(avIndex, 1)
    }
    // 根据当前点击的键盘创建对应的AVPlayer实例
    const avplayManager = new AVPlayerManager(letter.src)
    // 追加到数组中
    this.avPlayManagerList.push(avplayManager)

    // 添加一个播放完毕的回调,用来删除avPlayManagerList数组中的AvPlay
    avplayManager.playComplete = () => {
      const index = this.avPlayManagerList.findIndex(v => v.url === item!.src)
      this.avPlayManagerList.splice(index, 1)
    }
  }
  

按下键盘,判断按键是否正确

类似练习打字效果,按对按键了,就设置绿色,如:

image-20240728162125926

因为我们的琴谱是个二维数组

image-20240725163103371

因此,我们也是定义一个数组 [行的坐标,列的坐标],分别是二维数组相对应

  // 用户弹的到琴谱坐标
  nextRowColumn: number[] = [0, 0]

接着,也是在点击事件中,根据按下的按键和对应的琴谱是否相等,如果是,设置绿色

  // 点击键盘播放音乐
  playLetter(letter: LettemMusic) {

  // ....
    // 获取行坐标
    const row = this.nextRowColumn[0]
    // 获取列坐标
    const column = this.nextRowColumn[1]
    // 判断当前的坐标是否超出范围
    if (this.lyricList[row] && this.lyricList[row][column]) {
      // 获取坐标对应的琴谱
      const item = this.lyricList[row][column]
      // 判断按下的按键和对应的琴谱是否相等 如 L == L
      if (item.title === letter.name) {
        // 设置选中
        item.isCorrect = true
        // 以下代码是设置坐标递进
        if (this.lyricList[row][column+1]) {
          this.nextRowColumn[1] = column + 1
        } else if (this.lyricList[row+1]) {
          this.nextRowColumn[0] = row + 1
          this.nextRowColumn[1] = 0
        } else {
          console.log("最后一个了")
        }
      }
    }
  }

小结

  1. 本篇教程可能用词不够简洁,如按键、键盘、音乐、乐谱、琴谱有些名词其实是代表同一个意思。
  2. 页面结构功能没有拆分成组件独立管理
  3. 功能稍弱,如切换琴谱,按键反馈、登录、分享、排行功能都缺失,只实现了核心的功能

代码

https://gitee.com/ukSir/GGMusic

  • 7
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值