【系统】Unity3D中的AssetBundle资源管理器(三)

前言

上一次讲到了用asset做资源管理,但是在asset的管理的时候,我们是同步把所需要的asset全部都加载出来的,前段时间看了下别人的管理机制,发现可以用unity的资源加载依赖来做这件事,就是不用我们自己加载asset,而是改用记录asset的依赖就好了。

正文

基于unity自身的依赖加载

在以前我们了解过,unity在加载一个资源的时候,如果它所依赖的assetbundle已经被打开了,那当我们加载它的时候,unity会自动把它所依赖资源一起加载出来,让asset能正常使用。在这个情况下,那当我们在根据asset来记录依赖的时候,我们只需要把它依赖的asset所在的assetbundle给加载出来,然后去对asset做一个引用计数的记录,这样的话,我们就可以省下去做加载依赖的asset这步的操作。在上一次我们所测试下来的结果,让我在这个版本做的方案就是,对assetbundle的卸载使用true这个参数,让它完全卸载掉。所以asset对于我来说就是一个做依赖关系绑定的用途。

改用异步的方式来做资源加载

在体验游戏的时候,我们可能最讨厌的就是界面卡顿,当点一个按钮需要打开一个界面的时候,我们发现,界面需要很久才出现,在等待的时候,我们会产生点击失败的感觉,这样很影响用户体验了。这种卡顿在我之前的方案里,很大一部分是因为我使用的是同步加载接口,它会阻塞住线程,等待资源全部加载回来才结束。这样的结果不是我们想要的表现方式。所以我改成用异步的方案来加载资源。但是在加载的时候,因为是异步,所以也会出现加载资源回来的顺序不一定是按照我们加载之前的顺序回来的。而且要抵消全部加载回来才显示的问题,我们可以做一个UI表现的动效,来抹平加载时间内的UI没有资源展示。

加载方案

当我们加载一个UI界面的时候,我们可以用对这个UI界面所需要的资源进行一个大的分类,如果是那种滑动列表内的资源,我们可以做一个优先级比较低的加载顺序,然后滑动列表加载进来的时候,做一个进入动画。那界面上顺序比较高的是哪个呢?我认为应该是玩家第一眼就能见到的界面,当我们在加载回来之后就可以直接显示了,那些一开始让玩家见不到面的资源,就可以在播动效的时候,再加载回来,其实不会慢很多,但是我们这样做就可以及时响应玩家的操作。我们需要给资源做一个引用列表,哪几个需要显示的prefab在引用它,还要记录一个状态,如果在加载中,所有需要引用的资源直接添加到引用列表里,如果它已经加载成功了,那就直接返回回调。

骚操作(我一般叫奇巧淫技)

在unity自带的资源依赖管理里,我们在加载出来一个prefab的时候如果它的资源没有加载成功,但是我们现实出来,它是会变紫红色的,但是,如果我们在接下来的时间内能加载出它依赖的asset所在的assetbundle的话,unity就会自动把资源赋值上去,正常现实的。所以在加载如果玩家看不到的资源,我们就可以一开始不用理会,只要第一眼能看到的资源以及加载成功了,我们就可以直接显示prefab。这里我就不知道如果出现这样的情况在unity底层会不会有cpu的消耗,一直在找丢失的资源。

卸载方案

如果我们使用了异步来做资源管理的话,会出现一个问题,就是我们如果这个资源还在加载的时候,我们就已经要把它卸载了,那我们就要在资源加载结束的时候做一个判断,这个资源是不是还用。所以我们需要给这个资源的回调做一个管理,如果不需要的话,我们就把回调事件-1,当资源加载回来之后,发现自己没有任何一个资源需要引用了,那它就把自己的状态变成卸载状态,等待下一次的回收。

资源的引用计数的问题

在之前我们所做的所有方案都是在一个理想的状态下做的,如果我们生成的一个prefab的资源,它被clone了,直接走unity的实例化,然后再把它给赋值到一个其他的地方,这个时候我们是不知道它真实的生命周期,这样的情况下,我如果默认它的生命死亡时间是跟某一界面,或者某一模块相关的话,那我们卸载了资源,它在别的地方的实体就会出现紫红色,然后出现问题。所以这种情况下,我现在想到的办法就是框架限制,资源的生命周期就限制死了,不能其他的地方使用我这个里面的资源,你要用自己去加载去。这样的话就能暂时解决了。其实这种方案也是有问题的,靠自觉,不知道什么时候就会出现问题的。只能等下一次解决了。

【系统】Unity3D中的AssetBundle资源管理器(二)

前言

在之前的那片文章上写了我们可以根据Asset的依赖,来做ab包的加载。这样可以在Asset不在用的时候及时释放内存中的资源,减少内存的峰值。也可以减少一次性回收资源太多,导致cpu峰值。

正文

设计思路

准备阶段

首先我们需要在打资源的时候做一次资源相互依赖的配置。然后把这份配置放置在StreamingAssets下,然后可以动态热更下来。在使用的时候,我们先根据ab包里总的Minifest来把每个ab包中有那些资源给读取出来,然后再根据资源依赖关系配置,我们就可以把这两个文件关联起来,asset->depedencyAssets->assetbundle。

加载阶段

根据这样的关系我们在加载的时候,当需要加载一个asset的时候,我们可以去读取它所关联的depedencyAssets资源,然后判断这个资源有在被使用,如果被使用了,我们就给他的引用计数+1;如果没有我们就根据这个asset去找它在哪个assetbundle中,然后把这个assetbundle给加载出来,再看下它所引用的asset有没有加载出来,加载出来就引用计数加1。在这里我们可以看到,只在第一次加载的时候会去给它所依赖的Asset做一次引用计数+1,因为依赖的Asset只需要去关心它是不是被依赖了,而不需要关心依赖它的那个资源被加载几次;因为只要它还在被引用,那就自然而然依赖的资源就不能被回收释放的。

卸载阶段

当我们需要卸载一块资源的时候,我们首先要做的就是,把这个资源的引用计数给-1,然后当他已经完全没有被引用了,那我们就去寻找它所依赖的资源,然后也都减1;然后我们查找这个资源所在的assetbundle包,去遍历它里面的Asset是不是都不被引用了,如果都不被引用了,我们就给这个资源做个标记要被回收的;不立刻被回收而是去做标记的好处就是,当我们的下一次还要用ab包里的资源的时候,我们可以不用再去加载ab包了,直接使用从这个ab包里拿资源,然后把这个ab包在状态给修改回去。我们将找一个空余的时间去把所有不用的ab包给回收掉。

