1. 程式人生 > >Unity AssetBundle資源打包,Depend依賴關係

Unity AssetBundle資源打包,Depend依賴關係

這篇文章從AssetBundle的打包,使用,管理以及記憶體佔用各個方面進行了比較全面的分析,對AssetBundle使用過程中的一些坑進行填補指引以及噴! AssetBundle是Unity推薦的資源管理方式,官方列舉了諸如熱更新,壓縮,靈活等等優點,但AssetBundle的坑是非常深的,很多隱藏細節讓你使用起來需要十分謹慎,一不小心就會掉入深坑,打包沒規劃好,20MB的資源“壓縮”到了30MB,或者大量的包導致打包以及載入時的各種低效,或者莫名其妙地丟失關聯,或者記憶體爆掉,以及各種載入失敗,在網上研究了大量關於AssetBundle的文章,但每次看完之後,還是有不少疑問,所以只能通過實踐來解答心中的疑問,為確保結果的準確性,下面的測試在編輯器下,Windows,IOS下都進行了測試比較。 首先你為什麼要選擇AssetBundle,縱使他有千般好處,但一般選擇AssetBundle的原因就是,要做熱更新,動態更新遊戲資源,或者你Resource下的資源超過了它的極限(2GB還是4GB?),如果你沒有這樣的需求,那麼建議你不要使用這個壞東西,鬧心~~ 當你選擇了AssetBundle之後,以及我開始噴AssetBundle之前,我們需要對AssetBundle的工作流程做一個簡單的介紹: AssetBundle可以分為打包AssetBundle以及使用AssetBundle 打包需要在UnityEditor下編寫一些簡單的程式碼,來取出你要打包的資源,然後呼叫打包方法進行打包
Object obj = AssetDatabase.LoadMainAssetAtPath("Assets/Test.png");
BuildPipeline.BuildAssetBundle(obj, null,
                                  Application.streamingAssetsPath + "/Test.assetbundle",
                                 BuildAssetBundleOptions.CollectDependencies | BuildAssetBundleOptions.CompleteAssets
                                 | BuildAssetBundleOptions.DeterministicAssetBundle, BuildTarget.StandaloneWindows);

在使用的時候,需要用WWW來載入Bundle,然後再用加載出來的Bundle來Load資源
WWW w = new WWW("file://" + Application.streamingAssetsPath + "/Test.assetbundle");
myTexture = w.assetBundle.Load("Test");
【一,打包】 接下來我們來看一下打包: 1.資源的蒐集     在打包前我們可以通過遍歷目錄的方式來自動化地進行打包,可以有選擇性地將一些目錄打包成一個Bundle,這塊也可以用各種配置檔案來管理資源,也可以用目錄規範來管理     我這邊是用一個目錄規範對資源進行大的分類,分為公共以及遊戲內,遊戲外幾個大模組,然後用一套簡單命名規範來指引打包,例如用OBO(OneByOne)作為目錄字尾來指引將目錄下所有資源獨立打包,預設打成一個包,用Base字首來表示這屬於公共包,同級目錄下的其他目錄需要依賴於它     使用Directory的GetFiles和GetDirectories可以很方便地獲取到目錄以及目錄下的檔案
Directory.GetFiles("Assets/MyDirs", "*.*", SearchOption.TopDirectoryOnly);
    Directory.GetDirectories(Application.dataPath + "/Resources/Game", "*.*", SearchOption.AllDirectories);
2.資源讀取
    GetFiles蒐集到的資源路徑可以被載入,載入之前需要判斷一下字尾是否.meta,如果是則不取出該資源,然後將路徑轉換至Assets開頭的相對路徑,然後載入資源
string newPath = "Assets" + mypath.Replace(Application.dataPath, "");
    newPath = newPath.Replace("\\", "/");
    Object obj = AssetDatabase.LoadMainAssetAtPath(newPath);

