1. 程式人生 > >【Unity】Unity資源池的動態載入釋放和記憶體優化處理

【Unity】Unity資源池的動態載入釋放和記憶體優化處理

需求環境         在上一級的【解決方案】文章中,我們設計出了動態載入資源的業務流程,而這一節,我們就通過一些簡單的程式碼,來實現出業務流程中的效果。        吸取之前文章的經驗,如果按照正式專案的規格開發,本篇文章就會非常冗餘,所以我們優化一下,僅僅針對技術點進行講解與釋放,具體與工程相關的,我們就不再文章中講解,但你可以在Github的工程中找到它們。、        現在,我們先回顧一下之前所設計出的業務流程。       那麼,在這個業務流程中,我可以定義出在遊戲執行時,資源有三種狀態:       1、未載入       2、已經載入       3、已可以釋放
      三種狀態了某個資源此時的最佳使用環境,也就是說,接下來需要使用的資源,我就放到池中,而接下來很長一段時間內不需要使用的資源,我就徹底釋放掉。以確保程式的記憶體總是在可控範圍之內。 設計       為了達到這樣的目的,我們就需要劃分三個模組去做。       1、最基礎的資源載入,與池。       2、資源載入的自動記錄過程。       3、資源載入的動態釋放與載入過程。       首先,池,因為我們是模擬,所以這個就比較容易實現,在現實工程中,則可能需要考慮不同資源型別的具體邏輯。

    ///


    /// 池
    ///
    Dictionary<int, Stack> PoolDict = new Dictionary();

    /// 
    /// 正在工作的資源物件
    /// 
    Dictionary<Object, int> WorkingPool = new Dictionary();
       首先是2個定義,一個是回收池,一個是工作區,工作區用來反向查資源的ID,同時,也檢測是否有資源是通過其他方法載入的,理論上,遊戲內不應該存在其他的途徑來載入資源。       接下來,就是2份邏輯程式碼,一個是建立資源,它用到了之前我們實現的資源管理器,另一個是回收資源。

