前段时间面试,被问到 app 的自动更新是怎么做的,文件下载怎么实现的?用了多线程吗?是否支持断点续传?一下蒙逼,因为直接用第三方框架实现的文件下载,这些问题完全没想过。
回来后觉得这里面其实涉及很多知识点,就打算自己动手封装一个支持多线程多任务断点续传的库,用了一个星期的业余时间,目前主要功能基本完成,所以记录一下这个过程中遇到的问题和收获
1. 涉及知识点
听起来并不复杂的一个功能,但是实际动手做起来发现还是涉及了很多知识点的,概括一下主要涉及以下几个部分:
- HTTP 请求:为了尽量的减少对第三方框架的依赖,这个库里的 HTTP 请求部分就直接用
HttpURLConnection
来实现 - 断点续传:主要借助
RandomAccessFile
这个类来实现,这个类可以从文件的任意指定位置开始进行读写 - 多线程下载:涉及到多个子线程的同步,中断异常的处理,线程池的使用等
- 多任务下载:这里涉及到任务的调度和同步,比如限制同时下载的任务数,达到这个上限以后,再添加任务要等待,如何处理。暂停或者取消一个任务后如何自动启动等待的任务等。这里使用了阻塞队列和信号量来实现任务的调度
- 事件的发布:可以用广播, EventBus,Handler,回调
- 数据的持久化:退出程序后,保存下载任务的状态,再次打开后加载所有的任务,并能够继续下载。这里可以用数据库,临时文件 或者
SharedPreference
实现。
2. 整体设计
1. 下载请求的封装
用一个 JavaBean 类封装一个下载请求,包括了下载的 url 地址,文件保存的目录以及文件名,基本的参数其实就这三个,还可以根据需要添加更多的配置参数,比如指定并发的线程数等。如果参数比较多的情况,可以使用 Builder 模式
2. 任务的调度
回想我们使用迅雷下载时,填入下载链接,选好保存路径之后点击开始,任务就自动开始下载了。如果此时任务数已经达到上限,那么就会等待,直到有任务结束,再自动开始等待的任务。仔细思考之后,有以下几个要点:
- 因为添加任务是在主线程进行,所以应该是非阻塞的,任何时候执行添加一个任务都应该立即返回。所以这里考虑使用一个无上限的阻塞队列 LinkedBlockingQueue
- 任务一旦添加就自动开始,这里参考了 Volley 的 NetworkDispatcher 的设计,开启一个专门的任务调度线程,用一个死循环不断的从阻塞队列取出任务来执行,当队列为空时就阻塞,非空时就被唤醒并执行任务。其实就是一个典型的生产-消费模型
- 达到最大任务数后要等待有任务停止(包括成功,失败,暂停,取消几种情况)才能开始,这里很自然的想到用 Semaphore 来实现,当从阻塞队列取出一个任务后,还需要先成功获取一个信号量,才能继续开始执行,否则就阻塞。当任务停止的时候,释放一个信号量,之前等待的任务就可以自动开始执行了。
当然这里也可以不用信号量,通过一个计数器加一个等待队列实现调度。一个任务结束后,需要检查当前正在下载的任务数,以及是否有任务在等待队列,如果有并且计数器值小于上限值,就从等待队列取出一个任务执行。个人感觉使用信号量在概念上更加清晰。
3. 下载任务的执行
一个支持多线程断点续传的任务开始后,其实是分成了串行的两步执行的:
(1) 发起一次 HTTP 请求,获取下载文件的长度信息
(2) 根据文件的长度以及设置的线程数N,把下载任务分成N个子任务,每个子任务再分别发起HTTP请求,负责下载自己那一部分的数据并写入同一个文件中(RandomAccessFile
已经处理了同步问题)。
所以这里我的设计是先使用一个 AsyncTask
获取文件长度,再异步的回调里,开启N个子任务线程进行下载。
这里当然是使用线程池来执行子任务了,子任务都实现 Runnable
丢到线程池里。另外由于 AsyncTask
默认的实现是串行的,也可以让 AsyncTask
在默认的线程池上执行,这样就可以实现多个任务同时开始下载了。
4. 下载任务的封装和管理
首先要用一个类来描述一个下载任务,这个类的设计要考虑以下几点:
- 每一个下载任务和一个下载请求一一对应,所以下载任务中应该包含一个下载请求的字段
- 每个任务需要一个唯一的ID,这里考虑使用url+保存路径+文件名的字符串进行MD5运算,来作为一个任务的ID
- 需要记录下载文件的大小
- 需要一个字段标示当前任务所处的状态,比如正在下载,暂停,失败等,操作该字段需要同步
- 需要一个字段标示当前任务已经下载的字节数,操作该字段也需要同步
- 需要一个List字段保存已经开始下载的任务的子任务的信息,每个子任务中保存当前写入文件的位置以及结束写入的位置
然后就是需要一个集合来保存所有的已添加的任务,因为各种对任务的操作,比如暂停,取消,删除等都是要根据ID来找到一个对应任务,所以使用Map来保存可以保证查找的效率。
5. 事件发布设计
所有的事件都通过 LocalBroadcastManager
发布,然后使用者可以有两种方法实现对事件的监听,一种是定义自己的 Receiver 接收处理各种广播事件。还有一种是注册 Listener,然后我们在框架内部实现一个 BroadcastReceiver
,根据不同的事件调用 Listener 的不同的方法,这样封装的更好,不过某些场景自己注册Receiver还是更灵活一些,可以在 switch 里面对多个 case 合并处理
6. 任务状态的切换
最主要的部分就是如何暂停或者取消一个正在进行的任务。在下载的子任务线程里,会有一个循环从InputStream读取数据并写入文件的操作,我们就在这个循环这里加入对任务状态的判断,当状态是Downloading时,就继续下载,当状态被设为 Paused 时,就跳出循环,这样就实现了任务的暂停。
当然也可以用 FutureTask.cancel()
,在循环里判断 isInterrupted()
来实现,不过因为我们已经有了一个表示任务状态的字段,直接使用这个字段可以达到同样的效果。
当恢复一个暂停的任务时,不能让它直接开始,要把重新加到任务队列里面去,然后等待调度。因为可能已经达到任务上限,所以还是要重新拿到信号量才可以开始。
7. 任务的持久化
不考虑大量任务管理的场景的话,可以直接用 SharedPreference
配合 Gson 的序列化和反序列化,实现任务的持久化。用数据库的话就麻烦一点,要自己读写各个字段,当然也可以用 GreenDao,Realm 等orm框架,不过作为一个实验性项目,这块暂时先不做那么复杂吧。
3. 总结
初步的分析结束,整体的思路已经清楚了,主要的难点应该是在任务的调度,多线程的协作和同步。最后从用户的角度总结一下最终要实现的功能:
- 定义一个下载请求并加入下载队列,获得任务的ID以便后续的操作。任务自动开始下载,如果达到上限就等待
- 通过任务ID可以暂停,取消,恢复一个任务的执行
- 任何情况下退出都应该能保存任务的下载状态
下一篇就写具体的代码实现。