SAKA'S BLOG

jrtplib实现局域网内推送手机桌面

涉及的内容较多,碰到不熟悉的请自行谷歌,基本问题都能解决,并且不提供任何技术支持与demo,主要介绍一下思路。

  1. mediacodec编码与解码h264流
  2. h264流的基本知识
  3. rtp协议的基本知识
  4. jni编程的基础知识

主要功能是实现安卓局域网内桌面推送。

简单看一下效果:

视频中上方的是发送程序,下方是接收程序,操作上方的屏幕可以投影到下方,延迟基本保证小于0.5s,满足实时投影的需求。

对比一下ffplay,视频中右侧平板是发送程序,左侧平板是接收程序,上方电脑也是接收程序,发送程序同时向两个接收端发送数据:

对比可以看到,ffplay在播放白色动画时处理并不好,会在缓冲区卡住一些帧,不能立即刷新。没研究过sdl,不知道什么原因。

思路

关于这个项目有讲不完的东西,挑主要的讲一下:

server_client

大致流程基本如上图所示。

首先要向系统申请屏幕录制权限,系统授予权限后将录制的屏幕创建到一个virtualdisplay来接收数据,virtualdisplay接收一个由 mediacodec创建的surface作为参数,将所有的数据传递给surface。mediacodec不断获取surface的数据作为输入,编码后输出数据,将数据保存在一个阻塞队列中,然后启动线程不断的从队列中取出数据,经过rtp包装发送出去。

接收端在线程中不断接收rtp数据,组装成为单帧的h264数据,然后回调java方法放入阻塞队列中,mediacodec不断的从队列中取出数据作为输入,解码输出给由surfaceview创建的surface,即可显示数据。

获取桌面录制权限

android.media.projection是安卓api21提供的一个包,共有三个类:

  • MediaProjection 授予应用获取屏幕截图和系统声音的令牌
  • MediaProjection.Callback 第一个类的回调函数
  • MediaProjectionManager 管理令牌的工具

MediaProjectionManager

MediaProjectionManager非常简单,共有两个方法createScreenCaptureIntent(),getMediaProjection(int resultCode, Intent resultData)

想要获取录屏权限,首先需先实例化MediaProjectionManager,有两种方式

1
2
MediaProjectionManager projectionManager = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
pMediaProjectionManager rojectionManager = getSystemService(MediaProjectionManager.class); //API>=23

下面用到第一个方法了,createScreenCaptureIntent()会返回一个Inten类型,这个值我们必须传给startactivityforresult(),系统会弹出提示是否允许用户录制屏幕,用户选择的结果将会在onactivityresult方法中表示。

这种方式只能获取屏幕截图的权限,不能获取录制系统声音的权限

1
2
3
4
5
6
7
8
9
10
11
12
startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_CODE);
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode != RESULT_OK) {
Toast.makeText(this, "录屏权限获取失败", Toast.LENGTH_SHORT).show();
return;
}
MediaProjection mediaProjection = projectionManager.getMediaProjection(resultCode, data);
//其他代码
}
}

getMediaProjection(int resultCode, Intent resultData)接两个参数,假如第一个参数resultCode不等于RESULT_OK,那么我们湖区截屏权限就是败了,返回的MediaProjection为空。

MediaProjection

总共四个方法。先介绍最重要的一个:
createVirtualDisplay(String name, int width, int height, int dpi, int flags, Surface surface, VirtualDisplay.Callback callback, Handler handler)。这个方法创建了一个VirtualDisplay,承载了所有的截图后的图像数据,这些图像数据将会被渲染到传入的参数surface中,这个surface将是解码的重要工具。
第一个参数是要创建的virtualdisplay的名称,这个名称可以自己定,但是不能为空。width heiht dpi三个参数都必须大于0,是创建virtualdisplay的三个参数,比较简单。最后两个参数是用来启注册virtualdisplay的回调函数,假如是在主线程中handler可以省略。假如是在非主线程中,最好用handlerthread,需要开启looper,最后一个参数传入handlerthreadhandler就可。

stop()方法是用来结束本次截屏会话的,当调用此方法后系统会终止屏幕数据的输出,并且如果为mediaprojection注册了回调函数(mediaprojection.callback,只有一个自己的方法onstop()),就会回调给onstop()方法,在这里可以用来释放一些资源,比如virtualdisplay。

另外两个方法就是注册和解注册回调方法,

1
2
registerCallback (MediaProjection.Callback callback, Handler handler)//主线程中handler可以设置为空,非主线程需设置handler并开启looper。
unregisterCallback (MediaProjection.Callback callback)

这样就完成了获取屏幕录制权限,并将屏幕数据定向到了一个surface中。这个surface将在后边继续讲解。

创建编码器

mediacodec

关于这部分内容可以参考官方文档,或者安卓解码器MediaCodec解析;

mediacodec是安卓底层编码解码的一个工具,使用它可以以更高效率的编解码视频音频。

目的是将屏幕数据以rtp协议荷载h264流来传输,需要将屏幕数据转换为h264流,那么使用mediacodec创建一个编码器来将数据编码。

创建解码器是一个非常蛋疼的过程,谷歌官方推荐使用mediacodecinfo来获取支持的编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 private static MediaCodecInfo selectCodec(String mimeType) {
MediaCodecList list = new MediaCodecList(REGULAR_CODECS);
MediaCodecInfo[] infos = list.getCodecInfos();
for (MediaCodecInfo m : infos) {
if (!m.isEncoder()) {
continue;
}
for (String type : m.getSupportedTypes()) {
if (type.equalsIgnoreCase(mimeType)) {
Log.d(TAG, "the selected encoder is :" + m.getName());
return m;
}
}
}
return null;
}

这里的mimetype我们传入video/avc,该函数会挑选出适合的编码格式用来创建编码器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MediaCodecInfo codecInfo = selectCodec(MIME_TYPE);
if (codecInfo == null) {
throw new RuntimeException("不支持的格式");
}
MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, screenWidth, screenHeight);
//必须设置为COLOR_FormatSurface,因为是用surface作为输入源
int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface;
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);
format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
//设置帧率
format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
//设置抽取关键帧的间隔,以s为单位,负数或者0会不抽取关键帧
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL);
Log.d(TAG, "created video format: " + format);
encoder = MediaCodec.createByCodecName(codecInfo.getName());

因为使用surface作为输入源,系统会自动为解码器注入surface上的数据,开发者只需要关心数据的获取就行了。谷歌官方推荐使用异步回调方式来获取数据。

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
private void setEncodeCallback(MediaCodec encoder) {
encoder.setCallback(new MediaCodec.Callback() {
@Override
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int i) {
//这个方法在使用surface模式的时候不会回调
}
@Override
public void onOutputBufferAvailable(@NonNull MediaCodec mediaCodec, int i, @NonNullMediaCodec.BufferInfo bufferInfo) {
ByteBuffer outputBuffer = mediaCodec.getOutputBuffer(i);
//处理数据
mediaCodec.releaseOutputBuffer(i, false);
}
@Override
public void onError(@NonNull MediaCodec
mediaCodec, @NonNull MediaCodec.CodecException e) {
Log.e("TAG", "OnError\n" + e.getDiagnosticInfo());
}
@Override
public void onOutputFormatChanged(@NonNull MediaCodec
mediaCodec, @NonNull MediaFormat mediaFormat) {
Log.d("TAG", "onOutputFormatChanged");
getSpsPpsByteBuffer(mediaFormat);
}
});
}

回调函数的设置必须在mediacodec调用start方法之前。下面就可以调用start()函数使mediacodec进入一个running的状态了,在回调函数中会收到所有的数据。

