iOS音频播放 (二):AudioSession

本篇为《iOS音频播放》系列的第二篇。

在实施前一篇中所述的7个步骤之前还必须面对一个麻烦的问题,AudioSession。

本篇主要介绍关于AudioSession使用、期间需要注意的地方以及可能面临的坑。

AudioSession简介

AudioSession这个玩意的主要功能包括以下几点(图片来自官方文档):

确定你的app如何使用音频(是播放?还是录音?)

为你的app选择合适的输入输出设备(比如输入用的麦克风,输出是耳机、手机功放或者airplay)

协调你的app的音频播放和系统以及其他app行为(例如有电话时需要打断,电话结束时需要恢复,按下静音按钮时是否歌曲也要静音等)

AudioSession

AudioSession相关的类有两个:

AudioToolBox中的AudioSession

AVFoundation中的AVAudioSession

其中AudioSession在SDK 7中已经被标注为depracated,而AVAudioSession这个类虽然iOS 3开始就已经存在了,但其中很多方法和变量都是在iOS 6以后甚至是iOS 7才有的。所以各位可以依照以下标准选择:

如果最低版本支持iOS 5,可以使用AudioSession,也可以使用AVAudioSession;

如果最低版本支持iOS 6及以上,请使用AVAudioSession

下面以AudioSession类为例来讲述AudioSession相关功能的使用(很不幸我需要支持iOS 5。。T-T,使用AVAudioSession的同学可以在其头文件中寻找对应的方法使用即可,需要注意的点我会加以说明).

注意:在使用AVAudioPlayer/AVPlayer时可以不用关心AudioSession的相关问题,Apple已经把AudioSession的处理过程封装了,但音乐打断后的响应还是要做的(比如打断后音乐暂停了UI状态也要变化,这个应该通过KVO就可以搞定了吧。。我没试过瞎猜的>_<)。

注意:在使用MPMusicPlayerController时不必关心AudioSession的问题。

初始化AudioSession

使用AudioSession类首先需要调用初始化方法:

1234externOSStatusAudioSessionInitialize(CFRunLoopRefinRunLoop,CFStringRefinRunLoopMode,AudioSessionInterruptionListenerinInterruptionListener,void*inClientData);

前两个参数一般填NULL表示AudioSession运行在主线程上(但并不代表音频的相关处理运行在主线程上,只是AudioSession),第三个参数需要传入一个AudioSessionInterruptionListener类型的方法,作为AudioSession被打断时的回调,第四个参数则是代表打断回调时需要附带的对象(即回到方法中的inClientData,如下所示,可以理解为UIView animation中的context)。

1typedefvoid(*AudioSessionInterruptionListener)(void*inClientData,UInt32inInterruptionState);

这才刚开始,坑就来了。这里会有两个问题:

第一,AudioSessionInitialize可以被多次执行,但AudioSessionInterruptionListener只能被设置一次,这就意味着这个打断回调方法是一个静态方法,一旦初始化成功以后所有的打断都会回调到这个方法,即便下一次再次调用AudioSessionInitialize并且把另一个静态方法作为参数传入,当打断到来时还是会回调到第一次设置的方法上。

这种场景并不少见,例如你的app既需要播放歌曲又需要录音,当然你不可能知道用户会先调用哪个功能,所以你必须在播放和录音的模块中都调用AudioSessionInitialize注册打断方法,但最终打断回调只会作用在先注册的那个模块中,很蛋疼吧。。。所以对于AudioSession的使用最好的方法是生成一个类单独进行管理,统一接收打断回调并发送自定义的打断通知,在需要用到AudioSession的模块中接收通知并做相应的操作。

Apple也察觉到了这一点,所以在AVAudioSession中首先取消了Initialize方法,改为了单例方法sharedInstance。在iOS 5上所有的打断都需要通过设置id delegate并实现回调方法来实现,这同样会有上述的问题,所以在iOS 5使用AVAudioSession下仍然需要一个单独管理AudioSession的类存在。在iOS 6以后Apple终于把打断改成了通知的形式。。这下科学了。

第二,AudioSessionInitialize方法的第四个参数inClientData,也就是回调方法的第一个参数。上面已经说了打断回调是一个静态方法,而这个参数的目的是为了能让回调时拿到context(上下文信息),所以这个inClientData需要是一个有足够长生命周期的对象(当然前提是你确实需要用到这个参数),如果这个对象被dealloc了,那么回调时拿到的inClientData会是一个野指针。就这一点来说构造一个单独管理AudioSession的类也是有必要的,因为这个类的生命周期和AudioSession一样长,我们可以把context保存在这个类中。

监听RouteChange事件

如果想要实现类似于“拔掉耳机就把歌曲暂停”的功能就需要监听RouteChange事件:

12345678externOSStatusAudioSessionAddPropertyListener(AudioSessionPropertyIDinID,AudioSessionPropertyListenerinProc,void*inClientData);typedefvoid(*AudioSessionPropertyListener)(void*inClientData,AudioSessionPropertyIDinID,UInt32inDataSize,constvoid*inData);

调用上述方法,AudioSessionPropertyID参数传kAudioSessionProperty_AudioRouteChange,AudioSessionPropertyListener参数传对应的回调方法。inClientData参数同AudioSessionInitialize方法。

同样作为静态回调方法还是需要统一管理,接到回调时可以把第一个参数inData转换成CFDictionaryRef并从中获取kAudioSession_AudioRouteChangeKey_Reason键值对应的value(应该是一个CFNumberRef),得到这些信息后就可以发送自定义通知给其他模块进行相应操作(例如kAudioSessionRouteChangeReason_OldDeviceUnavailable就可以用来做“拔掉耳机就把歌曲暂停”)。

1234567891011//AudioSession的AudioRouteChangeReason枚举enum{kAudioSessionRouteChangeReason_Unknown=0,kAudioSessionRouteChangeReason_NewDeviceAvailable=1,kAudioSessionRouteChangeReason_OldDeviceUnavailable=2,kAudioSessionRouteChangeReason_CategoryChange=3,kAudioSessionRouteChangeReason_Override=4,kAudioSessionRouteChangeReason_WakeFromSleep=6,kAudioSessionRouteChangeReason_NoSuitableRouteForCategory=7,kAudioSessionRouteChangeReason_RouteConfigurationChange=8};

123456789101112//AVAudioSession的AudioRouteChangeReason枚举typedefNS_ENUM(NSUInteger,AVAudioSessionRouteChangeReason){AVAudioSessionRouteChangeReasonUnknown=0,AVAudioSessionRouteChangeReasonNewDeviceAvailable=1,AVAudioSessionRouteChangeReasonOldDeviceUnavailable=2,AVAudioSessionRouteChangeReasonCategoryChange=3,AVAudioSessionRouteChangeReasonOverride=4,AVAudioSessionRouteChangeReasonWakeFromSleep=6,AVAudioSessionRouteChangeReasonNoSuitableRouteForCategory=7,AVAudioSessionRouteChangeReasonRouteConfigurationChangeNS_ENUM_AVAILABLE_IOS(7_0)=8}

注意:iOS 5下如果使用了AVAudioSession由于AVAudioSessionDelegate中并没有定义相关的方法,还是需要用这个方法来实现监听。iOS 6下直接监听AVAudioSession的通知就可以了。

这里附带两个方法的实现,都是基于AudioSession类的(使用AVAudioSession的同学帮不到你们啦)。

