复用对象的技巧

10 篇文章 0 订阅
5 篇文章 0 订阅

合理的复用对象能够减少内存消耗,也能节省CPU时间,但也会增加一些常驻内存,但通常来说,这点内存消耗是值得的,频繁的生成和销毁对象,不仅会消耗内存,也会由于GC造成卡顿。

那我们应该在什么时候去复用对象呢?

一、在方法执行过程中临时使用的一些变量

二、对象生命周期较短,且使用次数频繁

三、多次获取,但不经常变化的对象或集合

我们通常使用缓存和对象池来解决这些问题

从本质上来说,对象池也是一种缓存技术,我们只是根据其设计复杂度进行区分。

一、我们先从缓存来举例

假如我们要在Update里执行一个字符串拼接的任务,如果用string的+号来拼接会产生较多的中间临时string变量,因此我们使用StringBuilder来拼接字符串

void Update()
{
    StringBuilder sb = new StringBuilder();
    sb.Append(str1);
    sb.Append(str2);
    sb.Append(Str3);
    UIText.text = sb.ToString();
}

本来用StringBuilder组装字符串效率是比较高的,但由于每次执行都重新分配了一个StringBuilder,内存反而会高一些,因此我们可以提取出StringBuilder到方法外,从而完成复用

StringBuilder sb = new StringBuilder();
void Update()
{
    sb.Clear();
    sb.Append(str1);
    sb.Append(str2);
    sb.Append(Str3);
    UIText.text = sb.ToString();
}

另外有一些容器也是经常被复用的,比如我们知道,如果在foreach迭代一个集合的时候,再对集合本身进行插入删除操作,在C#中就会抛出版本不一致的异常,因此我们通常会在迭代过程中用一个临时数组保存在迭代过程中需要插入的对象,在遍历结束后,再将列表中的新元素加入集合。此时也可以用缓存来解决这个问题

List<Item> AddList = new List<Item>();
void Update()
{
    AddList.Clear();
    foreach(var item in collection)
    {
       if (item.NeedAddNewItem())
       {
           AddList.Add(new Item);
       }
    }
    
    foreach (var addItem in AddList)
    {
        collection.Add(addItem);
    }
}

ps:如果我想要在遍历过程中新加入的元素也参与本次的遍历,该怎么做呢?

注意,因为此时的Item可能比较特殊,所以可能只给这个类使用。但是如果像是Transform和int或者string这种很多个类都会使用到的临时容器,我们可以用一个专门的缓存类或者对象池来复用这些容器。

另外需要注意的是,不是所有的对象都是能够被重复利用的,有一些Unity或C#提供的对象,本身不支持重置状态,就只能重新New了,比如UnityWebRequest, TaskCompletionSource等。在使用缓存之前要弄清楚,你所要复用的对象,是否支持复用,能够复用?如果要复用,需要怎么样清理状态和重新准备等。比如我们的StringBuilder和List都需要在使用前Clear一遍。

二、关于第二类问题,频繁使用但生命周期较短的对象,我们通常使用对象池技术来复用对象。

关于对象池,可以参考Unity源代码中频繁使用的对象池非常不错。

public class Pool<T> where T : new()
{
    private readonly Stack<T> Stack = new Stack<T>();
    private readonly Action<T> OnCreate;
    private readonly Action<T> OnGet;
    private readonly Action<T> OnRelease;
    
    
    public int CountAll { get; private set; }
    public int CountActive => CountAll - CountInactive;
    public int CountInactive => Stack.Count;

    public Pool(Action<T> onGet = null, Action<T> onRelease = null, Action<T> onCreate = null)
    {
        OnGet = onGet;
        OnRelease = onRelease;
        OnCreate = onCreate;
    }

    public T Get()
    {
        T element;
        if (Stack.Count == 0)
        {
            element = new T();
            OnCreate?.Invoke(element);
            CountAll++;
        }
        else
        {
            element = Stack.Pop();
        }
        OnGet?.Invoke(element);
        return element;
    }

    public void Release(T element)
    {
        OnRelease?.Invoke(element);
        Stack.Push(element);
    }

    public void Clear()
    {
        CountAll = 0;
        Stack.Clear();
    }
}

使用对象池最关键的地方在于要深刻理解你所复用对象的生命周期,和最大同时持有数量。需要保证对象在创建和回收时,总是成对的调用对象池的接口,以防止内存泄漏。

另外需要注意的点就是关于复用对象的清理和重用的准备工作,清理一定要彻底,确保对象能够重用,这点跟缓存技术是保持一致的。另外要说的清理就是,比如你在战斗中用了对象池,在战斗后需要清空掉整个对象池。这点其实很容易忽略,因为如果不清理的话,你对象池里的对象还是占据着内存空间的,并同时可能对整个战斗模块保持着引用,从而导致内存的释放不干净,内存泄漏等问题。

