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

laowu8245 8年前
   <p>下载应该是每个App都必须的一项功能,不采用第三方框架的话,就需要我们自己去实现下载工具了。如果我们自己实现可以怎么做呢?</p>    <p>首先如果服务器文件支持断点续传,则我们需要实现的主要功能点如下:</p>    <ul>     <li>多线程、断点续传下载</li>     <li>下载管理:开始、暂停、继续、取消、重新开始</li>    </ul>    <p>如果服务器文件不支持断点续传,则只能进行普通的单线程下载,而且不能暂停、继续。当然一般情况服务器文件都应该支持断点续传吧!</p>    <p>下边分别是单个任务下载、多任务列表下载、以及service下载的效果图:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/1797d3b9adeb452e8fe484ca6a982be7.gif"></p>    <p style="text-align:center">single_task</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/96ff82fff395ee6bbf7f12af38b210ff.gif"></p>    <p style="text-align:center">task_manage</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/63900cbab550c40518f68a6d0bf659b0.gif"></p>    <p style="text-align:center">service_task</p>    <h2>基本实现原理:</h2>    <p>接下来看看具体的实现原理,由于我们的下载是基于 <strong>okhttp</strong> 实现的,首先我们需要一个 <strong>OkHttpManager</strong> 类,进行最基本的网络请求封装:</p>    <pre>  <code class="language-java">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();          }      }  }</code></pre>    <p>这个类里包含了基本的超时配置、根据断点信息发起异步请求、校验服务器文件是否有更新、https证书配置等。这样网络请求部分就有了。</p>    <p>接下来,我们还需要数据库的支持,以便记录下载文件的基本信息,这里我们使用SQLite,只有一张表:</p>    <pre>  <code class="language-java">/**       * 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)";</code></pre>    <p>当然还有对应表的增删改查工具类,具体的可参考源码。</p>    <p>由于需要下载管理,所以线程池也是必不可少的,这样可以避免过多的创建子线程,达到复用的目的,当然线程池的大小可以根据需求进行配置,主要代码如下:</p>    <pre>  <code class="language-java">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;      }  }</code></pre>    <p>接下来就是我们核心的下载类 <strong>FileTask</strong> 了,它实现了 <strong>Runnable</strong> 接口,这样就能在线程池中执行,首先看下run()方法的逻辑:</p>    <pre>  <code class="language-java">@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());          }      }</code></pre>    <p>如果下载的目标文件、记录断点的临时文件、数据库记录都存在,则我们先判断服务器文件是否有更新,如果没有更新则根据之前的记录直接开始下载,否则需要先进行断点下载前的准备。如果记录文件不全部存在则需要先判断是否支持断点续传,如果支持则按照断点续传的流程进行,否则采用普通下载。</p>    <p>首先看下 <strong>prepareRangeFile()</strong> 方法,在这里进行断点续传的准备工作:</p>    <pre>  <code class="language-java">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 {              .............省略............          }      }</code></pre>    <p>首先是清除历史记录,创建新的目标文件和临时文件, <strong>childTaskCount</strong> 代表文件需要通过几个子任务去下载,这样就可以得到每个子任务需要下载的任务大小,进而得到具体的断点信息并记录到临时文件中。文件下载我们采用 <strong>MappedByteBuffer</strong> 类,相比 <strong>RandomAccessFile</strong> 更加的高效。同时执行 <strong>onStart()</strong> 方法将代表下载的准备阶段,具体细节后面会说到。</p>    <p>接下来看 <strong>saveRangeFile()</strong> 方法:</p>    <pre>  <code class="language-java">private void saveRangeFile() {             .................省略..............            for (int i = 0; i < childTaskCount; i++) {              final int tempI = i;              Call call = OkHttpManager.getInstance().initRequest(url, range.start[i], range.end[i], new Callback() {                  @Override                  public void onFailure(Call call, IOException e) {                      onError(e.toString());                  }                    @Override                  public void onResponse(Call call, Response response) throws IOException {                      startSaveRangeFile(response, tempI, range, saveFile, tempFile);                  }              });              callList.add(call);          }          .................省略..............      }</code></pre>    <p>就是根据临时文件保存的断点信息发起childTaskCount数量的异步请求,如果响应成功则通过 <strong>startSaveRangeFile()</strong> 方法分段保存文件:</p>    <pre>  <code class="language-java">private void startSaveRangeFile(Response response, int index, Ranges range, File saveFile, File tempFile) {        .................省略..............          try {              saveRandomAccessFile = new RandomAccessFile(saveFile, "rws");              saveChannel = saveRandomAccessFile.getChannel();              MappedByteBuffer saveBuffer = saveChannel.map(READ_WRITE, range.start[index], range.end[index] - range.start[index] + 1);                tempRandomAccessFile = new RandomAccessFile(tempFile, "rws");              tempChannel = tempRandomAccessFile.getChannel();              MappedByteBuffer tempBuffer = tempChannel.map(READ_WRITE, 0, TEMP_FILE_TOTAL_SIZE);                inputStream = response.body().byteStream();              int len;              byte[] buffer = new byte[BUFFER_SIZE];                while ((len = inputStream.read(buffer)) != -1) {                  //取消                  if (IS_CANCEL) {                      handler.sendEmptyMessage(CANCEL);                      callList.get(index).cancel();                      break;                  }                    saveBuffer.put(buffer, 0, len);                  tempBuffer.putLong(index * EACH_TEMP_SIZE, tempBuffer.getLong(index * EACH_TEMP_SIZE) + len);                  onProgress(len);                    //退出保存记录                  if (IS_DESTROY) {                      handler.sendEmptyMessage(DESTROY);                      callList.get(index).cancel();                      break;                  }                  //暂停                  if (IS_PAUSE) {                      handler.sendEmptyMessage(PAUSE);                      callList.get(index).cancel();                      break;                  }              }              addCount();          } catch (Exception e) {              onError(e.toString());          } finally {              .................省略..............          }</code></pre>    <p>在while循环中进行目前文件的写入和将当前下载到的位置保存到临时文件:</p>    <pre>  <code class="language-java">saveBuffer.put(buffer, 0, len);   tempBuffer.putLong(index * EACH_TEMP_SIZE, tempBuffer.getLong(index * EACH_TEMP_SIZE) + len);</code></pre>    <p>同时调用 <strong>onProgress()</strong> 方法将进度发送出去,其中取消、退出保存记录、暂停需要中断while循环。</p>    <p>因为下载是在子线程进行的,但我们一般需要在UI线程根据下载状态来更新UI,所以我们通过Handler将下载过程的状态数据发送到UI线程:即调用 handler.sendEmptyMessage() 方法。</p>    <p>最后 <strong>FileTask</strong> 类还有一个 <strong>saveCommonFile()</strong> 方法,即进行不支持断点续传的普通下载。</p>    <p>前边我们提到了通过Handler将下载过程的状态数据发送到UI线程,接下看下 <strong>ProgressHandler</strong> 类基本的处理:</p>    <pre>  <code class="language-java">private Handler mHandler = new Handler() {          @Override          public void handleMessage(Message msg) {              super.handleMessage(msg);              switch (mCurrentState) {                  case START:                      break;                  case PROGRESS:                      break;                  case CANCEL:                      break;                  case PAUSE:                      break;                  case FINISH:                      break;                  case DESTROY:                      break;                  case ERROR:                      break;              }          }      };</code></pre>    <p>在 handleMessage() 方法中,我们根据当前的下载状态进行相应的操作。</p>    <p>如果是 <strong>START</strong> 则需要将下载数据插入数据库,执行初始化回调等;如果是 <strong>PROGRESS</strong> 则执行下载进度回调;如果是 <strong>CANCEL</strong> 则删除目标文件、临时文件、数据库记录并执行对应回调等;如果是 <strong>PAUSE</strong> 则更新数据库文件记录并执行暂停的回调等;如果是 <strong>FINISH</strong> 则删除临时文件和数据库记录并执行完成的回调;如果是 <strong>DESTROY</strong> 则代表直接在Activity中下载,退出Activity则会更新数据库记录;最后的 <strong>ERROR</strong> 则对应出错的情况。具体的细节可参考源码。</p>    <p>最后在 <strong>DownloadManger</strong> 类里使用线程池执行下载操作:</p>    <pre>  <code class="language-java">ThreadPool.getInstance().getThreadPoolExecutor().execute(fileTask);     //如果正在下载的任务数量等于线程池的核心线程数,则新添加的任务处于等待状态          if (ThreadPool.getInstance().getThreadPoolExecutor().getActiveCount() == ThreadPool.getInstance().getCorePoolSize()) {              downloadCallback.onWait();          }</code></pre>    <p>以及判断新添加的任务是否处于等待的状态,方便在UI层处理。到这里核心的实现原理就完了,更多的细节可以参考源码。</p>    <h2>如何使用:</h2>    <p>DownloadManger是个单例类,在这里封装在了具体的使用操作,我们可以根据url进行下载的开始、暂停、继续、取消、重新开始、线程池配置、https证书配置、查询数据的记录数据、获得当前某个下载状态的数据:</p>    <ul>     <li> <p>开始一个下载任务我们可以通过三种方式来进行:</p> <p>1、通过DownloadManager类的 start(DownloadData downloadData, DownloadCallback downloadCallback) 方法,data可以设置url、保存路径、文件名、子任务数量:</p> </li>    </ul>    <p>2、先执行DownloadManager类的 setOnDownloadCallback(DownloadData downloadData, DownloadCallback downloadCallback) 方法,绑定data和callback,再执行 start(String url) 方法。</p>    <p>3、链式调用,需要通过DUtil类来进行:例如</p>    <pre>  <code class="language-java">DUtil.init(mContext)                  .url(url)                  .path(Environment.getExternalStorageDirectory() + "/DUtil/")                  .name(name.xxx)                  .childTaskCount(3)                  .build()                  .start(callback);</code></pre>    <p>start()方法会返回DownloadManager类的实例,如果你不关心返回值,使用 DownloadManger.getInstance(context) 同样可以得到DownloadManager类的实例,以便进行后续的暂停、继续、取消等操作。</p>    <p>关于callback可以使用 <strong>DownloadCallback</strong> 接口实现完整的回调:</p>    <pre>  <code class="language-java">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) {                      }                  }</code></pre>    <p>也可以使用 <strong>SimpleDownloadCallback</strong> 接口只实现需要的回调方法。</p>    <ul>     <li>暂停下载中的任务: pause(String url)</li>     <li> <p>继续暂停的任务: resume(String url)<br> <strong>ps</strong> :不支持断点续传的文件无法进行暂停和继续操作。</p> </li>     <li> <p>取消任务: cancel(String url) ,可以取消下载中、或暂停的任务。</p> </li>     <li>重新开始下载: restart(String url) ,暂停、下载中、已取消、已完成的任务均可重新开始下载。</li>     <li>下载数据保存: destroy(String url)、destroy(String... urls) ,如在Activity中直接下载,直接退出时可在 onDestroy() 方法中调用,以保存数据。</li>     <li>配置线程池: setTaskPoolSize(int corePoolSize, int maxPoolSize) ,设置核心线程数以及总线程数。</li>     <li>配置okhttp证书: setCertificates(InputStream... certificates)</li>     <li>在数据库查询单个数据 DownloadData getDbData(String url) ,查询全部数据: List<DownloadData> getAllDbData()<br> <strong>ps</strong> :数据库不保存已下载完成的数据</li>     <li>获得下载队列中的某个文件数据: DownloadData getCurrentData(String url)</li>    </ul>    <p> </p>