注意,编码器使用了surface作为输入源,则会选择surface模式,不会回调onInputBufferAvailable,我们同样不能手动输入数据给编码器,否则会发生错误。

系统会首先回调onOutputFormatChanged方法,这个方法就是关于h264的sps和pps信息,这两个数据在解析h264的时候非常重要,不能丢失。sps和pps会存放在MediaForamt中,可以通过getByteBuffer来获取,他们的key分别是”csd-0”和”csd-1”,注意顺序不能取反。

h264流屏幕在变化很小的时候产生的数据很小,变化的时候产生的数据较大,这些数据都是通过onOutputBufferAvailable函数回调回来。

所有的数据都存入一个线程安全的阻塞队列(LinkedBlockingQueue)中,启动另一个线程不断的从这个队列中取出数据传递给jni包装的jrtplib,发送包装好的rtp数据给接收端。

引入jni

编写CMakeLists.txt

1
2
set(SAKA_LOG_TAG "\"saka\"")
set(SAKA_LOG_LEVEL 1)
1
2
3
4
5
6
# log信息共四个选项
# 0--只显示tag
# 1--在0的基础上加上行号
# 2--在0的基础上加上方法名称
# 3--1和2
set(LOG_INFO_LEVEL 0)

定义三个变量,作为jni输出日志的标签和输出日志级别,此处我定义标签为”saka”;LOG_LEVEL设置为1,表示为verbose,输出全部日志;LOG_INFO_LEVEL设置0,只输出标签信息。

1
2
3
4
5
6
7
8
9
10
add_library( jrtp
STATIC
IMPORTED )
set_target_properties(jrtp PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}/libjrtp.a)


add_library(jthread STATIC IMPORTED)
set_target_properties(jthread PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}/libjthread.a)

常规写法,引入静态库jrtplib和jthread。

1
2
3
4
5
6
7
8
9
10
include_directories(
src/main/cpp/include/jrtplib3
src/main/cpp/include
${CMAKE_BINARY_DIR}
)

configure_file(
${CMAKE_SOURCE_DIR}/src/main/cpp/config.h.in
${CMAKE_BINARY_DIR}/config.h
)

包含头文件,引入jrtplib的所有头文件,同时自定义了一个config.h.in文件,在binary文件夹中会生成config.h文件,用来配置我们刚才设置的日志配置信息。

1
2
3
#cmakedefine SAKA_LOG_TAG @SAKA_LOG_TAG@
#cmakedefine SAKA_LOG_LEVEL @SAKA_LOG_LEVEL@
#cmakedefine LOG_INFO_LEVEL @LOG_INFO_LEVEL@

这就是config.h.in的主要内容

发送数据

首先讲一下MTU, 最大传输单元(Maximum Transmission Unit)是指一种通信协议的某一层上面所能通过的最大数据包大小(以字节为单位)。最大传输单元这个参数通常与通信接口有关(网络接口卡、串口等)。一般在1400-1500byte左右。

h264的每一帧数据包含一个固定的分隔符(0x000x000x000x01或者0x000x000x01),包括sps和pps同样会有,在发送rtp数据时需要将这些分隔符去掉。分隔符后面即NALU单元,每个NALU单元的大小不定,NALU单元的第一个字节是一个头信息。

有了MTU这么个蛋疼的玩意真的是让人头大了,假如所有的NALU都小于MTU多好啊…简直做梦!!!。
rtp默认采用udp形式发送所有数据,也就是我们包装好的一帧数据不能超过MTU的限制,对于小于MTU的NAL单元,我们可以直接打包发送,超过这个限制后,必须要将它分割为小于MTU的数据段来发送,这个分割方法在rtp协议中有具体讲解。然而jrtplib这个优秀的库并没有为我们封装这个方法,我们必须手动分割然后发送,否则数据接收者根本收不到结果。

