基于 HttpWebRequestUnityWebRequest 的多线程、断点续传文件下载器

设计

IDonwloadAgent为下载器接口,目前有两种实现,HttpDownload和UnityWebRequestDownload,可以无缝切换。IDonwloadAgent继承IEnumerator接口,可以无缝接入unity协程、UniTask(Async),实现非回调式异步等待,逻辑更清晰。IDonwloadAgent提供各种操作接口供Downloader调用,例如开始、暂停、重试、取消等等。

Downloader为工厂模式实现,管理所有下载器实例,避免重复下载,等等。通过DownloadAsync(DownloadInfo),即可异步下载文件。覆写s_OnDownloadAgentFactory委托,可以使用自定义IDownloadAgent实例。

下载器实现

HttpDownload

  1. 读取已下载文件流的Position,加上Http Range头,即可实现断点续传。
  2. 通过DownloadInfo中的Offset参数,加上Http Range头,即可切片下载。
  3. 每个HttpDownload都会使用c#线程池进行下载任务。(需注意unity切后台,线程终止问题,需要重新开启下载任务,否则下载器会假死,下载任务无法完成)
  4. 修改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

  1. 大体上同HttpDownload,但没有线程操作,unity托管。
  2. 使用DownloadHandlerScript实现下载回调。
  3. 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
}

Github

Moon GameFramework

参考

XAsset