关注点


这张图我们看到没有我们要加载的资源

这张图是我们第一次加载资源的时候它所被引用到的对象
这个时候当我们调用Resources.UnloadAsset卸载资源,它就会变成第一张图,这个资源就从内存里消失了,然后我们不去卸载ab包,第二次我们直接调用资源加载,我们看到的情况和之前是一样的。
但是,如果我们这个时候把ab包给卸载了,然后重新加载ab再加载asset之后,我们将看到这样的资源依赖图

这个时候我们再去调用Resources.UnloadAsset卸载资源你会看到资源没有被依赖,但是没有从内存中消失。

这个时候我们要卸载这个图片必须得调用assetbundle.Unload(true)来卸载,或者调用Resources.UnloadUnusedAssets()这来卸载,后一种卸载方式是会循环遍历所有的资源然后去卸载,所有会有很大的消耗;前一种的卸载方式是把资源依赖给强行解放了,所以当你实际上资源还在使用的话,就会变紫色,丢资源。
在这种Asset没有被回收的情况下,我们assetbundle没有回收,然后我们再从assetbundle中把资源加载出来,实际上使用的资源还是这个,它会被重新被引用上。但是如果我们调用了assetbundle.Unload(false)来把ab给回收了,但是没有去调用Resources.UnloadUnusedAssets()来回收asset,那你会发现这个丢失引用的资源就成了冗余资源,我再次从新加载ab,加载asset的时候,就是重新生成一份资源了

就成这样的情况了。

总结

源码地址
我们在做asset的资源管理的时候,一般来说直接调用true来做一次强行的资源回收,但是如果需要很在意表现,不想表现穿帮,对于一些资源就算冗余也无所谓的情况下,那最后调用一次Resources.UnloadUnusedAssets()来做一次资源的回收兜底行为。下一次我是做场景加载呢,还是做一个资源打包编辑器呢?看时间吧!鸽的前兆。

【编程基础】c#中的List源码解析

前言

我们在使用一些组件的时候,很容易在不了解它底层实现的情况下,使用出一些性能的问题。作为一个良好的程序员来说,能在在遇到这种问题的时候给出解决方案,算是一个基本的职业素质。所以我们需要在使用组件的时候知其所以然。

为什么需要了解List

List 这个组件是一个可伸缩的数组组件,我们会常常用它来代替数组,因为是可伸缩的,所以我们使用的地方太多了。

需要了解哪些东西

  1. 增加元素
  2. 删除元素
  3. 修改元素
  4. 怎么伸缩(这个其实在上面应该会说)

源码地址

正文

构造部分

首先我们来看下List的构造部分,源码如下:

public class List<T> : IList<T>,System.Collections.IList,IReadOnlyList<T>
{
    private const int _DefaultCapacity = 4;
    private T[] _items;
    [ContractPublicPropertyName("Count")]
    private int  _size;
    private int _version;
    [NoSerialized]
    private Object _syncRoot;
    static readonly T[] _emptyArray = new T[0];
    public List(){
        _items = _emptyArray;
    }
    public List(int capacity){
        if(capacity<0) ThrowHelper.ThrowArgumentOutOfRangeException(ExcptionArgument.capacity,ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
        Contract.EndContractBlock();
        if(capacity == 0)
            _items = _emptyArray;
        else
            _items = new T[capacity];
    }
    public List(IEnumerable<T> collection){
        if (collection==null)
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);
            Contract.EndContractBlock();
            ICollection<T> c = collection as ICollection<T>;
            if( c != null) {
                int count = c.Count;
                if (count == 0)
                {
                    _items = _emptyArray;
                }
                else {
                    _items = new T[count];
                    c.CopyTo(_items, 0);
                    _size = count;
                }
            }
            else {
                _size = 0;
                _items = _emptyArray;
                using(IEnumerator<T> en = collection.GetEnumerator()) {
                    while(en.MoveNext()) {
                        Add(en.Current);
                    }
                }
            }
        //...
        //其他内容
    }
}

从源码中我们可以知道,List继承IList,和IReadOnlyList这两个接口,IList是提供主要的接口,IReadOnlyList提供了迭代接口。
我们还能知道List内部是用数组来实现的,它有三种构造方法,一个是啥都不传的,默认初始化一个长度0的数组,一个是初始化数组长度的构造函数,一个是复制给定集合内容的构造函数,长度和内容都和传进来的数组一样。

添加

当我们看到第三种构造函数的时候,我们会发现最下面有一个Add这个函数的调用,这个函数就是List中添加。
源码如下:

public void Add(T item){
    if(_size == _item.Length) ExsureCapacity(_size + 1);
    _item[_size++] = item;
    _version++;
}
private void EnsureCapacity(int min){
    if(_item.Length < min){
        int newCapacity = _items.Length == 0 ? _defaultCapacity : _items.Length * 2;
        if((uint)newCapacity > Array.MaxArrayLength) newCapacity = Array.MaxArrayLength;
        if(newCapacity < min) newCapacity = min;
        Capacity = newCapacity;
    }
}
public int Capacity{
    get{
        Contract.Ensures(Contract.Result<int>() >= 0);
        return _item.Length;
    }
    set{
        if(value < _size){
            ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value, ExceptionResource.ArgumentOutOfRange_SmallCapacity);
        }
        Contract.EndContractBlock();
        if(value != _items.Length){
            if(value > 0){
                T[] newItems = new T[value];
                if(_size > 0){
                    Array.Copy(_items,0,newItems,0,_size);
                }
                _items = newItems;
            }else{
                _items = _emptyArray;
            }
        }
    }
}

