天天看点

使用UnityWebRequest实现断点续传

断点续传原理

记录已经下载到的本地文件大小,向资源服务器发送请求,拿到剩下还有多少没有下载(有请求头可以实现),然后接着没有下载到的地方开始再继续下载。

PS:只要确保是对同一个资源文件的下载操作,那么就不存在资源会下载错误的情况,当然如果你在断点续传的阶段发现资源服务器上的资源已经更新,那就得删除之前下载的文件然后重新下载。

UnityWebRequest下载文件

下载文件都是通过一个URL从资源服务器上GET到资源,这个在UnityWebRequest下反应出来就是创建一个GET类型的UnityWebRequest对象,然后去请求URL,最后在下载结束后去通过该对象下的downloadHandler获取下载到的字节并对其进行处理。

using (UnityWebRequest www = UnityWebRequest.Get(url))
            {
                yield return www.SendWebRequest();
                
                if (www.isNetworkError)
                {
                    Debug.Log("Error: " + www.error);
                    errorResponce?.Invoke(www);
                }
                else
                {
                    Debug.Log("Received!!!");
                    succesResponce?.Invoke(www);
                }
            }
           

基本框架就这个样子,但是这样就有个问题,因为是采用协程去处理,所以这样只能够在下载完成时去处理结果,而想要在下载中途就去处理诸如下载进度之类的,这个操作就肯定不行,所以网上有种解决方案就是将yield return语句改成没帧执行,知道www.isDown为true,这样就可以去是使用www.downloadProgress这个属性来标识下载进度。

但是个人并不建议这种方式,我认为这有悖于UnityWebRequest最初的设计初衷,因为Unity给我们提供了扩展downloadHandler的方法,这可以使我们不必去在使用方法上做改变,而是在参数上做改变,使用范围更广,也更好修改。

PS:后面中www参数都是指通过UnityWebRequest.Get(url)创建的对象,也就是这一次下载资源请求的对象

如何获取剩余还要下载多少

loadHandler.downloadedFileLen这个参数是获取已经下载的对象的大小,具体怎么获得后面会解释,这里这个用法就是去设置请求头,而参数"Range"的具体格式可以百度一下。

自定义DownloadHandler

自定义的DownloadHandler需要继承自DownloadHandlerScript,并重载里面的方法,这里我们需要自定义的是一个下载资源的DownloadHandler

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;

namespace RiseImmortal.Core
{
    public class RIDownloadHandler : DownloadHandlerScript
    {
        string m_SavePath = "";
        string m_TempFilePath = "";
        FileStream fs;

        public long totalFileLen { get; private set; }
        public long downloadedFileLen { get; private set; }
        public string fileName { get; private set; }
        public string dirPath { get; private set; }

        #region 事件
        /// <summary>
        /// 返回这条URL下需要下载的文件的总大小
        /// </summary>
        public event Action<long> eventTotalLength = null;

        /// <summary>
        /// 返回这次请求时需要下载的大小(即剩余文件大小)
        /// </summary>
        public event Action<long> eventContentLength = null;

        /// <summary>
        /// 每次下载到数据后回调进度
        /// </summary>
        public event Action<float> eventProgress = null;

        /// <summary>
        /// 当下载完成后回调下载的文件位置
        /// </summary>
        public event Action<string> eventComplete = null;
        #endregion

        /// <summary>
        /// 初始化下载句柄,定义每次下载的数据上限为200kb
        /// </summary>
        /// <param name="filePath">保存到本地的文件路径</param>
        public RIDownloadHandler(string filePath) : base(new byte[1024 * 200])
        {
            m_SavePath = filePath.Replace('\\', '/');
            fileName = m_SavePath.Substring(m_SavePath.LastIndexOf('/') + 1);
            dirPath = m_SavePath.Substring(0, m_SavePath.LastIndexOf('/'));
            m_TempFilePath = Path.Combine(dirPath, fileName + ".temp");

            fs = new FileStream(m_TempFilePath, FileMode.Append, FileAccess.Write);
            downloadedFileLen = fs.Length;
        }

        /// <summary>
        /// 请求下载时的第一个回调函数,会返回需要接收的文件总长度
        /// </summary>
        /// <param name="contentLength">如果是续传,则是剩下的文件大小;本地拷贝则是文件总长度</param>
        protected override void ReceiveContentLength(int contentLength)
        {
            if (contentLength == 0)
            {
                Debug.Log("【下载已经完成】");
                CompleteContent();
            }
            totalFileLen = contentLength + downloadedFileLen;
            eventTotalLength?.Invoke(totalFileLen);
            eventContentLength?.Invoke(contentLength);
        }

