1. 程式人生 > >Retrofit 2.0檔案上傳

Retrofit 2.0檔案上傳

使用Retrofit進行檔案上傳,肯定離不開Part & PartMap。

public interface FileUploadService {  
    @Multipart
    @POST("upload")
    Call<ResponseBody> upload(@Part("description") RequestBody description,
                              @Part MultipartBody.Part file);
}

上面是定義的介面方法,需要注意的是,這個方法不再有 @FormUrlEncoded 這個註解,而換成了 @Multipart,後面只需要在引數中增加 Part 就可以了。也許你會問,這裡的 Part 和 Field 究竟有什麼區別,其實從功能上講,無非就是客戶端向服務端發起請求攜帶引數的方式不同,並且前者可以攜帶的引數型別更加豐富,包括資料流。也正是因為這一點,我們可以通過這種方式來上傳檔案,下面我們就給出這個介面的使用方法:

//先建立 service
FileUploadService service = retrofit.create(FileUploadService.class);

//構建要上傳的檔案
File file = new File(filename);
RequestBody requestFile =
        RequestBody.create(MediaType.parse("application/otcet-stream"), file);

MultipartBody.Part body =
        MultipartBody.Part.createFormData
("aFile", file.getName(), requestFile); String descriptionString = "This is a description"; RequestBody description = RequestBody.create( MediaType.parse("multipart/form-data"), descriptionString); Call<ResponseBody> call = service.upload(description, body); call.enqueue
(new Callback<ResponseBody>() { @Override public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) { System.out.println("success"); } @Override public void onFailure(Call<ResponseBody> call, Throwable t) { t.printStackTrace(); } });

那麼我們去服務端看下我們的請求是什麼樣的:
HEADERS

Accept-Encoding: gzip
Content-Length: 470
Content-Type: multipart/form-data; boundary=9b670d44-63dc-4a8a-833d-66e45e0156ca
User-Agent: okhttp/3.2.0
X-Request-Id: 9d70e8cc-958b-4f42-b979-4c1fcd474352
Via: 1.1 vegur
Host: requestb.in
Total-Route-Time: 0
Connection: close
Connect-Time: 0

FORM/POST PARAMETERS

description: This is a description

RAW BODY

–9b670d44-63dc-4a8a-833d-66e45e0156ca
Content-Disposition: form-data; name=”description”
Content-Transfer-Encoding: binary
Content-Type: multipart/form-data; charset=utf-8
Content-Length: 21

This is a description
–9b670d44-63dc-4a8a-833d-66e45e0156ca
Content-Disposition: form-data; name=”aFile”; filename=”uploadedfile.txt”
Content-Type: application/otcet-stream
Content-Length: 32

Visit me: http://www.println.net
–9b670d44-63dc-4a8a-833d-66e45e0156ca–

我們看到,我們上傳的檔案的內容出現在請求當中了。如果需要上傳多個檔案,就宣告多個 Part 引數,或者試試 PartMap。

使用RequestBodyConverter簡化請求

上面為大家展示瞭如何用 Retrofit 上傳檔案,這個上傳的過程還是有那麼點兒不夠簡練,我們只是要提供一個檔案用於上傳,可我們前後構造了三個物件:

這裡寫圖片描述

天哪,肯定是哪裡出了問題。實際上,Retrofit 允許我們自己定義入參和返回的型別,不過,如果這些型別比較特別,我們還需要準備相應的 Converter,也正是因為 Converter 的存在, Retrofit 在入參和返回型別上表現得非常靈活。
下面我們把剛才的 Service 程式碼稍作修改:

public interface FileUploadService {  
    @Multipart
    @POST("upload")
    Call<ResponseBody> upload(@Part("description") RequestBody description,
        //注意這裡的引數 "aFile" 之前是在建立 MultipartBody.Part 的時候傳入的
        @Part("aFile") File file);
}

現在我們把入參型別改成了我們熟悉的 File,如果你就這麼拿去發請求,服務端收到的結果會讓你哭了的。。。
RAW BODY

–7d24e78e-4354-4ed4-9db4-57d799b6efb7
Content-Disposition: form-data; name=”description”
Content-Transfer-Encoding: binary
Content-Type: multipart/form-data; charset=utf-8
Content-Length: 21

This is a description
–7d24e78e-4354-4ed4-9db4-57d799b6efb7
Content-Disposition: form-data; name=”aFile”
Content-Transfer-Encoding: binary
Content-Type: application/json; charset=UTF-8
Content-Length: 35

