对象池——让你的Flash项目平稳消耗内存

程序开发时,必须考虑到内存消耗,否则,它可能拖慢程序运行,占用系统大量内存或是直接引发崩溃。本教程旨在帮你避免一些潜在问题。

我们来看看最终的效果

点击查看原图

在舞台上随意点击,会生成烟花特效,留意舞台左上方的的内存指示器。

步骤 1:简介

如果你曾用过任何工具、代码或类库来获取程序当前的内存消耗以评估测试,你一定多次注意到:内存占用时高时低(当然,假若从未有过,说明你代码太强了!)。虽然这些峰看起来挺酷,但对于你的程序不是什么好事儿,最终受害者是用户。

步骤2:内存使用的优劣之分

下面的图例是糟糕内存管理的很好例子。它来自一个游戏原型。你需要注意到两点:内存使用的大量尖刺和最大峰值。最大时几乎达到540Mb!这就是说此时这个游戏原型在用户PC的RAM里生生独吞下了540Mb——这显然要避免。

点击查看原图

问题是这样引发的:你在程序中创建了大量对象实例。在下一次垃圾回收运行之前,弃置的对象会一直呆在内存里,当它们被回收——内存占用解除,于是形成了巨大的峰。或者更坏的是,这些对象不满足回收条件,程序消耗内存持续增长,直到崩溃。

本教程不讨论垃圾回收机制。我们将建立一种结构,有效的管理内存中的对象,使它稳定利用并防止垃圾回收机制将其回收,以此来加快程序运行。看看还是那个游戏,在优化之后的性能表现

点击查看原图

步骤 3:对象池类型
可以这样理解对象池技术:在程序初始化时,实例化预设数量的对象,并贮存在内存里直到程序结束。当程序索取对象时,它会给出,当程序不再需要某个对象,它就会将其重置为初始状态。对象池类型很多,我们今天今天只看两种:静态和动态对象池。

静态对象池实例化预设数量的对象,并且在程序的整个生命周期都只保存这么多的对象。如果程序索取对象,但对象池已给出所有对象,它就返回一个null。使用这种对象池,一定要记住处理返回值为null的情形。

动态对象池在初始化时,也是实例化预设数量的对象。但是,当程序索取对象而池子已“空”的时候,它会自动实例化一个对象,增大池子的容量然后将这个对象添加进池子。

本教程中,我们将创建一个简单程序,但用户点击舞台,它会生成一些粒子。这些例子寿命有限,它们会被移出屏幕并回收到池子。为了实现效果,我们先做一个不使用对象池技术的demo,看看它的内存使用情况。然后再做一个采用此技术的,加以比较。

步骤 4:初始程序

打开FlashDevelop新建一个AS3工程。我们将使用一个小的彩色方块儿来充当粒子,使用代码绘制并向随机方向移动。新建一个类Particle,继承自Sprite。我想你能够独立完成这个例子类,因此只贴出记录粒子寿命并移出屏幕这部分代码。如果你有确实无法独立完成粒子类,文章开头有整个源文件的下载。

private var _lifeTime:int;

public function update(timePassed:uint):void { // Making the particle move x += Math.cos(_angle) * _speed * timePassed / 1000; y += Math.sin(_angle) * _speed * timePassed / 1000;

// Small easing to make movement look pretty
_speed -= 120 * timePassed / 1000;

// Taking care of lifetime and removal
_lifeTime -= timePassed;

if (_lifeTime <= 0)
{
    parent.removeChild(this);
}

}

上面的代码负责将粒子移出屏幕。变量_lifeTime用来控制粒子在屏幕上存在的毫秒数。我们在构造函数中将其初始化为1000。update()函数将按帧频触发,他接受两帧之间的时间差值作为参数,并递减粒子的寿命值。

private var _oldTime:uint;
private var _elapsed:uint;

private function init(e:Event = null):void { removeEventListener(Event.ADDED_TO_STAGE, init); // entry point stage.addEventListener(MouseEvent.CLICK, createParticles); addEventListener(Event.ENTER_FRAME, updateParticles);

_oldTime = getTimer();

}

private function updateParticles(e:Event):void { _elapsed = getTimer() - _oldTime; _oldTime += _elapsed;

for (var i:int = 0; i < numChildren; i++)
{
    if (getChildAt(i) is Particle)
    {
        Particle(getChildAt(i)).update(_elapsed);
    }
}

}

private function createParticles(e:MouseEvent):void { for (var i:int = 0; i < 10; i++) { addChild(new Particle(stage.mouseX, stage.mouseY)); } }

