相关文章推荐
无邪的夕阳  ·  IsBadReadPtr 函数 ...·  2 周前    · 
严肃的西装  ·  【前端调试】- ...·  1 周前    · 
微醺的大象  ·  java.lang.NoSuchMethod ...·  6 月前    · 
腼腆的薯片  ·  Flutter ...·  1 年前    · 
含蓄的人字拖  ·  排列组合_百度百科·  1 年前    · 
温文尔雅的大象  ·  python mysql convert ...·  1 年前    · 

在手机软件开发,尤其在游戏开发过程中,经常需要使用手机下载资源。手机虽然也是小型的计算机,但它的处理能力和台式机的标准处理能力相比,还是有不小的差距。大家在项目中可能遇到各式各样的问题:

“我的网速不稳定,我正在下载一个比较大的文件,已经快下载完了,突然断网了。再次启动,又要重新下载。”

“项目的小文件好几千个,下载时间很长。”

“下载一个大文件时,会分配一个与文件一样的内存,有没有办法分配很少的内存,或者下载过程中自己来决定内存的分配使用量呢?”

“项目资源体积很大,导致CPU占用大、堆内存分配频繁、量大,用户无法自己控制,能否提供‘边玩边下载’的功能呢?这样在下载的同时可以做其它事。”

相关的问题还有很多,有的影响了效率,有的消耗了资源,还有的带来了大量的重复劳动。有没有办法优化呢?无论是使用UnityWebRequest,还是HttpWebReuqest,都会有些困惑。要么只能主线程执行,要么无法控制下载速度,内存开销过大。至于断点续传功能,更是不知从何下手。

其实,不单是手游,早在端游开发的时候,我就遇到类似的问题,还写过一个组件。后来移植到手游开发过程中,非常好用,现在分享给大家。

这篇文章从简单的HTTP协议讲起,介绍怎么用Socket直接封装一个下载组件,然后讲解怎么有效地使用资源,加快或限制下载速度,同时控制下载过程中总的内存开销,以及断点续传、IPV6的支持,最后再讲讲项目中的热更新机制以及下载速度的评估。以下这张脑图,展示了文章的结构。

通过学习,可以快速掌握解决以上项目痛点的方法,方便快速应用到项目中。

这套组件是使用HTTP1.1的协议来封装的。

提到HTTP协议,大部分读者是比较熟悉的,但为了方便大家理解这套组件的实现原理,我还是简单地介绍下吧。主要是针对功能模块,其中不包括协议内容,感兴趣的同学可以通过百度查询。HTTP协议是一个Internet上传送超文本的传送协议,本质是TCP/IP之上的应用协议,它是基于文本明码的应答协议。向服务器请求一个资源(文件)的过程,如下图所示。

HTTP请求的过程,简言之,用Socket连接服务器之后,第一步是格式化请求的消息包(也就是Get消息包),发送;第二步是接受返回码;第三步是分析返回码,如果是正确的情况(即返回码是200或2xx),后面就直接接收请求文件的内容。

可以说,HTTP请求资源的关键是格式化Get消息包、接受应答包和返回码的解析。我附上了源码,并做了注释。

