..

读书笔记:《Unity3D高级编程:主程手记》

软件架构

架构的好坏

架构的好坏可以从以下几点进行判断。

  • 承载力

    • 从软件架构的程序意义来说,一个架构能承载多少个逻辑系统,当代码行数扩展到100万行时是否依然能够有序且规范地运行,以及程序员彼此工作的模块耦合度是否依然能保持原来的设计要求,能够承载多少个程序员共同开发,共同开发的效率又如何,这是对软件架构承载力的评定指标。
    • 从架构的目标上来看,对于服务器来说,当前架构能承受多少人同时访问,能承载的日均访问量是多少,这就是它承载力的体现。而对于客户端来说,能显示多少UI元素,可渲染多少模型(包括同屏渲染和非同屏渲染),则是它的承载力的体现。
  • 可拓展性

    • 架子能适应不同类型的需求,可添加不同类型的系统、不同功能的子系统,是非常必要的。软件架构也是同样的,但要具备更多功能就必须有更高的可扩展性。
    • 可扩展性的关键在于,是否能在添加新的子系统后不影响或者尽可能少影响其他子系统的运作。
  • 易用性

    • 易用性是架构师比较容易忽视的一个点。如果架构师设计了完整的架构,但具体执行时被程序员认为不好用,这时架构师还是执着地推动它的使用,那么团队间就会加深矛盾,这样开发效率就会下降。
  • 可伸缩性

    • 从服务器端的角度,当需要急速导入大量用户,做到能承载几百万人同时在线时,服务器可随时扩展到几百上千台服务器来提高承载量,而在访问量骤减,或者平时访问量比较少,甚至低到只有几十个人访问时,服务器可缩减到几台机子运作,这样就大大缩减了服务器费用的开销,可以根据需要随时变更架构的承载力来节省成本。
    • 从客户端的角度,伸缩力体现在是否既能适应大型项目上,如上百人协同开发一个复杂系统,也能适应小项目上,如1~3人小团队的快速开发环境,即小成本小作品的快速迭代。
  • 容错性以及错误的感知力

    • 容错性起到了防止产品在使用中出现错误而彻底不能使用的作用,它需要有备份方案自动启用功能,同时也能够让开发人员及时得知问题已经发生,以及问题的所在位置,最好能通过Email或者短信、电话等方式自动通知维护者,并记录错误信息。
    • 从服务器端角度,容错性包括数据库容错性、应用服务器容错性、缓存服务器容错性,以及中心服务器容错性,每个环节出现问题都会通知相关中心服务器改变策略,或者监控服务器检测得知该服务器出现故障,自动更换成备用服务器或者更换链路。
    • 从客户端角度,容错性包括当程序发生错误时,是否同样能够继续保持运行而不崩溃;当这个页面程序出错时,是否依然能够运行其他程序而不闪退或崩溃。同时所有出现的程序错误,都能及时地记录下来并发送到后台,存储为错误日志,便于开发人员及时得到详细的错误信息,能够根据错误信息快速找出问题所在。

软件架构的思维方式

软件架构的思维方式有以下几点。

  • 分层思维:分层是应对复杂性的基本思维武器。通过将系统划分成若干层次,每一层解决某个领域的问题并向上提供服务。有些层级也可以是纵向的,它贯穿其他横向层次,这种层级称为共享层。
  • 分治思维:将一次性无法解决的大问题拆分为小问题,小问题的解的集合就是大问题的解。
  • 演化思维:框架三分靠设计,七分靠演化,既在设计中演化,又在演化中设计。

构建 Unity3D 项目

Unity 的项目分层大致能分为五层,如下图所示。

block-beta
columns 7

UI框架层:7

核心逻辑框架层["核心逻辑\n框架层"]
block:core:6
columns 6
工具编辑器["工具编辑器"]
    角色行为框架["角色行为\n框架"]
    AI框架["AI框架"]
    地图场景与寻路框架["场景与\n寻路框架"]
    着色器与特效["着色器\n与特效"]
    设备平台["设备平台"]
end

资源管理层["资源管理层"]
block:source:6
columns 2
AssetBundle资源管理
Prefab资源管理
end