从上面的源码我们可以看出来,当List在Add一个item的时候,会先去判断这个数组是不是已经满了,如果满了就拓容,从4,16,32 ...n(上一次数组大小)*2 这样的大小来拓容的,当拓容之后,会新建一个新的数组,把原来数组内容给拷贝进去,然后再去添加新的元素。

  1. 如果你直接调用Capacity赋值数组商都为负数的时候,你会出现数组被清理成默认的,但是数组的标记size(这个代表当前添加到数组第几个位置)却没有清空,下一次你要用的时候,会浪费之前那么多的内存空间。

  2. 当你在添加一堆数据的时候(例如128个数据)在一开始没有给List赋值长度的话,它就会在你添加的时候进行六次扩容,然后这六次扩容中有五次是需要拷贝元素数据的,这样每次拷贝都会造成新的内存垃圾,这样就会给GC带来很多负担。

  3. 如果添加的数量不得当,也会照成内存空间的浪费,比如元素数量为520的时候,List就会扩容到1024个元素,这样就会多出502个空间单位没有被使用。

删除

删除我们主要看Remove这个函数
源码如下:

public bool Remove(T item){
    int index = indexOf(item);
    if (index>=0){
        RemoveAt(index);
        return true;
    }
    return false;
}
public int indexof(T item){
    Contract.Ensures(Contract.Result<int>() >= -1);
    Contract.Ensures(Contract.Result<int>() < Count);
    return Array.IndexOf(_items,item,0,_size);
}
public void RemoveAt(int index){
    if((uint)index >= (uint)_size){
        ThrowHelper.ThrowArgumentOutOfRangeException();
    }
    Contract.EndContractBlock();
    _size--;
    if(index < _size){
        Array.Copy(_items,index+1,_items,index,_size-index);
    }
    _item[size] = default(T);
    _version++;
}

Remove接口中包含了IndexOf和RemoveAt,这两个方法,IndexOf是为了定位要删除的元素的位置,这个接口的实现是按索引从0到n个位置做意义比较,时间复杂度为O(n);RemoveAt这个方法是用来删除指定位置的元素,但是它的实现是用Array.Copy来进行数组的覆盖。

插入

源码如下:

public void Insert(int index,T item){
    if((uint) index > (uint)_size){
        ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_ListInsert);
    }
    Contract.EndContractBlock();
    if(_size == _items.Length) EnsureCapacity(_size + 1);
    if(index < _size){
        Array.Copy(_items,index,_items,index+1,_size-index);
    }
    _items[index] = item;
    _size++;
    _version++;
}

从源码中我们可以看到,Insert和Add接口一样,都会先检查容量是否足够,如果不够就扩容,插入的位置是否是数组的最后,如果不是进行数组拷贝,将index后面的元素集体后移一位。
在这里我们可以很清楚一个概念,就是最坏的情况下,我们插入一个数据需要做2次数据拷贝,会造成GC时的压力。

思考

其他相关的接口比如AddRange,RemoveRange,InsertRange的原理其实和Add,Remove,Insert一样,区别就是从单个元素变成以容易为单位的操作,都是做一些重复覆盖的操作,和扩容。他们没有进行任何形式的优化,都是使用顺序迭代的方式。如果频繁调用,会照成不小的内存冗余和GC的压力。

其他的一些接口说明

比如[]的实现

public T this[int index] {
            get {
                // Following trick can reduce the range check by one
                if ((uint) index >= (uint)_size) {
                    ThrowHelper.ThrowArgumentOutOfRangeException();
                }
                Contract.EndContractBlock();
                return _items[index]; 
            }

            set {
                if ((uint) index >= (uint)_size) {
                    ThrowHelper.ThrowArgumentOutOfRangeException();
                }
                Contract.EndContractBlock();
                _items[index] = value;
                _version++;
            }
        }

这个就是直接使用数组的的索引方式来获取元素,赋值元素,

Clear清除接口

public void Clear() {
            if (_size > 0)
            {
                Array.Clear(_items, 0, _size); // Don't need to doc this but we clear the elements so that the gc can reclaim the references.
                _size = 0;
            }
            _version++;
        }

调用的时候不会去删除数组内的元素,只是将元素清零,然后设置_size为0;

Contains接口

public bool Contains(T item) {
   if ((Object) item == null) {
                for(int i=0; i<_size; i++)
                    if ((Object) _items[i] == null)
                        return true;
                return false;
            }
            else {
                EqualityComparer<T> c = EqualityComparer<T>.Default;
                for(int i=0; i<_size; i++) {
                    if (c.Equals(_items[i], item)) return true;
                }
                return false;
            }
        }

我们看到它就是做了一次线性比较的方式来对数组进行迭代查找,并且返回的是第一个比对上的元素。

ToArray接口

public T[] ToArray() {
            Contract.Ensures(Contract.Result<T[]>() != null);
            Contract.Ensures(Contract.Result<T[]>().Length == Count);

            T[] array = new T[_size];
            Array.Copy(_items, 0, array, 0, _size);
            return array;
        }

它的实现就是新建一个数组,然后把元素拷贝进去再返回出来。

Find 接口

 public T Find(Predicate<T> match) {
            if( match == null) {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.match);
            }
            Contract.EndContractBlock();

            for(int i = 0 ; i < _size; i++) {
                if(match(_items[i])) {
                    return _items[i];
                }
            }
            return default(T);
        }

同样适用的是线性查找,对每个元素都进行比较,时间复杂度为O(n).

Enumerator 迭代的实现

public Enumerator GetEnumerator() {
            return new Enumerator(this);
        }
IEnumerator<T> IEnumerable<T>.GetEnumerator() {
            return new Enumerator(this);
        }

        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() {
            return new Enumerator(this);
        }
public struct Enumerator : IEnumerator<T>, System.Collections.IEnumerator
        {
            private List<T> list;
            private int index;
            private int version;
            private T current;

            internal Enumerator(List<T> list) {
                this.list = list;
                index = 0;
                version = list._version;
                current = default(T);
            }

            public void Dispose() {
            }

            public bool MoveNext() {

                List<T> localList = list;

                if (version == localList._version && ((uint)index < (uint)localList._size)) 
                {                                                     
                    current = localList._items[index];                    
                    index++;
                    return true;
                }
                return MoveNextRare();
            }

            private bool MoveNextRare()
            {                
                if (version != list._version) {
                    ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
                }

                index = list._size + 1;
                current = default(T);
                return false;                
            }

            public T Current {
                get {
                    return current;
                }
            }

            Object System.Collections.IEnumerator.Current {
                get {
                    if( index == 0 || index == list._size + 1) {
                         ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumOpCantHappen);
                    }
                    return Current;
                }
            }

            void System.Collections.IEnumerator.Reset() {
                if (version != list._version) {
                    ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
                }

                index = 0;
                current = default(T);
            }

        }