(1)如何格式化Get消息包:

    // 功能:格式化HTTP消息包
    // 参数:szPathFileURL - 需要下载的文件的相对URL
    //       szServerIP - 服务器的IP
    //       nFileOffset - 文件偏移
    //       nDownSize - 下载字节数
    //       bDownAll - 是否下载全部(不是定点下载)
    //       bKeekAlive - 下载完成后是否保留链接
    // 返回:返回
    // 说明:
    string FormatRequestHeader(string szPathFileURL, string szServerIP, int nFileOffset, int nDownSize, bool bDownAll, bool bKeekAlive)
        StringBuilder szBuilder = new StringBuilder(1024);
        ///第1行:方法,请求的资源路径,版本
        szBuilder.AppendFormat( "GET {0} HTTP/1.1\r\n", szPathFileURL);
        ///第2行:主机
        szBuilder.AppendFormat("Host:{0}\r\n", szServerIP);
        ///第3行:接收的语言(忽略)
        ///第4行:接收的数据类型
        szBuilder.Append("Accept:*/*\r\n");
        ///第5行:浏览器类型
        szBuilder.Append("User-Agent:Mozilla/4.0 (compatible; MSIE 6.00; Android)\r\n");
        ///第6行:连接设置,保持或断开
        if (bKeekAlive)
            szBuilder.Append("Connection:Keep-Alive\r\n");
            szBuilder.Append("Connection:close\r\n"); // close 表示暂时的
        ///第7行:Cookie 这里不需要,就不填了
        ///第8行:请求的数据起始字节位置(断点续传的关键)
        if (!bDownAll)
            szBuilder.AppendFormat("Range: bytes={0}-{1}\r\n", nFileOffset, nFileOffset + nDownSize - 1);
        ///最后一行:空行(必须)
        szBuilder.Append("\r\n");
        ///返回结果
        return szBuilder.ToString();

(2)如何接收应答包:
如同Get消息包一样,应答包也是双换行符结束,所以需要逐个字符读取并解析。

  //////////////////////////////////////////////////////
   // 功能:接收回应包
   // 参数:szAnswer - 回应包
   //       dwErro - 错误号
   // 返回:
   // 说明:
   public bool ReceiveAnswer(ref string szAnswer, ref int dwErro)
       // 接收回应包
       bool bRet = false;
       byte []chBuf = new byte[1];
       char[] szTempReceive = new char[1025];
       int nTotalRecLen = 0;
       int nRecLen = 0, nIndex = 0;
       while (nTotalRecLen < 1024)
           nRecLen = ReceiveChar(chBuf);//这里从 Socket 中读取一个字符,但使用预读取的技术,优化读取性能;
           if (nRecLen == -1)
               dwErro = -5;
               break;
           if (nRecLen == 0) // 接收失败
               dwErro = -4;
               break;
           szTempReceive[nTotalRecLen++] = (char)chBuf[0];
           if (nTotalRecLen >= 4)
               // 如果出现一个空行,就结束接收
               nIndex = nTotalRecLen - 4;
               if (szTempReceive[nIndex] == '\r' && szTempReceive[nIndex + 1] == '\n'
                   && szTempReceive[nIndex + 2] == '\r' && szTempReceive[nIndex + 3] == '\n')
                   bRet = true;
                   break;
       szTempReceive[nTotalRecLen] = '\0';
       szAnswer = new string(szTempReceive, 0, nTotalRecLen);
       return bRet;

(3)如何分析返回码:
返回码的格式:HTTP/1.1 xxx …
因为这里发送时使用HTTP1.1,所以返回码的头部也是HTTP/1.1,跳过这8个字符,后面就是返回码,3位数字。

要加快下载,首先需要将下载与本地保存分开,下载使用一个或多个线程,本地保存使用一个线程。之所以保存文件单独使用一个线程,而不是在下载线程完成后保存文件,一方面是为了避免保存时的网络空闲,另一方面也是为了减少文件写入错误,避免文件写入加锁。

对于一个文件下载队列,如果只使用单线程下载,策略就很简单,只需要逐个下载就行了。如果想使用多个线程同时下载,就得考虑下载任务的分配机制了。这里介绍两种:

方式一:预先分配制
就是将下载的文件均匀地分配给不同的线程,每一个线程拿到一个下载队列,逐个下载。源码如下:

Int  nIndex = 0;
Int  nThreadMax = 5;
List<string> []downs= new List<string>()[ nThreadMax];
foreach(string url in downList)
Donws[nIndex++].Add(url);
nIndex %= nThreadMax;

下载线程的源码:

void  DownThread(List<string> downs)
foreach(string url in downs)
         // 开始下载
         DownFile(url);

方式二:抢占式分配
每个下载线程并没有预先分配的队列,而是在当前的文件或文件片段下载完成后,向管理器请求新的下载任务。源码如下:

    void DownThread()
          DownResInfo resInfo = null;
          CHttp http = new CHttp();
          while (!m_bNeedStop)
              if (PopDownFileInfo(out resInfo))
                  DownFile(http, resInfo.url, resInfo.nFileSize, resInfo.nDownSize);
                  break;
          http.Close();
          // 线程退出,线程数减1。
          System.Threading.Interlocked.Decrement(ref m_nDownThreadNumb);

显然,使用方式二会更好些。

这里以文件为单位,并没有将大文件再划分成多个片段,再将一个文件的不同片段分到多个线程下载。而是在同一个下载线程,从开头到结尾,按顺序下载。这样逻辑简单,也方便保存进度,只需要保存当前下载量(下载量也就是下次分片下载的起始位置)。

最坏的情况是,最后只剩一个大文件,仅剩一个线程下载,但我认为这个影响不大,可以接受。当然也可以将大文件划分成多个片段,分别在不同的线程下载。这样虽然也是可以的,但增加了复杂度,保存进度时也需要记录更多的信息,因为下载的进度不一定是从前向后的了。

2、如何限速,控制资源下载的开销

当然有些同学想边玩边下,不需要下载太快,需要控制下载的开销,有哪些手段呢?

如果是这样,那我们需要减少下载线程的数量,最好是只使用一个下载线程。

为什么这么说呢?原因有两个:
(1)使用多个下载线程会增加CPU的开销。
(2)使用多个下载线程,就需要记录多个文件的下载状态,用于重新启动后的继续下载。

如果只使用一个下载线程,那么只需要记录当前文件的下载状态,逻辑会更简单。
当然了,如果对这些都不在意,还是希望能更快地完成下载,那么使用多个线程下载是值得的。

如何限制下载速度呢?
首先,我们先用一个变量m_nDownSize统计当前下载的字节数,再用一个时间变量m_nLastTime来做时间戳变量。每一次从Socket中读取数据时,检测当前时间与时间戳变量是不是超过了一秒,如果超过,就重载当前下载的字节数,并重置时间戳。
新的时间戳 = 当前时间 – 流逝的时间%1000(取一秒的余数)
新的下载记录 = 当前下载速度 * 流逝的时间%1000(取一秒的余数)
大家注意到了,这里超过1秒后,并没有简单的下载量置零,将时间戳置成当前时间,而是将时间前移了一点。

比如:在1.5秒后才触发这个事件,那么新的时间戳 = 当前时间 – 0.5秒
新的下载记录 = 当前下载速度 * 0.5秒

在检查需要限制下载的接口IsNeedLimitDown中,也用了同样的方法,统计超过1秒逻辑时间后的速度,并将它与你希望限制的下载量做比较,如果已经超过了你限定的值,就调用Thread.Sleep接口,将线程挂起,不再向服务器请求新的下载或从Socket中读取要接收的数据。

稍后,我将详细讲解在限制下载速度中用到两个手段,分片下载与定额接收,由于这个与内存控制紧密关联,所以也放到后面讲。

这里我们可以先看一下分片下载函数的代码,从代码中了解限速的机制。
源码如下:

long    m_nDownSize; // 当前下载的大小
long    m_nTotalDownSize; // 当前总的下载大小
long    m_nLimitDownSize; // 每秒限制下载的大小
long    m_nLastTime; // 上一次统计的时间点
bool    IsNeedLimitDown()
    long  nNow = System.DateTime.Now.Ticks/10000000;
lock(this)
   if( m_nDownSize > m_nLimitDownSize)
