如何使用 @PartMap 和 @FieldMap 实现复杂的表单上传?

解读

国内面试里“文件+业务字段”混合上传是高频场景,如实名认证(身份证正反面 + 姓名、身份证号)、电商售后(多张凭证图 + 订单号、问题描述)。
@FieldMap 只能放在 @FormUrlEncoded 接口里,最终拼成 key=value&key=value 的文本体,不适合传二进制;
@PartMap 必须配合 @Multipart,每个 Part 可以携带文本或文件,且支持文件名、MimeType 等头部信息。
因此“复杂表单”= 既有可变长文本字段,又可能含 0~N 个文件,必须用 @PartMap 做统一载体;@FieldMap 仅作为“纯文本”场景的补充。
面试官想确认三件事:

  1. 是否知道 Retrofit 的 body 类型限制;
  2. 能否把动态 key、动态文件数量映射到 Map<String, RequestBody>;
  3. 是否了解中文文件名、大文件、内存压力的工程化细节。

知识点

  1. Retrofit 方法注解矩阵
    @FormUrlEncoded + @Field/@FieldMap → 文本表单,body 是 FormBody,OkHttp 最终 MediaType 为 application/x-www-form-urlencoded。
    @Multipart + @Part/@PartMap → 多段体,body 是 MultipartBody,MediaType 为 multipart/form-data; boundary=xxx,每段可自定义 Content-Disposition 与 Content-Type。

  2. RequestBody 创建方式
    文本:RequestBody.create(MediaType.parse("text/plain"), value)
    文件:RequestBody.create(MediaType.parse("image/jpeg"), file)
    进度监听:继承 RequestBody 重写 writeTo(BufferedSink),在 sink.write 循环里回调。

  3. PartMap 的 key 与文件名
    key 对应 Content-Disposition 里的 name 字段;
    文件名通过 Headers 额外传入:
    Headers headers = Headers.of("Content-Disposition", "form-data; name="file"; filename="身份证正面.jpg"")
    RequestBody body = RequestBody.create(MediaType.parse("image/*"), file)
    MultipartBody.Part part = MultipartBody.Part.create(headers, body)

  4. 动态字段组装
    后端要求字段顺序无关,但 key 可能运行时生成(如后端定义 field_0、field_1…),用 Map<String, RequestBody> partMap = new LinkedHashMap<>() 保证插入顺序。

  5. 内存与线程
    上传 500 MB 视频不能在主线程构造 RequestBody;
    用 @JvmStatic @Provides @OkHttpQualifier 给上传接口单独配置 60 s 读写超时、20 MB 缓存、Streaming 模式,防止 OOM。

  6. 国内 ROM 兼容
    小米、华为文件选择器返回 content:// 而非 file://,需用 ContentResolver.openInputStream() 拷贝到缓存目录再构造 RequestBody,否则 SQLite 抛出 “FileNotFoundException”。

  7. 混淆规则
    -keepattributes Signature 保留 Map 泛型,防止 PartMap 空指针。

答案

步骤一:定义接口

interface UploadApi {
    @Multipart
    @POST("kyc/upload")
    suspend fun uploadKyc(
        @PartMap partMap: Map<String, @JvmSuppressWildcards RequestBody>,
        @Part files: List<MultipartBody.Part>
    ): BaseResponse<String>
}

说明:PartMap 负责所有文本字段,List<MultipartBody.Part> 负责文件,避免 Map 里混用两种类型导致编译错误。

步骤二:构造文本 PartMap

val textMap = linkedMapOf<String, RequestBody>()
textMap["userId"] = RequestBody.create("text/plain".toMediaType(), "U123456")
textMap["idNo"]  = RequestBody.create("text/plain".toMediaType(), "1101********1234")

若后端要求字段名动态,可循环加入。

步骤三:构造文件 Part

val fileParts = arrayListOf<MultipartBody.Part>()
val frontFile = File(cacheDir, "front.jpg")
val frontBody = frontFile.asRequestBody("image/jpeg".toMediaType())
val frontHeader = Headers.headersOf(
    "Content-Disposition", "form-data; name=\"frontFile\"; filename=\"${frontFile.name}\""
)
fileParts += MultipartBody.Part.create(frontHeader, frontBody)

// 多图同理,循环加入

步骤四:一次性调用

viewModelScope.launch {
    val resp = uploadApi.uploadKyc(textMap, fileParts)
    if (resp.code == 200) toast("上传成功")
}

步骤五:进度监听(加分项)

class ProgressRequestBody(
    private val delegate: RequestBody,
    private val onProgress: (bytesWritten: Long, contentLength: Long) -> Unit
) : RequestBody() {
    override fun contentType() = delegate.contentType()
    override fun contentLength() = delegate.contentLength()
    override fun writeTo(sink: BufferedSink) {
        val counting = object : ForwardingSink(sink) {
            var bytesWritten = 0L
            override fun write(source: Buffer, byteCount: Long) {
                super.write(source, byteCount)
                bytesWritten += byteCount
                onProgress(bytesWritten, contentLength())
            }
        }
        delegate.writeTo(counting.buffer())
    }
}

将文件 RequestBody 包装成 ProgressRequestBody 后再放入 Part,即可在 UI 层实时刷新 ProgressBar。

拓展思考

  1. 如果后端同时支持 @FieldMap 和 @PartMap,如何优雅降级?
    可写两个接口:纯文本场景走 @FieldMap 节省流量;含文件时自动切换到 @PartMap,通过 Repository 层策略模式屏蔽差异。

  2. 上传 token 失效如何重试?
    在 OkHttp 拦截器里捕获 401,同步刷新 token 后重新构造 Request(注意 MultipartBody 只能写一次,需重新 newBuilder)。

  3. 弱网场景下如何分片?
    Retrofit 本身不支持分片,需在上层把文件按 2 MB 切片,每个切片当成独立 Part,后端用 name="chunk" 和额外字段 chunkIndex、totalChunks 做合并;此时 @PartMap 的 key 固定为 chunk,value 为不同切片 RequestBody。

  4. 与 Compose 协程联动
    使用 rememberCoroutineScope 启动上传,progress 用 MutableStateFlow<Float> 收集,Compose 侧直接 collectAsState() 实现“圆形进度条 + 取消按钮”效果,符合国内 UI 审美。