关于使用AsyncHttpClient做断点上传功能时无法回调进度的问题

在使用AsyncHttpClient做简单的非断点上传功能时,我们要想实时检测任务的开始、结束以及进度,需要实现AsyncHttpResponseHandler,并复写其各种onXXX()方法。代码如下:


    
     
     
  1. @Override
  2. public void onSuccess(int statusCode, Header[] headers, byte[] responseBody) {
  3. }
  4. @Override
  5. public void onFailure(int statusCode, Header[] headers, byte[] responseBody, Throwable error) {
  6. }
  7. @Override
  8. public void onProgress(long bytesWritten, long totalSize) {
  9. }
  10. @Override
  11. public void onCancel() {
  12. }
  13. @Override
  14. public void onFinish() {
  15. }
  16. @Override
  17. public void onRetry(int retryNo) {
  18. }
  19. @Override
  20. public void onStart() {
  21. }

其中,在onProgress里,我们可以得到上传的进度。以上说的是简单上传,运行一点问题都没有。
可是当我按照服务端提供的接口做断点上传时,onProgress就不能正常的返回进度了。为了找出原因,我对两种情况的http请求都进行了抓包,发现原因出在http请求头里。在简单上传时,请求头里的"Content-Type"字段默认为 "multipart/form-data"的形式,而服务端给我的接口中,请求头里的"Content-Type"必须是"application/octet-stream"。
为什么只是改了个请求头就不能回调进度了呢?我想只有在框架源码中才能找到答案。只有找到 onProgress这个回调最原始的地方才能知道原因。我先在 AsyncHttpResponseHandler里找到了这个 onProgress方法:

    
     
     
  1. /**
  2. * Fired when the request progress, override to handle in your own code
  3. *
  4. * @param bytesWritten offset from start of file
  5. * @param totalSize total size of file
  6. */
  7. public void onProgress(long bytesWritten, long totalSize) {
  8. AsyncHttpClient.log.v(LOG_TAG, String.format("Progress %d from %d (%2.0f%%)", bytesWritten, totalSize, (totalSize > 0) ? (bytesWritten * 1.0 / totalSize) * 100 : -1));
  9. }

发现它是在一个handleMessage方法的一个等于PROGRESS_MESSAGE 的case分支中被调用的:

   
    
    
  1. case PROGRESS_MESSAGE:
  2. response = (Object[]) message.obj;
  3. if (response != null && response.length >= 2) {
  4. try {
  5. onProgress((Long) response[0], (Long) response[1]);
  6. } catch (Throwable t) {
  7. AsyncHttpClient.log.e(LOG_TAG, "custom onProgress contains an error", t);
  8. }
  9. } else {
  10. AsyncHttpClient.log.e(LOG_TAG, "PROGRESS_MESSAGE didn't got enough params");
  11. }
  12. break;

接着找到这个PROGRESS_MESSAGE 是在sendProgressMessage方法中传递过来的。而这个 sendProgressMessage方法是复写的方法。它是在 AsyncHttpResponseHandler的父类 ResponseHandlerInterface中被定义的。

   
    
    
  1. @Override
  2. final public void sendProgressMessage(long bytesWritten, long bytesTotal) {
  3. sendMessage(obtainMessage(PROGRESS_MESSAGE, new Object[]{ bytesWritten, bytesTotal}));
  4. }
接下来只要能找到用 ResponseHandlerInterface来调用 sendProgressMessage的地方差不多就能找到问题所在了。果然在SimpleMultipartEntity类中,我找到了这个方法被调用的地方,它在updateProgress方法中被调用,从名字也可以看出这是用来更新进度的。

   
    
    
  1. private void updateProgress(long count) {
  2. bytesWritten += count;
  3. progressHandler.sendProgressMessage(bytesWritten, totalSize);
  4. }

再看这个updateProgress在哪几个地方被调用了:首先在这个writeTo方法中被调用了2次

   
    
    
  1. @Override
  2. public void writeTo(final OutputStream outstream) throws IOException {
  3. bytesWritten = 0;
  4. totalSize = (int) getContentLength();
  5. out.writeTo(outstream);
  6. updateProgress(out.size());
  7. for (FilePart filePart : fileParts) {
  8. filePart.writeTo(outstream);
  9. }
  10. outstream.write(boundaryEnd);
  11. updateProgress(boundaryEnd.length);
  12. }

