最近在做一个类似应用商店的项目,需要做大文件下载。
一开始我基于 OkHttp
和 AsyncTask
实现了一个下载器,并且爬取其他应用商店内的应用下载链接测试成功了,但是当接入到我们自己的后台时,却发现并不能下载成功,会出现下载到某一个不确定的进度后,网速骤降,下载停止,并等待一个超时的时间后,下载失败。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』的 OkHttp
和 Okio
项目下已经有数个这样的 Issue 了,但在以 PHP 为后台的项目中复现率较高,且很有可能是服务端的问题。
不信邪的我拿了其他测试机过来测试后发现,在华为手机上失败率极高,其他手机则基本正常,这样看来应该是 EMUI 对 ROM 做了魔改的问题了,但是,当我使用华为手机切换至数据流量进行下载时,下载却成功了,连接网速快的 Wi-Fi 也能下载成功,因为公司的网络有做限速处理,当下载到某一进度后,下载停止,等待一个超时的时间即下载失败。
我个人猜测是服务器有一个定时断开连接的设置,当到达这个时间点时,客户端与服务端的连接断开了,然后客户端继续等待,直至超时,抛出异常,而使用数据流量或者网速快的 Wi-Fi 时,由于下载时间短了,那么就相对没有这么容易到达断开连接的时间点,所以就能够下载成功。
于是去找我们的 PHP 大佬聊聊,大佬也没头绪,无奈,只能先更换我这边的下载方式了。重新调整了下载模块,改用 Android SDK 中的 DownloadManager
,这就相当于把下载的实现交给了系统,我这边就不需要管这么多了。
测试发现该方式更加不稳定,经常下着下着用于显示进度的 Notification
就不见了,然后过一段时间后又弹出继续下载,我猜测这种情况可能跟我自己实现的类似,但是 DownloadManager
可能默认有断线重连的机制,所以就会出现这种情况。
尽管有了断线重连,不过 Notification
的消失对用户来说这并不是好的交互,最终放弃该方案,转而寻找网上开源的下载器方案。
Android 开源的下载器似乎并不多,我只知道 LAIX(流利说,没错,就是那个『英语流利说』App 的公司)开源的 OkDownload
,OkDownload
的前身其实是 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
。
想了解更多关于该库的实现可以前往其项目主页。