最近在做一个类似应用商店的项目,需要做大文件下载。

一开始我基于 OkHttpAsyncTask 实现了一个下载器,并且爬取其他应用商店内的应用下载链接测试成功了,但是当接入到我们自己的后台时,却发现并不能下载成功,会出现下载到某一个不确定的进度后,网速骤降,下载停止,并等待一个超时的时间后,下载失败。OkHttp 抛出一个异常:

W/System.err: java.net.ProtocolException: unexpected end of stream
W/System.err:     at okhttp3.internal.http1.Http1ExchangeCodec$FixedLengthSource.read(Http1ExchangeCodec.kt:371)
W/System.err:     at okhttp3.internal.connection.Exchange$ResponseBodySource.read(Exchange.kt:276)
W/System.err:     at okio.RealBufferedSource$inputStream$1.read(RealBufferedSource.kt:158)
W/System.err:     at java.io.InputStream.read(InputStream.java:101)
W/System.err:     at ...

这里看样子是 Okio 的异常,因为 OkHttp 底层网络连接是使用 Socket,连接成功后则通过 Okio 库与远程 Socket 建立了 I/O 连接。

我在国内的技术论坛上找了一圈,能尝试的方法都用遍了,依然不行,于是上『Github』和『Stack Overflow』寻求答案,发现这个问题依然没有个最终的解决方法,甚至『Github』的 OkHttpOkio 项目下已经有数个这样的 Issue 了,但在以 PHP 为后台的项目中复现率较高,且很有可能是服务端的问题。

不信邪的我拿了其他测试机过来测试后发现,在华为手机上失败率极高,其他手机则基本正常,这样看来应该是 EMUI 对 ROM 做了魔改的问题了,但是,当我使用华为手机切换至数据流量进行下载时,下载却成功了,连接网速快的 Wi-Fi 也能下载成功,因为公司的网络有做限速处理,当下载到某一进度后,下载停止,等待一个超时的时间即下载失败。

我个人猜测是服务器有一个定时断开连接的设置,当到达这个时间点时,客户端与服务端的连接断开了,然后客户端继续等待,直至超时,抛出异常,而使用数据流量或者网速快的 Wi-Fi 时,由于下载时间短了,那么就相对没有这么容易到达断开连接的时间点,所以就能够下载成功。

于是去找我们的 PHP 大佬聊聊,大佬也没头绪,无奈,只能先更换我这边的下载方式了。重新调整了下载模块,改用 Android SDK 中的 DownloadManager,这就相当于把下载的实现交给了系统,我这边就不需要管这么多了。

测试发现该方式更加不稳定,经常下着下着用于显示进度的 Notification 就不见了,然后过一段时间后又弹出继续下载,我猜测这种情况可能跟我自己实现的类似,但是 DownloadManager 可能默认有断线重连的机制,所以就会出现这种情况。

尽管有了断线重连,不过 Notification 的消失对用户来说这并不是好的交互,最终放弃该方案,转而寻找网上开源的下载器方案。

Android 开源的下载器似乎并不多,我只知道 LAIX(流利说,没错,就是那个『英语流利说』App 的公司)开源的 OkDownloadOkDownload 的前身其实是 FileDownloader,至于改名 OkDownload,是跟 Square 叫板还是……

OkDownload 对于我的项目似乎又太过笨重,于是我又开始寻找别的替代方案,无意中就发现了 @任振铭DownloadModel

不得不说的是,@任振铭DownloadModel 的 README 文档真是太烂了,我使用的时候,已经更新到了 V1.0.5,但文档却还是基于 V1.0.2,而且还涉及到了 API 的变更,因此我不得不把源码扒下来才搞懂使用的方法。

先基于 V1.0.5 简单说一下用法吧。

首先在 Project 的「build.gradle」中配置 JitPack 仓库:

allprojects {
    repositories {
        ...
        maven { url 'https://www.jitpack.io' }
    }
}

然后在 Module 的「build.gradle」中添加依赖:

dependencies {
    ...
    implementation 'com.github.renzhenming:DownloadModel:(insert latest version)'
}

启动下载任务时:

DownloadManager.getInstance(context).download(new DownloadInfo.Builder()
        .setDownloadUrl(url)
        .setUniqueKey(uniqueKey)
        .setName(name)
        .build());

获取单例后调用 download() 方法进行下载,接收一个封装了下载信息的 DownloadInfo 参数,且会根据 DownloadInfo 中的 uniqueKey 来判断下载任务,由于库内有 MD5 的工具类,所以我一般将其设置为下载地址的 MD5 值。

如需暂停下载任务:

DownloadManager.getInstance(context).pause(uniqueKey);

根据 uniqueKey 来暂停相应的下载任务即可。

除了我们封装的下载信息外,DownloadInfo 中还会包含一些下载时的其他信息,比如下载状态和下载进度等,我们也可以根据 uniqueKey 来获取:

DownloadInfo downloadInfo = DownloadManager.getInstance(context).getDownloadInfo(uniqueKey);
if (downloadInfo != null) {
    float progress = downloadInfo.getProgress();
    int currentState = downloadInfo.getCurrentState();
    ...
}

最重要的,我们需要监听下载进度,就注册一个 Observer

