在热更新这条路上,加载方式是绕不开的第一道坎。ILRuntime 跑起来的前提,是把热更 DLL 顺利送到运行时手里。系列走到第六篇,从零开始的老读者应该已经把这个框架的基本流程摸清了:编辑器里编译一次,得到热更程序集,运行时加载,然后进入 ILRuntime 的解释执行世界。前几篇我们用本地文件加载的方式跑通了流程,但这在实际项目里远远不够。尤其是到了 Android 真机、iOS 打包、或者需要从远端拉取热更资源的场景下,Local 的方式要么受限于平台,要么不方便统一管理。这一篇我直接把加载方式彻底改掉,换到 UnityWebRequest 上,一次性把跨平台、异步、从服务器拉取这几件事都理顺。
先说为什么是 UnityWebRequest。Unity 老玩家应该还记得 WWW 这个类,早期很多热更新方案都用 WWW 从服务器拉 AssetBundle。但 WWW 在 Unity 2018 之后就被标记为过时了,官方主推的 UnityWebRequest 无论是性能、内存管理还是扩展性都更干净。更重要的是,UnityWebRequest 天然支持 http、https、file 等多种协议,这意味着我们可以用同一套代码同时处理“本地调试加载”和“远端正式加载”两种模式,不用为不同平台写两套逻辑。
这篇我不仅会给出最终的封装代码,还会把每一步的思路拆开讲清楚:为什么这么写,踩过哪些坑,遇到报错怎么排查。如果你正好卡在“热更 DLL 加载不出来”“Android 上路径写不对”这类问题上,这篇应该能帮你一次解决。
1. 加载方式演进与方案选型
1.1 从 File 到 UnityWebRequest:到底改了什么
早期做 ILRuntime 热更,最常见的加载方式就是直接用File.ReadAllBytes或者Assembly.LoadFile。这种方案在 Windows 编辑器和独立打包下跑得很好,速度快,代码也简单。但上了 Android 之后,问题就来了:Android 的 APK 内部资源是压缩包形式,你不能直接通过本地文件路径去读 AssetBundle 或者 DLL。要么把文件放到 persistentDataPath,要么通过AssetBundle.LoadFromFile配合路径去取。而 iOS 也有它自己的沙盒限制,本地路径需要特殊处理。
所以很多项目都会退一步:先用UnityWebRequest去下载一个 AssetBundle 或文件到本地,然后再加载。但现在我们干脆更进一步,直接用UnityWebRequest去请求目标地址,以二进制字节的方式拿回 DLL 内容,再交给 ILRuntime 的ILRuntime.Runtime.Enviorment.AppDomain来加载。这样既解决了平台差异,也把“下载”和“加载”两个动作统一了。
1.2 为什么不用 WWW 和 HttpClient
如果只是图省事,WWW用起来其实也很快,一行new WWW(url)就能拿到 bytes。但 WWW 的坑在于:它内部会创建临时文件缓存,而且在 Android 和 iOS 上处理本地文件协议时行为不一致。更关键的是,WWW 已经被 Unity 官方明确标记为 deprecated,新项目再采用等于一开始就在还技术债。
HttpClient在纯 C# 层很好用,但在 Unity 里集成有点尴尬。UnityWebRequest 本身是基于原生层实现的,在移动平台上底层走的是平台自己的网络栈,稳定性更有保障。而且 UnityWebRequest 支持协程和 async/await 两种方式,能很方便地和 Unity 生命周期整合,不需要额外管理线程。
1.3 方案选型时要考虑的两个核心指标
选加载方式,我主要看两点:跨平台一致性,内存占用。跨平台一致性在于:编辑器、Android、iOS、PC 上同一套代码行为要一致,不能这个平台能跑另一个平台报错。内存占用在于:热更 DLL 本身不大,几百 KB 到几 MB,但如果用 AssetBundle 打包 DLL,还要考虑 AssetBundle 的加载与卸载,组合起来需要谨慎设计。
UnityWebRequest 的DownloadHandlerBuffer拿到的data直接就是字节数组,用完可以马上释放请求对象,内存峰值可控。如果用 AssetBundle 方式,还要记得bundle.Unload(false),否则会积累内存。所以这篇我采用最直接的字节加载,流程最短,心智负担最小。
2. UnityWebRequest 加载热更 DLL 的完整实现
2.1 基础版本:请求字节并交给 ILRuntime
先把最简单的版本写出来。我们假设热更 DLL 是直接放在服务器上的一个文件,比如http://xxx.com/hotfix.dll,或者是打包成 AssetBundle 后放在 CDN 上。这个版本先处理“服务器直接放 DLL 文件”的情况。
using System.Collections; using UnityEngine; using UnityEngine.Networking; using ILRuntime.Runtime.Enviorment; public class HotUpdateLoader : MonoBehaviour { public string dllUrl = "http://127.0.0.1:8080/hotfix.dll"; private AppDomain _appDomain; private IEnumerator Start() { yield return LoadHotFixDll(dllUrl); // 加载完成后,可以执行热更里的入口方法 } private IEnumerator LoadHotFixDll(string url) { using (UnityWebRequest request = UnityWebRequest.Get(url)) { request.timeout = 30; yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogError("DLL 下载失败: " + request.error); yield break; } byte[] dllBytes = request.downloadHandler.data; if (dllBytes == null || dllBytes.Length == 0) { Debug.LogError("DLL 字节为空,请检查服务器路径"); yield break; } _appDomain = new AppDomain(); using (MemoryStream ms = new MemoryStream(dllBytes)) { _appDomain.LoadAssembly(ms, null, new Mono.Cecil.Pdb.PdbReaderProvider()); } // 初始化 ILRuntime,注册适配器、跨域继承等 InitializeILRuntime(); // 调用热更代码入口 _appDomain.Invoke("HotfixApp", "Main", null, null); } } private void InitializeILRuntime() { // 根据项目需要注册 CLR 重定向、委托适配器等 // 例如:_appDomain.DelegateManager.RegisterMethodDelegate<System.String>(); } }这段代码把关键步骤浓缩在一起:发起请求、拿到字节、构造 MemoryStream、LoadAssembly、初始化、调用入口。和原来用File.ReadAllBytes的差别,只是把“读取字节”这个动作的源头从本地磁盘换成了网络请求。ILRuntime 本身完全不知道字节是从哪里来的,对它来说只要收到一个MemoryStream就可以。
注意LoadAssembly的第二个参数我传了 null,这表示不加载 PDB 调试符号。如果是 Debug 模式想调试热更代码,可以把 pdb 也下载下来,并通过第三个参数传入PdbReaderProvider。Release 构建一般不需要 pdb,省点内存。
2.2 带超时、断线重试的健壮性封装
直接请求一次在实际项目里很容易翻车。移动网络抖动、服务端短暂不可用、CDN 边缘节点慢,都可能导致下载失败。这时候直接yield break会让用户卡在原地。所以我一般都会给加载封装加上重试机制和状态回调。
public class HotUpdateLoader : MonoBehaviour { public int maxRetryCount = 3; public float retryDelay = 1f; private IEnumerator LoadHotFixDllWithRetry(string url, System.Action<bool> onCompleted) { for (int i = 0; i < maxRetryCount; i++) { Debug.Log($"开始下载热更 DLL,第 {i + 1} 次尝试"); using (UnityWebRequest request = UnityWebRequest.Get(url)) { request.timeout = 20; yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { byte[] dllBytes = request.downloadHandler.data; if (dllBytes != null && dllBytes.Length > 0) { // 交给 ILRuntime 加载 LoadAssemblyFromBytes(dllBytes); onCompleted?.Invoke(true); yield break; } } else { Debug.LogWarning($"下载失败:{request.error}"); } } if (i < maxRetryCount - 1) { yield return new WaitForSecondsRealtime(retryDelay); } } Debug.LogError("多次尝试后仍然无法下载热更 DLL"); onCompleted?.Invoke(false); } }重试参数maxRetryCount和retryDelay我建议放到配置表里,不要写死在代码中。不同环境下网络情况差异很大,开发阶段 3 次重试足够了,但在弱网环境下可能 5 次更合适。WaitForSecondsRealtime不受Time.timeScale影响,即使切换后台或者暂停游戏,重试间隔也能保证。
2.3 带缓存校验的加载方案
如果热更 DLL 每次启动都从服务器下载,一来浪费流量,二来启动速度会慢。正常的方案是先下载到本地持久化目录,下次启动先检查本地有没有、校验 MD5 对不对,不对再下载。这个思路和热更资源更新是一体的,但这里要先把基础流程改对,所以我把加载封装成两个阶段:先保证能从服务器拿到字节,然后在本地缓存一份。
private string SavePath { get { string dir = Application.persistentDataPath + "/Hotfix"; if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); return dir + "/hotfix.dll"; } } private IEnumerator EnsureDllReady(string remoteUrl, System.Action<bool> onReady) { // 存在且字节有效,直接加载本地 if (System.IO.File.Exists(SavePath)) { byte[] cached = System.IO.File.ReadAllBytes(SavePath); if (cached.Length > 0) { LoadAssemblyFromBytes(cached); onReady?.Invoke(true); yield break; } } // 本地没有,走网络下载 yield return DownloadAndCache(remoteUrl, onReady); } private IEnumerator DownloadAndCache(string remoteUrl, System.Action<bool> onReady) { using (UnityWebRequest request = UnityWebRequest.Get(remoteUrl)) { request.timeout = 30; yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { byte[] dllBytes = request.downloadHandler.data; System.IO.File.WriteAllBytes(SavePath, dllBytes); LoadAssemblyFromBytes(dllBytes); onReady?.Invoke(true); } else { onReady?.Invoke(false); } } }这里省掉了 MD5 校验,因为核心目标是先改变加载方式。但如果在正式项目里,一定要在下载后比对服务器下发的 MD5 或 CRC,否则本地文件损坏会导致热更代码无法加载。后续等系列讲到资源更新时,我会把版本管理、MD5 校验等内容补上。
3. 关键细节:路径处理与 AssetBundle 里的 DLL
3.1 路径前缀如何处理
使用 UnityWebRequest 时,最容易翻车的点就是 URL 的前缀。加载远端文件,用http://或https://;加载本地文件,必须用file://前缀。很多人写本地加载时直接写C:/xxx/hotfix.dll,结果 UnityWebRequest 直接报错:Invalid URI。
在编辑器里做测试时,如果想把项目里的文件当本地文件加载,需要这样拼接:
string localPath = Path.Combine(Application.streamingAssetsPath, "hotfix.dll"); string url = "file://" + localPath;注意Application.streamingAssetsPath在编辑器下返回的是项目目录下的Assets/StreamingAssets绝对路径,在 Android 返回的是一个压缩包内的路径,不能直接用file://读取。所以如果在 Android 上想读取 StreamingAssets 里的热更 DLL,必须先通过UnityWebRequest以file://或jar:file://的方式读出来。但 Android 的jar:协议 UnityWebRequest 也支持,不过要小心路径写法。
更稳妥的实践是:StreamingAssets 只放“首次安装自带的初始资源”,启动后拷贝到 persistentDataPath,后面统一用 persistentDataPath 里的文件。这样所有平台行为一致。
3.2 如果 DLL 被包在 AssetBundle 里
实际项目中很少把 DLL 裸放在服务器上,一般会打包成 AssetBundle。一是 Hosting 方便,二是能压缩,三是可以和热更资源一起做版本管理。如果是 AssetBundle 里放的 DLL,加载流程就多一步:
private IEnumerator LoadDllFromAssetBundle(string bundleUrl) { using (UnityWebRequest request = UnityWebRequest.Get(bundleUrl)) { yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { yield break; } // 这里要用 DownloadHandlerAssetBundle 获取 AssetBundle 内容 using (AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(request)) { if (bundle == null) { Debug.LogError("AssetBundle 加载失败"); yield break; } TextAsset dllAsset = bundle.LoadAsset<TextAsset>("hotfix.dll"); if (dllAsset == null) { Debug.LogError("AssetBundle 中没有找到 hotfix.dll"); yield break; } byte[] dllBytes = dllAsset.bytes; LoadAssemblyFromBytes(dllBytes); } } }这里有个容易忽略的坑:DownloadHandlerAssetBundle.GetContent(request)会返回一个 AssetBundle,并且它使用的是请求内部的DownloadHandlerAssetBundle实例。一旦request被释放,AssetBundle 也会失效。所以必须在 using 块里完成LoadAssembly,不能等到request释放之后再操作 bundle。
另外,从 AssetBundle 加载出的dllAsset.bytes是原始字节,直接交给 ILRuntime 没问题。AssetBundle 的Unload(false)会释放资源对象但不销毁实例化的东西,但热更 DLL 的字节已经拷贝到dllBytes中了,所以即使 bundle 卸载,dllBytes依然存在,不影响后续的 Assembly 加载。
3.3 跨平台的具体差异对照
我在一次项目里统计过不同平台下 UnityWebRequest 加载本地 file 协议的差异,整理出一个表:
| 平台 | file:// 支持 | 路径示例 | 备注 |
|---|---|---|---|
| Windows 编辑器 | 支持 | file://C:/Projects/Game/Assets/hotfix.dll | 注意盘符后面要加/ |
| macOS 编辑器 | 支持 | file:///Users/user/Game/hotfix.dll | 三个斜杠 |
| Android | 支持(StreamingAssets 内需特殊处理) | file:///sdcard/Android/data/... | 直接访问应用私有目录没问题 |
| iOS | 支持 | file:///var/mobile/Containers/Data/Application/... | 沙盒内访问正常 |
| 独立 Linux | 支持 | file:///home/user/hotfix.dll | 和 macOS 一样三个斜杠 |
核心规则很简单:本地路径转 URL 时,Path.GetFullPath之后前面拼上file://,Windows 上C:\会变成file://C:\,Unity 能识别,但更标准的是把反斜杠转成正斜杠,就是file://C:/...。如果不确定,直接用Application.streamingAssetsPath等 Unity 提供的接口拼接,出错概率低。
4. 常见问题与排查技巧实录
4.1 DLL 下载成功但 LoadAssembly 报错
症状:UnityWebRequest 返回成功,dllBytes长度也在几千字节以上,但在LoadAssembly时抛异常,错误信息类似An exception was thrown: ...。
排查思路:先确认拿到的字节是不是一个完整的托管 DLL。很多服务器配置了 gzip 压缩,UnityWebRequest 默认不会自动解压,如果服务器返回的是 gzip 流,downloadHandler.data就是压缩后的数据,直接当 DLL 解析必然失败。解决办法是关闭服务器的 gzip,或者用Accept-Encoding: identity头强制取原始数据。另外,如果服务器返回的是 HTML 错误页面(比如 404 页面),data也能拿到内容,但不是 DLL。我建议加载前检查字节开头的标志位:DLL 文件开头是MZ(0x4D 0x5A)。
if (dllBytes[0] != 0x4D || dllBytes[1] != 0x5A) { Debug.LogError("文件不是有效的 DLL 格式"); }4.2 Android 上加载本地路径失败
症状:在 Editor 里一切正常,打包到 Android 之后,同样用file://去读本地 persistentDataPath 文件,却提示File not found。
原因:Android 下Application.persistentDataPath返回的目录通常是/storage/emulated/0/Android/data/包名/files,这个路径本身没问题。但如果设备没有授予应用存储权限,或者应用已被用户强制停止,访问这个目录会失败。在 Android 8.0 以后,应用私有目录不受外部存储权限限制,但 Unity 构建时默认的WRITE_EXTERNAL_STORAGE权限如果没有配置,写文件也可能被拒绝。
我的处理方式是:用 UnityWebRequest 加载之前,先确认目录是否存在并具备读写权限。判断方式可以用Directory.Exists和尝试写入空测试文件的方式验证。如果是 Android 6.0 以上的设备,需要运行时请求权限,这个很多人会忘。只要在 AndroidManifest 或 Unity Player Settings 里勾选对应的权限,然后代码里请求即可。
4.3 UnityWebRequest 在协程中卡住不返回
症状:调用yield return request.SendWebRequest()后,一直没有走到下一行,也不报错。
原因:大部分时候是请求的 URL 无法访问,超时时间设置太长(默认 0 表示无限),或者网络异常导致请求悬挂。还有个隐蔽原因:SendWebRequest在协程里运行时,如果物体被SetActive(false)或者场景卸载,协程生命周期中断,请求可能一直挂起。解决办法是给request.timeout设置明确的值,比如 10 秒或 20 秒,并且把加载逻辑挂在一个DontDestroyOnLoad的常驻物体上,避免场景切换导致协程被销毁。
另外还有一个容易被忽略的坑:在 Unity 2020 及以上版本中,request.isDone只能在SendWebRequest返回的AsyncOperation上使用,如果直接在协程中使用while (!request.isDone)循环,某些平台不会正常推进。推荐始终用yield return request.SendWebRequest()这种方式等待结果。
4.4 ILRuntime 初始化顺序错误导致加载后调用失败
症状:DLL 加载成功,但调用热更入口时报System.InvalidCastException或者NotSupportedException。
原因:加载热更 DLL 之后,ILRuntime 需要先完成初始化,包括注册各种委托适配器、CLR 重定向、跨域继承注册。如果这些没做,直接调用热更方法,解释器会找不到对应的注册信息。顺序应该是:构造AppDomain-> 加载 Assembly -> 注册委托/适配器 -> 初始化 -> 调用入口。有些项目会在LoadAssembly之前就去注册委托,结果导致热更代码里的委托类型解析失败。
我在封装时的建议是:把InitializeILRuntime放在LoadAssembly之后、Invoke之前执行。并且初始化代码中要尽可能把项目里可能用到的Action<>、Func<>、UnityAction<>等全部注册,而不是等报错才补。
补充:如果热更代码用到了 UnityEvent 或者其他跨域继承的类,必须在初始化时调用 `appDomain.RegisterCrossBindingAdaptor`,否则会在运行时崩溃。5. 实操心得与扩展建议
5.1 用协程还是 async/await
UnityWebRequest 本身支持SendWebRequest()返回AsyncOperation,配合UniTask或 Unity 2022 的Awaitable可以用 async 方式写。我的经验是:如果项目还在用旧版 Unity,协程最稳,不引入额外依赖;如果新项目且有条件使用 UniTask,封装成await会让代码干净很多,特别是多个下载任务并行时,协程写起来会嵌套得很深。
下面给出基于 UniTask 的加载示例(假设项目已经安装 UniTask):
using Cysharp.Threading.Tasks; using UnityEngine.Networking; public async UniTask<bool> LoadDllAsync(string url) { using (var request = UnityWebRequest.Get(url)) { request.timeout = 20; await request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { byte[] bytes = request.downloadHandler.data; LoadAssemblyFromBytes(bytes); return true; } return false; } }UniTask 的await会自动让出主线程,加载完成后回到主线程,这样后续LoadAssembly和初始化操作可以直接执行,不需要担心线程安全问题。
5.2 内存管理与释放
UnityWebRequest 的资源释放不能只依赖using关键字。在协程模式下,request.downloadHandler.data返回的是byte[],这个数组是 DownloadHandler 内部缓冲区的拷贝,拿到之后request.Dispose()并不会销毁dllBytes,可以安全使用。但如果直接使用request.downloadHandler.data而请求对象还没有 Dispose,内存中会有两份字节副本,一个是下载缓冲区,一个是托管字节数组。热更 DLL 本身不大,影响有限,但如果未来扩展到 LoadBundle 等大资源,就需要考虑复用缓冲区,或者用DownloadHandler.ReceiveData自定义处理。
关于AppDomain的内存释放:如果想重新加载新的热更 DLL,最好销毁旧的AppDomain再创建新的,否则旧的程序集仍然占据内存。ILRuntime 的AppDomain本身没有公开的 Dispose 方法,通常做法是置空引用然后等待 GC。如果项目有频繁切换热更 DLL 的需求,务必设计好引用隔离,别让热更对象残留。
5.3 发布到正式环境前一定要检查这些
下面这几条是我在项目上线前排查清单里的关键项,每条都是踩过坑换来的:
- 确认服务器返回的 DLL 文件带有正确的 Content-Type,有些服务器会对未知类型文件做拦截或编码转换。
- 如果用了 CDN,确认回源策略中 DLL 文件不会被压缩或二次打包。
- 在真机上测试弱网场景,把
timeout设置得尽量合理,不要用默认 0。 - 确认我们的脚本执行顺序:加载 DLL 的物体必须在场景最前面激活,否则可能在更新流程之前就开始了。
- 做好 Log 分级:网络请求的 URL、状态码、下载字节数一定要输出到日志,方便线上排错。
5.4 后续可以怎么扩展
这篇完成之后,我们已经拿到了一个“从服务器下载热更 DLL 字节”的稳定入口。下一步的方向很清晰:第一是增加版本校验和 MD5 校验,避免下载错误或本地缓存损坏;第二是把加载抽象成一个通用接口,支持从AssetBundle、byte[]、StreamingAssets多种来源获取 DLL,方便编辑器测试和远端更新切换;第三是快速启动优化,比如先加载一个包含代码的 AssetBundle,再并行下载其他资源,减少用户等待。
ILRuntime 热更新的完整链路里,加载只是第一步,但也是后续所有功能的地基。地基不稳,后面接什么都容易塌。这篇把 UnityWebRequest 这条路径理顺了,后面的版本管理、增量更新、代码调试也就能安心铺开了。