ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity热更新实录:HybridCLR接入 C#代码热更的选型与实践

Unity热更新实录:HybridCLR接入 C#代码热更的选型与实践 1. 为什么最终选了 HybridCLR而不是 Lua 或 ILRuntime做 Unity 项目的同学应该都有体会在国内做手游热更新几乎不是“可选项”而是“必选项”。原因很简单应用商店审核周期摆在那里线上出了个崩溃、卡死、数值配错的问题如果每次都要走重新发版玩家早就跑光了。以前团队用的是 Lua xLua 那一套方案也就是把核心玩法逻辑全塞进 Lua 脚本C# 只做薄薄的一层壳。刚开始觉得挺好后来维护成本越来越高Lua 代码没有类型检查写起来一时爽重构火葬场跨语言调用的性能损耗在 UI 密集的界面里特别扎眼更难受的是想用一些库还得先看它有没有 Lua 绑定没有就又得自己封装。后来接触到了 HybridCLR一条完全不同的路子——它不要求你把逻辑改成 Lua 或别的脚本语言而是直接让 C# 代码能进热更程序集。也就是说你平时怎么写 C# 逻辑热更时就怎么写几乎不用改代码风格和架构。这对团队来说是个巨大的心智解放。项目里那些和 UI、战斗、任务、背包相关的模块不用再分层成“Lua 写业务、C# 写底层”两套体系了统一用 C# 维护写单测也方便很多。当然也不是说 HybridCLR 就吊打所有方案。我更愿意把它看作一种“用更接近原生 C# 的方式做热更”的路线。Lua 方案胜在生态成熟、网上资料多ILRuntime 也是纯 C# 的解释器方案但它走的是反射和解释执行性能损耗和兼容性瑕疵比 HybridCLR 多一些。HybridCLR 的核心思路和它们都不同它不是让 C# 代码跑在解释器里而是让热更程序集的 IL 代码在 IL2CPP 的 AOT 基础上“打补丁”一部分函数直接本地执行一部分采用高效的寄存器解释器执行。实际用下来最直观的感受是逻辑代码迁移成本低不用重写语言Debug 的时候看得懂堆栈性能损耗也小一个量级。所以如果你正在纠结选型我的建议是如果团队熟 Lua、项目也已经是 Lua 架构、美术策划习惯了配 Lua 表那没必要强行换但如果是新项目或者老项目正打算做一轮架构梳理那 HybridCLR 非常值得考虑。它带来的核心收益是“一套代码走天下”开发效率、可调试性都要好不少。这一节最后说下适合的项目类型。MMO、卡牌、休闲、RPG 这类“线上内容更新频繁、玩法 bug 修复必须及时”的项目都是很典型的应用场景。单机游戏如果不需要发版后更新内容那接热更的意义就不大。另外如果你的项目用到的是 WebGL、微信小游戏这种平台热更方案还要额外考虑文件系统和平台 API 的限制会更麻烦一些。下面所讲的经验我默认是基于 Android/iOS 这些原生移动平台来展开的这也是 HybridCLR 最主流的战场。2. 接入前先把这两个概念彻底搞明白热更新接入过程中团队内部最容易出现争论的就是两个基础概念没对齐一个是“AOT”一个是“补充元数据”。我这里用自己的话讲讲因为如果这两个概念理解不到位后面配置工程、排查报错会一头雾水。2.1 AOT 和 JIT为什么 iOS 这么难办要理解 HybridCLR 出现的意义得先知道 Unity 在移动平台上是怎么把 C# 变成机器码的。Android 和 iOS 对动态代码的限制不同iOS 因为系统安全策略禁止应用在运行时动态生成可执行代码。所以 Unity 在 iOS 上只能使用 IL2CPP 模式先把 C# 编译成 IL再把 IL 转换成 C 代码最后由 Xcode 编译成 ARM 机器码。这套流程里没有“运行时解释执行”的步骤一切都是提前静态编译好的所以叫 AOT。问题也跟着来了如果线上代码有 Bug你改了一行 C#这行代码要变成 iOS 机器码就得重新提交 App Store 审核。等审核、等用户更新一等就是好几天甚至一两周。HybridCLR 做的事情是把“代码”转换成“数据”在应用启动后从服务器下载一段热更 DLL其实是一段元数据加 IL 字节码然后在本地把这个 DLL 里的函数注册到一个“解释器”里运行时按需解释执行。iOS 不允许你生成真正的机器码但它不禁止解释执行——这就像看一份菜谱照做而不是先起炉灶再生火逻辑上完全合规。2.2 主程序集和热更程序集的边界接入之前要规划好哪些程序集放在包里AOT 主程序集哪些程序集拆出来做热更。放在包里的程序集包括 UnityEngine 模块、项目的 Assembly-CSharp以及各种 SDK 封装做热更的一般建议至少把 GameLogic、UI、战斗数值、任务系统这些业务逻辑全放进去。主程序集会随着 App 发版更新热更程序集则通过服务器下载。值得强调的是AOT 并不是“完全不能更新”而是更新成本高、周期长。HybridCLR 本质上是把“发版”这个低频动作和“改代码”这个高频动作解耦。你发版时内置一个稳定的 AOT 程序集外壳平时的小修复、新玩法、配置更新全走热更 DLL。2.3 HybridCLR 的元数据技术让热更代码认识 AOT 类型HybridCLR 和早期热更方案相比有一个关键创新就是“补充元数据”。这是什么意思呢我用一个大白话例子来解释假设你 App 内置的程序集认识 A 类但热更代码里用了 B 类而 B 类是 AOT 程序集里的类型——如果解释器不认识 B 的元数据一调就崩。于是 HybridCLR 在打包时会把 AOT 程序集的“metadata”也传到服务器上热更启动时把这份元数据加载进来。相当于你运行时给解释器塞了一本“类型字典”让它知道 B 类长什么样、有什么方法、方法签名是什么。这个步骤是 HybridCLR 接入过程中必须做的一般通过编辑器菜单里的“Generate/All”和“Generate/LinkXml”来生成补充元数据文件运行时再调用HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly进行加载。很多新手第一次接忘了拷贝元数据文件或者加载顺序不对就会出现TypeLoadException或MissingMethodException后面我会专门讲排查方法。3. 手把手从零到一接入 HybridCLR我按自己的实战流程来写每一步都标注了易错点。如果你已经接过了可以重点看后面的坑点部分如果是从零开始的新手建议按顺序一步步走。3.1 环境要求Unity 版本与安装包选择我用的环境是 Unity 2021.3.16f1这个版本是长期支持版社区验证较多。如果你用的是 2022 或 Unity 6整体流程也一致但要注意 HybridCLR 是针对具体的 Unity 版本发版的不能只看“最新版”就无脑下。HybridCLR 的安装有两种方式。推荐直接用 Unity Package Manager 添加 git 地址https://github.com/focus-creative-games/hybridclr_unity.git或者从 Releases 页面下载对应的 .tgz 包手动安装。装完之后菜单栏会出现HybridCLR里面有各种命令入口。注意如果你是从老项目升级先备份工程再接因为安装过程会修改Packages/manifest.json和一些构建设置。3.2 设置 Player Settings这几项绝对不能漏打开 Player Settings重点核对下面几项Scripting Backend 必须是IL2CPP。HybridCLR 只支持 IL2CPP不支持 Mono这是硬性要求。Architecture 勾选 ARM64iOS 上不用管这个Xcode 会处理Android 必须勾。建议开启Strip Engine Code。如果不开启就打不出 complement 元数据需要的裁剪信息后续会遇到“类型信息被裁剪掉”的奇怪错误。Managed Stripping Level建议先保持在Low等流程通了再调更高层级否则裁剪级别太高可能把需要的类型剪掉。我第一次整合时就是忘了把 Scripting Backend 从 Mono 切到 IL2CPP结果跑到补充元数据加载时直接抛异常。这个检查做在前面能省很多排查时间。3.3 一键安装和编译理解背后发生了什么菜单栏选择HybridCLR/Installer它会帮你完成下图里这些步骤下载对应 Unity 版本的 il2cpp 补丁包对本地 Unity 安装目录里的 il2cpp 程序做改造安装com.code-philosophy.hybridclr运行时库。有人会问“是不是一定要把本地 Unity 的 il2cpp 改掉这会影响我其他项目吗”是的这一步必须做。HybridCLR 会在 il2cpp 的 AOT 编译器里注入一些解释器相关的元数据注册代码本质上是在 Unity 官方代码之外“加了一层薄补丁”。它会改动你 Unity 安装目录下的文件但只影响使用 IL2CPP 打包的工程。实测下来同一台机器的其他项目不会受影响。如果你不想改动本机全局的 Unity可以考虑用多个 Unity 版本比如一个专门给热更项目用另一个留作普通的 IL2CPP 打包。安装完看下Assets/HybridCLR目录下有没有自动生成的link.xml、hybridclr.json等文件。生成过程中如果遇到代理问题或下载超时挂代理可能解决但不建议长时间纠结在这里——某些公司内网会屏蔽 GitHub你可以手动把补丁包放到指定目录再执行安装。3.4 配置热更程序集从 Assembly-CSharp 里拆出来HybridCLR 要求你把业务代码放到独立的程序集里比如GameLogic.dll、GameHot.dll不能用默认的 Assembly-CSharp 直接做热更。我见过有人图省事想把 Assembly-CSharp 整个做成热更结果配置复杂、坑多。更合理的做法是新建几个 Assembly Definition.asmdef主工程只留入口、SDK 对接和启动逻辑。具体操作在 Assets 下建一个Scripts/HotUpdate目录放业务逻辑代码在该目录右键 Create - Assembly Definition命名为GameHot在 GameHot.asmdef 的 Inspector 里把 Auto Referenced 勾掉必要时指定对主程序集的引用把原来的业务脚本拖进去注意命名空间冲突。之后打开HybridCLR/Settings在Hot Update Assemblies列表里加上GameHot。这一步是告诉构建工具打包时除了生成 Assembly-CSharp还要把你指定的程序集作为热更 DLL 输出。如果没有配置后面动态加载时你会发现根本找不到 GameHot.dll。这里有个容易踩的坑热更程序集里不要直接 Define 那些“只在主工程有”的宏比如UNITY_EDITOR之外的开发宏也不要引用 StreamingAssets 里的第三方 DLL除非这些 DLL 本身也做了 AOT 或热更规划。程序集间的依赖关系要尽量是单向的主程序集可以引用热更程序集但热更程序集不要反向引用主程序集里那些“不存在的类型”否则运行时会报FileNotFound或TypeLoadException。3.5 构建热更 DLL一条命令解决在正式 Build 之前先在编辑器里跑一遍HybridCLR/Generate/All。它会生成桥接函数、AOT 泛型实例化代码、以及补充元数据所需的 C 代码。然后在 Console 窗口确认没有报错再执行HybridCLR/Build/BuildAssets或者直接打包主工程得到的热更程序集一般输出在BuildOutput/平台/Assemblies目录下。实际上Generate/All做了很多事包括Generate/Bridge、Generate/AOTGenericReference、Generate/LinkXml、Generate/ReversePInvoke等。除非你已经很懂内部细节否则不要单独跳过其中某一步。如果你改了 AOT 泛型相关代码或新增了接口务必重新跑Generate/All不然构建产物里会缺新方法的桥接。3.6 上传与加载流程客户端代码顺序决定生死DLL 构建出来之后要上传到你的资源服务器。客户端启动时的加载顺序我建议这样排先初始化本地文件系统检查版本号、下载资源包加载热更程序集的 DLL 字节数组可以用 UnityWebRequest 下载也可以从 StreamingAssets 或持久化目录读取先加载补充元数据调用HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly再用Assembly.Load(byte[])加载热更程序集最后通过反射找到入口类比如GameApp.Entry调用Start()。这里有个很关键的细节补充元数据加载一定要放在 Assembly.Load 之前。因为热更程序集的 IL 里可能直接引用了 AOT 程序集的类型元数据如果等热更程序集加载完再加载元数据解释器已经来不及解析了。Unity 主线程上同步调用这套流程没问题但建议包一层 try-catch 并把异常输出到日志文件线上定位问题时会非常有用。伪代码大概长这样var dllBytes await DownloadFromServer(GameHot.dll); var aotMetaBytes await DownloadFromServer(AOTMetadata.bytes); foreach (var dll in aotMetaList) { HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly(dll.bytes, HomologousImageMode.SuperSet); } var assm Assembly.Load(dllBytes); var entryType assm.GetType(GameApp.Entry); var entryMethod entryType.GetMethod(Start); entryMethod.Invoke(null, null);HomologousImageMode.SuperSet这个枚举最常用表示“用补充元数据来补全 AOT 程序集缺失的元数据”。如果用了ConsistentImageMode要求元数据要和原始构建时完全一致更容易出兼容问题建议新手直接使用 SuperSet。4. 热更工程里的程序集与泛型约束必须遵守的军规很多人在接入初期能跑通 Demo但一上真实业务就崩八成是泛型、反射、AOT 这三大类问题没处理好。4.1 泛型实例化为什么 List 会偶尔翻车IL2CPP 在打包时会对泛型做“提前实例化”也就是说如果某个泛型类在你的主工程里没有被用到对应类型参数的版本等到热更代码想用ListMyHotClass时AOT 环境里可能没有这个泛型类型的本地代码实现。这时 HybridCLR 解释器可以尝试用解释器模式兜底但部分情况还是需要你手动声明泛型引用让它能够在构建期就生成对应实现。HybridCLR 官方文档里有一个“AOT 泛型实例化”的说明。简单来说你可以在一个主程序集的类里写一些“永远不被调用”的静态方法用来“诱导”IL2CPP 生成需要的泛型变体。比如public static class AotLinkHelper { // 这个方法永远不会执行只是让IL2CPP提前生成泛型实例 public static void Link() { _ new ListMyHotClass(); _ new Dictionaryint, MyHotClass(); _ new JsonUtility.FromJsonMyHotClass(); } }这种方法看起来很土但非常有效。如果你的热更代码里用到了JsonUtility、ListT、DictionaryK,V而 T/K/V 是你自定义的热更类型我强烈建议在建完热更代码后先列出所有泛型使用点然后在主工程里补一个“AOT Link Helper”类。不然在真机上偶现ExecutionEngineException: Attempting to call method X for which no ahead of time (AOT) code was generated排查起来特别折磨。4.2 桥接函数和 ReversePInvokeHybridCLR 需要为一些方法生成“桥接函数”这个操作在Generate/All里会自动完成。桥接函数的作用是让解释器代码能顺畅调用 AOT 代码里的方法以及让 AOT 代码回调到热更代码里。如果你在热更代码里给主工程传了个委托主工程某个事件触发了这个委托而这个委托的调用路径上没有桥接就容易崩。我碰到的实际案例是这样的热更 UI 代码里给一个原生 SDK 的回调传了一个 C# 委托当 SDK 回调触发时直接提示无法找到对应实现。后来排查发现需要把委托签名定义在主工程程序集并且让Generate/All重新生成一次桥接代码问题就消失了。所以接入期间要记住每次新增方法、委托或修改了跨 AOT/热更接口的签名最好都重新执行一次Generate/All再重新打包。不重新生成桥接函数索引可能错位真机上会出现一些匪夷所思的错误。4.3 反射使用要克制因为 HybridCLR 解释器对 System.Reflection 的支持不是 100% 完整特别是和泛型、属性、私有字段强相关的反射机制有可能在个别平台上表现不一致。我并不是说不能用反射而是建议你反射尽量只用在“入口加载”这种低频场景高频热更业务里使用接口或抽象类替代反射调用如果一定反射先写好异常日志并在主流 Android 设备上多测几轮。5. 构建产物与资源热更的整合实践热更 DLL 只是“代码热更”的一部分实际商业项目里资源、配置、UI 图集、表格数据也都要热更。HybridCLR 本身不关心资源系统怎么做它只管代码。但部署架构上你必须考虑“代码更新”和“资源更新”的先后顺序以及版本兼容问题。5.1 版本号设计代码版本要单独管理建议引入一个“热更代码版本号”不要和资源版本号混在一起。简单做法Version类记录gameVersionApp 发版版本、codeVersionDLL 版本、resVersion资源版本。每次代码更新时把codeVersion加 1资源更新时单独加resVersion。服务器启动时返回一份版本配置客户端比对后决定是只下资源还是代码和资源都要下。为什么强调“分开管理”因为一次线上事故往往只涉及一份代码修改比如修崩溃但美术那边可能同时上传了一些新立绘资源。如果你用同一个版本号玩家就得重新下载全部资源体验很差。分开后可以做到“修代码只拉 DLL”流量消耗小更新速度快弱网环境也更友好。5.2 增量更新还是整包更新团队内建议用全量资源热更的方案很多比如打包 AssetBundle 后做差量或干脆每次全量下载。在代码热更这件事上DLL 文件本身很小几 MB 而已没必要做增量。直接把整个GameHot.dll和AOTMetadata.bytes作为两个文件下发就行省去做 hash 对比的麻烦。我见过一些团队试图把 DLL 拆成多个模块做增量最后项目复杂度暴涨收益却不大。除非你的热更 DLL 超过 20 MB否则整包里附带一份全量 DLL 是最省事、最稳定的。5.3 资源和代码的一致性有一个经典难题新代码需要新资源新资源也需要新代码去加载。如果服务器还没更新全或者客户端下载顺序不对就会出现“新 DLL 引用了不存在的 AssetBundle”或者“旧 DLL 打不开新资源格式”。我的做法是在服务器侧做“原子发布”先把资源传上去过几分钟再把新代码版本号暴露给客户端。或者在代码里做个简单兼容校验启动时比较资源的minCodeVersion与当前codeVersion如果不满足就不加载对应模块弹提示“资源更新中请稍后再试”。对中小团队来说这个校验逻辑能避免掉很多奇奇怪怪的线上问题。6. 真机调试中的常见报错与排查实录这部分我记录的是接入过程中最常见的四类报错以及我个人的排查思路。遇到报错不要慌先看是不是设置和加载顺序的问题再考虑代码层面的 AOT 约束。6.1 MissingMethodException / TypeLoadException八成和补充元数据有关如果运行时报MissingMethodException: Method not found: System.Void SomeClass::SomeMethod()优先怀疑三种情况补充元数据没有加载或者加载顺序不对你的热更程序集引用了一个主工程程序集里不存在的方法注意主工程可能已经由新包体替换但热更 DLL 还是老版本调用路径变了裁剪太狠把方法裁掉了。处理办法打开日志确认LoadMetadataForAOTAssembly是否在Assembly.Load之前执行成功如果异常发生在某个具体模块临时将 Managed Stripping Level 调为 Low再重新构建对比。关键排查清单 - LoadMetadataForAOTAssembly 是否返回成功 - 是否在加载热更 DLL 之前调用 - 异常方法所在的类型是 AOT 类型还是热更类型 - 如果 AOT检查是否开启了 Strip Engine Code6.2 ExecutionEngineExceptionAOT 泛型实例缺失的典型特征这类报错信息一般类似ExecutionEngineException: Attempting to call method System.Collections.Generic.Dictionary2...::.ctor for which no ahead of time (AOT) code was generated。解决办法分两步在项目中搜索这个泛型的调用点找到是在热更代码里被实例化的在主工程里添加 AOT Link Helper 静态方法把类型参数传给泛型。这个报错在编辑器里几乎不会出现因为编辑器走 Mono所以必须上真机测试。我建议从接入第一天起就定期打 Android 包进行冒烟测试不要只依赖 Editor 模式。6.3 加载 DLL 时提示 文件找不到路径或权限问题热更 DLL 如果放在 Application.persistentDataPath 下Android 上要注意目录是否已经创建好。有些机型对 IO 权限敏感在下载后立刻读取可能因为文件还没有 flush 导致 0 字节文件。稳妥方式是下载完成后加一个文件长度校验再尝试Assembly.Load(File.ReadAllBytes(path))。iOS 上则要注意不要直接使用 UnityWebRequest 下载到 Application.streamingAssetsPath那部分是只读的。6.4 遇到莫名崩溃先关闭裁剪和增量构建有段时间我的项目一加载热更就崩溃查了很久最后定位到是增量构建时旧的桥接代码和新的热更程序集索引错位了。解决办法是执行HybridCLR/Clear然后 Clean 掉Library/Bee目录再重新 Generate 和 Build。如果你改了代码后没有执行 Generate/All也容易出现类似情况。如果是真机崩溃但不报 C# 异常可以用 Xcode 或 Android logcat 抓取 native 堆栈。HybridCLR 官方文档里也提供了崩溃堆栈解析工具但最简单的做法还是先 Clean 后重新构建很多时候能直接解决所谓“灵异”问题。7. 性能监控与优化解释器模式下的函数调用有什么代价HybridCLR 的解释执行相比 AOT 本地机器码确实有一定性能损耗。但损耗远小于 Lua 那类方案也小于完全解释执行的 ILRuntime。根据官方给的数据解释器模式下纯算术运算大约是 AOT 的 1/30 到 1/2 不等但实际业务代码大部分时间花在 Unity 引擎调用和资源加载上这块不受影响。我自己的经验是像 UI 逻辑、玩法逻辑、简单的数据计算跑在解释器里完全感觉不到卡顿但如果热更代码写了特别重的循环比如 10 万一帧的寻路计算或物理运算那最好把它放到主工程 AOT 程序集里或者做分帧。有一个习惯值得养成业务逻辑里高频调用的“小而热”的函数尽量保持简单不要写很大的 switch 或层层嵌套的虚函数调用。解释器模式下这些函数间的跳转会产生额外开销。很多团队把战斗核心中真正吃性能的部分留在 AOT热更代码只做表现和玩法编排就是基于这个考虑。另外要关注的是内存。DLL 加载后Assembly对象和其中的元数据会常驻内存不用的热更程序集很难卸载。这带来一个实际问题如果你的项目期待“用热更彻底替换旧代码”那么旧代码占的内存不会立刻释放。在实际操作中我们通常将“核心基础模块”留在 AOT热更模块只做上层应用逻辑这样即便频繁更新内存增长的幅度也可控。8. 写在实际接入之后的几点心得项目跑通之后我再回头看整个接入过程最消耗时间的并不是做功能或写代码而是“对热更边界、AOT 约束、加载时序的理解”。如果团队里每个人都清楚哪段代码必须放 AOT、哪段代码可以放热更、哪些 API 调用会有坑后期的协作会顺畅很多。这套体系里我觉得最值得养成的好习惯有三个第一所有热更代码的改动都先跑编辑器的 Generate/All 再加包第二每次构建版本时自动保存一份“构建信息”文件包含 Unity 版本、HybridCLR 版本、DLL 的 MD5 值、git commit hash这样线上出问题能迅速定位是哪一次构建产出的包第三热更流程上要做灰度小规模设备先更新观察崩溃率和关键埋点再逐步全量放开。另外想说一点HybridCLR 发展很快版本更新频繁遇到问题优先看官方文档和 GitHub Release Notes再搜社区帖子别卡在一个已知问题上太久。不同 Unity 小版本之间il2cpp 的目录结构可能都有变化如果你是冷门版本第一次安装失败很正常多是补丁路径不匹配不要怀疑人生。热更新接入本身不难难的是把它做出“可以长期维护、支撑项目迭代”的形态。希望你接入顺利少踩我踩过的那些坑。
返回列表