Android DownloadManager简介

前一段时间看了一下Android自带的DownloadManager的相关代码,觉得比较有意思,在此简单做一些介绍。文本在此也仅仅是简单介绍,不涉及一些细枝末节的东西,还有比如数字版权之类的。

首先DownloadManager分为两个部分,对外部暴露的接口属于框架层,位于framework/base/core/java/android/app/DownloadManager.java,编译之后就在/system/framework.jar。其实这里的仅仅是类似于接口的东西,具体的实现在厂商第三方作为DownloadProvider,就像命名那样整个下载流程的核心就在这里的ContentProvider,从类的命名中可以非常容易的看出调用关系。

##DownloadManager
DownloadManager作为一个全局服务直接从Context中获得,其提供了所有的和任务相关的操作接口,这里从访问下载任务说起。对于下载任务最为常用的操作还是查询和对状态的监听,第一次看到DowloadManager提供的接口时我对于没有回调接口非常奇怪,而看到的仅仅是DownloadManager#query(query:Query),而且返回的是一个数据库游标。进而深入进去发现所有的核心就是DownloadProvider

###添加下载任务

1
2
3
4
5
6
public long enqueue(Request request) {
ContentValues values = request.toContentValues(mPackageName);
Uri downloadUri = mResolver.insert(Downloads.Impl.CONTENT_URI, values);
long id = Long.parseLong(downloadUri.getLastPathSegment());
return id;
}

向下载队列中添加一个下载任务请求,请求对象Request为DownloadManager的内部类。其提供了新建一个下载任务所需的所有属性,由于一个通用的下载功能实际上需要相当多的功能来支持,所以这里面提供了非常详细的属性来为每一个下载任务进行详细的设定,这点文档上已经有相当多的说明在此也不做过多的累赘。

###删除下载任务

1
2
3
4
5
6
7
public int markRowDeleted(long ... ids){
...
}
public int remove(long ... ids){
return markRowDeleted(ids);
}

根据任务的ID删除下载任务,由参数可知这是支持批量删除的。同时这个任务ID是添加下载任务成功时返回的,自然也可以通过查询下载任务获得,并且这个ID很容易看出实际上是数据库的对应任务的记录ID。这里的删除任务仅仅是设置一个删除标识,而具体的删除逻辑在之后的DownloadProvider进行说明。

###查询下载任务

1
2
3
4
5
6
7
public Cursor query(Query query) {
Cursor underlyingCursor = query.runQuery(mResolver, UNDERLYING_COLUMNS, mBaseUri);
if (underlyingCursor == null) {
return null;
}
return new CursorTranslator(underlyingCursor, mBaseUri);
}

对于信息的应用和处理,其实查询操作使用的频率是最高的,同时也是最为复杂的,因此这里专门设计了而一个静态类Query来处理查询条件。由于查到的内容实际上是数据库的信息,并且很多状态值对于外部应用实际上是不可见的,因此这里又专门设计了一个游标类CursorTranslator将数据库的信息转换为DownloadManager中规定的字段提供了类似翻译的功能。

##DownloadProvider
我第一次找到DownloadProvider的时候看到它的目录结构和各个类的名字还是比较困惑的,因为没有第一时间看到一个核心类,类似于任务调度中心的东西,用来管理下载线程、数据库、磁盘和回调等。索性整个项目非常小,看一遍之后发现整个结构还是非常有趣的,仔细看了下各个调用逻辑觉得受益匪浅,总结起来的话就是对于隐式回调(自造词/基于数据变更的回调)也就是ContentObserver给了无很多启发。那么接下来对DownloadProvider进行说明。

项目源码结构如上图,第一次看到这样的目录自然会被这几个类所吸引DownloadThreadDownloadService。也正是看到DowloadService一位这里是调度和控制中心,但是看了代码之后发现事件并非由这里激发,而仅仅是一个任务调度中心。那么先从几个核心类说起,再整体上说明其调用关系。

###下载线程
com.android.providers.downloads.DownloadThread

具体的下载任务线程,执行具体的下载任务,同时协调数据库记录和管理下载文件以及检测磁盘,以及数字版权等(DRM)。