1、判断是否插了耳机:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748+(BOOL)usingHeadset{#if TARGET_IPHONE_SIMULATORreturnNO;#endifCFStringRefroute;UInt32propertySize=sizeof(CFStringRef);AudioSessionGetProperty(kAudioSessionProperty_AudioRoute,&propertySize,&route);BOOLhasHeadset=NO;if((route==NULL)||(CFStringGetLength(route)==0)){// Silent Mode}else{/* Known values of route:* "Headset"* "Headphone"* "Speaker"* "SpeakerAndMicrophone"* "HeadphonesAndMicrophone"* "HeadsetInOut"* "ReceiverAndMicrophone"* "Lineout"*/NSString*routeStr=(__bridgeNSString*)route;NSRangeheadphoneRange=[routeStrrangeOfString:@"Headphone"];NSRangeheadsetRange=[routeStrrangeOfString:@"Headset"];if(headphoneRange.location!=NSNotFound){hasHeadset=YES;}elseif(headsetRange.location!=NSNotFound){hasHeadset=YES;}}if(route){CFRelease(route);}returnhasHeadset;}

2、判断是否开了Airplay(来自StackOverflow):

12345678910111213141516171819202122+(BOOL)isAirplayActived{CFDictionaryRefcurrentRouteDescriptionDictionary=nil;UInt32dataSize=sizeof(currentRouteDescriptionDictionary);AudioSessionGetProperty(kAudioSessionProperty_AudioRouteDescription,&dataSize,¤tRouteDescriptionDictionary);BOOLairplayActived=NO;if(currentRouteDescriptionDictionary){CFArrayRefoutputs=CFDictionaryGetValue(currentRouteDescriptionDictionary,kAudioSession_AudioRouteKey_Outputs);if(outputs!=NULL&&CFArrayGetCount(outputs)>0){CFDictionaryRefcurrentOutput=CFArrayGetValueAtIndex(outputs,0);//Get the output type (will show airplay / hdmi etcCFStringRefoutputType=CFDictionaryGetValue(currentOutput,kAudioSession_AudioRouteKey_Type);airplayActived=(CFStringCompare(outputType,kAudioSessionOutputRoute_AirPlay,0)==kCFCompareEqualTo);}CFRelease(currentRouteDescriptionDictionary);}returnairplayActived;}

设置类别

下一步要设置AudioSession的Category,使用AudioSession时调用下面的接口

123externOSStatusAudioSessionSetProperty(AudioSessionPropertyIDinID,UInt32inDataSize,constvoid*inData);

如果我需要的功能是播放,执行如下代码

1234UInt32sessionCategory=kAudioSessionCategory_MediaPlayback;AudioSessionSetProperty(kAudioSessionProperty_AudioCategory,sizeof(sessionCategory),&sessionCategory);

使用AVAudioSession时调用下面的接口

1234/* set session category */-(BOOL)setCategory:(NSString*)categoryerror:(NSError**)outError;/* set session category with options */-(BOOL)setCategory:(NSString*)categorywithOptions:(AVAudioSessionCategoryOptions)optionserror:(NSError**)outErrorNS_AVAILABLE_IOS(6_0);

至于Category的类型在官方文档中都有介绍,我这里也只罗列一下具体就不赘述了,各位在使用时可以依照自己需要的功能设置Category。

123456789//AudioSession的AudioSessionCategory枚举enum{kAudioSessionCategory_AmbientSound='ambi',kAudioSessionCategory_SoloAmbientSound='solo',kAudioSessionCategory_MediaPlayback='medi',kAudioSessionCategory_RecordAudio='reca',kAudioSessionCategory_PlayAndRecord='plar',kAudioSessionCategory_AudioProcessing='proc'};

1234567891011121314151617181920//AudioSession的AudioSessionCategory字符串/*  Use this category for background sounds such as rain, car engine noise, etc.Mixes with other music. */AVF_EXPORTNSString*constAVAudioSessionCategoryAmbient;/*  Use this category for background sounds.  Other music will stop playing. */AVF_EXPORTNSString*constAVAudioSessionCategorySoloAmbient;/* Use this category for music tracks.*/AVF_EXPORTNSString*constAVAudioSessionCategoryPlayback;/*  Use this category when recording audio. */AVF_EXPORTNSString*constAVAudioSessionCategoryRecord;/*  Use this category when recording and playing back audio. */AVF_EXPORTNSString*constAVAudioSessionCategoryPlayAndRecord;/*  Use this category when using a hardware codec or signal processor whilenot playing or recording audio. */AVF_EXPORTNSString*constAVAudioSessionCategoryAudioProcessing;

启用

有了Category就可以启动AudioSession了,启动方法:

12345678//AudioSession的启动方法externOSStatusAudioSessionSetActive(Booleanactive);externOSStatusAudioSessionSetActiveWithFlags(Booleanactive,UInt32inFlags);//AVAudioSession的启动方法-(BOOL)setActive:(BOOL)activeerror:(NSError**)outError;-(BOOL)setActive:(BOOL)activewithFlags:(NSInteger)flagserror:(NSError**)outErrorNS_DEPRECATED_IOS(4_0,6_0);-(BOOL)setActive:(BOOL)activewithOptions:(AVAudioSessionSetActiveOptions)optionserror:(NSError**)outErrorNS_AVAILABLE_IOS(6_0);

启动方法调用后必须要判断是否启动成功,启动不成功的情况经常存在,例如一个前台的app正在播放,你的app正在后台想要启动AudioSession那就会返回失败。

一般情况下我们在启动和停止AudioSession调用第一个方法就可以了。但如果你正在做一个即时语音通讯app的话(类似于微信、易信)就需要注意在deactive AudioSession的时候需要使用第二个方法,inFlags参数传入kAudioSessionSetActiveFlag_NotifyOthersOnDeactivation(AVAudioSession给options参数传入AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation)。当你的app deactive自己的AudioSession时系统会通知上一个被打断播放app打断结束(就是上面说到的打断回调),如果你的app在deactive时传入了NotifyOthersOnDeactivation参数,那么其他app在接到打断结束回调时会多得到一个参数kAudioSessionInterruptionType_ShouldResume否则就是ShouldNotResume(AVAudioSessionInterruptionOptionShouldResume),根据参数的值可以决定是否继续播放。

大概流程是这样的:

一个音乐软件A正在播放;

用户打开你的软件播放对话语音,AudioSession active;

音乐软件A音乐被打断并收到InterruptBegin事件;

对话语音播放结束,AudioSession deactive并且传入NotifyOthersOnDeactivation参数;

音乐软件A收到InterruptEnd事件,查看Resume参数,如果是ShouldResume控制音频继续播放,如果是ShouldNotResume就维持打断状态;

官方文档中有一张很形象的图来阐述这个现象:

然而现在某些语音通讯软件和某些音乐软件却无视了NotifyOthersOnDeactivation和ShouldResume的正确用法,导致我们经常接到这样的用户反馈:

你们的app在使用xx语音软件听了一段话后就不会继续播放了,但xx音乐软件可以继续播放啊。

好吧,上面只是吐槽一下。请无视我吧。

2014.7.14补充,7.19更新:

发现即使之前已经调用过AudioSessionInitialize方法,在某些情况下被打断之后可能出现AudioSession失效的情况,需要再次调用AudioSessionInitialize方法来重新生成AudioSession。否则调用AudioSessionSetActive会返回560557673(其他AudioSession方法也雷同,所有方法调用前必须首先初始化AudioSession),转换成string后为”!ini”即kAudioSessionNotInitialized,这个情况在iOS 5.1.x上比较容易发生,iOS 6.x 和 7.x也偶有发生(具体的原因还不知晓好像和打断时直接调用AudioOutputUnitStop有关,又是个坑啊)。

所以每次在调用AudioSessionSetActive时应该判断一下错误码,如果是上述的错误码需要重新初始化一下AudioSession。

附上OSStatus转成string的方法:

12345678910111213141516171819202122#import NSString*OSStatusToString(OSStatusstatus){size_tlen=sizeof(UInt32);longaddr=(unsignedlong)&status;charcstring[5];len=(status>>24)==0?len-1:len;len=(status>>16)==0?len-1:len;len=(status>>8)==0?len-1:len;len=(status>>0)==0?len-1:len;addr+=(4-len);status=EndianU32_NtoB(status);// strings are big endianstrncpy(cstring,(char*)addr,len);cstring[len]=0;return[NSStringstringWithCString:(char*)cstringencoding:NSMacOSRomanStringEncoding];}

打断处理

正常启动AudioSession之后就可以播放音频了,下面要讲的是对于打断的处理。之前我们说到打断的回调在iOS 5下需要统一管理,在收到打断开始和结束时需要发送自定义的通知。

使用AudioSession时打断回调应该首先获取kAudioSessionProperty_InterruptionType,然后发送一个自定义的通知并带上对应的参数。

12345678910111213staticvoidMyAudioSessionInterruptionListener(void*inClientData,UInt32inInterruptionState){AudioSessionInterruptionTypeinterruptionType=kAudioSessionInterruptionType_ShouldNotResume;UInt32interruptionTypeSize=sizeof(interruptionType);AudioSessionGetProperty(kAudioSessionProperty_InterruptionType,&interruptionTypeSize,&interruptionType);NSDictionary*userInfo=@{MyAudioInterruptionStateKey:@(inInterruptionState),MyAudioInterruptionTypeKey:@(interruptionType)};[[NSNotificationCenterdefaultCenter]postNotificationName:MyAudioInterruptionNotificationobject:niluserInfo:userInfo];}

收到通知后的处理方法如下(注意ShouldResume参数):

12345678910111213141516171819202122232425-(void)interruptionNotificationReceived:(NSNotification*)notification{UInt32interruptionState=[notification.userInfo[MyAudioInterruptionStateKey]unsignedIntValue];AudioSessionInterruptionTypeinterruptionType=[notification.userInfo[MyAudioInterruptionTypeKey]unsignedIntValue];[selfhandleAudioSessionInterruptionWithState:interruptionStatetype:interruptionType];}-(void)handleAudioSessionInterruptionWithState:(UInt32)interruptionStatetype:(AudioSessionInterruptionType)interruptionType{if(interruptionState==kAudioSessionBeginInterruption){//控制UI,暂停播放}elseif(interruptionState==kAudioSessionEndInterruption){if(interruptionType==kAudioSessionInterruptionType_ShouldResume){OSStatusstatus=AudioSessionSetActive(true);if(status==noErr){//控制UI,继续播放}}}}

小结

关于AudioSession的话题到此结束(码字果然很累。。)。小结一下:

如果最低版本支持iOS 5,可以使用AudioSession也可以考虑使用AVAudioSession,需要有一个类统一管理AudioSession的所有回调,在接到回调后发送对应的自定义通知;

如果最低版本支持iOS 6及以上,请使用AVAudioSession,不用统一管理,接AVAudioSession的通知即可;

根据app的应用场景合理选择Category;

在deactive时需要注意app的应用场景来合理的选择是否使用NotifyOthersOnDeactivation参数;

在处理InterruptEnd事件时需要注意ShouldResume的值。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容