Android 实现一个简单的下载工具

时间:2022-02-13 19:56:50

Android 实现一个简单的下载工具

2017-03-17 Othershe code小生

点击上方“code小生”关注本公众号


本篇由 Othershe 投稿,这是 Othershe 的第 1 篇投稿

地址:http://www.jianshu.com/p/6c57c93009e4


下载应该是每个 App 都必须的一项功能,不采用第三方框架的话,就需要我们自己去实现下载工具了。如果我们自己实现可以怎么做呢?

首先如果服务器文件支持断点续传,则我们需要实现的主要功能点如下:

  • 多线程、断点续传下载

  • 下载管理:开始、暂停、继续、取消、重新开始

如果服务器文件不支持断点续传,则只能进行普通的单线程下载,而且不能暂停、继续。当然一般情况服务器文件都应该支持断点续传吧!

下边分别是单个任务下载、多任务列表下载、以及 service 下载的效果图:

Android 实现一个简单的下载工具

single_task



Android 实现一个简单的下载工具

task_manage


Android 实现一个简单的下载工具

service_task



1

基本实现原理

接下来看看具体的实现原理,由于我们的下载是基于 okhttp 实现的,首先我们需要一个 OkHttpManager 类,进行最基本的网络请求封装:



public class OkHttpManager {
   ............省略..............
   /**
    * 异步(根据断点请求)
    *
    * @param url
    * @param start
    * @param end
    * @param callback
    * @return
    */
   public Call initRequest(String url, long start, long end, final Callback callback) {
       Request request = new Request.Builder()
               .url(url)
               .header("Range", "bytes=" + start + "-" + end)
               .build();

       Call call = builder.build().newCall(request);
       call.enqueue(callback);

       return call;
   }

   /**
    * 同步请求
    *
    * @param url
    * @return
    * @throws IOException
    */
   public Response initRequest(String url) throws IOException {
       Request request = new Request.Builder()
               .url(url)
               .header("Range", "bytes=0-")
               .build();

       return builder.build().newCall(request).execute();
   }

   /**
    * 文件存在的情况下可判断服务端文件是否已经更改
    *
    * @param url
    * @param lastModify
    * @return
    * @throws IOException
    */
   public Response initRequest(String url, String lastModify) throws IOException {
       Request request = new Request.Builder()
               .url(url)
               .header("Range", "bytes=0-")
               .header("If-Range", lastModify)
               .build();

       return builder.build().newCall(request).execute();
   }

   /**
    * https请求时初始化证书
    *
    * @param certificates
    * @return
    */
   public void setCertificates(InputStream... certificates) {
       try {
           CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
           KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
           keyStore.load(null);
           int index = 0;
           for (InputStream certificate : certificates) {
               String certificateAlias = Integer.toString(index++);
               keyStore.setCertificateEntry(certificateAlias, certificateFactory.generateCertificate(certificate));
               try {
                   if (certificate != null)
                       certificate.close();
               } catch (IOException e) {
               }
           }

           SSLContext sslContext = SSLContext.getInstance("TLS");
           TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());

           trustManagerFactory.init(keyStore);
           sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom());

           builder.sslSocketFactory(sslContext.getSocketFactory());

       } catch (Exception e) {
           e.printStackTrace();
       }
   }
}


这个类里包含了基本的超时配置、根据断点信息发起异步请求、校验服务器文件是否有更新、https 证书配置等。这样网络请求部分就有了。

接下来,我们还需要数据库的支持,以便记录下载文件的基本信息,这里我们使用SQLite,只有一张表:

    /**
    * download_info表建表语句
    */
   public static final String CREATE_DOWNLOAD_INFO = "create table download_info ("
           + "id integer primary key autoincrement, "
           + "url text, "
           + "path text, "
           + "name text, "
           + "child_task_count integer, "
           + "current_length integer, "
           + "total_length integer, "
           + "percentage real, "
           + "last_modify text, "
           + "date text)";

当然还有对应表的增删改查工具类,具体的可参考源码。

由于需要下载管理,所以线程池也是必不可少的,这样可以避免过多的创建子线程,达到复用的目的,当然线程池的大小可以根据需求进行配置,主要代码如下:

public class ThreadPool {
   //可同时下载的任务数(核心线程数)
   private int CORE_POOL_SIZE = 3;
   //缓存队列的大小(最大线程数)
   private int MAX_POOL_SIZE = 20;
   //非核心线程闲置的超时时间(秒),如果超时则会被回收
   private long KEEP_ALIVE = 10L;

   private ThreadPoolExecutor THREAD_POOL_EXECUTOR;

   private ThreadFactory sThreadFactory = new ThreadFactory() {
       private final AtomicInteger mCount = new AtomicInteger();

       @Override
       public Thread newThread(@NonNull Runnable runnable) {
           return new Thread(runnable, "download_task#" + mCount.getAndIncrement());
       }
   };

