
写 Retrofit 源码系列写到文件上传本来应该是比较靠后的问题但实际开发里大家问得最多的反而是它。平时用Multipart加几个Part参数就能把文件发出去看起来只有几行代码可真到了要加进度条、要排查上传失败、要处理大文件内存占用的时候就不得不去搞清楚 Retrofit 内部到底是怎么把若干个 Part 变成一个标准 HTTP 请求体的。这篇就把文件上传这条链路从注解解析到 OkHttp 实际发送彻底拆开适合正在读 Retrofit 源码、或者被上传进度和上传失败问题折腾过的 Android 开发者。看到最后你会发现文件上传真正复杂的地方其实不在 Retrofit 注解那层而在 RequestBody 与 MultipartBody 的流式组装方式以及拦截器对请求体的包装时机。1. 一次文件上传请求在 Retrofit 里经历了什么1.1 先看日常用法背后的三个注解几乎所有文件上传接口都长这样interface UploadApi { Multipart POST(files/upload) suspend fun upload( Part(description) description: RequestBody, Part file: MultipartBody.Part ): ResponseBody }调用时这么传参val description avatar.toRequestBody(text/plain.toMediaType()) val body file.asRequestBody(image/jpeg.toMediaType()) val part MultipartBody.Part.createFormData(file, file.name, body) api.upload(description, part)Multipart标注这个请求是 multipart/form-data 类型Part(description)表示这个参数要作为表单里的一个字符串字段Part file表示整个MultipartBody.Part直接作为一个完整的分片挂到请求体上。很多人以为Multipart只是给请求加一个 Content-Type 头其实这只是它最表面的作用。Retrofit 在解析方法时看到Multipart后会要求所有参数只能使用Part、PartMap或Body等有限几种方式并且最终构建出来的请求体类型必须是MultipartBody。这个校验过程在源码里做得很早如果方法缺少Multipart却用了PartRetrofit 在创建接口实例、甚至第一次调用时就会直接抛异常。1.2 从注解到请求体整条链路的真正顺序文件上传请求从调用方法到字节真正写进 Socket中间经过四个关键阶段接口方法被解析成RequestFactory里面保存了 URL、请求方法、是否 multipart、参数处理器列表。调用时把实际入参逐个交给ParameterHandler的apply方法把参数转换成MultipartBody.Part。所有 Part 被收集进MultipartBody.Builder构建出一个MultipartBody它同时实现了RequestBody。OkHttpCall把这个RequestBody交给 OkHttpOkHttp 走拦截器链最终通过BridgeInterceptor、CallServerInterceptor等把 body 的字节写入网络。其中第 2 步和第 3 步是文件上传独有的逻辑RequestFactory里几乎所有复杂代码都围绕这两步。读源码时建议从RequestFactory.parseParameter这个方法入手它会根据参数上的注解类型创建对应的ParameterHandler比如Part注解对应ParameterHandler.PartPartMap对应ParameterHandler.PartMap。这个方法被RequestFactory.Builder在接口首次加载时调用也就是说你在代码里写错了注解不需要真正发起网络请求只要第一次访问接口代理对象时就会暴露问题。这也是我建议项目启动阶段做接口注册检查和自测的原因很多上传相关的低级错误可以在编译期和启动期挡掉而不是等到用户上传文件时才崩溃。2. 注解解析细节Part 与 PartMap 如何变成 multipart 分片2.1 ParameterHandler.Part 的 apply 方法到底做了什么进入retrofit2.RequestFactory源码ParameterHandler是一个抽象类里面有四个关键实现Part、PartMap、Body、Field。文件上传主要关心前两个。ParameterHandler.Part内部持有两个东西一个ConverterT, RequestBody用来把方法参数转换成RequestBody还有一个 Part 注解上的名称值。它的apply方法看起来大致是Override void apply(RequestBuilder builder, T value) { if (value null) return; RequestBody body; if (converter ! null) { body converter.convert(value); } else { body (RequestBody) value; } builder.addPart(Part.create(body, name)); }这里有一个容易被忽略的细节如果Part注解里写了名称比如Part(description)而参数类型是RequestBodyRetrofit 会直接把它当作一个完整的表单字段写入Content-Disposition 头里会用注解上给定的 name。如果Part注解没写名称retrofit 要求参数类型必须是MultipartBody.Part因为只有MultipartBody.Part自身才带着完整的 Headers 和 name 信息。这一点在源码ParameterHandler.Part.create里有明确校验Part注解的value()为空时不允许参数类型不是MultipartBody.Part。很多人习惯在接口里写Part file: RequestBody然后抱怨“文件传上去没名字、服务端收不到 file 字段”根源就在这里Part没有名字RequestBody 又没法提供 name最终拼出来的 Content-Disposition 缺少name...不符合multipart/form-data规范服务端解析不出来就报 400。正确做法是使用MultipartBody.Part.createFormData(file, filename, body)创建一个带名字的 Part或者使用Part(file) file: RequestBody给注解补上名字。2.2 Converter 在文件上传里扮演的双重角色ConverterT, RequestBody在 Retrofit 里的作用通常被简化为“JSON 响应转换”但在上传场景里它的作用同样关键。ParameterHandler.Part里使用的 converter 是从Retrofit对象的requestBodyConverter拿到的。Retrofit 会先找开发者注册的Converter.Factory里有没有能处理T - RequestBody的 converter如果都没有最后会落到内置的BuiltInConverters它只支持四种类型RequestBody、MultipartBody.Part、ResponseBody和基本类型转RequestBody。这里有个很实用的推论如果你定义了某个数据类UserInfo并想让Part(user) user: UserInfo直接以 JSON 字符串作为表单字段发送理论上是可以的。你只需要给 Retrofit 配置一个能把UserInfo转成RequestBody的 converter比如某公司的XXXConverterFactory支持把任意对象序列化成 JSON 并封装成 RequestBody那么 Retrofit 在解析方法时就会自动选中它。所以文件上传并不只是“文件”二字所有要作为表单字段提交的内容最终都会变成一个RequestBody只是普通字段一般用toRequestBody()手动转文件字节则通过File.asRequestBody()生成一个惰性读取的RequestBody。PartMap走的是另一条路径。它的apply是遍历MapString, T的 entry每个 key 会用作表单项的名字value 必须能转换成RequestBody然后逐个builder.addPartOverride void apply(RequestBuilder builder, MapString, T value) { for (Map.EntryString, T entry : value.entrySet()) { String entryKey entry.getKey(); if (entryKey null) { throw new IllegalArgumentException(PartMap key was null.); } RequestBody body converter.convert(entry.getValue()); builder.addPart(Part.createFormData(entryKey, null, body)); } }注意PartMap源码里对 key 为 null 的情况直接抛异常但是不限制 key 是否为空字符串。空字符串的 key 会导致Content-Disposition: form-data; name很多服务端框架能解析但没法正确映射字段。所以使用PartMap时业务层必须过滤掉空 key 和空白 key不能依赖 Retrofit 帮我们把关。这个差异属于真实项目中特别容易踩的坑我在下面的报错排查章节会再展开。3. MultipartBody 组装边界值、流式写入与内存红线3.1 一个 multipart 请求体内部到底长什么样先说multipart/form-data协议本身。HTTP 请求体里面并不是简单地把多个文件内容拼起来而是每个分片之间用一段随机生成的boundary字符串分隔。整体结构类似--Boundary_1_2_3 Content-Disposition: form-data; namedescription 头像文件 --Boundary_1_2_3 Content-Disposition: form-data; namefile; filenameavatar.jpg Content-Type: image/jpeg 文件二进制字节 --Boundary_1_2_3--OkHttp 的MultipartBody在构造时做了两件事第一生成一个唯一的 boundary第二把所有 Part 保存到内部列表。boundary 的生成算法是随机数加十六进制字符串保证同一请求内的 boundary 不会与内容冲突的可能性极小。newBuilder()方法可以自定义 boundary但正常情况下不用管。MultipartBody.mediaType()会返回multipart/form-data; boundaryxxx这个 Content-Type 会作为最终请求头写入。很多时候服务端返回“缺失 boundary”或“boundary 格式错误”问题往往不是 Retrofit 没生成 boundary而是你在自定义拦截器里复制了 body 或者重写了 Content-Type却没有把multipart/form-data; boundaryxxx完整保留。我之前排查过一个案例某个网络库封装层为了统一加 token 头用了一个Interceptor重新构建了 Request里面用request.body的 contentLength 计算超时却忘了把原始 Content-Type 头复制过去结果服务端收到的请求头变成了裸的multipart/form-data没有 boundary 参数直接被拒。3.2 RequestBody 是“读文件”而不是“吞文件”关键源码集中在RequestBody.create()。OkHttp 提供了两个常用入口fun create(contentType: MediaType?, content: ByteArray): RequestBody fun create(contentType: MediaType?, file: File): RequestBody很多人以为这两个方法都会把整个文件读进内存实际区别非常大。传ByteArray的版本数据就在堆上writeTo 时直接写入传File的版本内部是object : RequestBody()它只保存了文件路径和 contentTypecontentLength()返回file.length()writeTo()里用source.use { sink.writeAll(it) }边读边写。这意味着整个文件不会一次性全部进内存内存占用取决于 OkHttp 的写缓冲区和操作系统文件读缓冲而不是文件大小本身。MultipartBody也是这个思路。它的writeTo(sink)方法会遍历每个 Part先写分隔符--boundary再写 Part 的 Headers 和空行最后调用 Part.body().writeTo(sink) 写入具体内容。因此整个请求体的组装过程是“流式串联”不会生成一个巨大的字节数组。理解这一点对排查大文件上传 OOM 很有帮助如果上传 100MB 视频时内存暴涨问题基本不在 MultipartBody而在于你上传前把文件读成了ByteArray或者使用了Base64编码后再传——这两种方式都会让内存出现两倍以上的膨胀。不过流式也不是没有代价。OkHttp 在发送请求前需要知道contentLength如果不知道就不得不使用 chunked 编码。MultipartBody.contentLength()会把所有 Part 的contentLength()相加再加上分隔符等固定开销。如果任何一个 Part 的contentLength()返回 -1整个MultipartBody.contentLength()就会返回 -1。这时候 OkHttp 会退化成 chunked 传输很多服务端和代理对 chunked 的表单解析支持并不好容易报 411 Length Required 或者解析失败。所以我在封装上传接口时会检查每个Part的 body 是否都知道准确长度。文件没问题问题是那些来自网络流、加密流、动态生成内容的自定义RequestBody必须手动实现contentLength()不能偷懒返回 -1。4. 给上传加进度拦截器 自定义 RequestBody 的实操方案4.1 为什么进度逻辑必须放在拦截器里而不是直接改 PartRetrofit 的上传进度回调并不在 Retrofit 源码里MultipartBody也没有暴露每个 Part 写了多少字节。最干净的方式是写一个Interceptor在请求发出去之前拿到RequestBody包一层能统计写入字节数的RequestBody再构建新的 Request 传给责任链。这比在业务层手动写一个 ProgressRequestBody 包装文件 Part 更有通用性因为拦截器能看到整个请求体不需要业务方对每个上传方法做单独的包装逻辑也方便统一开启关闭进度。拦截器里最关键的地方是不能随便替换request.body而丢失 Content-Type。正确写法是class ProgressInterceptor( private val onProgress: (bytesWritten: Long, contentLength: Long) - Unit ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val original chain.request() val body original.body ?: return chain.proceed(original) val progressBody object : RequestBody() { override fun contentType(): MediaType? body.contentType() override fun contentLength(): Long body.contentLength() override fun writeTo(sink: BufferedSink) { val countingSink object : ForwardingSink(sink) { private var bytesWritten 0L override fun write(source: Buffer, byteCount: Long) { super.write(source, byteCount) bytesWritten byteCount onProgress(bytesWritten, body.contentLength()) } } val bufferedSink countingSink.buffer() body.writeTo(bufferedSink) bufferedSink.flush() } } val newRequest original.newBuilder() .method(original.method, progressBody) .build() return chain.proceed(newRequest) } }这段代码有几个坑要细讲。第一为什么用ForwardingSink而不是直接重写write因为RequestBody.writeTo的入参类型是BufferedSink它不是底层 raw sink。如果直接在传入的BufferedSink外层包一层统计统计不到内部缓冲区实际落盘或落 Socket的字节。ForwardingSink把底层 sink 包住再通过buffer()变成BufferedSink这样统计的是真正写到底层 sink 的字节数与内容长度计算一致。但这个统计会包含 multipart 分隔符和 Part 头所以进度回调最终会从 0 到 100% 多出一点正常现象不用过度纠结。第二onProgress回调的线程由 OkHttp 调度决定。OkHttp 网络请求在独立的线程池里执行所以onProgress默认不在主线程。如果直接把回调传给 UI 更新必然崩。常见做法是内部用一个 Handler 切回主线程或者回调里直接抛给协程层用withContext(Dispatchers.Main)转一下。第三上传进度低频更新很有必要。如果文件较大ForwardingSink.write会被频繁调用每次回调都会触发 UI 刷新卡顿在低端机上特别明显。我习惯在实现里做节流比如每 100ms 或每 1% 才回调一次虽然写出来代码稍微长一点但在真实项目里这个是刚需。4.2 为什么建议把进度回调与上传方法解耦如果你不想给每个上传接口都加回调参数可以用一个全局的ProgressListener注册器按请求的 URL 或 header 标识区分。具体做法是在Header(Upload-Id) uploadId: String这样的参数上传一个唯一 ID拦截器里读取该 header把进度回调分发到对应 ID 监听者业务层通过uploadId取回调。这样 Retrofit 接口定义保持干净文件上传与进度监听逻辑完全解耦测试起来也容易替换。我在项目里是直接用object单例做监听器注册表配合协程的CompletableDeferred实现进度悬停等待效果不错。但这套方案要求每个调用方必须保证 ID 唯一否则回调会穿线。唯一 ID 可以用 UUID或者服务端下发的上传凭证。用拦截器还有一个额外收益重试请求时 progress body 也会随着新的 request 进入拦截器链所以如果你用 OkHttp 自带的 retryOnConnectionFailure 做连接级重试进度逻辑不需要特殊处理因为每次重试都会重新走一遍拦截器写字节的统计也会重置。这里要注意业务层面的上传失败重试一般不会由 OkHttp 自动做文件上传通常是幂等性没那么强的操作重试策略最好放在服务端返回明确错误码之后由业务层决定是否重新上传。5. 源码报错定位文件上传最常遇见的五个异常5.1 异常信息与根因对照表文件上传的报错信息通常很有指向性但很多人不看全就习惯性去改服务端配置最后发现是客户端注解写错。下面这个表是我在实际排查和源码阅读中整理出来的高频报错映射异常信息根因解决方向Part parameters can only be used with multipart encoding方法没有标注Multipart却使用了Part在方法上加MultipartPart annotation must have a name使用Part且参数类型不是MultipartBody.Part注解上又没有 name给注解补名称或改用MultipartBody.PartPartMap keys cannot be nullMap 的某个 key 是 null填充前过滤掉 null keyUnable to create Part converter for ...Retrofit 找不到能把参数类型转成 RequestBody 的 converter给 Retrofit 注册合适的Converter.Factory或手动改成RequestBody类型Content-Disposition中没有name字段使用无名的Part和RequestBody或MultipartBody.Part构建方式错误用createFormData并显式传入字段名这张表的前三行在源码RequestFactory.parseParameter的校验逻辑里都能找到对应的异常抛出语句。Retrofit 的设计哲学是尽早失败任何注解层面的错误都会在创建接口实例时暴露。这一点对有自动化测试和构建检查的团队非常友好接口定义错误根本不需要跑到真机上传才知道在本地写个单元测试创建Retrofit对象就能触发。5.2 手把手走一遍“服务端收不到字段”的排查链路服务端收到请求却解析不到字段是文件上传最玄学的问题。我分享一个完整排查思路比直接给结论有用。第一步先确认客户端发出的原始请求体长什么样。不要靠猜直接在 OkHttp 的 Application Interceptor 里打印 request body 的部分内容。对于二进制文件部分只打印前几百字节即可重点是看头部val buffer Buffer() source.peek().use { body.writeTo(it) } // 实际不推荐在拦截器里整体读 body容易破坏流更稳妥的做法是抓包工具或本地 Mock 服务接收 body 后落盘再用文本编辑器打开看头几行。正常应该看到每段Content-Disposition里都有name属性文件分片还有filename。第二步看 Content-Type 是否携带 boundary。如果请求头Content-Type: multipart/form-data; boundaryxxx是完整保留的通常 OkHttp 层没问题问题在服务端解析库。如果 Content-Type 被某些统一拦截器改写了按第 3 节说的方式修复。第三步关注字段顺序。不同服务端框架对字段顺序没有严格要求但如果服务端要求固定顺序而你用Map传参顺序不稳定可能导致签名校验类服务端逻辑失败。改用Part逐个传参可以保证顺序。第四步检查 Part 的 Content-Disposition 中是否有 FileName 与表单字段名的对应关系。MultipartBody.Part.createFormData(name, filename, body)第一个参数是表单字段名第二个参数是文件名。有人把两个参数传反导致服务端拿到nameavatar.jpgfilenamefile字段匹配不上。这个错误非常隐蔽代码编译正常、请求也成功但服务端就是取不到对应字段。6. 大文件上传的工程化超时、内存与重试策略6.1 超时参数不能只调连接超时和读取超时文件上传的耗时主要由“写入 socket”决定所以仅仅设置connectTimeout和readTimeout不够writeTimeout才是关键。OkHttp 默认写超时是 10 秒如果文件大、服务端处理慢、客户端上行带宽低10 秒内写不完全就报SocketTimeoutException: write timed out。我见过很多团队把readTimeout加到 60 秒上传大文件仍然超时就是因为忽略了writeTimeout。建议在构建OkHttpClient时把writeTimeout设置为按文件大小估算的值。一个可选经验公式是val writeTimeout maxOf(30L, fileSizeBytes / (512 * 1024) * 4).seconds()假设上行带宽约 512KB/s每 MB 至少给 4 秒30 秒兜底再留至少 2 倍余量。当然真实带宽波动很大最保险的是写超时设到 120 秒以上同时服务端做断点续传或者预检避免大文件重传。6.2 图片上传前先做内存优化别直接传原图源码层的流式读取解决的是传输过程中的内存问题但业务层如果一开始就把 Bitmap 压缩后的字节数组作为 RequestBody 传入内存问题还是会回到源头。Affected 的场景通常是相册选图一张 12MP 的照片解码成 ARGB_8888 大约需要 48MB 内存如果直接传原图 File 还好系统在 writeTo 时边读边写但如果为了做旋转、裁剪、缩略图而先转成 Bitmap 再拿 compressed ByteArray 上传峰值内存至少是原始文件的 3 到 5 倍。实战里我的习惯是相册场景一律先采样压缩控制最长边在 1920 或 2560 内再输出 JPEG 到缓存目录用压缩后的 File 构建 RequestBody。这样上传与图片处理完全分离也能避免压缩后的 Bitmap 还驻留在内存里占空间。6.3 上传重试的边界到底哪些错误值得重试OkHttp 的retryOnConnectionFailure只处理连接失败或路由切换这类问题不会因为服务端返回 500、400 而重试。文件上传的重试策略应该单独设计可安全重试的错误连接超时、写超时、SSL 握手失败、服务端明确告知 503 可重试。这些属于传输层问题重试大概率能成功。需要谨慎重试的错误服务端已经写入了一部分文件客户端重试可能产生重复分片或重复字段。此时最好先调用服务端的上传状态接口确认或者每次重试使用新的 boundary 并携带全局请求 ID便于服务端做幂等。不应该重试的错误业务参数错误400、鉴权失败401/403重试只会徒增压力。如果你用协程包装上传接口建议在 Repository 层做手动重试而不是用 OkHttp 的自动重试。手动重试可以可控次数、可控间隔还能在重试前刷新 Token。比如repeat(retryCount) { try { return api.upload(part) } catch (e: IOException) { if (it retryCount - 1) throw e delay(backoffMillis * (it 1)) } }这个方案只捕获 IOException因为上传超时、连接失败都会以 IOException 形式暴露而业务异常以 HttpException 形式暴露不会误重试。最后分享一个调试小技巧。文件上传时如果想快速验证生成的 multipart body 结构不必真的发送到远程服务器本地搭一个极简的 HTTP 服务收到请求后把 body 原样落盘到文件然后用文本编辑器打开查看头部的 boundary 与 Content-Disposition。Retrofit 源码里对 multipart 的组装顺序写得非常稳定只要头部信息正确剩下的网络传输阶段基本不会出问题。排查上传问题越早看到原始 body就越接近真相。