今天遇到了个问题,做一个异步下载器,支持断点续传功能。其中难点在于断点续传。本来想着直接使用BestHttp的,不知道是不是用的版本不对,并不直接做好断点续传,需要手动实现。
本篇文章将逐步更新完成,因为要将本功能接入到框架中,需要一定时间优化,还有要把之前的隐藏bug修复一下,敬请期待!!
下面代码将结合AI完成,AI真的加速了代码开发效率。

原理

下载对于各位开发者来说并不难;但是断点续传,询问AI得知,需要结合着http的请求透实现,下面是AI说的,也带着流程图

graph TD A[客户端发起下载] --> B{是否已下载过?} B -->|是| C[读取断点文件,获取offset] B -->|否| D[offset=0] C --> E[构造请求: Range: bytes=offset-] D --> E E --> F[服务器响应206,返回Content-Range] F --> G[客户端接收数据,写入文件offset位置] G --> H[更新断点文件offset] H --> I{是否传输完成?} I -->|否| E I -->|是| J[验证哈希,完成下载]

其实整个核心就是Range请求,Range是 HTTP 协议中用于请求资源部分内容的头部字段,其核心作用是让客户端告诉服务器 “需要获取文件的哪部分数据”,从而实现断点续传。基于本原理,完全可以实现分块的多线程下载,不过将会更加复杂。
YooAsset资源管理框架,其实现了一个跟好的下载器,并且支持异步下载以及断点续传,并且结合其内部的异步框架,实现更加优秀的逻辑。
因为之前已经各种学习(东拼西凑)了一个框架后续将直接在这个框架上接入。本篇文章仅仅是借助AI进行一个基本实现。

编写代码

首先,写一个任务类,任务类主要记录着当前已经下载完成的数据,和总的数据大小(创建时记录)。其他需要记录的可以自己添加。

using System;
using UnityEngine.Networking;

namespace 断点续传.Script
{
    public class DownloadTask
    {
        
        public string url;
        public string savePath;
        /// <summary>
        /// Unity的下载器
        /// </summary>
        public UnityWebRequest request;
        /// <summary>
        /// 文件总大小
        /// </summary>
        public long totalBytes;
        /// <summary>
        /// 已下载大小
        /// </summary>
        public long downloadedBytes;
        /// <summary>
        /// 已下载长度
        /// </summary>
        public long downloadProgress;
        public bool isPaused;
        public bool isDone;
        public bool isError;
        /// <summary>
        /// 错误日志
        /// </summary>
        public string errorMessage;
        /// <summary>
        /// 下载进度率刷新
        /// </summary>
        public Action<DownloadTask> onProgress;
        /// <summary>
        /// 完成下载
        /// </summary>
        public Action<DownloadTask> onCompleted;
        /// <summary>
        /// 下载文件处理器
        /// </summary>
        public DownloadHandlerFile downloadHandlerFile;
    }
}

关键部分来了,为了便于管理,将创建一个单例管理器,存储所有下载任务,并且返回一个上面的任务类,便于使用者控制下载状态。
不过,在此之前,先基于DownloadHandlerScript基类,实现一个下载写入器,在这里实现一个对已下载数据的处理逻辑,避免一直在内存中的占用。下面的代码就是自定义的下载处理函数,本次将边下边写入,避免内存爆炸。这里的代码要注意,写一个释放文件流的函数,避免在非下载时的文件占用警告。

using System.IO;
using UnityEngine.Networking;

namespace 断点续传.Script
{
    public class DownloadHandlerFile : DownloadHandlerScript
    {
        /// <summary>
        /// 存储的文件
        /// </summary>
        private string filePath;
        /// <summary>
        /// 文件流
        /// </summary>
        private FileStream fileStream;
        /// <summary>
        /// 存储文件是否基于追加形式
        /// </summary>
        private bool append;
        
        DownloadTask downloadTask;

