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