文章摘要
文章探讨了C#/Unity中的脚本热重载与热更新技术,分析其核心概念与典型应用场景(如紧急修复、动态玩法)。针对C#的热更新难点(如IL限制、AOT编译问题),详细对比了三种主流方案:1)嵌入Lua/Python脚本(灵活但性能低);2)ILRuntime/HybridCLR等框架(C#原生支持,性能适中);3)反射与动态代理(无依赖但维护复杂)。推荐新项目优先采用HybridCLR或ILRuntime,强调热更代码需与主工程解耦,并注意安全加密与自动化流程。文末附相关技术文档链接。
一、脚本热重载与热更新
1. 概念
- 脚本热重载/热更新:指在不重启程序、不重新打包的情况下,动态加载、替换、修复或扩展游戏/应用的业务逻辑代码。
- 主要用于快速修复bug、上线新功能、活动脚本动态下发等场景。
2. 典型需求
- 游戏上线后发现逻辑bug,需紧急修复
- 运营活动需要临时下发新玩法
- 客户端和服务器需要灵活扩展逻辑
二、C#热更新的难点
1. C#编译后为IL字节码
- Unity等C#项目,代码编译后生成DLL(.NET/Mono/IL2CPP),无法直接在运行时替换。
- 传统C#运行时(Mono/IL2CPP)不支持动态卸载和替换已加载的程序集。
- Unity的AOT(Ahead-Of-Time)编译(如iOS平台)不支持JIT,更难动态加载新代码。
2. 代码与资源强耦合
- 逻辑代码和资源(Prefab、场景等)往往紧密绑定,热更时要保证兼容性。
3. 安全性与性能
- 动态加载代码有安全风险,且解释执行/反射性能低于原生。
三、主流解决方案
1. 脚本语言嵌入(Lua、Python等)
- 原理:在C#项目中集成Lua、Python等脚本解释器,核心逻辑用脚本实现,C#只做壳和桥接。
- 优点:脚本文件可随时替换、热重载,跨平台无障碍。
- 缺点:与C#交互复杂,性能略低,调试不如C#方便。
- Unity常用方案:XLua、SLua、ToLua(Lua),Python for Unity等。
示例:XLua热更流程
// C#调用Lua脚本
LuaEnv luaEnv = new LuaEnv();
luaEnv.DoString("require 'main'"); // main.lua可随时替换
2. ILRuntime、XLua等C#热更框架
- ILRuntime:一个跨平台的.NET IL解释器,支持在Unity等环境下动态加载和执行C# DLL(热更DLL)。
- XLua:除了Lua解释器,还支持C#反射热更。
- HybridCLR:支持在IL2CPP下运行热更DLL(支持AOT+JIT混合)。
- 优点:可用C#写热更逻辑,语法一致,易于维护。
- 缺点:解释执行性能略低,部分特性受限(如泛型、ref/out等)。
- Unity常用方案:ILRuntime、HybridCLR、XLua(C#反射热更)。
ILRuntime热更流程简述
- 热更代码编译成DLL,放到服务器。
- 客户端下载DLL,ILRuntime加载并执行。
- 业务逻辑通过接口/委托与主工程交互。
示例代码
using ILRuntime.Runtime.Enviorment;
AppDomain appdomain = new AppDomain();
using (var fs = new FileStream("hotfix.dll", FileMode.Open, FileAccess.Read))
{
appdomain.LoadAssembly(fs);
}
appdomain.Invoke("HotFixClass", "HotFixMethod", null, null);
3. 反射与动态代理
- 原理:利用C#反射机制,动态加载和调用外部DLL或脚本。
- 优点:无需第三方框架,灵活。
- 缺点:AOT平台受限,性能较低,代码维护复杂。
- 适用场景:小规模热更、工具开发。
四、Unity热更新方案对比
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
Lua/Python | 跨平台、热更灵活 | 语法不同、调试不便 | 逻辑频繁变动的项目 |
ILRuntime | C#语法、易维护 | 性能略低、特性有限 | 大型项目 |
HybridCLR | 性能高、AOT支持 | 配置复杂、社区新 | 追求极致性能 |
反射/代理 | 灵活、无第三方依赖 | 维护难、AOT受限 | 小型项目/工具 |
五、实际开发建议
- 新项目优先考虑HybridCLR或ILRuntime,兼容性和性能较好。
- 逻辑频繁变动、策划驱动型项目可用Lua等脚本热更。
- 热更代码与主工程解耦,通过接口/事件/消息机制通信。
- 热更DLL/脚本需加密/校验,防止被篡改。
- 热更流程自动化,如自动打包、上传、下载、加载。
六、参考资料
下面以ILRuntime为例,详细介绍Unity下C#脚本热更新的完整实战流程,包括:
- 工程配置
- 热更DLL编写与打包
- Unity主工程集成ILRuntime
- 热更DLL的加载与调用
- 典型代码示例
一、ILRuntime热更新方案实战
1. 工程结构
假设你的Unity项目结构如下:
/Assets
/MainProject // 主工程C#代码
/ILRuntime // ILRuntime插件
/HotfixProject // 热更DLL工程(建议用VS单独建Class Library)
2. 环境准备
- Unity 2018+(推荐2020+)
- ILRuntime插件(直接下载解压到Assets/ILRuntime)
- Visual Studio(用于编译Hotfix DLL)
3. 热更DLL工程配置
1)新建Class Library工程
- 用VS新建一个 .NET Framework 3.5/4.0 的Class Library项目(如HotfixProject)。
- 添加引用:UnityEngine.dll、UnityEngine.CoreModule.dll(可从Unity安装目录拷贝)。
2)编写热更代码
// HotfixProject/Hello.cs
using System;
using UnityEngine;
public class Hello
{
public static void SayHello()
{
Debug.Log("Hello from Hotfix DLL!");
}
}
3)编译DLL
- 生成HotfixProject.dll,拷贝到Unity项目的StreamingAssets或远程服务器。
4. Unity主工程集成ILRuntime
1)导入ILRuntime
- 下载ILRuntime源码,解压到Assets/ILRuntime。
2)编写加载和调用热更DLL的代码
using System.IO;
using UnityEngine;
using ILRuntime.Runtime.Enviorment;
public class ILRuntimeLoader : MonoBehaviour
{
private AppDomain appDomain;
void Start()
{
StartCoroutine(LoadHotfixAssembly());
}
System.Collections.IEnumerator LoadHotfixAssembly()
{
// 假设DLL放在StreamingAssets
string dllPath = Application.streamingAssetsPath + "/HotfixProject.dll";
byte[] dllBytes = File.ReadAllBytes(dllPath);
appDomain = new AppDomain();
using (MemoryStream ms = new MemoryStream(dllBytes))
{
appDomain.LoadAssembly(ms);
}
Debug.Log("Hotfix DLL 加载完成");
// 调用热更代码
appDomain.Invoke("Hello", "SayHello", null, null);
}
}
3)挂载脚本到场景
- 将
ILRuntimeLoader
挂到任意GameObject上。
5. 运行效果
-
运行游戏,控制台输出:
Hello from Hotfix DLL!
-
你可以不重启主程序,只需替换DLL文件,即可热更逻辑。
6. 进阶:热更与主工程交互
1)接口通信
主工程定义接口,热更DLL实现:
// 主工程
public interface ILogic
{
void Run();
}
// 热更DLL
public class MyLogic : ILogic
{
public void Run()
{
Debug.Log("Run from hotfix!");
}
}
主工程通过ILRuntime实例化并调用:
var type = appDomain.LoadedTypes["MyLogic"];
var obj = appDomain.Instantiate("MyLogic");
((ILogic)obj).Run();
2)委托注册
主工程注册委托,热更DLL可直接调用。
7. 典型实战场景
- 活动脚本热更:活动逻辑写在DLL,活动上线只需替换DLL。
- bug修复:发现bug后,修复热更DLL,用户无需重新下载主包。
- 功能扩展:新玩法、新系统可通过热更DLL下发。
8. 注意事项
- 热更DLL不能引用UnityEditor等EditorOnly API。
- ILRuntime对泛型、ref/out等有部分限制,详见官方文档。
- iOS/IL2CPP平台需做AOT适配(生成跨域适配器、CLR绑定等)。
- 推荐用ILRuntime自动生成工具生成适配代码。
9. 参考资料
下面我将分别对Mono的程序集加载流程和IL2CPP的类型注册机制做更详细的源码分析。
你可以根据需要选择关注其中一部分,或都了解。
一、Mono的程序集加载流程源码分析
Mono是Unity早期的C#运行时。程序集(DLL)加载的核心流程主要在mono/metadata/assembly.c
和mono/metadata/appdomain.c
中实现。
1. 加载入口
C#层调用如Assembly.Load
、Assembly.LoadFrom
等,最终会走到Mono的C实现:
MonoAssembly* mono_assembly_load_from_full (MonoImage *image, const char *fname, MonoImageOpenStatus *status, gboolean refonly);
2. 加载流程简述
-
查找缓存
Mono会先查找当前AppDomain的已加载程序集缓存(domain->assemblies
),避免重复加载。 -
解析元数据
通过MonoImage解析PE文件,读取元数据(类型、方法、字段等)。 -
注册到AppDomain
加载成功后,将MonoAssembly
对象注册到当前AppDomain的程序集列表。 -
类型系统注册
对于每个Type,Mono会创建MonoClass
对象,注册到全局类型表。
3. 关键源码片段
assembly.c:
MonoAssembly* mono_assembly_load_from_full (MonoImage *image, const char *fname, MonoImageOpenStatus *status, gboolean refonly)
{
// 1. 检查缓存
// 2. 解析元数据
// 3. 创建MonoAssembly对象
// 4. 注册到AppDomain
// 5. 返回MonoAssembly*
}
appdomain.c:
void mono_domain_assemblies_add (MonoDomain *domain, MonoAssembly *assembly)
{
// 将assembly加入domain->assemblies链表
}
类型注册(class.c):
MonoClass* mono_class_get (MonoImage *image, guint32 type_token)
{
// 1. 查找全局类型表
// 2. 没有则创建MonoClass对象
// 3. 注册到全局类型表
}
4. 卸载机制
- Mono只允许卸载整个AppDomain,不能单独卸载某个Assembly。
- Unity只用一个主AppDomain,无法卸载主工程DLL。
二、IL2CPP的类型注册机制源码分析
IL2CPP是Unity的AOT方案,将C#编译为C++再编译为本地代码。类型注册和元数据管理在C++层实现。
1. 类型注册入口
IL2CPP在生成的C++代码中,会为每个C#类型生成对应的C++结构体和元数据注册代码。
自动生成的代码示例:
extern const Il2CppTypeDefinition g_TypeInfoTable[];
void RegisterTypes()
{
il2cpp_codegen_register_type(&g_TypeInfoTable[0]);
il2cpp_codegen_register_type(&g_TypeInfoTable[1]);
// ...
}
2. 类型元数据结构
il2cpp-class-internals.h:
typedef struct Il2CppClass
{
const Il2CppImage* image;
const char* name;
const char* namespaze;
// 方法表、字段表、父类、接口等
} Il2CppClass;
3. 类型注册流程
-
编译时生成元数据表
Unity Editor在构建时分析所有C#代码,生成类型、方法、字段等元数据表(如g_TypeInfoTable
)。 -
运行时注册
启动时,IL2CPP运行时会遍历元数据表,调用il2cpp_codegen_register_type
等函数注册所有类型。 -
类型查找
运行时通过类型名、token等在元数据表中查找类型信息。
4. 关键源码片段
il2cpp-class-internals.h:
void il2cpp_codegen_register_type(const Il2CppTypeDefinition* typeDef)
{
// 1. 分配Il2CppClass结构体
// 2. 填充类型信息
// 3. 注册到全局类型表
}
il2cpp-metadata.cpp:
const Il2CppTypeDefinition* MetadataCache::GetTypeDefinitionFromIndex(TypeDefinitionIndex index)
{
// 通过索引查找类型定义
}
5. 运行时不可变
- 所有类型、方法、字段在编译时固化,运行时只读。
- 没有Assembly.Load、Unload等机制,无法动态加载/卸载/替换类型。
总结
- Mono:程序集加载流程是“解析PE元数据→注册到AppDomain→注册类型到全局表”,只能卸载整个AppDomain,不能卸载单个DLL。
- IL2CPP:类型注册在C++层,所有类型在编译时固化,运行时只读,完全不支持动态加载/卸载/替换。