///


    /// 得到資源,如果池子裡有,直接拿,否則建立
    ///
    ///資源型別,方便上級使用
    ///資源id
    ///
    public T getObj(int _id)
        where T : Object
    {
        Object temp = null;
        //
池子裡有就取一個
        if (PoolDict.ContainsKey(_id) &&
            PoolDict[_id].Count > 0)

            temp = PoolDict[_id].Pop();

        //
如果池子裡沒有,就建立一個新的
        temp = DJAssetsManager.GetInstance().Load(_id);

        if (temp as T == null)
        {
            Debug.LogError("
程式碼寫錯了或資源配錯了,傳入的資源id與希望得到的型別不匹配");
            Debug.Break();
            return null;
        }

        //
加入工作池
        WorkingPool.Add(temp,_id);

        return (T)temp;
    }

    /// 
    /// 回收資源
    ///
    public void recObj(Object _obj)
    {
        if (WorkingPool.ContainsKey(_obj))
        {
            //
正常回收
            int id = WorkingPool[_obj];
            WorkingPool.Remove(_obj);

            if (PoolDict.ContainsKey(id) == false)
                PoolDict.Add(id, new Stack());

            PoolDict[id].Push(_obj);
        }
        else
        {
            //
不屬於池管理的資源直接刪除掉。不過得打出警告,按理說不應該存在
            Debug.LogWarning("檢測到非法建立的資源:" + _obj.name);
            Destroy(_obj);
        }
    }
        要注意的是,池僅僅負責資源的狀態轉換,並沒有處理資源的開關,與銷往邏輯,具體工程中可以根據資源型別分類編寫,也可以給資源掛在統一的邏輯指令碼去處理自己的銷燬回撥。       還有另一種方法,則是在使用池子進行資源銷燬之前,自己動手對資源進行回收相關的處理,這樣更依賴於人,不推薦團隊使用,但此時我們做範例,就不額外引入更多的業務邏輯 池測試       現在池已經弄好了,我們就需要簡單的做一個池子的小測試。開啟專案工程10-2PooL場景,我們能找到Test物件,它身上有指令碼PoolTest.Cs 。當遊戲執行時,我們就可以通過它去檢查池子是否生效。 

///


    /// 
測試池
    /// 

    private void testPool()
    {
        Profiler.BeginSample("資源載入");
        DateTime time = DateTime.Now;
        //
載入幹物妹
        var obj = DJPoolManager.GetInstance().getObj(0);

        Debug.Log("
載入花費了:" + (DateTime.Now -time).TotalMilliseconds);

        //
釋放幹物妹
        DJPoolManager.GetInstance().recObj(obj);
        Profiler.EndSample();
    }
        使用之後這個函式測試之後,我們可以發現,第一次載入花費了9毫秒,而第二次,則只用了2毫秒。         具體的花費,我們也需要通過效能分析器去檢視,使用 Profiler.BeginSample("資源載入")進行標記,這裡就不在額外擴充套件。         PS: 在文章程式碼中並沒有對預製體進行管理,這其實是不好的,最好手動的控制他們的載入與釋放。 資源生命週期的自動記錄         要記錄資源的生命週期,首先我們得確定自己的遊戲形勢,如果是大世界型別的遊戲,我們需要根據區域範圍來確定資源表,那麼如果是副本型別的,我們就需要以副本為單位記錄一份資源表。        並且,有的資源我們希望是動態載入的,而有的資源,比如主角的特效,模型,音訊等等,我們更希望它們是常駐的。所以,我們還需要區分一份資源是否需要動態載入。       知道了需求後,我們就可以對自動記錄表進行設計。為了講解清晰,我儘量的保持任何一個元素都只是為了測試,不與業務邏輯掛鉤。       在工程中,你可以到之前我們建立過的DJAssetsDefine 名稱空間,裡面我們新添加了這一次需要使用到的記錄表。

[System.Serializable]
    public class AssetPreConfig
    {
        ///


        /// 資源ID
        /// 
        public int AssetId;

        ///
        /// 載入時間
        /// 
        public float LordTime;


        ///
        /// 下一個次同類資源的載入時間,-1 就是再也沒有載入過了
        /// 
        public float NextTime = -1;
    }
        欄位很簡單,也有註釋說明,大家看註釋就好。         之後我們要讓它成為一張表,所以需要再建立一個檔案。在工程裡可以找到名為:DJAssetPreLoadTable.cs 的程式碼檔案。只有一個List,我打算直接使用List的索引來表示資源載入的前後關係,所以就不需要其他資訊了。

public class DJAssetPreLoadTable : DJTableBase
{
    ///


    /// 預載入列表
    ///
    public List Datas = new List();
}
自動記錄        有了表以後,我們就可以在遊戲執行時,把被載入的資源記錄到表中。這裡麵包含了一個邏輯過程。 程式碼如下:

///


    /// 得到一個克隆體
    ///
    ///資源id
    /// 是否預載入
    ///
    private Object getClone(int _id, bool _isPre = false)
    {
        //
預載入直接返回新的
        if (_isPre) return Object.Instantiate(PoolDict[_id].pre); ;

        //
池裡有從池裡拿
        if (PoolDict[_id].Pools.Count > 0)
        {
            currentIndex+= 1;
            return PoolDict[_id].Pools.Pop();
        }

        //
記錄下這次載入
        AutoLog(_id);

        //返回一個新的
        return Object.Instantiate(PoolDict[_id].pre);
    }
        AutoLog就是我們記錄程式碼,在PoolManager中,我定義一個新的字典,用來在執行時候讀取與儲存與自動記錄有關的資訊。下面是具體的AutoLog程式碼。

///


    /// 記錄資源
    ///
    public void AutoLog(int _id)
    {
        if (isAutoPre == false) return;

        Debug.Log("
記錄了資源,index " + currentIndex + "資源ID: " + _id);

        AssetPreConfig config = new AssetPreConfig();
        config.AssetId = _id;
        config.LordTime = Time.time - startTime;

        currentTable.Datas.Insert(currentIndex,config);
        currentIndex += 1;
        PreIndex += 1;
    }
     有了上面兩個函式後,我對之前我們的資源getObj函式進行了一些修改,使得可以在載入資源時,把資源表資訊的內容,記錄下來。

///


    /// 得到資源,如果池子裡有,直接拿,否則建立
    ///
    ///資源型別,方便上級使用
    ///資源id
    ///
    public T getObj(int _id)
        where T : Object
    {
        Object temp = null;

        //
建立一個池子
        if (PoolDict.ContainsKey(_id) == false)
            createObejctPool(_id);

        //
獲取一個克隆體
        temp = getClone(_id);

        //加入反查字典
        PrePoolDict.Add(temp,_id);

        if (temp as T == null)
        {
            Debug.LogError("
程式碼寫錯了或資源配錯了,傳入的資源id與希望得到的型別不匹配");
            Debug.Break();
            return null;
        }
        return (T)temp;
    }
       好,有了這些程式碼以後,我們就可以開始測試了記錄工作了。        當然,記錄流程呢還有其他程式碼,比如開始與結束等等,都是一些業務邏輯上的程式碼,如果我把他們貼上來,就會讓你迷糊,所以我貼出關鍵點,當讀者感興趣時,自己可以查閱github上的工程程式碼。 資源回收判定        大部分的資源被創建出來後,都有生命週期結束的時刻,當它的生命週期結束時,我們就需要決定是刪除它還是僅僅回收到池中。      在我們的解決方案中,我定義了一個規則,並且為了測試,改變了引數。 1、當一份資源建立時,根據下一次同類資源呼叫時間決定是否刪除 2、 為了測試,呼叫間隔為10秒 3、因為要知道同類資源下次呼叫時間,但又不希望執行時迴圈表,在自動記錄結束時,迴圈一次表進行判定。 4、如果一份資源被預載入了但是很久沒被使用過,則從記錄表中刪除該條資訊。(程式碼中未實現)。 程式碼如下:

///


    /// 回收資源
    ///
    public void recObj(Object _obj)
    {
        if (PrePoolDict.ContainsKey(_obj))
        {
            int id = PrePoolDict[_obj];
            //
清空反查
            PrePoolDict.Remove(_obj);

            PoolDict[id].Count -= 1;
            if (PoolDict[id].isDestroty== false)
            {
                //
正常回收
                Debug.Log("回收了:" + id);
                PoolDict[id].Pools.Push(_obj);
            }
            else
            {
                Debug.Log("
刪除了:" + id);
                if (PoolDict[id].Count == 0)
                {
                    //
刪除回收
                    Destroy(_obj);
                    //回收預製體
                    Resources.UnloadAsset(PoolDict[id].pre);
                    //去掉該資源的池資訊
                    PoolDict.Remove(id);
                }
                else
                {
                    //刪除回收
                    Destroy(_obj);
                }
            }
        }
        else
        {
            //不屬於池管理的資源直接刪除掉。不過得打出警告,按理說不應該存在
            Debug.LogWarning("檢測到非法建立的資源:" + _obj.name);
            Destroy(_obj);
        }
    }
       主要邏輯都有註釋,所以讀者應該可以看清楚關於資源回收的邏輯判定過程。至於額外的程式碼,就不貼出來,以免腦袋混亂。 資源自動預載入        當我們有了表,也自動記錄了,還有了資源回收機制以後,就可以開心的自動預載入記錄好的資源了。       在工程中,我直接把這個過程寫在了Update函式中,每一幀都檢測當前是否有資源需要載入,同時為了效能考慮,同一幀絕對不載入1份以上的資源。       這裡還有優化的空間,我們完全根據效能來決定什麼是否集中預載入,什麼時候不預載入,比如(戰鬥過程)。

///


    /// 預載入更新幀
    /// 
    void PreLoadUpdate()
    {
        //
沒東西可預載入了
        if (PreIndex >=currentTable.Datas.Count)
            return;

        AssetPreConfig config = currentTable.Datas[PreIndex];

        //
如果預載入的index所指向的內容在預載入時間內,就載入
        if (config.LordTime - (Time.time - startTime) <PRELOADTIME)
        {
            preObj(config.AssetId);
            PreIndex += 1;
            //
判斷之後該資源是回收還是刪除
            if (config.NextTime == -1 || config.NextTime >DESTROTYTIME)
            {
                PoolDict[config.AssetId].isDestroty = true;
            }
        }
    }

///


    /// 不管池子裡有多少,再生成一個放到池子裡
    ///
    ///
    public void preObj(int _id)
    {
        //
建立一個池子
        if (PoolDict.ContainsKey(_id) == false)
            createObejctPool(_id);

        PoolDict[_id].Pools.Push(getClone(_id, true));

        Debug.Log("
預載入了:" + PoolDict[_id].pre.name + "。 池中大小:" + PoolDict[_id].Pools.Count);
    }
        上面的程式碼一個Update中執行的,當判斷接下來2秒有一份資源請求時,就對其進行預載入。而下面的程式碼,就是生成一份資源,再直接丟入到池中。這樣,當2秒後這份資源需要使時,它就可以直接從池子裡獲取。 測試       把功能點寫完後,我們還需要對自己的程式碼進行測試,判斷是否達到了預期的目標。因為這次測試比較複雜,所以我寫了一個簡單的測試程式碼來幫我們完成這個過程。      在場景10-2PooL中,可以找到指令碼PoolTest.cs ,裡面包含了這次的測試過程,具體規則如下:      1、第一次測試,沒有任何記錄存在,每一次資源載入都經過克隆的過程。      2、第二次測試,前部分資源擁有記錄,所以在回收的時候進行刪除。      3、第三次測試,因為第二次檢測到了後面10秒內還有同類資源,所以前面資源不釋放。

private void test()
    {
        
自動測試 = false;
        //
設定測試資源
        LoadID = 0;
        //1
、3秒時載入資源,5秒釋放,12秒後加載資源。
        //預測結果。
        //第二次執行,載入資源時都只用從池裡取出。
        DJPoolManager.GetInstance().BeginAutoPreLoad("自動測試");
        wait(1, () => { load(); });
        wait(3, () => { load(); });
        wait(5, () => { Rec(); Rec(); });//
此時第二次執行時應該是刪除資源
        wait(12, () => { load(); });//此時第二次執行也應該已有預製體
        wait(15, () =>
        {
            DJPoolManager.GetInstance().EndAutoPreLoad();
            Debug.Log("
自動測試完成");
        });
    }

       原本我希望第三次測試的時候,應該是再次預載入,前2份資源應該被刪掉,但估算時間的時候算錯了1秒。導致三次結果都不同,不過覺得這種用例用來展現“自動優化”的過程更好,所以就保留了下來。       下面,就是三次測試的結果。 第一次 此時記錄表內的內容                                第二次                                              可以看到,前兩次的資源都有預載入,所以時間上間斷了。而第三次資源,卻比第一次還要多,因為中間發生了資源刪除事件。 第三次   這一次,沒有任何資源是在使用時才被載入的,前2份資源也不會“輕易”的放棄了自己生命,而是等待這第3份的呼叫。徹底完成了優化的過程。 結束語       如果和業務邏輯相結合,我們所演示的功能是不夠的,但卻構建了整個自動化的資源載入與釋放的核心框架,使得我們在專案後續的開發過程中,儘可能的不會在IO方面遇到困難。        同時,如果我們能繼續對這部分的工作進行優化,還能製作出更平緩的遊戲資源IO流程,提供更好的遊戲效能。