Android MediaProjection 截屏录屏适配与避坑指南 简介这份资源聚焦 Android 平台基于 MediaProjection 的截屏与录屏实现面向需要处理屏幕采集的 Android 开发者尤其是被 Android Q 前台服务限制卡住的同学。包内重点解决 Android Q 中 java.lang.SecurityException: Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION 异常并确认 targetSdk 32 下可用适合作为项目集成与排错参考。压缩包共 42 个文件约 120KB以 8 个 Kotlin 源码文件为核心配合 7 个 XML 布局与配置、3 个 Gradle 构建脚本及 properties、jar、proguard 规则等工程文件另有 webp 截图与说明素材目录结构完整可直接导入 Android Studio 运行调试。目前已有 4019 人学习下载。读者可从中获得前台服务类型声明、权限申请与 MediaProjection 调用链的完整示例快速定位高版本适配问题减少踩坑时间。1. 从一次投屏黑屏说起Android MediaProjection 截屏与录屏到底难在哪做 Android 端投屏或录屏功能时很多人第一次调用 MediaProjection 都会遇到同一个场景代码编译通过权限也申请了getMediaProjection()返回了对象VirtualDisplay 也创建成功结果ImageReader里拿到的画面全黑或者录出来的 MP4 只有第一帧。这不是玄学而是 MediaProjection 这套 API 从 Android 5.0 引入、到 Android 14 引入前台服务类型、再到 Android 15 进一步收紧权限之后每一代都加了新约束。标题里的「适配 Android」不是一句口号它意味着你要同时处理 API 级别差异、前台服务声明、权限回调时序、Surface 生命周期这四件事。这篇笔记面向的是已经动手写过截屏或录屏、但被黑屏、崩溃、录不全、后台被杀这些问题卡住的 Android 开发者我会把从权限申请到编码输出的完整链路拆开给出可直接抄的代码和参数也把每个版本踩过的坑标出来。2. MediaProjection 权限链路从 requestScreenCapture 到前台服务MediaProjection 的权限模型和普通运行时权限不一样它走的是ActivityResultContracts或旧的onActivityResult回调返回一个Intent再用这个 Intent 去getMediaProjection()。很多人卡在第一步就是因为把MediaProjectionManager.createScreenCaptureIntent()当成普通权限申请忘了它必须由 Activity 发起而且从 Android 14 开始拿到 MediaProjection 之后必须立刻启动一个foregroundServiceTypemediaProjection的前台服务否则系统会在几秒内回收授权。2.1 权限申请与 MediaProjection 获取的正确时序先看权限申请这一段。用registerForActivityResult是现在的主流做法注意createScreenCaptureIntent返回的 Intent 必须原样传给getMediaProjection不能自己 new 一个。// 在 Activity 中注册结果回调 private val mediaProjectionLauncher registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result - if (result.resultCode Activity.RESULT_OK result.data ! null) { // 关键result.data 必须原样保存后续 getMediaProjection 要用 val projectionIntent result.data!! // Android 14 要求先启动前台服务再获取 MediaProjection startMediaProjectionService(projectionIntent) } else { // 用户拒绝不要重试弹窗直接提示 Toast.makeText(this, 需要录屏权限才能继续, Toast.LENGTH_SHORT).show() } } // 发起申请 fun requestScreenCapture() { val manager getSystemService(MediaProjectionManager::class.java) mediaProjectionLauncher.launch(manager.createScreenCaptureIntent()) }这段代码的逻辑是createScreenCaptureIntent()会弹出一个系统级对话框用户点「立即开始」后result.data里携带一个 token这个 token 只能被消费一次。参数上要注意result.resultCode必须是RESULT_OKresult.data不能为 null。很多人把result.data存到静态变量里跨进程用这在 Android 10 之后会失败因为 Intent 里的 binder token 不能跨进程传递。2.2 Android 14 前台服务类型声明与启动顺序Android 14 开始foregroundServiceType必须显式声明mediaProjection否则启动前台服务会抛SecurityException。先在AndroidManifest.xml里加声明service android:name.MediaProjectionService android:foregroundServiceTypemediaProjection android:exportedfalse /然后在代码里启动服务注意顺序先startForeground再getMediaProjection。class MediaProjectionService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val projectionIntent intent?.getParcelableExtraIntent(projection_intent) // Android 14 必须先 startForeground再获取 MediaProjection startForeground(NOTIFICATION_ID, buildNotification()) val manager getSystemService(MediaProjectionManager::class.java) val projection manager.getMediaProjection(Activity.RESULT_OK, projectionIntent!!) // 注册回调Android 14 必须注册否则会抛异常 projection.registerCallback(object : MediaProjection.Callback() { override fun onStop() { // 用户从通知栏停止录屏时会回调这里 stopSelf() } }, Handler(Looper.getMainLooper())) // 后续创建 VirtualDisplay return START_NOT_STICKY } }这里有个血泪经验registerCallback在 Android 14 上是必须的不注册的话getMediaProjection之后调用createVirtualDisplay会直接抛IllegalStateException。参数上NOTIFICATION_ID不能是 0通知渠道在 Android 8.0 以上必须创建否则前台服务起不来。2.3 权限被回收的三种触发条件与监听方式MediaProjection 授权不是永久的以下三种情况会触发onStop用户在通知栏点「停止」、系统检测到应用进入后台且没有前台服务、用户切换了用户账户。监听方式就是上面代码里的MediaProjection.Callback。注意onStop回调之后VirtualDisplay会自动释放ImageReader的 Surface 会变成无效如果你还在往编码器里写数据会直接崩。常见做法是在onStop里把编码器、VirtualDisplay、ImageReader 全部按顺序释放顺序错了会 ANR。3. 截屏实现ImageReader 拿帧与 PixelFormat 选型截屏的核心是ImageReader它提供一个 SurfaceVirtualDisplay 把屏幕内容渲染到这个 Surface 上你从ImageReader里acquireLatestImage()拿到Image再转成 Bitmap。听起来简单但 PixelFormat 选错、maxImages 设小、线程阻塞都会导致黑屏或丢帧。3.1 ImageReader 创建参数与 VirtualDisplay 绑定先看创建参数。ImageReader.newInstance(width, height, format, maxImages)四个参数里format和maxImages最容易翻车。// 屏幕宽高注意要用真实分辨率不要用 DisplayMetrics 的缩放值 val metrics resources.displayMetrics val width metrics.widthPixels val height metrics.heightPixels // PixelFormat.RGBA_8888 兼容性最好但内存占用大 // PixelFormat.RGBX_8888 少一个 alpha 通道截屏场景够用 val imageReader ImageReader.newInstance( width, height, PixelFormat.RGBA_8888, 2 // maxImages 建议 2太大容易 OOM太小会丢帧 ) val virtualDisplay projection.createVirtualDisplay( ScreenCapture, width, height, metrics.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, imageReader.surface, null, null )参数说明width和height必须和实际屏幕分辨率一致用DisplayMetrics拿到的值在折叠屏或分屏模式下可能不准建议用WindowManager.getCurrentWindowMetrics()。maxImages设为 2 是经验值设 1 的话acquireLatestImage会阻塞设 4 以上在低内存设备上容易触发 GC 抖动。VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR是必须的不加的话 VirtualDisplay 不会自动镜像屏幕内容。3.2 从 Image 到 Bitmap 的转换与 RowStride 处理拿到Image之后不能直接image.planes[0].buffer就完事因为rowStride可能大于width * pixelStride直接拷贝会花屏。fun imageToBitmap(image: Image): Bitmap { val plane image.planes[0] val buffer plane.buffer val pixelStride plane.pixelStride val rowStride plane.rowStride val rowPadding rowStride - pixelStride * image.width // 先创建一个带 padding 的 Bitmap val bitmap Bitmap.createBitmap( image.width rowPadding / pixelStride, image.height, Bitmap.Config.ARGB_8888 ) bitmap.copyPixelsFromBuffer(buffer) // 再裁掉 padding 部分 return Bitmap.createBitmap(bitmap, 0, 0, image.width, image.height) }逻辑说明rowStride是每行实际占用的字节数可能因为硬件对齐比width * 4大。rowPadding就是多出来的部分。先创建宽一点的 Bitmap 把 buffer 拷进去再裁剪这样不会花屏。参数上pixelStride在 RGBA_8888 下通常是 4但某些设备可能是 8所以要用plane.pixelStride动态取不要写死。3.3 截屏线程模型为什么不能在主线程 acquireLatestImageacquireLatestImage()是阻塞调用如果 ImageReader 里没有新帧它会一直等。在主线程调用直接 ANR。正确做法是开一个 HandlerThread在后台线程循环拿帧。private val captureThread HandlerThread(ScreenCapture).apply { start() } private val captureHandler Handler(captureThread.looper) fun startCapture() { captureHandler.post(object : Runnable { override fun run() { val image imageReader.acquireLatestImage() ?: return try { val bitmap imageToBitmap(image) // 处理 bitmap比如保存或编码 } finally { image.close() // 必须 close否则 ImageReader 会卡死 } // 继续下一帧 captureHandler.post(this) } }) }注意image.close()必须放在 finally 里漏掉一次就会导致maxImages耗尽后续acquireLatestImage永远返回 null。这是最常见的黑屏原因之一。4. 录屏实现MediaCodec 编码与 MediaMuxer 封装录屏比截屏多两步把 ImageReader 的帧喂给 MediaCodec 编码成 H.264再用 MediaMuxer 封装成 MP4。这里最容易踩的坑是时间戳不对齐、编码器 surface 模式用错、Muxer 启动时机不对。4.1 MediaCodec 配置参数与 Surface 模式选择MediaCodec 有两种输入模式Surface 模式和 ByteBuffer 模式。录屏场景推荐 Surface 模式因为 VirtualDisplay 可以直接输出到 MediaCodec 的 inputSurface省去 ImageReader 中转性能更好。val mediaFormat MediaFormat.createVideoFormat( video/avc, width, height ).apply { setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) setInteger(MediaFormat.KEY_BIT_RATE, 4 * 1024 * 1024) // 4Mbps setInteger(MediaFormat.KEY_FRAME_RATE, 30) setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // 关键帧间隔 1 秒 } val codec MediaCodec.createEncoderByType(video/avc) codec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) val inputSurface codec.createInputSurface() codec.start() // VirtualDisplay 输出到编码器的 inputSurface val virtualDisplay projection.createVirtualDisplay( ScreenRecord, width, height, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, null, null )参数说明KEY_BIT_RATE设 4Mbps 是 1080p 30fps 的常用值太低会糊太高文件大。KEY_I_FRAME_INTERVAL设 1 秒方便拖动进度条。COLOR_FormatSurface是 Surface 模式必须的设成其他值会 configure 失败。4.2 MediaMuxer 启动时机与时间戳对齐MediaMuxer 不能在编码器 start 之前 start必须等INFO_OUTPUT_FORMAT_CHANGED回调之后才能拿到真正的 outputFormat。var muxerStarted false val muxer MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) var trackIndex -1 val bufferInfo MediaCodec.BufferInfo() while (true) { val outputIndex codec.dequeueOutputBuffer(bufferInfo, 10000) when { outputIndex MediaCodec.INFO_OUTPUT_FORMAT_CHANGED - { trackIndex muxer.addTrack(codec.outputFormat) muxer.start() muxerStarted true } outputIndex 0 - { if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG ! 0) { bufferInfo.size 0 // 忽略配置帧 } if (bufferInfo.size 0 muxerStarted) { val buffer codec.getOutputBuffer(outputIndex)!! buffer.position(bufferInfo.offset) buffer.limit(bufferInfo.offset bufferInfo.size) muxer.writeSampleData(trackIndex, buffer, bufferInfo) } codec.releaseOutputBuffer(outputIndex, false) } } }时间戳对齐的关键是bufferInfo.presentationTimeUs它由编码器根据 inputSurface 的帧时间自动生成不要手动改。如果发现录出来的视频快进或慢放多半是 VirtualDisplay 的刷新率和编码器帧率不匹配把KEY_FRAME_RATE设成和屏幕刷新率一致比如 60可以缓解。4.3 停止录屏时的资源释放顺序停止顺序错了会丢最后一帧或者 Muxer 文件损坏。正确顺序是先停止 VirtualDisplay再给编码器发 EOS等编码器输出完再 stop Muxer。fun stopRecording() { virtualDisplay.release() // 1. 先释放 VirtualDisplay codec.signalEndOfInputStream() // 2. 通知编码器结束 // 3. 继续 drain 编码器直到收到 BUFFER_FLAG_END_OF_STREAM // 4. 收到 EOS 后 codec.stop() codec.release() if (muxerStarted) { muxer.stop() // 5. 最后 stop Muxer muxer.release() } projection.stop() // 6. 停止 MediaProjection }漏掉signalEndOfInputStream会导致编码器一直等新帧Muxer 永远不 stop文件无法播放。这是录屏翻车的高频原因。5. 适配避坑黑屏、崩溃、录不全的排查清单这一章集中讲 5 个最常见的坑每条按现象、原因、解决来写。5.1 现象截屏全黑ImageReader 拿不到帧原因VirtualDisplay 创建时width和height用了缩放后的值或者maxImages设为 1 导致acquireLatestImage阻塞后返回 null。解决用WindowManager.getCurrentWindowMetrics()拿真实分辨率maxImages至少设 2并且确保在后台线程循环拿帧。5.2 现象Android 14 上启动前台服务抛 SecurityException原因AndroidManifest.xml里没有声明android:foregroundServiceTypemediaProjection或者声明了但startForeground时没有传对应的 type。解决在 manifest 里加声明并且在startForeground的第二个参数里传ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION。5.3 现象录屏文件只有第一帧后面全是黑原因MediaMuxer在INFO_OUTPUT_FORMAT_CHANGED之前就调用了start()或者writeSampleData时bufferInfo.size为 0 还继续写。解决严格等INFO_OUTPUT_FORMAT_CHANGED回调后再muxer.start()并且在写之前判断bufferInfo.size 0。5.4 现象录屏过程中应用切后台录屏自动停止原因Android 10 之后应用进入后台如果没有前台服务MediaProjection 会被系统回收。解决确保前台服务在录屏期间一直运行并且通知渠道不能被用户关闭。另外在onStop回调里做好资源清理避免二次崩溃。5.5 现象录出来的视频花屏或颜色不对原因ImageReader的PixelFormat和MediaCodec的COLOR_FormatSurface不匹配或者rowStride没处理。解决录屏场景直接用 Surface 模式不要经过 ImageReader如果必须用 ImageReader按 3.2 节处理rowStride。6. 进阶技巧用 MediaCodec 异步模式降低录屏延迟前面用的都是dequeueOutputBuffer同步模式它在低端设备上容易因为轮询超时导致掉帧。Android 5.0 之后 MediaCodec 支持异步模式通过setCallback注册回调编码器有输出时主动通知延迟更低。codec.setCallback(object : MediaCodec.Callback() { override fun onOutputBufferAvailable( codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo ) { if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG ! 0) { info.size 0 } if (info.size 0 muxerStarted) { val buffer codec.getOutputBuffer(index)!! buffer.position(info.offset) buffer.limit(info.offset info.size) muxer.writeSampleData(trackIndex, buffer, info) } codec.releaseOutputBuffer(index, false) } override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { trackIndex muxer.addTrack(format) muxer.start() muxerStarted true } override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { // 编码器出错清理资源 stopRecording() } override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { // Surface 模式下不需要手动喂数据留空 } }) codec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) codec.start() // 注意setCallback 必须在 start 之前调用异步模式的关键是setCallback必须在configure之后、start之前调用顺序错了会抛IllegalStateException。另外onOutputBufferAvailable里不要做耗时操作否则会阻塞编码器线程。我一般会把writeSampleData放到一个单独的写线程里用队列缓冲避免 Muxer 的 IO 拖慢编码。验证异步模式是否生效可以看两个指标一是录屏文件的帧率是否稳定在设定值附近二是onOutputBufferAvailable的调用间隔是否均匀。如果间隔抖动超过 10ms说明编码器在等资源需要降低分辨率或码率。这个方案值不值得做取决于你的场景如果只是偶尔截个图同步模式够用如果是长时间录屏或者投屏直播异步模式带来的延迟降低和帧率稳定是实打实的。我自己踩过最深的坑是忘了在onStop里释放编码器导致用户停止录屏后应用还在后台占着摄像头和编码器资源下次录屏直接失败。后来养成的习惯是任何 MediaProjection 相关的资源都在一个releaseAll()方法里按固定顺序释放并且加日志确认每一步都执行到。希望帮到你。本文还有配套的精品资源点击获取