粒子update()函数的代码你因该很熟悉:它是一个简单的时间循环的基础,在游戏中常用。别忘了导入声明:
import flash.events.Event;
import flash.events.MouseEvent;
import flash.utils.getTimer;

你现在可以测试这个程序,使用FlashDevelop内置的分析器。在屏幕上点击多次。下面是内存消耗的显示图:


点击查看原图

我拼命地点,直到垃圾回收机制运行。程序产生了2000多个符合回收条件的粒子。看起来像不像刚才那个游戏原型?很像,显然不能这样子做程序。为了更简便的测试内存消耗,我们将添加在步骤1提到的一个功能类。下面是Main.as:


private function init(e:Event = null):void
{
    removeEventListener(Event.ADDED_TO_STAGE, init);
    // entry point
    stage.addEventListener(MouseEvent.CLICK, createParticles);
    addEventListener(Event.ENTER_FRAME, updateParticles);

addChild(new Stats());

_oldTime = getTimer();

}

别忘了导入net.hires.debug.Stats,它会被用到的!

步骤 5:定义一个可以poolable(可以用对象池管理的)对象。

步骤4中的程序看起来非常简单,它产生了简单的粒子特效,但在内存方面表现糟糕。接下来,我们将开始使用对象池来解决这一问题。

首先,我们想一下如何安全有效的使对象实现pooled(被对象池管理)。在一个对象池中,我们必须确保它给出的对象符合使用标准,回收的对象完全独立(也就是不再被其它部分引用)。为了使每一个对象池中的对象都能满足以上条件,我们创建一个接口。这个接口约定两个函数renew()和destroy()。这样,我们在调用对象的这些方法时,就不必担心它们是否具备了。这也意味着,任何想进入对象池管理的对象都必须实现这个接口。下面是接口代码:


package
{
    public interface IPoolable
    {
        function get destroyed():Boolean;

    function renew():void;
    function destroy():void;
}

}

显然我们的粒子需要由对象池管理,因此要让它们实现Ipoolable接口。我们把构造函数中的代码都搬到renew()函数中,而且通过对象的destroy()函数解除了所有外部引用。代码如下:

/* INTERFACE IPoolable */

public function get destroyed():Boolean { return _destroyed; }

public function renew():void { if (!_destroyed) { return; }

_destroyed = false;

graphics.beginFill(uint(Math.random() * 0xFFFFFF), 0.5 + (Math.random() * 0.5));
graphics.drawRect( -1.5, -1.5, 3, 3);
graphics.endFill();

_angle = Math.random() * Math.PI * 2;

_speed = 150; // Pixels per second

_lifeTime = 1000; // Miliseconds

}

public function destroy():void { if (_destroyed) { return; }

_destroyed = true;

graphics.clear();

}

构造函数并不接受参数。如果你想给对象传递什么信息,需要通过它的函数来完成。鉴于renew()内部的运行方式,我们需要在构造函数里将_destroed变量设置为true,以保证renew()函数的运行。

这样我们的Particle类就实现了Ipoolable约定的功能,对象池也就能创建一池子的粒子了。

步骤 6:开始构建对象池

简单起见,对象池将设计成单例模式。这样我们写代码随时随地都能用得它。新建一个类“ObjectPool”,写入下面的代码,构建一个单例模式:

package
{
    public class ObjectPool
    {
        private static var _instance:ObjectPool;
        private static var _allowInstantiation:Boolean;

    public static function get instance():ObjectPool
    {
        if (!_instance)
        {
            _allowInstantiation = true;
            _instance = new ObjectPool();
            _allowInstantiation = false;
        }

        return _instance;
    }

    public function ObjectPool()
    {
        if (!_allowInstantiation)
        {
            throw new Error("Trying to instantiate a Singleton!");
        }
    }

}

}

变量_allowInstantiation是这个单例类的核心:它是private的,所以只有类本身才能修改,它唯一需要修改的时候,也就是在第一个实例被创建之前。

接下来思考在这个类内部该如何保存多个对象池。因为它将是全局的(也就是要做到将程序内任何一个合法对象进行对象池管理),我们必须给每个对象池取一个唯一的名字。怎样实现?方法很多,但目前我所想到的最好方法,就是使用对象自己的类名称。这样我们就会有“Particle”池,“Enemy”池等等…但这样存在一个问题。我们知道类名称只在包内具有唯一性,这即是说,在“enemies”包和“structures”包内可以同时存在一个“BaseObject”类,如果同时对他们实例化,对象池的管理就存在问题。

使用类名称作为对象池的标示符仍具有可行性,不过这就得求助于flash.utils.getQualifiedClassName()了。这个函数能够生成类的全称,含包路径在内。这样,用每个对象的类名称作为对象池标示符就没问题了。我们将在下一步来实现。