我们可以看到Enumerator这个接口在每次被获取的时候都是new出来的,如果使用大量的迭代器的时候就会有大量的对象,照成GC压力

Sort接口

public void Sort()
        {
            Sort(0, Count, null);
        }
 public void Sort(IComparer<T> comparer)
        {
            Sort(0, Count, comparer);
        }
public void Sort(int index, int count, IComparer<T> comparer) {
            if (index < 0) {
                ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
            }

            if (count < 0) {
                ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.count, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
            }

            if (_size - index < count)
                ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_InvalidOffLen);
            Contract.EndContractBlock();

            Array.Sort<T>(_items, index, count, comparer);
            _version++;
        }

        public void Sort(Comparison<T> comparison) {
            if( comparison == null) {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.match);
            }
            Contract.EndContractBlock();

            if( _size > 0) {
                IComparer<T> comparer = new Array.FunctorComparer<T>(comparison);
                Array.Sort(_items, 0, _size, comparer);
            }
        }
// Array.Sort
internal void Sort(int left, int length)
            {
#if FEATURE_CORECLR
                // Since QuickSort and IntrospectiveSort produce different sorting sequence for equal keys the upgrade 
                // to IntrospectiveSort was quirked. However since the phone builds always shipped with the new sort aka 
                // IntrospectiveSort and we would want to continue using this sort moving forward CoreCLR always uses the new sort.

                IntrospectiveSort(left, length);
#else
                if (BinaryCompatibility.TargetsAtLeast_Desktop_V4_5)
                {
                    IntrospectiveSort(left, length);
                }
                else
                {
                    DepthLimitedQuickSort(left, length + left - 1, IntrospectiveSortUtilities.QuickSortDepthThreshold);
                }
#endif
            }
             private void DepthLimitedQuickSort(int left, int right, int depthLimit)
            {
                // Can use the much faster jit helpers for array access.
                do
                {
                    if (depthLimit == 0)
                    {
                        // Add a try block here to detect IComparers (or their
                        // underlying IComparables, etc) that are bogus.
                        try
                        {
                            Heapsort(left, right);
                            return;
                        }
                        catch (IndexOutOfRangeException)
                        {
                            throw new ArgumentException(Environment.GetResourceString("Arg_BogusIComparer", comparer));
                        }
                        catch (Exception e)
                        {
                            throw new InvalidOperationException(Environment.GetResourceString("InvalidOperation_IComparerFailed"), e);
                        }
                    }

                    int i = left;
                    int j = right;

                    // pre-sort the low, middle (pivot), and high values in place.
                    // this improves performance in the face of already sorted data, or 
                    // data that is made up of multiple sorted runs appended together.
                    int middle = GetMedian(i, j);

                    // Add a try block here to detect IComparers (or their
                    // underlying IComparables, etc) that are bogus.
                    try
                    {
                        SwapIfGreaterWithItems(i, middle); // swap the low with the mid point
                        SwapIfGreaterWithItems(i, j);      // swap the low with the high
                        SwapIfGreaterWithItems(middle, j); // swap the middle with the high
                    }
                    catch (Exception e)
                    {
                        throw new InvalidOperationException(Environment.GetResourceString("InvalidOperation_IComparerFailed"), e);
                    }
                    Object x = keys[middle];
                    do
                    {
                        // Add a try block here to detect IComparers (or their
                        // underlying IComparables, etc) that are bogus.
                        try
                        {
                            while (comparer.Compare(keys[i], x) < 0) i++;
                            while (comparer.Compare(x, keys[j]) < 0) j--;
                        }
                        catch (IndexOutOfRangeException)
                        {
                            throw new ArgumentException(Environment.GetResourceString("Arg_BogusIComparer", comparer));
                        }
                        catch (Exception e)
                        {
                            throw new InvalidOperationException(Environment.GetResourceString("InvalidOperation_IComparerFailed"), e);
                        }
                        Contract.Assert(i >= left && j <= right, "(i>=left && j<=right)  Sort failed - Is your IComparer bogus?");
                        if (i > j) break;
                        if (i < j)
                        {
                            Object key = keys[i];
                            keys[i] = keys[j];
                            keys[j] = key;
                            if (items != null)
                            {
                                Object item = items[i];
                                items[i] = items[j];
                                items[j] = item;
                            }
                        }
                        i++;
                        j--;
                    } while (i <= j);

                    // The next iteration of the while loop is to "recursively" sort the larger half of the array and the
                    // following calls recrusively sort the smaller half.  So we subtrack one from depthLimit here so
                    // both sorts see the new value.
                    depthLimit--;

                    if (j - left <= right - i)
                    {
                        if (left < j) DepthLimitedQuickSort(left, j, depthLimit);
                        left = i;
                    }
                    else
                    {
                        if (i < right) DepthLimitedQuickSort(i, right, depthLimit);
                        right = j;
                    }
                } while (left < right);
            }

我们可以看到使用的排序方法是用的快排,所以它的时间复杂度为O(nlogn)

总结

在分析完之后,我们可以知道List的效率并不高,只是通用性强,方便而已,大部分的算法都是使用线性复杂度的算法,这种算法遇到规模比较大的计算量级的时候就会照成大量的CPU的消耗。而且它的内存分配方式极其的不合理,只要元素不断的增加就会多次new数组,然后抛弃原来的数组,造成GC压力。所以List是一个好用但不高效的组件。

【编程基础】Unity的内存管理

前言

最近在看完unity技术开放日中高川老师对于内存的讲解,对unity的内存有了一些全新的认识。写一篇文章来做一次学习总结

正文

Unity的内存结构

在unity中内存其实分为两块:

  1. Native Memory 原生内存

  2. Managed Memory 托管内存

分为这两块主要是因为Unity的底层是用c++所写的所以有一块Native Memory,业务层使用Mono为了能够跨平台所以在mono中的内存是Managed Memory。所以我们在考虑Unity的内存的时候,要分别讨论这两块内存的管理机制(分配,回收)

Native Memory

