如何在 Android 中录制屏幕内容,并以H.264数据流形式发送(屏幕广播)

这是一个不太常见的需求,因为博主本人所在公司是做教育相关产品的,故而有此需求,通过录制学生端pad屏幕,进行屏幕广播,本文主要介绍其中需要注意的一些关键点,详细代码可以在文末的 Github 仓库中查看。

1. 权限申请

不同于普通的动态权限申请,屏幕录制的权限在每次使用 App 时都需要重新申请一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
object Utils {
const val REQUEST_MEDIA_PROJECTION = 1

/**
* 申请录屏权限
*/
fun createPermission(activity: Activity) {
val mediaProjectionManager =
activity.application.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val intent = mediaProjectionManager.createScreenCaptureIntent()
activity.startActivityForResult(intent, REQUEST_MEDIA_PROJECTION)
}
}

onActivityResult 回调中保存 resultCodedata,这两个参数将会在后续用于实例化 MediaProjection 对象

1
2
3
4
5
6
7
8
9
10
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
//授权成功,保存intent,在后续需要使用该intent申请相关屏幕录制的对象
if (requestCode == Utils.REQUEST_MEDIA_PROJECTION) {
if (resultCode == Activity.RESULT_OK) {
//保存intent
GlobalConfig.intent = data!!
}
}
}

2. 创建 MediaCodec 编码器

1
2
3
4
5
6
7
mMediaCodecEncoder = MediaCodec.createEncoderByType("video/avc") // H.264编码格式
//配置编码器
val mediaFormat = Utils.getMediaFormat()
mMediaCodecEncoder.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
//该surface用于下一步中创建VirtualDisplay
surface = mMediaCodecEncoder.createInputSurface()
mMediaCodecEncoder.start()

3. 创建虚拟显示器 VirtualDisplay

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GlobalConfig.intent?.let {
(this.getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager).getMediaProjection(
AppCompatActivity.RESULT_OK,
it
).apply {
//使用MediaProjection创建VirtualDisplay
val dpi = resources.displayMetrics.densityDpi
LogUtils.d("dpi:$dpi")
val virtualDisplay = this.createVirtualDisplay(
"MainScreen", 720, 1280, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC, surface, null, null
)
LogUtils.d("创建成功: ${virtualDisplay?.display?.width} x ${virtualDisplay?.display?.height}")
}
} ?: run {
LogUtils.e("RecordService intent is null")
return
}

其中createVirtualDisplay参数有如下几种:

1
2
3
4
5
VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR:当没有内容显示时,允许将内容镜像到专用显示器上。
VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY:仅显示此屏幕的内容,不镜像显示其他屏幕的内容。
VIRTUAL_DISPLAY_FLAG_PRESENTATION:创建演示文稿的屏幕。
VIRTUAL_DISPLAY_FLAG_PUBLIC:创建公开的屏幕。
VIRTUAL_DISPLAY_FLAG_SECURE:创建一个安全的屏幕

一般来说用 VIRTUAL_DISPLAY_FLAG_PUBLIC 即可。

4. 开始录屏编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
    private fun startRecord() {
isRun = true
//防断流黑屏方法1:正常发送I帧 P帧,但是每隔1秒强制请求一次关键帧 I帧,
// setInterval(1000,1000) {
// if (isRun) {
// val params = Bundle()
// params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
// mMediaCodecEncoder.setParameters(params)
// }
// }
GlobalThreadPools.instance?.execute {
val mBufferInfo = MediaCodec.BufferInfo()
while (isRun) {
//输出缓冲区出列,返回缓冲的索引
val outputBufferIndex = mMediaCodecEncoder.dequeueOutputBuffer(
mBufferInfo,
-1 //超时时间,负数表示无限等待
)
if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
LogUtils.d("输出格式变化")
val format: MediaFormat = mMediaCodecEncoder.outputFormat
var byteBuffer = format.getByteBuffer("csd-0")
//根据缓冲区的容量创建一个字节数组,用于存储视频编码器的sps数据
val sps = ByteArray(byteBuffer?.capacity()!!)
byteBuffer.get(sps)
byteBuffer = format.getByteBuffer("csd-1")
//根据缓冲区的容量创建一个字节数组,用于存储视频编码器的pps数据
val pps = ByteArray(byteBuffer?.capacity()!!)
byteBuffer?.get(pps)
//拼接sps和pps
val spsPps = ByteArray(sps.size + pps.size)
System.arraycopy(sps, 0, spsPps, 0, sps.size)
System.arraycopy(pps, 0, spsPps, sps.size, pps.size)
h264SpsPpsData = spsPps
}
//索引为正数,表示缓冲区存在,可以获取缓冲区数据
if (outputBufferIndex >= 0) {
//传入索引值,获取缓冲区对象
val outputBuffer = mMediaCodecEncoder.getOutputBuffer(outputBufferIndex)
outputBuffer?.apply {
//确定该帧的起止位置
position(mBufferInfo.offset)
limit(mBufferInfo.offset + mBufferInfo.size)
//根据该帧的大小创建字节数组,并从缓冲区获取数据
val chunk = ByteArray(mBufferInfo.size)
get(chunk)
//获取帧画面数据完毕,调用编码器函数释放缓冲区,因为我们是录制屏幕,不需要渲染到surface,所以参数2传递false
mMediaCodecEncoder.releaseOutputBuffer(outputBufferIndex, false)
LogUtils.d("拿到录屏流数据:${chunk.size}")
//将流数据发送
if (chunk.isNotEmpty()) {
//防断流黑屏方法2:融合sps和pps,配合format中的每隔1秒请求一次关键帧 I帧
if ((chunk[4] and 0x1f).toInt() == 5) {
LogUtils.d("关键帧数据处理")
lifecycleScope.launch {
//发送sps和pps数据,这样可以避免掉线重连时因为没有sps和pps数据而导致黑屏
h264SpsPpsData?.let { data ->
sH264DataFlow.emit(data)
sOnReceiveH264DataCallback?.onReceiveH264Data(data)
}
}
}
//flow 与 回调各给一份 用kotlin的就用flow拿数据,用java就从回调拿数据
lifecycleScope.launch {
sH264DataFlow.emit(chunk)
}
sOnReceiveH264DataCallback?.onReceiveH264Data(chunk)
}
}
}
if (mBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
LogUtils.d("视频结束")
break
}
}
}
}

大致流程如下:

  1. 从编码器的缓冲队列获取缓冲数据索引 outputBufferIndex
  2. 索引 > 0 时,从编码器获取指定索引的缓冲 outputBuffer
  3. 根据 BufferInfo,从缓冲的中获取帧画面数据
  4. 用 Flow 或者 回调发送数据

写在最后

Demo代码仓库地址: junerver/TestCaptureAndRecord