当前位置: 当前位置:首页 > 百科 > 浅谈倩女手游中的资源更新正文

浅谈倩女手游中的资源更新

作者:休闲 来源:焦点 浏览: 【 】 发布时间:2024-05-09 02:24:24 评论数:

浅谈倩女手游中的资源更新

倩女幽魂手游是浅谈倩女一款通过Unity实现的MMORPG游戏,自2016年5月上线至今,手游已稳定运营4年。中的资源得益于游戏中稳定的更新资源更新系统,windows和android上线以来从未“强行更新”过。浅谈倩女

除传统标配的手游资源更新之外,倩女幽魂手游还额外设计了一些特殊机制来优化玩家在热更方面的中的资源体验。本篇文章将带大家了解倩女手游资源更新方面的更新一些实现细节,希望能帮助大家对游戏资源热更新有更全面的浅谈倩女认识。

本文主要内容包括以下几方面:

  1. 游戏的手游资源打包相关流程
  2. 游戏的资源更新和加载的整体流程。
  3. 资源更新的中的资源改进方案:PrePatch和小包体的实现。

在谈具体的更新热更新流程前,需要先了解一下游戏中与打包相关的浅谈倩女背景知识。

Unity编辑器可以理解为一个大型的手游虚拟机,会根据当前选择的中的资源平台将放入工程的资源自动转化(勾选AutoRefresh)为目标平台资源存入工程的Library目录,在打包阶段,Unity根据打包脚本从Library中提取对应资源,打包成对应的assetbundle,最终游戏资源都以单个assetbundle的形式存在,参考:

https://docs.unity3d.com/Manual/AssetBundlesIntro.html

倩女手游采用unity5.2.4制作,我们在调研Unity的打包方案过程中,遇到以下几个问题:

Unity 4.x 之前旧的打包方式已被unity废弃,采用的是push/pop的栈式管理打包过程中的依赖,栈顶层的bundle自动建立与栈底层的bundle的依赖,使用该方式打包的缺点在于整个打包过程和依赖关系完全要自己管理,需要额外写很多打包和依赖管理相关的代码。

Unity 5.x 引入新的打包方式,通过打包接口设置需要需要打包的资源列表后,Unity内部会自动分析依赖构建一个巨大的依赖图, 然后按照给定的列表打包,使用该方式打包,理论上能避免所有的资源冗余,但整个依赖的管理过程都由Unity自动分析,里面存在一些不可预料性,如果打包策略控制的不好,可能会出现一个小bundle 依赖好几个大bundle的情况,这样可能会导致加载资源过程中的内存尖峰。

由于我们游戏的资源量比较大,为了节省包体大小,统一采取的CompressedAssetBundle的方式进行打包,该方式有个缺点在于bundle加载进内存后,无论是否从bundle加载资源,unity都会自动将bundle展开成未压缩的状态,增加内存开销;Unity2018后可以采取ChunkBased的压缩方式 + LoadFromFile的方式优化该部分内存,这里不展开讲了。

经过各种测试和比较,最终我们采取的是使用Unity4.x的旧接口实现了一套手动管理资源依赖的方式进行打包,下面会详细讲解。

资源冗余是Unity打包系统里之前一直被人诟病的一块内容,在打包过程中如果对于资源的依赖管理的不好,就会出现资源的冗余,造成包体和内存的浪费。比如,两个prefab引用了同一个材质m,如果m不单独拆分打包,则两个prefab中都将打包该材质,如果两个prefab同时加载进内存,那内存中将存在两份同样的独立材质,造成内存浪费。

为了解决该问题,我们实现了一套手动管理依赖的方式对资源进行解依赖打包,具体的思路就是通过预处理资源,让每个单独打包的资源不直接依赖其他资源,只记录其依赖的资源路径,在资源加载过程中再根据记录的依赖路径对资源进行组装还原,以一个prefab为例,具体的过程如下图所示:



资源的解依赖过程,涉及到的一堆继承自CComponentRef的类,其主要功能都是资源的解依赖和组装依赖,以及解依赖过程需要存储的数据字段,所有类都必须实现基类的两个接口 RemoveRef() 和RebuildRef()。为了更能更详细的了解该过程,我们以CMeshRendererRef为例,并通过实际的具体代码详细讲解:

如上图:CMeshRendererRef 中总共记录了三个数据字段,m_MatPath记录的是对应的MeshRenderer引用的材质的具体路径,m_LightmapIndex和m_LightmapScaleOffset记录的是场景中的MeshRenderer对应的烘焙数据。