下边是一个简单的发送实现:

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
int send_rtp(char *data, size_t length) {
if (data) {
memset(sendBuf, 0, RTP_PACKET_MAX_LENGTH + 3);
if (length < 4) {
LOGE("数据长度错误");
return -1;
}

if (data[0] == 0 && data[1] == 0) {
if (data[2] == 1) {
a_length = 3;
} else if (data[2] == 0 && data[3] == 1) {
a_length = 4;
} else {
LOGE("分片错误了");
}
} else {
LOGE("非常严重的错误");
}

int status;
LOGD("接收到数据%d", length);
if (length - a_length <= RTP_PACKET_MAX_LENGTH) {
sess.SetDefaultMark(true);
LOGD("单帧");
memcpy(&sendBuf, data + a_length, length - a_length);
status = sess.SendPacket(sendBuf, length - a_length, 96, true, 3600);
checkerror(status);
} else {
LOGD("多帧");
int NAL_HEADER = (int) data[a_length];
char FU_INDICATOR = (char) (NAL_HEADER & 0xE0 | 0x1C);
sendBuf[0] = FU_INDICATOR;
size_t k = 0, l = 0;
k = (length - a_length - 1) / RTP_PACKET_MAX_LENGTH;
l = (length - a_length - 1) % RTP_PACKET_MAX_LENGTH;
int t = 0;
while (t < k || (t == k && l != 0)) {

if (t == 0) {
char FU_HEADER = (char) (NAL_HEADER & 0x1F | 0x80);
sendBuf[1] = FU_HEADER;
memcpy(&sendBuf[2], data + a_length + 1 + (t * RTP_PACKET_MAX_LENGTH),
RTP_PACKET_MAX_LENGTH);
status = sess.SendPacket(sendBuf, RTP_PACKET_MAX_LENGTH + 2, 96, false, 0);
checkerror(status);
t++;
} else if (t < k && l != 0) {
char FU_HEADER = (char) (NAL_HEADER & 0x1F | 0x00);
sendBuf[1] = FU_HEADER;
memcpy(&sendBuf[2], data + a_length + 1 + (t * RTP_PACKET_MAX_LENGTH),
RTP_PACKET_MAX_LENGTH);
status = sess.SendPacket(sendBuf, RTP_PACKET_MAX_LENGTH + 2, 96, false, 0);
checkerror(status);
t++;
} else if ((k == t && l > 0) || (t == (k - 1) && l == 0)) {
sess.SetDefaultMark(true);
size_t isSendLength;
if (l > 0) {
isSendLength = length - a_length + 1 - t * RTP_PACKET_MAX_LENGTH;
} else {
isSendLength = RTP_PACKET_MAX_LENGTH + 2;
}
char FU_HEADER = (char) (NAL_HEADER & 0x1F | 0x40);
sendBuf[1] = FU_HEADER;
memcpy(&sendBuf[2], data + a_length + 1 + (t * RTP_PACKET_MAX_LENGTH),
isSendLength);
status = sess.SendPacket(sendBuf, isSendLength, 96, true, 3600);
checkerror(status);
t++;
}
}
}

注意在分包时时间戳的设定,和最后一个分包的标记。

接收数据

同样使用jrtplib来接收数据,mediacodec解码,解码后有两种方式可以显示输出的数据:一是直接使用surfaceview渲染,二是采用image获取数据后手动渲染到surfacevew或者textureview或者opengl。后者更灵活,可以对数据进行二次处理,做一些滤镜效果等。这篇文章先讲第一种直接渲染的方式。

接收数据必须在线程中实现,同样将接收到的数据存入到一个阻塞队列中,然后不断的取出数据渲染到surface上。

当我们初始化好rtpsesson后,通过不断的接收到rtp数据,我们首先需要判断是否是分片的帧还是完整帧完整帧相对来说比较简单,直接回调java方法来放入队列;分片帧的话需要先拼装起来,相当于发送时候的分片的逆向,然后回调java方法放入队列。

简单的代码

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
do {
RTPPacket *packet;
while (sess.IsActive() && (packet = sess.GetNextPacket()) != NULL) {
printf("收到了包:%lu\n", packet->GetPayloadLength());
if (current_index == -1) {
current_index = packet->GetSequenceNumber();
start_index = current_index;
printf("set the num:%d\n", start_index);
} else {
current_index = packet->GetSequenceNumber();
}
SAKA_LOG_WARN("receive data length=%d", (int) packet->GetPayloadLength());

char indicator = packet->GetPayloadData()[0];
char header = packet->GetPayloadData()[1];
if ((indicator & 0x1F) != (uint8_t) 28 && packet->HasMarker()) //不是分割帧则直接存储
{
if ((indicator & 0x1F) == 0x07 ||
(indicator & 0x1F) == 0x08) {
type = 2;
} else {
type = 1;
}
packet_length = 0;
packet_length = packet->GetPayloadLength() + 4;
env->SetByteArrayRegion(target, 4, packet_length - 4,
(jbyte *) packet->GetPayloadData());

env->CallStaticVoidMethod(classz, passDataMethodId, target,
packet_length, type);


} else if ((indicator & 0x1F) == 0x1c) {//是分割帧,需要写入正常的数据

if ((header & 0xC0) == 0x80) {
uint8_t nal_header = (indicator & 0xE0) | (header & 0x1F);
env->SetByteArrayRegion(target, 4, 1, (const jbyte *) &nal_header);
env->SetByteArrayRegion(target, 5, packet->GetPayloadLength() - 2,
(jbyte *) (packet->GetPayloadData() + 2));
packet_length = packet->GetPayloadLength() + 3;
} else {
env->SetByteArrayRegion(target, packet_length,
packet->GetPayloadLength() - 2,
(jbyte *) (packet->GetPayloadData() + 2));
packet_length = packet_length + packet->GetPayloadLength() - 2;
if ((header & 0xC0) == 0x40) {
env->CallStaticVoidMethod(classz, passDataMethodId, target,
packet_length, 1);
}
}
}

sess.DeletePacket(packet);
}
} while (sess.IsActive() && sess.GotoNextSourceWithData());

注意着段代码并没有处理丢包的情况,全部依赖h264的IDR帧来实现修复。

初始化mediacodec

相对编码器来说简单一些:

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
MediaCodecInfo codecInfo = selectCodec(MIME_TYPE);
if (codecInfo == null) {
throw new RuntimeException("不支持的格式");
}
MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, screenWidth, screenHeight);
format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
mediaCodec = MediaCodec.createByCodecName(codecInfo.getName());
mediaCodec.setCallback(new MediaCodec.Callback() {
@Override
public void onInputBufferAvailable(@NonNull MediaCodec mc, int inputBufferId) {
//处理数据
}

@Override
public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNulMediaCodec.BufferInfo info) {
//直接释放即可
codec.releaseOutputBuffer(index, true);
}
@Override
public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e){

}

@Override
public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format){

}

})

mediaCodec.configure(mediaFormat, surface, null, 0);
mediaCodec.start();

注意,configure方法中的surface就是通过surfaceview的surfaceholder获取的surface,直接当参数传入即可,最后一个参数是flag,用来标记是否是编码,传入0表示作为解码。

onInputBufferAvailable会一直回调来让我们不断的从队列中取出数据提交给解码器。此处一定要注意,假如用户通过dequeuinputbuffer方法获取了缓冲的索引,必须调用queueinputbuffer方法来释放缓冲区,将缓冲区的所有权交给mediacodec,否则后续将不会回调onInputBufferAvailable方法。

队列中没有数据的时候可以提交空数据给解码器

1
mc.queueInputBuffer(inputBufferId, 0,0, 0, 0);

队列中有数据的时候首先要区分一下是不是sps或者pps,是的话就必须当做配置信息提交给解码器,不是的话就直接提交一帧完整的数据给解码器。

这样基本完成了整个项目。