3.打包函式     我們呼叫BuildPipeline.BuildAssetBundle來進行打包:     BuildPipeline.BuildAssetBundle有5個引數,第一個是主資源,第二個是資源陣列,這兩個引數必須有一個不為null,如果主資源存在於資源陣列中,是沒有任何關係的,如果設定了主資源,可以通過Bundle.mainAsset來直接使用它     第三個引數是路徑,一般我們設定為  Application.streamingAssetsPath + Bundle的目標路徑和Bundle名稱     第四個引數有四個選項,BuildAssetBundleOptions.CollectDependencies會去查詢依賴,BuildAssetBundleOptions.CompleteAssets會強制包含整個資源,BuildAssetBundleOptions.DeterministicAssetBundle會確保生成唯一ID,在打包依賴時會有用到,其他選項沒什麼意義     第五個引數是平臺,在安卓,IOS,PC下,我們需要傳入不同的平臺標識,以打出不同平臺適用的包,注意,Windows平臺下打出來的包,不能用於IOS
         在打對應的包之前應該先選擇對應的平臺再打包 4.打包的決策     在打包的時候,我們需要對包的大小和數量進行一個平衡,所有資源打成一個包,一個資源打一個包,都是比較極端的做法,他們的問題也很明顯,更多情況下我們需要靈活地將他們組合起來     打成一個包的缺點是載入了這個包,我們不需要的東西也會被載入進來,佔用額外記憶體,而且不利於熱更新     打成多個包的缺點是,容易造成冗餘,首先影響包的讀取速度,然後包之間的內容可能會有重複,且太多的包不利於資源管理     哪些模組打成一個包,哪些模組打成多個包,需要根據實際情況來,例如遊戲中每個怪物都需要打成一個包,因為每個怪物之間是獨立的,例如遊戲的基礎UI,可以打成一個包,因為他們在各個介面都會出現     PS.想打包進AssetBundle中的二進位制檔案,檔名的字尾必須為“.bytes” 【二,解包】     解包的第一步是將Bundle載入進來,new一個WWW傳入一個URL即可載入Bundle,我們可以傳入一個Bundle的網址,從網路下載,也可以傳入本地包的路徑,一般我們用file://開頭+Bundle路徑,來指定本地的Bundle,用http://https://開頭+Bundle網址來指定網路Bundle
string.Format("file://{0}/{1}", Application.streamingAssetsPath, bundlePath);
在安卓下路徑不一樣,如果是安卓平臺的本地Bundle,需要用jar:file://作為字首,並且需要設定特殊的路徑才能載入
string.Format("jar:file://{0}!/assets/{1}", Application.dataPath, bundlePath);
 傳入指定的URL之後,我們可以用WWW來載入Bundle,載入Bundle需要消耗一些時間,所以我們一般在協同裡面載入Bundle,如果載入失敗,你可以在www.error中得到失敗的原因
IEnumerator LoadBundle(string url)
{
    WWW www = = new WWW(url);
    yield return www;

    if (www.error != null)
    {
    Debug.LogError("Load Bundle Faile " + url + " Error Is " + www.error);
    yield break;
    }

    //Do something ...
}
除了建立一個WWW之外,還有另一個方法可以載入Bundle,WWW.LoadFromCacheOrDownload(url, version),使用這個函式對記憶體的佔用會小很多,但每次重新打包都需要將該Bundle對應的版本號更新(第二個引數version),否則可能會使用之前的包,而不是最新的包,LoadFromCacheOrDownload會將Bundle從網路或程式資源中,解壓到一個磁碟快取記憶體,一般可以理解為解壓到本地磁碟,如果本地磁碟已經存在該版本的資源,就直接使用解壓後的資源。對於AssetBundle所有對記憶體佔用的情況,後面會有一小節專門介紹它     LoadFromCacheOrDownload會記錄所有Bundle的使用情況,並在適當的時候刪除最近很少使用的資源包,它允許存在兩個版本號不同但名字一樣的資源包,這意味著你更新這個資源包之後,如果沒有更新程式碼中的版本號,你可能取到的會是舊版本的資源包,從而產生其他的一些BUG。另外,當你的磁碟空間不足的時候(硬碟爆了),LoadFromCacheOrDownload只是一個普通的new WWW!後面關於記憶體介紹的小節也會對這個感嘆號進行介紹的     拿到Bundle之後,我們就需要Load裡面的資源,有Load,LoadAll以及LoadAsyn可供選擇
//將所有物件載入資源
    Object[] objs = bundle.LoadAll();
 
    //載入名為obj的資源
    Object obj = bundle.Load("obj");
 
    //非同步載入名為resName,型別為type的資源
    AssetBundleRequest res = bundle.LoadAsync(resName, type);
        yield return res;
    var obj = res.asset;

 我們經常會把各種遊戲物件做成一個Prefab,那麼Prefab也會是我們Bundle中常見的一種資源,使用Prefab時需要注意一點,在Bundle中載入的Prefab是不能直接使用的,它需要被例項化之後,才能使用,而對於這種Prefab,例項化之後,這個Bundle就可以被釋放了
