您的位置:首页 > 移动开发 > Unity3D

Unity中的C#内存管理(一)

2016-05-19 15:45 363 查看
本系列文章的第一部分讨论了.Net/Mono和unity内存管理的基本知识。第二部分深入介绍了Unity
Profiler和CIL相关知识,以发现C#代码中不必要的内存分配。

第三篇文章即本文将介绍对象池。到目前为止,我们一直关注的是堆分配。现在我们还想要避免不必要的内存释放,以至于在游戏运行时不会因为垃圾回收器(GC)回收内存而产生帧率下降。对象池是解决这个问题的理想方案。我将展示三种对象池的完整代码(你可以在GithubGist中找到这些代码)。

从一个非常简单的对象池类开始

对象池背后的理念非常简单。不再使用new创建新的对象,而是在对象池中储存用过的对象,允许他们之后再被回收,从而在需要时重复使用他们。对象池最重要的一个特性、真正的对象池设计模式的本质是当我们需要获得一个新的对象时,不需要关心对象是重新创建的还是循环使用的原来对象。下面几行代码就是对象池设计模式的具体实现:

[C#] 纯文本查看 复制代码

?
非常简单,但这的确是核心模式的完美实现。(如果你不懂"where T..."语法,下面将会解释)。想用这个类,就不能像下面这样用new操作符来创建类:

[C#] 纯文本查看 复制代码

?
而是成对使用 New() 和 Store()方法:

[C#] 纯文本查看 复制代码

?
这样比较繁琐,因为你需要记住在New()方法之后在正确的位置调用Store()方法。不幸的是,没有一种通用的方法来简化此设计模式的使用,因为不管是ObjectPool还是C#编译器都不知道对象什么时候可以被重新使用。恩,其实还有一种通过垃圾回收器自动管理内存的方法。这种方法的缺点在文章开始处你已经读过。也就是说,在幸运的情况下,你可以使用文章最后说明的“对象池全部重置”模式。那里,所有的Store()调用都会被替换为调用ResetAll()方法。

增加ObjectPool 类的复杂度

我是简洁代码(simplicity)的粉丝,大道至简。但是,ObjectPool
类现阶段可能有些太简单了。如果你搜索C#的对象池库,会找到很多解决方案,其中有些方案相当精妙且复杂。因此,退一步来思考我们需要或者不需要什么功能,比如通用的对象池查找功能。

许多对象类型在被重新使用之前需要以某种方式“重置”。至少,所有的成员变量都可以被设置为其默认状态。这些都是由对象池透明处理而非使用者。重置调用的时机和方式由下面两个设计特征决定:

积极重置(每次存储时重置)或者延迟重置(对象使用前重置)。

重置由池(由池处理,对类来说透明)或者类(对池对象的声明者透明)来管理。

在上面的例子中,对象池“poolOfMyClass”被显示声明为类成员变量。很明显,这样的对象池需要为每个新类型资源声明一个实例(My2ndClass等)。还有一种方案,ObjectPool类可以创建和管理这些对象池,而用户不用关心这些。

你在这里还有这里可以找到几个对象池库,用它们来管理各种类型的资源(内存、数据库连接、游戏对象、外部assets等)。这往往会增加对象池代码的复杂度,因为,不同资源的处理逻辑差别很大。

一些稀缺资源类型(例如,数据库连接)对象池需要强制设定使用上限,并提供一种安全的方式来分配一个新的或者循环使用的对象。

如果对象池在某些时刻创建了大量对象,我们可能希望对象池有能力减少对象的创建(自动或者按命令)。

最后,对象池可以由多个线程共享,在这种情况下它必须是线程安全的。

上面这些特性哪些是值得实现的呢?我们都有自己的看法。但是,我来解释一下我自己的优先级。

重置功能必须有。但是,下面你会发现,完全没必要纠结重置逻辑是由对象池还是托管类来处理。你可能两者都需要,后文的代码分别实现了两种情况。

Unity强加了多线程限制。基本上,除了游戏主线程之外你还可以创建一个工作线程。但是,只有游戏主线程可以调用Unity的API。在我看来,这意味着我们可以为所有线程单独创建对象池,因此也可以移除“多线程支持”的需求。

就个人而言,我不介意为每个对象类型声明一个新的对象池。但是,还有一种方案:单例模式。ObjectPool类通过存储在静态变量中的对象池Dictionary创建和保存对象池实例。要让其正常工作,你必须保证ObjectPool类可以在多线程环境正常工作。然而,我至今为止也没有看到一个100%安全的多线程对象池解决方案。

在本篇教程中,我只关心对象池处理的一种稀缺资源类型:内存。但是,其它类型的对象池也很重要。只是超出了本教程的范围。

这里对象池不会强制设置最大限制。如果游戏消耗太多内存,说明游戏存在问题,这不是对象池要解决的问题。

同样,我们假定没有其它进程正在等待你尽快释放内存。这意味着重置是可以延迟的,而且对象池没有动态减少占用内存的功能。

带有初始化和重置功能的基本对象池

我们修正的ObjectPool <T>类如下所示:

[C#] 纯文本查看 复制代码

?
这种实现非常简单明了,参数T通过“whereT:class, new()”指定了两种限制方式。第一,T必须是一个类(毕竟,只有引用类型需要对象池),第二,它必须有无参构造函数。

构造函数使用估测的最大值作为对象池的第一个参数。其他两个参数都是可选参数。如果有值,则第一个用来重置对象池,第二个用来初始化一个新的对象池。ObjectPool<T>除构造函数外只有两个方法:New()、Store()。因为,对象池使用的是延迟重置方法,所以,所有工作都在New()方法中,即重新创建或者循环使用对象被初始化或重置之后。这就是两个可选参数的设计目的。下面是继承自MonoBehavior的对象池类:

[C#] 纯文本查看 复制代码

?
如果你看过本系列教程的第一篇,就会知道从内存角度来说,在poolOfListOfVector3定义两个匿名委托函数是可以的。一方面,它们并非真的闭包而是“局部定义函数”,另一方面,这不重要因为对象池有类级别的作用范围。

可以让托管类型重置自身的对象池

对象池的基础版有了它应该有的功能,但它有一个概念性的缺陷。它违反了封装原则,没有把初始化/重置对象和对象类型的定义分开,导致了代码的紧耦合,这是应该要避免的。在上面的SomeClass 例子中,没有备选方案,因为我们不能改变List<T>的定义。然而,当你在对象池中使用了自定义类型对象,你可能希望它们执行IResetable
接口。相应地ObjectPoolWithReset<T>类也因此可以无需使用两个闭包作为参数(为了灵活性而保留的)。

[C#] 纯文本查看 复制代码

?
带有整体重置功能的对象池

游戏中有些数据结构可能绝不会持续超过一个序列帧,而是在每帧结束前被释放。在这种情况下,如果很好的定义了所有对象重新存入对象池的时间点,那么这个对象池将更易用,效率也会更高。让我们先看看代码。

[C#] 纯文本查看 复制代码

?
改写之后的版本与最初版本基本一致。只是Store()被ResetAll()取代,这样当所有创建的对象被存入对象池时只需要调用一次。在类的内部,存储所有(甚至是正在被使用的)对象引用的Stack<T>被替换为List<T>,我们在list中也跟踪记录了最近被创建或释放对象的索引。那样的话,New()可以知道是要创建一个新的对象还是重置一个已存在对象。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: