ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ILRuntime热更DLL加载:从File API到UnityWebRequest的真机实践

ILRuntime热更DLL加载:从File API到UnityWebRequest的真机实践

1. 先看现场:默认加载代码在真机上是怎么死的

如果你跟着前面的文章一路做下来,现在的热更工程大概已经能在编辑器里跑起来了——ILRuntime 的 AppDomain 能成功加载 HotFix_Project.dll,通过 Invoke 回调热更入口。但我敢打赌,启动代码八成长这样:

string dllPath = Path.Combine(Application.streamingAssetsPath, "HotFix_Project.dll"); byte[] dllBytes = File.ReadAllBytes(dllPath); domain.LoadAssembly(dllBytes, null);

这套代码在编辑器里不会出任何问题,因为编辑器直接读的就是项目目录下的真实文件。但问题往往出在把包打到 Android 真机的那一刻:游戏启动,黑屏,Logcat 刷出一个DirectoryNotFoundException或FileNotFoundException,然后整个研发群里开始互相甩锅。今天这篇,就是把这段“本地读取”彻底替换成 UnityWebRequest,顺带把远程热更的下载、缓存、校验、回退全部讲清楚。

1.1 默认加载的教科书写法与它的适用边界

ILRuntime 官方示例里给过多种加载方式,最朴素的一种是直接开文件流:

using (var fs = new FileStream(dllPath, FileMode.Open, FileAccess.Read)) { domain.LoadAssembly(fs, null); }

很多人图省事,直接套一层File.ReadAllBytes。在 Windows 编辑器、Mac 编辑器以及部分独立平台上,这确实能跑。但请注意“适用边界”这个词——这类写法只适用于你能直接用文件系统路径访问到 dl 文件的场景。

而热更项目里,dll 通常放三个地方:

  • 随包进 StreamingAssets 的内置兜底版本;
  • 首启时从 CDN 拉到 persistentDataPath 的最新版本;
  • 极端情况下塞进 AssetBundle 里的TextAsset 封装版本。

除了第一种在非移动平台可以用 File API,剩下两种都需要程序自己处理网络下载、字节流还原。换一个加载方法,本质上就是把“我现在要从哪拿这一坨 byte[]”这个决定权,交给一个能跨平台工作的 IO 层。

1.2 三种平台的“读不到”,三种翻车姿势

编辑器之外的平台,File API 的失效方式各有各的精彩。

Android 是最容易翻车的。打包后 StreamingAssets 会被压进 APK 的 assets 目录,路径变成jar:file:///data/app/xxx/!/assets/这种带协议头的字符串。File.ReadAllBytes根本不认这种地址,直接抛异常。就算你想办法拿到了路径,底层也没法像普通文件一样打开。

iOS 稍好一点,StreamingAssets 会作为目录打进 mainBundle,理论上可以用 File API 读,但真机路径往往带空格、带中文,大小写敏感,目录层级也比编辑器里深得多。你要是拿编辑器路径直接硬编码,大概率踩到No such file or directory。

WebGL 则完全从根上断了这条路——浏览器沙箱里根本没有传统意义的文件系统,File类直接不可用,唯一合法的本地读法就是走UnityWebRequest的file://协议,或者把文件先通过服务器拉下来。

所以我常说:如果还停留在File.ReadAllBytes,那说明项目还没到真正上线热更的层面。热更不是“有这套机制就行”,而是要有一个在任何平台都能读、都能下的统一加载层。

1.3 这其实是在给启动流程补一层 IO 和网络

把File.ReadAllBytes换成 UnityWebRequest,表面上是替换一个 API,背后是给启动流程补上了两层能力:

  • 本机读取能力:UnityWebRequest 内部识别file://、jar:file://等协议,在 Android 上可以直接访问 StreamingAssets 里的文件,iOS、WebGL 也能用同一套逻辑,不再需要平台宏到处打补丁;
  • 远程拉取能力:热更的本质就是“dll 不随包更新,启动时从 CDN 拉最新版本”。这一步无论如何都要走网络,UnityWebRequest 就是目前官方推荐的网络层入口。

所以后续的代码会围绕一个原则来写:能统一,绝不平台分叉;能走 WebRequest,绝不用 File API。理解了这个原则,后面再改 AssetBundle 加载、改 Addressables 加载,思路也会顺很多。