DownloadThread的构造方法中最重要的就是DownloadInfo类,同时有一下几个比较重要的方法。

####下载任务实时状态

1
2
3
static class State{
...
}

静态内部类State在任务启动的初始化,并在伴随着下载任务的各个状态处理。在下载任务的各个阶段其只处理当前的Sate对象,从中读取信息,并将最新的状态更新进去。

####下载逻辑
从线程的起点public void run()方法开始,转向内部的private void runInternal()。xxxInternal的思想在android整个代码中随处可见,一个public的方法对外暴露调用接口,而内部再实现一个xxxInternal的私有方法进行实际的业务逻辑处理。接下来是两个相对来说行数最多的方法,runInternal和executeDownload(state:State)。

StopRequestException

在正式逻辑处理开始先要介绍一下StopRequestException,从逻辑上说这个是非常重要的。我想很多朋友都知道这样一个问题,各种return、break等控制语句其实控制的范围仅仅是方法体和语句块,那么如何终止方法调用甚至是整个调用链条呢?在没有try-catch块阻止的前提下,出现一个异常是可以中断当前线程调用的,那么在一个逻辑处理链条上,在某一个位置制造一个异常实际上此链条后的所有方法都会停止调用。抛出的异常会一直向调用的起点反馈,如果不做处理最终会到达整个程序的主线程,如果主线程依旧不做处理,那么会引起主线程的中断,造成整个程序的退出。

DownloadThread中,目前我感觉设计的思路是尽量的把每个步骤和逻辑分散在各个方法中。这样整个逻辑看起来非常清晰,不会造成各种逻辑混在一起,各种细枝末节的问题全部乱糟糟的堆在那里。调用从runInternal开始进行初始化,然后分发到executeDownload开始进入实际的下载操作,然后在调用各个分散的方法,层层嵌套。那么如果在某个很深的调用链条中产生一个事件要中断当前下载怎么办呢?这里的方法就是产生一个StopRequestException异常,这个异常可能是可预测的(如各类流、I/O等),或未知的。甚至是逻辑上产生的终端事件,如用户操作停止下载任务能等。各类事件全部封装成StopRequestException,向当前方法的上一个调用抛出,直到到达runInternal方法做任务退出的后续处理。

runInternal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void runInternal(){
...
State state = new State(mInfo);
...
try{
...
try{
state.mUrl = new URL(state.mRequestUri);
}catch(...){}
executeDownload(state);
...
}catch(StopRequestException error){
//重试和下载任务状态转换
}catch(Throwable ex){
//标记未知错误时的下载状态
}finally{
//下载任务结束后的后续处理操作
}
}

下载任务开始前和结束后都由此方法处理。开始前会进行一定的逻辑判断,如这个任务是否已经是下载完成状态,测试网络类型,开始流量监控,然后测试下载地址格式等。这里会初始化State对象,并且是局部对象。这个State对象是由DownloadInfo对象进行初始化的,而DownloadInfo是来自数据库的并且是实时与数据库保持同步的。

在这里先说明:

DownloadThread中,DowloadInfo为数据的来源,并且在这里保持只读,禁止对其进行修改。而实际的流操作带来的数据变更反应到临时的State对象中,并在改变的同时更新数据库信息。而数据库的变更又会实时的更新到DownloadInfo对象中。这个更新逻辑和实际的实现方式稍后进行详细说明。

executeDownload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void executeDownload(State state){
...
while(state.mRedirectionCount ==< Constants.MAX_REDIRECTS){
HttpURLConnection conn = null;
try{
...
addRequestHeaders(state, conn);
//初始化conn
final int responseCode = conn.getResponseCode();
switch(responseCode){
...
// 各种http 状态码分支判断
...
processResponseHeaders(state, conn);
...
transferData(state, conn);
}
}
}
}

从这个方法开始,进入正式的下载流程由于逻辑的拆分。这个方法实际上只处理网络请求阶段,后续的操作继续向下一级方法分发。