介绍

Unity实际上是一个C++引擎,所有的实体最终都会反映到C++上,也就是反映在Native Memory上,当我们使用Unity的组件的时候,我们就可以很明显的看到Native Memory的上升。所以在使用Unity的组件的时候,我们需要好好考虑Native Memory的内存变化,来进行一个正确的使用方案。

分配方案

New/MALLOC

  1. New是操作符,MALLOC是函数

  2. New从自由存储区(free store)上分配,MALLOC从堆上分配。凡是通过New进行的内存申请,就是自由存储区,它可以是堆上的,也可以是今天存储区的,还能使用New的时候不给对象分配内存,只是返回一个指针实参;MALLOC分配的堆是操作系统中的术语,是操作系统所维护的一块特殊内存。

  3. New内存分配成功时是放回对象的类型指针,类型严格与对象匹配,符合类型安全性的操作符,它不会试图访问自己没被授权的内存区域;Malloc分配成功返回的类型是void*,需要强制类型转换到我们需要的类型

  4. New内存分配失败时候,会抛出bac_alloc异常,不会返回Null;Malloc分配失败会返回Null。

  5. new不需要指定内存大小,编译器会自动计算出来;而Malloc需要显式的指出内存的大小。

  6. new分配经历三个步骤:第一步:调用operator new 函数(对于数组是operator new[])分配一块足够大的,原始的,未命名的内存空间来储存制定类型对象;第二:编译器运行相应的构造函数一构造对象;第三:构造成功后,返回一个指向该对象的指针;当被回收的时候,调用delete的时候会经历两个步骤:第一:调用对象的析构函数;第二:编辑器调用operator delete(数组调用operator delete[])函数释放内存空间。Malloc不会调用构造函数。

  7. new对数组的是可以在初始化的时候调用构造函数去初始化每一个元素,释放的时候可以对每个对象调用析构函数。malloc 在分配内存的时候,不知道你再内存上要放的是什么。

  8. new可以在operator new/operator delete的实现调用malloc的;但是malloc的实现不可以去调用new。

  9. new的operator new/operator delete是可以被重载的。malloc是不可以的。

  10. malloc分配内存后,如果使用的时候内存不足,它是可以调用realloc函数去重新分配内存扩容。new就不能这样直管的去扩容。

  11. New抛出异常的时候会调用用户的指定错误处理函数;malloc在内存不足的时候只会返回一个null

Unity重写New/Malloc

unity会定义一些宏定义,然后当调用New/Malloc分配内存的时候,不会去调用库函数,而是使用unity自己实现的函数。

如图所示当Unity在分配内存的时候会定义这个分配内存的类型(Memory Label),然后根据这个Memory Label来判断在哪里分配内存给用户。其中有stack Allocator(栈分配器),Batch Allocator(批量分配器),DynamicHeapAllocator(动态堆分配器)

Stack Allocator

这一块其实不是我们一般认为的栈帧,而是unity自己实现的一个栈类型的内存分配方案。他其实是用来做一些临时数据的内存分配,要十分快的不被使用了。

特性

Fast(快),Small(小),TemPorary(临时)

结构

如上图所示,一块数据包含header,user这两块内容

  1. header

这块数据保存着这个内存块的一些信息,比如是否被删除,user的大小,上一块的位置。

  1. user

这块就是用户实际使用到的数据。

使用方案

  1. 这一整个栈分配器,在程序启动的时候就定好是多大了,一般在编辑器模式下,主线程16M,子线程256kb,在runtime(运行)模式下主线程128kb-1M,子线程 65kb。

  2. 这个是通过移动栈顶指针的方式来做分配和回收的;当用户要分配一块内存的时候,直接在栈顶指针之下分配一块内存,然后把栈顶指针给下移;当回收的时候,先在header上标记删除,然后看这块内存是不是栈顶指针指向的那块内存,如果是,就把栈顶指针上移,检测上一块内存是不被标记回收,标记了就回收,没有就停在这块。在这里就会有一个问题出现,如果我回收的是中间那块内存,就会只标记,不做栈顶指针的移动,下次使用的时候,还是去分配新的内存,而没有对这块内存重复利用了,只有等下一次栈顶指针移动到这里才会回收重复利用。所以这样就产生了内存碎片,内存浪费。

  3. 如果需要分配的内存已经超出了栈分配器的剩余可用的内存大小的时候(这里指的是栈顶指针之下的内存),会把内容分配到DynamicHeapAllocator(动态堆分配器)中抛出一个MemoryManager.FallbackAllocation这个异常。在栈分配器可能分配一段内存零点几毫秒但是在动态堆分配里,它可能需要几毫秒,这个时间消耗飙升。

提醒

现在说完了栈分配器的一些特性,我们就可以对它的一些特性做一个解释了,为啥快呢?因为它只做指针的移动,而不会去做其他的操作,为啥需要临时呢?因为你不能永久的保持栈顶指针不动,而一直在回收中间部分的内存,这样就内存无法重复利用,引起内存碎片。造成很大的浪费,所以你需要快速的用完就丢掉。

未完待续

关于其他的内存分配器,等下回分享

Managed Memory

介绍

这个是托管内存,关于这块的内容网上到处都有,各种GC就是大部分说的就是这个托管内存的回收机制,它就是为了方便使用者不去做内存的管理,只需要使用,使用完了编辑器就会自动帮你回收内存,用的时候也是编辑器自动给你分配一块内存给你。
主流的GC算法:

  1. 保守内存回收(BoehmGC等)

  2. 分代式内存回收(sGen)

  3. 引用式内存回收(java)

关于GC的一些算法,我之后再详细写,现在先写一个mono中的BoehmGC,为啥mono里要用BoehmGC呢,而不用市场上现在很流行的分代GC算法呢?这个除了一些历史原因,还有一个原因是因为使用分代GC你会做一个内存块的移动,和对当前内存块的活跃度的判断;这些操作都会对cpc的计算力做一次消耗。

BoehmGC

这个GC算法是从c++中先使用的,所以带有很强烈的c++的一些特性,你可能在后面结合c++的一些特性能更好理解它有些东西为啥这样做的。