RemoveRef():专用于Editor打包脚本使用,作用是在打包阶段将原始资源解依赖后返回一个解依赖的资源和路径对应的dict,用于打包脚本后续对该部分解出来的依赖资源打包;具体对于CMeshRenderRef来说,就是将原始MeshRenderer上记录的材质清空,并记录材质的具体路径,同时,针对场景材质记录烘焙信息,具体代码如下:

RebuildRef(): 运行时接口,根据脚本上记录的依赖资源路径,使用协程异步还原出原始资源;具体对于CMeshRenderRef来说,就是根据之前存储的材质路径异步加载好对应的材质,重新赋值给MeshRenderer 组件,同时赋值上烘焙信息。

通过上述解依赖的方式进行打包,最大限度的提高了资源了复用程度,也避免了资源重复打包和加载过程中的资源冗余。但该方案带来的负面问题就是导致assetbundle数量很大(最新的包assetbundle个数超6w个), assetbundle数量太多一方面造成打包速度变慢,另一方面运行时加载一个资源需要同时加载过多assetbundle进行组装, 每个assetbundle都需要被解压缩,可能造成卡顿和内存尖峰。因此我们在升级引擎到unity2018时修改了部分打包策略,针对不需要解依赖的bundle进行了合并,这里不再赘述。

打包不稳定的问题是Unity5中打包另外一个比较棘手的问题,具体现象是同一个资源在完全没修改的情况下打包两次出来的assetbundle不一样,造成我们无法通过比较资源的MD5做资源的差量更新。

如何在Unity 5中避免多次打包的不稳定性呢?我们的解决方案是在打包系统手动管理打包资源的所有依赖信息,该信息存储再一个叫buildinfo的文件中,buildinfo完整记录了所有bundle依赖的资源和meta文件的大小和md5, 如下图所示。


有了buildinfo信息,每个bundle打包前,我们都尝试从之前打包存下来的buildinfo里尝试获取该bundle对应的依赖信息,判断该bundle是否要重新打包(如果bundle依赖的所有资源的md5和buildinfo里都一样,则不用重新打包),确保整个打包过程中只打真正有修改的那部分资源bundle,确保打包的稳定。另外一方面,通过该方式跳过很多资源的打包,也大大加速了打包的速度。如下图所示,我们封装了一个IsAssetBundleChanged的方法,用于判断一个bundle是否需要重打。

资源打包的输出,除了前面提到的一堆assetbundle之外,额外输出了一份bundle的描述数据CResourceCheckSumData,该数据继承自unity的 ScriptableObject。Unity特有的序列化方式,参考:

https://docs.unity3d.com/ScriptReference/ScriptableObject.html

描述了所有bundle的版本号,MD5信息(为了加速比较,我们将md5拆分成两个ulong字段分开存储)等一系列信息,用于资源更新时的资源比较获取需要更新的资源列表, 如下几张图分别描述了该数据结构。



最终,我们将CResourceCheckSumData 也打包成一个checksum.asset.assetbundle,与其他bundle放在一起,共同组成一个版本的资源,存在于资源服务器上,如下图所示。在游戏运行时将根据当前本地版本号与远程版本号比对出需要更新的资源并下载。 细心的读者可能会发现,右下图中的资源并不是一个个单独的assetbundle,而是一个个package,这个主要是由于我们的bundle数量比较多,为了避免热更资源下载过程中频繁创建http请求导致下载速度变慢,我们将bundle合并成3M每个的package,用于加速资源的下载。



从上面资源解依赖打包时提到的CMeshRendererRef的Rebuild过程可以看到资源加载用到了ResourceMgr中的Load方法,该方法是游戏中加载非场景异步资源的通用方法,另外还有一个Syncload方法用于少部分资源的同步加载,以及LoadLevel方法用于加载场景,其原理都很类似,以下对Load方法进行实际的讲解,先上代码。


总的来说,一个资源加载的总体流程是先尝试从cache中查找资源,如果找到则直接返回,如果未找到,则使用协程从资源加载器直接加载资源,然后对于加载的资源获取所有的CComponentRef再一一调用RebuildRef进行组装,因此实际的资源加载是一个递归加载和组装的过程。