2. 搞懂加载链路:ILRuntime 要的从来不是路径,而是 byte[]

很多初学者改加载方法时会有一个惯性思维:把Application.streamingAssetsPath这个路径调整好就行。其实 ILRuntime 对路径没有任何执念,它从头到尾只认一块数据:dll 文件对应的字节数组。

2.1 LoadAssembly 拿到 byte[] 之后做了什么

看一眼 ILRuntime 的核心调用:

byte[] dllBytes = ...; // 可以是文件读的,可以是网络下的,可以是 AB 里抽出来的 domain.LoadAssembly(dllBytes, null);

在LoadAssembly内部,ILRuntime 会先把 byte[] 包装成MemoryStream,再用 Mono.Cecil 去解析这个程序集的结构。Cecil 在这里的角色是“程序集阅读器”,它读取 IL(中间语言)指令,将类型、方法、字段的信息建立成内部模型。

之后,热更代码里真正被调用到的方法,才会由 ILRuntime 自带的解释器一句一句执行这些 IL 指令。整个过程和“这个 byte[] 是从哪个文件来的”没有任何关系。路径只是人类理解资源的语言,字节才是机器理解程序的原料。

2.2 byte[] 的 N 种来路,决定了热更方案能走多远

既然只认字节数组,那数据来路就非常灵活了。我见过实际项目用到的来路至少有这几种:

来路适合场景缺点
File.ReadAllBytes编辑器、独立平台、iOS 本地路径移动端平台分叉严重
UnityWebRequest下载远程 CDN 拉取依赖网络,需要超时重试
UnityWebRequest读 StreamingAssetsAndroid 内置文件仍需处理协议头
AssetBundle 里的TextAsset.bytes加密/整体打包方案要先加载 AB,流程更重
内存中动态拼接测试、自定义协议生产环境少见

把加载方法从 File API 改成 WebRequest,实际上是给数据来路增加了一个“远程”选项。这也是为什么标题里只说“更改加载方法”,而不是“新增加载模块”——本质上就是换一个数据源,同时把对平台差异的处理从业务代码里剥离出去。

2.3 把“加载程序集”和“调用入口”拆成两个阶段

容易出问题的地方在于:很多人把LoadAssembly和Invoke("App", "Main")写在同一个同步方法里,前面用File.ReadAllBytes时没问题,一改成 WebRequest 的协程或异步回调,顺序就乱了。

正确做法是把加载拆成两个阶段:

  • 第一阶段:拿到 byte[],调用LoadAssembly,让 ILRuntime 把程序集解析进自己的模块表;
  • 第二阶段:确认加载成功、相关绑定注册完成后,再通过Invoke触发热更入口。

第二阶段必须在第一阶段“真的完成”之后执行。如果用协程,就是yield return后面再 Invoke;如果是异步,就得把 Invoke 放到回调里。这一点听起来稀松平常,但实际排查过很多“为什么热更代码没跑起来”的问题,最后都卡在这里——加载是异步完成的,入口却被同步调用了。

3. 动手改造:用 UnityWebRequest 重写热更 dll 加载流程

进入正题。先给一个最小改造版,再给一套能上线的完整流程。两段代码都基于 UnityWebRequest,区别在于第一版只改了 API,第二版补上了超时、重试、缓存和回退。

3.1 最小改造版:File.ReadAllBytes 换成 UnityWebRequest

