干过几年音视频开发的人应该都有体会:这一块的知识体系跟普通业务开发完全不是一回事。业务开发你只需要懂Activity生命周期、懂 RecyclerView 的缓存机制,基本就能应付大部分需求了。但音视频不一样,它要求你同时掌握操作系统原理、编解码标准、图形渲染管线、性能优化策略,还要对 Native 层的内存管理有实际感知。更麻烦的是,Android 生态在这个领域是碎片的:Google 官方维护 ExoPlayer,但直播行业大量自研播放器;MediaCodec 是硬件编解码的抽象层,但各家 ROM 的底层实现差异能让你调试到怀疑人生。

很多人在面试前花大量时间背题,结果被问到“为什么 MediaCodec 需要 input surface 而不是直接传 ByteBuffer”或者“视频转动方向时 rotation 信息到底存在哪里”就卡住了。这些问题的背后不是单一知识点,而是整个音视频链路的数据流和容器格式逻辑。

这篇内容我按实际面试中出现的频率和深度,把 Android 音视频相关的常见面试题做一个系统拆解。重点不是给你标准答案,而是帮你理解每个问题背后的技术逻辑。搞清楚了这些,面试时无论问题怎么变形,你都能接住。

1. Android 音视频开发的知识框架:面试官到底在考什么

先理清一个宏观问题。音视频方向的面试题看起来五花八门,但本质上都围绕几条主线展开,面试官真正考察的是你对“数据从采集到渲染”这条完整链路的理解程度。

1.1 音视频链路的核心环节

一个典型的音视频处理流程可以拆成六个环节:采集、预处理、编码、封装、传输/存储、解码渲染。Android 平台上每个环节都有对应的 API 和最佳实践:

  • 采集 :CameraX / Camera2 负责视频帧采集,AudioRecord 负责 PCM 音频采集。
  • 预处理 :包括缩放、旋转、裁剪、美颜、降噪等操作,往往需要借助 SurfaceTexture、GPU 滤镜或 RenderScript。
  • 编码 :MediaCodec 是统一入口,底层是硬件编解码器,输出 H.264/H.265 视频流和 AAC 音频流。
  • 封装 :MediaMuxer 负责把编码后的流写入 MP4 等容器格式。
  • 传输/存储 :直播场景走 RTMP/RTSP 推流,点播场景可能是本地文件或网络流。
  • 解码渲染 :MediaCodec 解码 + SurfaceView/TextureView 渲染,或者使用 ExoPlayer 这样的高层封装。

面试官问你任何一个环节的问题,本质上都是在验证你对这条链路的掌握程度。所以复习的时候不要孤立地去背 API,要把每个 API 放在这条链路里理解它的位置和职责。

1.2 面试官考察的三个层次

根据我的经验,音视频面试题基本分三个层次:

第一层:API 使用层。 比如 MediaRecorder 和 MediaCodec 的区别、ExoPlayer 和 MediaPlayer 的区别、如何切换播放清晰度。这类问题考察的是你有没有实际做过项目。

第二层:原理理解层。 比如为什么 H.264 编码后的数据需要分 I 帧和 P 帧、音视频同步是怎么实现的、MediaCodec 的 input surface 和 input buffer 有什么区别。这类问题考察你是否理解底层机制。

第三层:性能优化层。 比如如何降低首帧延迟、如何避免音画不同步、如何优化内存占用、如何处理弱网下的播放卡顿。这类问题考察你是否有线上问题排查经验。

绝大多数候选人死在第二层——API 会用,但说不出原理。这篇文章我会把三个层次的内容都覆盖到,但重心放在第二层和第三层。

1.3 高频考点分布

我统计了近年来的面试反馈,Android 音视频方向的高频考点主要集中在以下几个模块:

  • 播放器架构与选型(ExoPlayer vs MediaPlayer vs 自研)
  • MediaCodec 的同步/异步模式和数据流
  • 音视频同步机制(A/V sync)
  • 硬解码与软解码的差异与选择
  • SurfaceView、TextureView、SurfaceFlinger 的渲染链路
  • Camera 采集与编码的联动
  • 音频焦点、回声消除、噪声抑制
  • 弱网优化、首帧优化、卡顿优化
  • H.264/H.265、AAC 等编码标准的核心概念

接下来逐个模块展开,每个模块我都会给出典型的面试题、解题思路和容易被忽略的细节。

2. 播放器架构与选型:从 MediaPlayer 到 ExoPlayer 再到自研

播放器是音视频开发最常碰到的场景,也是面试中必聊的话题。这一节我们把播放器的演进逻辑和选型依据讲透。

2.1 MediaPlayer 的封装层次与局限

MediaPlayer 是 Android 最早提供的多媒体播放 API,它本身是一个状态机:Idle、Initialized、Prepared、Started、Paused、Stopped、PlaybackCompleted、Error、End。每个状态之间都有合法的转换路径,调用错误的方法会在状态机校验时抛出 IllegalStateException。

这个状态机是面试里常考的基础点。比如:

面试题:MediaPlayer 的生命周期状态有哪些?为什么必须调用 prepare() 之后才能 start()?

这个问题的核心在于 MediaPlayer 是基于异步准备的。prepare() 和 prepareAsync() 的区别是:前者阻塞当前线程直到媒体源准备完成,后者通过 OnPreparedListener 回调通知准备结果。因为媒体源的解析可能涉及网络请求、文件读取、格式探测等耗时操作,如果都放在主线程同步执行,必然导致 ANR,所以才需要状态机来约束调用顺序。

MediaPlayer 的局限 也是面试官喜欢追问的点:

  • 支持的封装格式和编码格式依赖系统底层框架,不同 ROM 表现不一致
  • 扩展性差,无法自定义数据源(比如从加密文件读取、从内存读取)
  • 对 HLS、DASH 等流媒体协议支持不完整
  • 无法精细控制缓冲策略、渲染时机
  • 音频和视频的同步策略由底层固定,开发者无法干预

这些局限就是 ExoPlayer 出现的原因。

2.2 ExoPlayer 的模块化设计

