引用池的作用

GF 这里的引用池其实就是我们平常说的对象池,在程序中主要是起到防止对象被频繁创建和销毁、减少 gc、预加载等作用,若对对象池作用和原理不太清楚的话,可以参考书籍《游戏编程模式》中对象池模式一节。
GF 中池子有两种,一种叫引用池,一种叫对象池,两者原理一样,但具体实现和针对的对象不同,引用池一般用来储存普通的 C# 类型对象,而对象池则一般用于储存 UnityEngine 下的对象(如 Unity 中的 GameObject 对象),两者区别将在下一篇对象池篇中详细分析。本文将详细说一下其中的引用池。

引用池的实现

结构

引用池部分主要由 4 个部分组成,静态类 ReferencePool、ReferencePool 的内部类 ReferenceCollection、结构体 ReferencePoolInfo 和接口 IReference。

IReference 接口

IReference 接口只包含一个 Clear 方法,此方法会在对象回收池被调用,每一个需要被引用池储存的类型都需要实现此接口,以能清空当前状态,恢复到初始状态,供下次使用。

ReferenceCollection 池子类

游戏中对象池通常不止一个,对象池应该为每个需要用到对象池的类型,都创建一个对象池,不同类型的对象储存在各自类型的池子中。这里一个 ReferenceCollection 对象则代表了一个类型的引用池。

  • ReferenceCollection 内部用 m_References 字段(Queue 类型)来储存池子中的对象。
public T Acquire<T>() where T : class, IReference, new()
{
    if (typeof(T) != m_ReferenceType)
    {
        throw new GameFrameworkException("Type is invalid.");
    }
 
    m_UsingReferenceCount++;
    m_AcquireReferenceCount++;
    lock (m_References)
    {
        if (m_References.Count > 0)
        {
            return (T)m_References.Dequeue();
        }
    }
 
    m_AddReferenceCount++;
    return new T();
}
  • Acquire 方法获取池子中的一个对象,若当前池子中存在可用对象,则直接从队列取出,若当前池子没有可用对象则会通过反射创建新对象并返回,注意这里新创建的对象是不会直接放进池子中的,只有当用户把他放回池子的时候才会加入储存队列中。Acquire 泛型方法要求泛型参数是 class、实现了 IReference 接口,且具有公共的无参数构造方法。
public void Release(IReference reference)
{
    reference.Clear();
    lock (m_References)
    {
        if (m_EnableStrictCheck && m_References.Contains(reference))
        {
            throw new GameFrameworkException("The reference has been released.");
        }
 
        m_References.Enqueue(reference);
    }
 
    m_ReleaseReferenceCount++;
    m_UsingReferenceCount--;
}
  • 回收对象时需要调用 Release 方法,把对象作为参数传进来,方法内部会把对象加入到可用队列中。
  • Add 和 Remove 方法可以按数量直接往池子队列里添加或移除对象,RemoveAll 则清空引用池,这几个接口主要用于预加载和确保后续一定时间内不会用到这些对象时,在性能不敏感期间手动释放引用池。
  • 属性 ReferenceType 表示该引用池持有对象的类型。
  • 属性 UnusedReferenceCount、UsingReferenceCount、AcquireReferenceCount、ReleaseReferenceCount、AddReferenceCount、RemoveReferenceCount,分别表示目前池子可用的数量(剩余可被取出的数量)、被取出未归还的数量、请求获取的次数、释放的次数、实际实例化次数、主动移除次数。这些数据会在调用引用池相应接口时进行计算,外部可获取这些数据进行 Debug。

ReferencePool 类

ReferencePool 是一个静态类,负责管理所有类型的引用池,也是外部访问引用池的入口,注意,上面介绍的 ReferenceCollection 类,其实是 ReferencePool 类的私有内部类,外部不会直接访问 ReferenceCollection,而是通过访问静态类 ReferencePool 的 API,ReferencePool 内部再获取对应类型的 ReferenceCollection 进行相应的操作。

  • ReferencePool 包含一个 Dictionary 类型字段 s_ReferenceCollections,用于储存所有引用池实例,类内通过私有方法 GetReferenceCollection 向 s_ReferenceCollections 获取某个类型的引用池实例,若 s_ReferenceCollections 不存在该类型,则构造一个,为惰性初始化。
  • 属性 Count 可以获取引用池数量。
  • 其中 Acquire、Release、Add、Remove、RemoveAll 都是通过 GetReferenceCollection 获取到对应类型的引用池实例 ReferenceCollection 后,调用该实例的相应方法。
  • ClearAll 方法可以清空并销毁所有引用池。
  • GetAllReferencePoolInfos 方法可以获取所有引用池的数据,返回值是 ReferencePoolInfo 数组类型,具体数据其实来自于 ReferenceCollection 类的属性当中,这些数据主要用于外部对引用池 Debug。注意 ReferencePoolInfo 是个结构体,本身不会造成 gc 负担,但数组是会有 gc 的。

强制类型检查

特别地说一下,ReferencePool 类内有个 bool 类型变量 EnableStrictCheck,控制着类型检测开关,若处于 True 状态时会在部分对引用池操作的步骤中,加入类型检测的步骤。

