1. 项目概述为什么你需要一个UI框架如果你正在用CocosCreator做游戏尤其是那种界面稍微复杂一点的比如带主城、背包、商城、任务列表的RPG或者卡牌游戏那你大概率经历过这种痛苦场景里UI节点乱成一团脚本之间互相引用像蜘蛛网打开一个弹窗要写一堆active true/false关闭时还得手动管理资源释放更别提界面之间的跳转和数据通信了。这些“脏活累活”不仅消耗大量开发时间也让代码变得难以维护和扩展。这就是为什么我们需要一个专门的UI框架。它不是一个炫酷的渲染特效而是一套工程化的解决方案核心目标是管理复杂度。一个好的UI框架能帮你把界面开发从“手工作坊”升级到“标准化流水线”。它规定了界面如何加载、显示、隐藏、销毁如何传递数据如何管理层级甚至如何做界面间的通信。当你和团队都遵循这套规范时你会发现新增一个界面就像搭积木一样简单修改一个功能也不会牵一发而动全身。市面上的UI框架很多有重量级的如FairyGUI也有各种社区自研的轻量级方案。今天我们要深入探讨的是如何在CocosCreator中从零开始理解和构建一套适合自己项目的、专业的UI框架解决方案。这不仅仅是介绍一个现成的轮子更是带你理解轮子为什么这么造以及如何根据你的项目“路况”来调整轮子的花纹。2. 核心设计思路从“散装”界面到“框架化”管理在动手写代码之前我们必须想清楚框架要解决哪些核心问题。一个典型的游戏UI生命周期包括加载 - 初始化 - 显示 - 交互 - 刷新 - 隐藏 - 销毁。框架的设计就是围绕这个生命周期建立一套可靠的管控机制。2.1 核心需求解析首先我们拆解一下一个专业UI框架必须满足的几大核心需求界面生命周期管理这是框架的基石。需要明确定义每个界面从创建到销毁的各个阶段如onLoad,onShow,onHide,onDestroy并提供对应的钩子函数让开发者注入逻辑。框架需要确保这些生命周期被稳定、有序地调用。资源加载与释放UI通常关联着预制体Prefab、图集、音效等资源。框架必须提供便捷的资源加载接口并更重要的是要能自动化或半自动化地管理资源释放防止内存泄漏。这是很多新手项目性能问题的根源。界面层级与排序游戏界面有严格的视觉层级比如全屏背景、主界面、弹窗、提示框、加载遮罩、系统公告等。框架需要提供一个清晰的层级枚举如UILayer.BACKGROUND,UILayer.POPUP,UILayer.TIPS和自动排序逻辑确保界面按正确的Z序显示。界面间通信与数据传递打开一个背包界面需要传入玩家背包数据关闭一个购买确认框需要返回购买结果。框架需要提供一套优雅的数据传递机制避免直接通过全局变量或冗长的回调函数链来通信。界面导航与历史管理类似于浏览器的“前进”、“后退”在一些复杂的UI流程中如从主城-背包-装备详情-强化界面框架可能需要记录界面打开的历史方便一键返回。通用组件与工具一些几乎每个界面都会用到的功能比如统一的关闭按钮逻辑、自适应布局组件、多语言标签、UI动画控制器等应该被抽象成通用组件由框架提供避免重复造轮子。2.2 架构选型单例管理器 vs 事件驱动确定了需求接下来是架构选型。常见的UI框架架构有两种主流思路方案一中心化的单例管理器这是最常见也最直观的做法。我们创建一个UIManager单例作为整个UI系统的总控中心。所有界面的打开、关闭、查找、层级调整都通过调用UIManager.Instance的方法来完成。优点逻辑集中入口统一对于中小型项目来说结构清晰易于理解和上手。缺点UIManager会逐渐变成一个“上帝类”随着功能增多而日益臃肿模块间耦合度较高。方案二事件驱动与模块化这种方案更强调解耦。UIManager只负责最核心的界面实例管理和层级调度。具体的界面打开指令通过发布一个特定的事件例如EventType.OPEN_VIEW, {viewName: “BagView”, data: bagData}来触发。各个系统模块如背包系统、任务系统监听这些事件并做出响应。优点耦合度极低系统间通过事件通信非常灵活便于单元测试和模块复用。缺点流程不够直观调试时事件流可能难以追踪对团队的设计规范要求更高。对于大多数CocosCreator项目尤其是团队规模中等或以下的情况我强烈推荐采用一种“改良的中心化单例管理器”方案。即在方案一的基础上引入事件机制来处理非强制的界面间通信如界面状态变化通知同时保持核心生命周期的管理权在管理器手中。这样在简单和灵活之间取得了较好的平衡。3. 核心模块实现详解有了设计思路我们开始动手实现框架的核心模块。我会以TypeScript为例进行说明因为强类型在框架开发中能极大提升代码的健壮性和可维护性。3.1 基础数据结构与枚举定义首先定义一些基础类型和枚举这是框架的“宪法”。// 定义UI层级数值代表基础的Z序偏移量 export enum UILayer { BACKGROUND 0, // 背景层如全屏场景UI NORMAL 1000, // 普通层主界面、常驻UI POPUP 2000, // 弹窗层各种功能弹窗 TIPS 3000, // 提示层飘字、系统提示 LOADING 4000, // 加载遮罩层最高级 MAX 9999 } // 界面配置接口通常由一个配置表或装饰器生成 export interface IViewConfig { prefabPath: string; // 预制体资源路径 layer: UILayer; // 所属层级 cache?: boolean; // 是否缓存实例关闭时不销毁 } // 界面基类所有游戏界面都需要继承自此 export abstract class BaseView extends cc.Component { // 界面唯一标识通常用类名或配置表ID public viewName: string ; // 界面所属层级 protected layer: UILayer UILayer.NORMAL; // 界面是否可见active可能为true但被其他界面遮挡 protected isVisible: boolean false; // 生命周期资源加载后节点创建时调用 abstract onInit(data?: any): void; // 生命周期界面显示时调用可能是从缓存中恢复 abstract onShow(data?: any): void; // 生命周期界面隐藏时调用 abstract onHide(): void; // 生命周期界面被销毁时调用对于缓存界面关闭时可能不调用 abstract onDestroy(): void; // 提供一个便捷的关闭自身的方法 public closeSelf(): void { UIManager.getInstance().closeView(this.viewName); } }3.2 UIManager单例的实现这是框架的心脏。我们一步步实现它的关键功能。export class UIManager { private static _instance: UIManager; public static getInstance(): UIManager { if (!this._instance) { this._instance new UIManager(); } return this._instance; } // 根节点所有UI都挂载在这个节点下 private uiRoot: cc.Node null; // 各层级的容器节点 private layerNodes: MapUILayer, cc.Node new Map(); // 界面配置映射表 [viewName - IViewConfig] private viewConfigMap: Mapstring, IViewConfig new Map(); // 当前已打开的界面映射表 [viewName - BaseView] private openedViews: Mapstring, BaseView new Map(); // 缓存起来的界面实例 [viewName - BaseView] private cachedViews: Mapstring, BaseView new Map(); // 初始化UI根节点和层级容器 public init(uiRoot: cc.Node): void { this.uiRoot uiRoot; // 为每个UI层级创建空的容器节点并设置好Z序 for (let layer in UILayer) { if (isNaN(Number(layer))) continue; let layerNum Number(layer); let node new cc.Node(Layer_${UILayer[layerNum]}); node.parent this.uiRoot; node.setPosition(0, 0); // 设置Z序确保层级高的显示在上方 node.zIndex layerNum; this.layerNodes.set(layerNum, node); } console.log([UIManager] 初始化完成。); } // 注册界面配置可在游戏启动时从配置表加载 public registerView(viewName: string, config: IViewConfig): void { this.viewConfigMap.set(viewName, config); } }3.3 界面的打开、关闭与生命周期调度这是UIManager最核心的方法。打开一个界面需要处理资源加载、实例化、生命周期调用等一系列连锁反应。// 在UIManager类中继续添加 public async openView(viewName: string, data?: any, customLayer?: UILayer): PromiseBaseView { // 1. 检查配置 const config this.viewConfigMap.get(viewName); if (!config) { console.error([UIManager] 尝试打开未注册的界面: ${viewName}); return null; } // 2. 检查是否已经打开 if (this.openedViews.has(viewName)) { console.warn([UIManager] 界面 ${viewName} 已经处于打开状态将直接带到最前并刷新数据。); const view this.openedViews.get(viewName); this.bringViewToFront(view); view.onShow(data); // 重新触发onShow传入新数据 return view; } let viewInstance: BaseView null; // 3. 检查缓存 if (config.cache this.cachedViews.has(viewName)) { viewInstance this.cachedViews.get(viewName); this.cachedViews.delete(viewName); // 从缓存移到活跃集合 console.log([UIManager] 从缓存恢复界面: ${viewName}); } else { // 4. 加载预制体资源 try { const prefab await this.loadPrefab(config.prefabPath); const node cc.instantiate(prefab); viewInstance node.getComponent(BaseView); if (!viewInstance) { console.error([UIManager] 预制体 ${config.prefabPath} 未挂载BaseView派生组件。); node.destroy(); return null; } viewInstance.viewName viewName; // 调用初始化生命周期 viewInstance.onInit(data); } catch (error) { console.error([UIManager] 加载界面 ${viewName} 失败:, error); return null; } } // 5. 设置层级和父节点 const targetLayer customLayer || config.layer; const layerNode this.layerNodes.get(targetLayer); if (!layerNode) { console.error([UIManager] 找不到层级容器: ${targetLayer}); return null; } viewInstance.node.parent layerNode; viewInstance.node.active true; viewInstance[layer] targetLayer; // 设置层级信息 // 6. 加入已打开列表 this.openedViews.set(viewName, viewInstance); // 7. 调用显示生命周期 viewInstance.onShow(data); viewInstance.isVisible true; // 8. 根据层级处理可能需要的界面隐藏例如打开全屏弹窗时隐藏下层UI this.adjustViewVisibility(viewInstance); console.log([UIManager] 打开界面成功: ${viewName}); return viewInstance; } public closeView(viewName: string, isDestroy: boolean false): boolean { if (!this.openedViews.has(viewName)) { console.warn([UIManager] 尝试关闭未打开的界面: ${viewName}); return false; } const view this.openedViews.get(viewName); const config this.viewConfigMap.get(viewName); // 1. 调用隐藏生命周期 view.onHide(); view.isVisible false; // 2. 从已打开列表移除 this.openedViews.delete(viewName); // 3. 决定销毁还是缓存 if (isDestroy || !config.cache) { // 直接销毁 view.onDestroy(); view.node.destroy(); console.log([UIManager] 销毁界面: ${viewName}); } else { // 放入缓存 view.node.active false; view.node.parent null; // 从场景树移除但保留在内存 this.cachedViews.set(viewName, view); console.log([UIManager] 缓存界面: ${viewName}); } // 4. 关闭后可能需要重新显示之前被遮挡的界面 this.recoverViewVisibility(); return true; } // 将指定界面提到当前层级的最前面 private bringViewToFront(view: BaseView): void { const layerNode this.layerNodes.get(view[layer]); const children layerNode.children; const viewIndex children.indexOf(view.node); if (viewIndex -1) { view.node.setSiblingIndex(children.length - 1); } } // 调整界面可见性例如打开一个全屏POPUP时将NORMAL层的界面隐藏 private adjustViewVisibility(openingView: BaseView): void { // 这里可以实现你的遮挡逻辑。例如如果打开的是POPUP层则隐藏所有NORMAL层界面。 // 这是一个简化示例实际逻辑可能更复杂。 if (openingView[layer] UILayer.POPUP) { for (let [name, view] of this.openedViews) { if (view[layer] UILayer.NORMAL view ! openingView) { view.node.active false; // 注意这里只是隐藏节点没有触发onHide // 如果需要触发生命周期可以调用 view.onHide()但要小心状态管理。 } } } } private recoverViewVisibility(): void { // 关闭高层级界面后恢复低层级界面的显示 // 需要遍历所有打开界面判断哪些应该被恢复 // 此处省略具体实现逻辑与adjustViewVisibility相反 }注意adjustViewVisibility和recoverViewVisibility是实现“界面栈”或“模态遮挡”效果的关键但也是最容易出bug的地方。一个常见的坑是简单地用node.active来控制显示隐藏但这不会触发界面的onHide和onShow生命周期可能导致界面内部计时器、动画状态错误。更严谨的做法是维护一个“界面显示状态”的上下文在需要隐藏时调用view.onHide()并记录它本应是显示状态恢复时再调用view.onShow()。3.4 资源加载与缓存策略资源管理是UI框架的性能关键。我们上面用了loadPrefab这个异步方法下面我们实现一个带缓存和引用计数的资源管理器雏形。// 可以作为一个独立的ResourceManager这里简化为UIManager的一部分 private resourceCache: Mapstring, cc.Prefab new Map(); private refCount: Mapstring, number new Map(); // 资源引用计数 private async loadPrefab(path: string): Promisecc.Prefab { // 1. 检查内存缓存 if (this.resourceCache.has(path)) { const prefab this.resourceCache.get(path); this.refCount.set(path, (this.refCount.get(path) || 0) 1); console.log([UIManager] 从内存缓存加载资源: ${path}, 引用数: ${this.refCount.get(path)}); return prefab; } // 2. 从AssetBundle或resources加载 return new Promise((resolve, reject) { cc.resources.load(path, cc.Prefab, (err: Error, prefab: cc.Prefab) { if (err) { reject(err); return; } // 加载成功放入缓存 this.resourceCache.set(path, prefab); this.refCount.set(path, 1); console.log([UIManager] 动态加载资源成功: ${path}); resolve(prefab); }); }); } // 当界面被销毁而非缓存时需要释放资源 private releasePrefab(path: string): void { if (!this.refCount.has(path)) return; let count this.refCount.get(path) - 1; this.refCount.set(path, count); console.log([UIManager] 释放资源引用: ${path}, 剩余引用: ${count}); if (count 0) { // 引用为0从缓存中移除并允许引擎自动释放 this.resourceCache.delete(path); this.refCount.delete(path); cc.resources.release(path); console.log([UIManager] 资源已从缓存移除并释放: ${path}); } }在closeView中销毁界面时需要调用this.releasePrefab(config.prefabPath)。对于缓存界面则不能释放资源。4. 高级功能与最佳实践一个基础的框架搭建起来了但要投入生产环境我们还需要考虑更多。4.1 界面间通信事件总线与数据传递避免界面间直接引用。使用一个全局的事件总线EventBus是更解耦的方式。// 简单的事件总线实现 export class EventBus { private static listeners: Mapstring, Function[] new Map(); static on(event: string, callback: Function): void { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); } static off(event: string, callback: Function): void { const callbacks this.listeners.get(event); if (!callbacks) return; const index callbacks.indexOf(callback); if (index -1) callbacks.splice(index, 1); } static emit(event: string, ...args: any[]): void { const callbacks this.listeners.get(event); if (!callbacks) return; // 复制一份数组防止在回调中注册/移除事件导致迭代错误 callbacks.slice().forEach(cb cb(...args)); } } // 在界面中发送事件 // 背包界面卖出物品后通知主界面更新金币 EventBus.emit(ITEM_SOLD, { gold: 100 }); // 在主界面中监听 EventBus.on(ITEM_SOLD, (data) { this.updateGold(data.gold); }); // 别忘了在界面销毁时onDestroy取消监听防止内存泄漏 // EventBus.off(ITEM_SOLD, this.updateGold);对于打开界面时的数据传递我们已经在openView的data参数中实现。对于关闭界面后的回调一种更优雅的模式是使用Promise或异步函数。// 在UIManager中扩展openView返回一个包含界面实例和“等待关闭”Promise的对象 public async openViewWithResultT(viewName: string, data?: any): Promise{view: BaseView, result: T} { const view await this.openView(viewName, data); return new Promise((resolve) { // 假设我们在BaseView上增加一个closeCallback方法或事件 // 当界面关闭时会调用这个回调并传递结果 const originalOnDestroy view.onDestroy; view.onDestroy () { const result view[closeResult]; // 从界面实例上获取结果 if (originalOnDestroy) originalOnDestroy.call(view); resolve({view, result}); }; }); } // 使用示例打开一个确认框并等待用户选择 const {result} await UIManager.getInstance().openViewWithResultboolean(ConfirmPopup, {title: 确定删除}); if (result) { // 用户点击了确定 this.deleteItem(); }4.2 UI自动化测试框架集成思路结合热词“ui自动化测试框架”我们可以思考如何让我们的UI框架更易于测试。关键在于可寻址性和可模拟交互。为UI元素添加测试ID在编辑器中为重要的按钮、输入框等节点添加一个自定义组件或者利用节点的name属性赋予其唯一的测试ID如btn_submit,input_username。暴露可控的接口避免测试代码去模拟点击事件。而是让界面的关键操作如submitForm(),refreshList()成为公有方法测试代码可以直接调用。使用Page Object模式为每个界面创建一个测试类封装该界面的元素查找和操作。集成测试工具可以结合Jest、Mocha等单元测试框架以及像puppeteer或playwright的无头浏览器工具对于Web平台编写端到端的UI流测试。// 示例为登录界面编写一个简单的测试用例 describe(LoginView Test, () { let loginView: LoginView; beforeAll(async () { // 通过UIManager打开界面 loginView await UIManager.getInstance().openView(LoginView) as LoginView; }); it(should login successfully with correct credentials, async () { // 直接调用界面方法而非模拟点击 loginView.setAccount(testuser); loginView.setPassword(123456); const result await loginView.performLogin(); // performLogin返回一个Promise expect(result.success).toBe(true); expect(UIManager.getInstance().isViewOpened(MainView)).toBe(true); }); afterAll(() { UIManager.getInstance().closeView(LoginView, true); }); });4.3 与Unity UI框架的异同与借鉴热词中提到了“unity的ui框架”。以Unity的UGUI和常见框架如FairyGUI, GameFramework的UI模块为例我们可以借鉴一些成熟思想UI栈管理Unity中常用栈来管理弹窗确保关闭当前弹窗能回到上一个。我们的adjustViewVisibility可以升级为更严格的栈管理。数据绑定Unity有强大的数据绑定插件如UniRx, MVVM框架。在CocosCreator中我们可以手动实现或使用第三方库让界面自动响应数据模型的变化。动画系统集成将UI动画入场、出场、循环作为框架的一部分提供标准化的动画组件或接口让界面动效配置更简单。编辑器扩展像FairyGUI一样可以开发编辑器插件实现UI的所见即所得编辑、控件绑定、一键生成代码等功能极大提升美术和策划的工作效率。5. 常见问题与避坑指南在实际项目中应用自研UI框架一定会踩不少坑。以下是我总结的几个关键点和解决方案。5.1 内存泄漏排查这是UI框架的头号杀手。症状通常是游戏玩久了越来越卡甚至崩溃。坑1资源只加载不释放。确保releasePrefab逻辑正确并且refCount计数准确。特别注意那些不通过UIManager、而是直接cc.instantiate的预制体它们不会被框架管理。坑2事件监听未移除。在BaseView.onDestroy中必须清理所有全局事件监听、计时器this.schedule、动画回调等。一个最佳实践是在BaseView中维护一个清理函数列表。坑3节点引用残留。虽然节点被destroy了但如果其他地方还持有对它的引用比如存在一个全局数组里这部分内存也无法被回收。使用WeakMap或确保及时置空引用。排查工具善用CocosCreator的调试器 - 资源面板和性能面板观察Prefab、Texture等资源的数量和内存占用趋势。5.2 界面渲染顺序错乱有时界面会互相穿透或者子节点显示在父节点前面。原因CocosCreator的2D渲染顺序主要由node.zIndex和节点树顺序同级节点的setSiblingIndex决定。我们的layerNodes设置了基础Z序但同一层内的多个界面需要根据打开顺序动态调整它们的setSiblingIndex确保后打开的显示在前面。解决方案在openView中将新打开的界面节点通过setSiblingIndex置为当前层级容器的最后一个子节点。在bringViewToFront中也做同样操作。5.3 界面数据状态污染一个典型的场景背包界面被缓存了当你打开A角色的背包关闭再打开B角色的背包时显示的却是A角色的数据。原因缓存界面时其内部的数据状态也被保留了。onShow时没有用新数据完全重置界面。解决方案在BaseView中明确区分onInit只调用一次用于获取组件引用等初始化和onShow每次打开都可能调用用于用新数据刷新界面。对于缓存界面必须在onShow中完整地根据传入的data参数重置所有显示状态。// 在具体的界面实现中 export class BagView extends BaseView { private itemList: cc.Node[] []; private currentRoleId: number; onInit(data?: any): void { // 只做一次的事情查找节点绑定事件 this.itemList this.node.getChildByName(Content).children; this.node.getChildByName(btnClose).on(click, this.closeSelf, this); } onShow(data?: any): void { // 每次打开都要刷新 const roleId data?.roleId; if (roleId ! this.currentRoleId) { this.currentRoleId roleId; this.refreshAllItems(); // 根据新的roleId重新拉取并显示数据 } // 播放入场动画等 this.playEnterAnimation(); } onHide(): void { // 停止动画、音效等 this.stopAllAnimations(); } }5.4 异步加载导致的显示问题在资源加载完成前界面处于空白或错误状态。解决方案实现一个加载中遮罩。在UIManager.openView开始加载资源时自动打开一个位于UILayer.LOADING的透明遮罩界面可以有一个旋转的图标。在资源加载完成、界面onShow调用后再关闭这个遮罩。对于界面内部也需要异步加载大量资源的情况如滚动列表中的头像可以在界面内部实现局部的加载占位符。构建一个健壮的CocosCreator UI框架绝非一蹴而就它需要在项目迭代中不断打磨。从最基础的生命周期管理、资源缓存做起逐步加入事件通信、动画系统、数据绑定等高级特性最终形成一套契合团队工作流的完整解决方案。记住框架的目的是提升效率、降低错误而不是增加复杂度。如果你的项目非常简单几个界面而已那么直接用CocosCreator原生的节点管理也完全足够。但当界面数量超过20个交互逻辑开始复杂时一个设计良好的UI框架所带来的收益将是巨大的。
CocosCreator游戏UI框架设计:从零构建工程化解决方案
发布时间:2026/7/11 4:52:02
1. 项目概述为什么你需要一个UI框架如果你正在用CocosCreator做游戏尤其是那种界面稍微复杂一点的比如带主城、背包、商城、任务列表的RPG或者卡牌游戏那你大概率经历过这种痛苦场景里UI节点乱成一团脚本之间互相引用像蜘蛛网打开一个弹窗要写一堆active true/false关闭时还得手动管理资源释放更别提界面之间的跳转和数据通信了。这些“脏活累活”不仅消耗大量开发时间也让代码变得难以维护和扩展。这就是为什么我们需要一个专门的UI框架。它不是一个炫酷的渲染特效而是一套工程化的解决方案核心目标是管理复杂度。一个好的UI框架能帮你把界面开发从“手工作坊”升级到“标准化流水线”。它规定了界面如何加载、显示、隐藏、销毁如何传递数据如何管理层级甚至如何做界面间的通信。当你和团队都遵循这套规范时你会发现新增一个界面就像搭积木一样简单修改一个功能也不会牵一发而动全身。市面上的UI框架很多有重量级的如FairyGUI也有各种社区自研的轻量级方案。今天我们要深入探讨的是如何在CocosCreator中从零开始理解和构建一套适合自己项目的、专业的UI框架解决方案。这不仅仅是介绍一个现成的轮子更是带你理解轮子为什么这么造以及如何根据你的项目“路况”来调整轮子的花纹。2. 核心设计思路从“散装”界面到“框架化”管理在动手写代码之前我们必须想清楚框架要解决哪些核心问题。一个典型的游戏UI生命周期包括加载 - 初始化 - 显示 - 交互 - 刷新 - 隐藏 - 销毁。框架的设计就是围绕这个生命周期建立一套可靠的管控机制。2.1 核心需求解析首先我们拆解一下一个专业UI框架必须满足的几大核心需求界面生命周期管理这是框架的基石。需要明确定义每个界面从创建到销毁的各个阶段如onLoad,onShow,onHide,onDestroy并提供对应的钩子函数让开发者注入逻辑。框架需要确保这些生命周期被稳定、有序地调用。资源加载与释放UI通常关联着预制体Prefab、图集、音效等资源。框架必须提供便捷的资源加载接口并更重要的是要能自动化或半自动化地管理资源释放防止内存泄漏。这是很多新手项目性能问题的根源。界面层级与排序游戏界面有严格的视觉层级比如全屏背景、主界面、弹窗、提示框、加载遮罩、系统公告等。框架需要提供一个清晰的层级枚举如UILayer.BACKGROUND,UILayer.POPUP,UILayer.TIPS和自动排序逻辑确保界面按正确的Z序显示。界面间通信与数据传递打开一个背包界面需要传入玩家背包数据关闭一个购买确认框需要返回购买结果。框架需要提供一套优雅的数据传递机制避免直接通过全局变量或冗长的回调函数链来通信。界面导航与历史管理类似于浏览器的“前进”、“后退”在一些复杂的UI流程中如从主城-背包-装备详情-强化界面框架可能需要记录界面打开的历史方便一键返回。通用组件与工具一些几乎每个界面都会用到的功能比如统一的关闭按钮逻辑、自适应布局组件、多语言标签、UI动画控制器等应该被抽象成通用组件由框架提供避免重复造轮子。2.2 架构选型单例管理器 vs 事件驱动确定了需求接下来是架构选型。常见的UI框架架构有两种主流思路方案一中心化的单例管理器这是最常见也最直观的做法。我们创建一个UIManager单例作为整个UI系统的总控中心。所有界面的打开、关闭、查找、层级调整都通过调用UIManager.Instance的方法来完成。优点逻辑集中入口统一对于中小型项目来说结构清晰易于理解和上手。缺点UIManager会逐渐变成一个“上帝类”随着功能增多而日益臃肿模块间耦合度较高。方案二事件驱动与模块化这种方案更强调解耦。UIManager只负责最核心的界面实例管理和层级调度。具体的界面打开指令通过发布一个特定的事件例如EventType.OPEN_VIEW, {viewName: “BagView”, data: bagData}来触发。各个系统模块如背包系统、任务系统监听这些事件并做出响应。优点耦合度极低系统间通过事件通信非常灵活便于单元测试和模块复用。缺点流程不够直观调试时事件流可能难以追踪对团队的设计规范要求更高。对于大多数CocosCreator项目尤其是团队规模中等或以下的情况我强烈推荐采用一种“改良的中心化单例管理器”方案。即在方案一的基础上引入事件机制来处理非强制的界面间通信如界面状态变化通知同时保持核心生命周期的管理权在管理器手中。这样在简单和灵活之间取得了较好的平衡。3. 核心模块实现详解有了设计思路我们开始动手实现框架的核心模块。我会以TypeScript为例进行说明因为强类型在框架开发中能极大提升代码的健壮性和可维护性。3.1 基础数据结构与枚举定义首先定义一些基础类型和枚举这是框架的“宪法”。// 定义UI层级数值代表基础的Z序偏移量 export enum UILayer { BACKGROUND 0, // 背景层如全屏场景UI NORMAL 1000, // 普通层主界面、常驻UI POPUP 2000, // 弹窗层各种功能弹窗 TIPS 3000, // 提示层飘字、系统提示 LOADING 4000, // 加载遮罩层最高级 MAX 9999 } // 界面配置接口通常由一个配置表或装饰器生成 export interface IViewConfig { prefabPath: string; // 预制体资源路径 layer: UILayer; // 所属层级 cache?: boolean; // 是否缓存实例关闭时不销毁 } // 界面基类所有游戏界面都需要继承自此 export abstract class BaseView extends cc.Component { // 界面唯一标识通常用类名或配置表ID public viewName: string ; // 界面所属层级 protected layer: UILayer UILayer.NORMAL; // 界面是否可见active可能为true但被其他界面遮挡 protected isVisible: boolean false; // 生命周期资源加载后节点创建时调用 abstract onInit(data?: any): void; // 生命周期界面显示时调用可能是从缓存中恢复 abstract onShow(data?: any): void; // 生命周期界面隐藏时调用 abstract onHide(): void; // 生命周期界面被销毁时调用对于缓存界面关闭时可能不调用 abstract onDestroy(): void; // 提供一个便捷的关闭自身的方法 public closeSelf(): void { UIManager.getInstance().closeView(this.viewName); } }3.2 UIManager单例的实现这是框架的心脏。我们一步步实现它的关键功能。export class UIManager { private static _instance: UIManager; public static getInstance(): UIManager { if (!this._instance) { this._instance new UIManager(); } return this._instance; } // 根节点所有UI都挂载在这个节点下 private uiRoot: cc.Node null; // 各层级的容器节点 private layerNodes: MapUILayer, cc.Node new Map(); // 界面配置映射表 [viewName - IViewConfig] private viewConfigMap: Mapstring, IViewConfig new Map(); // 当前已打开的界面映射表 [viewName - BaseView] private openedViews: Mapstring, BaseView new Map(); // 缓存起来的界面实例 [viewName - BaseView] private cachedViews: Mapstring, BaseView new Map(); // 初始化UI根节点和层级容器 public init(uiRoot: cc.Node): void { this.uiRoot uiRoot; // 为每个UI层级创建空的容器节点并设置好Z序 for (let layer in UILayer) { if (isNaN(Number(layer))) continue; let layerNum Number(layer); let node new cc.Node(Layer_${UILayer[layerNum]}); node.parent this.uiRoot; node.setPosition(0, 0); // 设置Z序确保层级高的显示在上方 node.zIndex layerNum; this.layerNodes.set(layerNum, node); } console.log([UIManager] 初始化完成。); } // 注册界面配置可在游戏启动时从配置表加载 public registerView(viewName: string, config: IViewConfig): void { this.viewConfigMap.set(viewName, config); } }3.3 界面的打开、关闭与生命周期调度这是UIManager最核心的方法。打开一个界面需要处理资源加载、实例化、生命周期调用等一系列连锁反应。// 在UIManager类中继续添加 public async openView(viewName: string, data?: any, customLayer?: UILayer): PromiseBaseView { // 1. 检查配置 const config this.viewConfigMap.get(viewName); if (!config) { console.error([UIManager] 尝试打开未注册的界面: ${viewName}); return null; } // 2. 检查是否已经打开 if (this.openedViews.has(viewName)) { console.warn([UIManager] 界面 ${viewName} 已经处于打开状态将直接带到最前并刷新数据。); const view this.openedViews.get(viewName); this.bringViewToFront(view); view.onShow(data); // 重新触发onShow传入新数据 return view; } let viewInstance: BaseView null; // 3. 检查缓存 if (config.cache this.cachedViews.has(viewName)) { viewInstance this.cachedViews.get(viewName); this.cachedViews.delete(viewName); // 从缓存移到活跃集合 console.log([UIManager] 从缓存恢复界面: ${viewName}); } else { // 4. 加载预制体资源 try { const prefab await this.loadPrefab(config.prefabPath); const node cc.instantiate(prefab); viewInstance node.getComponent(BaseView); if (!viewInstance) { console.error([UIManager] 预制体 ${config.prefabPath} 未挂载BaseView派生组件。); node.destroy(); return null; } viewInstance.viewName viewName; // 调用初始化生命周期 viewInstance.onInit(data); } catch (error) { console.error([UIManager] 加载界面 ${viewName} 失败:, error); return null; } } // 5. 设置层级和父节点 const targetLayer customLayer || config.layer; const layerNode this.layerNodes.get(targetLayer); if (!layerNode) { console.error([UIManager] 找不到层级容器: ${targetLayer}); return null; } viewInstance.node.parent layerNode; viewInstance.node.active true; viewInstance[layer] targetLayer; // 设置层级信息 // 6. 加入已打开列表 this.openedViews.set(viewName, viewInstance); // 7. 调用显示生命周期 viewInstance.onShow(data); viewInstance.isVisible true; // 8. 根据层级处理可能需要的界面隐藏例如打开全屏弹窗时隐藏下层UI this.adjustViewVisibility(viewInstance); console.log([UIManager] 打开界面成功: ${viewName}); return viewInstance; } public closeView(viewName: string, isDestroy: boolean false): boolean { if (!this.openedViews.has(viewName)) { console.warn([UIManager] 尝试关闭未打开的界面: ${viewName}); return false; } const view this.openedViews.get(viewName); const config this.viewConfigMap.get(viewName); // 1. 调用隐藏生命周期 view.onHide(); view.isVisible false; // 2. 从已打开列表移除 this.openedViews.delete(viewName); // 3. 决定销毁还是缓存 if (isDestroy || !config.cache) { // 直接销毁 view.onDestroy(); view.node.destroy(); console.log([UIManager] 销毁界面: ${viewName}); } else { // 放入缓存 view.node.active false; view.node.parent null; // 从场景树移除但保留在内存 this.cachedViews.set(viewName, view); console.log([UIManager] 缓存界面: ${viewName}); } // 4. 关闭后可能需要重新显示之前被遮挡的界面 this.recoverViewVisibility(); return true; } // 将指定界面提到当前层级的最前面 private bringViewToFront(view: BaseView): void { const layerNode this.layerNodes.get(view[layer]); const children layerNode.children; const viewIndex children.indexOf(view.node); if (viewIndex -1) { view.node.setSiblingIndex(children.length - 1); } } // 调整界面可见性例如打开一个全屏POPUP时将NORMAL层的界面隐藏 private adjustViewVisibility(openingView: BaseView): void { // 这里可以实现你的遮挡逻辑。例如如果打开的是POPUP层则隐藏所有NORMAL层界面。 // 这是一个简化示例实际逻辑可能更复杂。 if (openingView[layer] UILayer.POPUP) { for (let [name, view] of this.openedViews) { if (view[layer] UILayer.NORMAL view ! openingView) { view.node.active false; // 注意这里只是隐藏节点没有触发onHide // 如果需要触发生命周期可以调用 view.onHide()但要小心状态管理。 } } } } private recoverViewVisibility(): void { // 关闭高层级界面后恢复低层级界面的显示 // 需要遍历所有打开界面判断哪些应该被恢复 // 此处省略具体实现逻辑与adjustViewVisibility相反 }注意adjustViewVisibility和recoverViewVisibility是实现“界面栈”或“模态遮挡”效果的关键但也是最容易出bug的地方。一个常见的坑是简单地用node.active来控制显示隐藏但这不会触发界面的onHide和onShow生命周期可能导致界面内部计时器、动画状态错误。更严谨的做法是维护一个“界面显示状态”的上下文在需要隐藏时调用view.onHide()并记录它本应是显示状态恢复时再调用view.onShow()。3.4 资源加载与缓存策略资源管理是UI框架的性能关键。我们上面用了loadPrefab这个异步方法下面我们实现一个带缓存和引用计数的资源管理器雏形。// 可以作为一个独立的ResourceManager这里简化为UIManager的一部分 private resourceCache: Mapstring, cc.Prefab new Map(); private refCount: Mapstring, number new Map(); // 资源引用计数 private async loadPrefab(path: string): Promisecc.Prefab { // 1. 检查内存缓存 if (this.resourceCache.has(path)) { const prefab this.resourceCache.get(path); this.refCount.set(path, (this.refCount.get(path) || 0) 1); console.log([UIManager] 从内存缓存加载资源: ${path}, 引用数: ${this.refCount.get(path)}); return prefab; } // 2. 从AssetBundle或resources加载 return new Promise((resolve, reject) { cc.resources.load(path, cc.Prefab, (err: Error, prefab: cc.Prefab) { if (err) { reject(err); return; } // 加载成功放入缓存 this.resourceCache.set(path, prefab); this.refCount.set(path, 1); console.log([UIManager] 动态加载资源成功: ${path}); resolve(prefab); }); }); } // 当界面被销毁而非缓存时需要释放资源 private releasePrefab(path: string): void { if (!this.refCount.has(path)) return; let count this.refCount.get(path) - 1; this.refCount.set(path, count); console.log([UIManager] 释放资源引用: ${path}, 剩余引用: ${count}); if (count 0) { // 引用为0从缓存中移除并允许引擎自动释放 this.resourceCache.delete(path); this.refCount.delete(path); cc.resources.release(path); console.log([UIManager] 资源已从缓存移除并释放: ${path}); } }在closeView中销毁界面时需要调用this.releasePrefab(config.prefabPath)。对于缓存界面则不能释放资源。4. 高级功能与最佳实践一个基础的框架搭建起来了但要投入生产环境我们还需要考虑更多。4.1 界面间通信事件总线与数据传递避免界面间直接引用。使用一个全局的事件总线EventBus是更解耦的方式。// 简单的事件总线实现 export class EventBus { private static listeners: Mapstring, Function[] new Map(); static on(event: string, callback: Function): void { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); } static off(event: string, callback: Function): void { const callbacks this.listeners.get(event); if (!callbacks) return; const index callbacks.indexOf(callback); if (index -1) callbacks.splice(index, 1); } static emit(event: string, ...args: any[]): void { const callbacks this.listeners.get(event); if (!callbacks) return; // 复制一份数组防止在回调中注册/移除事件导致迭代错误 callbacks.slice().forEach(cb cb(...args)); } } // 在界面中发送事件 // 背包界面卖出物品后通知主界面更新金币 EventBus.emit(ITEM_SOLD, { gold: 100 }); // 在主界面中监听 EventBus.on(ITEM_SOLD, (data) { this.updateGold(data.gold); }); // 别忘了在界面销毁时onDestroy取消监听防止内存泄漏 // EventBus.off(ITEM_SOLD, this.updateGold);对于打开界面时的数据传递我们已经在openView的data参数中实现。对于关闭界面后的回调一种更优雅的模式是使用Promise或异步函数。// 在UIManager中扩展openView返回一个包含界面实例和“等待关闭”Promise的对象 public async openViewWithResultT(viewName: string, data?: any): Promise{view: BaseView, result: T} { const view await this.openView(viewName, data); return new Promise((resolve) { // 假设我们在BaseView上增加一个closeCallback方法或事件 // 当界面关闭时会调用这个回调并传递结果 const originalOnDestroy view.onDestroy; view.onDestroy () { const result view[closeResult]; // 从界面实例上获取结果 if (originalOnDestroy) originalOnDestroy.call(view); resolve({view, result}); }; }); } // 使用示例打开一个确认框并等待用户选择 const {result} await UIManager.getInstance().openViewWithResultboolean(ConfirmPopup, {title: 确定删除}); if (result) { // 用户点击了确定 this.deleteItem(); }4.2 UI自动化测试框架集成思路结合热词“ui自动化测试框架”我们可以思考如何让我们的UI框架更易于测试。关键在于可寻址性和可模拟交互。为UI元素添加测试ID在编辑器中为重要的按钮、输入框等节点添加一个自定义组件或者利用节点的name属性赋予其唯一的测试ID如btn_submit,input_username。暴露可控的接口避免测试代码去模拟点击事件。而是让界面的关键操作如submitForm(),refreshList()成为公有方法测试代码可以直接调用。使用Page Object模式为每个界面创建一个测试类封装该界面的元素查找和操作。集成测试工具可以结合Jest、Mocha等单元测试框架以及像puppeteer或playwright的无头浏览器工具对于Web平台编写端到端的UI流测试。// 示例为登录界面编写一个简单的测试用例 describe(LoginView Test, () { let loginView: LoginView; beforeAll(async () { // 通过UIManager打开界面 loginView await UIManager.getInstance().openView(LoginView) as LoginView; }); it(should login successfully with correct credentials, async () { // 直接调用界面方法而非模拟点击 loginView.setAccount(testuser); loginView.setPassword(123456); const result await loginView.performLogin(); // performLogin返回一个Promise expect(result.success).toBe(true); expect(UIManager.getInstance().isViewOpened(MainView)).toBe(true); }); afterAll(() { UIManager.getInstance().closeView(LoginView, true); }); });4.3 与Unity UI框架的异同与借鉴热词中提到了“unity的ui框架”。以Unity的UGUI和常见框架如FairyGUI, GameFramework的UI模块为例我们可以借鉴一些成熟思想UI栈管理Unity中常用栈来管理弹窗确保关闭当前弹窗能回到上一个。我们的adjustViewVisibility可以升级为更严格的栈管理。数据绑定Unity有强大的数据绑定插件如UniRx, MVVM框架。在CocosCreator中我们可以手动实现或使用第三方库让界面自动响应数据模型的变化。动画系统集成将UI动画入场、出场、循环作为框架的一部分提供标准化的动画组件或接口让界面动效配置更简单。编辑器扩展像FairyGUI一样可以开发编辑器插件实现UI的所见即所得编辑、控件绑定、一键生成代码等功能极大提升美术和策划的工作效率。5. 常见问题与避坑指南在实际项目中应用自研UI框架一定会踩不少坑。以下是我总结的几个关键点和解决方案。5.1 内存泄漏排查这是UI框架的头号杀手。症状通常是游戏玩久了越来越卡甚至崩溃。坑1资源只加载不释放。确保releasePrefab逻辑正确并且refCount计数准确。特别注意那些不通过UIManager、而是直接cc.instantiate的预制体它们不会被框架管理。坑2事件监听未移除。在BaseView.onDestroy中必须清理所有全局事件监听、计时器this.schedule、动画回调等。一个最佳实践是在BaseView中维护一个清理函数列表。坑3节点引用残留。虽然节点被destroy了但如果其他地方还持有对它的引用比如存在一个全局数组里这部分内存也无法被回收。使用WeakMap或确保及时置空引用。排查工具善用CocosCreator的调试器 - 资源面板和性能面板观察Prefab、Texture等资源的数量和内存占用趋势。5.2 界面渲染顺序错乱有时界面会互相穿透或者子节点显示在父节点前面。原因CocosCreator的2D渲染顺序主要由node.zIndex和节点树顺序同级节点的setSiblingIndex决定。我们的layerNodes设置了基础Z序但同一层内的多个界面需要根据打开顺序动态调整它们的setSiblingIndex确保后打开的显示在前面。解决方案在openView中将新打开的界面节点通过setSiblingIndex置为当前层级容器的最后一个子节点。在bringViewToFront中也做同样操作。5.3 界面数据状态污染一个典型的场景背包界面被缓存了当你打开A角色的背包关闭再打开B角色的背包时显示的却是A角色的数据。原因缓存界面时其内部的数据状态也被保留了。onShow时没有用新数据完全重置界面。解决方案在BaseView中明确区分onInit只调用一次用于获取组件引用等初始化和onShow每次打开都可能调用用于用新数据刷新界面。对于缓存界面必须在onShow中完整地根据传入的data参数重置所有显示状态。// 在具体的界面实现中 export class BagView extends BaseView { private itemList: cc.Node[] []; private currentRoleId: number; onInit(data?: any): void { // 只做一次的事情查找节点绑定事件 this.itemList this.node.getChildByName(Content).children; this.node.getChildByName(btnClose).on(click, this.closeSelf, this); } onShow(data?: any): void { // 每次打开都要刷新 const roleId data?.roleId; if (roleId ! this.currentRoleId) { this.currentRoleId roleId; this.refreshAllItems(); // 根据新的roleId重新拉取并显示数据 } // 播放入场动画等 this.playEnterAnimation(); } onHide(): void { // 停止动画、音效等 this.stopAllAnimations(); } }5.4 异步加载导致的显示问题在资源加载完成前界面处于空白或错误状态。解决方案实现一个加载中遮罩。在UIManager.openView开始加载资源时自动打开一个位于UILayer.LOADING的透明遮罩界面可以有一个旋转的图标。在资源加载完成、界面onShow调用后再关闭这个遮罩。对于界面内部也需要异步加载大量资源的情况如滚动列表中的头像可以在界面内部实现局部的加载占位符。构建一个健壮的CocosCreator UI框架绝非一蹴而就它需要在项目迭代中不断打磨。从最基础的生命周期管理、资源缓存做起逐步加入事件通信、动画系统、数据绑定等高级特性最终形成一套契合团队工作流的完整解决方案。记住框架的目的是提升效率、降低错误而不是增加复杂度。如果你的项目非常简单几个界面而已那么直接用CocosCreator原生的节点管理也完全足够。但当界面数量超过20个交互逻辑开始复杂时一个设计良好的UI框架所带来的收益将是巨大的。