ExoPlayer 是 Google 开源的媒体播放器库,它不是基于 MediaPlayer 的封装,而是从零构建的一套播放框架。它的核心优势在于模块化:DataSource、Extractor、Loader、TrackSelector、Renderers 这些组件都是可替换的接口。

面试题:ExoPlayer 相比 MediaPlayer 有哪些优势?它的架构是如何支撑这些优势的?

ExoPlayer 的优势可以归纳为四点:

  1. 可定制性 :每个环节都是抽象接口,你可以自定义 DataSource 来实现从内存、加密文件、自定义协议读取数据;可以自定义 Renderer 来接入自己的音频处理逻辑。
  2. 自适应流支持 :内置 HLS、DASH、SmoothStreaming 协议的支持,并能根据网络带宽自动切换码率。这是 MediaPlayer 不具备的。
  3. 统一的行为一致性 :因为播放逻辑在应用层实现,不依赖系统 ROM 的 MediaPlayer 实现,所以跨设备行为一致。
  4. 可组合性 :音频渲染可以和视频渲染解耦,支持后台播放、纯音频播放等场景。

但这里有一个很重要的细节:ExoPlayer 的底层解码仍然依赖 MediaCodec,它只是把播放控制逻辑(数据读取、缓冲、轨道选择、同步)全部上提到应用层。所以 ExoPlayer 的自定义能力是有边界的——编解码本身还是走系统硬件或软件解码器。

2.3 自研播放器的动机与关键模块

面试中如果聊到自研播放器,很多候选人会直接说“因为 ExoPlayer 不够用”,但说不出具体哪里不够用。这里我列几个实际推动自研的典型场景:

  • 低延迟直播 :ExoPlayer 的缓冲策略偏保守,为了流畅会积压大量数据,导致延迟越来越高。直播场景需要精细控制缓冲区水位,甚至做到零缓冲起播(gop 缓存 + 秒开)。
  • 特殊协议 :比如基于 UDP 的自定义推流协议、加密的私有传输格式,ExoPlayer 的扩展接口不足以高效实现。
  • 深度播放器内核 :需要直接操作解码器输出做后处理(比如超分、HDR 转换),或者需要多路流同时解码(多视角直播)。
  • 精细化 QoS :需要细粒度地监控播放进度、缓冲时长、解码耗时、丢帧数据,这些在 ExoPlayer 里能拿到,但拿不全。

自研播放器的核心模块包括:网络加载模块(支持网络切换、重试、预加载)、解封装模块(demux)、解码调制模块(codec 管理、硬软解切换)、渲染模块(音视频分别渲染)、同步模块(av sync 策略)。任何一个模块的成熟度都会影响最终体验,所以一般团队不轻易自研。

面试中如果被问到“你如何设计一个播放器”,答案的层次应该是:

  1. 先规划模块划分:数据源、解封装、解码、渲染、同步、控制层
  2. 再讨论每层的关键接口设计(比如数据源层的 read 语义、渲染层的 vsync 对齐)
  3. 然后讨论跨层交互:缓冲状态如何上抛、解码器异常如何重试、seek 时如何刷新管道
  4. 最后是扩展性:如何接入新的封装格式、新的解码器

2.4 倍速播放的实现原理

倍速播放是一个高频考察点,它涉及播放器内核的多个模块。

最简单的实现方式是在解码后、渲染前对音频做重采样改变播放速度,同时保持音调不变(时间伸缩)。Android 上 SoundPool 和 ExoPlayer 都支持变速变调,底层用的算法是 Sonic 或 SoundTouch。

视频的倍速播放相对简单:要么丢帧(2 倍速时每 2 帧显示 1 帧),要么通过修改渲染时间戳来加速播放,但需要保证音频连续不卡顿。所以问题往往会聚焦到音画同步:

面试题:实现 2 倍速播放时,音视频同步是怎么处理的?

核心是音频时钟驱动。2 倍速播放时,音频播放的时钟比真实时间快 2 倍,视频渲染需要跟随音频时钟调整刷新节奏。比如每秒要渲染 60 帧的视频,2 倍速下需要渲染 120 帧才能跟上音频节奏,但实际可能没有这么多帧,这时就需要重复显示上一帧(frame repeating)。ExoPlayer 的 PlaybackParameters 就是通过调整音频的播放速率和音频时钟的推进速度来实现整体倍速。

3. MediaCodec 深度解析:编解码链路的灵魂

MediaCodec 是 Android 音视频开发绕不开的底层 API。面试中这一节内容极其重要,因为几乎所有涉及编解码的深入问题最后都会落到 MediaCodec 的行为逻辑上。

3.1 MediaCodec 的同步模式与异步模式

MediaCodec 从 API 21 开始支持异步模式,API 23 开始要求必须处理 error 回调。两种模式的核心区别在于缓冲区管理方式。

同步模式 :调用 dequeueInputBuffer(timeout) 从输入缓冲区队列取一个空 buffer,填充数据后再调用 queueInputBuffer 交还给编解码器;然后调用 dequeueOutputBuffer(timeout) 获取编码/解码后的数据。整个过程是主动拉取,线程需要自己管理阻塞和超时。

异步模式 :注册 OnInputBufferAvailableListener 和 OnOutputBufferAvailableListener 回调,编解码器在缓冲区可用时主动通知。开发者不需要阻塞等待,而是由回调驱动执行。

面试题:同步模式和异步模式各有什么优缺点?实际项目你选择哪种?

关键点在于:异步模式可以让编解码器内部更灵活地管理缓冲区复用,减少主线程阻塞,但回调线程和调用线程的切换会引入线程安全问题。同步模式实现简单、逻辑可控,但多路编解码时容易因为等待超时导致 CPU 空转。

实际项目里,纯音频编码或单路视频编码用同步模式完全够;但如果要同时做视频硬编、硬解、预览,或者做 OpenGL 滤镜处理后再编码,建议用异步模式减少线程阻塞时间。

3.2 input buffer 与 input surface 的本质区别

这是一个很多候选人答不上来的细节题。

MediaCodec 的输入端有两种方式:ByteBuffer 模式和 Surface 模式。

