1. 项目概述与核心价值做游戏开发尤其是用Cocos Creator资源加载是绕不开的一环。新手和老手都会遇到一个共同的痛点游戏启动时或者进入一个新场景时屏幕一片黑或者卡在一个静态LOGO界面用户完全不知道发生了什么只能干等。运气好等个三五秒运气不好遇到网络波动或者资源较大十几秒的“黑屏”足以让玩家失去耐心直接关掉游戏。这就是为什么一个清晰、流畅、能提供有效反馈的加载进度条不仅仅是“锦上添花”而是直接影响用户体验和留存率的“雪中送炭”。今天要聊的就是如何在Cocos Creator里从零开始实现一个专业级的资源加载进度条。这个进度条不是简单的cc.loader回调里改个数字那么简单我们要实现的是异步加载与实时状态反馈的深度结合。这意味着进度条不仅要能显示百分比还要能告诉用户当前在加载什么比如“正在加载场景纹理...”、“正在下载音频文件...”甚至在网络不佳时给出友好提示。我们会用到Cocos Creator内置的cc.resources或兼容老项目的cc.loader加载系统结合Promise或async/await进行流程控制最终打造一个稳定、可复用、视觉效果不错的加载管理器。无论你是正在开发你的第一款Cocos小游戏还是在优化一个已有项目的体验这套方案都能给你直接的参考。我会把原理讲透把代码掰开揉碎更重要的是分享那些官方文档里不会写的、我在实际项目中踩过的坑和总结出的最佳实践。让我们开始吧。2. 核心设计思路与方案选型在动手写代码之前我们先得把设计思路理清楚。一个健壮的加载进度系统核心目标就两个不阻塞主线程和提供尽可能多的有效反馈。2.1 为什么必须是“异步加载”在Cocos Creator中资源加载默认就是异步的。但“异步”不代表万事大吉。常见的错误做法是在一个update循环里疯狂地调用cc.resources.load或者用一串.then把几十个资源的加载链起来这很容易导致加载队列混乱、内存瞬间飙升或者回调地狱。我们需要的是一种可控的、队列化的异步加载。理想状态是有一个加载管理器LoadingManager它内部维护一个待加载的资源列表。管理器每次从列表中取出一个或一组资源发起异步加载请求。当这组加载完成后更新进度然后继续下一组。这样做的好处是负载平滑避免瞬间发起大量网络请求或IO操作导致卡顿。精准控制可以方便地在每组加载前后插入自定义逻辑如显示提示文本。错误隔离一组资源加载失败不影响其他组的加载流程便于错误处理。2.2 状态反馈的维度设计进度条如果只显示一个干巴巴的百分比信息量是远远不够的。我们应该设计多维度反馈总体进度最经典的百分比数字和进度条填充。这是必须的。当前任务描述用文字告诉用户“正在加载什么”比如“Home场景 (3/5)”、“英雄角色贴图”。这能极大缓解用户在等待时的焦虑感。次级进度对于单个大资源如一个包含大量子资源的Bundle可以显示其内部加载进度。速度/预估时间高级一点可以计算加载速度并预估剩余时间但这需要更稳定的网络环境作为前提在移动端波动较大实现需谨慎。视觉动效进度条的运动曲线、粒子效果、背景动画等让等待过程不那么枯燥。我们的方案将重点实现前三个维度第四个维度会简要讨论思路第五个则提供一些美术层面的建议。2.3 技术方案选型cc.resourcesvscc.assetManagerCocos Creator在2.4版本引入了新的资源管理系统cc.assetManager它比老的cc.loader功能更强大尤其是对分包Bundle的支持更好。但考虑到项目兼容性和学习曲线我们以cc.resources它是cc.assetManager的一个特殊Bundle为主进行讲解其原理和API与cc.assetManager加载Bundle是相通的。选择cc.resources的理由API简洁直观对于大多数不涉及复杂分包的项目cc.resources.loadDir,cc.resources.load足够使用。兼容性好从Cocos Creator 2.x到3.x的版本cc.resources都是推荐方式。概念清晰便于我们聚焦于加载流程本身而不是Bundle的管理细节。当然如果你的项目已经使用了多个自定义Bundle那么将本方案中的cc.resources替换为cc.assetManager.getBundle(‘bundleName’)是轻而易举的核心的队列管理和状态反馈逻辑完全通用。3. 构建加载管理器核心类设计接下来我们开始实现核心的LoadingManager。这个类将是一个单例负责统筹所有的加载任务。3.1 定义加载任务与状态首先我们需要定义“任务”是什么。一个任务可能是一个单独的资源也可能是一整个目录。// 定义单个加载任务项的类型 export interface ILoadTaskItem { type: ‘single’ | ‘dir’; // 加载类型单个资源 或 整个目录 urls: string | string[]; // 资源路径。single时为stringdir时为string[] bundle?: string; // 所属Bundle名默认为‘resources’ onProgress?: (completedCount: number, totalCount: number, item: any) void; // 单个任务的进度回调 onComplete?: (error: Error, assets: any) void; // 单个任务完成回调 } // 定义加载管理器的状态 export interface ILoadingStatus { totalTaskCount: number; // 总任务数 completedTaskCount: number; // 已完成任务数 currentTaskDesc: string; // 当前任务描述 overallProgress: number; // 总体进度 (0-1) subProgress: number; // 当前任务的次级进度 (0-1) isCompleted: boolean; // 是否全部完成 }3.2 实现LoadingManager核心逻辑下面是LoadingManager类的骨架实现包含了核心的队列执行逻辑。export class LoadingManager { private static _instance: LoadingManager null; private _taskQueue: ILoadTaskItem[] []; // 任务队列 private _status: ILoadingStatus; // 当前状态 private _onStatusUpdate: (status: ILoadingStatus) void; // 状态更新回调 public static getInstance(): LoadingManager { if (!this._instance) { this._instance new LoadingManager(); } return this._instance; } private constructor() { this.resetStatus(); } // 重置状态 private resetStatus(): void { this._status { totalTaskCount: 0, completedTaskCount: 0, currentTaskDesc: ‘’, overallProgress: 0, subProgress: 0, isCompleted: false }; } // 添加一个加载任务到队列 public addTask(task: ILoadTaskItem): void { this._taskQueue.push(task); } // 批量添加任务 public addTasks(tasks: ILoadTaskItem[]): void { this._taskQueue.push(...tasks); } // 设置状态更新回调UI层通过这个回调更新进度条 public setOnStatusUpdate(callback: (status: ILoadingStatus) void): void { this._onStatusUpdate callback; } // 开始执行队列中的所有任务核心方法 public async startLoading(): Promisevoid { this.resetStatus(); this._status.totalTaskCount this._taskQueue.length; for (let i 0; i this._taskQueue.length; i) { const task this._taskQueue[i]; this._status.currentTaskDesc 正在加载任务 ${i 1}/${this._status.totalTaskCount}; this._updateStatus(); // 通知UI更新 try { await this._executeSingleTask(task); this._status.completedTaskCount; this._status.overallProgress this._status.completedTaskCount / this._status.totalTaskCount; this._status.subProgress 0; // 当前任务完成次级进度归零 this._updateStatus(); } catch (error) { console.error(‘加载任务失败:’, task, error); // 这里可以加入更复杂的错误处理逻辑比如重试或跳过 // 为了演示我们直接抛出停止加载 throw new Error(任务加载失败: ${task.urls}, ${error.message}); } } this._status.isCompleted true; this._status.currentTaskDesc ‘加载完成’; this._updateStatus(); this._taskQueue []; // 清空队列 } // 执行单个任务 private async _executeSingleTask(task: ILoadTaskItem): Promiseany { return new Promise((resolve, reject) { const bundle task.bundle ? cc.assetManager.getBundle(task.bundle) : cc.resources; if (!bundle) { reject(new Error(Bundle不存在: ${task.bundle})); return; } let loader: any; if (task.type ‘dir’) { // 加载目录 loader bundle.loadDir(task.urls as string[], task.onProgress, (error, assets) { if (task.onComplete) task.onComplete(error, assets); if (error) reject(error); else resolve(assets); }); } else { // 加载单个资源 loader bundle.load(task.urls as string, task.onProgress, (error, asset) { if (task.onComplete) task.onComplete(error, asset); if (error) reject(error); else resolve(asset); }); } // 关键将loader的进度同步到subProgress if (loader task.onProgress undefined) { // 如果用户没有自定义进度回调我们默认绑定一个来更新次级进度 const originalOnProgress loader.onProgress; loader.onProgress (completedCount: number, totalCount: number, item: any) { this._status.subProgress totalCount 0 ? completedCount / totalCount : 0; this._updateStatus(); if (originalOnProgress) originalOnProgress(completedCount, totalCount, item); }; } }); } // 触发状态更新 private _updateStatus(): void { if (this._onStatusUpdate) { // 深拷贝状态避免外部修改内部数据 this._onStatusUpdate({...this._status}); } } }代码关键点解析单例模式确保全局只有一个加载管理器方便在任何地方访问和控制。任务队列使用数组_taskQueue维护一个FIFO先进先出队列startLoading方法会顺序执行。异步流程控制startLoading方法使用async/await让代码逻辑保持清晰的线性结构避免了回调地狱。双进度反馈overallProgress代表整体任务进度subProgress代表当前正在执行的那个任务的内部进度比如一个目录里有多少个文件加载完了。这是实现细腻反馈的关键。错误处理每个任务用try-catch包裹单个任务失败不会导致整个加载崩溃我们可以根据业务需求决定是重试、跳过还是报错。注意在实际项目中_executeSingleTask方法里的loader.onProgress绑定方式可能需要根据Cocos Creator的具体版本微调。有些版本中load或loadDir返回的不是一个cc.AssetManager.Task对象而是直接是Promise。如果是Promise我们可以用await配合一个自定义的包装函数来模拟进度。核心思想不变尽一切可能获取次级进度信息。4. 打造动态进度条UI与控制器管理器有了我们需要一个UI来展示状态。在Cocos Creator编辑器中我们创建一个名为LoadingScene的场景并布置以下节点bg(Sprite)背景图。progressBar(ProgressBar)Cocos Creator自带的进度条组件。barSprite(Sprite)作为进度填充的精灵。txtProgress(Label)显示百分比文本如“75%”。txtDesc(Label)显示当前任务描述如“正在加载角色动画...”。LoadingCtrl(Node挂载自定义组件)这个节点将承载我们的UI控制逻辑。4.1 UI控制器组件实现在LoadingCtrl组件中我们需要绑定UI元素并订阅LoadingManager的状态更新。const { ccclass, property } cc._decorator; ccclass export default class LoadingCtrl extends cc.Component { property(cc.ProgressBar) progressBar: cc.ProgressBar null; property(cc.Label) txtProgress: cc.Label null; property(cc.Label) txtDesc: cc.Label null; property(cc.Node) // 可选一个旋转的加载图标 loadingIcon: cc.Node null; private _loadingManager: LoadingManager null; onLoad() { this._loadingManager LoadingManager.getInstance(); // 订阅状态更新 this._loadingManager.setOnStatusUpdate(this._onLoadingStatusUpdate.bind(this)); // 初始化UI this._updateUI({ totalTaskCount: 0, completedTaskCount: 0, currentTaskDesc: ‘准备加载...’, overallProgress: 0, subProgress: 0, isCompleted: false }); if (this.loadingIcon) { cc.tween(this.loadingIcon) .by(1, { angle: 360 }) .repeatForever() .start(); } } // 外部调用的入口开始加载流程 public async startLoadingTasks(tasks: ILoadTaskItem[]): Promisevoid { this._loadingManager.addTasks(tasks); try { await this._loadingManager.startLoading(); this._onAllCompleted(); } catch (error) { this._onLoadFailed(error); } } // 状态更新回调 private _onLoadingStatusUpdate(status: ILoadingStatus): void { this._updateUI(status); } // 更新所有UI元素 private _updateUI(status: ILoadingStatus): void { // 计算综合进度整体进度占70%权重当前任务次级进度占30%权重使进度条更平滑 const combinedProgress status.overallProgress * 0.7 status.subProgress * 0.3; // 更新进度条使用缓动动画避免突兀跳动 cc.tween(this.progressBar.node) .to(0.3, { ‘progressBar.progress‘: combinedProgress }, { easing: ‘sineOut’ }) .start(); // 更新文本 if (this.txtProgress) { this.txtProgress.string ${Math.floor(combinedProgress * 100)}%; } if (this.txtDesc) { this.txtDesc.string status.currentTaskDesc; } } // 全部加载完成 private _onAllCompleted(): void { this.txtDesc.string ‘资源加载完毕’; // 可以在这里触发场景跳转比如延迟1秒后进入游戏主场景 this.scheduleOnce(() { cc.director.loadScene(‘MainScene’); }, 1); } // 加载失败处理 private _onLoadFailed(error: Error): void { console.error(‘加载流程失败:’, error); this.txtDesc.string 加载遇到问题: ${error.message}请检查网络或重启应用; this.txtProgress.string ‘失败’; // 可以显示一个重试按钮 // ... } }4.2 进度条视觉平滑技巧上面的_updateUI方法中有一个关键点进度计算与动画平滑。权重混合combinedProgress overallProgress * 0.7 subProgress * 0.3。为什么不直接用overallProgress因为任务之间的粒度可能不同。一个任务可能瞬间完成subProgress从0到1很快导致overallProgress阶梯式跳跃进度条会“卡顿”一下然后突然前进一段。混合subProgress后在单个任务执行期间进度条也能有平滑的连续运动体验更好。缓动动画使用cc.tween对progressBar.progress进行0.3秒的动画过渡。直接赋值会让进度条“瞬移”而一个短暂的动画过渡sineOut缓动让进度条的运动更自然、更有质感。这是专业级进度条和业余实现的细微差别对用户体验提升显著。实操心得进度条的速度感知设计。根据心理学中的“峰终定律”用户对等待时间的感知并非线性。我们可以让进度条在前70%走得稍快后30%走得稍慢并在最后100%时有一个短暂的“完成确认”动画如进度条满后闪烁一下或放大这会让用户感觉加载完成得更“扎实”减少了虚假进度条带来的不信任感。这可以通过修改缓动曲线或分段设置动画时间来模拟。5. 实战配置与启动加载流程现在我们把所有部分串联起来。假设我们有一个启动场景StartScene上面有一个“开始游戏”按钮。点击按钮后跳转到LoadingScene并开始加载游戏必需资源。5.1 定义加载任务清单我们创建一个专门的配置模块来管理需要预加载的资源这有利于维护。// LoadTaskConfig.ts import { ILoadTaskItem } from ‘./LoadingManager’; export const PRELOAD_TASKS: ILoadTaskItem[] [ { type: ‘dir’, urls: ‘textures/ui’, // 预加载所有UI纹理 onComplete: (err, assets) { console.log(‘UI纹理加载完成’, assets?.length); } }, { type: ‘dir’, urls: ‘sounds/bgm’, // 预加载背景音乐 onComplete: (err, assets) { console.log(‘BGM加载完成’); } }, { type: ‘single’, urls: ‘prefabs/hero’, // 预加载英雄预制体 onComplete: (err, asset) { console.log(‘英雄预制体加载完成’); } }, { type: ‘dir’, urls: ‘animations/effects’, // 预加载特效动画 onComplete: (err, assets) { console.log(‘特效动画加载完成’); } }, // ... 更多任务 ];5.2 启动场景的逻辑在StartScene的按钮回调中// StartSceneCtrl.ts import { PRELOAD_TASKS } from ‘./LoadTaskConfig’; ccclass export default class StartSceneCtrl extends cc.Component { onStartGameClick() { // 可以先播放一个按钮音效或动画 // ... // 跳转到加载场景并将任务列表作为参数传递 // 注意实际传递复杂参数需要用到常驻节点或全局变量这里为简化使用一个全局临时变量 (window as any).__PRELOAD_TASKS PRELOAD_TASKS; cc.director.loadScene(‘LoadingScene’); } }5.3 加载场景的逻辑优化修改LoadingCtrl的onLoad或start方法使其能接收并开始任务。// LoadingCtrl.ts 补充 export default class LoadingCtrl extends cc.Component { // ... 其他代码不变 start() { // 从全局变量获取任务并开始加载 const tasks (window as any).__PRELOAD_TASKS; if (tasks tasks.length 0) { this.startLoadingTasks(tasks); } else { console.warn(‘没有预加载任务直接进入游戏’); this._onAllCompleted(); } // 清理全局变量 delete (window as any).__PRELOAD_TASKS; } }6. 高级技巧与性能优化基础功能实现后我们来看看如何让它更强大、更稳健。6.1 处理大量小资源分帧加载如果你的资源列表包含成百上千个小文件比如大量配置表或图标一次性发起所有加载请求可能会在几帧内产生大量回调造成卡顿。这时可以使用分帧加载。思路是修改LoadingManager的_executeSingleTask或任务队列循环不是一次完成一个loadDir而是将一个大目录拆分成多个小批次在不同帧中执行。private async _executeSingleTask(task: ILoadTaskItem): Promiseany { if (task.type ‘dir’ (task.urls as string[]).length 50) { // 如果目录预估资源很多采用分帧加载 return this._loadDirInBatches(task); } else { // 原有逻辑 return this._loadNormally(task); } } private _loadDirInBatches(task: ILoadTaskItem): Promiseany { return new Promise(async (resolve, reject) { const allAssets []; const batchSize 20; // 每批加载20个 const urls task.urls as string[]; for (let i 0; i urls.length; i batchSize) { const batchUrls urls.slice(i, i batchSize); // 使用Promise.all并发加载这一批但一批完成后等待下一帧 const batchPromises batchUrls.map(url new Promise((res, rej) { cc.resources.load(url, (err, asset) { if (err) rej(err); else res(asset); }); }) ); try { const batchAssets await Promise.all(batchPromises); allAssets.push(...batchAssets); // 更新次级进度 this._status.subProgress (i batchSize) / urls.length; this._updateStatus(); // **关键每加载完一批让出主线程一帧** await this._waitForNextFrame(); } catch (error) { reject(error); return; } } resolve(allAssets); }); } private _waitForNextFrame(): Promisevoid { return new Promise(resolve { this.scheduleOnce(() resolve(), 0); }); }6.2 进度“假死”与心跳检测有时加载一个大文件如视频或大型纹理时网络IO会阻塞导致很长时间subProgress不更新进度条停滞用户会认为程序卡死了。解决方案是加入“心跳”或“脉冲”动画。即使进度数值没变我们也可以让进度条上的某个元素比如光点、粒子持续运动或者让百分比数字轻微抖动向用户传达“程序仍在工作”的信号。可以在LoadingCtrl的update方法中实现一个独立于真实进度的小动画update(dt: number) { // 如果最近0.5秒内真实进度没有变化就启动“心跳”动画 if (Date.now() - this._lastProgressUpdateTime 500) { this._showLoadingPulse(); } } private _showLoadingPulse(): void { // 例如让进度条上的一个光点来回移动或者让进度条背景亮度周期性变化 if (!this._isPulsing) { this._isPulsing true; cc.tween(this.progressBar.barSprite.node) .to(0.5, { scale: 1.05 }, { easing: ‘sineInOut’ }) .to(0.5, { scale: 1.0 }, { easing: ‘sineInOut’ }) .call(() { this._isPulsing false; }) .start(); } }6.3 资源加载的优先级与依赖更复杂的项目可能需要考虑加载优先级。比如首屏必需的UI资源优先级为高远处场景的模型优先级为低。我们可以在ILoadTaskItem中增加一个priority字段并在LoadingManager的addTask时根据优先级对_taskQueue进行排序。对于资源依赖比如预制体依赖精灵图Cocos Creator的加载系统会自动处理。但如果你需要显式控制加载顺序可以通过任务编排来实现将依赖资源作为前置任务。7. 常见问题排查与实战技巧在实际开发中你肯定会遇到各种各样的问题。这里我总结了一份“避坑指南”。7.1 进度条卡在某个百分比不动检查点1任务是否真的完成了在onComplete回调里加日志确认每个任务都正确触发完成回调。有时资源路径错误会导致静默失败。检查点2次级进度回调绑定是否成功如之前提到的不同Cocos Creator版本中loader.onProgress的可用性不同。如果subProgress永远为0进度条就会卡住。备用方案是如果检测不到进度回调就模拟一个从0到1的线性动画至少让进度条动起来。检查点3是否有同步阻塞操作确保在加载过程中没有执行大量的同步计算比如在主线程序里解析巨大的JSON这会阻塞UI更新。7.2 加载完成后进度条到100%但场景不切换检查点1_onAllCompleted回调是否被触发在方法内第一行加console.log确认。检查点2场景名是否正确cc.director.loadScene(‘MainScene’)中的‘MainScene’必须与构建后assets目录下的.scene文件名称完全一致大小写敏感。检查点3是否在Web平台检查浏览器控制台是否有CORS跨域错误。如果你用file://协议打开或本地服务器设置不当加载资源可能会失败。使用Cocos Creator自带的预览服务器或配置正确的本地服务器如Live Server可以解决。7.3 内存占用过高或增长异常检查点1是否重复加载资源确保相同的资源URL没有被多次添加到任务列表。cc.resources会对加载过的资源进行缓存但重复的加载请求依然会消耗网络和解析时间。检查点2加载完成后是否释放了加载界面资源在跳转到新场景前可以手动调用cc.resources.release(‘loading_scene_assets’)来释放加载场景独有的资源如图片、字体。但注意不要释放即将在新场景中要用到的公共资源。检查点3使用cc.assetManager的引用计数功能。对于更精细的内存管理可以迁移到cc.assetManager它提供了基于引用的自动释放功能。7.4 在真机上特别是低端安卓机加载慢或卡顿技巧1启用资源压缩与缓存。在Cocos Creator构建面板中确保开启了MD5 Cache和资源压缩如Web平台用Brotli或gzip。技巧2控制并发量。通过前面提到的分帧加载减少单帧内的IO压力。技巧3使用更小的纹理格式。对于安卓大量使用ASTC或ETC2纹理格式比PNG体积小很多加载更快。技巧4首包优化。将非首屏必需的资源放到远程服务器或分包中通过网络按需加载减少初始包体。7.5 进度条数值回退或抖动这通常是由于overallProgress和subProgress计算或混合方式有误。确保completedTaskCount只增不减。在任务失败重试的逻辑里不要减少这个计数。subProgress在单个任务开始时重置为0在任务完成后也重置为0避免上一个任务的进度影响下一个。使用我推荐的权重混合公式能有效平滑因任务粒度不均带来的跳跃感。8. 扩展实现带有时效预估的智能进度条最后作为进阶内容我们聊聊如何实现“预计剩余时间”。这个功能要谨慎使用因为网络速度的不确定性很大预估不准反而会引发用户反感。一个相对稳妥的实现思路是计算一个移动平均加载速度。记录数据在每个任务完成时记录该任务加载的数据量可通过cc.assetManager的getInfoWithPath等方法估算或自己维护一个资源大小表和所花费的时间。计算平均速度维护一个最近N个任务的平均速度队列比如最近5个平均速度 总加载数据量 / 总耗时。预估剩余时间剩余时间 剩余总数据量估算 / 当前平均速度。显示优化不要显示精确到秒的数字而是显示一个范围或模糊表述如“约需2-3分钟”、“很快完成…”。可以在UI上用一个逐渐缩短的虚线或另一个进度条来表示预估时间。这个功能实现起来代码量较大且严重依赖资源大小信息的准确性对于动态下载的资源尤其困难。因此我建议除非有强烈需求否则优先把基础进度条做稳定、做美观这比一个可能不准的“倒计时”更能提升用户体验。我个人在实际项目中的体会是一个加载系统最重要的不是功能有多炫酷而是稳定、流畅、反馈及时。把上面提到的核心管理器、双进度反馈、平滑动画和错误处理做好你的游戏在加载体验上就已经超过市面上很多产品了。剩下的高级功能可以根据项目需求和团队能力逐步叠加。希望这篇近万字的拆解能帮你彻底掌握Cocos Creator资源加载进度条的实现精髓。