這一部分主要是對Unity的Resources系統(tǒng)和AssetBundle系統(tǒng)進行深入討論。
分為四個部分:
有關(guān)Asset的底層細節(jié),Asset序列化和引用之間的關(guān)系;
Resources系統(tǒng)
AssetBundle 基礎(chǔ)
AssetBundle 實踐
這篇文章主要是第一部分:
Assets, Objects 和 序列化操作
要點:
- Asset和UnityEngine.Object的區(qū)別和聯(lián)系
- 實例ID(instance ID)
- 序列化和實例化
- MonoScript腳本
- 資源生命周期
- 復(fù)雜層次的Prefab的導(dǎo)入和優(yōu)化
Asset和Object
Asset指的是存儲在磁盤上的資產(chǎn)文件,在Unity工程下的Assets目錄下的所有文件都可以認(rèn)為是Asset。如材質(zhì)、紋理和FBX模型,這些都可以認(rèn)為是Asset。
Asset包括兩種,一種是使用Unity工具創(chuàng)建的,例如材質(zhì)這些;另外一種則是從外部導(dǎo)入的文件,Unity轉(zhuǎn)換成為自己可以使用的格式,如FBX文件。
而Object,一般指的是繼承UnityEngine.Object類的所有實例。Object指的是某個資源的特定實例,里面包含著序列化的數(shù)據(jù)。Object可以是Unity引擎使用到的任何資源形式,如網(wǎng)格、音頻片段、動畫片段等等。
兩個需要特別注意的Object類:
ScriptableObject給開發(fā)者提供了自定義數(shù)據(jù)類型的方法。繼承ScriptableObject的類型可以被Unity序列化和反序列化,而且可以在編輯器中的Inspector窗口中查看。MonoBehaviour提供了鏈接到MonoScript的包裝類。MonoScript是Unity用來持有某個特定腳本類的引用的數(shù)據(jù)類型。MonoScript不包含任何實際的執(zhí)行代碼。
Asset和Object的關(guān)系是一對多的,一個Asset文件里面可以包含一個或者多個Object。
Object之間的引用關(guān)系
Object之間可以互相引用。一個Object既可以引用存在于同一個Asset文件中的Object,也可以引用存在于其他Asset文件中的Object。例如,材質(zhì)Object就可以引用多個紋理Object,這些紋理Object可以存在于一個紋理Asset文件中,也可以存在于多個紋理Asset文件中。
那么,Unity是如何存儲這些引用的呢?
當(dāng)被序列化的時候,這些引用會使用兩份獨立的數(shù)據(jù)進行存儲:文件GUID(File GUID)和局部ID(local ID)。
文件GUID用來標(biāo)記Asset文件的具體位置。
局部ID則用來標(biāo)記Asset文件中的Object,局部ID不會重復(fù),一個Asset文件中不同的Object的局部ID也會唯一。
文件GUID存在于.meta文件中,這些.meta文件會在Asset文件第一次被導(dǎo)入Unity的時候生成,存在和Asset文件相同的文件夾內(nèi)。
這些信息是可以通過文本編輯器查看,將Unity工程的Editor Setting設(shè)置成expose Visible Meta File和to serialize Assets as Text。
新創(chuàng)建一個材質(zhì)文件,同時向工程中導(dǎo)入一個紋理文件,將材質(zhì)賦給場景中的立方體并且儲存這個場景。
使用文本編輯器打開材質(zhì)文件對應(yīng)的.meta文件:
fileFormatVersion: 2
guid: b2f39b876f4ffe247b63e00b09aea5cd
......
文件開頭出現(xiàn)的guid標(biāo)簽定義的就是材質(zhì)文件的GUID。
如果想要看到局部ID,使用文本編輯器打開材質(zhì)文件,可以看到類似于下面的信息:
--- !u!21 &2100000
Material:
serializedVersion: 3
... more data ...
在上面的例子中,&后面跟著的就是材質(zhì)的局部ID。如果材質(zhì)文件的.meta文件中的guid是abcdefg的話,那么使用文件GUID abcdefg和局部ID 2100000唯一確定了。
為什么同時采用文件GUID和局部ID?
簡單來講,是為了更強的健壯性和獨立于平臺的設(shè)計方法。
文件GUID提供了文件存儲位置的抽象,因為不同的文件的GUID不同,所以根據(jù)GUID就可以找到文件,文件的實際存儲位置就變得無關(guān)緊要了。這個文件可以自由移動,而不需要去更新所以引用到這個文件的其他文件的信息。
而任何Asset文件都可以包含多個Object,使用局部ID就可以對這些Object進行區(qū)分。
如果Asset文件關(guān)聯(lián)的文件GUID丟失,那么對這個Asset里面的Object的所有引用也會全部失效。這就是.meta為什么要存放在Asset文件的同一個文件夾下面,而且名字也要和Asset的名稱一樣。注意,Unity會重新生成誤刪或者放錯位置的.meta文件。同樣,當(dāng)Asset文件已經(jīng)不存在的時候,Unity也會刪除掉多余的.meta文件
Unity編輯器會維護一個所有文件的路徑和文件GUID之間對應(yīng)關(guān)系的映射表。當(dāng)新的Asset被導(dǎo)入的時候,映射表會記錄下GUID和文件路徑之間的關(guān)系。如果Unity打開的時候,.meta文件丟失,而Asset的路徑?jīng)]有發(fā)生變化,Unity可以確保生成一樣的GUID。
當(dāng)Unity編輯器關(guān)閉的時候,改變Asset文件的路徑,而且沒有移動對應(yīng)的.meta文件,那么對這個Asset文件中的Object的引用會失效。
復(fù)合Asset文件的導(dǎo)入
所有不是Unity創(chuàng)建的資源都需要導(dǎo)入的流程才能被Unity使用,Unity導(dǎo)入的流程是自動發(fā)生的,但是可以通過腳本對導(dǎo)入的過程進行自定義。AssetImporter和相關(guān)的子類就可以完成這些工作,例如TextureImporter的API就可以完成導(dǎo)入紋理資源,如PNG圖片或者JPG圖片的時候可以執(zhí)行的操作。
導(dǎo)入結(jié)果就是一個Asset文件中可能會包含一個或者多個Object。這些都是可以通過Unity編輯器的Inspector窗口查看。例如,當(dāng)一個紋理資源包含多個子Sprite時候,所有的子Sprite都會共享同一個GUID,而根據(jù)局部ID進行區(qū)分。
導(dǎo)入流程需要將原始的Asset文件轉(zhuǎn)換成為目標(biāo)平臺適用的資源類型,可能會執(zhí)行一些比較復(fù)雜的操作,比如紋理壓縮。試想一下,如果每次打開Unity都需要重復(fù)執(zhí)行這些操作,需要耗費很多時間。
所以,Unity采用的解決辦法是,將Asset文件導(dǎo)入的結(jié)果緩存在Library中。需要指出的是,導(dǎo)入的最終結(jié)果會放在Asset文件GUID前兩位數(shù)字命名的文件夾中,這些文件夾放在Library/metadata文件夾中。這些導(dǎo)入產(chǎn)生的Object會被序列化成一個和Asset文件GUID一樣的二進制文件。
雖然所有的Asset都是這樣處理,但是原生的Asset不需要轉(zhuǎn)換和反序列化操作。
序列化和實例化
雖然文件GUID和局部ID這種設(shè)計方法能夠保證穩(wěn)定性,但是另一方面,這樣的設(shè)計也會導(dǎo)致性能問題,所以需要在運行時能夠支撐性能。所以Unity內(nèi)部會維護一個緩存【在底層,這個緩存用PersistentManager來管理。內(nèi)部的轉(zhuǎn)換過程是通過Unity的C++的Remapper完成的,Remapper沒有通過任何API暴露】,這個緩存會將文件GUID和局部ID轉(zhuǎn)換成一個整數(shù),并且保證在某次運行過程中唯一。這些整數(shù)被稱為實例ID(instance ID),使用簡單的自增順序管理,當(dāng)新Object在管理器中被注冊的時候,便把當(dāng)前的實例ID賦給Object,同時自增加1.
緩存管理器會維護一個實例ID和存放在內(nèi)存中的Object之間的映射關(guān)系,這樣同時也能夠保證Object之間存在穩(wěn)定的引用關(guān)系。對一個實例ID進行解析就可以找到已經(jīng)加載的Object,如果對應(yīng)的目標(biāo)Object沒有被加載,那么根據(jù)文件GUID和局部ID也可以找到Object對應(yīng)的Asset文件,從存儲器中加載。
在啟動階段,Project場景中引用到的所有Object以及所有Resources目錄下的Object的Instance ID緩存都會被初始化。在運行過程中,如果從AssetBundle中加載進來創(chuàng)建新的Object,緩存中也會加入新的信息。當(dāng)Object失效的時候,緩存中的信息也會被清除掉。當(dāng)AssetBundle被卸載的時候,有可能會發(fā)生這些過程。
關(guān)于更多的AssetBundle的信息,可以參見
AssetBundle Usage Pattern
需要特別指出的是,某些特定平臺的某些事件可以強行從內(nèi)存中移除Object。例如,在iOS設(shè)備上,當(dāng)應(yīng)用被掛起的時候,圖形顯示相關(guān)的Asset會從顯卡內(nèi)存中移除。如果這些Object的源文件AssetBundle被卸載,Unity就不能重新載入這些Object的源數(shù)據(jù)了。關(guān)于這些Object現(xiàn)有的引用也會無效。上面的例子可能會導(dǎo)致出現(xiàn)網(wǎng)格丟失或者模型的紋理或材質(zhì)丟失的問題。
具體的實現(xiàn)細節(jié)可能比上面描述的情況更加復(fù)雜。在執(zhí)行很大的加載操作的時候,頻繁比較文件GUID和局部ID并不是非常劃算的操作。當(dāng)打包的時候,文件GUID和局部ID會建立一個簡單的映射關(guān)系。盡管如此,上面描述的流程還是大致符合的,只需要記住文件GUID和局部ID在運行的時候是獨一無二的。
還有,在運行階段,Asset文件的GUID也是不可以被查詢。
MonoScript腳本
MonoBehaviour對象會引用到MonoScript對象,MonoScript對象包含著定位到某個特定腳本對象的信息,但是這兩個對象都不會包括腳本類執(zhí)行的具體方法。
每個MonoScript包含三個字符串信息:程序集名稱,類名和命名空間。
每當(dāng)打包一個工程的時候,Unity會收集Assets下所有腳本文件,并將這些文件編譯成Mono程序集。Unity會對Assets下不同語言編寫的代碼進行區(qū)分,分開編譯成不同的程序集。而且Assets/Plugins目錄下的代碼也會被單獨處理。Plugins子目錄外的代碼被放入Assembly-CSharp.dll中。Plugins目錄內(nèi)的代碼被放入到Assembly-CSharp-firstpass.dll中。
這些程序集(加上提前編譯好的程序集DLL)都會被打進最后的Unity應(yīng)用中。MonoScript也會引用到這些程序集。不同于其他的資源類型,Unity應(yīng)用中的所有程序集會在一開始就被加載進來。
AssetBundle的MonoBehaviour組件上并沒有包含真正可執(zhí)行的代碼,是因為使用了MonoScript的原因。而且這樣也能夠保證允許不同的MonoBehaviour可以引用共享的類,即使MonoBehaviour是存在于不同的AssetBundle中。
資源的生命周期
UnityEngine.Object對象可以在指定的時間被加載進內(nèi)存或者從內(nèi)存中卸載。為了減少加載時間和管理應(yīng)用內(nèi)存,所以需要掌握Object資源的生命周期。
有兩種加載Object的方法:自動加載和顯示調(diào)用加載。
自動加載:當(dāng)Instance ID被引用的時候,Object并沒有存在內(nèi)存中,而且Object對應(yīng)的Asset文件可以被定位到的時候,Object就會被自動加載。
顯示調(diào)用加載:通過調(diào)用資源加載相關(guān)的API(如AssetBundle.LoadAsset)。
當(dāng)Object被加載的時候,Unity會嘗試解析所有的引用關(guān)系,并將這些引用到的文件GUID和局部ID轉(zhuǎn)換成Instance ID。
如果Instance ID被間接引用到的時候,而且滿足如下的兩個條件,那么Object就會立馬被加載進來:
- Instance ID對應(yīng)的Object還沒有被加載進內(nèi)存。
- Instance ID在緩存中注冊的文件GUID和局部ID已經(jīng)失效。
這個通常當(dāng)被引用之后很快就被執(zhí)行。
如果一個文件GUID和局部ID并沒有Instance ID,或者某個已經(jīng)卸載掉的Object的Instance ID引用著一個無效的文件GUID和局部ID。這個引用繼續(xù)唄把持,但是Object不會被加載。當(dāng)出現(xiàn)這種情況的時候,Unity編輯器中就會出現(xiàn)丟失的引用,在場景視圖中,不同類型的丟失Object的表現(xiàn)形式也各不相同:網(wǎng)格會不可見,而紋理貼圖則會變成洋紅色。
Object通常會在如下的情況下被卸載:
當(dāng)未被使用的Asset被清除的時候,關(guān)聯(lián)的Object也會被卸載。當(dāng)場景會強制卸載的時候,就會出現(xiàn)這種情況,例如調(diào)用Application.LoadLevel或者Resources.UnloadUnusedAssets。這個過程只會卸載掉沒有被引用的Object,沒有引用指的是沒有任何Mono變量持有這個Object的引用,場景中的其他活動狀態(tài)下的Object也沒有持有該Object的引用。
來自于Resources目錄中的Object可以通過調(diào)用Resources.UnloadAsset顯式卸載。這些Object的Instance ID仍然有效,而且緩存中的文件GUID和局部ID也仍然有效。如果Mono變量或者其他的Object再次持有已經(jīng)被Resources.UnloadAsset卸載掉的Object的引用的時候,被卸載掉的Object也會被再次加載進來。
來自于AssetBundle的Object的話,當(dāng)調(diào)用AssetBundle.Unload(true)的時候,會卸載掉Object,這樣會讓Object的Instance ID對應(yīng)的文件GUID和局部ID都會失效,而且所有關(guān)于被卸載的Object的引用都會變成丟失狀態(tài)。對于C#腳本而言,任何嘗試獲取已經(jīng)被卸載掉的Object中的方法或者屬性都會產(chǎn)生NullReferenceException的報錯信息。
如果調(diào)用的是AssetBundle.Unload(false),那么從這個AssetBundle中的正在處于活躍狀態(tài)下的Object不會被卸載,但是Unity會讓文件GUID和局部ID都失效,如果這些內(nèi)存中的Object被移除的話,Unity就不能重新加載這些Object,而且對這些Object的引用仍然保持著。
導(dǎo)入有著復(fù)雜層次的Prefab
當(dāng)序列化有著復(fù)雜層次的GameObject的時候,需要記住,整個層次結(jié)構(gòu)是一起被序列化的。層次中的每一個GameObject和組件在序列化中的數(shù)據(jù)都是獨立的。這對于加載和實例化這些GameObject的時間有著巨大影響。
當(dāng)創(chuàng)建一個新的具有復(fù)雜層次的GameObject時,主要的CPU消耗來自于如下地方:
- 讀取GameObject的數(shù)據(jù)(從磁盤加載,或者從內(nèi)存中已經(jīng)存在的GameObject)
- 建立新的Transform之間的父子層級關(guān)系
- 實例化新的GameObject和對應(yīng)的組件
- 調(diào)用GameObject和組件中的Awake方法
后面的三個步驟消耗的時間,對于GameObject是從磁盤中讀取數(shù)據(jù)還是從內(nèi)存中讀數(shù)據(jù)而言差別不大。但是對于第一個步驟而言,既和從加載的數(shù)據(jù)源頭有關(guān),也和層次中的GameObject和組件的數(shù)目相關(guān)。
在目前的情況下,從內(nèi)存中讀取數(shù)據(jù)肯定快于從磁盤中讀取數(shù)據(jù),而且不同平臺的差異也會非常大。桌面PC的速度通常快于移動設(shè)備。
所以,如果在存儲器讀取速度比較慢的設(shè)備上加載Prefab的時候,花在存儲器讀取序列化數(shù)據(jù)的時間遠遠超過實例化Prefab的時間,所以主要的消耗時間就是就存儲器I/O決定的。
還有,當(dāng)序列化一個巨大的Prefab的時候,其中的每個GameObject和組件都是分來序列化的——即使里面有很多的數(shù)據(jù)是重復(fù)的。如果一個UI包括30個一樣的元素,那么這個元素同樣會被序列化30次,一方面會導(dǎo)致序列化之后的文件很大,另一方面這樣也讓讀取慢了很多。
采取的優(yōu)化方法是,將重復(fù)的元素拆分成獨立的Prefab,在運行時實例化這些重用的元素,而不是將它們放到同一個Prefab中,依靠Unity的序列化系統(tǒng)去處理他們。這樣優(yōu)化的話,可能會節(jié)約不少時間。
另外,從場景中已經(jīng)存在的GameObject復(fù)制所花的時間會遠遠少于從存儲器中加載一個新的Prefab。
Unity 5.4 提示:從Unity5.4版本開始,transform在內(nèi)存中的存儲方式進行了修改,每個根節(jié)點的Transofrm和子節(jié)點的Transform信息在內(nèi)存中是連續(xù)儲存的。所以當(dāng)實例化新的GameObject,并且需要馬上改變GameObject的層次的時候,最好使用帶有parent參數(shù)的Game.Instantiate的方法。
參考:https://docs.unity3d.com/ScriptReference/Object.Instantiate.html
使用新的API可以避免對新的GameObject根節(jié)點Transform的內(nèi)存分配。在測試情況下,使用這個API通常會快5%~10%。