ByteBuffer 模式是你把像素数据(YUV/RGB)拷贝到一个 ByteBuffer 中,再 queue 给编码器。这个拷贝过程很耗时,而且需要自己管理 YUV 的格式转换(Camera 出来的是 YUV_420_888,编码器要的是 NV12 或 I420)。

Surface 模式是把一个 Surface 传给 MediaCodec 的 createInputSurface() ,然后通过 Canvas 或 OpenGL ES 绘制到这个 Surface 上。编码器直接从 Surface 中拉取数据,绕过了拷贝过程。

面试题:为什么视频编码推荐使用 input surface 而不是 ByteBuffer?

核心是性能。Surface 模式下,数据从产生到编码器的路径是 GPU 到硬件编码器的零拷贝通道(依赖厂商实现,但相比 ByteBuffer 的 CPU 拷贝开销小得多)。ByteBuffer 模式意味着每一帧图像都要先从 SurfaceFlinger 合成结果中读回 CPU,再拷贝到编码器的输入缓冲,性能开销大,而且编码器的输入缓冲大小有限,高分辨率高帧率下很容易成为瓶颈。

另一个重要区别是:Surface 模式天然支持 GPU 处理后的输出,比如你用 OpenGL 做了美颜、滤镜效果,可以直接渲染到这个输入 Surface 上,编码器拿到的是处理后的图像。如果用 ByteBuffer,你需要在 GPU 处理完成后调用 glReadPixels 把像素读回内存,这是一个极其昂贵的操作。

3.3 MediaCodec 的缓冲区状态流转

MediaCodec 的 buffer 状态可以从两个角度理解:codec 内部状态和调用者视角。

从调用者视角看,每个 buffer 可能处于以下几种状态:

  • Uninitialized :未初始化
  • Dequeued (input) :已从输入队列取出,等待填充数据
  • Queued (input) :已填充数据并交还 codec,等待编码
  • Dequeued (output) :已从输出队列取出,包含有效数据,等待处理
  • Queued (output) :已处理完毕并交还 codec,等待复用

理解这个流转对排查问题很有用。比如编码器输出异常时,经常是 input 队列和 output 队列的资源没有正确释放,导致 codec 卡死。规范的做法是:每次用完 output buffer 后必须调用 releaseOutputBuffer ,否则 codec 内部输出缓冲区耗尽, dequeueOutputBuffer 会一直返回 INFO_TRY_AGAIN_LATER。

3.4 MediaCodec 的编解码超时与异常处理

实际开发中,MediaCodec 的异常主要集中在几个场景:

  • input 超时 : dequeueInputBuffer(timeout) 一直超时,通常是预览/采集线程卡顿,没有及时把数据送入队列。
  • output 超时 : dequeueOutputBuffer(timeout) 一直拿不到输出,可能是编码器配置参数不合法,如分辨率不是编码器支持的范围。
  • ERROR_CODEC :编码器内部错误,常见原因是输入数据格式不对、编码规格不支持。

排查思路一般是:先把所有 getOutputFormat() 的返回值打出来,确认编码器实际生成的编码参数;然后检查输入 buffer 的 size 是否超过编码器限制;最后看是否在 onError 回调中做了合理的恢复——一般建议直接 release 重新创建 codec,而不是试图恢复。

3.5 硬解码与软解码的选择逻辑

这一块面试频率非常高。

面试题:硬解码和软解码的区别是什么?实际项目中你如何选择?

硬解码是 SoC 内部的专用硬件模块(如 Qualcomm 的 Venus、MediaTek 的 Video Processor)直接对码流解码,优点是功耗低、速度快、支持高分辨率高帧率;缺点是硬件实现各异,对异常码流的容错能力弱,有的编码格式不支持。

软解码是 CPU 上的软件解码器通过通用计算解码,兼容性最好,任何格式都能解,但 CPU 占用高、耗电快。

实际选择逻辑是:

  1. 优先尝试硬解,因为主流设备的硬解覆盖了绝大多数编码格式
  2. 硬解失败或设备不支持时,fallback 到软解
  3. 直播低延迟场景,软解有时更可控,因为硬解的模式切换延迟不可控
  4. 后台播放音频时,肯定走软解,因为不需要视频画面

还有一点是面试官常追问的: 如何判断硬解是否支持某个格式? 答案是 MediaCodecList 和 CodecCapabilities。通过遍历 MediaCodecList,找到匹配 MIME 类型的 codec,再检查 CodecCapabilities 里的 isFormatSupported 或直接尝试 createByCodecName。

4. 视频渲染链路:SurfaceView、TextureView 与 SurfaceFlinger

渲染是音视频链路中容易被忽略但面试会突然深挖的模块。很多候选人知道 SurfaceView 比 TextureView 性能好,但说不出为什么。

4.1 SurfaceView 与 TextureView 的底层差异

面试题:为什么视频播放推荐用 SurfaceView 而不是 TextureView?

SurfaceView 从名字看是 View,但它的渲染内容不在 View 体系中。SurfaceView 内部包含一个独立的 Surface,这个 Surface 在 Window 中是一个独立的图层(Layer),由 SurfaceFlinger 直接合成到屏幕上。SurfaceView 的 View 部分只负责提供一个“挖洞”的窗口位置。

TextureView 则不同,它不是一个独立图层,而是把 SurfaceTexture 的内容作为纹理贴到 View 的绘制体系中,由 View 的 onDraw 流程渲染出来。

这个差异带来的直接结果:

  • SurfaceView 的渲染走的是独立合成层,不经过 View 的 onDraw,几乎没有额外开销;TextureView 需要经过 View 绘制流程,性能开销大。
  • SurfaceView 可以在子线程直接往 Surface 上画数据,和 UI 线程解耦;TextureView 必须在 UI 线程的绘制流程中更新。
  • SurfaceView 的层级是固定的,你没法给它做 CSS 动画、旋转、透明度效果(如果做了,底层会做兼容处理,但性能会下降);TextureView 是普通 View,可以随意做变换。

但 TextureView 在一种场景下是更好的选择:需要在视频画面上叠加复杂的 UI 效果,或者需要对视频帧做后处理(比如截图后的美化)。SurfaceView 因为独立图层,截图时必须从 Surface 中 readPixels ,或者用 PixelCopy,比较麻烦。

