??大家好,我是阿趙。
??阿趙我做手機游戲已經有十幾年時間了。記得剛開始從做頁游的公司轉到去做手游的公司,在面試的時候很重要的一個點,就是會不會用Lua。使用Lua的原因很簡單,就是為了熱更新。
??熱更新游戲內容很重要。如果游戲內容需要改動,如果每次都要去平臺出新的安裝包提審,周期不可控,甚至像iOS這種提審特別麻煩的平臺,還有不過審的風險。比如出個春節活動,提審完可能春節都過完了,那么這個內容也就沒有意義。 但如果游戲的內容自己可以通過某些方法進行修改,不需要通過平臺的審核就能直接改動,那么游戲制作的靈活性就大大增加了。我們使用Unity引擎來開發游戲,游戲資源是可以通過AssetBundle的方式熱更新的,但C#代碼,以前是不能熱更新的,至少在iOS平臺是不能的,安卓和pc有辦法可以熱更新dll。
??Lua作為一個可以通過字符串或者字節資源的形式加載的腳本,在游戲熱更新上起到了重要的作用,起碼在近十年時間是出于統治地位的。但由于性能問題Lua也一直受到各種詬病,特別是在微信小游戲或者抖音小游戲上面,性能的確不是很好。
??最近和UWA溝通的過程中,聽說很多公司已經不用Lua,而改為用了HybridCLR(華佗)熱更新。華佗熱更新的原理是更新c#的程序集,也就是通過加載dll來實現代碼層面的熱更新,而且iOS也能用。不過由于公司的項目都是在用Lua開發的,如果換成華佗,等于整個項目要重新用C#寫一篇,成本還是很高的。
??學多一點東西肯定是有好處的,說不定以后新項目就能用得上。于是阿趙我也來學習一下華佗熱更新的用法,并且記錄一下一些使用的問題。
一、 安裝
HybridCLR華佗熱更新的在線文檔地址是:
文檔地址
??里面有比較詳細的安裝說明,可以根據步驟一步步來安裝。我只記錄一下我遇到的問題。
1、 對應的Unity版本
??在官方文檔里面說,華佗支持的Unity版本有這些:
??由于我有一個項目是使用2019.4.24開發的,一看文檔說支持2019.4.x,感覺挺好,但繼續看下去,會看到:
??從文檔看,2019只能在2019.4.40上面安裝。其實2019.4前面的版本的確挺多問題的,比如我之前發現的URP的SRPBatcher合并問題等,各位如果還在用2019版本開發的朋友,我也挺建議大家都升級到2019.4.40。無奈的是,如果項目已經上線了,再來換版本,可能會導致AssetBundle打包的資源會全部變更,Unity打包AssetBundle的時候會把版本號寫在文件開頭,所以就算你所有內容都沒變,只是換個Unity版本,打出來的AssetBundle文件也會全部改變的……
??不過幸好,華佗的文檔里面也有針對這種情況的處理辦法,就是先把項目切換到2019.4.40,然后安裝華佗,再切換會原來的版本。
??拋開項目已有版本的問題,其實就無所謂了,因為之后的版本很多都支持, 比如直接安裝2022.3.x版本,就沒這個問題了。
2、下載代碼
??由于代碼庫是從git下載,所以必須安裝git。
??然后通過Unity的PackageManager里面的Add package from git URL來安裝
庫地址:
https://gitee.com/focus-creative-games/hybridclr_unity.git
或
https://github.com/focus-creative-games/hybridclr_unity.git
??我自己嘗試的結果是沒辦法通過Add package from git URL來安裝,安裝了GIT和加了環境變量PATH也不行。我自己用GIT手動克隆,卻是沒問題的,這一點很神奇。
??于是解決這個問題的方法是,可以手動把地址檢出克隆到本地,然后把文件夾改名com.code-philosophy.hybridclr,并復制到項目里面和Assets文件夾同級的Packages文件夾
??復制后打開項目,會看到有華佗的菜單
??選擇安裝器,然后安裝
??按照文檔說明基本都可以自動安裝成功,但我還是失敗了,看報錯還是GIT的問題,于是我根據報錯,自己檢出克隆https://gitee.com/focus-creative-games/hybridclr到項目的HybridCLRData/hybridclr_repo文件夾
??檢出后再次點擊Install按鈕,就可以安裝成功了。
??HybridCLR菜單下出現了所有的選項子菜單。
二、 淺嘗華佗熱更新
1、 一些概念
??在使用華佗熱更新之前需要先了解一些概念
1. 程序集
??先來操作,最后再說為什么。在項目里面創建一個文件夾,叫做HotUpdate,或者叫其他都行,你自己喜歡:
??然后在這個文件夾里面,創建一個Assembly Definition文件:
??幫這個文件起個名字,比如我這里就叫做HotUpdate:
??注意要把Auto Referenced的勾選去掉。
??然后在這個文件夾里面創建一個C#腳本,我這里隨便命名為Hello:
??創建完之后,點選這個Hello腳本,會看到里面多了一個Assembly信息,里面說明了,這個Hello的腳本,是屬于HotUpdate.dll的。
??操作到此結束,下面解釋一下:
??這個創建文件夾和Assembly Definition文件的過程,是Unity引擎的程序集功能,其實就是指定了某個文件夾作為一個程序集的范圍。只要在這個文件夾下面的所有文件,包括子文件夾里面的文件,都屬于當前這個Assembly Definition文件的程序集里面的內容。
??一個程序集,字面意思就是程序的集合了,可以理解成是把里面的代碼都打包了,之后需要熱更新代碼,其實就是熱更新這個程序集的dll文件了。
2. AOT程序集和熱更新程序集
??使用Unity引擎制作游戲,各位肯定應該都會寫C#。在項目里面所寫的C#代碼,就算我們不特意的打程序集,它們也會出現在一個程序集里面,就是Assembly-CSharp.dll,然后我們又可以根據自己的需要,創建一些程序集,所以最后打包的時候,除了Assembly-CSharp.dll,還會有一些自己的dll。這些多個程序集,之后會用于華佗熱更新。
??這里有個問題,熱更新是以dll為單位的,那些可以熱更新的程序集,在使用華佗熱更新的時候,是會剝離出去,不會包含在主工程包里面的。而我們需要寫代碼加載這些dll文件,就必須有一些代碼是包含在主工程里面不能熱更新的。
??所以在使用華佗熱更新的時候,需要把程序集分成2部分,第一部分是包含在游戲主包里面不能熱更新的,成為AOT程序集,第二部分是可以熱更新的dll,成為熱更新程序集。
3. 程序集的規劃和程序集之間的引用關系
??由于程序集起碼要有AOT和可熱更兩個,甚至更多,所以在做之前,我們必須先規劃一下它們之間的關系。具體來說,就是總共需要多少個程序集才能滿足我們需要,既能熱更,又可以劃分清楚模塊,做到分塊更新。
??程序集之間的引用,有2種方式,第一種,就是在程序集上面勾上Auto Referenced,這樣它自動被其他程序集引用,可以互相調用里面的方法。
??另外一種,就是在程序集上面指定依賴關系,比如我再建一個HotUpdate2的程序集,不勾選Auto Referenced:
??這個時候如果HotUpdate程序集要訪問HotUpdate2程序集,可以選擇HotUpdate程序集,然后添加引用關系:
??只要在HotUpdate程序集的Assembly Definition References里面添加了HotUpdate2的引用,那么HotUpdate就能調用HotUpdate2里面的方法了。
??華佗熱更新里面有一個規則,AOT程序集是不能直接引用熱更新程序集的,不然在打包的時候會出錯。所以,我們在創建自己的可熱更程序集的時候,必須把Auto Referenced的勾選去掉,然后自己維護可熱更新程序集之間的引用關系。
2、 嘗試使用華佗熱更新
1. 指定需要熱更新的程序集
??在HybridCLR菜單下面選擇Settings設置:
??然后添加可熱更新的程序集:
??在這里設置了的程序集,在打主包的時候,程序集是不會包含在主包里面的。
2. 生成必須的東西
??在首次使用華佗熱更新的時候,必須先選擇Generate——All,生成所有必須的文件,其實就是All上面的哪些東西了。在之后的使用中,就不一定要生成All,可以根據實際需要來生成上面的內容。
3. 寫熱更新的測試代碼
??首先,為了打包之后看到控制臺的打印,先創建一個腳本:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class ConsoleToScreen : MonoBehaviour
{const int maxLines = 50;const int maxLineLength = 120;private string _logStr = "";private readonly List<string> _lines = new List<string>();public int fontSize = 15;void OnEnable() { Application.logMessageReceived += Log; }void OnDisable() { Application.logMessageReceived -= Log; }public void Log(string logString, string stackTrace, LogType type){foreach (var line in logString.Split('\n')){if (line.Length <= maxLineLength){_lines.Add(line);continue;}var lineCount = line.Length / maxLineLength + 1;for (int i = 0; i < lineCount; i++){if ((i + 1) * maxLineLength <= line.Length){_lines.Add(line.Substring(i * maxLineLength, maxLineLength));}else{_lines.Add(line.Substring(i * maxLineLength, line.Length - i * maxLineLength));}}}if (_lines.Count > maxLines){_lines.RemoveRange(0, _lines.Count - maxLines);}_logStr = string.Join("\n", _lines);}void OnGUI(){GUI.matrix = Matrix4x4.TRS(Vector3.zero, Quaternion.identity,new Vector3(Screen.width / 1200.0f, Screen.height / 800.0f, 1.0f));GUI.Label(new Rect(10, 10, 800, 370), _logStr, new GUIStyle() { fontSize = Math.Max(10, fontSize) });}
}
??在場景上面建個空物體,然后把腳本拖上去:
??這樣做的目的只是為了讓我們在接下來的測試中,把控制臺打印輸出到屏幕,讓我們知道熱更新有沒有生效。
??然后給Hello腳本修改一下:
using UnityEngine;public class Hello
{static public void Print(){Debug.Log("Hello World");}
}
??這里只有一個靜態方法,如果執行了,會打印Hello World到控制臺,通過上面的腳本,控制臺的打印就會出現在屏幕。
??最后,要加一個AOT腳本,作為游戲啟動、加載dll和調用dll。正常來說熱更新的dll文件應該放在CDN上,然后下載到本地。這里為了測試,就寫死放在StreamingAssets文件夾了。這里建一個叫做TestLoadDll的C#腳本:
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEngine;public class TestLoadDll : MonoBehaviour
{// Start is called before the first frame updatevoid Start(){Assembly dllLoader;
#if !UNITY_EDITORdllLoader = Assembly.Load(File.ReadAllBytes($"{Application.streamingAssetsPath}/HotUpdate.dll.bytes"));
#elsedllLoader = System.AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "HotUpdate");
#endifType type = dllLoader.GetType("Hello");type.GetMethod("Print").Invoke(null, null);}// Update is called once per framevoid Update(){}
}
??然后在場景里面建一個空物體,把腳本拖上去:
??這時候在編輯器里面運行,會看到Hello World打印出來了:
??腳本里面的內容很簡單,只是規定了在編輯器就直接讀取工程里面的HotUpdate程序集,在非編輯器的情況下,就讀取StreamingAssets文件夾下面的HotUpdate.dll.bytes文件。由于Unity的詭異規定,所以dll文件是不能直接讀取的,要把后綴改成bytes。
??然后后面的那段反射代碼
Type type = dllLoader.GetType("Hello");
type.GetMethod("Print").Invoke(null, null);
??不用害怕,這是因為AOT程序集不能直接引用可熱更新的HotUpdate程序集,所以才用反射調用一下,僅此而已,如果沒有特殊情況,是不需要這樣做的。
4. 打包熱更新用的dll
??在HybridCLR菜單選擇CompileDll——ActiveBuildTarget
??這時候會把對dll進行打包,打包的結果在
??項目文件夾\HybridCLRData\HotUpdateDlls\對應的平臺文件夾\:
??由于我現在的平臺是Windows,所以實際路徑會在StandaloneWindows64文件夾下。這里會看到了項目里面所用到的所有程序集的dll文件,其中就有我們想要熱更新的HotUpdate.dll。我們剛才也指定了HotUpdate2程序集,但由于里面一個腳本都沒有,所以是不會有dll打出來的。
??把HotUpdate.dll復制到StreamingAssets文件夾并重命名為HotUpdate.dll.bytes
5. 打包測試
??選擇一個文件夾,常規的打個PC包出來:
??發現打不出來,因為剛才指定了HotUpdate2程序集,但現在這個程序集是沒有內容的
??去華佗設置里面把HotUpdate2程序集從可熱更新的程序集里面去掉。這次就能正常打包了。
??運行打出來的包,能看到HelloWorld,證明打包成功,從剛才的讀取dll的代碼我們可以知道,現在是讀取了StreamingAssets里面的HotUpdate.dll.bytes作為代碼執行的。
6. 驗證熱更新修改代碼
??回到Hello腳本,修改一下:
using UnityEngine;public class Hello
{static public void Print(){Debug.Log("Hello Azhao");}
}
??把原來的Hello World改成Hello Azhao
??然后再次HybridCLR菜單選擇CompileDll——ActiveBuildTarget,打包dll
??再次在HybridCLRData\HotUpdateDlls\StandaloneWindows64目錄找到HotUpdate.dll文件,然后拷貝到之前打的PC包的StreamingAssets文件夾:
??這時候再次運行之前的PC包
??可以看到,現在PC包顯示的內容已經變成了Hello Azhao。到此為止,華佗熱更新的基本流程已經跑通了。
三、 華佗熱更新的深入使用
1、 嘗試AssetBundle加載資源
??接下來,嘗試把C#腳本掛在GameObject上,并通過AssetBundle加載這個GameObject看看:
??在HotUpdate程序集建一個PrintObject的C#腳本:
using UnityEngine;public class PrintObject : MonoBehaviour
{// Start is called before the first frame updatevoid Start(){Debug.Log("GameObject:" + gameObject.name);}// Update is called once per framevoid Update(){}
}
??然后建一個cube,把腳本掛上去:
??把這個Cube做成Prefab,并且設置AssetBundleName,打包AssetBundle:
??然后打包AssetBundle,把AssetBundle文件放到StreamingAssets文件夾:
??修改Hello腳本:
using UnityEngine;public class Hello
{static public void Print(){string path = Application.streamingAssetsPath + "/ab/cube.unity3d";AssetBundle ab = AssetBundle.LoadFromFile(path);if(ab){Object obj = ab.LoadAsset("Cube");if(obj != null){GameObject.Instantiate(obj);} }}
}
??生成dll,并且和AssetBundle一起拷貝到pc包的StreamingAssets文件夾:
??這時候,運行PC包,并沒有出現我們想要的情況,而是有個報錯:
??這是為什么呢?
2、關于代碼裁剪
??如果在打包的時候沒有用到某些Unity自帶的API,但后期在熱更新的代碼上加上,就會出現報錯,找不到方法。原因是IL2CPP的情況下,代碼裁剪是不能被禁止的,而之前沒有用過的API,在Unity打包的時候被裁剪掉了。
??一般來說,為了防止需要的Unity原生API代碼被裁剪的問題,可以在項目里面建一個link.xml文件,然后把需要保留不被裁剪的內容填進去。不過這樣手動收集是很麻煩的,華佗的工具里面自帶了收集link.xml的功能
??只要點一下,就會把項目里面有調用過的API加入到link.xml里面。
這里還有2個問題
1、 需要保留的代碼,除了加在link.xml之外,代碼還要必須顯式的引用過這些類或者函數,不然也還是會被裁剪。
2、 重新收集完link.xml之后,必須重新打包才能生效……
??這樣似乎就回到了使用Lua時的導出接口的操作了,沒有導出過接口的類和方法,不能熱更新……關鍵這一步你在編輯器內還很難發現,畢竟編輯器內的Unity自帶API是不會被裁剪的。
??這是一個我認為使用華佗熱更新最大的問題。畢竟Unity很多API可能在一開始的時候沒考慮到需要使用,后面用到才收集,就不能熱更新了。
??既然是需要重新出包了,所以也就不止是點一下LinkXml了,直接Generate——All,生成所有,那樣就穩妥了。
??全部重新生成之后,再次出包,就可以看到之前的報錯沒有了,可以加載AssetBundle里面的Cube,并且掛在上面的腳本也正常運行了:
3、 新增程序集的熱更新
??之前的例子里面只有1個可熱更新的程序集,叫做HotUpdate,現在我想在不重新出包的情況下,增加一個HotUpdate2的可熱更新程序集,試試能不能熱更新。
??由于之前是在AOT代碼里面寫死了需要加載HotUpdate.dll.bytes,所以如果增加新的程序集dll文件,肯定是不能加載的,所以要改成需要加載哪些dll文件要通過可熱更的文件來決定。
??這里為了測試簡單,我放一個dll.txt文本在StreamingAssets文件夾,然后在里面用逗號分隔需要加載的程序集名字。由于HotUpdate.dll需要通過反射來調用,所以我就不寫在txt里面了,這也說明,如果需要有一個程序調用入口,那么至少有一個dll是需要寫在代碼里面加載的。于是加載dll的代碼會變成這樣:
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEngine;public class TestLoadDll : MonoBehaviour
{// Start is called before the first frame updatevoid Start(){Assembly dllLoader;
#if !UNITY_EDITORstring path = Application.streamingAssetsPath + "/dll.txt";string content = File.ReadAllText(path);if(string.IsNullOrEmpty(content)==false) {string[] fileNames = content.Trim().Split(",");for(int i = 0;i < fileNames.Length; i++) { string fileName = fileNames[i];if(string.IsNullOrEmpty(fileName)==false){Assembly.Load(File.ReadAllBytes($"{Application.streamingAssetsPath}/"+fileName+".dll.bytes"));}}}dllLoader = Assembly.Load(File.ReadAllBytes($"{Application.streamingAssetsPath}/HotUpdate.dll.bytes"));
#elsedllLoader = System.AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "HotUpdate");
#endifType type = dllLoader.GetType("Hello");type.GetMethod("Print").Invoke(null, null);}// Update is called once per framevoid Update(){}
}
??到現在為止,先打個PC包,作為熱更新的基礎包。
??接下來同樣的手法,建立HotUpdate2文件夾和程序集,在里面添加一個PrintGameObject的腳本:
using UnityEngine;public class PrintGameObject : MonoBehaviour
{// Start is called before the first frame updatevoid Start(){Debug.Log("HotUpdate2:" + gameObject.name);}// Update is called once per framevoid Update(){}static public void Run(){Debug.Log("This is HotUpdate2");}
}
??把這個PrintGameObject腳本掛在之前的Cube預設上,原來的PrintObject腳本就不掛了:
??在HotUpdate程序集添加HotUpdate2程序集的引用:
??修改Hello腳本:
using UnityEngine;public class Hello
{static public void Print(){PrintGameObject.Run();string path = Application.streamingAssetsPath + "/ab/cube.unity3d";AssetBundle ab = AssetBundle.LoadFromFile(path);if(ab){Object obj = ab.LoadAsset("Cube");if(obj != null){GameObject.Instantiate(obj);} }}
}
??主要是加了一句PrintGameObject.Run();
??接下來還是常規操作,把HotUpdate2加到可熱更新的列表
??在dll.txt里面寫入HotUpdate2。然后打包AssetBundle、打包Dll,把這些東西都拷貝到PC包的StreamingAssets,然后運行,會看到:
??發現一個神奇的事情,HotUpdate2的代碼其實已經加載了,PrintGameObject里面的Run方法都打印出來This is HotUpdate2了,但掛在Cube上的PrintGameObject腳本卻找不到……
??接下來改一下做法,把Cube上面的PrintGameObject腳本去掉,變成在實例化GameObject之后用AddComponent來添加腳本:
using UnityEngine;public class Hello
{static public void Print(){PrintGameObject.Run();string path = Application.streamingAssetsPath + "/ab/cube.unity3d";AssetBundle ab = AssetBundle.LoadFromFile(path);if(ab){Object obj = ab.LoadAsset("Cube");if(obj != null){GameObject go = (GameObject)GameObject.Instantiate(obj);go.AddComponent<PrintGameObject>();} }}
}
??再次打包AssetBundle,打包dll,拷貝到PC包的StreamingAssets文件夾,運行PC包:
??會看到,添加成功了,PrintGameObject腳本也運行成功了。
??關于新增的程序集掛到GameObject的AssetBundle熱更的問題,我到最后都沒有解決,不知道是不是有解決辦法。我只能暫時得出結論,如果新增程序集,純代碼調用時沒問題的,但如果掛在GameObject上通過AssetBundle加載,就會有問題。
??這就導致一個問題,我們如果想出了安裝包之后可以長時間的熱更新,不需要重新出包,就必須對可能用到的程序集做好規劃,盡量不要去改變了。