【Android】文件分块上传
在完成一个项目时,遇到了需要上传长视频的场景,尽管可以手动限制视频清晰度和视频的码率帧率,但仍然避免不了视频大小过大的问题,且由于服务器原因,网络不太稳定。这个时候想到了可以将文件分块。
为什么选择文件分片上传
- 提高上传成功率:在网络不稳定或上传大文件时,一般上传可能因网络中断而导致整个上传过程失败,需要重新开始。而分片上传是将文件分成多个小块分别上传,即使某个分片上传失败,只需重新上传该分片,而不是整个文件,大大提高了上传的成功率。
- 实现断点续传:分片上传可以记录每个分片的上传进度,当上传因某种原因中断后,再次启动上传时,可以从上次中断的位置继续上传未完成的分片,无需从头开始,节省了时间和带宽。
- 并发上传:可以将多个分片同时上传到服务器,利用多线程或并发请求技术,充分利用网络带宽,加快上传速度。特别是对于大文件,并发上传多个分片能够显著缩短上传时间。
文件MD5 摘要计算
public static String getFileMD5String(File file) {
MessageDigest messageDigest = null;
FileInputStream fileInputStream = null;
try {
// 1. 初始化 MD5 摘要器
messageDigest = MessageDigest.getInstance("MD5");
// 2. 打开文件输入流
fileInputStream = new FileInputStream(file);
byte[] buffer = new byte[8192]; // 8KB 缓冲区
int length;
// 3. 流式读取文件内容
while ((length = fileInputStream.read(buffer)) != -1) {
messageDigest.update(buffer, 0, length); // 更新哈希计算
}
// 4. 生成最终哈希值
byte[] digest = messageDigest.digest();
// 5. 转换为十六进制字符串
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
String hex = Integer.toHexString(0xFF & b); // 字节转十六进制
if (hex.length() == 1) {
sb.append('0'); // 补零对齐
}
sb.append(hex);
}
return sb.toString();
} catch (...) {
// 异常处理
} finally {
// 关闭流
}
return null;
}
1. 为什么使用 MessageDigest
?
MessageDigest
是 Java 标准库中专门用于生成哈希摘要的类。- 支持多种算法(MD5、SHA-1、SHA-256 等),通过
getInstance("MD5")
指定算法。
2. 为什么分块读取(8KB 缓冲区)?
- 内存效率:直接读取整个文件到内存会导致 OOM(尤其处理大文件时)。
- 性能平衡:
- 过小(如 1KB)→ 增加 I/O 次数,降低性能。
- 过大(如 1MB)→ 占用更多内存,边际收益递减。
3. 流式更新的必要性
while ((length = fileInputStream.read(buffer)) != -1) {
messageDigest.update(buffer, 0, length);
}
- 逐块更新哈希状态,避免一次性处理整个文件。
- 即使文件大小超过内存限制,仍可正常计算。
并发上传
public void sendDetectVideo(File file, String token,String fileMD5String, LoadTasksCallBack callBack) {
long fileSize = file.length();
int totalChunks = (int) Math.ceil(fileSize * 1.0 / CHUNK_SIZE);
CountDownLatch latch = new CountDownLatch(totalChunks);
Map<Integer, Future<Boolean>> futures = new HashMap<>();
for (int i = 0; i < totalChunks; i++) {
final int chunkIndex = i;
long start = i * CHUNK_SIZE;
long end = Math.min((i + 1) * CHUNK_SIZE, fileSize);
Callable<Boolean> task = () -> {
try {
Log.d("TAG", "sendDetectVideo: " + token);
return uploadChunk(file, token, start, end, chunkIndex, totalChunks, fileMD5String, callBack);
} finally {
latch.countDown();
}
};
Future<Boolean> future = executorService.submit(task);
futures.put(chunkIndex, future);
}
try {
latch.await();
boolean allSuccess = true;
for (Future<Boolean> future : futures.values()) {
if (!future.get()) {
allSuccess = false;
break;
}
}
if (allSuccess) {
callBack.onSuccess("所有分块上传成功");
} else {
callBack.onFailed("部分分块上传失败");
}
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
callBack.onFailed("上传过程中出现异常: " + e.getMessage());
} finally {
executorService.shutdown();
}
}
初始化参数
long fileSize = file.length();
int totalChunks = (int) Math.ceil(fileSize * 1.0 / CHUNK_SIZE);
- 计算总分块数:根据文件大小和预设的
CHUNK_SIZE
(如5MB)计算需要分成多少块。
并发控制工具
CountDownLatch latch = new CountDownLatch(totalChunks);
Map<Integer, Future<Boolean>> futures = new HashMap<>();
- CountDownLatch:用于等待所有分块上传完成(初始值为总分块数)。
- Future集合:保存每个分块上传任务的执行结果。
遍历所有分块
for (int i = 0; i < totalChunks; i++) {
final int chunkIndex = i;
long start = i * CHUNK_SIZE;
long end = Math.min((i + 1) * CHUNK_SIZE, fileSize);
Callable<Boolean> task = () -> {
try {
return uploadChunk(...); // 上传分块
} finally {
latch.countDown(); // 无论成功与否,计数器减1
}
};
Future<Boolean> future = executorService.submit(task);
futures.put(chunkIndex, future);
}
- 分块范围计算:确定每个分块的起始(
start
)和结束(end
)位置。 - 任务定义:每个分块上传逻辑封装为
Callable
任务,上传完成后触发latch.countDown()
。 - 任务提交:将任务提交到线程池
executorService
,保存返回的Future
对象。
等待所有分块完成
try {
latch.await(); // 阻塞直到所有分块完成
// 检查所有任务结果
boolean allSuccess = true;
for (Future<Boolean> future : futures.values()) {
if (!future.get()) { // 获取任务执行结果
allSuccess = false;
break;
}
}
// 回调结果
if (allSuccess) {
callBack.onSuccess("所有分块上传成功");
} else {
callBack.onFailed("部分分块上传失败");
}
} catch (...) {
// 异常处理
} finally {
executorService.shutdown(); // 关闭线程池
}
- 阻塞等待:
latch.await()
确保主线程等待所有分块上传完成。 - 结果检查:遍历所有
Future
,检查每个分块是否上传成功。 - 回调通知:根据结果调用
onSuccess
或onFailed
。 - 资源释放:关闭线程池。
Build请求体
private boolean uploadChunk(File file, String token, long start, long end, int chunkIndex,int totalChunks, String MD5, LoadTasksCallBack callBack) {
RequestParams mToken = new RequestParams();
mToken.put("Authorization", "Bearer " + token);
MultipartBody.Builder requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM);
Log.d(TAG, "uploadChunk: " + chunkIndex + " " + totalChunks + " " + MD5);
MultipartBody multipartBody = requestBody.addFormDataPart("md5", MD5)
.addFormDataPart("chunkIndex", String.valueOf(chunkIndex))
.addFormDataPart("totalChunks", String.valueOf(totalChunks))
.addFormDataPart("file", "file"
, createChunkRequestBody(file, chunkIndex, start, end))
.build();
Request request = createRequest(URL.SEND_VIDEO_FILE_URL, multipartBody, mToken, start);
return executeRequest(request, callBack);
}
private Request createRequest(String url, MultipartBody multipartBody, RequestParams mToken, long start) {
Headers.Builder mHeadersBuilder = new Headers.Builder();
for (Map.Entry<String, String> entry : mToken.urlParams.entrySet()) {
mHeadersBuilder.add(entry.getKey(), entry.getValue());
}
Request.Builder requestBuilder = new Request.Builder()
.url(url)
.headers(mHeadersBuilder.build())
.post(multipartBody);
return requestBuilder.build();
}
private boolean executeRequest(Request request, LoadTasksCallBack callBack) {
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful()) {
String string = response.body().string();
System.out.println("FileonResponse: " + string);
callBack.onSuccess(string);
return true;
} else if (response.code() == 416) {
// 416表示请求的Range无效,可能需要重新上传该分块
System.out.println("onResponse: 分块上传失败,重新上传该分块");
callBack.onFailed("分块上传失败,重新上传该分块");
return false;
} else {
System.out.println("onResponse: 上传失败,状态码: " + response.code());
callBack.onFailed("上传失败,状态码: " + response.code());
return false;
}
} catch (IOException e) {
System.out.println("onFailure: " + "上传失败");
e.printStackTrace();
callBack.onFailed("上传失败: " + e.getMessage());
return false;
}
}
分块相关
为视频文件的指定分块生成一个RequestBody
对象,用于通过OkHttp将分块数据流式上传到服务器,避免一次性加载大文件到内存。
private RequestBody createChunkRequestBody(File videoFile,int chunkIndex, long start, long end) {
return new RequestBody() {
@Override
public MediaType contentType() {
return MediaType.parse("video/mp4");
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
try (RandomAccessFile file = new RandomAccessFile(videoFile, "r");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(8192);
long position = start;
long remaining = end - start;
while (remaining > 0) {
int readSize = (int) Math.min(buffer.capacity(), remaining);
buffer.limit(readSize);
int bytesRead = channel.read(buffer, position);
if (bytesRead == -1) break;
sink.write(buffer.array(), 0, bytesRead);
position += bytesRead;
remaining -= bytesRead;
buffer.clear();
}
}
}
};
}
方法签名
private RequestBody createChunkRequestBody(
File videoFile, // 要上传的视频文件
int chunkIndex, // 当前分块的索引(未直接使用)
long start, // 分块起始字节位置
long end // 分块结束字节位置
)
匿名内部类
返回一个自定义的RequestBody
对象,重写两个关键方法:
contentType()
- 指定内容类型
@Override
public MediaType contentType() {
return MediaType.parse("video/mp4"); // 明确告知服务器上传的是MP4视频
}
writeTo(BufferedSink sink)
- 数据写入逻辑
@Override
public void writeTo(BufferedSink sink) throws IOException {
try (
RandomAccessFile file = new RandomAccessFile(videoFile, "r"); // 只读模式打开文件
FileChannel channel = file.getChannel() // 获取NIO文件通道
) {
ByteBuffer buffer = ByteBuffer.allocate(8192); // 分配8KB缓冲区
long position = start; // 当前读取位置
long remaining = end - start; // 剩余需读取的字节数
while (remaining > 0) {
// 确定本次读取的字节数
int readSize = (int) Math.min(buffer.capacity(), remaining);
buffer.limit(readSize); // 设置缓冲区读取上限
// 从文件指定位置读取数据到缓冲区
int bytesRead = channel.read(buffer, position);
if (bytesRead == -1) break; // 文件已读完
// 将缓冲区数据写入网络流
sink.write(buffer.array(), 0, bytesRead);
// 更新位置和剩余字节数
position += bytesRead;
remaining -= bytesRead;
buffer.clear(); // 重置缓冲区供下次使用
}
}
}
流程示意
+----------------+ +----------------+ +----------------+
| 视频文件 | | ByteBuffer | | OkHttp请求流 |
| (分块范围: | ----> | (8KB缓冲区) | ----> | (BufferedSink) |
| start - end) | +----------------+ +----------------+
+----------------+
▲
| 每次定位到
| 新的position
+-----------------+