栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 系统运维 > 运维 > Linux

包体的入门到精通

Linux 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

包体的入门到精通

前言

本人在《龙之谷》负责资源管理工作一年多,积累相当多的打包经验。经验都写成博客,所以写一个总结篇,整理一下思路和感悟。

版本管理

版本管理,要从多平台、多渠道、多服务器、多语言等维度进行版本管理。

功能拆分

多平台,一般是3个平台(还有主机平台),如下

    iOSAndroidPC

多渠道,一般是上线前夕需要部署到正式服上的模块,例如以下

    huiweixiaomioppo...

多服务器,一般游戏依据需要,通用的如下

    内网测试服(包括私人测试服)外网测试服(用于接入外部渠道等需要的网络)商务服(用于外部推广使用)审核服(用于版号,ios等审核)准正式服(跟正式服基本一致,一般比正式服提前一个版本)正式服(对玩家开放的服务器)特殊服

实际上游戏运行时通过渠道、平台、版本等包体配置,来指定服务器请求服务器信息。

多语言,这个会根据需求,进行字符串、图片、语音等的替换。常用的方式是通过打包和资源导出时确定需要添加到当前渠道的语言包。

    中文繁体English...

多地区,如果全球发行的游戏,一般还要兼顾地区的多渠道(一般一个海外地区就一个渠道),笔者将其作为一个渠道处理。

CDN目录结构

对应上面的逻辑,我们CDN目录结构设计如下

--InTest
  --Default			
    --IOS
    --Android
    --PC
  --Test
--OutTest
  --Default
  --Test
--Ready
--Release
  --Default
  	--IOS		一般只有一个IOS平台
  	--PC		
  --huawei		一般渠道都只有Android
    --Android	
  --xiaomi
    --Android
  --Japanese	一般多地区,是跟渠道并行一个层级
  	--IOS
  	--Android
  	--PC  

上述只是资源管理部分,在很多情况下,服务器很可能是同一台服务器,比如常见的iOS和Android数据互通。这样是根据游戏启动,游戏包体对应配置到服务端获取对应服务器地址,再连接对应服务器。

CDN文件目录

在平台之下是多版本的资源,会得到如下的目录结构

--PC         		平台
--IOS
--Android
  --1001000			当前版本的资源目录,包含所有资源
    --Assets		ab导出的目录
      	--xxx1.ab	ab文件
      	--xxx2.ab
        --xxx3.ab
        --xxx4.ab
      --Assets		ab的manifast文件
    --Bytes			二进制配置文件目录
      --xxx1.bytes	二进制文件(加密)
  --1001001			只导出比1001001增加和修改的文件
    --Assets
      	--xxx1.ab	修改的保留
      	...xxx2.ab	相同的不放人目录
      	--xxx5.ab	增加的保留
      --Assets
  --1001002
  --1001003
  --1002000			换包需要新启midVer,会全部重新导出一遍资源
  --1002001
  --1001000.txt		版本文件表,包含所有文件信息
  --1001001.txt
  --1001002.txt
  --1002000.txt
  --1002001.txt
  --version.txt		当前版本号

上面的表示就一目了然了

所有,管理维度依次是——服务器类型=》渠道=》平台=》版本

Git管理

应对上述的复杂情况,Git管理目前已经成为一个主流。

    项目中最好只有一条主开发分支线,多服务器、多渠道、多语言、多平台的功能都被包含,关键节点拉出分支固定节点内容要发布一个渠道,从开发主分支拉出分支作为xx渠道分支,导出package热更xx渠道,从xx渠道分支拉出x.x.1版本,提交内容打patch,再从x.x.1 拉出x.x.2提交内容打patchxx渠道换包,将xx渠道合并回主开发分支,重复第2步
打包和资源文件 大包、小包和资源文件

游戏开发过程中,工程中资源分为可热更资源和不可热更资源。
可热更资源,一般包括

    美术资源音效资源配置文件脚本代码

不可热更资源

    需编译代码SDK,第三方库等平台相关环境

打包,就是指将不可热更资源依据平台环境,导出成平台安装包(iOS,Android,PC),
资源导出,就是将可热更资源导出成平台相关的资源文件,进安装包或热更。

打包,分大包和小包。
大包指平台安装包包含所有的资源文件,一般包体非常大,iOS平台会要求使用大包。
小包指平台安装包只包含少量必要资源文件,其他资源文件通过热更下载的方式,一般Android、PC平台使用。