数据管理层["数据管理层"]
block:data:6
columns 2
内存数据管理
外部数据管理
end

网络层:7

C# 技术要点

Unity3D 中 C# 的底层原理

Unity3D 运行 C# 程序时有两种机制,一种是 Mono,一种是 IL2CPP。

关于Mono

早年由于 .Net Framework 没有开源,无法支持跨平台运行,所以社区开发了 Mono 运行时用于在各个平台运行 C# 程序。

关于 IL2CPP

IL 是一种介于机器语言与高级语言之间的一种语言,诞生的原因是因为由于高级语言的抽象层级太高,如果直接编译为机器语言会非常复杂,高级语言先将自身编译为某种中间表示再编译为机器语言,会降低编译器的实现难度,并提高可维护性。而 .Net 系列的语言的编译出的中间语言就叫作 IL(Intermediate Language),遵循是的微软的 CLR(Common Intermediate Language) 规范。

IL2CPP 所代表的,就是先使用 Mono 将 C# 程序翻译为 IL 语言,IL2CPP 程序再将 IL 语言转换为 CPP 代码,然后使用各个平台的 CPP 编译器将程序直接编译为可执行的机器码。

这里需要注意的是,IL2CPP 有自己的虚拟机,主要用于内存管理,采用的是类似 Mono 的模式。

Unity 之所以需要 IL2CPP,原因如下:

  • 维护成本过大:Unity 的 Mono 虚拟机有自己的修改方案,需要自己维护独有的虚拟机程序。这导致 Unity 在各个平台完成移植工作时,工作量巨大,有时甚至不可能完成。在这种情况下,每新增一个平台,Unity 的项目组就要把虚拟机移植一遍,同时要解决不同平台虚拟机里的问题。而像 WebGL 这样基于浏览器的平台的移植工作甚至不太可能完成。
  • Mono 版本授权受限。Mono 版本无法升级,这也是 Unity 社区开发者抱怨最多的一条,很多 C# 的新特性无法使用。如果换成 IL2CPP,则可以通过 IL2CPP 自己开发一套组件来解决这个问题。
  • 提高运行效率。根据官方的实验数据,换成 IL2CPP 以后,程序的运行效率有了1.5~2.0倍的提升。

List 底层源码剖析

List使用数组形式作为底层数据结构,优点是使用索引方式提取元素很快。缺点是在扩容时会很糟糕,每次针对数组进行new操作都会造成内存垃圾,这给垃圾回收(GC)带来了很大负担。

List 在进行扩容操作的时候,是按照 2 的指数进行扩容。

List 是线程不安全的,当List里的元素不断增加时,会多次重新分配数组,导致原来的数组被抛弃,最后当GC被调用时就会造成回收的压力。

List并不是高效的组件,真实情况是,它比数组的效率还要差,它只是一个兼容性比较强的组件而已,好用但效率并不高。

Dictionary 底层源码剖析

在处理Hash冲突的方法中,通常有开放定址法、再Hash法、链地址法、建立一个公共溢出区等。Dictionary使用的解决冲突方法是拉链法,又称链地址法。

拉链法原理:

将所有关键字为同义词的节点链接在同一个单链表中。若选定的Hash表长度为n,则可将Hash表定义为一个由n个头指针组成的指针数组T[0…n-1]。凡是Hash地址为i的节点,均插入以 T[i] 为头指针的单链表中。T中各分量的初值均为空指针。

在Hash表上进行查找的过程与Hash表构建的过程基本一致。

给定Key值,根据造表时设定的Hash函数求得Hash地址,若表中此位置没有记录,则表示查找不成功;否则比较关键字,若给定值相等,则表示查找成功;否则,根据处理冲突的方法寻找“下一地址”,直到Hash表中某个位置为空或者表中所填记录的关键字等于给定值为止。

浮点数精度问题

在实际工作中,我们很多时候想通过使用double替换float来解决精度问题,最后基本都会以失败告终。。

精度问题:

  1. 数值比较不相等
  2. 数值计算结果不确定
  3. 数值比较不相等