另外在 SimpleMultipartEntity的内部类 FilePart类的writeTo方法中被调用了三次。同样从名字及代码中的file也可以看出,这一段其实是真正更新文件传输进度的地方!

   
    
    
  1. private class FilePart {
  2. .
  3. .
  4. .
  5. public void writeTo(OutputStream out) throws IOException {
  6. out.write(header);
  7. updateProgress(header.length);
  8. FileInputStream inputStream = new FileInputStream(file);
  9. final byte[] tmp = new byte[4096];
  10. int bytesRead;
  11. while ((bytesRead = inputStream.read(tmp)) != -1) {
  12. out.write(tmp, 0, bytesRead);
  13. updateProgress(bytesRead);
  14. }
  15. out.write(CR_LF);
  16. updateProgress(CR_LF.length);
  17. out.flush();
  18. AsyncHttpClient.silentCloseInputStream(inputStream);
  19. }
  20. }

但是看这里的代码写的没什么问题,为什么会没有被调用呢?
再来仔细研究一下SimpleMultipartEntity这个类发现:类的开头有段注释:意思是这是个主要用来发送一个或多个文件的简单的分段实体(请求体)。

   
    
    
  1. /**
  2. * Simplified multipart entity mainly used for sending one or more files.
  3. */

也就是说,在用AsyncHttpClient进行上传文件的时候,是把文件封装成这个http请求体来操作的,那我们再来仔细看看他到底做了些什么。
该类只有一个构造方法,目的是把ResponseHandlerInterface的实例传过来,好进行回调操作。然后他搞了一些boundary相关的东西。后来通过抓包发现,在发出的请求中出现的分割线原来就是在这里写入的。由于服务端给出的断点续传接口要求不要分割线,因此我就把这里的分割线相关的全部注释掉了。

   
    
    
  1. public BreakpointFileEntity(ResponseHandlerInterface progressHandler) {
  2. // final StringBuilder buf = new StringBuilder();
  3. // final Random rand = new Random();
  4. // for (int i = 0; i < 30; i++) {
  5. // buf.append(MULTIPART_CHARS[rand.nextInt(MULTIPART_CHARS.length)]);
  6. // }
  7. //
  8. // boundary = buf.toString();
  9. // boundaryLine = ("--" + boundary + STR_CR_LF).getBytes();
  10. // boundaryEnd = ("--" + boundary + "--" + STR_CR_LF).getBytes();
  11. this.progressHandler = progressHandler;
  12. }

再来看除了构造方法外,重载最多的就是addPart方法。可以看到,除了流文件外,其他的都是最终将一个file文件封装成 FilePart实例,然后添加到一个叫fileParts的集合中。然后在计算总大小(getContentLength)和写入(writeTo)的时候,再遍历集合来操作。
再来看另外两个方法createContentType和createContentDisposition,从名字可以看出是用来创建请求头里的ContentType和ContentDisposition的。

   
    
    
  1. private byte[] createContentType(String type) {
  2. String result = AsyncHttpClient.HEADER_CONTENT_TYPE + ": " + normalizeContentType(type) + STR_CR_LF;
  3. return result.getBytes();
  4. }
  5. private byte[] createContentDisposition(String key) {
  6. return (
  7. AsyncHttpClient.HEADER_CONTENT_DISPOSITION +
  8. // ": form-data; name=\"" + key + "\"" + STR_CR_LF).getBytes();
  9. ": attachment; name=\"" + key + "\"" + STR_CR_LF).getBytes();
  10. //attachment; filename="Folder.jpg"
  11. }
  12. private byte[] createContentDisposition(String key, String fileName) {
  13. return (
  14. AsyncHttpClient.HEADER_CONTENT_DISPOSITION +
  15. // ": form-data; name=\"" + key + "\"" +
  16. // "; filename=\"" + fileName + "\"" + STR_CR_LF).getBytes();
  17. ": attachment; name=\"" + key + "\"" +
  18. "; filename=\"" + fileName + "\"" + STR_CR_LF).getBytes();
  19. }