   ...................省略................

   public void setCorePoolSize(int corePoolSize) {
       if (corePoolSize == 0) {
           return;
       }
       CORE_POOL_SIZE = corePoolSize;
   }

   public void setMaxPoolSize(int maxPoolSize) {
       if (maxPoolSize == 0) {
           return;
       }
       MAX_POOL_SIZE = maxPoolSize;
   }

   public int getCorePoolSize() {
       return CORE_POOL_SIZE;
   }

   public int getMaxPoolSize() {
       return MAX_POOL_SIZE;
   }

   public ThreadPoolExecutor getThreadPoolExecutor() {
       if (THREAD_POOL_EXECUTOR == null) {
           THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(
                   CORE_POOL_SIZE, MAX_POOL_SIZE,
                   KEEP_ALIVE, TimeUnit.SECONDS,
                   new LinkedBlockingDeque<Runnable>(),
                   sThreadFactory);
       }
       return THREAD_POOL_EXECUTOR;
   }
}


接下来就是我们核心的下载类 FileTask 了,它实现了 Runnable 接口,这样就能在线程池中执行,首先看下 run() 方法的逻辑:


@Override
   public void run() {
       try {
           File saveFile = new File(path, name);
           File tempFile = new File(path, name + ".temp");
           DownloadData data = Db.getInstance(context).getData(url);
           if (Utils.isFileExists(saveFile) && Utils.isFileExists(tempFile) && data != null) {
               Response response = OkHttpManager.getInstance().initRequest(url, data.getLastModify());
               if (response != null && response.isSuccessful() && Utils.isNotServerFileChanged(response)) {
                   TEMP_FILE_TOTAL_SIZE = EACH_TEMP_SIZE * data.getChildTaskCount();
                   onStart(data.getTotalLength(), data.getCurrentLength(), "", true);
               } else {
                   prepareRangeFile(response);
               }
               saveRangeFile();
           } else {
               Response response = OkHttpManager.getInstance().initRequest(url);
               if (response != null && response.isSuccessful()) {
                   if (Utils.isSupportRange(response)) {
                       prepareRangeFile(response);
                       saveRangeFile();
                   } else {
                       saveCommonFile(response);
                   }
               }
           }
       } catch (IOException e) {
           onError(e.toString());
       }
   }

如果下载的目标文件、记录断点的临时文件、数据库记录都存在,则我们先判断服务器文件是否有更新,如果没有更新则根据之前的记录直接开始下载,否则需要先进行断点下载前的准备。如果记录文件不全部存在则需要先判断是否支持断点续传,如果支持则按照断点续传的流程进行,否则采用普通下载。

首先看下 prepareRangeFile() 方法,在这里进行断点续传的准备工作:

private void prepareRangeFile(Response response) {
     .................省略.................
       try {
           File saveFile = Utils.createFile(path, name);
           File tempFile = Utils.createFile(path, name + ".temp");

           long fileLength = response.body().contentLength();
           onStart(fileLength, 0, Utils.getLastModify(response), true);

           Db.getInstance(context).deleteData(url);
           Utils.deleteFile(saveFile, tempFile);

           saveRandomAccessFile = new RandomAccessFile(saveFile, "rws");
           saveRandomAccessFile.setLength(fileLength);

           tempRandomAccessFile = new RandomAccessFile(tempFile, "rws");
           tempRandomAccessFile.setLength(TEMP_FILE_TOTAL_SIZE);
           tempChannel = tempRandomAccessFile.getChannel();
           MappedByteBuffer buffer = tempChannel.map(READ_WRITE, 0, TEMP_FILE_TOTAL_SIZE);

           long start;
           long end;
           int eachSize = (int) (fileLength / childTaskCount);
           for (int i = 0; i < childTaskCount; i++) {
               if (i == childTaskCount - 1) {
                   start = i * eachSize;
                   end = fileLength - 1;
               } else {
                   start = i * eachSize;
                   end = (i + 1) * eachSize - 1;
               }
               buffer.putLong(start);
               buffer.putLong(end);
           }
       } catch (Exception e) {
           onError(e.toString());
       } finally {
           .............省略............
       }
   }


首先是清除历史记录,创建新的目标文件和临时文件, childTaskCount 代表文件需要通过几个子任务去下载,这样就可以得到每个子任务需要下载的任务大小,进而得到具体的断点信息并记录到临时文件中。文件下载我们采用 MappedByteBuffer  类,相比RandomAccessFile 更加的高效。同时执行 onStart() 方法将代表下载的准备阶段,具体细节后面会说到。

接下来看 saveRangeFile() 方法见源码,文章有字数限制就是根据临时文件保存的断点信息发起 childTaskCount 数量的异步请求,如果响应成功则通过 startSaveRangeFile()方法【见源码,文章有字数限制】见分段保存文件。