// 注意這裡!!之前是檔案的內容,現在變成了檔案的路徑
{“path”:”samples/uploadedfile.txt”}
–7d24e78e-4354-4ed4-9db4-57d799b6efb7–

服務端收到了一個檔案的路徑,它肯定會覺得你在逗他。

好了,不鬧了,這明顯是 Retrofit 在發現自己收到的實際入參是個 File 時,不知道該怎麼辦,情急之下給 toString了,而且還是個 JsonString(後來查證原來是使用了 GsonRequestBodyConverter。。)。
接下來我們就自己實現一個 FileRequestBodyConverter:

static class FileRequestBodyConverterFactory extends Converter.Factory {
  @Override
  public Converter<File, RequestBody> requestBodyConverter(Type type, Annotation[] parameterAnnotations, Annotation[] methodAnnotations, Retrofit retrofit) {
    return new FileRequestBodyConverter();
  }
}

static class FileRequestBodyConverter implements Converter<File, RequestBody> {

  @Override
  public RequestBody convert(File file) throws IOException {
    return RequestBody.create(MediaType.parse("application/otcet-stream"), file);
  }
}

在建立 Retrofit 的時候記得配置上它:

.addConverterFactory(new FileRequestBodyConverterFactory())

這樣,我們的檔案內容就能上傳了。來,看下結果吧:
RAW BODY

–25258f46-48b0-4a6b-a617-15318c168ed4
Content-Disposition: form-data; name=”description”
Content-Transfer-Encoding: binary
Content-Type: multipart/form-data; charset=utf-8
Content-Length: 21

This is a description
–25258f46-48b0-4a6b-a617-15318c168ed4
//注意看這裡,filename 沒了
Content-Disposition: form-data; name=”aFile”
//多了這一句
Content-Transfer-Encoding: binary
Content-Type: application/otcet-stream
Content-Length: 32

Visit me: http://www.println.net
–25258f46-48b0-4a6b-a617-15318c168ed4–

檔案內容成功上傳了,當然其中還存在一些問題,這個目前直接使用 Retrofit 的 Converter 還做不到,原因主要在於我們沒有辦法通過 Converter 直接將 File 轉換為 MultiPartBody.Part,如果想要做到這一點,我們可以對 Retrofit 的原始碼稍作修改,這個我們下面再談。

繼續簡化檔案上傳的介面

上面我們曾試圖簡化檔案上傳介面的使用,儘管我們已經給出了相應的 File -> RequestBody 的 Converter,不過基於 Retrofit本身的限制,我們還是不能像直接構造 MultiPartBody.Part 那樣來獲得更多的靈活性。這時候該怎麼辦?當然是 Hack~~
首先明確我們的需求:

  • 檔案的 Content-Type 需要更多的靈活性,不應該寫死在 Converter 當中,可以的話,最好可以根據檔案的副檔名來映射出來對應的 Content-Type, 比如 image.png -> image/png;
  • 在請求的資料中,能夠正常攜帶 filename 這個欄位。

為此,我增加了一套完整的引數解析方案:

  • 增加任意型別轉換的 Converter,這一步主要是滿足後續我們直接將入參型別轉換為 MultiPartBody.Part 型別:
public interface Converter<F, T> {
  ...

  abstract class Factory {
    ...
    //返回一個滿足條件的不限制類型的 Converter
    public Converter<?, ?> arbitraryConverter(Type originalType,
          Type convertedType, Annotation[] parameterAnnotations, Annotation[] methodAnnotations, Retrofit retrofit){
      return null;
    }
  }
}

需要注意的是,Retrofit 類當中也需要增加相應的方法:

public <F, T> Converter<F, T> arbitraryConverter(Type orignalType,
                                                 Type convertedType, Annotation[] parameterAnnotations, Annotation[] methodAnnotations) {
  return nextArbitraryConverter(null, orignalType, convertedType, parameterAnnotations, methodAnnotations);
}