步骤 7:创建对象池

既然我们已经有方法来标识每个对象池,现在就用代码来实现吧。我们的对象池应该足够灵活,同时支持静态和动态类型(我们在步骤3提到的概念,还记得吧?)。我们还需要存储每个对象池的容量和内部活动对象(就是已经被程序使用)的数量。一个好方法是建立一个私有类,用它存储所有这些信息,并将对象池保存到一个Object里。

package
{
    public class ObjectPool
    {
        private static var _instance:ObjectPool;
        private static var _allowInstantiation:Boolean;

    private var _pools:Object;

    public static function get instance():ObjectPool
    {
        if (!_instance)
        {
            _allowInstantiation = true;
            _instance = new ObjectPool();
            _allowInstantiation = false;
        }

        return _instance;
    }

    public function ObjectPool()
    {
        if (!_allowInstantiation)
        {
            throw new Error("Trying to instantiate a Singleton!");
        }

        _pools = {};
    }

}

}

class PoolInfo { public var items:Vector.<IPoolable>; public var itemClass:Class; public var size:uint; public var active:uint; public var isDynamic:Boolean;

public function PoolInfo(itemClass:Class, size:uint, isDynamic:Boolean = true)
{
    this.itemClass = itemClass;
    items = new Vector.<IPoolable>(size, !isDynamic);
    this.size = size;
    this.isDynamic = isDynamic;
    active = 0;

    initialize();
}

private function initialize():void
{
    for (var i:int = 0; i < size; i++)
    {
        items[i] = new itemClass();
    }
}

} 上面的代码创建了一个私有类,它可以保存一个对象池所有信息。我们还创建了一个对象_pools来持有对所有对象池的引用。下面,我们将在这个类中,创建一个函数来注册对象池: ***代码 public function registerPool(objectClass:Class, size:uint = 1, isDynamic:Boolean = true):void { if (!(describeType(objectClass).factory.implementsInterface.(@type == “IPoolable”).length() > 0)) { throw new Error(“Cant pool something that doesnt implement IPoolable!”); return; }

var qualifiedName:String = getQualifiedClassName(objectClass);

if (!_pools[qualifiedName])
{
    _pools[qualifiedName] = new PoolInfo(objectClass, size, isDynamic);
}

}

代码看起来有些微妙,但先别害怕,这就来解释。第一个if语句看起来很晦涩。你可能以前从未见识过这些函数,下面罗列出它们的功能:
我们传递一个对象给describeType()函数,它就能够生成一个XML用来描述这个对象的所有信息。
若对应一个类,那么它的所有信息被包含在factory标签里。
在这个标签里,XML又将所有的接口信息描述在一个implementsInterface标签里。
我们检测一下Ipoolable接口是否在里面。如果找到了,我们就可以把这个类加入对象池,因为我们可以将它转化为IObect。

接下来的代码,若该对象池不存在,那就在_pools中新建一个。随之,PoolInfo类的构造函数会调用内部的initialize()函数,从而创建一个由我们预设大小的对象池。接下来就要使用它了!

步骤 8:获取一个对象
上一步我们创建了一个能够注册对象池的函数,但现在为了使用它,我们需要从中取得对象。这很简单:如果池子未“空”我们就返回一个对象。如果池子已“空”,我们就看看它是不是动态类型;如果是,那就增加它的容量,创建一个对象并返回。若不是,那就返回null。(你也可以选择抛错,但最好让代码在这种情况下保持继续运行)

下面是getObj()函数:

public function getObj(objectClass:Class):IPoolable
{
    var qualifiedName:String = getQualifiedClassName(objectClass);

if (!_pools[qualifiedName])
{
    throw new Error("Cant get an object from a pool that hasnt been registered!");
    return;
}

var returnObj:IPoolable;

if (PoolInfo(_pools[qualifiedName]).active == PoolInfo(_pools[qualifiedName]).size)
{
    if (PoolInfo(_pools[qualifiedName]).isDynamic)
    {
        returnObj = new objectClass();

        PoolInfo(_pools[qualifiedName]).size++;
        PoolInfo(_pools[qualifiedName]).items.push(returnObj);
    }
    else
    {
        return null;
    }
}
else
{
    returnObj = PoolInfo(_pools[qualifiedName]).items[PoolInfo(_pools[qualifiedName]).active];

    returnObj.renew();
}

PoolInfo(_pools[qualifiedName]).active++;

return returnObj;

}