在 while 循环中进行目前文件的写入和将当前下载到的位置保存到临时文件,同时调用 onProgress() 方法将进度发送出去,其中取消、退出保存记录、暂停需要中断while循环。

因为下载是在子线程进行的,但我们一般需要在 UI 线程根据下载状态来更新 UI,所以我们通过 Handler 将下载过程的状态数据发送到 UI 线程:即调用handler.sendEmptyMessage() 方法。

最后 FileTask 类还有一个 saveCommonFile() 方法,即进行不支持断点续传的普通下载。

前边我们提到了通过 Handler 将下载过程的状态数据发送到UI线程,接下看下ProgressHandler 类基本的处理:

在 handleMessage() 方法中,我们根据当前的下载状态进行相应的操作。
如果是 START 则需要将下载数据插入数据库,执行初始化回调等;如果是 PROGRESS 则执行下载进度回调;如果是 CANCEL 则删除目标文件、临时文件、数据库记录并执行对应回调等;如果是 PAUSE 则更新数据库文件记录并执行暂停的回调等;如果是 FINISH 则删除临时文件和数据库记录并执行完成的回调;如果是 DESTROY则代表直接在 Activity 中下载,退出 Activity 则会更新数据库记录;最后的 ERROR 则对应出错的情况。具体的细节可参考源码。

最后在 DownloadManger 类里使用线程池执行下载操作以及判断新添加的任务是否处于等待的状态,方便在UI层处理。到这里核心的实现原理就完了,更多的细节可以参考源码。


2

如何使用

DownloadManger 是个单例类,在这里封装在了具体的使用操作,我们可以根据 url 进行下载的开始、暂停、继续、取消、重新开始、线程池配置、https 证书配置、查询数据的记录数据、获得当前某个下载状态的数据:

  • 开始一个下载任务我们可以通过三种方式来进行:
    1、通过 DownloadManager 类的
    start(DownloadData
    downloadData, DownloadCallback downloadCallback)
    方法,data 可以设置 url、保存路径、文件名、子任务数量:

2、先执行 DownloadManager 类的setOnDownloadCallback(DownloadData
downloadData, DownloadCallback downloadCallback)
方法,绑定 data 和callback,再执行start(String url)方法。

3、链式调用,需要通过DUtil类来进行:例如

DUtil.init(mContext)
   .url(url)
   .path(Environment.getExternalStorageDirectory() + "/DUtil/")
   .name(name.xxx)
   .childTaskCount(3)
   .build()
   .start(callback);



start() 方法会返回 DownloadManager 类的实例,如果你不关心返回值,使用DownloadManger.getInstance(context)同样可以得到 DownloadManager 类的实例,以便进行后续的暂停、继续、取消等操作。

关于 callback 可以使用 DownloadCallback 接口实现完整的回调:

new DownloadCallback() {
                   //开始
                   @Override
                   public void onStart(long currentSize, long totalSize, float progress) {
                   }
                   //下载中
                   @Override
                   public void onProgress(long currentSize, long totalSize, float progress) {
                   }
                   //暂停
                   @Override
                   public void onPause() {
                   }
                   //取消
                   @Override
                   public void onCancel() {
                   }
                   //下载完成
                   @Override
                   public void onFinish(File file) {
                   }
                   //等待
                   @Override
                   public void onWait() {
                   }
                   //下载出错
                   @Override
                   public void onError(String error) {
                   }
               }

也可以使用 SimpleDownloadCallback 接口只实现需要的回调方法。

  • 暂停下载中的任务:pause(String
    url)

  • 继续暂停的任务:resume(String
    url)

    ps:不支持断点续传的文件无法进行暂停和继续操作。

  • 取消任务:cancel(String
    url)
    ,可以取消下载中、或暂停的任务。

  • 重新开始下载:restart(String
    url)
    ,暂停、下载中、已取消、已完成的任务均可重新开始下载。

  • 下载数据保存:destroy(String
    url)、destroy(String... urls)
    ,如在Activity 中直接下载,直接退出时可在onDestroy()方法中调用,以保存数据。

  • 配置线程池:setTaskPoolSize(int
    corePoolSize, int maxPoolSize)
    ,设置核心线程数以及总线程数。

  • 配置 okhttp 证书:setCertificates(InputStream...
    certificates)

  • 在数据库查询单个数据DownloadData
    getDbData(String url)
    ,查询全部数据:List<DownloadData> getAllDbData()
    ps:数据库不保存已下载完成的数据

  • 获得下载队列中的某个文件数据:DownloadData
    getCurrentData(String url)

到这里基本的就介绍完了,更多的细节和具体的使用都在 demo 中,不合理的地方还请多多指教哦。


3

github地址

https://github.com/Othershe/DUtil