基于 HttpWebRequest
和 UnityWebRequest
的多线程、断点续传文件下载器
设计
IDonwloadAgent
为下载器接口,目前有两种实现,HttpDownload和UnityWebRequestDownload,可以无缝切换。IDonwloadAgent继承IEnumerator接口,可以无缝接入unity协程、UniTask(Async),实现非回调式异步等待,逻辑更清晰。IDonwloadAgent提供各种操作接口供Downloader调用,例如开始、暂停、重试、取消等等。
Downloader
为工厂模式实现,管理所有下载器实例,避免重复下载,等等。通过DownloadAsync(DownloadInfo),即可异步下载文件。覆写s_OnDownloadAgentFactory委托,可以使用自定义IDownloadAgent实例。
下载器实现
HttpDownload
- 读取已下载文件流的Position,加上Http Range头,即可实现断点续传。
- 通过DownloadInfo中的Offset参数,加上Http Range头,即可切片下载。
- 每个HttpDownload都会使用c#线程池进行下载任务。(需注意unity切后台,线程终止问题,需要重新开启下载任务,否则下载器会假死,下载任务无法完成)
- 修改buffer大小,貌似可以达成限速效果。
HttpDownload 完整实现
public sealed class HttpDownload : IDownloadAgent
{
// 通过buffer size 貌似可以限速?
public static long s_ReadBufferSize = 1024 * 64;
#region Instance
private readonly byte[] m_ReadBuffer = new byte[s_ReadBufferSize];
private FileStream m_Writer;
public HttpDownload()
{
Status = EDownloadStatus.Wait;
Position = 0;
}
public DownloadInfo Info { get; set; }
public EDownloadStatus Status { get; private set; }
public string Error { get; private set; }
public event Action<IDownloadAgent> Completed;
public bool IsDone => Status == EDownloadStatus.Failed || Status == EDownloadStatus.Success;
public float Progress => Position * 1f / Info.Size;
public long Position { get; private set; }
private void Retry()
{
Status = EDownloadStatus.Wait;
Start();
}
public void Resume()
{
Retry();
}
public void Pause()
{
Status = EDownloadStatus.Wait;
}
public void Cancel()
{
Error = "User Cancel.";
Status = EDownloadStatus.Failed;
}
public void Complete()
{
if (Completed != null)
{
Completed(this);
Completed = null;
}
}
public void Update() { }
private void Run()
{
try
{
Downloading();
CloseWrite();
if (Status == EDownloadStatus.Wait) return;
if (Status == EDownloadStatus.Failed) return;
if (Position != Info.Size)
{
Error = $"Download length {Position} mismatch to {Info.Size}";
Status = EDownloadStatus.Failed;
return;
}
Status = EDownloadStatus.Success;
}
catch (Exception e)
{
Error = e.Message;
Status = EDownloadStatus.Failed;
}
finally
{
CloseWrite();
}
}
private void CloseWrite()
{
if (m_Writer != null)
{
m_Writer.Flush();
m_Writer.Close();
m_Writer = null;
}
}
private static bool CheckValidationResult(object sender, X509Certificate certificate, X509Chain chain,
SslPolicyErrors spe)
{
return true;
}
private void Downloading()
{
// TODO
// 下载中,长时间没速度,也应该进行处理
var request = CreateWebRequest();
using (var response = request.GetResponse())
{
if (response.ContentLength > 0)
{
if (Info.Size == 0) Info.Size = response.ContentLength + Position;
using (var reader = response.GetResponseStream())
{
if (Position < Info.Size)
{
while (Status == EDownloadStatus.Progressing)
{
if (ReadToEnd(reader))
{
break;
}
}
}
}
}
else
{
Status = EDownloadStatus.Success;
}
}
}
private WebRequest CreateWebRequest()
{
WebRequest request;
if (Info.DownloadUrl.StartsWith("https", StringComparison.OrdinalIgnoreCase))
{
ServicePointManager.ServerCertificateValidationCallback = CheckValidationResult;
request = GetHttpWebRequest();
}
else
{
request = GetHttpWebRequest();
}
return request;
}
private WebRequest GetHttpWebRequest()
{
var httpWebRequest = (HttpWebRequest)WebRequest.Create(Info.DownloadUrl);
httpWebRequest.ProtocolVersion = HttpVersion.Version11;
var from = Info.Offset + Position;
var to = Info.Offset + Info.Size;
httpWebRequest.AddRange(from, to);
// TODO 加上超时
//httpWebRequest.Timeout = 1000000; // 默认是1000s
return httpWebRequest;
}
private bool ReadToEnd(Stream reader)
{
var len = reader.Read(m_ReadBuffer, 0, m_ReadBuffer.Length);
if (len > 0)
{
m_Writer.Write(m_ReadBuffer, 0, len);
Position += len;
return false;
}
return true;
}
public void Start()
{
if (Status != EDownloadStatus.Wait) return;
Status = EDownloadStatus.Progressing;
var fileInfo = new FileInfo(Info.SavePath);
if (fileInfo.Exists && fileInfo.Length > 0)
{
if (Info.Size > 0 && fileInfo.Length == Info.Size)
{
Status = EDownloadStatus.Success;
Position = Info.Size;
return;
}
// TODO
// issue:
// IOException: Sharing violation on path
// 文件读取了多次
if (m_Writer == null)
m_Writer = fileInfo.OpenWrite();
Position = m_Writer.Length - 1;
if (!Info.IsValid(Position))
{
Error = $"Invalid Range [{Info.Offset + Position }-{ Info.Offset + Position + Info.Size}]";
Status = EDownloadStatus.Failed;
return;
}
if (Position > 0) m_Writer.Seek(-1, SeekOrigin.End);
}
else
{
if (!Info.IsValid(Position))
{
Error = $"Invalid Range [{Info.Offset + Position }-{ Info.Offset + Position + Info.Size}]";
Status = EDownloadStatus.Failed;
return;
}
var dir = Path.GetDirectoryName(Info.SavePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
m_Writer = File.Create(Info.SavePath);
Position = 0;
}
Task.Run(Run);
}
#endregion
#region IEnumerator Impl
bool IEnumerator.MoveNext() => !IsDone;
void IEnumerator.Reset()
{ }
object IEnumerator.Current => null;
#endregion
}
UnityWebRequestDownload
- 大体上同HttpDownload,但没有线程操作,unity托管。
- 使用DownloadHandlerScript实现下载回调。
- DownloadHandlerScript传入buffer,可以减少内存分配。
UnityWebRequestDownload 完整实现
public sealed class UnityWebRequestDownload : IDownloadAgent
{
public sealed class MyDownloadScript : DownloadHandlerScript
{
private UnityWebRequestDownload m_Download;
public MyDownloadScript(UnityWebRequestDownload download, byte[] bytes) : base(bytes)
{
m_Download = download;
}
protected override float GetProgress()
{
return m_Download.Progress;
}
protected override bool ReceiveData(byte[] buffer, int dataLength)
{
return m_Download.ReceiveData(buffer, dataLength);
}
protected override void CompleteContent()
{
m_Download.CompleteContent();
}
}
public long Position { get; private set; }
public DownloadInfo Info { get; set; }
public bool IsDone => Status == EDownloadStatus.Failed || Status == EDownloadStatus.Success;
public EDownloadStatus Status { get; private set; }
public event Action<IDownloadAgent> Completed;
public float Progress => Position * 1f / Info.Size;
private UnityWebRequest m_Request;
private FileStream m_Writer;
public static long s_ReadBufferSize = 1024 * 4;
private readonly byte[] m_ReadBuffer = new byte[s_ReadBufferSize];
public string Error { get; internal set; }
public override string ToString()
{
return Info.ToString();
}
public void Start()
{
if (Status != EDownloadStatus.Wait) return;
Status = EDownloadStatus.Progressing;
Error = null;
var fileInfo = new FileInfo(Info.SavePath);
if (fileInfo.Exists && fileInfo.Length > 0)
{
if (Info.Size > 0 && fileInfo.Length == Info.Size)
{
Status = EDownloadStatus.Success;
Position = Info.Size;
return;
}
if (m_Writer == null)
m_Writer = fileInfo.OpenWrite();
Position = m_Writer.Length - 1;
if (!Info.IsValid(Position))
{
Error = $"Invalid Range [{Info.Offset + Position }-{ Info.Offset + Position + Info.Size}]";
Status = EDownloadStatus.Failed;
return;
}
if (Position > 0) m_Writer.Seek(-1, SeekOrigin.End);
}
else
{
if (!Info.IsValid(Position))
{
Error = $"Invalid Range [{Info.Offset + Position }-{ Info.Offset + Position + Info.Size}]";
Status = EDownloadStatus.Failed;
return;
}
Position = 0;
if (!Info.IsValid(Position))
{
Error = $"Invalid Range [{Info.Offset + Position }-{ Info.Offset + Position + Info.Size}]";
Status = EDownloadStatus.Failed;
return;
}
var dir = Path.GetDirectoryName(Info.SavePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
m_Writer = File.Create(Info.SavePath);
}
m_Request = CreateHttpWebRequest();
}
public void Update()
{
if (Status == EDownloadStatus.Progressing)
{
if (m_Request.isDone && m_Request.downloadedBytes < (ulong)Info.Size)
{
Error = "unknown error: downloadedBytes < len";
}
if (!string.IsNullOrEmpty(m_Request.error))
{
Error = m_Request.error;
}
}
}
public void Pause()
{
Status = EDownloadStatus.Wait;
}
public void Resume()
{
Retry();
}
public void Cancel()
{
Error = "User Cancel.";
Status = EDownloadStatus.Failed;
CloseWrite();
}
public void Complete()
{
if (Completed != null)
{
Completed(this);
Completed = null;
}
CloseWrite();
}
private UnityWebRequest CreateHttpWebRequest()
{
var request = UnityWebRequest.Get(Info.DownloadUrl);
request.downloadHandler = new MyDownloadScript(this, m_ReadBuffer);
var from = Info.Offset + Position;
var to = Info.Offset + Info.Size;
request.SetRequestHeader("Range", "bytes=" + (from) + "-" + (to));
request.SendWebRequest();
// TODO 加上超时
//m_Request.Timeout = 1000000; // 默认是1000s
return request;
}
private void CloseWrite()
{
if (m_Writer != null)
{
m_Writer.Close();
m_Writer.Dispose();
m_Writer = null;
}
if (m_Request != null)
{
m_Request.Abort();
m_Request.Dispose();
m_Request = null;
}
}
private void Retry()
{
CloseWrite();
Status = EDownloadStatus.Wait;
Start();
}
#region Handler
internal bool ReceiveData(byte[] buffer, int dataLength)
{
if (!string.IsNullOrEmpty(m_Request.error))
{
Error = m_Request.error;
return false;
}
m_Writer.Write(buffer, 0, dataLength);
Position += dataLength;
return Status == EDownloadStatus.Progressing;
}
internal void CompleteContent()
{
Status = EDownloadStatus.Success;
CloseWrite();
}
#endregion
#region IEnumerator Impl
bool IEnumerator.MoveNext() => !IsDone;
void IEnumerator.Reset()
{ }
object IEnumerator.Current => null;
#endregion
}