Retrofit中使用@PartMap實現帶進度回調的文件上傳

  因項目重構需要,最近一直在研究Retrofit的使用,並且封裝成適合自己項目的網絡請求框架。因爲我們的項目中已經和後臺約定,所有請求都使用POST請求,並且只接受JSON格式的參數,返回結果也是JSON格式。因此,我要封裝的網絡請求框架只需考慮三種請求:

  1. Post with JSON, 就是向後臺提交JSON數據
  2. Upload with progress,就是實現帶進度回調的文件上傳
  3. Download with progress,就是實現帶進度回調的文件下載

  第一種請求在Retrofit2提交JSON格式數據 已做簡要說明,現在來說說第二種請求,上傳文件。上傳文件的需求一般包含但不僅限於

  • 上傳單文件(如 上傳頭像)
  • 上傳多文件(如 上傳幾張APP截圖)
  • 表單+多文件(PC Web時代經常使用,如 提交註冊信息+身份證照片)

  這裏要說明的是,上傳頭像可能使用圖片請求框架會專業一點,這個不是本文討論的重點。重點是,你總要爲自己的網絡請求框架提供起碼是上傳單文件功能,就是不帶描述信息,只有個默認的關鍵字“file”的那種。
  在閱讀了Retrofit推薦的入門教程Retrofit 2 — How to Upload Files to Server後,確實簡單實現了自己的上傳文件API。

public interface ApiService {//接口定義
    @Multipart
    @POST
    Call<ResponseBody> upload(@Url String url, @HeaderMap Map<String, String> headers, @Part MultipartBody.Part file);
}

使用時

MediaType mediaType = MediaType.parse("multipart/form-data");
RequestBody originalRequestBody = RequestBody.create(mediaType, mUploadedFile);
MultipartBody.Part body =MultipartBody.Part.createFormData("file", mUploadedFile.getName(), originalRequestBody);
HttpRequest.sApiService.upload(mUrl, mHeaderMap,body);

  這樣很簡單,所以接下來是考慮上傳進度。網上有人說使用攔截器,也有人說包裝RequestBody。對於後者,大部分技術博客中的使用代碼是這樣的。

//把回調傳進去,實現進度回調
FileRequestBody fileRequestBody = new FileRequestBody(originalRequestBody, mCallback);
MultipartBody.Part body =MultipartBody.Part.createFormData("file", mUploadedFile.getName(), fileRequestBody);
HttpRequest.sApiService.upload(mUrl, mHeaderMap,body);

  FileRequestBody一般長下面這樣。FileRequestBody是RequestBody的子類,在其內部也維持一個原來的RequestBody的引用。並且還傳入了含有進度回調方法的自定義Callback。因爲父類的指針可以指向子類的對象,所以在上面構建MultipartBody.Part時偷樑換柱,把originalRequestBody換掉。

public class FileRequestBody extends RequestBody {

    private RequestBody mOriginalRequestBody;//實際請求體
    private ECallback mCallback;//上傳回調接口
    private BufferedSink bufferedSink;//包裝完成的BufferedSink

    public FileRequestBody(RequestBody originalRequestBody, ECallback callback) {
        this.mOriginalRequestBody = originalRequestBody;
        this.mCallback = callback;
    }

    @Override
    public MediaType contentType() {
        return mOriginalRequestBody.contentType();
    }

    @Override
    public long contentLength() throws IOException {
        return mOriginalRequestBody.contentLength();
    }

    @Override
    public void writeTo(BufferedSink sink) throws IOException {
        if (bufferedSink == null) {
            //包裝
            bufferedSink = Okio.buffer(sink(sink));
        }
        //寫入
        mOriginalRequestBody.writeTo(bufferedSink);
        //必須調用flush,否則最後一部分數據可能不會被寫入
        bufferedSink.flush();
    }

    long startTime = 0L;

    private Sink sink(Sink sink) {
        return new ForwardingSink(sink) {
            //當前寫入字節數
            long bytesWritten = 0L;
            //總字節長度,避免多次調用contentLength()方法
            long contentLength = 0L;

            @Override
            public void write(Buffer source, long byteCount) throws IOException {
                super.write(source, byteCount);
                if (contentLength == 0) {
                    //獲得contentLength的值,後續不再調用
                    contentLength = contentLength();
                }
                //增加當前寫入的字節數
                bytesWritten += byteCount;
                long currentTime = System.currentTimeMillis();
                if (currentTime - startTime > Config.UPLOAD_PROGRESS_INTERVAL || bytesWritten == contentLength) {
                    startTime = currentTime;
                    LogUtils.d("uploaded " + bytesWritten + " of " + contentLength + ", startTime=" + FormatUtils.toDate(startTime) + ", endTime=" + FormatUtils.toDate(currentTime));
                    //回調 要切換到主線程
                    EApp.getInstance().runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            mCallback.onProgressOnUi(bytesWritten, contentLength);
                        }
                    });
                }
            }
        };
    }
}

  本着尊重他人勞動成果和對自己代碼負責的態度,對上述代碼進行了多方測試,發現並沒有可行性。即,不包裝RequestBody時可以上傳成功,只是無法獲取進度;而包裝RequestBody後請求根本無法成功。這個問題困擾了我一週,雖然有自己對Retrofit理解不足的原因,但難免有被網絡資源誤導之嫌。上述對Retrofit的上傳進度回調的解決方式在網上可以說千篇一律,恐怕多有直接摘抄引用,卻並未註明出處,並且沒有實際檢驗。
  上傳文件是基本需求,而進度回調則是錦上添花。現在一般不會用手機來上傳太大的文件,4G網絡上傳速度也很快。如果錦上添花的東西不可行哪怕只是不穩定,我也寧願砍掉它而保留最基本最原始的需求,而不展示進度。
  所幸,除了使用攔截器,我還找到他法,就是使用@PartMap註解,代碼如下。不熟悉@PartMap的使用的可移步Retrofit 2 — Passing Multiple Parts Along a File with @PartMap