解决方案:

  1. 由一台机器决定计算结果,只计算一次,且认定这个值为准确值,把这个值传递给其他设备或模块,只用这个变量结果进行判断,也省去了多次计算浪费CPU内存空间
  2. 改用 intlong 类型来替代浮点数
  3. 用定点数保持一致性并缩小精度问题
  4. 用字符串代替浮点数

委托、事件、装箱、拆箱

排序算法

业务逻辑优化技巧

  • 使用List和Dictionary时提高效率
    • List
      • 当使用 List 插入时,是向数组中写入元素,并遍历其后面的数据依次向后移动的过程。
      • Contains() 函数,它是一个以遍历形式来寻找结果的函数,每次使用它,都会从头到尾遍历一次,直到寻找到结果,Remove() 也一样,它也是以遍历的形式存在的。
    • Dictionary
      • Dictionary 使用哈希冲突来解决关键字,Hash 值与容器中数组的映射和获取 Hash 值的函数 GetHashCode() 比较关键。Hash 冲突与数组大小有关,所以在初始的时候为 Dictionary 设置合理的初始大小能够比让 Dictionary 自己扩容性能要更好一些。
      • Dictionary使用 Object 类的 GetHashCode() 来获取类实例的Hash值,而 GetHashCode() 是用算法将内存地址转化为哈希值的过程,因此,我们可以认为它只是一个算法,并没有对任何值做缓存,每次调用它都会计算一次Hash值,这是比较隐形的性能损耗。如果频繁使用 GetHasCode() 作为关键字来提取Value,那么我们应该关注 GetHashCode() 的算力损耗,并确认是否可以用唯一ID(标识)的方式来代替 GetHashCode() 算法。
  • 巧用 Struct
    • Struct 是被分配在栈上的,使用完毕后的回收非常快,不会产生内存碎片也不需要内存垃圾回收,对 CPU 读取连续数据也非常友好
    • Struct 的数据存储是连续的,能提高内存访问速度,提高连续内存缓存命中率(如果 Struct 太大,超过了缓存复制的数据块,则缓存不再起作用)
  • 尽可能地使用对象池
    • 内存分配和内存消耗是非常影响性能的,也是提高程序效率的关键所在。既要减少内存分配次数和内存碎片,也要避免内存卸载带来的内存损失。
    • 尽量减少创建对象,使用对象池循环使用对象是一个好的选择。
  • 字符串导致的性能问题
    • C# 对 string 类型没有任何的缓存机制,对 string 类型的任何更改都会重新创建一个 string
    • 解决方案:
      • 自建 string 缓存机制,Dictionary<int, string> strCache,一个 id 对应一个字符串
      • 使用一些 native 方案,在 unsafe 模式下进行指针操作,直接去改变原 string 的值。
  • 字符串的隐藏问题
    • string 在比较字符串的时候,会先比较两个字符串的引用是否一样,如果不一样,则逐字符串比对。也就是说 string 的比较是一个非常费时的过程。

数据表与程序

数据表的种类

数据表是连接美术、设计策划和程序的桥梁。艺术家用它来配置效果,设计师用它来调整游戏的数值平衡,程序员用它来判断逻辑,所以数据表的意义非常大。

可以认为数据表是一个本地的数据库,只不过这个数据库里的数据是不可被修改的只读数据。

在实际项目的开发中,它们大部分是从Excel里生成,再导入游戏中去的,也有其他产生方式,比如使用比较原始的方法直接写在代码里。

  • Excel:快捷、方便、易于保存、上手快、方便传播
  • 代码数据
    • 通常是临时级别的数据,在进行更改、增加、删除时大大增加了程序员的工作量。
    • 这种放在代码里的数据,基本只保存于 Demo 阶段或 Mini 游戏中,因为数据量小、更改的次数少,所以不会特别在意数值的平衡性。
  • 文本数据
    • 文本是一种常用的数据表形式,例如,使用以 .json.xml.csv 为扩展名的文件,里面全是字符串形式的文本,既包括数字的形式,也包括字符串的形式。在程序读取这些字符串内容后,再将它们转化为相应的数据类型,如整数、浮点数、文本、数组,为程序所用。
  • 比特流数据
    • 比特流数据是一种相对机器来说稍微直接点的数据表现形式。我们是将数据以byte的形式存放在文件里,程序通过读取二进制文件里的数据,按一定的规则将其转化为所需要的数据。
    • 相比文本形式的数据文件,比特流数据文件的特点是,占用的空间更小,解析速度更快。
      • 一个10MB的文件在读取的时候是很慢的,因为CPU要等待I/O设备从硬盘里读取数据再放入内存,假如项目中有几个甚至几十个这样的数据文件,在游戏进行中卡顿就很难避免。这么大的数据文件光读取整个内容就已经让I/O速度很慢了,更别说还需要在读取文本数据后进行解析。文本解析要让成千上万个字符串转化为数字或者浮点数,这会消耗比较多的CPU计算量。
    • 但其缺点也存在,通用性较差,数据格式改变比较困难,无法直观看到文件中的内容,也无法做到不依靠程序进行任意修改。

数据表的制作方式

Excel是大部分数值策划者喜欢选用的填数工具,因为Excel为处理数据而生,而Excel转换为什么格式就需要选择了。

最简单的就是直接将Excel里的数据复制粘贴到文本文件中作为游戏数据。但问题是,当需要将多个Excel转换为文件数据时,我们就会遇到麻烦。比如,当我们手动导入时常要想想有没有复制粘贴错,是真的操作错了,还是只是自己健忘。

为了避免出错概率和次数,自动化和流水线就成为进阶的方式,我们可将所有需要人工操作的流程全部写入程序,让程序来帮助我们完成工作。

主流的制作自动化程序的方式有很多,例如,使用Shell或Bat(Window批处理)设计自动化流程操作,也有通过特定语言编写自动化程序的,比如使用C#从Excel中读取数据后写入特定文件,使用.NET库或者其他第三方库来取得Excel里的数据,再将数据以自己希望的格式输出到文件中。

让数据使用起来更加方便

使用什么形式的文件作为数据表并不重要,CSV也照样能把游戏运行得很好,因为这些技术并不能决定游戏的性能,只要我们喜欢,什么形式都可以。很多时候,我们在选定数据存储规则时,大都选择自己喜欢的方式,只要符合团队的做事风格即可,因为这能提升我们的工作效率,加快开发速度,团队不用浪费时间去适应新的规则。

数据表的关键作用是连接游戏策划设计师与其他部门,所以我们在制定数据导入/导出规则的时候需要考虑设计师的体验因素。如何让策划在配置数据表的时候能够有更好的体验就成了关键。

如果只是单个表有了自动化,策划设计人员可以自由地将Excel数据转化成能让程序员读取的数据格式,但是策划设计人员一直在对数据进行变动,特别是对字段的类型、字段的名字进行调整,今天这个字段定义为ID,明天这个字段成为time,或者插入一个新的字段,删除旧的字段,或者新增一个数据表,或者删除一个旧的数据表,等等,这会让程序员很头疼,每次更改都需要及时通知程序员,即使及时收到通知,也会遇到不少麻烦。

一个可行的办法是,在程序命令中预留几个参数,参数指向某个需要导出的文件及sheet。那么在命令行里,执行这个程序且后面跟上参数就能导出数据。

在Excel中生成数据时自动与程序的变量对齐,就能起到校验和索引的作用,为此我们再来加一个让Excel字段名与程序对应的规则。编写一个程序,让程序生成一段代码,这段代码里变量定义的数字与每个数据表上字段名的索引对应,将每个要导出的sheet里的头行的列名作为变量名写入程序变量定义中,以方便程序在读取数据表时列名与数据表对齐,这样不仅有了读取数据表列的Key值,还无形中进行了表数据与程序索引的校验。

多语言的实现

使用 Excel , Json, Csv 或者任何的数据存储方案,最终的目的是实现 Key-Value 列表。当需要输出某些内容的时候,调用相应的方法填入 Key,从表格中获取 Value 进行输出。

1string content = TextMgr.GetTextString("Win", Language.Chinese);

