【Unity】Unity資源池的動態載入釋放和記憶體優化處理
阿新 • • 發佈:2018-12-29
需求環境
在上一級的【解決方案】文章中,我們設計出了動態載入資源的業務流程,而這一節,我們就通過一些簡單的程式碼,來實現出業務流程中的效果。
吸取之前文章的經驗,如果按照正式專案的規格開發,本篇文章就會非常冗餘,所以我們優化一下,僅僅針對技術點進行講解與釋放,具體與工程相關的,我們就不再文章中講解,但你可以在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 名稱空間,裡面我們新添加了這一次需要使用到的記錄表。
/// 資源ID
///
public int AssetId;
///
/// 載入時間
///
public float LordTime;
///
/// 下一個次同類資源的載入時間,-1 就是再也沒有載入過了
///
public float NextTime = -1;
} 欄位很簡單,也有註釋說明,大家看註釋就好。 之後我們要讓它成為一張表,所以需要再建立一個檔案。在工程裡可以找到名為:DJAssetPreLoadTable.cs 的程式碼檔案。只有一個List,我打算直接使用List的索引來表示資源載入的前後關係,所以就不需要其他資訊了。
/// 預載入列表
///
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秒內還有同類資源,所以前面資源不釋放。
///
/// 池
///
Dictionary<int, Stack> PoolDict = new Dictionary();
///
/// 正在工作的資源物件
///
Dictionary<Object, int> WorkingPool = new Dictionary();
///
/// 得到資源,如果池子裡有,直接拿,否則建立
///
///資源型別,方便上級使用
///資源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("自動測試完成");
});
}