4.2 SurfaceFlinger 的合成机制对渲染的影响

要理解 Surface 渲染的性能差异,就得知道 SurfaceFlinger 做了什么。

SurfaceFlinger 是 Android 的合成系统,它负责把所有应用的 Surface 合成到屏幕的显示缓冲区。每个 Surface 可以关联一个 BufferQueue,生产方是应用的渲染线程,消费方是 SurfaceFlinger。

视频播放时,视频解码器输出帧后,数据经过 releaseOutputBuffer(bufferIndex, timestamp) 传入 Surface 的 BufferQueue,SurfaceFlinger 在每次 vsync 信号到来时合成所有图层并输出到屏幕。

这里的关键是: vsync 节奏决定了视频帧的显示时机 。视频的帧率(如 24fps、30fps)和屏幕刷新率(如 60Hz、90Hz、120Hz)不一定匹配。SurfaceFlinger 会在每个 vsync 点选择最新的视频帧显示,如果视频帧率低于刷新率,那部分 vsync 会重复显示上一帧,这就会产生“掉帧”或“卡顿”的感知。

所以 ExoPlayer 里有个重要优化是 setVideoFrameMetadataListener ,用于获取每一帧的 presentationTimeUs,通过回调感知实际渲染时间,然后调整后续帧的提交时机。

4.3 视频旋转与镜像的坑

这个问题在实际开发中经常踩,面试也常考。

面试题:竖屏拍摄的视频在竖屏手机上播放,为什么还是会横着?怎么处理?

核心原因是视频帧本身只有宽度和高度两个维度,旋转信息存在容器层或编码层 SEI 中。对 MP4 来说,旋转信息存在 moov box 的 tkhd (Track Header Box)的 matrix 字段中;对 H.264 裸流来说,可能存在于 SEI 的 display orientation 信息中。

播放器必须读取这个旋转信息,对解码后的画面做旋转才能正确显示。ExoPlayer 的做法是:解码后通过 VideoFrameMetadataListener 拿到旋转角度,然后用变换矩阵旋转画面。MediaPlayer 内部也会处理 MediaExtractor 读出的 rotation 信息。

但如果你用 MediaCodec 直接解码 MP4 文件,解码器输出的 YUV 帧是包含旋转前的数据的,你需要自己读取 rotation 再做旋转,否则看到的就是横的。

镜像则是另一个维度:前置摄像头采集的视频默认做了镜面翻转(selfie 模式),这个镜像信息不会写入编码数据,而是采集端的特殊处理。保存视频时是否需要镜像,取决于产品需求。

5. 采集与编码实战:Camera2/CameraX 与 MediaCodec 的协同

这一节讲采集端到编码端的关键细节,面试题往往围绕“如何把 Camera 的数据送到编码器”。

5.1 Camera 预览与编码的两种数据流

Camera 采集场景有两种典型的数据流:

  • 预览流 :Camera 采集的帧直接送到 SurfaceView/TextureView 显示,用于给用户看
  • 编码流 :Camera 采集的帧送到 MediaCodec 的输入 Surface,用于录制或推流

同一个 Camera 设备可以同时输出到多个 Surface 吗?可以。Camera2 的 CaptureRequest 支持设置多个 target Surface。但要注意:不同的 Surface 可能有不同的分辨率(预览 1920x1080,编码 1280x720)、不同的帧率要求。

关键问题是: Camera 输出到 Surface 的帧格式是什么?

Camera2 管道输出的格式是 SurfaceTexture 中的纹理格式(通常是 ImageFormat.PRIVATE ),对应用层来说是 OES 纹理。MediaCodec 输入 Surface 也能接受 OES 纹理。所以最直接的方案是让 Camera 直接输出到编码器的 Surface,不走应用层拷贝。

如果要加滤镜、美颜或做旋转,就需要在中间插入一个 GPU 处理环节:Camera 先输出到 SurfaceTexture,然后通过 OpenGL ES 将纹理渲染到自定义的 FBO(帧缓冲对象),做滤镜处理后,再渲染到 MediaCodec 的输入 Surface。

5.2 编码参数的选择逻辑

编码参数直接影响视频质量、码率和兼容性。面试中会问如何选择合理的参数,下面是实际项目中的参考值:

参数 推荐值 说明
编码格式 H.264 Baseline/Main Profile 兼容性最好
分辨率 与原视频比例一致或对齐 16:2 避免拉伸变形
帧率 24/30/60 直播建议 30fps 以内
码率 720p 建议 2~4 Mbps 1080p 建议 5~8 Mbps
I 帧间隔 1~2 秒(如 30fps 下 GOP=30/60) 影响 seek 和首帧
码控模式 CBR(直播)/ VBR(点播) 直播要控制码率峰值

码率选择是难点。码率太低会糊,太高会卡网络、占存储。一个经验公式: 码率 ≈ 分辨率宽 × 高 × 帧率 × 运动复杂度系数 / 压缩比 。实际产品里一般通过对比测试确定。直播场景因为要控制延迟和带宽波动,通常选 CBR 并设置 buffer size 为码率的 1~2 倍,让编码器有缓冲调节空间。

5.3 视频旋转在编码链路中的位置

前置摄像头竖屏录制时,如果直接把 Camera 输出编码,预览是正常的,但视频文件播放时是横的。原因在于:Camera 的传感器通常固定为横向,即使你把手机竖拿,传感器输出的数据方向也是横向的。

解决方案有三个层次:

  1. 在 Camera 采集时,通过 CaptureRequest.SCALER_CROP_REGION 和 SENSOR_ORIENTATION 计算出旋转角度,在拍照或录制时对画面做旋转。
  2. GPU 端在采集后将纹理旋转 90/180/270 度再给编码器,录出来的文件就是正的。
  3. 编码时把 rotation 写入 MP4 的 metadata,播放器根据 rotation 显示,文件数据保持横向,但播放时自动转正。

方案 3 最省时省力,但缺点是:如果你想对视频做逐帧处理(比如云端转码),rotation 存在 metadata 中会导致处理结果不直观。很多视频编辑 App 是方案 2 和 3 结合的。