if(0 == m_nLastTime)
   m_nLastTime = nNow;
           long nPassTime = nNow - m_nLastTime;      
           if(nPassTime < 1)
              nPassTime = 1;
           return m_nDownSize*1000/nPassTime > m_nLimitDownSize;
return false;
void   LimitSpeed()
while(IsNeedLimitDown())
Thread.Sleep(10);
       // 收到指定字节数据的事件
       void  OnReceive(int  nDownSize)
           // 统计下载量,下载进度
           long nNow = System.DateTime.Now.Ticks / 10000000;
           lock (this)
               if (0 == m_nLastTime)
                   m_nLastTime = nNow;
               long nPassTime = nNow - m_nLastTime;
               if (nPassTime > 1000)
                   m_nLastTime = nNow - nPassTime % 1000;
                   m_nDownSize = (m_nDownSize + nDownSize) * (nPassTime % 1000) / nPassTime;
                   m_nDownSize += nDownSize;
               m_nTotalDownSize += nDownSize;

分片下载的功能,由下边的DownFile函数实现。

          // 功能:分片下载一个文件(默认分片大小是300K)
       void DownFile(CHttp http, string url, int nFileSize, int nLastDownSize)
            // 如果文件比较小的话,可以不分片下载,直接下载整个文件
       if (nFileSize == 0)
                CHttpDown.GetDownFileSize(url, out nFileSize);            
            int nPageSize = 1024 * 300; 
// 分片的大小,应小于最大限制下载速度,这里默认选用300K,读者自己根据项目修改
            int nFileOffset = nLastDownSize;  // 从上一次下载的位置接着下载,如果你每次下载都保存了这个值的情况下
            int nDownSize = 0;
            for (; nFileOffset < nFileSize; nFileOffset += nPageSize)
                // 先限速
                LimitSpeed();
                // 开始分片下载
                nDownSize = nFileOffset + nPageSize < nFileSize ? nPageSize : (nFileSize - nFileOffset); 
                if (!DownPart(http, url, nFileOffset, nDownSize, nFileSize))
                    NotifyDownEvent(url, false);
                    return ;
            NotifyDownEvent(url, true); // 通知文件下载成功事件

3、如何降低堆内存分配

对于多个线程下载,那么怎么降低下载过程中的内存分配开销呢?要减少堆内存的分配,关键是限速,定额下载,还有定额接收。

(1)限速
限速就是限制一定的下载速度,这个是通过定额下载来实现的。

(2)定额下载
定额下载是通过Range:bytes这个字段,每次请求文件下载时,并不请求整个文件,而是每次只请求一部分,分片下载,这样就能很容易地控制下载速度,也就能控制Socket层接收的内存开销。

(3)定额接收
定额接收就是在Socket收包时,并不一次性分配一个超大的内存块,而是只分配一个固定的内存(比如:4KB或300KB)。当这块数据接收完成了,提交到写线程写入到文件,并不是等整个文件完整接收完后再写入。写入完成后,再将这个内存块放到内存池重复利用,由于这是一个循环的过程,实际上并不会产生大量的堆积,除非是下载太快,写入太慢。

如果出现下载太快,写入太慢的(通过IsNeedLimitDown可以检测当前是不是超速下载了,也可以通过当前内存池分配的总量来检测),调用Thread.Sleep将接收线程挂起就可以了。

定额接收可以达到限制下载速度的目的,本质上是通过TCP拥包机制来实现的,当前TCP窗口端缓存塞满时(客户端故意不读取引起的),TCP连接的另一端会降低直至停止发送数据包。当然了,定额接收的主要作用,是降低在接收过程的中堆内存分配开销,可以实现用很少量的内存来达到接收大文件的目的。分两种情况:

在分片下载的限速模式下,下载文件时指定下载的字节数,而不是一次性下载整个文件,这种情况下内存的最大开销是多少呢?
比如限制300KB每秒,那么下载时,通过Range:bytes指定下载的量(比如300KB)。在当前1秒以内,如果下载完300KB后,就不再主动向服务器请求下载。在这种情况下,内存的最大开销是300KB * 2 = 600KB。为什么是600KB?这个说的是极限情况,因为有可能上一帧已下载300KB,提交给写线程,但并没有完成写入过程。
所以使用这种方式,一个或多个线程下载,理论上内存的最大开销是你限速的2倍,当然实际情况到不了这个值。

下面我们来看具体的代码:

    bool DownPart(CHttp http, string url, int nFileOffset, int nDownSize, int nFileSize)
            // 调用 HTTP 下载的代码
            nDownSize = http.PrepareDown(url, nFileOffset, nDownSize, nDownSize == 0);
            if (nDownSize <= 0)
                Debug.LogError("文件下载失败,url:" + url + "(" + nFileOffset + "-" + nDownSize + ")");
                return false; 
            byte[] szTempBuf = null;
            int nCurDownSize = 0;
            int nRecTotal = 0;
            int nRecLen = 0;
            int nOffset = 0;
            nCurDownSize = nDownSize > 4096 ? 4096 : nDownSize;
            MemBlock pBlock = AllockBlock(url, nFileOffset, nCurDownSize, nFileSize);  
// 从内存池中取一个4K的内存片
            while(nDownSize > 0 && !m_bNeedStop)
           // 必要的话,在这里添加限速功能或限制接收速度的功能,以免网速太快,导致一秒内分配太多内存
           //LimitSpeed();
                nRecLen = http.FastReceiveMax(ref szTempBuf, ref nOffset, 4096 - nRecTotal);
                if(nRecLen > 0)
                    OnReceive(nRecLen);  // 统计下载的流量
                    Array.Copy(szTempBuf, nOffset, pBlock.data, nRecTotal, nRecLen);
                    nRecTotal += nRecLen;
               // 如果当前块接收满了
                    if(nRecTotal >= nCurDownSize)
                        PushWrite(pBlock);// 提交写文件
                        nRecTotal = 0;
                        nDownSize -= nCurDownSize;
                        nFileOffset += nCurDownSize;
                        nCurDownSize = nDownSize > 4096 ? 4096 : nDownSize;
                        // 必要的话,加上限额等待
                        if(nCurDownSize > 0)
                            WaitBlock(1024 * 1024); // 检测当前内存池分配的总量,超过就挂起
                            pBlock = AllockBlock(url, nFileOffset, nCurDownSize, nFileSize);  
// 从内存池中取一个4K的内存片
                    return false; // 文件读取失败,可能是网络出问题了
            return true;

下面是写线程的代码:

       // 功能:写线程
       void  WriteThread()
           while(!m_bNeedStop)
               MemBlock pList = null;
               lock (this)
                   pList = m_WriteList;
                   m_WriteList = null;
               if(pList == null)
                   if (m_nDownThreadNumb <= 0)
                       break;
                   Thread.Sleep(1); // 没有要写的文件,小睡一会,减少 CPU 的开销
                   continue;
               pList = Reverse(pList);
               // 开始写入文件吧
               MemBlock pBlock = null;
               while (pList != null)
                   pBlock = pList;
                   pList = pList.m_pNext;
                   WriteBlock(pBlock);  // 写入文件
                   FreeBlock(pBlock); // 回收内存
           m_InvalidBlock = null; // 不需要内存池了
           // 在这里通知主线程,下载结束
           // 线程退出,线程数减1
           System.Threading.Interlocked.Decrement(ref m_nWriteThreadNumb);

二、 加速方法汇总

这个特别重要,按我的项目经验与项目中测试结果来看,如果小文件数量庞大,会大幅延长下载时间。原因是HTTP下载使用了短连接,每次下载一个文件或片段,都需要重建TCP连接,而这个重建的时间有可能超过了文件本身下载的时间。解决这个问题的方案,就是将下载资源文件合并成一个大文件。再利用断点续传功能,指定下载偏移地址,就可以实现从一个合包的大文件中提取你想要的资源文件。

