手游HTTP多线程下载原理与优化
1 | 网络下载组件能够解决哪些问题
在手机软件开发,尤其在游戏开发过程中,经常需要使用手机下载资源。手机虽然也是小型的计算机,但它的处理能力和台式机的标准处理能力相比,还是有不小的差距。大家在项目中可能遇到各式各样的问题:
“我的网速不稳定,我正在下载一个比较大的文件,已经快下载完了,突然断网了。再次启动,又要重新下载。”
“项目的小文件好几千个,下载时间很长。”
“下载一个大文件时,会分配一个与文件一样的内存,有没有办法分配很少的内存,或者下载过程中自己来决定内存的分配使用量呢?”
“项目资源体积很大,导致CPU占用大、堆内存分配频繁、量大,用户无法自己控制,能否提供‘边玩边下载’的功能呢?这样在下载的同时可以做其它事。”
相关的问题还有很多,有的影响了效率,有的消耗了资源,还有的带来了大量的重复劳动。有没有办法优化呢?无论是使用UnityWebRequest,还是HttpWebReuqest,都会有些困惑。要么只能主线程执行,要么无法控制下载速度,内存开销过大。至于断点续传功能,更是不知从何下手。
其实,不单是手游,早在端游开发的时候,我就遇到类似的问题,还写过一个组件。后来移植到手游开发过程中,非常好用,现在分享给大家。
这篇文章从简单的HTTP协议讲起,介绍怎么用Socket直接封装一个下载组件,然后讲解怎么有效地使用资源,加快或限制下载速度,同时控制下载过程中总的内存开销,以及断点续传、IPV6的支持,最后再讲讲项目中的热更新机制以及下载速度的评估。以下这张脑图,展示了文章的结构。
通过学习,可以快速掌握解决以上项目痛点的方法,方便快速应用到项目中。
2 | HTTP资源请求的过程
这套组件是使用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"); else 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位数字。
3 | 下载组件和功能模块的实现
一、 资源的使用
1、如何使用多线程下载,加速下载过程?
既然用到多线程,就需要加锁,这个使用lock关键字就可以了。
如:
lock(this) { // 这里访问或修改类成员变量 }
要加快下载,首先需要将下载与本地保存分开,下载使用一个或多个线程,本地保存使用一个线程。之所以保存文件单独使用一个线程,而不是在下载线程完成后保存文件,一方面是为了避免保存时的网络空闲,另一方面也是为了减少文件写入错误,避免文件写入加锁。
对于一个文件下载队列,如果只使用单线程下载,策略就很简单,只需要逐个下载就行了。如果想使用多个线程同时下载,就得考虑下载任务的分配机制了。这里介绍两种:
方式一:预先分配制
就是将下载的文件均匀地分配给不同的线程,每一个线程拿到一个下载队列,逐个下载。源码如下:
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); } else 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; } else 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倍,当然实际情况到不了这个值。 -
不限制下载速度,下载时请求整个文件的情况下,内存是怎么样的分配情况?
这种情况,必须使用定额接收,每秒只接收指定限制的量(比如:1MB),再配合内存池,单次接收建议使用4KB,每收满4KB的包就提交给写入线程写入到文件,再将这4KB的内存回收重复使用。
在这种情况下,总的内存开销 = 系统层TCP窗口接收缓存的最大值+每秒定额接收的最大值*2;对于应用层来讲,总的内存开销 = 每秒定额接收的最大值*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的内存片 } } } else { 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); }
二、 加速方法汇总
当然,对于大多数项目来说,都是在游戏登陆前下载好所有的资源,这种情况,希望是下载速度越快越好,等待的时间越短越好。
那么有什么办法呢?我们做个汇总。
1、选用更好的CDN厂商,使用CDN加速。
2、使用多线程下载,但不是线程数越多越好,这个需要测试,一般取4个或5个就足够了。
3、减少下载的文件数量,特别减少小文件的数量。
这个特别重要,按我的项目经验与项目中测试结果来看,如果小文件数量庞大,会大幅延长下载时间。原因是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码,但实际这个不是必须的)。
下载进度 = 当前已经下载的字节数/总的下载字节数量
注意:简要信息里记录的文件大小必须是正确可靠的,不然计算出来的进度是不正确的。预估下载时间一般并不需要显示给玩家,但如果想计算,可以计算一个总的平均值。
平均下载速度 = 当前下载字节数 / 下载的时间
最近平均下载速度 = 最近下载字节数 / 最近单位时间
(这个单位时间,可以取若干秒,也可以取分钟)
总的下载时间 = 剩余下载量 / 最近平均下载速度 * 单位时间
如果希望在下载过程中退出应用后,再次登陆,还是按上次下载进度走,这个办法也是有的。但需要保存当前下载的版本号、总的下载量和已经下载的量,并在每次保存下载文件里,重新将这些信息写入到一个临时文件。
再次启动时,当前初始下载的量就不是零了,而是从这个临时文件中读取的值。如果再次启动时,最新的版本号与临时文件中的版本号不一致,就需要丢弃上一次下载的进度信息,重新从零开始。