4. 表单字段与文件混合上传:@PartMap、Map<String, RequestBody> 与字段类型转换

在 Retrofit 中,单文件上传可以通过 @Part MultipartBody.Part 实现;但实际业务经常需要在一次请求中同时携带多个文件以及额外的普通表单字段(如用户 ID、备注、分类标签等)。这时就需要使用 @PartMap 结合 Map<String, RequestBody> 将字段和文件统一成“键-请求体”对进行混合上传。本章深入剖析其原理、类型转换、实现流程及调试要点。

4.1 核心原理:PartMap 的工作机制

当 Retrofit 接口方法标注 @Multipart 时,@PartMap 注解会将一个 Map<String, RequestBody> 参数中的每一个键值对转换为一个独立的 MultipartBody.Part。键作为 表单域名,值作为 请求体内容。对于普通文本字段,使用 RequestBody.create(MediaType, String) 构造;对于文件字段,使用 RequestBody.create(MediaType, File) 或 RequestBody.create(MediaType, byte[]) 构造。

关键点:@PartMap 无法与 @Part 混用同一接口方法(部分旧版本支持,但官方推荐统一使用 @PartMap 管理所有表单项和文件项)。

提示:MediaType 必须与内容匹配。文本字段统一使用 MediaType.parse("text/plain");文件字段使用对应的 MIME 类型,如 "image/jpeg"、"application/octet-stream"。

4.2 类型转换与 RequestBody 构建

在构建 Map<String, RequestBody> 时,需将每种字段类型转换为 RequestBody。下表列举常见类型及其转换方式:

源数据类型 对应 MediaType 转换示例(kotlin)
String text/plain RequestBody.create(MediaType.parse("text/plain"), "value")
Int / Long / Boolean text/plain RequestBody.create(MediaType.parse("text/plain"), value.toString())
File 文件实际 MIME(如 image/jpeg) RequestBody.create(MediaType.parse("image/jpeg"), file)
byte[] application/octet-stream RequestBody.create(MediaType.parse("application/octet-stream"), bytes)
Uri(需要先读取内容流) 取决于文件类型 读取 InputStream 转为 byte[] 后构造

注意:对于文件上传,推荐使用 MultipartBody.Part 单独处理文件(可保留文件名),但若必须与普通字段混合在同一个 Map 中,则需使用 RequestBody 包装且无法携带原始文件名(文件名需额外通过表单字段传递)。

4.3 接口定义与调用示例

Retrofit 接口

@Multipart
@POST("upload/mixed")
suspend fun uploadWithFields(
    @PartMap parts: Map<String, @JvmSuppressWildcards RequestBody>
): Response<UploadResult>

构建 Map 并调用(Kotlin 协程示例)

suspend fun performMixedUpload() {
    val partMap = mutableMapOf<String, RequestBody>()

    // 1. 添加普通文本字段
    partMap["user_id"] = RequestBody.create(
        MediaType.parse("text/plain"), "12345"
    )
    partMap["remark"] = RequestBody.create(
        MediaType.parse("text/plain"), "混合上传测试"
    )

    // 2. 添加文件(假设 file1 为 File 对象)
    val file1 = File("/data/photo.jpg")
    partMap["photo\"; filename=\"photo.jpg"] = RequestBody.create(
        MediaType.parse("image/jpeg"), file1
    )

    // 3. 添加另一个文件(使用 byte[])
    val fileBytes = File("/data/doc.pdf").readBytes()
    partMap["document\"; filename=\"doc.pdf"] = RequestBody.create(
        MediaType.parse("application/pdf"), fileBytes
    )

    val response = apiService.uploadWithFields(partMap)
    // handle response
}

注意:文件字段的键写法必须包含 "; filename=\"实际文件名\"",否则服务器端接收到的文件名可能为空或乱码。这是 @PartMap 模拟 Content-Disposition 的标准方式。

4.4 典型混合上传流程图