public <F, T> Converter<F, T> nextArbitraryConverter(Converter.Factory skipPast,
                              Type type, Type convertedType,  Annotation[] parameterAnnotations, Annotation[] methodAnnotations) {
  checkNotNull(type, "type == null");
  checkNotNull(parameterAnnotations, "parameterAnnotations == null");
  checkNotNull(methodAnnotations, "methodAnnotations == null");

  int start = converterFactories.indexOf(skipPast) + 1;
  for (int i = start, count = converterFactories.size(); i < count; i++) {
    Converter.Factory factory = converterFactories.get(i);
    Converter<?, ?> converter =
            factory.arbitraryConverter(type, convertedType, parameterAnnotations, methodAnnotations, this);
    if (converter != null) {
      //noinspection unchecked
      return (Converter<F, T>) converter;
    }
  }
  return null;
}
  • 再給出 arbitraryConverter 的具體實現:
public class TypedFileMultiPartBodyConverterFactory extends Converter.Factory {
    @Override
    public Converter<TypedFile, MultipartBody.Part> arbitraryConverter(Type originalType, Type convertedType, Annotation[] parameterAnnotations, Annotation[] methodAnnotations, Retrofit retrofit) {
        if (originalType == TypedFile.class && convertedType == MultipartBody.Part.class) {
            return new FileRequestBodyConverter();
        }
        return null;
    }
}
public class TypedFileMultiPartBodyConverter implements Converter<TypedFile, MultipartBody.Part> {

    @Override
    public MultipartBody.Part convert(TypedFile typedFile) throws IOException {
        RequestBody requestFile =
                RequestBody.create(typedFile.getMediaType(), typedFile.getFile());
        return MultipartBody.Part.createFormData(typedFile.getName(), typedFile.getFile().getName(), requestFile);
    }
}
public class TypedFile {
    private MediaType mediaType;
    private String name;
    private File file;

    public TypedFile(String name, String filepath){
        this(name, new File(filepath));
    }

    public TypedFile(String name, File file) {
        this(MediaType.parse(MediaTypes.getMediaType(file)), name, file);
    }

    public TypedFile(MediaType mediaType, String name, String filepath) {
        this(mediaType, name, new File(filepath));
    }

    public TypedFile(MediaType mediaType, String name, File file) {
        this.mediaType = mediaType;
        this.name = name;
        this.file = file;
    }

    public String getName() {
        return name;
    }

    public MediaType getMediaType() {
        return mediaType;
    }

    public File getFile() {
        return file;
    }
}
  • 在宣告介面時,@Part 不要傳入引數,這樣 Retrofit 在 ServiceMethod.Builder.parseParameterAnnotation 方法中解析 Part時,就會認為我們傳入的引數為 MultiPartBody.Part 型別(實際上我們將在後面自己轉換)。那麼解析的時候,我們拿到前面定義好的 Converter,構造一個 ParameterHandler:
...
} else if (MultipartBody.Part.class.isAssignableFrom(rawParameterType)) {
    return ParameterHandler.RawPart.INSTANCE;
} else {
    Converter<?, ?> converter =
            retrofit.arbitraryConverter(type, MultipartBody.Part.class, annotations, methodAnnotations);
    if(converter == null) {
        throw parameterError(p,
                "@Part annotation must supply a name or use MultipartBody.Part parameter type.");
    }
    return new ParameterHandler.TypedFileHandler((Converter<TypedFile, MultipartBody.Part>) converter);
}
...
static final class TypedFileHandler extends ParameterHandler<TypedFile>{

  private final Converter<TypedFile, MultipartBody.Part> converter;

  TypedFileHandler(Converter<TypedFile, MultipartBody.Part> converter) {
    this.converter = converter;
  }

  @Override
  void apply(RequestBuilder builder, TypedFile value) throws IOException {
    if(value != null){
      builder.addPart(converter.convert(value));
    }
  }
}
  • 這時候再看我們的介面宣告:
public interface FileUploadService {
  @Multipart
  @POST("upload")
  Call<ResponseBody> upload(@Part("description") RequestBody description,
                            @Part TypedFile typedFile);
}

使用方法:

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("http://www.println.net/")
    .addConverterFactory(new TypedFileMultiPartBodyConverterFactory())
    .addConverterFactory(GsonConverterFactory.create())
    .build();

FileUploadService service = retrofit.create(FileUploadService.class);
TypedFile typedFile = new TypedFile("aFile", filename);
String descriptionString = "This is a description";
RequestBody description =
        RequestBody.create(
                MediaType.parse("multipart/form-data"), descriptionString);

Call<ResponseBody> call = service.upload(description, typedFile);
call.enqueue(...);

至此,我們已經通過自己的雙手,讓 Retrofit 點亮了自定義上傳檔案的技能,風騷等級更上一層樓!