当然也有同学说,既然重建TCP连接比较费时,那么可不可以让TCP连接一直保持呢?答案是理论上可以的,但事实上很多CDN厂商并不支持长连接的TCP,所以如果尝试保存长连接,是会导致下载失败的。

那么如何减少小文件的数量,将资源文件合并到一个大的文件里面。这里我给大家介绍一下合包文件格式,大家可以在这个基础上调整自己文件格式。

首先,文件的开头,有一个固定大小的头部信息。如果使用结构体:

struct  FileHeader
int   Version;     // 版本号,可以不需要
int   FileCount;   // 文件数量
int   SimpleSize;  // 简要信息的大小,简要信息之后,就是真实的文件内容

文件头之后就是文件的简要信息:

struct FileSimpleInfo
string  szFileName;  // 文件名(也可以是资源名)
int    nFileSize;     //  文件大小
int    nPackOffset;   // 当前文件在合包文件中的偏移
string  szVersion;    // 文件的版本号

下载时先下载12个字节,可以得到简要信息的大小,再下载简要信息,最后再逐个下载文件。下载这个合包文件,可以设置一个分片下载的量,比如设置成300KB,太大与太小都不太合适。

4、对于分片下载的文件,合理控制分片的大小,也可以加快下载的速度,这个需要在自己项目中去测试。

分片太小,会增加TCP连接的时间,导致下载速度上不去。分片太大,对于网络不好的用户,会导致大量的重复下载,也不利于控制下载速度。

将小文件合成一个大包下载,会增加额外的工作量,比如需要写打包的全包工具,还要写额外的下载代码,增加功能的复杂度,所以优先使用多个线程下载来加速下载,这个方法简单高效。

三、 附加功能

1、断点续传的关键

文件下载了一半掉线了,怎么接着下载?这就看Get消息中的Range字段了。我们在使用Get消息向服务器请求下载时,可以带上Range字段,这个可以指定下载的文件偏移与下载的大小。

Range:bytes = 起始偏移-结束位置

下载的大小 = 结束位置-起始偏移+1

比如:我只下载前10字节,是这样的Range:bytes =0-9
例如:

“GET /HEAOFiles/All/ZHC/2019/09301.jpg HTTP/1.1\r\n
Host:www.heao.gov.cn\r\n
Accept:*/*\r\n
User-Agent:Mozilla/4.0 (compatible; MSIE 6.00; Android)\r\n
Connection:Close\r\n
Range: bytes=0-9\r\n\r\n”

Range:bytes=0-0 这个表示下载0字节(下载零字节),这个可以用于获取下载文件的大小。
例如:

“GET /HEAOFiles/All/ZHC/2019/09301.jpg HTTP/1.1\r\n
Host:www.heao.gov.cn\r\n
Accept:*/*\r\n
User-Agent:Mozilla/4.0 (compatible; MSIE 6.00; Android)\r\n
Connection:Close\r\n
Range: bytes=0-0\r\n\r\n”

这样我们在下载时,对于大文件,我们可以使用分块下载,比如:一次请求4KB的数据包,然后再记录下载的进度。将下载的进度保存到一个本地临时文件,如果应用被杀,再次启动时,就可以检测这个临时文件,读取里面的内容继续下载。

当然了,实际应用时这个临时文件记录的信息会更复杂一些,可能需要记录当前下载的资源版本号、当前下载的文件、进度,可能还需要加一些检验码,以检测这个文件有没有被破坏。

2、对IPV6的支持

由于苹果提审需要IPV6的网络环境测试,所以下载这块,也是必须要支持的,这个需要注意一下。关于IPV6的支持,Unity 4.7.2以上版本都是支持的,并不需要特殊的处理。IPV6网络检测的代码也很简单,就是去访问下载服务器的域名,再遍历这个地址列表,如果里面存在AddressFamily.InterNetworkV6标记的,那么网络就是IPV6的环境。