转换数据时,也需要注意拆分数据表的问题,如果把所有数据表的数据都集中在一个数据文件里,那么游戏在加载数据表时,就需要在一瞬间集中处理,导致CPU阻塞时间过长,发生游戏卡顿现象,这样做并不合理,我们需要让游戏表现得尽可能顺畅,因此要尽可能将阻塞情况平摊在时间线上。

分散读取比较可取,读取表数据时要合理安排读取顺序,将I/O与CPU消耗的时间分散开来,不会一下子对I/O或CPU有大的需求量。很多时候,我们会采用按需读取的方式去读取数据,但多数情况是在某个瞬间需要大部分数据,此时按需读取已经不再有效,最好的办法是在加载时指定读取顺序,并隔帧读取。

用户界面

UGUI系统的原理及其组件使用

UGUI是在3D网格下建立起来的UI系统,它的每个可显示的元素都是通过3D模型网格的形式构建起来的。当UI系统被实例化时,UGUI系统首先要做的就是构建网格。

也就是说,Unity3D在制作一个图元,或者一个按钮,或者一个背景时,都会先构建一个方形网格,再将图片放入网格中。可以理解为构建了一个3D模型,用一个网格绑定一个材质球,材质球里存放要显示的图片。

如果每个元素都生成一个模型且绑定一个材质球存入一张图片,那么界面上成千上万个元素就会拥有成千上万个材质球,以及成千上万张图。这样使得引擎在渲染时就需要读取成千上万张图以及成千上万个材质球,如果GPU对每个材质球和网格都进行渲染,将会导致GPU的负担重大,我们可以理解为一个材质球拥有一个DrawCall会导致DrawCall过高(DrawCall的原理将在后面章节介绍)。

UGUI系统对这种情况进行了优化,它将一部分相同类型的图片集合起来合成一张图,然后将拥有相同图片、相同着色器的材质球指向同一个材质球,并且把分散开的模型网格合并起来,这样就生成几个大网格和几个不同图集的材质球,以及少许整张的图集,节省了很多材质球、图片、网格的渲染,UI系统的效率提升了很多,游戏在进行时也顺畅了许多。这就是我们常常在UI系统制作中提到的图集概念,它把很多张图片放置在一张图集上,使得大量的图片和材质球不需要重复绘制,只要改变模型顶点上的UV和颜色即可。

UGUI系统并不是将所有的网格和材质球都合并成一个,因为这样模型前后层级就会有问题,它只是把相同层级的元素,以及相同层级上的拥有相同材质球参数的进行合并处理。合并成一个网格,就相当于是一个静止的模型,如果移动了任何元素,或者销毁了任何元素,或者改变了任何元素的参数,原来合并的网格就不符合新的要求了,于是UGUI系统就会销毁这个网格,并重新构建一个。我们设想一下,如果每时每刻都在移动一个元素,那么UGUI系统就会不停地拆分合并网格,也就会不停地消耗CPU来使得画面保持应有的样子。这些合并和拆分的操作会消耗很多CPU,我们要尽一切可能节省CPU内存,尽量把多余的CPU让给核心逻辑。UGUI系统在制作完成后,性能优劣差距很多时候都会出现在这里,我们要想方设法合并更多的元素,减少重构网格的次数,以达到更少的性能开销目的。

快速构建一个简单易用的UI框架

UIManager 需要承担的工作:查找现有的某个UI,需要销毁UI,以及完成UI的统一接口调用和调配

UI 基类需要承担的工作:提供初始化、开启和关闭接口

UI 优化

我们可以从几个方面来讲解UI的优化,包括UI动静分离、拆分UI、预加载、Alpha分离、字体拆分、滚屏优化、网格重构优化、UI展示与关闭的优化、对象池的运用、贴图设置的优化、内存泄漏、针对高低端机型的优化、图集拼接的优化、UI业务逻辑中GC的优化等。

动静分离

动指的是元素移动,或者缩放频率比较高的 UI。静则是界面上不会移动、旋转、缩放、更换贴图的和颜色的的 UI。

