注意事项:使用需配合UniTask插件
GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.
1️⃣ 需求
从服务器或对象存储桶中下载文件到指定文件夹,示例场景:第一次启动游戏时下载初始数据(音视频、图片等)
2️⃣ 优点
- UniTask异步下载
- 分块写入操作
- 支持下载任何文件(图像、视频、pdf、apk等)
- 支持下载多个文件
- 支持下载跟踪器(下载进度、文件大小、文件数)
- 支持链式编程
- 支持PC、Android下载到Application.persistentDataPath目录下 (其它路径没有试,有测试的小伙伴可以评论一下)
3️⃣ 后期规划
- 支持MD5文件校验
4️⃣ 关键代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
public class Downloader
{
private Action onStart;
private Action onComplete;
private int filesCount;
public Downloader()
{
Init();
}
private void Init()
{
filesCount = 0;
if (!Directory.Exists($"{Application.persistentDataPath}/Resources"))
{
Directory.CreateDirectory($"{Application.persistentDataPath}/Resources");
}
}
/// <summary>
/// 开始下载
/// </summary>
/// <param name="startDownload"></param>
/// <returns></returns>
public Downloader OnStart(Action startDownload)
{
onStart = startDownload;
return this;
}
/// <summary>
/// 下载单个文件
/// </summary>
/// <param name="url"></param>
/// <param name="downloadTracker">下载跟踪器</param>
/// <param name="downLoadError"></param>
public Downloader DownloadFileAsync(string url, Action<DownloadTracker> downloadTracker = null, Action<string> downLoadError = null)
{
DownloadFile(url, PathConfig.ResSavePath(url), downloadTracker,downLoadError).Forget();
return this;
}
/// <summary>
/// 下载多个文件
/// </summary>
/// <param name="urls"></param>
/// <param name="downloadTracker">下载跟踪器</param>
/// <param name="currenDownloadCont">当前下载文件数量,总文件数量</param>
/// <param name="downLoadError">下载错误</param>
public Downloader DownloadMultipleFileAsync(List<string> urls,
Action<int, int> currenDownloadCont = null, Action<DownloadTracker> downloadTracker = null,
Action<string> downLoadError = null)
{
DownloadMultipleFiles(urls, currenDownloadCont,downloadTracker, downLoadError).Forget();
return this;
}
/// <summary>
/// 下载完成回调
/// </summary>
/// <param name="complete"></param>
public void OnComplete(Action complete)
{
this.onComplete = complete;
}
private async UniTask DownloadFile(string url, string savePath,
Action<DownloadTracker> currentTracker , Action<string> downLoadError, bool isMultiple = false)
{
onStart?.Invoke();
try
{
var fileSize = await GetFileSize(url);
if (fileSize > 0)
{
using var request = UnityWebRequest.Get(url);
request.downloadHandler = new DownloadHandlerBuffer();
var asyncOperation = request.SendWebRequest();
while (!asyncOperation.isDone)
{
// 更新下载进度
var currentProgress = request.downloadProgress;
var currentSize = BytesToMB(request.downloadedBytes);
currentTracker?.Invoke(new DownloadTracker(fileSize, currentSize,
currentProgress > 0.99f ? 1f : currentProgress));
await UniTask.Yield();
}
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError("Error downloading: " + request.error);
downLoadError?.Invoke(request.error);
return;
}
await WriteFile(request.downloadHandler.data, savePath, currentTracker);
Debug.Log("File saved to: " + savePath);
if (isMultiple)
{
// 一种线程安全的操作,用于原子性地递增变量filesCount的值
Interlocked.Increment(ref filesCount);
}
else
{
await UniTask.Yield();
onComplete?.Invoke();
}
}
else if (fileSize < 0)
{
downLoadError?.Invoke("获取文件失败");
}
}
catch (Exception e)
{
Debug.LogError("DownloadFile exception: " + e.Message);
downLoadError?.Invoke(e.Message);
}
}
private async UniTask DownloadMultipleFiles(List<string> urls,
Action<int, int> currenDownCont,Action<DownloadTracker> downloadTracker,Action<string> downLoadError)
{
if (urls == null || urls.Count == 0) return;
var resUrls = urls.Distinct().ToList();
onStart?.Invoke();
for (var i = 0; i < resUrls.Count; i++)
{
currenDownCont?.Invoke(i + 1, resUrls.Count);
await DownloadFile(resUrls[i], PathConfig.ResSavePath(resUrls[i]), downloadTracker, downLoadError, true);
}
if (filesCount >= resUrls.Count)
{
onComplete?.Invoke();
}
}
private async UniTask<float> GetFileSize(string url)
{
try
{
// 增加URL合法性检查
if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
{
Debug.LogError("Invalid URL");
return -1;
}
using var headRequest = UnityWebRequest.Head(url);
var asyncOperation = headRequest.SendWebRequest();
// 直接await异步操作,而不是使用UniTask.WaitUntil
// 等待请求完成
await UniTask.WaitUntil(() => asyncOperation.isDone);
if (headRequest.result != UnityWebRequest.Result.Success)
{
// 使用UnityWebRequest.error获取详细错误信息
Debug.LogError($"Request failed: {headRequest.error}");
return -1;
}
if (long.TryParse(headRequest.GetResponseHeader("Content-Length"), out long fileSize))
{
var size = fileSize / (1024f * 1024f);
// 使用格式化字符串
Debug.Log($"File size: {size} MB");
return size;
}
else
{
Debug.LogError("Error parsing file size");
return -1;
}
}
catch (Exception e)
{
// 捕获并处理异步操作中可能抛出的异常
Debug.LogError($"Exception occurred: {e.Message}");
return -1;
}
}
/// <summary>
/// 写入文件
/// </summary>
/// <param name="data"></param>
/// <param name="savePath"></param>
/// <param name="downloadTracker"></param>
private async UniTask WriteFile(byte[] data, string savePath, Action<DownloadTracker> downloadTracker)
{
// 验证路径是否合法
if (!Path.IsPathRooted(savePath) || !Directory.Exists(Path.GetDirectoryName(savePath)))
{
throw new ArgumentException("Invalid save path.", nameof(savePath));
}
var chunkSize = 4096 * 100; // 每次写入的块大小
var totalBytesWritten = 0;
try
{
await using var fileStream =
new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096);
while (totalBytesWritten < data.Length)
{
var remainingBytes = data.Length - totalBytesWritten;
var bytesToWrite = Math.Min(chunkSize, remainingBytes);
await fileStream.WriteAsync(data, totalBytesWritten, bytesToWrite);
totalBytesWritten += bytesToWrite;
// 计算并更新写入进度
var writeProgress = (float)totalBytesWritten / data.Length;
downloadTracker?.Invoke(new DownloadTracker(0f, 0f, writeProgress));
}
}
catch (IOException ex)
{
// 记录或处理文件IO异常
Debug.LogError($"File write error: {ex.Message}");
}
catch (UnauthorizedAccessException ex)
{
// 记录或处理权限异常
Debug.LogError($"Unauthorized access error: {ex.Message}");
}
catch (OperationCanceledException)
{
// 处理取消操作
Debug.LogError("Write operation cancelled.");
}
}
private float BytesToMB(ulong bytes)
{
return (float)bytes / (1024 * 1024);
}
public class DownloadTracker
{
private readonly float m_TotalSize;
private readonly float m_CurrentSize;
private readonly float m_CurrentProgress;
public DownloadTracker(float totalSize, float currentSize, float currentProgress)
{
m_TotalSize = totalSize;
m_CurrentSize = currentSize;
m_CurrentProgress = currentProgress;
}
/// <summary>
/// 获取下载进度
/// </summary>
/// <returns></returns>
public float GetProgress()
{
return m_CurrentProgress;
}
/// <summary>
/// 获取文件总大小
/// </summary>
/// <returns></returns>
public float GetTotalSize()
{
return m_TotalSize;
}
/// <summary>
/// 获取当前下载大小
/// </summary>
/// <returns></returns>
public float GetCurrentSize()
{
return m_CurrentSize;
}
}
public static class PathConfig
{
public static string ResSavePath(string url)
{
return $"{Application.persistentDataPath}/Resources/{ExtractFileNameFromUrl(url)}";
}
/// <summary>
/// 从Url中提取文件名
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
static string ExtractFileNameFromUrl(string url)
{
if (string.IsNullOrEmpty(url))
return string.Empty;
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
// 提取路径部分
string path = uri.AbsolutePath;
// 获取最后一个斜杠后的字符串,即文件名
int lastSlashIndex = path.LastIndexOf('/');
if (lastSlashIndex >= 0 && lastSlashIndex < path.Length - 1)
{
return path.Substring(lastSlashIndex + 1);
}
}
return string.Empty;
}
}
}
5️⃣ 示例代码
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class Test : MonoBehaviour
{
private Button btn;
private Downloader m_Downloader;
public Image img;
public Text text, debugText, progressText;
private void Start()
{
m_Downloader = new Downloader();
btn = GetComponent<Button>();
btn.onClick.AddListener(DownloadFile);
}
private void DownloadFile()
{
m_Downloader.OnStart(() =>
{
Debug.Log("开始下载");
}).DownloadFileAsync("https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png",
(downloadTracker) =>
{
Debug.Log((downloadTracker.GetProgress()));
}).OnComplete(() =>
{
Debug.Log("下载完成");
});
}
public void DownloadMultipleFiles()
{
var urls = new List<string>()
{
"http://example.com/Resource/Video/GameVieo.mp4",
"http://example.com/Resource/Video/video.mp4"
};
m_Downloader.DownloadMultipleFileAsync(urls,
(currentCount, totalCount) =>
{
text.text = $"正在下载资源 [{currentCount}/{totalCount}]";
},
(Tracker) =>
{
img.fillAmount = Tracker.GetProgress();
progressText.text = $"[{Tracker.GetCurrentSize():F2}MB/{Tracker.GetTotalSize():F2}MB]-{Mathf.RoundToInt(Tracker.GetProgress() * 100)}%";
},
(error) => { Debug.LogError($"下载资源失败:{error}"); })
.OnComplete(() =>
{
debugText.text = "所有资源下载完成";
});
}
}
6️⃣ 运行结果
PC
Android
7️⃣ 更新
# 2024.05.09
- 新增错误回调
- 修复下载多个文件时失败也会计数的BUG
# 2024.05.10
- 新增开始下载回调方法
- 新增异常处理
- 新增下载多文件去重
- 重构部分代码