iOS音频播放 (一):概述


前言

从事音乐相关的app开发也已经有一段时日了,在这过程中app的播放器几经修改我也因此对于iOS下的音频播放实现有了一定的研究。写这个系列的博客目的一方面希望能够抛砖引玉,另一方面也是希望能帮助国内其他的iOS开发者和爱好者少走弯路(我自己就遇到了不少的坑=。=)。

本篇为《iOS音频播放》系列的第一篇,主要将对iOS下实现音频播放的方法进行概述。


基础

先来简单了解一下一些基础的音频知识。

目前我们在计算机上进行音频播放都需要依赖于音频文件,音频文件的生成过程是将声音信息采样、量化和编码产生的数字信号的过程,人耳所能听到的声音,最低的频率是从20Hz起一直到最高频率20KHZ,因此音频文件格式的最大带宽是20KHZ。根据奈奎斯特的理论,只有采样频率高于声音信号最高频率的两倍时,才能把数字信号表示的声音还原成为原来的声音,所以音频文件的采样率一般在40~50KHZ,比如最常见的CD音质采样率44.1KHZ。

对声音进行采样、量化过程被称为脉冲编码调制(Pulse Code Modulation),简称PCM。PCM数据是最原始的音频数据完全无损,所以PCM数据虽然音质优秀但体积庞大,为了解决这个问题先后诞生了一系列的音频格式,这些音频格式运用不同的方法对音频数据进行压缩,其中有无损压缩(ALAC、APE、FLAC)和有损压缩(MP3、AAC、OGG、WMA)两种。

目前最为常用的音频格式是MP3,MP3是一种有损压缩的音频格式,设计这种格式的目的就是为了大幅度的减小音频的数据量,它舍弃PCM音频数据中人类听觉不敏感的部分,从下面的比较图我们可以明显的看到MP3数据相比PCM数据明显矮了一截(图片引自imp3论坛)。

上图为pcm数据上图为mp3数据

MP3格式中的码率(BitRate)代表了MP3数据的压缩质量,现在常用的码率有128kbit/s、160kbit/s、320kbit/s等等,这个值越高声音质量也就越高。MP3编码方式常用的有两种固定码率(Constant bitrate,CBR)和可变码率(Variable bitrate,VBR)。

MP3格式中的数据通常由两部分组成,一部分为ID3用来存储歌名、演唱者、专辑、音轨数等信息,另一部分为音频数据。音频数据部分以帧(frame)为单位存储,每个音频都有自己的帧头,如图所示就是一个MP3文件帧结构图(图片同样来自互联网)。MP3中的每一个帧都有自己的帧头,其中存储了采样率等解码必须的信息,所以每一个帧都可以独立于文件存在和播放,这个特性加上高压缩比使得MP3文件成为了音频流播放的主流格式。帧头之后存储着音频数据,这些音频数据是若干个PCM数据帧经过压缩算法压缩得到的,对CBR的MP3数据来说每个帧中包含的PCM数据帧是固定的,而VBR是可变的。


iOS音频播放概述

了解了基础概念之后我们就可以列出一个经典的音频播放流程(以MP3为例):

  1. 读取MP3文件
  2. 解析采样率、码率、时长等信息,分离MP3中的音频帧
  3. 对分离出来的音频帧解码得到PCM数据
  4. 对PCM数据进行音效处理(均衡器、混响器等,非必须)
  5. 把PCM数据解码成音频信号
  6. 把音频信号交给硬件播放
  7. 重复1-6步直到播放完成

在iOS系统中apple对上述的流程进行了封装并提供了不同层次的接口(图片引自官方文档)。

CoreAudio的接口层次

下面对其中的中高层接口进行功能说明:

  • Audio File Services:读写音频数据,可以完成播放流程中的第2步;
  • Audio File Stream Services:对音频进行解码,可以完成播放流程中的第2步;
  • Audio Converter services:音频数据转换,可以完成播放流程中的第3步;
  • Audio Processing Graph Services:音效处理模块,可以完成播放流程中的第4步;
  • Audio Unit Services:播放音频数据:可以完成播放流程中的第5步、第6步;
  • Extended Audio File Services:Audio File Services和Audio Converter services的结合体;
  • AVAudioPlayer/AVPlayer(AVFoundation):高级接口,可以完成整个音频播放的过程(包括本地文件和网络流播放,第4步除外);
  • Audio Queue Services:高级接口,可以进行录音和播放,可以完成播放流程中的第3、5、6步;
  • OpenAL:用于游戏音频播放,暂不讨论

