所谓资源代码更新,就是当我们重启游戏的时候,游戏会根据当前服务器版本与客户端版本下载更新资源。游戏里面经常会使用到资源更新技术,特别是对于国内的手游以及端游,而国内经常会用热更新(Hot Update)指代这种技术,其实这种形容是不准确的。因为热更新是在不重启游戏的情况下更新资源与代码,而目前大部分游戏是需要重启才能更新相应的资源以及代码。
注意,要完全读懂下面的内容,你需要Untiy3D编程基础,假如你对Unity3D游戏开发感兴趣,欢迎浏览Unity3D官网开发文档以学习相关知识。
Unity3D资源代码更新
关于Unity3D的资源更新,可以先浏览官网有关Asset Bundle的相关介绍:AssetBundles,这里面涵盖了Asset Bundle的含义以及一些大概的流程,以及使用方法,相关的内容本人就不在Blog中复述了,这篇文章的重点在于,在官网提供的API的基础上,提供一些实战性质比较强的方法讨论。
Asset Bundle打包
在下载资源之前,我们需要把资源打包成AssetBundle包,这就需要使用到接口BuildPipeline.BuildAssetBundle(注意,这个接口已经在新版本里面标注废弃了,所以我修改了标题,限于Unity3D 5.0版本之前,新版本的打包机制我会后面描述)。这里着重讨论BuildAssetBundleOptions的几种枚举参数。
- UncompressedAssetBundle 创建非压缩的Asset bundle资源。在创建以及加载时会比压缩资源要快,但是包大小会比压缩了的资源大。当没有指定这个枚举参数时,Unity3D会使用默认的压缩方式对资源进行压缩,压缩的资源包要小,但是创建以及加载时会比非压缩慢,因为过程需要压缩以及解压缩操作。
- CollectDependencies 创建Asset Bundle时,集成跟当前资源有依赖关系的资源。举个例子,就是A资源跟B资源有依赖关系,那么在打包A资源的时候,就会把B资源也打包进去,也就是说,打包A资源,得到的结果是AB都在这个Asset Bundle包里面。这么做有一个好处,就是在加载A资源时,因为A资源是需要B资源的,但是假如没有B资源的存在,那么加载完A资源就会出现问题。添加了这个操作,可以避免这个问题的出现。另外,在Unity3D 5.x版本里面,我看到这个枚举类型被移除了,因为还没有使用过5.x开发项目,所以不知道是不是官方针对这些进行了优化,有开发过新版本资源包加载功能的同学们,欢迎各位来讨论。
- AppendHashToAssetBundleName、DeterministicAssetBundle 这两个主要是用于做版本管理用的,两个一起作用时,打包的时候Unity3D会生成hash码,假如不添加DeterministicAssetBundle枚举操作的话,每次生成的hash码都会不一样,这点需要注意。
在Unity3D资源代码更新中,为了达到目的以及最优的效果——减小包大小、最优化内存、减少CPU消耗,我们需要根据不同的资源采取不同的策略。根据我的开发经验,客户端需要更新的内容一般有以下几种:
- 代码
- 图片
- 关卡
- 界面
- 游戏配置
代码更新的机制需要特殊处理,所以我会另外开一个文章讲述代码更新机制,而针对这些非代码资源的打包操作,一般有两种策略:
- 把所有的资源打包成一个大包
有过这方面开发经验的人也知道,当打包操作添加了CollectDependencies枚举的情况下,Unity3D在对资源进行Asset Bunle打包操作时,会对当前打包资源具有依赖关系的另外一个资源也打包进去,这样你不用关心这些资源之间的依赖关系,不用担心这些包因为依赖产生的多余的资源,但是打成大包最大的缺点在于,一旦有改动就会牵一发而动全身——需要把整包重新打包。这样造成的后果是,用户需要每次重复下载一些没有变动的资源,造成了网络流量的浪费,并且等待下载的时间会造成用户的流失。 - 把所有的资源分别打成单独的小包
为了避免打成大包造成的缺点,可以采取的策略是把每个资源打成单独的小包,这样的好处是,我们不必再为牵一发动全身的问题发愁了,在有版本管理的前提下,用户只需要下载被更新了的小包。但是打包成小包同样有缺点,假如在打包时,不选择CollectDependencies的策略的话,那么资源上存在了依赖关系的资源或者引用关系会丢失,给我们造成不可估量的后果。而选择CollectDependencies操作,我们需要确保打包资源的低耦合高内聚,也就是说尽量避免引用其他资源的情况,否则就会造成包大小的浪费。
目前我采用的是两种策略混合使用的办法,对于那些很少会更新,或者说整包很小的资源模块,采取大包的加载策略,而对于图片、策划配置这种经常会变动的资源,采取使用小包的管理方式。
讲完了打包的复杂度规划,下面我们来谈究竟要不要使用UncompressedAssetBundle操作,也就是说,要不要使用非压缩包的问题,下面我将分开描述选择是否压缩包的情况:
- 使用Unity3D自带的压缩方式
当我们没有给BuildAssetBundleOptions指定UncompressedAssetBundle枚举时,Unity3D将会使用自带的Asset Bundle压缩算法进行压缩,一旦使用了这个方法,在加载Asset Bundle包时,无法使用AssetBundle.CreateFromFile方法,根据官方文档的描述,CreateFromFile是所有加载Asset Bundle包方法里面最快的,并且是同步的方法,但是他只能在非压缩模式下使用。另外,使用压缩的Asset Bundle包,你需要每次解压缩,为了避免每次解压缩耗费的时间,你可能还需要把解压缩之后的二进制文件在具有读写权限的文件目录下再次另外存储一份解压之后的文件,当然你也可以不这么做,因为这样可以减少程序员的工作量,但是带来的代价是,你每次加载的时候必须要忍受漫长的解压时间,会使得文件的读取非常没有效率。 - 使用Unity3D的非压缩模式
给BuildAssetBundleOptions指定UncompressedAssetBundle枚举时,Unity3D将会使用非压缩的方式处理打包的操作。非压缩的方式避免了打包以及解压包的时候进行的压缩以及解压缩的过程,并且可以使用CreateFromFile接口,但是它的坏处也是显而易见的,非压缩包的空间占用非常大。
两种模式都有自己的优缺点,针对第一种的缺点,我已经在第一点的描述中提出了解决方案,而我采用的是第二种方式,为了解决非压缩包占用空间大的问题,我引入了LZMA插件以解决这个问题。
使用LZMA压缩算法压缩包
关于LZMA的介绍,我们可以参考维基网站,有人说Untiy3D的Asset Bundle压缩算法就是基于LZMA的,我没有看过源代码所以不敢下定论,LZMA有一个开源的类库,我们可以去网站下载LZMA的SDK。
在官网下载LZMA后,把CS文件下的7zip解压到Unity3D工程目录下的Plugins文件下就可以使用,添加之后Settings.cs会报错,由于我们并不需要用到这个类里面的功能,因此把它删掉并不影响我们使用LZMA。
首先我们需要编写压缩操作的代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19/// <summary>
/// 使用LZMA压缩资源
/// </summary>
/// <param name="sourceFile">要压缩的资源路径</param>
/// <param name="destFile">存放压缩文件的资源路径</param>
public void CompressFile(string sourceFile, string destFile)
{
SevenZip.Compression.LZMA.Encoder encoder = new SevenZip.Compression.LZMA.Encoder();
using (FileStream sourceStream = new FileStream(sourceFile, FileMode.Open))
{
using (FileStream destStream = new FileStream(destFile, FileMode.Create))
{
encoder.WriteCoderProperties(destStream);
destStream.Write(BitConverter.GetBytes(sourceStream.Length), 0, 8);
encoder.Code(sourceStream, destStream, sourceStream.Length, -1, null);
}
}
}
其中sourceFile是需要压缩的资源的绝对路径,而destFile是存放压缩后资源的绝对路径。
压缩之后,我们还需要解压缩操作的代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24/// <summary>
/// 解压LZMA资源
/// </summary>
/// <param name="sourceFile">需要解压的资源路径</param>
/// <param name="destFile">需要存放解压资源的路径</param>
public void DecomressFile(string sourceFile, string destFile)
{
SevenZip.Compression.LZMA.Decoder decoder = new SevenZip.Compression.LZMA.Decoder();
using (FileStream sourceStream = new FileStream(sourceFile, FileMode.Open))
{
using (FileStream destStream = new FileStream(destFile, FileMode.Create))
{
byte[] properties = new byte[5];
sourceStream.Read(properties, 0, 5);
byte[] fileLengthsArray = new byte[8];
sourceStream.Read(fileLengthsArray, 0, 8);
long fileLengths = BitConverter.ToInt64(fileLengthsArray, 0);
decoder.SetDecoderProperties(properties);
decoder.Code(sourceStream, destStream, sourceStream.Length, fileLengths, null);
}
}
}
同理,sourceFile是需要解压资源的绝对路径,destFile是解压后存放资源的路径
以上就是涉及到LZMA相关的操作。
当我们使用了LZMA压缩之后,我们可以大大的减少我们游戏下载包所需的流量,并且,我们只需要在下载完之后解压相应的资源到指定的目录,那么下次我们就可以直接加载无压缩的资源,避免了重复解压资源的操作,加快了我们读取资源的效率。
版本管理
讲完了针对单个资源的最优操作,接下来我们要讨论一下架(偷)构(懒)层面的问题了。
在游戏上线之后的维护工作中,我们需要针对每次需要更新的文件出包,这也就代表了每次更新文件我们需要检查资源有哪些是需要被更新的,而这个过程进行人工检查一方面没有效率,另外一个方面很容易出错。试想一下,你需要知道哪些资源是有更新的,哪些没有,假如你把这个过程弄错了,那么就需要重新出包,一旦打包的过程出了差错,用户还有可能下载到错误的资源,导致无法估量的后果。所以我们需要针对我们需要更新的资源进行自动化操作。
版本打包自动化
在对资源打包之前,我们需要知道哪些文件是被更新的,这时候我们需要用到SHA-1对文件信息校验。
SHA-1文件校验
SHA-1是目前应用比较广泛的hash函数算法之一,其发展是基于MD5而来的,这里我不详细描述hash的概念,实现以及作用,有兴趣的读者可以自行搜索相关的文章。想要知道我们的文件有没有被更改,我们需要计算每次文件的SHA-1码,并拿这次的SHA-1码跟上次的SHA-1码进行对比,假如两者之间的hash是匹配的,那么证明了这个文件没有被更改,相反,假如两者之间的hash码匹配失败,那么证明了这个文件发生了更改,也就表明了这个文件是需要被更新出去的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19using System.Security.Cryptography;
using System.IO;
using System;
/// <summary>
/// SHA-1文件校验
/// </summary>
/// <param name="filePath">需要被计算的文件路径</param>
/// <returns>返回文件的hash值</returns>
public string GetSHA1Hash(string filePath)
{
using (SHA1Managed sha1 = new SHA1Managed())
{
using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
return BitConverter.ToString(sha1.ComputeHash(fs));
}
}
}
文件信息存储以及管理
计算完资源文件的hash码之后,我们需要把文件的hash码存储下来,用来对下次的hash码进行比对。另外为了取得对应文件的hash码,我们还需要存储文件的名字。除了这些信息,我们需要添加一个版本号,一旦我们的资源文件发生了变更,版本号随之变大,这样在更新的时候,资源更新的逻辑就可以根据版本号进行比对,以了解当前客户端的版本跟服务器上面的资源版本有没有不一样的地方。建立版本号有一个好处,就是当我们每次进入游戏的时候,我们不需要进行字符串比对,只需要进行版本比对就可知道资源文件有没有被更新,虽然为了知道具体哪个文件发生变动,hash码的对比是无法被避免的,但是一旦客户端上线,我们不可能经常更新资源文件,这时候只需进行版本对比就可以大大的节省版本匹配时客户端进入的时间。另外这里还有一个优化技巧,就是当零碎的资源文件特别多时,我们可以建立分组,每个组有自己的版本号,然后我们建立一个大版本文件,管理这些分组,这样在分组下的文件发生变动时,我们就可以直接更改该组的文件版本号,另外的组不用更改,这样我们可以避免多余的hash值对比,从而提高程序的速度。
(我还是画个图吧等我下个画图软件orz)
综合下来,我们需要存储的文件信息有:
- 版本号
- 文件名字
- hash码
删除多余信息
在维护客户端版本的过程中,我们可能需要删除那些已经不需要的文件,同样,我们可以根据我们当前文件路劲下的文件跟版本文件中存在的文件进行比对,根据文件的名字信息存储,我们可以知道在旧的文件信息中,哪些是在当前资源文件中依然存在的,哪些是被删除的,针对哪些需要被删除的文件,我们可以对其删除,并且把其信息从版本信息中移除,避免不必要的比对,删除多余文件的办法,我们可以使用File.Delete。
上传服务器
生成文件信息之后,用户需要到资源服务器上下载更新的文件,这也就意味着我们需要把更新了的文件上传。对于传输协议我们使用的是FTP文件传输协议,但是由于FTP使用的是明文传输,为了安全性考虑,防止站点信息泄露,我建议使用FTPS,或者使用SSH对传输进行加密。