        /// <summary>
        /// 从网络获取数据时候的回调,每帧调用一次
        /// </summary>
        /// <param name="data">接收到的数据字节流,总长度为构造函数定义的200kb,并非所有的数据都是新的</param>
        /// <param name="dataLength">接收到的数据长度,表示data字节流数组中有多少数据是新接收到的,即0-dataLength之间的数据是刚接收到的</param>
        /// <returns>返回true为继续下载,返回false为中断下载</returns>
        protected override bool ReceiveData(byte[] data, int dataLength)
        {
            if(data == null || data.Length == 0)
            {
                Debug.LogFormat("【下载中】<color=yellow>下载文件{0}中,没有获取到数据,下载终止</color>", fileName);
                return false;
            }
            fs?.Write(data, 0, dataLength);
            downloadedFileLen += dataLength;

            var progress = (float)downloadedFileLen / totalFileLen;
            eventProgress?.Invoke(progress);

            return true;
        }

        /// <summary>
        /// 当接受数据完成时的回调
        /// </summary>
        protected override void CompleteContent()
        {
            Debug.LogFormat("【下载完成】<color=green>完成对{0}文件的下载,保存路径为{1}</color>", fileName, m_SavePath);
            fs.Close();
            fs.Dispose();
            if (File.Exists(m_TempFilePath))
            {
                if (File.Exists(m_SavePath))
                    File.Delete(m_SavePath);
                File.Move(m_TempFilePath, m_SavePath);
            }
            else
            {
                Debug.LogFormat("【下载失败】<color=red>下载文件{0}时失败</color>", fileName);
            }
            eventComplete?.Invoke(m_SavePath);
        }

        public void ErrorDispose()
        {
            fs.Close();
            fs.Dispose();
            if (File.Exists(m_TempFilePath))
            {
                File.Delete(m_TempFilePath);
            }
            Dispose();
        }
    }
}

           

解释一些核心:

  1. 原理:在下载完成之前,把所有下载的字节通过FileStream写入到一个后缀为.temp的临时文件里,当下载完成后,把这个临时文件修改为正式文件。(也就是Move函数的操作)
  2. 如何确定下载到的位置和临时文件的位置:关于这一点在创建这个Handler时就需要传入保存的位置,而这个位置即是文件下载后在本地的位置,也可以通过该位置自己去定义.temp文件的位置,当然,如果该路径是无效的,那么在创建该Handler时就会报错,这属于需要修改的bug。
  3. 事件的设置顺序是否有要求:事件的绑定只要是在开始请求之前都可以。
  4. ErrorDispose的作用:这个是自定义的函数,因为我们的Handler使用了文件流,所以在Handler出错中断时需要及时关闭,否则你双击文件会显示一直被占用,而同时Handler报错一般是由于文件下载出错,所以我们应该删除这个temp文件重新下载,特别是文件特别小的情况下,你出错之后temp文件大小可能显示3kb(远大于要下载的文件大小)这时候说明文件是错误的,你不删除就是个占有bug。
  5. ReceiveContentLength函数的意义:这个函数相当于网上一些请求说先去请求一次获得要下载的资源的总大小,因为我们是断点续传,所以这里的大小只的是我们这次下载需要下载的总大小,作用就是前做一层保险,也可以通过该函数获取下载大小来做显示,这个函数一定会在www.sendWebRequest后首先被执行,也就是说不需要担心它先调用ReceiveData再调用这个函数的情况。

怎么使用这个自定义的DownloadHandler

我因为是项目需要,所以做了几层包装,所以这里就只把一些重要步骤列出来:

  1. 创建对象:
var loadHandler = new RIDownloadHandler(request.savePath);
loadHandler.eventProgress += LoadProcessEvent;
loadHandler.eventComplete += LoadCompleteEvent;
           

创建一个我们自定义的RIDownloadHandler,给这个Handler绑定事件,然后这个Handler我们就准备完毕了(这里我只绑了两个事件,各位可以自己绑其他的事件)

  1. 创建www请求并设置downloadHandler:
using (var www = UnityWebRequest.Get(request.url))
                {
                    www.chunkedTransfer = true;
                    www.disposeDownloadHandlerOnDispose = true;
                    www.SetRequestHeader("Range", "bytes=" + loadHandler.downloadedFileLen + "-");
                    www.downloadHandler = loadHandler;
                    yield return www.SendWebRequest();
                    if (www.isNetworkError || www.isHttpError)
                    {
                        Debug.LogFormat("【下载失败】下载文件{0}失败,失败原因:{1}", loadHandler.fileName, www.error);
                        ErrorHandler(www);
                        loadHandler.ErrorDispose();
                    }
                }
           

这里的逻辑非常好理解,主要就是设置downloadHandler,以及在出错时去处理下ErrorDispose。

PS:关于多请求的封装,博主这里的做法是通过一个列表,先把要请求的连接存起来,再一次性Request,循环遍历实现列表中的所有请求,把所有请求的结果再保存起来,根据URL去拿到对应的结果就可以实现多请求,具体体现就是

httpSystem.Add(Request);

httpSystem.GET(result=>{ var data = result.GetData(url); });

继续阅读