Android 大文件分块上传实战:突破表单数据限制的完整方案
一、问题背景与核心思路
1.1 场景痛点
当 Android 客户端需要上传 500MB 的大文件到服务器,而服务器表单限制为 2MB 时,传统的直接上传方案将完全失效。此时需要设计一套分块上传机制,将大文件拆分为多个小块,突破服务器限制。
1.2 核心思路
分块上传 + 服务端合并:
- 将文件切割为 ≤2MB 的块
- 逐块上传至服务器
- 服务端接收后按顺序合并
二、Android 客户端实现细节
2.1 分块处理与上传流程
完整代码实现(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)) continueval chunkData = readChunk(fis, chunkNumber)val isLastChunk = chunkNumber == totalChunks - 1try {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_SIZEfis.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)确保唯一性
fun generateFileId(file: File): String {val digest = MessageDigest.getInstance("SHA-256")file.inputStream().use { is ->val buffer = ByteArray(8192)var read: Intwhile (is.read(buffer).also { read = it } > 0) {digest.update(buffer, 0, read)}}return digest.digest().toHex() }
-
进度持久化存储:使用 SharedPreferences 记录上传进度
private 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)
// 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 接收分块接口
@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();}@Asyncpublic void asyncMergeFile(String fileId, int totalChunks) {// 实现合并逻辑}
}
3.2 合并文件实现
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 加密传输
-
服务端优化
- 设置合理的临时文件清理策略
- 使用异步合并操作避免阻塞请求线程
- 实现分块哈希校验(示例代码见下方)
分块校验示例(服务端):
// 计算分块MD5
String receivedHash = DigestUtils.md5Hex(chunk.getInputStream());
if (!receivedHash.equals(clientProvidedHash)) {throw new InvalidChunkException("Chunk hash mismatch");
}
七、扩展方案:第三方云存储集成
对于不想自行实现分块上传的场景,可考虑以下方案:
-
阿里云OSS分片上传
val 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
TransferUtility transferUtility = TransferUtility.builder().s3Client(s3Client).context(context).build();MultipleFileUpload upload = transferUtility.uploadDirectory(bucketName, remoteDir, localDir, new ObjectMetadataProvider() {@Overridepublic void provideObjectMetadata(File file, ObjectMetadata metadata) {metadata.setContentType("application/octet-stream");}});upload.setTransferListener(new UploadListener());
八、关键点总结
- 分块策略:合理设置分块大小,生成唯一文件标识
- 断点续传:本地持久化上传进度,支持网络恢复
- 完整性校验:客户端与服务端双端校验分块数据
- 并发控制:平衡并行上传数量与服务器压力
- 错误处理:实现自动重试与异常上报机制
- 安全防护:身份验证 + 传输加密 + 大小限制
九、参考资源
- Retrofit 官方文档
- Spring Boot 文件上传指南
- AWS Android SDK 文档
- MD5校验最佳实践