Mod系統(tǒng)實(shí)戰(zhàn):AssetBundle熱更與腳本擴(kuò)展)
1. 為什么要在Unity3D里自己造一個(gè)Mod系統(tǒng)做游戲開(kāi)發(fā)這行尤其是獨(dú)立團(tuán)隊(duì)或者小規(guī)模工作室遲早會(huì)碰到一個(gè)繞不開(kāi)的問(wèn)題玩家想要自定義內(nèi)容而你的代碼架構(gòu)從一開(kāi)始就沒(méi)給這件事留口子。我最早接觸Mod系統(tǒng)是在一個(gè)沙盒建造類項(xiàng)目里當(dāng)時(shí)策劃跑過(guò)來(lái)問(wèn)“能不能讓玩家自己加方塊貼圖”我第一反應(yīng)是“改AssetBundle就行”結(jié)果真動(dòng)手才發(fā)現(xiàn)移動(dòng)端的熱更新限制、資源加載路徑、腳本注入安全每一個(gè)都是坑。這個(gè)項(xiàng)目標(biāo)題說(shuō)的是“簡(jiǎn)單實(shí)現(xiàn)”但簡(jiǎn)單不等于簡(jiǎn)陋。我理解的“簡(jiǎn)單”是架構(gòu)清晰、依賴少、PC和移動(dòng)端都能跑而不是功能殘缺。所以這篇文章我會(huì)從零開(kāi)始把一套可用的Mod系統(tǒng)拆開(kāi)講透包括資源加載、配置解析、腳本擴(kuò)展點(diǎn)設(shè)計(jì)以及移動(dòng)端特有的權(quán)限和路徑問(wèn)題。適合誰(shuí)看如果你已經(jīng)會(huì)用Unity做基礎(chǔ)開(kāi)發(fā)知道AssetBundle或者Addressables怎么用但沒(méi)系統(tǒng)做過(guò)Mod框架那這篇就是給你寫(xiě)的。如果你是完全的新手建議先把Unity的資源加載流程摸一遍再回來(lái)不然有些細(xì)節(jié)會(huì)卡住。核心關(guān)鍵詞我先擺出來(lái)Unity3D Mod系統(tǒng)、移動(dòng)端資源加載、AssetBundle熱更、腳本擴(kuò)展、跨平臺(tái)路徑管理。這幾個(gè)詞會(huì)貫穿全文你可以在腦子里先有個(gè)印象。2. 整體架構(gòu)設(shè)計(jì)與技術(shù)選型思路2.1 為什么不用現(xiàn)成的Mod框架市面上確實(shí)有一些開(kāi)源的Unity Mod方案比如基于Mono.Cecil的腳本注入、或者用Lua做熱更層。我試過(guò)其中兩三種最后都放棄了。原因很直接移動(dòng)端對(duì)反射和動(dòng)態(tài)代碼生成的限制越來(lái)越嚴(yán)IL2CPP模式下很多方案直接跑不起來(lái)。而且現(xiàn)成框架往往綁定了一套完整的資源管理邏輯你項(xiàng)目里已有的AssetBundle流程要硬塞進(jìn)去改造成本比自研還高。所以我的選擇是資源層用AssetBundle配置層用JSON腳本層用接口反射注冊(cè)。這套組合在PC和移動(dòng)端都能穩(wěn)定運(yùn)行不依賴任何第三方熱更庫(kù)代碼量可控出問(wèn)題也好排查。2.2 核心模塊劃分整個(gè)Mod系統(tǒng)我拆成四個(gè)模塊每個(gè)模塊職責(zé)單一方便單獨(dú)調(diào)試Mod發(fā)現(xiàn)與加載器掃描指定目錄識(shí)別Mod包結(jié)構(gòu)讀取清單文件。資源管理器負(fù)責(zé)從AssetBundle里加載預(yù)制體、貼圖、音頻等資源并處理依賴關(guān)系。配置解析器讀取JSON格式的Mod配置映射到游戲內(nèi)的數(shù)據(jù)對(duì)象。擴(kuò)展點(diǎn)注冊(cè)中心提供接口讓Mod代碼掛載到游戲邏輯的特定節(jié)點(diǎn)上。這四個(gè)模塊之間的通信全部通過(guò)事件或者接口調(diào)用不直接互相引用內(nèi)部實(shí)現(xiàn)。這樣做的好處是你以后想換資源加載方式比如換成Addressables只需要改資源管理器其他模塊不動(dòng)。2.3 跨平臺(tái)路徑策略PC和移動(dòng)端最大的差異在文件路徑。PC上你可以隨便用Application.dataPath往上翻幾層移動(dòng)端尤其是AndroidStreamingAssets是只讀的persistentDataPath才是可寫(xiě)目錄。我的做法是public static string GetModRootPath() { #if UNITY_ANDROID !UNITY_EDITOR return Path.Combine(Application.persistentDataPath, Mods); #elif UNITY_IOS !UNITY_EDITOR return Path.Combine(Application.persistentDataPath, Mods); #else return Path.Combine(Application.dataPath, ../Mods); #endif }注意Android上如果Mod資源需要隨包發(fā)布得先放在StreamingAssets里首次啟動(dòng)時(shí)拷貝到persistentDataPath。這個(gè)拷貝操作只需要做一次用版本號(hào)或者文件哈希做標(biāo)記。2.4 Mod包結(jié)構(gòu)約定為了讓加載器能統(tǒng)一處理我定了一個(gè)固定的目錄結(jié)構(gòu)Mods/ ModA/ mod.json resources.bundle preview.png ModB/ mod.json resources.bundlemod.json是必須的里面至少包含Mod名稱、版本、作者、入口程序集名稱如果有腳本。resources.bundle是AssetBundle文件里面打包了所有資源。preview.png可選用于在Mod列表里顯示縮略圖。這個(gè)結(jié)構(gòu)看起來(lái)簡(jiǎn)單但實(shí)際用起來(lái)很穩(wěn)。我試過(guò)讓Mod支持多個(gè)AssetBundle文件結(jié)果依賴關(guān)系管理變得復(fù)雜后來(lái)統(tǒng)一成一個(gè)Bundle內(nèi)部靠AssetBundleManifest處理依賴省心很多。3. 核心細(xì)節(jié)解析與實(shí)操要點(diǎn)3.1 AssetBundle的打包策略打包這塊我踩過(guò)最大的坑是移動(dòng)端和PC端的Bundle不能混用。Unity在打包AssetBundle時(shí)不同平臺(tái)的壓縮格式和序列化方式不一樣你拿PC上打的包放到Android上加載會(huì)直接報(bào)錯(cuò)。所以我的構(gòu)建腳本里一定會(huì)根據(jù)當(dāng)前BuildTarget輸出到不同目錄[MenuItem(ModTools/Build Mod Bundles)] public static void BuildModBundles() { string outputPath Path.Combine(Application.dataPath, ../Mods/ModA); if (!Directory.Exists(outputPath)) Directory.CreateDirectory(outputPath); BuildPipeline.BuildAssetBundles(outputPath, BuildAssetBundleOptions.ChunkBasedCompression, EditorUserBuildSettings.activeBuildTarget); }ChunkBasedCompression是我推薦的壓縮方式它比LZMA解壓快比不壓縮體積小移動(dòng)端加載時(shí)內(nèi)存占用也合理。LZ4HC壓縮率更高但解壓慢不適合頻繁加載的場(chǎng)景。實(shí)操心得打包時(shí)一定要把AssetBundleManifest也打進(jìn)去加載資源前先讀Manifest拿到依賴列表再加載目標(biāo)Bundle。不然遇到共享貼圖或者共享Shader會(huì)出現(xiàn)資源丟失或者重復(fù)加載。3.2 資源加載的同步與異步選擇移動(dòng)端上同步加載大Bundle會(huì)卡主線程表現(xiàn)就是游戲瞬間掉幀。我的原則是小于1MB的Bundle可以同步加載大于1MB的一律異步。異步加載用協(xié)程或者UniTask都可以我這邊為了不引入額外依賴用的是原生協(xié)程。IEnumerator LoadModBundleAsync(string bundlePath, ActionAssetBundle onComplete) { var request AssetBundle.LoadFromFileAsync(bundlePath); yield return request; if (request.assetBundle null) { Debug.LogError($加載Bundle失敗: {bundlePath}); onComplete?.Invoke(null); yield break; } onComplete?.Invoke(request.assetBundle); }加載完成后Bundle不要立刻Unload因?yàn)槔锩娴馁Y源可能還在場(chǎng)景里用著。我的做法是維護(hù)一個(gè)引用計(jì)數(shù)當(dāng)所有從該Bundle加載的資源都被釋放后再調(diào)用Unload(true)。3.3 配置文件的解析與容錯(cuò)mod.json的解析看起來(lái)簡(jiǎn)單但實(shí)際會(huì)遇到各種奇葩情況BOM頭、注釋、字段缺失、類型不對(duì)。我一開(kāi)始用JsonUtility.FromJson結(jié)果發(fā)現(xiàn)它不支持字典和嵌套數(shù)組后來(lái)?yè)Q成了輕量級(jí)的JSON解析庫(kù)自己寫(xiě)了個(gè)包裝層。[Serializable] public class ModManifest { public string name; public string version; public string author; public string entryAssembly; public string[] dependencies; }解析的時(shí)候一定要做字段校驗(yàn)比如name為空就跳過(guò)這個(gè)Modversion格式不對(duì)就記警告。我見(jiàn)過(guò)有玩家手動(dòng)改mod.json把版本號(hào)寫(xiě)成中文的如果不做容錯(cuò)整個(gè)Mod列表加載都會(huì)崩。注意移動(dòng)端讀取JSON文件時(shí)要用File.ReadAllText指定UTF-8編碼不然中文Mod名稱會(huì)亂碼。PC上默認(rèn)編碼可能是GBK所以統(tǒng)一指定編碼最保險(xiǎn)。3.4 腳本擴(kuò)展點(diǎn)的設(shè)計(jì)這是整個(gè)Mod系統(tǒng)里最敏感的部分。移動(dòng)端IL2CPP模式下動(dòng)態(tài)加載C#腳本基本不可行所以我的方案是Mod腳本必須提前編譯成DLL并且和游戲主程序集一起打包。也就是說(shuō)Mod的代碼擴(kuò)展能力在構(gòu)建時(shí)就確定了運(yùn)行時(shí)只負(fù)責(zé)反射調(diào)用。具體做法是定義一個(gè)接口public interface IModExtension { string ModName { get; } void OnLoad(); void OnUnload(); void OnGameStart(); }Mod開(kāi)發(fā)者實(shí)現(xiàn)這個(gè)接口編譯成DLL后放到Mod目錄。游戲啟動(dòng)時(shí)用Assembly.LoadFrom加載DLL反射找到實(shí)現(xiàn)類創(chuàng)建實(shí)例并調(diào)用OnLoad。var assembly Assembly.LoadFrom(dllPath); var types assembly.GetTypes().Where(t typeof(IModExtension).IsAssignableFrom(t) !t.IsInterface); foreach (var type in types) { var instance Activator.CreateInstance(type) as IModExtension; instance?.OnLoad(); _loadedMods.Add(instance); }實(shí)操心得反射創(chuàng)建實(shí)例時(shí)如果Mod的構(gòu)造函數(shù)里有異常整個(gè)加載流程會(huì)中斷。所以一定要用try-catch包住每個(gè)Mod的加載過(guò)程一個(gè)Mod失敗不影響其他Mod。4. 完整實(shí)操流程與關(guān)鍵環(huán)節(jié)實(shí)現(xiàn)4.1 環(huán)境準(zhǔn)備與目錄搭建先在項(xiàng)目根目錄下建一個(gè)Mods文件夾和Assets同級(jí)。然后在Assets里建一個(gè)ModSystem文件夾放所有Mod系統(tǒng)相關(guān)的腳本。目錄結(jié)構(gòu)如下ProjectRoot/ Assets/ ModSystem/ ModLoader.cs ModResourceManager.cs ModConfigParser.cs ModExtensionRegistry.cs Mods/ ModA/ mod.json resources.bundlePC上Mods目錄直接可讀寫(xiě)Android上需要把Mods放到StreamingAssets里首次啟動(dòng)拷貝到persistentDataPath??截愡壿嬑覍?xiě)成一個(gè)靜態(tài)方法在游戲啟動(dòng)時(shí)調(diào)用一次。4.2 Mod加載器的完整實(shí)現(xiàn)加載器的流程分四步掃描目錄、讀取清單、加載Bundle、注冊(cè)擴(kuò)展。我把它寫(xiě)成一個(gè)協(xié)程方便異步處理。public IEnumerator LoadAllMods() { string modRoot GetModRootPath(); if (!Directory.Exists(modRoot)) { Debug.LogWarning(Mod目錄不存在跳過(guò)加載); yield break; } string[] modDirs Directory.GetDirectories(modRoot); foreach (string dir in modDirs) { string manifestPath Path.Combine(dir, mod.json); if (!File.Exists(manifestPath)) continue; string json File.ReadAllText(manifestPath, Encoding.UTF8); ModManifest manifest null; try { manifest JsonUtility.FromJsonModManifest(json); } catch (Exception e) { Debug.LogError($解析mod.json失敗: {dir}, {e.Message}); continue; } if (string.IsNullOrEmpty(manifest.name)) { Debug.LogWarning($Mod名稱為空跳過(guò): {dir}); continue; } string bundlePath Path.Combine(dir, resources.bundle); if (File.Exists(bundlePath)) { yield return LoadModBundleAsync(bundlePath, (bundle) { if (bundle ! null) { _loadedBundles[manifest.name] bundle; } }); } if (!string.IsNullOrEmpty(manifest.entryAssembly)) { string dllPath Path.Combine(dir, manifest.entryAssembly); if (File.Exists(dllPath)) { LoadModAssembly(dllPath, manifest.name); } } } }這段代碼里L(fēng)oadModBundleAsync是前面寫(xiě)的異步加載方法LoadModAssembly負(fù)責(zé)反射加載DLL。每個(gè)Mod獨(dú)立處理互不影響。4.3 資源加載與實(shí)例化Bundle加載完后從里面取資源就很簡(jiǎn)單了public GameObject InstantiateModPrefab(string modName, string prefabName) { if (!_loadedBundles.TryGetValue(modName, out AssetBundle bundle)) { Debug.LogError($Mod未加載: {modName}); return null; } GameObject prefab bundle.LoadAssetGameObject(prefabName); if (prefab null) { Debug.LogError($預(yù)制體不存在: {prefabName}); return null; } return Object.Instantiate(prefab); }這里有個(gè)細(xì)節(jié)LoadAsset的名字必須和打包時(shí)設(shè)置的名字一致。我建議在打包腳本里統(tǒng)一用文件名作為Asset名稱避免手動(dòng)指定出錯(cuò)。4.4 配置數(shù)據(jù)的映射與使用Mod配置里可能包含數(shù)值、顏色、路徑等數(shù)據(jù)。我一般定義一個(gè)通用的ModConfig類用字典存鍵值對(duì)然后由各個(gè)Mod自己解析。[Serializable] public class ModConfig { public string key; public string value; public string type; // int, float, string, color }游戲邏輯里需要讀取配置時(shí)通過(guò)ModConfigParser.GetValue(modName, key)獲取內(nèi)部做類型轉(zhuǎn)換和緩存。這樣Mod開(kāi)發(fā)者只需要在mod.json里加配置項(xiàng)不用改代碼。4.5 擴(kuò)展點(diǎn)的實(shí)際掛載擴(kuò)展點(diǎn)注冊(cè)中心維護(hù)一個(gè)事件列表Mod在OnLoad時(shí)注冊(cè)自己的回調(diào)public static class ModExtensionRegistry { private static Dictionarystring, Action _events new Dictionarystring, Action(); public static void Register(string eventName, Action callback) { if (!_events.ContainsKey(eventName)) _events[eventName] callback; else _events[eventName] callback; } public static void Trigger(string eventName) { if (_events.TryGetValue(eventName, out var action)) action?.Invoke(); } }游戲主邏輯在關(guān)鍵節(jié)點(diǎn)調(diào)用Trigger比如OnGameStart、OnPlayerDeath、OnLevelLoaded。Mod開(kāi)發(fā)者只需要在OnLoad里調(diào)用Register就能把自己的邏輯掛上去。注意事件回調(diào)里如果拋異常會(huì)中斷后續(xù)所有回調(diào)。所以Trigger內(nèi)部要用try-catch包住每個(gè)回調(diào)記錄日志但不中斷。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 Bundle加載失敗排查表現(xiàn)象可能原因排查方法加載返回null路徑錯(cuò)誤打印完整路徑檢查文件是否存在加載報(bào)錯(cuò)“無(wú)法讀取”平臺(tái)不匹配確認(rèn)Bundle是用當(dāng)前平臺(tái)打的資源加載為nullAsset名稱不對(duì)用bundle.GetAllAssetNames()打印所有名稱實(shí)例化后材質(zhì)丟失Shader未打包把Shader加入Bundle或使用Always Included Shaders移動(dòng)端加載卡頓同步加載大Bundle改用異步加載或拆分Bundle這個(gè)表是我實(shí)際排查時(shí)總結(jié)的基本覆蓋了八成以上的問(wèn)題。尤其是Shader丟失PC上編輯器里正常打包到移動(dòng)端就變粉紅就是因?yàn)镾hader沒(méi)進(jìn)Bundle。5.2 反射加載DLL的常見(jiàn)異常移動(dòng)端IL2CPP下Assembly.LoadFrom加載的DLL必須是提前編譯好的而且不能包含UnityEditor相關(guān)的代碼。我遇到過(guò)Mod開(kāi)發(fā)者把編輯器腳本也打進(jìn)DLL結(jié)果運(yùn)行時(shí)直接崩。解決辦法是在Mod開(kāi)發(fā)文檔里明確寫(xiě)清楚只引用UnityEngine和游戲主程序集不要引用UnityEditor。另一個(gè)坑是DLL的.NET版本。Unity 2021之后默認(rèn)用.NET Standard 2.1如果Mod DLL是用.NET Framework 4.8編譯的可能加載失敗。統(tǒng)一用.NET Standard 2.1編譯最穩(wěn)。5.3 移動(dòng)端權(quán)限與路徑問(wèn)題Android 11之后persistentDataPath的訪問(wèn)不需要額外權(quán)限但如果你想把Mod放到外部存儲(chǔ)比如/sdcard/就需要申請(qǐng)存儲(chǔ)權(quán)限。我的建議是不要碰外部存儲(chǔ)全部放在persistentDataPath里省去權(quán)限申請(qǐng)的麻煩。iOS上更嚴(yán)格persistentDataPath是沙盒內(nèi)的Mod只能通過(guò)應(yīng)用內(nèi)下載或者iTunes文件共享導(dǎo)入。如果要做Mod導(dǎo)入功能得額外寫(xiě)文件選擇器邏輯這塊我暫時(shí)沒(méi)做因?yàn)榇蟛糠忠苿?dòng)端Mod需求都是內(nèi)置的。5.4 性能優(yōu)化與內(nèi)存管理Mod系統(tǒng)跑起來(lái)后內(nèi)存占用是必須監(jiān)控的。我一般會(huì)在加載完所有Mod后調(diào)用Resources.UnloadUnusedAssets()清理一次。另外每個(gè)Bundle的引用計(jì)數(shù)要維護(hù)好不用的時(shí)候及時(shí)Unload(false)只卸載Bundle文件本身不銷毀已加載的資源。public void UnloadMod(string modName) { if (_loadedBundles.TryGetValue(modName, out AssetBundle bundle)) { bundle.Unload(false); _loadedBundles.Remove(modName); } }實(shí)操心得Unload(true)會(huì)銷毀所有從該Bundle加載的資源如果場(chǎng)景里還有對(duì)象在用會(huì)直接變missing。所以除非確定不用了否則一律用Unload(false)。5.5 Mod沖突與加載順序多個(gè)Mod可能修改同一個(gè)配置或者注冊(cè)同一個(gè)事件。我的處理方式是按Mod目錄名稱排序加載后加載的Mod覆蓋先加載的。同時(shí)在配置解析時(shí)如果發(fā)現(xiàn)同一個(gè)key被多個(gè)Mod定義記錄警告并采用最后一個(gè)。事件注冊(cè)則相反多個(gè)Mod注冊(cè)同一個(gè)事件時(shí)全部保留按注冊(cè)順序依次調(diào)用。這樣Mod之間可以協(xié)作比如一個(gè)Mod加UI另一個(gè)Mod加邏輯。6. 一些實(shí)戰(zhàn)中的個(gè)人體會(huì)這套Mod系統(tǒng)我在兩個(gè)項(xiàng)目里用過(guò)一個(gè)是PC端的建造游戲一個(gè)是移動(dòng)端的休閑模擬。PC上基本沒(méi)遇到大問(wèn)題移動(dòng)端踩的坑主要集中在路徑和Shader上。后來(lái)我養(yǎng)成了一個(gè)習(xí)慣每次打包Mod Bundle后先在目標(biāo)平臺(tái)上跑一遍加載測(cè)試確認(rèn)沒(méi)問(wèn)題再發(fā)布。這個(gè)步驟看起來(lái)多余但能省掉大量玩家反饋的排查時(shí)間。另外Mod系統(tǒng)的文檔比代碼更重要。我見(jiàn)過(guò)太多開(kāi)發(fā)者包括我自己早期覺(jué)得“代碼寫(xiě)好了就行”結(jié)果Mod開(kāi)發(fā)者不知道怎么打包、怎么命名、怎么調(diào)試最后Mod生態(tài)根本起不來(lái)。所以如果你打算長(zhǎng)期維護(hù)Mod功能一定要寫(xiě)一份清晰的Mod開(kāi)發(fā)指南把目錄結(jié)構(gòu)、配置格式、擴(kuò)展點(diǎn)列表、常見(jiàn)錯(cuò)誤都列出來(lái)。最后分享一個(gè)小技巧在游戲里加一個(gè)“Mod調(diào)試模式”開(kāi)啟后可以在屏幕上顯示當(dāng)前加載的Mod列表、Bundle數(shù)量、內(nèi)存占用。這個(gè)功能在排查玩家反饋時(shí)特別有用玩家截個(gè)圖你就能定位問(wèn)題不用反復(fù)問(wèn)“你裝了什么Mod”。