一、问题背景与核心思路
1.1 场景痛点
当 Android 客户端需要上传 500MB 的大文件到服务器,而服务器表单限制为 2MB 时,传统的直接上传方案将完全失效。此时需要设计一套分块上传机制,将大文件拆分为多个小块,突破服务器限制。
1.2 核心思路
分块上传 + 服务端合并:
- 将文件切割为 ≤2MB 的块
- 逐块上传至服务器
- 服务端接收后按顺序合并
二、Android 客户端实现细节
2.1 分块处理与上传流程
完整代码实现(Kotlin)
kotlin
// FileUploader.kt
object FileUploader {
// 分块大小(1.9MB 预留安全空间)
private const val CHUNK_SIZE = 1.9 * 1024 * 1024
suspend fun uploadLargeFile(context: Context, file: File) {
val fileId = generateFileId(file) // 生成唯一文件标识
val totalChunks = calculateTotalChunks(file)
val uploadedChunks = loadProgress(context, fileId) // 加载已上传分块记录
FileInputStream(file).use { fis ->
for (chunkNumber in 0 until totalChunks) {
if (uploadedChunks.contains(chunkNumber)) continue
val chunkData = readChunk(fis, chunkNumber)
val isLastChunk = chunkNumber == totalChunks - 1
try {
uploadChunk(fileId, chunkNumber, totalChunks, chunkData, isLastChunk)
saveProgress(context, fileId, chunkNumber) // 记录成功上传的分块
} catch (e: Exception) {
handleRetry(fileId, chunkNumber) // 重试逻辑
}
}
}
}
private fun readChunk(fis: FileInputStream, chunkNumber: Int): ByteArray {
val skipBytes = chunkNumber * CHUNK_SIZE
fis.channel().position(skipBytes.toLong())
val buffer = ByteArray(CHUNK_SIZE)
val bytesRead = fis.read(buffer)
return if (bytesRead < buffer.size) buffer.copyOf(bytesRead) else buffer
}
}
关键技术点解析
-
唯一文件标识生成:通过文件内容哈希(如 SHA-256)确保唯一性
kotlinfun generateFileId(file: File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { is -> val buffer = ByteArray(8192) var read: Int while (is.read(buffer).also { read = it } > 0) { digest.update(buffer, 0, read) } } return digest.digest().toHex() }
-
进度持久化存储:使用 SharedPreferences 记录上传进度
kotlinprivate fun saveProgress(context: Context, fileId: String, chunk: Int) { val prefs = context.getSharedPreferences("upload_progress", MODE_PRIVATE) val key = "${fileId}_chunks" val existing = prefs.getStringSet(key, mutableSetOf()) ?: mutableSetOf() prefs.edit().putStringSet(key, existing + chunk.toString()).apply() }
2.2 网络请求实现(Retrofit + Kotlin Coroutine)
kotlin
// UploadService.kt
interface UploadService {
@Multipart
@POST("api/upload/chunk")
suspend fun uploadChunk(
@Part("fileId") fileId: RequestBody,
@Part("chunkNumber") chunkNumber: RequestBody,
@Part("totalChunks") totalChunks: RequestBody,
@Part("isLast") isLast: RequestBody,
@Part chunk: MultipartBody.Part
): Response<UploadResponse>
}
// 上传请求封装
private suspend fun uploadChunk(
fileId: String,
chunkNumber: Int,
totalChunks: Int,
chunkData: ByteArray,
isLast: Boolean
) {
val service = RetrofitClient.create(UploadService::class.java)
val requestFile = chunkData.toRequestBody("application/octet-stream".toMediaType())
val chunkPart = MultipartBody.Part.createFormData(
"chunk",
"chunk_${chunkNumber}",
requestFile
)
val response = service.uploadChunk(
fileId = fileId.toRequestBody(),
chunkNumber = chunkNumber.toString().toRequestBody(),
totalChunks = totalChunks.toString().toRequestBody(),
isLast = isLast.toString().toRequestBody(),
chunk = chunkPart
)
if (!response.isSuccessful) {
throw IOException("Upload failed: ${response.errorBody()?.string()}")
}
}
三、服务端实现(Spring Boot 示例)
3.1 接收分块接口
java
@RestController
@RequestMapping("/api/upload")
public class UploadController {
@Value("${upload.temp-dir:/tmp/uploads}")
private String tempDir;
@PostMapping("/chunk")
public ResponseEntity<?> uploadChunk(
@RequestParam String fileId,
@RequestParam int chunkNumber,
@RequestParam int totalChunks,
@RequestParam boolean isLast,
@RequestPart("chunk") MultipartFile chunk) {
// 创建临时目录
Path tempDirPath = Paths.get(tempDir, fileId);
if (!Files.exists(tempDirPath)) {
try {
Files.createDirectories(tempDirPath);
} catch (IOException e) {
return ResponseEntity.status(500).body("Create dir failed");
}
}
// 保存分块
Path chunkFile = tempDirPath.resolve("chunk_" + chunkNumber);
try {
chunk.transferTo(chunkFile);
} catch (IOException e) {
return ResponseEntity.status(500).body("Save chunk failed");
}
// 如果是最后一块则触发合并
if (isLast) {
asyncMergeFile(fileId, totalChunks);
}
return ResponseEntity.ok().build();
}
@Async
public void asyncMergeFile(String fileId, int totalChunks) {
// 实现合并逻辑
}
}
3.2 合并文件实现
java
private void mergeFile(String fileId, int totalChunks) throws IOException {
Path tempDir = Paths.get(this.tempDir, fileId);
Path outputFile = Paths.get("/data/final", fileId + ".dat");
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(outputFile))) {
for (int i = 0; i < totalChunks; i++) {
Path chunk = tempDir.resolve("chunk_" + i);
Files.copy(chunk, out);
}
out.flush();
}
// 清理临时文件
FileUtils.deleteDirectory(tempDir.toFile());
}
四、技术对比与方案选择
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
传统表单上传 | 实现简单 | 受限于服务器大小限制 | 小文件上传(<2MB) |
分块上传 | 突破大小限制,支持断点续传 | 实现复杂度较高 | 大文件上传(>100MB) |
第三方云存储SDK | 无需自行实现,功能完善 | 依赖第三方服务,可能有费用产生 | 需要快速集成云存储的场景 |
五、关键实现步骤总结
-
客户端分块切割
- 确定分块大小(建议略小于限制值)
- 生成唯一文件ID(基于文件内容哈希)
- 实现可恢复的上传进度记录
-
分块上传
- 使用多部分表单上传每个分块
- 携带分块元数据(序号/总数/文件ID)
- 实现超时重试机制
-
服务端处理
- 按文件ID创建临时存储目录
- 验证分块完整性(可选MD5校验)
- 原子性合并操作
-
可靠性增强
- 断点续传支持
- 网络异常自动重试
- 上传完整性校验
六、注意事项与优化建议
-
分块大小优化
- 建议设置为
服务器限制值 * 0.95
(如 1.9MB) - 测试不同分块大小对传输效率的影响
- 建议设置为
-
并发控制
- 可并行上传多个分块(需服务端支持)
- 合理控制并发数(建议 3-5 个并行)
-
安全防护
- 添加身份验证(JWT Token)
- 限制单个文件的最大分块数
- 使用 HTTPS 加密传输
-
服务端优化
- 设置合理的临时文件清理策略
- 使用异步合并操作避免阻塞请求线程
- 实现分块哈希校验(示例代码见下方)
分块校验示例(服务端):
java
// 计算分块MD5
String receivedHash = DigestUtils.md5Hex(chunk.getInputStream());
if (!receivedHash.equals(clientProvidedHash)) {
throw new InvalidChunkException("Chunk hash mismatch");
}
七、扩展方案:第三方云存储集成
对于不想自行实现分块上传的场景,可考虑以下方案:
-
阿里云OSS分片上传
kotlinval oss = OSSClient(context, endpoint, credentialProvider) val request = InitiateMultipartUploadRequest(bucketName, objectKey) val uploadId = oss.initMultipartUpload(request).uploadId // 上传分片 val partETags = mutableListOf<PartETag>() for (i in chunks.indices) { val uploadPartRequest = UploadPartRequest( bucketName, objectKey, uploadId, i+1).apply { partContent = chunks[i] } partETags.add(oss.uploadPart(uploadPartRequest).partETag) } // 完成上传 val completeRequest = CompleteMultipartUploadRequest( bucketName, objectKey, uploadId, partETags) oss.completeMultipartUpload(completeRequest)
-
AWS S3 TransferUtility
javaTransferUtility transferUtility = TransferUtility.builder() .s3Client(s3Client) .context(context) .build(); MultipleFileUpload upload = transferUtility.uploadDirectory( bucketName, remoteDir, localDir, new ObjectMetadataProvider() { @Override public void provideObjectMetadata(File file, ObjectMetadata metadata) { metadata.setContentType("application/octet-stream"); } }); upload.setTransferListener(new UploadListener());
八、关键点总结
- 分块策略:合理设置分块大小,生成唯一文件标识
- 断点续传:本地持久化上传进度,支持网络恢复
- 完整性校验:客户端与服务端双端校验分块数据
- 并发控制:平衡并行上传数量与服务器压力
- 错误处理:实现自动重试与异常上报机制
- 安全防护:身份验证 + 传输加密 + 大小限制