自动化Jenkins系统

自动化打包和资源导出,一般依赖Jenkins系统。
由上文,我们在多渠道、多平台、多服务器三个大维度,每个维度上还有版本维度。然后游戏工程一般非常大,如何解决n * n * n * n的需求呢?

笔者设计了以3个平台(iOS,Android,Pc)建立三个游戏工程目录,通过Git的快速切换分支功能,实现一键式自动化打包和热更:

    基于指定分支拉出渠道分支,指定版本号,然后对三个平台打包和资源导出功能基于指定分支(热更分支),导出热更资源的功能

当然,打包和资源导出都是相当耗时的操作。

平台安装包

不同平台都会生成对应的工程和安装包,然后上传CDN,并生成二维码用于下载。

资源文件

资源文件,一般更合适的名字叫可热更文件。一般包括三种

美术类资源,泛指贴图、模型、动作等,也包括语音、音效等,由美术制作

在导出过程中,一般需要依赖项切割、图集转换、数据压缩等流程,生成引擎和平台相关的资源 数据类资源,指保存数据的配置文件,一般策划维护

在导出过程中,一般需要导表、加密、压缩等流程,生成二进制流文件 代码类资源,指可以热更的脚本代码文件, 由程序开发

在导出过程中,一般需要混淆、加密等安全性流程,生成二进制流文件 最小化依赖树

在美术类资源导出过程中,解决冗余资源、资源复用、加载效率一直是一个难题。
我们知道,为了减少成本和资源大小,多个相似场景会复用大量相同的模型和材质,多个特效会复用相同的材质和贴图,多个角色会复用骨骼和动作,多个UI复用icon。
而这些资源复用,必然导致资源与资源之间的依赖关系,如果处理不好依赖关系,就会造成资源的冗余。问题就归结为需要一个算法实现对资源节点划分成不同的资源文件,保证每个资源节点只出现一次,而资源文件数量最少。
算法分以下几步:

    提供资源路径列表作为输入节点(根节点)对根节点分析依赖项,并将依赖项作节点,形成依赖图对依赖图剪枝(去边),形成依赖树对依赖树减点(去点),形成最小依赖树对依赖树合点(合并点),优化最小依赖树提取最小依赖树,生成打包节点列表

我们先对A,B两个资源root节点进行依赖分析, 经过第1步和第2步,我们实际上得到的图是这样的

注:圆角方形为root节点
当然,我们是不需要这么复杂的图结构的,开始剪枝。

第3步解决的重复依赖关系,简单讲就是父依赖子,子依赖孙,如果父也依赖孙,这条(父依赖孙)是不需要的,需要去除。那么可以得到如下依赖树。

第4步特别难理解,又是依赖,又是被依赖,读者可以对照图加深理解。简单解释就是树删除一个子节点,孙节点全部挂在父节点下。然后我们得到如下的图

我们现在已经得到了我们想要的最小依赖树的所有节点
第5步是一个优化,就不在这里阐述了。

第6步我们获得上图,提取最小依赖树,导出资源文件。

优势:

    导出的资源没有冗余,包体最小化文件数量最小化(非最小,是无冗余情况下最小),下载和加载效率高加载资源时不会加载多余资源
资源热更

在游戏开发中,热更并下载资源,对商业化游戏来说是一个必须的需求。

版本号

版本号是一个通识的,可被玩家查看的标识,也就是最通俗的1.1.1,1.1.2,1.2.1这样的版本号。

版本号一般由三个段组成,
maxVer表示第几代游戏,表示完全跨度的版本,比如龙之谷,龙之谷2,龙之谷3这样的差异
midVer表示换包版本号,表示包体更新,也就是需要更包,换包才需要提升一位
minVer表示资源版本号,表示可以直接可以通过下载热更的,也就是在游戏开始自己提示下载资源的

文件版本和版本文件表

文件版本,顾名思义,每个资源文件有自己的版本号,这个版本号用于确定当前这个资源文件使用的是哪次打包导出的资源文件。为了更准确的,还会有文件长度和文件md5作为检验文件正确性。

我们给每个资源文件赋予了版本号,对于大量文件,就会有对所有版本文件的列表信息构成的版本文件表。这个版本文件表,每个版本都有一个。详细的可参照上文的CDN文件目录。

版本文件表 1001002.txt

xxx1.ab 1.1.0 6744 27692ef1a246710fc647318f38ce6262
xxx2.ab 1.1.1 563 69bfcb99c6c8f8eb91abb1d08c8508c6
xxx3.ab 1.1.0 3422 f19c51bda4b6776287ae9c83e1b7b10b
xxx4.ab 1.1.0 3155 f1a4caff246e377e25002c820db3e327
xxx5.ab 1.1.2 52344 6bfdab505ccf6a38921bfb0bdd184083
...

在版本文件表中,大多数都是安装包的版本号(例如1.1.0),只是少数需要热更的资源文件,才会是热更分支的版本号(例如1.1.1,1.1.2)。安装包的版本号对应的资源文件一般是全量的,热更的资源文件一般是增量的。所以我们玩游戏时,初始下载资源非常多,热更的下载非常少。

热更流程

一般游戏的下载流程大同小异,需要注意以下几个地方。

提取热更文件,是通过本地版本号与资源文件版本号进行对比确定

本地版本号确定的是当前包体版本资源文件版本号大于本地版本号说明资源文件需要新增或者更新版本文件表中的资源文件版本号,用于确定下载哪个版本的资源文件 下载到一半重启,在这种情况下,是否会出现重复下载呢?

不会,如果一个文件确定是热更文件,会先校验大小是否一致,不一致确定下载校验大小一致,还会比较MD5是否一致,不一致下载MD5校验一致,认为下载完成 保存本地版本号,在下载全部完成后,需要提升本地版本号为CDN版本号

旧的本地版本号可能跨多个版本号到CDN版本号提升本地版本号是为了记录包体版本已经更新 玩家不同意,这种情况,一般是直接退出游戏。 多线程断点续传下载

想要高效并稳定地下载文件一直是开发中的一个痛点。

游戏的下载需求

我们能看到市面上,大量游戏都在下载上卡死,断网,重启无效等等问题。我们来总结一下要解决的问题:

    网络请求异常处理——断网、请求失败、请求超时、网络波动、下载到一半等问题文件读写异常处理——文件读失败、文件写失败、文件写一半等问题游戏进程异常处理——下载到一半、文件写一半、玩家退出等问题重启现场恢复处理——可恢复性,继续下载文件的下载正确性——文件长度、文件校验,文件可读性文件线程高效下载——多线程,异步回调文件

下文会为上述的每个问题,提供对应的解决方案。

下载管理器

下图是下载管理器的结构,外部只暴露三个函数,负责异步下载,同步下载,下载回调刷新

内部对同步下载,直接返回结果。
内部实现对异步下载创建线程,对外部隐藏异步线程。
上述异步回调实际上是通过主线程调用DownloadUpdate来遍历的同步回调。

TIP:为什么设计成Update里同步回调呢?

如果直接通过异步线程进行回调,那么在回调函数里必须要处理线程锁的问题和回调可能运行逻辑报错的问题,这样势必造成代码的耦合度增加。
而通过主线程调用Update来回调,可以隐藏了线程逻辑,也可以保证主线程的线性运行。

多线程运行逻辑

很多人可能会有一个简单处理方法,一个文件一个线程,运行结束就销毁。
too young too simple 一个线程要占用1M的内存,而在热更中,几千个小文件轻轻松松的。
那么,我们需要解决几个问题:

    线程的数量上限——内存占用上限,同时运行数量线程的重复利用——降低创建销毁开销线程内存释放——下载结束,不占用内存

很简单的,我们就能想到生产者-消费者模型

整合上文,我们需要四个队列,分别为请求队列,运行队列,完成队列,异常队列。

private static object _lock = new object();
private const int MAX_THREAD_COUNT = 20;

private Queue _readyList;
private Dictionary _runningList;
private List _completeList;
private List _errorList;

既然是多线程,那么上面的锁_lock就是必须的,解决竞争问题。这边_lock是唯一的,必然不会有死锁问题。

MAX_THREAD_COUNT = 20是一个测试得出最高效的值,如果你对内存限制有要求,可以降低个数。

TIP:为什么线程数远大于CPU核数还会更高效?

在下载过程中,有大量时间是在等待网络请求和IO请求的返回,并不消耗CPU运行时间。而一般瓶颈在网络带宽,而不是IO和CPU,多一点的线程,才能够真正跑满网络带宽,实现更高效下载。

线程实现了线程创建、线程循环和线程销毁的逻辑,其实没啥难度

TIP:为什么设计成出现异常就结束线程?

下载一个文件异常,情况非常多,很难判断是网络,线程,IO问题。所以简单地将线程直接结束,让新创建的线程来下载是比较方便的做法。

