ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity WebGL 下 Addressables 加载进度条实现与常见坑

Unity WebGL 下 Addressables 加载进度条实现与常见坑 简介围绕Unity Addressables框架实现资源与场景异步加载并展示加载进度条的完整项目适用于需要在WebGL平台优化资源加载体验的Unity开发者。压缩包共2000个文件整体约940.41MB文件类型涵盖813个bin资源数据、285个meta配置、180个md说明文档、115个mat材质、70张jpg与47张png贴图、36个asset资产文件并包含少量prefab、fbx和cs脚本等基本覆盖工程配置、场景素材、文档说明与脚本代码目录结构清晰便于检索。目前已有888人学习下载。这套资料详细演示了Addressables分组配置、LoadAssetAsync/LoadSceneAsync异步调用、进度监听、资源释放等核心API用法还结合HTML5 progress元素给出WebGL进度条实现方案并附有构建缓存与打包记录可帮助开发者快速复现完整加载流程、掌握WebGL平台下的资源优化与排错思路。1. WebGL 里用 Addressables 加载资源和场景进度条为什么反而成了最麻烦的一环在 WebGL 上做加载资源和场景目前 Unity 项目里最常用的方案就是 Addressables。它把资源按组打成 AssetBundle运行时在浏览器里通过网络按需下载加载资源和场景也走同一套异步管线。也正因为“浏览器 网络 异步”三件事叠在一起等待时间被明显拉长进度条从可选变成了必须。不少团队在 PC 上能靠一句异步等待草草了事WebGL 版却翻了车进度条卡在 90%、切场景后内存翻倍、甚至白屏。这篇文章围绕“unity addressables 加载资源和场景 显示进度条”这条链先把加载管线拆开再给出可复现的进度条实现最后把我踩过的坑一条条列出来。适合正在做 WebGL 导出、微信小游戏打包或关注包体优化的 Unity 开发者。2. 先摸清 Addressables 的加载管线资源组、Key 与 AsyncOperationHandle 的配合很多人一上来就拖个 Slider然后每帧读一个叫“进度”的字段却发现进度条要么不动要么乱跳。这是因为 Addressables 的加载不是一条线而是一棵依赖树。树的每个分支可能是一个本地 AssetBundle也可能是一个远程 AssetBundleUnityWebRequest 每完成一个分支进度就跳一下所以最终呈现出来的 PercentComplete 天然是台阶状的。想做出稳定的进度条先得把这条管线里谁先谁后搞清楚。2.1 一次加载请求背后发生了什么Addressables 初始化时才加载 CatalogCatalog 里记录的是每个 Key 对应的所有 AssetBundle 及其依赖关系。调 LoadAssetAsync 或 LoadSceneAsync 时流程大致是四步在 Catalog 里查 Key 对应的 Location。根据 Location 找到目标 AssetBundle以及它依赖的所有 Bundle。把所有依赖 Bundle 下载并加载完如果是远程地址则走 UnityWebRequest。依赖就绪后反序列化资源对象再往前走回调或实例化。对 WebGL 来说第三步是进度条最难缠的地方依赖包里可能有几十个 Bundle其中有本地Local也有远程Remote的。只要其中一个 Bundle 特别大进度条就会长时间停在某个值上看起来像死机。所以写进度条不能只盯着最终返回值还要知道当前到底卡在哪一步。2.2 加载同一个对象用 Address 还是 LabelAddressables 有三种常见 Key 类型Address、Label、AssetReference。它们的加载粒度完全不同直接影响进度条的平稳程度。Key 类型加载粒度对进度条的影响适用场景Address 字符串定位到单一资源进度只受该资源的依赖 Bundle 影响相对可控场景加载、单个 UI 弹窗、单个角色Label 标签一次加载整组资源进度是整组 Bundle 的加权值跳动明显批量预热、按模块预下载AssetReference序列化引用和 Address 一样走 handle 进度只在预制体里拖引用时推荐要做进度条时我一般更推荐以 Address 作 Key粒度越细进度越好预判。Label 加载时只要其中一个 Bundle 体积大进度就会在某个百分比上停很久观感非常差。Label 更适合做启动阶段的无界面预下载做可见进度条是给自己找麻烦。2.3 进度到底读哪个字段PercentComplete 与 GetDownloadStatus 的配合进度条的数值来源在 Addressables 里有两套分别是 AsyncOperationHandle.PercentComplete 和 GetDownloadStatus().Percent。它们不是同一个东西字段计算范围典型表现PercentComplete整条加载链路的综合进度依赖多时呈台阶状可能先满后回跳GetDownloadStatus().Percent只统计还有多少字节没下载完对远程 Bundle 更线性但加载阶段会报满配合方式很简单拿到 handle 后在 while 循环里优先看下载状态下载结束后再用 PercentComplete 覆盖后半段。下面是一个最小脚本可以直接挂到 Canvas 上跑using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; using UnityEngine.UI; using System.Collections; public class AddressableProgressBar : MonoBehaviour { public Slider progressSlider; public Text progressText; IEnumerator Start() { // 手动初始化避免后续加载撞上 Catalog 未就绪 var initHandle Addressables.InitializeAsync(); yield return initHandle; // 用 Address 加载单一预制体 var handle Addressables.LoadAssetAsyncGameObject(enemy_boss); while (!handle.IsDone) { float progress Mathf.Clamp01(handle.PercentComplete); progressSlider.value progress; progressText.text ${(progress * 100f):F0}%; yield return null; } if (handle.Status AsyncOperationStatus.Succeeded) { Instantiate(handle.Result, transform.position, Quaternion.identity); } else { Debug.LogError($加载失败: {handle.OperationException}); } // 这里释放的是句柄实例化出来的物体不受影响 Addressables.Release(handle); } }这段代码的逻辑是先初始化 Addressables再按 Key 加载资源。每帧读取 PercentComplete等句柄 IsDone 后取 Result。参数说明里有几个值得注意的点一是 Slider 的 value 要提前设到 0不要留 Editor 里的旧值二是handle.Status AsyncOperationStatus.Succeeded一定要判断WebGL 在弱网下经常返回 Failed只等 IsDone 会误认为成功三是 Release 必须和 LoadAssetAsync 成对出现如果你换成 InstantiateAsync对应就要用 ReleaseInstance用错会连带把实例销毁。2.4 句柄释放与进度条并存一个常见误用进度条本身不持有资源持有资源的一直是句柄。很多项目把释放逻辑写在进度条回调里这本身没错但经常犯一个顺序问题进度条刚到 100%玩家还站在场景里代码就把 load 句柄 release 了结果角色身上的贴图、声音全部失效。释放逻辑的正确姿势是“什么时候确定没人再引用这块资源什么时候释放”和进度条走到头没有必然关系。下面这种写法是反例// 错误示范加载结束立刻释放 var handle Addressables.LoadAssetAsyncGameObject(ui_icon); yield return handle; var prefab handle.Result; var obj Instantiate(prefab); Addressables.Release(handle); // 如果后续还依赖 prefab这里就会出问题正确的做法是把句柄存到成员变量或专门的资源登记表里等 UI 关闭、场景退出时再释放。进度条只负责展示加载状态不负责替资源生命周期做决定。3. 从资源到场景把加载进度条落到 WebGL 发布版也能用的实现先加载单个资源再扩展场景是大多数人熟悉的路子但场景加载在 WebGL 上有几个和编辑器里完全不同的表现远程 Bundle 要先下载、下载完还要反序列化、场景激活前还有一段卡顿。所以场景进度条不能照搬“资源加载的进度条”要做两段式。这一章给出的是我实际在项目里用过的完整写法。3.1 加载单个资源的最小进度条实现先说单个资源。这个进度条适合加载弹窗、道具、角色模型这类场景内单点资源。用 LoadAssetAsync 加一个 while 循环每帧把 PercentComplete 映射到 Slider 上using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; using UnityEngine.UI; using System.Collections; public class SingleAssetLoader : MonoBehaviour { public Slider progressSlider; public Text progressText; public void LoadDialog() { StartCoroutine(LoadAsset(ui/dialog_confirm)); } IEnumerator LoadAsset(string key) { var handle Addressables.LoadAssetAsyncGameObject(key); while (!handle.IsDone) { float p Mathf.Clamp01(handle.PercentComplete); progressSlider.value p; progressText.text ${(p * 100f):F0}%; yield return null; } if (handle.Status AsyncOperationStatus.Succeeded) { // Result 是预制体实例化后放到指定父节点 var go Instantiate(handle.Result, transform); go.transform.localPosition Vector3.zero; } else { Debug.LogError($资源加载失败: {key}, {handle.OperationException}); } Addressables.Release(handle); } }逻辑说明这里用一个协程替代 Update协程的好处是每个加载任务互不阻塞。参数上key必须是 Addressables 配置好的 Address如果填成 AssetBundle 文件名反而找不到。Instantiate之后立刻 Release 是安全的因为handle.Result引用的资源在实例化后对象自身已持有引用数据释放句柄只是让 Addressables 不再追踪这块资源。真正需要注意的是如果后续还要Instantiate第二次就不能在这个时刻释放。3.2 场景切换的加载进度条LoadSceneAsync 的完整用法场景加载比资源加载多的一个环节是场景激活。Unity 在激活新场景前会把旧的场景卸载掉这个动作在 WebGL 上可能造成几百毫秒的卡顿而handle.PercentComplete在到达 0.9 左右后就不会继续涨直到激活完成才直接跳到 1。所以进度条会在 90% 左右停一会儿这是正常现象不代表死锁。下面这段代码是最小可用的场景加载进度条using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; using UnityEngine.SceneManagement; using UnityEngine.UI; using System.Collections; public class SceneLoader : MonoBehaviour { public Slider progressSlider; public Text progressText; public string sceneKey Scenes/Level_Forest; public void LoadLevel() { StartCoroutine(LoadSceneWithProgress(sceneKey)); } IEnumerator LoadSceneWithProgress(string key) { var handle Addressables.LoadSceneAsync(key, LoadSceneMode.Single); while (!handle.IsDone) { float p Mathf.Clamp01(handle.PercentComplete); progressSlider.value p; progressText.text $场景加载 {(p * 100f):F0}%; yield return null; } if (handle.Status AsyncOperationStatus.Succeeded) { // 新场景已经激活此时释放旧场景句柄防止 Old Bundle 占用内存 Addressables.Release(handle); } else { Debug.LogError($场景加载失败: {handle.OperationException}); } } }这里的LoadSceneMode.Single会在加载完成时卸载旧场景。把Addressables.Release(handle)放在 Succeeded 之后是给旧场景的 Addressable 资源做收尾场景本身已经由场景系统接管不会因为释放句柄而被卸载。3.3 WebGL 上把进度拆成“下载依赖 加载场景”两段如果你把上面的代码打包到 WebGL并且场景的第一个 Bundle 是从远程 CDN 拉取的就会发现问题进度条在前 1 秒几乎不动。因为LoadSceneAsync是先下载依赖、再加载场景下载阶段和加载阶段混在一个 PercentComplete 里。更稳的做法是显式调用DownloadDependenciesAsync把两个阶段分开给玩家一个更真实的反馈const float downloadWeight 0.7f; IEnumerator LoadSceneWithTwoStageProgress(string key) { // 第一阶段把场景依赖的远程 Bundle 全部拉下来 var downloadHandle Addressables.DownloadDependenciesAsync(key); while (!downloadHandle.IsDone) { var status downloadHandle.GetDownloadStatus(); float percent status.Percent; progressSlider.value percent * downloadWeight; progressText.text $下载 {(percent * 100f):F0}%; yield return null; } if (downloadHandle.Status ! AsyncOperationStatus.Succeeded) { Debug.LogError($依赖下载失败: {downloadHandle.OperationException}); yield break; } // 第二阶段加载场景本体进度从 70% 继续走到 100% var sceneHandle Addressables.LoadSceneAsync(key, LoadSceneMode.Single); while (!sceneHandle.IsDone) { float p Mathf.Clamp01(sceneHandle.PercentComplete); progressSlider.value downloadWeight (1f - downloadWeight) * p; progressText.text $加载场景 {(p * 100f):F0}%; yield return null; } if (sceneHandle.Status AsyncOperationStatus.Succeeded) { Addressables.Release(sceneHandle); progressText.text 进入场景; } else { Debug.LogError($场景加载失败: {sceneHandle.OperationException}); } }这段代码的关键点是GetDownloadStatus()。DownloadStatus 会返回 TotalBytes 和 DownloadedBytesPercent 就是两者的比值它反映的是真实下载量而不是内部句柄的加权估算。把下载阶段映射到 0~70%加载阶段映射到 70%~100%看起来会比单个 PercentComplete 顺滑很多。WebGL 在浏览器里会把下载完的 Bundle 缓存进 IndexedDB第二次进同一个场景时下载阶段会极快跳过进度条会直接进入加载场景阶段如果你看到两次运行进度条节奏不一样多半是浏览器缓存生效了。3.4 进度条文本别每帧刷新WebGL 上尤其如此大多数示例代码里progressText.text ${...}%写在 while 循环内部这在 PC 上没有感觉在 WebGL 上会拖慢帧率。因为浏览器环境没有真正的多线程字符串拼接和 UGUI 脏矩形重建每帧都在和主线程抢 CPU。进度条本身用 Slider.value 每帧赋值没问题但文本建议 0.1 秒更新一次float lastUpdateTime 0f; while (!handle.IsDone) { if (Time.unscaledTime - lastUpdateTime 0.1f) { lastUpdateTime Time.unscaledTime; progressSlider.value handle.PercentComplete; progressText.text ${(handle.PercentComplete * 100f):F0}%; } yield return null; }这里的Time.unscaledTime不受游戏暂停影响加载场景时如果有人改过 Time.timeScale用 unscaledTime 更保险。文本 0.1 秒刷新一次人眼完全感知得到帧率却能省下一大批重建开销。4. Addressables WebGL 避坑清单卡 90%、内存翻倍、包体没变小怎么办下面是几条我从 WebGL 发布项目里一层层排查出来的经验每条按“现象 → 原因 → 解决”的顺序写基本覆盖了进度条上线后最常见的几类翻车。4.1 进度条卡在 90% 后不动玩家以为白屏了现象进度条一路走到 90% 左右然后停住短则两三秒长则十秒以上操作也没反应。原因有两类。一类是场景激活前的卡顿也就是 LoadSceneAsync 在等待旧场景卸载和新场景激活WebGL 单线程下这个过程会占满主线程。另一类是资源反序列化Shader 变体编译和材质加载都在这个阶段执行Bundle 越大卡得越久。解决先在场景激活前给玩家一个过渡提示把 90% 到 100% 改成“进入场景中”或者用一个循环转圈动画替代精确百分比。如果卡顿集中在某个大 Bundle用 Addressables Profiler 看是哪个资源拖慢把大资源拆小或者提前在下载阶段预加载。进度条卡住本身不可怕可怕的是没有任何反馈让玩家以为崩了。4.2 多次切场景后浏览器内存一路涨最后崩溃现象连续切换三四个场景后浏览器标签页的内存占用一路增长最终白屏或者直接提示页面无响应。原因旧场景的 Addressable 句柄没释放。LoadSceneAsync 用 Single 模式确实会卸载旧场景但旧场景用到的 AssetBundle 不会自动卸载因为 Addressables 不知道你是否还要复用这些资源。越切场景驻留的无主 Bundle 越多。解决保存旧场景的加载句柄新场景激活后再Addressables.Release(旧句柄)。如果旧场景是通过InstantiateAsync生成的物体还需要遍历场景里的物体调Addressables.ReleaseInstance否则即使句柄释放了实例和资源仍然被引用。这里可以配合内存分析在浏览器 DevTools 里拍一个 Heap Snapshot能看到大量 2~5MB 的 ArrayBuffer基本就是没释放的 Bundle。4.3 包体优化没有生效远程资源全部打进了首包现象发布到 WebGL 后首包体积和原本打包前差不多甚至更大。打开 Addressables Groups 窗口后发现所有资源都在 Default Local Group 里。原因资源没有被分配到 Remote Group。只有把资源组的 Content Packing Loading 设置改成 Remote构建时才会单独生成远程 Bundle并配上 Remote Load Path 对应的 URL。如果一直沿用默认的 Local所有资源都会打进本地 Bundle。解决打开 Addressables Groups 窗口把场景资源、关卡美术、音频这类后加载的内容移到单独的 Remote Group。Remote Build Path 指构建输出目录Remote Load Path 指运行时下载的 URL这里要填 CDN 或服务器地址。构建前用 Build Bundles 跑一遍确认输出目录里有对应.bundle文件且不在首包里。包体优化做到最后就是首包只装核心逻辑把后续关卡 Bundle 全挪到远程。4.4 粒子特效在 WebGL 上反复加载内存明显上浮现象某个场景切出去再切回来粒子特效的材质、贴图内存明显上涨多次切换后上涨趋势不回落。原因粒子特效依赖的 Shader、材质和 MonoBehaviour 很可能挂在同一个 Bundle 里。第一次加载时这些资源进入了 WebGL 的 GPU 内存第二次加载如果走了另一个分组或另一个 Catalog 入口旧的一份没有被释放两份同时驻留。粒子特效本身的内存泄漏在 WebGL 上比模拟器更隐蔽因为编辑器里 Play Mode 的内存可以快速回收浏览器不行。解决把粒子特效按场景分到独立 Group场景退出时主动卸载该 Group 的 Bundle。如果粒子特效跨场景复用就把它的 Shader 和材质放到全局常驻组只加载一次不要把同一份特效放在多个场景 Bundle 里到处复制。最关键还是释放粒子系统停掉后调用ReleaseInstance并把引用置空别等着 GC 去收WebGL 的 GC 表现和编辑器差很多。4.5 进度条到 100%画面还是白屏现象进度条走满玩家点击开始界面切到新场景但画面持续白屏没有报错。原因最常见的是场景虽然加载成功了但场景里的引导脚本在 Awake 或 OnEnable 阶段访问了尚未加载完成的资源。WebGL 上异步初始化顺序更严格如果依赖的资源还在下载中脚本会自动静默跳过初始化导致场景空转。另一个原因是手动初始化时序不对进度条走完后脚本才发现 Catalog 还没就绪。解决在场景加载前显式调用Addressables.InitializeAsync()确保 Catalog 先就绪。场景里的核心脚本不要依赖 Awake 做资源初始化改成监听SceneManager.sceneLoaded事件或者用协程等一个自定义的“场景资源就绪”标记。白屏问题排查时第一步先看 Console 有没有异常第二步检查场景中脚本的初始化顺序第三步再怀疑渲染层。5. 进度条之后的一公里用 Profiler 核对加载耗时与内存峰值进度条能跑只是及格线真正要回答的问题是加载到底慢在哪、内存峰值出在哪。WebGL 上不能用 Unity Profiler 直接抓浏览器网络线程我惯用的做法是分段打点把每一段耗时打印出来再对照资源大小判断瓶颈。using System.Diagnostics; IEnumerator LoadSceneAndLog(string key) { var sw Stopwatch.StartNew(); var downloadHandle Addressables.DownloadDependenciesAsync(key); yield return downloadHandle; sw.Stop(); Debug.Log($依赖下载: {sw.ElapsedMilliseconds} ms, 大小: {downloadHandle.GetDownloadStatus().TotalBytes / 1024f} KB); sw.Restart(); var sceneHandle Addressables.LoadSceneAsync(key, LoadSceneMode.Single); yield return sceneHandle; sw.Stop(); Debug.Log($场景加载: {sw.ElapsedMilliseconds} ms); }这种日志在 WebGL 的浏览器 Console 里直接能看到比猜进度快得多。下载阶段慢就去查 CDN 带宽和 Bundle 体积场景加载阶段慢就去查反序列化和 Shader 编译时间。我在项目里还养成了一个习惯所有 Addressables 句柄都放一个静态登记表加载时登记释放时移除每次发版前跑一遍加载逻辑看日志里有没有“句柄已释放但资源仍被引用”的警告。再验证一步在 Addressables Groups 窗口里用 Analyze 检查重复引用和不可达资源。这个工具能直接列出哪些 Bundle 被多个场景引用、哪些资源没有归属比手动翻代码可靠得多。如果以后要做微信小游戏打包这套逻辑也基本通用只是需要额外留意平台对单个 Bundle 体积的限制。进度条最终是要给玩家看的但它更是一面镜子把加载链路的每一段照得清清楚楚。希望帮到你。本文还有配套的精品资源点击获取
返回列表