OkHttp文件上传(2):实现文件分块上传
前言
分块上传和断点下载很像,就是讲文件分为多份来传输,从而实现暂停和继续传输。区别是断点下载的进度保存在客户端,ey往是写入数据库,分块上传的进度保存在服务器,每次可以通过文件的md5请求服务器,来获取最新的上传偏移量。但是这样明显效率偏低,客户端可以把offSet保存在内存,每上传一块文件服务器返回下一次的offSet。只不过这个offSet不需要保存在数据库,每次app关闭在打开继续上传可以请求服务器,获取最新偏移量。
分块上传原理
1.客户端向服务端申请文件的上传地址
a. 如果上传过,直接返回uuid (快速上传)
b. 没上传过,返回 上传地址url + 上传偏移量offset
下面上传一段31M大小的mp4文件,申请上传地址服务端返回offSet = 0表示文件没有上传过,需要从头开始上传
image.png2.客户端对本地文件进行分块,比如10M为一块chunk
上传第一块:
image.png3.客户端以标准表单方式,上传 offset 到 offset+chunk的文件分块,每次上传完服务端返回新的offset,客户端更新offset值并继续下一次上传,如此循环。
上传最后一块:
image.png4.最后服务端返回文件uuid,代表整个文件上传成功
基于Okhttp的实现
Okhttp已经支持表单形式的文件上传,剩下的关键就是:
构造分块文件的RequestBody,对本地文件分块,和服务端约定相关header,保存offset实现分块上传
构造RequestBody
继承之前实现的进度监听RequestBody:
public class MDProgressRequestBody extends FileProgressRequestBody {
protected final byte[] content;
public MDProgressRequestBody(byte[] content, String contentType , ProgressListener listener) {
this.content = content;
this.contentType = contentType;
this. listener = listener;
}
@Override
public long contentLength() {
return content.length;
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
int offset = 0 ;
//计算分块数
count = (int) ( content.length / SEGMENT_SIZE + (content.length % SEGMENT_SIZE != 0?1:0) );
for( int i=0; i < count; i++ ) {
int chunk = i != count -1 ? SEGMENT_SIZE : content.length - offset;
sink.buffer().write(content, offset, chunk );//每次写入SEGMENT_SIZE 字节
sink.buffer().flush();
offset += chunk;
listener.transferred( offset );
}
}
}
注意这个RequestBody传入Byte数组,从而实现了对文件的分块上传。
对文件分块
上面的RequestBody支持传输Byte数组,那么如何把文件切割成byte[]:
/**
* 文件分块工具
* @param offset 起始偏移位置
* @param file 文件
* @param blockSize 分块大小
* @return 分块数据
*/
public static byte[] getBlock(long offset, File file, int blockSize) {
byte[] result = new byte[blockSize];
RandomAccessFile accessFile = null;
try {
accessFile = new RandomAccessFile(file, "r");
accessFile.seek(offset);
int readSize = accessFile.read(result);
if (readSize == -1) {
return null;
} else if (readSize == blockSize) {
return result;
} else {
byte[] tmpByte = new byte[readSize];
System.arraycopy(result, 0, tmpByte, 0, readSize);
return tmpByte;
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (accessFile != null) {
try {
accessFile.close();
} catch (IOException e1) {
}
}
}
return null;
}
基于OkHttp的分块上传
关键就是构造Request对象:
protected Request generateRequest(String url) {
// 获取分块数据,按照每次10M的大小分块上传
final int CHUNK_SIZE = 10 * 1024 * 1024;
//切割文件为10M每份
byte[] blockData = FileUtil.getBlock(offset, new File(fileInfo.filePath), CHUNK_SIZE);
if (blockData == null) {
throw new RuntimeException(String.format("upload file get blockData faild,filePath:%s , offest:%d", fileInfo.filePath, offset));
}
curBolckSize = blockData.length;
// 分块上传,客户端和服务端约定,name字段传文件分块的始偏移量
String formData = String.format("form-data;name=%s; filename=file", offset);
RequestBody filePart = new MDProgressRequestBody(blockData, "application/octet-stream ", this);
MultipartBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addPart(Headers.of("Content-Disposition", formData), filePart)
.build();
// 创建Request对象
Request request = new Request.Builder()
.url(url)
.post(requestBody)
.build();
return request;
}
用OkHttp执行上传:
上传开始前调用获取上传地址的接口,从而获取初始offSet,然后开始上传:
```java
while (offset < fileInfo.fileSize) {
//doUpload是阻塞式方法,必须返回结果后才下一次调用
int result = doUpload(url); // readResponse()会修正偏移量
if (result != STATUS_RETRY) {
return result;
}
}
定义文件上传的执行方法doUpload:(和上文OkHttp监听进度的文件上传一样,只是不过构造的Request不同)
protected int doUpload(String url){
try {
OkHttpClient httpClient = OkHttpClientMgr.Instance().getOkHttpClient();
call = httpClient.newCall( generateRequest(url) );
Response response = call.execute();
if (response.isSuccessful()) {
sbFileUUID = new StringBuilder();
return readResponse(response,sbFileUUID);
} else( ... ) { // 重试
return STATUS_RETRY;
}
} catch (IOException ioe) {
LogUtil.e(LOG_TAG, "exception occurs while uploading file!",ioe);
}
return isCancelled() ? STATUS_CANCEL : STATUS_FAILED_EXIT;
}
这里的readRespones读取服务端结果,更新offSet数值:
// 解析服务端响应结果
protected int readResponse(Response response, StringBuilder sbFileUUID) {
int exitStatus = STATUS_FAILED_EXIT;
ResponseBody body = response.body();
if (body == null) {
LogUtil.e(LOG_TAG, "readResponse body is null!", new Throwable());
return exitStatus;
}
try {
String content = body.string();
JSONObject jsonObject = new JSONObject(content);
if (jsonObject.has("uuid")) { // 上传成功,返回UUID
String uuid = jsonObject.getString("uuid");
if (uuid != null && !uuid.isEmpty()) {
sbFileUUID.append(uuid);
exitStatus = STATUS_SUCCESS;
} else {
LogUtil.e(LOG_TAG, "readResponse fileUUID return empty! ");
}
} else if (jsonObject.has("offset")) { // 分块上传完成,返回新的偏移量
long newOffset = (long) jsonObject.getLong("offset");
if (newOffset != offset + curBolckSize) {
LogUtil.e(LOG_TAG, "readResponse offest-value exception ! ");
} else {
offset = newOffset; // 分块数据上传完成,修正偏移
exitStatus = STATUS_RETRY;
}
} else {
LogUtil.e(LOG_TAG, "readResponse unexpect data , no offest、uuid field !");
}
} catch (Exception ex) {
LogUtil.e(LOG_TAG, "readResponse exception occurs!", ex);
}
return exitStatus;
}
说明
1.offSet值是保存在服务端的,比如中途上传失败了,下次继续上传,调用申请上传地址接口,服务端会返回最新的offSet告诉你从哪开始上传。
2.本文方案不支持多线程分块上传,必须按照文件切割的顺序,依次上传