//需要先例項化
    GameObject obj = GameObject.Instantiate(bundle.Load("MyPrefab")) as GameObject;

 對於從Bundle中加載出來的Prefab,可以理解為我們直接從資源目錄下拖到指令碼上的一個Public變數,是未被例項化的Prefab,只是一個模板     如果你用上面的程式碼來載入資源,當你的資源慢慢多起來的時候,你可能會發現一個很坑爹的問題,你要載入的資源載入失敗了,例如你要載入一個GameObject,但是整個載入過程並沒有報錯,而當你要使用這個GameObject的時候,出錯了,而同樣的程式碼,我們在PC上可能沒有發現這個問題,當我們打安卓或IOS包時,某個資源載入失敗了。     出現這種神奇的問題,首先是懷疑打包的問題,包太大了?刪掉一些內容,不行!重新打一個?還是不行!然後發現來來回回,都是這一個GameObject報的錯,難道是這個GameObject裡面部分資源有問題?對這個GameObject各種分析,把它大卸八塊,處理成一個很簡單的GameObject,還是不行!難道是名字的問題?把這個GameObject的名字改了一下,可以了!     本來事情到這就該結束了,但是,這也太莫名其妙了吧!而且,最重要的是,哥就喜歡原來的名字!!把這個資源改成新的名字,怎麼看怎麼變扭,怎麼看都沒有原來的名字好看,所以繼續折騰了起來~     首先單步跟蹤到這個資源的Load,資源被成功Load出來了,但是Load出來的東西有點怪怪的,明顯不是一個GameObject,而是一個莫名其妙的東西,可能是Unity生成的一箇中間物件,也許是一個索引物件,反正不是我要的東西,打包的GameObject怎麼會變成這個玩意呢?於是在載入Bundle的地方,把Bundle LoadAll了一下,然後檢視這個Bundle裡面的內容     在這裡我們可以看到,有一個叫RoomHallView和RoomMainView的GameObject,並且,LoadAll之後的資源比我打包的資源要多很多,看樣子所有關聯到的資源都被自動打包進去了,陣列的427是RoomHallView的GameObject,而431才是RoomMainView的GameObject。可以看到名字叫做RoomMainView和RoomHallView的物件有好幾個,GameObject,Transform,以及一個只有名字的物件,它的型別是一個ReferenceData。     仔細檢視可以發現,RoomHallView的GameObject是排在陣列中所有名為RoomHallView物件的最前面,而RoomMainView則是ReferenceData排在前面,當我們Load或者LoadAsyn時,是一次陣列的遍歷,當遍歷到名字匹配的物件時,則將物件返回,LoadAsyn會對型別進行匹配,但由於我們傳入的是Object,而幾乎所有的物件都是Object,所以返回的結果就是第一個名字匹配的物件     在Load以及LoadAsyn時,除了名字,把要載入物件的型別也傳入,再除錯,原來的名字也可以正常被讀取到了,這個細節非常的坑,因為在官網並沒有提醒,而且示例的sample也沒有說應該注意這個地方,並且出現問題的機率很小。所以一旦出現,就坑死了