IPAddress[] ipa = Dns.GetHostAddresses(szCDNAddr);
foreach(IPAddress ipAddr in ipa)
      if (ipAddr.AddressFamily == AddressFamily.InterNetworkV6)
        // 出现这个就表示在IPV6的网络环境下

3、如何检测哪些文件需要热更新

要检测哪些文件是需要热更新的,只需要一个纲要文件,记录当前版本下所有文件的版本号即可。这个很简单,分5个步骤:

(1) 启动时连接CDN服务器,根据APK包中设置的版本号,下载对应服务器版本配置表,并从当前服务器版本配置信息中得到对应资源版本号。取版本信息也可以自己搭建一个版本服务器(非Web服务器,不使用HTTP协议),自己用二进制消息协议与服务器通讯,得到当前的版本信息。自己搭建服务器还有一个好处,就是iOS审查问题,可以根据版本号来跳过热更新检查,直接进入游戏。

(2) 根据资源版本号,下载纲要文件,得到所有的资源文件的版本号信息。
版本信息里面至少包括文件名、文件大小和对应的资源版本号,MD5校验码(也可以不需要)。
注:服务器纲要文件,并不需要每次下载,可以加一个版本记录,有版本更新就下载。

(3) 对比本地所有的文件与服务器的信息。
对比的结果有三个:一个是新增(本地没有,服务器有),一个是修改(版本号发生改变),一个是删除(本地有,服务器没有)。
注:本地纲要文件会打包时打进APK中,需要每次更新重新修改里面的内容。

(4) 整理需要下载的列表,开始多线程下载过程。
注:在下载之前,需要检测上一次保存的正在下载的文件信息。
如果有,对比它的版本号,将它已经下载的字节数(进度)信息合并到当前下载列表中。

(5) 等待下载,并更新本地纲要文件。
注:如果这里面有下载失败的,需要重新这个下载的过程,直到重复N次下载之后或完全没有下载错误。下面是一个纲要文件的例子:

<?xml version="1.0" encoding="utf-8"?><br/>
<Assets BundleVersion="2.6.0" AssetsPackageVersion="2.6.0.2">  
<Asset Version="2.6.0.1" Name="movie_sm_sl04.xml" Size="831"/>
<Asset Version="2.6.0.2" Name="ui_texture_001.unity" Size="4850"/>
</Assets>

4、如何计算下载进度,预估下载时间

要知道下载进度,首先得知道需要下载的所有文件的总字节大小。进度不能按下载文件数量来定,而是要按下载字节数来定。所以在下载文件之前,需要先下载一个纲要文件。这个文件记录了当前版本所有的文件的简要信息,包括版本号、文件大小、文件校验码(一般是MD5码,但实际这个不是必须的)。

下载进度 = 当前已经下载的字节数/总的下载字节数量

注意:简要信息里记录的文件大小必须是正确可靠的,不然计算出来的进度是不正确的。预估下载时间一般并不需要显示给玩家,但如果想计算,可以计算一个总的平均值。

平均下载速度 = 当前下载字节数 / 下载的时间
最近平均下载速度 = 最近下载字节数 / 最近单位时间
(这个单位时间,可以取若干秒,也可以取分钟)
总的下载时间 = 剩余下载量 / 最近平均下载速度 * 单位时间

如果希望在下载过程中退出应用后,再次登陆,还是按上次下载进度走,这个办法也是有的。但需要保存当前下载的版本号、总的下载量和已经下载的量,并在每次保存下载文件里,重新将这些信息写入到一个临时文件。

再次启动时,当前初始下载的量就不是零了,而是从这个临时文件中读取的值。如果再次启动时,最新的版本号与临时文件中的版本号不一致,就需要丢弃上一次下载的进度信息,重新从零开始。