目录
1. 前言
最近在做 Android 混合项目的开发,涉及到 WebView 控件的文件下载功能,这里总结一下。
Android 中 Webview 控件默认是不支持文件下载的,需要设置其属性才支持。Webview 实现下载的方式主要有三种:
- 跳转到浏览器下载
- 使用系统的下载服务
- 自定义下载
本人能想到的只有三种,如有遗漏,还请赐教~~
记得添加 网络权限&文件读取权限,此处忽略不计,不懂请自行百度
2. WebView 设置下载监听器
Webview 默认是不支持文件下载的,需要给其设置下载监听器setDownloadListener
。
webView.setDownloadListener(new DownloadListener() {
@Override
public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength) {
//处理下载事件
}
});
- DownloadListener 里面只有一个方法 onDownloadStart,具体的参数含义可查看源码,里面注释的很详细。
- 下载的URL 通过onDownloadStart方法参数传递,可以在这里处理下载事件。
- 每当有文件需要下载时,该方法就会被回调。
3. WebView 下载文件
3.1 跳转到浏览器下载
这种方式最为简单粗暴,直接把下载任务抛给浏览器,剩下的就不用我们管了。
private void downloadByBrowser(String url) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setData(Uri.parse(url));
startActivity(intent);
}
缺点:
无法感知下载完成,所以就没有后续的处理,如 apk 下载完成后打开安装界面、文件下载完后打开文件 等。
3.2 使用系统的下载服务
DownloadManager 是系统提供的用于处理下载的服务,使用者只需提供 下载 URI 和 存储路径,并进行简单的设置。
DownloadManager 会在后台进行下载,并且在下载失败、网络切换以及系统重启后尝试重新下载。
添加下载任务:
/**
* 使用系统的下载服务
*
* @param url 下载地址
* @param contentDisposition attachment;filename=测试专用.wps;filename*=utf-8''测试专用.wps
* @param mimeType application/octet-stream
*/
private void downloadBySystem(String url, String contentDisposition, String mimeType) {
LogUtil.d(TAG, "downloadBySystem:url=" + url + ",contentDisposition="
+ contentDisposition + ",mimeType=" + mimeType);
// 指定下载地址
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
// 允许媒体扫描,根据下载的文件类型被加入相册、音乐等媒体库
request.allowScanningByMediaScanner();
// 设置通知的显示类型,下载进行时和完成后显示通知
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
// 设置通知栏的标题,如果不设置,默认使用文件名
// request.setTitle("This is title");
// 设置通知栏的描述
// request.setDescription("This is description");
// 允许在计费流量下下载
request.setAllowedOverMetered(false);
// 允许该记录在下载管理界面可见
request.setVisibleInDownloadsUi(false);
// 允许漫游时下载
request.setAllowedOverRoaming(true);
// 允许下载的网路类型
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);
// 设置下载文件保存的路径和文件名
String fileName = URLUtil.guessFileName(url, contentDisposition, mimeType);
LogUtil.d(TAG, "fileName:" + fileName);
//storage/emulated/0/Android/data/项目名/files
// request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);
// request.setDestinationInExternalPublicDir(ConstantPath.getCommonPath(mContext), fileName);
//Android/data/项目名/files/storage/emulated/0/Android/data/项目名/files
// request.setDestinationInExternalFilesDir(this, ConstantPath.getCommonPath(mContext), fileName);
//另外可选一下方法,自定义下载路径
Uri mDestinationUri = Uri.withAppendedPath(Uri.fromFile(
new File(ConstantPath.getRootPath(ConstantPath.ANDROIDMOBILE))), fileName);
// Uri mDestinationUri = Uri.withAppendedPath(Uri.fromFile(
// new File(ConstantPath.getCommonPath(mContext))), fileName);
request.setDestinationUri(mDestinationUri);
final DownloadManager downloadManager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
// 添加一个下载任务
long downloadId = downloadManager.enqueue(request);
LogUtil.d(TAG, "downloadId:" + downloadId);
}
怎么知道文件下载成功呢?
系统在下载完成后会发送一条广播,里面有任务 ID,告诉调用者任务完成,通过 DownloadManager 获取到文件信息就可以进一步处理。
接收系统下载完成发出的广播:
private class DownloadCompleteReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
LogUtil.d(TAG + ",onReceive. intent:{}", intent != null ? intent.toUri(0) : null);
if (intent != null) {
if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) {
long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
LogUtil.d(TAG, "downloadId:" + downloadId);
DownloadManager downloadManager = (DownloadManager) context.getSystemService(DOWNLOAD_SERVICE);
//application/octet-stream
String type = downloadManager.getMimeTypeForDownloadedFile(downloadId);
LogUtil.d(TAG, "getMimeTypeForDownloadedFile:" + type);
if (TextUtils.isEmpty(type)) {
type = "*/*";
}
Uri uri = downloadManager.getUriForDownloadedFile(downloadId);
LogUtil.d(TAG + ",UriForDownloadedFile:{}", uri.toString());
if (uri != null) {
// LogUtil.d(TAG + ",UriForDownloadedFile:{}", getRealFilePath(uri));
// jsRouteUtil.evaluateJavascript("javascript:getFilePath('" + ConstantPath.ANDROIDMOBILE + "')", webView);
// downLoadFile.openDonwload(FileUtil.getRealFilePath(mContext, uri));
downLoadFile.openDonwload(uri, type);
//
// String fileName = getFileRealNameFromUri(uri);
// LogUtil.d(TAG + ",UriForDownloadedFile:{}", fileName);
}
}
}
}
}
打开文件:
/**
* 打开文件
* @param uri 文件路劲 content://downloads/all_downloads/67
* @param type application/octet-stream
*/
public void openDonwload(Uri uri, String type) {
if (uri != null) {
try {
Intent intent = new Intent();
//设置intent的Action属性
intent.setAction(Intent.ACTION_VIEW);
//获取文件file的MIME类型
//tring type = FileType.getType(".docx");
LogUtil.d("type", type);
//7.0和即以上:不加这句话,wps软件不会打开文件,只会打开到软件列表;7.0以下,不加这句话,wps无法访问文件
//uri访问时,即content://..,要添加文件访问权限
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
//设置intent的data和Type属性。
intent.setDataAndType(uri, type);
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
Toast.makeText(context, "无法打开此文件", Toast.LENGTH_SHORT).show();
}
}
}
到这里为止,利用系统服务下载就算结束了。
总结:
系统服务只能监测到下载的开始和结束,至于下载过程中的暂停、重试等机制,系统已经帮我们做好了。
这种模式到底友不友好,要根据使用场景和具体的需求来评定了。
坑点
在实际开发中,本人遇到了一些坑点,这里做下记录。
1、下载文件格式错误
我要下载的文件是docx格式的文件,但实际下载下来的文件变成了bin格式。
在上诉代码中,通过 DownloadManager 指定了下载文件的文件名,而文件名是根据 URLUtil.guessFileName 方法对 contentDisposition 进行解析得到的。
//得到文件名
String fileName = URLUtil.guessFileName(url, contentDisposition, mimeType);
公司服务器上返回 contentDisposition 和 mimeType 值分别是:
- contentDisposition:attachment;filename=测试专用.wps;filename*=utf-8’'测试专用.wps
- mimeType:application/octet-stream
带着这两个值去看下 URLUtil.guessFileName 源码:
(此处只列出关键语句,可自行查看相关源码细节)
public static final String guessFileName(
String url,
@Nullable String contentDisposition,
@Nullable String mimeType) {
String filename = null;
String extension = null;
// If we couldn't do anything with the hint, move toward the content disposition
if (filename == null && contentDisposition != null) {
filename = parseContentDisposition(contentDisposition);
if (filename != null) {
int index = filename.lastIndexOf('/') + 1;
if (index > 0) {
filename = filename.substring(index);
}
}
}
// If all the other http-related approaches failed, use the plain uri
.....
// Split filename between base and extension
// Add an extension if filename does not have one
int dotIndex = filename.indexOf('.');
if (dotIndex < 0) {
.......
} else {
if (mimeType != null) {
// Compare the last segment of the extension against the mime type.
// If there's a mismatch, discard the entire extension.
int lastDotIndex = filename.lastIndexOf('.');
//typeFromExt=wps
String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
filename.substring(lastDotIndex + 1));
//typeFromExt=wps,mimeType=application/octet-stream
if (typeFromExt != null && !typeFromExt.equalsIgnoreCase(mimeType)) {
//根据mimeType来得到相应的文件格式
extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
if (extension != null) {
extension = "." + extension;
}
}
}
if (extension == null) {
//extension=.wps
extension = filename.substring(dotIndex);
}
//filename=测试
filename = filename.substring(0, dotIndex);
}
//将文件名和格式后缀拼接
return filename + extension;
}
根据源码可知,文件的格式是根据 mimeType 类型来决定的,查看相关的资料,得到 mimeType 与文件格式对应关系:
private static String[][] MIME_MapTable = {
//{后缀名,MIME类型}
{".3gp", "video/3gpp"},
{".apk", "application/vnd.android.package-archive"},
{".asf", "video/x-ms-asf"},
{".avi", "video/x-msvideo"},
{".bin", "application/octet-stream"},
{".bmp", "image/bmp"},
{".c", "text/plain"},
{".class", "application/octet-stream"},
{".conf", "text/plain"},
{".cpp", "text/plain"},
{".doc", "application/msword"},
{".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
{".xls", "application/vnd.ms-excel"},
{".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
{".exe", "application/octet-stream"},
{".gif", "image/gif"},
{".gtar", "application/x-gtar"},
{".gz", "application/x-gzip"},
{".h", "text/plain"},
{".htm", "text/html"},
{".html", "text/html"},
{".jar", "application/java-archive"},
{".java", "text/plain"},
{".jpeg", "image/jpeg"},
{".jpg", "image/jpeg"},
{".js", "application/x-javascript"},
{".log", "text/plain"},
{".m3u", "audio/x-mpegurl"},
{".m4a", "audio/mp4a-latm"},
{".m4b", "audio/mp4a-latm"},
{".m4p", "audio/mp4a-latm"},
{".m4u", "video/vnd.mpegurl"},
{".m4v", "video/x-m4v"},
{".mov", "video/quicktime"},
{".mp2", "audio/x-mpeg"},
{".mp3", "audio/x-mpeg"},
{".mp4", "video/mp4"},
{".mpc", "application/vnd.mpohun.certificate"},
{".mpe", "video/mpeg"},
{".mpeg", "video/mpeg"},
{".mpg", "video/mpeg"},
{".mpg4", "video/mp4"},
{".mpga", "audio/mpeg"},
{".msg", "application/vnd.ms-outlook"},
{".ogg", "audio/ogg"},
{".pdf", "application/pdf"},
{".png", "image/png"},
{".pps", "application/vnd.ms-powerpoint"},
{".ppt", "application/vnd.ms-powerpoint"},
{".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
{".prop", "text/plain"},
{".rc", "text/plain"},
{".rmvb", "audio/x-pn-realaudio"},
{".rtf", "application/rtf"},
{".sh", "text/plain"},
{".tar", "application/x-tar"},
{".tgz", "application/x-compressed"},
{".txt", "text/plain"},
{".wav", "audio/x-wav"},
{".wma", "audio/x-ms-wma"},
{".wmv", "audio/x-ms-wmv"},
{".wps", "application/vnd.ms-works"},
{".xml", "text/plain"},
{".z", "application/x-compress"},
{".zip", "application/x-zip-compressed"},
{"", "*/*"}
};
根据对应表可知,application/octet-stream 返回 .bin 格式。同时也能看到,要想得到 .docx 格式,mimeType 的值要为 application/vnd.openxmlformats-officedocument.wordprocessingml.document
String fileName = URLUtil.guessFileName(url, contentDisposition, "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
//或者
String fileName = URLUtil.guessFileName(url, contentDisposition, "application/vnd.ms-works")
这样设置后,下载文件名的格式就是 .docx,也就是说,服务器给的 mimeType 有问题。
懒得和服务端撕逼,再细看下源码会发现,当 mimeType 为 null 时,会截取文件名后缀来得到格式名(已在上诉源码中标注了),所以我将mimeType设置为了null。
String fileName = URLUtil.guessFileName(url, contentDisposition, null);
String fileName = URLUtil.guessFileName(url, contentDisposition, mimeType);
这种设置方法得到下载文件格式,在有些设备上是正常的;但是在有些设备上(华为MatePad Pro)就会出现文件格式错误问题,这可能和系统有关系。
2、下载文件名问题
根据上诉那样设置后,下载下来的文件名如下:
可以看出,文件名是直接使用了 contentDisposition 这个值,但是实际上我想要的是 测试专用.docx
。
又去看了下 URLUtil.guessFileName 的源码,发现 filename 是根据 /
截取 contentDisposition 值得到的,但是我的 contentDisposition=attachment;filename=测试专用.wps;filename*=utf-8''测试专用.wps
,里面没有/
,而是多了''
。
又去和服务端的撕逼了下,他们说这是服务器返回的,他们没改过,无语,那我自己改吧。。。
把''
替换成/
contentDisposition = contentDisposition.replace("''", "/");
String fileName = URLUtil.guessFileName(url, contentDisposition, null);
这样就ok了
3.3 自定义下载任务
有了下载链接就可以自己实现网络部分,自定义下载实现的方式有很多,根据你的项目框架选择自定义方式。
这里举个例子,使用 HttpURLConnection 和 AsyncTask 实现:
private class DownloadTask extends AsyncTask<String, Void, Void> {
// 传递两个参数:URL 和 目标路径
private String url;
private String destPath;
@Override
protected void onPreExecute() {
log.info("开始下载");
}
@Override
protected Void doInBackground(String... params) {
log.debug("doInBackground. url:{}, dest:{}", params[0], params[1]);
url = params[0];
destPath = params[1];
OutputStream out = null;
HttpURLConnection urlConnection = null;
try {
URL url = new URL(params[0]);
urlConnection = (HttpURLConnection) url.openConnection();
urlConnection.setConnectTimeout(15000);
urlConnection.setReadTimeout(15000);
InputStream in = urlConnection.getInputStream();
out = new FileOutputStream(params[1]);
byte[] buffer = new byte[10 * 1024];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
in.close();
} catch (IOException e) {
log.warn(e);
} finally {
if (urlConnection != null) {
urlConnection.disconnect();
}
if (out != null) {
try {
out.close();
} catch (IOException e) {
log.warn(e);
}
}
}
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
log.info("完成下载");
String mimeType = getMIMEType(url);
Uri uri = Uri.fromFile(new File(destPath));
log.debug("mimiType:{}, uri:{}", mimeType, uri);
openDonwload(uri, type);
}
}
private String getMIMEType(String url) {
String type = null;
String extension = MimeTypeMap.getFileExtensionFromUrl(url);
log.debug("extension:{}", extension);
if (extension != null) {
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
}
return type;
}
// 使用
mWebView.setDownloadListener(new DownloadListener() {
@Override
public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) {
//fileName 的问题上诉已讲
String fileName = URLUtil.guessFileName(url, contentDisposition, mimeType);
//String destPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
// .getAbsolutePath() + File.separator + fileName;
String destPath = ConstantPath.getCommonPath(mContext) + File.separator + fileName;
new DownloadTask().execute(url, destPath);
}
});
优点:
可以感知下载进度,处理开始、取消、失败、完成等事件。
缺点:
必须自己处理网络带来的问题。
4. 总结
- 对下载没有任何要求,可使用 跳转到浏览器下载 方式实现下载。
- 只关心下载的 开始 和 结束 的,可使用 系统的下载服务 方式实现下载。
- 关心下载的全过程,即 开始、结束、暂停、下载进度 等过程的,可 自定义下载任务 方式实现下载。