1. 项目概述与方案选型
做Android音频开发的同学应该都有过这种尴尬:产品需求从"播个MP3"变成"播一套加密的音频课",再变成"要支持后台播放、连续剧级的无缝衔接",MediaPlayer和SoundPool开始各种不给力。音频格式压制方式五花八门,有的资源还在带宽和音质之间反复横跳,解码策略、缓冲策略、声道映射全都要自己掌控。这时候,FFmpeg + OpenSL ES的组合就成了绕不开的答案。
这套方案解决的核心问题非常直接:用FFmpeg做底层解封装和解码,把MP3、AAC、FLAC、WAV、M4A这些格式统一输出成PCM裸流;再用OpenSL ES这个Android原生音频接口,把PCM数据以极低的延迟送进扬声器或耳机。相比直接用MediaPlayer,你得到的是对解码过程的完全控制:可以自己管理缓冲水位、可以定制重采样参数、可以拿到原始PCM数据做可视化或者变声处理、甚至可以接自己的播放器状态机。
适合阅读这篇文章的读者,我理解有两类:一类是已经能跑通MediaPlayer但需要对音频管线有更高掌控力的Android开发者,另一类是准备从零开始搞跨平台音视频引擎、想搞明白FFmpeg在Android上到底怎么落地的同学。文章里我会把从JNI层到Native层的完整链路拆开来讲,重点讲清楚三个东西:FFmpeg解码怎么组织、OpenSL ES播放怎么回调、两者之间的缓冲和同步怎么处理。这些都是我自己在项目里反复调过、踩过坑之后沉淀下来的做法,不是书上抄来的理论。
2. FFmpeg解码链路与核心原理
2.1 解码器的初始化与流信息获取
先把话放在前面:FFmpeg在Android上的集成方式不是本文重点,有太多文章讲过NDK编译FFmpeg静态库或者so库的过程。这里更值得花时间的是解码器的生命周期管理——很多人在这一步就埋下了内存泄漏和崩溃的隐患。
解码一个音频文件,标准的流程是打开输入、查找流信息、定位音频流、打开解码器,对应代码如下:
AVFormatContext *fmt_ctx = NULL; AVCodecContext *codec_ctx = NULL; AVCodec *decoder = NULL; int audio_stream_index = -1; avformat_open_input(&fmt_ctx, file_path, NULL, NULL); avformat_find_stream_info(fmt_ctx, NULL); for (int i = 0; i < fmt_ctx->nb_streams; i++) { if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { audio_stream_index = i; break; } } decoder = avcodec_find_decoder(fmt_ctx->streams[audio_stream_index]->codecpar->codec_id); codec_ctx = avcodec_alloc_context3(decoder); avcodec_parameters_to_context(codec_ctx, fmt_ctx->streams[audio_stream_index]->codecpar); codec_ctx->pkt_timebase = fmt_ctx->streams[audio_stream_index]->time_base; avcodec_open2(codec_ctx, decoder, NULL);这里有个非常关键的点:avcodec_parameters_to_context必须调,而且必须在avcodec_open2之前。新版FFmpeg(4.x之后)里AVCodecContext的extradata等信息不会自动从流里带过来,不手动拷贝的话打开解码器时会直接失败或者解出花屏般的炸音。这个属于最容易踩的坑,我后面会在排查章节再展开。
还有个值得说的细节是pkt_timebase的设置。代码里我从stream->time_base里取到了解码器的时间基,这个时间基在下游做音画同步、进度seek、倍速播放时都至关重要。如果这里漏了,后面调av_rescale_q做时间戳换算时,算出来的播放位置会完全错乱。
2.2 解码循环与Packet处理
初始化完成之后,就是读取、解码、输出PCM的三段式循环。FFmpeg 3.1之后推荐的解码方式是send/receive模型,旧的avcodec_decode_audio4已经废弃。新模型的逻辑是:不管读到什么包,先发给解码器,然后反复从解码器拿帧,直到拿不到为止。
AVPacket *pkt = av_packet_alloc(); AVFrame *frame = av_frame_alloc(); while (av_read_frame(fmt_ctx, pkt) >= 0) { if (pkt->stream_index == audio_stream_index) { int ret = avcodec_send_packet(codec_ctx, pkt); while (ret >= 0) { ret = avcodec_receive_frame(codec_ctx, frame); if (ret == AVERROR(EAGAIN)) { break; } else if (ret < 0) { break; } // 到这里,frame里面就是解码后的音频数据 process_decoded_frame(frame); } } av_packet_unref(pkt); }很多初学者只写了一层avcodec_send_packet然后马上avcodec_receive_frame一次就完事,这样在遇到需要内部缓冲多个packet才能解出一帧的编码格式时(比如某些AAC配置、或者解码器内部有delay的情况),就会丢失音频数据,表现为播放开头少了一截或者声音断续。
另外提醒一下:av_packet_unref必须执行,否则av_read_frame每次返回的packet内部引用计数不降,内存会一路涨上去。我用过的某个项目的线上版本就出过这种问题,播放一个半小时的音频,内存从80MB涨到400MB,最后被系统杀进程。
2.3 重采样环节为什么绕不过去
解码器吐出来的音频格式,跟OpenSL ES要求的格式,几乎永远不是一回事。AAC解码后可能是fltp(浮点planar),MP3可能是s16p(16位整型planar),而OpenSL ES在Android上最通用、最不容易出兼容性问题的是PCM_SAMPLE_FORMAT_S16这种非planar的16位整型。声道数也可能不一致:文件里可能是5.1声道或者单声道,而播放设备通常需要双声道立体声输出。采样率也可能不一样:资源文件可能是44100Hz,但你为了降低CPU占用或统一处理逻辑,希望输出到播放管线时统一成48000Hz。
这些不一致,都要靠FFmpeg的swresample库来处理。设置重采样器的代码大概长这样:
SwrContext *swr = swr_alloc_set_opts( NULL, AV_CH_LAYOUT_STEREO, // 输出声道布局:双声道 AV_SAMPLE_FMT_S16, // 输出采样格式:16位整型 48000, // 输出采样率 codec_ctx->ch_layout, // 输入声道布局 codec_ctx->sample_fmt, // 输入采样格式 codec_ctx->sample_rate, // 输入采样率 0, NULL ); swr_init(swr);每次拿到一帧解码后的frame,调用swr_convert转成目标格式,然后丢给播放缓冲区:
uint8_t *out_buffer[1] = { NULL }; av_samples_alloc(out_buffer, NULL, 2, frame->nb_samples, AV_SAMPLE_FMT_S16, 0); int out_samples = swr_convert( swr, out_buffer, frame->nb_samples, (const uint8_t **)frame->extended_data, frame->nb_samples ); int data_size = av_samples_get_buffer_size(NULL, 2, out_samples, AV_SAMPLE_FMT_S16, 1); // 此时 out_buffer[0] 指向的就是干净的PCM数据,可以写入播放缓冲区这里的frame->nb_samples是解码帧的采样点数,经过重采样之后输出的采样点数可能会变化(44100Hz转48000Hz时数量会变多),但缓冲大小用av_samples_alloc配合nb_samples申请通常够用,因为重采样通常是整数倍率或者相近倍率,输出不会比输入多太多。如果你在非常规场景下做大幅倍率变换,建议按swr_get_out_samples来精确计算输出大小。
3. OpenSL ES播放管线与缓冲区设计
3.1 OpenSL ES对象模型扫盲
OpenSL ES是Android NDK直接暴露的硬件级音频接口,走的是低延迟路径。它跟Java层的AudioTrack不是一回事,AudioTrack底层最终也会走到类似的AudioFlinger服务,但OpenSL ES能让你在Native层直接维护BufferQueue,少走一次JNI调用,延迟表现更好。
很多人在OpenSL ES上碰到的第一个坎是它的对象模型。它把所有东西抽象成对象加接口:先创建Engine对象,从Engine拿到Engine接口;然后通过Engine接口创建OutputMix对象用于最终输出;再通过Engine接口创建AudioPlayer对象,AudioPlayer上挂了缓存队列接口、播放控制接口、音量接口。对象和接口的获取关系如果不熟,写出来的代码会是一堆类型强转和错误检查。
创建Engine的标准代码:
SLObjectItf engine_object = NULL; SLEngineItf engine_engine = NULL; SLEngineOption engine_options[] = { { SL_ENGINEOPTION_THREADSAFE, SL_BOOLEAN_TRUE } }; slCreateEngine(&engine_object, 1, engine_options, 0, NULL, NULL); engine_object->Realize(engine_object, SL_BOOLEAN_FALSE); engine_object->GetInterface(engine_object, SL_IID_ENGINE, &engine_engine);创建AudioPlayer的时候,缓冲队列接口是关键。它的机制是:你创建播放器时指定一个buffer queue,然后不断把PCM数据塞进队列的buffer里,OpenSL ES内部会按固定节奏消费这些buffer。每当一个buffer消费完毕,它会回调你的BufferQueueCallback,你在这个回调里往里继续塞数据。这就是典型的生产者消费者模型。
3.2 缓冲区的尺寸与数量选择
Buffer设计是整个音频播放体验的命门。Buffer太小,CPU稍微一忙来不及填充,就会导致底层消费到空Buffer然后发生underrun,声音出现爆音或卡顿;Buffer太大,延迟就会变高,体现在看电影时音画不同步、打游戏时音效滞后、做K歌类App时演唱者听到自己声音有可感知的延迟。
我的经验值是这样:按48kHz采样率、16位双声道计算,每秒的PCM数据量是48000 × 2 × 2 = 192KB/s。单Buffer大小设成2 * frame_size或者固定2048字节到4096字节比较合适,Buffer数量用4到8个。2048字节大概相当于10.67毫秒的声音,4个Buffer就有约42毫秒的缓冲余量,既能抵抗一定的调度抖动,又不会造成明显的延迟感。
提示:Buffer数量不是越多越好。超过8个之后,总缓冲时间逼近100毫秒,音画不同步的感知会非常明显。我实测过用16个Buffer播放在线视频,嘴型和声音已经明显对不上了。
BufferQueueCallback的典型写法:
void bqPlayerCallback(SLAndroidSimpleBufferQueueItf bq, void *context) { PlayerContext *pctx = (PlayerContext *)context; int bytes_read = fill_pcm_buffer(pctx->pcm_buffer, BUFFER_SIZE); if (bytes_read > 0) { (*bq)->Enqueue(bq, pctx->pcm_buffer, bytes_read); } }这里的fill_pcm_buffer就是从你的解码循环里拿数据的入口。整个架构里,解码线程往一个环形缓冲区里写PCM数据,OpenSL ES的回调从这个环形缓冲区里读数据。一个线程写、一个线程读,必须加锁或者用无锁队列,否则数据竞争会导致声音撕裂或者崩溃。
3.3 解码线程与播放回调的协同
实际工程里,我建议把解码和播放彻底分离:一个解码线程持续从文件读取、解码、重采样、写入共享环形缓冲;OpenSL ES的BufferQueueCallback从环形缓冲读取数据并Enqueue。这样做的原因是:解码耗时不稳定,尤其是遇到I/O瓶颈或复杂编码格式时,单帧解码可能从几十微秒跳到几毫秒。如果让OpenSL ES的回调直接去解码,回调线程会因为耗时过长而错过播放周期,造成大段声音缺失。
环形缓冲的实现可以用std::vector<uint8_t>配合读写指针,也可以直接用一个现成的无锁SPSC队列。我的做法是维护一个定长的环形缓冲,容量设为1到2秒的PCM数据量,读写端各自维护自己的位置,用原子变量控制并发访问。解码线程发现缓冲满时,可以主动等待,或者直接跳过这一帧(跳帧适合流媒体场景,本地文件播放建议等待)。
这里有个细节很多人不问:OpenSL ES的BufferQueueCallback是跑在什么线程上的?其实它是由AudioFlinger内部的播放线程触发的,不是你创建播放器的那个线程。所以千万不要在回调里做任何重的操作,比如加锁时间过长、做日志输出、调JNI。你只能在回调里做最本质的事情——取数据、Enqueue。任何多余动作都会让AudioFlinger的播放线程被拖住,表现就是卡顿和丢帧。
4. 关键代码实现与参数配置
4.1 Native层的初始化代码总览
把前面的零散片段整合成一份完整的初始化流程,大致分五步:
// 1. 初始化FFmpeg解码器并用重采样器转换格式 init_decoder(file_path); // avformat_open_input + avcodec_open2 init_resampler(); // swr_alloc_set_opts + swr_init // 2. 创建OpenSL ES引擎 create_engine(); // 3. 创建输出混合器 create_output_mixer(); // 4. 创建播放器,绑定缓冲队列 create_player(); // 5. 启动解码线程 pthread_create(&decode_thread, NULL, decode_loop, NULL);播放器创建的关键代码段,注意BufferQueue回调的注册时机是在Realize之后、SetPlayState之前:
SLDataLocator_AndroidSimpleBufferQueue loc_bufq = { SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE, BUFFER_COUNT }; SLDataSource audio_src = { &loc_bufq, NULL }; SLDataLocator_OutputMix loc_outmix = { SL_DATALOCATOR_OUTPUTMIX, output_mix_object }; SLDataSink audio_sink = { &loc_outmix, NULL }; const SLInterfaceID ids[] = { SL_IID_ANDROIDSIMPLEBUFFERQUEUE }; const SLboolean req[] = { SL_BOOLEAN_TRUE }; engine_engine->CreateAudioPlayer( engine_engine, &player_object, &audio_src, &audio_sink, 1, ids, req ); player_object->Realize(player_object, SL_BOOLEAN_FALSE); player_object->GetInterface(player_object, SL_IID_PLAY, &player_play); player_object->GetInterface(player_object, SL_IID_ANDROIDSIMPLEBUFFERQUEUE, &player_buffer_queue); player_buffer_queue->RegisterCallback(player_buffer_queue, bqPlayerCallback, this); player_play->SetPlayState(player_play, SL_PLAYSTATE_PLAYING);注意这个细节:audio_src里的pFormat传的是NULL,这意味着OpenSL ES使用默认格式。在音频场景下,默认格式要求PCM数据是16位整型、双声道、采样率跟当前音频输出设备一致。这个默认格式的采样率因设备而异,我在某款老平板上遇到过默认输出采样率是44.1kHz的情况。所以更稳妥的做法是显式指定格式——创建另一个声源结构体,把SL_ANDROID_PCM_SAMPLE_FORMAT_S16、采样率、声道数全部写清楚。
4.2 让重采样器匹配设备采样率
很多人把输出采样率拍脑袋设成44100或者48000就完事了,但这里有个隐蔽的坑:如果OpenSL ES底层设备的输出采样率跟你指定的不一致,系统会做一次隐式的重采样,可能引入额外的延迟和轻微的音质损失。想要彻底绕开,可以查询设备的native采样率。
通过AudioManager的getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)可以在Java层拿到这个值,然后通过JNI传给Native层的重采样器。没有这个值的话,退而求其次用48000也行,绝大多数现代设备的native采样率就是48000。
JNI传参这段很简单:
int sampleRate = Integer.parseInt( audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE) ); nativeSetOutputSampleRate(sampleRate);nativeSetOutputSampleRate内部做的事情就是销毁旧的SwrContext,用新的采样率重新创建。重采样器的swr_init开销并不小,不要在每个解码帧里反复创建。
4.3 Seek与暂停的实现细节
播放器做暂停很容易:player_play->SetPlayState(SL_PLAYSTATE_PAUSED),然后解码线程继续运行让它填充缓冲也行,或者同时暂停解码线程也行,两种策略都可以。但暂停时如果OpenSL ES还在回调消费buffer,你一暂停播放,底层的BufferQueue会一直空着,等恢复播放时可能有几百毫秒的空白期。解决办法是在暂停的同时也暂停解码线程并清空环形缓冲,恢复播放时从头开始填充,这样能保证恢复播放时声音来得干脆。
Seek操作是另一个容易出事的地方。FFmpeg的seek函数是av_seek_frame,但朴素地用av_seek_frame(fmt_ctx, audio_stream_index, target_ts, AVSEEK_FLAG_BACKWARD)会出现一个现象:seek之后解码器内部可能残留旧位置的状态,解出来的第一帧还是seek前的数据。这是因为解码器有内部缓存和delay。
正确的做法是seek之后调用avcodec_flush_buffers(codec_ctx),把解码器的内部缓存清空。同时SwrContext也有类似问题,重采样器内部状态会保留旧数据,所以seek后建议也swr_init一下,或者直接销毁重建。清理顺序不能反:先清FFmpeg解码器,再清SwrContext,最后清环形缓冲和OpenSL ES的队列。
BufferQueue的清空方式是循环Dequeue直到返回错误码:
while ((*player_buffer_queue)->Dequeue(player_buffer_queue, &buf) == SL_RESULT_SUCCESS) { // 什么都不做,就是清空 }4.4 文件末尾的收尾处理
解码循环读到av_read_frame返回AVERROR_EOF之后,还要再次调用avcodec_send_packet传一个NULL packet,把解码器内部缓冲的剩余帧全部吐出来。这个动作叫flush decoder。很多人的播放器在结尾会少几百毫秒声音,就是因为忘了这一步。
avcodec_send_packet(codec_ctx, NULL); while (ret >= 0) { ret = avcodec_receive_frame(codec_ctx, frame); if (ret < 0) break; process_decoded_frame(frame); }flush完成后,等OpenSL ES把环形缓冲里的最后一段数据全部消费掉,再回调播放完成事件。判断播放完成的标志是:环形缓冲为空,且解码线程已经退出,且BufferQueue里没有Pending的Buffer。光靠解码线程退出判断是不够的,因为缓冲里的数据可能还有1秒钟没播完。
5. 常见问题与排查技巧实录
5.1 声音卡顿、断续到没法听
这类问题90%出在生产者消费者模型失衡上。先用最简单的办法定位:在fill_pcm_buffer里统计环形缓冲的写入速度和读取速度。如果写入端经常出现缓冲满等待,或者回调端经常读不到数据,就能确定瓶颈所在。
我这个项目里遇到过一个非常典型的案例:解码线程用了sched_yield()来做缓冲满时的等待,结果在四核设备上,解码线程频繁让出CPU,反而被线程调度器晾在一边,导致填充不及时。换成条件变量等待之后,问题彻底消失。所以不要觉得忙等就是无辜的,线程调度策略对实时性影响极大。
另一个常见原因是解码线程和BufferQueue回调线程抢锁。加锁范围内的代码越少越好,我见过有人把memcpy大片拷贝都放锁里,只要回调线程碰巧跟解码线程同时触发,锁等待时间就直接击穿播放周期。
5.2 播放出来的声音变调或变速
变调不是重采样器的问题,而是采样率配置错了。排查思路是打印三个采样率:解码器输出的原始采样率、重采样器的输出采样率、OpenSL ES创建AudioPlayer时指定的采样率。三者关系必须是:重采样器输出 = AudioPlayer指定采样率 = 设备理想输出。
如果重采样器输出是44100,而AudioPlayer指定的也是44100,但设备native是48000,这时候系统隐式重采样,一般不会变调只是增加延迟。真正变调的是:重采样器设置成了48000,但AudioPlayer的buffer里给的数据实际是44100的。这种错位通常是因为解码帧的frame->sample_rate不是你想当然的值,某些格式(比如每个MP3帧可以单独声明采样率)会坑你。
5.3 内存持续上涨直到崩溃
前面提过packet没有unref会导致内存上涨。其实还有两个隐蔽点。一个是av_frame_unref漏调:avcodec_receive_frame返回成功拿到的frame是带引用计数的,处理完了必须释放,否则解码器内部缓冲会无限膨胀。另一个是SwrContext重采样时用av_samples_alloc申请的输出缓冲,如果每次解码帧都重新申请而不释放,一样涨。
我的习惯是复用输出缓冲:在重采样器创建后,一次性按最大frame->nb_samples申请好缓冲,后续解码帧只要数量不超过上限就直接复用。
5.4 不同Android版本的兼容性问题
OpenSL ES在Android API 21之前可用性很差,21之后的系统实现相对稳定。如果你还支持Android 5.0以下的设备,建议直接降级用Java层AudioTrack方案兜底。另外在Android 8.0之后,某些OEM设备上OpenSL ES的BufferQueue行为有细微差异,比如回调触发频率、underrun后是否能自动恢复。我踩过的一个坑是某品牌的电视盒子,在underrun之后BufferQueue就再也不回调了,播放直接停住。最终的规避方案是在回调里检查时间戳,如果两次回调间隔超过200毫秒,认为发生underrun,主动清空队列并重新启动播放状态。
针对这个问题的保护代码:
if (now_ms - last_callback_ms > 200) { // 可能发生了underrun,重启播放器 (*player_play)->SetPlayState(player_play, SL_PLAYSTATE_STOPPED); while ((*player_buffer_queue)->Dequeue(player_buffer_queue, &buf) == SL_RESULT_SUCCESS) {} (*player_play)->SetPlayState(player_play, SL_PLAYSTATE_PLAYING); } last_callback_ms = now_ms;这段逻辑虽然暴力,但在兼容性场景下非常有效。注意SetPlayState不能反复进入同一个状态,否则部分实现会直接返回错误。
5.5 常见问题速查表
| 表现 | 根因 | 解决方案 |
|---|---|---|
| 播放开头声音缺失 | 忘了flush解码器 | avcodec_send_packet传NULL吐出残余帧 |
| 声音断续卡顿 | 缓冲大小或数量不匹配 | 调整Buffer数量到4至8个,大小2048至4096字节 |
| 播放变调/变速 | 重采样输出与播放器配置采样率不一致 | 统一三者采样率,打印核对 |
| 内存持续上涨 | 忘记unref packet/frame | 每次处理完毕调用对应unref |
| 暂停后恢复有空白 | 队列未清空、状态残留 | 暂停时清空BufferQueue |
| 长时间播放后停止响应 | underrun后回调不恢复 | 检测回调间隔,重启播放状态 |
| 打开解码器失败 | 未拷贝codecpar参数 | 调用avcodec_parameters_to_context |
| 多声道文件声音异常 | 声道布局映射错误 | 重采样器显式指定AV_CH_LAYOUT_STEREO |
5.6 性能优化的几个实测结论
如果对CPU占用有要求,优先做三件事。第一,FFmpeg编译时只保留你真正用到的解码器,去掉所有编码器、协议和设备模块,这个能省掉很大一部分二进制体积和初始化开销。我有个项目只做AAC和MP3解码,裁剪后so体积从19MB降到5MB左右,解码初始化时间也明显变快。第二,全局只创建一个SwrContext,不要每个音频文件都重建;重采样器的初始化比较昂贵,包含查表、状态分配等操作。第三,把解码线程优先级调高,setpriority(PRIO_PROCESS, 0, ANDROID_PRIORITY_AUDIO)这种级别,能减少被其他普通线程抢占的机会。
还有一点针对低延迟场景:如果未来要扩展到播放延迟极敏感的场景,比如K歌实时监听或游戏音效,建议关注OpenSL ES的SL_ANDROID_KEY_STREAM_TYPE和SL_ANDROIDKEY_PERFORMANCE_MODE的配置。性能模式设为SL_ANDROID_PERFORMANCE_MODE_LOW_LATENCY时,系统会绕过一些中间处理环节,将音频延迟压到20毫秒以内。但这个模式下BufferQueue的Buffer数量和大小限制会更严格,调试起来也更麻烦,所以只在真正需要低延迟时才开。
6. 结束前的最后一点体会
这套FFmpeg + OpenSL ES的方案,我前前后后在三个项目里做过完整的落地,第一次花了两周才把所有坑趟平,第二次用之前沉淀的代码只花了半天就接好了一个新的音频模块。回头看,整个方案最核心的其实不是某一行的API调用,而是那个"解码线程 + 环形缓冲 + 回调消费"的架构骨架。骨架搭对了,后面换解码器、换播放接口、加倍速、加变声,都只是往里填肉的事。骨架搭错了,后面每一步都是痛苦。
另外,调试音频问题时一定要善用日志和桩点。我在解码循环和回调里分别打了时间戳日志,用logcat -s AudioDemo过滤,就能看到每一帧的处理耗时和每次回调的间隔。有一次用户反馈某台机型声音延迟明显,我直接用日志拉出时间线,发现是那台设备上BufferQueue回调周期从10毫秒被拉长到了30毫秒,属于系统底层行为差异,用了调整缓冲大小来适配解决。
最后再分享一个小技巧:开发阶段在你的Native层加一个dump_pcm_to_file的开关,把重采样后的PCM数据直接写成文件。遇到"播放出来声音不对"的情况,先用Audacity打开这个PCM文件看波形,能快速区分是你处理链路的错还是OpenSL ES的错。这个习惯帮我省下了无数怀疑人生的时间。