在这里,我们额外研究两类特殊的问题。

第一个是:数组怎么复用?

有一种情况下是,有些Unity或C#系统接口,接受的参数就是一个Array<T>类型的数组,该数组要特定的长度,所以你不能传一个足够大的Array数组,然后再要求系统用多少拿多少,通常没有这个接口。但问题来了,数组的长度不固定,我们该怎么复用?

面对这个问题要具体情况具体分析,首先要确定在你的逻辑里,总共会有多少长度不同的数组,如果统计出来的跨度很长很大,那么复用的必要性就很小了。但如果调用的次数很多,很频繁,但来来回回就那么几个长度,我们就可以根据数据的长度不同,去缓存不同长度的数组对象,从而达成复用。

读表会经常用到字符串切割,会涉及到string[]的临时分配,但统计出来,比如配表的最大也就长度为10,但是执行可能会有百万次。因此复用的收益就非常高了。

另一种情况就是,接口允许你传入一个数组,和指定的长度,在这种情况下,也先看数组的长度分布,如果很少就同上述做法即可,可如果跨度很大呢?别急,有一种办法,就是我们选若干个长度固定的数组,当你需要分配数组时,我总是会给你一个略微大你一点的数组给你。这样就可以满足要求,那这个长度怎么选呢?一般来说就是2的幂次方。但是有个限制,当过大的时候,都走同一个池子即可。

也就是我们准备了长度为4,8,16,32,64...等若干大小的数组池子。你如果需要一个15的我就给你16长度的,需要100的,就给你128长度的,以此类推。还是注意,这种情况的前提是,你的接口允许在接受数组时,接受额外的长度参数。

private Queue<byte[]> Pools_4 = new Queue<byte[]>();
private Queue<byte[]> Pools_8 = new Queue<byte[]>();
private Queue<byte[]> Pools_16 = new Queue<byte[]>();
private Queue<byte[]> Pools_32 = new Queue<byte[]>();
private Queue<byte[]> Pools_64 = new Queue<byte[]>();
private Queue<byte[]> Pools_128 = new Queue<byte[]>();
private Queue<byte[]> Pools_256 = new Queue<byte[]>();
private Queue<byte[]> Pools_512 = new Queue<byte[]>();
private Queue<byte[]> Pools_1024 = new Queue<byte[]>();
private List<byte[]> Pools_Other = new List<byte[]>();

当然,有一种情况是,你可以传入一个数组的起始位置和长度,上述用2的倍数来做有一个坏处是,空间浪费的情况比较严重。那能不能优化呢?其实是可以的。怎么办,就是自己写一套垃圾回收系统和内存分配系统,在一段连续的数组中,自己去找到可用的数组段,然后把下标和长度返回给外部系统使用。怎么样,是不是很可怕。其中还涉及到越界访问,内存分配和回收,内存压缩,碎片整理等等问题,实现代价非常大,但是效果肯定也是最好的。这里就不展开讨论了,这也是一种设想,实际的落地难度非常之大。

第二种情况是,Monobehavior和Component这类对象怎么复用?

这其实是一个稍微复杂的问题,因为我们知道继承自Component的脚本我们是没办法直接复用的,因为即便你可以保存这个脚本到池子里,你也不能够直接对一个新的GameObject去Add一个你缓存的Component对象,对吧?你只能沟通过AddComponent方法去对一个GameObject New新的组件上去。所以脚本的缓存是需要和GameObject一起绑定的,这叫实体对象池。

这里要聊的是核心思路,所以你如果要复用一个对象,那么这一类对象应该来自同一个预制体复制出来的对象,上边所挂的脚本都是一致的,没问题吧?

那么对象复用的问题解决了,还差什么?

没错,就是对象的还原和设值。因此脚本上的值你都需要提取出来,到一个独立的序列化文件当中去,每一个逻辑实体都需要保存独立的序列化文件,然后从对象池中拿到实体对象以后,再把序列化的内容反向填充到实体对象上的组件当中去。这样才能达成复用。

是不是觉得说起来很容易。其实做起来很难,难度完全取决于你要复用的脚本。

如果是像Transform这样简单的脚本,只需要把位置和旋转序列化起来就足够了。但是那种复杂的怎么做,比如粒子系统,上边有数值,有选项,还有曲线,还有各种对其他GameObject的引用,这就复杂多了。你不可能序列化所有的内容。

所以结论是,能序列化的序列化,不能序列化的就绑在具体的脚本上。

