目前正在做一个视频相关的项目,里面有个需求是:安卓手机端需要随时可以录制视频,时间可能是几分钟或者几个小时,然后录制的适配需要传到服务器上。如何录制这里暂时不说,我们主要研究一个如何上传的问题。
按照用户的需求,视频的分辨率要达到720p,最大码率设定为2.5Mbps,这样一分钟的大小大概是20MB左右,一个小时在1200MB。如果直接上传1200M的文件,肯定会存在:
- 上传端、接收端需要消耗大量内存处理这个文件,容易导致内存溢出;
- 网络带宽会被上传操作占满,导致网络拥堵;
- 传输时间过长,容易导致终端,然后必须重新上传。
针对上面的问题,我们提出了响应的解决对策:
- 减少单个录制文件的大小:约定录制时,每5分钟重新生成一个新的录制文件。控制单个文件在100M左右。避免超大文件的存储压力和数据准确性。在30M带宽的情况下,大约半分钟可以传输完,不会读网络造成很大的压力。
- 传输的时候,终端按照1MB大小分块传输,服务器端每次收到1MB文件就存入本地,传输过程中,对每个分块标记序号和md5值,确保分块传输的完整性和准确性。
- 传输过程异常中断时,再次发起传输时,终端先去服务器查询相应文件的最后传输成功位置,然后从这个位置开始继续传输,避免重复传输导致的网络开销。
对于5分钟录制一个文件,在安卓端启动一个定时器即可。每5分钟执行一次录制停止,指定新文件名,然后重新启动录制的操作。
安卓端上传的核心代码:
while (
chunck <= chuncks
&&uploadStatus!= UploadStatus.UPLOAD_STATUS_PAUSE
&&uploadStatus!= UploadStatus.UPLOAD_STATUS_ERROR)
{
uploadStatus = UploadStatus.UPLOAD_STATUS_UPLOADING;
//分块读取并传输,避免一次性读入的内存开销。
final byte[] mBlock = FileUtils.getBlock((chunck - 1) * blockLength, file, blockLength);
//对数据做md5校验,如果服务器收到数据的md5和终端的不一致,这个数据需要重新传输。
String md5 = MD5Utils.getMD5String(mBlock);
Map<String, String> params = new HashMap<String, String>();
params.put("name", file.getName());//fileName
params.put("chunks", chuncks + "");
params.put("chunk", chunck + "");
params.put("filelength", file.length() + "");//文件的总大小,服务器校验大小是否一致
params.put("md5str", md5);
params.put("debugstr", debugstr);
MultipartBody.Builder builder = new MultipartBody.Builder()
.setType(MultipartBody.FORM);
addParams(builder, params);
RequestBody requestBody = RequestBody.create(MEDIA_TYPE_MARKDOWN, mBlock);
builder.addFormDataPart("mFile", file.getName(), requestBody);//filename
Log.i("onUploadSuccessurl",url);
Request request = new Request.Builder()
.url(url)
.post(builder.build())
.build();
Response response = null;
response = mClient.newCall(request).execute();
服务器端收到数据后,先校验md5值是否一致,不一致的话,会给终端反馈失败的标识位。如检测到当前文件以及传输完毕,就会把收到的所有分段合并成一个文件。核心代码如下:
//计算md5是否一致
String server_md5 = MD5Utils.getFileMD5String(savedFile);
Logger.info("MD5校验:终端"+md5str+">>>服务器"+server_md5+" "+(server_md5.equals(md5str)));
if (md5str!=null && md5str.length()>0 && server_md5.equals(md5str)==false) {
throw new Exception("收到数据的md5校验失败");
}
//文件传输完毕
if (schunk != null && schunk.intValue() == schunks.intValue()) {
List<File> toDelete = new ArrayList<File>();
//写临时文件,如果直接写最终的文件,那处理失败的时候,就会导致正常文件也错误了。
String tmpFile = newFileName+"_temp";
outputStream = new BufferedOutputStream(new FileOutputStream(new File(tmp, tmpFile)));
// 遍历文件合并
for (int i = 1; i <= schunks; i++) {
File partFile = new File(tmp, i + "_" + name);
byte[] bytes = FileUtils.readFileToByteArray(partFile);
System.out.println("文件合并:" + i + "/" + schunks+"..."+bytes.length);
outputStream.write(bytes);
outputStream.flush();
toDelete.add(partFile);
try {
//确保缓存写入
Thread.sleep(10);
}catch(Exception e) {}
}
outputStream.flush();
try {
outputStream.close();//关闭流
}catch(Exception ee) {}
try {
//确保缓存写入
Thread.sleep(50);
}catch(Exception e) {}
File _file = new File(tmp, tmpFile);
System.out.println("生成的文件长度"+_file.length()+", 终端反馈的长度:"+fileLength);
//判断长度是否一致,不一致的话,需要重新上传
if (fileLength>0 && _file.length()!=fileLength) {
System.out.println("生成的文件长度和终端反馈的不一致,终端"+fileLength+",服务器"+_file.length());
}
//判断原来是否有
File dst = new File(tmp, newFileName);
if (dst.exists()==false) {
_file.renameTo(dst);
System.out.println("重命名到最终的文件"+dst.getName()+"---"+dst.length());
}else {
//用更大的文件,存在一边传一边写的情况
if (_file.length()>dst.length()) {
_file.renameTo(dst);
System.out.println("将较大的文件重命名到最终的文件"+dst.getName()+"---"+dst.length());
}else {
//比已有的还小就不用处理了
System.out.println("文件大小一致,不处理"+dst.getName()+"---"+dst.length());
}
}
//删除分片文件
for (File file : toDelete) {
//不能因为个别文件删除失败导致所有文件不删除
try {
System.out.println("删除"+file.getName()+">>>"+file.length());
file.delete();
}catch(Exception eee) {}
}
response.getWriter().write("{\"status\":true,\"url\":\"" + getUrl(dst) + "\"}");
}else{
response.getWriter().write("{\"status\":true,\"newName\":\"" + newFileName + "\"}");
}
通过上面的机制,基本可以保证终端录制的大文件,能及时、准确的上传到服务器上。当然在实际调试过程中,也遇到了不少问题:
- 手机端的数据写入延迟。在开始阶段,总是发现上传到服务器的文件无法播放,将手机本地和服务器的文件做对比后,返现文件的开始和结尾部分都有差异。后来发现录制完成后,手机中的文件并没有立即写入磁盘,需要等待一定时间,文件越大,这个时间越久。
- 重复传输导致服务器端的文件损坏。主要是同一个文件传输了两次,第一次的传输任务正常合并视频文件的过程中,第二次传输任务也往同一个目标文件合并视频文件,读写冲突导致最后生成的视频文件损坏。后来改为每次合并视频文件到随机生成的临时文件,合并完成后,再改名为最终的文件,比如对同一个文件并发写导致的错误。