如何使用 @PartMap 和 @FieldMap 实现复杂的表单上传?
解读
国内面试里“文件+业务字段”混合上传是高频场景,如实名认证(身份证正反面 + 姓名、身份证号)、电商售后(多张凭证图 + 订单号、问题描述)。
@FieldMap 只能放在 @FormUrlEncoded 接口里,最终拼成 key=value&key=value 的文本体,不适合传二进制;
@PartMap 必须配合 @Multipart,每个 Part 可以携带文本或文件,且支持文件名、MimeType 等头部信息。
因此“复杂表单”= 既有可变长文本字段,又可能含 0~N 个文件,必须用 @PartMap 做统一载体;@FieldMap 仅作为“纯文本”场景的补充。
面试官想确认三件事:
- 是否知道 Retrofit 的 body 类型限制;
- 能否把动态 key、动态文件数量映射到 Map<String, RequestBody>;
- 是否了解中文文件名、大文件、内存压力的工程化细节。
知识点
-
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。 -
RequestBody 创建方式
文本:RequestBody.create(MediaType.parse("text/plain"), value)
文件:RequestBody.create(MediaType.parse("image/jpeg"), file)
进度监听:继承 RequestBody 重写 writeTo(BufferedSink),在 sink.write 循环里回调。 -
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) -
动态字段组装
后端要求字段顺序无关,但 key 可能运行时生成(如后端定义 field_0、field_1…),用 Map<String, RequestBody> partMap = new LinkedHashMap<>() 保证插入顺序。 -
内存与线程
上传 500 MB 视频不能在主线程构造 RequestBody;
用 @JvmStatic @Provides @OkHttpQualifier 给上传接口单独配置 60 s 读写超时、20 MB 缓存、Streaming 模式,防止 OOM。 -
国内 ROM 兼容
小米、华为文件选择器返回 content:// 而非 file://,需用 ContentResolver.openInputStream() 拷贝到缓存目录再构造 RequestBody,否则 SQLite 抛出 “FileNotFoundException”。 -
混淆规则
-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。
拓展思考
-
如果后端同时支持 @FieldMap 和 @PartMap,如何优雅降级?
可写两个接口:纯文本场景走 @FieldMap 节省流量;含文件时自动切换到 @PartMap,通过 Repository 层策略模式屏蔽差异。 -
上传 token 失效如何重试?
在 OkHttp 拦截器里捕获 401,同步刷新 token 后重新构造 Request(注意 MultipartBody 只能写一次,需重新 newBuilder)。 -
弱网场景下如何分片?
Retrofit 本身不支持分片,需在上层把文件按 2 MB 切片,每个切片当成独立 Part,后端用 name="chunk" 和额外字段 chunkIndex、totalChunks 做合并;此时 @PartMap 的 key 固定为 chunk,value 为不同切片 RequestBody。 -
与 Compose 协程联动
使用 rememberCoroutineScope 启动上传,progress 用 MutableStateFlow<Float> 收集,Compose 侧直接 collectAsState() 实现“圆形进度条 + 取消按钮”效果,符合国内 UI 审美。