以上代码涉及资源的Cache策略,这并不是我们讨论的重点。另外,里面涉及到m_Loaders数组,可以理解为资源加载器,游戏中总共有两种资源加载器:CLocalFileResourceLoader 和CBundleResourceLoader, 分别实现了从本地Editor加载资源和从bundle加载资源,通过使用一个全局bool变量UseBundle进行控制,默认情况下Editor中都是使用CLocalFileResourceLoader加载资源, 打包的版本使用CBundleResourceLoader加载资源。

CBundleResourceLoader在加载资源前需要先加载资源对应的bundle,在实现过程中,我们将加载bundle这个操作抽象成了多个BundleLoader,具体关系如下:


从上图可以看出,游戏中根据bundle加载总共实现了三类BundleLoader,可以理解为从三类不同的文件系统加载bundle,目前我们游戏中都使用的是CVFSBundleLoader进行加载,而CRemoteBundleLoader主要用于后面要讲的小包体流程。不管是哪类BundleLoader,都是先判断该bundle是否patch过,如果patch过则直接从patch目录加载,否则从对应的文件系统中加载,以LocalBundleLoader为例,其实现很简单:

前面提到,游戏中的每个版本号都对应了相应版本号的一份资源,到具体的客户端时,我们如何根据当前的客户端状态判断是否要更新,要更新哪些资源呢?这里就涉及到客户端的版本号管理了。版本号管理分为客户端本地版本号和远程版本号。

每个客户端本地我们都维护了两个版本号:引擎版本(EngineVersion)、游戏版本(GameVersion)。另外,引擎版本和游戏版本都带一个小版本号:补丁版本(PatchVersion)。

  • 引擎版本(EngineVersion):记录的是游戏安装包内置的版本号,与包体的原始资源版本相对应,由于包体的原始资源不可能变更,该版本号不会跟随包体的升级变化而变化,该版本号记录在一个C#类CClientVersion中,由QA进行管理和修改。之所以需要管理引擎版本,是因为游戏中有部分代码无法进行热更,如Native代码或 SDK修改等。
  • 游戏版本(GameVersion + PatchVersion):记录的是当前客户端经过升级之后的资源版本号,可以理解为客户端的逻辑版本号,该版本号决定了客户端是否能正常进入服务器(若版本号与服务器版本号不一致的话则不能进入游戏)。具体格式举例:407200P3, 在每次资源更新完后存在游戏的Patch文件夹下。

远程版本号与本地版本号相比,除了游戏版本号之外,还额外记录了一个最低支持的引擎版本号(MinEngineVersion), 当客户端EngineVersion小于 MinEngineVersion时,意味着客户端的引擎过低,需要强更重新下载最新的客户端才能进游戏。远程版本号的格式举例:407200:414899P2,冒号前面是MinEngineVersion,冒号后面是GameVersion + PatchVersion。

资源更新的过程,简单来说就是根据本地的GameVersion下载patch更新到远程GameVersion的过程,先整体概述一下资源更新的流程如下:



接下来就按照上述图的步骤,一一解释整个资源更新流程中每步的执行。

  • LoadLocalVersion: 加载本地的版本号,EngineVersion 直接获取CClientVersion.cs 中记录的版本号,GameVersion尝试从Patch目录读取version.txt并进行解析获得,如果patch目录不存在该文件,则GameVersion赋值为LocalVersion。
  • LocadRemoteVersion: 该过程通过协程进行,使用 unity的www发起 http请求从一个链接中获得并进行解析。
  • EngineVersion 比较主要有以下两种特殊情况:

1)LocalEngineVersion < RemoteMinEngineVersion,如果相比还更小,则客户端将弹框提示用户去下载最新的版本,如下图,当玩家点确定时,游戏将自动跳转到不同平台的应用市场对应的页面引导玩家下载最新的客户端版本。


2)LocalEngineVersion>RemoteEngineVersion: 含义表示当前客户端属于测试包,客户端将设置一个IsTesting的标签,并通过远程重新下载一个测试的版本号替换当前的RemoteEngineVersion, 后续进入游戏的的服务器列表也将根据IsTesting 标签替换成测试用的服务器列表。该判断主要用于当游戏新版本处于审核中时区分外网玩家和审核人员,审核人员只能进入测试服务器,不会干扰正常的外网服务器。

  • GameVersion 比较主要有以下几种情况:

1)LocalEngineVersion > LocalGameVersion: 客户端当前的引擎版本比当前的GameVersion 大,说明该客户端是从旧的客户端覆盖安装了最新的客户端,删除Patch目录(Patch记录的是LocalGameVersion对应的资源)。