可以看到apple提供的接口类型非常丰富,可以满足各种类别类需求:

  • 如果你只是想实现音频的播放,没有其他需求AVFoundation会很好的满足你的需求。它的接口使用简单、不用关心其中的细节;

  • 如果你的app需要对音频进行流播放并且同时存储,那么AudioFileStreamer加AudioQueue能够帮到你,你可以先把音频数据下载到本地,一边下载一边用NSFileHandler等接口读取本地音频文件并交给AudioFileStreamer或者AudioFile解析分离音频帧,分离出来的音频帧可以送给AudioQueue进行解码和播放。如果是本地文件直接读取文件解析即可。(这两个都是比较直接的做法,这类需求也可以用AVFoundation+本地server的方式实现,AVAudioPlayer会把请求发送给本地server,由本地server转发出去,获取数据后在本地server中存储并转送给AVAudioPlayer。另一个比较trick的做法是先把音频下载到文件中,在下载到一定量的数据后把文件路径给AVAudioPlayer播放,当然这种做法在音频seek后就回有问题了。);

  • 如果你正在开发一个专业的音乐播放软件,需要对音频施加音效(均衡器、混响器),那么除了数据的读取和解析以外还需要用到AudioConverter来把音频数据转换成PCM数据,再由AudioUnit+AUGraph来进行音效处理和播放(但目前多数带音效的app都是自己开发音效模块来坐PCM数据的处理,这部分功能自行开发在自定义性和扩展性上会比较强一些。PCM数据通过音效器处理完成后就可以使用AudioUnit播放了,当然AudioQueue也支持直接使对PCM数据进行播放。)。下图描述的就是使用AudioFile + AudioConverter + AudioUnit进行音频播放的流程(图片引自官方文档)。

iOS音频播放 (二):AudioSession

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

  1. 确定你的app如何使用音频(是播放?还是录音?)
  2. 为你的app选择合适的输入输出设备(比如输入用的麦克风,输出是耳机、手机功放或者airplay)
  3. 协调你的app的音频播放和系统以及其他app行为(例如有电话时需要打断,电话结束时需要恢复,按下静音按钮时是否歌曲也要静音等)

AudioSession

AudioSession相关的类有两个:

  1. AudioToolBox中的AudioSession
  2. 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类首先需要调用初始化方法:

1
2
3
4
extern OSStatus AudioSessionInitialize(CFRunLoopRef inRunLoop,  CFStringRef inRunLoopMode,  AudioSessionInterruptionListener inInterruptionListener,  void *inClientData); 

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

1
typedef void (*AudioSessionInterruptionListener)(void * inClientData, UInt32 inInterruptionState); 

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

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

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

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

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


监听RouteChange事件

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

1
2
3
4
5
6 7 8 
extern OSStatus AudioSessionAddPropertyListener(AudioSessionPropertyID inID,  AudioSessionPropertyListener inProc,  void *inClientData);  typedef void (*AudioSessionPropertyListener)(void * inClientData,  AudioSessionPropertyID inID,  UInt32 inDataSize,  const void * inData); 

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

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

1
2
3
4
5
6 7 8 9 10 11 
//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  }; 
1
2
3
4
5
6 7 8 9 10 11 12 
//AVAudioSession的AudioRouteChangeReason枚举
typedef NS_ENUM(NSUInteger, AVAudioSessionRouteChangeReason) {  AVAudioSessionRouteChangeReasonUnknown = 0,  AVAudioSessionRouteChangeReasonNewDeviceAvailable = 1,  AVAudioSessionRouteChangeReasonOldDeviceUnavailable = 2,  AVAudioSessionRouteChangeReasonCategoryChange = 3,  AVAudioSessionRouteChangeReasonOverride = 4,  AVAudioSessionRouteChangeReasonWakeFromSleep = 6,  AVAudioSessionRouteChangeReasonNoSuitableRouteForCategory = 7,  AVAudioSessionRouteChangeReasonRouteConfigurationChange NS_ENUM_AVAILABLE_IOS(7_0) = 8 } 

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


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

