上一篇我们把工程结构和基础环境都搭好了,这次的目标只有一个:把第一个真正能跑的 HelloWorld 跑通。看到标题带“(二)”,说明你已经走过了环境配置那一关,但肯定也跟我当时一样,心里憋着不少疑问——ILRuntime 到底是靠什么把 C# 代码变成热更新代码的?为什么我写的热更新 DLL 总是加载失败?Unity 自己不是也能加载 DLL 吗,费这么大劲搞 ILRuntime 图什么?
这篇文章我就从“为什么”讲起,再到“怎么写”“怎么调”,把 ILRuntime 从零到能跑的第一个 HelloWorld 完整走一遍。不管是刚接触热更新的小白,还是看过几篇教程但还没真正跑通的开发者,读完之后你至少能独立把示例复现出来,顺便把 ILRuntime 的几个核心机制弄明白。
1. 先搞懂 ILRuntime 到底在做什么
1.1 一句话理解它的运行方式
ILRuntime 本质上是在 Unity 的 Mono 虚拟机内部又实现了一个小小的 .NET 运行时,专门用“解释执行”的方式去跑热更新 DLL 里的 IL 指令。我在第一篇提到过,热更新代码会被编译成一个 DLL,这个 DLL 在游戏启动时以字节数组的形式被读进内存,然后交给 ILRuntime 的 AppDomain 对象去加载和解释。
我猜很多人第一反应是:Unity 不是已经能加载 DLL 了吗?直接用 Assembly.Load 不就行了?问题在于,Unity 的 Assembly.Load 在 iOS 这类 AOT 平台上是被禁止的,因为系统不允许在运行时动态生成可执行代码。这就好比房东只允许你住框架已经建好的房子,不允许你自己在房间里再砌一堵承重墙。ILRuntime 的思路则完全不同——它不去请求系统创建新代码,而是自己写了个“翻译官”,把 DLL 里的 IL 指令一条条翻译成本地操作。这样既绕开了平台限制,也实现了真正的代码热更。
用大白话来说:你写的热更新 C# 代码,不是被编译成机器码执行的,而是被 ILRuntime 当成数据一样“读”着执行的。这个解释器是纯 C# 写的,跨平台,Windows、Android、iOS、WebGL 都能跑。这也是 ILRuntime 能在众多热更新方案里站住脚的根本原因。
1.2 它和 Lua 热更新的区别
我在团队里聊热更新方案时,最常被问到的问题就是:为什么不用 Lua?很多成熟项目用 Lua 做热更已经验证了很多年,方案稳定,资料也多。但 ILRuntime 最大的优势是——开发和维护热更新逻辑不需要换语言。
这里我举个例子。假设你要在热更新里写一个抽卡逻辑,用 Lua 就要在 C# 和 Lua 之间来回倒数据,table 和 class 互相转换,调试时的类型问题也会让人头疼。而 ILRuntime 这套方案,你可以直接写 C# 类,该继承继承、该多态多态,IDE 的智能提示和重构工具全都保留,团队里不需要额外养写 Lua 的同事。
当然它也不是没有代价。ILRuntime 的原始性能比 Lua 好一些,但和原生 C# 比还是有差距。尤其是在大量字符串拼接、频繁遍历集合这类 CPU 敏感操作上,解释执行的开销会被放大。我在项目里的经验是:核心战斗、渲染、物理这类对性能要求极高的逻辑放在主工程,把 UI、玩法配置、活动逻辑这种迭代频繁的部分放进热更 DLL,两边分工明确,性能问题也就不是问题了。
1.3 为什么 Unity 不能直接动态加载 C# 代码
我刚开始接触热更新时也天真地想过:既然 DLL 可以放到 StreamingAssets 里,那我用 C# 的反射动态加载不就行了?后来踩了坑才明白,动态加载这个动作本身,在不同平台上待遇完全不同。
- Windows / macOS 编辑器环境下:Assembly.Load 可以正常工作,DLL 加载起来毫无压力,所以很多新手在编辑器里测试没毛病,一打 Android 包就傻眼。
- iOS 平台:苹果禁止应用在运行时动态申请可执行内存,Assembly.Load 这种 JIT 相关的 API 会被系统直接拒绝,程序可能闪退,甚至上架被拒。
- Android 平台:虽然支持 JIT,但 Unity 的 IL2CPP 打包方式下,动态加载托管 DLL 的支持也有很大限制。
ILRuntime 正是为了解决这套平台限制而生的,它在所有平台上都是靠解释器执行热更代码,不依赖系统级 JIT。这也是我最终选择它的核心原因——一套代码,全平台通用,不用针对不同平台各写一套热更方案。
2. 准备两个工程:主工程与热更新工程
2.1 工程目录结构与分工
ILRuntime 的工程结构,核心是要把“主工程”和“热更新工程”彻底分开。我第一次接入的时候,偷懒把热更新脚本直接放在 Unity 工程的 Assets 目录里,结果发现编译时这些脚本也被打进了主程序集,热更的意义直接没了。所以这一步一定要从一开始就分清楚。
我的目录结构通常长这样:
- HotUpdateProject(主工程,一个正常的 Unity 工程)
- Assets/
- Scripts/(主工程代码,比如 GameBootstrap、热更管理器)
- Plugins/(ILRuntime 的 DLL 和它的依赖)
- StreamingAssets/HotUpdate.dll(热更 DLL 的输出位置)
- HotUpdateDLL/(这是一个独立的 C# 类库工程,不在 Assets 里)
- Scripts/(热更新代码)
- bin/Release/HotUpdate.dll(编译产物)
- Assets/
主工程负责所有基础功能:引擎初始化、ILRuntime 运行时的创建、热更 DLL 的加载、以及调用热更入口。热更新工程则专注写业务逻辑,将来要更新游戏内容,只需要重新编译这个 DLL,上传到服务器,客户端启动时下载新的 DLL 替换掉旧的就行。
2.2 热更新工程的关键配置
热更新工程必须是独立的类库项目,我用的是 .NET Framework 4.x 或者 .NET Standard 2.0,具体可以根据你 Unity 版本对应的 API Compatibility Level 来选。在这个工程里,一定要手动引用你主工程对应的 UnityEngine.dll,这样热更新代码里才能写 MonoBehaviour、GameObject、Debug 这些 Unity 类型。
这里有个非常容易踩的坑:Unity 不同版本对应的 UnityEngine.dll 路径不同,你在热更新工程里引用的版本必须和主工程严格一致。我见过有人引了 Unity 2018 的 UnityEngine.dll,结果主工程是 Unity 2021,运行的时候各种“找不到函数”“类型不匹配”的报错,排查了半天才反应过来是程序集版本不一致。
另外一个配置点:热更新工程里不要勾选“自动引用系统程序集”之外的额外引用,尤其是不要引入主工程里没有的 NuGet 包。ILRuntime 加载 DLL 时会解析它的所有依赖程序集,如果某个依赖在主工程里不存在,加载直接失败,而且报错还不明显。
2.3 生成、拷贝热更新 DLL 的步骤
热更新工程的编译产物是普通的 DLL 文件,每次改完代码重新编译,然后把 bin 目录下的 DLL 拷贝到主工程的 StreamingAssets/HotUpdate.dll。这个过程我建议做成一个批处理或者脚本,避免手动拷贝漏掉文件。
我习惯的做法是,在热更新工程编译后,用一条命令行指令把 DLL 复制过去:
copy /Y "HotUpdateDLL\bin\Release\HotUpdate.dll" "HotUpdateProject\Assets\StreamingAssets\HotUpdate.dll"如果你用 macOS 或者 Linux,换成对应的 cp 命令就行。这一步看似简单,但手动操作的情况下,我至少有过三次因为忘了拷贝旧 DLL,导致编辑器里调试了半天问题依旧,最后发现加载的还是老版本。你的时间很宝贵,别在这种细节上浪费。
还有一点,StreamingAssets 路径在 PC 上是直接读取的,但在 Android 上,文件在 APK 内部,需要先把文件拷贝到可写目录再读取。做热更新的时候,这一点尤其关键,后面我会再提。
3. 写出第一个 HelloWorld 热更脚本
3.1 热更新脚本的代码写法和注意点
现在进入正题。在热更新工程里新建一个 C# 文件,定义一个入口类。先从最简单的静态方法开始,跑通全链路。
using UnityEngine; namespace HotUpdate { public static class HotEntry { public static void Main() { Debug.Log("Hello from ILRuntime!"); Debug.Log("热更新代码执行成功,当前时间是:" + System.DateTime.Now); } } }注意几个细节:命名空间尽量写完整,后面用反射或者 ILRuntime 接口调用时需要用到类型全名;代码里引用的 UnityEngine 命名空间带来的类型,会在热更 DLL 里被解析成对主程序集 UnityEngine 的引用,所以主工程只要已经加载了 Unity 引擎程序集,热更侧就能直接用,不需要额外处理。
编译这个工程,得到 HotUpdate.dll。注意编译时要选 Release 模式,Debug 模式下生成的一些调试信息可能会干扰 ILRuntime 的加载。编译成功后,按上面说的路径拷贝到主工程 StreamingAssets 目录。
3.2 主工程初始化与加载热更新 DLL
回到 Unity 主工程,在场景里放一个空物体,挂一个名为 GameBootstrap 的 MonoBehaviour。它的任务是创建 ILRuntime 的运行时环境,加载热更 DLL。
using System.IO; using ILRuntime.Runtime.Enviorment; using UnityEngine; public class GameBootstrap : MonoBehaviour { private AppDomain _appDomain; private void Start() { // 1. 创建 ILRuntime 的运行时 _appDomain = new AppDomain(); // 2. 执行内部初始化,注册一些基础信息和类型重定向 _appDomain.Initialize(); // 3. 读取热更 DLL 的字节数组 string dllPath = Path.Combine(Application.streamingAssetsPath, "HotUpdate.dll"); byte[] dllBytes = File.ReadAllBytes(dllPath); // 4. 把热更 DLL 交给 ILRuntime 加载 _appDomain.LoadAssembly(dllBytes); Debug.Log("热更 DLL 加载成功"); // 5. 调用热更入口 _appDomain.Invoke("HotUpdate.HotEntry", "Main", null, null); } }这段代码里有几个容易出问题的地方。路径拼接时我用的是 Path.Combine,避免手写字符串时漏掉斜杠;在 Android 上 Application.streamingAssetsPath 这个路径不能直接用 File.ReadAllBytes 读取,需要先解压到沙盒目录。编辑器下,这段代码是没问题的,但如果你要真机测试,记得额外处理文件拷贝。
_appDomain.Invoke第一个参数是类型全名,第二个参数是方法名,第三个是实例对象(静态方法传 null),最后是参数数组。刚跑通第一个 HelloWorld 时,先别急着用复杂的方式去创建实例和调用方法,反射式的 Invoke 最简单,也最容易排查问题。
3.3 调用入口:用反射跑通第一行日志
在 Unity 编辑器里点运行,如果一切正常,Console 里会出现两行日志:
Hello from ILRuntime! 热更新代码执行成功,当前时间是:...看到这两行日志,说明什么?说明热更 DLL 已经被成功加载,ILRuntime 虚拟机成功解释执行了热更侧的 C# 代码,并且热更侧调用 UnityEngine.Debug.Log 也成功转发回了主工程。这已经是一个完整的最小闭环——热更代码不在主程序集里,主程序集通过 ILRuntime 加载并执行了它。
如果你在这里卡住了,日志没出来,我建议先检查以下几点:热更工程有没有编译成功,编译后的 DLL 有没有被拷贝到 StreamingAssets 目录,加载路径有没有写错。这三个点占了初学者踩坑的八成。我在第一次调试时,DLL 一直加载失败,排查到最后发现是热更工程输出路径变了,StreamingAssets 里放的是三天前的旧 DLL,此类错误非常隐蔽。
4. 跨层通信:热更新代码怎么调用 Unity API
4.1 从热更新侧调用 UnityEngine 方法
HelloWorld 里那句 Debug.Log 看起来很简单,但它在 ILRuntime 里经历了一个完整的跨层调用过程。热更代码执行到 Debug.Log 时,ILRuntime 发现目标是主工程里的 UnityEngine 类型,于是它会通过“方法重定向”机制找到真正的方法入口,把调用转发过去。
这意味着热更新代码跟主工程代码之间的边界并不是物理隔绝的,恰恰相反,两者可以自由互相调用。热更代码里可以创建 GameObject、加载资源、播放音效、操作 UI,几乎主工程能做的事它都能做。
public static class HotEntry { public static void Main() { var go = new GameObject("HotObject"); go.AddComponent<SampleComponent>(); } }不过这里就有一个新问题了:热更侧创建出来的 GameObject 是真实存在于 Unity 场景里的,但这个 GameObject 上挂的组件,它的生命周期和主工程里的 MonoBehaviour 不完全一样。为什么会这样?这就是热更新方案里最绕的一个点,我专门在下面展开说。
4.2 委托与回调:事件系统接入
热更新代码里写按钮点击事件,是另一个高频场景。按钮的 onClick 是 UnityEngine.Events.UnityAction 类型,它是一个委托。主工程和热更工程里的委托,属于两个不同运行时环境,不能直接互相指向。
所以 ILRuntime 提供了一个委托注册机制,把你的委托转换器注册到运行时里,之后热更侧才能把自己的方法绑定到主工程的委托事件上。
using UnityEngine.Events; private void RegisterDelegate() { _appDomain.DelegateManager.RegisterDelegateConvertor<UnityAction>((obj) => { if (obj is DelegateAdapter) { return ((DelegateAdapter)obj).GetDelegate<UnityAction>(); } return null; }); }这个代码手写比较繁琐,ILRuntime 官方其实提供了一个自动生成工具,可以自动生成一堆委托转换器,你只要在运行时调用它生成出来的初始化方法就行。等到第五章我会详细讲。你先记住:热更侧和主工程侧的委托如果直接赋值,很可能报类型转换异常,这不是你的业务逻辑写错了,而是跨运行时环境的类型隔离所致。
4.3 跨域继承与 MonoBehaviour 适配器方案
我想重点强调一个很多人一开始都会踩的认知误区:热更代码可以继承 MonoBehaviour,但你不能用 AddComponent 把热更类挂到 GameObject 上。原因很简单,AddComponent 只会识别当前 Unity 引擎注册过的原生组件类型,ILRuntime 里的类对它来说完全是透明的。
比如你在热更侧写:
public class MyHotMono : MonoBehaviour { private void Start() { } }然后在主工程侧尝试gameObject.AddComponent<MyHotMono>();,这时候会报错,因为它找不到这个类型。正确的做法是用适配器模式:在主工程准备一个桥接组件,热更新侧写普通 C# 类,桥接组件在生命周期方法里把调用转发给热更对象。
public class HotBehaviourBridge : MonoBehaviour { public string HotTypeName; private AppDomain _domain; private object _hotInstance; private void Awake() { _domain = GameBootstrap.Instance.GetAppDomain(); _hotInstance = _domain.Instantiate(HotTypeName); _domain.Invoke(HotTypeName, "Awake", _hotInstance, null); } private void Start() { _domain.Invoke(HotTypeName, "Start", _hotInstance, null); } private void Update() { _domain.Invoke(HotTypeName, "Update", _hotInstance, null); } }对应的热更侧类:
public class MyHotMono { public void Awake() { } public void Start() { Debug.Log("桥接组件驱动热更对象执行 Start"); } public void Update() { } }这是目前我在项目里验证过最稳定的方式,虽然多写了一层桥接,但好处是热更侧不用去处理 MonoBehaviour 生命周期细节,主工程侧也清楚边界在哪,代码反而更清晰。
5. 注册绑定:CLR 绑定与委托注册详解
5.1 为什么要做 CLR 绑定
HelloWorld 跑通之后,用不了多久你就会发现一个问题:热更侧频繁调用 UnityEngine 的函数时,性能明显比原生慢。我在测试一个 UI 列表滚动时,每帧更新几十个 UI 元素,FPS 掉得特别厉害,一开始还以为是列表逻辑写得差,后来定位到是热更侧频繁调用主工程 API,每次都走了反射。
ILRuntime 的解决方案就是 CLR 绑定。它的原理是在运行时初始化时,把方法名和签名注册成指向真实 C# 方法的委托,这样调用时不再需要通过反射去查找,速度能提升数倍。
用一个直白的类比来说:反射就像你去一个没有索引的巨型图书馆找一本书,得一本一本翻;CLR 绑定则是你手里拿着准确的索书号,直奔书架。对于方法调用这种高频操作,索引和没索引的差距是巨大的。
5.2 使用自动生成工具生成绑定代码
ILRuntime 提供了自动生成绑定代码的编辑器工具,不需要手写。在 Unity 菜单栏找到 ILRuntime 相关的选项,运行生成 CLR 绑定代码,工具会扫描你工程里引用的程序集和类型,生成一个初始化脚本,放到 Assets 目录里。
生成之后,在你的初始化流程里加上这一句:
_appDomain.Initialize(); Bindings.Generated.CLRBindings.Initialize(_appDomain); _appDomain.LoadAssembly(dllBytes);有一点要注意:生成绑定代码之前,先把热更工程里要调用的 Unity API 用起来,因为工具是根据你主工程里已经存在的代码来生成绑定列表的。如果你在热更侧用了某个类型或方法,但它没被生成工具收录,那这个调用仍然会走反射,性能就得不到保障。
另外生成绑定代码会增加一定的包体大小,所以在选择需要绑定的类型时,我的原则是只绑热更逻辑里实际用到的高频类型,比如 Transform、GameObject、Debug、UI 组件这些,没必要把整个 UnityEngine 命名空间全勾上,每次生成的代码越多,主工程启动时的注册耗时也会增加。
5.3 委托注册与应用示例
跟 CLR 绑定一样,委托注册也有自动生成工具。生成后同样在初始化时调用:
Bindings.Generated.DelegateRegistration.Initialize(_appDomain);有了这一行,前面说的 UnityAction 委托转换器就被自动注册进去了。之后在热更侧写按钮事件时,直接给 onClick 赋值就行,不会再报类型转换异常。我建议你不管用不用到事件系统,都把这个委托注册打开,因为热更代码一旦涉及 UI 交互,这几乎是不可避免的。
委托注册生成的代码量一般不大,它只针对你在热更工程里引用过的委托类型生成转换器。如果新写了一个带自定义委托类型的 API,重新生成一次即可。
6. 调试验证与常见问题排查
6.1 日志堆栈信息不准确怎么办
ILRuntime 解释执行热更代码时,抛异常打印出来的堆栈跟原生 C# 不一样,它显示的是 ILRuntime 内部的解释器栈帧,刚开始看会非常懵。我在第一次调试热更异常时,盯着“at ILRuntime.Runtime.Intepreter.ILIntepreter.Execute...”这类日志看了半天,完全定位不到自己哪行代码出错。
解决办法是开启 PDB 符号文件支持。在热更新工程编译时,同时输出 pdb 文件——实际上 C# 类库工程默认就会生成。把热更 DLL 连同 pdb 文件一起加载进去,ILRuntime 就能在异常堆栈里显示原始代码行号,排查问题的效率会高很多。
_appDomain.LoadAssembly(dllBytes, pdbBytes);Debug 模式下 pdb 文件放在 DLL 同目录下,加载时读出来一并传给 LoadAssembly 即可。Release 模式下去掉调试符号后,异常堆栈信息会少一些,但 ILRuntime 依然能定位到方法级别。
6.2 类型转换异常和字段访问异常的排查
这类问题在热更新开发里非常常见。最常见的几个报错是:把主工程类型传进热更新侧时,运行时发现类型不匹配;或者在热更侧访问主工程某些字段、方法时,报“没有找到”的错误。
我在第二章里说过,热更新工程引用的 UnityEngine.dll 必须和主工程版本一致,就是为了避免这类问题。一旦版本不一致,类型名称虽然看着一样,但类型标记在 ILRuntime 眼里是两个不同的东西,运行时就会抛类型转换异常。
更隐蔽的情况是:你在热更新 DLL 里引用了主工程自定义类型,但主工程程序集还没有被 ILRuntime 识别。解决办法是确保在加载热更 DLL 之前,把相关依赖程序集先加载好,或者在创建 AppDomain 之后主动加载主程序集。如果涉及多个热更 DLL 互相引用,加载顺序也要保证用到的依赖在前。
6.3 打包真机之后跑不起来的几个原因
编辑器里一切正常,打包到 Android 或 iOS 后热更代码不执行或者直接崩溃,这个问题我遇到过太多回。最常见的情况是 StreamingAssets 路径在真机上的读取方式和编辑器不一样。
Android 上 Application.streamingAssetsPath 指向的是 APK 内部的压缩路径,不能用 File.ReadAllBytes 直接读,正确做法是先把文件从包内拷贝到 Application.persistentDataPath,再读取。正常的游戏热更新流程中,DLL 本来就会从下载目录读取,所以这个文件拷贝问题只是测试阶段才需要面对的。
iOS 上则要额外注意 IL2CPP 的设置。我遇到过热更 DLL 在 iOS 上加载成功,但调用某个 UnityEngine 方法直接崩溃,后来发现是生成绑定代码时,那个方法在 iOS 的 AOT 环境下没有生成对应的元数据,所以 ILRuntime 在解析时找不到入口。解决方式是在 PlayerSettings 里勾选合适的管理器 stripping 选项,或者把那个类型补进 CLR 绑定列表。
还有一个很基础但容易被忽略的点:热更 DLL 的文件名在打包进 AssetBundle 或 StreamingAssets 时,可能会被压缩工具改名或者加后缀。加载时用的路径必须和最终包里的路径完全一致,大小写和扩展名都不能差。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 加载 DLL 抛 FileNotFoundException | 路径错误或 DLL 未被正确打包 | 检查 StreamingAssets 路径,确认文件已拷贝且未被改名 |
| 调用热更方法提示找不到类型 | 类型全名写错或 DLL 加载的是旧版本 | 核对命名空间和类型名,重新编译并替换 DLL |
| 热更侧调用 UnityEngine 方法报异常 | UnityEngine.dll 版本不一致或缺少 CLR 绑定 | 检查热更工程引用的 Unity 版本,重新生成绑定代码 |
| 委托事件赋值报类型转换异常 | 缺少委托注册 | 生成并调用委托注册初始化方法 |
| Android 真机日志无输出 | 路径读取方式不对 | 先拷贝到 persistentDataPath 再读取 |
| iOS 真机调用崩溃 | 类型被裁剪或绑定缺失 | 补全 CLR 绑定,检查 PlayerSettings 裁剪选项 |
写在最后的一点实际体会
ILRuntime 这套方案,我前前后后接入了好几个项目,整体感受是:学习曲线不算陡,但坑很多,而且很多坑是文档里不会写清楚的。上面这些内容,基本覆盖了我从 HelloWorld 到正式在业务里使用热更新所经历的全部关键节点。其中我个人觉得最值得反复检查的地方,一是引用程序集版本的一致性,二是路径读取在真机上的差异,三是热更代码和主工程的边界划分。这三块只要从一开始就绷紧弦,后续踩坑的概率会小很多。
这个 HelloWorld 跑通之后,下一步我建议你去尝试把自己的一个业务功能拆一部分到热更工程里,比如一个登录流程或者一个 UI 面板,先在编辑器里跑通,再考虑打包到真机。直接在项目里大规模铺开热更新,风险太高,不如先从一个真实功能练手,逐步建立信心。