2)LocalEngineVersion == LocalGameVersion && LocalPatchVersion == 0:同上属于覆盖安装了最新的客户端并且之前旧版本无patch,尝试删除残留的patch目录。

3)LocalGameVersion>=RemoteGameVersion&&m_LocalPatchVersion>= m_RemotePatchVersion: 版本号和远程版本号一致,跳过patch检查,直接进入游戏。

  • LoadLocalChecksum:

加载本地的checksum列表,注意该列表由原始包体里的资源m_LocalChecksumInfo和已经patch部分的资源m_LocalPatchedChecksumInfo两部分合并组成,具体代码如下:

  • LoadRemoteChecksum:

和本地类似,远程更新列表也由m_RemoteChecksumInfo和m_RemotePatchedChecksumInfo两部分组成,具体代码如下:

上述代码里有个GetRemoteGameBaseUrl() 和GetRemotePatchUrl() 是指的部署在cdn上的资源远程url,该url通过当前平台、远程版本号和一个基础的url拼接而来,具体代码如下:

  • GenPatchList:

该过程就是根据前面两步生成的本地的checksum列表与远程的checksum列表里的所有资源进行差分对比,判断资源版本号和资源的MD5值,如果其中一个不一样则表示该资源需要下载,因此将其加入待更新列表。

  • DownloadPatch:

该步骤执行的逻辑很简单,就是根据上一步生成的patch列表,拼接成真正的URL,利用unity协程和WWW把需要下载的资源从远端下载一遍,存入本地的patch目录。

  • UpdateVersion:

Patch完整下完之后,我们将LocalGameVersion 和LocalPatchVersion 更新并写入本地的patch目录,并将原本patch过的资源和本次新patch的资源列表合并,更新本地patch目录的checksum文件,该步骤一定是在patch完整下载成功后再进行。

  • GenPatchedList:

通过读取本地的patch过的checksum列表文件,生成一个dict, 用于最终游戏逻辑加载资源判断哪些资源是patch过的,patch过的资源去patch目录读,否则去包体里面读,无论是否有没有资源更新,该步骤在启动游戏阶段都要做一遍。


为了优化玩家在每个月版本时更新的大小,我们在每个月要发布新版本前的一周,会开放prepatch, 在玩家玩游戏的过程中,将下个月需要更新的资源偷偷的下载下来,存入游戏的prepatch目录,该过程只在wifi下进行,并且采取了闲时下载的机制,确保不会影响玩家正常的游戏体验。 等待新版本发布之后,在检查资源更新列表前,我们会通过判断文件的md5方式先检查下prepatch目录是否已经下载好了资源,如果已经下载完毕,则直接从prepatch目录拷贝过来,这样,在真正新版本发布时玩家需要热更的资源就会很少甚至完全不用更新下载任何资源就能进入游戏了。

Prepatch的整体流程和前面提到的资源更新流程基本完全一致,因此实现起来比较容易,相当于把PatchMgr的代码抄了一遍放在一个叫PrePatchMgr的类中,和PatchMgr不一样的地方是为了解决写文件过程中带来的卡顿问题,我们采取了多线程写文件的方式,测试下来表现良好,基本在玩家完全无感知的情况下进行。

经过Prepatch后,对于patch流程的唯一修改在于GenPatchList, 除了根据本地checksum列表和远程checksum比较出需要下载的资源之外,再额外到prepatch目录查看一下当前目标资源是否已经下载过(文件存在且MD5值和远端checksum中记录的一样),如果已经下载过,则直接拷贝过来,否则,再执行下面的流程从远端下载。

随着包体越来越大,一方面会导致Android包体超过2g导致手机无法安装,另外一方面,如果包体太大,会对游戏拉新产生比较大的影响。因此,我们进一步改进了我们的资源更新和加载机制,实现了可以动态调整打出包含部分资源的指定大小的一个小包,玩家在游戏过程中使用按需下载和闲时下载策略的小包体流程,小包体实现过程中,有以下几个主要问题要解决:

1)小包的资源组织,小包里应该包含哪些资源,哪些资源可以进行边玩边下的。

2)外网小包和大包的兼容运营,如何识别一个外网客户端是小包还是大包,同时发布更新时大包和小包能同时下载到正确的资源。

3)小包体并非所有的资源都能在进入游戏后按需下载,比如游戏中的代码,shader等资源patch,如何区分游戏中哪些资源可以边玩边下,哪些资源要提早下完。

4)小包如何进行资源远程下载,确保玩家在玩的过程中能较快的下载到当前需要使用的资源。