1、判断是否插了耳机:

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 
+ (BOOL)usingHeadset { #if TARGET_IPHONE_SIMULATOR  return NO; #endif   CFStringRef route;  UInt32 propertySize = sizeof(CFStringRef);  AudioSessionGetProperty(kAudioSessionProperty_AudioRoute, &propertySize, &route);   BOOL hasHeadset = NO;  if((route == NULL) || (CFStringGetLength(route) == 0))  {  // Silent Mode  }  else  {  /* Known values of route:  * "Headset"  * "Headphone"  * "Speaker"  * "SpeakerAndMicrophone"  * "HeadphonesAndMicrophone"  * "HeadsetInOut"  * "ReceiverAndMicrophone"  * "Lineout"  */  NSString* routeStr = (__bridge NSString*)route;  NSRange headphoneRange = [routeStr rangeOfString : @"Headphone"];  NSRange headsetRange = [routeStr rangeOfString : @"Headset"];   if (headphoneRange.location != NSNotFound)  {  hasHeadset = YES;  }  else if(headsetRange.location != NSNotFound)  {  hasHeadset = YES;  }  }   if (route)  {  CFRelease(route);  }   return hasHeadset; } 

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

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 
+ (BOOL)isAirplayActived {  CFDictionaryRef currentRouteDescriptionDictionary = nil;  UInt32 dataSize = sizeof(currentRouteDescriptionDictionary);  AudioSessionGetProperty(kAudioSessionProperty_AudioRouteDescription, &dataSize, &currentRouteDescriptionDictionary);   BOOL airplayActived = NO;  if (currentRouteDescriptionDictionary)  {  CFArrayRef outputs = CFDictionaryGetValue(currentRouteDescriptionDictionary, kAudioSession_AudioRouteKey_Outputs);  if(outputs != NULL && CFArrayGetCount(outputs) > 0)  {  CFDictionaryRef currentOutput = CFArrayGetValueAtIndex(outputs, 0);  //Get the output type (will show airplay / hdmi etc  CFStringRef outputType = CFDictionaryGetValue(currentOutput, kAudioSession_AudioRouteKey_Type);   airplayActived = (CFStringCompare(outputType, kAudioSessionOutputRoute_AirPlay, 0) == kCFCompareEqualTo);  }  CFRelease(currentRouteDescriptionDictionary);  }  return airplayActived; } 

设置类别

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

1
2
3
extern OSStatus AudioSessionSetProperty(AudioSessionPropertyID inID,  UInt32 inDataSize,  const void *inData); 

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

1
2
3
4
UInt32 sessionCategory = kAudioSessionCategory_MediaPlayback; AudioSessionSetProperty (kAudioSessionProperty_AudioCategory,  sizeof(sessionCategory),  &sessionCategory); 

使用AVAudioSession时调用下面的接口

1
2
3
4
/* set session category */
- (BOOL)setCategory:(NSString *)category error:(NSError **)outError; /* set session category with options */ - (BOOL)setCategory:(NSString *)category withOptions: (AVAudioSessionCategoryOptions)options error:(NSError **)outError NS_AVAILABLE_IOS(6_0); 

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

1
2
3
4
5
6 7 8 9 
//AudioSession的AudioSessionCategory枚举
enum {  kAudioSessionCategory_AmbientSound = 'ambi',  kAudioSessionCategory_SoloAmbientSound = 'solo',  kAudioSessionCategory_MediaPlayback = 'medi',  kAudioSessionCategory_RecordAudio = 'reca',  kAudioSessionCategory_PlayAndRecord = 'plar',  kAudioSessionCategory_AudioProcessing = 'proc'  }; 
1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 
//AudioSession的AudioSessionCategory字符串
/*  Use this category for background sounds such as rain, car engine noise, etc.
 Mixes with other music. */ AVF_EXPORT NSString *const AVAudioSessionCategoryAmbient;  /* Use this category for background sounds. Other music will stop playing. */ AVF_EXPORT NSString *const AVAudioSessionCategorySoloAmbient;  /* Use this category for music tracks.*/ AVF_EXPORT NSString *const AVAudioSessionCategoryPlayback;  /* Use this category when recording audio. */ AVF_EXPORT NSString *const AVAudioSessionCategoryRecord;  /* Use this category when recording and playing back audio. */ AVF_EXPORT NSString *const AVAudioSessionCategoryPlayAndRecord;  /* Use this category when using a hardware codec or signal processor while  not playing or recording audio. */ AVF_EXPORT NSString *const AVAudioSessionCategoryAudioProcessing; 

启用

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

1
2
3
4
5
6 7 8 
//AudioSession的启动方法
extern OSStatus AudioSessionSetActive(Boolean active); extern OSStatus AudioSessionSetActiveWithFlags(Boolean active, UInt32 inFlags);  //AVAudioSession的启动方法 - (BOOL)setActive:(BOOL)active error:(NSError **)outError; - (BOOL)setActive:(BOOL)active withFlags:(NSInteger)flags error:(NSError **)outError NS_DEPRECATED_IOS(4_0, 6_0); - (BOOL)setActive:(BOOL)active withOptions:(AVAudioSessionSetActiveOptions)options error:(NSError **)outError NS_AVAILABLE_IOS(6_0); 

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

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

大概流程是这样的:

  1. 一个音乐软件A正在播放;
  2. 用户打开你的软件播放对话语音,AudioSession active;
  3. 音乐软件A音乐被打断并收到InterruptBegin事件;
  4. 对话语音播放结束,AudioSession deactive并且传入NotifyOthersOnDeactivation参数;
  5. 音乐软件A收到InterruptEnd事件,查看Resume参数,如果是ShouldResume控制音频继续播放,如果是ShouldNotResume就维持打断状态;

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

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

你们的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的方法:

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 
#import <Endian.h>

NSString * OSStatusToString(OSStatus status) {  size_t len = sizeof(UInt32);  long addr = (unsigned long)&status;  char cstring[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 endian   strncpy(cstring, (char *)addr, len);  cstring[len] = 0;   return [NSString stringWithCString:(char *)cstring encoding:NSMacOSRomanStringEncoding]; } 

打断处理

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

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

1
2
3
4
5
6 7 8 9 10 11 12 13 
static void MyAudioSessionInterruptionListener(void *inClientData, UInt32 inInterruptionState) {  AudioSessionInterruptionType interruptionType = kAudioSessionInterruptionType_ShouldNotResume;  UInt32 interruptionTypeSize = sizeof(interruptionType);  AudioSessionGetProperty(kAudioSessionProperty_InterruptionType,  &interruptionTypeSize,  &interruptionType);   NSDictionary *userInfo = @{MyAudioInterruptionStateKey:@(inInterruptionState),  MyAudioInterruptionTypeKey:@(interruptionType)};   [[NSNotificationCenter defaultCenter] postNotificationName:MyAudioInterruptionNotification object:nil userInfo:userInfo]; } 

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

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 
- (void)interruptionNotificationReceived:(NSNotification *)notification {  UInt32 interruptionState = [notification.userInfo[MyAudioInterruptionStateKey] unsignedIntValue];  AudioSessionInterruptionType interruptionType = [notification.userInfo[MyAudioInterruptionTypeKey] unsignedIntValue];  [self handleAudioSessionInterruptionWithState:interruptionState type:interruptionType]; }  - (void)handleAudioSessionInterruptionWithState:(UInt32)interruptionState type:(AudioSessionInterruptionType)interruptionType {  if (interruptionState == kAudioSessionBeginInterruption)  {  //控制UI,暂停播放  }  else if (interruptionState == kAudioSessionEndInterruption)  {  if (interruptionType == kAudioSessionInterruptionType_ShouldResume)  {  OSStatus status = AudioSessionSetActive(true);  if (status == noErr)  {  //控制UI,继续播放  }  }  } } 

小结

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

  • 如果最低版本支持iOS 5,可以使用AudioSession也可以考虑使用AVAudioSession,需要有一个类统一管理AudioSession的所有回调,在接到回调后发送对应的自定义通知;
  • 如果最低版本支持iOS 6及以上,请使用AVAudioSession,不用统一管理,接AVAudioSession的通知即可;
  • 根据app的应用场景合理选择Category
  • 在deactive时需要注意app的应用场景来合理的选择是否使用NotifyOthersOnDeactivation参数;
  • 在处理InterruptEnd事件时需要注意ShouldResume的值。

示例代码

这里有我自己写的AudioSession的封装,如果各位需要支持iOS 5的话可以使用一下。


下篇预告

下一篇将讲述如何使用AudioFileStreamer分离音频帧,以及如何使用AudioQueue进行播放。

下一篇将讲述如何使用AudioFileStreamer提取音频文件格式信息和分离音频帧。


参考资料

AudioSession

原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

3

Comments

iOS音频播放 (三):AudioFileStream

Audio Playback in iOS (Part 3) : AudioFileStream


前言

本来说好是要在第三篇中讲AudioFileStreamAudioQueue,但写着写着发现光AudioFileStream就好多内容,最后还是决定分篇介绍,这篇先来说一下AudioFileStream,下一篇计划说一下和AudioFileStream类似的AudioFile,下下篇再来说AudioQueue

在本篇那种将会提到计算音频时长duration和音频seek的方法,这些方法对于CBR编码形式的音频文件可以做到比较精确而对于VBR编码形式的会存在较大的误差(关于CBR和VBR,请看本系列的第一篇),具体讲到duration和seek时会再进行说明。


AudioFileStream介绍

在第一篇中说到AudioFileStreamer时提到它的作用是用来读取采样率、码率、时长等基本信息以及分离音频帧。那么在官方文档中Apple是这样描述的:

To play streamed audio content, such as from a network connection, use Audio File Stream Services in concert with Audio Queue Services. Audio File Stream Services parses audio packets and metadata from common audio file container formats in a network bitstream. You can also use it to parse packets and metadata from on-disk files

根据Apple的描述AudioFileStreamer用在流播放中,当然不仅限于网络流,本地文件同样可以用它来读取信息和分离音频帧。AudioFileStreamer的主要数据是文件数据而不是文件路径,所以数据的读取需要使用者自行实现,

支持的文件格式有:

  • MPEG-1 Audio Layer 3, used for .mp3 files
  • MPEG-2 ADTS, used for the .aac audio data format
  • AIFC
  • AIFF
  • CAF
  • MPEG-4, used for .m4a, .mp4, and .3gp files
  • NeXT
  • WAVE

上述格式是iOS、MacOSX所支持的音频格式,这类格式可以被系统提供的API解码,如果想要解码其他的音频格式(如OGG、APE、FLAC)就需要自己实现解码器了。


初始化AudioFileStream

第一步,自然是要生成一个AudioFileStream的实例:

1
2
3
4
5
extern OSStatus AudioFileStreamOpen (void * inClientData,  AudioFileStream_PropertyListenerProc inPropertyListenerProc,  AudioFileStream_PacketsProc inPacketsProc,  AudioFileTypeID inFileTypeHint,  AudioFileStreamID * outAudioFileStream); 

第一个参数和之前的AudioSession的初始化方法一样是一个上下文对象;

第二个参数AudioFileStream_PropertyListenerProc是歌曲信息解析的回调,每解析出一个歌曲信息都会进行一次回调;

第三个参数AudioFileStream_PacketsProc是分离帧的回调,每解析出一部分帧就会进行一次回调;

第四个参数AudioFileTypeID是文件类型的提示,这个参数来帮助AudioFileStream对文件格式进行解析。这个参数在文件信息不完整(例如信息有缺陷)时尤其有用,它可以给与AudioFileStream一定的提示,帮助其绕过文件中的错误或者缺失从而成功解析文件。所以在确定文件类型的情况下建议各位还是填上这个参数,如果无法确定可以传入0(原理上应该和这篇博文近似);

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 
//AudioFileTypeID枚举
enum {  kAudioFileAIFFType = 'AIFF',  kAudioFileAIFCType = 'AIFC',  kAudioFileWAVEType = 'WAVE',  kAudioFileSoundDesigner2Type = 'Sd2f',  kAudioFileNextType = 'NeXT',  kAudioFileMP3Type = 'MPG3', // mpeg layer 3  kAudioFileMP2Type = 'MPG2', // mpeg layer 2  kAudioFileMP1Type = 'MPG1', // mpeg layer 1  kAudioFileAC3Type = 'ac-3',  kAudioFileAAC_ADTSType = 'adts',  kAudioFileMPEG4Type = 'mp4f',  kAudioFileM4AType = 'm4af',  kAudioFileM4BType = 'm4bf',  kAudioFileCAFType = 'caff',  kAudioFile3GPType = '3gpp',  kAudioFile3GP2Type = '3gp2',  kAudioFileAMRType = 'amrf' }; 

第五个参数是返回的AudioFileStream实例对应的AudioFileStreamID,这个ID需要保存起来作为后续一些方法的参数使用;

返回值用来判断是否成功初始化(OSStatus == noErr)。


解析数据

在初始化完成之后,只要拿到文件数据就可以进行解析了。解析时调用方法:

1
2
3
4
extern OSStatus AudioFileStreamParseBytes(AudioFileStreamID inAudioFileStream,  UInt32 inDataByteSize,  const void* inData,  UInt32 inFlags); 

第一个参数AudioFileStreamID,即初始化时返回的ID;

第二个参数inDataByteSize,本次解析的数据长度;

第三个参数inData,本次解析的数据;

第四个参数是说本次的解析和上一次解析是否是连续的关系,如果是连续的传入0,否则传入kAudioFileStreamParseFlag_Discontinuity

这里需要插入解释一下何谓“连续”。在第一篇中我们提到过形如MP3的数据都以帧的形式存在的,解析时也需要以帧为单位解析。但在解码之前我们不可能知道每个帧的边界在第几个字节,所以就会出现这样的情况:我们传给AudioFileStreamParseBytes的数据在解析完成之后会有一部分数据余下来,这部分数据是接下去那一帧的前半部分,如果再次有数据输入需要继续解析时就必须要用到前一次解析余下来的数据才能保证帧数据完整,所以在正常播放的情况下传入0即可。目前知道的需要传入kAudioFileStreamParseFlag_Discontinuity的情况有两个,一个是在seek完毕之后显然seek后的数据和之前的数据完全无关;另一个是开源播放器AudioStreamer的作者@Matt Gallagher曾在自己的blog中提到过的:

the Audio File Stream Services hit me with a nasty bug: AudioFileStreamParseBytes will crash when trying to parse a streaming MP3.

In this case, if we pass the kAudioFileStreamParseFlag_Discontinuity flag to AudioFileStreamParseBytes on every invocation between receiving kAudioFileStreamProperty_ReadyToProducePackets and the first successful call to MyPacketsProc, then AudioFileStreamParseBytes will be extra cautious in its approach and won't crash.

Matt发布这篇blog是在2008年,这个Bug年代相当久远了,而且原因未知,究竟是否修复也不得而知,而且由于环境不同(比如测试用的mp3文件和所处的iOS系统)无法重现这个问题,所以我个人觉得还是按照Matt的work around在回调得到kAudioFileStreamProperty_ReadyToProducePackets之后,在正常解析第一帧之前都传入kAudioFileStreamParseFlag_Discontinuity比较好。

回到之前的内容,AudioFileStreamParseBytes方法的返回值表示当前的数据是否被正常解析,如果OSStatus的值不是noErr则表示解析不成功,其中错误码包括:

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 
enum
{
 kAudioFileStreamError_UnsupportedFileType = 'typ?',  kAudioFileStreamError_UnsupportedDataFormat = 'fmt?',  kAudioFileStreamError_UnsupportedProperty = 'pty?',  kAudioFileStreamError_BadPropertySize = '!siz',  kAudioFileStreamError_NotOptimized = 'optm',  kAudioFileStreamError_InvalidPacketOffset = 'pck?',  kAudioFileStreamError_InvalidFile = 'dta?',  kAudioFileStreamError_ValueUnknown = 'unk?',  kAudioFileStreamError_DataUnavailable = 'more',  kAudioFileStreamError_IllegalOperation = 'nope',  kAudioFileStreamError_UnspecifiedError = 'wht?',  kAudioFileStreamError_DiscontinuityCantRecover = 'dsc!' }; 

大多数都可以从字面上理解,需要提一下的是kAudioFileStreamError_NotOptimized,文档上是这么说的:

It is not possible to produce output packets because the file's packet table or other defining info is either not present or is after the audio data.

它的含义是说这个音频文件的文件头不存在或者说文件头可能在文件的末尾,当前无法正常Parse,换句话说就是这个文件需要全部下载完才能播放,无法流播。

注意AudioFileStreamParseBytes方法每一次调用都应该注意返回值,一旦出现错误就可以不必继续Parse了。


解析文件格式信息

在调用AudioFileStreamParseBytes方法进行解析时会首先读取格式信息,并同步的进入AudioFileStream_PropertyListenerProc回调方法

来看一下这个回调方法的定义

1
2
3
4
typedef void (*AudioFileStream_PropertyListenerProc)(void * inClientData,  AudioFileStreamID inAudioFileStream,  AudioFileStreamPropertyID inPropertyID,  UInt32 * ioFlags); 

回调的第一个参数是Open方法中的上下文对象;

第二个参数inAudioFileStream是和Open方法中第四个返回参数AudioFileStreamID一样,表示当前FileStream的ID;

第三个参数是此次回调解析的信息ID。表示当前PropertyID对应的信息已经解析完成信息(例如数据格式、音频数据的偏移量等等),使用者可以通过AudioFileStreamGetProperty接口获取PropertyID对应的值或者数据结构;

1
2
3
4
extern OSStatus AudioFileStreamGetProperty(AudioFileStreamID inAudioFileStream,  AudioFileStreamPropertyID inPropertyID,  UInt32 * ioPropertyDataSize,  void * outPropertyData); 

第四个参数ioFlags是一个返回参数,表示这个property是否需要被缓存,如果需要赋值kAudioFileStreamPropertyFlag_PropertyIsCached否则不赋值(这个参数我也不知道应该在啥场景下使用。。一直都没去理他);

这个回调会进来多次,但并不是每一次都需要进行处理,可以根据需求处理需要的PropertyID进行处理(PropertyID列表如下)。

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 
//AudioFileStreamProperty枚举
enum
{  kAudioFileStreamProperty_ReadyToProducePackets = 'redy',  kAudioFileStreamProperty_FileFormat = 'ffmt',  kAudioFileStreamProperty_DataFormat = 'dfmt',  kAudioFileStreamProperty_FormatList = 'flst',  kAudioFileStreamProperty_MagicCookieData = 'mgic',  kAudioFileStreamProperty_AudioDataByteCount = 'bcnt',  kAudioFileStreamProperty_AudioDataPacketCount = 'pcnt',  kAudioFileStreamProperty_MaximumPacketSize = 'psze',  kAudioFileStreamProperty_DataOffset = 'doff',  kAudioFileStreamProperty_ChannelLayout = 'cmap',  kAudioFileStreamProperty_PacketToFrame = 'pkfr',  kAudioFileStreamProperty_FrameToPacket = 'frpk',  kAudioFileStreamProperty_PacketToByte = 'pkby',  kAudioFileStreamProperty_ByteToPacket = 'bypk',  kAudioFileStreamProperty_PacketTableInfo = 'pnfo',  kAudioFileStreamProperty_PacketSizeUpperBound = 'pkub',  kAudioFileStreamProperty_AverageBytesPerPacket = 'abpp',  kAudioFileStreamProperty_BitRate = 'brat',  kAudioFileStreamProperty_InfoDictionary = 'info' }; 

这里列几个我认为比较重要的PropertyID:

1、kAudioFileStreamProperty_BitRate

表示音频数据的码率,获取这个Property是为了计算音频的总时长Duration(因为AudioFileStream没有这样的接口。。)。

1
2
3
4
5
6 7 
UInt32 bitRate;
UInt32 bitRateSize = sizeof(bitRate); OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_BitRate, &bitRateSize, &bitRate); if (status != noErr) {  //错误处理 } 

2014.8.2 补充: 发现在流播放的情况下,有时数据流量比较小时会出现ReadyToProducePackets还是没有获取到bitRate的情况,这时就需要分离一些拼音帧然后计算平均bitRate,计算公式如下:

1
UInt32 averageBitRate = totalPackectByteCount / totalPacketCout; 

2、kAudioFileStreamProperty_DataOffset

表示音频数据在整个音频文件中的offset(因为大多数音频文件都会有一个文件头之后才使真正的音频数据),这个值在seek时会发挥比较大的作用,音频的seek并不是直接seek文件位置而seek时间(比如seek到2分10秒的位置),seek时会根据时间计算出音频数据的字节offset然后需要再加上音频数据的offset才能得到在文件中的真正offset。

1
2
3
4
5
6 7 
SInt64 dataOffset;
UInt32 offsetSize = sizeof(dataOffset); OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_DataOffset, &offsetSize, &dataOffset); if (status != noErr) {  //错误处理 } 

3、kAudioFileStreamProperty_DataFormat

表示音频文件结构信息,是一个AudioStreamBasicDescription的结构

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 
struct AudioStreamBasicDescription
{  Float64 mSampleRate;  UInt32 mFormatID;  UInt32 mFormatFlags;  UInt32 mBytesPerPacket;  UInt32 mFramesPerPacket;  UInt32 mBytesPerFrame;  UInt32 mChannelsPerFrame;  UInt32 mBitsPerChannel;  UInt32 mReserved; };  AudioStreamBasicDescription asbd; UInt32 asbdSize = sizeof(asbd); OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_DataFormat, &asbdSize, &asbd); if (status != noErr) {  //错误处理 } 

4、kAudioFileStreamProperty_FormatList

作用和kAudioFileStreamProperty_DataFormat是一样的,区别在于用这个PropertyID获取到是一个AudioStreamBasicDescription的数组,这个参数是用来支持AAC SBR这样的包含多个文件类型的音频格式。由于到底有多少个format我们并不知晓,所以需要先获取一下总数据大小:

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 
//获取数据大小
Boolean outWriteable; UInt32 formatListSize; OSStatus status = AudioFileStreamGetPropertyInfo(inAudioFileStream, kAudioFileStreamProperty_FormatList, &formatListSize, &outWriteable); if (status != noErr) {  //错误处理 }  //获取formatlist AudioFormatListItem *formatList = malloc(formatListSize); OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_FormatList, &formatListSize, formatList); if (status != noErr) {  //错误处理 }  //选择需要的格式 for (int i = 0; i * sizeof(AudioFormatListItem) < formatListSize; i += sizeof(AudioFormatListItem)) {  AudioStreamBasicDescription pasbd = formatList[i].mASBD;  //选择需要的格式。。 } free(formatList); 

5、kAudioFileStreamProperty_AudioDataByteCount

顾名思义,音频文件中音频数据的总量。这个Property的作用一是用来计算音频的总时长,二是可以在seek时用来计算时间对应的字节offset。

1
2
3
4
5
6 7 
UInt64 audioDataByteCount;
UInt32 byteCountSize = sizeof(audioDataByteCount); OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_AudioDataByteCount, &byteCountSize, &audioDataByteCount); if (status != noErr) {  //错误处理 } 

2014.8.2 补充: 发现在流播放的情况下,有时数据流量比较小时会出现ReadyToProducePackets还是没有获取到audioDataByteCount的情况,这时就需要近似计算audioDataByteCount。一般来说音频文件的总大小一定是可以得到的(利用文件系统或者Http请求中的contentLength),那么计算方法如下:

1
2
3
UInt32 dataOffset = ...; //kAudioFileStreamProperty_DataOffset UInt32 fileLength = ...; //音频文件大小 UInt32 audioDataByteCount = fileLength - dataOffset; 

5、kAudioFileStreamProperty_ReadyToProducePackets

这个PropertyID可以不必获取对应的值,一旦回调中这个PropertyID出现就代表解析完成,接下来可以对音频数据进行帧分离了。


计算时长Duration

获取时长的最佳方法是从ID3信息中去读取,那样是最准确的。如果ID3信息中没有存,那就依赖于文件头中的信息去计算了。

计算duration的公式如下:

1
double duration = (audioDataByteCount * 8) / bitRate 

音频数据的字节总量audioDataByteCount可以通过kAudioFileStreamProperty_AudioDataByteCount获取,码率bitRate可以通过kAudioFileStreamProperty_BitRate获取也可以通过Parse一部分数据后计算平均码率来得到。

对于CBR数据来说用这样的计算方法的duration会比较准确,对于VBR数据就不好说了。所以对于VBR数据来说,最好是能够从ID3信息中获取到duration,获取不到再想办法通过计算平均码率的途径来计算duration。


分离音频帧

读取格式信息完成之后继续调用AudioFileStreamParseBytes方法可以对帧进行分离,并同步的进入AudioFileStream_PacketsProc回调方法。

回调的定义:

1
2
3
4
5
typedef void (*AudioFileStream_PacketsProc)(void * inClientData,  UInt32 inNumberBytes,  UInt32 inNumberPackets,  const void * inInputData,  AudioStreamPacketDescription * inPacketDescriptions); 

第一个参数,一如既往的上下文对象;

第二个参数,本次处理的数据大小;

第三个参数,本次总共处理了多少帧(即代码里的Packet);

第四个参数,本次处理的所有数据;

第五个参数,AudioStreamPacketDescription数组,存储了每一帧数据是从第几个字节开始的,这一帧总共多少字节。

1
2
3
4
5
6 7 8 
//AudioStreamPacketDescription结构
//这里的mVariableFramesInPacket是指实际的数据帧只有VBR的数据才能用到(像MP3这样的压缩数据一个帧里会有好几个数据帧)
struct AudioStreamPacketDescription {  SInt64 mStartOffset;  UInt32 mVariableFramesInPacket;  UInt32 mDataByteSize; }; 

下面是我按照自己的理解实现的回调方法片段:

1
2
3
4
5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 
static void MyAudioFileStreamPacketsCallBack(void *inClientData,  UInt32 inNumberBytes,  UInt32 inNumberPackets,  const void *inInputData,  AudioStreamPacketDescription *inPacketDescriptions) {  //处理discontinuous..   if (numberOfBytes == 0 || numberOfPackets == 0)  {  return;  }   BOOL deletePackDesc = NO;  if (packetDescriptioins == NULL)  {  //如果packetDescriptioins不存在,就按照CBR处理,平均每一帧的数据后生成packetDescriptioins  deletePackDesc = YES;  UInt32 packetSize = numberOfBytes / numberOfPackets;  packetDescriptioins = (AudioStreamPacketDescription *)malloc(sizeof(AudioStreamPacketDescription) * numberOfPackets);   for (int i = 0; i < numberOfPackets; i++)  {  UInt32 packetOffset = packetSize * i;  descriptions[i].mStartOffset = packetOffset;  descriptions[i].mVariableFramesInPacket = 0;  if (i == numberOfPackets - 1)  {  packetDescriptioins[i].mDataByteSize = numberOfBytes - packetOffset;  }  else  {  packetDescriptioins[i].mDataByteSize = packetSize;  }  }  }   for (int i = 0; i < numberOfPackets; ++i)  {  SInt64 packetOffset = packetDescriptioins[i].mStartOffset;  UInt32 packetSize = packetDescriptioins[i].mDataByteSize;   //把解析出来的帧数据放进自己的buffer中  ...  }   if (deletePackDesc)  {  free(packetDescriptioins);  } 

一篇对iOS音频比较完善的文章相关推荐

  1. 一步一步教你实现iOS音频频谱动画(一)

    如果你想先看看最终效果再决定看不看文章 -> bilibili 示例代码下载 第二篇:一步一步教你实现iOS音频频谱动画(二) 基于篇幅考虑,本次教程分为两篇文章,本篇文章主要讲述音频播放和频谱 ...

  2. iOS 音频播放,录音,视频播放,拍照,视频录制

    iOS开发系列--音频播放.录音.视频播放.拍照.视频录制 2014-12-26 09:15 by KenshinCui, 149110 阅读, 67 评论, 收藏, 编辑 --iOS多媒体 概览 随 ...

  3. 视频直播APP源码开发iOS音频播放流程

    视频直播APP源码开发iOS音频播放流程 概览 随着移动互联网的发展,如今的手机早已不是打电话.发短信那么简单了,播放音乐.视频.录音.拍照等都是很常用的功能.在iOS中对于多媒体的支持是非常强大的, ...

  4. 《转》iOS音频视频初级开发

    代码改变世界 Posts - 73, Articles - 0, Comments - 1539 Cnblogs Dashboard Logout HOME CONTACT GALLERY RSS K ...

  5. iOS音频播放(一):概述

    (本文转自码农人生) 前言 从事音乐相关的app开发也已经有一段时日了,在这过程中app的播放器几经修改,我也因此对于iOS下的音频播放实现有了一定的研究.写这个 系列的博客目的一方面希望能够抛砖引玉 ...

  6. iOS音频播放 (二):AudioSession 转

    原文出处 :http://msching.github.io/blog/2014/07/08/audio-in-ios-2/ 前言 本篇为<iOS音频播放>系列的第二篇. 在实施前一篇中所 ...

  7. iOS音频播放 (一):概述 转

    2019独角兽企业重金招聘Python工程师标准>>> 基础 先来简单了解一下一些基础的音频知识. 目前我们在计算机上进行音频播放都需要依赖于音频文件,音频文件的生成过程是将声音信息 ...

  8. iOS音频编程之实时语音通信

    http://blog.csdn.net/it_yangjing/article/details/51909991 在CSDN上显示的代码格式不全,在github blog地址显示正确 iOS音频编程 ...

  9. iOS音频播放 (八):NowPlayingCenter和RemoteControl

    转自 码农人生 ChengYin's coding life http://msching.github.io/blog/2014/11/06/audio-in-ios-8/ iOS音频播放 (八): ...

最新文章

  1. 大规模户外环境的具有地标的语义SLAM
  2. 常见消息队列对比(ActiveMQ、ZeroMQ、kafka、RabbitMQ)?
  3. struts2 spring jfreechart 整合
  4. mysql实现表单号:表单编码+时间+表单号
  5. samba 服务器搭建
  6. Python 创建随机mac地址(单播、组播)
  7. as3 与 java_每天学一点Flash(48) As3.0 与 java 通信(1)
  8. python对象传递_Python参数传递对象的引用原理解析
  9. 聊聊Elasticsearch的CachedSupplier
  10. 基于HBR的云上统一备份最佳实践
  11. ipynb--pdf
  12. windows下使用rsync
  13. java虚拟机 安卓系统下载_java虚拟机下载 v5.0 官方免费版
  14. PHP工程师历年企业笔试真题汇总
  15. 通用后台管理系统前端界面Ⅺ——信息列表页(弹窗复用增改、CRUD前端基础实现)
  16. ORA-19511 ANS1017E (RC-50) Session rejected: TCP/IP connection failure
  17. 计算机应用基础补考申请书,院级教改课题申请书-《计算机应用基础》教学.doc...
  18. A股哪家上市公司年报更亮眼?
  19. 央视解说之韩乔生巅峰之作--夏普
  20. input/textarea 输入框点击有边框解决方法

热门文章

  1. req和resp的作用及常用方法
  2. 纯css实现图片带倒影效果的hover翻转特效
  3. Redmi K60 Pro参数配置怎么样 性能怎么样 红米 K60 Pro屏幕参数
  4. Mac OS 下使用OBS 推流到斗鱼直播平台
  5. es中 term多字段查询
  6. 升级到鸿蒙6g够用吗,现在手机6G运行内存够用吗?说出来你别不信,原来这才是真相...
  7. Elasticsearch关联关系
  8. KNN算法原理详解及python代码实现
  9. awk linux 数组,Linux中的awk数组的基本使用方法
  10. 【OpenCV】中copyto()函数的使用方法理解