反正总结对象池技术就是,弄清楚能不能复用,如何复用。复用不变的部分,然后通过清理和赋值来达到可用的状态,也不要追求万物都池子化,还是看性价比。

三、多次获取,但不经常变化的对象或集合。

这类问题相对隐晦,但处理起来相对简单。我们知道Unity的很多接口都是返回T[]数组格式的,比如GetComponentsInChildren(),比如.AnimationClips,每次调用都会返回新的数组给你。Unity之所以不缓存就是为了防止你对数组中的内容修改,从而从外部破坏了内部所维持的稳定关系。因此如果不注意,每次使用的时候都重新调用接口。就会造成数组分配的GC浪费。

比如这样一段脚本

public void SetTxtByObjName(string objName, string txt)
{
    Text[] tempTexts = GetComponentsInChildren<Text>();
    for (int i = 0; i < tempTexts.Length; i++)
    {
        if (tempTexts[i].name == objName)
        {
            tempTexts[i].text = txt;
        }
    }
}

可以看到,每次调用这个接口时,都会重新生成一个Text数组。

我们看一下另一段代码的处理

private void BackupSortingOrder(GameObject gameObject)
{
    var renderers = ListPool<Renderer>.New();
    gameObject.GetComponentsInChildren<Renderer>(true, renderers);
    foreach (Renderer renderer in renderers)
    {
        var sortingOrder = renderer.sortingOrder;
        SortingOrderBackup[renderer] = sortingOrder;
        sortingOrder += SORTING_ORDER_ADD;
        renderer.sortingOrder = sortingOrder;
    }
    ListPool<Renderer>.Free(renderers);
}

这一段就很标准了,因为前提是你知道GetComponents方法通常都有个重载的版本,它允许你传入一个List对象,由接口对List填充。所以当你调用一个返回数组的方法时,先看看每次返回是否是相同的(GetHashCode是否一致),如果不相同,再看看有没有重载的方法能够传入List或者有些方法直接名字叫XXXNoAlloc的,尽量去使用那些版本。如果都没有那怎么办。那就用缓存调用一次后存在本地即可,例如:

private Text[] tempTexts;

void Start()
{
    tempTexts = GetComponentsInChildren<Text>();
}

public void SetTxtByObjName(string objName, string txt)
{
    for (int i = 0; i < tempTexts.Length; i++)
    {
        if (tempTexts[i].name == objName)
        {
            tempTexts[i].text = txt;
        }
    }
}

当然这并不是说不允许调用直接返回数组的版本,核心还是看调用的次数,很多脚本只会在Awake的时候调用一次完成设置后就不再调用,生命周期和GameObject对象保持一致。这样的情况下,复用的必要性就没那么大了。

总结

合理的复用对象,能够提高内存的紧凑率,提升游戏的性能。在文章中,我们提到了常用的缓存以及对象池技术。其实除了上述说的这些,单例模式也是一种对象复用的思想。在研发中,我们刚开始要学习技术,后来就要学习思想,我们在实现功能之余也要多思考,实现同样的功能有哪些不同的做法,各种不同的做法效率和适用性如何,进行横向对比,从而举一反三。从写对一个功能到写好一个功能,总体而言就是希望大家在功能之余也能够多思考性能问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
《分析模式:可复用对象模型》是一本面向软件开发领域的经典著作,由Martin Fowler等人合著。该书从分析模型的角度出发,介绍了如何构建可复用对象模型。 首先,该书强调了对象模型的重要性。对象模型是软件开发过程中的关键组成部分,它描述了系统中的实体、它们之间的关系、行为和属性。一个好的对象模型可以提供更好的可读性、可维护性和可扩展性。 其次,该书提供了一些实用的指导原则和技巧,帮助读者构建可复用对象模型。其中包括面向对象的基本原则和概念,如封装、继承、多态等。此外,书中还介绍了一些常见的设计模式和架构模式,如单例模式、观察者模式、MVC模式等,它们可以帮助开发者更好地设计和组织对象模型。 此外,该书还提供了一些案例研究和实例,通过具体的示例向读者展示了如何应用这些原则和模式来构建可复用对象模型。这些实例来自不同的领域,包括电子商务、金融、医疗等,读者可以根据自身的实际需求来借鉴和应用。 总的来说,该书通过深入浅出的方式,系统地介绍了构建可复用对象模型的方法和技巧。它不仅可以帮助读者提升自己的分析建模能力,还可以提供一个参考和指导,帮助开发者设计出更高质量、更易维护和可扩展的软件系统。无论是初学者还是有经验的开发者,都可以从中受益匪浅。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值