TIP:如果线程都异常结束了呢?

其实在Update()里有对线程状态的遍历操作,保证线程数量少了创建,异常销毁。

下载回调和线程遍历

Update总共四个步骤,前三步都是运行回调函数,实现回调隐藏线程化
UpdateThread线程遍历就三个逻辑,关闭卡死线程、网络断开处理和开启新线程。
这样子就实现了线程的动态创建和销毁。

文件下载流程

针对文件下载,要保证准确性和高效,设计了如下的流程

获取文件长度,如果下载数据未传入,从CDN拉取文件长度下载文件,断点续传逻辑下载,详见下文MD5校验,文件下载后,校验MD5是否一致,不一致重新下载解压文件,如果文件是压缩包,需要解压文件 断点续传下载逻辑

断点续传有多种方式实现,比如迅雷的下载,是将大文件切成4MB的块,然后不同线程(p2p)下载对应块,然后所有块下载完成,校验一下,整个大文件就下载完成了。
而这里涉及的场景,是对单一路径(CDN)的下载,并不需要那么复杂的逻辑,只需要的是一个可以继续下载的功能。
下载逻辑如下图:

TIP:为什么不下载整个文件,然后写整个文件?

直接写整个文件,这是很多游戏的暴力做法,但导致的问题会很多

    文件过大会占用大量内存文件越大,写入失败概率越高进程/线程意外死亡,文件存在但只有一半大小(文件损坏)浪费网络流量,重启需要重新下载

TIP:每次下载的固定长度为多少?

根据文件系统每个文件块一般大小是4KB(不同文件系统可能不一样),最好是4KB的整数倍,这样能够实现最高效率。这里定义const int oneReadLen = 16 * 1024;,是折中的做法,读者可以自己根据需求定义。

TIP:断点续传是怎么实现的?

当下载线程重新下载这个文件的时候,可以通过判断对应的临时文件是否存在,并获取临时文件的长度,然后从当前长度开始下载,这样就实现了断点续传。

有读者会问,IO写文件写一半怎么办,文件改名失败怎么办,网络断开怎么办?

do while

资源加载

上文已经用最小依赖树导出了资源,那么游戏运行时就要按依赖关系加载资源了。
注:下文用ab(AssetBundle)指代上文的最小依赖树导出资源文件

四个队列

既然是加载资源,那必然会有队列,笔者这边依据需求和优化要求,设计成四个队列,准备队列、加载队列、完成队列和销毁队列。

代码如下

private Dictionary _readyABList; //预备加载的列表
private Dictionary _loadingABList; //正在加载的列表
private Dictionary _loadedABList; //加载完成的列表
private Dictionary _unloadABList; //准备卸载的列表

队列之间,队列成员的转移需要一个触发点,而这样的触发点如果都写在加载和销毁逻辑里,耦合度过高,而且逻辑复杂还容易出错。

TIP:为什么没有设计异常队列?

    一般资源加载,都是默认资源是存在的资源如果不存在,一定是策划没有把资源放进去(嗯,一定是这样)设计上是加载了总依赖关系的Mainfest,是对文件存在性可以进行判断的从性能的角度,通过File.exists()来判断文件存在性,是效率低下的方式代码中对异常是有处理的,会有重复加载,下载和修复完整性的逻辑

笔者很喜欢的一种设计,就是通过Update来降低耦合度,这种方式代码清晰,逻辑简单,但缺点也很明显,丢失原始现场。

回到本篇文章,当然是通过Update来运行逻辑,如下

TIP:为什么Update里三个函数的运行顺序跟队列转移顺序不一样?

    UpdateReady在UpdateLoad后面,可以实现当前帧就创建新的加载,否则要等到下一帧UpdateUnLoad放最后,是因为正在加载的资源要等到加载完才能卸载
函数接口

根据上面的逻辑,很容易设计下面的接口逻辑

LoadMainfest是用来加载文件列表和依赖关系的,一般在游戏热更之后,游戏登录界面之前进行游戏资源依赖初始化。

依赖加载——递归&引用计数&队列&回调

依赖加载,是ab加载逻辑里最难最复杂最容易出bug的地方,也是本文的难点。

难点为一下几点:

    加载时,root节点和depend节点引用计数的正确增加卸载时,root节点和depend节点引用计数的正确减少还未加载,准备加载,正在加载,已经加载节点关系处理节点加载完成,回调逻辑的高效和正确性

