
1. 项目概述为什么棋牌游戏需要模块化与热更新做棋牌游戏开发的朋友尤其是像“创胜棋牌”这类包含多种玩法如斗地主、麻将、德州扑克的项目肯定都遇到过类似的困境每次更新一个玩法哪怕只是修复一个按钮的显示问题都需要用户重新下载几百兆甚至上G的完整安装包。用户流失率有多高数据不会说谎。更头疼的是随着玩法模块越来越多主工程的代码和资源会变得无比臃肿编译一次的时间长得可以去泡杯咖啡团队协作也容易互相“踩脚”。这个项目要解决的就是这两个核心痛点。我们利用CocosCreator作为前端游戏引擎Node.js作为后端资源管理与版本控制服务构建一套多模块动态加载与热更新系统。简单来说就是把整个游戏拆分成一个“核心壳”和多个独立的“玩法模块”。核心壳永远不变包含了游戏大厅、用户系统、通用UI框架等基础功能。而每个棋牌玩法比如“广东麻将”、“欢乐斗地主”都是一个独立的模块包可以按需从服务器动态下载、加载到游戏中并且支持不重启游戏的热更新。这不仅仅是技术上的炫技它直接关系到项目的生死线。对于运营而言可以快速上线新玩法进行A/B测试也可以针对特定节日活动更新某个模块的界面和规则响应速度从“天”级别提升到“分钟”级别。对于开发而言模块解耦让团队可以并行开发斗地主组和麻将组互不干扰极大提升了开发效率。这套方案就是我们团队在实战中趟出来的一条路。2. 核心架构设计前后端分离的模块化思路要实现动态加载和热更新不能只在前端 CocosCreator 里折腾必须有一个可靠的后端来管理模块的版本、提供下载服务。我们采用的是经典的前后端分离架构。2.1 前端CocosCreator架构设计前端的核心思想是“主工程子包”。CocosCreator 本身提供了Asset Bundle机制这为我们实现模块化提供了完美的底层支持。我们将不同的棋牌游戏玩法打包成独立的 Asset Bundle。主工程Main Bundle这是用户首次下载的安装包。它体积很小只包含游戏启动器、用户登录/注册界面。游戏大厅场景、通用弹窗、网络通信框架。模块加载管理器本项目的核心脚本。一些所有模块都会用到的公共资源如通用音效、字体、基础按钮图集。子模块包Feature Bundles每个棋牌玩法都是一个独立的 Asset Bundle。例如bundle-poker: 包含所有扑克类游戏斗地主、德州扑克的场景、脚本、专属UI和动画资源。bundle-mahjong: 包含所有麻将类游戏的资源。bundle-lobby: 一个特殊的包用于更新大厅的UI皮肤或活动界面。加载流程玩家进入游戏大厅后主工程的模块管理器会向 Node.js 服务器查询当前可用的模块列表及其最新版本号。当玩家点击“进入斗地主”时管理器会检查本地是否已下载、且版本是否为最新的bundle-poker包。如果不是则触发下载流程如果是则直接加载该Bundle并跳转到斗地主场景。2.2 后端Node.js Express服务设计后端服务肩负着版本控制和资源分发的重任。我们用一个轻量级的Node.js Express框架来实现。版本清单服务提供一个接口如/api/manifest返回一个 JSON 文件。这个文件是所有模块的“总目录”记录了每个模块包Bundle的名称、版本号、MD5校验码、文件大小以及下载地址。{ bundles: { bundle-poker: { version: 1.2.0, md5: a1b2c3d4e5..., size: 5242880, url: https://your-cdn.com/bundles/bundle-poker_v1.2.0.zip }, bundle-mahjong: { version: 1.1.5, md5: f6g7h8i9j0..., size: 7340032, url: https://your-cdn.com/bundles/bundle-mahjong_v1.1.5.zip } }, engineVersion: 2.4.10 // 可选用于兼容性检查 }静态资源服务模块包.zip或.jsc等文件需要托管在服务器上。我们可以直接用 Express 的express.static中间件来提供这些文件的下载或者为了更好的性能和扩展性将其上传至阿里云OSS、腾讯云COS等对象存储服务Node.js 服务只负责返回下载链接。更新逻辑后端服务还需要一个简单的管理后台或通过命令行脚本用于上传新的模块包并自动生成/更新上面的版本清单 JSON。当开发完成一个新版本的斗地主模块后构建出bundle-poker运行部署脚本脚本会将压缩包传至CDN并更新服务器上的manifest.json文件中的版本号。注意版本清单的生成一定要自动化手动修改极易出错。我们通常会在 CI/CD 流程如 Jenkins 或 GitHub Actions中在构建完成后自动执行部署脚本。3. 关键技术实现与代码解析理论讲清楚了我们来看具体怎么实现。这里会给出核心环节的代码示例和关键配置。3.1 CocosCreator 项目配置与模块打包首先在 CocosCreator 编辑器中配置 Asset Bundle。创建模块文件夹在assets目录下为每个模块创建独立的文件夹例如assets/bundle-poker,assets/bundle-mahjong。配置 Bundle打开项目设置 - 资源管理器 - Asset Bundle。点击“添加”新建一个 Bundle名称设为bundle-poker根目录选择刚才创建的assets/bundle-poker文件夹。重复此步骤创建bundle-mahjong等。关键设置压缩类型选择Zip。这是热更新的前提因为我们需要下载的是压缩包。配置为远程包务必勾选。这告诉 CocosCreator 此 Bundle 不会打包在主工程里需要从远程服务器加载。勾选后该 Bundle 在构建时会生成在独立的remote目录下。构建项目构建时选择发布平台如 Web Mobile。在构建模板中选择default或link如果希望代码分包。点击构建。完成后打开构建目录你会发现除了常见的web-mobile文件夹还有一个remote文件夹。里面正是你配置的所有远程 Bundle例如remote/bundle-poker。3.2 Node.js 后端服务搭建我们创建一个简单的server.js文件。// server.js const express require(express); const fs require(fs).promises; const path require(path); const app express(); const PORT 3000; // 托管静态资源存放模块包的目录 // 假设我们把构建好的 remote 文件夹整个放到了服务器的 public 目录下 app.use(/remote, express.static(path.join(__dirname, public/remote))); // 读取并返回版本清单 app.get(/api/manifest, async (req, res) { try { const manifestPath path.join(__dirname, data, manifest.json); const data await fs.readFile(manifestPath, utf8); const manifest JSON.parse(data); // 可以在这里根据请求头等信息返回差异化的清单如灰度发布 res.json(manifest); } catch (error) { console.error(读取清单失败:, error); res.status(500).json({ error: Failed to load manifest }); } }); // 一个简单的管理接口用于触发清单更新生产环境需要加鉴权 app.post(/admin/update-manifest, async (req, res) { // 这里应该调用一个脚本去扫描 public/remote 下的文件 // 计算MD5生成新的 manifest.json // 示例中我们简化处理 console.log(手动触发清单更新...); // ... 调用生成脚本的逻辑 ... res.json({ message: Update triggered }); }); app.listen(PORT, () { console.log(资源服务器运行在 http://localhost:${PORT}); console.log(清单地址: http://localhost:${PORT}/api/manifest); });你需要一个data/manifest.json文件。这个文件可以通过一个独立的脚本generate-manifest.js来生成确保准确性。// generate-manifest.js const fs require(fs).promises; const path require(path); const crypto require(crypto); const fsExtra require(fs-extra); // 需要安装: npm install fs-extra async function calculateMD5(filePath) { const fileBuffer await fs.readFile(filePath); const hash crypto.createHash(md5); hash.update(fileBuffer); return hash.digest(hex); } async function generateManifest() { const remoteRoot path.join(__dirname, public/remote); const manifest { bundles: {}, engineVersion: 2.4.10 }; const bundleDirs await fs.readdir(remoteRoot); for (const dir of bundleDirs) { const bundlePath path.join(remoteRoot, dir); const stat await fs.stat(bundlePath); if (!stat.isDirectory()) continue; // 寻找 .zip 文件CocosCreator构建的Bundle压缩包 const files await fs.readdir(bundlePath); const zipFile files.find(f f.endsWith(.zip)); if (!zipFile) { console.warn(在目录 ${dir} 中未找到.zip文件跳过。); continue; } const fullPath path.join(bundlePath, zipFile); const stats await fs.stat(fullPath); const md5 await calculateMD5(fullPath); manifest.bundles[dir] { version: 1.0.0, // 版本号应从package.json或构建配置中读取这里简化 md5: md5, size: stats.size, url: http://localhost:3000/remote/${dir}/${zipFile} // 生产环境替换为你的CDN域名 }; } await fsExtra.outputJson(path.join(__dirname, data, manifest.json), manifest, { spaces: 2 }); console.log(Manifest 生成成功); } generateManifest().catch(console.error);实操心得版本号管理是门学问。我们团队采用[主版本].[功能版本].[热修复版本]的规则并与 Git Tag 关联。generate-manifest.js脚本会读取模块目录下的version.txt或解析package.json来获取准确版本避免人工错误。3.3 CocosCreator 前端加载管理器实现这是前端的核心代码我们创建一个BundleManager.ts脚本。// BundleManager.ts import { _decorator, Component, assetManager, AssetManager } from cc; const { ccclass, property } _decorator; ccclass(BundleManager) export class BundleManager extends Component { private static _instance: BundleManager null; public static get instance(): BundleManager { return this._instance; } // 本地存储的清单信息用于对比更新 private localManifest: any null; // 服务器最新的清单信息 private serverManifest: any null; // 需要更新的Bundle列表 private updatingBundles: Setstring new Set(); protected onLoad(): void { if (BundleManager._instance) { this.destroy(); return; } BundleManager._instance this; // 从本地缓存加载旧的清单 this.loadLocalManifest(); // 开始检查更新 this.checkForUpdates(); } // 从本地存储如localStorage加载清单 private loadLocalManifest() { const localManifestStr cc.sys.localStorage.getItem(game_manifest); if (localManifestStr) { try { this.localManifest JSON.parse(localManifestStr); } catch (e) { console.error(解析本地清单失败, e); this.localManifest { bundles: {} }; } } else { this.localManifest { bundles: {} }; } } // 保存清单到本地存储 private saveLocalManifest(manifest: any) { cc.sys.localStorage.setItem(game_manifest, JSON.stringify(manifest)); this.localManifest manifest; } // 1. 检查更新获取服务器清单并比较 public async checkForUpdates(): Promiseboolean { console.log(开始检查资源更新...); try { const response await fetch(http://localhost:3000/api/manifest); // 替换为你的服务器地址 this.serverManifest await response.json(); } catch (error) { console.error(获取服务器清单失败:, error); // 可以在这里触发重试逻辑或降级处理如使用本地缓存的Bundle return false; } const updates: string[] []; const serverBundles this.serverManifest.bundles; for (const [bundleName, serverInfo: any] of Object.entries(serverBundles)) { const localInfo this.localManifest.bundles[bundleName]; // 如果本地没有记录或者版本号不同或者MD5不同则需要更新 if (!localInfo || localInfo.version ! serverInfo.version || localInfo.md5 ! serverInfo.md5) { updates.push(bundleName); this.updatingBundles.add(bundleName); } } if (updates.length 0) { console.log(发现 ${updates.length} 个模块需要更新:, updates); // 可以在这里显示一个更新提示UI让用户选择是否现在更新 this.showUpdateDialog(updates); return true; // 有更新 } else { console.log(所有模块均为最新版本。); return false; // 无更新 } } // 2. 执行更新下载并加载新的Bundle public async updateBundle(bundleName: string): Promiseboolean { if (!this.serverManifest) { await this.checkForUpdates(); } const bundleInfo this.serverManifest.bundles[bundleName]; if (!bundleInfo) { console.error(服务器清单中未找到模块: ${bundleName}); return false; } console.log(开始更新模块: ${bundleName}, 版本: ${bundleInfo.version}); // 显示下载进度条 this.showDownloadProgress(bundleName, 0); return new Promise((resolve, reject) { // CocosCreator 的 assetManager 支持直接加载远程Bundle assetManager.loadBundle(bundleInfo.url, { version: bundleInfo.version }, (err, bundle) { if (err) { console.error(加载模块 ${bundleName} 失败:, err); this.showDownloadProgress(bundleName, -1); // 显示失败 reject(err); return; } console.log(模块 ${bundleName} 加载成功); // 更新本地清单中该模块的信息 this.localManifest.bundles[bundleName] { version: bundleInfo.version, md5: bundleInfo.md5, size: bundleInfo.size }; this.saveLocalManifest(this.localManifest); this.updatingBundles.delete(bundleName); this.showDownloadProgress(bundleName, 100); // 显示完成 resolve(true); }); }); } // 3. 加载已存在的Bundle本地或已更新 public async loadBundle(bundleName: string): PromiseAssetManager.Bundle | null { // 首先尝试从已加载的Bundle中查找 let bundle assetManager.getBundle(bundleName); if (bundle) { console.log(模块 ${bundleName} 已在内存中。); return bundle; } // 如果正在更新等待更新完成 if (this.updatingBundles.has(bundleName)) { console.log(模块 ${bundleName} 正在更新请稍候...); // 这里可以实现一个等待机制比如返回一个Promise在updateBundle完成后resolve return new Promise((resolve) { const checkInterval setInterval(() { if (!this.updatingBundles.has(bundleName)) { clearInterval(checkInterval); this.loadBundle(bundleName).then(resolve); } }, 500); }); } // 检查本地清单看是否有该Bundle的记录 const localInfo this.localManifest.bundles[bundleName]; if (localInfo) { // 如果有记录说明已经下载过尝试从本地缓存加载 // CocosCreator 的 loadBundle 会优先查找本地缓存 console.log(从本地缓存加载模块: ${bundleName}); return new Promise((resolve, reject) { assetManager.loadBundle(bundleName, (err, bundle) { if (err) { console.warn(从缓存加载模块 ${bundleName} 失败尝试重新下载, err); // 加载失败可能缓存损坏触发更新 this.updateBundle(bundleName).then(success { if (success) this.loadBundle(bundleName).then(resolve).catch(reject); else reject(new Error(Failed to load and update bundle: ${bundleName})); }); } else { resolve(bundle); } }); }); } else { // 本地没有记录直接触发下载更新 console.log(首次下载模块: ${bundleName}); const success await this.updateBundle(bundleName); if (success) { return this.loadBundle(bundleName); // 递归调用这次应该能从缓存加载了 } else { throw new Error(无法下载模块: ${bundleName}); } } } // 4. 使用Bundle加载资源例如场景 public async loadScene(bundleName: string, sceneName: string): Promisevoid { try { const bundle await this.loadBundle(bundleName); if (!bundle) { throw new Error(Bundle ${bundleName} not available.); } return new Promise((resolve, reject) { bundle.loadScene(sceneName, (err, scene) { if (err) { reject(err); return; } cc.director.runScene(scene); resolve(); }); }); } catch (error) { console.error(加载场景 ${sceneName} 失败:, error); // 这里应该给用户一个友好的错误提示 } } // --- UI 相关方法示例--- private showUpdateDialog(bundles: string[]) { // 实现一个模态对话框显示需要更新的模块列表和总大小让用户确认 console.log(UI: 显示更新对话框, bundles); // 假设用户点击了“立即更新” this.startUpdatingAll(bundles); } private showDownloadProgress(bundleName: string, percent: number) { // 更新UI上的进度条percent为-1表示失败100表示完成 console.log(UI: 模块 ${bundleName} 下载进度: ${percent}%); } private async startUpdatingAll(bundles: string[]) { for (const bundleName of bundles) { await this.updateBundle(bundleName); } console.log(所有模块更新完成); // 可以在这里触发一个全局事件通知游戏可以进入大厅了 } }将这个脚本挂载到游戏启动场景的一个常驻节点上。游戏启动时BundleManager会自动检查更新。当玩家点击某个游戏按钮时调用BundleManager.instance.loadScene(bundle-poker, poker-lobby)即可。4. 动态加载与热更新的核心流程详解让我们把上面的代码串起来看看一个完整的“点击斗地主图标到进入游戏”的流程。启动与初始化游戏启动BundleManager的onLoad被调用。它从localStorage读取上次保存的localManifest然后调用checkForUpdates()向http://your-server/api/manifest请求最新的清单。清单比对将服务器返回的serverManifest与localManifest逐条对比。发现bundle-poker的版本号从本地的1.1.0变成了服务器的1.2.0MD5也不同。于是将其加入待更新列表。用户交互界面上弹出一个提示框“发现斗地主模块有更新约5MB是否现在下载”。用户点击“确定”。下载与加载BundleManager调用updateBundle(bundle-poker)。该方法使用assetManager.loadBundle并传入服务器上.zip包的完整URL。CocosCreator 引擎会处理网络下载、解压、缓存到本地文件系统对于Web平台是IndexedDB等一系列复杂操作。同时我们通过回调或事件可以更新UI进度条。缓存与记录下载加载成功后引擎会将该Bundle缓存到本地。同时我们将serverManifest中关于bundle-poker的最新信息version, md5更新到localManifest并保存回localStorage。这就是热更新的关键下次游戏启动时localManifest里的版本号已经是最新的1.2.0不会再触发更新。加载场景当更新完成或本地已是最新版本时调用loadScene(bundle-poker, poker-lobby)。loadBundle方法会先查找内存再根据Bundle名从本地缓存加载最后返回Bundle对象。再用这个Bundle对象加载具体的场景。场景切换完成玩家进入最新的斗地主游戏房间。“热”体现在哪里在整个流程中除了可能下载新Bundle文件外游戏主进程大厅从未关闭重启。模块的加载、替换是在内存中动态完成的。对于玩家来说可能只是看到了一个下载进度条然后按钮就亮了点击即可进入新版本的游戏。注意事项对于原生平台iOS/Android动态加载远程代码JavaScript可能会受到应用商店审核政策或系统安全限制。通常纯资源纹理、动画、配置的热更新没有问题但包含逻辑的脚本热更新需要更谨慎的设计可能需要配合引擎的字节码或使用其他脚本系统。5. 实战中遇到的坑与解决方案这套系统听起来美好但在实际落地中我们踩了不少坑。这里分享几个最有代表性的。问题一清单文件被浏览器缓存导致客户端无法获取最新版本。现象服务器明明更新了manifest.json但部分玩家手机上的游戏始终不触发更新。原因浏览器或WebView对GET /api/manifest这个接口响应进行了缓存。解决方案服务器端在 Express 接口中设置响应头禁止缓存。app.get(/api/manifest, async (req, res) { // ... 读取manifest ... res.setHeader(Cache-Control, no-store, no-cache, must-revalidate, proxy-revalidate); res.setHeader(Pragma, no-cache); res.setHeader(Expires, 0); res.json(manifest); });客户端在请求清单时添加一个随机参数如时间戳来强制跳过缓存。const timestamp new Date().getTime(); const response await fetch(http://your-server/api/manifest?t${timestamp});问题二模块间依赖和公共资源重复。现象bundle-poker和bundle-mahjong都用了同一套音效文件导致两个模块包体积都很大。解决方案抽取公共Bundle在 CocosCreator 中创建一个bundle-common将共享的资源通用UI图集、公共音效、网络协议定义文件等放进去。在主工程和所有子模块中都可以通过assetManager.loadBundle先加载bundle-common再加载具体模块。依赖声明在模块的package.json或自定义配置中声明其依赖的公共Bundle。BundleManager在加载某个模块前先检查并加载其依赖的公共模块。问题三更新过程中网络中断或失败。现象下载到90%突然断网整个模块损坏下次启动也无法使用。解决方案实现断点续传和完整性校验。CocosCreator 的assetManager在原生平台上对远程Bundle的下载支持有限。对于大型模块我们采用了更底层的jsb.AssetsManager或自行实现一个下载器。完整性校验每个模块包下载完成后立即计算其本地文件的MD5与manifest.json中记录的MD5对比。如果不一致则删除损坏文件记录失败次数下次重试。重试机制为每个模块下载设置最大重试次数如3次。全部失败后标记该模块为“不可用”并提示用户检查网络。问题四版本兼容性冲突。现象更新了bundle-poker到 v2.0.0其内部脚本调用了主工程v1.0.0中一个已废弃的API导致运行时错误。解决方案契约化接口主工程与模块之间通过定义清晰的、稳定的API接口进行通信例如使用全局事件、或一个专门的ModuleBridge单例。模块只允许通过这些接口与主工程交互避免直接调用内部函数。版本约束在manifest.json中增加minEngineVersion模块要求的最低主工程版本和engineVersion当前主工程版本字段。模块加载前进行校验如果不满足则阻止加载并提示用户升级主App。向后兼容主工程的更新应尽量向后兼容。如果必须做出破坏性更新可以考虑在过渡期同时维护新旧两套接口或者通过配置开关来控制。6. 性能优化与进阶技巧当模块数量多、用户网络环境复杂时以下优化能显著提升体验。差分更新增量更新痛点模块v1.1.0到v1.2.0可能只改了几个文件但用户需要重新下载整个10MB的zip包。方案在服务器端构建时不仅生成完整包还生成一个与上一版本的差分包.patch。客户端manifest.json中同时提供完整包URL和差分包URL。BundleManager根据本地已有版本智能选择下载差分包体积小然后在本地与旧文件合并生成新版本文件。这需要服务器端和客户端都实现一套差分算法如bsdiff复杂度较高但对流量敏感的场景收益巨大。预加载与懒加载结合预加载玩家在游戏大厅时可以静默在后台下载最热门的几个模块如斗地主、麻将这样当玩家点击时几乎无需等待。懒加载对于不常用的模块如某些比赛场仅在玩家第一次点击时才开始下载。BundleManager可以设计一个优先级队列来管理下载任务。本地缓存清理策略随着版本迭代本地会堆积很多旧版本的模块文件。可以定期如每启动5次或在检测到本地存储空间不足时触发清理逻辑。规则可以是保留当前使用的版本删除所有其他版本的缓存文件。assetManager提供了removeBundle的API。使用CDN加速将所有的模块.zip文件放到阿里云OSS、腾讯云COS等对象存储服务并开启CDN加速。这能极大提升全国乃至全球用户的下载速度。Node.js 服务器只负责提供轻量的manifest.json接口。监控与统计在BundleManager的关键节点开始下载、下载成功、下载失败、MD5校验失败埋点将数据上报到你的游戏统计后台。这能帮助你发现哪个模块的更新失败率高、哪个地区用户下载速度慢等问题以便针对性优化。这套基于 CocosCreator 和 Node.js 的动态加载与热更新方案经过我们多个棋牌项目的验证是稳定且高效的。它不仅仅适用于棋牌游戏任何需要模块化、快速迭代的中大型 CocosCreator 项目都可以借鉴其核心思想。最关键的是它把更新的主动权从应用商店手中夺了回来交给了开发和运营团队这对于追求敏捷和用户体验的项目来说价值不可估量。