
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 换成 UnityWebRequestusing 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”的前提下工作。真正要支持远程热更启动流程至少要加这几步优先读 persistentDataPath 里的缓存版本没有缓存就从 CDN 下载最新版本下载成功后写盘下次启动直接走本地缓存所有远程来源都失败时回退到 StreamingAssets 内置版本下载过程中带上版本号和 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, Actionbyte[] 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减小流量和破解面。如果只发布 dllLoadAssembly第二参数直接传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.RegisterMethodDelegateint(); domain.DelegateManager.RegisterMethodDelegatefloat(); domain.DelegateManager.RegisterMethodDelegatestring(); domain.DelegateManager.RegisterDelegateConvertorUnityEngine.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 快速核对清单与最终经验改动完成后我一般会拿着下面这个表逐项过一遍场景要求编辑器启动能走远程下载失败能回退本地 StreamingAssetsAndroid 真机热更从 CDN 下载成功写入 persistentDataPathAndroid 真机断网能读内置 StreamingAssets 兜底版本iOS 真机热更路径大小写正确WebRequest 能访问内置 dllWebGL 浏览器不能调用 File API必须走 WebRequest缓存损坏启动时能检测并重新下载弱网重试三次重试后仍失败给出明确错误提示入口调用初始化顺序正确Invoke 能触发热更 Main 方法最后说个个人体会改加载方法这步本身不难难的是把“加载”这件事放进整个启动流程里想清楚。ILRuntime 这套方案真正考验人的地方从来不是 API 怎么调而是边缘情况怎么兜。远程失败回退本地、缓存坏了能重下、平台差异被统一隔离做到这三条热更启动流程才算是真的能交出去。以上流程里的思路同样适用于 AssetBundle 内嵌 dll 和 Addressables 加载热更资源只是数据来源多包了一层判断逻辑完全一致。