我们来一一分解
首先,看一下ab节点的引用计数要实现的逻辑

注: 上图显示加载和销毁都需要递归标记依赖节点的依赖节点
TIP:为什么引用计数一定要递归标记所有子节点?

我们需要确定一个节点是否需要销毁,是通过引用计数是否为零来判断的,很多语言使用的内存回收机制就是引用计数。
如果只标记当前节点和其一层依赖项,当其依赖项也作为主加载节点,我就没办法判断二层依赖节点是否需要销毁了。
例如按上述逻辑,

    加载A,标记A+1,C+1加载C,标记A+1,C+2,D+1卸载C,标记A+1,C+1,D+0这里就会卸载D,而实际上,D仍然是需要保留的,不能卸载

所以,带依赖关系的引用计数,需要递归标记所有子节点,才能确认任意一个节点是否需要卸载。
每次加载,都要递归标记,会不会有效率问题?
很幸运,在绝大多数情况,依赖节点关系不会超过三层,依赖节点总数量不超过10个(生成最小依赖树情况下),一般游戏至少一半以上ab节点都是单节点,不包含需要拆分的依赖关系。

用引用计数的方法,可以确定一个资源是否需要销毁。

上面构造了递归引用计数的逻辑,我们再加入队列的逻辑。

队列逻辑在上文已经描述过了,总结几个要点

    当一个节点引用计数由0变为1时,需要创建ab节点,加入准备队列或加载队列。当一个节点加载完ab,将其加入完成队列当一个节点引用计数由1变为0时,需要加入销毁队列。

从这里,上文已经完成了整个异步加载的逻辑,已经实现创建到销毁的代码。但异步加载还有一个问题没有解决——判断ab节点加载完成。

我们需要在ab节点及其依赖ab节点都加载完后,告诉上层调用逻辑,ab资源加载完了。

笔者提供一种解耦的方式——回调
我们先用图示表示加载A和B到完成的整个过程

注:圆角方形表示ab自身加载完成,箭头表示依赖关系
上图,会按以下回调逻辑

    同时加载A和B,标记引用计数D自身加载完,会回调C;
    C自身没有加载完,然后C会记录子依赖加载情况C自身加载完,但子依赖没加载完,不操作B自身加载完,但子依赖没加载完,不操作E自身加载完,会回调C;
    C的子依赖加载完了,C自己也加载完了,回调A和B;
    A自己没加载完,不操作;
    B自己已经加载完了,子依赖也加载完了,B完成加载A自身加载完,子依赖已经加载完了,A完成加载

按照上述逻辑,读者应该能够理解回调在解决的问题了吧。

到这里,超级复杂的依赖加载问题就解决啦,我们可以欢快地开始使用异步加载啦!!!

异步加载和同步加载一起用

异步加载已经很复杂了,如果还要在异步加载的基础上,使用同步加载,是不是感觉很头大!!!

你会发现难点就一个——正在加载的节点如何强制加载完。
我们这里有四个队列,准备队列、加载队列、完成队列和销毁队列。当加载时分类讨论

    销毁队列不用管,是一个标记队列,用于延迟卸载,不影响加载逻辑完成队列也很简单,只用增加引用计数就可以了准备队列还没开始加载,只需要解决引用计数和依赖关系回调加载队列正在加载中,除了解决引用计数和依赖关系回调,还要解决ab异步转同步的问题

总结一下,就是三个问题——引用计数、依赖关系回调和ab异步转同步

引用计数可以很简单啦,递归一下所有依赖节点,都+1就解决了。
注意:同步加载和异步加载会导致引用计数是2次,需要调用2次Unload才会卸载

依赖关系回调需要强制手动运行被依赖项的回调函数,然后改变队列

ab异步转同步,很幸运的,Unity提供了同步转异步的方式(其他引擎需要自己实现)

在异步请求一个AssetBundle的时候,会返回一个AssetBundleCreateRequest对象,Unity的官方文档上写
AssetBundleCreateRequest.assetBundle的时候这样说:

Description Asset object being loaded (Read Only).

Note that accessing asset before isDone is true will stall the loading process.

经测试,在isDone是false的时候,直接调用request.assetBundle,可以拿到同步加载的结果

到这里,资源加载就讲完了

总结

上文五个部分,将包体的整个流程都讲了一遍,有一些是概念和流程,有一些是本人设计的算法,算是抛砖引玉吧,给大家一些启发。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/750880.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号