做音乐类App,你迟早会面对一个选择:播放音频到底是直接上AVPlayer,还是自己去折腾AudioRenderer?我最初图省事,想着AVPlayer一句play()就完事,结果在做一个需要逐字解析歌词、实时卡点、还要预留均衡器接口的播放器时,AVPlayer的黑盒特性让我吃尽了苦头。后来把底层换成AudioRenderer,一切才顺畅起来。如果你也在HarmonyOS上想做一个音质可控、进度可控、交互接近云音乐客户端的播放器,这篇源码教学应该能帮你少走很多弯路。
AudioRenderer是什么? 它是HarmonyOS音频服务里负责音频渲染(也就是播放)的底层组件,输入是裸PCM数据,输出是扬声器或耳机的声音。它不负责解码,只管把你喂给它的数据按你指定的采样率、通道数、位深播出来。听起来简单,但正是这种"只做出口"的设计,让你能完全掌控音频数据的来源和节奏——这才是仿云音乐类App播放内核该有的样子。
这篇文章我会从选型逻辑、状态机原理、完整封装代码、仿云音乐界面接入、再到真机踩坑,一条线讲清楚。适合已经能跑通Hello World、想深入音频领域的HarmonyOS开发者,也适合被AVPlayer限制住想寻找更底层方案的同行。
1. 为什么音乐类App的播放内核一定要选AudioRenderer
1.1 AVPlayer是黑盒,AudioRenderer是一根水管
先想清楚一个事实:AVPlayer这类高层封装,内部帮你完成了三件事——解封装、解码、渲染同步。你给一个网络地址或本地文件URI,它自己拉流、解码、吐声音。这在播放"完整音频文件"时确实省心,但如果你要在播放过程中做点"文章",就麻烦了。
做了音乐播放器的人应该都遇到这些需求:
- 播放的同时要解析LRC歌词,并且逐字滚动,需要知道当前音频的时间轴位置。
- 想做音效调节,比如EQ均衡器、人声增强、降噪,必须拿到原始音频帧数据。
- 想做一个"无缝播放"(gapless playback)效果,一首歌结束前后要精确到帧地衔接下一首。
- 需要自己管理缓冲策略,比如从网络流实时拉取的音频,不想让AVPlayer的自动缓存策略干扰你的加载逻辑。
这时候AVPlayer就是个黑盒,它能给你currentTime和duration,但改不了内部的数据流,也不允许你介入缓冲策略。而AudioRenderer就是一根水管,管子的另一端是音频设备,你想往里倒自来水、净水、还是掺了果汁的水,完全由你自己控制。你倒多少水、什么时候倒、水龙头开多大,都写在你的代码里。
1.2 AudioRenderer适合的三种典型场景
结合我做仿云音乐客户端的经验,AudioRenderer真正发光的场景有三类:
第一类:解码后自定义播放。 音频文件(MP3/AAC/FLAC)先用AVCodec解码成PCM裸流,再把PCM喂给AudioRenderer。中间你完全可以把解码后的数据分帧处理,实现变速不变调(sonic算法)、重采样、混音。
第二类:逐帧处理和进度卡点。 歌词逐字滚动、音游节奏点判定,这类需求对时间轴精度要求很高。AVPlayer给你的currentTime是播放器自己维护的,而AudioRenderer的进度取决于你write()了多少字节,理论上可以精确到采样点。实测中我用字节数换算出毫秒级进度,歌词滚动的精确度比AVPlayer方案提升了一大截。
第三类:低延迟实时渲染。 游戏音效、语音通话、实时K歌跟唱,这些场景里音频延迟每多50ms都很明显。AudioRenderer作为底层渲染出口,链路短,能压的延迟空间比AVPlayer大得多。
1.3 什么时候不要用AudioRenderer
也要泼一盆冷水,AudioRenderer不是银弹。
- 如果你的需求只是"点一下播放、再点一下暂停、显示一个总时长",请直接AVPlayer。写一堆write逻辑反而是过度设计。
- 如果你要播放的是带封装格式的本地音乐文件,AudioRenderer本身不认MP3编码,你得先走一遍AVDemuxer解封装、AVCodec解码,链路串起来代码量不小。
- 如果项目排期紧、团队成员对音频领域不熟,用AudioRenderer会把一个小功能做成一个大工程。
一句话总结:AVPlayer是帮你开车,AudioRenderer是给你发动机,自己在前面铺路。 我选AudioRenderer的原因很明确,我要的不是"能播",而是"可控制地播"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开工前的准备:API 20工程环境与调试链路
2.1 DevEco Studio的SDK配置
HarmonyOS 6(API 20)的工程创建其实比早期简单了不少,但如果你的DevEco Studio版本太老,可能看不到对应的SDK。
打开DevEco Studio,在文件菜单新建工程时,选择"Empty Ability"模板即可。关键一步是确认编译SDK版本:
File > Project Structure > Project里,Compile SDK选API 20。HarmonyOS SDK路径下要能看到OpenHarmony或HarmonyOS NEXT对应的SDK包。
如果本地没有API 20的SDK,通过DevEco Studio的Settings > SDK Manager在线下载。下载量比较大,建议提前处理好网络环境,否则等SDK下完一个小时就没了。
工程创建后,build-profile.json5文件里会声明compileSdkVersion、targetSdkVersion:
json5复制{
"app": {
"signingConfigs": [],
"products": [
{
"name": "default",
"signingConfig": "default",
"compileSdkVersion": "5.0.0(20)",
"compatibleSdkVersion": "5.0.0(20)",
"runtimeOS": "HarmonyOS"
}
]
}
}
注意compatibleSdkVersion可以适当降低,比如4.1.0(18),这样应用能覆盖更多老设备。不过音频相关API有版本门槛,如果用到API 20才开放的接口,必须把兼容版本调到20,别为了兼容性牺牲功能。
2.2 权限与module.json5配置
很多新手以为播放音频要申请ohos.permission.MODIFY_AUDIO_SETTINGS之类的权限,实际上使用AudioRenderer播放PCM音频不需要申请任何运行时权限。
你要在module.json5里确认的无非是以下几项:
json5复制{
"module": {
"name": "entry",
"type": "entry",
"deviceTypes": ["phone", "tablet"],
"requestPermissions": []
}
}
requestPermissions留空数组即可。如果你后续要做的是录音+播放,才需要申请ohos.permission.MICROPHONE。
真正的坑不在权限,而在音频焦点。当你同时打开音乐App和视频App,谁的声音该压谁?HarmonyOS用音频焦点(AudioFocus)来协调。AudioRenderer本身不强制你申请焦点,但从云音乐客户端这类App的角度说,你最好主动处理焦点事件,不然后台播放时被其他App打断,声音会乱套。后面章节我会讲到怎么用audio.getAudioManager()监听焦点变化。
2.3 HDB真机调试与无线调试
模拟器上跑音频播放存在音色失真、延迟虚高的问题,建议直接上真机。HarmonyOS的真机调试链路是HDB(HarmonyOS Device Debugging Bus),类似安卓的ADB,但工具链不同。
先用USB连接手机,在开发者选项里打开"USB调试"。然后命令行验证设备:
bash复制hdb list targets
如果能看到设备序列号,说明连接成功。装应用:
bash复制hdb install entry-default-signed.hap
现在比较新的HarmonyOS版本(比如热词里提到的4.2及更高版本)都支持无线调试。操作路径是:
- 手机:
设置 > 系统 > 开发者选项 > 无线调试,启用后记下IP地址和端口号。 - 电脑:
hdb tconn 192.168.1.100:5555,然后hdb shell验证。
无线调试的实战价值在于:音频卡顿、延迟这类问题往往需要你在设备前反复听声音、改参数、重装,如果每次都插拔USB,迭代效率太低了。我后来全程无线调试,手机放桌上,电脑改代码,热重载后直接试听,体验好了很多。
3. 掌握时序:AudioRenderer状态机与参数选型
3.1 六态流转逻辑
AudioRenderer不是一个"能用就行"的组件,它对方法调用顺序有严格状态约束。搞懂状态机,排错时能少一半功夫。
整个生命周期涉及这些状态:
STATE_INVALID:无效状态,创建失败或已释放。STATE_PREPARED:准备就绪,AudioRenderer创建成功后处于这个状态,等start()。STATE_RUNNING:运行中,start()成功进入,此时才能write()。STATE_PAUSED:暂停,pause()后进入,可用start()恢复。STATE_STOPPED:停止,stop()后进入,音频设备已经关闭输出。STATE_RELEASED:已释放,release()后进入,实例不可再用。
严格的流转顺序是:
code复制PREPARED -> RUNNING -> PAUSED -> RUNNING -> STOPPED -> RELEASED
注意几点:
- 从
PAUSED恢复调用的是start(),不是别的。 - 从
STOPPED再播放,不能直接start(),而要重新start()吗?实际上AP定义里STOPPED后仍可调start()重新回到RUNNING,这点和某些音频框架不一样,但要在start()前确认缓冲状态。 release()之后就是RELEASED,没有回头路,想再用必须重新创建实例。
用监听回调掌握状态变化:
typescript复制renderer.on('stateChange', (state: audio.AudioState) => {
console.info(`AudioRenderer state changed: ${state}`);
});
我强烈建议你从一开始就加上这个监听,不只为了调试。真实场景里用户狂点播放按钮、切后台、来电话,状态会变得很快,有回调日志才能定位问题。
3.2 参数详解:采样率、通道数、位深怎么匹配数据源
AudioRendererOptions里最核心的是streamInfo,它决定了解释PCM数据的方式。参数不匹配,后果就是声音变调、明显噪声、或者完全没声。
看一个典型配置:
typescript复制import { audio } from '@kit.AudioKit';
let streamInfo: audio.AudioStreamInfo = {
// 采样率:每秒采样点数
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
// 通道数:双声道立体声
channels: audio.AudioChannel.CHANNEL_2,
// 采样格式:16bit有符号整型
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
// 编码类型:裸PCM
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
};
这几个参数必须和你的数据源完全一致,而不是和系统别的播放器一致。比如你解码MP3得到的是44100Hz、双声道、16bit,那这里就填44100/2/S16LE。如果你拿到的是从网络上下载的PCM,先用超声波方式(比如查文件头)确认格式,再填参数。
计算一下单位时间的数据量,这个对后续分配buffer很关键:
code复制每秒字节数 = 采样率 × 通道数 × 位深/8
44100 × 2 × 2 = 176400 字节 ≈ 172.27 KB
一分钟的音频就是10MB左右。做内存规划时,这个数心里要有数。
rendererInfo里另一个关键参数是usage,它告诉了系统音频策略你播放的是什么类型的内容。常见值有:
STREAM_USAGE_MUSIC:音乐播放。STREAM_USAGE_MOVIE:视频。STREAM_USAGE_GAME:游戏音效。STREAM_USAGE_VOICE_COMMUNICATION:语音通话。
选错usage的后果不是不响,而是音效策略不对。比如你播放通知音却选了STREAM_USAGE_MUSIC,可能被系统音量里的音乐音量控制,而不是通知音量控制。仿云音乐App的播放页,用STREAM_USAGE_MUSIC就对了。
3.3 用户态缓冲:为什么write()会阻塞
创建AudioRenderer时,系统会给你一个推荐缓冲大小。你不需要自己拍脑袋决定一次write多少字节,用getBufferSize()查:
typescript复制let bufferSize: number = await renderer.getBufferSize();
这个返回值的单位是字节,表示系统音频通路一次能流畅处理的数据量。实测中,如果你write()的buffer远小于推荐值,开销会急剧上升(每写一次都有系统调用);如果远大于推荐值,延迟会变大,声音起止变迟钝。
AudioRenderer的write()是阻塞式的。你可以把它理解成"往一个水库里倒水,水库快满了,你就必须等它排掉一些再继续倒"。所以不要直接在主线程里循环write,一旦音频消费速度跟不上你的写入速度,主线程会被堵死,UI直接掉帧。
常见做法是开一个独立线程(TaskPool或Worker)去write,或者用回调模式。等到第4章实战封装时,我会给出具体代码。
4. 源码实战:把AudioRenderer封装成可直接用的AudioPlayer
4.1 初始化Renderer并绑定状态回调
这一节我直接给完整代码,你把文件加入工程就能用。先建一个AudioPlayer.ets,核心是一个类,负责AudioRenderer的整个生命周期。
typescript复制import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';
const TAG = 'AudioPlayer';
export class AudioPlayer {
private renderer: audio.AudioRenderer | null = null;
private bufferSize: number = 0;
private state: audio.AudioState = audio.AudioState.STATE_INVALID;
private isReleased: boolean = false;
// 播放参数
private samplingRate: number = 44100;
private channels: number = 2;
private byteDepth: number = 2; // 16bit = 2字节
async init(): Promise<void> {
if (this.renderer) {
return;
}
const streamInfo: audio.AudioStreamInfo = {
samplingRate: this.samplingRate as audio.AudioSamplingRate,
channels: this.channels as audio.AudioChannel,
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
};
const rendererInfo: audio.AudioRendererInfo = {
usage: audio.StreamUsage.STREAM_USAGE_MUSIC,
rendererFlags: 0
};
const options: audio.AudioRendererOptions = {
streamInfo: streamInfo,
rendererInfo: rendererInfo
};
try {
this.renderer = await audio.createAudioRenderer(options);
this.bufferSize = await this.renderer.getBufferSize();
this.renderer.on('stateChange', (state: audio.AudioState) => {
this.state = state;
console.info(`${TAG} state changed: ${state}`);
});
console.info(`${TAG} init success, bufferSize=${this.bufferSize}`);
} catch (err) {
let e = err as BusinessError;
console.error(`${TAG} init failed, code=${e.code}, message=${e.message}`);
}
}
}
这里有几个细节值得注意:
samplingRate、channels用数字类型存储,但接口要求的是枚举类型(AudioSamplingRate、AudioChannel),我还做了断言。这是因为实际开发中这些参数可能来自服务端下发的音频信息,你不会想写死死板板的值。renderer判空是必要的,createAudioRenderer可能因为系统资源不足而抛异常。- 建议把
bufferSize缓存下来,因为后面每次write()和它息息相关。
4.2 主动喂数据write()与回调模式
AudioRenderer的数据供给有两种主流方式:主动write和writeData回调。
主动write是最可控的方式。你拿到PCM数据的ArrayBuffer,调用renderer.write(buffer),它会返回实际写入的字节数。
typescript复制async writePcm(buffer: ArrayBuffer): Promise<number> {
if (!this.renderer || this.isReleased) {
return -1;
}
try {
// 写入PCM数据,返回实际写入的字节数
let written = await this.renderer.write(buffer);
return written;
} catch (err) {
let e = err as BusinessError;
console.error(`${TAG} write failed, code=${e.code}, message=${e.message}`);
return -1;
}
}
注意write()的返回值是实际写入的字节数,不是"成功/失败"。因为缓冲区可能一次性塞不了那么多数据,你要根据返回值决定是否重试写入剩余部分。
writeData回调模式则更像"系统来要数据":你注册一个回调,系统播放到缓冲快耗尽时,就会执行回调,你在里面返回数据。这种模式的优点是系统自己掌握节奏,延迟更均匀,但对业务侧的数据供给速度要求高,如果回调里执行耗时操作,会出现杂音。
typescript复制this.renderer.on('writeData', (buffer: ArrayBuffer) => {
// 这里把业务侧准备好的PCM数据拷贝进buffer
// 需要确保数据量不超过buffer.byteLength
});
我实测下来,两种模式在现代真机上都能稳定工作。但如果要做进度精确控制,优先主动write——你每写一次就知道写了多少字节,进度条完全是"透明"的。回调模式虽然省心,但拿到"当前播放位置"会比较绕。下面整个封装我都用主动write。
4.3 播放/暂停/停止/释放的完整时序
这一节是核心,方法不多,但顺序错了就会报StateError。
typescript复制async play(): Promise<void> {
if (!this.renderer || this.isReleased) {
return;
}
if (this.state === audio.AudioState.STATE_RUNNING) {
return;
}
try {
await this.renderer.start();
this.state = audio.AudioState.STATE_RUNNING;
} catch (err) {
let e = err as BusinessError;
console.error(`${TAG} start failed, code=${e.code}, message=${e.message}`);
}
}
async pause(): Promise<void> {
if (!this.renderer || this.isReleased) {
return;
}
if (this.state !== audio.AudioState.STATE_RUNNING) {
return;
}
try {
await this.renderer.pause();
this.state = audio.AudioState.STATE_PAUSED;
} catch (err) {
let e = err as BusinessError;
console.error(`${TAG} pause failed, code=${e.code}, message=${e.message}`);
}
}
async stop(): Promise<void> {
if (!this.renderer || this.isReleased) {
return;
}
if (this.state === audio.AudioState.STATE_STOPPED || this.state === audio.AudioState.STATE_RELEASED) {
return;
}
try {
await this.renderer.stop();
this.state = audio.AudioState.STATE_STOPPED;
} catch (err) {
let e = err as BusinessError;
console.error(`${TAG} stop failed, code=${e.code}, message=${e.message}`);
}
}
async release(): Promise<void> {
if (!this.renderer || this.isReleased) {
return;
}
try {
await this.renderer.release();
this.isReleased = true;
this.state = audio.AudioState.STATE_RELEASED;
} catch (err) {
let e = err as BusinessError;
console.error(`${TAG} release failed, code=${e.code}, message=${e.message}`);
}
}
这里要特别强调两点:
- start()成功后才能write。在
STATE_PREPARED状态下直接write(),会抛出状态异常。这是新手最容易踩的坑。 - 从PAUSED恢复继续播放,调用start()而不是play()。有些框架的pause/resume是独立接口,HarmonyOS这里统一用start()。
如果是一首完整的歌,播放到结尾后建议顺序调用:
typescript复制await audioPlayer.stop();
await audioPlayer.release();
不能跳过stop()直接release(),否则可能造成系统音频服务层面的资源清理不干净,影响下一次创建实例。
4.4 时长与播放进度的数学原理
AudioRenderer没有现成的getDuration(),但这难不倒做过流媒体的人。时长的计算公式其实小学算术水平:
code复制总时长(秒) = 总字节数 / (采样率 × 通道数 × 位深/8)
举个例子,一首歌解码后PCM数据总共是37,162,800字节,采样率44100、双声道、16bit:
code复制总时长 = 37162800 / (44100 × 2 × 2) = 37162800 / 176400 = 210.67秒
播放进度则靠"已写字节数"推算:
code复制当前进度(秒) = 已写入且被消费的字节数 / (采样率 × 通道数 × 位深/8)
这个进度不需要系统回调,你在每次write()成功后累加written字段即可:
typescript复制private totalWrittenBytes: number = 0;
async writePcm(buffer: ArrayBuffer): Promise<number> {
let written = await this.writePcmInternal(buffer);
if (written > 0) {
this.totalWrittenBytes += written;
}
return written;
}
getCurrentPositionMs(): number {
const bytesPerSec = this.samplingRate * this.channels * this.byteDepth;
return Math.floor(this.totalWrittenBytes / bytesPerSec * 1000);
}
这里有一个微妙问题:write()成功不代表音频已经播放到了那里,它只代表数据进了系统缓冲。所以严格说,这个进度是"已经提交给系统的数据",比真实听到的声音超前了几个buffer。但如果你的bufferSize取得合理,超前量只有几十毫秒,人耳根本感觉不到,做歌词滚动完全够用了。
5. 仿云音乐风格的界面接入:让播放内核跑起来
5.1 页面布局与状态管理
有了AudioPlayer封装,UI层就可以很干净了。仿云音乐客户端的播放页,核心元素无非是封面、歌名、时间进度、Slider进度条、播放/暂停按钮。用ArkUI声明式语法写起来不复杂。
typescript复制import { AudioPlayer } from './AudioPlayer';
@Entry
@Component
struct MusicPlayerPage {
@State isPlaying: boolean = false;
@State currentTime: number = 0;
@State totalDuration: number = 0;
private player: AudioPlayer = new AudioPlayer();
aboutToAppear(): void {
this.initPlayer();
}
async initPlayer(): Promise<void> {
await this.player.init();
// 假设你已经拿到了PCM数据总字节数
// 这里为了演示,先硬编码一个示例值
this.totalDuration = 210;
}
build() {
Column({ space: 20 }) {
// 封面图
Column()
.width(240)
.height(240)
.backgroundColor('#3A3A3A')
.borderRadius(20)
.margin({ top: 60 })
// 歌名和歌手
Text('测试歌曲')
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('HarmonyOS实战')
.fontSize(14)
.fontColor('#888888')
// 进度条
Row() {
Text(this.formatTime(this.currentTime))
Slider({ value: this.currentTime, min: 0, max: this.totalDuration })
.layoutWeight(1)
.onChange((value: number) => {
this.currentTime = value;
// 拖动进度条时可能需要seek
})
Text(this.formatTime(this.totalDuration))
}
.width('90%')
// 播放/暂停按钮
Button(this.isPlaying ? '暂停' : '播放')
.width(80)
.height(80)
.fontSize(20)
.onClick(() => {
if (this.isPlaying) {
this.player.pause();
} else {
this.player.play();
}
this.isPlaying = !this.isPlaying;
})
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
formatTime(seconds: number): string {
let min = Math.floor(seconds / 60);
let sec = Math.floor(seconds % 60);
return `${min < 10 ? '0' + min : min}:${sec < 10 ? '0' + sec : sec}`;
}
}
这里的totalDuration是秒为单位,formatTime把它格式化成mm:ss。注意Slider的max、value、currentTime单位要保持一致,否则UI会跳。
5.2 进度刷新:定时器与电量优化
要让进度条动起来,需要一个定时器每秒钟刷新一次currentTime。最简单直接的做法:
typescript复制private timer: number = -1;
startProgressTimer(): void {
if (this.timer !== -1) {
return;
}
this.timer = setInterval(() => {
if (this.isPlaying) {
this.currentTime = this.player.getCurrentPositionMs() / 1000;
}
}, 1000);
}
aboutToDisappear(): void {
if (this.timer !== -1) {
clearInterval(this.timer);
this.timer = -1;
}
}
这个方案能用,但有两个隐患:
- 1秒一次刷新,Slider在拖动时会显得一顿一顿。云音乐客户端的进度条是丝滑的,实测把间隔降到200ms视觉体验会好很多,但如果你的播放内核在异步线程,200ms的定时器压力也不大。
- 页面在后台时定时器依然在跑,白白耗电。优化的做法是监听页面的onPageHide事件暂停定时器,onPageShow再恢复。这块属于细节优化,上线前一定要做。
5.3 播放/暂停按钮的状态管理
按钮要反映真实状态,但有个细节:点击播放按钮后,AudioRenderer进入RUNNING需要一点时间,如果你立即把isPlaying设为true,UI先动了,但音频还没响,用户会有"点了没反应"的错觉。更好的做法是用状态回调来驱动UI:
typescript复制this.player.onStateChanged((state: audio.AudioState) => {
if (state === audio.AudioState.STATE_RUNNING) {
this.isPlaying = true;
} else if (state === audio.AudioState.STATE_PAUSED) {
this.isPlaying = false;
}
});
也就是说,UI状态由AudioRenderer的真实状态驱动,而不是由点击事件驱动。这样无论用户连点多少次、系统因为其他App抢占音频焦点而暂停,UI都不会和声音打架。
6. 踩坑实录:从报错到声音流畅的完整排错链路
6.1 一上来就write(),结果StateError
我第一次用AudioRenderer时,创建完实例就直接write(),结果马上抛了个StateError。错误信息大致是"Renderer is not started"。问题根源在于AudioRenderer的状态机约束:STATE_PREPARED状态下只能调start(),write()必须等到STATE_RUNNING。
排查方式:
- 在
start()之后添加日志,打印状态值,确认是STATE_RUNNING。 - 用
renderer.state属性或stateChange回调。
修复后的正确顺序:
typescript复制await renderer.start();
let written = await renderer.write(pcmBuffer);
另外要小心:start()返回的是Promise,如果你忘了await,代码根本不会按预期顺序执行。我见过不少同事在async函数里漏掉await,结果全部时序错乱。
6.2 声音像机器人——采样率不匹配
有一次我播放从服务端拉下来的PCM数据,声音出来像机器人变声,音调明显不对。排查了半天,发现服务端下发的音频是22050Hz,但我在AudioRenderer里写死44100Hz。
这个问题的本质是:采样率决定了解释PCM数据的时间基准。你用44100Hz去解释22050Hz采样的数据,相当于把本来每秒22050个点拉到每秒44100个点来播,声音速度变成原来的一半,音调明显降低。
解决方式:
- 拿到数据源时先探明采样率,再从Options里动态设置。
- 如果数据源格式是未知的,可以用
audio.AudioSamplingRate枚举的常见值逐个尝试,虽然不优雅但在调试阶段很实用。
这类问题还有一个容易被忽略的场景:同一首歌的前奏和副歌采样率相同,但不同CD压制的版本可能不同。所以不要把采样率写死,一定要从解码器或元数据里取。
6.3 播放完没释放,再次进入页面报资源不足
有用户在播放页进出几次后,createAudioRenderer抛了ErrorCode 6800101(系统资源不足)。原因是前几次页面销毁时只停了播放,没有调用release(),AudioRenderer对象虽然从JS侧看来被回收了,但系统音频服务层面的资源并没有释放。
排查方案是在aboutToDisappear()里统一释放:
typescript复制aboutToDisappear(): void {
this.player.release();
}
如果页面里还启动了解码器、音频采集器等资源,也要统一释放。AudioRenderer和AudioCapturer、AVCodec这些资源是独立的,各自都要调release()。
这里有个细节:release()其实是个异步操作,但很多场景下页面销毁不会等你异步完成。稳妥做法是在release之前调用stop(),然后不await,让它慢慢释放:
typescript复制this.player.stop();
this.player.release();
不await会有极小概率导致新页面创建AudioRenderer时旧资源还没清干净,但实测发生概率很低。如果资源极其紧张,可以把release放到异步任务里,延迟100ms执行。
6.4 write()阻塞导致UI掉帧
在把AudioRenderer接入仿云音乐UI后,我遇到一个非常恼人的问题:播放过程中滑动页面,明显感觉到卡顿。抓trace后定位为write()方法占据了主线程大量时间。
原因很清楚:write()是阻塞式调用,如果当前音频缓冲比较满,write就会一直等。主线程一旦被write堵住,UI就动不了。
解决办法是把write放到独立线程。HarmonyOS里常见做法是TaskPool:
typescript复制import { taskpool } from '@kit.ArkTS';
@Concurrent
async function writePcmTask(rendererProxy: audio.AudioRenderer, buffer: ArrayBuffer): Promise<number> {
let written = await rendererProxy.write(buffer);
return written;
}
// 业务侧调用
let written = await taskpool.execute(writePcmTask, renderer, buffer);
但这里有个麻烦:taskpool里传对象要求对象可序列化,AudioRenderer可能不能直接作为参数传递。更稳妥的做法是把AudioRenderer实例放在一个单例类里,taskpool里的任务通过模块级函数访问它。或者,如果实时性要求不高,用异步直接await write也可以,因为状态机的约束下,write只在RUNNING时被调用,主线程只会在那一小段等待。
我最终采用的方案是:AudioPlayer内部维护一个简单的生产者-消费者队列,UI线程只管把PCM数据丢进队列,内部一个异步循环从队列取出数据再write。这样UI线程永远不会被阻塞。
typescript复制private queue: ArrayBuffer[] = [];
pushData(buffer: ArrayBuffer): void {
this.queue.push(buffer);
this.processQueueIfNeeded();
}
private processing: boolean = false;
private async processQueueIfNeeded(): Promise<void> {
if (this.processing) {
return;
}
this.processing = true;
while (this.queue.length > 0) {
let data = this.queue.shift();
if (data && this.renderer && !this.isReleased) {
await this.renderer.write(data);
}
}
this.processing = false;
}
这个方案还有一个好处:暂停时queue会积压数据,恢复播放时继续消费,实现了自然的"缓冲恢复"效果,不用额外处理暂停前后的数据衔接。
6.5 焦点抢占与音量策略
最后一个坑不报错,但体验影响很大:用户来电话、切后台、按下音量键时,播放行为要和系统策略对齐。
HarmonyOS用AudioManager管理音频焦点。我在播放前设置好焦点请求:
typescript复制import { audio } from '@kit.AudioKit';
let audioManager = audio.getAudioManager();
let focusRequest: audio.AudioFocusRequest = {
streamUsage: audio.StreamUsage.STREAM_USAGE_MUSIC,
focusType: audio.AudioFocusType.AUDIO_FOCUS_TYPE_GAIN,
// 其他配置...
};
audioManager.requestAudioFocus(focusRequest);
同时监听焦点变化:
typescript复制audioManager.on('audioFocusChange', (focusType: audio.AudioFocusType) => {
if (focusType === audio.AudioFocusType.AUDIO_FOCUS_TYPE_LOSS) {
// 失去焦点,暂停播放
this.player.pause();
}
});
音量策略方面,StreamUsage选STREAM_USAGE_MUSIC后,播放声音会默认受媒体音量键控制,这块不用额外编码。
调试的时候,多打开几个App切来切去,把音频焦点变化日志打出来,基本就能看清整个策略是否正常。
最后再分享一个小技巧
如果你在真机上反复测试播放,会发现每次重新进入页面、重新初始化AudioRenderer,都要先等createAudioRenderer和start()。这中间有个几毫秒到几十毫秒的间隙,在安静状态下能听出"咔嚓"声。
解决办法是预创建+预热:
- 在应用启动后(比如首页加载完成后),提前创建好AudioRenderer实例但先不
start()。 - 用户点击播放时,把第一段PCM数据准备好,再
start(),然后立刻write()。
这样"点击到出声"的延迟基本可以压到接近零。如果你做的是专业级播放器,这个体验差异很值得优化。
AudioRenderer这条路踏进去之后,你会发现音频播放的世界比一个play()方法广阔得多。从状态机到缓冲策略,再到焦点管理,每个环节都值得反复打磨。希望这篇实战教学能帮你少踩几个我踩过的坑,在你的仿云音乐播放器路上推一把。