之所以需要分离出来,是因为 UGUI 使用网格模型构建 UI 画面,构建后都执行了合并网格的操作,因为如果不合并会增加很多 DrawCall,进而导致渲染队列阻塞。

虽然合并操作能够减少 DrawCall,但是 UGUI 中无论哪个 UI 元素,只要一动就需要合并网格,这会导致原本不需要重新构建的内容也一并构建。

所以,需要将会动的 UI 元素和不会动的元素分离开,让网格合并的范围缩小,这样就能节省 CPU 的开销。

UGUI 中的网格合并是以 Canvas 为基础单位的,会动的 UI 元素的 Canvas 即使嵌套在不会动的 UI 元素的 Canvas 下,也不会影响合并网格。

拆分过重的 UI

随着项目的推进,经手 UI 的人越来越多,有时一个 Prefab 里装着 2~3 个界面。这会导致在实例化和初始化时 CPU 的消耗变大。

我们可以把隐藏的 UI 拆分出来,使其称为独立运作的界面,只在需要展示时才能调用实例化。

如果在拆分后界面内容依然太多,则需要进行二次拆分,即把二次显示的内容进一步拆分。

什么是二次显示的内容?打个比方,一个界面打开时会显示一些内容(例如动画),之后或者点击后才能看到另外一些内容,或者当点击按钮时才出现某些图标和动效,那么这些内容就可以视为二次显示的内容。可以考虑将其拆分出来设置成为一个预置体,当我们需要时它再加载。

UI 预加载

当UI实例化时,需要将Prefab实例化到场景中,期间还会有网格的合并、组件的初始化、渲染初始化、图片的加载、界面逻辑的初始化等程序调用,会消耗很多CPU。

我们可以在游戏开始前加载 UI 资源但不进行实例化,这样在需要使用的时候,CPU 只需要进行实例化和初始化即可。

所有的预加载都会引出另一个问题,即 CPU 集中消耗会带来卡顿现象。预加载并没有削减 CPU 消耗,只是将这些消耗分离了或提前了,拆分到了各个时间碎片里,让人感觉不到一瞬间的 CPU 消耗。

UI 字体拆分

项目中的字体通常会占用很大的空间,如果几个不同的字体一起展示在屏幕上,会消耗较大的内存。字体很多有时候不可避免,但需要规范和整理,并且也需要优化。我们需要更高的性能效率,拆分字体会让字体的加载速度更快,也会让场景的加载速度更快。

解决方案是把字体中的常用字拆分出来,另外生成一个字体文件,让字体文件变小,消耗内存变少,最终使得加载变快。

网格重构的优化

UGUI系统的网格合并机制是,只有将拥有相同材质球的网格合并在一起,才能达到最佳效果。一个材质球对应一个图集,只有相同图集内的图片才需要合并在一起。

在 UGUI 系统中,当元素需要改变颜色时,是通过改变顶点的颜色来实现的,即改变当前元素的顶点颜色,然后将它们重新合并到整块的网格里去。因为不能直接从原来合并好的网格上找到当前的顶点位置,所以需要一次性合并重构网格。改变Alpha也是同样的道理,Alpha本身就是Color里的一个浮点数,附在顶点上并成为顶点的一个属性,所以改变Alpha也就是改变顶点的Color属性。

在UI动画里,每一帧都会改变UGUI的颜色和Alpha,自然,UGUI的每一帧也都会对网格进行一次重构。这样做消耗了大量的CPU运算,通常会使得UI在运行动画时效率低下,即使动静分离也无济于事。

我们可以自己建一个材质球,提前告诉UGUI:我们使用自己的特殊的材质球进行渲染。当颜色动画对颜色和Alpha进行更改时,我们直接基于自定义的材质球改变颜色和Alpha。这样UGUI就不需要重构网格了,因为把渲染的工作交给了新的材质球,而新的材质球在颜色和Alpha上的变化都是通过改变材质球属性来实现的,并不是通过UGUI设置顶点颜色来达到效果的,这样就减少了UGUI重构网格的消耗。

UI 展示与关闭的优化