这两个方法主要是在FilePart的createHeader方法里,通过此方法返回一个header对象,供FilePart持有。由于我们的断点上传要求的http头的Content_Type和Content_Disposition分别是:"application/octet-stream"和“attachment; filename="xxxx”的,所以这两个地方我需要改过来。
另外,由于断点之后再续传,应该是从同一个文件的不同字节位置开始上传的,这个位置startPos是服务器那边返回过来的,所以原来的从开头位置读取文件的做法就要做相应的修改了。

   
    
    
  1. // 修改为从指定位置开始读取
  2. public void writeTo(OutputStream out) throws IOException {
  3. out.write(header);
  4. updateProgress(header.length);
  5. FileInputStream inputStream = new FileInputStream(file);
  6. inputStream.skip(startPos); // 从指定位置开始读
  7. final byte[] tmp = new byte[4096];
  8. int bytesRead;
  9. while ((bytesRead = inputStream.read(tmp)) != -1) {
  10. out.write(tmp, 0, bytesRead);
  11. updateProgress(bytesRead);
  12. }
  13. out.write(CR_LF);
  14. updateProgress(CR_LF.length);
  15. out.flush();
  16. AsyncHttpClient.silentCloseInputStream(inputStream);
  17. }

但是这个startPos怎么传到这里来是个问题。我们可以在FilePart中新增一个startPos的成员变量,然后重载一个带startPos参数的构造方法。

   
    
    
  1. // 新增构造方法
  2. public FilePart(String key, File file, int startPos, String type, String customFileName) {
  3. header = createHeader(key, TextUtils.isEmpty(customFileName) ? file.getName() : customFileName, type);
  4. this.file = file;
  5. this.startPos = startPos;
  6. }
然后在新增一个addPart方法,里面的add的FilePart对象的构造方法用我们刚才新增的带startPos的。

   
    
    
  1. // 新增
  2. public void addPart(String key, File file, int startPos, String type, String customFileName) {
  3. fileParts.add(new FilePart(key, file, startPos, normalizeContentType(type), customFileName));
  4. }

由于这个addPart方法是在外部的RequestParams类中Add file params地方调用的。因为 RequestParams类是在HttpClint包中不好修改, 所以我们可以复制一个 RequestParams类命名为Breakpoint RequestParams ,然后把其中的 Add file params一段,修改为以下部分,因为file文件是通过 fileWrapper这个对象带过去的,所以我们同样可以用它把startPos参数带过去。

   
    
    
  1. // Add file params
  2. // for (ConcurrentHashMap.Entry<String, FileWrapper> entry : fileParams.entrySet()) {
  3. // FileWrapper fileWrapper = entry.getValue();
  4. // entity.addPart(entry.getKey(), fileWrapper.file, fileWrapper.contentType, fileWrapper.customFileName);
  5. // }
  6. // 新增
  7. for (ConcurrentHashMap.Entry<String, FileWrapper> entry : fileParams.entrySet()) {
  8. FileWrapper fileWrapper = entry.getValue();
  9. entity.addPart(entry.getKey(), fileWrapper.file, fileWrapper.startPos, fileWrapper.contentType, fileWrapper.customFileName);
  10. }
因此,就需要把 Breakpoint RequestParams中的 FileWrapper这个内部类新增一个构造方法:

   
    
    
  1. public static class FileWrapper implements Serializable {
  2. public final File file;
  3. public final String contentType;
  4. public final String customFileName;
  5. public final int startPos;
  6. public FileWrapper(File file, String contentType, String customFileName) {
  7. this.file = file;
  8. this.contentType = contentType;
  9. this.customFileName = customFileName;
  10. this.startPos = 0;
  11. }
  12. // 新增
  13. public FileWrapper(File file,int startPos, String contentType, String customFileName) {
  14. this.file = file;
  15. this.contentType = contentType;
  16. this.customFileName = customFileName;
  17. this.startPos = startPos;
  18. }
  19. }
由于上面的for (ConcurrentHashMap.Entry<String, FileWrapper> entry : fileParams.entrySet())遍历的是fileParams这个map集合。所以我们再找到这个map装进数据的地方,并将此处的put方法的参数加上 startPos。

   
    
    
  1. /**
  2. * Adds a file to the request with both custom provided file content-type and file name
  3. *
  4. * @param key the key name for the new param.
  5. * @param file the file to add.
  6. * @param contentType the content type of the file, eg. application/json
  7. * @param customFileName file name to use instead of real file name
  8. * @throws FileNotFoundException throws if wrong File argument was passed
  9. */
  10. // public void put(String key, File file, String contentType, String customFileName) throws FileNotFoundException {
  11. // if (file == null || !file.exists()) {
  12. // throw new FileNotFoundException();
  13. // }
  14. // if (key != null) {
  15. // fileParams.put(key, new FileWrapper(file, contentType, customFileName));
  16. // }
  17. // }
  18. // 转为断点上传准备的
  19. // int startPos 传输的起始位置, int fragmentLength 传输碎片的长度
  20. public void put(String key, File file, int startPos, String contentType, String customFileName) throws FileNotFoundException {
  21. if (file == null || !file.exists()) {
  22. throw new FileNotFoundException();
  23. }
  24. if (key != null) {
  25. fileParams.put(key, new FileWrapper(file, startPos, contentType, customFileName));
  26. }
  27. }

