Unity中的资源管理-引用计数

本文分享Unity中的资源管理-引用计数

在前面的文章中, 我们一起学习了对象池的基本原理和几种实现, 今天和大家继续聊聊另一个资源管理中比较重要的技术: 引用计数.

GC的基础知识

GC(Garbage Collection)是一种用来自动管理内存的方案, 开发者不需要过多的操心资源的释放问题, 很多语言(比如C#, Java)等, 都有自己的GC.

我们知道, 内存分配可以来自于栈和堆, 一般栈空间是跟随函数的生命周期, 在函数使用期间申请, 在函数使用后释放, 一般在函数内部申请的局部变量(非new关键字申请), 在函数执行完毕后就会被销毁. 所谓函数栈就是指函数栈空间, 每个函数各自独立.

而来自堆空间的申请的内存(一般是new关键字申请)的生命周期会超过函数, 甚至超过类, 是整个程序共享的. 这种内存理论上是无限的大(受程序内存影响), 要求开发者自己申请, 自己释放(因为跨越了作用域).

在没有GC的语言, 比如C/C++中, 开发者需要自己在合适的地方释放内存, 不然就会造成内存泄漏(某块内存不再使用, 但是无法释放), 使用这些语言是需要十分小心的, 所以这些语言的门槛一般比拥有GC的语言高. 相信各位都曾经被指针, 引用等概念所折磨过吧.

在GC的语言中, 将堆内存进一步划分为托管堆非托管堆. 顾名思义, 托管堆就是由GC托管, 维护, 而非托管堆由开发者自己管理(比如文件引用).

拥有GC后, 内存释放的大旗由GC扛过去了, 大部分情况下, 开发者不再操心内存释放的问题, 这极大的降低了开发门槛, 让开发者更加专注于业务, 不必时时刻刻小心内存的问题.

当然, 有好处那就有坏处, 不然C/C++这些语言早就淘汰了.

有GC的语言, 最大的缺点还是在于GC本身.

首先, GC本身需要占用一定的内存, 消耗一定的性能, 无法像C的语言一样, 充分发挥性能. 即拥有GC的语言天然就比不拥有GC的语言更大更复杂更慢(这里只是说普遍情况).

其次, GC的回收总不会十分及时, 虽然能够进行手动干预, 但是也会缺失很大的灵活性和及时性.

最后, 习惯于GC的开发者总是对GC有很大的信任, 习惯于放飞自我, 从而很有可能无法正确的使用内存, 造成内存泄漏.

什么是引用计数

虽然大部分内存可以被托管, 我们无需操心, 但是对于非托管的内存, 比如游戏中用到的各种资源, texture, sound, prefab等, 我们还是需要自己来维护的.

最常用的方案就是引用计数.

简单的说, 引用计数就是为对象的每一个引用都维持一个计数, 释放引用只是将计数减一, 只有在计数为0时才执行真正的对象销毁.

在游戏客户端开发中, 占用大量内存的其实就是各种各样的资源, 而资源是有限的, 并且申请, 初始化, 销毁资源的代价一般比较昂贵, 所以引用计数更多的会用来管理这些内存, 在真正需要的时候才申请, 使用和销毁.

引用计数的原理

和对象池一样, 引用计数的原理非常简单, 但是要用好却并不容易, 简单说就是谁申请谁释放, 申请和释放"成对"使用.

下面我们定义一个引用计数接口和类, 并附加一个简单的测试用例:

public interface IRefCounter {
    // 引用计数
    int refCount {get;}

    // 增持
    void Retain();

    // 减持
    void Release();

    // 为零时的操作
    void OnRefZero();
}

public class RefCounter : IRefCounter {
    public int refCount {get; private set;}

    // 加1
    public void Retain() {
        refCount++;
    }
    
    // 减1
    public void Release() {
        refCount--;

        if (refCount == 0) {
            OnRefZero();
        }
    }

    public virtual void OnRefZero() {
		Debug.Log("释放资源")
    }
}

// 进出房间, 第一个进房间开门, 最后一个离开房间关门
public class Door : RefCounter {
    public void SwitchOn() {
        Retain();

        if (refCount > 1) return;
        Debug.Log("第一个进房间, 开门.");
    }

    public void SwitchOff() {
        Release();
    }

    public override void OnRefZero() {
        Debug.Log("最后一个离开房间, 关门.");
    }
}

public class Person {
    private Door m_Door;

    // 进房间相当于申请内存并持有(count++)
    public void EnterDoor(Door door) {
        m_Door = door;

        door.SwitchOn();
    }

    // 出房间相当于释放内存并减持(count--)
    public void LeaveDoor() {
        Assert.IsNotNull(m_Door, "需要先进房间!");

        m_Door.SwitchOff();
        m_Door = null;
    }
}

public class RefCounterTest : MonoBehaviour {
    public Button btnOpen;
    public Button btnClose;

    private List<Person> m_AllPerson = new List<Person>();

    private void Awake() {
        var door = new Door();
        
        // 每进一个人, 开一次门, 只有第一个才是真正的开门
        btnOpen.onClick.AddListener(() => {
            var person = new Person();
            person.EnterDoor(door);

            m_AllPerson.Add(person);
        });

        // 每退出一个人, 关一次门, 只有最后一个才真正关门
        btnClose.onClick.AddListener(() => {
            var person = m_AllPerson.FirstOrDefault();
            if (person == null)
                return;

            person.LeaveDoor();
            m_AllPerson.Remove(person);
        });
    }

    private void OnDestroy() {
        foreach(var person in m_AllPerson) {
            person.LeaveDoor();
        }

        m_AllPerson.Clear();
    }
}

代码比较简单, 就是每个人进门时做一个计数, 所有人出门后才关门.

自动回收

想象一下, 假设我们的门建设在游戏<<我的世界>>中, 在所有人离开后不仅要关门, 还要将门整个销毁并回收材料, 只需要在上面的实现中, 关门的时候加上资源回收即可.

但是这里依然有一个问题, 就是如果门建立好以后, 一直没有人进去怎么办? 我们什么时候回收呢?

这里就可以使用所谓自动释放池(Autorelease Pool), 在每个门创建之后, 为了最后都能回收, 先将其交给一个管理器, 在帧的末尾进行释放, 如果在此之前有人进门, 则不会进行回收, 而是延迟到所有的人出门, 如果在帧的末尾之前还没有人进门, 就直接释放. 说起来比较麻烦, 实现起来超级简单:

public class AutoReleasePool : Singleton<AutoReleasePool> {
    private List<IRefCounter> m_AutoReleaseCounter = new List<IRefCounter>();

    public void AddCounter(IRefCounter counter) {
        m_AutoReleaseCounter.Add(counter);
    }

    public void AutoRelease() {
        foreach(var counter in m_AutoReleaseCounter) {
            counter.Release();
        }
        
        m_AutoReleaseCounter.Clear();
    }
}

public interface IRefCounter {
    //.....

    // 自动释放
    void AutoRelease();
}

public class RefCounter : IRefCounter {
    //.....
    
    // 自动释放
    public void AutoRelease() {
        AutoReleasePool.instance.AddCounter(this);
    }
}

public class Door : RefCounter {
    //.....
    
    public override void OnRefZero() {
        Debug.Log("关门并销毁, 回收资源.");
    }
}

public class RefCounterTest : MonoBehaviour {
    //.....
    
    private void Awake() {
        var door = new Door();
        door.AutoRelease();

        //.....
    }

    //.....
    private void LateUpdate() {
        AutoReleasePool.instance.AutoRelease();
    }
}

每一帧的末尾都进行减持, 如果没有其它持有者, 就会触发回收. 在帧末尾之前使用完毕, 也只会在自动释放池中进行回收.

自动释放池相当于一个保险, 避免申请了但是实际却没有使用的问题.

总结

引用计数的原理和实现很简单, 但是有各种各样的使用方法, 在不同的情况有不同的变体, 但是总是绕不过一个核心就是: 谁申请谁释放, 申请和释放成对使用. 只要牢牢抓住这个核心, 再复杂的实现也能轻易理解.

今天给大家介绍了引用计数的基本概念, 并提供了一个简单的例子, 最后还添加了一个自动释放池作为保险, 这足以应对大部分应用场景了.

目前的内容都是资源管理的前置条件, 在接下来的几篇文章中, 我们将正式进入Unity中的资源管理内容, 结合Unity官方的方法和这些前置理论就可以构成一套完整的大型商业项目可以使用的资源管理方案. 希望感兴趣的同学持续关注.

好了, 今天的内容就是这些, 希望对大家有所帮助.

上一篇:Unity 指定区域随机实例化预制体Prefab 代码


下一篇:UNITY UGUI跟随3D物体的坐标转换