5.4 音频采集与编码的配合

音频采集走 AudioRecord,采集到的 PCM 数据一般用 MediaCodec 编码成 AAC。这里有两个常见坑:

第一个是采样率和声道数不匹配。手机麦克风常用的采样率是 44100Hz 或 48000Hz,声道数通常是 1(麦克风)或 2(立体声)。AAC 编码器支持的 profile(LC, HE-AAC, HE-AACv2)对采样率和声道数有要求。比如 HE-AAC 一般要求采样率 44.1kHz 或 48kHz,超过就不支持。

第二个是 getMinBufferSize() 的返回值。很多人在编码音频时没有正确读取最小缓冲区大小,导致 AudioRecord 读取时产生溢出或连续性错误。实际的读取循环要注意:每次 read 的数据量应该小于等于 buffer size,否则会丢数据。另外,定时读取的间隔要跟 buffer 的填充速度匹配——比如每次读 2048 字节,在 44.1kHz 单声道 16bit 下对应约 23ms 的音频,而编码一帧 AAC 恰好需要 1024 个采样(约 23ms),所以一次读取刚好对应一次编码,这种对齐能让 pipeline 更稳定。

6. 音视频同步:播放器与录制器都必须面对的难题

音视频同步是面试中的重头戏,几乎每个音视频岗位都会问。

6.1 音视频同步的三种策略

播放器的同步首先取决于以哪个时钟为基准:

  • 以音频时钟为主 :音频数据连续、播放均匀,不需要跳过帧,所以一般以音频的播放进度为基准时钟,视频向音频对齐。
  • 以视频时钟为主 :视频帧间隔不均匀、受 vsync 影响,不推荐作为主时钟。
  • 以系统时钟为主 :以 System.nanoTime() 为统一基准,音视频各自向系统时钟对齐。因为系统时钟不准的话,音频和视频的漂移会累积,这种方案较少用。

实际软件中,最流行的是音频时钟为主:视频帧根据音频时钟计算目标显示时间,如果视频帧晚了就丢帧追进度,如果早了就重复显示等待。

6.2 MediaCodec 输出时间戳与渲染时间的对齐

面试题:视频帧的渲染时间是谁决定的?

解码器输出每一帧时携带一个 presentationTimeUs (PTS),这是编码端写入的时间戳。播放器拿到 PTS 后,根据主时钟计算这一帧应该何时显示。

但 Android 上有个特殊问题:渲染时间不完全是播放器说了算,SurfaceFlinger 的 vsync 会决定最终上屏时间。所以 ExoPlayer 做了这样的设计:所有帧先按 PTS 排队,在距离目标显示时间还有一段时间(比如提前 30ms)时,将帧 releaseOutputBuffer 给 Surface,SurfaceFlinger 在下一个 vsync 到来时统一合成显示。

如果每帧都在距离目标显示时间很接近时才提交,那么 vsync 的相位误差会导致部分帧晚一两个 vsync 显示,造成周期性卡顿。这需要播放器根据帧率动态调整提前量。

6.3 录制场景的音视频同步

录制和播放的同步逻辑不一样,因为录制时编码器接收音频和视频的时间必须是对齐的。

录制链路常见的同步方案是:音频和视频分别采集,分别编码,在 Muxer 写入 MP4 时用 writeSampleData 的 presentationTimeUs 来对齐。

问题在于:音频的采集时间戳来自 AudioRecord.getTimestamp() 或读取时用 System.nanoTime() 推算;视频的时间戳来自 Camera 帧回调或 SurfaceTexture 的 getTimestamp() (这个时间是 CLOCK_MONOTONIC 纳秒)。两者基准统一后,需要做一个偏移换算。

有一个坑是:不同厂商设备的 SurfaceTexture 时间戳不一定与 System.nanoTime() 完全同源,所以有的项目会在采集到第一帧视频时记录起始时间差,之后所有时间戳都减去这个差值,强行对齐。

6.4 A/V 不同步的排查方法

面试中追问“如果发现录出来的视频音画不同步,你怎么排查?”答案要有层次:

  1. 先确认是录制时不同步还是播放时不同步:把录好的文件用播放器反复 seek,观察偏移是否固定。如果固定,多半是容器层时间戳错位;如果不固定且越发严重,多半是采集时钟漂移。
  2. 检查采集端:音视频各自的采样率、读取间隔是否稳定,是否有丢帧。音频的 read 循环如果错过 deadline 会导致时间跳跃。
  3. 检查编码端:编码器配置的 KEY_MAX_INPUT_SIZE 、 KEY_FRAME_RATE 、 KEY_COLOR_FORMAT 是否正确。输入数据顺序不能乱。
  4. 检查 muxer 写入:MP4 的采样表(stts, stss, ctts)构建是否正确,PTS 不能有重复或倒退。
  5. 播放器缓冲区:软解和硬解在 seek 后需要 flush codec,否则会输出之前的缓存帧,导致慢半拍。

这个问题很能体现候选人的实战能力,因为只有真正做过才能在回答中给到这种具体细节。

7. 播放器优化:首帧、卡顿、弱网与内存

优化类问题是面试里的压轴题,通常以场景题出现:“你负责的播放器在弱网环境下很卡,怎么优化?”

7.1 首帧优化的核心思路

首帧时间(TTFF,Time To First Frame)指的是从用户点击播放到第一帧画面显示的时间。优化思路可以拆成几个阶段:

  • 连接阶段 :DNS 缓存、HTTPDNS、TCP 预连接、TLS 1.3 会话恢复。
  • 加载阶段 :降低起播码率,先加载低清晰度切流;服务端支持 seek 到关键帧起点;使用 range 请求只加载前几个关键帧。
  • 解封装阶段 :MP4 的 moov box 要放在文件头部,避免播放器等到下载完整个 moov 才能解析;如果放在尾部,需要服务端支持 faststart 或在客户端先解析到 moov 再起播。
  • 解码阶段 :首帧视频数据到位后尽快送入解码器。如果是直播,可以限制 GOP 长度,让播放器收到 IDR 帧后立即解码。