@Multipart
@POST
Call<ResponseBody> uploadWithProgress(@Url String url, @HeaderMap Map<String, String> headers, @PartMap Map<String, RequestBody> partMap);  
FileRequestBody fileRequestBody = new FileRequestBody(originalRequestBody, mCallback);
Map<String, RequestBody> partMap = new HashMap<String, RequestBody>();
/*下面這裏沒有爲什麼,在不帶進度的上傳接口(使用@Multipart)調試成功後,用fiddler抓包獲取request的正確格式 Content-Disposition: form-data; name="file"; filename="avatar1.jpg" */
fileKey += "\"; filename=\"" + mUploadedFile.getName();
partMap.put(fileKey, fileRequestBody);
sApiService.uploadWithProgress(mUrl, mHeaderMap, partMap);

  這裏使用的還是上文的FileRequestBody。我並沒有使用@PartMap來傳form+多文件,而是使用它來塞一個包裝過後的RequestBody。正如代碼中的註釋所說,fileKey那裏爲什麼要那樣拼裝,答案是沒有爲什麼。很多時候調試網絡請求,使用工具抓http包,對header和body進行分析可以解決很多問題。這段代碼親測有效,源碼地址https://github.com/ashima0512/OrangeRetrofitDemo
  使用fiddler抓取手機APP的http請求,可參考使用Fiddler對手機進行抓包

後記

  之所以不使用攔截器方式實現上傳文件的進度回調,是我封裝這個框架有一個原則,就是全局只有一個ApiService,所有請求方法(上文提到的3中請求方法)都放在裏面。而不是像官方推薦的Tutorial或者Demo一樣,爲每個請求方法都寫一個XXXService,然後調用動態代理,生成對象。

public interface ApiService {
    @POST
    Call<ResponseBody> postWithJson(@Url String url, @HeaderMap Map<String, String> headers, @Body RequestBody paramBody);
    @Multipart
    @POST
    Call<ResponseBody> upload(@Url String url, @HeaderMap Map<String, String> headers, @Part MultipartBody.Part file);
    @Multipart
    @POST
    Call<ResponseBody> uploadWithProgress(@Url String url, @HeaderMap Map<String, String> headers, @PartMap Map<String, RequestBody> partMap);
    @Streaming
    @GET
    Call<ResponseBody> downloadFile(@Url String url, @HeaderMap Map<String, String> headers);
}
public static ApiService sApiService = ServiceGenerator.createService(ApiService.class);

  如上,全局只有一個ApiService,並且代理對象只生成一次。因此我並不希望在ServiceGenerator中對OkHttpClient.Builder或者Retrofit.Builder做過多的事情。

public class ServiceGenerator {
    private static OkHttpClient.Builder okHttpclientBuilder;
    private static Retrofit.Builder retrofitBuilder;

    public static <S> S createService(Class<S> serviceClass) {
        okHttpclientBuilder = new OkHttpClient.Builder();
        //實際上並沒有依靠這裏的base url,因爲在自己封裝的api中會加上base url
        //目前封裝的幾個接口都是沒有用到GsonConverter,是傳入ResponseBody,拿到響應結果後自己解析
        retrofitBuilder = new Retrofit.Builder()
                .baseUrl(Config.baseUrl)/*.addConverterFactory(GsonConverterFactory.create())*/;
        //千萬不要手賤在這裏加上HttpLoggingInterceptor,因爲如果加上,上傳回調進度會失敗,大大的坑
        //okHttpclientBuilder.addInterceptor(new HttpLoggingInterceptor());
        Retrofit retrofit = retrofitBuilder.client(okHttpclientBuilder.build()).build();
        return retrofit.create(serviceClass);
    }
}

  連每次請求的那些相同的header也在@HeaderMap中動態指定。GsonConverter也沒有使用到,而是在Call的T中傳入ResponseBody,這樣Retrofit不會用默認或者指定的Converter去反序列化響應結果。在我拿到響應結果後自己解析。這樣做的好處是:控制權掌握在自己手中,使用時並不需要記住Retrofit的API細節,比如該如何添加、如何自定義Converter才能滿足自己項目的需求;Instead,依賴的是自己封裝的、在具體的網絡請求框架之上的Customized API;如果把Retrofit換成其他框架如Volly,我的上層API和項目中的調用方式不需要更改。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章