《HarmonyOS技术精讲-Media Library Kit》之音频管理与播放
《HarmonyOS技术精讲-Media Library Kit》之音频管理与播放
为什么需要Media Library Kit管理音频
HarmonyOS开发里,直接操作文件系统去读取音频文件并不是推荐做法。一是不同设备(手机、平板、手表)存储路径差异大,二是系统媒体数据库本身有缓存机制,直接读文件获取不到完整的元数据信息(比如艺术家、专辑封面、时长)。
Media Library Kit正是为了解决这个问题。它提供了一层统一接口,让开发者可以像操作数据库一样查询、增删改设备上的媒体文件,同时自动处理了文件路径差异和权限管控。
这个场景适合做本地音乐播放器、音频剪辑工具、录音管理应用。但不适合需要实时远程音频流的场景,后者应该走AVPlayer直接传入网络URL。
环境说明
DevEco Studio 版本:DevEco Studio NEXT Beta1 及以上(建议6.0+) HarmonyOS SDK 版本:API 12 及以上 目标设备:手机(真机调试)核心实现:从查询到播放
1. 权限声明(常被忽略)
使用Media Library Kit必须先申请ohos.permission.READ_MEDIA和ohos.permission.WRITE_MEDIA权限。在HarmonyOS NEXT上,这两个权限属于user_grant类型,需要动态弹窗询问用户。
在module.json5中添加:
{"module":{"requestPermissions":[{"name":"ohos.permission.READ_MEDIA","reason":"用于读取设备上的音乐文件"},{"name":"ohos.permission.WRITE_MEDIA","reason":"用于删除设备上的音乐文件"}]}}2. 查询音频列表
查询音频的核心是getAudioAssets()方法配合FetchOptions。FetchOptions的selections参数支持复杂的SQL条件过滤。
创建一个工具类AudioManager.ets:
import{mediaLibrary}from'@kit.MediaLibraryKit';import{common}from'@kit.AbilityKit';exportinterfaceAudioItem{id:numbertitle:stringartist:stringduration:numberuri:string}exportclassAudioManager{privatemediaLib:mediaLibrary.MediaLibrary|null=null;asyncinit(context:common.Context):Promise<void>{this.mediaLib=awaitmediaLibrary.getMediaLibrary(context);}asyncqueryAudioList():Promise<AudioItem[]>{if(!this.mediaLib){thrownewError('AudioManager not initialized');}// 构造查询条件:只查音频文件letfetchOp=newmediaLibrary.FetchOptions();fetchOp.selections=mediaLibrary.AudioKey.DISPLAY_NAME+' != ? ';fetchOp.selectionArgs=[''];// 按时长排序fetchOp.order=mediaLibrary.AudioKey.DURATION+' DESC';letfetchResult=awaitthis.mediaLib.getAudioAssets(fetchOp);letcount=fetchResult.getCount();letresult:AudioItem[]=[];for(leti=0;i<count;i++){letasset=awaitfetchResult.getObjectByPosition(i);result.push({id:asset.id,title:asset.title||'',artist:asset.artist||'未知艺术家',duration:asset.duration,uri:asset.uri});}fetchResult.close();returnresult;}}这段代码有几个设计细节值得注意:
selections参数不能为空字符串,否则查询会失败。这里用了一个不等于空的条件来绕过,实际场景下可能需要更精确的过滤,比如只查大于0秒的音频。fetchResult使用完后必须close(),否则会资源泄漏。官方文档没有强调这一点,但实际测试中发现不关会导致后续查询变慢甚至失败。
3. 获取元数据的另一种方式
getAudioAssets返回的是AudioAsset对象,它本身已经包含了常用的元数据字段。但如果你需要更详细的元数据(比如专辑名、音轨号),可以单独查询某个Asset的详细信息:
asyncgetAudioDetail(uri:string):Promise<mediaLibrary.AudioAsset|null>{if(!this.mediaLib)returnnull;letfetchOp=newmediaLibrary.FetchOptions();fetchOp.selections=mediaLibrary.AudioKey.URI+' = ? ';fetchOp.selectionArgs=[uri];letfetchResult=awaitthis.mediaLib.getAudioAssets(fetchOp);if(fetchResult.getCount()>0){letasset=awaitfetchResult.getObjectByPosition(0);fetchResult.close();returnasset;}fetchResult.close();returnnull;}很少在单个查询里指定mediaLibrary.AudioKey.URI,因为URI本身就能唯一标识一个音频。不过这种方式适合你已经有了URI但需要重新获取最新元数据的场景。
4. 集成AVPlayer播放
这一步比较关键,很多人会在ArkUI组件里直接new AVPlayer,然后忘记处理生命周期导致各种问题。推荐把播放逻辑封装成一个独立的AudioPlayerService类。
新建AudioPlayerService.ets:
import{avPlayer}from'@kit.MediaKit';exportclassAudioPlayerService{privateplayer:avPlayer.AVPlayer|null=null;privateisReleased:boolean=false;asyncinit():Promise<void>{this.player=awaitavPlayer.createAVPlayer();this.isReleased=false;// 注册状态回调(调试用)this.player.on('stateChange',(state:avPlayer.AVPlayerState)=>{console.info(`AVPlayer state:${state}`);});}asyncplay(uri:string):Promise<void>{if(!this.player||this.isReleased){awaitthis.init();}// 设置播放源this.player.url=uri;awaitthis.player.prepare();awaitthis.player.play();}pause():void{this.player?.pause();}asyncseek(time:number):Promise<void>{awaitthis.player?.seek(time);}getCurrentTime():number{returnthis.player?.currentTime??0;}getDuration():number{returnthis.player?.duration??0;}// 订阅时间更新(注意:需要在UI组件中配合@State使用)onTimeUpdate(callback:(time:number)=>void):void{this.player?.on('timeUpdate',(time:number)=>{callback(time);});}release():void{if(this.player){this.player.release();this.player=null;}this.isReleased=true;}}为什么要用类封装?因为AVPlayer的生命周期和页面生命周期是异步的。如果直接在组件build()里创建,页面离开时播放器不会自动释放,再次进入时会重复创建,导致资源泄漏。
5. 删除音频
删除音频需要两步:先查出AudioAsset对象,然后调用deleteAsset。注意删除操作会同时删除文件系统和数据库记录。
在AudioManager中添加:
asyncdeleteAudio(id:number):Promise<boolean>{if(!this.mediaLib)returnfalse;try{// 先根据id查询到对应的AudioAssetletfetchOp=newmediaLibrary.FetchOptions();fetchOp.selections=mediaLibrary.AudioKey.ID+' = ? ';fetchOp.selectionArgs=[id.toString()];letfetchResult=awaitthis.mediaLib.getAudioAssets(fetchOp);if(fetchResult.getCount()>0){letasset=awaitfetchResult.getObjectByPosition(0);leturis=[asset.uri];awaitthis.mediaLib.deleteAssets(uris);fetchResult.close();returntrue;}fetchResult.close();returnfalse;}catch(error){console.error(`deleteAudio failed:${error}`);returnfalse;}}注意deleteAssets接收的是URI数组,不是id。很多初学者会直接传id进去导致删除失败。
6. 创建音频列表UI
新建AudioListPage.ets:
import{AudioManager,AudioItem}from'./AudioManager';import{AudioPlayerService}from'./AudioPlayerService';import{common}from'@kit.AbilityKit';@Entry@Componentstruct AudioListPage{@StateaudioList:AudioItem[]=[];@StatecurrentTime:number=0;@StateisPlaying:boolean=false;@StatecurrentPlayingUri:string='';privateaudioMgr:AudioManager=newAudioManager();privateplayerService:AudioPlayerService=newAudioPlayerService();privatecontext:common.Context=getContext(this);aboutToAppear():void{this.loadAudioList();}asyncloadAudioList():Promise<void>{awaitthis.audioMgr.init(this.context);letlist=awaitthis.audioMgr.queryAudioList();this.audioList=list;}asynconPlay(item:AudioItem):Promise<void>{if(this.currentPlayingUri===item.uri){// 同一首歌,切换播放/暂停if(this.isPlaying){this.playerService.pause();}else{awaitthis.playerService.play(item.uri);}this.isPlaying=!this.isPlaying;}else{// 切换歌曲this.playerService.release();awaitthis.playerService.init();awaitthis.playerService.play(item.uri);this.currentPlayingUri=item.uri;this.isPlaying=true;// 订阅时间更新this.playerService.onTimeUpdate((time:number)=>{this.currentTime=time;});}}asynconDelete(item:AudioItem):Promise<void>{letsuccess=awaitthis.audioMgr.deleteAudio(item.id);if(success){letindex=this.audioList.indexOf(item);if(index>-1){this.audioList.splice(index,1);// 如果删除的是正在播放的歌曲if(this.currentPlayingUri===item.uri){this.playerService.release();this.currentPlayingUri='';this.isPlaying=false;this.currentTime=0;}}}}build(){Column(){List({space:8}){ForEach(this.audioList,(item:AudioItem)=>{ListItem(){Column(){Text(item.title).fontSize(16).fontWeight(FontWeight.Bold)Text(item.artist+' · '+this.formatDuration(item.duration)).fontSize(12).fontColor(Color.Gray)// 播放进度条if(item.uri===this.currentPlayingUri){ProgressBar({value:this.currentTime,total:item.duration,type:ProgressType.Linear}).width('100%').height(6).margin({top:4})}Row(){Button(this.isPlaying&&item.uri===this.currentPlayingUri?'暂停':'播放').onClick(()=>this.onPlay(item))Button('删除').onClick(()=>this.onDelete(item))}}.padding(12).backgroundColor(Color.White).borderRadius(8)}},(item:AudioItem)=>item.id.toString())}.width('100%').layoutWeight(1)}.padding(16).width('100%').height('100%')}formatDuration(seconds:number):string{letmin=Math.floor(seconds/60);letsec=Math.floor(seconds%60);return`${min}:${sec.toString().padStart(2,'0')}`;}}这段UI有几个关键点:
aboutToAppear生命周期中初始化,页面消失时播放器不会自动停止,这里没有在aboutToDisappear里释放播放器,因为用户可能切任务后回来继续听。实际项目需要根据业务决定。- 进度条使用
ProgressBar组件,需要传入currentTime和total。注意currentTime是@State变量,随时间更新UI会自动重渲染。 - ForEach的key使用了id而不是索引,避免列表删除后索引错乱。
踩坑记录
坑1:查询结果为空,但手机里明明有音乐
现象:getAudioAssets返回的fetchResult.getCount()为0。手机里用系统播放器能看到音乐文件。
原因:Media Library Kit查询的是系统媒体数据库,不是文件系统。数据库首次扫描可能需要时间,或者文件存储在非标准目录下(比如应用私有目录)。更常见的一个问题是:FetchOptions的selections参数写错了条件格式,导致所有记录被过滤掉。
解决方案:
- 确认音频文件存放在公共目录(如
Music、Download)下,而不是应用私有沙箱目录。 - 写查询条件时先打印
selections调试。 - 调用前确认权限已授予。
坑2:播放完成后,重新播放时状态异常
现象:第一首歌播完,点击第二首播放,AVPlayer报错"prepare failed"。
原因:播放结束后,AVPlayer状态变为completed,复用同一个player实例需要先reset()到idle状态,再重新设置url和prepare。很多人直接设置url时会触发错误。
解决方案:在play方法里加状态判断:
asyncplay(uri:string):Promise<void>{if(!this.player||this.isReleased){awaitthis.init();}else{// 如果播放器不是idle状态,先resetletstate=this.player.state;if(state!==avPlayer.AVPlayerState.IDLE){this.player.reset();}}this.player.url=uri;awaitthis.player.prepare();awaitthis.player.play();}类似的问题在seek操作上也会出现,建议每次操作前都检查一下播放器状态。
最佳实践
- 不要在
build()中频繁创建AVPlayer实例。每次build()都会导致组件重建,如果每次创建新的AVPlayer,内存泄漏和性能问题会非常严重。应该把播放器实例提升到类成员或全局单例。 - 进度条的刷新频率控制在500ms以内。
timeUpdate事件默认每秒触发一次,如果你需要更平滑的进度条,可以在订阅回调里用setInterval每200ms读取一次currentTime。但注意不要用太高的频率,否则ArkUI的UI渲染可能扛不住。 - 删除操作后必须同步更新UI的状态。直接更新
@State数组可以触发UI刷新,但如果你删除的是当前正在播放的歌曲,需要额外处理播放器释放和进度条归零。建议在删除的回调里检查currentPlayingUri。
完整项目入口
// Index.etsimport{AudioListPage}from'./AudioListPage';@Entry@Componentstruct Index{build(){Column(){AudioListPage()}.width('100%').height('100%')}}FAQ
Q:为什么真机上能听到音乐,但模拟器上查询列表总是空?
A:模拟器没有真实的媒体数据库,文件系统里的音乐文件不会被getAudioAssets识别。建议真机调试,或者手动在模拟器的MediaStore模拟数据。
Q:删除音频后,再次查询发现文件还在?
A:检查删除是否有报错。如果删除成功但数据库没更新,可能需要手动调用mediaLib.release()后重新获取实例。另外确认文件不是系统保护的音乐文件,系统文件无法通过Media Library Kit删除。
Q:播放过程中页面返回,重新进入后播放继续,但进度条不动?
A:页面返回时@State变量会被重置,但播放器实例因为不是组件成员,所以没有被销毁。进度条不动是因为onTimeUpdate的回调里没有重新刷新@State。解决方法:在aboutToAppear中重新订阅时间更新回调,并更新currentTime。