从上图我们可以看到它的内存管理属于一个二级管理,第一级代表的这个kind的类型,常见的有 PTRFREE(无指针类型),Normal(比较一般的类型),还有 Uncollectable(不可回收类型);一般Boehm自己用到的会放在Uncollectable中。然后再下面你看到它有一个size的二级,这里代表的是当前它下面所挂的内存的大小,它会把所有和自己所表示大小一样的内存块通过链表来链接在自己的下面。

分配

当用户要分配一个8byte大小的内存的时候,因为现在表面的最小是16,所以它就会把16下的内存块拿出一个给用户,剩下的8byte就直接浪费了。如果16byte的内存链表下面没有可用的内存块的时候,它就会从更大一级的内存块(32)中拿一块出来,然后一分为二16byte用户拿去用,剩下的16byte挂载16byte链表下。

回收

当用户要回收一块内存的会去找它物理相连的地址上的数据是不是也被回收了,如果也被回收了,那他们两个就合并在一起,然后挂在一个更大的链表下面等待使用(比如两个Block0),减少内存碎片。

标记回收

标记回收是怎么去找我哪块内存需要回收,在Boehm里它是属于非精准性回收。表示分配的内存没人用但是不一定内被标记到可以回收,还有一种是没人用的内存也没有办法被使用;说明这两种的内存被认为还在被引用。

我们看上图,对象的引用是因为在一个地方保存了他的地址,所以被认为是被引用了。在Boehm中他认为保存的这个数据是指针,它靠的是猜,怎么猜的呢?它会保存自己从HEAP分配内存的最低地址和最高地址,就好比现在我们看到的0x012它可能是一个数,但是它刚好在这个区间内,那不好意思了,你就是指针了,那通过你指向的位置的对象,也就被我引用了,那你就不能别回收了。还有一个0x013(也认为是一个数)如果把它也刚好在这个区间,但是它直接指向的那个地址其实是一个没人用到的(或者已经被回收的),Boehm就会把它标记到黑名单里,其他地方在使用内存的时候刚好踩到这块内存,Boehm会给你分配别的地方的内存,这块现在就是不能用了。
所以当你分配了一堆小内存的时候,那这个时候,你产生黑名单的概率和不能回收的概率都会大大提高的。

总结

在使用内存的时候,我们需要优先考虑分配大内存,然后再去分配小内存;减少内存碎片的,降低黑名单,以及不能回收的概率;也可以在大内存被回收之后可以被重复切成小块内存使用。但是当你有一堆小内存,他们的物理内存地址不是连续的时候,就算你能真实大小满足这一次要使用的内存大小的时候,但是因为你不是连续的,所以不能用,导致系统需要从新分配一块连续的内存给用户使用。

【系统】unity3D中的assetbundle资源管理器(一)

导言

assetbundle 的解释

说起 U3D 的 assetbundle 大家应该都不会很陌生吧,我的理解是 —— assetbundle 属于一种 unity 自己可认的压缩文件格式,把你想放在一个包里的资源给打包到一起,这样就形成了一个 assetbundle。

但是使用这个压缩格式还有一个注意点,就是可能你要打包的资源,它引用到了另外一处资源,即它对一个外部资源有依赖。所以你在打 assetbundle 的时候,unity 会把这个依赖关系记载在 manifest 的 Dependecies 上,虽然是对 asset 的依赖,但是 unity 帮你管理的是大范围的依赖关系,所以记录下来的是 assetbundle 对 assetbundle 的依赖。

为什么要做 assetbundle 的资源管理器

当我们在使用 assetbundle 做资源热更新的时候,我们需要了解,在使用asset资源的时候,assetbundle是怎么被使用的。比如我们需要加载一个prefab的时候,我们需要先解包这个prefab所在的assetbundle,然后我们还需要解包这个prefab所依赖的asset所在的assetbundle。这个asset到asset的依赖是unity帮我们管理的,会记录在asset中,我们在用文本打开资源的文件的时候,是可以看到在unity中这个asset的所有信息,包括它依赖的asset,(这部分就不详细展开了)。但我们把assetbundle在内存中解压之后,内存就会存放这个assetbundle的镜像,内存就会变大,如果我们只解压不去卸载这部分内存的话,那游戏内存就会持续升高,超出系统所给的内存大小,系统就会把引用杀死,造成闪退。所以我们需要在加载的时候解压assetbundle,在不用的时候把这个assetbundle给回收了。

实现方案

准备阶段

我们将在这个阶段先解压assetbundle的整个manifest,然后给所有的assetbundle给记一个int的唯一标识,这步是为了在后面使用assetbundle的时候可以使用int来做标识,减少string的使用,节省gc。再把所有assetbundle所包含的asset的资源和这个assetbundle做好一个依赖关系。

使用阶段

当我们要加载某一个asset的时候,我们先判断这个asset有被加载,加载过的,我们就asset做一个引用计数+1的操作,如果没有的话,我们就去加载这个asset所在的assetbundle,然后我们在加载assetbundle的时候,看这个assetbundle还使用了哪些assetbundle,我们都做一次加载,做一次引用计数加1的操作,然后我们在实例化出来这个asset。

使用完成阶段

但我们卸载asset的时候,会对这个asset来做一个引用计数-1的操作,如果引用计数小于等于0了,我们就可以卸载这个asset,进而对这个assetbundle来做一次-1的操作,但assetbundle的计数也小于等于0的时候,我们就可以卸载这个assetbundle了,对它所依赖的assetbundle的引用计数-1。这一整个assetbundle的使用周期就结束了

注意点

在做assetbundle的引用计数的时候,我遇到了一个问题,我是使用GetDirectDependencies来获取这个assetbundle所依赖的单一次的assetbundle,所以如果所依赖的assetbundle再依赖我这个assetbundle的时候,我这时候就会出现循环依赖(a->b->a这种情况),然后卸载就会出现问题,因为我在引用计数的时候对a其实做了两次引用计数了,卸载我其实就引用计数-1,然后因为还有一次引用计数所以b不会被卸载,所以对a的引用也不会减少,这样实际上我们其实不需要引用了,但是事实上循环引用导致我们卸载不掉。
在这里我提供两个解决方案:

  1. 我们做一次调用栈的计数,如果这次调用循环中,如果这次调用的资源是记录在栈里的,那我们就不做引用计数+1的操作了。
  2. 我们换一个接口,GetAllDependencies 这个接口返回的就是这个assetbundle所依赖的所有资源,包括它依赖的依赖。这样我们就可以一次性对所有的资源做引用计数加1了。

