zl程序教程

您现在的位置是:首页 >  移动开发

当前栏目

Android 音视频开发(六) -- Android Mediaprojection 截屏和录屏

Android开发 -- 音视频 截屏 录屏
2023-09-27 14:28:04 时间

Android 音视频开发(一) – 使用AudioRecord 录制PCM(录音);AudioTrack播放音频
Android 音视频开发(二) – Camera1 实现预览、拍照功能
Android 音视频开发(三) – Camera2 实现预览、拍照功能
Android 音视频开发(四) – CameraX 实现预览、拍照功能
Android 音视频开发(五) – 使用 MediaExtractor 分离音视频,并使用 MediaMuxer合成新视频(音视频同步)
Android 音视频开发(六) – Android Mediaprojection 截屏和录屏

音视频工程

这章学习Android录屏,效果如下:

截屏录屏

从这一章,我们将看到

  1. MediaProjection 的基本使用
  2. ImageReader 与 MediaProjection 实现截屏
  3. MediaRecorder 与 MediaProjection 实现录屏

一. MediaProjection 的基本使用

MediaProjection 的使用非常简单,调用的是MediaProjectionManager对象,它会创建申请录屏的 Intent:

mediaManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
mediaManager.createScreenCaptureIntent().apply {
    startActivityForResult(this, 2)
}

此时会弹出一个申请录屏的弹窗,点击确定就开始录屏了,如果点击确定,就可以在 onActivityResult 中拿到 MediaProjection 对象

 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
      super.onActivityResult(requestCode, resultCode, data)
      if (requestCode == 2 && resultCode == Activity.RESULT_OK) {
          data?.let {
              //获取到操作对象
              mediaProjection = mediaManager.getMediaProjection(resultCode, it)
              }
      }
 }

1.1. 获取数据

拿到 MediaProjection 对象,就可以去拿数据了, 它会通过创建 VirtualDisplay 去获取屏幕的内容,VirtualDisplay 是一个虚拟显示,它会根据应用提供的 Surface ,把内容渲染到 Surface上,这里的 Surface 可以是 ImageReader 的,也可以是MediaRecorder 或 MediaCodec的。
它的调用为:

virtualDisplay = mediaProjection?.createVirtualDisplay(
         TAG,  //virtualDisplay 的名字,随意写 
         dm.widthPixels, //virtualDisplay 的宽
         dm.heightPixels, //virtualDisplay 的高
         dm.densityDpi, // virtualDisplay 的 dpi 值,这里都跟应用保持一致即可 
         // 显示的标志位,不同的标志位,截取不同的内容,具体看源码解释
         DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
         surface, //获取内容的 surface
         null, //回调
         null  //回调执行的handler

二. 截屏

实际上,此时你的程序已经在获取屏幕的数据的,如果你的surface 是 SurfaceView,还会看到一帧一帧的数据。
截屏实际上就是获取当前屏幕的画面,这里可以使用 ImageReader ,Imageereader 类允许应用程序直接访问渲染到 Surface 中的图像数据,在使用 Camrea2 获取拍照数据也是使用了它。Android 音视频开发(三) – Camera2 实现预览、拍照功能
然而需要注意的是,Camera 获取的是 YUV 数据,而MediaProjection 获取的则是 RGBA 的数据,所以它的初始化为:

    private fun configImageReader() {
        val dm = resources.displayMetrics
        imageReader = ImageReader.newInstance(dm.widthPixels, dm.heightPixels,
                PixelFormat.RGBA_8888, 1).apply {
            
            //监听图片生成
            setOnImageAvailableListener({
                savePicTask(it)
            }, null)
            //把内容投射到ImageReader 的surface
            mediaProjection?.createVirtualDisplay(TAG, dm.widthPixels, dm.heightPixels, dm.densityDpi,
                    DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC, surface, null, null)
        }

    }

需要注意的是初始化 ImageReader 的第三个参数,改为 PixelFormat.RGBA_8888,获取线性的像素RGBA,后面生成图片需要,大小则设置为1,这里后面解释。
然后把 ImageReader 的 surface 给VirtualDisplay就可以了,此时 ImageReader 就能拿到 VirtualDisplay 的内容了,而我们设置了 setOnImageAvailableListener 图片监听,但有数据时,就会回调,我们就可以保存图片了。

2.1 保存图片

首先调用 ImageReader 的acquireLatestImage() ,从 ImageReader 的队列中获取最新的 Image,删除旧图像。如果没有新图像可用,返回 null。所以从原理看,ImageReader 初始化最大个数设置为2,则能避免 null 的情况,但是如果都能获取到新图片,则会显示两张,这里为了美观,就设置成1,大家可以试试。

拿到 Image 后,就可以通过 getPlanes 拿到buffer了,只要不是 yuv,只需要拿 planes[0] 的数据即可:

 //获取捕获的照片数据
  image = reader.acquireLatestImage()
  val width = image.width
  val height = image.height
  //拿到所有的 Plane 数组
  val planes = image.planes
  val plane = planes[0]
  val buffer: ByteBuffer = plane.buffer

如果转换成图片?
从上面的 ByteBuffer 可知,其实可以使用 bitmap.copyPixelsFromBuffer(buffer) 直接转换,但这里存在一个问题,因为线性内存对称问题,有些手机是不对称的,导致宽高比不一致,就会出现花屏碎屏的问题。所以需要做一个转换:

val buffer: ByteBuffer = plane.buffer
 //相邻像素样本之间的距离,因为RGBA,所以间距是4个字节
 val pixelStride = plane.pixelStride
 //每行的宽度
 val rowStride = plane.rowStride
 //因为内存对齐问题,每个buffer 宽度不同,所以通过pixelStride * width 得到大概的宽度,
 //然后通过 rowStride 去减,得到大概的内存偏移量,不过一般都是对齐的。
 val rowPadding = rowStride - pixelStride * width
 // 创建具体的bitmap大小,由于rowPadding是RGBA 4个通道的,所以也要除以pixelStride,得到实际的宽
 val bitmap = Bitmap.createBitmap(width + rowPadding / pixelStride,
         height, Bitmap.Config.ARGB_8888)
 bitmap.copyPixelsFromBuffer(buffer)

注释已经说清楚了,就不多赘述,详细代码如下:

    /**
     * 保存图片
     */
    private fun savePicTask(reader: ImageReader) {
        scopeIo {
            var image: Image? = null
            try {
                //获取捕获的照片数据
                image = reader.acquireLatestImage()
                val width = image.width
                val height = image.height
                //拿到所有的 Plane 数组
                val planes = image.planes
                val plane = planes[0]

                val buffer: ByteBuffer = plane.buffer
                //相邻像素样本之间的距离,因为RGBA,所以间距是4个字节
                val pixelStride = plane.pixelStride
                //每行的宽度
                val rowStride = plane.rowStride
                //因为内存对齐问题,每个buffer 宽度不同,所以通过pixelStride * width 得到大概的宽度,
                //然后通过 rowStride 去减,得到大概的内存偏移量,不过一般都是对齐的。
                val rowPadding = rowStride - pixelStride * width
                // 创建具体的bitmap大小,由于rowPadding是RGBA 4个通道的,所以也要除以pixelStride,得到实际的宽
                val bitmap = Bitmap.createBitmap(width + rowPadding / pixelStride,
                        height, Bitmap.Config.ARGB_8888)
                bitmap.copyPixelsFromBuffer(buffer)

                withMain {
                    val canvas = surfaceview.holder.lockCanvas()
                    with(canvas) {
                        drawBitmap(bitmap, 0f, 0f, null)
                        surfaceview.holder.unlockCanvasAndPost(this)
                    }
                    Toast.makeText(this@MediaProjectionActivity, "保存成功", Toast.LENGTH_SHORT).show()
                    mediaProjection?.stop()
                }
            } catch (e: java.lang.Exception) {
                Log.d(TAG, "zsr doInBackground: $e")
            } finally {
                //记得关闭 image
                try {
                    image?.close()
                } catch (e: Exception) {
                }
            }
        }
    }

三. 录屏

MediaProjection 能获取屏幕的数据,这个就有很多操作空间,如常用的录屏到文件再播放,游戏录屏功能,也可以把数据用 MediaCodec 编码之后发送给其他接收端,如Maxhub、录播,eshare 这些投屏软件。
这里使用的 MediaRecorder 去保存录屏数据到文件,再播放。

3.1 初始化 MediaRecorder

上面说到,MediaProjection 的 VirtualDisplay 会把数据放到 surface 上,所以使用 MediaRecorder 的 surface 就能拿到数据了,再把它放到文件即可。

val dm = resources.displayMetrics
recorder = MediaRecorder()
 recorder?.apply {
     //setAudioSource(MediaRecorder.AudioSource.MIC) //音频载体
     setVideoSource(MediaRecorder.VideoSource.SURFACE) //视频载体
     setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) //输出格式
     setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB) //音频格式
     setVideoEncoder(MediaRecorder.VideoEncoder.H264) //视频格式
     setVideoSize(dm.widthPixels, dm.heightPixels) //视频大小
     //帧率,30是比较舒服的帧率
     setVideoFrameRate(30)
     //比特率,不需要太高的比特率,3m就很清晰了
     setVideoEncodingBitRate(3 * 1024 * 1024) 
     //设置文件位置
     setOutputFile(file.absolutePath)
 }

MediaRecoder 的配置也比较简单,只要设置视频格式,编码格式,和帧率这些常规的操作即可。其实 MediaRecorder 也可以录制音频,可以录制环绕音等,也就是传屏软件的音视频同步功能,但你得自己计算 pts ,算出偏差值,不然就会出现视频音频对不上。虽然 Android 10 后也提供了接口,但是也得第三方应用支持才行。好吧,跑题了,这里只需要视频数据即可。

接着调用 prepare() 准备,然后把 surface 给 MediaProjection 即可:

try {
     prepare()
     virtualDisplay = mediaProjection?.createVirtualDisplay(
             TAG,  //virtualDisplay 的名字,随意写
             dm.widthPixels, //virtualDisplay 的宽
             dm.heightPixels, //virtualDisplay 的高
             dm.densityDpi, // virtualDisplay 的 dpi 值,这里都跟应用保持一致即可
             // 显示的标志位,不同的标志位,截取不同的内容,具体看源码解释
             DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
             surface, //获取内容的 surface
             null, //回调
             null  //回调执行的handler
     )
 } catch (e: Exception) {
     Log.e(TAG, "MediaRecord prepare fail : $e")
     e.printStackTrace()
     return false
 }
 recorder?.start()

这里就可以保存数据到文件了,但你想暂停时,可以使用

 recorder?.stop()
mediaProjection?.stop()

然后再使用 MediaPlayer 或者其他播放视频的软件播放即可。

参考:
https://developer.android.google.cn/reference/android/media/ImageReader?hl=en
https://developer.android.google.cn/reference/android/media/Image.Plane?hl=en