做Unity客户端这些年,只要是做手游的,基本绕不开热更新这个话题。资源热更还好说,AssetBundle或者Addressable都能解决,真正麻烦的是代码热更。以前用Lua,好是好,但团队要养Lua程序员,C#的代码也没法直接复用。后来项目组切到了ILRuntime这套方案,配合Addressable做资源加载,总算把代码热更这件事落地了。这篇笔记就专门聊聊怎么用ILRuntime配合Addressable在Unity里实现代码热更,把方案设计、主工程和热更工程怎么拆、跨域交互怎么做、常见的坑怎么踩,一次讲清楚。1. 为什么选ILRuntime + Addressable这套组合1.1 先搞明白你要解决什么问题手游发出去之后,玩家手机上装着的是旧版本客户端。如果线上出现了严重BUG,或者要加新玩法、调数值、改UI逻辑,总不能每次都让玩家重新下载几百兆的安装包。资源热更可以解决美术资源、配置表、UI Prefab的更新,但C#逻辑本身被编译进了Assembly-CSharp,Unity的Assembly-CSharp在移动平台上不支持运行时动态加载新的程序集,这就逼着你要么换语言(Lua),要么用解释器方案(ILRuntime),要么用AOT+补充元数据方案(HybridCLR)。ILRuntime是一个纯C#实现的.NET运行时解释器。它做的事情很底层——读取托管DLL里的IL指令,然后在Unity的Mono或者IL2CPP环境下逐条解释执行。也就是说,你把游戏逻辑写在一个独立的DLL工程里,发版前把这个DLL转成字节数组或者AssetBundle打出去,玩家的客户端启动时通过网络下载到新的DLL,然后动态加载、解释执行。出BUG了就改热更工程里的C#代码,重新编译DLL,下发一个几十KB的文件,玩家下次冷启动或者切场景时就能跑新逻辑。选ILRuntime而不选Lua,主要原因是团队全是C#程序员,不想为了热更单独整一套Lua栈。ILRuntime让业务层可以主要用C#写,既保留了静态语言的类型检查、IDE补全、重构能力,又拿到了热更能力。选Addressable而不直接裸用AssetBundle,是因为热更DLL只是资源体系里的一个小文件,Addressable带来的寻址管理、依赖处理、异步加载API、远程内容分发能力,能直接把DLL加载这件事纳入统一管线,不用自己手写一个文件下载器。1.2 同赛道方案优缺点对比现在Unity圈子提到代码热更,主要就是三派:Lua、ILRuntime(包括其他C#解释器)、HybridCLR。结合我自己在项目里的实际感受,三者的优劣挺明显的。方案上手难度性能代码复用度适配IL2CPPLua + xLua/sLua中,需要团队会Lua解释执行,跨域调用开销大低,C#逻辑无法复用好ILRuntime低,C#直接写业务解释执行,比原生C#慢一个量级但优化空间大高,大部分C#代码直接搬需要同步ILRuntime主分支HybridCLR中,需要理解AOT泛型原理接近原生性能极高,几乎无感开发需要处理AOT泛型补充Lua的问题不在于性能,而在于工程化。写Lua的人少、IDE弱、类型弱、写多了代码非常乱,而且和C#层的交互永远绕不开一层bridge。HybridCLR性能虽好,但需要你对IL2CPP的AOT机制有比较深的认识,遇到泛型坑的时候排查心智负担重。ILRuntime的性能确实比原生慢,但实际上游戏逻辑大量是I/O、UI刷新、网络包处理,这些本来就是瓶颈在别处的操作,纯计算密集的部分做做优化,做一个性能打包机放到原生层,整体是压得住场面的。Addressable在这套方案里扮演的角色相对纯粹——它是DLL文件的搬运工。原本用AssetBundle还需要自己做依赖分组、加载卸载、版本比对,Addressable把这些流程抽象成了AddressableAssetSettings、AssetGroup和异步Handle。更重要的是,Addressable天然支持Remote Catalog和Remote Content,也就是远程构建产物。这点正好和热更DLL的下载链路无缝结合:只要把DLL文件标成Addressable资源并丢到远程Hosting,客户端就可以用同一套API拉取DLL和拉取UI资源,没必要为DLL单独造一套下载器。2. 准备工作:ILRuntime接入Addressable工程2.1 从零搭一个热更DLL的独立工程这一步很多人忽略,但它决定了你后续能省多少麻烦。ILRuntime的推荐做法是:热更代码不要放在Unity主工程里,而是单独建一个C#工程,输出成一个独立的DLL。原因很现实——Unity主工程里UnityEngine程序集和ProjectAssembly是绑定在一起的,如果你直接在Unity工程里写脚本再热更,加载出来的DLL会和主工程里的Assembly-Sharp冲突,ILRuntime官方也明确不推荐直接热更Unity主工程代码。我搭建时的目录结构大概是这样的:HotfixSolution/ ├── HotfixCore/ // 热更DLL工程,引用UnityEngine.dll ├── HotfixMain/ // 热更入口,继承主工程定义的接口 └── UnityClient/ // 主工程,引用ILRuntime程序集HotfixCore和HotfixMain都以.dll输出,目标框架建议选.NET Standard 2.0或者.NET Framework 3.5 / 4.6,具体看Unity版本和ILRuntime分支版本要求。要说明的是,Unity引擎本身没有安装在代码工程里,所以需要从Unity安装目录下的Editor/Data/Managed/UnityEngine/引用对应的UnityEngine DLL。选择哪份UnityEngine引用,要看你的目标管线和ILRuntime是否支持,通常用Unity自带的那份即可。写热更代码时还有个习惯问题——不是所有Unity API都能在热更层直接用。ILRuntime对UnityEngine程序集的解释是运行时进行的,部分类型方法涉及到NotSupportedException,比如某些反射操作、某些泛型类型。遇到这种地方,标准做法是把这些不支持的能力下沉到主工程,通过接口暴露给热更层。这个我后面会专门用一节来讲。2.2 Addressable的装箱与分组策略Addressable侧要做的准备其实很简单:把编译出来的热更DLL(通常是一个TextAsset文件)拖进一个专门的AssetGroup,命名为HotfixAssets。DLL文件在Unity工程里作为TextAsset存在,打AssetBundle时不会做二进制处理,加载出来就是byte[],正好能给ILRuntime用。有几个细节值得注意。第一,Addressable的BuildPath和LoadPath决定最终输出位置。调试阶段可以把LoadPath设为Local,直接把DLL打进包里,方便断点调试。正式发布阶段必须切到Remote,配合CDN或者自己的服务器做内容分发。第二,每个热更DLL文件的Addressable Address要固定好,比如就叫hotfix_main.dll,不要在代码里写死路径,要用Addressable的Address去加载,这样换平台、换路径都不用改代码。第三,Addressable的Catalog本身会存在版本一致性问题上——如果旧客户端拿着旧Catalog去加载新Address,DLL版本不一致会导致加载失败,所以版本管理一定要做好。这里放一个Addressable异步加载DLL的最小代码模式:using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; public static class HotfixLoader { public static async void LoadAndExecute(string dllAddr, string pdbAddr) { var dllHandle = Addressables.LoadAssetAsync<TextAsset>(dllAddr); var pdbHandle = Addressables.LoadAssetAsync<TextAsset>(pdbAddr); await dllHandle.Task; await pdbHandle.Task; byte[] dllBytes = dllHandle.Result.bytes; byte[] pdbBytes = pdbHandle.Result != null ? pdbHandle.Result.bytes : null; // 交给ILRuntime初始化 var appDomain = new ILRuntime.Runtime.Enviorment.AppDomain(); using (var ms = new MemoryStream(dllBytes)) using (var pd = pdbBytes != null ? new MemoryStream(pdbBytes) : null) { appDomain.LoadAssembly(ms, pd, new ILRuntime.Mono.Cecil.Pdb.PdbReaderProvider()); } // 执行热更入口 appDomain.Invoke("HotfixMain.Entry", "Start", null, null); } }这段代码在实际项目中通常还会挂在启动界面做协程或异步流程处理,因为Addressable加载远程资源之前可能还要检查并下载Content。整体来说,Addressable这一层的代码量其实很少,核心复杂度还是集中在ILRuntime的初始化与跨域通信上。3. 核心接入:代码结构与入口约定3.1 主工程与热更工程的边界划分很多第一次接触ILRuntime的开发者都会问一个问题:“热更代码和主工程代码该怎么分层?”答案很关键——分层和边界决定了后续开发和调试的体验,越早确定越好。我在项目中约定一套规矩:主工程只做平台相关、性能敏感、或者ILRuntime不支持的底层操作,比如文件读写、网络库、SDK接入、渲染后处理、复杂计算。业务逻辑,包括UI流程、战斗逻辑、数据管理、消息处理,全部放进热更工程。热更与主工程的通信,不直接通过反射裸调,而是通过一组预定义接口和委托来做。具体来说,主工程先声明一个IHotfixApp接口:// 主工程 public interface IHotfixApp { void Start(); void Update(float deltaTime); void LateUpdate(float deltaTime); void OnApplicationPause(bool pause); void OnApplicationQuit(); void Destroy(); }热更工程里写一个类实现这个接口,并跨越域以接口实例的方式被主工程调用。ILRuntime的CLR绑定和跨域适配器会把热更类实例转换成可传递的IHotfixApp接口对象。这样主工程完全不用感知热更细节,触发生命周期时只调用接口方法。热更层代码长这样:// 热更工程 public class HotfixApp : IHotfixApp { public void Start() { // 初始化热更内部管理、加载配置、进入登录界面 } public void Update(float deltaTime) { // 驱动业务逻辑轮询 } }用反射方式加载DLL并创建实例也异常简单:var appType = appDomain.GetType("HotfixEntry.HotfixApp"); var appInstance = appDomain.Instantiate<IHotfixApp>(appType); appInstance.Start();Instantiate<T>其实是ILRuntime提供的CrossBindingAdaptor能力,在注册了适配器后,可以直接把热更对象向上转型为主工程接口。这里的效率比用Invoke反射高很多,因为跨域调用走的是直接的方法调用约定,不是基于字符串查找。3.2 初始化与委托注册,踩过的性能大坑用ILRuntime最怕的就是跨域调用。先说结论:如果只注册了接口适配器,但没有做委托绑定优化,一个简单方法调用的成本可能跑到微秒级别,大量频繁调用时性能会很难看。ILRuntime每次跨域调用,来自于解释器栈和CLR栈的相互切换,再加上参数解析、类型转换,开销远超原生调用。为了压这部分耗时,我坚持做两件事。第一件事是只把高频调用收窄到最少入口。比如游戏循环里,主工程只在Update里调一次HotfixApp.Update,热更层内部再分发到各业务模块。这种“单入口+热更内聚”的设计把跨域次数控制到了最低。第二件事是注册CLR绑定(ILRuntime的RegisterCLRMethodRedirection)。每当热更代码调用了某个主工程高频C#方法,例如Debug.Log、List<T>.Add、DateTime.Now、String.Format,ILRuntime默认是按反射方式走的,非常慢。使用CLR绑定把这些方法映射到预先写好的委托或函数指针,调用速度几乎可以追平原生。下面是注册委托的常见模板,我在启动阶段统一执行:appDomain.RegisterCLRMethodRedirection(GetMethod("Debug.Log"), MyLogRedirect); // 或者针对具体类型: appDomain.DelegateManager.RegisterDelegateDelegate<System.Action<float>>(); appDomain.DelegateManager.RegisterDelegateDelegate<System.Action<string>>();注意注册的位置:RegisterCLRMethodRedirection必须在LoadAssembly之后、任何热更代码调用该方法之前完成。如果你在热更逻辑已经开始运行之后才注册,然后热更代码又触发了一次之前未绑定的调用,ILRuntime会第二次走反射路径,性能就会突然劣化。所以肯定的准入原则是:启动时将你用到的所有高频方法尽可能一次配齐。3.3 入口约定:主工程启动时到底该做什么启动时序设计不好,Debug时容易一头雾水。我现在的项目大致是这样排的:启动画面(Splash)里先显示版本检查逻辑。调用Addressable初始化与远程Catalog更新,必要时下载新DLL。加载DLL到MemoryStream。初始化AppDomain,设置绑定,注册适配器。Instantiate热更入口,执行Start。主循环的Update里驱动热更入口的Update。这里有个引子顺序的细节:先加载DLL或先初始化AppDomain其实有讲究。ILRuntime的AppDomain先创建再LoadAssembly,注册绑定可以在LoadAssembly前后都做,但必须在Invoke之前。我的习惯是:创建AppDomain → 初始化基础类型绑定 + 委托注册 → LoadAssembly → 注册接口适配器和热更侧的类型重定向。原因是LoadAssembly本身不需要调用绑定,但AppDomain在加载完DLL后立刻会做类型校验,此时某些类型实例化操作如果依赖绑定映射,也可能触发反射遍历。代码结构上,我会把启动流程封成一个服务类:public class HotfixBootstrap : MonoBehaviour { private ILRuntime.Runtime.Enviorment.AppDomain _appDomain; private IHotfixApp _app; private async void Start() { await EnsureHotfixDllReady(); // 远程Catalog检查等 LoadDomainAndExecute(); } private void Update() => _app?.Update(Time.deltaTime); private void LateUpdate() => _app?.LateUpdate(Time.deltaTime); }3.4 热更入口是否需要传参有些项目需要把服务器地址、用户ID这些信息传给热更层。最直接的做法是入口方法带上参数:appDomain.Invoke("HotfixMain.Entry", "Start", null, new object[] { serverUrl, userId });这种方式可用,但参数一多就难维护。更好的方案是主工程实现一个IEnvBridge接口,通过适配器注册进热更层,热更代码启动时从桥接接口取环境配置。这样热更层看不到主工程的私有结构,未来加参数也只改接口定义,不破坏热更DLL的ABI兼容性。所谓ABI兼容性,就是说热更DLL被替换时要保证接口签名不变。如果新DLL里改了IHotfixApp接口的方法签名,但主工程还是旧的,那加载运行时就会抛异常。所以版本管理时要习惯把热更公共接口单独抽到一个低层程序集,且约定接口签名冻结,主工程和热更工程各自引用同一份接口定义。4. 深入跨域交互:接口适配器与委托绑定4.1 为什么热更层需要适配器才能继承Unity组件Unity的脚本继承自MonoBehaviour,而MonoBehaviour由Unity引擎管理生命周期。ILRuntime解释执行的热更类不能直接作为new Component()或AddComponent出来的组件挂在GameObject上,因为引擎的脚本回调是按原生类型识别的。标准做法是主工程定义一个适配器MonoBehaviour,内部持有热更逻辑对象,由适配器转发事件。public class HotfixMonoBridge : MonoBehaviour { private object _hotfixObject; public void Bind(object hotfixObject) { _hotfixObject = hotfixObject; } private void Update() { if (_hotfixObject != null) _appDomain.Invoke(_hotfixObject, "OnUpdate", null, null); } }这种方式有三个好处:热更层可以写纯逻辑类,不需要继承UnityEngine的任何类;主工程侧的MonoBehaviour生命周期完全受Unity引擎管理,不会被热更层干扰;后续如果要迁移到其他热更方案,主工程桥接层不用大刀阔斧改。再说继承链的问题。如果热更层想实现接口IFoo,但主工程想通过接口直接调用,必须实现一个CrossBindingAdaptor并注册到AppDomain。这个适配器通常用一个主工程侧的类继承热更侧接口,并桥接转发。好在ILRuntime官方提供了一个CrossBindingAdaptor和AdaptorHelper模板,按照模板生成即可,工作量不大。4.2 委托绑定:跨域回调的三个具体写法跨域回调是指在主工程执行某异步操作,完成后把结果回传给热更层。例如网络请求、加载资源完成回调。如果回调用的委托没有预先注册,ILRuntime支持运行时的委托绑定,但每次绑定都有成本,而且不注册有时会直接报错。我的原则是:一切回调委托尽力提前注册。常见写法有三种。第一种是直接注册DelegateManager:appDomain.DelegateManager.RegisterDelegateDelegate<Action<byte[], string>>();第二种是对应到具体委托类型,使用RegisterFunctionDelegate。比如热更层定义了一个Func<int, int>,主工程想调用这个函数指针并等待返回:appDomain.DelegateManager.RegisterFunctionDelegate<Func<int, int>>();第三种是针对UnityEngine定义的那些带UnityAction的接口,比如Button.onClick.AddListener。这个在ILRuntime热更层写UI交互时极其常用。务必在初始化时对每个用到的UnityAction变体做委托注册:appDomain.DelegateManager.RegisterDelegateDelegate<UnityEngine.Events.UnityAction>(); appDomain.DelegateManager.RegisterDelegateDelegate<UnityEngine.Events.UnityAction<bool>>();如果不注册,你可能在点击按钮时碰到“没有注册委托”或“无法创建委托”的异常。这类坑排查起来还挺难,因为异常信息不一定发生在AddListener的那一行,往往是点下按钮那一刻才炸。4.3 泛型与值类型跨域泛型跨域在ILRuntime里属于高风险区。比如你在热更层写List<SomeStruct>.Sort(),如果SomeStruct是主工程定义的结构体,ILRuntime对泛型实例化走的路径非常绕,经常触发MissingMethodException或性能雪崩。我的习惯是:热更层永远不自定义和使用复杂泛型结构跨域,涉及到跨域的集合可以统一换成主工程定义的ArrayList包装或者用接口封装成操作。如果一定要传值类型,注意不要频繁装箱。ILRuntime对值类型跨域时会复制,但如果你在主工程侧把int装箱成object再传给热更层,再做拆箱,一次调用就多几倍耗时。性能敏感处全部走原生类型参数,把复杂数据结构封装成主工程管理的句柄ID,比如int handleID,传给热更层操作。5. Addressable加载细节与版本管理5.1 热更DLL放Addressable到底怎么放最合适我见过两种常见做法。第一种是直接把DLL作为TextAsset放在Remote组。第二种是先打成AssetBundle,再把AB文件让Addressable管理。这两种的差别主要在体积和加载管道。直接把DLL当TextAsset,Addressable内部还是会生成AssetBundle,加载时通过TextAsset读取字节,效率尚可,代码最简单。而把DLL打成AssetBundle再塞进Addressable,多了一层手动处理,体积不会明显变小,但缓存管理要自己操心。所以绝大多数项目我推荐第一种,少一个环节就少一种出错的可能。Addressable组的设计按我的标准分为三类:启动组:包含启动场景、基础UI、公共Shader等。业务组:按玩法模块拆分,比如登录组、战斗组、商城组。热更组:只放DLL和对应PDB,不混合其他业务资源。远程内容要开启RemoteBuildPath和RemoteLoadPath。地址分发时,最好是URL带版本号,例如https://cdn.example.com/content/hotfix_1.0.3/hotfix_main.bundle。这样CDN层面做不到旧缓存污染,因为版本号一变URL就变了。5.2 Catalog版本不匹配的经典翻车场景Addressable远程加载依赖Catalog文件,Catalog本身由Addressable构建时生成。旧客户端如果保存了旧的Catalog缓存,而服务端撤掉了旧文件,新Catalog又不兼容,可能出现加载失败或加载了旧资源。通常解法有两条:一是启动时强制更新Catalog,二是使用Addressables.CheckForCatalogUpdates做变更检测。private async Task UpdateCatalog() { var handle = Addressables.CheckForCatalogUpdates(false); await handle.Task; if (handle.Status == AsyncOperationStatus.Succeeded && handle.Result != null && handle.Result.Count > 0) { var updateHandle = Addressables.UpdateCatalogs(handle.Result); await updateHandle.Task; } }热更流程中,Catalog更新必须发生在LoadContent之前。有的项目只把DLL放Addressable,资源图集还是旧的AssetBundle体系,那确实不用管Catalog,但既然统一用Addressable,Catalog维护就绕不开。版本号策略上,除了Addressable Catalog的版本,我自己还会在DLL里加一个自定义的头,包含热更版本号、最低客户端版本号、签名等信息。DLL加载时先校验头,再决定是否执行。这套双重版本校验主要防这样场景:某玩家停在一个很老的客户端版本上,服务端发布了新热更,但客户端没同步升级一些协议字段或第三方SDK,强行跑新热更逻辑反而有兼容性风险。有了最低客户端版本号校验,老包可以优雅提示升级,而不是白屏。5.3 校验与容错热更文件下载后,最好先做一次MD5或Hash校验再交给ILRuntime,尤其是从公网CDN下载的内容。校验逻辑放在主工程,因为它不依赖热更层。如果校验失败,要允许重试下载,同时记录日志。连续失败超过三次,我一般会弹一个带重试按钮的通用错误界面,不建议静默重试,因为玩家可能以为自己断网了。还有一点值得注意:下载DLL时不要直接覆盖本地旧的缓存文件。如果你用Addressables.LoadAssetAsync<TextAsset>,缓存内部由Addressable管理,出问题时可以把缓存清掉重新拉取。如果是自研下载器,务必采用“先写临时文件,校验通过后再原子替换”的方式,否则下到一半进程被杀,本地就残留一个损坏的DLL,下次加载直接崩溃。6. 常见问题与排查技巧6.1 运行时报错:找不到类型或方法我最近遇到过一个案例:热更DLL里用了一个新的C#语法特性,比如switch模式匹配或ref struct,编到一半就炸。ILRuntime对C#高级语法的支持是逐步更新的,官方仓库分支也不完全同步。排查方向主要分两层:第一层,编译问题。先在本地用.NET Framework编译一遍,看看引用的程序集是否完整。如果是在Unity工程里编译热更DLL,不要勾选“使用Unity自带的Mono库”,要引用独立的UnityEngine。第二层,运行时加载。用appDomain.GetType返回null,大概率是类型全名写错,或者程序集没有加载成功。可以写一段Debug代码,遍历appDomain.LoadedTypes查看已加载的全部类型。还有一个比较隐蔽的问题:热更DLL引用了主工程程序集,而主工程程序集的类型没有在AppDomain里注册。这种情况即便编译器没报错,运行时也会在类型解析阶段失败。解法是要在LoadAssembly前先appDomain.LoadAssembly主工程动态库,或者用appDomain.LoadAssembly加载主工程时确保类型可用,否则你在热更层引用的那些主工程接口会解析不到。6.2 调试与日志:看不到堆栈信息怎么办ILRuntime的Exception堆栈不完整是出了名的。你在主工程的try-catch里捕获热更层抛出的异常,默认可能只有一两行,根本定位不到具体代码行。ILRuntime提供了AppDomain的OnLogError和OnLogInfo回调,可以把解释器栈信息输出到Unity日志。另外官方还提供了ILRuntimeStackInfo和RegisterCLRMethodRedirection的调试支持,开启后能把IL层调用栈还原出来。我的个人做法是:托管层自己封装一个日志工具函数,把当前类名、方法名、行号一起打出来。由于在热更层里能拿到反射的堆栈,我把异常信息包装成统一格式,主工程用原生Debug.Log打到控制台。这套方案胜在简单稳定,上线后也能靠日志定位到大部分问题。6.3 内存泄漏:热更对象释放不掉热更层创建的对象由ILRuntime的堆管理,但如果主工程引用它,就会形成跨域对象引用。比如你向一个原生列表List<object>里Add了热更对象,然后不清理列表,这个热更对象永远不会被GC。再比如UI事件里注册了热更层的委托,界面销毁时没有反注册,也会泄漏。排查内存泄漏时,优先检查三处:静态字段、UI事件注册、资源引用。热更层全局单例如果引用了主工程的大对象,长期不释放就会内存膨胀。项目中我养成了退出模块时统一Dispose的习惯:热更层模块关闭时把所有静态引用置空,主工程侧也把所有热更层回调反注册干净。IO流这块也要小心。ILRuntime加载DLL后,MemoryStream要不要释放?官方的示例里经常用using块,但有一些老版本在Dispose掉MemoryStream之后,ILRuntime解析PDB时会抛异常。如果你用的是官方新版,LoadAssembly的PDB读取逻辑会内部持有读取器,不一定要求外层Stream保持存活。稳妥起见,加载完DLL后不要提前Dispose底层Stream,等AppDomain释放时再销毁,或者干脆不做Dispose,交给GC兜底。6.4 热更后旧本地缓存与状态残留热更DLL升级时,旧DLL里的静态变量已经存在内存中,但新版DLL的静态字段还是初始值。如果新版本逻辑依赖旧版状态的连续性,就必须自己处理。项目里常用方案是:主工程保留一个序列化数据容器,热更层通过桥接接口读写关键状态,DLL卸载重载时原始数据不丢。关于“卸载DLL”,严格来说ILRuntime没有完全卸载AppDomain的能力。热更升级时,旧AppDomain和热更对象都还会在内存里躺着,直接丢弃不动,等下次冷启动。所以线上热更生效时机我一般选择在登录回主城、切场景这种空闲点,告诉玩家“资源已更新,重启后生效”,或者干脆静默等待玩家下一次杀进程。不推荐在线热切主逻辑,风险大,收益小。7. 性能优化:让ILRuntime跑得更接近原生7.1 先量化,再优化接任何热更方案都应该有个清晰的性能底线。我这边在接入后做了一轮专项测试:分别测量原生调用、ILRuntime裸调用、ILRuntime带CLR绑定三种情况下,简单Add函数的耗时。实测结果(仅供参考,不同机型差异很大)大概是:原生调用一个函数:约几纳秒到几十纳秒。ILRuntime裸跨域调用一次:几百纳秒到几微秒,严重时超过10微秒。ILRuntime带CLR绑定普通方法:几十纳秒到几百纳秒,接近但略慢于原生。这个量级意味着,如果你在战斗模块的逐帧循环里每帧做上千次跨域调用,单帧CPU时间就容易被吃掉几毫秒。所以优化的总原则就是“减少调用次数,尽量在热更边界内解决问题”。怎么减少?实际项目里的做法通常是:把战斗里最频繁的伤害计算、Buff结算、AI决策都放到热更层内部用纯解释代码执行。主工程只负责逻辑输入,比如输入向量、碰撞事件,以及渲染侧调用。一次主工程→热更的批量数据传递,胜过十几次热更→主工程的小调用。7.2 拆装箱与集合类型优化热更代码中尽量不要反复new临时List、Dict或String。ILRuntime的GC和Mono的GC行为不一样,热更层分配过多对象会造成解释器堆碎片,GC停顿更加明显。能用数组就用数组,能复用对象就复用。值类型跨域时要避免装箱拆箱。一个典型场景就是GetComponent<T>()返回组件对象然后传给热更层,别用object接来转去。直接定义带类型参数的方法签名,让ILRuntime的CLR绑定生成对应委托,可以少很多类型转换开销。字符串拼接也是个隐藏杀手。热更层里大量使用"xxx" + var会产生巨量GC,我在项目里封了个StrBuilder工具类,底层用缓存StringBuilder复用,日志、UI文案这类高频拼接全部走它。7.3 异步与协程在热更层怎么写热更层用async/await是可行的,但ILRuntime对状态机的支持有一定代价。初次接入时踩过一回坑:热更里的异步方法遇到Task.Delay,就一直没有续体。排查下来是异步适配器没有正确注册。ILRuntime的官方示例和部分第三方库提供了AsyncBridge或者TaskExtensions,把Unity的异步操作与解释器调度连接起来。一定要在初始化阶段注册好,否则热更层的await就是个定时炸弹。如果项目阶段不想碰async的复杂度,可以用传统协程IEnumerator。ILRuntime对协程的支持是通过CoroutineAdapter实现的,它能把IEnumerator转换成Unity协程。但注意,热更层协程返回的IEnumerator是热更域对象,主工程用StartCoroutine启动时,引擎侧的适配器必须能识别。这个同样依赖注册适配器。我的最终建议是:热更层少用async/await,多用传统协程或者纯同步驱动。同步驱动配合一个自定义的帧更新循环,比协程更容易控制时间片,也不需要额外的状态机解释性能开销。7.4 数据驱动与业务拆分性能优化到后期,瓶颈往往不在ILRuntime本身,而在架构。我后来把战斗技能配置、任务配置、怪物配置全部转成配置表数据驱动,热更层按照配置解释执行,减少大量重复代码。数据驱动让热更DLL体积也降了下来,线上热更只需替换DLL本身,配置表可以走Addressable单独下发,互不干扰。从架构角度看,热更逻辑应该按模块拆分,而不是一个巨大的HotfixApp控制一切。比如战斗模块、UI模块、商城模块各自分包,后续可以只热更其中一包。ILRuntime可以在一个AppDomain里Load多个Assembly,这样不同模块可以分别编译、分别下发,但跨Assembly的类型相互引用会增加一些解析成本。目前我常用的做法还是统一为一个主DLL,按命名空间和模块文件划分,需要时再拆。8. 实测数据与上线后的压测感受8.1 跑一个简单的性能基准把方案落进工程后,我拿一台中端Android机跑过基准,大致记录如下:每秒调用热更层Update一次,跨域耗时约0.01ms。战斗单位数量从50涨到200,纯解释执行伤害结算逻辑,单帧耗时增加约1.2ms。热更层频繁读取配置表(Dictionary查找),单次约0.002ms,可接受。热更层频繁调用Debug.Log,因为CLR绑定,日志开销反而从每次0.05ms降到0.01ms。数据背后真实的启示是:低频框架调用和中等规模的业务逻辑,ILRuntime完全可以支撑;高频细粒度操作仍然是死穴,模数函数的跨域调用一多就容易被耗掉。所以项目上线前,一定有专门的性能代码走查,重点排查循环内和Update内有没有热更跨域操作。8.2 灰度与热更回滚方案线上热更不是“发出去就完事”。灰度环境我通常先让内部包把DLL更新到最新,观察Crash率和关键接口错误上报。如果问题严重,立即把DLL版本回退。Addressable的远程内容支持多版本并存,只要Catalog里保留旧版本文件,回滚就是改一下URL或Catalog指向。好的回滚策略是:服务器至少保留最近三个热更版本内容。这样即使某个版本出现恶性问题,可以迅速切回上一个稳定版本。同时客户端启动时如果检测到本地有旧DLL缓存,能做到“先校验线上最新Catalog,再决定下载哪个版本”。压测部分不必追求极限帧率,真实场景更应该关注卡顿率:每帧CPU时间超过100ms也算卡顿。我这边压测时最高规格战斗场景卡顿率控制在0.2%以内,才敢放量推全量。9. 这个方案还能怎么扩展9.1 与美术资源热更的联动既然Addressable已经承担DLL的加载,那美术资源热更完全可以走同一条管线。当前方案能做的事是:DLL变更驱动UI逻辑更新,同时Addressable的远程组里放新UI Prefab和Shader变体,两个链路同步版本。DLL里做逻辑分支判断,根据玩家客户端版本加载不同引擎资源。比如某个活动界面要上线,旧客户端逻辑里根本不存在这个界面,需要热更DLL新增界面类,同时通过Addressable拉取新界面Prefab。这种情况下,热更DLL只负责引入新的Prefab地址并加载,渲染层不依赖原生。这一套联动打通之后,运营可以直接在后台发布“新版本客户端资源包”,玩家连App Store都不用走,体验和原生一致。9.2 向多平台平滑迁移ILRuntime虽然跨平台纯C#,但IL2CPP下要做一些额外适配。比如需要同步ILRuntime的主分支代码,确保AOT裁剪后的元数据不会影响解释执行。我建议如果团队没有专人维护ILRuntime分支,就尽量锁版并保留本地patch,不要频繁跟随上游更新。每次升级地址系统和Unity主版本前,都要先跑一轮全量回归,专门盯着跨域调用和泛型部分。将来如果想换或加入HybridCLR,当前主工程与热更层通过接口隔离的设计可以平滑迁移过来:只要热更层依然实现IHotfixApp并提供入口方法,替换运行时引擎时,主工程启动代码只改初始化部分,业务层基本不动。这也是把跨域边界收口在接口上的最大红利。9.3 持续集成与自动化构建热更DLL的构建流程最好接入自动构建脚本,避免手工编译和拷贝闹出版本错乱。我在CI里做了几步:拉取最新代码,编译HotfixSolution,把DLL和PDB复制到Unity工程的某个固定目录,再触发Unity打包地址组并上传远程服务。每次构建把DLL版本号写入一个配置表,服务端和客户端都能读到。这个流程一开始有点繁琐,但磨合好了之后,发一版热更几乎是全自动,出错的几率也大幅下降。需要留意的是,CI里用的UnityLicense和构建参数要保持和正式发版一致,不然打出来的包与本地有差异。10. 最后再分享一点实战体会做热更这件事,很容易一上来就陷入“怎么把代码加载起来”的细节里,反而忽略了整体架构的约束。我踩过最大的坑,是前期没把主工程与热更层的接口边界设计清楚,导致后续每次需求迭代都在跨域调用上浪费时间,这里改一下接口签名,那里补一个委托注册,效率低到不行。如果让我重新做一次,我会先把IHotfixApp、IEnvBridge、IEventBus这几个核心接口的签名彻底GC冻结,把双端数据类型往纯C#基础类型上收,把所有引擎相关的对象都留在主工程侧。热更层只写“剧情流程、状态流转、数据加工、UI组合”,这种分离一到位,后面所有性能问题和热更兼容问题都会成片减少。平时看一些新项目组接ILRuntime,总喜欢把Unity的MonoBehaviour直接放进热更层,结果被生命周期、序列化、编辑器行为这类问题缠住。其实只要记住一条铁律:热更层不做引擎对象的具体持有,只通过主工程桥接层访问引擎能力,就能躲开九成的坑。Addressable只是帮你把DLL和资源安安全全送到客户端,真正决定方案上限的,永远是你的分层架构和边界管理。希望这篇笔记能帮你少走点弯路。
做Unity客户端这些年,只要是做手游的,基本绕不开热更新这个话题。资源热更还好说,AssetBundle或者Addressable都能解决,真正麻烦的是代码热更。以前用Lua,好是好,但团队要养Lua程序员,C#的代码也没法直接复用。后来项目组切到了ILRuntime这套方案,配合Addressable做资源加载,总算把代码热更这件事落地了。这篇笔记就专门聊聊怎么用ILRuntime配合Addressable在Unity里实现代码热更,把方案设计、主工程和热更工程怎么拆、跨域交互怎么做、常见的坑怎么踩,一次讲清楚。1. 为什么选ILRuntime + Addressable这套组合1.1 先搞明白你要解决什么问题手游发出去之后,玩家手机上装着的是旧版本客户端。如果线上出现了严重BUG,或者要加新玩法、调数值、改UI逻辑,总不能每次都让玩家重新下载几百兆的安装包。资源热更可以解决美术资源、配置表、UI Prefab的更新,但C#逻辑本身被编译进了Assembly-CSharp,Unity的Assembly-CSharp在移动平台上不支持运行时动态加载新的程序集,这就逼着你要么换语言(Lua),要么用解释器方案(ILRuntime),要么用AOT+补充元数据方案(HybridCLR)。ILRuntime是一个纯C#实现的.NET运行时解释器。它做的事情很底层——读取托管DLL里的IL指令,然后在Unity的Mono或者IL2CPP环境下逐条解释执行。也就是说,你把游戏逻辑写在一个独立的DLL工程里,发版前把这个DLL转成字节数组或者AssetBundle打出去,玩家的客户端启动时通过网络下载到新的DLL,然后动态加载、解释执行。出BUG了就改热更工程里的C#代码,重新编译DLL,下发一个几十KB的文件,玩家下次冷启动或者切场景时就能跑新逻辑。选ILRuntime而不选Lua,主要原因是团队全是C#程序员,不想为了热更单独整一套Lua栈。ILRuntime让业务层可以主要用C#写,既保留了静态语言的类型检查、IDE补全、重构能力,又拿到了热更能力。选Addressable而不直接裸用AssetBundle,是因为热更DLL只是资源体系里的一个小文件,Addressable带来的寻址管理、依赖处理、异步加载API、远程内容分发能力,能直接把DLL加载这件事纳入统一管线,不用自己手写一个文件下载器。1.2 同赛道方案优缺点对比现在Unity圈子提到代码热更,主要就是三派:Lua、ILRuntime(包括其他C#解释器)、HybridCLR。结合我自己在项目里的实际感受,三者的优劣挺明显的。方案上手难度性能代码复用度适配IL2CPPLua + xLua/sLua中,需要团队会Lua解释执行,跨域调用开销大低,C#逻辑无法复用好ILRuntime低,C#直接写业务解释执行,比原生C#慢一个量级但优化空间大高,大部分C#代码直接搬需要同步ILRuntime主分支HybridCLR中,需要理解AOT泛型原理接近原生性能极高,几乎无感开发需要处理AOT泛型补充Lua的问题不在于性能,而在于工程化。写Lua的人少、IDE弱、类型弱、写多了代码非常乱,而且和C#层的交互永远绕不开一层bridge。HybridCLR性能虽好,但需要你对IL2CPP的AOT机制有比较深的认识,遇到泛型坑的时候排查心智负担重。ILRuntime的性能确实比原生慢,但实际上游戏逻辑大量是I/O、UI刷新、网络包处理,这些本来就是瓶颈在别处的操作,纯计算密集的部分做做优化,做一个性能打包机放到原生层,整体是压得住场面的。Addressable在这套方案里扮演的角色相对纯粹——它是DLL文件的搬运工。原本用AssetBundle还需要自己做依赖分组、加载卸载、版本比对,Addressable把这些流程抽象成了AddressableAssetSettings、AssetGroup和异步Handle。更重要的是,Addressable天然支持Remote Catalog和Remote Content,也就是远程构建产物。这点正好和热更DLL的下载链路无缝结合:只要把DLL文件标成Addressable资源并丢到远程Hosting,客户端就可以用同一套API拉取DLL和拉取UI资源,没必要为DLL单独造一套下载器。2. 准备工作:ILRuntime接入Addressable工程2.1 从零搭一个热更DLL的独立工程这一步很多人忽略,但它决定了你后续能省多少麻烦。ILRuntime的推荐做法是:热更代码不要放在Unity主工程里,而是单独建一个C#工程,输出成一个独立的DLL。原因很现实——Unity主工程里UnityEngine程序集和ProjectAssembly是绑定在一起的,如果你直接在Unity工程里写脚本再热更,加载出来的DLL会和主工程里的Assembly-Sharp冲突,ILRuntime官方也明确不推荐直接热更Unity主工程代码。我搭建时的目录结构大概是这样的:HotfixSolution/ ├── HotfixCore/ // 热更DLL工程,引用UnityEngine.dll ├── HotfixMain/ // 热更入口,继承主工程定义的接口 └── UnityClient/ // 主工程,引用ILRuntime程序集HotfixCore和HotfixMain都以.dll输出,目标框架建议选.NET Standard 2.0或者.NET Framework 3.5 / 4.6,具体看Unity版本和ILRuntime分支版本要求。要说明的是,Unity引擎本身没有安装在代码工程里,所以需要从Unity安装目录下的Editor/Data/Managed/UnityEngine/引用对应的UnityEngine DLL。选择哪份UnityEngine引用,要看你的目标管线和ILRuntime是否支持,通常用Unity自带的那份即可。写热更代码时还有个习惯问题——不是所有Unity API都能在热更层直接用。ILRuntime对UnityEngine程序集的解释是运行时进行的,部分类型方法涉及到NotSupportedException,比如某些反射操作、某些泛型类型。遇到这种地方,标准做法是把这些不支持的能力下沉到主工程,通过接口暴露给热更层。这个我后面会专门用一节来讲。2.2 Addressable的装箱与分组策略Addressable侧要做的准备其实很简单:把编译出来的热更DLL(通常是一个TextAsset文件)拖进一个专门的AssetGroup,命名为HotfixAssets。DLL文件在Unity工程里作为TextAsset存在,打AssetBundle时不会做二进制处理,加载出来就是byte[],正好能给ILRuntime用。有几个细节值得注意。第一,Addressable的BuildPath和LoadPath决定最终输出位置。调试阶段可以把LoadPath设为Local,直接把DLL打进包里,方便断点调试。正式发布阶段必须切到Remote,配合CDN或者自己的服务器做内容分发。第二,每个热更DLL文件的Addressable Address要固定好,比如就叫hotfix_main.dll,不要在代码里写死路径,要用Addressable的Address去加载,这样换平台、换路径都不用改代码。第三,Addressable的Catalog本身会存在版本一致性问题上——如果旧客户端拿着旧Catalog去加载新Address,DLL版本不一致会导致加载失败,所以版本管理一定要做好。这里放一个Addressable异步加载DLL的最小代码模式:using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; public static class HotfixLoader { public static async void LoadAndExecute(string dllAddr, string pdbAddr) { var dllHandle = Addressables.LoadAssetAsync<TextAsset>(dllAddr); var pdbHandle = Addressables.LoadAssetAsync<TextAsset>(pdbAddr); await dllHandle.Task; await pdbHandle.Task; byte[] dllBytes = dllHandle.Result.bytes; byte[] pdbBytes = pdbHandle.Result != null ? pdbHandle.Result.bytes : null; // 交给ILRuntime初始化 var appDomain = new ILRuntime.Runtime.Enviorment.AppDomain(); using (var ms = new MemoryStream(dllBytes)) using (var pd = pdbBytes != null ? new MemoryStream(pdbBytes) : null) { appDomain.LoadAssembly(ms, pd, new ILRuntime.Mono.Cecil.Pdb.PdbReaderProvider()); } // 执行热更入口 appDomain.Invoke("HotfixMain.Entry", "Start", null, null); } }这段代码在实际项目中通常还会挂在启动界面做协程或异步流程处理,因为Addressable加载远程资源之前可能还要检查并下载Content。整体来说,Addressable这一层的代码量其实很少,核心复杂度还是集中在ILRuntime的初始化与跨域通信上。3. 核心接入:代码结构与入口约定3.1 主工程与热更工程的边界划分很多第一次接触ILRuntime的开发者都会问一个问题:“热更代码和主工程代码该怎么分层?”答案很关键——分层和边界决定了后续开发和调试的体验,越早确定越好。我在项目中约定一套规矩:主工程只做平台相关、性能敏感、或者ILRuntime不支持的底层操作,比如文件读写、网络库、SDK接入、渲染后处理、复杂计算。业务逻辑,包括UI流程、战斗逻辑、数据管理、消息处理,全部放进热更工程。热更与主工程的通信,不直接通过反射裸调,而是通过一组预定义接口和委托来做。具体来说,主工程先声明一个IHotfixApp接口:// 主工程 public interface IHotfixApp { void Start(); void Update(float deltaTime); void LateUpdate(float deltaTime); void OnApplicationPause(bool pause); void OnApplicationQuit(); void Destroy(); }热更工程里写一个类实现这个接口,并跨越域以接口实例的方式被主工程调用。ILRuntime的CLR绑定和跨域适配器会把热更类实例转换成可传递的IHotfixApp接口对象。这样主工程完全不用感知热更细节,触发生命周期时只调用接口方法。热更层代码长这样:// 热更工程 public class HotfixApp : IHotfixApp { public void Start() { // 初始化热更内部管理、加载配置、进入登录界面 } public void Update(float deltaTime) { // 驱动业务逻辑轮询 } }用反射方式加载DLL并创建实例也异常简单:var appType = appDomain.GetType("HotfixEntry.HotfixApp"); var appInstance = appDomain.Instantiate<IHotfixApp>(appType); appInstance.Start();Instantiate<T>其实是ILRuntime提供的CrossBindingAdaptor能力,在注册了适配器后,可以直接把热更对象向上转型为主工程接口。这里的效率比用Invoke反射高很多,因为跨域调用走的是直接的方法调用约定,不是基于字符串查找。3.2 初始化与委托注册,踩过的性能大坑用ILRuntime最怕的就是跨域调用。先说结论:如果只注册了接口适配器,但没有做委托绑定优化,一个简单方法调用的成本可能跑到微秒级别,大量频繁调用时性能会很难看。ILRuntime每次跨域调用,来自于解释器栈和CLR栈的相互切换,再加上参数解析、类型转换,开销远超原生调用。为了压这部分耗时,我坚持做两件事。第一件事是只把高频调用收窄到最少入口。比如游戏循环里,主工程只在Update里调一次HotfixApp.Update,热更层内部再分发到各业务模块。这种“单入口+热更内聚”的设计把跨域次数控制到了最低。第二件事是注册CLR绑定(ILRuntime的RegisterCLRMethodRedirection)。每当热更代码调用了某个主工程高频C#方法,例如Debug.Log、List<T>.Add、DateTime.Now、String.Format,ILRuntime默认是按反射方式走的,非常慢。使用CLR绑定把这些方法映射到预先写好的委托或函数指针,调用速度几乎可以追平原生。下面是注册委托的常见模板,我在启动阶段统一执行:appDomain.RegisterCLRMethodRedirection(GetMethod("Debug.Log"), MyLogRedirect); // 或者针对具体类型: appDomain.DelegateManager.RegisterDelegateDelegate<System.Action<float>>(); appDomain.DelegateManager.RegisterDelegateDelegate<System.Action<string>>();注意注册的位置:RegisterCLRMethodRedirection必须在LoadAssembly之后、任何热更代码调用该方法之前完成。如果你在热更逻辑已经开始运行之后才注册,然后热更代码又触发了一次之前未绑定的调用,ILRuntime会第二次走反射路径,性能就会突然劣化。所以肯定的准入原则是:启动时将你用到的所有高频方法尽可能一次配齐。3.3 入口约定:主工程启动时到底该做什么启动时序设计不好,Debug时容易一头雾水。我现在的项目大致是这样排的:启动画面(Splash)里先显示版本检查逻辑。调用Addressable初始化与远程Catalog更新,必要时下载新DLL。加载DLL到MemoryStream。初始化AppDomain,设置绑定,注册适配器。Instantiate热更入口,执行Start。主循环的Update里驱动热更入口的Update。这里有个引子顺序的细节:先加载DLL或先初始化AppDomain其实有讲究。ILRuntime的AppDomain先创建再LoadAssembly,注册绑定可以在LoadAssembly前后都做,但必须在Invoke之前。我的习惯是:创建AppDomain → 初始化基础类型绑定 + 委托注册 → LoadAssembly → 注册接口适配器和热更侧的类型重定向。原因是LoadAssembly本身不需要调用绑定,但AppDomain在加载完DLL后立刻会做类型校验,此时某些类型实例化操作如果依赖绑定映射,也可能触发反射遍历。代码结构上,我会把启动流程封成一个服务类:public class HotfixBootstrap : MonoBehaviour { private ILRuntime.Runtime.Enviorment.AppDomain _appDomain; private IHotfixApp _app; private async void Start() { await EnsureHotfixDllReady(); // 远程Catalog检查等 LoadDomainAndExecute(); } private void Update() => _app?.Update(Time.deltaTime); private void LateUpdate() => _app?.LateUpdate(Time.deltaTime); }3.4 热更入口是否需要传参有些项目需要把服务器地址、用户ID这些信息传给热更层。最直接的做法是入口方法带上参数:appDomain.Invoke("HotfixMain.Entry", "Start", null, new object[] { serverUrl, userId });这种方式可用,但参数一多就难维护。更好的方案是主工程实现一个IEnvBridge接口,通过适配器注册进热更层,热更代码启动时从桥接接口取环境配置。这样热更层看不到主工程的私有结构,未来加参数也只改接口定义,不破坏热更DLL的ABI兼容性。所谓ABI兼容性,就是说热更DLL被替换时要保证接口签名不变。如果新DLL里改了IHotfixApp接口的方法签名,但主工程还是旧的,那加载运行时就会抛异常。所以版本管理时要习惯把热更公共接口单独抽到一个低层程序集,且约定接口签名冻结,主工程和热更工程各自引用同一份接口定义。4. 深入跨域交互:接口适配器与委托绑定4.1 为什么热更层需要适配器才能继承Unity组件Unity的脚本继承自MonoBehaviour,而MonoBehaviour由Unity引擎管理生命周期。ILRuntime解释执行的热更类不能直接作为new Component()或AddComponent出来的组件挂在GameObject上,因为引擎的脚本回调是按原生类型识别的。标准做法是主工程定义一个适配器MonoBehaviour,内部持有热更逻辑对象,由适配器转发事件。public class HotfixMonoBridge : MonoBehaviour { private object _hotfixObject; public void Bind(object hotfixObject) { _hotfixObject = hotfixObject; } private void Update() { if (_hotfixObject != null) _appDomain.Invoke(_hotfixObject, "OnUpdate", null, null); } }这种方式有三个好处:热更层可以写纯逻辑类,不需要继承UnityEngine的任何类;主工程侧的MonoBehaviour生命周期完全受Unity引擎管理,不会被热更层干扰;后续如果要迁移到其他热更方案,主工程桥接层不用大刀阔斧改。再说继承链的问题。如果热更层想实现接口IFoo,但主工程想通过接口直接调用,必须实现一个CrossBindingAdaptor并注册到AppDomain。这个适配器通常用一个主工程侧的类继承热更侧接口,并桥接转发。好在ILRuntime官方提供了一个CrossBindingAdaptor和AdaptorHelper模板,按照模板生成即可,工作量不大。4.2 委托绑定:跨域回调的三个具体写法跨域回调是指在主工程执行某异步操作,完成后把结果回传给热更层。例如网络请求、加载资源完成回调。如果回调用的委托没有预先注册,ILRuntime支持运行时的委托绑定,但每次绑定都有成本,而且不注册有时会直接报错。我的原则是:一切回调委托尽力提前注册。常见写法有三种。第一种是直接注册DelegateManager:appDomain.DelegateManager.RegisterDelegateDelegate<Action<byte[], string>>();第二种是对应到具体委托类型,使用RegisterFunctionDelegate。比如热更层定义了一个Func<int, int>,主工程想调用这个函数指针并等待返回:appDomain.DelegateManager.RegisterFunctionDelegate<Func<int, int>>();第三种是针对UnityEngine定义的那些带UnityAction的接口,比如Button.onClick.AddListener。这个在ILRuntime热更层写UI交互时极其常用。务必在初始化时对每个用到的UnityAction变体做委托注册:appDomain.DelegateManager.RegisterDelegateDelegate<UnityEngine.Events.UnityAction>(); appDomain.DelegateManager.RegisterDelegateDelegate<UnityEngine.Events.UnityAction<bool>>();如果不注册,你可能在点击按钮时碰到“没有注册委托”或“无法创建委托”的异常。这类坑排查起来还挺难,因为异常信息不一定发生在AddListener的那一行,往往是点下按钮那一刻才炸。4.3 泛型与值类型跨域泛型跨域在ILRuntime里属于高风险区。比如你在热更层写List<SomeStruct>.Sort(),如果SomeStruct是主工程定义的结构体,ILRuntime对泛型实例化走的路径非常绕,经常触发MissingMethodException或性能雪崩。我的习惯是:热更层永远不自定义和使用复杂泛型结构跨域,涉及到跨域的集合可以统一换成主工程定义的ArrayList包装或者用接口封装成操作。如果一定要传值类型,注意不要频繁装箱。ILRuntime对值类型跨域时会复制,但如果你在主工程侧把int装箱成object再传给热更层,再做拆箱,一次调用就多几倍耗时。性能敏感处全部走原生类型参数,把复杂数据结构封装成主工程管理的句柄ID,比如int handleID,传给热更层操作。5. Addressable加载细节与版本管理5.1 热更DLL放Addressable到底怎么放最合适我见过两种常见做法。第一种是直接把DLL作为TextAsset放在Remote组。第二种是先打成AssetBundle,再把AB文件让Addressable管理。这两种的差别主要在体积和加载管道。直接把DLL当TextAsset,Addressable内部还是会生成AssetBundle,加载时通过TextAsset读取字节,效率尚可,代码最简单。而把DLL打成AssetBundle再塞进Addressable,多了一层手动处理,体积不会明显变小,但缓存管理要自己操心。所以绝大多数项目我推荐第一种,少一个环节就少一种出错的可能。Addressable组的设计按我的标准分为三类:启动组:包含启动场景、基础UI、公共Shader等。业务组:按玩法模块拆分,比如登录组、战斗组、商城组。热更组:只放DLL和对应PDB,不混合其他业务资源。远程内容要开启RemoteBuildPath和RemoteLoadPath。地址分发时,最好是URL带版本号,例如https://cdn.example.com/content/hotfix_1.0.3/hotfix_main.bundle。这样CDN层面做不到旧缓存污染,因为版本号一变URL就变了。5.2 Catalog版本不匹配的经典翻车场景Addressable远程加载依赖Catalog文件,Catalog本身由Addressable构建时生成。旧客户端如果保存了旧的Catalog缓存,而服务端撤掉了旧文件,新Catalog又不兼容,可能出现加载失败或加载了旧资源。通常解法有两条:一是启动时强制更新Catalog,二是使用Addressables.CheckForCatalogUpdates做变更检测。private async Task UpdateCatalog() { var handle = Addressables.CheckForCatalogUpdates(false); await handle.Task; if (handle.Status == AsyncOperationStatus.Succeeded && handle.Result != null && handle.Result.Count > 0) { var updateHandle = Addressables.UpdateCatalogs(handle.Result); await updateHandle.Task; } }热更流程中,Catalog更新必须发生在LoadContent之前。有的项目只把DLL放Addressable,资源图集还是旧的AssetBundle体系,那确实不用管Catalog,但既然统一用Addressable,Catalog维护就绕不开。版本号策略上,除了Addressable Catalog的版本,我自己还会在DLL里加一个自定义的头,包含热更版本号、最低客户端版本号、签名等信息。DLL加载时先校验头,再决定是否执行。这套双重版本校验主要防这样场景:某玩家停在一个很老的客户端版本上,服务端发布了新热更,但客户端没同步升级一些协议字段或第三方SDK,强行跑新热更逻辑反而有兼容性风险。有了最低客户端版本号校验,老包可以优雅提示升级,而不是白屏。5.3 校验与容错热更文件下载后,最好先做一次MD5或Hash校验再交给ILRuntime,尤其是从公网CDN下载的内容。校验逻辑放在主工程,因为它不依赖热更层。如果校验失败,要允许重试下载,同时记录日志。连续失败超过三次,我一般会弹一个带重试按钮的通用错误界面,不建议静默重试,因为玩家可能以为自己断网了。还有一点值得注意:下载DLL时不要直接覆盖本地旧的缓存文件。如果你用Addressables.LoadAssetAsync<TextAsset>,缓存内部由Addressable管理,出问题时可以把缓存清掉重新拉取。如果是自研下载器,务必采用“先写临时文件,校验通过后再原子替换”的方式,否则下到一半进程被杀,本地就残留一个损坏的DLL,下次加载直接崩溃。6. 常见问题与排查技巧6.1 运行时报错:找不到类型或方法我最近遇到过一个案例:热更DLL里用了一个新的C#语法特性,比如switch模式匹配或ref struct,编到一半就炸。ILRuntime对C#高级语法的支持是逐步更新的,官方仓库分支也不完全同步。排查方向主要分两层:第一层,编译问题。先在本地用.NET Framework编译一遍,看看引用的程序集是否完整。如果是在Unity工程里编译热更DLL,不要勾选“使用Unity自带的Mono库”,要引用独立的UnityEngine。第二层,运行时加载。用appDomain.GetType返回null,大概率是类型全名写错,或者程序集没有加载成功。可以写一段Debug代码,遍历appDomain.LoadedTypes查看已加载的全部类型。还有一个比较隐蔽的问题:热更DLL引用了主工程程序集,而主工程程序集的类型没有在AppDomain里注册。这种情况即便编译器没报错,运行时也会在类型解析阶段失败。解法是要在LoadAssembly前先appDomain.LoadAssembly主工程动态库,或者用appDomain.LoadAssembly加载主工程时确保类型可用,否则你在热更层引用的那些主工程接口会解析不到。6.2 调试与日志:看不到堆栈信息怎么办ILRuntime的Exception堆栈不完整是出了名的。你在主工程的try-catch里捕获热更层抛出的异常,默认可能只有一两行,根本定位不到具体代码行。ILRuntime提供了AppDomain的OnLogError和OnLogInfo回调,可以把解释器栈信息输出到Unity日志。另外官方还提供了ILRuntimeStackInfo和RegisterCLRMethodRedirection的调试支持,开启后能把IL层调用栈还原出来。我的个人做法是:托管层自己封装一个日志工具函数,把当前类名、方法名、行号一起打出来。由于在热更层里能拿到反射的堆栈,我把异常信息包装成统一格式,主工程用原生Debug.Log打到控制台。这套方案胜在简单稳定,上线后也能靠日志定位到大部分问题。6.3 内存泄漏:热更对象释放不掉热更层创建的对象由ILRuntime的堆管理,但如果主工程引用它,就会形成跨域对象引用。比如你向一个原生列表List<object>里Add了热更对象,然后不清理列表,这个热更对象永远不会被GC。再比如UI事件里注册了热更层的委托,界面销毁时没有反注册,也会泄漏。排查内存泄漏时,优先检查三处:静态字段、UI事件注册、资源引用。热更层全局单例如果引用了主工程的大对象,长期不释放就会内存膨胀。项目中我养成了退出模块时统一Dispose的习惯:热更层模块关闭时把所有静态引用置空,主工程侧也把所有热更层回调反注册干净。IO流这块也要小心。ILRuntime加载DLL后,MemoryStream要不要释放?官方的示例里经常用using块,但有一些老版本在Dispose掉MemoryStream之后,ILRuntime解析PDB时会抛异常。如果你用的是官方新版,LoadAssembly的PDB读取逻辑会内部持有读取器,不一定要求外层Stream保持存活。稳妥起见,加载完DLL后不要提前Dispose底层Stream,等AppDomain释放时再销毁,或者干脆不做Dispose,交给GC兜底。6.4 热更后旧本地缓存与状态残留热更DLL升级时,旧DLL里的静态变量已经存在内存中,但新版DLL的静态字段还是初始值。如果新版本逻辑依赖旧版状态的连续性,就必须自己处理。项目里常用方案是:主工程保留一个序列化数据容器,热更层通过桥接接口读写关键状态,DLL卸载重载时原始数据不丢。关于“卸载DLL”,严格来说ILRuntime没有完全卸载AppDomain的能力。热更升级时,旧AppDomain和热更对象都还会在内存里躺着,直接丢弃不动,等下次冷启动。所以线上热更生效时机我一般选择在登录回主城、切场景这种空闲点,告诉玩家“资源已更新,重启后生效”,或者干脆静默等待玩家下一次杀进程。不推荐在线热切主逻辑,风险大,收益小。7. 性能优化:让ILRuntime跑得更接近原生7.1 先量化,再优化接任何热更方案都应该有个清晰的性能底线。我这边在接入后做了一轮专项测试:分别测量原生调用、ILRuntime裸调用、ILRuntime带CLR绑定三种情况下,简单Add函数的耗时。实测结果(仅供参考,不同机型差异很大)大概是:原生调用一个函数:约几纳秒到几十纳秒。ILRuntime裸跨域调用一次:几百纳秒到几微秒,严重时超过10微秒。ILRuntime带CLR绑定普通方法:几十纳秒到几百纳秒,接近但略慢于原生。这个量级意味着,如果你在战斗模块的逐帧循环里每帧做上千次跨域调用,单帧CPU时间就容易被吃掉几毫秒。所以优化的总原则就是“减少调用次数,尽量在热更边界内解决问题”。怎么减少?实际项目里的做法通常是:把战斗里最频繁的伤害计算、Buff结算、AI决策都放到热更层内部用纯解释代码执行。主工程只负责逻辑输入,比如输入向量、碰撞事件,以及渲染侧调用。一次主工程→热更的批量数据传递,胜过十几次热更→主工程的小调用。7.2 拆装箱与集合类型优化热更代码中尽量不要反复new临时List、Dict或String。ILRuntime的GC和Mono的GC行为不一样,热更层分配过多对象会造成解释器堆碎片,GC停顿更加明显。能用数组就用数组,能复用对象就复用。值类型跨域时要避免装箱拆箱。一个典型场景就是GetComponent<T>()返回组件对象然后传给热更层,别用object接来转去。直接定义带类型参数的方法签名,让ILRuntime的CLR绑定生成对应委托,可以少很多类型转换开销。字符串拼接也是个隐藏杀手。热更层里大量使用"xxx" + var会产生巨量GC,我在项目里封了个StrBuilder工具类,底层用缓存StringBuilder复用,日志、UI文案这类高频拼接全部走它。7.3 异步与协程在热更层怎么写热更层用async/await是可行的,但ILRuntime对状态机的支持有一定代价。初次接入时踩过一回坑:热更里的异步方法遇到Task.Delay,就一直没有续体。排查下来是异步适配器没有正确注册。ILRuntime的官方示例和部分第三方库提供了AsyncBridge或者TaskExtensions,把Unity的异步操作与解释器调度连接起来。一定要在初始化阶段注册好,否则热更层的await就是个定时炸弹。如果项目阶段不想碰async的复杂度,可以用传统协程IEnumerator。ILRuntime对协程的支持是通过CoroutineAdapter实现的,它能把IEnumerator转换成Unity协程。但注意,热更层协程返回的IEnumerator是热更域对象,主工程用StartCoroutine启动时,引擎侧的适配器必须能识别。这个同样依赖注册适配器。我的最终建议是:热更层少用async/await,多用传统协程或者纯同步驱动。同步驱动配合一个自定义的帧更新循环,比协程更容易控制时间片,也不需要额外的状态机解释性能开销。7.4 数据驱动与业务拆分性能优化到后期,瓶颈往往不在ILRuntime本身,而在架构。我后来把战斗技能配置、任务配置、怪物配置全部转成配置表数据驱动,热更层按照配置解释执行,减少大量重复代码。数据驱动让热更DLL体积也降了下来,线上热更只需替换DLL本身,配置表可以走Addressable单独下发,互不干扰。从架构角度看,热更逻辑应该按模块拆分,而不是一个巨大的HotfixApp控制一切。比如战斗模块、UI模块、商城模块各自分包,后续可以只热更其中一包。ILRuntime可以在一个AppDomain里Load多个Assembly,这样不同模块可以分别编译、分别下发,但跨Assembly的类型相互引用会增加一些解析成本。目前我常用的做法还是统一为一个主DLL,按命名空间和模块文件划分,需要时再拆。8. 实测数据与上线后的压测感受8.1 跑一个简单的性能基准把方案落进工程后,我拿一台中端Android机跑过基准,大致记录如下:每秒调用热更层Update一次,跨域耗时约0.01ms。战斗单位数量从50涨到200,纯解释执行伤害结算逻辑,单帧耗时增加约1.2ms。热更层频繁读取配置表(Dictionary查找),单次约0.002ms,可接受。热更层频繁调用Debug.Log,因为CLR绑定,日志开销反而从每次0.05ms降到0.01ms。数据背后真实的启示是:低频框架调用和中等规模的业务逻辑,ILRuntime完全可以支撑;高频细粒度操作仍然是死穴,模数函数的跨域调用一多就容易被耗掉。所以项目上线前,一定有专门的性能代码走查,重点排查循环内和Update内有没有热更跨域操作。8.2 灰度与热更回滚方案线上热更不是“发出去就完事”。灰度环境我通常先让内部包把DLL更新到最新,观察Crash率和关键接口错误上报。如果问题严重,立即把DLL版本回退。Addressable的远程内容支持多版本并存,只要Catalog里保留旧版本文件,回滚就是改一下URL或Catalog指向。好的回滚策略是:服务器至少保留最近三个热更版本内容。这样即使某个版本出现恶性问题,可以迅速切回上一个稳定版本。同时客户端启动时如果检测到本地有旧DLL缓存,能做到“先校验线上最新Catalog,再决定下载哪个版本”。压测部分不必追求极限帧率,真实场景更应该关注卡顿率:每帧CPU时间超过100ms也算卡顿。我这边压测时最高规格战斗场景卡顿率控制在0.2%以内,才敢放量推全量。9. 这个方案还能怎么扩展9.1 与美术资源热更的联动既然Addressable已经承担DLL的加载,那美术资源热更完全可以走同一条管线。当前方案能做的事是:DLL变更驱动UI逻辑更新,同时Addressable的远程组里放新UI Prefab和Shader变体,两个链路同步版本。DLL里做逻辑分支判断,根据玩家客户端版本加载不同引擎资源。比如某个活动界面要上线,旧客户端逻辑里根本不存在这个界面,需要热更DLL新增界面类,同时通过Addressable拉取新界面Prefab。这种情况下,热更DLL只负责引入新的Prefab地址并加载,渲染层不依赖原生。这一套联动打通之后,运营可以直接在后台发布“新版本客户端资源包”,玩家连App Store都不用走,体验和原生一致。9.2 向多平台平滑迁移ILRuntime虽然跨平台纯C#,但IL2CPP下要做一些额外适配。比如需要同步ILRuntime的主分支代码,确保AOT裁剪后的元数据不会影响解释执行。我建议如果团队没有专人维护ILRuntime分支,就尽量锁版并保留本地patch,不要频繁跟随上游更新。每次升级地址系统和Unity主版本前,都要先跑一轮全量回归,专门盯着跨域调用和泛型部分。将来如果想换或加入HybridCLR,当前主工程与热更层通过接口隔离的设计可以平滑迁移过来:只要热更层依然实现IHotfixApp并提供入口方法,替换运行时引擎时,主工程启动代码只改初始化部分,业务层基本不动。这也是把跨域边界收口在接口上的最大红利。9.3 持续集成与自动化构建热更DLL的构建流程最好接入自动构建脚本,避免手工编译和拷贝闹出版本错乱。我在CI里做了几步:拉取最新代码,编译HotfixSolution,把DLL和PDB复制到Unity工程的某个固定目录,再触发Unity打包地址组并上传远程服务。每次构建把DLL版本号写入一个配置表,服务端和客户端都能读到。这个流程一开始有点繁琐,但磨合好了之后,发一版热更几乎是全自动,出错的几率也大幅下降。需要留意的是,CI里用的UnityLicense和构建参数要保持和正式发版一致,不然打出来的包与本地有差异。10. 最后再分享一点实战体会做热更这件事,很容易一上来就陷入“怎么把代码加载起来”的细节里,反而忽略了整体架构的约束。我踩过最大的坑,是前期没把主工程与热更层的接口边界设计清楚,导致后续每次需求迭代都在跨域调用上浪费时间,这里改一下接口签名,那里补一个委托注册,效率低到不行。如果让我重新做一次,我会先把IHotfixApp、IEnvBridge、IEventBus这几个核心接口的签名彻底GC冻结,把双端数据类型往纯C#基础类型上收,把所有引擎相关的对象都留在主工程侧。热更层只写“剧情流程、状态流转、数据加工、UI组合”,这种分离一到位,后面所有性能问题和热更兼容问题都会成片减少。平时看一些新项目组接ILRuntime,总喜欢把Unity的MonoBehaviour直接放进热更层,结果被生命周期、序列化、编辑器行为这类问题缠住。其实只要记住一条铁律:热更层不做引擎对象的具体持有,只通过主工程桥接层访问引擎能力,就能躲开九成的坑。Addressable只是帮你把DLL和资源安安全全送到客户端,真正决定方案上限的,永远是你的分层架构和边界管理。希望这篇笔记能帮你少走点弯路。