Retrofit 系列文章导读:

  • Android Retrofit 源码系列(一)~ 原理剖析
  • Android Retrofit 源码系列(二)~ 自定义 CallAdapter
  • Android Retrofit 源码系列(三)~ 整合 RxJava、Coroutine 分析
  • Android Retrofit 源码系列(四)~ 文件上传
  • Android Retrofit 源码系列(五)~ 设计模式分析

前言

今天我们来聊一聊 Retrofit 文件上传。为了调试 Retrofit 文件上传功能,我搭建了简单的服务器来接收客户端上传的文件。为了减少篇幅我就不将服务端的代码贴出来了,有兴趣的可以查看我的 GitHub :https://github.com/chiclaim/WebApp

本文主要讲 Retrofit 文件上传功能主要包括:

  • Retrofit 文件上传
  • 文件上传遇到的问题
  • 分析问题原因以及如何解决该问题

Retrofit 文件上传

在实际开发中我们可能经常遇到文件上传的功能,多文件上传,图文上传等。

比如我们要上传单个文件,外加一个描述字段,我们想一下在网页端我们是怎么做的(一个文件选择器和一个输入框):

那么我们通过 Retrofit 中如何来实现呢,大家在网上一搜很容易就能找到,例如:

/** * 单文件上传 */@Multipart@POST("fileUpload")fun upload(    @Part("description") description: RequestBody,    @Part file: MultipartBody.Part): Callfun fileUpload() {    val fileRequestBody = RequestBody.create(mediaType, file)    val filePart = MultipartBody.Part.createFormData("file_1", file.name, fileRequestBody)    val formFieldPart = RequestBody.create(MultipartBody.FORM, "单文件上传")    uploadService.upload(formFieldPart, filePart)}

MultipartBody.Part.createFormData 方法有 3 个参数:

createFormData(String name, @Nullable String filename, RequestBody body){    if (name == null) {        throw new NullPointerException("name == null");    }    // ...}

其中 namefilename 有什么区别? filename 顾名思义就是文件的名称,那 name 代表的是什么呢?

有的开发者没有搞清楚其中的区别,就将 name 设置成 filename,这可能会产生问题,因为 filename 是可以为空的,而 name 是不能为空的,否则会抛出空指针异常。

其实这一行代码:

val filePart = MultipartBody.Part.createFormData("file_1", file.name, fileRequestBody)

相当于 HTML 页面里面的 文件选择控件 对应的代码:

createFormData 方法的第一个参数 name 就相当于 input 控件的 name 属性

多文件上传(List)

如果我们的需要上传的文件数量是可变的呢?我们可以将 MultipartBody.Part 放在一个集合中:

/** * 多文件上传(List) */@Multipart@POST("fileUpload")fun upload(    @Part("description") description: RequestBody,    @Part parts: List): Call// ======文件上传fun fileUpload() {    val formFieldBody = RequestBody.create(MultipartBody.FORM, "通过 List 多文件上传")    execute(uploadService.upload(formFieldBody, buildListPart()))}private fun buildListPart(): List {    val list = arrayListOf()    var index = 0    files.forEach {        val fileUri = it.value        val mediaType = getMediaType(fileUri)        val file = UriHelper.getFileFromUri(applicationContext, fileUri)        val fileRequestBody = RequestBody.create(mediaType, file)        val filePart = MultipartBody.Part.createFormData("file_${++index}", file.name, fileRequestBody)        list.add(filePart)    }    return list}

服务器端我们打印了文本字段以及上传的图片信息:

for (FileItem item : formItems) {    // 处理文件    if (!item.isFormField()) {        if (Objects.isNull(item.getName()) || "".equals(item.getName().trim())) continue;        String fileName = new File(item.getName()).getName();        String filePath = uploadPath + File.separator + fileName;        File storeFile = new File(filePath);        item.write(storeFile);        System.out.println("文件存储路径:" + storeFile.getAbsolutePath());    } else { // 处理普通字段        System.out.println(getFieldName -> " + item.getFieldName() + ", getString -> " + item.getString("UTF-8"));    }}

然后我们来测试下,服务器控制台输出结果:

getFieldName -> description, getString -> 通过 List 多文件上传文件存储路径:D:\xxx\WebApp\out\artifacts\ServletDemo_Web_exploded\upload\shumei.txt

如果需要上传的文本字段的数量也是可变的呢?我们可以将文本字段放在 Map 集合中,然后使用 @PartMap 注解来修饰:

@Multipart@POST("fileUpload")fun upload(    @PartMap partMap: HashMap,    @Part parts: List): Call

我们来测试下,看看服务器控制台输出结果:

getFieldName -> description0, getString -> 通过 List 多文件上传 PartMap0getFieldName -> description1, getString -> 通过 List 多文件上传 PartMap1文件存储路径:D:\xxx\WebApp\out\artifacts\ServletDemo_Web_exploded\upload\shumei.txt

其实对于文本字段我们也可以放在 List 中,也就是说不用单独声明一个 Map 集合来保存普通文本信息:

@Multipart@POST("fileUpload")fun upload(    @Part parts: List): Callprivate fun buildListPart(): List {    val list = arrayListOf()    list.add(MultipartBody.Part.createFormData("description0", "通过 List 多文件上传 in list 参数0"))    list.add(MultipartBody.Part.createFormData("description1", "通过 List 多文件上传 in list 参数1"))    var index = 0    files.forEach {        val fileUri = it.value        val mediaType = getMediaType(fileUri)        val file = UriHelper.getFileFromUri(applicationContext, fileUri)        val fileRequestBody = RequestBody.create(mediaType, file)        val filePart = MultipartBody.Part.createFormData("file_${++index}", file.name, fileRequestBody)        list.add(filePart)    }    return list}

我们来测试下,看看服务器控制台输出结果:

getFieldName -> description0, getString -> 通过 List å¤šæ–‡ä»¶ä¸Šä¼  in list 参数0getFieldName -> description1, getString -> 通过 List å¤šæ–‡ä»¶ä¸Šä¼  in list 参数1文件存储路径:D:\xxx\WebApp\out\artifacts\ServletDemo_Web_exploded\upload\shumei.txt

从服务器控制台输出的结果可以看出,日志出现了乱码,下面我们就来分析下具体原因。

乱码原因分析及解决

我们先从服务器端的角度来看这个问题,上面乱码的内容是通过 FileItem.getString 方法获取的,我们就来看下该方法的源码:

public String getString() {    byte[] rawdata = this.get();    String charset = this.getCharSet();    if (charset == null) {        charset = "ISO-8859-1";    }    try {        return new String(rawdata, charset);    } catch (UnsupportedEncodingException var4) {        return new String(rawdata);    }}

从中得知,如果没有设置字符集 Charset,那么则使用 ISO-8859-1,这肯定会产生乱码,所以调用 FileItem.getString 方法的时候传递 UTF-8 字符集:

item.getString("UTF-8")

此时,我们再来看看服务器控制台的输出:

getFieldName -> description0, getString -> 通过 List 多文件上传 in list 参数0getFieldName -> description1, getString -> 通过 List 多文件上传 in list 参数1文件存储路径:D:\xxx\WebApp\out\artifacts\ServletDemo_Web_exploded\upload\shumei.txt

发现乱码问题已经好了。有人可能会问了,服务器端使用 UTF-8 来读取,你怎么客户端是以 UTF-8 编码发送过来的呢?因为 Android 是使用 UTF-8 来进行编码的。

我们还可以通过另一个例子对乱码问题再讲解下。我在 WebApp 里新建了一个 upload.jsp ,如下图所示:

如果服务器端不设置 item.getString("UTF-8"),网页端上传中文也会乱码的。因为网页端也是以 UTF-8 编码发送过来的,为什么呢?因为我们在 jsp 文件中设置了 UTF-8 字符集:

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>

我们可以将其改成 GBK:

<%@ page language="java" contentType="text/html; charset=GBK" pageEncoding="GBK" %>

看看控制台输出:

getFieldName -> username, getString -> �ȸ�ռ��文件存储路径:D:\xxx\WebApp\out\artifacts\ServletDemo_Web_exploded\upload\222.pdf

这是因为网页客户端通过 GBK 编码将参数传递服务器,服务器以 UTF-8 来解码,所以出现了乱码问题。

好,我们在回到 Android 端。在服务器端不设置 UTF-8 的情况下,为什么通过 @PartMap 的方法是传递中文就不会乱码,通过上面方式就会乱码呢?

对于这个问题,我们分别抓包看下就知道,它们两个在传输的过程中有什么差别?

通过 @PartMap

--601c02cc-341c-4362-8a53-c7190066ee3fContent-Disposition: form-data; name="description0"Content-Transfer-Encoding: binaryContent-Type: multipart/form-data; charset=utf-8Content-Length: 56通过 List 多文件上传 PartMap0--601c02cc-341c-4362-8a53-c7190066ee3fContent-Disposition: form-data; name="description1"Content-Transfer-Encoding: binaryContent-Type: multipart/form-data; charset=utf-8Content-Length: 56通过 List 多文件上传 PartMap1

通过 List

--601c02cc-341c-4362-8a53-c7190066ee3fContent-Disposition: form-data; name="description0"Content-Length: 63通过 List 多文件上传 in list 参数0--601c02cc-341c-4362-8a53-c7190066ee3fContent-Disposition: form-data; name="description1"Content-Length: 63通过 List 多文件上传 in list 参数1

对比我们发现 @PartMapList 的方式多传输了:

Content-Transfer-Encoding: binaryContent-Type: multipart/form-data; charset=utf-8

也就是说客户端告知了服务器我是以 utf-8 编码的,那么服务器在解析这个请求的时候,封装 FileItem 的时候就会设置 charset,FileItem.getString() 的时候里面的 charset 就不会为空,自然就不会以 ISO-8859-1 来解码了。

同样都是 Retrofit API,为什么一个就会设置 charset,一个就不会呢? 我们深入源码来看看究竟。

先看下我们是怎么创建 MultipartBody.Part 的:

list.add(MultipartBody.Part.createFormData("description0", "通过 List 多文件上传 in list 参数0"))list.add(MultipartBody.Part.createFormData("description1", "通过 List 多文件上传 in list 参数1"))

我们是通过 MultipartBody.Part.createFormData 来创建 Part 然后放进集合的,那么我们就来看看该方法:

public static Part createFormData(String name, String value) {  return createFormData(name, null, RequestBody.create(null, value));}

再来看看 RequestBody.create 方法:

public static RequestBody create(@Nullable MediaType contentType, String content) {    Charset charset = UTF_8;    if (contentType != null) {      charset = contentType.charset();      if (charset == null) {        charset = UTF_8;        contentType = MediaType.parse(contentType + "; charset=utf-8");      }    }    byte[] bytes = content.getBytes(charset);    return create(contentType, bytes);}

我们发现只有当 MediaType contentType 不为空的时候会设置 content-typecharset

而 createFormData 方法将 MediaType 设置为 null,所以在抓包的时候为什么没有向服务器传递 charset 信息。

既然知道了原因,我们设置 MediaType 不就可以从客户端角度来解决这个问题吗?

所以只能我们自己来创建 RequestBody,然后将 RequestBody 传递给 createFormData 方法即可:

val param1 = RequestBody.create(MultipartBody.FORM, "通过 List 多文件上传 in list 参数0")list.add(MultipartBody.Part.createFormData("description0", null, param1))val param2 = RequestBody.create(MultipartBody.FORM, "通过 List 多文件上传 in list 参数1")list.add(MultipartBody.Part.createFormData("description1", null, param2))

在服务器端不设置 UTF-8 的情况下,看看控制台输出的结果:

getFieldName -> description0, getString -> 通过 List 多文件上传 in list 参数0getFieldName -> description1, getString -> 通过 List 多文件上传 in list 参数1文件存储路径:D:\xxx\WebApp\out\artifacts\ServletDemo_Web_exploded\upload\shumei.tx

多文件上传(MultipartBody)

除了 List 的方式来实现 多图文 上传,还可以通过 MultipartBody 来实现:

// UploadService.java@POST("fileUpload")fun upload(    @Body multipartBody: MultipartBody): Call// 组装参数private fun buildMultipartBody(): MultipartBody {    val builder = MultipartBody.Builder()        val param1 = RequestBody.create(MultipartBody.FORM, "通过 MultipartBody 多文件上传 in buildMultipartBody 参数1")    builder.addFormDataPart("description0", null, param1)    val param2 = RequestBody.create(MultipartBody.FORM, "通过 MultipartBody 多文件上传 in buildMultipartBody 参数1")    builder.addFormDataPart("description0", null, param2)    var index = 0    files.forEach { entry: Map.Entry ->        val uri = entry.value        val mediaType = getMediaType(uri)        val file = UriHelper.getFileFromUri(applicationContext, uri)        val requestBody = RequestBody.create(mediaType, file)        builder.addFormDataPart("file${++index}", file.name, requestBody)    }    return builder.build()}// 执行上传文件uploadService.upload(buildMultipartBody())

来看看服务器控制台输出结果:

getFieldName -> description0, getString -> 通过 MultipartBody 多文件上传 in buildMultipartBody 参数1getFieldName -> description0, getString -> 通过 MultipartBody 多文件上传 in buildMultipartBody 参数1文件存储路径:D:\dev\Workspace\MyGitHub\WebApp\out\artifacts\ServletDemo_Web_exploded\upload\shumei.tx

小结

本文主要介绍了 Retrofit 多图文上传功能,以及上传过程中遇到的中文乱码问题,我们从网页端、Android客户端、服务器端、Retrofit 源码角度 来分析了产生的原因及解决方案。

本文涉及到的服务器端程序代码地址:WebApp

本文涉及到的客户端程序代码地址:AndroidAll

AndroidAll 中除了 Retrofit,还有 Android 程序员需要掌握的技术栈,如:程序架构、设计模式、性能优化、数据结构算法、Kotlin、Flutter、NDK、Router、RxJava、Glide、LeakCanary、Dagger2、Retrofit、OkHttp、ButterKnife、Router 等等。持续更新,欢迎 star。

更多相关文章

  1. 一款常用的 Squid 日志分析工具
  2. GitHub 标星 8K+!一款开源替代 ls 的工具你值得拥有!
  3. RHEL 6 下 DHCP+TFTP+FTP+PXE+Kickstart 实现无人值守安装
  4. Linux 环境下实战 Rsync 备份工具及配置 rsync+inotify 实时同步
  5. android调用其他人的so文件
  6. Android实现文件选择
  7. Android(安卓)Studio 中Kotlinx开发
  8. android APK包 反编译
  9. Android之属性动画Animator

随机推荐

  1. Android(安卓)源码中增加自定义系统服务
  2. 加快Android(安卓)Studio的编译速度
  3. 模拟器1.5 :Avd 创建,adb 命令攻略
  4. Android(安卓)Studio将lib项目打包成jar
  5. Android(安卓)FFmpeg(一)、Windows编译So
  6. 10天学通Android开发(4)-用户布局与常用
  7. Android(安卓)之 AndroidX 库
  8. Android(安卓)UI开发篇之 ViewPager+九宫
  9. Android(安卓)Studio 构建变体(Build Vari
  10. 无法对jar进行签名,Android(安卓)jarsign