这里首先进行state的重置,避免重试操作可能存在的缓存,而重试操作是在上一级runInternal中执行的。从这里开始所有的后续操作都要跟随这state对象(有点面向过程的感觉),读取当前的任务状态,并将操作结果反馈到state中。之后检测磁盘的缓存数据,从数据完整性的角度上在此判断任务是否已经完成。

随后在整个网络请求外层使用一个while控制重定向次数,在每一次重定向中重新初始化HttpURLConnection并根据http状态码判断是开始下载,还是进行重定向。在开始网络请求前有一个方法addRequestHeaders,这里主要的操作为在请求头中增加一些用户自定义的信息,之后对User-Agent进行测试,然后就是ETAGf-Match。而最后就是最重要的断点续传信息Range,完整值格式是bytes=currentBytes-totalBytes。给服务器一个数据传输的起始点,而这里的totalBytes是可以省略的,相等于告诉服务器,从currentBytes开始传输剩余的部分。

在服务器返回之后使用processResponseHeaders(state, conn)方法处理服务器返回,而比较重要的操纵又转发到readResponseHeaders(state, conn)方法,更新state属性。这里我们最关心的是服务器返回的文件长度Content-Length信息,在获取这个信息成功之后将其保存在state中,并更新数据库。而在这些都处理完成之后开始流传输。

transferData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void transferData(State state, HttpURLConnection) throws StopRequestException{
...
try{
try{
in = conn.getInputStream();
}catch(...){}
try{
if(... ){
//DRM 数字版权
out = new DrmOutputStream(drmClient, file, state.mMimeType);
}else{
out = new FileOutputStream(state.mFilename, true);
...
}
}catch(...){}
transferData(state, in, out);
...
}
}

这里实际是流传输开始的准备工作。从HttpURLConnection中获取输入流,如果需要版权监控的话使用DrmOutputStream做文件输出流,否则直接使用FileOutputStream做输出流。这里关于DRM的东西本人不是很了解,也就不多做说明了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void transferData(State state, InputStream in, OutputStream out)
throws StopRequestException {
final byte data[] = new byte[Constants.BUFFER_SIZE];
for (;;) {
int bytesRead = readFromResponse(state, data, in);
if (bytesRead == -1) { // success, end of stream already reached
handleEndOfStream(state);
return;
}
state.mGotData = true;
writeDataToDestination(state, data, bytesRead, out);
state.mCurrentBytes += bytesRead;
reportProgress(state);
if (Constants.LOGVV) {
Log.v(Constants.TAG, "downloaded " + state.mCurrentBytes + " for "
+ mInfo.mUri);
}
checkPausedOrCanceled(state);
}
}

从这里开始是正式的文件写入操作,同样这个很容易理解的过程被分为了好几个方法单独执行。readFromResponse方法做的仅仅是一个从http输入流中读取一个数组缓冲区,并做了操作失败的处理,这理主要是更新数据库。writeDataToDestination自然就是将读到的缓冲区写入文件,这个谐都成功后在更新state的任务进度,然后使用reportProgress进行流量统计,下载速度计算和更新任务状态和进度到数据库。在这个单元操作结束后使用checkPausedOrCanceled方法检测当前任务是否被外部操作打断,如暂停任务和删除任务等,最后调用checkConnectivity进行王状态检测。而以上这个过程是在一个for循环中,读输入流是一个I/O阻塞操作,每次循环的操作内容就是读取的缓冲区,结束循环的条件是正常的流结束、网络异常和之外的各类异常操作。

###DownloadProvider
接下来是一个逻辑上的核心,尽管从功能上说只是一个事件分发中心。

虽然初看起来整个DownloadProvider乱糟糟的一堆代码,但是它本质上是一个ContentProvider,是一个数据提供者。因此我们从本质上来看它究竟是做什么的。我们都知道对于SQLite数据库的操作可以非常简单的一个实现一个DatabaseHelper继承SQLiteOpenHelper来封装对于SqliteDatabase的调用,但是考虑到跨进程通信和解耦ContentProvider就是一个非常好的选择。