优化点

  1. 上面所说的assetbundle的循环依赖,其实我们可以做关于asset的依赖管理,asset的依赖是不太会出现asset之间的循环依赖。这种优化还能解决我们关于assetbundle的提早释放。比如assetbundle(a)中两个资源(a1,a2)对两个assetbundle(b,c)里的资源(b1,c1)做依赖。(a1->b1,a2->c1)这种情况的依赖在我现在的方案中,因为是记录的assetbundle的依赖关系,所以就算a1被卸载了,不使用了,但是a还有一个引用,所以a不会被卸载,b和c也就不会被卸载。其实b不再被使用了,但是还是没有办法被卸载了。如果我们做的是asset之间的依赖引用,那么我们a1被卸载了,b就不再被引用了,就可以被卸载出来

  2. 因为我们使用的assetbundle做的资源依赖管理,所以我们调用的卸载接口是直接assetbundle.Unload(true)。这个接口的问题就是,如果我们的资源还在使用,却调用到了这个接口,那么资源就会丢失,就会出现紫色的。所以最后还是要回到使用asset的依赖管理上。

demo

第一版本的demo

【unity】优化-cpu

CPU耗时

类中存在空的updata,lateupdate和FixedUpdate这样的方法

出现问题的主要原因

  1. 这些方法是native层对托管层的调用,c++与c#之间的通信本身就存在一定的开销
  2. 当这些方法的调用,Unity会进行一系列的安全监测(比如保证GameObject没有被销毁)导致cpu时间的消耗

这个类中的方法存在Camera.main的调用

这个方法获取属性的方法实际上是一个Get的方法,每次调用的时候都会去寻找场景中第一个tag为“MainCamera”的相机,并且返回。这个寻找是遍历所有的tag,并且不会被缓存的。

该类的方法中存在ComputeBuffer.GetData的调用

这个是从GPU的buffer中读取对应的计算结果并输入到相应的数据中,由于整个过程是一个同步的操作,所以调用的时候会堵塞调用线程,直到GPU返回数据为止。

该类中存在对纹理SetPixels的调用

SetPixels用于对纹理特定的mipmap层的像素进行修改,它会将一组数据的像素值赋值到贴图的指定mipmap层,调用Apply()后将像素传到显卡。

该类的方法中存在GameObject.SendMessage调用

这个方法会遍历Gameobject上所有的组件,以及组件中的所有函数,会导致很高的cpu消耗。

该类的方法中存在GetComponentsInChindren调用/GetCompoentsInParent调用

这两个的使用都会涉及到较大范围内的搜索遍历。Unity在GameObject的GetComponentsInChildren的方法是支持传入一个List的,这个是可以减少堆内存的分配。

调用了FindObjectsOfType调用

这个会对于场景中的GameObject和Component进行遍历,并将与目标type类型相同的组件以数组的方式返回。这个方法还会产生对堆内存的分配。

存在Reflection相关函数的调用

在调用反射相关的方法时,需要获取类型与函数等信息,并且进行参数校验,错误处理,安全性检查等。还会造成堆内存的分配。

存在对Renderer进行Material/Materials的获取

如果对renderer类型调用.material和.materials,那么unity就会生成新的材质球实例。所以我们可以使用MaterialPropertyBlock来替代Material属性的操作。使用相同的Shader但是Material实例不同的GameObject是无法进行合批操作,所以dc增加了,也就增加了cpu的消耗。
每次调用.material都会生成一个新的Material实例,并且GameObject销毁后,Material的实例是没有办法进行自动销毁的。所以需要手动调用“UnloadUnusedAssets"来进行卸载,就会添加性能开销;如果管理不好的话,内存也会很高。

Ugui中UpdateBatches占用耗时较高

在ugui的UI元素刷新是基于canvas下的,所以当我们ui上的数据做SetActive的时候是会把Canvas下的所有元素都触发了,可以看到Rendering.UpdateBatches的调用会比较多

Ugui中的Rebatch优化

Rebatch发生在c++层面,指的是Canvas分析UI节点生成最优批次的过程,节点数过多会导致算法(贪心)的耗时比较长。对应SetVerticesDirty,当一个canvas中包含的mesh发生改变就会触发(setactivity,transform,颜色,文本内容等),canvas独立处理,互相不影响的,消耗在meshes按照深度和重叠情况排序,共享材质的检测。
优化点:

  1. Canvas动静分离,合理规划,不能太多(会提高DC的消耗)
  2. 减少节点层次和数量,合批计算量小,速度快。
  3. 使用相同材质贴图的ui尽量保持深度相同。

Ugui中的Rebuild

rebuild发生在c#层面,是ugui库中的layout组件调整RectTransform尺寸,Graphic组件更新material,以及mask执行Cull的过程,耗时和发生变化的节点数量基本呈现线性相关。

只有LayoutGroup的直接子节点,并且是 Graphic类型的(比如 Image 和 Text)会触发SetLayoutDirty。

Graphic改变的原因包括,基本的大小、旋转以及文字的变化、图片的修改等等,对应SetMaterialDirty。
优化点:

  1. 少用layout

堆内存优化

  • 无法及时的释放内存。
  • 过多的分配次数会到时内存中的碎片过多,从而无法开辟所需要的连续内存。
  • 内存的GC会照成卡顿

存在.tag的调用

获取tag的时候实际上是调用了get_tag()函数,从native层返回一个字符串,字符串的返回会造成堆内存的分配,unity也没有通过缓存的方法来做优化。

该类中存在对纹理的GetPixels()/GetPixels32调用

一般是为了获取指定mipmap层的全部像素信息,而图片上的像素往往是很庞大的。
会在堆内存上分配内存,用来存储纹理数据的像素信息,而且引擎不会对其进行缓存。

使用了Linq相关的函数调用

linq在执行过程中会产生一些临时变量,而且会用到委托。如果使用委托作为条件的判断方式,时间开销就会很高,并且会造成一定的堆内存分配。

存在对Renderer进行sharedMaterials的获取

对.sharedMaterials的调用,依旧会分配堆内存,每次调用都会分配一个用来存放Material的索引的数组。