        public DownloadHandlerFile(string path, bool append,DownloadTask task) : base()
        {
            this.filePath = path;
            this.append = append;
            downloadTask=task;
        
            // 确保目录存在
            string directory = Path.GetDirectoryName(path);
            if (!Directory.Exists(directory))
            {
                Directory.CreateDirectory(directory);
            }

            // 打开文件流
            fileStream = new FileStream(
                path, 
                append ? FileMode.Append : FileMode.Create, 
                FileAccess.Write, 
                FileShare.Read
            );
        }

        public void Pause()
        {
            fileStream.Close();
        }

        /// <summary>
        /// 接收下载到的数据
        /// </summary>
        protected override bool ReceiveData(byte[] data, int dataLength)
        {
            if (data == null || data.Length == 0)
                return false;//终止下载器

            fileStream.Write(data, 0, dataLength);
            downloadTask.downloadedBytes += dataLength;
            return true;//继续下载
        }

        /// <summary>
        /// 当下载完成时被调用,可以在这里进行清理或最终处理。
        /// </summary>
        protected override void CompleteContent()
        {
            fileStream.Close();
            fileStream.Dispose();
        }
    }
}

最后的就是整个管理代码。主要的是记录所有下载任务,便于下载管理。而且根据取消时的需求,决定是否删除本地文件。获取长度时,经过测试,使用UnityWebRequest的UnityWebRequest headReq = UnityWebRequest.Head(url)请求时,Content-Length返回的值和实际不符,不知道是啥问题,最终通过在下载过程中来使用request.GetResponseHeader("Content-Length")函数获取目标文件大小,同时要注意,返回的是一个string值,要判断是否为空白,然后尝试转为长度值。

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;
using DownloadHandlerFile = 断点续传.Script.DownloadHandlerFile;
using File = UnityEngine.Windows.File;

namespace 断点续传.Script
{

    public class AsyncDownloader : MonoBehaviour
    {
       
        private static AsyncDownloader _instance;
        public static AsyncDownloader Instance
        {
            get
            {
                if (_instance == null)
                {
                    GameObject obj = new GameObject("AsyncDownloader");
                    _instance = obj.AddComponent<AsyncDownloader>();
                    DontDestroyOnLoad(obj);
                }
                return _instance;
            }
        }

        private List<DownloadTask> activeTasks = new List<DownloadTask>();

        /// <summary>
        /// 创建下载任务,并且立即启动下载
        /// </summary>
        /// <param name="url">下载地址</param>
        /// <param name="savePath">保存路径</param>
        /// <param name="onProgress">进度回调</param>
        /// <param name="onCompleted">完成回调</param>
        /// <returns></returns>
        public DownloadTask DownloadFile(string url, string savePath, 
            Action<DownloadTask> onProgress = null, 
            Action<DownloadTask> onCompleted = null)
        {
            // 检查是否已有相同任务
            var existingTask = activeTasks.Find(t => t.url == url && t.savePath == savePath);
            if (existingTask != null)
            {
                if (existingTask.isPaused)
                {
                    ResumeDownload(existingTask);
                }
                return existingTask;
            }

            var task = new DownloadTask()
            {
                url = url,
                savePath = savePath,
                onProgress = onProgress,
                onCompleted = onCompleted,
                isPaused = false,
                isDone = false
            };

            activeTasks.Add(task);
            StartCoroutine(DownloadCoroutine(task));
            return task;
        }