ExoPlayer 里有 load 的 playbackLooper 和 bufferForPlaybackMs 参数。降低 bufferForPlaybackMs (如从默认的 50ms 调到 0)能减少缓冲等待,但会增加播放中断风险。实际调优是在播放中断率和首帧延迟之间找平衡。

7.2 卡顿优化:从缓冲策略到预测式加载

卡顿的本质是渲染消费速率大于数据供给速率。可以从两端入手:提高供给速率、降低消费速率。

提高供给速率的手段包括:多线程加载、增大 buffer、调整下载调度策略(优先级:正在播放的片段 > 后续几秒 > 远端片段)、根据网络情况动态切码率。

降低消费速率的手段包括:丢帧策略(视频允许丢帧,音频不能丢)、降低清晰度、减小缓冲。

ExoPlayer 的 DefaultLoadControl 提供了几个关键参数:

  • bufferForPlaybackMs :最小缓冲时长,低于这个值就暂停播放进入 buffering。
  • bufferForPlaybackAfterRebufferMs :卡顿后的缓冲时长阈值。
  • maxBufferMs :最大缓冲时长,超过就停止加载,避免过大浪费内存和流量。

面试题:如何设计自适应码率切换(ABR)策略?

成熟的 ABR 策略不是只盯着当前网速,而是综合考虑:下载速度的平滑值(EMA)、当前缓冲区的水位、下一个分片的大小、设备性能。比如带宽够了但缓冲区低,要保守,先降到低码率把缓冲区补起来再升档;带宽不够但缓冲区充足,可以维持当前码率并等带宽恢复,避免频繁切换。

7.3 弱网下的播放策略

弱网(高延迟、高丢包)时,最忌讳的是无脑加大缓冲,因为这会同时提高延迟。直播场景下,低延迟和流畅是一对矛盾。

常用的策略有:

  • GOP 缓存 :服务端保留最近 1~2 个 GOP 的缓存,新观众加入时直接从关键帧开始拉流,缩短首帧时间。
  • FEC 前向纠错 :对关键帧数据附加冗余包,客户端收到冗余可以恢复少量丢包,避免因为丢包导致关键帧解码失败。
  • 码率平滑 :使用基于吞吐量的自适应码率切换,切换时不产生卡顿(关键帧切换、渐进式切档)。
  • 追帧/丢帧 :直播场景如果延迟越来越大,客户端可以选择跳过部分非关键帧追回延迟,但这个过程要控制在用户无感范围内。

7.4 内存与耗电优化

音视频开发的内存问题集中在解码器输出帧和纹理上。视频解码器输出 YUV 帧时,一帧 1080p I420 大约是 3MB(1920x1080x1.5)。如果 BufferQueue 里有 3 帧,就是 9MB,加上编码、预览等环节,很容易膨胀。

优化手段:

  • 使用 Surface 模式,避免 YUV 数据 buffer 拷贝到应用层。
  • 控制 BufferQueue 的深度,不要让解码器过度预取。
  • 及时 releaseOutputBuffer ,不要持有帧做延迟处理。
  • 视频播放页如果有列表,考虑只保留可见项的 Surface。

耗电方面,硬解明显比软解省电。另外,Camera 预览的帧率可以按场景调整:录制 4K 视频时没必要 60fps 预览,30fps 足够。

8. 编码标准核心概念:H.264、H.265 与 AAC 的面试考点

面试官在深入编码问题时,常会问一些概念题。这一节把必须掌握的标准知识点整理出来。

8.1 H.264 的 I 帧、P 帧、B 帧与 GOP

H.264 的视频序列由帧编码类型组成:

  • I 帧(关键帧) :完整编码的一帧,可独立解码,不依赖其他帧。
  • P 帧(预测帧) :参考前一个 I 或 P 帧,只存储与参考帧的差异。
  • B 帧(双向预测帧) :参考前后两个帧,压缩率高但解码顺序和显示顺序不同。

GOP(Group of Pictures)就是从一个关键帧开始到下一个关键帧前的所有帧序列。GOP 长度影响两个重要指标:seek 精度和错误恢复能力。GOP 越长,压缩效率越高,但 seek 到非关键帧位置时只能丢帧到下一个关键帧,错误传播窗口也长。

面试题:直播场景为什么一般不用 B 帧?

B 帧的解码依赖后续帧,会导致编码延迟增加(要等后面的帧到达才能解码当前的 B 帧),同时会打乱帧的显示顺序。直播要低延迟,所以一般只用 IPPP 结构;视频编辑/点播场景则可以用 B 帧提升压缩率。

8.2 H.265 相比 H.264 的优势与代价

H.265(HEVC)的压缩率比 H.264 高约 50%,在同等画质下码率更低。代价是编解码复杂度大幅提升,对硬件要求高,导致很多中低端设备的硬解能力不足。

另一个关键点是专利授权问题。H.265 的专利池比 H.264 复杂得多,商业使用可能要付专利费,这也是很多产品沿用 H.264 的原因。

新一代的 AV1 和未来的 VVC(H.266)也有类似问题:压缩率更高,但软解极其吃力,硬解覆盖还比较薄弱。项目选型时要根据目标设备、内容类型、网络条件做权衡。

8.3 AAC 编码的 profile 与封装

AAC 常见 profile 是 LC-AAC、HE-AAC(AAC+)、HE-AACv2(Enhanced AAC+)。

  • LC-AAC:兼容性最好,音质好,码率在 96~256kbps。
  • HE-AAC:带 SBR(频带复制),在低码率下通过高频复制提升听感,适合 32~64kbps 的语音/广播场景。
  • HE-AACv2:加入 PS(参数立体声),码率可以压到 16~32kbps。

面试中常问:“直播/IM 语音用什么编码?”答案一般是:语音场景用 Opus(低延迟、高质量、开源),音乐场景用 AAC-LC。Opus 在延迟和压缩率上都优于 AAC,但编码器在 Android 上没有原生支持,一般用原生库集成 libopus。

8.4 容器格式:MP4 的 moov box 与 seek 机制

MP4 的 seek 依赖 moov box 中的 stbl(Sample Table Box)信息。stbl 包含:

  • stts (decoding time to sample):解码时间到采样序号映射
  • stss (sync sample):关键帧列表
  • stco/co64 (chunk offset):采样数据在文件中的偏移

播放器拿到 stss 就知道哪些是关键帧,seek 到目标时间附近时先跳到最近的关键帧,再向后解码到目标位置。

如果 moov box 在文件尾部(很多设备录制时默认如此),播放器必须下载完整个 moov box 才能开始播放。对于网络视频,服务端必须在响应 range 请求时做好 moov 优先返回,或者视频文件本身用 faststart 处理过(把 moov 移到文件头)。

这个细节面试中经常以“为什么本地播放一个 MP4 秒开,但网络播放同样的文件很慢”的形式出现。

9. ExoPlayer 进阶:缓存、自适应流与 Segment 调度

ExoPlayer 虽然用起来简单,但深入优化时涉及的概念很多。这一节列几个面试中真正能拉开差距的点。

9.1 ExoPlayer 的缓存机制

ExoPlayer 的缓存由 SimpleCache (现已更名为 ExoPlayerCacheDirectoryProvider + CacheDataSource )实现。默认情况下,ExoPlayer 是从网络直接读取数据流交给解码器,不持久化缓存。如果要支持离线缓存/边下边播,需要把 DataSource.Factory 包装为 CacheDataSource 。

CacheDataSource 有两个缓存级别:文件缓存(CacheListener)和内存缓存( LeastRecentlyUsedCacheEvictor )。缓存写入磁盘前会经过 CacheWriter 。实际项目中一般都要限制缓存目录大小、设置过期策略。

面试题:边下边播时,用户拖动进度条到未缓存区域,该怎么处理?

ExoPlayer 的 LoadControl 会根据当前播放范围决定哪些 chunk 需要加载。Seek 到未缓存位置时,会中断当前下载并跳转。优化点是:实现 ChunkSource 的 getNextChunk 方法时,要根据当前播放位置和用户可能 seek 的目标位置按优先级排队加载。ExoPlayer 默认实现是顺序加载,不会智能预取用户常跳转的区域。所以做视频列表类 App 时,通常要自定义 DefaultDashChunkSource 或 HlsChunkSource ,把下载调度改成“当前播放片段优先 + 预取下一集首段”。

9.2 HLS 与 DASH 的 Segment 调度

HLS 的媒体流由很多小文件(ts 或 fmp4)组成,播放器需要定期播放 m3u8 来获取新的 segment 列表(直播场景)。

面试题经常问: HLS 直播的延迟为什么高?

原因在于:HLS 的每个 segment 典型时长为 6 秒,播放器至少要保证 3 个 segment 的缓冲才开始播放,加上播放列表更新的时间,天然就有 20~30 秒的延迟。后来的 LL-HLS(Low Latency HLS)把 segment 切得更小(0.5~2 秒),加上部分 segment 的 partial 加载,才把延迟降到 3~5 秒。

ExoPlayer 支持 LL-HLS 的部分实现,但实际部署中很多厂商还是自研 MPEG-DASH 的 CMAF + Chunked Transfer 来实现低延迟。

9.3 TrackSelector 的轨道选择逻辑

ExoPlayer 的 DefaultTrackSelector 会根据多个维度选择音频/视频轨道: TrackSelection 里包含带宽评估、帧率、分辨率、语言偏好、安全级别限制(DRM)等。

面试中一个经典问题是: 播放器自动选择合适的音轨和字幕轨道的逻辑是什么?

答案中要提到 TrackSelector 的工作方式:

  1. 从 TrackGroupArray 中筛选所有可用的轨道
  2. 根据 RendererConfiguration (设备支持的渲染能力)过滤不支持的轨道
  3. 根据 Parameters (应用设置的首选项,如语言、最大分辨率、是否允许 HDR)排序
  4. 对视频轨,按带宽黄金交叉( bandwidthMeter 的值和当前 buffer 状态)选择码率
  5. 对音频轨,优先选择用户语言且 codec 支持的轨道

实际开发中,如果你要自定义码率切换策略,需要继承 BaseTrackSelector 重写 selectTracks 方法,或者直接设置 DefaultTrackSelector.Parameters 。

9.4 ExoPlayer 的精确 seek 与关键帧 seek

ExoPlayer 播放 MP4 时,seek 默认是精确 seek(精确到目标时间戳),需要解码目标帧之前的所有帧。如果用户频繁拖动,这个过程会非常慢,所以 ExoPlayer 有个 setSeekParameters(SeekParameters.CLOSEST_SYNC) 参数,允许 seek 到最近的关键帧,牺牲精确性换取速度。

直播场景一般不让用户 seek 到历史位置(或者是通过服务端录制文件回放实现),所以 seek 参数设计意义不大。点播场景,尤其是长视频,seek 优化直接影响用户拖动体验。

10. 音频开发细节:路由、焦点、回声与降噪

音频部分在面试中虽然占比不如视频,但一旦问到细节,很多人会露怯。

10.1 音频路由与 AudioTrack 缓冲

播放音频的高层 API 是 MediaPlayer 或 ExoPlayer 的音频部分,底层最终走 AudioTrack 。AudioTrack 直接和 AudioFlinger 通信,通过 write() 方法播放 PCM 帧。

AudioTrack 的缓冲大小直接影响延迟。缓冲越大,越不容易 underrun(欠载导致声音断续),但延迟越高。游戏/音乐创作场景要求低延迟,所以通常用 MODE_STREAM 配合 setBufferSizeInFrames 手动控制。

面试问题: 为什么通话类 App 要用 AudioTrack.MODE_STREAM 而不是 MODE_STATIC ?

MODE_STATIC 是一次性把音频数据写入固定的共享内存,适合铃声、提示音这种短音频;MODE_STREAM 是持续写入流式数据,适合通话、音乐播放这种持续长音频。 MODE_STATIC 没有连续写入能力和线程同步机制,短音频延迟低但只适合一次性播放。

10.2 音频焦点(Audio Focus)管理

音频焦点是 Android 多音频流协作的机制。两个应用同时播声音时,系统需要决定谁能出声。

