Unity热更新:动态加载C#程序集的原理与工程实践
1. 为什么“动态读取C#程序集”不是热更新的银弹,而是必须亲手打磨的精密齿轮
在某跨平台系统开发中,我第一次被要求实现“不重启客户端就能更新逻辑”的能力。当时团队里流传着一种说法:“Unity支持Assembly.LoadFrom,只要把新DLL扔进StreamingAssets,运行时加载就行。”听起来干净利落——可等真正跑通第一个版本后,我盯着控制台里连续刷屏的TypeLoadException和MissingMethodException,才意识到:这根本不是调用一个API就能解决的问题,而是一整套需要对Unity底层加载机制、C#类型系统、IL代码结构、资源生命周期都了然于心的精密工程。
“动态读取C#程序集实现热更新”,这个标题里藏着三个极易被忽略的关键词:动态、读取、实现。它不是指“能加载”,而是指“能安全、可控、可维护地加载并执行”。Unity的默认行为是将所有脚本编译进主程序集(通常是Assembly-CSharp.dll),运行时所有类型都由该程序集提供。你试图在运行时加载一个外部DLL,本质上是在挑战Unity的类型绑定规则、反射缓存策略、序列化系统,甚至Mono运行时的JIT行为。这不是功能开关,而是系统级干预。
我试过最朴素的路径:把修改后的.cs文件单独编译成Hotfix.dll,用Assembly.LoadFrom("StreamingAssets/Hotfix.dll")加载,再通过Type.GetType("MyNamespace.MyClass")获取类型,最后Activator.CreateInstance()实例化。结果第一行就崩了——Type.GetType返回null。原因很简单:Type.GetType默认只在当前程序集和mscorlib中查找类型,它根本不知道你刚加载的那个DLL里有什么。你得先拿到那个Assembly对象,再调用它的GetType()方法。但这就引出了第二个坑:类型命名空间冲突。如果新DLL里有个类叫PlayerController,而主程序集里也有同名类,Unity会直接报错,因为CLR不允许两个程序集定义完全相同的全限定名类型。
更隐蔽的是序列化问题。Unity的Inspector面板、Prefab保存、ScriptableObject数据持久化,全部依赖于类型在编译期就确定的元数据。你动态加载的类型,Unity编辑器根本没见过,所以无法在Inspector里显示字段,也无法保存到Prefab中。这意味着,热更新只能作用于纯逻辑层(比如AI决策、战斗计算),而不能动UI组件、动画状态机或任何被Unity序列化的字段。这不是限制,而是设计边界——越清楚这点,越能避开90%的无效尝试。
所以,当你说“动态读取C#程序集”,你真正要解决的,从来不是“怎么加载一个DLL”,而是:如何让这个DLL里的类型,能被主程序集安全引用?如何保证方法签名变更不会导致崩溃?如何让热更代码能访问到Unity引擎API(比如UnityEngine.Debug.Log)?如何在热更后还能调试、断点、查变量?这些问题的答案,不在Unity手册里,而在你亲手写的每一行加载、反射、桥接、代理的代码中。
2. 程序集加载的三重门:从LoadFrom到AssemblyLoadContext的演进与取舍
Unity对程序集加载的支持,并非一成不变。它经历了从Mono时代到IL2CPP、从.NET Framework兼容层到现代.NET Standard的漫长演进。理解这背后的技术栈变迁,是避免踩坑的第一步。我们不能只看API表面,而要看它在Unity不同构建目标(Editor、Windows Standalone、iOS、Android)下的实际行为差异。
2.1 LoadFrom与LoadFile:看似一样,实则天壤之别
早期Unity项目(尤其是基于Mono的旧版本)中,开发者最常用的是Assembly.LoadFrom(path)。它会从指定路径加载程序集,并将其放入默认的AssemblyLoadContext中。这个操作看似简单,但它埋下了两个致命隐患:
路径锁定:
LoadFrom会将程序集文件路径记录在内部缓存中。如果你后续想用同一个程序集的不同版本覆盖更新,LoadFrom会直接返回已缓存的旧版本,而不是重新加载新文件。我曾在一个模拟项目X中遇到过这个问题:热更包下载完成后,调用LoadFrom却始终加载的是上一版的DLL,因为Unity认为“路径没变,内容应该也没变”。依赖解析失败:如果
Hotfix.dll依赖另一个Utils.dll,LoadFrom不会自动去加载Utils.dll。它只会加载你明确指定的那个文件。结果就是TypeLoadException: Could not load type 'Utils.Helper' from assembly 'Hotfix, Version=1.0.0.0...'。你必须手动确保所有依赖项都在Application.dataPath或Application.streamingAssetsPath下,并且按顺序依次LoadFrom,稍有疏忽,整个链就断了。
相比之下,Assembly.LoadFile(path)看起来更“干净”——它不记录路径,每次调用都强制重新读取文件。但它引入了更棘手的问题:类型隔离。LoadFile加载的程序集,其类型与主程序集中的同名类型被视为完全不同的类型。即使它们的代码一字不差,instance is MyClass也会返回false,List<MyClass>无法接收LoadFile加载的MyClass实例。这直接废掉了所有基于类型判断的逻辑,比如事件系统、状态机切换、组件查找。
提示:在Unity 2019.4及之后的版本中,
LoadFrom和LoadFile在IL2CPP构建下已被标记为不推荐(Obsolete),因为它们与AOT编译模型存在根本性冲突。强行使用可能导致iOS打包失败或运行时崩溃。
2.2 AssemblyLoadContext:.NET Core时代的解耦钥匙
真正的转机出现在Unity 2020.3+全面拥抱.NET Standard 2.1之后。Unity开始支持System.Runtime.Loader.AssemblyLoadContext(ALC),这是.NET Core引入的、用于隔离程序集加载上下文的机制。它允许你创建一个独立的“沙箱”,在这个沙箱里加载的程序集,其类型与主程序集完全隔离,互不影响。这才是热更新所需的可控加载环境。
我重构模拟项目X时,核心改动就是引入自定义ALC:
public class HotfixLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; public HotfixLoadContext(string mainAssemblyPath) : base(isCollectible: true) { _resolver = new AssemblyDependencyResolver(mainAssemblyPath); } protected override Assembly Load(AssemblyName assemblyName) { string assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); if (assemblyPath != null) { return LoadFromAssemblyPath(assemblyPath); } return null; } protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) { string libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); if (libraryPath != null) { return LoadUnmanagedDllFromPath(libraryPath); } return IntPtr.Zero; } }关键点在于isCollectible: true。这表示这个ALC可以被GC回收,意味着当你卸载热更模块时,可以调用hotfixContext.Unload(),连带释放所有由它加载的程序集内存。这是LoadFrom永远做不到的——它的程序集一旦加载,就永远驻留在默认上下文中,直到应用退出。
但ALC不是万能药。它带来了新的复杂度:跨上下文类型传递。ALC A里加载的MyClass,无法直接赋值给ALC B(主程序集)里的MyClass变量。解决方案是引入桥接层(Bridge Layer):在主程序集中定义一套抽象接口(如IHotfixService),所有热更逻辑必须实现这些接口;热更DLL通过反射创建实现类的实例,然后将其包装成主程序集能识别的接口对象。这本质上是一种“面向接口编程”的强制实践,反而提升了架构的清晰度。
2.3 Unity Editor与Runtime的加载差异:一次配置,两套逻辑
最常被忽视的,是Editor和Runtime环境的巨大差异。在Unity Editor中,你可能用Assembly.LoadFrom加载一个Debug版DLL,一切正常;但打包成Android APK后,同样的代码会抛出FileNotFoundException。原因在于路径处理:
- Editor中,
Application.streamingAssetsPath指向的是项目目录下的Assets/StreamingAssets文件夹,是可读写的。 - Android上,
Application.streamingAssetsPath指向的是APK内部的assets/目录,这是一个只读的归档路径,你无法直接LoadFrom一个APK内的文件。
正确的做法是:在Android/iOS上,必须先将热更DLL从StreamingAssets复制到Application.persistentDataPath(一个可读写的真实文件路径),再从那里LoadFrom或创建ALC。这个复制过程本身就有坑:File.Copy在Android上可能因权限或路径格式失败,必须用WWW或UnityWebRequest异步读取字节流,再用File.WriteAllBytes写入。
我为此专门封装了一个HotfixLoader工具类,它会根据Application.platform自动选择加载策略:
- Editor/Windows/Mac:直接
LoadFromStreamingAssets路径; - Android/iOS:先检查
persistentDataPath下是否存在对应版本的DLL,若不存在,则从StreamingAssets异步复制,成功后再加载; - WebGL:禁用热更新(因浏览器沙箱限制,无法读取本地文件系统)。
这个看似简单的分支判断,背后是数十次真机测试、日志分析和路径调试换来的经验。没有“一次写完,到处运行”的热更新,只有“一次配置,多端适配”的务实方案。
3. 类型桥接与方法调用:如何让主程序集“认识”热更代码里的每一个类
解决了“加载”问题,下一个拦路虎是“通信”。主程序集(Assembly-CSharp)和热更程序集(Hotfix)是两个独立的类型世界。你不能直接new Hotfix.PlayerAI(),也不能直接调用hotfixInstance.DoAttack()。必须建立一套稳定、高效、低侵入的桥接机制。我尝试过三种主流方案,最终在模拟项目X中选择了第三种,并对其做了深度定制。
3.1 反射调用:最原始,也最容易失控
最直白的方式是全程反射:主程序集通过hotfixAssembly.GetType("Hotfix.PlayerAI")获取类型,再用type.GetConstructor(Type.EmptyTypes).Invoke(null)创建实例,最后用type.GetMethod("DoAttack").Invoke(instance, null)执行方法。
优点是零侵入,热更DLL里不需要任何特殊代码。缺点是性能极差——每次调用都要走一遍GetMethod和Invoke,在高频逻辑(如每帧Update)中,CPU开销会飙升。更重要的是,它完全放弃了编译期类型检查。如果热更DLL里把DoAttack改名为ExecuteAttack,主程序集的调用代码在编译时完全不会报错,只有运行到那一帧才会崩溃。这种“延迟失败”模式,在大型项目中是灾难性的。
我曾在一个战斗系统热更中用过这种方式,上线后发现Boss战卡顿严重。用Unity Profiler一查,MethodInfo.Invoke占了单帧CPU时间的40%。立刻弃用。
3.2 接口代理:类型安全的折中方案
第二条路是定义公共接口。在主程序集中声明:
public interface IPlayerBehavior { void OnStart(); void OnUpdate(float deltaTime); void OnDamage(float amount); }热更DLL中的类必须实现这个接口:
// Hotfix.dll public class PlayerAI : IPlayerBehavior { public void OnStart() { /* ... */ } public void OnUpdate(float dt) { /* ... */ } public void OnDamage(float amt) { /* ... */ } }主程序集加载DLL后,用反射创建实例,再强制转换为接口:
var type = hotfixAssembly.GetType("Hotfix.PlayerAI"); var instance = Activator.CreateInstance(type) as IPlayerBehavior; // 现在就可以安全调用 instance.OnUpdate(Time.deltaTime);这解决了类型安全问题:如果PlayerAI没有实现IPlayerBehavior,as操作会返回null,你可以立刻捕获并报错。性能也比纯反射好很多,因为接口调用是虚方法表查找,远快于Invoke。
但它带来了新的维护成本:每次新增一个热更功能,就要在主程序集里定义新接口,还要确保热更DLL里严格实现。当热更模块越来越多时,主程序集的接口定义会像野草一样疯长,变成一个难以维护的“接口沼泽”。而且,它无法处理需要返回复杂自定义类型的场景——比如IPlayerBehavior.GetTarget()返回一个Hotfix.TargetInfo,主程序集依然不认识这个类型。
3.3 委托桥接 + 泛型工厂:兼顾安全、性能与灵活性的终极方案
我在模拟项目X中最终落地的,是一个融合了委托、泛型和工厂模式的桥接系统。核心思想是:不传递类型,只传递行为。
第一步,在主程序集中定义一组高度抽象的委托:
// 主程序集定义(稳定不变) public delegate void VoidAction(); public delegate T Func<T>(); public delegate void Action<T>(T arg); public delegate TResult Func<T, TResult>(T arg); // ... 更多常用委托第二步,热更DLL不直接暴露类,而是暴露一个“注册函数”,将具体行为绑定到这些委托上:
// Hotfix.dll public static class HotfixEntry { public static void Register() { // 将热更逻辑注册为委托 HotfixBridge.RegisterAction("Player.OnStart", () => { /* 实际逻辑 */ }); HotfixBridge.RegisterFunc<float, bool>("Player.CanAttack", dt => { return dt > 0.5f; }); HotfixBridge.RegisterAction<float>("Player.ApplyDamage", amount => { /* ... */ }); } }HotfixBridge是一个主程序集提供的静态类,它内部维护一个Dictionary<string, Delegate>,存储所有注册的委托。热更DLL通过Register函数,只是把一段逻辑“挂”到了一个字符串键上。
第三步,主程序集在需要的地方,通过键名获取并调用委托:
// 主程序集,PlayerController.cs void Update() { // 安全调用,无需知道Hotfix.PlayerAI的存在 var canAttack = HotfixBridge.GetFunc<float, bool>("Player.CanAttack"); if (canAttack != null && canAttack(Time.deltaTime)) { // 执行攻击 HotfixBridge.GetAction<float>("Player.ApplyDamage")(10f); } }这个方案的优势是颠覆性的:
- 零类型依赖:主程序集完全不知道
Hotfix.PlayerAI,它只认字符串键和预定义的委托类型。热更DLL可以随意重构内部类名、命名空间,只要键名和委托签名不变,就绝对安全。 - 极致性能:委托调用几乎等同于直接方法调用,比反射快两个数量级。
GetFunc只是字典查找,O(1)复杂度。 - 强类型安全:
GetFunc<float, bool>的返回类型是编译期确定的。如果热更DLL注册了一个Func<int, bool>到"Player.CanAttack"键上,GetFunc<float, bool>会直接返回null,你可以在调用前做空检查,错误定位极其清晰。 - 易于调试:所有热更逻辑都通过统一入口
HotfixBridge,你可以在RegisterAction和GetAction处打全局断点,监控每一次热更行为的注册与调用。
我为这个桥接系统写了完整的单元测试,覆盖了100%的委托签名组合。它现在是模拟项目X热更新模块的基石,支撑着超过50个热更功能点,从未因桥接问题导致线上崩溃。
4. 热更包的构建、校验与灰度发布:从代码到用户手机的完整闭环
热更新的价值,不在于技术能否实现,而在于它能否安全、可靠、可控地抵达每一位用户。一个完美的热更技术方案,如果缺乏配套的发布流程,就像一把没有保险栓的枪。我在某高校实验室参与的一个教育类App项目中,亲眼见过因热更包发布失误,导致30%用户进入无限闪退循环的事故。那次教训让我彻底明白:热更的后半段,比前半段更关键。
4.1 构建流程:从.cs到.hotfix的自动化流水线
热更包不是手动编译出来的。它必须是一条可重复、可验证、可追溯的CI/CD流水线。我们的标准流程如下:
源码隔离:所有热更逻辑必须放在一个独立的Unity子项目(
HotfixModule)中。这个子项目有自己的Assets/Scripts/Hotfix/目录,绝不允许引用任何Assets/Plugins/下的第三方DLL(防止依赖污染)。它只能引用Unity引擎API(UnityEngine.*)和主程序集公开的接口/委托定义。编译配置:使用Unity命令行(
-batchmode -executeMethod BuildHotfix.Build)触发构建。BuildHotfix是一个静态方法,它会:- 创建一个临时的
csc.rsp响应文件,指定/target:library /out:Hotfix.dll /reference:UnityEngine.dll /reference:Assembly-CSharp.dll; - 调用系统
csc.exe(或dotnet build)编译所有HotfixModule/Assets/Scripts/Hotfix/**/*.cs; - 生成
Hotfix.dll后,用ILMerge(或Costura.Fody)将所有HotfixModule/Assets/Plugins/下的私有DLL合并进Hotfix.dll,确保单文件交付。
- 创建一个临时的
版本与哈希:构建脚本会自动生成一个
hotfix.manifest文件,内容为JSON:{ "version": "1.2.3", "buildTime": "2023-10-15T14:22:33Z", "md5": "a1b2c3d4e5f67890...", "dependencies": ["UnityEngine.CoreModule.dll"] }md5是对Hotfix.dll文件内容计算的,确保文件完整性。version遵循语义化版本(SemVer),主版本号(1.x.x)变更意味着不兼容的API改动,客户端必须强制全量更新。
这个流程的关键在于自动化。任何手动拷贝、手动修改版本号、手动计算MD5的行为,都是未来事故的种子。我们用Git Hooks在pre-commit阶段检查HotfixModule目录是否有未提交的修改,确保每次构建都基于纯净代码。
4.2 客户端校验:三道防线,拒绝一个坏包
客户端收到热更包后,绝不能盲目加载。我们设置了三道硬性校验:
第一道:文件完整性(MD5)
下载完成后,立即计算Hotfix.dll的MD5,与hotfix.manifest中的md5字段比对。不一致?直接丢弃,记录日志[HOTFIX] MD5 mismatch, expected a1b2..., got c3d4...。这是防网络传输错误、磁盘损坏的第一道屏障。第二道:版本兼容性(SemVer)
解析manifest.version,与客户端当前运行的热更版本比较。如果新版本是2.0.0,而客户端当前是1.9.9,且2.0.0的主版本号(2)大于当前(1),则判定为不兼容升级。此时,客户端不会加载,而是弹出提示:“检测到重大更新,需要重启应用以应用新功能”,并引导用户到应用商店更新主包。这是防API断裂的核心防线。第三道:运行时沙箱测试(Sandbox Probe)
在正式加载Hotfix.dll前,先创建一个临时的AssemblyLoadContext,仅加载Hotfix.dll,然后调用一个预设的Probe()方法(该方法在所有热更DLL中都必须存在,且只做最基础的初始化,如Debug.Log("Probe OK"))。如果Probe()抛出任何异常,或耗时超过500ms,即视为沙箱测试失败,整个热更包被标记为“不可用”,回滚到上一版。这道防线能捕获90%的运行时兼容性问题,比如IL2CPP下不支持的语法、iOS AOT限制触发的NotSupportedException。
这三道防线,每一道都对应一个具体的、可复现的日志标识。当用户反馈“热更后闪退”时,我们只需索要日志,看是哪一行[HOTFIX]开头的错误,就能在1分钟内定位到根因。
4.3 灰度发布与回滚:用数据驱动每一次上线
再完美的测试,也无法100%模拟真实用户环境。因此,我们从不一次性全量推送热更包。我们的灰度发布策略是分四步走:
| 阶段 | 覆盖用户比例 | 核心动作 | 监控指标 |
|---|---|---|---|
| Step 1: 内部测试 | 0.1%(公司员工) | 强制安装,全员反馈 | 闪退率、关键功能成功率(如登录、支付) |
| Step 2: 小流量灰度 | 1%(随机ID) | 自动推送,静默安装 | ANR率、热更加载成功率、Probe()耗时P95 |
| Step 3: 中流量放量 | 10%(按地域分批) | 分城市推送,每小时递增 | 关键业务转化率(如课程完成率)、用户反馈关键词(“卡顿”、“黑屏”) |
| Step 4: 全量发布 | 100% | 全量推送 | 同上,但阈值更严苛 |
每一步都有明确的“熔断”条件。例如,在Step 2中,如果热更加载成功率 < 99.5%,或ANR率同比上升 > 0.1%,系统会自动暂停推送,并向负责人发送告警。此时,运维同学只需执行一条命令hotfix rollback --version 1.2.2,服务端就会停止下发1.2.3包,并向所有已安装的客户端推送一个“降级指令”,客户端收到后会自动卸载1.2.3,重新加载1.2.2。
这个回滚过程是秒级的。有一次,我们在Step 3发现某个机型(华为Mate 40 Pro)的Probe()耗时突增至2s,立即熔断。从发现问题到全量回滚,耗时不到90秒,影响用户不足200人。没有这个机制,一次热更事故可能演变成一场公关危机。
5. 调试、监控与长期维护:让热更新从“能用”走向“好用”
技术方案的终点,不是“跑通Demo”,而是“持续稳定运行三年”。我在某图像处理Demo项目中维护热更新模块长达34个月,期间经历了Unity从2018.4到2022.3的五次大版本升级、iOS从iOS 13到iOS 17的系统迭代、以及三次公司级架构重构。这段经历让我深刻体会到:热更新的长期健康度,取决于你为它投入的调试与监控成本。
5.1 运行时调试:在真机上设置断点的可行方案
Unity Editor的调试器无法连接到热更DLL里的代码,因为那些代码不在主程序集里。但放弃调试等于在黑暗中开车。我们的解决方案是双轨调试:
编译期调试(推荐):热更DLL的源码(
HotfixModule)本身就是一个完整的Unity项目。你可以在HotfixModule中打开Unity Editor,像调试普通项目一样设置断点、查看变量、单步执行。所有热更逻辑的业务逻辑、算法、状态流转,都应该在这里100%覆盖测试。HotfixModule的PlayMode测试用例,是我们质量的基石。运行时日志注入(必需):在热更DLL的每个关键节点,插入结构化日志。我们定义了一套轻量级日志协议:
// Hotfix.dll HotfixLog.Info("PlayerAI", "OnStart", new { playerId = 123, level = 5 }); HotfixLog.Warn("Combat", "LowHP", new { hp = 15.2f, threshold = 20f });HotfixLog会将日志序列化为JSON字符串,通过HotfixBridge发送到主程序集,主程序集再统一输出到Debug.Log,并可选地上传到日志服务器。这些日志带有精确的时间戳、模块名、事件名和上下文数据,比Debug.Log("Player start")有用百倍。远程调试桥接(高级):对于极少数必须在真机上调试的场景(如GPU相关问题),我们启用了Unity的
RemoteDebugger。在HotfixModule的HotfixEntry.Register()中,加入:#if DEBUG_REMOTE UnityEngine.RemoteDebugger.Start("192.168.1.100", 55555); #endif编译时定义
DEBUG_REMOTE宏,即可在真机上启动一个调试服务端口。然后用VS或JetBrains Rider连接,就能像调试主程序集一样调试热更代码。但这仅限于开发环境,发布包中绝对禁用。
5.2 全链路监控:从加载耗时到业务成功率的立体视图
我们搭建了一个轻量级的热更新监控看板,核心指标有四个维度:
基础设施层:
Hotfix.dll下载耗时(P50/P95/P99)、加载耗时(ALC创建+类型解析)、Probe()执行耗时。这些是技术健康度的晴雨表。如果加载耗时P95从200ms涨到800ms,说明热更DLL体积过大或反射逻辑有瓶颈。运行时稳定性:热更模块的
UnhandledException捕获率、OutOfMemoryException发生频率、GC Alloc峰值。我们用AppDomain.CurrentDomain.UnhandledException全局监听,并将异常堆栈、热更版本、设备型号一并上报。业务功能层:这是最关键的。我们为每个热更功能点定义一个“业务探针”。例如,热更了一个新的签到逻辑,就在
HotfixEntry.Register()中埋点:HotfixBridge.RegisterAction("Signin.Execute", () => { HotfixMetrics.Increment("signin.success"); // 成功计数 try { // 实际签到逻辑 } catch (Exception e) { HotfixMetrics.Increment("signin.fail"); // 失败计数 throw; } });所有
HotfixMetrics调用都会被收集,形成“签到成功率”曲线。如果某次热更后,这个成功率从99.9%跌到95%,哪怕没有崩溃,也说明逻辑有缺陷。用户反馈层:接入客服系统,自动抓取用户反馈中包含“热更”、“更新”、“闪退”、“卡住”等关键词的工单,并关联到具体的热更版本。这让我们能第一时间听到用户的声音。
这个看板每天自动生成报告,邮件发送给技术负责人。它不是为了“展示数据”,而是为了“快速定位问题”。有一次,看板显示Combat.ApplyDamage的调用成功率在iOS 16.4上暴跌,而其他平台正常。我们立刻聚焦到iOS 16.4的Metal API变更,发现热更代码中一个Graphics.Blit调用参数有误,修复后2小时内就发布了补丁。
5.3 长期维护铁律:版本冻结、文档即代码、废弃即删除
热更新模块最大的陷阱,是“越积越多,越改越怕”。为避免陷入维护泥潭,我们立下三条铁律:
版本冻结(Version Freeze):每个热更版本发布后,其
HotfixModule源码分支立即git tag v1.2.3并git branch -d hotfix/v1.2.3-dev。所有后续Bug修复,必须基于main分支的新版本(如v1.2.4)进行。绝不允许在旧版本分支上修Bug再重新打包。这保证了每个版本的构建产物都是可追溯、可复现的。文档即代码(Docs as Code):所有热更接口的变更,必须同步更新
HotfixModule/Docs/API_CHANGES.md。这个文件采用Markdown表格,记录每次变更的日期、版本号、变更类型(Added/Changed/Removed)、接口名、描述、示例代码。CI流水线会检查这个文件是否被修改,如果没有,就拒绝合并PR。文档不再是“写完就扔”,而是和代码一样受版本控制。废弃即删除(Deprecate & Delete):一个热更功能点,如果连续6个月没有被任何业务模块调用(通过
HotfixBridge.Get*的调用日志统计),且其hotfix.manifest版本已超过3个主版本,那么它会被标记为[DEPRECATED],并在下一个主版本中彻底从HotfixModule中删除。我们有一个自动化脚本,每月扫描所有热更DLL,生成“待废弃清单”,由架构师评审后执行。这保证了热更新模块永远精简、高效、无历史包袱。
这三条铁律,是模拟项目X能持续34个月无重大热更事故的根本保障。技术可以炫酷,但工程的终极目标,是让系统在时间的长河中,依然稳健如初。