ContentProvider既然本质是一个对数据库操作的接口,所以我们只需要关心它的insertupdatequerydelete几个方法。通过源码我们可以知道DonwloadProvider管理的仅仅是downloads这一张表,而这个表的结构全部在Downloads中进行定义,其位置在framework/core/java/android/provider/。其他的一些方法大多是为了简化数据库操作,完全可以不去考虑。这里只需要知道DownloadProvider的作用仅仅是在数据变更,也就是insertupdate的时候回调注册过来的ContentObserver,进而成为事件激发的来源。也正是这个功能,整个下载流程得以运转开来。而这个具体的中转流程在DownloadService中进行说明。

###DownloadService
DownloadService确实是整个下载流程的核心,但是从它的调用入口和抛出UnsupportedOperationException异常的onBind方法我们就可以看出,这个服务是对外隐藏的。而启动这个服务的入口有两个,一个是DownloadReceiver另外一个就是DownloadProvider

DownloadReceiver是一个内外部事件收集器,其主要的功能实际就是启动DownloadService当然启动的目的就是刷新所有的在缓存中的任务状态,并根据最新的状态执行相应的操作。而Provider中则是在初始化和数据变更是时有选择的启动,那么一旦整个下载应用挂掉这里只有通过广播接收器启动,除非外部直接访问DownloadProvider。而系统对外暴露的DownloadManager接口实际都是在操作直接访问Proviser的。接下来进入正文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
...
private final Map<Long, DownloadInfo> mDownloads = Maps.newHashMap();
...
private class DownloadManagerContentObserver extends ContentObserver {
public DownloadManagerContentObserver() {
super(new Handler());
}
@Override
public void onChange(final boolean selfChange) {
if (Constants.LOGVV) {
Log.v(Constants.TAG, "Service getproviderChange");
}
enqueueUpdate();
}
}
...
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
int returnValue = super.onStartCommand(intent, flags, startId);
if (Constants.LOGVV) {
Log.v(Constants.TAG, "Service onStart");
}
mLastStartId = startId;
enqueueUpdate();
return returnValue;
}
...

先看这一部分代码。mDownloads实际上是缓存了目前所有的下载任务,其查询标记为long型的数据库ID。onStartCommand方法是在服务第一次启动和重复启动时调用,这里的DownloadManagerContentObserver是之注册的整个数据操作的Uri上的,而他们最终都会调用到enqueueUpdate()这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private boolean updateLocked() {
...
final Cursor cursor = resolver.query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
null, null, null, orderBy);
try{
final DownloadInfo.Reader reader = new DownloadInfo.Reader(resolver, cursor);
final int idColumn = cursor.getColumnIndexOrThrow(Downloads.Impl._ID);
while (cursor.moveToNext()) {
final long id = cursor.getLong(idColumn);
...
DownloadInfo info = mDownloads.get(id);
if (info != null) {
updateDownload(reader, info, now);
} else {
info = insertDownloadLocked(reader, now);
}
...
if (info.mDeleted) {
...
deleteFileIfExists(info.mFileName);
resolver.delete(info.getAllDownloadsUri(), null, null);
}else{
final boolean activeDownload = info.startDownloadIfReady(mExecutor);
final boolean activeScan = info.startScanIfReady(mScanner);
}
}
}
}

首先enqueueUpdate()通过Handler转到updateLocked()进行实际的操作,updateLocked()的主要方法如上。

updateLocked的内容非常直观,主要进行任务信息和状态同步。首先从DonwloadProvider中查出全出的下载信息,然后和缓存变量mDownloads进行比较,如果存在就执行updateDownload不存在就执行insertDownloadLocked。而不管是新增还是更新实际的操作都由DownloadInfo中的Reader静态类执行。而Reader的功能都是非常简单,将当前数据库游标指向的当前行数据同步到info变量中。之后使用更新过的info变量检测其实际状态。

接下来实际上只有两种操作,如果任务状态为删除则删除数据库信息并删除缓存文件,否则直接调用info#startDownloadIfReady方法,关于startDownloadIfReady将会在DownloadInfo中进行说明。

至此,整个下载过程如下。