ReferencePool 类的 InternalCheckReferenceType 方法:

private static void InternalCheckReferenceType(Type referenceType)
{
    if (!m_EnableStrictCheck)
    {
        return;
    }
 
    if (referenceType == null)
    {
        throw new GameFrameworkException("Reference type is invalid.");
    }
 
    if (!referenceType.IsClass || referenceType.IsAbstract)
    {
        throw new GameFrameworkException("Reference type is not a non-abstract class type.");
    }
 
    if (!typeof(IReference).IsAssignableFrom(referenceType))
    {
        throw new GameFrameworkException(Utility.Text.Format("Reference type '{0}' is invalid.", referenceType.FullName));
    }
}

ReferenceCollection 类中的 Release 方法:

public void Release(IReference reference)
{
    reference.Clear();
    lock (m_References)
    {
        if (m_EnableStrictCheck && m_References.Contains(reference))
        {
            throw new GameFrameworkException("The reference has been released.");
        }
 
        m_References.Enqueue(reference);
    }
 
    m_ReleaseReferenceCount++;
    m_UsingReferenceCount--;
}

根据上面代码我们可以观察出,开启类型检测,一方面检测是不是非抽象 Class,且实现了 IReference 接口的 Class,另一方面是释放对象(也就是把对象放回对象池时),需要检查这个对象是不是已经在池子中了,如果业务逻辑有误,同一个对象,重复放回池子的话,那整个池子的状态就被破坏了,下次取出时可能会发生奇怪且难以定位的 bug(如取出某个对象,在使用期间突然状态被 Clear 了)。

示例

引用池以本系列文章的有限状态机篇的示例作为扩展,把状态类实例作为复用对象,这样如果我们重复销毁、创建状态机持有者实例时,可以复用这些状态实例。
可到有限状态机篇参看原本未使用引用池时的实现。主要修改是 IdleState 和 MoveState 实现 IReference 接口的 Move 方法,并添加静态方法 Create,作用是向引用池获取一个自身类型的对象。Player 类则注意调用状态类的 Create 方法来获取实例,并且在 Player 销毁时释放状态实例。

空闲状态类

using UnityEngine;
using GameFramework.Fsm;
using ProcedureOwner = GameFramework.Fsm.IFsm<Player>;
using UnityGameFramework.Runtime;
using GameFramework;
 
public class IdleState : FsmState<Player>, IReference
{
    //触发移动的指令列表
    private static KeyCode[] MOVE_COMMANDS = { KeyCode.LeftArrow, KeyCode.RightArrow, KeyCode.UpArrow, KeyCode.DownArrow };
 
    protected override void OnInit(ProcedureOwner fsm) {
        base.OnInit(fsm);
    }
 
    protected override void OnEnter(ProcedureOwner fsm) {
        base.OnEnter(fsm);
    }
 
    protected override void OnUpdate(ProcedureOwner fsm, float elapseSeconds, float realElapseSeconds) {
        base.OnUpdate(fsm, elapseSeconds, realElapseSeconds);
 
        foreach (var command in MOVE_COMMANDS)
        {
            //触发任何一个移动指令时
            if (Input.GetKeyDown(command))
            {
                //记录这个移动指令
                fsm.SetData<VarInt32>("MoveCommand", (int)command);
                //切换到移动状态
                ChangeState<MoveState>(fsm);
            }
        }
    }
 
    protected override void OnLeave(ProcedureOwner fsm, bool isShutdown) {
        base.OnLeave(fsm, isShutdown);
    }
 
    protected override void OnDestroy(ProcedureOwner fsm) {
        base.OnDestroy(fsm);
    }
 
    public static IdleState Create() {
        IdleState state = ReferencePool.Acquire<IdleState>();
        return state;
    }
 
    public void Clear() {
        //此类无状态记录,Clear为空实现
    }
}

移动状态类

using UnityEngine;
using GameFramework.Fsm;
using ProcedureOwner = GameFramework.Fsm.IFsm<Player>;
using UnityGameFramework.Runtime;
using GameFramework;
 
public class MoveState : FsmState<Player>, IReference
{
    private static readonly float EXIT_TIME = 1f;
    private float exitTimer;
    private KeyCode moveCommand;
 
    protected override void OnInit(ProcedureOwner fsm) {
        base.OnInit(fsm);
    }
 
    protected override void OnEnter(ProcedureOwner fsm) {
        base.OnEnter(fsm);
 
        //进入移动状态时,获取移动指令数据
        moveCommand = (KeyCode)(int)fsm.GetData<VarInt32>("MoveCommand");
    }
 
    protected override void OnUpdate(ProcedureOwner fsm, float elapseSeconds, float realElapseSeconds) {
        base.OnUpdate(fsm, elapseSeconds, realElapseSeconds);
 
        //计时器累计时间
        exitTimer += elapseSeconds;
 
        //switch(moveCommand)
        //{
        //根据移动方向指令向对应方向移动
        //}
 
        //达到指定时间后
        if (exitTimer > EXIT_TIME)
        {
            //切换回空闲状态
            ChangeState<IdleState>(fsm);
        }
    }
 
    protected override void OnLeave(ProcedureOwner fsm, bool isShutdown) {
        base.OnLeave(fsm, isShutdown);
 
        //推出移动状态时,把计时器清零
        exitTimer = 0;
        //清空移动指令
        moveCommand = KeyCode.None;
        fsm.RemoveData("MoveCommand");
    }
 
    protected override void OnDestroy(ProcedureOwner fsm) {
        base.OnDestroy(fsm);
    }
 
    public static MoveState Create() {
        MoveState state = ReferencePool.Acquire<MoveState>();
        return state;
    }
 
    public void Clear() {
        //还原状态内数据
        exitTimer = 0;
        moveCommand = KeyCode.None;
    }
}

玩家类

using System.Collections.Generic;
using UnityEngine;
using GameFramework.Fsm;
using StarForce;
using GameFramework;
 
public class Player : MonoBehaviour
{
    private static int SERIAL_ID = 0;
 
    private IFsm<Player> fsm;
 
    // Start is called before the first frame update
    void Start()
    {
        //创建状态列表(不用引用池)
        //List<FsmState<Player>> stateList = new List<FsmState<Player>>() { new IdleState(), new MoveState() };
 
        //创建状态列表(使用引用池)
        List<FsmState<Player>> stateList = new List<FsmState<Player>>() { IdleState.Create(), MoveState.Create() };
 
        //创建状态机,注意,对于所有持有者为Player类型的状态机的名字参数不能重复,这里用自增ID避免重复
        fsm = GameEntry.Fsm.CreateFsm<Player>((SERIAL_ID++).ToString(), this, stateList);
        //以IdleState为初始状态,启动状态机
        fsm.Start<IdleState>();
    }
 
    // Update is called once per frame
    void Update()
    {
 
    }
 
    private void OnDestroy()
    {
        //取出状态机所有状态
        FsmState<Player>[] states = fsm.GetAllStates();
        //销毁状态机
        GameEntry.Fsm.DestroyFsm(fsm);
 
        //把状态实例归还引用池
        foreach (var item in states)
        {
            ReferencePool.Release((IReference)item);
        }
    }
}

Inspector 面板

可以通过引用池的 Inspector 面板 Enable Strick Check 来开启上文说的类型检测,面板上还会具体显示当前引用池数量,并按程序集分开显示所有类型的引用池的 ReferencePoolInfo 信息。可以通过此面板方便地检查业务逻辑中有没有正确使用引用池,例如某个对象只会在某个流程中会使用,我们可以检测在流程循环中,这个对象的 Acquire 和 Release 是否相等,而流程结束时,Using 是否为 0,Unused 是否与 Add 相等。

思考

为什么需要类型检查

上面已经提及到,类型检查一方面检查是不是非抽象 Class,且实现了 IReference 接口的 Class,另一方面是释放对象(也就是把对象放回对象池时),需要检查这个对象是不是已经在池子中了。我们再来看看下面 ReferencePool 的一段源码。

/// <summary>
/// 从引用池获取引用。
/// </summary>
/// <typeparam name="T">引用类型。</typeparam>
/// <returns>引用。</returns>
public static T Acquire<T>() where T : class, IReference, new()
{
    return GetReferenceCollection(typeof(T)).Acquire<T>();
}
 
/// <summary>
/// 从引用池获取引用。
/// </summary>
/// <param name="referenceType">引用类型。</param>
/// <returns>引用。</returns>
public static IReference Acquire(Type referenceType) {
    InternalCheckReferenceType(referenceType);
    return GetReferenceCollection(referenceType).Acquire();
}

可以看到当用泛型时是不需要调用 InternalCheckReferenceType 的,只有用非泛型时才需要调用 InternalCheckReferenceType 来检查,其实是因为泛型通过 where 来进行约束了,如果使用者以错误的方式使用的话是会产生编译错误的,但如果用参数类型为 Type 的重载的话,内部会使用反射来创建实例,这样无法像泛型那样具备编译时的类型安全特性,所以需要先一步检查类型,若错误则抛出错误,中断逻辑,避免后续实例化失败破坏了引用池的状态。

什么时候开启类型检查

类型检查也是基于反射的,对性能会造成一定影响,特别是我们使用引用池一般都是针对高频复用的实例,这样造成的性能损耗并不符合我们的要求。实际上开启类型检查后,启动框架也会在 Console 出现提示 “Strict checking is enabled for the Reference Pool. It will drastically affect the performance.“。
对此,作者 E 大建议是仅在测试环境下开启,ReferencePool 在 Inspector 面板直接提供了 AlwaysEnable、OnlyEnableWhenDevelopment、OnlyEnableInEditor、AlwaysDisable 4 种模式可选,分别对应 总是启用、仅在开发模式时启用、仅在编辑器中启用、总是禁用,大家在使用时可以根据需求选择。

最后

GameFramework 解析 系列目录:GameFramework 解析:开篇

个人原创,未经授权,谢绝转载!