手把手带你实现 鸿蒙应用-键盘音乐
手把手带你实现 鸿蒙应用-键盘音乐
先看结果
关键技术
- 基本布局技巧
- 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;
- 动态获取状态栏高度,存到全局状态中
-
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("最后一个了")
}
}
}
}
小结
- 本篇教程可能用词不够简洁,如按键、键盘、音乐、乐谱、琴谱有些名词其实是代表同一个意思。
- 页面结构功能没有拆分成组件独立管理
- 功能稍弱,如切换琴谱,按键反馈、登录、分享、排行功能都缺失,只实现了核心的功能