在这个函数中,我们首先检验对象池是否存在。若存在,我们接着检验对象池是否为“空”:若已经“空”了,但类型是动态类,我们就创建一个对象并把它加入对象池。若它不是动态类型,代码返回null。如果pool尚有未被使用的对象,我们就取出最开头儿的那个未被使用的对象,调用它的renew()函数。这一点很重要:我们对一个已经存在于池子的对象调用renew()函数,目的是使它处于可以使用的状态。(即初始化)

你可能会问,我们在这个函数里为什么不用那个很酷的describeType()呢?原因很简单:describeType函数在每次被调用的时候都会生成一个XML,所以,我们尽量不要创建那些极耗内存且我们无法控制的对象。而且,仅仅检验对象池是否存在已经足够:如果这个类压根儿就没有实现IPooable接口,那它就不可能拥有自己的对象池。如果它不用有自己的对象池,函数的第一个if语句就足以将其捕获。

我们现在修改Main类并使用对象池啦!代码如下:

private function init(e:Event = null):void
{
    removeEventListener(Event.ADDED_TO_STAGE, init);
    // entry point
    stage.addEventListener(MouseEvent.CLICK, createParticles);
    addEventListener(Event.ENTER_FRAME, updateParticles);

_oldTime = getTimer();

ObjectPool.instance.registerPool(Particle, 200, true);

}

private function createParticles(e:MouseEvent):void { var tempParticle:Particle;

for (var i:int = 0; i < 10; i++)
{
    tempParticle = ObjectPool.instance.getObj(Particle) as Particle;
    tempParticle.x = e.stageX;
    tempParticle.y = e.stageY;

    addChild(tempParticle);
}

}

点击编译并测试内存消耗。这是我得到的结果:

点击查看原图

相当的酷,是吧?

步骤 9:向对象池返还对象

我们已经成功实现了一个可以获取对象的对象池。但还不算完。我们现在只是从池子里取对象,但还没有在废置后把它们放回去。接着在ObjectPool.as类里加入一个返还对象的函数:

public function returnObj(obj:IPoolable):void
{
    var qualifiedName:String = getQualifiedClassName(obj);

if (!_pools[qualifiedName])
{
    throw new Error("Cant return an object from a pool that hasnt been registered!");
    return;
}

var objIndex:int = PoolInfo(_pools[qualifiedName]).items.indexOf(obj);

if (objIndex >= 0)
{
    if (!PoolInfo(_pools[qualifiedName]).isDynamic)
    {
        PoolInfo(_pools[qualifiedName]).items.fixed = false;
    }

    PoolInfo(_pools[qualifiedName]).items.splice(objIndex, 1);

    obj.destroy();

    PoolInfo(_pools[qualifiedName]).items.push(obj);

    if (!PoolInfo(_pools[qualifiedName]).isDynamic)
    {
        PoolInfo(_pools[qualifiedName]).items.fixed = true;
    }

    PoolInfo(_pools[qualifiedName]).active--;
}

}

我们来梳理一下这个函数:首先检验传递进来的对象是否有相应的对象池。想必你很熟悉代码了——唯一的不同点,就是此处我们使用一个实例来获得类的限定名,前面用的是类,但这不影响输出。

接着,我们获取这个对象在对象池中的索引值。若它根本就不在(就是小于0)对象池内,我们就忽略它。一旦确定它在对象池内,我们就把它从当前位置删除并重新插入到最后面。为什么呢?因为我们计算被程序使用的对象数量时,是从对象池的最前端往后数的,我们需要将对象池从新整理,以使得所有被返还并闲置的对象处于对象池尾部。这就是我们在这个函数实现的功能。

对于静态类型的对象池,由于我们创建的Vector对象是固定长度的。因此无法使用splice()和push()方法。变通方案就是,暂时改变它的fixed属性为false,移出对象并从末尾重新插入,然后再将fixed属性改回true。我们还需要将活动对象的数量递增1.这样之后,就完成了返还对象的工作。

现在我们已经创建了返还对象的代码,我们可以使粒子在“将死之时”自动的将自己返还到对象池。在Particle.as内部这样写:

public function update(timePassed:uint):void
{
    // Making the particle move
    x += Math.cos(_angle) * _speed * timePassed / 1000;
    y += Math.sin(_angle) * _speed * timePassed / 1000;

// Small easing to make movement look pretty
_speed -= 120 * timePassed / 1000;

// Taking care of lifetime and removal
_lifeTime -= timePassed;

if (_lifeTime <= 0)
{
    parent.removeChild(this);

    ObjectPool.instance.returnObj(this);
}

}

注意到我们增加了一个函数调用ObjectPool.instance.returnObj()。这就是为何它能自动将自己返还给对象池。我们现在可以测试程序了。

点击查看原图

看到没,即使点击产生上百的粒子,内存依然相当稳定!