using System.Collections; using UnityEngine; using UnityEngine.Networking; public class HotFixBoot : MonoBehaviour { private ILRuntime.Runtime.Enviorment.AppDomain domain; IEnumerator Start() { domain = new ILRuntime.Runtime.Enviorment.AppDomain(); domain.Initialize(); string url = GetStreamingAssetUrl("HotFix_Project.dll"); using (UnityWebRequest request = UnityWebRequest.Get(url)) { request.timeout = 15; yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { byte[] dllBytes = request.downloadHandler.data; domain.LoadAssembly(dllBytes, null); Debug.Log("热更程序集加载成功"); } else { Debug.LogError("热更程序集加载失败:" + request.error); } } } private string GetStreamingAssetUrl(string fileName) { #if UNITY_ANDROID // Android 上 streamingAssetsPath 本身是 jar:file:// 协议,直接拼文件名即可 return Application.streamingAssetsPath + "/" + fileName; #else // 其他平台统一补 file:// 前缀 return "file://" + Application.streamingAssetsPath + "/" + fileName; #endif } }

这段代码已经能解决绝大多数“真机读不到 StreamingAssets 内 dll”的问题。核心就是GetStreamingAssetUrl里对 Android 做了区分:Android 的Application.streamingAssetsPath自带协议头,不能再拼file://,其他平台则会返回普通路径,需要补file://。

3.2 上线版本:超时、重试、版本校验和本地回退

最小改造版只能在“随包内置 dll”的前提下工作。真正要支持远程热更,启动流程至少要加这几步:

  1. 优先读 persistentDataPath 里的缓存版本;
  2. 没有缓存,就从 CDN 下载最新版本;
  3. 下载成功后写盘,下次启动直接走本地缓存;
  4. 所有远程来源都失败时,回退到 StreamingAssets 内置版本;
  5. 下载过程中带上版本号和 MD5 校验,防止拿到半截文件或错误包。

我把一个线上用过的精简流程剥出来,代码结构如下:

using System; using System.Collections; using System.IO; using System.Security.Cryptography; using UnityEngine; using UnityEngine.Networking; public class HotFixBoot : MonoBehaviour { private ILRuntime.Runtime.Enviorment.AppDomain domain; private const string DllName = "HotFix_Project.dll"; private const string RemoteBase = "https://your-cdn.example.com/hotfix/"; IEnumerator Start() { domain = new ILRuntime.Runtime.Enviorment.AppDomain(); domain.Initialize(); domain.UnityMainThreadID = Environment.CurrentManagedThreadId; byte[] dllBytes = null; // 1. 优先读本地缓存 string cachedPath = Path.Combine(Application.persistentDataPath, DllName); if (File.Exists(cachedPath)) { dllBytes = File.ReadAllBytes(cachedPath); Debug.Log("使用持久化目录缓存 dll"); } // 2. 缓存不存在或版本过期,远程下载 if (dllBytes == null) { dllBytes = DownloadDllWithRetry(RemoteBase + DllName); if (dllBytes != null) { File.WriteAllBytes(cachedPath, dllBytes); Debug.Log("远程下载成功并写入缓存"); } } // 3. 远程失败,回退内置版本 if (dllBytes == null) { yield return LoadFromStreamingAssets(DllName, bytes => dllBytes = bytes); } // 4. 最终校验 if (dllBytes == null || dllBytes.Length == 0) { Debug.LogError("所有来源都无法取得热更 dll,启动流程终止"); yield break; } // 5. 加载进 ILRuntime domain.LoadAssembly(dllBytes, null); domain.Invoke("HotFix_Project.App", "Main", null, null); } private byte[] DownloadDllWithRetry(string url) { byte[] result = null; for (int i = 0; i < 3; i++) { using (UnityWebRequest request = UnityWebRequest.Get(url)) { request.timeout = 10; request.SendWebRequest(); while (!request.isDone) { /* 同步阻塞轮询 */ } if (request.result == UnityWebRequest.Result.Success) { result = request.downloadHandler.data; break; } Debug.LogWarning($"第 {i + 1} 次下载失败:{request.error}"); } } return result; } private IEnumerator LoadFromStreamingAssets(string fileName, Action<byte[]> onDone) { #if UNITY_ANDROID string url = Application.streamingAssetsPath + "/" + fileName; #else string url = "file://" + Application.streamingAssetsPath + "/" + fileName; #endif using (UnityWebRequest request = UnityWebRequest.Get(url)) { yield return request.SendWebRequest(); onDone?.Invoke(request.result == UnityWebRequest.Result.Success ? request.downloadHandler.data : null); } } }

上面为了演示把远程下载写成了同步轮询,实际项目建议整体跑协程,避免卡主线程太久。核心思路保持不变:缓存优先、远程补充、内置兜底。

3.3 关键代码逐段拆解,想清楚再抄

如果你在编辑器里直接运行上面的代码,大概率会踩到一个细节:第一次远程下载成功后,File.WriteAllBytes写进 persistentDataPath,但第二次启动走的是缓存,版本号还是旧的。所以正式项目里,本地缓存目录里通常不是直接放 dll,而是放一个版本描述文件,结构大概长这样:

{ "version": 20250601, "dllMd5": "a1b2c3..." }

启动时先读这个 JSON,再决定要不要下载。上面代码略掉了这部分,实际落地时建议在DownloadDllWithRetry之前先拉一次version.json,本地版本低于远程版本才触发下载。

MD5 校验也要做,否则 CDN 上文件传了一半、客户端被强杀,下次开机加载的是损坏的字节数组,ILRuntime 会报出极其隐晦的解析错误。校验函数很简单:

private string ComputeMd5(byte[] bytes) { using (MD5 md5 = MD5.Create()) { byte[] hash = md5.ComputeHash(bytes); return BitConverter.ToString(hash).Replace("-", "").ToLower(); } }

下载完成后比对一下,不一致就丢弃重下。别嫌这一步麻烦,热更文件损坏导致的“假黑屏”是最难查的一类问题。

3.4 关于 dll 的 pdb:调试时下,发布时不下

ILRuntime 的LoadAssembly有一个带 PDB 的重载,可以把调试符号一起灌进去,编辑器里就能打断点调试热更代码:

byte[] dllBytes = ...; byte[] pdbBytes = ...; using (var dllStream = new MemoryStream(dllBytes)) using (var pdbStream = new MemoryStream(pdbBytes)) { domain.LoadAssembly(dllStream, new PdbReader(pdbStream)); }

建议是:开发期远程同时下载 dll 和 pdb,方便查问题;发布版本只发 dll,减小流量和破解面。如果只发布 dll,LoadAssembly第二参数直接传null,不影响功能。

4. 加载成功只是第一步:初始化、绑定与入口调用

很多项目改成 WebRequest 加载后,dll 确实进去了,但紧接着又冒出一堆新问题:热更代码里调用Debug.Log直接抛异常、UnityAction转换失败、入口方法死活调不出来。这些大多不是加载本身的问题,而是初始化阶段顺序错了。

4.1 CLR 绑定和 Delegate 注册必须赶在加载之前

ILRuntime 的机制是“解释执行热更 IL”,但它运行在 Unity 的 AOT 环境里。热更代码里访问 Unity 引擎的接口时,不能依赖反射,必须预先告诉 ILRuntime:哪些类型、哪些方法要用 CLR 绑定接管。

所以标准的初始化顺序是:

domain = new ILRuntime.Runtime.Enviorment.AppDomain(); domain.Initialize(); // 注册 CLR 绑定 RegisterBindings(); // 注册委托转换 RegisterDelegates(); // 再加载热更 dll LoadHotFixAssembly();

如果顺序反了,先LoadAssembly再去注册委托,后面一旦碰到事件回调、lambda 表达式这类需要跨程序集转换的调用,就会报Cannot find Delegate或TryConvert错误。

一些常用绑定如下:

domain.DelegateManager.RegisterMethodDelegate<int>(); domain.DelegateManager.RegisterMethodDelegate<float>(); domain.DelegateManager.RegisterMethodDelegate<string>(); domain.DelegateManager.RegisterDelegateConvertor<UnityEngine.Events.UnityAction>(act => { return () => { ((UnityEngine.Events.UnityAction)act)(); }; });

具体注册哪些,取决于热更代码里用到多少跨域委托,但上面几个属于“建议开局就注册”的常备项。

4.2 AppDomain 初始化、主线程 ID 和 UnityEngine 交互

初始化时有一个很容易被忽略的赋值:

domain.UnityMainThreadID = Environment.CurrentManagedThreadId;

ILRuntime 内部对主线程是有记忆的,部分 API 会校验当前线程是不是主线程。Unity 的引擎 API 要求在主线程执行,如果你在协程里加载完 dll,又在一个后台线程里直接调用热更代码,很容易触发线程相关异常。

除此之外,各类“为了支持 UnityEngine 类型交互”的适配操作,也建议在Initialize之后立刻做掉。ILRuntime 官方文档里的那套Adaptor、CrossBinding注册代码,放到Initialize和LoadAssembly之间,是最稳的位置。

4.3 从网络加载完 dll 后,怎么安全地调出热更入口

当LoadAssembly正常返回,热更程序集就已经进入 AppDomain 的类型缓存了。接下来调用入口,一般写法是:

domain.Invoke("HotFix_Project.App", "Main", null, null);

这里有两个细节容易被忽略。

一个是要确认热更入口类和方法名的大小写完全正确。在 Windows 编辑器下不区分大小写可能碰巧能跑,Android 和 iOS 上却严格区分,最后表现就是“编辑器正常、真机没反应”。

另一个是Invoke的参数。第三个参数是方法参数数组,如果是无参方法就传null;如果要传参,ILRuntime 会自动做装箱转换,但类型必须和热更方法签名一致,否则静默失败。稳妥的做法是在热更入口包一层无参方法,参数统一在热更内部解析,这样外部的调用链就不用频繁变更。

5. 真机实测清单:改完千万别只在编辑器里跑

代码改完,真正的战斗才开始。下面这些坑,是我在多个项目里反复踩过的,按频率从高到低列出来。每一条都能单独让你排查一晚上。

5.1 Android StreamingAssets 的特殊 URI 验证方法

在 Android 上,Application.streamingAssetsPath返回的是jar:file://...,UnityWebRequest 能识别这种协议,所以可以直接拼路径。但请注意:这个问题只在“真机”上存在。Android 模拟器有时行为不一致,Unity 编辑器里更是完全模拟不出来。

一个快速验证方法:打一个只有 StreamingAssets 读取逻辑的测试包,放到真机上跑,Logcat 里看到jar:file://开头的 URL,并且request.result为Success,说明通道没问题。如果看到file://开头的 URL,说明平台宏没生效,多半是#if UNITY_ANDROID写错了位置,或者没有加UNITY_ANDROID这个预编译定义。

5.2 弱网、断网、包损坏:启动流程要能自己恢复

真机上最容易复现的启动崩溃,往往不是代码逻辑错,而是“网络请求超时时间太短 + 没做重试 + dll 写盘到一半进程被杀”。

弱网测试时我见过太多类似表现:CDN 偶发一个 5xx,启动直接失败;或者下载了 90% 断电,下次进来加载一个截断的 dll,解析器报出一长串看不懂的异常。

解决方向有两层:

  • 下载层:超时时间不要低于 10 秒,重试至少 3 次,重试间隔用指数退避(1 秒、2 秒、4 秒);
  • 写入层:先写dll.tmp,写完再改名成正式文件。下次启动发现有.tmp残留,直接删除重下。
string tmpPath = cachedPath + ".tmp"; File.WriteAllBytes(tmpPath, bytes); File.Move(tmpPath, cachedPath, true);

这样即使写入中途被杀,也不会污染正式缓存。

5.3 UnityWebRequest 的生命周期:字节数组什么时候释放

用using (UnityWebRequest request = ...)是标准写法,但很多人忽略了一个前提:request.downloadHandler.data返回的字节数组,在Dispose之后还能不能用。

Unity 内部对DownloadHandlerBuffer的data返回值做过拷贝,多数情况下 even 释放掉 handler 后依然能读。但在版本升级后我碰到过一次拿到空数组的情况,所以最稳妥的做法是在using块内部、request.result == Success之后立刻把数据拷贝到自己的变量里:

byte[] localBytes = new byte[request.downloadHandler.data.Length]; Array.Copy(request.downloadHandler.data, localBytes, localBytes.Length);

如果你定制了自定义DownloadHandlerScript,这条规则更要严格遵循,释放时机完全取决于你的实现,不能赌框架行为。

5.4 快速核对清单与最终经验

改动完成后,我一般会拿着下面这个表逐项过一遍:

场景要求
编辑器启动能走远程下载,失败能回退本地 StreamingAssets
Android 真机热更从 CDN 下载成功,写入 persistentDataPath
Android 真机断网能读内置 StreamingAssets 兜底版本
iOS 真机热更路径大小写正确,WebRequest 能访问内置 dll
WebGL 浏览器不能调用 File API,必须走 WebRequest
缓存损坏启动时能检测并重新下载
弱网重试三次重试后仍失败给出明确错误提示
入口调用初始化顺序正确,Invoke 能触发热更 Main 方法

最后说个个人体会:改加载方法这步本身不难,难的是把“加载”这件事放进整个启动流程里想清楚。ILRuntime 这套方案真正考验人的地方从来不是 API 怎么调,而是边缘情况怎么兜。远程失败回退本地、缓存坏了能重下、平台差异被统一隔离,做到这三条,热更启动流程才算是真的能交出去。以上流程里的思路同样适用于 AssetBundle 内嵌 dll 和 Addressables 加载热更资源,只是数据来源多包了一层,判断逻辑完全一致。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进