大家好,我是阿赵。
阿赵我做手机游戏已经有十几年时间了。记得刚开始从做页游的公司转到去做手游的公司,在面试的时候很重要的一个点,就是会不会用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 update
void Start()
{
Assembly dllLoader;
#if !UNITY_EDITOR
dllLoader = Assembly.Load(File.ReadAllBytes($"{Application.streamingAssetsPath}/HotUpdate.dll.bytes"));
#else
dllLoader = System.AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "HotUpdate");
#endif
Type type = dllLoader.GetType("Hello");
type.GetMethod("Print").Invoke(null, null);
}
// Update is called once per frame
void 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 update
void Start()
{
Debug.Log("GameObject:" + gameObject.name);
}
// Update is called once per frame
void 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 update
void Start()
{
Assembly dllLoader;
#if !UNITY_EDITOR
string 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"));
#else
dllLoader = System.AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "HotUpdate");
#endif
Type type = dllLoader.GetType("Hello");
type.GetMethod("Print").Invoke(null, null);
}
// Update is called once per frame
void Update()
{
}
}
到现在为止,先打个PC包,作为热更新的基础包。
接下来同样的手法,建立HotUpdate2文件夹和程序集,在里面添加一个PrintGameObject的脚本:
using UnityEngine;
public class PrintGameObject : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
Debug.Log("HotUpdate2:" + gameObject.name);
}
// Update is called once per frame
void 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加载,就会有问题。
这就导致一个问题,我们如果想出了安装包之后可以长时间的热更新,不需要重新出包,就必须对可能用到的程序集做好规划,尽量不要去改变了。