如果你已经写过一段时间的界面代码第一次接触相机模块的感觉大概会是这个技术栈怎么和平时写页面完全不是一回事。回调嵌套层层叠叠明明点一下拍照结果要等好几个异步通知预览方向还经常是歪的换个设备表现又不一样。我在带新人时经常说别急着去看各种封装库先把“相机与多媒体框架”这套底层的协作方式理顺后面做扫码、录像、实时滤镜、视频编辑才不会总在同一个坑里打转。这章要讲的就是这条完整链路从相机打开、预览、拍照取帧到MediaCodec硬编码、MediaMuxer封装、MediaExtractor解封装。“相机与多媒体框架”这几个词听起来像是一个系统组件的功能介绍但真正落到项目里它其实是“如何把一帧画面从传感器一路搬到屏幕或文件”的工程问题。无论你是想自己写一个能用Camera2完成的扫码页还是想给视频App加入自定义录制能力底层都是同一套逻辑。1. 为什么相机模块总让人感觉“像另一门技术”1.1 一条完整的相机链路先从底层认识它大多数人第一次看到相机的代码会很懵因为相机并不是一个直接提供“拍照()”方法的黑盒。从按下快门到拿到一张照片中间的完整链路大致是这样镜头采集光线后图像传感器把光信号转成电信号随后经过ISP图像信号处理器做降噪、自动白平衡、曝光补偿再通过驱动交给相机硬件抽象层系统服务统一管理多个应用的调用请求最后才轮到你的应用通过相机设备接口拿到帧数据。这一条链路里的每一步都可能因为厂商实现不同而表现不同。对应用开发者来说我们接触的是最上层那一小段设备打开、配置会话、发起预览请求、接收帧回调。但为什么要理解下层链路因为很多问题不在你的代码里。比如某些设备上你明显感觉到预览延迟可能是ISP在处理自动曝光流程又比如连续拍照性能不稳定可能是因为底层HAL里的帧缓冲被占满。带着整条链路的视角去排查会比盲目改参数高效很多。1.2 决定开发方式的三件事异步、坐标系、帧生命周期相机开发之所以难受主要是因为它有三个特点几乎决定了你所有代码的写法。第一全流程异步。你的每一次操作都通过回调返回结果没有同步阻塞的“等待拍照完成”。于是你在代码里常常看到这样的结构打开设备成功后才能创建会话会话配置成功后才能发起请求请求的回调里才有帧数据。如果不把状态流转管理好很快就变成“回调地狱”。第二坐标系不统一。传感器的物理方向、设备自然方向、当前屏幕方向是三个不同的东西。手机竖着拿、横着拿传感器输出的依然是一张长期固定方向的画面需要你自己算旋转角度。这个问题会在后面单独展开因为90%的初学项目都在这里摔过跤。第三帧数据是有生命周期的。摄像头输出的每一帧Buffer来自一个有限的缓冲区池你拿到了就要及时处理、及时释放否则缓冲区耗尽画面会卡顿甚至黑屏。学会像管理连接池一样管理帧Buffer是迈过新手阶段的标志。还有一个容易忽略的点现在系统提供的CameraX组件已经把这套流程封装得很友好适合快速实现扫码、拍照。但封装的代价是当你遇到特殊需求或性能问题还是要回到Camera2的底层概念去排查。所以这章不打算只教你调用某个封装库而是把底层链路讲清楚。2. 预览通路的正确打开方式从Surface到画面渲染2.1 Surface才是相机真正的工作对象很多初学者会以为相机预览是把图像直接画到View上实际上相机并不认识View它只认Surface。Surface可以理解成一块“生产者和消费者共享的内存画布”相机负责把帧数据写进这块画布系统负责把画布内容合成并显示到屏幕。你通过SurfaceView或TextureView拿到的是一个可用的Surface真正干活的是这个底层对象。这也解释了为什么相机会说“帧到了”但你的View还没显示出来——因为Surface的数据还需要经过系统的合成器上屏。如果再往深处说Surface背后还连着BufferQueue它管理着多个Buffer在生产者和消费者之间流转。相机每输出一帧就从队列里申请一个空闲Buffer写完后交给消费者比如编码器或解析器消费消费完再归还队列。理解这个机制后很多灵异现象就有了解释。比如你在页面销毁后没有及时关闭相机新的页面重新打开时会发现Camera设备被占用因为旧连接还握着底层资源。又比如你把ImageReader的maxImages设置得过大内存暴涨设置得过小帧处理不及时导致生产者阻塞。这些最终都指向同一个核心谁在什么时候持有Buffer、什么时候释放。2.2 一段最常出现的Camera2打开流程在用Camera2之前如果你只是想快速实现功能我更推荐先理解下面这套最标准的启动顺序。每次打开相机几乎都是这五步第一步通过CameraManager列出并选择摄像头ID拿到该摄像头的CameraCharacteristics确认能否支持你需要的输出尺寸和帧格式。第二步在后台线程上调用openCamera注意不能直接在UI线程做耗时的相机初始化。第三步收到onOpened回调后拿着CameraDevice创建CameraCaptureSession并把预览Surface作为Target传进去。第四步会话配置成功后构建一个TEMPLATE_PREVIEW类型的CaptureRequest。第五步调用setRepeatingRequest持续下发预览请求。val manager getSystemService(Context.CAMERA_SERVICE) as CameraManager manager.openCamera(cameraId, object : StateCallback() { override fun onOpened(device: CameraDevice) { device.createCaptureSession( listOf(previewSurface), object : SessionCallback() { override fun onConfigured(session: CameraCaptureSession) { val request device.createCaptureRequest( CameraDevice.TEMPLATE_PREVIEW ).apply { addTarget(previewSurface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) } session.setRepeatingRequest(request.build(), null, backgroundHandler) } }, backgroundHandler ) } override fun onDisconnected(device: CameraDevice) device.close() override fun onError(device: CameraDevice, error: Int) device.close() }, backgroundHandler)整个过程非常像“先建连接再开会话最后发请求”的流水线。新手最常见的错误是在onOpened里立刻去构建CaptureRequest并调用setRepeatingRequest完全跳过会话配置回调。那样得到的Session还没就绪请求自然发不出去预览会一直黑屏。所以请你务必记住会话建立成功是发送预览请求的绝对前提。2.3 方向与镜像为什么预览总是躺着的方向问题是相机开发里最经典、也最容易让新人心态爆炸的问题。传感器在硬件上有一个固定的安装方向大多数后置摄像头的sensorOrientation是90度前置摄像头常见的是270度。而设备的“自然方向”和当前屏幕旋转角度又是另一套坐标。于是你拿到的那帧画面不会自动适应竖屏。处理方向的标准思路是先通过CameraCharacteristics拿到传感器的方向值再把当前Display的rotation换算成度数最后根据前置或后置做不同的角度补偿。整体来说后置摄像头通常用“传感器方向减屏幕旋转方向”的差值前置摄像头方向上除了角度还要额外考虑镜像效果。不同设备会有一些特殊组合所以一个成品项目里通常会维护一个“摄像头ID传感器方向”的映射表在运行时打日志验证。这里的常见误区是只处理了预览画面的方向没有处理输出文件的方向或者反过来。记住预览用的TextureView可以通过Matrix做旋转而最终写入编码器或JPEG的帧数据也需要在计算尺寸时应用同样的旋转规则。很多视频录制出来是横的往往就是省略了后者。2.4 预览尺寸选择的一个筛选模型每个摄像头能支持的输出尺寸往往有十几二十个直接把最大的拿来用并不是好主意。最大的尺寸通常伴随着更高的帧带宽、更大的内存占用而且在低端机上容易发热掉帧。比较理性的做法是先定义一个目标比例然后从支持列表里筛选出最接近的那个。举个例子如果界面是竖屏拍照通常希望输出是4:3或16:9。你可以遍历StreamConfigurationMap.getOutputSizes(ImageFormat.JPEG)先过滤出宽高比在容差范围内的尺寸再按分辨率从高到低取第一个。这样既能避免画面变形又不至于为了多出一点面积付出性能代价。至于最大尺寸更适合用在确实需要高分辨率输出的专业场景比如证件照或电商拍摄普通预览流程完全没必要。3. 拍照与取帧JPEG、YUV和ImageReader的分工3.1 三种取帧方式的适用场景拍照不是只有“拿到一张压缩图”这一种方式。按数据形态分常见有三种JPEG回调、ImageReader取YUV帧、RAW数据。理解它们的分工可以帮你避免很多不必要的格式转换。JPEG回调是最简单也最常用的拍照方式你发起一个TEMPLATE_STILL_CAPTURE请求等回调收到一张已经压缩好的JPEG字节流。优点是省事、兼容性好缺点是压缩和解码都需要时间而且很难做实时滤镜。如果你只是做一个基础拍照应用选它准没错。ImageReader适合需要实时处理帧的场景。它可以输出YUV_420_888格式的原始帧也可以输出JPEG格式在扫码、人脸检测、实时滤镜这类需求里非常常见。YoYUV帧的好处是省去了反复压缩和解码坏处是格式细节多你要自己处理旋转、裁剪、镜像。RAW数据则是专业模式才需要的东西它保存的是传感器原始感光数据色彩信息最丰富但数据量巨大而且普通用户根本看不出区别。我的建议是除非你在做摄影类App否则完全不用碰RAW。3.2 YUV 420系列格式的识别与处理YUV家族有很多排列方式最常遇到的是YUV420系列。这个系列共用一个规律亮度信息用整幅图像的全部像素表示色度信息按2x2的区域共用一组分量。所以一幅分辨率为width×height的图像Y分量占width×height个字节U和V合起来再占一半总字节数就是width×height×3/2。以1080×1920为例算一下就是3110400字节。这个数字很有用比如你创建一个用于存放YUV帧的ByteBuffer时可以直接用它做容量估算也可以用来快速判断拿到的数据是不是完整的YUV画面。接下来要区分具体排列方式。NV21是先放Y平面再交错放V和UNV12同样先放Y平面但交错顺序是U和VI420则是Y、U、V三个平面顺序排列YV12是Y、V、U三个平面排列。安卓相机的YUV_420_888格式本身是抽象的它允许一个Image内有多个Plane每个Plane还可以有自己的像素行间距所以不能想当然地按“整块内存”读。判断方法是查看每个Plane的getPixelStride和getRowStride如果值为2说明是交错排列值为1说明是连续平面。转换到编码器输入时通常可以先判断设备到底支持哪种颜色格式再决定是否要做一次内存重排避免设备不兼容导致的绿屏花屏。3.3 拍照本身是一连串需要编排的异步任务实现一个最简单的拍照按钮真实逻辑比想象中复杂先要在预览会话里暂停或降级预览请求某些设备在连续预览模式下直接插入拍照请求会导致丢帧或卡顿然后创建一个TEMPLATE_STILL_CAPTURE请求把闪光灯策略、自动对焦模式设好等拍照请求的回调返回JPEG数据后再把预览请求恢复。这个过程很像一个微型状态机空闲、预览中、拍照请求下发、拍照完成、恢复预览。如果你不做状态控制用户快速连点两次快门第二次请求很可能因为前一次还未完成而被系统忽略表现出来就是“没反应”。所以我在项目里习惯用一个简单的布尔状态来隔离拍照中阶段同时在界面层做防重复点击。不要小看这个设计它在真机上能避免一大批偶发问题。拍照回调和预览回调还是同一套线程模型记得不要把图片解码、文件写入这些耗时操作放在相机回调线程里否则会直接把帧处理队列堵死。正确做法是回调里只做数据搬移立刻切到业务线程再做压缩、写文件、上传等操作。4. MediaCodec硬编码视频从“显示”到“落盘”的必经之路4.1 编码器配置里的每个参数都有实际代价视频录制离不开MediaCodec它是一套统一暴露硬件编码能力的接口。你不用关心芯片是哪个厂商只要按MediaFormat的约定告诉它你要什么它会尽量挑合适的硬件编码器。但如果参数给得不合理设备会退回到软件编码发热、掉帧、卡顿立刻出现。创建编码器时的关键参数就那么几个编码格式、宽高、码率、帧率、关键帧间隔、颜色格式。以H.264为例一个常见的配置大概是这样的val format MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height) format.setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000) format.setInteger(MediaFormat.KEY_FRAME_RATE, 30) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) format.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)码率、帧率、关键帧间隔这三个参数之间是互相牵制的。码率决定了同样一帧画面分到多少bit给太低画面全是块状噪声给太高文件增长快。帧率决定了时间戳的疏密如果实际渲染帧率跟不上配置值播放时可能看起来忽快忽慢。关键帧间隔则影响视频跳跃和拖动定位的精度。实际编码选型我一般这样定1280×72030fps普通场景用2到4Mbps1920×108030fps4到8Mbps如果是4K建议20Mbps以上。直播推流选CBR保证码率稳定本地存储用VBR或CQ可以兼顾画质和体积。不要盲目把码率压得很低再用“画质优化”去补救那是白费力气。4.2 时间戳一个被忽略但价值极高的细节在MediaCodec的接口里每个编码后的Buffer都要带一个presentationTimeUs时间戳单位是微秒。很多初学者不理解为什么这个值这么重要以及为什么录出来的视频会“后半段卡成PPT”。时间戳实际上是编码器判断帧顺序和帧间隔的唯一依据。你告诉它这一帧应该在30fps流里的第几毫秒它就按这个时间工作。如果你传的时间戳乱跳编码器输出的帧序就会乱封装后的文件播放起来自然忽快忽慢。生成稳定时间戳的标准做法是确定一个单调递增的时钟源比如用System.nanoTime()再把它换算成微秒。计算每一帧的偏移量时以系统启动后的一个基准点为零点而不是直接用当前毫秒数。如果使用相机回调里的帧时间也要先确认它是不是单调递增部分设备的帧序号存在重复或跳跃。还要注意编码器INFOBufferInfo里必须分别设置时间戳和Flags。关键的帧要标记BUFFER_FLAG_KEY_FRAME这是后续做关键帧提取、秒级定位的依据。很多人在这一步漏掉标记导致MediaMuxer写出来的视频无法拖动进度条。4.3 一整套硬编码会话的标准流程一次标准硬编码录制最推荐的输入方式是Surface。你用编码器的createInputSurface拿一个Surface把它作为预览或处理链路的输出目标相机的帧直接写进这个Surface编码器自动把连续帧编码成H.264流。这么做比手动把ByteBuffer塞进编码器快得多因为数据全程在内存里流转省去应用层的拷贝。流程分两端一端是输入相机或OpenGL渲染向Surface送帧另一端是输出在循环里不断从MediaCodec取编码后的数据。取输出时要用dequeueOutputBuffer拿到一个可用Buffer后把它的BufferInfo信息连同Buffer里的数据一起交给MediaMuxer写文件然后releaseOutputBuffer归还。完整的迷你流程如上图所说手动处理“输入Surface纹理化”会复杂一些但如果你不需要滤镜直接在CameraCaptureSession里同时放预览Surface和编码器Surface就能实现清晰度不错的录制。注意编码器的宽高必须和相机的输出尺寸匹配不一致时要么用OpenGL处理要么预先选一组双方都支持的尺寸。5. 音视频落盘与二次利用MediaMuxer和MediaExtractor的配合5.1 用MediaMuxer把编码流写成MP4MediaCodec编码出来的是一串压缩帧流不能直接当视频文件播放还需要用一个容器把它们装起来。MediaMuxer就是负责把视频轨、音频轨按时间序写入MP4等容器的工具。它的使用有非常强制的顺序先addTrack返回轨道编号然后start接着反复writeSampleData最后stop并release。你可能会觉得这顺序很自然但实际项目里常见的错误是在拿到编码器输出格式之前就调start导致SPS、PPS这些参数没写进去文件在播放器里打不开或首帧花屏。正确做法是先取编码器的输出MediaFormat把它作为addTrack的参数。输出MediaFormat里通常带有csd-0、csd-1两个字节缓冲它们就是解码器需要的参数集。没有它们后面再正确的数据也白搭。另外当你同时写入多个轨道时writeSampleData调用需要保证时间戳有序尤其是含有B帧的视频流必须先按解码时间排序再写入不然某些播放器会跳过或卡住。5.2 用MediaExtractor做解封装与关键帧提取有写就有读。MediaExtractor负责从既有文件中抽取出压缩帧数据常用于视频缩略图、转码、音轨提取这类再处理任务。它的使用思路是setDataSource定位文件选中一个轨道然后循环readSampleData和advance一帧一帧往后走。如果你只想提取视频的关键帧做缩略图可以用getSampleFlags判断当前样本是不是SAMPLE_FLAG_SYNC_FRAME遇到关键帧就把对应的压缩数据交给一个解码器拿到原始帧再缩略成图片。这样既不需要把视频完整解码也不会因为漏掉关键帧而出现花屏。这里有一个经验MediaExtractor读取的是压缩数据不代表你能直接拿到原始画像。真要得到一帧可以显示的Bitmap还是要配合解码器。转码流程也无非是“Extractor抽帧-MediaCodec解码-另一个MediaCodec编码-Muxer写容器”整套链路串起来后你会发现多媒体开发无非就是“解封装、解码、处理、编码、封装”这五个环节的排列组合。6. 实战项目中绕不开的四个相机坑位6.1 页面切后台再回来预览黑屏且偶发崩溃很多新手把相机写好后一按Home键再回来预览就黑屏了有时候还会闪退。原因几乎都在于生命周期没有管好。Activity暂停时你没有关闭相机回到前台时又试图把旧的TextureView Surface重新放进会话可这个Surface在后台已经被系统销毁了。标准做法是onPause里关闭相机设备并释放会话onResume里等待新的Surface回调再从头走一遍打开相机的流程。不要想着复用之前的链接因为你不知道Surface是否已经被底层释放。简单的状态机判断也很必要避免onResume和onPause连续触发时出现重复打开、重复关闭。还有人会在onResume里同步调用openCamera导致UI线程阻塞卡顿。正确做法是让CameraManager的打开请求跑在独立的后台Handler线程上回调回来后再切回UI线程更新状态。相机的每个回调都有自己的线程归属创建Session和发送请求最好都放到同一个Handler避免多线程竞争。6.2 输出画面变形原因是尺寸比例没有对齐变形问题是最容易定位又最容易反复出现的。相机的原始画面比例和你预览View的宽高比以及最终编码输出的比例如果三者不完全一致显示出来就是拉伸或挤压。拿竖屏举例相机支持的输出可能有4:3、16:9、1:1等等你想全屏预览就选16:9的画布但拍照输出非要选最大的4:3保存照片后上下就会多出一截旋转处理后还可能出现比例错乱。排查思路是先统一概念预览Surface、拍照输出、编码输入是三份独立数据它们各自可以有不同的尺寸。但预览和最终输出至少要满足同一个比例偏好。如果界面是全屏预览我会优先把预览尺寸选成与界面接近比例的最接近项而不是能选多大选多大。至于拍照结果再单独从JPEG尺寸里选一个合理的比例并在保存时按裁剪策略居中裁剪。6.3 前后置切换时方向混乱与镜像颠倒前置摄像头除了方向还多了一个镜像问题。预览时你希望看到镜像画面因为自拍习惯里“照镜子”更自然但在保存照片或视频时通常又想保存非镜像的原画面。这就导致预览和输出两套处理不同很多人因为共用一套旋转逻辑切换前置后就发现左右手对调了。处理方案是拆分两套矩阵一套用于TextureView的显示变换前置时加一个水平翻转另一套用于最终输出的帧数据处理默认不镜像除非业务明确要求自拍镜像输出。同时每个摄像头都要单独记录sensorOrientation不要写死成同一个角度。我在调试时喜欢把“设备ID、传感器方向、当前预览旋转角、输出旋转角”全部打成日志肉眼确认一遍再固化到参数配置里能省不少真机适配的时间。6.4 录视频掉帧别急着怀疑编码器录视频时出现掉帧第一反应很多人都会去调低码率或改编码参数但最值得怀疑的往往是前面几层预览Surface和编码Surface是不是共用了同一条BufferQueue的瓶颈区域ImageReader的maxImages设置是不是太少应用处理不过来时帧生产者被阻塞编码输入Surface接收帧的线程是不是与UI线程互相抢占。另一个常见元凶是相机回调里的耗时操作。如果你在ImageReader.OnImageAvailableListener里直接做了图像旋转、人脸检测、网络上传那么应用处理一帧的时间就会超过帧间隔缓冲区很快就满了。解决方法是让监听器只负责把Image引用转出去让专门的工作线程去处理同时给ImageReader一个合适的maxImages值我一般建议不低于3具体要看单帧业务耗时。最后还有一个很容易忽略的变量帧时间戳。相机输出时间戳和编码器期望时间戳不一致时合成的视频会呈现不自然的抽帧感。用固定间隔按配置帧率生成时间戳大部分设备都能稳定出片。每次遇到掉帧问题我都会先记录实际到达的帧间隔若间隔稳定在40毫秒左右而帧率期望是30问题的根源肯定不在编码器而在更上游的采集或传输。在调试相机和多媒体链路时我最常用的一个习惯是把每一层实际拿到的数据特征都先打成日志Surface尺寸、像素格式、帧到达间隔、时间戳差值、编码输出Buffer大小。很多看起来极其诡异的问题一旦把这些数据摊开看几秒钟就能锁定方向。相机与多媒体框架的难点从来不在于某个API不会用而在于上下游协作的每一环都可能让你“看起来一切正常但结果不对”。把这套链路理清了后面看任何播放器源码、滤镜框架、自定义录制方案都会轻松不少。
阅读完成 · 觉得有帮助?