@PartMap 混合上传执行流程 客户端构建 Map 添加 text/plain 字段 添加文件 RequestBody @PartMap 解析 OkHttp 组装 MultipartBody 发送 multipart/form-data 服务端按 name 获取 说明 ① 文件字段的键需附带 "; filename=\"xxx\"" 以传输文件名。 ② 使用 @PartMap 时无法为每个 Part 单独设置 Content-Disposition 的额外参数(如 charset)。 ③ 服务端通过表单域名(键名)区分普通字段和文件数据。

4.5 调试与常见问题

常见错误 1:文件字段键名格式错误 — 若缺少 "; filename=\"..." 部分,服务端可能无法正确解析文件名,甚至报“missing content-disposition”异常。

常见错误 2:MIME 类型不匹配 — 使用错误的 MediaType 会导致服务端拒绝接收。建议通过文件扩展名动态推断 MIME。

常见错误 3:Map 中包含 null 值 — Retrofit 在构建 Part 时会抛出 NullPointerException。必须确保 Map 中所有 value 非空。

调试技巧: 在 OkHttp 客户端添加日志拦截器查看实际发出的 multipart 请求体:

val logging = HttpLoggingInterceptor().apply {
    level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
    .addInterceptor(logging)
    .build()

日志输出示例(关键部分):

Content-Type: multipart/form-data; boundary=xxx
--xxx
Content-Disposition: form-data; name="user_id"
Content-Transfer-Encoding: binary

12345
--xxx
Content-Disposition: form-data; name="photo"; filename="photo.jpg"
Content-Type: image/jpeg
Content-Transfer-Encoding: binary

...binary data...

确认每个 part 的 Content-Disposition 字段符合预期。

4.6 性能与稳定性建议

  • 避免大文件异步读取:对于大文件(超过 10MB),建议使用流式 RequestBody 而不是一次性加载到 byte[],防止 OOM。可使用 RequestBody.create(MediaType, file) 内部自动处理。
  • 统一 MIME 映射:编写工具函数根据文件扩展名返回正确的 MediaType,避免硬编码错误。
  • 超时设置:混合上传通常包含较大文件,建议单独对上传接口设置更长的 writeTimeout:
val client = OkHttpClient.Builder()
    .writeTimeout(60, TimeUnit.SECONDS)
    .build()
  • 字段校验前置:在发送请求前检查必填字段和文件是否存在,避免网络请求空耗。

4.7 完整代码模板(Kotlin + Retrofit + OkHttp)

以下提供一个可复用的“构建混合上传 Map”辅助函数,适用于大多数业务场景。

object UploadHelper {
    private fun getMimeType(fileName: String): String = when {
        fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> "image/jpeg"
        fileName.endsWith(".png") -> "image/png"
        fileName.endsWith(".gif") -> "image/gif"
        fileName.endsWith(".pdf") -> "application/pdf"
        fileName.endsWith(".mp4") -> "video/mp4"
        else -> "application/octet-stream"
    }

    fun buildMixedPartMap(
        textFields: Map<String, String>,
        fileFields: Map<String, File>
    ): Map<String, RequestBody> {
        val map = mutableMapOf<String, RequestBody>()
        textFields.forEach { (key, value) ->
            map[key] = RequestBody.create(MediaType.parse("text/plain"), value)
        }
        fileFields.forEach { (key, file) ->
            val mime = getMimeType(file.name)
            // 键格式:表单域名; filename="文件名"
            val partKey = "$key\"; filename=\"${file.name}"
            map[partKey] = RequestBody.create(MediaType.parse(mime), file)
        }
        return map
    }
}

// 使用示例
suspend fun example() {
    val textFields = mapOf("user_id" to "100", "category" to "report")
    val fileFields = mapOf("document" to File("/tmp/report.pdf"))
    val parts = UploadHelper.buildMixedPartMap(textFields, fileFields)
    val response = apiService.uploadWithFields(parts)
}