情怀棋牌源代码工程实践(2/3):大厅容器化、模块注册与按需热更新
花了半年时间把这套体量巨大的项目修到能稳定跑起来。最大的感受是:大厅只做“目录服务”,模块像“插件”一样可插拔,更新只追内容不追时间。把这三件事打牢,后面 600+ 模块才不会失控。下面把我落地的一整套做法与代码摊开讲,能直接拷到工程里跑。
1. 思路先行:大厅=目录服务,模块=插件
-
大厅不参与重计算,只负责入口编目、UI 承载与权限/能力判断。
-
每个模块自带
meta.json描述自身:入口脚本、资源包名、能力要求、图标等。 -
模块通过 注册中心 暴露给大厅;真正进入时再懒加载 AssetBundle。
-
资源与代码按公共依赖与热度/字母拆包;大厅启动包要尽量小。
目录大致这样:
client/
hall/
UINewMain.ts
module-registry.ts
ui/
ItemModule.prefab
modules/
alpha/ # 示例模块
meta.json
MainAlpha.ts
beta/
meta.json
assets/
bundles/
common/ # 公共资源(字体/图集/音效/基础UI)
shared/
src/ # 协议、确定性内核、能力常量
2. 模块元数据规范(meta.json)
{
"name": "alpha",
"bundle": "modules_alpha",
"entry": "MainAlpha",
"icon": "icons/alpha.png",
"caps": ["bundle-v2", "det-core>=1.7.3"],
"desc": "示例交互模块",
"tags": ["练习", "标准规则"]
}
校验与默认值:
// client/hall/meta.ts
export type ModuleMeta = {
name: string; bundle: string; entry: string;
icon?: string; caps?: string[]; desc?: string; tags?: string[];
};
export function normalize(m: Partial<ModuleMeta>): ModuleMeta {
if (!m.name || !m.bundle || !m.entry) throw new Error('bad meta');
return { icon: '', caps: [], desc: '', tags: [], ...m } as ModuleMeta;
}
3. 注册中心与能力协商
// client/hall/module-registry.ts
import { normalize, ModuleMeta } from './meta';
type CapChecker = (caps: string[]) => boolean;
export class ModuleRegistry {
private map = new Map<string, ModuleMeta>();
private allow: CapChecker = () => true;
useCapability(checker: CapChecker) { this.allow = checker; }
register(meta: Partial<ModuleMeta>) {
const m = normalize(meta);
if (!this.allow(m.caps || [])) return; // 不满足能力要求则不展示
this.map.set(m.name, m);
}
list() { return [...this.map.values()].sort((a, b) => a.name.localeCompare(b.name)); }
get(name: string) { return this.map.get(name); }
}
export const registry = new ModuleRegistry();
能力判断(与服务端配置结合):
// client/hall/caps.ts
export function makeCapChecker(clientCaps: string[], serverCaps: string[]) {
const have = new Set([...clientCaps, ...serverCaps]);
return (need: string[] = []) => need.every(req => {
if (req.includes('>=')) {
const [key, min] = req.split('>=');
const cur = [...have].find(s => s.startsWith(key));
return !!cur && cur.localeCompare(`${key}${min}`) >= 0;
}
return have.has(req);
});
}
启动阶段加载 meta 列表:
// client/hall/bootstrap.ts
import { registry } from './module-registry';
import { makeCapChecker } from './caps';
export async function bootstrap() {
const serverCaps = await fetch('/config/caps.json').then(r => r.json()); // 远端开关
const clientCaps = ['bundle-v2', 'det-core>=1.7.3'];
registry.useCapability(makeCapChecker(clientCaps, serverCaps));
const metas: any[] = await fetch('/config/modules/index.json').then(r => r.json());
for (const url of metas) {
const meta = await fetch(url).then(r => r.json());
registry.register(meta);
}
}
4. 大厅 UI:即点即载