        /// <summary>
        /// 异步执行的下载逻辑函数
        /// </summary>
        /// <param name="task">任务</param>
        /// <returns></returns>
        private IEnumerator DownloadCoroutine(DownloadTask task)
        {
            // 检查已下载文件大小(断点续传),基于已下载文件大小信息
            FileInfo fileInfo = new FileInfo(task.savePath);
            //判断是否需要徐
            bool resumeDownload = fileInfo.Exists && fileInfo.Length > 0;
        
            // 创建下载请求
            if (resumeDownload)
            {
                //如果断点续传,则需要设置Rangeq请求头,设置请求的数据范围
                task.request = UnityWebRequest.Get(task.url);
                task.request.SetRequestHeader("Range", $"bytes={fileInfo.Length}-");
            }
            else
            {
                //直接下载
                task.request = UnityWebRequest.Get(task.url);
            }

            var downlaodFileHandle=new DownloadHandlerFile(task.savePath, resumeDownload,task);
            // 设置下载处理器
            task.request.downloadHandler = downlaodFileHandle;
            task.downloadHandlerFile=downlaodFileHandle;
            // 开始下载
            task.request.SendWebRequest();
            
            

            // 更新进度
            // 在本循环内,每一帧检查是否请求暂停
            // 其实暂停就行进行了取消
            while (!task.request.isDone)
            {
                //如果总大小没有记录,则获取
                if (task.totalBytes<=0)
                {
                    //这两种状态时,获取长度信息头
                    if (task.request.result == UnityWebRequest.Result.InProgress||task.request.result==UnityWebRequest.Result.Success )
                    {
                        //获取,保证不为空和能正常把string转为long值
                        string lengthHeader = task.request.GetResponseHeader("Content-Length");
                        if (!string.IsNullOrEmpty(lengthHeader) && long.TryParse(lengthHeader, out long size))
                        {
                            //存储
                            task.totalBytes = size;
                        }
                    }
                }
                //Debug.Log($"长度:{task.request.GetResponseHeader("Content-Length")}+{task.request.result}+{task.totalBytes}");
                if (task.isPaused)
                {
                    task.request.Abort();
                    task.downloadHandlerFile.Pause();
                    yield break;
                }

                if (task.onProgress != null)
                {
                    task.onProgress.Invoke(task);
                }
                yield return null;
            }

            // 处理完成状态
            if (task.request.result == UnityWebRequest.Result.Success)
            {
                task.isDone = true;
            }
            else
            {
                task.isError = true;
                task.errorMessage = task.request.error;
            }

            // 触发完成回调
            if (task.onCompleted != null)
            {
                task.onCompleted.Invoke(task);
            }

            // 清理
            activeTasks.Remove(task);
            task.request.Dispose();
        }

        /// <summary>
        /// 暂停下载
        /// </summary>
        /// <param name="task">下载任务</param>
        public void PauseDownload(DownloadTask task)
        {
            if (task != null && !task.isPaused && !task.isDone)
            {
                task.isPaused = true;
                if (task.request != null)
                {
                    task.request.Abort();
                }
            }
        }

        /// <summary>
        /// 重启下载
        /// </summary>
        /// <param name="task"></param>
        public void ResumeDownload(DownloadTask task)
        {
            if (task != null && task.isPaused && !task.isDone)
            {
                task.isPaused = false;
                StartCoroutine(DownloadCoroutine(task));
            }
        }

        /// <summary>
        /// 取消下砸
        /// </summary>
        /// <param name="task"></param>
        public void CancelDownload(DownloadTask task)
        {
            if (task != null && !task.isDone)
            {
                task.isPaused = true;
                if (task.request != null)
                {
                    task.request.Abort();
                }
                activeTasks.Remove(task);
                if (File.Exists(task.savePath))
                {
                    File.Delete(task.savePath);
                }
            }
        }

        /// <summary>
        /// 根据任务获取下载进度
        /// </summary>
        /// <param name="task"></param>
        /// <returns></returns>
        public static float GetProgress(DownloadTask task)
        {
            if (task == null || task.totalBytes <= 0) return 0;
            return (float)task.downloadedBytes / task.totalBytes;
        }
    }
}

上面这三个脚本代码就算是核心内容了,核心要素就是设置请求头Range,值为一个范围bytes= start-end,使用本请头,只要服务器不做限制,可以实现分块下载。

仓库和下载地址

本篇代码已开源到我的一个测试仓库中的UnityAsyncDownload分支下。
同时也打包成了个工具包:下载地址

在把他加到自己的框架里的时候,进行了修改,写了一个支持Unitask异步(用的自己实现的一个异步基类,详细请看我的另一篇文章[# Unity中基于UniTask的异步实现]的版本