这是我参与更文挑战的第9天,活动详情查看: 更文挑战
前言
在昨天的文章中说道,以egret引擎为基础的H5游戏场景管理,基本做法都是创建一个场景管理类,但是很多引擎框架并没有涉及UI管理,Html5网络游戏和网页游戏,是属于打开浏览器就可以直接玩的,有些是移动端(手机或者平板),但是我们在进入游戏的时候,不应当将所有UI资源都加载进去,并且每次切换场景的时候,如果每次都重新加载和释放,势必影响游戏体验。所以,对于一个UI资源丰富的大型手游而言,必须对场景切换、UI资源都进行合理管理,深度优化游戏性能,减少draw all 和内存消耗。
本文阅读大纲:
- 1.游戏场景管理
- 2.游戏UI资源管理
- 3.GC机制简介
游戏场景管理
跟上文方式一样, 首先创建一个所有场景的父类Scene,创建场景管理器SceneManger,所有场景的切换,弹出关闭都是由SceneManger类来控制,这样方便对场景进行统一管理。
abstract class Scene extends eui.Component{
public constructor() {
super();
// 监听组件创建完毕 也就是场景的外观创建完毕
this.addEventListener(eui.UIEvent.CREATION_COMPLETE,this.onComplete,this);
}
protected abstract onComplete();
}
记得在入口文件 Main.ts 中引入场景管理类 SceneManage,首先将Main.ts中createGameScene()方法中的代码删掉,再调用下面的方法,将this定为起始场景(舞台)。后面不再赘述,下面介绍UI面板管理。
游戏UI管理
为什么要进行不同场景的UI资源也要进行管理呢,难道不是加载、销毁一把梭吗?
当然不是,比如有这么一个功能,现在有一个游戏项目,玩家进入世界场景,再从世界场景切换出来,进入我的家园 这个场景,该如何设计呢? 当然也有可能进入副本地图之类的,怎么做?
一般场景肯定有进入场景、退出场景、再次进入场景、退出场景、清理场景、更新场景的基本功能,大多游戏基本都是这样,场景有了,这个时候就需要手动创建一个管理器,否则你每次切换场景,UI都全清?有人说即便对象池不清理,界面资源应该释放,这个也不合理,因为你无法保障场景的复用性。
比较保守的做法就是,场景归场景管、UI归UI管,场景切换有管理类、UI应该也需要做一个LRU策略,在这里,UI需要根据使用频率来进行清理,比如15分钟一次都没用到,我们就可以认为该UI资源使用不频繁,这样我们就可以干掉图集和内存,这里只需要设置一个计时器,每次打开的时候记录上时间,并且我只针对关闭的面板遍历,因为已经打开的面板根本不需要去清理,亦不需要去检测,UI资源中最占内存的还是图集,如果可以清理掉低频使用的图集就可以剩下很多内存,减少游戏整体内存压力,而且大型游戏UI资源占游戏体积是最大的。
我们平常使用的游戏引擎,底层默认的操作是只要关闭UI就自动清理,这样会造成CPU发热上升,虽然这样积极地清理内存,但是用户体验就下降了。所以,我们在这里创建的UI管理器,不需要管理UI的释放,只需要给出一个UI面板名,负责调用管理器的开、关就好。
至于UI的释放,可以设计一个机制,除了当前场景,已启用的UI资源15分钟内没有再次启用就自动释放(有点模仿GC的感觉)。这里的15分钟计数器检测的是关闭面板的使用次数,面板打开一次就记一次使用次数,这里关闭的面板值得是曾开启的面板。在这里,我们不需要记录每个面板之前的开启次数、现在的开启次数,只需要在打开的时候递增次数,给出每个面板的总次数即可。15分钟检测的时候如果在你设定的频率,我们可以认为它使用的频繁,如果低于你设置的频率,就可以认为它是低频率使用的面板UI,15分钟后可以清理了。在以上的UI管理器设计思路中,最麻烦的是有些UI资源,它横跨几个面板,属于交叉复用资源,sceneMenu使用了sceneBoot里的资源sound,但是又来该面板的UI被清理掉了,玩家切换到sceneBoot的时候发现没有UI又要重新加载。
在清理UI这方面,需要分情况(比如上面这两张游戏场景图,你可以发现有些UI是复用的)。比如简单的数据类可以通过设置 null 等着GC来回收,比如显示对象,它可能有父对象并且被其他对象引用着,因此需要先 remove child,再进行 delete ,回收的原则是没有其他对象在引用这个对象,因为设置null的前提是你知道这个对象被谁引用了,GC内部的引用计数法会判断对象是否被引用了来决定是否回收。
再补充一点,对象在无人引用的时候就会被删除,可以手动赋值 null,以 Egret 引擎为例,UI从舞台上移除一般不会再被引用了,遗漏移除对象比较好检测,但是 delete 的泄漏比较隐蔽,这一方面,每个团队每个人都有自己的想法。
V8 GC机制
上面提到了利用GC来回收,这里顺便回顾一下GC机制,V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)分为新生代和老生代两部分。
新生代算法
新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将From 空间和 To 空间互换,这样 GC 就结束了。
老生代算法
老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。在讲算法前,先来说下什么情况下对象会出现在老生代空间中:
-
新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
-
To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。老生代中的空间很复杂,有如下几个空间
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不变的对象空间
NEW_SPACE, // 新生代用于 GC 复制算法的空间
OLD_SPACE, // 老生代常驻对象空间
CODE_SPACE, // 老生代代码对象空间
MAP_SPACE, // 老生代 map 对象
LO_SPACE, // 老生代大空间对象
NEW_LO_SPACE, // 新生代大空间对象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情况会先启动标记清除算法:
- 某一个空间没有分块的时候
- 空间中被对象超过一定限制
- 空间不能保证新生代中的对象移动到老生代中
在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。
这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行。清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
总结
以上操作就是UI管理的整体思路,可以结合之前的场景管理一同复习,游戏引擎没有给出,当然每个团队的做法不同,对应场景管理和UI管理,如果有更好的方法,希望大家提出一起学习交流。