// client/hall/UINewMain.ts
const { ccclass, property } = cc._decorator;
import { registry } from './module-registry';
@ccclass
export default class UINewMain extends cc.Component {
@property(cc.Node) grid!: cc.Node;
@property(cc.Prefab) itemPrefab!: cc.Prefab;
@property(cc.Node) loading!: cc.Node;
@property(cc.ProgressBar) bar!: cc.ProgressBar;
async onLoad() {
await (window as any).__BOOT__?.(); // 调 bootstrap,提前准备注册表
this.render();
}
private render() {
this.grid.removeAllChildren();
for (const m of registry.list()) {
const it = cc.instantiate(this.itemPrefab);
it.getChildByName('Label').getComponent(cc.Label)!.string = m.name;
it.on(cc.Node.EventType.TOUCH_END, () => this.enter(m.bundle, m.entry));
this.grid.addChild(it);
}
}
private enter(bundleName: string, entry: string) {
this.loading.active = true; this.bar.progress = 0;
cc.assetManager.loadBundle(bundleName, (err, bundle) => {
if (err) return this.fail(err);
bundle.preload(entry, cc.Prefab, (c, t, i) => {
this.bar.progress = t ? i / t : 0;
}, () => {
bundle.load(entry, cc.Prefab, (e, prefab: cc.Prefab) => {
if (e) return this.fail(e);
this.loading.active = false;
const node = cc.instantiate(prefab);
cc.director.getScene().addChild(node);
});
});
});
}
private fail(e: any) {
this.loading.active = false;
cc.log('load failed:', e);
// 这里可以弹降级提示或回滚到稳定版本
}
}
网格虚拟化(模块很多时):
// client/hall/virtual-grid.ts
@ccclass export default class VirtualGrid extends cc.Component {
@property(cc.ScrollView) sv!: cc.ScrollView;
@property(cc.Prefab) cell!: cc.Prefab;
@property ccSize: cc.Size = cc.size(220, 110);
private items: any[] = []; private cells: cc.Node[] = [];
private cols = 5; private height = 0;
setData(items: any[]) {
this.items = items;
const rows = Math.ceil(items.length / this.cols);
this.height = rows * (this.ccSize.height + 16);
this.sv.content.height = this.height;
this.updateVisible();
}
updateVisible() {
const y = this.sv.getScrollOffset().y;
const vh = this.sv.node.height;
const first = Math.max(0, Math.floor(y / (this.ccSize.height + 16)) - 2);
const last = Math.min(Math.ceil((y + vh) / (this.ccSize.height + 16)) + 2, Math.ceil(this.items.length / this.cols));
const need = (last - first) * this.cols;
while (this.cells.length < need) this.cells.push(cc.instantiate(this.cell));
this.cells.forEach((n, idx) => {
const r = first + Math.floor(idx / this.cols);
const c = idx % this.cols;
const i = r * this.cols + c;
if (!n.parent) this.sv.content.addChild(n);
n.active = i < this.items.length;
if (!n.active) return;
n.setPosition(c * (this.ccSize.width + 16) + 140, this.height - r * (this.ccSize.height + 16) - 80);
n.getChildByName('Label').getComponent(cc.Label)!.string = this.items[i].name;
n.off(cc.Node.EventType.TOUCH_END);
n.on(cc.Node.EventType.TOUCH_END, () => this.node.emit('enter', this.items[i]));
});
}
}
5. 资源切包与公共依赖抽取
5.1 目录与命名
assets/bundles/
common/ # 字体、通用图集、音效、基础控件
modules_alpha/
modules_beta/
theme_light/
theme_dark/
-
公共资源统一放
common,模块包禁止私带公共文件。 -
主题资源(皮肤)独立为可选包,避免大厅启动就全载入。
-
模块命名
modules_xxx,不要与任意公共包重名。
5.2 重复资源扫描(脚本)
// tools/check-dup.js
const fs = require('fs'), path = require('path'), crypto = require('crypto');
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true })
.flatMap(e => e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]);
}
function hash(fp) { return crypto.createHash('md5').update(fs.readFileSync(fp)).digest('hex'); }
const base = path.resolve('./build/bundles');
const files = walk(base).filter(f => /\.(png|jpg|mp3|ttf|fnt|json)$/i.test(f));
const map = new Map();
for (const f of files) {
const h = hash(f);
if (!map.has(h)) map.set(h, []);
map.get(h).push(f);
}
for (const [h, list] of map.entries()) {
if (list.length > 1) console.log('[DUP]', h, '\n ', list.join('\n '), '\n');
}
6. 按需热更新:只追“内容哈希”
6.1 生成清单与签名
// tools/gen-manifest.js
const fs = require('fs'), path = require('path'), crypto = require('crypto');
function walk(dir){ return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e => e.isDirectory()?walk(path.join(dir,e.name)):[path.join(dir,e.name)]); }
function md5(fp){ return crypto.createHash('md5').update(fs.readFileSync(fp)).digest('hex'); }
function hmac(content, key){ return crypto.createHmac('sha256', key).update(content).digest('hex'); }
const base = path.resolve(process.argv[2] || './build'); // Creator 输出
const out = path.resolve(process.argv[3] || './dist/manifest.json');
const key = process.env.MANIFEST_KEY || 'dev-key';
const files = walk(base).map(f => f.replace(base + path.sep, '').replace(/\\/g, '/'));
const table = Object.fromEntries(files.map(f => [f, md5(path.join(base, f))]));
const manifest = { version: Date.now(), files: table, signature: hmac(JSON.stringify(table), key) };
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, JSON.stringify(manifest, null, 2));
console.log('manifest:', out);
6.2 客户端增量下载与校验
// client/net/hot-update.ts
type Manifest = { version: number; files: Record<string,string>; signature: string };
function hmacSim(s: string){ // 示例:请替换真实HMAC
return cc.js.formatStr('%s-%d', cc.js.hash(s).toString(16), s.length);
}
export async function hotUpdate(baseUrl: string) {
const remote: Manifest = await fetch(baseUrl + '/manifest.json').then(r => r.json());
if (remote.signature !== hmacSim(JSON.stringify(remote.files))) throw new Error('bad signature');
const local: Manifest = JSON.parse(localStorage.getItem('manifest') || '{"version":0,"files":{}}');
const diff = Object.entries(remote.files).filter(([f,h]) => local.files[f] !== h).map(([f]) => f);
let ok = 0;
for (const f of diff) {
await download(baseUrl + '/' + f, f); // 断点续传请结合 Range/ETag
ok++;
cc.systemEvent.emit('hot-progress', ok / diff.length);
}
localStorage.setItem('manifest', JSON.stringify(remote));
}
async function download(url: string, rel: string) {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error('download fail: ' + url);
const buf = await res.arrayBuffer();
// H5 可放 indexedDB,原生平台写入沙箱;此处省略存储实现
}
6.3 断点续传的简易 Node 静态服务(支持 Range)
// server/static-range.js
const http = require('http'), fs = require('fs'), path = require('path');
const ROOT = path.resolve('./cdn');
http.createServer((req, res) => {
const file = path.join(ROOT, decodeURIComponent(req.url || '/'));
if (!fs.existsSync(file)) return res.writeHead(404).end();
const stat = fs.statSync(file);
const range = req.headers.range;
if (range) {
const [s, e] = range.replace(/bytes=/, '').split('-').map(Number);
const start = isNaN(s) ? 0 : s;
const end = isNaN(e) ? stat.size - 1 : e;
res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${stat.size}` });
fs.createReadStream(file, { start, end }).pipe(res);
} else {
res.writeHead(200, { 'Content-Length': stat.size });
fs.createReadStream(file).pipe(res);
}
}).listen(8088, () => console.log('cdn on :8088'));
7. Creator 工程:Prefab、Widget 与对象池
7.1 Prefab 组织与命名
-
大厅:
UINewMain.prefab只含顶部栏、公告条、模块网格、底部菜单。 -
子组件:
UIHeader.prefab / UINotifyBar.prefab / UIGrid.prefab可复用。 -
入口按钮 Prefab 只包含
Icon与Label,事件在父级统一绑定。
7.2 分辨率适配(1360×760 为编辑基准)
// client/hall/adapt.ts
export function adapt(canvas: cc.Canvas) {
const r = canvas.node.width / canvas.node.height;
const BASE = 1360 / 760;
const narrow = r < BASE;
const header = cc.find('Canvas/Header').getComponent(cc.Widget)!;
header.top = narrow ? 4 : 20; header.isAlignHorizontalCenter = true;
const grid = cc.find('Canvas/Grid').getComponent(cc.Widget)!;
grid.left = narrow ? 8 : 40; grid.right = narrow ? 8 : 40; grid.top = 90; grid.bottom = 30;
}
7.3 对象池与泄漏观察
// client/engine/node-pool.ts
export class NodePool {
private pool: cc.Node[] = [];
constructor(private prefab: cc.Prefab, private cap = 64) {}
get(){ return this.pool.pop() || cc.instantiate(this.prefab); }
put(n: cc.Node){ if (this.pool.length < this.cap) this.pool.push(n); else n.destroy(); }
}
// client/engine/leak-watch.ts
const weak = new WeakSet<object>();
export function track(o: object){ weak.add(o); }
export function audit(){
// 实际工程可结合节点树快照与自定义句柄表,定期比对未释放对象
cc.log('leak-watch tick');
}
8. 灰度与回滚:把“风险”做成开关
-
大厅启动时拉取 远端配置,根据账号/平台/网络条件决定展示哪些模块、拿哪个 bundle 版本。
-
本地始终保留一个可用上版;新包加载失败直接回退。
-
每个 bundle 附带
build-info.json,崩溃日志能定位到具体提交。
// client/net/config.ts
export type BuildInfo = { commit: string; version: number; builtAt: string };
export async function fetchBuildInfo(bundle: string): Promise<BuildInfo> {
try {
const info = await fetch(`/cdn/${bundle}/build-info.json`).then(r => r.json());
return info;
} catch {
return { commit: 'unknown', version: 0, builtAt: new Date(0).toISOString() };
}
}
9. 真实坑位与修复策略(踩过再说)
-
循环依赖
大厅组件引用模块通用工具,模块又回引大厅 UI,构建后互指导致运行时空对象。做法:把通用工具抽到client/common/,同时在 tsconfig 里禁止路径回到大厅。 -
包内资源私带公共图集
初期很多模块各自带了一份公共图集,启动后纹理内存炸。脚本扫描 + 构建阶段检测,统一改为引用bundles/common。 -
Promise 悬挂
模块切场景时未取消的then()导致回调落到无效节点。改成AbortController/手动标记取消;或在onDestroy里统一off。 -
更新回退不彻底
仅回退某模块包,没有同步回退 manifest,导致下一次仍然以新清单比对。修复:回退操作必须同时回写manifest与本地 bundle 版本号。
10. 清单(可直接落地)
-
模块
meta.json规范与注册中心 -
能力协商(客户端/服务端双因子)
-
大厅即点即载 + 进度条 + 网格虚拟化
-
资源切包与重复扫描脚本
-
清单式热更新(生成脚本 + 客户端增量下载)
-
Range 静态服务(断点续传)
-
Prefab/Widget 规范 + 对象池 + 泄漏观察
-
灰度/回滚与 build-info 关联
下一篇把指标观测、异常聚合、发布证据链、确定性回归流水线、构建脚本与 CI/CD完整串起来,给出一套真正可上线的“工程闭环”。
文章纯技术交流,不涉及商用,转载请注明出处!
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)