再重载一个简单参数的put方法共客户端调用,这样客户端只用调用param.put(“file”,fileToUpload,startPos),就可以实现断点上传了。

   
    
    
  1. // 新增
  2. public void put(String key, File file, int startPos) throws FileNotFoundException {
  3. put(key, file, startPos, null, null);
  4. }
客户端调用代码:

   
    
    
  1. private void uploadSingleFile(final TranslistFileBean fileBean, final String startPos) {
  2. String token = ExitApplication.getInstance().getToken();
  3. AsyncHttpClient client = new AsyncHttpClient();
  4. String url = ControlEasyHttpUtils.getBaseUrlNoV1() + "upload?_token=" + token + "&folder_id=" + fileBean.getFolderId();
  5. BreakpointRequestParams params = new BreakpointRequestParams();
  6. File file = new File(fileBean.getPath());
  7. try {
  8. params.put("file", file, Integer.parseInt(startPos));
  9. } catch (FileNotFoundException e) {
  10. e.printStackTrace();
  11. }
  12. // 不加这一段文件名字会变乱码
  13. String encode = null;
  14. try {
  15. encode = URLEncoder.encode(fileBean.getName(), "UTF-8");
  16. encode = encode.replace("+", "%20"); // 把转码后的+好还原为空格
  17. } catch (UnsupportedEncodingException e) {
  18. e.printStackTrace();
  19. }
  20. // 添加请求头
  21. client.addHeader("Content-Length", (file.length() - Integer.parseInt(startPos)) + "");
  22. client.addHeader("Content-Disposition", "attachment;filename=\"" + encode + "\"");
  23. client.addHeader("Session-ID", fileBean.getSession_id());
  24. client.addHeader("X-Content-Range", "bytes " + startPos + "-" + (file.length() - 1) + "/" + (file.length()));
  25. client.addHeader("Content-Type", "application/octet-stream");
  26. BreakpointAsyncHttpResponseHandler responseHandler = new BreakpointAsyncHttpResponseHandler(mContext, fileBean);
  27. // 设置 到了该获取加密进度的时候的监听
  28. responseHandler.setTime2GetPackPersentListener(this);
  29. RequestHandle requestHandle = client.post(mContext, url, params, responseHandler);
  30. //把 上传线程 添加到全局map变量中,或者替换原来的
  31. ExitApplication.getInstance().mCancelableMap.put(fileBean, requestHandle);
  32. }

这样如果是第一次上传,startPos 设置为"0",如果是续传,就传服务器返回的字节位置即可。这种情况下是可以上传成功的,但还是有个问题,上传上去的文件发现已坏,后来发现原来是文件头部被写入了http请求头中的信息,也就是FilePart类中的header信息也被写入到文件中去了,于是,我就header的写入注释掉了,发现问题解决了。

   
    
    
  1. // 修改为从指定位置开始读取
  2. public void writeTo(OutputStream out) throws IOException {
  3. // 这里如果不把header去掉的话,就会将头部一些信息写入到文件开头里,造成文件损坏!
  4. // out.write(header);
  5. // updateProgress(header.length);
  6. FileInputStream inputStream = new FileInputStream(file);
  7. inputStream.skip(startPos); // 从指定位置开始读
  8. final byte[] tmp = new byte[4096];
  9. int bytesRead;
  10. while ((bytesRead = inputStream.read(tmp)) != -1) {
  11. out.write(tmp, 0, bytesRead);
  12. updateProgress(bytesRead);
  13. }
  14. out.write(CR_LF);
  15. updateProgress(CR_LF.length);
  16. out.flush();
  17. AsyncHttpClient.silentCloseInputStream(inputStream);
  18. }

需要完整代码的请在留言里注明邮箱,谢谢!


猜你喜欢

转载自blog.csdn.net/woshiwangbiao/article/details/52858480