手把手带你实现 鸿蒙应用 键盘音乐
先看结果
关键技术
- 基本布局技巧
- AVPlayer
- 面向对象
- 全部采用 V2版本 状态管理技术
新建一个项目
- 创建项目
- 新建项目
- 目录结构 - 可以后期用到再去新建
设置全局沉浸式
设置和不设置全局沉浸式的区别是这样的
- 在
src/main/ets/entryability/EntryAbility.ets
文件内进行编辑 loadContent
中进行设置
// 1 设置应用全屏 let windowClass: window.Window = windowStage.getMainWindowSync(); // 获取应用主窗口 // 2 设置沉浸式 windowClass.setWindowLayoutFullScreen(true)
- 此时效果是这样的 , 文字也会直接在状态栏上显示
- 此时,考虑到不同设备的状态栏高度可能不同,所以我们需要
- 动态获取状态栏高度,存到全局状态中
AppStorageV2
- 页面读取全局状态中的状态栏高度,单独给页面进行设置
// 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 a. AppStatu 是自定义类,用来存储数据 状态栏高度数据的
src/main/ets/types/index.ets
@ObservedV2 export class AppStatu { @Trace vpHeight: number =0 }
搭建背景
build() { Column({ space: 30 }) { } .width("100%") .height("100%") .backgroundImage($r("app.media.startIcon")) .backgroundImageSize(ImageSize.FILL) .backdropBlur(1000) // 对背景进行模糊 }
搭建琴谱
琴谱背景区域
使用背景图片+模糊搭建琴谱区域,高度由内容撑开
@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) // 对背景进行模糊 }
定义琴谱数据类型
琴谱只需要两个字段
- 琴谱对应歌曲的标题
title
- 琴谱 对应的英文字母
content
src/main/ets/types/index.ets
@ObservedV2 export class Lyric { @Trace title: string = "" @Trace content: string[] = [] }
定义字母的正确和不正确的状态类型
- 如图所示,绿色为正确
- 黄色为未输入或者不正确
@ObservedV2 export class LyricStatu { @Trace title: string = "" @Trace isCorrect: boolean = false }
处理要渲染的数据
为了方便页面的效果处理,我们需要将手上的数据,简单处理下,方便页面渲染
- 手上的数据
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",] }
- 处理后的数据结构
- 为什么要这样处理,因为让它方便渲染
- 如何处理呢 在页面打开的时候在
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) // 对背景进行模糊 }
得到结果
搭建键盘
准备音频资源
键盘一个26个字母,对应边有26个声音。一一相对应
其中,我们的静态资源存放在 rawFile中,鸿蒙应用在打包时不会对里面的文件做任何的编译处理,然后在使用的时候需要搭配AVPlayer使用。如
const res = await getContext().resourceManager.getRawFd("paino1.mp3") AVPlayer实例.fdSrc = res
定义字母和音频映射数据
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) // 对背景进行模糊 }
按下键盘,播放音乐功能
关键流程
- 封装AVPlayer管理类,每一个按键对应一个单独声音,因为上一个声音没有播放完毕,我们是可以同时播放第二个、第三个声音的,所以可以通过实例化多个 AVPlayer来使其一一对应
- 点击键盘 获取键盘对应的音乐路径
- 将音乐路径传递给AVPlayer,使其播放声音
了解AVPlayer
使用AVPlayer可以实现端到端播放原始媒体资源,本开发指导将以完整地播放一首音乐作为示例,向开发者讲解AVPlayer音频播放相关功能。
播放的全流程包含:创建AVPlayer,设置播放资源,设置播放参数(音量/倍速/焦点模式),播放控制(播放/暂停/跳转/停止),重置,销毁资源。
在进行应用开发的过程中,开发者可以通过AVPlayer的state属性主动获取当前状态或使用on('stateChange')方法监听状态变化。如果应用在音频播放器处于错误状态时执行操作,系统可能会抛出异常或生成其他未定义的行为。
使用流程基本围绕这一张图即可
AVPlayer基本使用流程
- 创建 AVPlayer 实例 此时,avPlayer进入空闲状态 idle
const avPlayer = await media.createAVPlayer()
- 监听状态的改变 我们对播放器的每一个操作,都会影响到它状态发生改变
avPlayer.on("stateChange", (state) => { switch (state) { // 如果播放器初始化完毕,那么就让它开始状态 case "initialized": avPlayer.prepare() break; case "prepared": // 如果播放器准备完毕,就让它变成开始播放 avPlayer.play() break; default: break; } })
- 设置播放音乐的URL
const res = await getContext().resourceManager.getRawFd(this.url) avPlayer.fdSrc = res // 设置完播放器后,播放器会进入 initialized 状态
- 开始播放
我们已经在prepared
状态中,设置了自动播放了avPlayer.play()
核心思路讲解
- 我们思考一下弹钢琴的逻辑,我们是不是可以同时按下多个按键,同时播放声音的? 所以我们需要 new 多个 AVPlayer播放器实例
- 如果你重复按下两个相同的琴键,终止上一个琴键的播放,马上开启新的一个琴键的播放
- 最后,当这个琴键播放完毕时,我们要销毁掉这个实例,释放内存
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
对琴谱数据进行扁平化处理
方便判断按下的键盘是否正确和播放正确的按键音乐
// 用来判断按下的按键和琴谱是否对应的 letterFlat: LettemMusic[] = [] aboutToAppear() { this.letterFlat = this.letters.flat() // ... }
给键盘添加点击事件
.onClick(() => this.playLetter(item))
实现点击播放音乐
// 用来管理正在播放的声音对应的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) } }
按下键盘,判断按键是否正确
类似练习打字效果,按对按键了,就设置绿色,如:
因为我们的琴谱是个二维数组
因此,我们也是定义一个数组 [行的坐标,列的坐标],分别是二维数组相对应
// 用户弹的到琴谱坐标 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("最后一个了") } } } }
小结
- 本篇教程可能用词不够简洁,如按键、键盘、音乐、乐谱、琴谱有些名词其实是代表同一个意思。
- 页面结构功能没有拆分成组件独立管理
- 功能稍弱,如切换琴谱,按键反馈、登录、分享、排行功能都缺失,只实现了核心的功能