bundle.Load("MyPrefab", typeof(GameObject))

   另外,不要在IOS模擬器上測試AssetBundle,你會收到bad url的錯誤 【三,依賴】     依賴和打包息息相關,之所以把依賴單獨分開來講,是因為這玩意太坑了....... 【1.打包依賴】     在我們打包的時候,將兩個資源打包成單獨的包,那麼兩個資源所共用的資源,就會被打包成兩份,這就造成了冗餘,所以我們需要將公共資源抽出來,打成一個Bundle,然後後面兩個資源,依賴這個公共包,那麼還有另外一種方法,就是把它們三打成一個包,但這不利於後期維護           我們使用BuildPipeline.PushAssetDependencies()和BuildPipeline.PopAssetDependencies()來開啟Bundle之間的依賴關係,當我們呼叫PushAssetDependencies之後,會開啟依賴模式,當我們依次打包 A B C時,如果A包含了B的資源,B就不會再包含這個資源,而是直接依賴A的,如果A和B包含了C的資源,那麼C的這個資源舊不會被打包進去,而是依賴A和B。這時候只要有同樣的資源,就會向前依賴,當我們希望,B和C依賴A,但B和C之間不互相依賴,就需要巢狀Push Pop了,當我們呼叫PopAssetDependencies就會結束依賴