存在Input.touches调用

.touches的实现是每次都会new一个数组touches,从而造成一定的堆内存分配。

存在对TextAsset调用

在获取bytes属性的时候,Unity会从Native层获取字节数组(byte[]),从而分配一定的堆内存

C#和lua的穿插引用

c#层会维护一个cache来引用那些被lua访问过的C#层对象,这是为了防止当lua中再次访问该c#对象时,这个对象已经被c#的gc给回收掉了。但是如果lua始终保持着对某个c#层对象的引用,那讲会导致无法被释放。造成堆内存泄漏。

[算法]归并排序

概念

使用的思想是分治思想。把需要排序的数组中重建分成两部分,然后前后两部分分别排序,需要一直分解下去,直到能两个值相互比较。然后再将排序好的两个部分合并在一起。

合并

我们需要申请一个临时数组,这个数组的大小和我们排序的两个组的大小之和一样。然后我们用两个下标i,j,来标识我们当前读到的是两个组的第几个位置,然后这两个下标的值来做比较,但一个值满足要求的时候,我们就把这个值放在临时数组里,然后这个下标后移一位,继续比较。当任意一个下标到达那个组的最后一位的时候,我们就不需要做比较了,直接把另外一组剩下的放在临时数组的最后。

void merge_sort_c(int [] a,int p,int r)
            {
                if (p >= r)
                    return;
                int q = (p + r) / 2;
                merge_sort_c(a,p, q);
                merge_sort_c(a, q + 1, r);
                merage(a, p, q, r);
            }
            void merage(int[]a,int p,int q,int r)
            {
                int i = p;
                int j = q+1;
                int k = 0;
                int[] temp = new int[r - p+1];
                while (i <= q && j <= r)
                {
                    if (a[i] <= a[j])
                    {
                        temp[k++] = a[i++];
                    }
                    else
                    {
                        temp[k++] = a[j++];
                    }
                }
                int start = i;
                int end = q;
                if (j <= r)
                {
                    start = j;
                    end = r;
                }
                while (start <= end)
                {
                    temp[k++] = a[start++];
                }
                for(int ii = 0; ii < r - p+1; ii++)
                {
                    a[p + ii] = temp[ii];
                }
            }
  1. 当我们把第二组的多出来的数据整体放在第一组后面,那归并排序是稳定排序

  2. 归并排序的时间复杂度o(nlogn)

  3. 归并排序的空间复杂度不是原地排序,因为在合并的时候需要临时空间。o(n)

[算法]选择排序

概念

也分为未排序区和排序区。但是选择排序每次都从未排序中找到我们要的元素放在排序区的最后。

int[] t = new int[10] { 43, 23, 432, 5, 2, 6, 43, 2, 67, 4 };
                for(int i = 0; i < t.Length; i++)
                {
                    int v = t[i];
                    int index = i;
                    for (int j = t.Length-1; j > i; --j)
                    {
                        if (v > t[j])
                        {
                            index = j;
                            v = t[j];
                        }
                    }
                    t[index] = t[i];
                    t[i] = v;
                }
                for(int i = 0; i < t.Length; i++)
                {
                    Console.WriteLine("{0}={1}", i, t[i]);
                }
  1. 因为只会交换一次我们找到满足条件的数据放在已排序的最后,所以这个排序的空间复杂度为o(1),是原地排序算法

  2. 这个排序不是稳定排序算法,因为每次都会去找剩下满足的元素和前面的元素进行交换

  3. 最坏,最好,和平均时间复杂度都是o(n*n).

[算法]插入排序

概念

插入排序就是把数组分为两个区间,一个是有序区间,一个是未排序区间。初始的时候有序区间就只有一个元素,那就是数组的第一个元素,然后取未排序区间中的元素往有序区间中插进去,一直等到未排序区间中的所有元素为空。

int[] t = new int[10] { 54, 23, 43, 2, 3, 5, 1, 2, 6, 7 };
                for (int i = 0; i < t.Length; i++)
                {
                    int v = t[i];
                    int j = i - 1;
                    for (; j > 0; --j)
                    {
                        if (v > t[j])
                        {
                            t[j + 1] = t[j];
                        }
                        else
                        {
                            break;
                        }
                    }
                }
                for(int i = 0; i < t.Length; i++)
                {
                    Console.WriteLine("{0}={1}", i, t[i]);
                }
  1. 插入排序算法不需要额外的空间,所以它的空间复杂度为o(1),是原地排序算法。

  2. 插入的时候只是插到它所需要的位置,不会修改它后面位置的顺序,所以是稳定排序算法。

  3. 最好情况下,我们要排序的数据是有序的,所以我们只需要做o(n)的时间复杂度。最坏的情况下,我们要排序的数据是逆序的,所以我们要做o(nn)的时间复杂度。在数组中插入一个数据的时间平均时间复杂度是o(n),我们需要执行n次操作,所以平均时间复杂度是o(nn)。

[算法]冒泡排序

概念

每次冒泡就是相邻的两个数进行比较,如果两个数据的关系不满足我们所要求的的,那我们就进行交换。一次冒泡操作至少会让一个元素移动到它所应该在的位置。

int[] t = new int[10] { 2, 3, 4, 2, 43, 54, 23, 43, 1, 67};
for(int i = 0; i < t.Length; i++)
    {
      for(int j = 0; j < t.Length-i-1; j++)
          {
              if (t[j+1] > t[j])
                 {
                   int temp = t[j+1];
                   t[j+1] = t[j];
                   t[j] = temp;
                  }
            }
     }
     for(int i = 0; i < t.Length; i++)
        {
          Console.WriteLine("{0}={1}", i, t[i]);
        }
  1. 因为只涉及到相邻的两个数的操作,所以它的空间复杂度是o(1)是原地算法排序。

  2. 因为只有当相邻的两个元素不一致的时候我们才会去交换两个元素的位置,所以这个排序也是一个稳定排序算法。

  3. 在最好的情况下就是说他们已经是有序的情况下他们的时间复杂度是o(n),在最坏的情况下就是说它们是反倒序的情况下,它的时间复杂度是o(nn);还有一种叫平均时间复杂度n(n-1)/4,一般来说我们就看做o(n*n)。