Android 文件下载支持下载状态回调,断点续传(2)
DownloadUtil
package com.sdt.devicemanager.utils;
import android.content.Context;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.sdt.devicemanager.ssl.SSLHelper;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
/**
* 文件下载工具类
* 支持:
* - 下载进度回调
* - 断点续传(可选)
* - 多线程下载管理
* - 主线程回调
*/
public class DownloadUtil {
private static final String TAG = "DownloadUtil";
/**
* 下载回调接口
*/
public interface OnDownloadListener {
/**
* 下载开始
*
* @param totalBytes 文件总大小(-1表示未知)
* @param downloadedBytes 已下载的字节数(断点续传时 > 0)
*/
void onStart(long totalBytes, long downloadedBytes);
/**
* 下载进度回调
*
* @param totalBytes 文件总大小
* @param downloadedBytes 已下载的字节数
* @param progress 进度百分比 0-100
*/
void onProgress(long totalBytes, long downloadedBytes, int progress);
/**
* 下载成功
*
* @param file 下载完成的文件对象
*/
void onSuccess(File file);
/**
* 下载失败
*
* @param e 异常信息
*/
void onFail(Exception e);
}
private static final int DEFAULT_TIMEOUT = 60;
private static final int BUFFER_SIZE = 8192;
/**
* 默认下载路径
*/
public static final String DEFAULT_DOWNLOAD_PATH = Environment.getExternalStorageDirectory().getAbsolutePath() + "/deviceManager/";
private final OkHttpClient client;
private final Handler mMainHandler;
/**
* 下载线程池
*/
private static final ExecutorService downloadExecutor =
new ThreadPoolExecutor(
3, // 核心线程数
6, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(100), // 队列容量
new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "download-thread-" + count.getAndIncrement());
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满了后的策略
);
// 双检锁单例
private static volatile DownloadUtil downloadInstance;
// 正在执行的下载任务
private final Map<String, Call> downloadCalls = new ConcurrentHashMap<>();
private DownloadUtil(Context context) {
client = SSLHelper.getUnsafeOkHttpClient(context)
.connectTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
.build();
mMainHandler = new Handler(Looper.getMainLooper());
}
/**
* 获取单例实例
*/
public static DownloadUtil getInstance(Context context) {
if (downloadInstance == null) {
synchronized (DownloadUtil.class) {
if (downloadInstance == null) {
downloadInstance = new DownloadUtil(context.getApplicationContext());
}
}
}
return downloadInstance;
}
/**
* 下载文件
*
* @param url 下载地址
* @param savePath 保存路径(文件夹路径)
* @param fileName 文件名
* @param listener 下载回调
*/
public void download(String url, String savePath, String fileName, OnDownloadListener listener) {
download(url, savePath, fileName, false, listener);
}
/**
* 下载文件,可选择是否启用断点续传
*
* @param url 下载地址
* @param savePath 保存路径(文件夹路径)
* @param fileName 文件名
* @param enableResume 是否启用断点续传
* @param listener 下载回调
*/
public void download(String url, String savePath, String fileName, boolean enableResume, OnDownloadListener listener) {
if (url == null || url.isEmpty()) {
postToMain(() -> listener.onFail(new IllegalArgumentException("Download URL is empty")));
return;
}
if (fileName == null || fileName.isEmpty()) {
postToMain(() -> listener.onFail(new IllegalArgumentException("File name is empty")));
return;
}
// 在后台线程中执行下载
downloadExecutor.execute(() -> {
try {
// 创建保存目录
File saveDir = new File(savePath);
if (!saveDir.exists()) {
if (!saveDir.mkdirs()) {
postToMain(() -> listener.onFail(new IOException("Failed to create directory: " + savePath)));
return;
}
}
// 目标文件
File targetFile = new File(saveDir, fileName);
long downloadedBytes = 0;
// 如果启用断点续传且文件已存在,获取已下载的字节数
if (enableResume && targetFile.exists()) {
downloadedBytes = targetFile.length();
Log.d(TAG, "Resume download from byte: " + downloadedBytes);
}
String downloadId = generateDownloadId(url, targetFile);
doDownload(url, targetFile, downloadedBytes, enableResume, downloadId, listener);
} catch (Exception e) {
postToMain(() -> listener.onFail(e));
}
});
}
/**
* 执行实际下载
*/
private void doDownload(String url, File targetFile, long downloadedBytes,
boolean enableResume, String downloadId, OnDownloadListener listener) {
Request.Builder builder = new Request.Builder().url(url);
// 断点续传时添加 Range 头
if (enableResume && downloadedBytes > 0) {
builder.addHeader("Range", "bytes=" + downloadedBytes + "-");
}
Request request = builder.build();
Call call = client.newCall(request);
downloadCalls.put(downloadId, call);
call.enqueue(new Callback() {
@Override
public void onResponse(Call call, Response response) {
downloadCalls.remove(downloadId);
try {
if (!response.isSuccessful()) {
postToMain(() -> listener.onFail(new IOException("Download failed, HTTP " + response.code())));
return;
}
// 获取文件总大小
long totalBytes;
if (enableResume && downloadedBytes > 0) {
// 断点续传时,从 Content-Range 头解析总大小
String contentRange = response.header("Content-Range");
if (contentRange != null && contentRange.contains("/")) {
String[] parts = contentRange.split("/");
totalBytes = Long.parseLong(parts[1]) + downloadedBytes;
} else {
totalBytes = -1;
}
} else {
totalBytes = response.body() != null ? response.body().contentLength() : -1;
}
// 下载进度回调包装
final long finalDownloadedBytes = downloadedBytes;
final long finalTotalBytes = totalBytes;
// 写入文件
boolean success = writeToFile(response.body().byteStream(), targetFile,
downloadedBytes, totalBytes, enableResume, listener);
if (success) {
postToMain(() -> listener.onSuccess(targetFile));
}
} catch (Exception e) {
Log.e(TAG, "Download error: " + e.getMessage(), e);
postToMain(() -> listener.onFail(e));
} finally {
response.close();
}
}
@Override
public void onFailure(Call call, IOException e) {
downloadCalls.remove(downloadId);
Log.e(TAG, "Download failed: " + e.getMessage(), e);
postToMain(() -> listener.onFail(e));
}
});
}
/**
* 将输入流写入文件,并报告进度
*/
private boolean writeToFile(InputStream inputStream, File targetFile,
long downloadedBytes, long totalBytes,
boolean enableResume, OnDownloadListener listener) {
FileOutputStream fos = null;
try {
// 断点续传时追加模式
fos = new FileOutputStream(targetFile, enableResume && downloadedBytes > 0);
byte[] buffer = new byte[BUFFER_SIZE];
int len;
long currentDownloaded = downloadedBytes;
int lastProgress = -1;
// 通知开始下载
postToMain(() -> listener.onStart(totalBytes, downloadedBytes));
while ((len = inputStream.read(buffer)) != -1) {
fos.write(buffer, 0, len);
currentDownloaded += len;
// 计算进度
int progress = -1;
if (totalBytes > 0) {
progress = (int) ((currentDownloaded * 100.0) / totalBytes);
}
// 只在进度变化时回调
if (progress != lastProgress && progress >= 0) {
lastProgress = progress;
final int finalProgress = progress;
final long finalCurrentDownloaded = currentDownloaded;
postToMain(() -> listener.onProgress(totalBytes, finalCurrentDownloaded, finalProgress));
}
}
fos.flush();
Log.d(TAG, "Download completed: " + targetFile.getAbsolutePath());
return true;
} catch (IOException e) {
Log.e(TAG, "Write file error: " + e.getMessage(), e);
return false;
} finally {
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
Log.w(TAG, "Close file error: " + e.getMessage());
}
}
}
}
/**
* 取消下载
*/
public void cancelDownload(String url, File file) {
String downloadId = generateDownloadId(url, file);
Call call = downloadCalls.get(downloadId);
if (call != null && !call.isCanceled()) {
call.cancel();
downloadCalls.remove(downloadId);
Log.d(TAG, "Download canceled: " + url);
}
}
/**
* 生成下载任务ID
*/
private String generateDownloadId(String url, File file) {
return url + "_" + file.getAbsolutePath();
}
/**
* 发送到主线程执行
*/
private void postToMain(Runnable runnable) {
if (Looper.myLooper() == Looper.getMainLooper()) {
runnable.run();
} else {
mMainHandler.post(runnable);
}
}
/**
* 获取默认下载路径
*/
public static String getDefaultDownloadPath() {
return DEFAULT_DOWNLOAD_PATH;
}
}
DownloadUtil 使用示例
package com.sdt.devicemanager.utils;
import android.content.Context;
import android.util.Log;
import android.widget.Toast;
import java.io.File;
/**
* DownloadUtil 使用示例
*
* 展示如何使用 DownloadUtil 下载论坛附件
* 下载接口:/forumTopic/download?id=10 (GET方法)
*/
public class DownloadUtilExample {
private static final String TAG = "DownloadUtilExample";
/**
* 示例1:基础下载(不启用断点续传)
*
* @param context 上下文
* @param fileId 附件ID
*/
public static void example1_BasicDownload(Context context, int fileId) {
// 1. 构建下载URL
String baseUrl = "https://your-domain.com"; // 替换为实际域名
String downloadUrl = baseUrl + "/forumTopic/download?id=" + fileId;
// 2. 设置下载路径和文件名
String savePath = FilesUtils.getDefaultDownloadPath();
String fileName = "forum_attachment_" + fileId + ".pdf"; // 根据实际文件类型调整
// 3. 执行下载
DownloadUtil.getInstance(context).download(downloadUrl, savePath, fileName,
new DownloadUtil.OnDownloadListener() {
@Override
public void onStart(long totalBytes, long downloadedBytes) {
Log.d(TAG, "下载开始 - 总大小: " + FilesUtils.formatFileSize(totalBytes)
+ ", 已下载: " + FilesUtils.formatFileSize(downloadedBytes));
}
@Override
public void onProgress(long totalBytes, long downloadedBytes, int progress) {
Log.d(TAG, "下载进度: " + progress + "%"
+ " - " + FilesUtils.formatFileSize(downloadedBytes)
+ "/" + FilesUtils.formatFileSize(totalBytes));
}
@Override
public void onSuccess(File file) {
Log.d(TAG, "下载成功: " + file.getAbsolutePath());
Toast.makeText(context, "下载成功: " + file.getName(), Toast.LENGTH_SHORT).show();
// 可以在这里打开文件
// FilesUtils.openFile(context, file);
}
@Override
public void onFail(Exception e) {
Log.e(TAG, "下载失败: " + e.getMessage(), e);
Toast.makeText(context, "下载失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
/**
* 示例2:启用断点续传的下载
*
* @param context 上下文
* @param fileId 附件ID
*/
public static void example2_ResumeDownload(Context context, int fileId) {
String baseUrl = "https://your-domain.com";
String downloadUrl = baseUrl + "/forumTopic/download?id=" + fileId;
String savePath = FilesUtils.getDefaultDownloadPath();
String fileName = "forum_attachment_" + fileId + ".docx";
// 启用断点续传(第四个参数为 true)
DownloadUtil.getInstance(context).download(downloadUrl, savePath, fileName, true,
new DownloadUtil.OnDownloadListener() {
@Override
public void onStart(long totalBytes, long downloadedBytes) {
if (downloadedBytes > 0) {
Log.d(TAG, "断点续传 - 已下载: " + FilesUtils.formatFileSize(downloadedBytes));
} else {
Log.d(TAG, "开始新下载");
}
}
@Override
public void onProgress(long totalBytes, long downloadedBytes, int progress) {
Log.d(TAG, "进度: " + progress + "%");
}
@Override
public void onSuccess(File file) {
Log.d(TAG, "下载完成: " + file.getAbsolutePath());
Toast.makeText(context, "下载完成", Toast.LENGTH_SHORT).show();
}
@Override
public void onFail(Exception e) {
Log.e(TAG, "下载失败", e);
Toast.makeText(context, "下载失败", Toast.LENGTH_SHORT).show();
}
});
}
/**
* 示例3:批量下载多个附件
*
* @param context 上下文
* @param fileIds 附件ID数组
*/
public static void example3_BatchDownload(Context context, int[] fileIds) {
String baseUrl = "https://your-domain.com";
String savePath = FilesUtils.getDefaultDownloadPath();
for (int fileId : fileIds) {
String downloadUrl = baseUrl + "/forumTopic/download?id=" + fileId;
String fileName = "attachment_" + fileId + ".pdf";
DownloadUtil.getInstance(context).download(downloadUrl, savePath, fileName,
new DownloadUtil.OnDownloadListener() {
@Override
public void onStart(long totalBytes, long downloadedBytes) {
Log.d(TAG, "文件 " + fileId + " 开始下载");
}
@Override
public void onProgress(long totalBytes, long downloadedBytes, int progress) {
// 批量下载时可以减少进度日志频率
if (progress % 25 == 0) { // 每25%记录一次
Log.d(TAG, "文件 " + fileId + " 进度: " + progress + "%");
}
}
@Override
public void onSuccess(File file) {
Log.d(TAG, "文件 " + fileId + " 下载成功: " + file.getName());
}
@Override
public void onFail(Exception e) {
Log.e(TAG, "文件 " + fileId + " 下载失败", e);
}
});
}
}
/**
* 示例4:取消下载
*
* @param context 上下文
* @param fileId 附件ID
*/
public static void example4_CancelDownload(Context context, int fileId) {
String baseUrl = "https://your-domain.com";
String downloadUrl = baseUrl + "/forumTopic/download?id=" + fileId;
String savePath = FilesUtils.getDefaultDownloadPath();
String fileName = "attachment_" + fileId + ".pdf";
File targetFile = new File(savePath, fileName);
// 取消下载
DownloadUtil.getInstance(context).cancelDownload(downloadUrl, targetFile);
Log.d(TAG, "已取消下载: " + fileName);
}
/**
* 示例5:在 Activity 中完整使用
*
* 这是在 Activity 中的典型用法:
*
* <pre>
* public class ForumAttachmentActivity extends AppCompatActivity {
*
* private void downloadAttachment(int attachmentId, String fileName) {
* String baseUrl = "https://your-domain.com";
* String downloadUrl = baseUrl + "/forumTopic/download?id=" + attachmentId;
* String savePath = FilesUtils.getDefaultDownloadPath();
*
* DownloadUtil.getInstance(this).download(downloadUrl, savePath, fileName,
* new DownloadUtil.OnDownloadListener() {
* @Override
* public void onStart(long totalBytes, long downloadedBytes) {
* // 显示进度对话框
* showProgressDialog();
* }
*
* @Override
* public void onProgress(long totalBytes, long downloadedBytes, int progress) {
* // 更新进度条
* updateProgress(progress);
* }
*
* @Override
* public void onSuccess(File file) {
* // 隐藏进度对话框
* hideProgressDialog();
* Toast.makeText(ForumAttachmentActivity.this,
* "下载成功: " + file.getName(), Toast.LENGTH_SHORT).show();
*
* // 可选:打开文件
* FilesUtils.openFile(ForumAttachmentActivity.this, file);
* }
*
* @Override
* public void onFail(Exception e) {
* // 隐藏进度对话框
* hideProgressDialog();
* Toast.makeText(ForumAttachmentActivity.this,
* "下载失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
* }
* });
* }
* }
* </pre>
*/
public static void example5_ActivityUsage() {
// 参见方法注释中的完整示例代码
}
}