UI 的展示与关闭动作最常见,但打开界面和关闭界面都会消耗一定的CPU,因为打开时需要实例化和初始化,关闭需要销毁GameObject。事实上,在实际项目中CPU的消耗量巨大。

  • 利用碎片时间进行预加载,这会让展示速度更快
  • 在关闭时隐藏节点而不是销毁,界面打开时再次激活所有节点。关闭和激活时,虽然内存没有变化,但网格重构和组件激活会有大量的CPU消耗,所以移出屏幕代替隐藏会更好
    • 移出屏幕的UI并不会让CPU的消耗全部消失,为了减少更多的消耗,我们在移出屏幕后还需要关闭一些脚本上的更新内容,但即使是这样,也会有不少组件是无法停止的,只不过比起销毁和隐藏会好很多。
    • 当需要显示时再移入屏幕,有时候移入后进行初始化回到原来的状态也是必要的。可是移出屏幕后,相机仍然会对其进行裁剪判断,因此我们还需要设置UI为不可见的层级 Layout,使其排除在相机渲染之外,这样就节省了相机的裁剪消耗,当需要展示时再设置回UI层级。

GC 优化

GC主要是指堆上的内存分配和回收,Unity3D中会定时对堆内存进行GC操作。

Unity3D中的堆内存只会增加,不会减少,也就是当堆内存不足时,会向系统申请更多内存,但不会在空闲时还给系统,除非应用结束重新开始。

主要有三个操作会触发GC操作:

  1. 在堆内存上进行内存分配操作,如果内存不够,就会触发GC操作来利用闲置的内存。
  2. 自动的触发GC操作,不同的平台运行的频率不一样。
  3. 被强制执行GC操作。

可以通过以下三种方法来降低GC操作的影响:

  1. 减少GC的运行次数。
  2. 减少单次GC的运行时间。
  3. 将GC的运行时间延迟,避免在关键时刻触发,比如可以在加载场景的时候调用GC。

降低 GC 的实现方案:

  1. 对游戏进行重构,减少堆内存的分配和引用的分配。更少的变量和引用会减少GC操作中的检测个数,从而提高GC操作的运行效率
  2. 降低堆内存分配和回收的频率,尤其是在关键时刻。
  3. 可以试着分析GC操作和堆内存扩展的时间,使其按照可预测的顺序执行。

减少内存垃圾的方法:

  1. 缓存变量,达到重复利用的目的,减少不必要的内存垃圾
  2. 减少逻辑调用。堆内存分配,最坏的情况就是在其反复调用的函数中进行堆内存分配,例如,在每帧都调用的Update()和LateUpdate()函数里,如果有内存分配,就会放大内存垃圾。
  3. 清除列表。进行列表分配时清除列表,而不是不停地生成新的列表。
  4. 对象池。使用对象池技术保留废弃的内存变量,重复利用时不再需要重新分配内存,而是利用对象池内旧有的对象。
  5. 字符串。在C#中,字符串是引用类型变量而不是值类型变量,即使看起来它存储的是字符串的值。这就意味着字符串会生成一定的内存垃圾,由于代码中经常使用字符串,所以我们需要对其格外小心。
  6. 移除游戏中的Debug.Log()等LOG日志函数的代码。对于游戏来说,LOG日志其实很消耗资源,特别是在战斗激烈的情况下,本来就宝贵的CPU大量消耗在了LOG日志上不划算,它不但分配了字符串,还不间断地往文件里写数据。
  7. 协程。调用StartCoroutine()会产生少量的内存垃圾,因为Unity3D会生成实体来管理协程。任何在游戏关键时刻调用的协程都要特别注意,尤其是包含延迟回调的协程。
  8. Foreach循环。在Unity3D 5.5以前的版本中,foreach的迭代也会生成内存垃圾,主要来自其后的迭代器。(现在已经没有这样的影响了)
  9. 函数引用。函数的引用,无论是指向匿名函数还是显式函数(在Unity3D中这两种函数都是引用类型变量),它们的分配都会在堆内存上进行。
  10. LINQ和常量表达式。由于LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试。
  11. 主动调用GC操作。
There is nothing new under the sun.