Unity游戏Mod开发:MelonLoader双运行时架构原理与实践指南

📅 2026/8/2 19:58:51
Unity游戏Mod开发:MelonLoader双运行时架构原理与实践指南
1. 项目概述当Unity游戏遇上“双系统”难题如果你是一个热衷于在《英灵神殿》、《星露谷物语》或者《幻兽帕鲁》这类Unity引擎开发的游戏中折腾模组Mod的玩家那你很可能遇到过一种让人抓狂的情况游戏更新了你心爱的Mod集体失效要么游戏闪退要么功能错乱。这背后往往不是Mod作者偷懒而是一个更深层的技术难题——Unity游戏运行时环境的“单行道”限制。传统的Mod加载方式无论是通过Assembly-CSharp.dll的“暴力”替换还是依赖BepInEx等框架进行补丁本质上都是在游戏主进程的“地盘”上动手术。一旦游戏本体更新底层代码结构发生变化这些“手术”的接口就可能全部错位导致Mod崩溃。这就是“Unity游戏双运行时Mod加载问题”的核心。想象一下游戏本身是一个封闭的、不断升级的操作系统比如Windows而Mod是运行在这个系统上的应用程序。每次系统大版本更新应用程序就可能因为API接口改变而无法运行。传统的解决方案是让应用程序Mod去强行适配每一个新系统版本这无疑是低效且脆弱的。而MelonLoader的出现就像是为Unity游戏引入了一个“虚拟机”或“兼容层”。它不再试图在游戏的主运行时Mono或IL2CPP里直接动刀而是创造了一个独立的、稳定的第二运行时环境。所有的Mod都运行在这个由MelonLoader管理的“沙盒”里通过一套精心设计的桥梁与游戏本体进行安全、可控的交互。这样一来游戏本体的更新只要不破坏桥梁的基础协议Mod就无需重写实现了“一次编写多处运行”的理想状态。对于Mod开发者和玩家社区而言这不仅仅是技术上的优雅更是生态上的解放极大地降低了维护成本提升了Mod的生存能力和兼容性。2. 核心原理拆解MelonLoader的“双核”架构要理解MelonLoader如何解决双运行时问题我们需要深入其架构设计。它本质上是一个“引导器”Bootstrap Loader在游戏进程启动的早期就介入完成一系列精妙的“偷梁换柱”和“环境搭建”工作。2.1 传统单运行时加载的瓶颈在MelonLoader之前主流的Unity Mod框架如早期的UnityModManager以及功能强大的BepInEx主要依赖于“托管注入”和“Harmony补丁”技术。它们的典型工作流程是注入通过修改游戏启动参数或使用外部注入器将框架的托管DLL加载到游戏进程。劫持框架接管Unity引擎的脚本加载流程。修补使用Harmony等库在游戏运行时动态修改打补丁原有的游戏方法Method插入Mod的逻辑。执行Mod代码作为补丁的一部分在游戏原有的AppDomain应用程序域中执行。问题显而易见所有Mod代码与游戏代码共享同一个运行时环境和内存空间。当游戏更新特别是Unity版本升级或代码混淆Obfuscation策略改变时原有方法的签名、内存地址甚至整个类结构都可能发生变化。Harmony补丁所依赖的“目标方法”找不到了自然导致Mod失效。这要求Mod作者必须紧跟每一次游戏更新逆向工程新的程序集更新补丁目标整个过程繁琐且高度不确定。2.2 MelonLoader的“隔离沙盒”方案MelonLoader采用了截然不同的思路与其在别人的房子里装修不如在旁边盖一栋自己的新楼然后用天桥连接。其核心架构分为三层原生层Native Layer这是MelonLoader的根基通常是一个用C/C编写的原生库如MelonLoader.dll或version.dll。它的任务是在操作系统层面最早被加载利用操作系统的DLL劫持或注入技术抢在Unity引擎初始化之前取得控制权。关键动作它负责准备.NET运行时环境。对于使用IL2CPP后端编译的游戏这是现代Unity游戏的主流它将C#代码编译为C再编译为原生机器码性能更高但更难修改MelonLoader的原生层会手动初始化.NET运行时为托管代码的执行铺平道路。托管核心层Managed Core Layer在原生层搭建好.NET环境后MelonLoader会加载自己的托管核心程序集如MelonLoader.ModHandler.dll。这个层是“沙盒”的管理中心。核心创新——创建新的AppDomain这是MelonLoader解决兼容性问题的关键一步。它不会将Mod加载到游戏默认的AppDomain中而是创建一个全新的、独立的AppDomain。这个新的AppDomain拥有自己独立的程序集加载上下文、安全边界和生命周期。游戏本体的代码运行在默认域而所有Mod代码都运行在这个新建的“MelonDomain”中。Mod代理与通信桥Proxy Bridge两个AppDomain之间不能直接访问对象或调用方法。MelonLoader通过一套“代理”系统建立通信。事件代理MelonLoader在游戏默认域中植入轻量级的“监听器”。当游戏触发特定事件如Awake,Start,Update,OnGUI等时监听器会捕获这些事件。消息传递监听器通过.NET的Marshal机制或自定义的进程内通信IPC方式将事件消息“转发”到MelonDomain。Mod执行在MelonDomain中Mod订阅这些事件。当收到转发来的事件消息时对应的Mod代码便被安全地触发执行。反向调用如果Mod需要调用游戏内的函数或读取/修改游戏对象则通过类似的代理桥向默认域发送“请求”由默认域中的代理代码代为执行再将结果封送Marshal回MelonDomain。注意这种基于AppDomain隔离的方案对使用Mono后端的老游戏支持最好。对于IL2CPP游戏由于整个游戏逻辑都变成了原生代码MelonLoader需要做更多底层工作如Hook IL2CPP的API函数来实现事件监听和交互但其“隔离”的核心思想不变。这种架构带来的核心优势稳定性Mod代码的崩溃绝大多数会被隔离在MelonDomain内不会导致整个游戏进程崩溃虽然极端情况仍可能波及。兼容性游戏本体更新时只要MelonLoader植入的“事件监听器”接口保持稳定这通常比游戏内部方法签名稳定得多Mod就无需修改。MelonLoader框架本身的更新可以独立于游戏进行。热重载由于Mod存在于独立的AppDomain理论上可以实现Mod的动态加载和卸载而无需重启游戏部分功能已实现或正在完善中。安全性为未来实现Mod签名验证、权限控制提供了天然的架构基础。3. 实操部署从零开始为游戏安装MelonLoader理论很美好实践出真知。下面我们以一款典型的Unity游戏为例手把手完成MelonLoader的安装与配置。这里假设游戏为《英灵神殿》Valheim它从早期Mono运行时转向了IL2CPP是检验MelonLoader能力的绝佳例子。3.1 环境准备与工具选择在开始前你需要准备目标游戏确保你拥有游戏的合法副本并且其安装目录路径不包含中文或特殊字符。MelonLoader安装器最推荐使用官方发布的MelonLoader.Installer.exe。这是一个图形化工具能自动检测游戏信息并完成安装比手动操作更可靠。.NET Desktop RuntimeMelonLoader依赖.NET框架。请根据安装器的提示安装对应版本通常是.NET 6.0或更高版本的运行时。为什么不推荐手动拖拽DLL早期有些教程会教你手动下载MelonLoader.dll、version.dll等文件复制到游戏根目录。这种方法极不可靠因为文件版本可能不匹配。无法自动处理IL2CPP游戏的复杂依赖如UnityDependencies。缺少自动配置MelonLoader目录结构的能力。 使用官方安装器是避免各种玄学问题的最优解。3.2 分步安装流程实录步骤一运行安装器并定位游戏下载最新的MelonLoader.Installer。以管理员身份运行避免文件写入权限问题。点击Select按钮浏览并选择游戏的启动执行文件例如valheim.exe。安装器会自动分析该文件识别出游戏名称、Unity版本以及后端是Mono还是IL2CPP。步骤二关键配置选项解析安装器主界面会有几个关键选项理解它们能避免后续麻烦Version选择MelonLoader的版本。通常选择最新的Stable稳定版。对于某些非常新的游戏可能需要尝试Pre-Release预览版。Type这个选项至关重要。它会根据检测结果自动选择Mono或IL2CPP。绝对不要手动更改除非你非常清楚你在做什么。选错类型会导致加载器根本无法工作。Download Dependencies务必勾选。这将自动下载并安装MelonLoader运行所必须的.NET依赖项和Unity基础库。Create MelonLoader Directory务必勾选。这会在游戏根目录创建MelonLoader文件夹这是所有Mod和配置的“家”。配置完成后界面会类似下图以Valheim的IL2CPP为例[Selected Game: Valheim] Unity Version: 2022.3.9 Loader Type: IL2CPP Target Version: v0.6.1 Stable确认无误后点击Install。步骤三安装完成与验证安装过程通常很快成功后会提示Installation Complete!。此时查看游戏根目录你会发现新增了以下关键文件和文件夹version.dll或winhttp.dll依赖注入器用于IL2CPPMelonLoader文件夹Managed存放MelonLoader自身和核心依赖的托管DLL。Mods这是你以后放Mod文件.dll的地方。UserDataMod生成的配置文件、日志文件会存放在这里。MelonLoader.log最重要的日志文件任何加载问题首先查看它。首次运行验证直接双击启动游戏。如果MelonLoader安装成功你会看到游戏启动前首先会弹出一个控制台窗口黑色背景显示MelonLoader的加载日志。日志会依次显示加载器版本、游戏信息、扫描到的Mod列表、各Mod的初始化状态。进入游戏主菜单后控制台窗口可能会自动最小化或隐藏可配置。检查MelonLoader.log文件末尾应有Application Started字样且没有大量的红色错误ERROR信息。实操心得第一次运行如果卡住或闪退99%的问题可以通过查看MelonLoader.log解决。常见的错误包括.NET运行时版本不对、游戏文件被反病毒软件误删、Mods文件夹内有 incompatible不兼容的旧版Mod。养成“遇事不决看日志”的习惯。3.3 Mod的安装与管理MelonLoader的Mod通常以.dll动态链接库文件的形式提供有时会附带一个同名的.deps.json依赖描述文件。安装Mod将下载的Mod的.dll文件及其依赖文件直接放入游戏根目录\MelonLoader\Mods\文件夹即可。MelonLoader会在下次游戏启动时自动扫描并加载它们。Mod配置许多Mod支持自定义配置。首次运行加载该Mod后通常在MelonLoader\UserData\或MelonLoader\UserData\Mod名\文件夹下会生成一个.cfg或.json配置文件。你可以用文本编辑器打开并修改修改后大部分Mod支持在游戏中通过快捷键如F1实时重载配置。Mod排序与依赖部分Mod之间存在依赖关系。MelonLoader通过Mod程序集中的元数据如MelonInfo特性来识别依赖。如果Mod A依赖Mod B你必须同时安装B且B的版本需满足要求。加载顺序通常是自动处理的但如果有问题可以尝试在Mods文件夹内通过文件名前缀如01_BaseMod.dll,02_ExtensionMod.dll来手动调整加载顺序。禁用Mod不想加载某个Mod最简单的方法不是删除它而是将其文件后缀从.dll改为.dll.disabled。MelonLoader会忽略此类文件方便你随时启用。4. 开发者视角如何为MelonLoader编写一个Mod对于开发者而言MelonLoader提供了一套清晰且强大的API。下面我们通过创建一个简单的“Hello World” Mod来演示其开发流程和核心概念。4.1 开发环境搭建安装.NET SDK你需要安装与目标MelonLoader版本匹配的.NET SDK如.NET 6.0。可以从微软官网下载。创建类库项目使用Visual Studio、Rider或VS Code创建一个新的“类库”项目目标框架选择Net6.0或对应版本。引用MelonLoader API通过NuGet包管理器或手动引用添加MelonLoader包。更常见的方式是直接从已安装的游戏目录中引用游戏根目录\MelonLoader\Managed\MelonLoader.dll和游戏根目录\MelonLoader\Managed\UnityEngine.CoreModule.dll以及其他你可能需要的Unity模块DLL。4.2 编写第一个Mod游戏内显示FPS下面是一个功能完整、代码注释详细的Mod示例它在游戏屏幕左上角显示当前帧率FPS。using MelonLoader; using UnityEngine; // 1. 定义Mod主类并继承 MelonMod namespace MyFirstMelonMod { public class MyFirstMelonMod : MelonMod { // 2. 声明Mod元数据必须 // 这会被MelonLoader在加载时读取用于识别、排序和管理Mod public override void OnInitializeMelon() { // 此方法在Mod所有程序集加载后、游戏初始化前调用 // 适合进行全局变量初始化、配置读取等一次性操作 LoggerInstance.Msg($Mod {Info.Name} v{Info.Version} 初始化成功); } // 3. 重写OnSceneWasLoaded方法在场景加载后执行 // 我们在这里创建并初始化一个GameObject来承载我们的UI逻辑 public override void OnSceneWasLoaded(int buildIndex, string sceneName) { // 检查是否加载到了游戏主场景避免在加载界面也创建 if (sceneName main || buildIndex 1) // 场景名或索引需根据具体游戏调整 { LoggerInstance.Msg($进入游戏主场景: {sceneName}); InitializeFPSCounter(); } } // 4. 重写OnUpdate方法每一帧都会被调用 // 这是实现FPS计算和UI更新的核心 public override void OnUpdate() { // 如果我们的FPS计数器对象存在就更新FPS数值 if (_fpsCounterObject ! null) { UpdateFPS(); } } // 5. 重写OnGUI方法用于绘制Unity的即时模式GUI (IMGUI) // 这是绘制屏幕左上角FPS文本的地方 public override void OnGUI() { // 只有进入游戏主场景后才绘制 if (_fpsCounterObject ! null) { // 设置GUI样式 GUI.skin.label.fontSize 20; GUI.skin.label.normal.textColor Color.green; // 在屏幕(10, 10)的位置绘制一个标签显示FPS GUI.Label(new Rect(10, 10, 200, 30), $FPS: {_currentFPS:F1}); } } // --- 以下是Mod私有的实现逻辑 --- private GameObject _fpsCounterObject; private float _currentFPS; private float _deltaTimeAccumulator; private int _frameCount; private void InitializeFPSCounter() { // 创建一个空的GameObject用于在游戏场景中挂载我们的逻辑虽然我们主要用OnGUI _fpsCounterObject new GameObject(Melon_FPSCounter); GameObject.DontDestroyOnLoad(_fpsCounterObject); // 切换场景时不销毁 LoggerInstance.Msg(FPS计数器已创建。); } private void UpdateFPS() { // 经典的FPS平滑计算算法 _deltaTimeAccumulator Time.unscaledDeltaTime; // 使用不受时间缩放影响的deltaTime _frameCount; // 每0.5秒更新一次显示的FPS值避免数字跳动太快 if (_deltaTimeAccumulator 0.5f) { _currentFPS _frameCount / _deltaTimeAccumulator; _frameCount 0; _deltaTimeAccumulator 0f; } } } }代码关键点解析继承MelonMod这是所有MelonLoader Mod的基类提供了完整的生命周期钩子。MelonInfo特性未在代码中显式展示通常在AssemblyInfo.cs中定义这是Mod的“身份证”必须定义。它包含了Mod的名称、版本、作者等信息以及至关重要的Game属性用于指定这个Mod兼容哪个游戏通过游戏进程名或ID防止Mod被加载到错误的游戏中。生命周期方法OnInitializeMelon: 最早被调用用于一次性初始化。OnSceneWasLoaded: 场景加载事件是创建游戏内对象、初始化Mod功能的理想位置。OnUpdate: 游戏每帧调用用于需要持续运行的逻辑如FPS计算、按键检测。OnGUI: 每帧调用用于绘制简单的屏幕UI。对于复杂UI推荐使用Unity的UGUI系统并通过GameObject创建。日志输出使用LoggerInstance.Msg()或.Warning()、.Error()来输出信息。这些日志会写入MelonLoader.log是调试的利器。游戏对象管理通过new GameObject()创建的对象如果需要持久存在务必调用GameObject.DontDestroyOnLoad()。4.3 编译、打包与测试编译项目在开发环境中编译生成YourModName.dll文件。准备依赖如果你的Mod引用了第三方库如Newtonsoft.Json需要将这些库的DLL一起打包。部署测试将生成的.dll文件复制到目标游戏的MelonLoader\Mods\目录下。启动游戏并调试观察MelonLoader控制台窗口的输出看你的Mod是否被正确识别和加载。使用LoggerInstance输出调试信息。如果Mod导致游戏崩溃第一时间检查MelonLoader.log末尾的堆栈跟踪信息。5. 深度应用MelonLoader在复杂场景下的实践掌握了基础后我们来看看MelonLoader如何应对更复杂的Mod开发需求这才能真正体现其“双运行时”架构的优势。5.1 与游戏对象交互安全地修改游戏状态Mod的终极目的往往是改变游戏行为。在MelonLoader的隔离架构下我们不能直接操作游戏默认域中的对象。MelonLoader通过UnhollowerBaseLib对于IL2CPP或Il2CppInterop等中间层提供了安全访问游戏对象和组件的能力。示例修改玩家生命值假设我们想制作一个“上帝模式”Mod让玩家生命值锁定。// 这是一个概念性示例具体类名和方法名需通过游戏反编译或查阅社区文档获得 using MelonLoader; using UnhollowerBaseLib; // IL2CPP交互库 using Il2CppNamespace; // 游戏具体的IL2CPP命名空间 public class GodModeMod : MelonMod { private PlayerController _player; public override void OnUpdate() { if (_player null) { // 通过GameObject.Find查找玩家对象需知道游戏内对象名或标签 var playerObj GameObject.Find(PlayerCharacter(Clone)); if (playerObj ! null) { // 使用Il2Cpp交互获取组件 _player playerObj.GetComponentPlayerController(); LoggerInstance.Msg(玩家对象已找到。); } } if (_player ! null _player.Health _player.MaxHealth) { // 直接修改IL2CPP对象的属性 _player.Health _player.MaxHealth; // 注意某些游戏可能将Health设为私有需要通过反射或Harmony补丁来修改 } } }重要提示直接修改属性虽然简单但可能破坏游戏逻辑或引发反作弊检测。更优雅、更兼容的做法是使用Harmony补丁。MelonLoader完美集成了Harmony库允许你在MelonDomain中对游戏默认域的方法进行补丁。由于补丁逻辑运行在隔离域即使补丁失败也较难导致游戏完全崩溃。5.2 使用Harmony进行非侵入式修改Harmony是MelonLoader生态中的重型武器。它允许你前置Prefix、后置Postfix或完全替换Transpiler游戏原有方法。示例修改物品堆叠上限假设游戏有一个Item.GetMaxStackSize()方法我们想将所有物品堆叠上限改为999。using HarmonyLib; using MelonLoader; [HarmonyPatch(typeof(Item), nameof(Item.GetMaxStackSize))] public class ItemStackPatch { // Postfix补丁在原方法执行后运行可以修改其返回值 static void Postfix(ref int __result) { // 将原方法的返回值(__result)修改为999 __result 999; } } public class StackMod : MelonMod { public override void OnInitializeMelon() { // 在Mod初始化时应用所有Harmony补丁 HarmonyInstance.PatchAll(); LoggerInstance.Msg(物品堆叠补丁已应用。); } }优势这种方式没有直接硬编码修改游戏内存而是通过IL指令注入来改变逻辑。当游戏更新导致Item.GetMaxStackSize方法地址变化时Harmony库会尝试重新定位并应用补丁只要方法签名名称、参数、返回类型没变补丁就依然有效兼容性比直接内存操作强得多。5.3 处理配置与数据持久化一个成熟的Mod需要可配置。MelonLoader推荐使用其内置的MelonPreferences系统。using MelonLoader; public class MyConfigurableMod : MelonMod { // 定义配置类别和项 private MelonPreferences_Category _category; private MelonPreferences_Entrybool _isModEnabled; private MelonPreferences_Entryfloat _someValue; public override void OnInitializeMelon() { // 创建配置类别会对应配置文件中的一个节(Section) _category MelonPreferences.CreateCategory(MyCoolMod); // 创建配置项 _isModEnabled _category.CreateEntry(Enabled, true, description: 是否启用本Mod); _someValue _category.CreateEntry(Multiplier, 1.5f, description: 效果倍率); // 加载已保存的配置 MelonPreferences.Load(); LoggerInstance.Msg($Mod已加载。当前状态{_isModEnabled.Value}, 倍率{_someValue.Value}); } public override void OnUpdate() { if (!_isModEnabled.Value) return; // 根据配置决定是否执行逻辑 // 使用 _someValue.Value 来影响游戏逻辑... } }配置会自动保存到MelonLoader/UserData/MyCoolMod.cfg玩家可以手动编辑部分Mod还会提供游戏内的配置界面。6. 故障排查与社区生态即使有优秀的框架开发和使用Mod也难免遇到问题。MelonLoader的强项之一就是提供了清晰的排查路径。6.1 常见问题速查表问题现象可能原因解决方案游戏启动无反应或闪退1. MelonLoader版本与游戏不兼容。2. 缺少.NET运行时。3. 杀毒软件/Windows Defender拦截了注入。1. 查看游戏社区确认支持的MelonLoader版本或尝试更新/回退Loader版本。2. 使用安装器重新安装确保勾选“Download Dependencies”。3. 将游戏目录添加到杀毒软件白名单以管理员身份运行游戏。控制台窗口一闪而过游戏正常启动但无Mod效果1. Mods文件夹内无有效.dll文件。2. Mod因依赖缺失或版本冲突加载失败。1. 检查MelonLoader\Mods目录是否有.dll文件。2. 查看MelonLoader.log寻找Failed to load或Skipped的Mod记录根据错误信息安装缺失依赖或更新Mod。游戏能进但某个特定Mod导致崩溃或功能异常1. Mod与当前游戏版本不兼容。2. Mod与其他Mod冲突。3. Mod本身存在Bug。1. 检查该Mod的发布页面确认支持的游戏版本。2. 采用“二分法”禁用一半Mod测试逐步缩小冲突范围。3. 查看日志中该Mod初始化时的错误堆栈向Mod作者反馈。OnGUI绘制的内容不显示1. 游戏使用了新的URP/HDRP渲染管线OnGUI默认不渲染。2. GUI绘制代码逻辑有误。1. 对于新管线游戏需要使用MelonLoader的事件系统订阅特定的渲染事件或使用Unity的UGUI/TextMeshPro创建Canvas。2. 检查OnGUI方法是否被正确重写绘制代码是否在条件内执行。Harmony补丁不生效1. 目标方法名、参数签名错误。2. 方法已被游戏代码完全内联或优化掉IL2CPP常见。3. Harmony实例未正确初始化或应用补丁。1. 使用dnSpy、ILSpy或游戏提供的调试信息精确获取方法签名。2. 尝试对调用该方法的上一级方法打补丁。3. 确保在OnInitializeMelon中调用了HarmonyInstance.PatchAll()。6.2 日志你的第一诊断工具MelonLoader.log文件是解决一切问题的起点。它位于游戏根目录的MelonLoader文件夹内。打开它关注以下部分开头MelonLoader版本、游戏信息、.NET环境。确认环境正确。Mod加载列表会列出所有扫描到的Mod并标记Loaded成功、Skipped跳过通常因为不兼容或Failed失败。失败信息会包含异常详情。运行时错误游戏运行中发生的任何由Mod引发的异常都会在这里打印出完整的堆栈跟踪StackTrace这是定位Bug的黄金信息。6.3 社区与资源MelonLoader的成功离不开活跃的社区。以下是一些关键资源官方GitHub获取最新版本、安装器和源代码。Issue列表是寻找已知问题和解决方案的好地方。游戏特定的Mod社区如《英灵神殿》的Nexus Mods页面、相关Discord频道。这里聚集了针对该游戏的Mod作者和玩家能获得最直接的兼容性信息和Mod资源。Mod开发Wiki与示例MelonLoader的Wiki提供了详细的API文档。在GitHub的示例仓库中有大量入门和进阶的Mod代码示例。调试工具dnSpy/ILSpy用于反编译游戏程序集Assembly-CSharp.dll等是查找类名、方法名、理解游戏逻辑的必备工具。Unity Explorer一个强大的运行时调试Mod可以在游戏内以树状结构浏览场景、对象、组件实时查看和修改属性是开发复杂Mod的神器。我个人在长期使用和开发MelonLoader Mod的过程中最深的一点体会是“隔离”带来的不仅是稳定更是一种思维方式的转变。你不再需要像过去那样小心翼翼地生怕碰坏游戏内存里的任何东西。在MelonLoader构建的沙盒里你可以更大胆地实验因为你知道最坏的结果可能就是自己的Mod崩溃而游戏本体大概率安然无恙。这种安全感极大地激发了创作和探索的欲望。同时它的架构也迫使开发者去思考更清晰的接口和事件驱动的逻辑这本身就是一种良好的工程实践训练。对于玩家而言下次当你看到心爱的游戏更新而Mod列表一片飘绿兼容时或许可以默默感谢一下背后这个“双运行时”的巧妙设计。