iOS (socket+H264+videoToolbox)视频内存增长与播放速度控制
视频播放的时候 控制视频的内存在这张图的消耗速度上
录像是存储在某台服务器里面的,服务端一般看客户端要多少数据 ,就会开个while循环,一次读取多少字节的数据。然后几乎没有时间间隔的把数据送出去。这时候在播放端就需要做好播放速度的控制,因为接收速度完全取决于网速,如果没有做这段,那么视频的播放速度就和网速一样,网速快的时候 ,视频呈现快进的效果。网速慢的时候会出现卡帧的效果。直播的时候,由于录像采集方控制了采集的速度,所以传输给播放端的时候不需要处理播放速度。
按照标准的H264来说,应该每一帧都有pts时间戳 ,这样就可以做视频的同步来解决问题。
但就有奇葩的没有pts的H264流,该怎么处理这种流呢?
把流下载下来存文件,放到VLC里面 也能够正常播放,那么VLC是怎么做到流速控制,他怎么在没有pts的情况下知道间隔多少毫秒播放呢。用ffmpeg打印文件信息,ffprose就能什么都打印出来,拿到avformat里面的帧率 是1/25 那么就是每秒25帧,就是每隔40毫秒需要渲染一帧视频画面。
如果不是标准的25帧的帧率,那么可以通过avopeninput多等几帧来尝试解析文件,让ffmpeg
帮你猜测帧率,然后再写好等待函数的具体毫秒时间。
至于流播放的过慢应该怎么控制,可以通过一个视频缓冲区来控制,比如我们一秒钟能够保证视频
的流畅体验,那么小于25帧 就不应该播放,应该继续拉流把缓冲区至少填充到25帧,再以间隔40毫秒的速度
播放下去。而缓冲区也不能够无限的大,当视频比如说已经5秒(125帧)了。那就不要再继续拉流了。
这样可以做到流量节省,同时保证流畅的播放体验,也不会造成缓冲区过大的问题。
从上面的内容,我们知道要实现两个东西
视频缓冲区,播放间隔速度控制
在实际编码的过程中
出现了两个比较严重的问题,一个是内存增长过快,到一定程度就会被苹果杀掉了。
一个是数据拷贝的时候数据已经被其他线程释放了,导致读写的时候出现越界崩溃的问题。
还有播放速度无法控制。
针对内存增长过快,原因是拉流缓冲区释放不及时,接收速度远超过播放速度,高人提供了一个解决方案,
就是提前预分配内存,首先分配一个大概2M大小的内存,拉流收到的视频帧直接存到这个内存里面去
控制一个读写的位置,每次的新帧就会一直拼在后面,这样write的位置就会一直往后,read的位置则每读取
一包数据,就向后移动一个位置,当write写到了内存长度的最后的时候,又从头开始写,因为read已经读过了
的部分,就可以覆盖写了,这样包装所有的数据都是在这2M内的,当播放结束的时候,再把2M的内存完全释放。
这样就不用反复分配释放某个内存,而苹果是ARC的,什么时候释放也不是我们能控制的,因此这段代码就C
代码的实现,需要分配和释放成对出现,当然这从某种角度讲,也叫内存池。这里有一套java的实现,
翻译成C的就好了。
package com.temobi.util;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class VideoBuffer {
private static final int HEADLEN = 8;
private static final int MAGIC = 0xabababab;
private Lock lockWrite = new ReentrantLock(); // buffer write lock
private int writePos; // write position
private int readPos; // read position
private int dataLength; // data length of the buffer
private int bufferSize; // buffer size
private byte[] buffer; // buffer
public VideoBuffer(int size) {
writePos = 0;
readPos = 0;
dataLength = 0;
bufferSize = size;
buffer = new byte[bufferSize];
}
public boolean isHasData() {
return dataLength > HEADLEN ? true : false;
}
public int getFreeSpaceSize() {
return bufferSize - dataLength;
}
//splice the buffer to tailed
public boolean spliceBuffer(VideoBuffer buffer) {
byte[] buf = null;
if (!buffer.isHasData()) {
return false;
}
int availableLen = makeRoom(buffer.getDataSize());
if (availableLen < buffer.getDataSize()) {
return false;
}
int len = buffer.getFirstData(buf);
if (len > 0) {
spliceData(buf, len);
len = buffer.getSecondData(buf);
if (len > 0) {
spliceData(buf, len);
}
return true;
}
return false;
}
public void clearBuffer() {
lockWrite.lock();
try {
writePos = 0;
readPos = 0;
dataLength = 0;
} finally {
lockWrite.unlock();
}
}
public int writeOnePacket(byte[] Data, int len) {
lockWrite.lock();
try {
int packetLen = len + HEADLEN;
if (bufferSize < packetLen )
return 0;
if(getFreeSpaceSize() < packetLen) {
int availableLen = makeRoom(packetLen);
if (availableLen < packetLen) {
return 0;
}
}
byte[] head = new byte[HEADLEN];
int2Bytes(head, 0, MAGIC, 4);
int2Bytes(head, 4, packetLen, 4);
appendData(head, HEADLEN);
appendData(Data, len);
return packetLen;
} finally {
lockWrite.unlock();
}
}
// read and remove one packet frome buffer
public int getOnePacket(byte[] outBuf) {
lockWrite.lock();
try {
if (HEADLEN > dataLength) {
return 0;
}
// first, read head from buffer
byte[] buf = new byte[HEADLEN];
int rtn = readData(buf, HEADLEN);
if (rtn == 0)
return 0;
// second, parse head to get body length
int bodyLen = bytes2Int(buf, 4) - HEADLEN;
// third, read body from buffer
bodyLen = readData(outBuf, bodyLen);
return bodyLen;
} finally {
lockWrite.unlock();
}
}
public int getFirstData(byte[] buf) {
int len = 0;
lockWrite.lock();
try {
if (writePos > readPos) {
//**********************************
// ^ ^
// readPos writePos
//**********************************
len = dataLength;
buf = new byte[len];
System.arraycopy(buffer, readPos, buf, 0, len);
} else {
//**********************************
// ^ ^
// writePos readPos
//**********************************
len = bufferSize - readPos;
if (len > 0) {
buf = new byte[len];
System.arraycopy(buffer, readPos, buf, 0, len);
}
}
return len;
} finally {
lockWrite.unlock();
}
}
public int getSecondData(byte[] buf) {
int len = 0;
lockWrite.lock();
try {
if (writePos < readPos) {
len = writePos;
buf = new byte[len];
System.arraycopy(buffer, 0, buf, 0, len);
}
return len;
} finally {
lockWrite.unlock();
}
}
public int getDataSize() {
lockWrite.lock();
try {
return dataLength;
} finally {
lockWrite.unlock();
}
};
// read and remove data from buffer
private int readData(byte[] buf, final int dataLen) {
lockWrite.lock();
try {
int firstPart;
if (dataLen > dataLength)
return 0;
if ((firstPart = bufferSize - readPos) < dataLen) {
System.arraycopy(buffer, readPos, buf, 0, firstPart);
System.arraycopy(buffer, 0, buf, firstPart, dataLen - firstPart);
} else {
System.arraycopy(buffer, readPos, buf, 0, dataLen);
}
readPos += dataLen;
if (readPos >= bufferSize)
readPos -= bufferSize;
dataLength -= dataLen;
return dataLen;
} finally {
lockWrite.unlock();
}
}
// read data from buffer but don't remove
private int readDataNotRemove(byte[] buf, final int dataLen) {
lockWrite.lock();
try {
if (dataLen > dataLength)
return 0;
int firstPart;
if ((firstPart = bufferSize - readPos) < dataLen) {
System.arraycopy(buffer, readPos, buf, 0, firstPart);
System.arraycopy(buffer, 0, buf, firstPart, dataLen - firstPart);
} else {
System.arraycopy(buffer, readPos, buf, 0, dataLen);
}
return dataLen;
} finally {
lockWrite.unlock();
}
}
// remove one packet from buffer
private void removeOnePacket() {
lockWrite.lock();
try {
if (dataLength < HEADLEN)
return;
byte[] buf = new byte[HEADLEN];
int dataLen = readDataNotRemove(buf, HEADLEN);
dataLen = bytes2Int(buf, 4);
readPos += dataLen;
if (readPos >= bufferSize)
readPos -= bufferSize;
dataLength -= dataLen;
} finally {
lockWrite.unlock();
}
}
// append data to buffer
private int appendData(byte[] buf, int len) {
lockWrite.lock();
try {
int firstPart = 0;
if ((firstPart = bufferSize - writePos) < len) {
System.arraycopy(buf, 0, buffer, writePos, firstPart);
System.arraycopy(buf, firstPart, buffer, 0, len - firstPart);
} else {
System.arraycopy(buf, 0, buffer, writePos, len);
}
writePos += len;
if (writePos >= bufferSize)
writePos -= bufferSize;
dataLength += len;
return len;
} finally {
lockWrite.unlock();
}
}
// make room for data limit by length
private int makeRoom(int length) {
if(length + HEADLEN <= bufferSize) {
while (bufferSize - dataLength < length) {
removeOnePacket();
}
}
return bufferSize - dataLength;
}
private void spliceData(byte[] buf, int len) {
lockWrite.lock();
try {
if (writePos >= readPos) {
int copyLen = bufferSize - writePos;
if (copyLen < len) {
System.arraycopy(buf, 0, buffer, writePos, copyLen);
System.arraycopy(buf, copyLen, buffer, 0, len - copyLen);
writePos = len - copyLen;
dataLength += copyLen;
} else {
System.arraycopy(buf, 0, buffer, writePos, len);
writePos += len;
dataLength += copyLen;
}
} else {
System.arraycopy(buf, 0, buffer, writePos, len);
writePos += len;
dataLength += len;
}
} finally {
lockWrite.unlock();
}
}
public static void java_int2C_Bytes(byte[] inByte, int nStart, int value, int len) {
for (int i = 0; i < len; i++) {
inByte[i + nStart] = (byte) (value >> 8 * i);
}
}
public static void java_long2C_Bytes(byte[] inByte, int nStart, long value, int len) {
for (int i = 0; i < len; i++) {
inByte[i + nStart] = (byte) (value >> 8 * i);
}
}
public static short c_bytes2Java_Short(byte[] b, int start, int len) {
short sum = 0;
for (int i = 0; i < len; i++) {
short n = (short) ((short)b[i + start] & (short)0xff);
n <<= (i) * 8;
sum += n;
}
return sum;
}
public static int c_bytes2Java_Int(byte[] b, int start, int len) {
int sum = 0;
for (int i = 0; i < len; i++) {
int n = b[i + start] & 0xff;
n <<= (i) * 8;
sum += n;
}
return sum;
}
public static long c_bytes2Java_long(byte[] b, int start, int len) {
long sum = 0;
for (int i = 0; i < start; i++) {
long n = b[i] & 0xff;
n <<= (i) * 8;
sum += n;
}
return sum;
}
public static void int2Bytes(byte[] inByte, int nStart, int value, int len) {
int nEnd = nStart + len;
for (int i = 0; i < len; i++) {
inByte[nEnd - i - 1] = (byte) (value >> 8 * i);
}
}
public static void long2Bytes(byte[] inByte, int nStart, long value, int len) {
int nEnd = nStart + len;
for (int i = 0; i < len; i++) {
inByte[nEnd - i - 1] = (byte) (value >> 8 * i);
}
}
public static short bytes2Short(byte[] b, int start) {
return (short) (b[1 + start] & 0xFF | (b[0 + start] & 0xFF) << 8);
}
public static int bytes2Int(byte[] b, int start) {
return b[3 + start] & 0xFF | (b[2 + start] & 0xFF) << 8 | (b[1 + start] & 0xFF) << 16
| (b[0 + start] & 0xFF) << 24;
}
public static long bytes2long(byte[] b, int start) {
return b[7 + start] & 0xFF | (b[6 + start] & 0xFF) << 8 | (b[5 + start] & 0xFF) << 16
| (b[4 + start] & 0xFF) << 24 | (b[3 + start] & 0xFF) << 32 | (b[2 + start] & 0xFF) << 40
| (b[1 + start] & 0xFF) << 48 | (b[0 + start] & 0xFF) << 56;
}
public static void main(String[] args) {
// TODO Auto-generated method stub
}
}
上面的代码遇到了需要多个线程同时读写的情况, java里面有个ReentrantLock 可重入锁,
iOS中我们常用的是NSLock这种互斥锁,当已经开始写入的时候时候被加了锁,那么读取的时候再
加锁就会造成死锁了。就是不能多次加锁的意思,必须先解锁再加锁,可能有的人会说条件锁
为什么不受影响,但条件锁也是先解锁发了信号量出去再加锁的。最后找到了IOS最通用的锁
同步锁,@synchronized (self) {} 这种锁的效率最低,但是支持锁里面再加锁的。
再说内存共享的问题,为什么要内存共享,因为数据量非常大,一个高清的I帧,有的能到70K
的大小,如果到处做拷贝复制的操作,如果释放不及时,必然会造成内存的增长
socket接收端把数据发给缓冲区的时候,应该是异步拷贝放入缓冲区后立刻释放掉,
防止数据处理影响到socket下一段流的接收速度,播放端播放的时候,应该直接拿缓冲区的数据来播放,
而不是拷贝一份去播放,因为始终只有这个缓冲区的数据存在,而内存又是提前分配的,
就使得内存在一定范围内保持恒定。
应该注意缓冲区不应该过小,这样会导致write过快达到内存大小,然后又从头再写
这时候就会覆盖原来的数据,如果read速度跟不上write写的速度,那么因为数据是共享的,
当read的数据刚读取到要解码的时候,数据已经变了,这样就解析不出来帧或者有一部分数据
缺失导致花帧。因此设计的时候,应该保证缓冲区大小由外部来分配。
通过调整,在网络最快的情况下也能保证read的位置始终慢于write的位置即可。
这是一张改造完成后的内存消耗图,可以看到还是比较稳定的。
然后是播放时间的控制,按照前面的内容所讲,一开始我是开了一个while循环来通过睡眠固定的间隔36毫秒
来保障视频的渲染速度,渲染也要几毫秒,但实际在测试的过程中,发现视频最终的渲染并不是按照40毫秒的
间隔来的,精准时间可以拿CFAbsoluteTimeGetCurrent() 这个函数来看,问了一下,原来是线程的睡眠
时间并不可靠,线程间使用都是在线程在空闲的情况下,再进行下一轮睡眠,而在while循环中,可能还有其他的
内容与函数,一旦有存在等待的情况,就会导致间隔加长,视频渲染就会出现慢放的效果。
正确的写法是使用计时器,而各个平台的计时器不通用,应该用各自平台自己的写法,原因是只有各自系统
清楚有无线程问题导致计时器产生误差,苹果平台的计时器就是NSTimer和GCD两种,NSTimer的计时器误差
在50毫秒以上,而我们的计时器要求精度在36毫秒,就只能选用GCD的时间调用,GCD也是在线程里面的,
不知道是否会受到影响。
最后渲染的部分,记得使用异步串行队列实现,防止阻塞计时器,最后看下播放效果已经控制的还不错了。
目前还有两个问题,在I帧的解码需要消耗很长时间,看上去有70毫秒,不知道有没有办法解决。还有
计时器里面有可能出现没有帧需要等待的情况,这时候再等36毫秒,可能就慢了,等1毫秒,计时器本身
又变成了一个消耗性能的东西,这两点需要优化。其余已经做得很好了。
Videotoolbox在硬解码的时候,应该注意到当sps 和pps发生变化的情况下,需要重新
把init再执行一遍,不然按照旧的sps 和pps 是无法解析视频的。
拉流的内存消耗如果过快,可以考虑是否在同一线程while循环中持续拉流,可能会导致内存无法及时释放,建议拉流的业务和拉流的网络请求类分开线程处理,可以保证拉流的网络数据回调后能够尽快的释放掉