如果是通过DownlaodManager操作下载任务,其会直接与ContentProvider(DownloadProvider/DownloadProvider)进行交互。然后通过选择启动DownloadService,查询Provider将下载任务同步到缓存中。DownloadService在更新缓存的同时会根据当前任务的状态确认实际下载任务的状态,这里使用DownloadInfo处理DownloadThread的实际状态。

在任务正常运行中,最频繁的操作是下载任务DownloadThread每次I/O之后更新数据库。这时基本是Provider#update,这时主要通过DownlaodService中注册的ContentObserver直接回调至刷新任务缓存。同时第三方对下载任务的监听也是通过ContentObserver完成,同时和DownloadService一样,需要重新查询一次Provider获取更新之后的信息。

任务状态转换分为用户操作和下载任务操作。用户操作在目前主要是通过DownloadManager完成,其本质也是调用Provider#update。在下载过程中通过StopRequestException激发出来的中断任务操作,最后也是要通过Provider#update完成。而最后最任务的操作都是在DownloadSrvice完成。

DownloadReceiver也是一个启动源,如网络变更、磁盘挂载等等。

DownloadInfo

DownloadInfo的主要功能是任务的缓存对象,同时管理着实际的下载任务线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean startDownloadIfReady(ExecutorService executor) {
synchronized (this) {
final boolean isReady = isReadyToDownload();
final boolean isActive = mSubmittedTask != null && !mSubmittedTask.isDone();
final boolean isAlreadyFull = isAlreadyFullThreadPool();
if (isReady && !isActive && !isAlreadyFull) {
if (mStatus != Impl.STATUS_RUNNING) {
mStatus = Impl.STATUS_RUNNING;
ContentValues values = new ContentValues();
values.put(Impl.COLUMN_STATUS, mStatus);
mContext.getContentResolver().update(getAllDownloadsUri(), values, null, null);
}
mTask = new DownloadThread(
mContext, mSystemFacade, this, mStorageManager, mNotifier);
mSubmittedTask = executor.submit(mTask);
}
return isReady;
}
}

DownloadInfo中的Reader类在DownloadService中就已经将其功能体现的很清楚了,在此就不在说明。而DownloadInfo中重要的方法,比如startDownloadIfReady这个是DownloadService刷新任务时基本都会调用的,很多对任务的判断和操作都转移到这里。

这里的主要操作是对启动下载线程的条件判断。如数据库的状态是否为已将完成,下载线程是否正在运行,线程池是否已满等等,在这些之后才最终启动DownloadThread。同时也保证了各个事件源在不停刷新DownloadService时保证下载任务不会被重复启动和意外停止。

总结

就如我在最开始说的那样,一个通用的下载管理工具实际上考虑相当多的细枝末节的东西。在最终的代码实现上会非常多看起来完全没有必要的功能,但是这都是在实际使用中出现的问题。而且这些细枝末节的东西慢慢看下去其实也是非常有趣的,因为它们会丰富我们的知识,甚至会学到新的对问题的思维方式。

其次,虽然DownloadProvider这个项目是没有对外开放的,但是在看代码的时候可能会注意到对下载任务的实际操作都是通过对其中的ContentProvider的修改来完成的。那么是否可以这样想我们可以绕过DownloadManager直接操作DownloadProvider来完成下载任务的操作和查询?这里我觉得是完全可以的。但是这里有个问题,实际的下载任务和对外暴露的显示结果是有很大的出入的,同时对任务的查询也是非常的繁琐。直接对下载任务的更改如果确定了某些属性的变更不会对DownloadService在成不不良影响的话是没有问题的,但是在实际的使用用我们为什吗要关系这些东西?而且也不能确定未来Downloads中数据的结构发生变化,而且在DownloadProvider中的DatabaseHelper#upgradeTo,我们会看到已经积累的几百的版本号如果在某个版本中直接操作数据库真的是非常不明智的。

那么对于DownloadManager的介绍就到这里了,这篇文章如标题那样仅仅是一个简介。整个项目流程是非常的简单的,如果有时间的话推荐看一下。