Android设备一对多录屏直播——(音视频采集,Tcp传输)
转载请注明出处:https://blog.****.net/sunmmer123
上篇把关于设备连接这块说完了,主要就是采用UDP连接这种方式,只要在同组地址下,多台客户端能自动连接上播放端,这就适应我这个一对多的投屏场景;接下来我会详细的说一下我传输的步骤。
上篇:Android设备一对多录屏直播(Udp设备连接)
https://blog.****.net/sunmmer123/article/details/82734245
讲代码
音视频采集
- 这里关于音视频采集,我大致的说下,到时候细节大家看代码
-
视频采集
(1)MediaProjection获得 VirtualDisplay。
(2)建立一个VideoMediaCodec,获得MediaCodec的InputSurface,将InputSurface设置给Virtual。
(3)读取VideoMediaCodec里产生的视频数据,参考H264解析规则,获得Sps,Pps,和每一个帧具体信息,第一帧前,一定要加Sps,Pps(否则无法解析),并保证第一帧是关键帧.
视频采集这块需要注意的点:
传输H264的裸流的需要格式为:Sps + Pps + IDR + ……. + Sps + Pps + IDR + …
在视频这块需要做的处理是手动的把h264的头部给加上,同时确保视频最开始是sps,pps。
-
音频采集
(1)获取AudioRecord,从AudioRecord中获取pcm。
(2)建立AudioMediaCodec,将pcm转码为AAC(从AudioRecord中获取的是AAC裸流)给AAC数据添加ADTS头消息,顺道添加(0,0,0,1)头部信息,方便分割。
音频采集这块需要注意的点:给编码出的aac裸流添加adts头字段
这个关于音视频采集具体注意点的解释可以看我篇一推荐的那俩篇博文
这里我主要做了一个 找出采集频率对应下标的处理,动态的去查匹配。
// TODO: 2018/6/5 找出采集频率对应下标
private int getFrequencyIdx(int defaultFrequency) {
Map<Integer, Integer> samplingFrequencyIndexMap = new HashMap<>();
samplingFrequencyIndexMap.put(96000, 0);
samplingFrequencyIndexMap.put(88200, 1);
samplingFrequencyIndexMap.put(64000, 2);
samplingFrequencyIndexMap.put(48000, 3);
samplingFrequencyIndexMap.put(44100, 4);
samplingFrequencyIndexMap.put(32000, 5);
samplingFrequencyIndexMap.put(24000, 6);
samplingFrequencyIndexMap.put(22050, 7);
samplingFrequencyIndexMap.put(16000, 8);
samplingFrequencyIndexMap.put(12000, 9);
samplingFrequencyIndexMap.put(11025, 10);
samplingFrequencyIndexMap.put(8000, 11);
return samplingFrequencyIndexMap.get(defaultFrequency);
}
数据传输
- 数据传输格式
- 文件发送采用TCP传输文件内容,这里我将文本消息内容和音视频字节数组等参数封装在socket发送的数据流的头部。
整个数据流可以分为几部分:
(具体格式根据你们自己需求来定)
——————————————
{编码版本}{主指令}{子指令}{文本数据长度}{音视频数据长度}
- 视频帧在流里的格式:长度+H.264格式帧
- 音频帧在流里的格式:长度+[0x00,0x00,0x00,0x01]+AAC格式帧
- [0x00,0x00,0x00,0x01]是H.264数据头文件,播放端读取流的时候,先读前4个字节,获取长度,根据长度再读指定字节;到时候解析播放的时候根据你定的数据结构,规则解析。
- 数据传输
- 关于传输这块采用Tcp传输,设备之间建立一个长连接,采用ServerSocket,当播放端初始化成功后,给推送端发送成功标识,推送端收到标识后,开启发送线程。
大致步骤:
1、首先我们在使用Udp与播放端建立连接成功以后,拿到播放端IP地址,当我们采集音视频数据时,建立TCP连接。
2、采集数据将数据放入ArrayBlockQueue,ArrayBlockQueue会根据队列中数据的数量,按一定规则决定丢帧。
3、播放端在监听端口号时要求采集端发送数据,则通过tcp向采集端发送成功标识,播放端开启发送线程。
-
采集端
- 收到播放端成功信息,开启发送线程
@Override
public void connectSuccess(ReceiveData data) {
//收到数据后,解析后得到数据
Log.e(TAG, "connectSuccess: " + data.getHeader().getSubCmd());
if (data == null) {
return;
}
int subCmd = data.getHeader().getSubCmd();
switch (subCmd) {
case 0x01:
//连接成功,开启发送线程
mWrite.start();
break;
}
}
/**
* by wt
* @param mainCmd 主指令
* @param subCmd 子指令
* @param sendBody 文本内容
* @param sendBuffer 音视频内容
*/
public EncodeV1(int mainCmd, int subCmd, String sendBody, byte[] sendBuffer) {
this.mainCmd = mainCmd;
this.subCmd = subCmd;
this.sendBody = sendBody;
this.sendBuffer = sendBuffer;
}
public byte[] buildSendContent() {
int bodyLength = 0;
int bodyByte = 0;
ByteBuffer bb = null;
//文本数据
if (!TextUtils.isEmpty(sendBody)) {
bodyLength = sendBody.getBytes().length;
}
//音视频数据
if (sendBuffer.length != 0) {
bodyByte = sendBuffer.length;
}
//创建内存缓冲区
bb = ByteBuffer.allocate(18 + bodyLength + bodyByte);
bb.put(ScreenImageApi.encodeVersion1); //0-1编码版本
bb.put(ByteUtil.int2Bytes(mainCmd)); //1-5 主指令
bb.put(ByteUtil.int2Bytes(subCmd)); //5-9 子指令
bb.put(ByteUtil.int2Bytes(bodyLength)); //9-13位,文本数据长度
bb.put(ByteUtil.int2Bytes(bodyByte)); //13-17位,音视频数据长度
byte[] tempb = bb.array();
bb.put(ByteUtil.getCheckCode(tempb));
//数据字节数组
if (bodyLength != 0) {
bb.put(sendBody.getBytes());
}
if (sendBuffer.length != 0) {
bb.put(sendBuffer);
}
return bb.array();
}
- 播放端
播放端是整个一对多投屏的重点,播放端这边创建一个ServerSocket对象,并设置监听端口,每收到采集端发来的投屏指令后,播放端都会开启一个接收线程,同时把这些线程存到一个集合中去管理,默认给第一个线程发送允许采集端开启发送数据线程的标识,当停止投屏,连接断开时,移除相对应的线程,允许第二个线程去向采集端发送成功标识,依次类推。
1、监听端口号,解析数据,拿到主指令和子指令,判断是否是请求投屏操作。
//把线程给添加进来
private List<AcceptMsgThread> acceptMsgThreadList;
new Thread() {
@Override
public void run() {
super.run();
try {
// 创建一个ServerSocket对象,并设置监听端口
serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
InetSocketAddress socketAddress = new InetSocketAddress(Constants.TCPPORT);
serverSocket.bind(socketAddress);
serverSocket.setSoTimeout(20000);
acceptMsgThreadList.clear();
while (isAccept) {
//服务端接收客户端的连接请求
Socket socket = serverSocket.accept();
InputStream inputStream = socket.getInputStream();
byte[] temp = mAnalyticUtils.readByte(inputStream, 18);
ReceiveHeader receiveHeader = mAnalyticUtils.analysisHeader(temp);
if (receiveHeader.getMainCmd() == ScreenImageApi.RECORD.MAIN_CMD) {//投屏请求
//开启接收H264和Aac线程
// TODO: 2018/7/18 wt保证流里数据读干净
if (receiveHeader.getStringBodylength() != 0) {
mAnalyticUtils.analyticData(inputStream, receiveHeader);
}
AcceptMsgThread acceptMsgThread = new AcceptMsgThread(socket,
mEncodeV1, mListener, TcpServer.this);
acceptMsgThread.start();
//把线程添加到集合中去
acceptMsgThreadList.add(acceptMsgThread);
if (acceptMsgThreadList.size() > 1) {
continue;
}
//默认先发送成功标识给第一个客户端
acceptMsgThreadList.get(0).sendStartMessage();
//把第一个投屏的设备对象记录下来
acceptMsgThread1 = acceptMsgThreadList.get(0);
} else {
Log.e(TAG, "socket close");
socket.close();
}
}
} catch (Exception e) {
Log.e("wt", "run: 走停止");
} finally {
Log.e(TAG, "TcpServer: thread close");
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}.start();
2、AcceptMsgThread 接收线程里主要做俩件事情,第一去向采集端发送成功标识,第二件事解析采集端发送过来的音视频数据,然后传出播放显示。
// TODO: 2018/6/14 向客户端回传初始化成功标识 @param size 当前线程集合里的投屏设备数量
public void sendStartMessage() {
Log.d("wtt", "sendStartMessage: 发送成功标识");
//告诉客户端我已经初始化成功
byte[] content = mEncodeV1.buildSendContent();
try {
outputStream.write(content);
isSendSuccess = true;
if (mTcpListener != null) {
mTcpListener.connect(this);
}
} catch (IOException e) {
if (mTcpListener != null) {
Log.e(TAG, "sendStartMessage: 断开2");
isSendSuccess = false;
mTcpListener.disconnect(e, this);
}
}
}
// TODO: 2018/6/14 去读取数据
private void readMessage() {
try {
while (startFlag) {
//开始接收客户端发过来的数据
byte[] header = mAnalyticDataUtils.readByte(InputStream, 18);
//数据如果为空,则休眠,防止cpu空转, 0.0 不可能会出现的,会一直阻塞在之前
if (header == null || header.length == 0) {
SystemClock.sleep(1);
continue;
}
// 根据协议分析数据头
ReceiveHeader receiveHeader = mAnalyticDataUtils.analysisHeader(header);
if (receiveHeader.getStringBodylength() == 0 && receiveHeader.getBuffSize() == 0) {
SystemClock.sleep(1);
continue;
}
if (receiveHeader.getEncodeVersion() != ScreenImageApi.encodeVersion1) {
Log.e(TAG, "接收到的数据格式不对...");
continue;
}
// TODO: 2018/6/12 根据指令处理相关事务
ReceiveData receiveData = mAnalyticDataUtils.analyticData(InputStream, receiveHeader);
if (receiveData == null || receiveData.getBuff() == null) {
continue;
}
mTcpListener.successMsg(receiveData.getSendBody(), this);
//区分音视频
mDecoderUtils.isCategory(receiveData.getBuff());
}
} catch (Exception e) {
if (mTcpListener != null) {
Log.e(TAG, "断开1 readMessage: = " + e.toString());
mTcpListener.disconnect(e, this);
isSendSuccess = false;
}
} finally {
startFlag = false;
try {
socket.close();
} catch (IOException e) {
mTcpListener.disconnect(e, this);
}
}
}
3、到这多投的处理差不多可以了,下面就是当连接断开的时候,要注意移除线程,然后允许第二台设备发送数据,当然要想第二台投屏的时候直接抢断,可以自己改下逻辑。
@Override
public void disconnect(Exception e, AcceptMsgThread thread) {
boolean remove = acceptMsgThreadList.remove(thread);
//删除相对应数据信息
if (deviceName.size() != 0) {
deviceName.remove(thread);
}
disconnectListener(e);
Log.e(TAG, "移除成功" + remove + "acceptTcpDisConnect: 个数" + acceptMsgThreadList.size());
if (acceptMsgThreadList == null || acceptMsgThreadList.size() == 0) {
return;
}
//如果停止的不是正在投屏的线程,就不再去走下面的方法
if (thread != acceptMsgThreadList.get(0) && thread != acceptMsgThread1) {
return;
}
//开启第下一个投屏
acceptMsgThreadList.get(0).sendStartMessage();
}
关于一对多投屏中音视频采集以及数据传输这块介绍完了,关于传输这块主要就说了一对多投场景下的设计,到时候具体的可以看代码,有什么不理解的欢迎交流讨论。
接下来就音视频开发这块遇到延时问题处理做个描述介绍:
延时优化:正在编写,请稍等!