DownloadManager.getInstance(context).registerObserver(new DownloadManager.DownloadObserver() {
    @Override
    public void onDownloadStateChanged(DownloadInfo info) {
        if (info.getDownloadUrl().equals(downloadUrl)) {
            ...
        }
    }
});

但是一般不会采取匿名类的方式,而是在最外层的类中 implements 这个 DownloadManager.DownloadObserver 接口:

public class ClassName implements DownloadManager.DownloadObserver {
    ...
    @Override
    public void onDownloadStateChanged(DownloadInfo info) {
        if (info.getDownloadUrl().equals(downloadUrl)) {
            ...
        }
    }
}

并在需要注册监听的位置调用:

DownloadManager.getInstance(context).registerObserver(this);

这样我们如果需要移除监听,就可以直接调用:

DownloadManager.getInstance(context).unregisterObserver(this);

下载的文件位置为 /内部存储/download_asset/ 目录下。

假如使用它来下载 Apk 文件,我们还可以使用其提供的 API 来跳转安装:

DownloadManager.getInstance(context).install(context, uniqueKey);

跳转安装应用需要配置以下权限:

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

为了适配 Android 7.0(Nougat,API Level 24)的应用间文件共享,还需要配置在「AndroidManifest.xml」中配置:

<application ...>
    ...
    <provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="${applicationId}.download.fileprovider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/filepath" />
    </provider>
</application>

并新建 /res/xml/filepath.xml 文件:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <paths>
        <external-path
            name="external_storage_root"
            path="." />
        <files-path
            name="."
            path="." />
    </paths>
</resources>

但是目前项目还没有兼容 Android Q(Quince Tart,Android 10,API Level 29)的分区存储,所以我也给项目提了个 Issue(还是这个项目的第一个 Issue 呢),希望 @任振铭 后续会支持。

那么目前来说,我们可以通过如下方式兼容 Android Q 的分区存储:

<application ...
    android:requestLegacyExternalStorage="true"
    tools:targetApi="q">
    ...
</application>

@任振铭DownloadModel 的基本用法就是这些,其实支持的功能也是不少的:

  • 多线程下载:有些大文件下载的需求中,单线程下载无法满足对效率的追求,此时想尽可能多的利用硬件资源提高下载速度,那可以选择开启 2 个线程或 3 个线程甚至 10 个线程(只要你内核足够)来下载这一个文件,虽然效率不能说成倍提高,但是作用还是很明显的。
  • 多文件下载:使用者不需要关心这么多情况,只需把每个下载链接都丢给下载器即可,下载器会告知下载状态,使用者根据状态来更新界面显示即可,其他的一切交给下载器来处理。使用者也可以自定义线程池来管理每一个下载任务,设置下载器的产能,这些接口都通过 IThreadPool 接口开放出来,自主性很高。
  • 断点下载:断点下载算是一个基本的功能了。下载过程中用户可能主动停止下载,也可能是系统原因导致下载被迫中断,但是这些也不需要开发者去关心,需要做的依然是丢一个链接进去,其他的不用考虑。
  • 自定义下载路径:每个项目下载的存储路径都是不同的,所以必须将下载路径的设置接口开放给使用者,不然这就不能称之为一个通用的框架,IPath 接口已经提供了支持。
  • 自定义缓存管理:下载过程中的状态信息需要保存起来,以备后边使用,比如从缓存中拿到上次的下载节点来更新当前的下载进度,ICache 把缓存的读取和写入的能力提供给使用者,只需要实现这个接口,来处理自己的逻辑即可,当然如果图省事,也可以直接使用我默认的缓存工具。
  • 线程池管理:每个项目选用的线程池策略也是各有不同的,有的需要单线程顺序下载,有的需要多线程并列执行,所以作者把线程池的接口也开放出来,给使用者自定义的能力,就是上文提到的 IThreadPool 接口。
  • 开放的网络接口:项目里网络请求是直接通过 HttpUrlConnection 实现的,使用者可能希望通过其他第三方的开源网络框架实现,那么就可以自定义一个类实现 IConnection 接口来实现自己的网络。

尽管上面已经做了介绍,但实际的使用我强烈建议参照 @任振铭 提供的 Demo,否则不一定能把这个框架使用得合理,尤其是在 RecyclerView 的列表中去下载,当然归根结底我认为是 API 的设计不够简单合理以及文档的不完善。

还需要提个醒,假如在项目中想使用 Notification 来显示进度的话,建议做一层过滤,可以参照我的代码:

private void refreshNotification(float progress, int downloadState, String downloadUrl) {
    ...
    switch (downloadState) {
        case DownloadInfo.STATE_DOWNLOAD:
            if ((int) (progress * 100) > mProgress) {
                mProgress = (int) (progress * 100);
                // 刷新 Notification 更新 Progress
            }
            break;
        ...
    }
}

因为监听的进度更新极快,几毫秒就能收到一次进度的回调,进度精确到小数点后 7 位,当文件较大时,有可能几秒钟内进度的前两位小数基本不会有变化,而频繁的无用更新会导致整个 App 卡顿,所以我们可以做过滤,只有在前两位小数有变更时才去刷新 Notification

想了解更多关于该库的实现可以前往其项目主页