面试中常考的是 AudioFocus 和 AudioManager 的用法:

  • requestAudioFocus 获取焦点
  • OnAudioFocusChangeListener 监听焦点变化(获得、丢失、丢失短暂、延迟获得)
  • 丢失焦点时暂停、失焦后恢复(duck 处理——音量降低是否允许)

这个问题的实际应用场景是:你的视频 App 正在播放,这时候来了导航语音,导航请求了焦点,你的 App 应该暂停还是降低音量?产品需求不同处理不同,但实现逻辑都是通过 AudioFocusRequest.Builder 配置 setAcceptDelayedFocusGain 和 setWillPauseWhenDucked (是否允许被缩小音量)来实现。

10.3 回声消除与噪声抑制

通话类 App 里回声和噪声是核心问题。Android 提供的原生 API 是 AcousticEchoCanceler 和 NoiseSuppressor ,但很多设备的效果并不可靠。

面试问题: 回声是怎么产生的?怎么消除?

回声是扬声器播放对方的声音,通过空气传回麦克风,又被录进去发送给对方。消除逻辑是:麦克风采集的信号和扬声器播放的信号相关,可以在 DSP 里用自适应滤波器估计回声路径,然后从麦克风信号中减去。

Android 设备碎片化带来的问题是:不同设备的回声路径差异大, AcousticEchoCanceler 的底层实现质量参差。很多 VoIP 应用的做法是直接禁用原生 AEC,改用 WebRTC 的音频处理模块(APM)做回声消除和降噪,保证跨设备行为一致。

11. 性能监控与线上问题排查

最后讲一个面试官一定会问的领域:线上问题排查。这部分的回答决定了你是“纸上谈兵”还是“实战派”。

11.1 线上播放失败指标

监控播放器不能只看有卡顿、没卡顿。成熟产品会统计以下指标:

  • 视频起播成功率
  • 首帧显示耗时(分位数:P50/P95/P99)
  • 播放中断率(playback interruption rate)
  • 卡顿时长占比(stall ratio)
  • 平均码率、切码率次数
  • 硬解失败回退率、解码错误率
  • 音频/视频渲染延迟差(A/V sync error)

这些指标要和业务层面(播放按钮点击、播放完成率)结合分析,才能定位具体模块的问题。

11.2 排查链路:从日志到定位

如果接到线上反馈“部分用户播放卡顿”,排查链路一般是:

  1. 按用户维度和设备维度聚合卡顿率,看是不是特定的设备/系统版本集中。
  2. 抓网络抓包,看下载速率、RTT 和重传率。如果网络层正常,继续排。
  3. 看解码统计: MediaCodec 的 getMetrics() 能拿到解码器丢帧、超时、错误码。
  4. 看渲染层: OnVideoFrameMetadataListener 的时间和 vsync 的匹配度。如果渲染端落后,可能是 SurfaceFlinger 进程卡顿导致的。
  5. 用 adb shell dumpsys SurfaceFlinger --latency 验证 vsync 稳定性。
  6. 最后才是代码逻辑排查。

这个链路的顺序很重要:先排除设备/系统问题,再排除网络问题,最后才看自己的代码。很多人一上来就怀疑自己的播放器代码,结果查了好几天发现是某型号手机的 GPU 驱动 bug。

11.3 常见崩溃与 ANR 的定位

音视频相关崩溃常见有:

  • releaseOutputBuffer 回调索引重复释放,导致 IllegalArgumentException 。
  • 在 surfaceDestroyed 后继续往 Surface 提交帧,导致空指针或异常。
  • 编解码器 create 后忘 release,导致资源泄漏。
  • 音频焦点处理不当,在后台播放时反复获取焦点和释放焦点,导致 ANR。

定位方法不外乎:崩溃日志看调用栈、ANR 看 CPU 占用和主线程堆栈、配合日志开关笼统过滤。但真正的功夫在提前预防:关键路径加 try-catch、资源释放放在 finally 里、异步回调里避免直接操纵 UI 或继续调用已经 release 的 codec。

12. 写在最后的经验分享:面试中如何把知识串联成体系

这篇文章涵盖了 Android 音视频最常见的面试知识点。最后分享几点我在实际面试和带新人过程中的体会,希望能帮你避免一些弯路。

第一,不要背题,要背链路。 面试官问“MediaCodec 的 input surface 怎么用”,如果你只回答“创建一个 Surface 传给 createInputSurface”,那只能算及格。但如果从 Camera 采集、GPU 处理、surface 零拷贝、编码器消费的角度讲,面试官立刻就能判断你是真的做过还是临时背的。知识不是孤立的点,而是一条从采集到渲染的链路,你能指出每个环节的数据形态(YUV、纹理、码流)和转换边界,面试就成功了大半。

第二,概念要落到代码。 比如我说“SurfaceFlinger 合成图层”,如果你能补充一句“具体到代码上,Surface 的 BufferQueue 有 maxDequeuedBufferCount 和 maxAcquiredBufferCount 参数,处理不好就会黑屏/卡帧”,这就比单纯背概念强太多。每一层原理最好都能对应到你写过的某个 bug 或调过的某个参数,哪怕是带新人时踩到的都行。

第三,性能指标比功能更能体现水平。 面试官问“优化播放器”,如果你的回答是“用 ExoPlayer 的 setBufferForPlaybackMs 调参数”,这显得太浅。如果能说清楚你通过播放中断率指标发现缓冲参数哪里不合理,然后结合用户场景做 AB 测试验证效果,这就能让他看到你有数据驱动的优化意识。

第四,工具链要熟练。 面试中聊优化时,如果你能提到 adb shell dumpsys media.player 、 adb shell dumpsys SurfaceFlinger --latency 这些工具的具体用法,会加分很多。这些命令是实际的线上排查手段,不是背概念。

最后的最后再提醒一句:Android 音视频的面试题变化很多,但这篇文章覆盖的主干知识几乎适用于所有变化。你在复习时多画几遍数据流向图,把每一帧数据从采集到显示的路径和对应的系统组件、时间戳、内存拷贝位置都标出来,这种“建模思维”比刷一百道题都管用。面试官问什么都能绕回到你对数据链路的理解上,因为这才是音视频开发的核心能力。

Logo

邀请您加入社区

更多推荐