Android相机与多媒体框架:从CameraX到MediaCodec实战拆解 讲真移动开发里“相机与多媒体框架”这块是所有中级开发者跨不过去的一道坎。平时做个业务页面、写个列表熟练工一天能交付好几屏但一碰到相机预览、拍照、录像、音视频播放整个项目就像踩进了泥潭黑屏、旋转角度不对、内存暴涨、文件存不进去最离谱的是同一段代码在测试机上什么问题都没有一发版到用户手里就各种崩溃。我早期做一款相册类App时光一个拍照后返回缩略图的转圈就被用户骂了一个版本。这篇文章我就把第12章“相机与多媒体框架”重新拆透不讲那些看了一眼就忘的API罗列而是按照我实际做项目的思路带你从框架选型、底层链路、实操实现一路到问题排查和性能优化。适合正在做Android应用、想系统搞懂相机和音视频链路的朋友也适合刚接手的项目里正好有“扫一扫”“拍视频上传”“边播边存”这类需求时你临时翻出来救急。1. 整体设计与思路拆解1.1 为什么相机和多媒体不能按普通功能对待很多新手拿到需求“加个拍照功能”第一反应是打开官网例子抄一段CameraX预览代码跑通了就提交。短期看没问题但项目进入中后期就麻烦了页面A需要后置摄像头扫码页面B需要前置摄像头自拍加水印页面C需要同时展示相册中视频的缩略图和从网络播放高清视频。如果每一个需求都各写一套相机调用和播放器封装代码会迅速腐化成没人敢动的“屎山”。相机和多媒体框架和普通UI组件的本质区别在于它涉及硬件设备资源必须处理生命周期和竞争。它的数据链路是从底层硬件到上层渲染的多级管道任何一个环节处理不当都会丢帧或卡顿。它的文件存储、权限策略在过去十年里改动频繁兼容性问题高发。所以当你决定实现相机或播放功能时“选型”就变成最先要回答的问题而不是先找代码。1.2 方案选型CameraX、Camera2还是底层APIAndroid上相机API有三个层级Camera已废弃、Camera2、CameraX。很多教材直接教你用Camera2写一个满屏状态回调的Demo学完除了“不明觉厉”没有任何沉淀。我建议你先用CameraX绝大部分业务相机需求都够用。对比维度CameraXCamera2学习成本低约半天能上手高需要理解CaptureSession、RequestTemplate等状态机生命周期自动绑定LifecycleOwner需要自己管理相机设备开关分辨率策略内置分辨率和旋转计算完全手动出错概率高扩展能力支持扩展接口但深度控制弱可做手动对焦、RAW输出、传感器级控制适用场景扫码、拍照、普通录像专业相机、自定义HDR、夜景模式等那是不是就不用学Camera2了不是。当你需要针对不同机型做“特殊调教”比如在光线极暗时强制拉高ISO、在连拍模式下调整每一帧的参数组CameraX的封装反而会成为阻力。此时就要在CameraX的代码里补一个camera2CameraInfo或者直接走下层的CameraManager。我的习惯是默认用CameraX留好底层接口真到了需要下探的那天再逐层拆封也不慌。1.3 播放器选型也能单写一篇文章多媒体框架里另一条主线是播放。最原始的MediaPlayer现在已经不适合复杂场景了它把解码、渲染、音频焦点、DRM都包在内部你想扩展一个“缓存到本地再播”都费劲。目前主流方案是Media3内部包含ExoPlayer它的组件化设计让你可以按需替换数据源、自定义渲染器、无缝切换清晰度。再往下走如果业务里还有音视频剪辑、转码这类需求你迟早要接触MediaCodec。它比播放器更底层但理解它之后你会对“一帧视频怎么从原始数据变成H.264”这件事有真正的掌控力。这一章我从相机讲到播放最终都会落到底层的数据流动上只有搞懂数据流动上面的API再怎么变你都能快速接手。2. 核心细节解析与实操要点2.1 相机数据流从Sensor到Surface再到编码器相机预览的本质是图像传感器采集到的画面从底层内存传到上层渲染。听起来简单中间却有三件事要注意一是渲染载体选择。Android上预览画面可以显示在SurfaceView或TextureView上。很多人不知道它们有什么区别理解起来也简单SurfaceView是“挖了一个独立窗口”内容由应用直接画到窗口里不走普通View的合成流程性能更好适合视频播放和相机预览但它不能直接做透明动画、模糊效果等View变换。TextureView则是一个普通View你把相机数据流作为SurfaceTexture的输入数据会作为纹理交给View渲染所以可以任意平移、旋转、加滤镜但代价是额外的合成开销和内存占用。我做美颜类App时优先用TextureView做扫码这类要高帧率的场景就换回SurfaceView。二是旋转方向。手机相机的传感器返回的原始方向是固定的但用户拿手机的姿势千变万化。CameraX会通过targetRotation自动处理预览方向底层用ImageInfo.rotationDegrees来标注帧的旋转角如果你直接读ImageProxy的像素去做人脸检测或加水印一定要基于这个角度做旋转否则你会得到一张歪着脸的图。三是帧数据格式。预览给到上层的帧默认是YUV格式最常见的是YUV_420_888。YUV里Y代表亮度UV代表色度人眼对亮度更敏感所以视频压缩也大量利用了这点。你要做图像算法时不要直接把它转成Bitmap再算那样既慢又耗内存。比如要做条形码检测可以只转Y平面数据运算量少一个量级。2.2 编解码器理解MediaCodec你就赢了三分之二MediaCodec是Android所有编解码的底座播放器、录制器、转码器最终都会走到它。它的核心模型是输入输出两个缓冲区队列你向输入队列塞原始数据比如摄像头的一帧或一个音频PCM块它解码/编码后往输出队列放结果。很多初学者不理解为什么不能“喂”多少就“取”多少是因为编解码有缓冲和复用机制。你需要循环查询它的状态及时把用过的输入缓冲交给它同时从输出缓冲取数据。写成代码大概是这样的循环while (true) { int inIndex codec.dequeueInputBuffer(timeoutUs); if (inIndex 0) { ByteBuffer inputBuffer codec.getInputBuffer(inIndex); // 往inputBuffer写入一帧数据 codec.queueInputBuffer(inIndex, 0, size, presentationTimeUs, 0); } int outIndex codec.dequeueOutputBuffer(bufferInfo, timeoutUs); if (outIndex 0) { ByteBuffer outputBuffer codec.getOutputBuffer(outIndex); // 处理编码后的数据比如写入MediaMuxer中 codec.releaseOutputBuffer(outIndex, false); } }搞懂这个循环再去用Camera2录制、用Media3自定义渲染器会顺畅得多。因为所有“花里胡哨”的API都只是在帮你管理这套循环。2.3 音视频同步为什么视频会越播越卡做播放器时常见的坑是音画不同步。很多人以为是网络问题其实大多数原因是你的播放器没有以“音频时钟”为基准。音频数据是均匀连续播放的适合做时钟基准视频帧则可以丢帧、重复所以要让视频渲染和音频播放对齐通常以AudioTimestamp或avSync参数为基准。Media3内部已经处理得很好了但如果你自己用MediaCodec实现播放器这条经验能救你于水火千万不要用System.currentTimeMillis()来同步音视频系统时间的跳变比如用户改时间会让你整个播放节奏瞬间崩掉。3. 实操过程与核心环节实现3.1 工程初始化依赖和权限准备开始写代码前先确认工程的build.gradle里是否配置了必要的依赖。下面的配置是基于当前常用版本的写法你使用时按项目实际版本号对齐即可dependencies { implementation(androidx.camera:camera-core:1.3.4) implementation(androidx.camera:camera-camera2:1.3.4) implementation(androidx.camera:camera-lifecycle:1.3.4) implementation(androidx.camera:camera-view:1.3.4) implementation(androidx.media3:media3-exoplayer:1.4.1) implementation(androidx.media3:media3-ui:1.4.1) }权限方面在AndroidManifest.xml里声明相机和录音权限uses-feature android:nameandroid.hardware.camera android:requiredtrue / uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.RECORD_AUDIO /如果你的应用只做网络播放和本地相册选视频不需要录音权限。但做录像就一定需要因为不管你要不要收音系统级的录像功能默认会打开麦克风。我当时就在这一步吃过亏只声明了相机权限一调用录像用例直接闪退日志提示缺录音权限。3.2 相机预览20行代码跑通主流程回到第5章那个经典的相机预览Demo用CameraX写其实很短。我把结构拆开看class CameraPreviewActivity : AppCompatActivity() { private lateinit var imageCapture: ImageCapture override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val previewView PreviewView(this) setContentView(previewView) val future ProcessCameraProvider.getInstance(this) future.addListener({ val cameraProvider future.get() bindCameraUseCases(cameraProvider, previewView) }, ContextCompat.getMainExecutor(this)) } private fun bindCameraUseCases( cameraProvider: ProcessCameraProvider, previewView: PreviewView ) { val preview Preview.Builder() .setTargetRotation(Surface.ROTATION_90) .build() .also { it.setSurfaceProvider(previewView.surfaceProvider) } imageCapture ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build() val cameraSelector CameraSelector.DEFAULT_BACK_CAMERA cameraProvider.unbindAll() cameraProvider.bindToLifecycle( this, cameraSelector, preview, imageCapture ) } }注意最后那个cameraProvider.unbindAll()不是随便调的。它确保Activity重建或切换摄像头时上一个生命周期实例绑定的相机设备被释放否则会报“当前相机正在被其他用例使用”。这里我还要强调一点PreviewView并不是直接显示相机帧的View它会根据内部实现自动选择使用SurfaceView还是TextureView。你可以在布局里手动指定androidx.camera.view.PreviewView android:idid/preview_view android:layout_widthmatch_parent android:layout_heightmatch_parent app:implementationModesurface_view /Bachelor时间段不同的implementationMode会影响创建速度和是否支持动态变换。如果你追求最快出画面选surface_view如果你要对预览加圆角或旋转动画选texture_view。3.3 拍照点一下快门存一张相册图片拍照逻辑核心很简单构建ImageCapture用例调用takePicture在回调里把数据写入文件。但要考虑文件保存位置这里建议大家直接写到MediaStore系统会自动处理分区存储权限不会漏文件private fun takePhoto() { val photoFile File(cacheDir, temp_${System.currentTimeMillis()}.jpg) val outputOptions ImageCapture.OutputFileOptions.Builder(photoFile).build() imageCapture.takePicture( outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { val contentValues ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, IMG_${System.currentTimeMillis()}.jpg) put(MediaStore.Images.Media.MIME_TYPE, image/jpeg) put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp) } val uri contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) uri?.let { contentResolver.openOutputStream(it)?.use { os - photoFile.inputStream().use { fis - fis.copyTo(os) } } } } override fun onError(exc: ImageCaptureException) { Log.e(Camera, 拍照失败, exc) } } ) }为什么要先存临时文件再复制到MediaStore我在实际项目中图省事直接拿ByteArray写MediaStore遇到大图几千万像素时就出现OOM。CameraX的takePicture支持直接输出到一个文件再走流复制内存消耗几乎可以忽略。这一步的原理是相机原始帧压缩和保存都在Native层完成Java层只负责文件流搬运所以稳妥。3.4 录像VideoCapture与Media3播放录像和拍照是两个用例VideoCapture需要单独绑定。录制结束后得到一个MP4文件再用Media3播放这段视频两条链路就串起来了val videoCapture VideoCapture.Builder() .setTargetRotation(Surface.ROTATION_90) .build() cameraProvider.bindToLifecycle( this, cameraSelector, preview, videoCapture )这里有一个默认录制的参数值得注意VideoCapture默认的分辨率可能不是摄像头最大分辨率而是系统推荐的均衡值。你要追求画质需要手动在Builder里设置setResolutionSelector否则用户缩放全屏播放时画面可能发糊。播放就用Media3 ExoPlayer的的简易版val playerView: PlayerView findViewById(R.id.playerView) val player ExoPlayer.Builder(this).build() playerView.player player player.setMediaItem(MediaItem.fromUri(videoUri)) player.prepare() player.playWhenReady true如果你还需要“拍摄后立即预览”的体验可以把录制文件的Uri直接喂给播放器用户几乎无感知地完成“拍→看”的闭环。这种方式比先弹一个对话框选“是否保存”再跳转页面要现代很多也更适合短视频类应用。3.5 音频采集录制和播放不是一条路做录像时你可能会以为麦克风采集的音频会自动和视频流一起走但系统层的设计是分开的。MediaCodec只处理视频流音频需要用AudioRecord采集PCM然后交给MediaCodec编码成AAC最后用MediaMuxer把两路码流合进一个MP4容器。简化流程大概是val audioRecord AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.MIC) .setAudioFormat(...) // 采样率44100Hz、单声道、PCM_16BIT .build() // 编码循环 while (isRecording) { val readSize audioRecord.read(audioBuffer, 0, audioBuffer.size) // 往编码器输入队列塞入audioBuffer } // 每编出一帧AAC就写入mediaMuxer这段链路我花了一整周才真正吃透核心问题在于音视频的presentationTimeUs展示时间戳必须对齐。视频的时间戳来自每一帧被采集时的系统时间音频的则来自持续累加的采样间隔。如果你的基准不同合成出来的文件会音画错位。我自己的做法是在开始录制时记录startTime System.nanoTime()之后每一帧的时间戳都用相对时间避免系统时间跳变导致串轨。4. 常见问题与排查技巧实录4.1 预览黑屏但摄像头指示灯亮着黑屏是最常见的你第一步要区分是“流没通”还是“渲染层没显示”。一个快速定位技巧在Preview.setSurfaceProvider之前打日志或用preview.implementationMode切换抓现场。如果指示灯亮但没有画面大概率是Provider设置晚了或者你已经把预览绑到了一个被销毁的LifecycleOwner上。第二个高频黑屏原因是“摄像头占用”比如你测试机上同时开着微信扫码。系统同一时刻只允许一个App访问单个摄像头CameraX会在后台等摄像头释放但等不到就什么都不渲染。可以监听Camera.State回调把它作为“相机当前状态”的信号提示用户“请关闭其他使用相机的应用”。4.2 拍照后照片方向是歪的这是一个老生常谈的坑。表面上是“竖屏拍照拍出来的照片却是横的”原因是相机传感器的计数器方向和自然方向不一致。CameraX默认已经在内部通过rotationDegrees做了一次纠正你拿到照片文件时通常已经转正。但如果你在拍照回调里又做了一次“Exif读取旋转角并手动转正”就会重复旋转最后得到一张歪得更离谱的图。正确的做法是保存时只保存原始输出展示时通过ExifInterface读取TAG_ORIENTATION判断是否需要旋转而不要修改文件本身的Exif信息。图片库类应用通常都能识别比如微信、系统相册都能正确还原一旦你擅自旋转图片其他应用再读取时就会双重旋转。4.3 保存到相册时报错“Permission Denied”很多人在Android 11以上遇到这种情况以为自己没有申请存储权限其实是你用的是旧的WRITE_EXTERNAL_STORAGE权限而系统已经不授予新的分区存储逻辑。我建议所有的新App都不要再申请这个权限直接用MediaStore插入文件再由系统映射到外部存储。真要让用户能访问相册只需要保留READ_MEDIA_IMAGES和READ_MEDIA_VIDEO权限Android 13或者用系统相册选择器ActivityResultContracts.PickVisualMedia还不用存储权限体验也更安全。4.4 播放视频卡顿、OOM播放卡顿通常从两端查网络端和渲染端。网络端要看是不是没做自适应码率导致在弱网环境强行拉高清晰度渲染端要看是否开启了落盘缓存即CacheDataSource是否在列表页同时创建了多个Player却没释放。我这里有个经验列表页的视频卡片不要“预加载完整个Player”而是用一个轻量的Bitmap加载器出帧只有点进详情页才创建真正的Player。这样滑动列表时用的资源几乎为零。OOM高发于你手动把视频转成Bitmap去做缩略图。正确套路是使用MediaMetadataRetriever.getFrameAtTime()条件合适时用较小的outWidth和outHeight参数请求缩略图。千万不要直接用BitmapFactory.decodeFile加载一个视频帧的完整分辨率它会瞬间吃满你的堆内存。4.5 录像时没有声音或声音极轻这个问题排查只有一个思路检查AudioRecord的采集线程是否有足够的Buffering。在部分手机上录音线程如果和录像数据写文件的主循环共用同一个线程会互相阻塞导致录进来的AAC帧时间戳跳变。你单独开一条线程采集音频并给采集Buffer设置一个最小缓冲大小的系数通常是官方建议值的2倍基本能解决。5. 性能优化与真机调试心得5.1 从预览到编码帧率为什么掉了一半一个很常被低估的坑你把PreviewView的分辨率设成摄像头最大分辨率比如4K结果预览帧率明显低于30fps。这是因为相机输出到Preview的每一帧都是完整分辨率系统在合成时经历了很大的缩放开销。优化办法是给预览单独指定一个较小的默认分辨率比如1280x720肉眼几乎看不出和1080P的区别但帧率能提升一倍以上。录像的分辨率如果需要4K就录像单独用大分辨率预览降级让两条链路分开跑。CameraX从ResolutionSelector的设计上已经支持这种“分层策略”不要图省事直接把整个CameraBinder统一用一个分辨率。5.2 真机调试不能只看一台机器做相机和多媒体应用真机测试的覆盖面直接决定你的发布质量。我给自己的项目定了一个最低测试矩阵一台老旗舰Android 10、骁龙865、一台新中端Android 13、骁龙7系、一台大屏平板。老旗舰能暴露出内存和图形管线问题新中端能反映兼容性优化是否到位平板则能检验布局和旋转方向。不要依赖模拟器模拟器上CameraX往往直接走虚拟摄像头很多真实硬件的坑都被抹平了。见过太多“模拟器上预览完美真机黑屏”的惨案。5.3 选型理解把CameraX扩展想象成一层壳当业务要求“在人脸上加个特效贴纸”“实时调整美颜参数”你大概率要走到CameraX的底层借助CameraControl和CameraInfo去控制曝光、对焦甚至要引入一些专门做实时滤镜的库。此时你的代码结构要用好“装饰模式”把滤镜处理逻辑挂在ImageAnalysis用例上每帧进来后做处理再重新投递给编码器或渲染器而不要试图重写CameraX的内部流程。我见过一个项目为了做“瘦脸”效果直接在OutputFileOptions的输出文件上做二次处理效果不仅延迟高还被用户吐槽卡顿。正确方式是在ImageAnalysis分析器里用OpenGL做滤镜渲染到纹理再把这个纹理同步给预览或者编码器。你真正要学的是如何管理“一帧图像从分析到显示”的完整生命周期而不是把滤镜效果放到存储后处理。最后分享一点实操Xin得这是我个人在实际项目中积累的最有价值的一条经验无论你最终选了什么框架都先把“数据流”画一遍不要上来就写代码。比如相机链路一帧图上从Sensor出来经过哪个Surface走到哪个编码器最后又写入哪一个文件格式每一步涉及哪个对象根本没画清楚前越写越混乱。另一个小技巧是给CameraX增加一层自己的应用级封装不要到处直接调用bindToLifecycle。统一做成一个CameraManager对外暴露openPreview(callback)、takePhoto(onResult)、startRecord(onFinish)内部把CameraX管好。这样将来要换底层实现或者支持多摄像头同时预览改动范围会被限制在一个文件里不至于全项目都被牵连。相机和多媒体框架这一章看起来杂你只要抓住“生命周期绑定”和“数据链路落地”这两个主线后续不管互联网上冒出多少新库、新API都能等你吃透它之后继续写你的业务。判断一个技术点是否真正掌握标准也很朴素你不看抄来的Demo能自己从零搭起一个“预览→拍照→存储→显示”的最小闭环就成了。