string path = Application.streamingAssetsPath;
  BuildPipeline.PushAssetDependencies();
 
  BuildTarget target = BuildTarget.StandaloneWindows;
  
  BuildPipeline.BuildAssetBundle(AssetDatabase.LoadMainAssetAtPath("Assets/UI_tck_icon_houtui.png"), null,
                                 path + "/package1.assetbundle",
                                 BuildAssetBundleOptions.CollectDependencies | BuildAssetBundleOptions.CompleteAssets
                                 | BuildAssetBundleOptions.DeterministicAssetBundle, target);
 
 
  BuildPipeline.BuildAssetBundle(AssetDatabase.LoadMainAssetAtPath("Assets/New Material.mat"), null,
                                 path + "/package2.assetbundle",
                                 BuildAssetBundleOptions.CollectDependencies | BuildAssetBundleOptions.CompleteAssets
                                 | BuildAssetBundleOptions.DeterministicAssetBundle, target);
 
 
  BuildPipeline.PushAssetDependencies();
  BuildPipeline.BuildAssetBundle(AssetDatabase.LoadMainAssetAtPath("Assets/Cube.prefab"), null,
                                 path + "/package3.assetbundle",
                                 BuildAssetBundleOptions.CollectDependencies | BuildAssetBundleOptions.CompleteAssets
                                 | BuildAssetBundleOptions.DeterministicAssetBundle, BuildTarget.StandaloneWindows);
  BuildPipeline.PopAssetDependencies();
 
 
 
  BuildPipeline.PushAssetDependencies();
  BuildPipeline.BuildAssetBundle(AssetDatabase.LoadMainAssetAtPath("Assets/Cubes.prefab"), null,
                                 path + "/package4.assetbundle",
                                 BuildAssetBundleOptions.CollectDependencies | BuildAssetBundleOptions.CompleteAssets
                                 | BuildAssetBundleOptions.DeterministicAssetBundle, target);
  BuildPipeline.PopAssetDependencies();
 
  BuildPipeline.PopAssetDependencies();

 上面的程式碼演示瞭如何使用依賴,這個測試使用了一個紋理,一個材質,一個正方體Prefab,還有兩個正方體組成的Prefab,材質使用了紋理,而兩組正方體都使用了這個材質,上面的程式碼用Push開啟了依賴,打包紋理,然後打包材質(材質自動依賴了紋理),然後嵌套了一個Push,打包正方體(正方體依賴前面的材質和紋理),然後Pop,接下來再嵌套了一個Push,打包那組正方體(不依賴前面的正方體,依賴材質和紋理)     如果我們只開啟最外面的Push Pop,而不巢狀Push Pop,那麼兩個正方體組成的Prefab就會依賴單個正方體的Prefab,依賴是一把雙刃劍,它可以去除冗餘,但有時候我們又需要那麼一點點冗餘 【2.依賴丟失】     當我們的Bundle之間有了依賴之後,就不能像前面那樣簡單地直接Load對應的Bundle了,我們需要把Bundle所依賴的Bundle先載入進來,這個載入只是WWW或者LoadFromCacheOrDownload,並不需要對這個Bundle進行Load,如果BundleB依賴BundleA,當我們要載入BundleB的資源時,假設BundleA沒有被載入進來,或者已經被Unload了,那麼BundleB依賴BundleA的部分就會丟失,例如每個正方體上都掛著一個指令碼,當我們不巢狀Push Pop時,單個正方體的Bundle沒有被載入或者已經被解除安裝,我們載入的那組正方體上的指令碼就會丟失,指令碼也是一種資源,當一個指令碼已經被打包了,依賴這個包的資源,就不會被再打進去 Cubes和Cube都掛載同一個指令碼,TestObje,Cubes依賴Cube,將Cube所在的Bundle Unload,再Load Cubes的Bundle,Cubes的指令碼丟失,指令碼,紋理,材質等一切資源,都是如此   【3.更新依賴】     在打包的時候我們需要指定BuildAssetBundleOptions.DeterministicAssetBundle選項,這個選項會為每個資源生成一個唯一的ID,當這個資源被重新打包的時候,確定這個ID不會改變,包的依賴是根據這個ID來的,使用這個選項的好處是,當資源需要更新時,依賴於該資源的其他資源,不需要重新打包     A -> B -> C     當A依賴B依賴C時,B更新,需要重新打包C,B,而A不需要動,打包C的原因是,因為B依賴於C,如果不打包C,直接打包B,那麼C的資源就會被重複打包,而且B和C的依賴關係也會斷掉 【四,記憶體】     在使用WWW載入Bundle時,會開闢一塊記憶體,這塊記憶體是Bundle檔案解壓之後的記憶體,這意味著這塊記憶體很大,通過Bundle.Unload可以釋放掉這塊記憶體,Unload true和Unload false 都會釋放掉這塊記憶體,而這個Bundle也不能再用,如果要再用,需要重新載入Bundle,需要注意的是,依賴這個Bundle的其他Bundle,在Load的時候,會報錯     得到Bundle之後,我們用Bundle.Load來載入資源,這些資源會從Bundle的記憶體被複製出來,作為Asset放到記憶體中,這意味著,這塊記憶體,也很大,Asset記憶體的釋放,與Unity其他資源的釋放機制一樣,可以通過Resources.UnloadUnuseAsset來釋放沒有引用的資源,也可以通過Bundle.Unload(true)來強制釋放Asset,這會導致所有引用到這個資源的物件丟失該資源     上面兩段話可以得出一個結論,在new WWW(url)的時候,會開闢一塊記憶體儲存解壓後的Bundle,而在資源被Load出來之後,又會開闢一塊記憶體來儲存Asset資源,WWW.LoadFromCacheOrDownload(url)的功能和new WWW(url)一樣,但LoadFromCacheOrDownload是將Bundle解壓到磁碟空間而不是記憶體中,所以LoadFromCacheOrDownload返回的WWW物件,本身並不會佔用過多的記憶體(只是一些索引資訊,每個資源對應的磁碟路徑,在Load時從磁碟取出),針對手機上記憶體較小的情況,使用WWW.LoadFromCacheOrDownload代替new WWW可以有效地節省記憶體。但LoadFromCacheOrDownload大法也有不靈驗的時候,當它不靈驗時,LoadFromCacheOrDownload返回的WWW物件將佔用和new WWW一樣的記憶體,所以不管你的Bundle是如何創建出來的,都需要在不使用的時候,及時地Unload掉。     另外使用LoadFromCacheOrDownload需要注意的問題是——第二個引數,版本號,Bundle重新打包之後,版本號沒有更新,取出的會是舊版本的Bundle,並且一個Bundle快取中可能會存在多箇舊版本的Bundle,例如1,2,3 三個版本的Bundle       在Bundle Load完之後,不需要再使用該Bundle了,進行Unload,如果有其他Bundle依賴於該Bundle,則應該等依賴於該Bundle的Bundle不需要再Load之後,Unload這個Bundle,一般出現在大場景切換的時候。     我們知道在打包Bundle的時候,有一個引數是mainAsset,如果傳入該引數,那麼資源會被視為主資源打包,在得到Bundle之後,可以用AssetBundle.mainAsset直接使用,那麼是否在WWW獲取Bundle的時候,就已經將mainAsset預先Load出來了呢?不是!在我們呼叫AssetBundle.mainAsset取出mainAsset時,它的get方法會阻塞地去Load mainAsset,然後返回,AssetBundle.mainAsset等同於Load("mainAssetName")       PS.重複Load同一個資源並不會開闢新的記憶體來儲存這個資源 【五,其他】     在使用AssetBundle的開發過程中,我們經常會對資源進行調整,調整之後需要對資源進行打包才能生效,對開發效率有很大的影響,所以在開發中我們使用Resource和Bundle相容的方式     首先將資源管理封裝到一個Manager中,從Bundle中Load資源還是從Resource裡面Load資源,都由它決定,這樣可以保證上層邏輯程式碼不需要關心當前的資源管理型別     當然,我們所有要打包的物件,都在Resource目錄下,並且使用嚴格的目錄規範,然後使用指令碼物件,來記錄每個資源所在的Bundle,以及所對應的Resource目錄,在資源發生變化的時候,更新指令碼物件,Manager在執行時使用指令碼物件的配置資訊,這裡的指令碼物件我們是使用程式碼自動生成的,當然,你也可以用配置表,效果也是一樣的     版本管理也可以交由指令碼物件來實現,每次打包的資源,需要將其版本號+1,指令碼物件可儲存所有資源的版本號,版本號可以用於LoadFromCacheOrDownload時傳入,也可以手動寫入配置表,在我設計的指令碼物件中,每個資源都會有一個所屬Bundle,Resource下相對路徑,版本號等三個屬性     在版本釋出的時候,你需要先打包一次Bundle,並且將Resource目錄改成其他的名字,然後再打包,確保Resource目錄下的資源沒有被重複打包,而如果你想打的是Resource版本,則需要將StreamingAssets下的Bundle檔案刪除     指令碼物件的使用如下:     1.先設計好儲存結構     2.寫一個繼承於ScriptObject的類,用可序列化的容器儲存資料結構(List或陣列),Dictionary等容器無法序列化,public之後在
[Serializable]
public class ResConfigData
{
    public string ResName; //資源名字
    public string BundleName; //包名字
    public string Path; //資源路徑
    public int Vesrion; //版本號
}
 
[System.Serializable]
public class ResConfig : ScriptableObject
{
    public List<ResConfigData> ConfigDatas = new List<ResConfigData>();
}

4.在指定的路徑讀取物件,讀取不到則建立物件
ResConfig obj = (ResConfig)AssetDatabase.LoadAssetAtPath(path, typeof(ResConfig));
if (obj == null)
{
   obj = ScriptableObject.CreateInstance<ResConfig>();
   AssetDatabase.CreateAsset(obj, path);
}

3.寫入資料,直接修改obj的陣列,並儲存(不儲存下次啟動Unity資料會丟失)
EditorUtility.SetDirty(obj);
由於陣列操作不方便,所以我們可以將資料轉化為方便各種增刪操作的Dictionary容器儲存,在保持時將其寫入到持久化的容器中