由于外网绝大部分玩家都是从旧版本慢慢升级上来的,一般不会下载新的客户端,我们制作的小包体的首要目标人群是新注册用户。因此,小包体中包含的资源首要满足新玩家的体验,从新手阶段到30级之前,所有的场景、UI、和角色模型等资源都默认加入到小包体中,而同时为了兼顾老玩家下载了新包后的体验,目前我们外放的版本最终打出一个1.5g左右的包。同时,在登陆界面给玩家一个选择,是全部下载进入游戏还是采用边玩边下的方式进入游戏。


1)小包体的识别和区分

由于小包体的资源组织方式、资源更新内容和方式与大包不一样,在代码中我们需要加以区分,代码中我们提供了2个接口加以区分, IsSmallPacketClient 含义是该原始包体是否是小包体,直接通过一个宏区分,IsCompleteSmallPacketClient含义是当前包体是不是已经完全更新完资源的小包体,通过本地写一个文件进行区分。


2)小包体中的基础资源

小包体并非对所有资源都能采取边玩边下策略,不少资源需要在进入游戏前一定要被更新到,此类资源我们称为基础资源,目前包括主要游戏中同步加载的资源、shader、代码等。

·小包体中的Package重新生成

为了加速资源资源的下载过程,我们会将游戏中的bundle分成一个个单独的3M的package,但测试发现,该方式对于小包体的应用场景并不合适,主要原因是每次都需要下载3M的内容,会造成边玩边下过程中,资源请求后延迟很久才能真正下载完所需要的资源。因此,小包体中我们采取了另外一套package生成策略,每个package 500k,确保能比较快速地下载到所需资源。



小包体的整体更新流程和之前的更新流程相比,有以下几个变化。

  • GameVersion判断:在之前的更新流程中,如果判断本地版本号和远程版本号一致,可以直接进入游戏,但如果是小包体,由于我们不知道玩家是否之前选择过边玩边下(如果选择了边玩边下也会更新资源,但版本号会被更新到最新),因此,版本号一致的情况下仍然需要检查下是否有patch需要被下载。
  • GenPatchList: 根据远程checksum 和本地checksum生成patch列表的同时,也会根据当前资源是否是基础资源,则加入到基础资源列表。如果有patch需要下载,则会弹窗供用户选择,如果是立即下载,则执行和大包更新一样的流程,等待完全下载完毕再进入游戏,如果选择边玩边下模式,则根据基础资源列表下载,基础资源下载完后则可以直接进入游戏。
  • StartDelayDownload:在下载完基础资源列表后,小包会额外开启一个协程,用于在游戏中边玩边下的逻辑处理。从以下代码可以看出,该协程在有资源下载时会额外从高、中、低三个队列不断的获取需要下载的资源package进行下载。


1)m_NeedDownloadPackage2FileInfo: 具有最低优先级,包含了所有等待下载的资源,该列表主要用于闲时下载,只有当高中两个队列无资源需下载并且在wifi情况下,才会按照列表顺序下载资源。

2)m_DownloadQueueLowPriority: 低优先级列表,该列表由RemoteBundleLoader 负责填充,下载当前客户端正在被请求的,优先级比较低的资源,主要包括一些NPC和其他角色的资源。

3)m_DownloadQueueHighPriority: 高优先级列表,该列表由RemoteBundleLoader 负责填充,下载当前客户端正在被请求的,优先级比较低的资源,主要包括场景资源。将场景资源放在最高优先级队列的目的是为了尽可能降低远程加载场景的延迟。

小包体的资源加载,主要依赖于上述提到的CRemoteBundleLoader类,该类的主要逻辑是根据GenPatchedList 步骤生成的Dict,根据一个资源的patch状态选择从本地加载还是通过远程下载再加载。本地加载的逻辑和前面提到的VFSBundleLoader基本一致,远程下载,就是将下载请求加入到前面提到的下载队列,并将资源请求返回,等待资源真正从远端下载完之后再从本地加载, 具体代码如下。


以上过程是倩女幽魂手游中关于资源更新部分的绝大部分所有逻辑,其中截取了很多代码片段,为了让代码片段更加简洁,我去掉了其中的一些异常处理逻辑。该套逻辑从游戏开发到运维期总共已经接近6年时间,一直很稳定。

希望本篇文章对大家进行游戏开发时的资源热更新有所帮助,如有任何问题,欢迎在评论区和我们交流讨论。